feat: Implement graphical lootdrop cards for lootdrop and claimed messages.
This commit is contained in:
BIN
src/assets/graphics/lootdrop/template.png
Normal file
BIN
src/assets/graphics/lootdrop/template.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
135
src/graphics/lootdrop.ts
Normal file
135
src/graphics/lootdrop.ts
Normal 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');
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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]
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user