feat: Implement graphical lootdrop cards for lootdrop and claimed messages.

This commit is contained in:
syntaxbullet
2025-12-24 23:13:16 +01:00
parent 66d5145885
commit a227e5db59
5 changed files with 166 additions and 27 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

135
src/graphics/lootdrop.ts Normal file
View File

@@ -0,0 +1,135 @@
import { GlobalFonts, createCanvas, loadImage } from '@napi-rs/canvas';
import path from 'path';
// Register Fonts (same as studentID.ts)
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');
export async function generateLootdropCard(amount: number, currency: string): Promise<Buffer> {
const templatePath = path.join(process.cwd(), 'src', 'assets', 'graphics', 'lootdrop', '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 Lootdrop Text (Title-ish)
ctx.save();
ctx.font = '48px IBMPlexSansCondensed-SemiBold';
ctx.fillStyle = '#FFFFFF';
ctx.textAlign = 'center';
ctx.shadowBlur = 10;
ctx.shadowColor = 'rgba(255, 255, 255, 0.5)';
// Center of lower half (512-1024) is roughly 768
ctx.fillText('A STAR IS FALLING', canvas.width / 2, 660);
ctx.restore();
// Draw Reward Amount
ctx.save();
ctx.font = '72px IBMPlexMono-Bold';
ctx.fillStyle = '#DAC7A1';
ctx.textAlign = 'center';
//ctx.shadowBlur = 15;
//ctx.shadowColor = 'rgba(255, 215, 0, 0.8)';
ctx.fillText(`${amount} ${currency}`, canvas.width / 2, 760); // Below title
ctx.restore();
// Crop the image by 64px on all sides
const croppedWidth = template.width - 128;
const croppedHeight = template.height - 128;
const croppedCanvas = createCanvas(croppedWidth, croppedHeight);
const croppedCtx = croppedCanvas.getContext('2d');
// Draw the original canvas onto the cropped canvas, shifted by -64
croppedCtx.drawImage(canvas, -64, -64);
return croppedCanvas.toBuffer('image/png');
}
export async function generateClaimedLootdropCard(amount: number, currency: string, username: string, avatarUrl: string): Promise<Buffer> {
const templatePath = path.join(process.cwd(), 'src', 'assets', 'graphics', 'lootdrop', '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);
// Add a colored overlay to signify "claimed"
ctx.fillStyle = 'rgba(10, 10, 20, 0.85)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Draw Claimed Text (Title-ish)
ctx.save();
ctx.font = '48px IBMPlexSansCondensed-SemiBold';
ctx.fillStyle = '#FFFFFF';
ctx.textAlign = 'center';
ctx.shadowBlur = 10;
ctx.shadowColor = 'rgba(255, 255, 255, 0.5)';
ctx.fillText('STAR CLAIMED', canvas.width / 2, 660);
ctx.restore();
// Draw "by username" with Avatar
ctx.save();
ctx.font = '36px IBMPlexSansCondensed-SemiBold';
ctx.fillStyle = '#AAAAAA';
// Calculate layout for centering Group (Avatar + Text)
const text = `by ${username}`;
const textMetrics = ctx.measureText(text);
const textWidth = textMetrics.width;
const avatarSize = 50;
const gap = 15;
const totalWidth = avatarSize + gap + textWidth;
const startX = (canvas.width - totalWidth) / 2;
const baselineY = 830;
// Draw Avatar
try {
const avatar = await loadImage(avatarUrl);
ctx.save();
ctx.beginPath();
// Center avatar vertically relative to text roughly (baseline - ~half cap height)
// 36px text ~ 27px cap height. Center roughly at baselineY - 14
const avatarCenterY = baselineY - 14;
ctx.arc(startX + avatarSize / 2, avatarCenterY, avatarSize / 2, 0, Math.PI * 2);
ctx.closePath();
ctx.clip();
ctx.drawImage(avatar, startX, avatarCenterY - avatarSize / 2, avatarSize, avatarSize);
ctx.restore();
} catch (e) {
// Fallback if avatar fails to load, just don't draw it (or maybe shift text?)
// For now, let's just proceed, the text will be off-center if avatar is missing but that's acceptable edge case
console.error("Failed to load avatar", e);
}
// Draw Text
ctx.textAlign = 'left';
ctx.fillText(text, startX + avatarSize + gap, baselineY);
ctx.restore();
ctx.save();
ctx.font = '72px IBMPlexMono-Bold'; // Match Amount size
ctx.fillStyle = '#E6D2B5'; // Lighter gold/beige for better contrast
ctx.textAlign = 'center';
ctx.shadowBlur = 10;
ctx.shadowColor = 'rgba(0, 0, 0, 0.8)'; // Dark shadow for contrast
ctx.fillText(`${amount} ${currency}`, canvas.width / 2, 760); // Same position as Unclaimed Amount
ctx.restore();
// Crop the image by 64px on all sides
const croppedWidth = template.width - 128;
const croppedHeight = template.height - 128;
const croppedCanvas = createCanvas(croppedWidth, croppedHeight);
const croppedCtx = croppedCanvas.getContext('2d');
// Draw the original canvas onto the cropped canvas, shifted by -64
croppedCtx.drawImage(canvas, -64, -64);
return croppedCanvas.toBuffer('image/png');
}

View File

@@ -17,17 +17,19 @@ export async function handleLootdropInteraction(interaction: ButtonInteraction)
content: `🎉 You successfully claimed **${result.amount} ${result.currency}**!` content: `🎉 You successfully claimed **${result.amount} ${result.currency}**!`
}); });
// Update original message to show claimed state const { content, files, components } = await getLootdropClaimedMessage(
const originalEmbed = interaction.message.embeds[0];
if (!originalEmbed) return;
const { embeds, components } = getLootdropClaimedMessage(
originalEmbed.title || "💰 LOOTDROP!",
interaction.user.id, interaction.user.id,
interaction.user.username,
interaction.user.displayAvatarURL({ extension: "png" }),
result.amount || 0, result.amount || 0,
result.currency || "Coins" result.currency || "Coins"
); );
await interaction.message.edit({ embeds, components }); await interaction.message.edit({
content,
embeds: [],
files,
components
});
} }
} }

