separate upgrade png logic from execution logic
This commit is contained in:
@@ -1,10 +1,8 @@
|
||||
const { SlashCommandBuilder } = require('discord.js');
|
||||
const torn = require('../../torn.js');
|
||||
const config = require('../../config.json');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const { createCanvas, registerFont } = require('canvas');
|
||||
const renderer = require('../../utils/UpgradeRenderer.js');
|
||||
|
||||
module.exports = {
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -12,184 +10,24 @@ module.exports = {
|
||||
.setDescription('Generate the faction upgrades PNG'),
|
||||
|
||||
async execute(interaction) {
|
||||
await interaction.deferReply(); // give more time for image generation
|
||||
|
||||
const data = await torn.faction.upgrades();
|
||||
|
||||
// Build lines with group metadata (core / peace / war)
|
||||
const lines = []; // { text: string, group: 'core'|'peace'|'war'|null }
|
||||
lines.push({ text: 'Core Upgrades', group: 'core' });
|
||||
let armoryNames = [];
|
||||
for (const upgrade of data.upgrades.core.upgrades) {
|
||||
if (upgrade.name && String(upgrade.name).toLowerCase().includes('armory')) {
|
||||
armoryNames.push(upgrade.name.replace(/\s+armory$/i, ''));
|
||||
} else {
|
||||
lines.push({ text: ` ${upgrade.name} - ${upgrade.ability}`, group: 'core' });
|
||||
}
|
||||
}
|
||||
if (armoryNames.length) {
|
||||
lines.push({ text: ` Armory: ${armoryNames.join(', ')}`, group: 'core' });
|
||||
}
|
||||
lines.push({ text: '', group: null });
|
||||
|
||||
lines.push({ text: 'Peace Upgrades', group: 'peace' });
|
||||
for (const branch of data.upgrades.peace) {
|
||||
lines.push({ text: ` ${branch.name}`, group: 'peace' });
|
||||
for (const upgrade of branch.upgrades) {
|
||||
lines.push({ text: ` ${upgrade.name} - ${upgrade.ability}`, group: 'peace' });
|
||||
}
|
||||
}
|
||||
lines.push({ text: '', group: null });
|
||||
|
||||
lines.push({ text: 'War Upgrades', group: 'war' });
|
||||
for (const branch of data.upgrades.war) {
|
||||
lines.push({ text: ` ${branch.name}`, group: 'war' });
|
||||
for (const upgrade of branch.upgrades) {
|
||||
lines.push({ text: ` ${upgrade.name} - ${upgrade.ability}`, group: 'war' });
|
||||
}
|
||||
}
|
||||
|
||||
// Image rendering settings
|
||||
const padding = 24;
|
||||
const maxWidth = 1100;
|
||||
const fontSize = 18;
|
||||
const fontFamily = 'Sans';
|
||||
const fontSpec = `${fontSize}px ${fontFamily}`;
|
||||
|
||||
// Temporary canvas for measurement
|
||||
const measureCanvas = createCanvas(10, 10);
|
||||
const measureCtx = measureCanvas.getContext('2d');
|
||||
measureCtx.font = fontSpec;
|
||||
|
||||
function wrapLine(ctx, text, maxW) {
|
||||
const words = text.split(' ');
|
||||
const wrapped = [];
|
||||
let line = '';
|
||||
for (const word of words) {
|
||||
const test = line ? `${line} ${word}` : word;
|
||||
const w = ctx.measureText(test).width;
|
||||
if (w > maxW && line) {
|
||||
wrapped.push(line);
|
||||
line = word;
|
||||
} else {
|
||||
line = test;
|
||||
}
|
||||
}
|
||||
if (line) wrapped.push(line);
|
||||
return wrapped;
|
||||
}
|
||||
|
||||
const baseColors = {
|
||||
core: config.upgradeColors.core,
|
||||
peace: config.upgradeColors.peace,
|
||||
peaceDim: config.upgradeColors.peaceDim,
|
||||
war: config.upgradeColors.war,
|
||||
warDim: config.upgradeColors.warDim
|
||||
};
|
||||
|
||||
|
||||
const state = (data.state || '').toLowerCase();
|
||||
let dimMode = 'desaturate';
|
||||
let inactiveGroup = null;
|
||||
if (state === 'peace') {
|
||||
dimMode = 'opacity';
|
||||
inactiveGroup = 'war';
|
||||
} else if (state === 'war') {
|
||||
dimMode = 'opacity';
|
||||
inactiveGroup = 'peace';
|
||||
}
|
||||
|
||||
function colorForGroup(group) {
|
||||
if (!group) return '#ffffff';
|
||||
if (group === 'core') return baseColors.core;
|
||||
|
||||
if (dimMode === 'opacity') {
|
||||
if (group === inactiveGroup) return group === 'peace' ? baseColors.peaceDim : baseColors.warDim;
|
||||
return group === 'peace' ? baseColors.peace : baseColors.war;
|
||||
} else {
|
||||
// fallback darker variants when state is unknown
|
||||
return group === 'peace' ? baseColors.peaceDim : baseColors.warDim;
|
||||
}
|
||||
}
|
||||
|
||||
// Wrap and measure lines while preserving group and level
|
||||
const fontSizes = { 0: 26, 1: 22, 2: 18 };
|
||||
const lineHeightFactor = 1.3;
|
||||
|
||||
let visualLines = []; // { text, group, level, fontSize, lineHeight }
|
||||
let measuredMaxWidth = 0;
|
||||
const textMaxWidth = maxWidth - padding * 2;
|
||||
for (const ln of lines) {
|
||||
if (!ln.text) {
|
||||
visualLines.push({ text: '', group: null, level: 0, fontSize: fontSizes[0], lineHeight: Math.round(fontSizes[0] * lineHeightFactor) });
|
||||
continue;
|
||||
}
|
||||
|
||||
const leading = (ln.text.match(/^ */) || [''])[0].length;
|
||||
let level = Math.min(2, Math.floor(leading / 2));
|
||||
// Use smallest font size for core upgrade list items (they are indented)
|
||||
if (ln.group === 'core' && level === 1) level = 2;
|
||||
const rawText = ln.text.trim();
|
||||
const fsz = fontSizes[level] || fontSize;
|
||||
|
||||
measureCtx.font = `${fsz}px ${fontFamily}`;
|
||||
const wrapped = wrapLine(measureCtx, rawText, textMaxWidth);
|
||||
for (const wln of wrapped) {
|
||||
const w = Math.ceil(measureCtx.measureText(wln).width);
|
||||
visualLines.push({ text: wln, group: ln.group, level, fontSize: fsz, lineHeight: Math.round(fsz * lineHeightFactor) });
|
||||
measuredMaxWidth = Math.max(measuredMaxWidth, w);
|
||||
}
|
||||
}
|
||||
|
||||
const canvasWidth = Math.min(maxWidth, measuredMaxWidth + padding * 2);
|
||||
const canvasHeight = padding * 2 + visualLines.reduce((sum, l) => sum + l.lineHeight, 0);
|
||||
|
||||
const canvas = createCanvas(canvasWidth, canvasHeight);
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// rounded corners, if you think i wouldnt have an ai do this for me youre silly
|
||||
const cornerRadius = 24;
|
||||
ctx.fillStyle = config.upgradeColors.background;
|
||||
(function roundedRect(ctx, x, y, w, h, r) {
|
||||
const radius = Math.max(0, Math.min(r, Math.min(w / 2, h / 2)));
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + radius, y);
|
||||
ctx.lineTo(x + w - radius, y);
|
||||
ctx.quadraticCurveTo(x + w, y, x + w, y + radius);
|
||||
ctx.lineTo(x + w, y + h - radius);
|
||||
ctx.quadraticCurveTo(x + w, y + h, x + w - radius, y + h);
|
||||
ctx.lineTo(x + radius, y + h);
|
||||
ctx.quadraticCurveTo(x, y + h, x, y + h - radius);
|
||||
ctx.lineTo(x, y + radius);
|
||||
ctx.quadraticCurveTo(x, y, x + radius, y);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
})(ctx, 0, 0, canvasWidth, canvasHeight, cornerRadius);
|
||||
|
||||
ctx.textBaseline = 'top';
|
||||
|
||||
let y = padding;
|
||||
for (const vln of visualLines) {
|
||||
ctx.font = `${vln.fontSize}px ${fontFamily}`;
|
||||
ctx.fillStyle = colorForGroup(vln.group);
|
||||
const textWidth = Math.ceil(ctx.measureText(vln.text).width);
|
||||
const x = Math.round((canvasWidth - textWidth) / 2);
|
||||
ctx.fillText(vln.text, x, y);
|
||||
y += vln.lineHeight;
|
||||
}
|
||||
|
||||
const outDir = path.resolve(__dirname, '..', '..', 'public');
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
|
||||
const outFile = path.join(outDir, 'upgrades.png');
|
||||
const buffer = canvas.toBuffer('image/png');
|
||||
fs.writeFileSync(outFile, buffer);
|
||||
await interaction.deferReply();
|
||||
|
||||
try {
|
||||
const data = await torn.faction.upgrades();
|
||||
const buffer = renderer.render(data);
|
||||
|
||||
const outDir = path.resolve(__dirname, '..', '..', 'public');
|
||||
if (!fs.existsSync(outDir)) {
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
}
|
||||
|
||||
const outFile = path.join(outDir, 'upgrades.png');
|
||||
fs.writeFileSync(outFile, buffer);
|
||||
|
||||
await interaction.editReply({ files: [outFile] });
|
||||
} catch (err) {
|
||||
await interaction.editReply('Generated upgrades image but failed to attach it.');
|
||||
console.debug('Failed to attach image to interaction reply:', err.message || err);
|
||||
console.error('Error generating upgrades image:', err);
|
||||
await interaction.editReply('Failed to generate upgrades image.');
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
24
tasks/autoUpdateUpgrades.js
Normal file
24
tasks/autoUpdateUpgrades.js
Normal file
@@ -0,0 +1,24 @@
|
||||
module.exports = async (client, torn, config) => {
|
||||
console.debug("Task: Executing autoUpdateUpgrades");
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const renderer = require('../utils/UpgradeRenderer.js');
|
||||
|
||||
try {
|
||||
const data = await torn.faction.upgrades();
|
||||
const buffer = renderer.render(data);
|
||||
|
||||
const outDir = path.resolve(__dirname, '..', 'public');
|
||||
if (!fs.existsSync(outDir)) {
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
}
|
||||
|
||||
const outFile = path.join(outDir, 'upgrades.png');
|
||||
fs.writeFileSync(outFile, buffer);
|
||||
console.debug("autoUpdateUpgrades: Successfully updated upgrades.png");
|
||||
} catch (err) {
|
||||
console.error("autoUpdateUpgrades: Failed to update upgrades.png", err);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports.schedule = '0 * * * *';
|
||||
214
utils/UpgradeRenderer.js
Normal file
214
utils/UpgradeRenderer.js
Normal file
@@ -0,0 +1,214 @@
|
||||
const { createCanvas } = require('canvas');
|
||||
const config = require('../config.json');
|
||||
|
||||
class UpgradeRenderer {
|
||||
constructor() {
|
||||
this.padding = 24;
|
||||
this.maxWidth = 1100;
|
||||
this.fontFamily = 'Sans';
|
||||
this.baseColors = {
|
||||
core: config.upgradeColors.core,
|
||||
peace: config.upgradeColors.peace,
|
||||
peaceDim: config.upgradeColors.peaceDim,
|
||||
war: config.upgradeColors.war,
|
||||
warDim: config.upgradeColors.warDim,
|
||||
background: config.upgradeColors.background
|
||||
};
|
||||
this.fontSizes = { 0: 26, 1: 22, 2: 18 };
|
||||
this.lineHeightFactor = 1.3;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the upgrades data to a PNG buffer.
|
||||
* @param {Object} data The data returned from torn.faction.upgrades()
|
||||
* @returns {Buffer} The PNG image buffer
|
||||
*/
|
||||
render(data) {
|
||||
const lines = this.buildLines(data);
|
||||
const state = (data.state || '').toLowerCase();
|
||||
const dimMode = (state === 'peace' || state === 'war') ? 'opacity' : 'desaturate';
|
||||
const inactiveGroup = state === 'peace' ? 'war' : (state === 'war' ? 'peace' : null);
|
||||
|
||||
// Measurement Canvas
|
||||
const measureCanvas = createCanvas(10, 10);
|
||||
const measureCtx = measureCanvas.getContext('2d');
|
||||
|
||||
const visualLines = this.measureAndWrapLines(measureCtx, lines);
|
||||
const { width, height } = this.calculateCanvasDimensions(visualLines);
|
||||
|
||||
// Final Canvas
|
||||
const canvas = createCanvas(width, height);
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
this.drawBackground(ctx, width, height);
|
||||
this.drawText(ctx, visualLines, width, dimMode, inactiveGroup);
|
||||
|
||||
return canvas.toBuffer('image/png');
|
||||
}
|
||||
|
||||
buildLines(data) {
|
||||
const lines = []; // { text: string, group: 'core'|'peace'|'war'|null }
|
||||
|
||||
// Core
|
||||
lines.push({ text: 'Core Upgrades', group: 'core' });
|
||||
const armoryNames = [];
|
||||
if (data.upgrades.core.upgrades) {
|
||||
for (const upgrade of data.upgrades.core.upgrades) {
|
||||
if (upgrade.name && String(upgrade.name).toLowerCase().includes('armory')) {
|
||||
armoryNames.push(upgrade.name.replace(/\s+armory$/i, ''));
|
||||
} else {
|
||||
lines.push({ text: ` ${upgrade.name} - ${upgrade.ability}`, group: 'core' });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (armoryNames.length) {
|
||||
lines.push({ text: ` Armory: ${armoryNames.join(', ')}`, group: 'core' });
|
||||
}
|
||||
lines.push({ text: '', group: null });
|
||||
|
||||
// Peace
|
||||
lines.push({ text: 'Peace Upgrades', group: 'peace' });
|
||||
if (data.upgrades.peace) {
|
||||
for (const branch of data.upgrades.peace) {
|
||||
lines.push({ text: ` ${branch.name}`, group: 'peace' });
|
||||
for (const upgrade of branch.upgrades) {
|
||||
lines.push({ text: ` ${upgrade.name} - ${upgrade.ability}`, group: 'peace' });
|
||||
}
|
||||
}
|
||||
}
|
||||
lines.push({ text: '', group: null });
|
||||
|
||||
// War
|
||||
lines.push({ text: 'War Upgrades', group: 'war' });
|
||||
if (data.upgrades.war) {
|
||||
for (const branch of data.upgrades.war) {
|
||||
lines.push({ text: ` ${branch.name}`, group: 'war' });
|
||||
for (const upgrade of branch.upgrades) {
|
||||
lines.push({ text: ` ${upgrade.name} - ${upgrade.ability}`, group: 'war' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
measureAndWrapLines(ctx, lines) {
|
||||
const visualLines = [];
|
||||
const textMaxWidth = this.maxWidth - this.padding * 2;
|
||||
|
||||
for (const ln of lines) {
|
||||
if (!ln.text) {
|
||||
visualLines.push({
|
||||
text: '',
|
||||
group: null,
|
||||
level: 0,
|
||||
width: 0,
|
||||
fontSize: this.fontSizes[0],
|
||||
lineHeight: Math.round(this.fontSizes[0] * this.lineHeightFactor)
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const leading = (ln.text.match(/^ */) || [''])[0].length;
|
||||
let level = Math.min(2, Math.floor(leading / 2));
|
||||
if (ln.group === 'core' && level === 1) level = 2;
|
||||
|
||||
const fsz = this.fontSizes[level];
|
||||
ctx.font = `${fsz}px ${this.fontFamily}`;
|
||||
|
||||
const rawText = ln.text.trim();
|
||||
const wrapped = this.wrapLine(ctx, rawText, textMaxWidth);
|
||||
|
||||
for (const wln of wrapped) {
|
||||
const w = Math.ceil(ctx.measureText(wln).width);
|
||||
visualLines.push({
|
||||
text: wln,
|
||||
group: ln.group,
|
||||
level,
|
||||
width: w,
|
||||
fontSize: fsz,
|
||||
lineHeight: Math.round(fsz * this.lineHeightFactor)
|
||||
});
|
||||
}
|
||||
}
|
||||
return visualLines;
|
||||
}
|
||||
|
||||
wrapLine(ctx, text, maxW) {
|
||||
const words = text.split(' ');
|
||||
const wrapped = [];
|
||||
let line = '';
|
||||
for (const word of words) {
|
||||
const test = line ? `${line} ${word}` : word;
|
||||
const w = ctx.measureText(test).width;
|
||||
if (w > maxW && line) {
|
||||
wrapped.push(line);
|
||||
line = word;
|
||||
} else {
|
||||
line = test;
|
||||
}
|
||||
}
|
||||
if (line) wrapped.push(line);
|
||||
return wrapped;
|
||||
}
|
||||
|
||||
calculateCanvasDimensions(visualLines) {
|
||||
let maxW = 0;
|
||||
let totalH = 0;
|
||||
for (const line of visualLines) {
|
||||
maxW = Math.max(maxW, line.width);
|
||||
totalH += line.lineHeight;
|
||||
}
|
||||
return {
|
||||
width: Math.min(this.maxWidth, maxW + this.padding * 2),
|
||||
height: this.padding * 2 + totalH
|
||||
};
|
||||
}
|
||||
|
||||
drawBackground(ctx, width, height) {
|
||||
const cornerRadius = 24;
|
||||
ctx.fillStyle = this.baseColors.background;
|
||||
|
||||
const r = Math.max(0, Math.min(cornerRadius, Math.min(width / 2, height / 2)));
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(r, 0);
|
||||
ctx.lineTo(width - r, 0);
|
||||
ctx.quadraticCurveTo(width, 0, width, r);
|
||||
ctx.lineTo(width, height - r);
|
||||
ctx.quadraticCurveTo(width, height, width - r, height);
|
||||
ctx.lineTo(r, height);
|
||||
ctx.quadraticCurveTo(0, height, 0, height - r);
|
||||
ctx.lineTo(0, r);
|
||||
ctx.quadraticCurveTo(0, 0, r, 0);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
drawText(ctx, visualLines, width, dimMode, inactiveGroup) {
|
||||
ctx.textBaseline = 'top';
|
||||
let y = this.padding;
|
||||
|
||||
for (const vln of visualLines) {
|
||||
ctx.font = `${vln.fontSize}px ${this.fontFamily}`;
|
||||
ctx.fillStyle = this.getColorForGroup(vln.group, dimMode, inactiveGroup);
|
||||
|
||||
const x = Math.round((width - vln.width) / 2);
|
||||
ctx.fillText(vln.text, x, y);
|
||||
y += vln.lineHeight;
|
||||
}
|
||||
}
|
||||
|
||||
getColorForGroup(group, dimMode, inactiveGroup) {
|
||||
if (!group) return '#ffffff';
|
||||
if (group === 'core') return this.baseColors.core;
|
||||
|
||||
if (dimMode === 'opacity') {
|
||||
if (group === inactiveGroup) return group === 'peace' ? this.baseColors.peaceDim : this.baseColors.warDim;
|
||||
return group === 'peace' ? this.baseColors.peace : this.baseColors.war;
|
||||
} else {
|
||||
return group === 'peace' ? this.baseColors.peaceDim : this.baseColors.warDim;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new UpgradeRenderer();
|
||||
Reference in New Issue
Block a user