diff --git a/avatars/flags.png b/avatars/flags.png new file mode 100644 index 0000000..18e958f Binary files /dev/null and b/avatars/flags.png differ diff --git a/ppfun-bridge/src/pixelplanet/Palette.js b/ppfun-bridge/src/pixelplanet/Palette.js new file mode 100644 index 0000000..9a288b3 --- /dev/null +++ b/ppfun-bridge/src/pixelplanet/Palette.js @@ -0,0 +1,127 @@ + +class Palette { + + constructor(colors) { + this.length = colors.length; + this.rgb = new Uint8Array(this.length * 3); + this.colors = new Array(this.length); + this.abgr = new Uint32Array(this.length); + this.fl = new Array(this.length); + + let cnt = 0; + for (let index = 0; index < colors.length; index++) { + const r = colors[index][0]; + const g = colors[index][1]; + const b = colors[index][2]; + this.rgb[cnt++] = r; + this.rgb[cnt++] = g; + this.rgb[cnt++] = b; + this.colors[index] = `rgb(${r}, ${g}, ${b})`; + this.abgr[index] = (0xFF000000) | (b << 16) | (g << 8) | (r); + this.fl[index] = [r / 256, g / 256, b / 256]; + } + } + + /* + * Check if a color is light (closer to white) or dark (closer to black) + * @param color Index of color in palette + * @return dark True if color is dark + */ + isDark(color) { + color *= 3; + const r = this.rgb[color++]; + const g = this.rgb[color++]; + const b = this.rgb[color]; + const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b; + return (luminance < 128); + } + + /* + * Get last matching color index of RGB color + * @param r r + * @param g g + * @param b b + * @return index of color + */ + getIndexOfColor(r, g, b) { + const { rgb } = this; + let i = rgb.length / 3; + while (i > 0) { + i -= 1; + const off = i * 3; + if (rgb[off] === r + && rgb[off + 1] === g + && rgb[off + 2] === b + ) { + return i; + } + } + return null; + } + + /* + * Take a buffer of indexed pixels and output it as ABGR Array + * @param chunkBuffer Buffer of indexed pixels + * @return ABRG Buffer + */ + buffer2ABGR(chunkBuffer) { + const { length } = chunkBuffer; + const colors = new Uint32Array(length); + let value; + const buffer = chunkBuffer; + + let pos = 0; + for (let i = 0; i < length; i++) { + value = (buffer[i] & 0x3F); + colors[pos++] = this.abgr[value]; + } + return colors; + } + + /* + * Take a buffer of indexed pixels and output it as RGB Array + * @param chunkBuffer Buffer of indexed pixels + * @return RGB Buffer + */ + buffer2RGB(chunkBuffer) { + const { length } = chunkBuffer; + const colors = new Uint8Array(length * 3); + let color; + let value; + const buffer = chunkBuffer; + + let c = 0; + for (let i = 0; i < length; i++) { + value = buffer[i]; + + color = (value & 0x3F) * 3; + colors[c++] = this.rgb[color++]; + colors[c++] = this.rgb[color++]; + colors[c++] = this.rgb[color]; + } + return colors; + } + + /* + * Create a RGB Buffer of a specific size with just one color + * @param color Color Index of color to use + * @param length Length of needed Buffer + * @return RGB Buffer of wanted size with just one color + */ + oneColorBuffer(color, length) { + const buffer = new Uint8Array(length * 3); + const r = this.rgb[color * 3]; + const g = this.rgb[color * 3 + 1]; + const b = this.rgb[color * 3 + 2]; + let pos = 0; + for (let i = 0; i < length; i++) { + buffer[pos++] = r; + buffer[pos++] = g; + buffer[pos++] = b; + } + + return buffer; + } +} + +export default Palette; diff --git a/ppfun-bridge/src/pixelplanet/constants.js b/ppfun-bridge/src/pixelplanet/constants.js new file mode 100644 index 0000000..4f2c787 --- /dev/null +++ b/ppfun-bridge/src/pixelplanet/constants.js @@ -0,0 +1,5 @@ +export const WIDTH = 1024; +export const HEIGHT = 768; +export const MAX_SCALE = 40; // 52 in log2 +export const TILE_SIZE = 256; +export const TILE_ZOOM_LEVEL = 4; diff --git a/ppfun-bridge/src/pixelplanet/index.js b/ppfun-bridge/src/pixelplanet/index.js new file mode 100644 index 0000000..93928a2 --- /dev/null +++ b/ppfun-bridge/src/pixelplanet/index.js @@ -0,0 +1,73 @@ +import fetch from 'node-fetch'; + +import renderCanvas from './renderCanvas'; +import Palette from './Palette'; +import { + TILE_SIZE, + TILE_ZOOM_LEVEL, +} from './constants'; + +const linkRegExp = /(#[a-z]*,-?[0-9]*,-?[0-9]*(,-?[0-9]+)?)/gi; +const linkRegExpFilter = (val, ind) => ((ind % 3) !== 2); + +let canvases = null; + +async function fetchCanvasData() { + try { + const response = await fetch('https://pixelplanet.fun/api/me'); + if (response.status >= 300) { + throw new Error('Can not connect to pixelplanet!'); + } + const data = await response.json(); + const canvasMap = new Map(); + + const ids = Object.keys(data.canvases); + for (let i = 0; i < ids.length; i += 1) { + const id = ids[i]; + const canvas = data.canvases[id]; + canvas.id = id; + canvas.palette = new Palette(canvas.colors); + canvas.maxTiledZoom = Math.log2(canvas.size / TILE_SIZE) / TILE_ZOOM_LEVEL * 2; + canvasMap.set(canvas.ident, canvas); + } + canvases = canvasMap; + console.log('Successfully fetched canvas data from pixelplanet'); + } catch (e) { + console.log('Couldn\'t connect to pixelplanet, trying to connect again in 60s'); + setTimeout(fetchCanvasData, 60000); + throw(e) + } +} +fetchCanvasData(); + +export async function parseCanvasLinks(msg) { + const { + text, + } = msg; + + if (!text || !canvases) { + return; + } + + const msgArray = text.split(linkRegExp).filter(linkRegExpFilter); + if (msgArray.length > 1) { + const coordsParts = msgArray[1].substr(1).split(','); + const [ canvasIdent, x, y, z ] = coordsParts; + + const canvas = canvases.get(canvasIdent); + if (!canvas) { + return; + } + console.log(`Fetch canvas ${canvas.title} on ${x}/${y} with zoom ${z}`); + + const image = await renderCanvas(canvas, x, y, z); + msg.reply({ + attachments: [ + { + image, + text: `${canvas.title} on ${x}/${y} with zoom ${z}`, + } + ], + }); + } +} diff --git a/ppfun-bridge/src/pixelplanet/loadChunk.js b/ppfun-bridge/src/pixelplanet/loadChunk.js new file mode 100644 index 0000000..ef5cce5 --- /dev/null +++ b/ppfun-bridge/src/pixelplanet/loadChunk.js @@ -0,0 +1,62 @@ +import fetch from 'node-fetch'; + +import { + createCanvas, + loadImage, + createImageData, +} from 'canvas'; + +import { + TILE_SIZE, +} from './constants'; + +export async function getChunk( + canvas, + zoom, + cx, + cy, +) { + const tile = createCanvas(TILE_SIZE, TILE_SIZE); + + const canvasId = canvas.id; + if (canvas.maxTiledZoom === zoom) { + await fetchBaseChunk(canvasId, canvas.palette, zoom, cx, cy, tile); + } else { + await fetchTile(canvasId, zoom, cx, cy, tile); + } + return tile; +} + +async function fetchBaseChunk( + canvasId, + palette, + zoom, + cx, cy, + tile, +) { + const url = `https://pixelplanet.fun/chunks/${canvasId}/${cx}/${cy}.bmp`; + console.log(`Fetching ${url}`); + const response = await fetch(url); + if (response.ok) { + const arrayBuffer = await response.arrayBuffer(); + if (arrayBuffer.byteLength) { + const chunkArray = new Uint8Array(arrayBuffer); + const abgr = palette.buffer2ABGR(chunkArray); + const imageData = createImageData( + new Uint8ClampedArray(abgr.buffer), + TILE_SIZE, + TILE_SIZE, + ); + const ctx = tile.getContext('2d'); + ctx.putImageData(imageData, 0, 0); + } + } +} + +async function fetchTile(canvasId, zoom, cx, cy, tile) { + const url = `https://pixelplanet.fun/tiles/${canvasId}/${zoom}/${cx}/${cy}.png`; + console.log(`Fetching ${url}`); + const image = await loadImage(url); + const ctx = tile.getContext('2d'); + ctx.drawImage(image, 0, 0); +} diff --git a/ppfun-bridge/src/pixelplanet/renderCanvas.js b/ppfun-bridge/src/pixelplanet/renderCanvas.js new file mode 100644 index 0000000..289f7d3 --- /dev/null +++ b/ppfun-bridge/src/pixelplanet/renderCanvas.js @@ -0,0 +1,93 @@ +import { createCanvas } from 'canvas'; + +import { getChunk } from './loadChunk'; + +import { + WIDTH, + HEIGHT, + MAX_SCALE, + TILE_SIZE, + TILE_ZOOM_LEVEL, +} from './constants'; + +function coordToChunk(z, canvasSize, tiledScale) { + return Math.floor((z + canvasSize / 2) / TILE_SIZE * tiledScale); +} + +function chunkToCoord(z, canvasSize, tiledScale) { + return Math.round(z * TILE_SIZE / tiledScale - canvasSize / 2); +} + +async function drawChunk( + ctx, xOff, yOff, + canvas, tiledZoom, xc, yc, +) { + try { + const chunk = await getChunk(canvas, tiledZoom, xc, yc); + ctx.drawImage(chunk, xOff,yOff); + } catch(e) { + console.log(`Chunk ${xc} / ${yc} - ${tiledZoom} is empty`); + } +} + +export default async function renderCanvas( + canvas, + x, + y, + z, +) { + const can = createCanvas(WIDTH, HEIGHT); + const ctx = can.getContext('2d'); + const canvasSize = canvas.size; + + let scale = 2 ** (z / 10); + scale = Math.max(scale, TILE_SIZE / canvas.size); + scale = Math.min(scale, MAX_SCALE); + + let tiledScale = (scale > 0.5) + ? 0 + : Math.round(Math.log2(scale) / 2); + tiledScale = TILE_ZOOM_LEVEL ** tiledScale; + const tiledZoom = canvas.maxTiledZoom + Math.log2(tiledScale) / 2; + + const relScale = scale / tiledScale; + ctx.scale(relScale, relScale); + ctx.fillStyle = canvas.palette.colors[0]; + ctx.imageSmoothingEnabled = false; + ctx.patternQuality = "nearest"; + ctx.antialias = 'none'; + ctx.fillRect(0, 0, WIDTH, HEIGHT); + + const tlX = Math.floor(x - WIDTH / 2 / scale); + const tlY = Math.floor(y - HEIGHT / 2 / scale); + const brX = Math.floor(x - 1 + WIDTH / 2 / scale); + const brY = Math.floor(y - 1 + HEIGHT / 2 / scale); + + const tlCX = coordToChunk(tlX, canvasSize, tiledScale); + const tlCY = coordToChunk(tlY, canvasSize, tiledScale); + const brCX = coordToChunk(brX, canvasSize, tiledScale); + const brCY = coordToChunk(brY, canvasSize, tiledScale); + + console.log(`Load chunks from ${tlCX} / ${tlCY} to ${brCX} / ${brCY}`); + const chunkMax = canvasSize / TILE_SIZE * tiledScale; + + const promises = []; + for (let xc = tlCX; xc <= brCX; xc += 1) { + for (let yc = tlCY; yc <= brCY; yc += 1) { + const xOff = Math.round((chunkToCoord(xc, canvasSize, tiledScale) - tlX) * tiledScale); + const yOff = Math.round((chunkToCoord(yc, canvasSize, tiledScale) - tlY) * tiledScale); + + if (xc < 0 || xc >= chunkMax || yc < 0 || yc >= chunkMax) { + ctx.clearRect(xOff, yOff, TILE_SIZE, TILE_SIZE); + continue; + } + + promises.push( + drawChunk(ctx, xOff, yOff, canvas, tiledZoom, xc, yc), + ); + } + } + await Promise.all(promises); + + return can.toBuffer('image/png'); +} diff --git a/ppfun-bridge/src/ppfunMatrixBridge.js b/ppfun-bridge/src/ppfunMatrixBridge.js index 18765a7..7908bac 100644 --- a/ppfun-bridge/src/ppfunMatrixBridge.js +++ b/ppfun-bridge/src/ppfunMatrixBridge.js @@ -9,7 +9,6 @@ class PPfunMatrixBridge { const { apiSocketKey, apiSocketUrl, - ppfunId, homeserverUrl, domain, registration, @@ -27,7 +26,6 @@ class PPfunMatrixBridge { this.ppfunSocket = new PPfunSocket( apiSocketUrl, apiSocketKey, - ppfunId, ); this.matrixBridge = new MatrixBridge({ homeserverUrl, @@ -39,7 +37,6 @@ class PPfunMatrixBridge { } }); this.port = port; - this.ppfunId = ppfunId; this.domain = domain; this.prefix = 'pp'; @@ -153,7 +150,7 @@ class PPfunMatrixBridge { this.ppfunSocket.emit( 'sendChatMessage', (uid) ? name : `[mx] ${name}`, - uid || this.ppfunId, + uid || null, msg, cid, ); diff --git a/ppfun-bridge/src/ppfunsocket.js b/ppfun-bridge/src/ppfunsocket.js index bf948c3..ff388e2 100644 --- a/ppfun-bridge/src/ppfunsocket.js +++ b/ppfun-bridge/src/ppfunsocket.js @@ -4,15 +4,14 @@ const EventEmitter = require('events'); const WebSocket = require('ws') class PPfunSocket extends EventEmitter { - constructor(url, apisocketkey, ppfunId) { + constructor(url, apisocketkey) { super(); this.url = url; this.apisocketkey = apisocketkey; - this.ppfunId = ppfunId; this.timeConnected = null; this.isConnected = false; this.flagMap = new Map(); - this.flagMap.set(ppfunId, 'yy'); + this.flagMap.set(null , 'yy'); this.ws; this.onWsClose = this.onWsClose.bind(this); console.log('PPfunSocket: Connecting to WebSocket')