diff --git a/src/assets/fonts/Asap.ttf b/src/assets/fonts/Asap.ttf deleted file mode 100644 index d950caa..0000000 Binary files a/src/assets/fonts/Asap.ttf and /dev/null differ diff --git a/src/assets/fonts/NotoColorEmoji.ttf b/src/assets/fonts/NotoColorEmoji.ttf new file mode 100644 index 0000000..041d9d9 Binary files /dev/null and b/src/assets/fonts/NotoColorEmoji.ttf differ diff --git a/src/assets/fonts/NotoSansArabic-Bold.ttf b/src/assets/fonts/NotoSansArabic-Bold.ttf new file mode 100644 index 0000000..ab5b96f Binary files /dev/null and b/src/assets/fonts/NotoSansArabic-Bold.ttf differ diff --git a/src/assets/fonts/NotoSansJP-Bold.ttf b/src/assets/fonts/NotoSansJP-Bold.ttf new file mode 100644 index 0000000..f1596e8 Binary files /dev/null and b/src/assets/fonts/NotoSansJP-Bold.ttf differ diff --git a/src/assets/fonts/NotoSansKR-Bold.ttf b/src/assets/fonts/NotoSansKR-Bold.ttf new file mode 100644 index 0000000..14eabfe Binary files /dev/null and b/src/assets/fonts/NotoSansKR-Bold.ttf differ diff --git a/src/assets/fonts/NotoSansSC-Bold.ttf b/src/assets/fonts/NotoSansSC-Bold.ttf new file mode 100644 index 0000000..1edc546 Binary files /dev/null and b/src/assets/fonts/NotoSansSC-Bold.ttf differ diff --git a/src/assets/fonts/NotoSansTC-Bold.ttf b/src/assets/fonts/NotoSansTC-Bold.ttf new file mode 100644 index 0000000..46bb6f6 Binary files /dev/null and b/src/assets/fonts/NotoSansTC-Bold.ttf differ diff --git a/src/assets/fonts/NotoSansThai-Bold.ttf b/src/assets/fonts/NotoSansThai-Bold.ttf new file mode 100644 index 0000000..9c6798c Binary files /dev/null and b/src/assets/fonts/NotoSansThai-Bold.ttf differ diff --git a/src/assets/fonts/gg sans Medium.ttf b/src/assets/fonts/gg sans Medium.ttf new file mode 100644 index 0000000..5930912 Binary files /dev/null and b/src/assets/fonts/gg sans Medium.ttf differ diff --git a/src/assets/fonts/gg sans Semibold.ttf b/src/assets/fonts/gg sans Semibold.ttf new file mode 100644 index 0000000..92118cf Binary files /dev/null and b/src/assets/fonts/gg sans Semibold.ttf differ diff --git a/src/assets/fonts/segoe-ui-symbol.ttf b/src/assets/fonts/segoe-ui-symbol.ttf new file mode 100644 index 0000000..f0f47bb Binary files /dev/null and b/src/assets/fonts/segoe-ui-symbol.ttf differ diff --git a/src/assets/images/default.png b/src/assets/images/default.png new file mode 100644 index 0000000..af86aef Binary files /dev/null and b/src/assets/images/default.png differ diff --git a/src/assets/images/developer.png b/src/assets/images/developer.png new file mode 100644 index 0000000..53b7b4c Binary files /dev/null and b/src/assets/images/developer.png differ diff --git a/src/assets/images/leaderboardBackground.png b/src/assets/images/leaderboardBackground.png index ed29844..44f2f8f 100644 Binary files a/src/assets/images/leaderboardBackground.png and b/src/assets/images/leaderboardBackground.png differ diff --git a/src/assets/images/top1.png b/src/assets/images/top1.png new file mode 100644 index 0000000..5490fd6 Binary files /dev/null and b/src/assets/images/top1.png differ diff --git a/src/assets/images/top2.png b/src/assets/images/top2.png new file mode 100644 index 0000000..418cfe2 Binary files /dev/null and b/src/assets/images/top2.png differ diff --git a/src/assets/images/top3.png b/src/assets/images/top3.png new file mode 100644 index 0000000..2d3b989 Binary files /dev/null and b/src/assets/images/top3.png differ diff --git a/src/classes/LeaderboardCanvas.ts b/src/classes/LeaderboardCanvas.ts index e25cd26..160e0e9 100644 --- a/src/classes/LeaderboardCanvas.ts +++ b/src/classes/LeaderboardCanvas.ts @@ -3,41 +3,62 @@ import { join as joinPaths } from 'path'; import { GlobalFonts, createCanvas, loadImage } from '@napi-rs/canvas'; import type { GuildMember } from 'discord.js'; import { avatarsColors } from '../constants'; +import { renderMultiFontText } from '../functions/renderMultiFontText'; import { setFont } from '../functions/setFont'; import { userModel } from '../schemas/UserModel'; import type { IUserModel } from '../schemas/UserModel'; import { Util } from './Util'; -GlobalFonts.registerFromPath(joinPaths(__dirname, '../assets/fonts/Asap.ttf'), 'asap'); +// Register fonts +GlobalFonts.registerFromPath(joinPaths(__dirname, '../assets/fonts/NotoColorEmoji.ttf'), 'Noto Color Emoji'); +GlobalFonts.registerFromPath(joinPaths(__dirname, '../assets/fonts/segoe-ui-symbol.ttf'), 'Segoe UI Symbol'); +GlobalFonts.registerFromPath(joinPaths(__dirname, '../assets/fonts/gg sans Bold.ttf'), 'Asap'); +GlobalFonts.registerFromPath(joinPaths(__dirname, '../assets/fonts/gg sans Medium.ttf'), 'gg sans'); +GlobalFonts.registerFromPath(joinPaths(__dirname, '../assets/fonts/NotoSansArabic-Bold.ttf'), 'Noto Sans Arabic'); +GlobalFonts.registerFromPath(joinPaths(__dirname, '../assets/fonts/NotoSansJP-Bold.ttf'), 'Noto Sans JP'); +GlobalFonts.registerFromPath(joinPaths(__dirname, '../assets/fonts/NotoSansKR-Bold.ttf'), 'Noto Sans KR'); +GlobalFonts.registerFromPath(joinPaths(__dirname, '../assets/fonts/NotoSansSC-Bold.ttf'), 'Noto Sans SC'); +GlobalFonts.registerFromPath(joinPaths(__dirname, '../assets/fonts/NotoSansTC-Bold.ttf'), 'Noto Sans TC'); +GlobalFonts.registerFromPath(joinPaths(__dirname, '../assets/fonts/NotoSansThai-Bold.ttf'), 'Noto Sans Thai'); export class LeaderboardCanvas { member: GuildMember; type: string; + constructor(member: GuildMember, type: string) { this.member = member; this.type = type; } + async generate() { const canvas = createCanvas(800, 1000); const context = canvas.getContext('2d'); - context.save(); const backgroundImage = await loadImage(joinPaths(__dirname, '../assets/images/leaderboardBackground.png')); - context.drawImage(backgroundImage, 0, 0, canvas.width, canvas.height); const characters = readdirSync(joinPaths(__dirname, '../assets/characters/')); + // Pre-load rank icons including developer icon + const rankIcons = { + top1: await loadImage(joinPaths(__dirname, '../assets/images/top1.png')), + top2: await loadImage(joinPaths(__dirname, '../assets/images/top2.png')), + top3: await loadImage(joinPaths(__dirname, '../assets/images/top3.png')), + default: await loadImage(joinPaths(__dirname, '../assets/images/default.png')), + developer: await loadImage(joinPaths(__dirname, '../assets/images/developer.png')) + }; + + // Define special developer user IDs + const developerIds = ['456361646273593345', '633600812970541056']; + const addUser = async (userData: IUserModel, index: number) => { const offset = 160 * index; - const discordUser = (await this.member.client.users.fetch(userData.userID.toString()).catch(() => null)) || this.member.client.user; let userCharacter = ''; - if (characters.includes(`${discordUser.username}.png`)) { userCharacter = `${discordUser.username}.png`; } else if (discordUser.bot) { @@ -47,62 +68,134 @@ export class LeaderboardCanvas { ? `${userData.character}.png` : Util.seedShuffle( characters.filter((findAvatar) => findAvatar !== 'Clyde.png' && findAvatar !== 'Phibi.png'), - parseInt(discordUser.username) + parseInt(discordUser.id) )[0]; } const level = userData ? userData.level : 0; + let displayName: string; + if (this.type === 'local') { + try { + const guildMember = await this.member.guild.members.fetch(userData.userID.toString()); + displayName = guildMember.displayName; + } catch { + displayName = discordUser.globalName || discordUser.username; + } + } else { + displayName = discordUser.globalName || discordUser.username; + } - const displayName = discordUser.displayName; - - const mainFont = 'asap'; + const secondaryFont = 'gg sans'; + const mainFont = 'Asap'; const backgroundColor = '#3a3c41'; const mainColor = avatarsColors[userCharacter.toLowerCase().split('.')[0]!]; const tertiaryColor = '#8F9396'; context.save(); - context.beginPath(); context.arc(160, 170 + offset, 70, 0, Math.PI * 2, true); context.closePath(); context.clip(); - context.fillStyle = backgroundColor; context.fillRect(0, 0, canvas.width, canvas.height); const avatar = await loadImage(joinPaths(__dirname, '../assets/characters/', userCharacter)); - context.drawImage(avatar, 100, 110 + offset, 120, 120); - context.restore(); - context.font = setFont(canvas, displayName, 300, mainFont, 50, 10, 'bold'); + // Prepare base font for multi-font rendering (increased size) + const baseFont = setFont( + canvas, + displayName, + 300, + mainFont, + 70, // Increased name font size + 20 + ); + + context.fillStyle = mainColor; + renderMultiFontText( + context, + displayName, + canvas.width / 3.0, + 150 + offset, + baseFont, + canvas.width - 250, + true // Enable bold for non-emoji + ); + + context.fillStyle = tertiaryColor; + context.font = `${40}px '${mainFont}'`; + const baseX = canvas.width / 3.0; + const baseY = 220 + offset; + + const levelLabel = 'Level: '; + context.fillText(levelLabel, baseX, baseY); + const levelLabelWidth = context.measureText(levelLabel).width; + + context.font = `bold ${40}px '${mainFont}'`; context.fillStyle = mainColor; - context.fillText(displayName, canvas.width / 2.8, 150 + offset); + const levelValue = level.toString(); + const levelValueWidth = context.measureText(levelValue).width; + context.fillText(levelValue, baseX + levelLabelWidth, baseY); + + const spacingAfterLevel = 30; + const spacingReductionXPValue = -10; + + context.font = `${40}px '${secondaryFont}'`; context.fillStyle = tertiaryColor; - context.fillText(`Level: ${level} XP: ${userData.currentXP}`, canvas.width / 2.8, 200 + offset); + const xpLabel = '・XP:'; + const xpLabelWidth = context.measureText(xpLabel).width; + const xpLabelX = baseX + levelLabelWidth + levelValueWidth + spacingAfterLevel; + context.fillText(xpLabel, xpLabelX, baseY); + + context.font = `bold ${40}px '${mainFont}'`; + context.fillStyle = mainColor; + const xpValue = userData.currentXP.toString(); + const xpValueX = xpLabelX + xpLabelWidth - spacingReductionXPValue; + context.fillText(xpValue, xpValueX, baseY); + + const rank = index + 1; + const userId = userData.userID.toString(); + + let rankIcon; + if (developerIds.includes(userId)) { + rankIcon = rankIcons.developer; + } else { + switch (rank) { + case 1: + rankIcon = rankIcons.top1; + break; + case 2: + rankIcon = rankIcons.top2; + break; + case 3: + rankIcon = rankIcons.top3; + break; + default: + rankIcon = rankIcons.default; + } + } + + const iconSize = 80; + const iconX = canvas.width - 145; + const iconY = 130 + offset; + context.drawImage(rankIcon, iconX, iconY, iconSize, iconSize); }; if (this.type === 'global') { const globalUsers: IUserModel[] = await userModel.find({}).exec(); - globalUsers.sort((a, b) => b.totalXP - a.totalXP); for (let i = 0; i < 5; i++) { - const userData = globalUsers[i]; - if (userData) { - await addUser(userData, i); - } + if (globalUsers[i]) await addUser(globalUsers[i]!, i); } } else if (this.type === 'local') { const localUsers: IUserModel[] = await userModel.find({ guildID: this.member.guild.id }).exec(); - localUsers.sort((a, b) => b.totalXP - a.totalXP); + for (let i = 0; i < 5; i++) { - const userData = localUsers[i]; - if (userData) { - await addUser(userData, i); - } + if (localUsers[i]) await addUser(localUsers[i]!, i); } } diff --git a/src/classes/RankCanvas.ts b/src/classes/RankCanvas.ts index 9bceca0..56b9e13 100644 --- a/src/classes/RankCanvas.ts +++ b/src/classes/RankCanvas.ts @@ -3,12 +3,24 @@ import { join as joinPaths } from 'path'; import { GlobalFonts, createCanvas, loadImage } from '@napi-rs/canvas'; import type { GuildMember } from 'discord.js'; import { avatarsColors, levelingConfig } from '../constants'; +import { renderMultiFontText } from '../functions/renderMultiFontText'; import { roundRect } from '../functions/roundRect'; import { setFont } from '../functions/setFont'; import { userModel } from '../schemas/UserModel'; import { Util } from './Util'; -GlobalFonts.registerFromPath(joinPaths(__dirname, '../assets/fonts/Asap.ttf'), 'asap'); +// Register emoji font +GlobalFonts.registerFromPath(joinPaths(__dirname, '../assets/fonts/NotoColorEmoji.ttf'), 'Noto Color Emoji'); +GlobalFonts.registerFromPath(joinPaths(__dirname, '../assets/fonts/segoe-ui-symbol.ttf'), 'Segoe UI Symbol'); +GlobalFonts.registerFromPath(joinPaths(__dirname, '../assets/fonts/gg sans Bold.ttf'), 'Asap'); +GlobalFonts.registerFromPath(joinPaths(__dirname, '../assets/fonts/gg sans Medium.ttf'), 'gg sans'); +// Register new Asian language fonts +GlobalFonts.registerFromPath(joinPaths(__dirname, '../assets/fonts/NotoSansArabic-Bold.ttf'), 'Noto Sans Arabic'); +GlobalFonts.registerFromPath(joinPaths(__dirname, '../assets/fonts/NotoSansJP-Bold.ttf'), 'Noto Sans JP'); +GlobalFonts.registerFromPath(joinPaths(__dirname, '../assets/fonts/NotoSansKR-Bold.ttf'), 'Noto Sans KR'); +GlobalFonts.registerFromPath(joinPaths(__dirname, '../assets/fonts/NotoSansSC-Bold.ttf'), 'Noto Sans SC'); +GlobalFonts.registerFromPath(joinPaths(__dirname, '../assets/fonts/NotoSansTC-Bold.ttf'), 'Noto Sans TC'); +GlobalFonts.registerFromPath(joinPaths(__dirname, '../assets/fonts/NotoSansThai-Bold.ttf'), 'Noto Sans Thai'); export class LevelCanvas { private readonly member: GuildMember; @@ -24,19 +36,14 @@ export class LevelCanvas { context.save(); const backgroundImage = await loadImage(joinPaths(__dirname, '../assets/images/levelBackground.png')); - context.drawImage(backgroundImage, 0, 0, canvas.width, canvas.height); const globalData = await userModel.find({}).exec(); - globalData.sort((a, b): number => b.totalXP - a.totalXP); - const localData = globalData.filter((data): boolean => data.guildID === this.member.guild.id); - const userData = localData.find((data) => data.userID === this.member.user.id); const characters = readdirSync(joinPaths(__dirname, '../assets/characters/')); - let userCharacter = ''; if (characters.includes(`${this.member.user.username}.png`)) { @@ -59,9 +66,9 @@ export class LevelCanvas { const nextLevelXP = (!level ? 1 : level + 1) * levelingConfig.nextLevelXP; const levelPercentage = Math.floor((currentXP / nextLevelXP) * 100); - const displayName = this.member.user.displayName; - - const mainFont = 'asap'; + const displayName = this.member.displayName; + const secondaryFont = 'gg sans'; + const mainFont = 'Asap'; const backgroundColor = '#3a3c41'; const mainColor = avatarsColors[userCharacter.toLowerCase().split('.')[0]!]; const secondaryColor = `${mainColor}90`; @@ -71,39 +78,44 @@ export class LevelCanvas { context.arc(125, 125, 80, 0, Math.PI * 2, true); context.closePath(); context.clip(); - context.fillStyle = backgroundColor; context.fillRect(0, 0, canvas.width, canvas.height); const avatar = await loadImage(joinPaths(__dirname, '../assets/characters/', userCharacter)); - context.drawImage(avatar, 50, 50, 150, 150); - context.restore(); - context.font = setFont(canvas, displayName, 300, mainFont, 50, 10, 'bold'); - context.fillStyle = mainColor; - context.fillText(displayName, canvas.width / 2.8, canvas.height / 3.3); + // Prepare base font for multi-font rendering + const baseFont = setFont(canvas, displayName, 300, mainFont, 50, 10); + // Render username with multi-font support + context.fillStyle = mainColor; + renderMultiFontText( + context, + displayName, + canvas.width / 2.8, + canvas.height / 3.3, + baseFont, + canvas.width - 50, + true // Enable bold for non-emoji + ); + + // Rest of the code remains the same... const topWords = ['Level', 'Local', 'Global']; - const topText = `${topWords[0]} ${topWords[1]} ${topWords[2]}`; - context.font = setFont(canvas, topText, 300, mainFont, 38, 10, 'bold'); + context.font = setFont(canvas, topText, 300, secondaryFont, 38, 10); context.fillStyle = tertiaryColor; context.fillText(topWords[0], canvas.width / 2.8, canvas.height / 2.2); context.fillText(topWords[1], canvas.width / 2.0, canvas.height / 2.2); context.fillText(topWords[2], canvas.width / 1.55, canvas.height / 2.2); const localRank = localData.findIndex((data) => data.userID === this.member.user.id) + 1; - const globalRank = globalData.findIndex((data) => data.userID === this.member.user.id) + 1; - const bottomWords = [`${level}`, localRank ? `#${localRank}` : 'n/a', globalRank ? `#${globalRank}` : 'n/a']; const bottomText = `${bottomWords[0]} ${bottomWords[1]} ${bottomWords[2]}`; - - context.font = setFont(canvas, bottomText, 300, mainFont, 38, 10, 'bold'); + context.font = setFont(canvas, bottomText, 300, mainFont, 38, 10); context.fillStyle = secondaryColor; context.fillText(bottomWords[0], canvas.width / 2.8, canvas.height / 1.7); context.fillText(bottomWords[1], canvas.width / 2.0, canvas.height / 1.7); diff --git a/src/functions/fontUtils.ts b/src/functions/fontUtils.ts new file mode 100644 index 0000000..8e57063 --- /dev/null +++ b/src/functions/fontUtils.ts @@ -0,0 +1,58 @@ +import { isEmoji } from './isEmoji'; + +export const getCharScript = (char: string): string => { + // First check if it's an emoji using improved detection + if (isEmoji(char)) return 'emoji'; + + const code = char.codePointAt(0); + if (code === undefined) return 'latin'; + + // Korean ranges + if ( + (code >= 0xac00 && code <= 0xd7af) || // Hangul Syllables + (code >= 0x1100 && code <= 0x11ff) || // Hangul Jamo + (code >= 0x3130 && code <= 0x318f) + ) { + // Hangul Compatibility Jamo + return 'korean'; + } + + // Japanese ranges + if ( + (code >= 0x3040 && code <= 0x309f) || // Hiragana + (code >= 0x30a0 && code <= 0x30ff) || // Katakana + (code >= 0x31f0 && code <= 0x31ff) || // Katakana Phonetic Extensions + (code >= 0xff00 && code <= 0xffef) + ) { + // Halfwidth and Fullwidth Forms + return 'japanese'; + } + + // Chinese ranges (Simplified and Traditional) + if ( + (code >= 0x4e00 && code <= 0x9fff) || // CJK Unified Ideographs + (code >= 0x3400 && code <= 0x4dbf) || // CJK Extension A + (code >= 0x20000 && code <= 0x2a6df) || // CJK Extension B + (code >= 0x2a700 && code <= 0x2b73f) + ) { + // CJK Extension C + return 'han'; + } + + // Thai range + if (code >= 0x0e00 && code <= 0x0e7f) return 'thai'; + + // Arabic range + if ( + (code >= 0x0600 && code <= 0x06ff) || + (code >= 0x0750 && code <= 0x077f) || + (code >= 0x08a0 && code <= 0x08ff) + ) { + return 'arabic'; + } + + // Other non-Latin characters + if (code > 255) return 'symbol'; + + return 'latin'; +}; diff --git a/src/functions/isEmoji.ts b/src/functions/isEmoji.ts new file mode 100644 index 0000000..eb69055 --- /dev/null +++ b/src/functions/isEmoji.ts @@ -0,0 +1,2 @@ +const emojiRegex = /(\p{Extended_Pictographic}|\p{Emoji_Presentation}|\p{Emoji_Component})/gu; +export const isEmoji = (text: string) => emojiRegex.test(text); diff --git a/src/functions/renderMultiFontText.ts b/src/functions/renderMultiFontText.ts new file mode 100644 index 0000000..5f4072b --- /dev/null +++ b/src/functions/renderMultiFontText.ts @@ -0,0 +1,65 @@ +import type { SKRSContext2D } from '@napi-rs/canvas'; +import { getCharScript } from './fontUtils'; + +export const renderMultiFontText = ( + context: SKRSContext2D, + text: string, + x: number, + y: number, + baseFont: string, + maxWidth?: number, + useBold = true +) => { + let currentX = x; + + const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' }); + const segments = Array.from(segmenter.segment(text)); + + const fontSizeMatch = baseFont.match(/(\d+)px/); + const fontSize = fontSizeMatch ? parseInt(fontSizeMatch[1]!, 10) : 40; + + for (const segment of segments) { + const char = segment.segment; + const script = getCharScript(char); + + let fontFamily; + switch (script) { + case 'emoji': + fontFamily = 'Noto Color Emoji'; + break; + case 'arabic': + fontFamily = 'Noto Sans Arabic'; + break; + case 'thai': + fontFamily = 'Noto Sans Thai'; + break; + case 'japanese': + fontFamily = 'Noto Sans JP'; + break; + case 'korean': + fontFamily = 'Noto Sans KR'; + break; + case 'han': + fontFamily = 'Noto Sans SC'; + break; + case 'symbol': + fontFamily = 'Segoe UI Symbol'; + break; + default: + const match = baseFont.match(/"([^"]+)"/); + fontFamily = match ? match[1] : 'Asap'; + } + + const fontWeight = useBold && script !== 'emoji' && script !== 'symbol' ? 'bold ' : ''; + context.font = `${fontWeight}${fontSize}px "${fontFamily}"`; + + const charWidth = context.measureText(char).width; + if (maxWidth && currentX + charWidth > maxWidth) { + context.fillText('...', currentX, y); + return; + } + + context.fillText(char, currentX, y); + currentX += charWidth; + } +}; diff --git a/src/functions/setFont.ts b/src/functions/setFont.ts index fc218a3..e60b465 100644 --- a/src/functions/setFont.ts +++ b/src/functions/setFont.ts @@ -4,16 +4,15 @@ export const setFont = ( canvasArg: Canvas, text: string, limit = 300, - font = 'sans-serif', + font = 'gg sans', fontSize = 70, - fontDecrement = 10, - preFont = '' + fontDecrement = 10 ): string => { const canvasContext = canvasArg.getContext('2d'); do { fontSize -= fontDecrement; - canvasContext.font = `${preFont ? `${preFont} ` : ''}${fontSize}px ${font}`; + canvasContext.font = `${fontSize}px ${font}`; } while (canvasContext.measureText(text).width > canvasArg.width - limit); return canvasContext.font;