diff --git a/.editorconfig b/.editorconfig index 9f58bad..6790d75 100644 --- a/.editorconfig +++ b/.editorconfig @@ -2,7 +2,7 @@ charset = utf-8 end_of_line = crlf indent_size = 4 -indent_style = space +indent_style = tab insert_final_newline = false max_line_length = 120 tab_width = 4 @@ -196,7 +196,7 @@ ij_typescript_wrap_comments = true indent_size = 2 indent_style = tab max_line_length = 80 -tab_width = 2 +tab_width = 4 ij_continuation_indent_size = 2 ij_javascript_align_imports = false ij_javascript_align_multiline_array_initializer_expression = false diff --git a/.gitignore b/.gitignore index 3735d76..a09c9d1 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,8 @@ /*.bat /.idea /.env +/commands/levels/UserLevelCard/cache/ /BaseClasses/ /commands/diplomacy/ /Error/ -/libs/ +/libs/ \ No newline at end of file diff --git a/commands/handler/functions/autoThread.js b/commands/handler/functions/autoThread.js index 322b3f4..c0710ad 100644 --- a/commands/handler/functions/autoThread.js +++ b/commands/handler/functions/autoThread.js @@ -32,7 +32,7 @@ module.exports = { title += name + ' ' + time; } - msg.startThread({ name: title }); + await msg.startThread({ name: title }); } }; diff --git a/commands/handler/functions/pingPromoter.js b/commands/handler/functions/pingPromoter.js index cbc0c1c..3a22919 100644 --- a/commands/handler/functions/pingPromoter.js +++ b/commands/handler/functions/pingPromoter.js @@ -43,14 +43,14 @@ module.exports = { if(embed) { msg.log('Embed found'); break; - }; + } - }; + } if(!embed?.description?.includes('Успешный Up!')) { msg.log('Embed is invalid. Description: ' + embed.description); return; - }; + } msg.log('CD started'); await sleep(this.COOLDOWN_UP); diff --git a/commands/levels/UserLevelCard/CacheController.js b/commands/levels/UserLevelCard/CacheController.js new file mode 100644 index 0000000..a72415e --- /dev/null +++ b/commands/levels/UserLevelCard/CacheController.js @@ -0,0 +1,79 @@ +const fs = require('fs').promises; +const p = require('path') + +class CacheController { + + #path = ''; + #type = ''; + + constructor (path, type) { + this.#path = path.slice(0, -9) + "/UserLevelCard/cache/" + type + this.#type = '/' + type + console.log("Cache controller connected: " + this.#path); + this.clearAll(); + } + + async get(name) { + const endPath = this.#path + '/' + name; + let data = null; + const displayPlace = this.#type + '/' + name; + console.log("Getting cached data from: " + displayPlace); + try { + data = await fs.readFile(endPath); + } catch (e) { + console.log("Failed to get cache"); + return null; + } + console.log("Cache found in: " + displayPlace); + return data; + } + + async getAsJson(name) { + const endPath = this.#path + '/' + name + '.json'; + let data = null; + const displayPlace = this.#type + '/' + name + '.json'; + console.log("Getting cached data from: " + displayPlace); + + try { + data = JSON.parse(await fs.readFile(endPath, 'utf8')); + } catch (e) { + console.log('Cache not found'); + return null; + } + console.log("Cache found in: " + displayPlace); + return data; + } + + async set(name, file) { + const displayPlace = this.#type + '/' + name + ' (' + file.length + ')'; + console.log("Writing cached data to: " + displayPlace) + try { + await fs.writeFile(this.#path + '/' + name, file, {encoding: 'utf-8', }) + } catch { + console.log("Failed to write cache") + return null + } + console.log("Cache written to: " + displayPlace) + return true + } + + async setAsJson(name, object) { + await this.set(name + '.json', JSON.stringify(object)); + } + + async clear(name) { + console.log('Clearing cache: ' + this.#type + ' ' + name) + await fs.rm(this.#path + '/' + name) + } + + async clearAll() { + console.log('Clearing all caches: ' + this.#type) + const files = await fs.readdir(this.#path + '/') + for (const file of files) { + await this.clear(file) + } + console.log('Caches cleared: ' + this.#type) + } +} + +module.exports = CacheController \ No newline at end of file diff --git a/commands/levels/UserLevelCard/CanvasWrapper.js b/commands/levels/UserLevelCard/CanvasWrapper.js new file mode 100644 index 0000000..65d1bb9 --- /dev/null +++ b/commands/levels/UserLevelCard/CanvasWrapper.js @@ -0,0 +1,16 @@ +const CanvasElement = require('./wrapperClasses/CanvasElement'); +const Icon = require('./wrapperClasses/Icon'); +const Rect = require('./wrapperClasses/Rect'); +const TextBox = require('./wrapperClasses/TextBox'); +const Label = require('./wrapperClasses/Label'); +const ProgressBar = require('./wrapperClasses/ProgressBar'); + + +module.exports = { + CanvasElement, + Rect, + TextBox, + Icon, + Label, + ProgressBar +} diff --git a/commands/levels/UserLevelCard/UserLevelCard.js b/commands/levels/UserLevelCard/UserLevelCard.js new file mode 100644 index 0000000..59afc35 --- /dev/null +++ b/commands/levels/UserLevelCard/UserLevelCard.js @@ -0,0 +1,751 @@ +const Canvas = require('canvas'); +const fs = require('fs'); +const { MessageAttachment, Snowflake } = require('discord.js'); + +const { ALIGNMENT, COLOURS, STYLE, RESOLUTION } = require('./renderingConstants'); +const { Rect, TextBox, Icon, Label, ProgressBar } = require('./CanvasWrapper'); +const { UserLevels } = require('../UserLevels'); + +const GifEncoder = require('gif-encoder'); +const { streamToBuffer } = require('@jorgeferrero/stream-to-buffer'); +const { loadImage, createCanvas } = require('canvas'); +const gifFrames = require('gif-frames'); + +const fontsRoot = './commands/levels/UserLevelCard/fonts/' + +Canvas.registerFont(fontsRoot + 'Inter/static/Inter-Bold.ttf', {family: 'Inter', weight: 'Bold'}); +Canvas.registerFont(fontsRoot + 'Inter/static/Inter-Light.ttf', {family: 'Inter', weight: 'Light'}); +Canvas.registerFont(fontsRoot + 'PT_Sans/PTSans-Regular.ttf', {family: 'Sans', weight: 'Regular'}); +Canvas.registerFont(fontsRoot + 'Montserrat/static/Montserrat-Medium.ttf', {family: 'Montserrat', weight: 'Medium'}); +Canvas.registerFont(fontsRoot + 'Montserrat/static/Montserrat-Bold.ttf', {family: 'Montserrat', weight: 'Bold'}); + + +class UserLevelCards { + + static assets = {}; + static cachedImages = { + }; + + /** + * + * @type {{Snowflake: {userLevel: UserLevels, card: Canvas}}} + */ + static cachedCards = { + }; + + static #cachedUserLevelCards = { + }; + + static getCachedCard(id) { + return UserLevelCards.cachedCards[id]; + } + + static loadAssets(path) { + const endPath = path.slice(0, -9) + "/UserLevelCard/assets" + let assets = {}; + + fs.readdir(endPath, (err, files) => { + files.forEach(file => { + const img = new Canvas.Image() + img.src = endPath + '/' + file; + assets[file.split('.')[0]] = img; + }); + }); + + return assets; + } + + constructor() { + this.canvas = Canvas.createCanvas(RESOLUTION.CARD_WIDTH, RESOLUTION.CARD_HEIGHT); + + this.mainBackground = new Rect( + this.canvas, + 0, 0, RESOLUTION.CARD_WIDTH, RESOLUTION.CARD_HEIGHT, + ALIGNMENT.TOP_LEFT, COLOURS.DARK_GRAY, STYLE.ROUNDING + ) + + this.darkBackground = new Rect( + this.canvas, + STYLE.BORDER_SIZE, + STYLE.BORDER_SIZE, + RESOLUTION.CARD_WIDTH - STYLE.BORDER_SIZE * 2, + RESOLUTION.CARD_HEIGHT - STYLE.BORDER_SIZE * 2, + ALIGNMENT.TOP_LEFT, COLOURS.BLACK, STYLE.ROUNDING / 2 + ) + + this.displayname = new TextBox(this.canvas, + 0, 0, 1, 1, ALIGNMENT.TOP_LEFT + ) + + this.username = new TextBox(this.canvas, + 0, 0, 1, 1, ALIGNMENT.TOP_LEFT + ) + + this.expText = new TextBox(this.canvas, + 0, 0, 1, 1, ALIGNMENT.TOP_CENTER + ) + + this.avatar = new Icon(this.canvas, undefined, + STYLE.BORDER_SIZE, + STYLE.AVATAR_SHIFT, + STYLE.AVATAR_SIZE, + STYLE.AVATAR_SIZE + ); + + this.banner = new Icon(this.canvas, undefined, + 0, + 0, + RESOLUTION.CARD_WIDTH, + STYLE.AVATAR_SHIFT + STYLE.AVATAR_SIZE / 2, + ); + + this.progressbar = new ProgressBar(this.canvas, + 0, 0, STYLE.PROGRESSBAR_WIDTH, STYLE.PROGRESSBAR_HEIGHT, + ALIGNMENT.TOP_LEFT, COLOURS.DARK_GRAY, STYLE.PROGRESSBAR_ROUNDING + ); + + this.lvlLabel = new Label(this.canvas, 0, 0, 1, 1, ALIGNMENT.TOP_RIGHT, COLOURS.BLACK, STYLE.ROUNDING / 2); + + this.msgAll = new Label( + this.canvas, 0, 0, 1, 1, + ALIGNMENT.TOP_RIGHT, COLOURS.DARK_GRAY, STYLE.ROUNDING / 4 + ); + this.msgAll.faceShift *= 1.5; + this.msgAll + .setIcon(new Icon(this.canvas, undefined,0, 0, 40, 40)) + .setSecondaryText(new TextBox(this.canvas, 0, 0, 1, 1)); + this.msgAll.secondaryText.color = COLOURS.GRAY; + this.msgAll.secondaryText.changeText('Всего', 150, STYLE.LABEL_STATS_SECONDARY_FONT_SIZE); + + this.msgAvg = new Label( + this.canvas, 0, 0, 1, 1, + ALIGNMENT.TOP_RIGHT, COLOURS.DARK_GRAY, STYLE.ROUNDING / 4 + ); + this.msgAvg.faceShift *= 1.5; + this.msgAvg + .setIcon(new Icon(this.canvas, undefined,0, 0, 40, 40)) + .setSecondaryText(new TextBox(this.canvas, 0, 0, 1, 1)); + this.msgAvg.secondaryText.color = COLOURS.GRAY; + this.msgAvg.secondaryText.changeText('Учтено', 150, STYLE.LABEL_STATS_SECONDARY_FONT_SIZE); + + this.symAll = new Label( + this.canvas, 0, 0, 1, 1, + ALIGNMENT.TOP_RIGHT, COLOURS.DARK_GRAY, STYLE.ROUNDING / 4 + ); + this.symAll.faceShift *= 1.5; + this.symAll + .setIcon(new Icon(this.canvas, undefined,0, 0, 40, 40)) + .setSecondaryText(new TextBox(this.canvas, 0, 0, 1, 1)); + this.symAll.secondaryText.color = COLOURS.GRAY; + this.symAll.secondaryText.changeText('Всего', 150, STYLE.LABEL_STATS_SECONDARY_FONT_SIZE); + + this.symAvg = new Label( + this.canvas, 0, 0, 1, 1, + ALIGNMENT.TOP_RIGHT, COLOURS.DARK_GRAY, STYLE.ROUNDING / 4 + ); + this.symAvg.faceShift *= 1.5; + this.symAvg + .setIcon(new Icon(this.canvas, undefined,0, 0, 40, 40)) + .setSecondaryText(new TextBox(this.canvas, 0, 0, 1, 1)); + this.symAvg.secondaryText.color = COLOURS.GRAY; + this.symAvg.secondaryText.changeText('Среднее', 150, STYLE.LABEL_STATS_SECONDARY_FONT_SIZE); + + this.overpost = new Label( + this.canvas, 0, 0, 1, 1, + ALIGNMENT.TOP_RIGHT, COLOURS.DARK_GRAY, STYLE.ROUNDING / 4 + ); + this.overpost.faceShift *= 1.5; + this.overpost + .setIcon(new Icon(this.canvas, undefined,0, 0, 40, 40)) + .setSecondaryText(new TextBox(this.canvas, 0, 0, 1, 1)); + this.overpost.secondaryText.color = COLOURS.GRAY; + this.overpost.secondaryText.changeText('Оверпост', 150, STYLE.LABEL_STATS_SECONDARY_FONT_SIZE); + + this.activity = new Label( + this.canvas, 0, 0, 1, 1, + ALIGNMENT.TOP_RIGHT, COLOURS.DARK_GRAY, STYLE.ROUNDING / 4 + ); + this.activity.faceShift *= 1.5; + this.activity + .setIcon(new Icon(this.canvas, undefined,0, 0, 40, 40)) + .setSecondaryText(new TextBox(this.canvas, 0, 0, 1, 1)); + this.activity.secondaryText.color = COLOURS.GRAY; + } + + generateProgressbar(userLevel) { + this.progressbar.context.textBaseline = "hanging"; + this.progressbar + .moveToObject(this.displayname) + .move(0, STYLE.PROGRESSBAR_SHIFT); + + this.progressbar.currLvlTxt.fontSize = STYLE.ROLE_LIMIT_FONT_SIZE + this.progressbar.currLvlTxt.font = 'Montserrat Medium' + + this.progressbar.nxtLvlTxt.fontSize = STYLE.ROLE_LIMIT_FONT_SIZE + this.progressbar.nxtLvlTxt.font = 'Montserrat Medium' + + this.progressbar.applyToUser(userLevel); + + this.progressbar.maxLvl.asset = UserLevelCards.assets['tada'] + this.progressbar.draw(); + } + + generateUsername(userLevel) { + this.displayname.fontSize = STYLE.USERNAME_MAX_FONT_SIZE; + this.displayname.changeText(userLevel.member.displayName.truncate(19), STYLE.USERNAME_MAX_WIDTH, STYLE.USERNAME_MAX_FONT_SIZE); + this.displayname.context.textBaseline = "alphabetic"; + this.displayname.font = 'Inter Bold' + this.displayname + .moveToObject(this.darkBackground) + .move( + STYLE.DARK_BACKGROUND_INNER_SHIFT, + STYLE.DARK_BACKGROUND_INNER_SHIFT*1.5 + + this.displayname.context.measureText(this.displayname.text) + .actualBoundingBoxAscent + ); + + this.displayname.draw(STYLE.USERNAME_MAX_WIDTH); + + this.username.changeText(userLevel.member.user.username.truncate(14), RESOLUTION.CARD_WIDTH - STYLE.USERNAME_MAX_WIDTH, STYLE.USERNAME_MAX_FONT_SIZE - 15); + this.username.context.textBaseline = "alphabetic"; + this.username.font = 'Inter Regular' + this.username + .moveToObject(this.displayname) + .move(STYLE.DARK_BACKGROUND_INNER_SHIFT + this.displayname.w); + + this.username.color = COLOURS.GRAY; + this.username.draw(STYLE.USERNAME_MAX_WIDTH); + + } + + async generateExpValues(userLevel) { + this.expText.fontSize = STYLE.USERNAME_MAX_FONT_SIZE + 15; + this.expText.font = 'Montserrat Bold' + this.expText.changeText(userLevel.getExpFull().toLocaleString().replaceAll(' ', '.').replaceAll(',', '.')); + this.expText + .moveToObject(this.progressbar) + .move(0, - this.expText.h - STYLE.PROGRESSBAR_SHIFT_UP); + this.expText.draw(); + } + + async generateAvatar(userLevel) { + const cachedAvatar = await UserLevelCards.cachedImages.avatars.getAsJson(userLevel.member.id); + const currentAvatarUrl = userLevel.member.displayAvatarURL({format: 'png', size: 1024, dynamic: userLevel.flags.animatedMediaContentEnabled}); + + if (cachedAvatar && (currentAvatarUrl === cachedAvatar.avatarUrl)) { + this.avatar.asset = await loadImage(Buffer.from(cachedAvatar.asset, 'base64')); + if (cachedAvatar.gif) + this.avatar.gif = await gifFrames( + { url: Buffer.from(cachedAvatar.gif, 'base64'), frames: 'all', outputType: 'png' }); + userLevel.isAvatarCached = true; + } else { + await this.avatar.loadAssetFromUrl(currentAvatarUrl); + UserLevelCards.cachedImages.avatars.setAsJson(userLevel.member.id,{ + asset: this.avatar.buffer?.toString('base64'), + gif: this.avatar.gifbuffer?.toString('base64'), + avatarUrl: currentAvatarUrl + }); + } + + this.avatar + .makeRounded() + .draw(); + } + + generateDarkBackground() { + this.darkBackground + .moveToObject(this.avatar) + .move(0, this.avatar.h + STYLE.DARK_BACKGROUND_SHIFT); + + this.darkBackground.h = + RESOLUTION.CARD_HEIGHT - this.darkBackground.y - STYLE.BORDER_SIZE; + + this.darkBackground.draw(); + } + + async generateBanner(userLevel) { + const bannerAllowedHeight = STYLE.AVATAR_SIZE / 2 + STYLE.AVATAR_SHIFT; + const cachedBanner = await UserLevelCards.cachedImages.banners.getAsJson(userLevel.member.id); + const currentBannerUrl = userLevel.getBannerUrl(userLevel.flags.animatedMediaContentEnabled); + + this.banner.asset = UserLevelCards.assets['default_banner']; + userLevel.isBannerCached = true; + this.banner.w = RESOLUTION.CARD_WIDTH; + + if (currentBannerUrl) { + if ((cachedBanner?.asset || cachedBanner?.gif)&& (currentBannerUrl === cachedBanner.bannerUrl)) { + this.banner.asset = await loadImage(Buffer.from(cachedBanner.asset, 'base64')); + if (cachedBanner.gif) + this.banner.gif = await gifFrames( + { url: Buffer.from(cachedBanner.gif, 'base64'), frames: 'all', outputType: 'png' } + ); + } else { + userLevel.isBannerCached = false; + await this.banner.loadAssetFromUrl(currentBannerUrl); + UserLevelCards.cachedImages.banners.setAsJson(userLevel.member.id, { + asset: this.banner.buffer?.toString('base64'), + bannerUrl: currentBannerUrl, + gif: this.banner.gifbuffer?.toString('base64') + }); + } + } + + this.banner.useOriginalAspect(); + if (this.banner.h < bannerAllowedHeight){ + this.banner.h = bannerAllowedHeight; + this.banner.useOriginalAspect(true); + } + + this.banner.alignment = ALIGNMENT.CENTER_CENTER; + + this.banner + .moveToPoint(RESOLUTION.CARD_WIDTH / 2, bannerAllowedHeight / 2); + + this.banner.makeRounded([STYLE.ROUNDING, STYLE.ROUNDING, 0, 0], [0, RESOLUTION.CARD_WIDTH, 0, STYLE.AVATAR_SIZE / 2 + STYLE.AVATAR_SHIFT]) + this.banner.draw(); + } + + generateCurrLevelLabel(userLevel) { + this.lvlLabel + .setIcon(new Icon( + this.canvas, undefined, + 0, 0, 40, 40 + )); + + this.lvlLabel.icon.asset = UserLevelCards.assets[ + userLevel.getRole().cache.name + .toLowerCase() + .replace(' ', '_') + ] + this.lvlLabel.icon.useOriginalAspect(); + this.lvlLabel.primaryText.font = 'Montserrat Medium' + this.lvlLabel.primaryText.changeText(userLevel.getRole().cache.name, 170, STYLE.LABEL_ROLE_FONT_SIZE) + + this.lvlLabel.reposElements(); + this.lvlLabel.move(-this.lvlLabel.h); + + this.lvlLabel + .moveToObject(this.darkBackground) + .move(0, -this.lvlLabel.h - STYLE.DARK_BACKGROUND_SHIFT); + + this.lvlLabel.draw(); + } + + generateStats(userLevel) { + this.generateMsgAll(userLevel); + this.generateMsgAvg(userLevel); + this.generateSymAll(userLevel); + this.generateSymAvg(userLevel); + this.generateOverpost(userLevel); + this.generateActivity(userLevel); + } + + generateMsgAll(userLevel) { + + this.msgAll.icon.asset = UserLevelCards.assets['messages']; + this.msgAll.icon.useOriginalAspect(); + + + this.msgAll.primaryText.changeText( + userLevel.getMessagesAll() + .toLocaleString().replaceAll(' ', '.').replaceAll(',', '.'), 150, STYLE.LABEL_STATS_PRIMARY_FONT_SIZE + ); + + + this.msgAll.reposElements(); + this.msgAll.alignment = ALIGNMENT.BOTTOM_LEFT; + this.msgAll + .moveToObject(this.progressbar.currLvlTxt) + .move(0, this.msgAll.h + STYLE.STATS_GRID_SHIFT); + + this.msgAll.draw(); + } + + generateMsgAvg(userLevel) { + + this.msgAvg.icon.asset = UserLevelCards.assets['messages']; + this.msgAvg.icon.useOriginalAspect(); + + + this.msgAvg.primaryText.changeText( + userLevel.getMessagesLegit() + .toLocaleString().replaceAll(' ', '.').replaceAll(',', '.'),150, STYLE.LABEL_STATS_PRIMARY_FONT_SIZE + ); + + + this.msgAvg.reposElements(); + this.msgAvg.alignment = ALIGNMENT.TOP_LEFT; + this.msgAvg + .moveToObject(this.msgAll) + .move(this.msgAll.w + STYLE.STATS_GRID_GAP, 0); + + this.msgAvg.draw(); + } + + generateSymAll(userLevel) { + + this.symAll.icon.asset = UserLevelCards.assets['symbols']; + this.symAll.icon.useOriginalAspect(); + + + this.symAll.primaryText.changeText( + userLevel.getSymbols() + .toLocaleString().replaceAll(' ', '.').replaceAll(',', '.'),150, STYLE.LABEL_STATS_PRIMARY_FONT_SIZE + ); + + + this.symAll.reposElements(); + this.symAll.alignment = ALIGNMENT.TOP_LEFT; + this.symAll + .moveToObject(this.msgAll) + .move(0, this.msgAll.h + STYLE.STATS_GRID_GAP); + + this.symAll.draw(); + } + + generateSymAvg(userLevel) { + + this.symAvg.icon.asset = UserLevelCards.assets['symbols']; + this.symAvg.icon.useOriginalAspect(); + + + this.symAvg.primaryText.changeText( + userLevel.getSymbolsAvg() + .toLocaleString().replaceAll(' ', '.').replaceAll('.', ','),150, STYLE.LABEL_STATS_PRIMARY_FONT_SIZE + ); + + + this.symAvg.reposElements(); + this.symAvg.alignment = ALIGNMENT.TOP_LEFT; + this.symAvg + .moveToObject(this.symAll) + .move(this.symAll.w + STYLE.STATS_GRID_GAP, 0); + + this.symAvg.draw(); + } + + generateOverpost(userLevel) { + + this.overpost.icon.asset = UserLevelCards.assets['overpost']; + this.overpost.icon.useOriginalAspect(); + + + this.overpost.primaryText.changeText( + (userLevel.getOverpost() + '%').replaceAll('.', ',') ,150, 31 + ); + + this.overpost.reposElements(); + this.overpost.alignment = ALIGNMENT.TOP_LEFT; + this.overpost + .moveToObject(this.symAll) + .move(0, this.symAll.h + STYLE.STATS_GRID_GAP); + + this.overpost.draw(); + } + + generateActivity(userLevel) { + + this.activity.icon.asset = UserLevelCards.assets['activity']; + this.activity.icon.useOriginalAspect(); + + + this.activity.primaryText.changeText( + userLevel.getActivity() + '/30 дней',150, STYLE.LABEL_STATS_PRIMARY_FONT_SIZE + ); + this.activity.secondaryText.changeText( + userLevel.getActivityPer() + '%',150, STYLE.LABEL_STATS_SECONDARY_FONT_SIZE + ); + + this.activity.reposElements(); + this.activity.alignment = ALIGNMENT.TOP_LEFT; + this.activity + .moveToObject(this.overpost) + .move(0, this.overpost.h + STYLE.STATS_GRID_GAP); + + this.activity.draw(); + } + + /** + * @param {Canvas} canvas + * @param {userLevel} userLevel + * @param {String} time + */ + generateTime(canvas, userLevel, time) { + const footerBackground = new Rect(canvas, 0, 0, 1, 1, + ALIGNMENT.BOTTOM_LEFT, COLOURS.BLACK + ); + + this.footer = new TextBox(canvas, 0, 0, 1, 1, + ALIGNMENT.BOTTOM_LEFT, COLOURS.DARK_GRAY, + '', 23 + ); + + this.footer + .moveToObject(this.darkBackground) + .move( + STYLE.DARK_BACKGROUND_INNER_SHIFT, + -STYLE.DARK_BACKGROUND_INNER_SHIFT + ); + + + + let txtCached = ''; + if (userLevel.isCachedFull) { + txtCached = 'всё'; + } else if (userLevel.isAvatarCached && userLevel.isBannerCached) { + txtCached = 'аватар+баннер'; + } else if (userLevel.isAvatarCached) { + txtCached = 'аватар'; + } else if (userLevel.isBannerCached) { + txtCached = 'баннер'; + } else { + txtCached = 'нет' + } + + const txt = `Сгенерировано за: ${round(getMilliseconds() - time, 1)}мс. Кеш: ${txtCached}` + + this.footer.font = 'Montserrat Medium' + this.footer.changeText(txt, undefined, 23); + + footerBackground.h = this.footer.h; + footerBackground.w = RESOLUTION.CARD_WIDTH - (STYLE.BORDER_SIZE * 2 + STYLE.DARK_BACKGROUND_INNER_SHIFT) ; + + footerBackground.moveToObject(this.footer); + footerBackground.draw(); + this.footer.draw(); + } + + /** + * + * @param {Canvas} canvas + * @param {UserLevels} userLevel + * @param {Interaction} int + * @returns {GIFEncoder|null} + */ + async animate(canvas, userLevel, int=undefined) { + const ctx = canvas.getContext('2d'); + let aGif; + let bGif; + let aGifDelay = 0 + let bGifDelay = 0 + let aGifLength = 0 + let bGifLength = 0 + + + if (!userLevel.isAnimated()) return null; + if ((!this.avatar.gif && !this.banner.gif) || !userLevel.flags.animatedMediaContentEnabled) return null; + + if (this.avatar.gif) { + aGif = this.avatar.gif; + aGifDelay = aGif[0].frameInfo.delay; + aGifLength = aGif.length; + } + if (this.banner.gif) { + bGif = this.banner.gif; + bGifDelay = bGif[0].frameInfo.delay; + bGifLength = bGif.length; + } + + const frameTime = Math.min(aGif ? aGifDelay : bGifDelay, bGif ? bGifDelay : aGifDelay); + const aGifFullTime = aGifDelay * aGifLength; + const bGifFullTime = bGifDelay * bGifLength; + const fullTime = Math.max(aGifFullTime, bGifFullTime); + + const aGifAllowedTime = aGif ? (Math.floor((fullTime + frameTime)/aGifFullTime) * aGifFullTime) : fullTime; + const bGifAllowedTime = bGif ? (Math.floor((fullTime + frameTime)/bGifFullTime) * bGifFullTime) : fullTime; + + const gif = new GifEncoder(canvas.width, canvas.height, { highWaterMark: 8 * 1000 * 1000 * 24 }); + gif.setDelay(Math.min(aGif ? aGifDelay : bGifDelay, bGif ? bGifDelay : aGifDelay) * 10); + gif.setQuality(15); + gif.setRepeat(0); + //gif.setTransparent(0x000000); + gif.setDispose(0); + + gif.writeHeader(); + + let aFrame = 0; + let bFrame = 0; + let aPreviousFrame = -1; + let bPreviousFrame = -1; + let currTime = 0; + let frameTimes = [0, 0, 0]; + + const gStart = Date.now(); + let previousIntSend = 0; + + for (let frame = 0; (currTime < fullTime); frame++) { + const fStart = getMilliseconds(); + //console.time('drawAssets in') + if (aGif) { + aFrame = Math.floor(Math.min(aGifAllowedTime, currTime) / aGifDelay) % aGifLength; + if (aPreviousFrame != aFrame) { + aPreviousFrame = aFrame; + + if(this.avatar.cachedGifFrames?.[aFrame]){ + this.avatar.asset = this.avatar.cachedGifFrames[aFrame]; + } else { + this.avatar.asset = await Canvas.loadImage( + await streamToBuffer(aGif[aFrame].getImage()) + ); + //this.avatar.cachedGifFrames[aFrame] = this.avatar.asset; + } + + this.avatar.context = canvas.getContext('2d'); + this.avatar.makeRounded(); + this.avatar.draw(ctx); + } + } + if (bGif) { + bFrame = Math.floor(Math.min(bGifAllowedTime, currTime) / bGifDelay) % bGifLength; + if (bPreviousFrame != bFrame) { + bPreviousFrame = bFrame; + + if (this.banner.cachedGifFrames?.[bFrame]) { + this.banner.asset = this.banner.cachedGifFrames[bFrame]; + } else { + this.banner.asset = await Canvas.loadImage( + await streamToBuffer(bGif[bFrame].getImage()) + ); + //this.banner.cachedGifFrames[bFrame] = this.banner.asset; + } + this.banner.context = canvas.getContext('2d'); + this.banner.makeRounded( + [STYLE.ROUNDING, STYLE.ROUNDING, 0, 0], [ + 0, + RESOLUTION.CARD_WIDTH, + 0, + STYLE.AVATAR_SIZE / 2 + STYLE.AVATAR_SHIFT + ]); + this.banner.draw(ctx); + } + } + //console.timeEnd('drawAssets in') + + currTime += frameTime; + + //console.time('addFrame in') + gif.addFrame(ctx.getImageData(0, 0, canvas.width, canvas.height).data); + //console.timeEnd('addFrame in') + + const fTime = round(getMilliseconds() - fStart, 3); + frameTimes.unshift(fTime); + const pTime = round(Date.now() - gStart, 3); + const avgTime = pTime/frame; + const avgByP3 = (frameTimes[0] + frameTimes[1] + frameTimes[2]) / 3; + const speed = round( 1000 / avgByP3, 1); + const reTime = round((((fullTime - currTime)/frameTime)*avgByP3)/1000, 3); + const gRe = round(((fullTime/frameTime)*avgTime)/1000); + + process.stdout.write( + this.getPlaneTextProgressBar( + currTime/fullTime, + 20 + ) + ' ' + aFrame + ' ' + bFrame + ' | Passed:' + (pTime/1000).toFixed(3) + 's; ETA:' + reTime + 's; Speed: ' + speed + ' frames/s' + + ' '.repeat(10) + '\r' + ); + + if (int?.deferred && (Math.round((getMilliseconds() - previousIntSend)/1000) >= 2)) { + previousIntSend = getMilliseconds(); + int.editReply( + { + content: '`' + this.getPlaneTextProgressBar(currTime/fullTime,20, true) + + ('`\nНачато: Окончание: ') + + } + ) + } + } + + console.log('') + gif.finish(); + + return gif; + } + + /** + * + * @param {number} progress Прогресс. от 0.0 до 1.0 + * @param {number} length Длинна полоски. ПО умолчанию 20 символов + * @param {boolean} toDiscord Строка которая будет добавлена после прогресс + * бара + * @return {string} + */ + getPlaneTextProgressBar(progress, length=20, toDiscord=false) { + progress = Math.max(Math.min(progress, 1), 0) + + const pr = '▉'.repeat(Math.round(progress * length)); + const re = (toDiscord ? 'ㅤ' : ' ').repeat(Math.round((1 - progress) * length)); + + return '[' + pr + re + ']'; + } + + static async generate(userLevel, int) { + return await (new UserLevelCards()).generate(userLevel, int); + } + + async generate(userLevel, int) { + const gStart = getMilliseconds(); + + if (userLevel.isCached() && !userLevel.isAnimated()) { + const buffer = await UserLevelCards.cachedImages.cards.get(userLevel.member.id + '.png'); + if(buffer) { + const img = await loadImage(buffer) + const canvas = createCanvas( + RESOLUTION.CARD_WIDTH, RESOLUTION.CARD_HEIGHT) + const ctx = canvas.getContext('2d') + ctx.drawImage( + img, 0, 0, RESOLUTION.CARD_WIDTH, RESOLUTION.CARD_HEIGHT) + userLevel.isCachedFull = true; + this.generateTime(canvas, userLevel, gStart); + + return new MessageAttachment( + canvas.toBuffer('image/png'), `${userLevel.getExp()}.png`); + } + } + + if (userLevel.isGifCached() && userLevel.isAnimated()) { + const buffer = await UserLevelCards.cachedImages.cards.get(userLevel.member.id + '.gif'); + return new MessageAttachment(buffer, `${userLevel.getExp()}.gif`); + } + + this.mainBackground.draw(); + + await this.generateBanner(userLevel); + + this.generateDarkBackground(); + + await this.generateAvatar(userLevel); + + this.generateUsername(userLevel); + + this.generateProgressbar(userLevel); + + await this.generateExpValues(userLevel); + + this.generateCurrLevelLabel(userLevel); + + this.generateStats(userLevel); + + this.generateTime(this.canvas, userLevel, gStart); + + const gif = await this.animate(copyCanvas(this.canvas), userLevel, int); + + UserLevelCards.cachedCards[userLevel.member.id] = {userLevel:userLevel} + + if (gif) { + const buffer = gif.read() + UserLevelCards.cachedCards[userLevel.member.id] = {userLevel:userLevel, gif: true} + UserLevelCards.cachedImages.cards.set(userLevel.member.id + '.gif', buffer) + return new MessageAttachment(buffer, `${userLevel.getExp()}.gif`); + } + + const buffer = this.canvas.toBuffer('image/png') + + UserLevelCards.cachedImages.cards.set(userLevel.member.id + '.png', buffer) + + return new MessageAttachment(buffer, `${userLevel.getExp()}.png`); + } + +} + +module.exports = UserLevelCards \ No newline at end of file diff --git a/commands/levels/UserLevelCard/assets/activity.svg b/commands/levels/UserLevelCard/assets/activity.svg new file mode 100644 index 0000000..b1c2396 --- /dev/null +++ b/commands/levels/UserLevelCard/assets/activity.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/commands/levels/UserLevelCard/assets/alive.svg b/commands/levels/UserLevelCard/assets/alive.svg new file mode 100644 index 0000000..221340e --- /dev/null +++ b/commands/levels/UserLevelCard/assets/alive.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/commands/levels/UserLevelCard/assets/default_banner.png b/commands/levels/UserLevelCard/assets/default_banner.png new file mode 100644 index 0000000..ef0bef1 Binary files /dev/null and b/commands/levels/UserLevelCard/assets/default_banner.png differ diff --git a/commands/levels/UserLevelCard/assets/god.svg b/commands/levels/UserLevelCard/assets/god.svg new file mode 100644 index 0000000..4fee1a8 --- /dev/null +++ b/commands/levels/UserLevelCard/assets/god.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/commands/levels/UserLevelCard/assets/godlike.svg b/commands/levels/UserLevelCard/assets/godlike.svg new file mode 100644 index 0000000..675985c --- /dev/null +++ b/commands/levels/UserLevelCard/assets/godlike.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/commands/levels/UserLevelCard/assets/godmaster.svg b/commands/levels/UserLevelCard/assets/godmaster.svg new file mode 100644 index 0000000..d816cfc --- /dev/null +++ b/commands/levels/UserLevelCard/assets/godmaster.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/commands/levels/UserLevelCard/assets/godseeker.svg b/commands/levels/UserLevelCard/assets/godseeker.svg new file mode 100644 index 0000000..d3a578e --- /dev/null +++ b/commands/levels/UserLevelCard/assets/godseeker.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/commands/levels/UserLevelCard/assets/its_alive.svg b/commands/levels/UserLevelCard/assets/its_alive.svg new file mode 100644 index 0000000..bb5925f --- /dev/null +++ b/commands/levels/UserLevelCard/assets/its_alive.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/commands/levels/UserLevelCard/assets/messages.svg b/commands/levels/UserLevelCard/assets/messages.svg new file mode 100644 index 0000000..4ade70b --- /dev/null +++ b/commands/levels/UserLevelCard/assets/messages.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/commands/levels/UserLevelCard/assets/overpost.svg b/commands/levels/UserLevelCard/assets/overpost.svg new file mode 100644 index 0000000..bda58cf --- /dev/null +++ b/commands/levels/UserLevelCard/assets/overpost.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/commands/levels/UserLevelCard/assets/super_alive.svg b/commands/levels/UserLevelCard/assets/super_alive.svg new file mode 100644 index 0000000..9474d88 --- /dev/null +++ b/commands/levels/UserLevelCard/assets/super_alive.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/commands/levels/UserLevelCard/assets/symbols.svg b/commands/levels/UserLevelCard/assets/symbols.svg new file mode 100644 index 0000000..2a0982c --- /dev/null +++ b/commands/levels/UserLevelCard/assets/symbols.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/commands/levels/UserLevelCard/assets/tada.svg b/commands/levels/UserLevelCard/assets/tada.svg new file mode 100644 index 0000000..703147a --- /dev/null +++ b/commands/levels/UserLevelCard/assets/tada.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/commands/levels/UserLevelCard/assets/very_alive.svg b/commands/levels/UserLevelCard/assets/very_alive.svg new file mode 100644 index 0000000..08a8f8e --- /dev/null +++ b/commands/levels/UserLevelCard/assets/very_alive.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/commands/levels/UserLevelCard/fonts/Inter/Inter-VariableFont_slnt,wght.ttf b/commands/levels/UserLevelCard/fonts/Inter/Inter-VariableFont_slnt,wght.ttf new file mode 100644 index 0000000..ec3164e Binary files /dev/null and b/commands/levels/UserLevelCard/fonts/Inter/Inter-VariableFont_slnt,wght.ttf differ diff --git a/commands/levels/UserLevelCard/fonts/Inter/static/Inter-Black.ttf b/commands/levels/UserLevelCard/fonts/Inter/static/Inter-Black.ttf new file mode 100644 index 0000000..5aecf7d Binary files /dev/null and b/commands/levels/UserLevelCard/fonts/Inter/static/Inter-Black.ttf differ diff --git a/commands/levels/UserLevelCard/fonts/Inter/static/Inter-Bold.ttf b/commands/levels/UserLevelCard/fonts/Inter/static/Inter-Bold.ttf new file mode 100644 index 0000000..8e82c70 Binary files /dev/null and b/commands/levels/UserLevelCard/fonts/Inter/static/Inter-Bold.ttf differ diff --git a/commands/levels/UserLevelCard/fonts/Inter/static/Inter-ExtraBold.ttf b/commands/levels/UserLevelCard/fonts/Inter/static/Inter-ExtraBold.ttf new file mode 100644 index 0000000..cb4b821 Binary files /dev/null and b/commands/levels/UserLevelCard/fonts/Inter/static/Inter-ExtraBold.ttf differ diff --git a/commands/levels/UserLevelCard/fonts/Inter/static/Inter-ExtraLight.ttf b/commands/levels/UserLevelCard/fonts/Inter/static/Inter-ExtraLight.ttf new file mode 100644 index 0000000..64aee30 Binary files /dev/null and b/commands/levels/UserLevelCard/fonts/Inter/static/Inter-ExtraLight.ttf differ diff --git a/commands/levels/UserLevelCard/fonts/Inter/static/Inter-Light.ttf b/commands/levels/UserLevelCard/fonts/Inter/static/Inter-Light.ttf new file mode 100644 index 0000000..9e265d8 Binary files /dev/null and b/commands/levels/UserLevelCard/fonts/Inter/static/Inter-Light.ttf differ diff --git a/commands/levels/UserLevelCard/fonts/Inter/static/Inter-Medium.ttf b/commands/levels/UserLevelCard/fonts/Inter/static/Inter-Medium.ttf new file mode 100644 index 0000000..b53fb1c Binary files /dev/null and b/commands/levels/UserLevelCard/fonts/Inter/static/Inter-Medium.ttf differ diff --git a/commands/levels/UserLevelCard/fonts/Inter/static/Inter-Regular.ttf b/commands/levels/UserLevelCard/fonts/Inter/static/Inter-Regular.ttf new file mode 100644 index 0000000..8d4eebf Binary files /dev/null and b/commands/levels/UserLevelCard/fonts/Inter/static/Inter-Regular.ttf differ diff --git a/commands/levels/UserLevelCard/fonts/Inter/static/Inter-SemiBold.ttf b/commands/levels/UserLevelCard/fonts/Inter/static/Inter-SemiBold.ttf new file mode 100644 index 0000000..c6aeeb1 Binary files /dev/null and b/commands/levels/UserLevelCard/fonts/Inter/static/Inter-SemiBold.ttf differ diff --git a/commands/levels/UserLevelCard/fonts/Inter/static/Inter-Thin.ttf b/commands/levels/UserLevelCard/fonts/Inter/static/Inter-Thin.ttf new file mode 100644 index 0000000..7aed55d Binary files /dev/null and b/commands/levels/UserLevelCard/fonts/Inter/static/Inter-Thin.ttf differ diff --git a/commands/levels/UserLevelCard/fonts/Montserrat/Montserrat-Italic-VariableFont_wght.ttf b/commands/levels/UserLevelCard/fonts/Montserrat/Montserrat-Italic-VariableFont_wght.ttf new file mode 100644 index 0000000..9c397d2 Binary files /dev/null and b/commands/levels/UserLevelCard/fonts/Montserrat/Montserrat-Italic-VariableFont_wght.ttf differ diff --git a/commands/levels/UserLevelCard/fonts/Montserrat/Montserrat-VariableFont_wght.ttf b/commands/levels/UserLevelCard/fonts/Montserrat/Montserrat-VariableFont_wght.ttf new file mode 100644 index 0000000..656db66 Binary files /dev/null and b/commands/levels/UserLevelCard/fonts/Montserrat/Montserrat-VariableFont_wght.ttf differ diff --git a/commands/levels/UserLevelCard/fonts/Montserrat/OFL.txt b/commands/levels/UserLevelCard/fonts/Montserrat/OFL.txt new file mode 100644 index 0000000..f435ed8 --- /dev/null +++ b/commands/levels/UserLevelCard/fonts/Montserrat/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2011 The Montserrat Project Authors (https://github.com/JulietaUla/Montserrat) + +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: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +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/commands/levels/UserLevelCard/fonts/Montserrat/README.txt b/commands/levels/UserLevelCard/fonts/Montserrat/README.txt new file mode 100644 index 0000000..526747d --- /dev/null +++ b/commands/levels/UserLevelCard/fonts/Montserrat/README.txt @@ -0,0 +1,81 @@ +Montserrat Variable Font +======================== + +This download contains Montserrat as both variable fonts and static fonts. + +Montserrat is a variable font with this axis: + wght + +This means all the styles are contained in these files: + Montserrat-VariableFont_wght.ttf + Montserrat-Italic-VariableFont_wght.ttf + +If your app fully supports variable fonts, you can now pick intermediate styles +that aren’t available as static fonts. Not all apps support variable fonts, and +in those cases you can use the static font files for Montserrat: + static/Montserrat-Thin.ttf + static/Montserrat-ExtraLight.ttf + static/Montserrat-Light.ttf + static/Montserrat-Regular.ttf + static/Montserrat-Medium.ttf + static/Montserrat-SemiBold.ttf + static/Montserrat-Bold.ttf + static/Montserrat-ExtraBold.ttf + static/Montserrat-Black.ttf + static/Montserrat-ThinItalic.ttf + static/Montserrat-ExtraLightItalic.ttf + static/Montserrat-LightItalic.ttf + static/Montserrat-Italic.ttf + static/Montserrat-MediumItalic.ttf + static/Montserrat-SemiBoldItalic.ttf + static/Montserrat-BoldItalic.ttf + static/Montserrat-ExtraBoldItalic.ttf + static/Montserrat-BlackItalic.ttf + +Get started +----------- + +1. Install the font files you want to use + +2. Use your app's font picker to view the font family and all the +available styles + +Learn more about variable fonts +------------------------------- + + https://developers.google.com/web/fundamentals/design-and-ux/typography/variable-fonts + https://variablefonts.typenetwork.com + https://medium.com/variable-fonts + +In desktop apps + + https://theblog.adobe.com/can-variable-fonts-illustrator-cc + https://helpx.adobe.com/nz/photoshop/using/fonts.html#variable_fonts + +Online + + https://developers.google.com/fonts/docs/getting_started + https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Fonts/Variable_Fonts_Guide + https://developer.microsoft.com/en-us/microsoft-edge/testdrive/demos/variable-fonts + +Installing fonts + + MacOS: https://support.apple.com/en-us/HT201749 + Linux: https://www.google.com/search?q=how+to+install+a+font+on+gnu%2Blinux + Windows: https://support.microsoft.com/en-us/help/314960/how-to-install-or-remove-a-font-in-windows + +Android Apps + + https://developers.google.com/fonts/docs/android + https://developer.android.com/guide/topics/ui/look-and-feel/downloadable-fonts + +License +------- +Please read the full license text (OFL.txt) to understand the permissions, +restrictions and requirements for usage, redistribution, and modification. + +You can use them in your products & projects – print or digital, +commercial or otherwise. + +This isn't legal advice, please consider consulting a lawyer and see the full +license for all details. diff --git a/commands/levels/UserLevelCard/fonts/Montserrat/static/Montserrat-Black.ttf b/commands/levels/UserLevelCard/fonts/Montserrat/static/Montserrat-Black.ttf new file mode 100644 index 0000000..7bb6575 Binary files /dev/null and b/commands/levels/UserLevelCard/fonts/Montserrat/static/Montserrat-Black.ttf differ diff --git a/commands/levels/UserLevelCard/fonts/Montserrat/static/Montserrat-BlackItalic.ttf b/commands/levels/UserLevelCard/fonts/Montserrat/static/Montserrat-BlackItalic.ttf new file mode 100644 index 0000000..172e249 Binary files /dev/null and b/commands/levels/UserLevelCard/fonts/Montserrat/static/Montserrat-BlackItalic.ttf differ diff --git a/commands/levels/UserLevelCard/fonts/Montserrat/static/Montserrat-Bold.ttf b/commands/levels/UserLevelCard/fonts/Montserrat/static/Montserrat-Bold.ttf new file mode 100644 index 0000000..efddc83 Binary files /dev/null and b/commands/levels/UserLevelCard/fonts/Montserrat/static/Montserrat-Bold.ttf differ diff --git a/commands/levels/UserLevelCard/fonts/Montserrat/static/Montserrat-BoldItalic.ttf b/commands/levels/UserLevelCard/fonts/Montserrat/static/Montserrat-BoldItalic.ttf new file mode 100644 index 0000000..b7d8031 Binary files /dev/null and b/commands/levels/UserLevelCard/fonts/Montserrat/static/Montserrat-BoldItalic.ttf differ diff --git a/commands/levels/UserLevelCard/fonts/Montserrat/static/Montserrat-ExtraBold.ttf b/commands/levels/UserLevelCard/fonts/Montserrat/static/Montserrat-ExtraBold.ttf new file mode 100644 index 0000000..3059507 Binary files /dev/null and b/commands/levels/UserLevelCard/fonts/Montserrat/static/Montserrat-ExtraBold.ttf differ diff --git a/commands/levels/UserLevelCard/fonts/Montserrat/static/Montserrat-ExtraBoldItalic.ttf b/commands/levels/UserLevelCard/fonts/Montserrat/static/Montserrat-ExtraBoldItalic.ttf new file mode 100644 index 0000000..c21a396 Binary files /dev/null and b/commands/levels/UserLevelCard/fonts/Montserrat/static/Montserrat-ExtraBoldItalic.ttf differ diff --git a/commands/levels/UserLevelCard/fonts/Montserrat/static/Montserrat-ExtraLight.ttf b/commands/levels/UserLevelCard/fonts/Montserrat/static/Montserrat-ExtraLight.ttf new file mode 100644 index 0000000..f1b405e Binary files /dev/null and b/commands/levels/UserLevelCard/fonts/Montserrat/static/Montserrat-ExtraLight.ttf differ diff --git a/commands/levels/UserLevelCard/fonts/Montserrat/static/Montserrat-ExtraLightItalic.ttf b/commands/levels/UserLevelCard/fonts/Montserrat/static/Montserrat-ExtraLightItalic.ttf new file mode 100644 index 0000000..382293d Binary files /dev/null and b/commands/levels/UserLevelCard/fonts/Montserrat/static/Montserrat-ExtraLightItalic.ttf differ diff --git a/commands/levels/UserLevelCard/fonts/Montserrat/static/Montserrat-Italic.ttf b/commands/levels/UserLevelCard/fonts/Montserrat/static/Montserrat-Italic.ttf new file mode 100644 index 0000000..eee45ba Binary files /dev/null and b/commands/levels/UserLevelCard/fonts/Montserrat/static/Montserrat-Italic.ttf differ diff --git a/commands/levels/UserLevelCard/fonts/Montserrat/static/Montserrat-Light.ttf b/commands/levels/UserLevelCard/fonts/Montserrat/static/Montserrat-Light.ttf new file mode 100644 index 0000000..c5dfdb7 Binary files /dev/null and b/commands/levels/UserLevelCard/fonts/Montserrat/static/Montserrat-Light.ttf differ diff --git a/commands/levels/UserLevelCard/fonts/Montserrat/static/Montserrat-LightItalic.ttf b/commands/levels/UserLevelCard/fonts/Montserrat/static/Montserrat-LightItalic.ttf new file mode 100644 index 0000000..5bdce7f Binary files /dev/null and b/commands/levels/UserLevelCard/fonts/Montserrat/static/Montserrat-LightItalic.ttf differ diff --git a/commands/levels/UserLevelCard/fonts/Montserrat/static/Montserrat-Medium.ttf b/commands/levels/UserLevelCard/fonts/Montserrat/static/Montserrat-Medium.ttf new file mode 100644 index 0000000..dfc7e2f Binary files /dev/null and b/commands/levels/UserLevelCard/fonts/Montserrat/static/Montserrat-Medium.ttf differ diff --git a/commands/levels/UserLevelCard/fonts/Montserrat/static/Montserrat-MediumItalic.ttf b/commands/levels/UserLevelCard/fonts/Montserrat/static/Montserrat-MediumItalic.ttf new file mode 100644 index 0000000..ce56883 Binary files /dev/null and b/commands/levels/UserLevelCard/fonts/Montserrat/static/Montserrat-MediumItalic.ttf differ diff --git a/commands/levels/UserLevelCard/fonts/Montserrat/static/Montserrat-Regular.ttf b/commands/levels/UserLevelCard/fonts/Montserrat/static/Montserrat-Regular.ttf new file mode 100644 index 0000000..aa9033a Binary files /dev/null and b/commands/levels/UserLevelCard/fonts/Montserrat/static/Montserrat-Regular.ttf differ diff --git a/commands/levels/UserLevelCard/fonts/Montserrat/static/Montserrat-SemiBold.ttf b/commands/levels/UserLevelCard/fonts/Montserrat/static/Montserrat-SemiBold.ttf new file mode 100644 index 0000000..cbf44db Binary files /dev/null and b/commands/levels/UserLevelCard/fonts/Montserrat/static/Montserrat-SemiBold.ttf differ diff --git a/commands/levels/UserLevelCard/fonts/Montserrat/static/Montserrat-SemiBoldItalic.ttf b/commands/levels/UserLevelCard/fonts/Montserrat/static/Montserrat-SemiBoldItalic.ttf new file mode 100644 index 0000000..7f9153d Binary files /dev/null and b/commands/levels/UserLevelCard/fonts/Montserrat/static/Montserrat-SemiBoldItalic.ttf differ diff --git a/commands/levels/UserLevelCard/fonts/Montserrat/static/Montserrat-Thin.ttf b/commands/levels/UserLevelCard/fonts/Montserrat/static/Montserrat-Thin.ttf new file mode 100644 index 0000000..7c90a54 Binary files /dev/null and b/commands/levels/UserLevelCard/fonts/Montserrat/static/Montserrat-Thin.ttf differ diff --git a/commands/levels/UserLevelCard/fonts/Montserrat/static/Montserrat-ThinItalic.ttf b/commands/levels/UserLevelCard/fonts/Montserrat/static/Montserrat-ThinItalic.ttf new file mode 100644 index 0000000..94bcf55 Binary files /dev/null and b/commands/levels/UserLevelCard/fonts/Montserrat/static/Montserrat-ThinItalic.ttf differ diff --git a/commands/levels/UserLevelCard/fonts/PT_Sans/OFL.txt b/commands/levels/UserLevelCard/fonts/PT_Sans/OFL.txt new file mode 100644 index 0000000..adf9d01 --- /dev/null +++ b/commands/levels/UserLevelCard/fonts/PT_Sans/OFL.txt @@ -0,0 +1,94 @@ +Copyright (c) 2010, ParaType Ltd. (http://www.paratype.com/public), +with Reserved Font Names "PT Sans" and "ParaType". + +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: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +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/commands/levels/UserLevelCard/fonts/PT_Sans/PTSans-Bold.ttf b/commands/levels/UserLevelCard/fonts/PT_Sans/PTSans-Bold.ttf new file mode 100644 index 0000000..f82c3bd Binary files /dev/null and b/commands/levels/UserLevelCard/fonts/PT_Sans/PTSans-Bold.ttf differ diff --git a/commands/levels/UserLevelCard/fonts/PT_Sans/PTSans-BoldItalic.ttf b/commands/levels/UserLevelCard/fonts/PT_Sans/PTSans-BoldItalic.ttf new file mode 100644 index 0000000..3e6cf4e Binary files /dev/null and b/commands/levels/UserLevelCard/fonts/PT_Sans/PTSans-BoldItalic.ttf differ diff --git a/commands/levels/UserLevelCard/fonts/PT_Sans/PTSans-Italic.ttf b/commands/levels/UserLevelCard/fonts/PT_Sans/PTSans-Italic.ttf new file mode 100644 index 0000000..b06ce61 Binary files /dev/null and b/commands/levels/UserLevelCard/fonts/PT_Sans/PTSans-Italic.ttf differ diff --git a/commands/levels/UserLevelCard/fonts/PT_Sans/PTSans-Regular.ttf b/commands/levels/UserLevelCard/fonts/PT_Sans/PTSans-Regular.ttf new file mode 100644 index 0000000..adaf671 Binary files /dev/null and b/commands/levels/UserLevelCard/fonts/PT_Sans/PTSans-Regular.ttf differ diff --git a/commands/levels/UserLevelCard/renderingConstants.js b/commands/levels/UserLevelCard/renderingConstants.js new file mode 100644 index 0000000..a859678 --- /dev/null +++ b/commands/levels/UserLevelCard/renderingConstants.js @@ -0,0 +1,64 @@ +const INITIAL_WIDTH = 350; +const SCALE = 2.0; +const WIDTH = INITIAL_WIDTH * SCALE; + +const RESOLUTION = { + CARD_WIDTH: WIDTH, + CARD_HEIGHT: Math.round(WIDTH * 1.6) +} + +const ALIGNMENT = { + TOP_LEFT: [0, 0], + TOP_CENTER: [0, 0.5], + TOP_RIGHT: [0, 1], + CENTER_LEFT: [0.5, 0], + CENTER_CENTER: [0.5, 0.5], + CENTER_RIGHT: [0.5, 1], + BOTTOM_LEFT: [1, 0], + BOTTOM_CENTER: [1, 0.5], + BOTTOM_RIGHT: [1, 1] +} + +const STYLE = { + AVATAR_SHIFT: RESOLUTION.CARD_WIDTH / 3.5, + AVATAR_SIZE: RESOLUTION.CARD_WIDTH / 3.5, + AVATAR_BG_BORDER: 5 * SCALE, + DARK_BACKGROUND_SHIFT: RESOLUTION.CARD_WIDTH / 35, + DARK_BACKGROUND_INNER_SHIFT: RESOLUTION.CARD_WIDTH / 29.1666, + ROUNDING: RESOLUTION.CARD_WIDTH / 21.875, + BORDER_SIZE: 15 * SCALE, + ROLE_LIMIT_FONT_SIZE: 22.5 * 0.7 * SCALE, + PROGRESSBAR_HEIGHT: RESOLUTION.CARD_WIDTH / 31.67 * 1.6, + PROGRESSBAR_WIDTH: RESOLUTION.CARD_WIDTH - ((15 * SCALE) * 2 + (12 * SCALE) * 2), + PROGRESSBAR_ROUNDING: (RESOLUTION.CARD_WIDTH / 21.875) / 4, + PROGRESSBAR_SHIFT: RESOLUTION.CARD_WIDTH / 31.67 * 5, + PROGRESSBAR_SHIFT_UP: RESOLUTION.CARD_WIDTH / 29.1666, + PROGRESSBAR_SHIFT_DOWN: RESOLUTION.CARD_WIDTH / 58.3333, + MAX_LEVEL_ICON_SIDE_LENGTH: RESOLUTION.CARD_WIDTH / 20, + USERNAME_MAX_WIDTH: (RESOLUTION.CARD_WIDTH - ((RESOLUTION.CARD_WIDTH / 23.3333) * 4)) * 0.7, + USERNAME_MAX_FONT_SIZE: 22.5 * SCALE, + STATS_GRID_SHIFT: 18 * SCALE, + STATS_GRID_GAP: 10 * SCALE, + LABEL_ROLE_FONT_SIZE: 16 * SCALE, + LABEL_STATS_PRIMARY_FONT_SIZE: (15.5 * SCALE) - 4, + LABEL_STATS_SECONDARY_FONT_SIZE: (15.5 * SCALE) - 8, +} + +const COLOURS = { + BLACK: '#18191c', + DARK_GRAY: '#2b2d31', + GRAY: '#68696c', + RED: '#ff3737', + WHITE: '#ffffff' + +} + +module.exports = { + INITIAL_WIDTH, + SCALE, + WIDTH, + RESOLUTION, + ALIGNMENT, + STYLE, + COLOURS +} \ No newline at end of file diff --git a/commands/levels/UserLevelCard/wrapperClasses/CanvasElement.js b/commands/levels/UserLevelCard/wrapperClasses/CanvasElement.js new file mode 100644 index 0000000..f08cb16 --- /dev/null +++ b/commands/levels/UserLevelCard/wrapperClasses/CanvasElement.js @@ -0,0 +1,90 @@ +const { ALIGNMENT, RESOLUTION } = require('../renderingConstants'); + +/** + * Класс представляющий основные характеристики любого отображаемого элемента + * Такие как координаты, габариты, выравнивание и контекст + */ +class CanvasElement { + constructor(canvas, x=0, y=0, w=1, h=1, alignment=ALIGNMENT.TOP_LEFT) { + this.x = x; + this.y = y; + this.w = w; + this.h = h; + this.alignment = alignment; + this.context = canvas.getContext('2d'); + } + + /** + * Относительно от своей текущей позиции смещает элемент по холсту + * + * @param {number} x + * @param {number} y + * @returns {CanvasElement} + */ + move(x=0, y=0) { + this.x += x; + this.y += y; + return this; + } + + /** + * Явное указание координат элемента + * @param {number} x + * @param {number} y + * @returns {CanvasElement} + */ + moveToPoint(x, y) { + this.x = x; + this.y = y; + this.reapplyAlignment(); + return this; + } + + /** + * Перемещение элемента на место другого элемента с учётом выравнивания перемещаемого элемента + * + * @param obj {CanvasElement} + * @returns {CanvasElement} + */ + moveToObject(obj) { + this.moveToPoint(obj.x + obj.w * this.alignment[1], obj.y + obj.h * this.alignment[0]); + return this; + } + + /** + * Смещает начальную точку объекта из левого верхнего в указанный + */ + reapplyAlignment(alignment=undefined) { + alignment = alignment ?? this.alignment; + this.x -= this.w * alignment[1]; + this.y -= this.h * alignment[0]; + } + + /** + * Возвращает координаты точки на элементе выровненной по указанному выравниванию + * + * @param alignment + * @returns {{x: number, y: number}} + */ + getAlignedPoint(alignment=ALIGNMENT.TOP_LEFT) { + return { + x: this.x + (this.w * alignment[1]), y: this.y + (this.h * alignment[0]) + }; + } + + /** + * Делает то же что и getAlignedPoint, но ограничивает координаты габаритами холста + * + * @param alignment + * @param {[]} bounds + * @returns {{x: number, y: number}} + */ + getInBoundAlignedPoint(alignment=ALIGNMENT.TOP_LEFT, bounds) { + return { + x: Math.min(Math.max(this.x + (this.w * alignment[1]), bounds[0]), bounds[1]), + y: Math.min(Math.max(this.y + (this.h * alignment[0]), bounds[2]), bounds[3]) + }; + } +} + +module.exports = CanvasElement; diff --git a/commands/levels/UserLevelCard/wrapperClasses/Icon.js b/commands/levels/UserLevelCard/wrapperClasses/Icon.js new file mode 100644 index 0000000..571a075 --- /dev/null +++ b/commands/levels/UserLevelCard/wrapperClasses/Icon.js @@ -0,0 +1,170 @@ +const UserLevelCards = require('../UserLevelCard'); +const { ALIGNMENT, STYLE } = require('../renderingConstants'); +const CanvasElement = require('./CanvasElement'); +const Canvas = require('canvas'); +const gifFrames = require('gif-frames'); +const { streamToBuffer } = require('@jorgeferrero/stream-to-buffer'); + +/** + * Класс репрезентации любого элемента который имеет отрисовку картинки + */ +class Icon extends CanvasElement { + + /** + * Возвращает ассет загруженный из источника + * + * @param {String} path + * @returns {Image} + */ + static #loadAsset(path) { + const img = new Canvas.Image(); + img.src = path; + + return img; + } + + /** + * Возвращает ассет загруженный из кеша + * + * @param assetName + * @returns {Image} + */ + static #loadAssetFromCache(assetName) { + const img = UserLevelCards.assets[assetName]; + + if (!img) log.error("Unknown asset: " + assetName) + + return img; + } + + constructor( + canvas, assetName=undefined, x=0, y=0, w=1, h=1, alignment=ALIGNMENT.TOP_LEFT + ) { + super(canvas, x, y, w, h, alignment); + if (assetName) this.asset = Icon.#loadAssetFromCache(assetName); + this.gif = undefined; + this.buffer = undefined + } + + /** + * Загрузка ассета по Url + * + * @param {String} url + * @returns {Promise} + */ + async loadAssetFromUrl(url) { + try { + if (url.split('?')[0].endsWith('.gif')) { + this.cachedGifFrames = []; + this.gifbuffer = Buffer.from(await (await fetch(url)).arrayBuffer()); + this.gif = await gifFrames( + { url: this.gifbuffer, frames: 'all', outputType: 'png' }); + this.buffer = await streamToBuffer(this.gif[0].getImage()); + this.asset = await Canvas.loadImage(this.buffer); + } else { + this.gif = undefined; + this.cachedGifFrames = []; + this.buffer = Buffer.from(await (await fetch(url)).arrayBuffer()); + this.asset = await Canvas.loadImage(this.buffer); + } + } catch (e) { + log.warn(e) + log.warn('Не удалось загрузить асет') + } + + + } + + /** + * Устанавливает соотношение сторон элемента как у картинки + * + * @param {boolean} setHeightAsPrimary Если true то высота останется + * неизменной, иначе ширина + * @returns {boolean} + */ + useOriginalAspect(setHeightAsPrimary=false) { + if (!this.asset) return false; + this.originalAspect = this.asset.naturalWidth / this.asset.naturalHeight; + if (!setHeightAsPrimary) { + this.h = this.w / this.originalAspect; + } else { + this.w = this.h * this.originalAspect; + } + }; + + /** + * Скругляет края по заданным параметрам + * + * @param {number | Array} r Радиус дуги для скругления углов. Можно + * указать 1 число либо же массив из 4 чисел. Второе позволит указать + * радиус индивидуально для каждого угла прямоугольника. + * @param {[]} bounds + * @returns {Icon} + */ + makeRounded(r=undefined, bounds=undefined) { + this.context.save(); // Сохраниние кофигурации без вырезания + + this.context.beginPath(); // Начало пути маски + + if (r === undefined) { // Скругление квадрата до круга + const pos = this.getAlignedPoint(ALIGNMENT.CENTER_CENTER); + this.context.arc( + pos.x, pos.y, Math.max(this.w, this.h) * 0.5, 0, Math.PI * 2 + ); + + } else if (typeof r === 'number') { // Скругление прямоугольника по указанному радиусу для всех углов + let pos = this.getInBoundAlignedPoint(ALIGNMENT.TOP_LEFT); + this.context.arc( + pos.x + r, pos.y + r, r, 0, Math.PI, Math.PI * 1.5 + ); + pos = this.getInBoundAlignedPoint(ALIGNMENT.TOP_RIGHT); + this.context.arc( + pos.x - r, pos.y + r, r, Math.PI * 1.5, Math.PI * 2 + ); + pos = this.getInBoundAlignedPoint(ALIGNMENT.BOTTOM_RIGHT); + this.context.arc( + pos.x - r, pos.y - r, r, Math.PI * 2, Math.PI * 0.5 + ); + pos = this.getInBoundAlignedPoint(ALIGNMENT.BOTTOM_LEFT); + this.context.arc( + pos.x + r, pos.y - r, r, Math.PI * 0.5, Math.PI + ); + + } else if (r instanceof Array) { // Скругление прямоугольника по указанному радиусу для каждого угла по отдельности + let pos = this.getInBoundAlignedPoint(ALIGNMENT.TOP_LEFT, bounds); + this.context.arc( + pos.x + r[0], pos.y + r[0], r[0], Math.PI, Math.PI * 1.5 + ); + pos = this.getInBoundAlignedPoint(ALIGNMENT.TOP_RIGHT, bounds); + this.context.arc( + pos.x - r[1], pos.y + r[1], r[1], Math.PI * 1.5, Math.PI * 2 + ); + pos = this.getInBoundAlignedPoint(ALIGNMENT.BOTTOM_RIGHT, bounds); + this.context.arc( + pos.x - r[2], pos.y - r[2], r[2], Math.PI * 2, Math.PI * 0.5 + ); + if (bounds) + this.context.arc( + STYLE.BORDER_SIZE + STYLE.AVATAR_SIZE / 2, STYLE.AVATAR_SHIFT + STYLE.AVATAR_SIZE / 2, STYLE.AVATAR_BG_BORDER + STYLE.AVATAR_SIZE / 2, Math.PI * 2, Math.PI, true + ); + pos = this.getInBoundAlignedPoint(ALIGNMENT.BOTTOM_LEFT, bounds); + this.context.arc( + pos.x + r[3], pos.y - r[3], r[3], Math.PI * 0.5, Math.PI + ); + } + + this.context.closePath(); // Замыкание пути + this.context.clip(); // Вырезание по задданой путём маске + + return this; + } + + draw(context=undefined) { + const ctx = context ?? this.context; + if (this.asset) + ctx.drawImage(this.asset, this.x, this.y, this.w, this.h); + this.context.restore(); + } +} + +module.exports = Icon; diff --git a/commands/levels/UserLevelCard/wrapperClasses/Label.js b/commands/levels/UserLevelCard/wrapperClasses/Label.js new file mode 100644 index 0000000..fd9d8ad --- /dev/null +++ b/commands/levels/UserLevelCard/wrapperClasses/Label.js @@ -0,0 +1,88 @@ +const Rect = require('./Rect'); +const { ALIGNMENT, COLOURS } = require('../renderingConstants'); +const TextBox = require('./TextBox'); + +/** + * Класс репрезентации элемента Label. По сути являтеся цветной пилюлей с + * обязательным основным текстом, необязательным вторичным текстом и + * необязательной иконкой. + */ +class Label extends Rect { + + constructor ( + canvas, x=0, y=0, w=1, h=1, + alignment=ALIGNMENT.TOP_LEFT, color=COLOURS.DARK_GRAY, rounding=0, + text='', fontSize=70 + ) { + super(canvas, x, y, w, h, alignment, color, rounding); + this.primaryText = new TextBox(canvas, x, y, w, h, ALIGNMENT.CENTER_CENTER, COLOURS.WHITE, text, fontSize, 'Montserrat Medium'); + this.secondaryText = null; + this.icon = null; + this.elShift = 10; + this.faceShift = this.elShift * 2; + this.hFaceShift = this.faceShift / 1; + } + + setPrimaryText(primaryText) { + this.primaryText = primaryText; + return this; + } + + setSecondaryText(secondaryText) { + this.secondaryText = secondaryText; + return this; + } + + setIcon(icon) { + this.icon = icon; + return this; + } + + /** + * Перемещает элементы лейбла на свои позиции относительно фона лейбла + */ + reposElements() { + this.w = this.primaryText.w + (this.hFaceShift * 2) + + (this.icon ? this.icon.w + this.elShift : 0) + + (this.secondaryText ? this.secondaryText.w + this.elShift : 0); + this.h = this.primaryText.h + (this.faceShift * 1.5); + + this.primaryText.alignment = ALIGNMENT.CENTER_LEFT; + this.primaryText.moveToObject(this); + this.primaryText.move(this.hFaceShift, 0); + + if (this.secondaryText) { + this.secondaryText.alignment = ALIGNMENT.CENTER_RIGHT; + this.secondaryText.moveToObject(this); + this.secondaryText.move(-this.hFaceShift, 0); + } + if (this.icon) { + this.primaryText.move(this.icon.w + this.elShift, 0) + this.icon.alignment = ALIGNMENT.CENTER_LEFT; + this.icon.moveToObject(this); + this.icon.move(this.hFaceShift, 0); + } + } + + draw(context=undefined) { + const ctx = context ?? this.context; + + this.reposElements(); + + super.draw(ctx); + this.primaryText.move(0, -3) + this.primaryText.context.textBaseline = 'middle'; + this.primaryText.move(0, this.primaryText.h / 1.7); + + this.primaryText.draw(ctx); + if (this.secondaryText) { + this.secondaryText.context.textBaseline = 'middle'; + this.secondaryText.alignment = ALIGNMENT.TOP_LEFT; + this.secondaryText.moveToPoint(this.secondaryText.x, this.primaryText.y); + this.secondaryText.draw(ctx); + } + if (this.icon) this.icon.draw(ctx); + } +} + +module.exports = Label; diff --git a/commands/levels/UserLevelCard/wrapperClasses/ProgressBar.js b/commands/levels/UserLevelCard/wrapperClasses/ProgressBar.js new file mode 100644 index 0000000..efbbdd7 --- /dev/null +++ b/commands/levels/UserLevelCard/wrapperClasses/ProgressBar.js @@ -0,0 +1,119 @@ +const Rect = require('./Rect'); +const { ALIGNMENT, COLOURS, STYLE } = require('../renderingConstants'); +const TextBox = require('./TextBox'); +const Icon = require('./Icon'); + + +/** + * Класс репрезентации элемента индикатора прогресса. Состоит из нескольких скруглённых прямоугольников (Фон; Основная полоса оптыа; Полоса штрафа) и двух тектовых полей (Знеачение текущего уровня и значение слудующего уровня) + */ +class ProgressBar extends Rect { + + constructor ( + canvas, x=0, y=0, w=1, h=1, + alignment=ALIGNMENT.TOP_LEFT, color=COLOURS.DARK_GRAY, rounding=0, + ) { + super(canvas, x, y, w, h, alignment, color, rounding); + this.expBar = new Rect(canvas, x, y, w, h, alignment, COLOURS.WHITE, rounding); + this.fineBar = new Rect(canvas, x, y, w, h, alignment, COLOURS.RED, rounding); + this.currLvlTxt = new TextBox(canvas, x, y, w, 100, ALIGNMENT.TOP_LEFT, COLOURS.WHITE, '...', 40); + this.nxtLvlTxt = new TextBox(canvas, x, y, 100, h, ALIGNMENT.TOP_RIGHT, COLOURS.WHITE, '...', 40); + this.maxLvl = new Icon(canvas, undefined, x, y, + STYLE.MAX_LEVEL_ICON_SIDE_LENGTH, STYLE.MAX_LEVEL_ICON_SIDE_LENGTH, + ALIGNMENT.TOP_RIGHT + ); + } + + move(x=0, y=0) { + super.move(x, y); + this.reposElements(); + + return this; + } + + moveToPoint (x, y) { + super.moveToPoint(x, y); + this.reposElements(); + return this + } + + /** + * Перемещает элементы прогресс бара на свои позиции относительно фона прогресс бара + */ + reposElements() { + this.expBar.moveToObject(this); + this.fineBar.moveToObject(this); + this.currLvlTxt + .moveToObject(this) + .move(0, STYLE.PROGRESSBAR_SHIFT_DOWN + this.h); + this.nxtLvlTxt + .moveToObject(this) + .move(0, STYLE.PROGRESSBAR_SHIFT_DOWN + this.h); + this.maxLvl + .moveToObject(this) + .move(0, STYLE.PROGRESSBAR_SHIFT_DOWN + this.h); + } + + /** + * Меняет данные под указанного пользователя + * + * @param userLevel + */ + applyToUser(userLevel) { + if (userLevel.getExpFine()) { + this.fineBar.shown = true; + const nextAmount = ( + userLevel.getNextRole() === true + ? userLevel.getRole()?.value + : userLevel.getNextRole()?.value + ); + + this.fineBar.w = STYLE.PROGRESSBAR_WIDTH * ( + nextAmount < userLevel.getExpFull() + ? 1 + : userLevel.getExpFull()/nextAmount + ); + } + + this.expBar.color = userLevel.getNextRoleColor(); + + const expProgress = (userLevel.getNextRoleProgress() !== true + ? userLevel.getNextRoleProgress() : 100) + + + this.expBar.w = Math.max(STYLE.PROGRESSBAR_WIDTH * expProgress / 100, this.rounding * 2); + + this.currLvlTxt.changeText( + userLevel.getRole().value.toLocaleString() + .replaceAll(' ', '.') + .replaceAll(',', '.') + ); + + if (userLevel.getNextRole() !== true) { + this.nxtLvlTxt.shown = true; + this.maxLvl.shown = false; + this.nxtLvlTxt.changeText( + userLevel.getNextRole().value.toLocaleString() + .replaceAll(' ', '.') + .replaceAll(',', '.'), + ); + } else { + this.maxLvl.shown = true; + this.nxtLvlTxt.shown = false; + } + this.reposElements(); + } + + draw(context=undefined) { + const ctx = context ?? this.context; + + super.draw(ctx); + if (this.fineBar.shown) this.fineBar.draw(ctx); + this.expBar.draw(ctx); + this.currLvlTxt.draw(ctx); + if (this.nxtLvlTxt.shown) this.nxtLvlTxt.draw(ctx); + if (this.maxLvl.shown) this.maxLvl.draw(ctx); + } +} + +module.exports = ProgressBar; diff --git a/commands/levels/UserLevelCard/wrapperClasses/Rect.js b/commands/levels/UserLevelCard/wrapperClasses/Rect.js new file mode 100644 index 0000000..609cf24 --- /dev/null +++ b/commands/levels/UserLevelCard/wrapperClasses/Rect.js @@ -0,0 +1,26 @@ +const CanvasElement = require('./CanvasElement'); +const { ALIGNMENT, COLOURS } = require('../renderingConstants'); + +/** + * Класс репрезентации любого элемента который имеет отрисовку прямоугольника + */ +class Rect extends CanvasElement { + constructor( + canvas, x=0, y=0, w=1, h=1, + alignment=ALIGNMENT.TOP_LEFT, color=COLOURS.BLACK, rounding=0 + ) { + super(canvas, x, y, w, h, alignment); + this.color = color; + this.rounding = rounding; + } + + draw(context=undefined) { + const ctx = context ?? this.context; + ctx.fillStyle = this.color; + ctx.beginPath(); + ctx.roundRect(this.x, this.y, this.w, this.h, this.rounding); + ctx.fill(); + } +} + +module.exports = Rect; diff --git a/commands/levels/UserLevelCard/wrapperClasses/TextBox.js b/commands/levels/UserLevelCard/wrapperClasses/TextBox.js new file mode 100644 index 0000000..088bf4f --- /dev/null +++ b/commands/levels/UserLevelCard/wrapperClasses/TextBox.js @@ -0,0 +1,90 @@ +const CanvasElement = require('./CanvasElement'); +const { ALIGNMENT, COLOURS, SCALE } = require('../renderingConstants'); + +/** + * Класс репрезентации любого элемента текста + */ +class TextBox extends CanvasElement { + constructor( + canvas, x=0, y=0, w=1, h=1, + alignment=ALIGNMENT.TOP_LEFT, color=COLOURS.WHITE, + text='', fontSize=70, font='Montserrat Medium' + ) { + super(canvas, x, y, w, h, alignment); + this.color = color; + this.text = text; + this.fontSize = fontSize * SCALE; + this.font = font; + this.context.textBaseline = "top"; + } + + /** + * Изменяет текст на новый + * + * @param {String} newTxt + * @param {number} maxTxtWidth + * @param {number} maxFont + */ + changeText(newTxt, maxTxtWidth, maxFont) { + this.text = newTxt; + this.fontSize = maxFont ?? this.fontSize; + this.applyText(maxTxtWidth, maxFont); + this.reapplyAlignment(); + } + + /** + * Устанавливает шрифт и его размер + * + * @param {String} font + * @param {String} fontSize + */ + applyFont(font=undefined, fontSize=undefined) { + font = font ?? this.font; + fontSize = fontSize ?? this.fontSize; + const fnt = font.split(' '); + this.context.font = ` ${fnt[1] ?? ''} ${fontSize}px ${fnt[0]}`; + } + + /** + * Прмеряет текст к месту его отрисовки + * + * @param {number} maxTxtWidth + * @param {number} maxFont + * @returns {string} + */ + applyText(maxTxtWidth, maxFont) { + this.applyFont(); + let outputWidth = this.context.measureText(this.text).width; + if (outputWidth > maxTxtWidth) { + let stepSize = Math.max(Math.ceil((outputWidth - maxTxtWidth) / 10), 1); + do { + this.fontSize -= stepSize; + this.applyFont(); + outputWidth = this.context.measureText(this.text).width; + stepSize = Math.max(Math.ceil((outputWidth - maxTxtWidth) / 10), 1); + } while ((outputWidth > maxTxtWidth) && (this.fontSize > 1)); + } + + if(this.fontSize > maxFont) { + this.fontSize = maxFont; + this.applyFont(); + } + + const txtMetrics = this.context.measureText(this.text); + + this.w = txtMetrics.width; + this.h = txtMetrics.actualBoundingBoxDescent; + + return this.context.font; + }; + + draw(maxTxtWidth, context=undefined) { + const ctx = context ?? this.context; + ctx.font = this.applyText(maxTxtWidth); + ctx.fillStyle = this.color; + this.applyFont(this.font, this.fontSize); + ctx.fillText(this.text, this.x, this.y); + } +} + +module.exports = TextBox; diff --git a/commands/levels/UserLevels.js b/commands/levels/UserLevels.js index 4cab1a5..20e3921 100644 --- a/commands/levels/UserLevels.js +++ b/commands/levels/UserLevels.js @@ -1,3 +1,7 @@ +const UserLevelCard = require('./UserLevelCard/UserLevelCard'); +const UserLevelCards = require('./UserLevelCard/UserLevelCard'); +const bitFields = require('./bitFields.json'); + class UserLevels { /** @@ -51,52 +55,64 @@ class UserLevels { this.#roles = roles; this.#rolesIDs = rolesIDs; - const users = DB.query('SELECT * FROM levels WHERE id = ?', [ - this.member.id - ]); - - if (users[0]) { - this.finded = true; - this.#primitiveData = { - messagesLegit: users[0].messagesLegit, - messagesAll: users[0].messagesAll, - activity: users[0].activity, - symbols: users[0].symbols, - last: users[0].last - }; - } else if (create) { - DB.query('INSERT INTO levels (`id`) VALUES (?)', [this.member.id]); - this.#primitiveData = { - messagesLegit: 0, - messagesAll: 0, - activity: 1, - symbols: 0, - last: 0 - }; - } - + return new Promise(async resolve => { + const users = await DB.query('SELECT * FROM levels WHERE id = ?', [ + this.member.id + ]); + + + if (users[0]) { + this.finded = true; + this.#primitiveData = { + messagesLegit: users[0].messagesLegit, + messagesAll: users[0].messagesAll, + activity: users[0].activity, + symbols: users[0].symbols, + last: users[0].last, + banner: users[0].banner, + flags: users[0].flags + }; + } else if (create) { + await DB.query('INSERT INTO levels (`id`) VALUES (?)', [this.member.id]); + this.#primitiveData = { + messagesLegit: 0, + messagesAll: 0, + activity: 30, + symbols: 0, + last: 0, + banner: '', + flags: 0 + }; + } + + resolve(this); + }); }; /** * Обновляет данные пользователя в базе данных */ - update () { + async update () { // TODO: Модулю настала пизда, очень много флудит коннектами к БД. // Надо сделать кеширование левелов и регулярную синхронизацию с БД. // Пушто создание коннекта после каждого сообщения юзера - кладет БД. // На похуй будем ловить ошибки от базы, хуй с ней, если скипнем одно-два сообщения юзера try { - DB.query( - 'UPDATE levels SET messagesAll = ?, messagesLegit = ?, symbols = ?, last = ? WHERE id = ?', + await DB.query( + 'UPDATE levels SET messagesAll = ?, messagesLegit = ?, symbols = ?, last = ?, banner = ?, flags = ? WHERE id = ?', [ this.#primitiveData.messagesAll, this.#primitiveData.messagesLegit, this.#primitiveData.symbols, this.#primitiveData.last, + this.#primitiveData.banner, + this.#primitiveData.flags, this.member.id ] ); - } catch (e) {} + } catch (e) { + console.error(e) + } return this; }; @@ -149,7 +165,6 @@ class UserLevels { return this; }; - /** * *************************************************************************** * Функции возвращения примитивных данных @@ -199,6 +214,13 @@ class UserLevels { // return this.#primitiveData.activity; }; + /** + * @return {String} + */ + getBanner() { + return this.#primitiveData.banner; + } + /** * *************************************************************************** @@ -323,6 +345,14 @@ class UserLevels { return this.#advancedData.nextRole = this.#roles[role.pos - 1] ?? true; }; + getNextRoleColor () { + const nextRole = this.getNextRole() === true + ? this.getRole() + : this.getNextRole() + + return dec2hex(nextRole.cache.color); + }; + /** * Возвращает прогресс до следующей роли. Возвращает true - если следующей * роли нет @@ -344,6 +374,77 @@ class UserLevels { return this.#advancedData.nextRoleProgress = nextRoleProgress; }; + /** + * Объект содержащий пары ключ + bool значение + * @return {Object} + */ + get flags () { + let flags = {}; + for (let flagEntry in bitFields.flags) { + flags[flagEntry] = Boolean(this.#primitiveData.flags & bitFields.flags[flagEntry]); + } + return flags; + } + + /** + * Объект содержащий пары ключ + bool значение + * @param value {Object} + */ + set flags (value) { + let flagsObjectCurrent = {}; + for (let flagEntry in bitFields.flags) { + flagsObjectCurrent[flagEntry] = Boolean( + this.#primitiveData.flags & bitFields.flags[flagEntry] + ); + } + let flagsNumericTarget = 0; + for (let flagEntry in flagsObjectCurrent) { + if (bitFields.flags[flagEntry] === undefined) { + throw new Error('Attempting to change an unknown flag'); + } + flagsNumericTarget += ((value[flagEntry] !== undefined) + ? bitFields.flags[flagEntry] * value[flagEntry] + : bitFields.flags[flagEntry] * flagsObjectCurrent[flagEntry]); + } + this.#primitiveData.flags = flagsNumericTarget; + } + + getBannerUrl(dynamic=false) { + if (!this.#primitiveData.banner) return null; + if (this.flags.bannerSyncedWithDiscord) + return `https://cdn.discordapp.com/banners/${this.member.id}/${this.#primitiveData.banner}.${this.#primitiveData.banner.startsWith('a_') & dynamic ? 'gif': 'png'}?size=1024`; + return this.#primitiveData.banner; + } + + async setBannerUrl(banner) { + this.#primitiveData.banner = banner; + await this.update(); + return this; + } + + isAvatarAnimated() { + return this.member.displayAvatarURL({dynamic: true})?.endsWith('.gif'); + } + + isBannerAnimated() { + return this.getBannerUrl(true)?.split('?')[0].endsWith('.gif'); + } + + isAnimated() { + return ( this.flags.animatedMediaContentEnabled && (!!this.isAvatarAnimated() || !!this.isBannerAnimated()) ); + } + + mayBeAnimated() { + return ( !this.flags.animatedMediaContentEnabled && (!!this.isAvatarAnimated() || !!this.isBannerAnimated()) ); + } + + isCached() { + return ( this.equals(UserLevelCards.getCachedCard(this.member.id)?.userLevel) ); + } + + isGifCached() { + return ( this.equals(UserLevelCards.getCachedCard(this.member.id)?.userLevel) && UserLevelCards.getCachedCard(this.member.id)?.gif); + } /** * *************************************************************************** @@ -361,15 +462,13 @@ class UserLevels { this.#embed = new Discord.MessageEmbed(); this.#embed.setTitle('Статистика пользователя'); - this.#embed.setThumbnail(this.member.user.avatarURL({ dynamic: true })); - this.#embed.setDescription(this.member.toString()); this.addMessages(); - this.addSymbols(); this.addOverpost(); + this.addSymbols(); this.addActivity(); - this.addExp(); - this.addNextRole(); + this.addImage(); + this.addFooter(); this.setColor(); @@ -383,10 +482,18 @@ class UserLevels { const messagesAll = this.getMessagesAll().toLocaleString(); const messagesLegit = this.getMessagesLegit().toLocaleString(); - this.#embed.addField( - 'Cообщения:', - messagesAll + ' (Из них учитываются: ' + messagesLegit + ')' - ); + this.#embed.addFields([ + { + name: 'Cообщения:', + value: messagesAll, + inline: true + }, + { + name: 'Из них учитываются:', + value: messagesLegit, + inline: true + }, + ]); }; /** @@ -396,10 +503,18 @@ class UserLevels { const symbols = this.getSymbols().toLocaleString(); const symbolsAvg = this.getSymbolsAvg().toLocaleString(); - this.#embed.addField( - 'Cимволы:', - symbols + ' (AVG ' + symbolsAvg + ')' - ); + this.#embed.addFields([ + { + name: 'Cимволы:', + value: symbols, + inline: true + }, + { + name: 'AVG:', + value: symbolsAvg, + inline: true + }, + ]); }; /** @@ -408,7 +523,7 @@ class UserLevels { addOverpost () { const overpost = this.getOverpost(); - this.#embed.addField('Оверпост:', overpost + '%'); + this.#embed.addField('Оверпост:', overpost + '%', true); }; /** @@ -418,11 +533,12 @@ class UserLevels { const activity = this.getActivity(); const activityPer = this.getActivityPer(); - if (activityPer === 100) return; + //if (activityPer === 100) return; this.#embed.addField( - 'Активность за последние 30 дней:', - activityPer + '% (' + activity + '/' + '30)' + 'Активность*:', + activityPer + '% (' + activity + '/' + '30)', + true ); }; @@ -453,18 +569,43 @@ class UserLevels { let text = nextRole === true ? '🎉' : nextRole.cache.toString() + ' ' + nextRoleProgress + '%'; - this.#embed.addField('Прогресс:', role.cache.toString() + ' -> ' + text); + this.#embed.addFields([{ name: 'Прогресс:', value: role.cache.toString() + ' -> ' + text }]); }; /** * Устанавливает у эмбеда цвет текущей роли пользователя */ setColor () { - const role = this.getRole(); + //const role = this.getRole(); - this.#embed.setColor(role.cache.color); + this.#embed.setColor("#2b2d31"); //role.cache.color }; + addImage () { + this.#embed.setImage('https://cdn.discordapp.com/attachments/1039311543894020156/1130793726428586055/GpL91Zm.png'); + }; + + addFooter () { + this.#embed.setFooter('*Активность за последние 30 дней'); + }; + + /** + * Сравнивает текущий экземпляр класса с предоставленным + * @param {UserLevels} userLevel + */ + equals(userLevel) { + if (!userLevel) return false; + + return (userLevel.member.id === this.member.id) + && (userLevel.getMessagesAll() === this.getMessagesAll()) + && (userLevel.getMessagesLegit() === this.getMessagesLegit()) + && (userLevel.getSymbols() === this.getSymbols()) + && (userLevel.getSymbolsAvg() === this.getSymbolsAvg()) + && (userLevel.member.displayAvatarURL() === this.member.displayAvatarURL()) + && (userLevel.getBannerUrl() === this.getBannerUrl()) + //&& (userLevel.flags.animatedMediaContentEnabled === this.flags.animatedMediaContentEnabled) + } + } module.exports = UserLevels; diff --git a/commands/levels/bitFields.json b/commands/levels/bitFields.json new file mode 100644 index 0000000..07e7261 --- /dev/null +++ b/commands/levels/bitFields.json @@ -0,0 +1,11 @@ +{ + "flags": { + "bannerSyncedWithDiscord": 1, + "bannerRemoved": 2, + "bannerBlocked": 4, + "animatedMediaContentEnabled": 8, + "animatedAppearanceEnabled": 16, + "alertBannerSyncAvailableNoticed": 32, + "alertAnimationsAvailableNoticed": 64 + } +} \ No newline at end of file diff --git a/commands/levels/index.js b/commands/levels/index.js index 2c2131b..8d8bd0b 100644 --- a/commands/levels/index.js +++ b/commands/levels/index.js @@ -3,8 +3,12 @@ const BaseCommand = require('../../BaseClasses/BaseCommand'); const LangSingle = require('../../BaseClasses/LangSingle'); const { GuildMember, + User, + Interaction, CommandInteraction, UserContextMenuInteraction, + ButtonInteraction, + ModalSubmitInteraction, InteractionReplyOptions } = require('discord.js'); @@ -12,6 +16,10 @@ const slashOptions = require('./slashOptions'); const { title, description } = require('./about.json'); const noXPChannels = require('./noXPChannels.json'); const UserLevels = require('./UserLevels'); +const UserLevelCards = require('./UserLevelCard/UserLevelCard'); +const CacheController = require('./UserLevelCard/CacheController'); +const preparedUiMessages = require('./preparedUIMessages'); +const preparedAlertsMessages = require('./preparedAlertsMessages'); class Levels extends BaseCommand { @@ -43,18 +51,26 @@ class Levels extends BaseCommand { this.description = new LangSingle(description); this.slashOptions = slashOptions; - this.roles = DB.query('SELECT * FROM levels_roles'); - this.roles.sort((a, b) => b.value - a.value); - this.rolesIDs = []; - for (let r = 0; r < this.roles.length; r++) { - this.roles[r].pos = r; - this.roles[r].cache = guild.roles.cache.get(this.roles[r].id); - if (this.roles[r].id === '648762974277992448') continue; - this.rolesIDs.push(this.roles[r].id); - } return new Promise(async resolve => { + this.roles = await DB.query('SELECT * FROM levels_roles'); + this.roles.sort((a, b) => b.value - a.value); + this.rolesIDs = []; + + for (let r = 0; r < this.roles.length; r++) { + this.roles[r].pos = r; + this.roles[r].cache = guild.roles.cache.get(this.roles[r].id); + if (this.roles[r].id === '648762974277992448') continue; + this.rolesIDs.push(this.roles[r].id); + } + + this.cardGenerator = UserLevelCards; + this.cardGenerator.assets = UserLevelCards.loadAssets(path); + this.cardGenerator.cachedImages.avatars = new CacheController(path, "avatars"); + this.cardGenerator.cachedImages.banners = new CacheController(path, "banners"); + this.cardGenerator.cachedImages.cards = new CacheController(path, "cards"); + resolve(this); }); @@ -62,45 +78,32 @@ class Levels extends BaseCommand { /** - * Обработка команды + * Обработка команды. * Выдаёт статистику по пользовтаелю и ссылку на страницу * @param {CommandInteraction|UserContextMenuInteraction} int Команда * пользователя * @param {GuildMember} member Объект пользователя - * @return {InteractionReplyOptions|Object} + * @return {Promise<{content: InteractionReplyOptions|Object, + * userLevel:UserLevels, type: string}|{error:string, type:string}>} */ async call (int, member) { - const user = new UserLevels(member, this.roles, this.rolesIDs); + const user = await new UserLevels(member, this.roles, this.rolesIDs); + let type = 'reply'; - if (!user.finded) return { error: 'Unknown User' }; + if (user.isAnimated()) { + await int.deferReply(); + type = 'editReply' + } - const embed = user.getEmbed(); + if (!user.finded) return { error: 'Unknown User', type: type }; - const status = !commands.handler && !commands.handler.siteStatus; + const status = !commands.handler?.siteStatus; return { - embeds: [embed], - components: [ - { - type: 1, components: [ - { - type: 2, - style: 5, - url: constants.SITE_LINK + '/levels', - label: 'Таблица', - disabled: status - }, - { - type: 2, - style: 5, - url: constants.SITE_LINK + '/levels?id=' + user.member.id, - label: 'Статистика пользователя', - disabled: status - } - ] - } - ] + content: await preparedUiMessages.cardShowMessage(this.cardGenerator, user, status, int), + userLevel: user, + type: type }; } @@ -111,19 +114,33 @@ class Levels extends BaseCommand { * @param {CommandInteraction} int Команда пользователя */ async slash (int) { - const content = await this.call( + const data = await this.call( int, int.options.getMember('user') ?? int.member ); + //if (data.userLevel.flags.animatedMediaContentEnabled || data.userLevel.flags.animatedAppearanceEnabled) {} - if (content.error) { - return int.reply({ - content: reaction.emoji.error + ' ' + int.str(content.error), + if (data.error) { + return int[data.type]({ + content: reaction.emoji.error + ' ' + int.str(data.error), ephemeral: true }); } - int.reply(content); + data.content.fetchReply = true + await int[data.type](data.content); + + if (!int.options.getMember('user')) { + + if (data.userLevel.flags.bannerRemoved) + return await preparedAlertsMessages.followUpBannerRemovedAlert(int, data.userLevel); + if(data.userLevel.mayBeAnimated() && !data.userLevel.flags.alertAnimationsAvailableNoticed) + return await preparedAlertsMessages.followUpAnimationsAvailableAlert(int, data.userLevel); + if(data.userLevel.flags.bannerBlocked) + return + if(!data.userLevel.flags.bannerSyncedWithDiscord && !!(await data.userLevel.member.user.fetch()).banner && !data.userLevel.flags.alertBannerSyncAvailableNoticed) + return await preparedAlertsMessages.followUpBannerSyncAvailableAlert(int, data.userLevel) + } } /** @@ -131,24 +148,220 @@ class Levels extends BaseCommand { * @param {UserContextMenuInteraction} int */ async contextUser (int) { - const content = await this.call(int, int.targetMember); + const data = await this.call(int, int.targetMember); - if (content.error) { - return int.reply({ + if (data.error) { + return int[data.type]({ content: reaction.emoji.error + ' ' + int.str(content.error), ephemeral: true }); } - content.ephemeral = true; - int.reply(content); + data.content.ephemeral = true; + await int.reply(data.content); + + if (int.targetMember === int.member) { + + if (data.userLevel.flags.bannerRemoved) + return await preparedAlertsMessages.followUpBannerRemovedAlert(int, data.userLevel); + if(data.userLevel.mayBeAnimated() && !data.userLevel.flags.alertAnimationsAvailableNoticed) + return await preparedAlertsMessages.followUpAnimationsAvailableAlert(int, data.userLevel); + if(data.userLevel.flags.bannerBlocked) + return + if(!data.userLevel.flags.bannerSyncedWithDiscord && !!(await data.userLevel.member.user.fetch()).banner && !data.userLevel.flags.alertBannerSyncAvailableNoticed) + return await preparedAlertsMessages.followUpBannerSyncAvailableAlert(int, data.userLevel) + } + } + + /** + * Обработка кнопок команды + * @param {ButtonInteraction} int + */ + async button (int) { + const params = int.customId.split('|'); + const btnType = params[1]; + let cardMessageId = params[3]; + const member = await guild.members.fetch(params[2]); + const isMod = await this.permission(int.member); + const userLevel = await new UserLevels( + member, this.roles, this.rolesIDs); + let type = 'update'; + let defered = userLevel.isAnimated(); + + if (defered) { + if (btnType !== 'ready' && btnType !== 'hub' && btnType !== 'animatedMediaContent' && btnType !== 'animatedAppearance') { + await int.deferUpdate(); + } + + if (btnType == 'hub') { + await int.deferReply({ephemeral: true}); + } + + type = 'editReply'; + } + + switch (btnType) { + case 'alert': { + return await preparedAlertsMessages.handleAlerts(int, userLevel, this.cardGenerator); + } + + case 'syncWithProfile': { + userLevel.flags = { bannerSyncedWithDiscord: true }; + await userLevel.setBannerUrl(member.user.banner); + return int[type]( + await preparedUiMessages.bannerEphemeralActionSheet( + int.client.users.cache.get(params[2]), userLevel, + params[4], isMod, int.user, int + )); + } + case 'bannerMain': { + await int[type]( + await preparedUiMessages.bannerEphemeralActionSheet( + int.client.users.cache.get(params[2]), userLevel, + params[3], isMod, int.user, int + )); + + if (member.user == int.user && userLevel.flags.bannerRemoved) { + await this.followUpBannerRemovedAlert(int, userLevel); + } + return; + } + case 'setCustom': { + return int.showModal( + await preparedUiMessages.setCustomBannerModal( + int.client.users.cache.get(params[2]), + userLevel.getBannerUrl(), cardMessageId + )); + } + case 'remove': { + userLevel.flags = { + bannerRemoved: true, + bannerSyncedWithDiscord: false + }; + await userLevel.setBannerUrl(null); + + return int[type]( + await preparedUiMessages.bannerEphemeralActionSheet( + int.client.users.cache.get(params[2]), userLevel, + cardMessageId, isMod, int.user, int + )); + } + case 'block': { + userLevel.flags = { bannerBlocked: !userLevel.flags.bannerBlocked }; + await userLevel.setBannerUrl(null); + return int.update( + await preparedUiMessages.bannerEphemeralActionSheet( + int.client.users.cache.get(params[2]), userLevel, + cardMessageId, isMod, int.user, int + )); + } + case 'ready': { + await int.deferUpdate(); + await int.webhook.deleteMessage('@original'); + + const message = await int.channel.messages.fetch(cardMessageId); + return message.edit( + await preparedUiMessages.cardShowMessage(this.cardGenerator, + userLevel, !commands?.handler?.siteStatus + )); + } + case 'hub': { + if (!defered) type = 'reply'; + + if (member.user != int.user && !isMod) { + return int[type]( + { content: 'Отказано в доступе', ephemeral: true }); + } + + return int[type]( + await preparedUiMessages.hubEphemeralActionSheet( + this.cardGenerator, userLevel, int.message.id)) + } + case 'hubBack': { + return int[type]( + await preparedUiMessages.hubEphemeralActionSheet( + this.cardGenerator, userLevel, cardMessageId)) + } + case 'animatedMain': { + return int[type]( + await preparedUiMessages.animationsEphemeralActionSheet( + this.cardGenerator, userLevel, cardMessageId, int + ) + ); + } + case 'animatedMediaContent': { + userLevel.flags = { animatedMediaContentEnabled: !userLevel.flags.animatedMediaContentEnabled }; + await userLevel.update(); + + type = 'update' + + if(userLevel.flags.animatedMediaContentEnabled) { + await int.deferUpdate(); + type = 'editReply'; + } + + return await int[type]( + await preparedUiMessages.animationsEphemeralActionSheet( + this.cardGenerator, userLevel, cardMessageId, int + ) + ); + } + case 'animatedAppearance': { + userLevel.flags = { animatedAppearanceEnabled: !userLevel.flags.animatedAppearanceEnabled }; + await userLevel.update(); + + if(userLevel.isAnimated()) { + await int.deferUpdate(); + type = 'editReply'; + } + + return await int[type]( + await preparedUiMessages.animationsEphemeralActionSheet( + this.cardGenerator, userLevel, cardMessageId + ) + ); + } + } } + /** + * + * @param {ModalSubmitInteraction}int + * @returns {Promise|void|void>} + */ + async modal (int) { + const params = int.customId.split('|'); + const modalType = params[1]; + const cardMessageId = params[3]; + const member = await guild.members.fetch(params[2]); + const isMod = await this.permission(int.member); + const userLevel = await new UserLevels(member, this.roles, this.rolesIDs); + + switch (modalType) { + case 'setCustomBanner': { + userLevel.flags = { bannerSyncedWithDiscord: false }; + await userLevel.update(); + const url = int.components[0].components[0].value; + if(url) { + try { + new URL(url); + } catch (e) { + return int.reply({ + content: 'Вы указали не ссылку!', + ephemeral: true + }); + } + } + await userLevel.setBannerUrl(url); + return int.update(await preparedUiMessages.bannerEphemeralActionSheet(int.client.users.cache.get(params[2]), userLevel, cardMessageId, isMod, int.user)) + } + } + } /** * Обработчик сообщений пользователя - * Мониторинг всех сообщений для начисления опыта пользователям. Игнорируются - * сообщения бота и в некоторых каналах. + * Мониторинг всех сообщений для начисления опыта пользователям. + * Игнорируются сообщения бота и в некоторых каналах. * @param {Message} msg Сообщение пользователя */ async message (msg) { @@ -158,13 +371,32 @@ class Levels extends BaseCommand { if (this.noXPChannels.includes(channel.parentId)) return; if (this.noXPChannels.includes(channel.id)) return; - let user = new UserLevels(msg.member, this.roles, this.rolesIDs, true); + let user = await new UserLevels(msg.member, this.roles, this.rolesIDs, true); - user.userMessageCounting(msg) - .update() + (await user.userMessageCounting(msg) + .update()) .updateRole(); } + /** + * + * @param {User|GuildMember} user + */ + async permission(user){ + if (user instanceof User) + user = await guild.members.fetch(user.id); + if (!(user instanceof GuildMember)) + return false; + + return user.roles.cache.hasAny( + ...[ + '613412133715312641', + '916999822693789718', + '920407448697860106' + ] + ) + + } } module.exports = Levels; diff --git a/commands/levels/preparedAlertsMessages.js b/commands/levels/preparedAlertsMessages.js new file mode 100644 index 0000000..74d9a8e --- /dev/null +++ b/commands/levels/preparedAlertsMessages.js @@ -0,0 +1,160 @@ +const preparedUiMessages = require('./preparedUIMessages'); + +/** + * Отправляет follow up с предупреждением об удалении банера + * @param {ButtonInteraction|ModalSubmitInteraction|CommandInteraction} int + * @param {UserLevels} userLevel + */ +async function followUpBannerRemovedAlert(int, userLevel) { + let content = 'Ваш баннер был удалён модерацией.\nВ будущем вам может быть запрещён доступ к смене банера' + + if (userLevel.flags.bannerBlocked) content = 'Ваш баннер был удалён и заблокирован модерацией.\nВы больше не можете изменять свой баннер' + + await int.followUp({ + content: content, + ephemeral: true + }) + userLevel.flags = { bannerRemoved: false } + await userLevel.update(); +} + +/** + * Отправляет follow up с подсказкой о баннере из дискорда + * @param {ButtonInteraction|ModalSubmitInteraction|CommandInteraction} int + * @param {UserLevels} userLevel + */ +async function followUpBannerSyncAvailableAlert(int, userLevel) { + let content = 'Обнаружен баннер в вашем профиле Дискорда!\nХотите добавить его в свою карточку уровня?' + const cardMessageId = (await int.fetchReply()).id + + await int.followUp({ + content: content, + ephemeral: true, + components: [ + { + type: 1, components: [ + { + type: 2, + style: 3, + customId: 'levels|alert|' + userLevel.member.id + '|' + cardMessageId + '|syncBanner', + label: 'Добавить', + }, + { + type: 2, + style: 2, + customId: 'levels|alert|' + userLevel.member.id + '|' + cardMessageId + '|ignore', + label: 'Позже', + }, + { + type: 2, + style: 2, + customId: 'levels|alert|' + userLevel.member.id + '|' + cardMessageId + '|disableBannerSyncAlert', + label: 'Никогда', + }, + ] + } + ] + }) +} + +/** + * Отправляет follow up с подсказкой об анимированных карточках + * @param {ButtonInteraction|ModalSubmitInteraction|CommandInteraction} int + * @param {UserLevels} userLevel + */ +async function followUpAnimationsAvailableAlert(int, userLevel) { + let content = 'Похоже у вас анимированный аватар или баннер.\nХотите включить анимированную карточку уровня? (Может потребоваться больше времени для генерации)' + const cardMessageId = (await int.fetchReply()).id + + await int.followUp({ + content: content, + ephemeral: true, + components: [ + { + type: 1, components: [ + { + type: 2, + style: 3, + customId: 'levels|alert|' + userLevel.member.id + '|' + cardMessageId + '|enableAnimations', + label: 'Включить', + }, + { + type: 2, + style: 2, + customId: 'levels|alert|' + userLevel.member.id + '|' + cardMessageId + '|ignore', + label: 'Позже', + }, + { + type: 2, + style: 2, + customId: 'levels|alert|' + userLevel.member.id + '|'+ cardMessageId + '|disableAnimationsAlert', + label: 'Никогда', + }, + ] + } + ] + }) +} + +async function handleAlerts(int, userLevel, cardGenerator) { + const params = int.customId.split('|'); + const actionType = params[4]; + const cardMessageId = params[3]; + const member = await guild.members.fetch(params[2]); + + switch (actionType) { + case 'syncBanner': { + userLevel.flags = { bannerSyncedWithDiscord: true, alertBannerSyncAvailableNoticed: true }; + await userLevel.setBannerUrl(member.user.banner); + + await int.deferUpdate(); + await int.webhook.deleteMessage('@original'); + + const message = await int.channel.messages.fetch(cardMessageId); + return message.edit( + await preparedUiMessages.cardShowMessage(cardGenerator, + userLevel, !commands?.handler?.siteStatus + )); + } + case 'enableAnimations': { + userLevel.flags = { animatedMediaContentEnabled: true, alertAnimationsAvailableNoticed: true }; + await userLevel.update() + + await int.deferUpdate(); + await int.webhook.deleteMessage('@original'); + + const message = await int.channel.messages.fetch(cardMessageId); + return message.edit( + await preparedUiMessages.cardShowMessage(cardGenerator, + userLevel, !commands?.handler?.siteStatus + )); + } + case 'disableBannerSyncAlert': { + userLevel.flags = { alertBannerSyncAvailableNoticed: true }; + await userLevel.update() + await int.deferUpdate(); + await int.webhook.deleteMessage('@original'); + break; + } + case 'disableAnimationsAlert': { + userLevel.flags = { alertAnimationsAvailableNoticed: true }; + await userLevel.update() + await int.deferUpdate(); + await int.webhook.deleteMessage('@original'); + break; + } + case 'ignore': { + await int.deferUpdate(); + await int.webhook.deleteMessage('@original'); + break; + } + } +} + + +module.exports = { + followUpBannerRemovedAlert, + followUpBannerSyncAvailableAlert, + followUpAnimationsAvailableAlert, + handleAlerts +} \ No newline at end of file diff --git a/commands/levels/preparedUIMessages.js b/commands/levels/preparedUIMessages.js new file mode 100644 index 0000000..d02bd34 --- /dev/null +++ b/commands/levels/preparedUIMessages.js @@ -0,0 +1,288 @@ +const { MessageAttachment, GuildMember, Snowflake } = require('discord.js'); +const UserLevelCards = require('./UserLevelCard/UserLevelCard'); +const UserLevels = require('./UserLevels'); + +/** + * + * @param {UserLevelCards} cardGenerator + * @param {UserLevels} user + * @param {boolean} status + * @param {Interaction} int + * @return {Promise<{components: [{components: [{style: number, disabled, + * label: string, type: number, url: string}], type: number},{components: + * [{style: number, disabled, label: string, type: number, url: + * string},{style: number, label: string, type: number, customId: string}], + * type: number}], files: *[]}>} + */ +async function cardShowMessage (cardGenerator, user, status, int) { + + console.time(`${user.member.id}: Сard generated in`); + const attachment = await cardGenerator.generate(user, int); + console.timeEnd(`${user.member.id}: Сard generated in`); + const payoad = { + content: null, + files: [attachment], + components: [ + { + type: 1, components: [ + { + type: 2, + style: 5, + url: constants.SITE_LINK + '/levels?id=' + user.member.id, + label: 'Статистика пользователя', + disabled: status + } + ] + }, + { + type: 1, components: [ + { + type: 2, + style: 5, + url: constants.SITE_LINK + '/levels', + label: 'Таблица', + disabled: status + }, + { + type: 2, + style: 1, + customId: 'levels|hub|' + user.member.id, + label: 'Управлять', + }, + ] + }, + ] + }; + + return payoad; +} + +async function hubEphemeralActionSheet (cardGenerator, user, cardMessageId, int) { + console.time(`${user.member.id}: Сard generated in`); + const attachment = await cardGenerator.generate(user, int); + console.timeEnd(`${user.member.id}: Сard generated in`); + return { + files: [attachment], + ephemeral: true, + content: '**Панель управления**', + components: [ + { + type: 1, components: [ + { + type: 2, + style: 1, + customId: 'levels|animatedMain|' + user.member.id + '|' + cardMessageId, + label: 'Анимации', + }, + { + type: 2, + style: 1, + customId: 'levels|bannerMain|' + user.member.id + '|' + cardMessageId, + label: 'Баннер', + }, + ] + }, + ] + }; +} + +async function animationsEphemeralActionSheet (cardGenerator, user, cardMessageId, int) { + console.time(`${user.member.id}: Сard generated in`); + const attachment = await cardGenerator.generate(user, int); + console.timeEnd(`${user.member.id}: Сard generated in`); + return { + files: [attachment], + ephemeral: true, + content: '**Панель управления анимациями**', + components: [ + { + type: 1, components: [ + { + type: 2, + style: user.flags.animatedMediaContentEnabled ? 1 : 2, + customId: 'levels|animatedMediaContent|' + user.member.id + '|' + cardMessageId, + label: 'Аватар и Баннер', + }, + { + type: 2, + disabled: true, + style: user.flags.animatedAppearanceEnabled ? 1 : 2, + customId: 'levels|animatedAppearance|' + user.member.id + '|' + cardMessageId, + label: 'При появлении', + }, + ] + }, + { + type: 1, components: [ + { + type: 2, + style: 2, + customId: 'levels|hubBack|' + user.member.id + '|' + cardMessageId, + label: 'Назад', + }, + { + type: 2, + style: 3, + customId: 'levels|ready|' + user.member.id + '|' + cardMessageId, + label: 'Применить', + }, + ] + }, + ] + }; +} + +/** + * + * @param {User} user + * @param {UserLevels} userLevel + * @param {Snowflake} cardMessageId + * @param {boolean} permission + * @param {User} author + * @returns {Promise<{components: [{components: [{style: number, disabled: *, + * label: string, type: number, customId: string}], type: number}], + * ephemeral: boolean, files: MessageAttachment[]}>} + */ +async function bannerEphemeralActionSheet (user, userLevel, cardMessageId, permission=false, author=undefined) { + const guildUser = await user.fetch(); + let syncWithProfileTxt = 'Использовать из профиля'; + let setCustomTxt = 'Использовать кастомный'; + let bannerUrl = userLevel.getBannerUrl(true); + let disableSyncWithProfile = false; + + if (userLevel.flags.bannerSyncedWithDiscord) { + syncWithProfileTxt = 'Используется из профиля'; + disableSyncWithProfile = true; + } else { + if (bannerUrl) setCustomTxt = 'Изменить кастомный'; + } + + if (!guildUser.banner) { + syncWithProfileTxt = 'Нет баннера в профиле' + disableSyncWithProfile = true; + } + + if(bannerUrl === guildUser.bannerURL()) { + disableSyncWithProfile = true; + } else { + userLevel.flags = { bannerSyncedWithDiscord: false }; + } + + let bannerIsDefault = false; + + if (!bannerUrl) { + bannerUrl = './commands/levels/UserLevelCard/assets/default_banner.png'; + bannerIsDefault = true; + } + + const navigationComponents = { + type: 1, components: [ + { + type: 2, + style: 2, + customId: 'levels|hubBack|' + guildUser.id + '|' + cardMessageId, + label: 'Назад', + }, + { + type: 2, + style: 3, + customId: 'levels|ready|' + guildUser.id + '|' + cardMessageId, + label: 'Применить', + }, + ] + }; + + const modComponents = { + type: 1, components: [ + { + type: 2, + style: 4, + customId: 'levels|remove|' + guildUser.id + '|' + cardMessageId, + label: 'Удалить баннер', + disabled: bannerIsDefault + }, + { + type: 2, + style: userLevel.flags.bannerBlocked ? 3 : 4, + customId: 'levels|block|' + guildUser.id + '|' + cardMessageId, + label: userLevel.flags.bannerBlocked ? 'Разблокировать баннер' : 'Удалить и заблокировать баннер', + disabled: false + }, + ] + } + + const blocked = userLevel.flags.bannerBlocked && author == userLevel.member.user + + const response = { + content: '**Панель взаимодействия с баннером**\n> Текущий баннер:' + (blocked ? '\n\n**Вам запрещёно изменять свой баннер**' : ''), + ephemeral: true, + files: [new MessageAttachment(bannerUrl)], + components: [ + { + type: 1, components: [ + { + type: 2, + style: 1, + customId: 'levels|syncWithProfile|' + guildUser.id + '|' + (guildUser.banner ?? '') + '|' + cardMessageId, + label: syncWithProfileTxt, + disabled: disableSyncWithProfile || blocked + }, + { + type: 2, + style: 1, + customId: 'levels|setCustom|' + guildUser.id + '|' + cardMessageId, + label: setCustomTxt, + disabled: blocked + }, + ] + }, + ] + }; + + if(permission) response.components.push(modComponents); + response.components.push(navigationComponents); + return response; +} + +/** + * + * @param {User} user + * @param {string} bannerUrl + * @param {Snowflake} cardMessageId + * @returns {Promise<{components: [{components: [{min_length: number, style: + * number, label: string, placeholder: string, type: number, customId: + * string, value: *, required: boolean}], type: number}], title: string, + * customId: string}>} + */ +async function setCustomBannerModal (user, bannerUrl=undefined, cardMessageId) { + const guildUser = await user.fetch(); + + return { + title: 'Смена баннера', + customId: 'levels|setCustomBanner|' + guildUser.id + '|' + cardMessageId, + components: [ + { + type: 1, components: [ + { + type: 4, + style: 1, + customId: 'levels|setCustomBanner|url', + label: 'Ссылка на банер', + min_length: 15, + value: bannerUrl, + placeholder: "Оставьте пустым чтобы вернуть стандартный баннер", + required: false + }, + ] + }, + ] + }; +} + +module.exports = { + cardShowMessage, + bannerEphemeralActionSheet, + setCustomBannerModal, + hubEphemeralActionSheet, + animationsEphemeralActionSheet +} diff --git a/commands/voice/index.js b/commands/voice/index.js index 1a29a4c..7216921 100644 --- a/commands/voice/index.js +++ b/commands/voice/index.js @@ -101,7 +101,7 @@ class Voice extends BaseCommand { async slash (int) { if (int.options.getSubcommand() === 'auto-sync') { await int.deferReply({ ephemeral: true }); - DB.query(`UPDATE users SET mode = "${int.options.getString('mode')}" WHERE id = ${int.user.id};`)[0]; + await DB.query(`UPDATE users SET mode = "${int.options.getString('mode')}" WHERE id = ${int.user.id};`)[0]; await int.editReply({ content: reaction.emoji.success + ' ' + int.str('Settings changed'), ephemeral: true @@ -172,14 +172,14 @@ class Voice extends BaseCommand { if (state.member.user.bot) return; // проверка на бота - if (channel.after.id === this.channelCreate.id) this.create(after); + if (channel.after.id === this.channelCreate.id) await this.create(after); if (!before.channel || channel.before.id === this.channelCreate.id || before.channel.members.filter(m => !m.user.bot).size) { return; } - this.delete(before.channel, user); + await this.delete(before.channel, user); } @@ -194,7 +194,7 @@ class Voice extends BaseCommand { async create (data) { let preset; try { - preset = DB.query(`SELECT * FROM users WHERE id = '${data.member.id}';`)[0]; + preset = await DB.query(`SELECT * FROM users WHERE id = '${data.member.id}';`)[0]; } catch (e) { console.log('DB error occurred:\n' + e); } @@ -220,8 +220,7 @@ class Voice extends BaseCommand { const channel = await data.guild.channels.create(name, obj); data.setChannel(channel).catch(reason => channel.delete()); - - + this.channelCreate.permissionOverwrites.create(data.member, { CONNECT: false }); @@ -287,14 +286,14 @@ class Voice extends BaseCommand { bitrate: voice.channel.bitrate, userLimit: voice.channel.userLimit }); - if (DB.query( + if (await DB.query( `SELECT * FROM users WHERE id = ${voice.member.user.id};`)[0]) { - DB.query( + await DB.query( `UPDATE users SET voice_data = ? WHERE id = ${voice.member.user.id};`, [voice_data] )[0]; } else { - DB.query( + await DB.query( `INSERT INTO users VALUES (?, ?, ?, ?);`, [voice.member.user.id, 0, voice.channelId, voice_data] )[0]; @@ -307,7 +306,7 @@ class Voice extends BaseCommand { */ async sync (voice) { let voiceConfiguration = JSON.parse(( - DB.query(`SELECT * FROM users WHERE id = ${voice.member.user.id};`)[0] + await DB.query(`SELECT * FROM users WHERE id = ${voice.member.user.id};`)[0] ).voice_data); if (!voiceConfiguration) return 'There is no data entry in the database associated with you. Use `/upload` to fix it.'; diff --git a/commands/warn/Warn.js b/commands/warn/Warn.js index 40cea9b..3f09b2b 100644 --- a/commands/warn/Warn.js +++ b/commands/warn/Warn.js @@ -194,15 +194,15 @@ class Warn { /** * Сохраняет модель в базу данных */ - save () { + async save () { if (this.#id) { - DB.query( + await DB.query( 'UPDATE warns SET reason = ?, flags = ? WHERE id = ?', [this.reason, this.flagsRaw, this.#id] ); } else { - DB.query( + await DB.query( 'INSERT INTO warns (type, target, reason, author, reference, date, flags) VALUES (?, ?, ?, ?, ?, ?, ?)', [ this.#type, @@ -214,7 +214,7 @@ class Warn { this.flagsRaw ] ); - this.#id = DB.query('SELECT MAX(id) as max FROM warns')[0].max; + this.#id = await DB.query('SELECT MAX(id) as max FROM warns')[0].max; } return this; @@ -259,8 +259,8 @@ class Warn { * @param {number|string} id * @return {Warn} */ - static get (id) { - const data = DB.query('SELECT * FROM warns WHERE id = ?', [id]); + static async get (id) { + const data = await DB.query('SELECT * FROM warns WHERE id = ?', [id]); if (!data[0]) return undefined; return new this(data[0]); @@ -272,11 +272,11 @@ class Warn { * @param {Snowflake|string} [target] ID пользователя * @return {Warn} */ - static last (target) { + static async last (target) { const query = target ? `SELECT * FROM warns WHERE id = (SELECT MAX(id) FROM warns WHERE target = ${target})` : `SELECT * FROM warns WHERE id = (SELECT MAX(id) FROM warns)`; - const data = DB.query(query); + const data = await DB.query(query); if (!data[0]) return undefined; return new this(data[0]); @@ -288,11 +288,11 @@ class Warn { * @param {Snowflake|string} [target] ID пользователя * @return {Warn[]} */ - static all (target) { + static async all (target) { const query = target ? `SELECT * FROM warns WHERE NOT flags & 4 AND target = ${target}` : `SELECT * FROM warns WHERE NOT flags & 4`; - const data = DB.query(query); + const data = await DB.query(query); let warns = []; for (let i = data.length; i >= 0; i--) { diff --git a/commands/warn/WarnPagination.js b/commands/warn/WarnPagination.js index c10a697..a1e35b0 100644 --- a/commands/warn/WarnPagination.js +++ b/commands/warn/WarnPagination.js @@ -1,4 +1,5 @@ const EmbedBuilder = require('./EmbedBuilder'); +const UserLevelCards = require('../levels/UserLevelCard/UserLevelCard'); class WarnPagination { @@ -48,28 +49,32 @@ class WarnPagination { * @constructor */ constructor (Warn, target, pageNumber, pageCount) { - this.pageNumber = Number(pageNumber ?? 1); - this.pageCount = Number(pageCount ?? 5); + return new Promise(async resolve => { + this.pageNumber = Number(pageNumber ?? 1); + this.pageCount = Number(pageCount ?? 5); - if (target) this.target = target; + if (target) this.target = target; - const skip = this.pageCount * (this.pageNumber - 1); + const skip = this.pageCount * (this.pageNumber - 1); - const query = target - ? `FROM warns WHERE target = ${this.target.id} AND NOT flags & 4` - : `FROM warns WHERE NOT flags & 4`; - this.count = DB.query('SELECT COUNT(*) AS count ' + query)[0].count; - const data = DB.query( - 'SELECT * ' + query + ' ORDER BY id DESC LIMIT ?, ?', - [skip, this.pageCount] - ); + const query = target + ? `FROM warns WHERE target = ${this.target.id} AND NOT flags & 4` + : `FROM warns WHERE NOT flags & 4`; + this.count = await DB.query('SELECT COUNT(*) AS count ' + query)[0].count; + const data = await DB.query( + 'SELECT * ' + query + ' ORDER BY id DESC LIMIT ?, ?', + [skip, this.pageCount] + ); - for (const row of data) { - row.date *= 1000; - this.list.push(new Warn(row)); - } + for (const row of data) { + row.date *= 1000; + this.list.push(new Warn(row)); + } - this.pageLast = Math.ceil(this.count / this.pageCount); + this.pageLast = Math.ceil(this.count / this.pageCount); + + resolve(this); + }); } /** diff --git a/functions/copyCanvas.js b/functions/copyCanvas.js new file mode 100644 index 0000000..6242c7c --- /dev/null +++ b/functions/copyCanvas.js @@ -0,0 +1,17 @@ +const Canvas = require('canvas'); + +/** + * Создаёт копию канваса + * @param {Canvas} oldCanvas + * @returns {Canvas} + */ + +global.copyCanvas = function (oldCanvas) { + + let newCanvas = Canvas.createCanvas(oldCanvas.width, oldCanvas.height); + let context = newCanvas.getContext('2d'); + + context.drawImage(oldCanvas, 0, 0); + + return newCanvas; +} diff --git a/functions/dec2hex.js b/functions/dec2hex.js new file mode 100644 index 0000000..1413b3f --- /dev/null +++ b/functions/dec2hex.js @@ -0,0 +1,13 @@ +/** + * Конвертирует строку + * Дополнительно можно указать символ отступа слева или справа. + * + * @return {string} + */ +global.dec2hex = function (dec, targetLength=6, addHashtag=true) { + let hex = dec.toString(16); + const dif = Math.max(targetLength - hex.length, 0); + hex = "0".repeat(dif) + hex; + if (addHashtag) hex = "#" + hex; + return hex; +}; \ No newline at end of file diff --git a/functions/getMilliseconds.js b/functions/getMilliseconds.js new file mode 100644 index 0000000..31b2aa2 --- /dev/null +++ b/functions/getMilliseconds.js @@ -0,0 +1,7 @@ +/** + * Возвращает количество милисекунд с 3 цифрами после запятой с начала эпохи (01.01.1970) + */ +global.getMilliseconds = function () { + const hrTime = Number(process.hrtime.bigint()); + return round(hrTime / 1000000, 9) +} diff --git a/functions/round.js b/functions/round.js new file mode 100644 index 0000000..c516136 --- /dev/null +++ b/functions/round.js @@ -0,0 +1,9 @@ +/** + * Округляет с указанным количеством символов после запятой + * @param value + * @param {number} toFixed + */ +global.round = function (value, toFixed=0) { + const powedToF = Math.pow(10, toFixed); + return Math.round((value + Number.EPSILON) * powedToF) / powedToF; +} diff --git a/index.js b/index.js index ad50c9e..673d70a 100644 --- a/index.js +++ b/index.js @@ -1,5 +1,6 @@ -!async function () { - require('dotenv').config(); +console.time('Client initialized in'); + +require('dotenv').config(); console.time('Client initialized in'); global.Discord = require('discord.js'); @@ -9,16 +10,24 @@ console.log('Start index.js'); - global.DB = new (require('sync-mysql'))({ - host: process.env.DB_HOST, - user: process.env.DB_USER, - password: process.env.DB_PASSWORD, - database: process.env.DB_DATABASE, - charset: 'utf8mb4' - }); +const mysql = require('mysql2/promise'); +const util = require('util'); + +const connection = mysql.createPool({ + connectionLimit: 10, + host: process.env.DB_HOST, + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + database: process.env.DB_DATABASE, + charset: 'utf8mb4' +}); - // TODO: Избавиться от старой либы, полностью перейти на typeorm - await (require('./libs/DB.js')).DB.init(); +global.DB = { + query: async (sql, values=undefined) => { + const [rows, fields] = await (await connection).query(sql, values); + return rows; + } +}; client.on('ready', require('./init')); diff --git a/init.js b/init.js index d4578b3..ed7c491 100644 --- a/init.js +++ b/init.js @@ -25,7 +25,7 @@ const init = [ * Пример: "help" * @type {string[]} */ -global.debugAllowModules = []; +global.debugAllowModules = ['levels']; module.exports = async () => { diff --git a/locales/ru.json b/locales/ru.json index 00a9e2b..990918d 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -11,16 +11,16 @@ "no": "против", "Why you choose": "Почему вы выбрали", "Enter your valuable opinion": "Введите ваше ценное мнение", - "Vote submmited": "Голос подтверждён", + "Vote submitted": "Голос подтверждён", "Vote changed": "Голос изменён", "There are no votes yet": "Голосов пока нет", "Vote not found": "Голос не найден", "You aren't connect to voice channel": "Вы не подключены к голосовому каналу", "Settings changed": "Настройки изменены", - "Voice channel configuration updated in DB": "Конфигурция канала записана в базу данных", + "Voice channel configuration updated in DB": "Конфигурация канала записана в базу данных", "Invalid duration provided": "Введена неверная продолжительность", "Unknown User": "Неизвестный пользователь", - "Can't resolve the user data": "Неудалось обработать данные пользователя", + "Can't resolve the user data": "Не удалось обработать данные пользователя", "Role not found": "Роль не найдена", "Show list of all Game Roles": "Показать список всех доступных Игровых Ролей", "There is no data entry in the database associated with you. Use `/upload` to fix it.": "Отсутствует запись в базе данных связанная с вами. Используйте `/upload` чтобы исправить это.", diff --git a/locales/uk.json b/locales/uk.json index c2fbf7a..9a91e18 100644 --- a/locales/uk.json +++ b/locales/uk.json @@ -11,7 +11,7 @@ "no": "проти", "Why you choose": "Чому ви обрали", "Enter your valuable opinion": "Напишіть свою думку", - "Vote submmited": "Вибір підтверджено", + "Vote submitted": "Вибір підтверджено", "Vote changed": "Вибір змінено", "There are no votes yet": "Поки ніхто не голосував", "Vote not found": "Вибір не знайдено", diff --git a/package.json b/package.json index 680d286..8a0dc47 100644 --- a/package.json +++ b/package.json @@ -9,15 +9,23 @@ "author": "BVN4", "license": "ISC", "dependencies": { + "@discordjs/builders": "^1.3.0", "@discordjs/collection": "^0.7.0", "@discordjs/rest": "^0.5.0", + "@jorgeferrero/stream-to-buffer": "^2.0.6", + "canvas": "^2.10.1", "discord-api-types": "^0.33.4", - "discord.js": "^13.12.0", + "discord.js": "^13.16.0", "dotenv": "^16.0.3", + "gif-encoder": "^0.7.2", + "gif-frames": "^1.0.1", "mysql": "^2.18.1", - "reflect-metadata": "^0.1.13", + "mysql2": "^3.5.2", "sync-mysql": "^3.0.1", "transliteration": "^2.2.0", + "undici": "^5.11.0" + "mysql": "^2.18.1", + "reflect-metadata": "^0.1.13", "typeorm": "^0.3.17" }, "repository": {