View File

@@ -94,10 +94,10 @@ class LootdropService {
const reward = Math.floor(Math.random() * (max - min + 1)) + min; const reward = Math.floor(Math.random() * (max - min + 1)) + min;
const currency = config.lootdrop.reward.currency; const currency = config.lootdrop.reward.currency;
const { embeds, components } = getLootdropMessage(reward, currency); const { content, files, components } = await getLootdropMessage(reward, currency);
try { try {
const message = await channel.send({ embeds, components }); const message = await channel.send({ content, files, components });
// Persist to DB // Persist to DB
await DrizzleClient.insert(lootdrops).values({ await DrizzleClient.insert(lootdrops).values({

View File

@@ -1,31 +1,29 @@
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from "discord.js"; import { ActionRowBuilder, AttachmentBuilder, ButtonBuilder, ButtonStyle } from "discord.js";
import { createBaseEmbed } from "@lib/embeds"; import { generateLootdropCard, generateClaimedLootdropCard } from "@/graphics/lootdrop";
export function getLootdropMessage(reward: number, currency: string) { export async function getLootdropMessage(reward: number, currency: string) {
const embed = createBaseEmbed( const cardBuffer = await generateLootdropCard(reward, currency);
"💰 LOOTDROP!", const attachment = new AttachmentBuilder(cardBuffer, { name: "lootdrop.png" });
`A lootdrop has appeared! Click the button below to claim **${reward} ${currency}**!`,
"#FFD700"
);
const claimButton = new ButtonBuilder() const claimButton = new ButtonBuilder()
.setCustomId("lootdrop_claim") .setCustomId("lootdrop_claim")
.setLabel("CLAIM REWARD") .setLabel("CLAIM REWARD")
.setStyle(ButtonStyle.Success) .setStyle(ButtonStyle.Secondary) // Changed to Secondary to fit the darker theme better? Or keep Success? Let's try Secondary with custom emoji
.setEmoji("💸"); .setEmoji("🌠");
const row = new ActionRowBuilder<ButtonBuilder>() const row = new ActionRowBuilder<ButtonBuilder>()
.addComponents(claimButton); .addComponents(claimButton);
return { embeds: [embed], components: [row] }; return {
content: "",
files: [attachment],
components: [row]
};
} }
export function getLootdropClaimedMessage(originalTitle: string, userId: string, amount: number, currency: string) { export async function getLootdropClaimedMessage(userId: string, username: string, avatarUrl: string, amount: number, currency: string) {
const newEmbed = createBaseEmbed( const cardBuffer = await generateClaimedLootdropCard(amount, currency, username, avatarUrl);
originalTitle || "💰 LOOTDROP!", const attachment = new AttachmentBuilder(cardBuffer, { name: "lootdrop_claimed.png" });
`✅ Claimed by <@${userId}> for **${amount} ${currency}**!`,
"#00FF00"
);
const newRow = new ActionRowBuilder<ButtonBuilder>() const newRow = new ActionRowBuilder<ButtonBuilder>()
.addComponents( .addComponents(
@@ -37,5 +35,9 @@ export function getLootdropClaimedMessage(originalTitle: string, userId: string,
.setDisabled(true) .setDisabled(true)
); );
return { embeds: [newEmbed], components: [newRow] }; return {
content: ``, // Remove content as the image says it all
files: [attachment],
components: [newRow]
};
} }