Skip to content
This repository was archived by the owner on Dec 29, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file removed src/assets/fonts/Asap.ttf
Binary file not shown.
Binary file added src/assets/fonts/NotoColorEmoji.ttf
Binary file not shown.
Binary file added src/assets/fonts/NotoSansArabic-Bold.ttf
Binary file not shown.
Binary file added src/assets/fonts/NotoSansJP-Bold.ttf
Binary file not shown.
Binary file added src/assets/fonts/NotoSansKR-Bold.ttf
Binary file not shown.
Binary file added src/assets/fonts/NotoSansSC-Bold.ttf
Binary file not shown.
Binary file added src/assets/fonts/NotoSansTC-Bold.ttf
Binary file not shown.
Binary file added src/assets/fonts/NotoSansThai-Bold.ttf
Binary file not shown.
Binary file added src/assets/fonts/gg sans Medium.ttf
Binary file not shown.
Binary file added src/assets/fonts/gg sans Semibold.ttf
Binary file not shown.
Binary file added src/assets/fonts/segoe-ui-symbol.ttf
Binary file not shown.
Binary file added src/assets/images/default.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/assets/images/developer.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified src/assets/images/leaderboardBackground.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/assets/images/top1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/assets/images/top2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/assets/images/top3.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
145 changes: 119 additions & 26 deletions src/classes/LeaderboardCanvas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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);
}
}

Expand Down
54 changes: 33 additions & 21 deletions src/classes/RankCanvas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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`)) {
Expand All @@ -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`;
Expand All @@ -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);
Expand Down
58 changes: 58 additions & 0 deletions src/functions/fontUtils.ts
Original file line number Diff line number Diff line change
@@ -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';
};
Loading