UNPKG

d-bot

Version:

A quirky Discord bot made for single, small, private servers

384 lines (367 loc) 18 kB
// A comic strip generator using the message log const util = require(__base+'core/util.js'); const storage = require(__base+'core/storage.js'); const messages = require(__base+'core/messages.js'); const discord = require(__base+'core/discord.js'); const config = require(__base+'core/config.js'); const requireUncached = require('require-uncached'); const images = requireUncached('./helpers/comic/images.js'); const Canvas = require('canvas'); const download = require('download'); // ✔️️ Multiple messages from the same user can clump into one frame // ✔️ Pay attention to message times to create conversations, and insert pauses with silent frames // ✔️ If message contains only a URL, character should be holding up a link symbol // ✔️ Randomly transpose actors alone in frame horizontally // Make random platforms for viper to be on, so he is in frame (separate image drawn under viper) // Markov can show up randomly in the last frame to deliver a non-sequitur // Create "themes" with location backgrounds and/or activities and/or outfits for the actors // Grab linked images and draw them in the frame // Allow generating a comic from a search term // Use Twemoji lib to draw emoji const SCALE = 2; const FRAME_WIDTH = 200 * SCALE; // Frame dimensions const FRAME_HEIGHT = 150 * SCALE; const FRAME_COLUMNS = 2; const DEFAULT_FONT_SIZE = 36; const FONT_FAMILY = 'px "SF Action Man"'; const FONT_COLOR = '#222222'; const FONT_SHADOW_COLOR = '#FFFFFF'; const MSG_POOL_SIZE = 30; const DEFAULT_FRAME_COUNT = 4; const MIN_PAUSE_TIME = 3 * 60 * 1000; const LEFT = 'left', RIGHT = 'right'; let _commands = {}; _commands.comic = async function(data) { if(!config.comic) return data.reply('The comic command has not been configured!'); // if(data.userID === '86919912156573696') return data.reply('No more comics for you, Raz'); let query = { channel: config.comic.channel, // content: /(^|\s)((https?:\/\/)?[\w-]+(\.[\w-]+)+\.?(:\d+)?(\/\S*\.(png)))/gi, $not: { content: '' } }; let skip = 0; if(data.params[0] !== 'that') { // Grab messages from a random point // util.timer.start('count messages'); let count = await messages.cursor(db => db.ccount(query)); // util.timer.stop('count messages'); skip = util.randomInt(count - skip); } // util.timer.start('get messages'); let msgPool = await messages.cursor(db => db.cfind(query).sort({time:-1}).skip(skip).limit(MSG_POOL_SIZE)); msgPool = msgPool.map(({ content, user, time }) => ({ text: discord.fixMessage(util.emojiToText(content).replace(/<(:\w+:)\d+>/gi,'$1'), data.server), time, user: config.comic.users[user] || user })); // util.timer.stop('get messages'); let dialogue = buildDialogue(msgPool); //console.log(dialogue); let frames = createFrames(dialogue); // util.timer.start('draw comic'); await drawActors(frames); drawText(frames); let { canvas: mainCanvas, ctx: mainContext } = createCanvas(FRAME_WIDTH * FRAME_COLUMNS, FRAME_HEIGHT * 2); for(let f = 0; f < frames.length; f++) { frames[f].number = f + 1; drawFrameToComic(mainContext, mainCanvas, frames[f]); } fillText(mainContext, (new Date(msgPool[0].time)).toLocaleDateString(), mainCanvas.width - 2, mainCanvas.height - 4, 28, RIGHT, 1); // util.timer.stop('draw comic'); // util.timer.start('upload'); let filename = `comic-${Date.now()}.png`; require('fs').writeFile(storage.getStoragePath(filename), mainCanvas.toBuffer(), () => {}); discord.uploadFile({ to: data.channel, filename, file: mainCanvas.toBuffer() }/*, () => util.timer.stop('upload').results().reset()*/); }; function createCanvas(width, height) { let newCanvas = new Canvas(width, height), newCtx = newCanvas.getContext('2d'); newCtx.patternQuality = 'best'; return { canvas: newCanvas, ctx: newCtx }; } function buildDialogue(messages) { let dialogue = []; let longestPause = 0; let totalPauseTime = 0; let addBeat = function() { if(dialogue.length > 0) { let pauseLength = dialogue[0].time - beat.time; longestPause = Math.max(pauseLength, longestPause); totalPauseTime += pauseLength; } dialogue.unshift(beat); // Add beat to dialogue (reversed because the messages are reversed) beat = {}; }; let beat = {}; for(let i = 0; i < messages.length; i++) { // Loop through messages, newest to oldest if(dialogue.length === DEFAULT_FRAME_COUNT) break; let { user, text, time } = messages[i]; // console.log('m =',m,'user:',user,'text:',text,'time:',time); // console.log('beat:',beat); if(beat.speaker) { // If speaker already defined for this beat //console.log('speaker already defined:',beat.speaker); if(beat.speaker === user) { // If beat speaker matches current message speaker //console.log('speaker matches message user'); let joinedText = text + ' \n \n ' + beat.text; let textFit = planText.bind( // Test fit null, joinedText, LEFT, images.genericCollisions, DEFAULT_FONT_SIZE / 6 ); if(beat.time - time < MIN_PAUSE_TIME && textFit()) { // If msg is < min pause time & text fits //console.log('fits and less than 3 min, joining'); beat.text = text + ' \n \n ' + beat.text; beat.time = time; } else { // Pause too long or doesn't fit, so this beat is done //console.log('pause too long or doesn't fit, adding beat'); addBeat(); i--; // Run through this message again } } else { // If different speaker //console.log('different speaker, adding beat'); addBeat(); i--; // Run through this message again } } else { // Beat has no speaker //console.log('new beat, setting speaker time and text'); beat.speaker = user; beat.text = text; beat.time = time; } } let averagePauseLength = (totalPauseTime / (dialogue.length - 1)); for(let b = dialogue.length - 1; b > 0; b--) { // Check if pause time is more than twice the average, and more than min pause time let pauseTime = dialogue[b].time - dialogue[b - 1].time; if(pauseTime > Math.max(MIN_PAUSE_TIME, averagePauseLength * 2)) { dialogue.splice(b, 0, { pause: true, time: dialogue[b - 1].time + pauseTime / 2 }); dialogue.shift(); break; // Only one pause per comic } } // console.log(dialogue); return dialogue; } function createFrames(dialogue) { let frames = [], actors = {}; let placeActor = function(actor, side) { for(let aKey of Object.keys(actors)) { if(actors[aKey] === side) delete actors[aKey]; // Remove actor already on this side } actors[actor] = side; }; let lastSpeaker; // Place actors (first pass) for(let i = 0; i < dialogue.length; i++) { let { speaker, time, text } = dialogue[i]; let frame = { actors: {}, speaker, time, text }; if(speaker) { // If beat has a speaker if(!lastSpeaker) { // First speaker placeActor(speaker, util.flip() ? LEFT : RIGHT); // Put speaker on random side } else { // If not on first frame if(lastSpeaker && speaker !== lastSpeaker.actor) { // If different than last speaker // Put new speaker on opposite side placeActor(speaker,util.flip(lastSpeaker.side)); } } lastSpeaker = { side: actors[speaker], actor: speaker }; } // for(let aKey in actors) { if(!actors.hasOwnProperty(aKey)) continue; // if(aKey !== speaker && dialogue[pa + 1] && aKey !== dialogue[pa + 1].speaker) { // // If actor not speaking this frame or next frame, chance of leaving // if(Math.random() > 0.7) delete actors[aKey]; // } // } frame.actors = Object.assign({}, actors); // Write actors to frame // console.log('actors placed in frame',pa,frame.actors); frames.push(frame); } let leftActorFill, rightActorFill; for(let i = frames.length - 1; i >= 0; i--) { let { actors } = frames[i]; let leftActor = Object.keys(actors).find(aKey => actors[aKey] === LEFT); let rightActor = Object.keys(actors).find(aKey => actors[aKey] === RIGHT); if(leftActor) leftActorFill = leftActor; if(rightActor) rightActorFill = rightActor; if(!leftActor && leftActorFill) actors[leftActorFill] = LEFT; if(!rightActor && rightActorFill) actors[rightActorFill] = RIGHT; } // console.log(JSON.stringify(frames, null, '\t')); return frames; } async function drawActors(frames) { let bgColor = { h: Math.random(), s: 0.15, v: 0.9 }; // console.log('drawing actors to frames'); // Draw actors to frames for(let i = 0; i < frames.length; i++) { // console.log('drawing frame',da-frames.length+5); let frame = frames[i]; let bgCanvas = createCanvas(FRAME_WIDTH, FRAME_HEIGHT); bgCanvas.ctx.rect(0,0,FRAME_WIDTH,FRAME_HEIGHT); let bgGradient = bgCanvas.ctx.createRadialGradient( FRAME_WIDTH/2, 0, FRAME_HEIGHT/2, FRAME_WIDTH/2, FRAME_HEIGHT/2, FRAME_HEIGHT ); let hueOffset = 0; if(i === frames.length - 1 && !frames[i - 1].speaker) hueOffset = Math.random() * 0.3; let dark = util.hsvToRGB(bgColor.h + hueOffset, bgColor.s, bgColor.v), light = util.hsvToRGB( bgColor.h + hueOffset + Math.random() * 0.12, bgColor.s - Math.random() * 0.07, bgColor.v + Math.random() * 0.07 ); bgGradient.addColorStop(0, 'rgba(' + light.r + ',' + light.g + ',' + light.b + ',1)'); bgGradient.addColorStop(1, 'rgba(' + dark.r + ',' + dark.g + ',' + dark.b + ',1)'); bgCanvas.ctx.fillStyle = bgGradient; bgCanvas.ctx.fill(); let image = frame.text && frame.text.match(/(^|\s)((https?:\/\/)?[\w-]+(\.[\w-]+)+\.?(:\d+)?(\/\S*\.(png)))/gi); if(image && image[0]) { get_image: try { let imgData = await download(image[0]); let bgImage = new Canvas.Image; bgImage.src = imgData; if(!imgData || !bgImage.width) break get_image; let scale = Math.max(FRAME_WIDTH / bgImage.width, FRAME_HEIGHT / bgImage.height); let newWidth = bgImage.width * scale, newHeight = bgImage.height * scale; let ox = (FRAME_WIDTH - newWidth) / 2, oy = (FRAME_HEIGHT - newHeight) / 2; bgCanvas.ctx.drawImage(bgImage, 0, 0, bgImage.width, bgImage.height, ox, oy, newWidth, newHeight); } catch(e) { console.log(e); } } frame.bgImage = bgCanvas.canvas; frame.collisionMaps = []; frame.actorImage = createCanvas(FRAME_WIDTH, FRAME_HEIGHT); for(let aKey of Object.keys(frame.actors)) { let actorState = 'idle'; if(frame.speaker) { if(frame.speaker === aKey) { actorState = frame.text.substr(0,4) === 'http' ? 'link' : 'talk'; } else { actorState = 'listen'; } } else { actorState = Object.keys(frame.actors).length === 1 ? 'alone' : 'idle'; } let frameImage = images.getImage(aKey,actorState); // console.log(aKey,actorState,frame.actors[aKey]); if(frame.actors[aKey] === LEFT) { frame.actorImage.ctx.translate(FRAME_WIDTH,0); frame.actorImage.ctx.scale(-1,1); frameImage.collisionMap = images.flipCollision(frameImage.collisionMap); } let xOffset = 0; if(actorState === 'alone') xOffset = util.randomInt(150); frame.collisionMaps.push(frameImage.collisionMap); frame.actorImage.ctx.drawImage(frameImage.img,0,0,FRAME_WIDTH,FRAME_HEIGHT,xOffset*-1,0,FRAME_WIDTH,FRAME_HEIGHT); if(frame.actors[aKey] === LEFT) { frame.actorImage.ctx.translate(FRAME_WIDTH,0); frame.actorImage.ctx.scale(-1,1); } } } } function drawText(frames) { for(let frame of frames) { let { text, actors, collisionMaps } = frame; if(!frame.text) continue; let { lines, fontSize, align } = planText(text, actors[frame.speaker], collisionMaps, DEFAULT_FONT_SIZE); frame.textImage = createCanvas(FRAME_WIDTH, FRAME_HEIGHT); for(let { text, x, y } of lines) { fillText(frame.textImage.ctx, text, x, y, fontSize, align, SCALE); } } } function drawFrameToComic(ctx, canvas, frame) { // console.log('drawFrameToComic'); // console.log('drawing:',frame.number,frame.speaker,frame.actors); // console.log('text plan:',JSON.stringify(frame.textPlan, null, '\t')); let frameX = (frame.number - 1) % FRAME_COLUMNS * FRAME_WIDTH, frameY = Math.floor((frame.number - 1) / FRAME_COLUMNS) * FRAME_HEIGHT; //ctx.fillStyle = '#eeeeee'; // Draw frame BG color //ctx.fillRect((frame.number-1) % 2 * F_WIDTH, Math.floor((frame.number-1) / 2) * F_HEIGHT, F_WIDTH, F_HEIGHT); ctx.drawImage(frame.bgImage, frameX, frameY); if(frame.textImage) ctx.drawImage(frame.textImage.canvas, frameX, frameY); ctx.drawImage(frame.actorImage.canvas, frameX, frameY); if(frame.number === DEFAULT_FRAME_COUNT) { // After last frame is drawn // Draw frame borders ctx.clearRect(FRAME_WIDTH - 4, 0, 8, canvas.height); ctx.clearRect(0, FRAME_HEIGHT - 4, canvas.width, 8); } } function fillText(context, text, x, y, size, align, shadowSpread) { context.font = size + FONT_FAMILY; context.textAlign = align; context.fillStyle = FONT_SHADOW_COLOR; context.shadowColor = FONT_SHADOW_COLOR; context.shadowBlur = size / 16 * shadowSpread; context.shadowOffsetX = 0; context.shadowOffsetY = 0; for(let s = 0; s < 4; s++) { // Draw shadows let ox = 0, oy = shadowSpread; switch(s) { case 0: ox = -shadowSpread; break; case 1: ox = shadowSpread; break; case 2: oy = -shadowSpread; break; } context.fillText(text, x + ox, y + oy); } context.fillStyle = FONT_COLOR; // Draw primary color context.fillText(text, x, y); } function planText(text, align, collisionMaps, maxShrink) { //console.log('planning text, objects:',JSON.stringify(objects, null, '\t')); for(let s = 0; s <= maxShrink; s++) { let plan = { fontSize: DEFAULT_FONT_SIZE - s, align: align, lines: [] }; plan.lineHeight = Math.round(plan.fontSize * 0.85); let horizontalPadding = Math.round(plan.fontSize * 0.35); let ctx = createCanvas(FRAME_WIDTH, FRAME_HEIGHT).ctx; ctx.font = plan.fontSize + FONT_FAMILY; ctx.textAlign = align; let words = text.split(' '); let line = ''; let y = Math.round(plan.fontSize), textHeight = Math.round(plan.fontSize * 0.7); let space = images.getEmptySpace(y-textHeight, textHeight, collisionMaps); let x = align === LEFT ? space.left + horizontalPadding : space.right - horizontalPadding, maxWidth = space.right - space.left - horizontalPadding * 2; for(let n = 0; n < words.length; n++) { let urlDomain = util.getDomain(words[n]); let currentWord = urlDomain ? '<' + urlDomain + '>' : words[n]; if(currentWord === '') continue; if(currentWord === '\n') { if(line !== '') plan.lines.push({ x: x, y: y, text: line }); line = ''; y += plan.lineHeight; space = images.getEmptySpace(y-textHeight, textHeight, collisionMaps); x = align === LEFT ? space.left + horizontalPadding : space.right - horizontalPadding; maxWidth = space.right - space.left - horizontalPadding * 2; continue; } let testLine = line + (line === '' ? '' : ' ') + currentWord; let testWidth = ctx.measureText(testLine).width; if ((!maxWidth || testWidth > maxWidth) && n > 0) { plan.lines.push({ x: x, y: y, text: line }); line = currentWord; y += plan.lineHeight; space = images.getEmptySpace(y-textHeight, textHeight, collisionMaps); x = align === LEFT ? space.left + horizontalPadding : space.right - horizontalPadding; maxWidth = space.right - space.left - horizontalPadding * 2; } else { line = testLine; } } plan.height = y; plan.lines.push({ x: x, y: y, text: line }); if(plan.height <= FRAME_HEIGHT/1.5) { return plan; } } return false; } module.exports = { commands: _commands, // dev: true, help: { comic: ['Generate a comic', '', 'that'] } };