UNPKG

d-bot

Version:

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

323 lines (305 loc) 13.8 kB
// Big in Japan const util = require(__base+'core/util.js'); const discord = require(__base+'core/discord.js'); const config = require(__base+'core/config.js'); const storage = require(__base+'core/storage.js'); const { Thumbnail } = require('./helpers/canvas.js'); const Canvas = require('canvas'); const GIFEncoder = require('gifencoder'); const fs = require('fs'); const imagemin = require('imagemin'); const imageminGifsicle = require('imagemin-gifsicle'); const download = require('download'); const _commands = {}; const IMAGE_PATH = storage.getStoragePath('play.gif'); const RES = 1; // Output image scale const WIDTH = 200; const HEIGHT = 300; const TOP_SPACE = 40; // Before pegs const BOTTOM_SPACE = 50; // After pegs const SLOTS = 6; // Deploy positions const SLOT_WIDTH = WIDTH / (SLOTS + 1); const GOALS = 8; const GOAL_HEIGHT = 20; const PEG_RADIUS = 2.5; const BUMPER_RADIUS = 5; const BALL_RADIUS = 9; const PEG_SPACING = Math.ceil(PEG_RADIUS * 2 + BALL_RADIUS * 2.5); const PEG_AREA_HEIGHT = HEIGHT - TOP_SPACE - BOTTOM_SPACE; const PEG_AREA_WIDTH = WIDTH - PEG_SPACING * 2; const GRAVITY = 0.012; const FRICTION = 0.9; const ELASTICITY = 0.7; const FRAME_DIVIDER = 6; const FPS = 30; const MAX_SUBFRAMES = 150 * 7.5 * FRAME_DIVIDER; // Avg 150 frames per megabyte, max 7.5mb upload const DIM_COLOR = '#888888'; const BORDER_COLOR = '#AAAAAA'; const PEG_COLOR = '#CCCCCC'; const DEFAULT_USER_COLOR = '#AAABAD'; const DEFAULT_BALL_COLOR = '#CCCCCC'; const BG_COLOR = '#202225'; const AVATAR_URL = 'https://cdn.discordapp.com/avatars/'; const SLOT_IMAGE = new Canvas(WIDTH * RES, TOP_SPACE * RES); function drawSlots() { let slotCtx = SLOT_IMAGE.getContext('2d'); slotCtx.strokeStyle = DIM_COLOR; slotCtx.lineWidth = RES * 3; slotCtx.lineCap = 'round'; slotCtx.fillStyle = PEG_COLOR; slotCtx.font = `${Math.floor(TOP_SPACE * 0.45 * RES)}px Roboto`; slotCtx.textBaseline = 'top'; slotCtx.textAlign = 'center'; for(let s = 0; s < SLOTS; s++) { let x = Math.floor((s + 1) * SLOT_WIDTH * RES); slotCtx.beginPath(); slotCtx.moveTo(x + 0.5 - SLOT_WIDTH * 0.2 * RES, TOP_SPACE * 0.61 * RES); slotCtx.lineTo(x + 0.5, TOP_SPACE * 0.75 * RES); slotCtx.lineTo(x + 0.5 + SLOT_WIDTH * 0.2 * RES, TOP_SPACE * 0.61 * RES); slotCtx.stroke(); slotCtx.fillText((s + 1).toString(), x + RES - (s + 1 === 1 ? 2 : 0) * RES, 0); } } drawSlots(); let game = false; function generateMap() { let pegs = []; let map = { width: WIDTH, height: HEIGHT, pegs }; let pegRows = Math.floor(PEG_AREA_HEIGHT / PEG_SPACING) + 1; let pegRowHeight = Math.floor(PEG_AREA_HEIGHT / (pegRows - 1)); let maxXPegs = Math.floor(PEG_AREA_WIDTH / PEG_SPACING) + 1; let prevXPegCount = 0; let prevBumpers = false; for(let r = 0; r < pegRows; r++) { let pegY = TOP_SPACE + pegRowHeight * r; let xPegCount; do { xPegCount = util.randomInt(Math.max(2, Math.ceil(maxXPegs * 2 / 3)), maxXPegs); } while(xPegCount % 2 === prevXPegCount % 2 || (r === 0 && xPegCount === SLOTS)); prevXPegCount = xPegCount; let xPegWidth = util.random(PEG_SPACING, PEG_AREA_WIDTH / (xPegCount - 1)); let xPegPadding = (WIDTH - (xPegCount - 1) * xPegWidth) / 2; if(xPegPadding > BUMPER_RADIUS + PEG_SPACING && !prevBumpers && r + 1 < pegRows) { pegs.push([0, pegY + pegRowHeight / 2, BUMPER_RADIUS]); pegs.push([WIDTH, pegY + pegRowHeight / 2, BUMPER_RADIUS]); prevBumpers = true; } else prevBumpers = false; for(let x = 0; x < xPegCount; x++) { pegs.push([Math.round(xPegPadding + xPegWidth * x), pegY, PEG_RADIUS]); } } return map; } function drawMap(map) { let canvas = new Canvas(WIDTH * RES, HEIGHT * RES); let ctx = canvas.getContext('2d'); ctx.fillStyle = BG_COLOR; ctx.fillRect(0, 0, WIDTH * RES, HEIGHT * RES); ctx.fillStyle = PEG_COLOR; for(let [pegX, pegY, pegRadius] of map.pegs) { ctx.beginPath(); ctx.arc(pegX * RES, pegY * RES, pegRadius * RES, 0, 2 * Math.PI); ctx.fill(); } ctx.strokeStyle = BORDER_COLOR; ctx.lineWidth = RES; ctx.beginPath(); ctx.moveTo(RES / 2, RES / 2); ctx.lineTo(WIDTH * RES - RES / 2, RES / 2); ctx.lineTo(WIDTH * RES - RES / 2, HEIGHT * RES - RES / 2); ctx.lineTo(RES / 2, HEIGHT * RES - RES / 2); ctx.closePath(); ctx.stroke(); return canvas; } let dotProduct = (a, b) => a.x * b.x + a.y * b.y; let subtract = (a, b) => ({ x: a.x - b.x, y: a.y - b.y }); let multiply = (a, f) => ({ x: a.x * f, y: a.y * f }); function simulate(map, cb) { let frame = 0; let canvas = new Canvas(WIDTH * RES, HEIGHT * RES); let ctx = canvas.getContext('2d'); let encoder = new GIFEncoder(WIDTH * RES, HEIGHT * RES); encoder.createReadStream().pipe(fs.createWriteStream(IMAGE_PATH)); encoder.start(); encoder.setRepeat(0); encoder.setDelay(Math.round(1000 / FPS)); encoder.setQuality(4); //console.time('simulating result'); //util.timer.reset(); ctx.drawImage(map.image, 0, 0); ctx.beginPath(); ctx.moveTo(RES, RES); ctx.lineTo(WIDTH * RES - RES, RES); ctx.lineTo(WIDTH * RES - RES, HEIGHT * RES - RES); ctx.lineTo(RES, HEIGHT * RES - RES); ctx.closePath(); ctx.clip(); // Don't draw over map border let playersDone = 0; let slotRound = 0; let roundFrames = Math.ceil(Math.sqrt(2 * HEIGHT / GRAVITY)); // t = sqrt(2d/a) function nextRound() { if(slotRound + 1 > game.maxSlotStack) return; game.slots.forEach(slot => { if(game.players.has(slot[slotRound])) game.players.get(slot[slotRound]).status.falling = true; }); slotRound++; } nextRound(); while((playersDone < game.players.size || slotRound < game.maxSlotStack) && frame < MAX_SUBFRAMES) { //util.timer.start('drawing frame'); let subFrame = frame % FRAME_DIVIDER; //util.timer.stop('drawing frame'); game.players.forEach(({ p, v, status, color, avatarImg }, playerID) => { if(!status.falling) return; ctx.globalAlpha = subFrame === 0 ? 1 : Math.pow(0.5, FRAME_DIVIDER - subFrame); ctx.fillStyle = color; ctx.beginPath(); ctx.arc(p.x * RES, p.y * RES, BALL_RADIUS * RES + RES, 0, 2 * Math.PI); ctx.fill(); ctx.save(); ctx.shadowColor = color; ctx.shadowBlur = 3 * RES; ctx.drawImage(avatarImg, (p.x - BALL_RADIUS) * RES, (p.y - BALL_RADIUS) * RES); ctx.restore(); v.y += GRAVITY; if(p.x < BALL_RADIUS || p.x > WIDTH - BALL_RADIUS) { v.x *= -ELASTICITY; p.x = p.x < BALL_RADIUS ? BALL_RADIUS : WIDTH - BALL_RADIUS; } game.players.forEach(({ p: p2, v: v2, status: status2 }, player2ID) => { if(playerID === player2ID || !status2.falling) return; let playerCollisionDist = Math.pow(BALL_RADIUS * 2, 2); if(Math.abs(p.x - p2.x) > BALL_RADIUS * 2 || Math.abs(p.y - p2.y) > BALL_RADIUS * 2 || Math.pow(p.x - p2.x, 2) + Math.pow(p.y - p2.y, 2) > playerCollisionDist) return; let n = { x: p2.x - p.x, y: p2.y - p.y }; // Collision normal let rv = subtract(v, v2); // Relative velocity let dp = dotProduct(rv, n); if(dp <= 0) return; // Already moving away let u = multiply(n, dp / dotProduct(n, n)); // Perpendicular let u2 = multiply(n, -dp / dotProduct(n, n)); // Perpendicular let w = subtract(v, u); // Parallel let w2 = subtract(v2, u2); // Parallel let nv = subtract(multiply(w, FRICTION / 2), multiply(u, ELASTICITY / 2)); // New velocity let nv2 = subtract(multiply(w2, FRICTION / 2), multiply(u2, ELASTICITY / 2)); // New velocity v.x = nv.x; // Set new velocity v.y = nv.y; v2.x = nv2.x; // Set new velocity v2.y = nv2.y; }); for(let [pegX, pegY, pegRadius] of map.pegs) { // Detect peg collisions let pegCollisionDist = Math.pow(pegRadius + BALL_RADIUS, 2); if(p.x + BALL_RADIUS <= pegX - pegRadius || p.x - BALL_RADIUS > pegX + pegRadius || p.y + BALL_RADIUS <= pegY - pegRadius || p.y - BALL_RADIUS > pegY + pegRadius || Math.pow(p.x - pegX, 2) + Math.pow(p.y - pegY, 2) > pegCollisionDist) continue; //console.log('old velocity:', v.x, v.y); let n = { x: pegX - p.x, y: pegY - p.y }; // Collision normal let dp = dotProduct(v, n); if(dp <= 0) return; // Already moving away let u = multiply(n, dp / dotProduct(n, n)); // Perpendicular let w = subtract(v, u); // Parallel let nv = subtract(multiply(w, FRICTION), multiply(u, ELASTICITY)); // New velocity v.x = nv.x; // Set new velocity v.y = nv.y; //console.log('new velocity:', v.x, v.y); } }); game.players.forEach(({ p, v, status }) => { if(!status.falling) return; if(Math.abs(v.x) + Math.abs(v.y) <= GRAVITY) status.stillFrames++; else status.stillFrames = 0; p.x += v.x; p.y += v.y; if(p.y >= HEIGHT + BALL_RADIUS || status.stillFrames > 30) { playersDone++; status.falling = false; } }); if(subFrame === 0) { encoder.addFrame(ctx); ctx.drawImage(map.image, 0, 0); } frame++; if(frame % roundFrames === 0) nextRound(); } encoder.finish(); //util.timer.results(); //console.timeEnd('simulating result'); //console.time('optimizing gif'); imagemin([IMAGE_PATH], { use: [imageminGifsicle({ optimizationLevel: 2 })] }).then(() => { setTimeout(() => fs.readFile(IMAGE_PATH, cb), 300); // Delay file read }).catch(console.log); } _commands.pachinko = function(data) { game = false; if(game) return data.reply('There is already a pachinko game in progress!'); game = { players: new Map(), channel: data.channel, slots: [], maxSlotStack: 0 }; for(let i = 0; i <= SLOTS; i++) game.slots.push([]); game.map = generateMap(); game.map.image = drawMap(game.map); let canvas = new Canvas(game.map.image.width, game.map.image.height); let ctx = canvas.getContext('2d'); ctx.drawImage(game.map.image, 0, 0); ctx.drawImage(SLOT_IMAGE, 0, 0); discord.bot.uploadFile({ to: data.channel, filename: `pachinko-${Date.now()}.png`, file: canvas.toBuffer(), message: `**__Pachinko!__**\nUse \`${config.prefixes[0]}p\` to choose a slot #` }); }; function testRun() { _commands.pachinko({ channel: '209177876975583232', reply: (msg, polite, cb) => discord.sendMessage('209177876975583232', msg, polite, cb) }); } // if(!discord.bot.connected) discord.bot.on('ready', testRun); // else testRun(); module.exports = { listen(data) { if(!game || game.channel !== data.channel || data.command !== 'p') return; let slot = +data.paramStr; if(!(slot > 0 && slot <= SLOTS)) return data.reply(`Pick a slot from 1 to ${SLOTS}`); let user = data.messageObject.member; let avatarImg = new Canvas(BALL_RADIUS * 2 * RES, BALL_RADIUS * 2 * RES); let avatarCtx = avatarImg.getContext('2d'); game.players.set(data.userID, { slot, color: user.color !== null ? ('#' + user.color.toString(16)) : DEFAULT_USER_COLOR, avatarImg, v: { x: util.random(GRAVITY * -20, GRAVITY * 20), y: 0 }, p: { x: slot * SLOT_WIDTH, y: 0 }, status: { stillFrames: 0 } }); for(let i = 0; i < 12; i++) { game.players.set('test' + i, { slot, color: '#eaa5f3', avatarImg, v: { x: util.random(GRAVITY * -20, GRAVITY * 20), y: 0 }, p: { x: ((i % 6) + 1) * SLOT_WIDTH, y: 0 }, status: { stillFrames: 0 } }); game.slots[i % 6 + 1].push('test' + i); } game.slots[slot].push(data.userID); game.maxSlotStack = Math.max(game.maxSlotStack, game.slots[slot].length); avatarCtx.beginPath(); avatarCtx.arc(BALL_RADIUS * RES, BALL_RADIUS * RES, BALL_RADIUS * RES, 0, 2 * Math.PI); let avatarURL = `${AVATAR_URL}${data.userID}/${user.avatar}.png`; download(avatarURL).then(imgData => { let img = new Canvas.Image; img.src = imgData; avatarCtx.clip(); let resizedAvatar = new Thumbnail(img, BALL_RADIUS * 2 * RES, 3); avatarCtx.drawImage(resizedAvatar, 0, 0); simulate(game.map, (err, file) => { if(err) return console.log(err); discord.bot.uploadFile({ to: data.channel, filename: `pachinko-${Date.now()}.gif`, file }); }); }).catch(err => { console.log(err); avatarCtx.fillStyle = DEFAULT_BALL_COLOR; avatarCtx.fill(); }); }, dev: true, commands: _commands };