d-bot
Version:
A quirky Discord bot made for single, small, private servers
334 lines (318 loc) • 14.3 kB
JavaScript
// Bring some beauty to the dull grays of discord chat
var util = require(__base+'core/util.js');
var discord = require(__base+'core/discord.js');
var Canvas = require('canvas');
var { wordsToNumbers } = require('words-to-numbers');
var requireUncached = require('require-uncached');
const { resizeCanvas, cropCanvas, flipCanvas, rotateCanvas, UnitContext } = requireUncached('./helpers/canvas.js');
const { COLORS, COLOR_MODS, SHAPES, DRAW_SHAPE, SIZE_SHAPE } = requireUncached('./helpers/imagery/main.js');
var { Color } = requireUncached('./helpers/color.js');
const MAX_WIDTH = 1200; // Max resolution of final image
const MAX_HEIGHT = 900;
const BASE_RES = 200; // Maximum pixels per unit, before scaling down
const SPACE = 0.1; // Unit space between elements
const ASPECT = 4 / 2; // Target aspect ratio (Discord's image embedding is 4:3, but 4:2 is better aesthetically)
// TODO: Ideas
// Write text -- /draw a big red "hello"
// Quantity -- /draw 30 boxes
// Improvisation -- /draw something in a circle, or just /draw
// Color variation -- /draw dark red square, pale blue dot
// Color schemes -- randomly choose a color scheme when colors not specified
// Modifications -- /add a red square /move the blue circle down
// Show this to the procjam server when it's impressive enough!
function drawShape(elem, res) {
if(!elem.shape) return;
elem.canvas = new Canvas(Math.ceil(elem.width * res), Math.ceil(elem.height * res));
let ctx = new UnitContext(elem.canvas.getContext('2d'), res);
ctx.fillStyle = elem.color.hex;
DRAW_SHAPE[elem.shape](ctx, elem);
if(elem.flipped) elem.canvas = flipCanvas(elem.canvas);
if(elem.rotation) {
elem.canvas = rotateCanvas(elem.canvas, elem.rotation);
if(elem.rotation !== 2) {
Object.assign(elem, { width: elem.height, height: elem.width, ox: elem.oy, oy: elem.ox });
}
}
}
function sizeElements(elems) { // Set element sizes
elems.forEach(elem => elem.u = 1);
elems.sort((a, b) => b.u !== a.u ? b.u - a.u : a.num - b.num);
}
function transformElements(elems) {
elems.forEach(elem => {
Object.assign(elem, { ox: 0, oy: 0 }, SIZE_SHAPE[elem.shape](elem.u));
elem.flipped = elem.flip && util.flip();
elem.rotation = elem.rotate ? util.randomInt(3) : 0;
});
}
function elemsCollide(e1, e2) {
return e2.x < e1.x + e1.u && e2.x + e2.u > e1.x && e2.y < e1.y + e1.u && e2.y + e2.u > e1.y;
}
function elemsTouch(e1, e2) {
return ((e2.x === e1.x + e1.u || e2.x + e2.u === e1.x) && (e2.y < e1.y + e1.u && e2.y + e2.u > e1.y))
|| ((e2.y === e1.y + e1.u || e2.y + e2.u === e1.y) && (e2.x < e1.x + e1.u && e2.x + e2.u > e1.x));
}
function arrangeElements(elems) {
let box = { bTop: 0, bBottom: 0, bLeft: 0, bRight: 0, width: 0, height: 0 };
let getNewBoundingBox = elem => {
let newBox = {
bTop: Math.min(box.bTop, elem.y),
bBottom: Math.max(box.bBottom, elem.y + elem.u),
bLeft: Math.min(box.bLeft, elem.x),
bRight: Math.max(box.bRight, elem.x + elem.u)
};
return Object.assign(newBox, { width: newBox.bRight - newBox.bLeft, height: newBox.bBottom - newBox.bTop });
};
let placed = [];
let placeMap = {};
let collides = elem => { // TODO: Find a way to optimize this
for(let i = 0; i < placed.length; i++) {
if(elemsCollide(elem, placed[i])) return true;
}
return false;
};
let getScore = elem => {
let newBox = getNewBoundingBox(elem);
let wider = newBox.width > box.width, taller = newBox.height > box.height;
let bits = 12;
let score = (1 << bits++) - Math.min(Math.abs(elem.x), Math.abs(elem.y));
if(Math.abs(1 - newBox.width / newBox.height / ASPECT)
<= Math.abs(1 - box.width / box.height / ASPECT)) score += 1 << bits++;
if(!taller || !wider) score += 1 << bits++;
if(!wider && !taller) score += 1 << bits;
return score;
};
util.timer.reset();
elems.forEach(elem => {
let bestPlace;
let bestScore = 0;
// util.timer.start('finding valid positions');
for(let y = box.bTop; y <= box.bBottom; y++) {
for(let x = box.bLeft; x <= box.bRight; x++) {
if(placeMap[x + ':' + y]) continue;
let place = { x, y, u: elem.u };
if(collides(place)) continue;
let score = getScore(place);
if(score > bestScore) {
bestScore = score;
bestPlace = place;
}
}
}
// util.timer.stop('finding valid positions');
elem.x = bestPlace.x;
elem.y = bestPlace.y;
placed.push(elem);
for(let x = 0; x < elem.u; x++) {
for(let y = 0; y < elem.u; y++) {
placeMap[(elem.x + x) + ':' + (elem.y + y)] = true;
}
}
Object.assign(box, getNewBoundingBox(elem));
});
// util.timer.results();
return box;
}
var _commands = {};
_commands.draw = function(data) {
if(data.channel !== '209177876975583232') return;
if(data.params.length === 0) return data.reply('Describe something, e.g. `a red circle`');
let words = data.paramStr.split(' ');
words = words.map((word, index) => {
let wordObj = { text: word.toUpperCase(), index, toString() { return this.text; } };
let number = wordsToNumbers(word.toLowerCase());
if(!isNaN(number)) wordObj.number = number;
return wordObj;
});
let elements = [];
let element = { num: 1, colors: [], colorMods: [] };
function parse(phrase) {
let parsed = false;
let parsedElement = Object.assign({}, element);
let phraseText = phrase.join(' ');
let toNumber = wordsToNumbers(phraseText.toLowerCase());
if(phrase[0].number && phrase[phrase.length -1].number && !isNaN(toNumber)) {
parsedElement.quantity = toNumber;
return parsedElement;
}
let foundDelimiter = false;
if(phraseText.substr(-1) === ',') {
phraseText = phraseText.substr(0, phraseText.length - 1);
foundDelimiter = true;
}
if(phraseText === 'AND') {
foundDelimiter = true;
parsed = true;
} else if(COLOR_MODS[phraseText]) {
parsedElement.colorMods.push(COLOR_MODS[phraseText]);
parsed = true;
} else if(COLORS[phraseText]) {
parsedElement.colors.push(COLORS[phraseText]);
parsed = true;
} else if(SHAPES[phraseText]) {
parsedElement.shape = SHAPES[phraseText];
parsed = true;
} else if(phraseText.slice(-1) === 'S') {
let singularS = phraseText.substr(0, phraseText.length - 1);
let singularES = phraseText.substr(0, phraseText.length - 2);
if(SHAPES[singularS]) {
parsedElement.shape = SHAPES[singularS];
parsedElement.plural = true;
parsed = true;
}
if(phraseText.slice(-2) === 'ES' && SHAPES[singularES]) {
parsedElement.shape = SHAPES[singularES];
parsedElement.plural = true;
parsed = true;
}
}
if(foundDelimiter && parsedElement.shape) {
parsedElement.complete = true;
parsed = true;
}
// TODO: When an unknown word is encountered, ask the user what they mean, thus creating a feature request
if(parsed) return parsedElement;
}
for(let i = 0; i < words.length; i++) {
let lookAhead = i;
let phrase = [];
let extraWords = 0;
let parsedElement;
while(lookAhead < words.length) {
phrase.push(Object.assign({}, words[lookAhead]));
let parsed = parse(phrase);
if(parsed) {
parsedElement = parsed;
extraWords = lookAhead - i;
}
lookAhead++;
}
// if(parsedElement) console.log('parsed:',phrase.slice(0, 1+extraWords).join(' '));
element = parsedElement || element;
if(element.complete) {
elements.push(element);
element = { num: element.num + 1, colors: [], colorMods: [] };
}
i += extraWords;
}
elements.push(element);
elements = elements.filter(elem => elem.shape); // Elements need a shape
if(elements.length === 0) return data.reply('Describe something, e.g. `a red circle`');
// elements.forEach(elem => console.log(JSON.stringify(elem, null, '\t')));
elements.forEach(elem => { // Pluralize
if(elem.plural) {
delete elem.plural;
let qty = Math.min(2000, elem.quantity) || util.randomInt(2, 4);
delete elem.quantity;
for(let i = 0; i < qty - 1; i++) {
elements.push(Object.assign({ child: i }, elem));
}
}
});
sizeElements(elements); // Give elements sizes
transformElements(elements); // Flip and rotate elements
let box = arrangeElements(elements); // Arrange elements on canvas
let res = Math.min(MAX_WIDTH / (box.width * (1 + SPACE)), MAX_HEIGHT / (box.height * (1 + SPACE)), BASE_RES);
elements.forEach(elem => { // Color and draw elements
elem.color = new Color(elem.colors[elem.child % elem.colors.length || 0] || COLORS.DEFAULT);
elem.colorMods.forEach(mod => elem.color.modify(mod));
elem.color.vary();
// console.log(JSON.stringify(elem, null, '\t'));
drawShape(elem, res);
});
let canvas = new Canvas(box.width * res * (1 + SPACE), box.height * res * (1 + SPACE));
let ctx = canvas.getContext('2d');
elements.forEach(elem => ctx.drawImage( // Draw to canvas
elem.canvas,
(elem.x - box.bLeft + (elem.u - 1) * SPACE / 2) * res * (1 + SPACE) + elem.ox * res,
(elem.y - box.bTop + (elem.u - 1) * SPACE / 2) * res * (1 + SPACE) + elem.oy * res
));
//canvas = cropCanvas(resizeCanvas(canvas, 1200, 900), 20);
discord.uploadFile({
to: data.channel, filename: data.paramStr.split(' ').join('-') + '.png', file: canvas.toBuffer()
});
};
module.exports = {
commands: _commands,
help: {
draw: ['Draw a picture based on your description', 'big red circle and a blue square']
}
};
function arrangeElementsRadial(elems) {
let PRECISION = 10; // Number of pixels to move each increment
let box = { bTop: 0, bBottom: 0, bLeft: 0, bRight: 0, width: 0, height: 0 };
let getNewBoundingBox = elem => {
let newBox = {
bTop: Math.min(box.bTop, elem.y),
bBottom: Math.max(box.bBottom, elem.y + elem.height),
bLeft: Math.min(box.bLeft, elem.x),
bRight: Math.max(box.bRight, elem.x + elem.width)
};
return Object.assign(newBox, { width: newBox.bRight - newBox.bLeft, height: newBox.bBottom - newBox.bTop });
};
let placed = [];
let dot = new Canvas(2,2);
let dotCtx = dot.getContext('2d');
dotCtx.fillStyle = '#00FF00';
dotCtx.fillRect(0,0,2,2);
let dots;
for(let i = 0; i < elems.length; i++) {
let elem = elems[i];
dots = [];
let ctx = elem.canvas.getContext('2d');
ctx.fillStyle = '#FF0000';
ctx.font = '24px Arial';
ctx.textBaseline = 'middle';
ctx.textAlign = 'center';
ctx.fillText((i + 1).toString(), elem.width / 2, elem.height / 2);
elem.x = -elem.width / 2;
elem.y = -elem.height / 2;
let collides = () => {
for(let j = 0; j < placed.length; j++) if(elemsCollide(elem, placed[j])) return true;
return false;
};
let valid = !collides();
let distance = 0; // Distance from origin
while(!valid) {
distance += PRECISION;
let radIncrement = Math.min(0.5, PRECISION / distance); // Yes, it is this simple
let rad = 0;
let randomOffset = util.random(radIncrement);
let spots = [];
while(rad < 2 * Math.PI) {
let spot = {
cx: Math.sin(rad + randomOffset) * distance,
cy: -Math.cos(rad + randomOffset) * distance
};
spot.x = spot.cx - elem.width / 2;
spot.y = spot.cy - elem.height / 2;
dots.push({ x: spot.cx, y: spot.cy, canvas: dot });
spots.push(spot);
rad += radIncrement;
}
spots.sort((a, b) => {
return (Math.abs(a.cy) - Math.abs(b.cy));
let newBoxA = getNewBoundingBox(a), newBoxB = getNewBoundingBox(b);
if(Math.abs(a.x) < PRECISION / 2 && newBoxA.width / newBoxA.height >= ASPECT) return -1;
if(Math.abs(a.y) < PRECISION / 2 && newBoxA.width / newBoxA.height <= ASPECT) return -1;
if(Math.abs(b.x) < PRECISION / 2 && newBoxB.width / newBoxB.height >= ASPECT) return 1;
if(Math.abs(b.y) < PRECISION / 2 && newBoxB.width / newBoxB.height <= ASPECT) return 1;
return (newBoxA.width - box.width + (newBoxA.height - box.height) * ASPECT) -
(newBoxB.width - box.width + (newBoxB.height - box.height) * ASPECT);
});
for(let s = 0; s < spots.length; s++) {
elem.x = spots[s].x;
elem.y = spots[s].y;
if(!collides(elem)) {
valid = true;
break;
}
}
}
placed.push(elem);
//console.log(i+1,' x:',Math.round(elem.x),'y:',Math.round(elem.y),'w:',elem.width,'h:',elem.height);
Object.assign(box, getNewBoundingBox(elem));
}
elems.push(...dots);
return box;
}