diff --git a/src/assets/fonts/IBMPlexMono-Bold.ttf b/src/assets/fonts/IBMPlexMono-Bold.ttf new file mode 100644 index 0000000..247979c Binary files /dev/null and b/src/assets/fonts/IBMPlexMono-Bold.ttf differ diff --git a/src/assets/fonts/IBMPlexSansCondensed-SemiBold.ttf b/src/assets/fonts/IBMPlexSansCondensed-SemiBold.ttf new file mode 100644 index 0000000..816b131 Binary files /dev/null and b/src/assets/fonts/IBMPlexSansCondensed-SemiBold.ttf differ diff --git a/src/assets/fonts/Orbitron.ttf b/src/assets/fonts/Orbitron.ttf deleted file mode 100644 index 1fc54ce..0000000 Binary files a/src/assets/fonts/Orbitron.ttf and /dev/null differ diff --git a/src/assets/student-id-class-badge.png b/src/assets/student-id-class-badge.png new file mode 100644 index 0000000..c0b0bf2 Binary files /dev/null and b/src/assets/student-id-class-badge.png differ diff --git a/src/assets/student-id-template.png b/src/assets/student-id-template.png new file mode 100644 index 0000000..0076c8d Binary files /dev/null and b/src/assets/student-id-template.png differ diff --git a/src/commands/user/profile.ts b/src/commands/user/profile.ts index e9b980a..02ce5de 100644 --- a/src/commands/user/profile.ts +++ b/src/commands/user/profile.ts @@ -1,6 +1,8 @@ import { createCommand } from "@/lib/utils"; -import { SlashCommandBuilder, EmbedBuilder } from "discord.js"; +import { SlashCommandBuilder, EmbedBuilder, AttachmentBuilder } from "discord.js"; import { userService } from "@/modules/user/user.service"; +import { classService } from "@/modules/class/class.service"; +import { generateStudentIdCard } from "@/graphics/studentID"; export const profile = createCommand({ data: new SlashCommandBuilder() @@ -16,24 +18,25 @@ export const profile = createCommand({ const targetUser = interaction.options.getUser("user") || interaction.user; const targetMember = await interaction.guild?.members.fetch(targetUser.id).catch(() => null); + const targetUserClass = await userService.getUserClass(targetUser.id); + const classBalance = await classService.getClassBalance(targetUserClass?.id || BigInt(0)); const user = await userService.getOrCreateUser(targetUser.id, targetUser.username); - const embed = new EmbedBuilder() - .setTitle(`${targetUser.username}'s Profile`) - .setThumbnail(targetUser.displayAvatarURL({ size: 512 })) - .setColor(targetMember?.displayHexColor || "Random") - .addFields( - { name: "Level", value: `${user!.level || 1}`, inline: true }, - { name: "XP", value: `${user!.xp || 0}`, inline: true }, - { name: "Balance", value: `${user!.balance || 0}`, inline: true }, - { name: "Class", value: user!.class?.name || "None", inline: true }, - { name: "Joined Discord", value: ``, inline: true }, - { name: "Joined Server", value: targetMember ? `` : "Unknown", inline: true } - ) - .setFooter({ text: `ID: ${targetUser.id}` }) - .setTimestamp(); + const cardBuffer = await generateStudentIdCard({ + username: targetUser.username, + avatarUrl: targetUser.displayAvatarURL({ extension: 'png', size: 256 }), + id: targetUser.id, + level: user!.level || 1, + xp: user!.xp || 0n, + au: user!.balance || 0n, + cu: classBalance || 0n, + className: user!.class?.name || "D" + }); + + const attachment = new AttachmentBuilder(cardBuffer, { name: 'student-id.png' }); + + await interaction.editReply({ files: [attachment] }); // Send mostly just the image as requested, or maybe both? User said "show that user's student id". Image is primary. - await interaction.editReply({ embeds: [embed] }); } }); diff --git a/src/graphics/studentID.ts b/src/graphics/studentID.ts new file mode 100644 index 0000000..2c6d57d --- /dev/null +++ b/src/graphics/studentID.ts @@ -0,0 +1,127 @@ +import { GlobalFonts, createCanvas, loadImage } from '@napi-rs/canvas'; +import path from 'path'; + +// Register Fonts +const fontDir = path.join(process.cwd(), 'src', 'assets', 'fonts'); +GlobalFonts.registerFromPath(path.join(fontDir, 'IBMPlexSansCondensed-SemiBold.ttf'), 'IBMPlexSansCondensed-SemiBold'); +GlobalFonts.registerFromPath(path.join(fontDir, 'IBMPlexMono-Bold.ttf'), 'IBMPlexMono-Bold'); + +const textColor = '#FFE7C5'; +const secondaryTextColor = '#9C7D53'; +const accentColor = '#3F2923'; + +interface StudentCardData { + username: string; + avatarUrl: string; + id: string; + level: number; + au: bigint; // Astral Units + cu: bigint; // Constellation Units + xp: bigint; // Experience + className?: string | null; +} + +export async function generateStudentIdCard(data: StudentCardData): Promise { + const templatePath = path.join(process.cwd(), 'src', 'assets', 'student-id-template.png'); + const template = await loadImage(templatePath); + + const canvas = createCanvas(template.width, template.height); + const ctx = canvas.getContext('2d'); + + // Draw Template + ctx.drawImage(template, 0, 0); + + // Draw Avatar + const avatarSize = 111; + const avatarX = 18; + const avatarY = 64; + + try { + const avatar = await loadImage(data.avatarUrl); + ctx.save(); + ctx.beginPath(); + // Square avatar + ctx.rect(avatarX, avatarY, avatarSize, avatarSize); + ctx.clip(); + ctx.drawImage(avatar, avatarX, avatarY, avatarSize, avatarSize); + ctx.restore(); + } catch (e) { + console.error("Failed to load avatar", e); + } + + // Draw ID + ctx.save(); + ctx.font = '12px IBMPlexMono-Bold'; + ctx.fillStyle = secondaryTextColor; + ctx.textAlign = 'left'; + ctx.fillText(`ID: ${data.id}`, 302, 30); + ctx.restore(); + + // Draw Username + ctx.save(); + ctx.font = '32px IBMPlexSansCondensed-SemiBold'; + ctx.fillStyle = textColor; + ctx.textAlign = 'left'; + ctx.fillText(data.username, 140, 107); + ctx.restore(); + + // Draw Class + try { + const classBadge = await loadImage(path.join(process.cwd(), 'src', 'assets', 'student-id-class-badge.png')); + ctx.save(); + ctx.beginPath(); + ctx.drawImage(classBadge, 100, 126, 58, 58); + ctx.font = '32px IBMPlexMono-Bold'; + ctx.fillStyle = textColor; + ctx.textAlign = 'left'; + ctx.fillText(data.className || 'Unknown', 120, 162); + ctx.restore(); + } catch (e) { + console.error("Failed to load class badge", e); + } + + // Draw Astral Units + ctx.save(); + ctx.font = '32px IBMPlexMono-Bold'; + ctx.fillStyle = textColor; + ctx.textAlign = 'left'; + ctx.fillText(data.au.toString(), 163, 218); + ctx.restore(); + + // Draw Constellation Units + ctx.save(); + ctx.font = '32px IBMPlexMono-Bold'; + ctx.fillStyle = textColor; + ctx.textAlign = 'left'; + ctx.fillText(data.cu.toString(), 163, 153); + ctx.restore(); + + // Draw Level + ctx.save(); + // check how many digits the level has and adjust the position accordingly + const levelDigits = data.level.toString().length; + ctx.font = '12px IBMPlexMono-Bold'; + ctx.fillStyle = secondaryTextColor; + ctx.textAlign = 'left'; + if (levelDigits === 1) { + ctx.fillText(data.level.toString(), 406, 265); + } else { + ctx.fillText(data.level.toString(), 400, 265); + } + ctx.restore(); + + // Draw Next Level + ctx.save(); + // check how many digits the next level has and adjust the position accordingly + const nextLevelDigits = (data.level + 1).toString().length; + ctx.font = '24px IBMPlexMono-Bold'; + ctx.fillStyle = textColor; + ctx.textAlign = 'left'; + if (nextLevelDigits === 1) { + ctx.fillText((data.level + 1).toString(), 438, 255); + } else { + ctx.fillText((data.level + 1).toString(), 431, 255); + } + ctx.restore(); + return canvas.toBuffer('image/png'); +} diff --git a/src/modules/class/class.service.ts b/src/modules/class/class.service.ts index b90f3f9..062881d 100644 --- a/src/modules/class/class.service.ts +++ b/src/modules/class/class.service.ts @@ -25,7 +25,12 @@ export const classService = { }; return tx ? await execute(tx) : await DrizzleClient.transaction(execute); }, - + getClassBalance: async (classId: bigint) => { + const cls = await DrizzleClient.query.classes.findFirst({ + where: eq(classes.id, classId), + }); + return cls?.balance || 0n; + }, modifyClassBalance: async (classId: bigint, amount: bigint, tx?: any) => { const execute = async (txFn: any) => { const cls = await txFn.query.classes.findFirst({ diff --git a/src/modules/user/user.service.ts b/src/modules/user/user.service.ts index d7ac09c..4d4e1b2 100644 --- a/src/modules/user/user.service.ts +++ b/src/modules/user/user.service.ts @@ -32,6 +32,13 @@ export const userService = { }; return tx ? await execute(tx) : await DrizzleClient.transaction(execute); }, + getUserClass: async (id: string) => { + const user = await DrizzleClient.query.users.findFirst({ + where: eq(users.id, BigInt(id)), + with: { class: true } + }); + return user?.class; + }, createUser: async (id: string | bigint, username: string, classId?: bigint, tx?: any) => { const execute = async (txFn: any) => { const [user] = await txFn.insert(users).values({