diff --git a/src/actions/index.js b/src/actions/index.js index eddf48b..a9a2302 100644 --- a/src/actions/index.js +++ b/src/actions/index.js @@ -233,139 +233,6 @@ export function notify(notification: string) { }; } -function gotCoolDownDelta(delta: number) { - return { - type: 'COOLDOWN_DELTA', - delta, - }; -} - -let pixelTimeout = null; -export function tryPlacePixel( - i: number, - j: number, - offset: number, - color: ColorIndex, -): ThunkAction { - return async (dispatch) => { - pixelTimeout = Date.now() + 5000; - await dispatch(setPlaceAllowed(false)); - - // TODO: - // this is for resending after captcha returned - // window is ugly, put it into redux or something - window.pixel = { - i, - j, - offset, - color, - }; - - dispatch({ - type: 'REQUEST_PLACE_PIXEL', - i, - j, - offset, - color, - }); - }; -} - -export function receivePixelReturn( - retCode: number, - wait: number, - coolDownSeconds: number, -): ThunkAction { - return (dispatch) => { - try { - /* - * the terms coolDown is used in a different meaning here - * coolDown is the delta seconds of the placed pixel - */ - if (wait) { - dispatch(setWait(wait)); - } - if (coolDownSeconds) { - dispatch(notify(coolDownSeconds)); - if (coolDownSeconds < 0) { - dispatch(gotCoolDownDelta(coolDownSeconds)); - } - } - - let errorTitle = null; - let msg = null; - switch (retCode) { - case 0: - dispatch(placedPixel()); - break; - case 1: - errorTitle = 'Invalid Canvas'; - msg = 'This canvas doesn\'t exist'; - break; - case 2: - errorTitle = 'Invalid Coordinates'; - msg = 'x out of bounds'; - break; - case 3: - errorTitle = 'Invalid Coordinates'; - msg = 'y out of bounds'; - break; - case 4: - errorTitle = 'Invalid Coordinates'; - msg = 'z out of bounds'; - break; - case 5: - errorTitle = 'Wrong Color'; - msg = 'Invalid color selected'; - break; - case 6: - errorTitle = 'Just for registered Users'; - msg = 'You have to be logged in to place on this canvas'; - break; - case 7: - errorTitle = 'Place more :)'; - // eslint-disable-next-line max-len - msg = 'You can not access this canvas yet. You need to place more pixels'; - break; - case 8: - dispatch(notify('Pixel protected!')); - break; - case 9: - // pixestack used up - dispatch(pixelWait()); - break; - case 10: - // captcha, reCaptcha or hCaptcha - if (typeof window.hcaptcha !== 'undefined') { - window.hcaptcha.execute(); - } else { - window.grecaptcha.execute(); - } - break; - case 11: - errorTitle = 'No Proxies Allowed :('; - msg = 'You are using a Proxy.'; - break; - default: - errorTitle = 'Weird'; - msg = 'Couldn\'t set Pixel'; - } - if (msg) { - dispatch(pixelFailure()); - dispatch(sweetAlert( - (errorTitle || `Error ${retCode}`), - msg, - 'error', - 'OK', - )); - } - } finally { - pixelTimeout = null; - dispatch(setPlaceAllowed(true)); - } - }; -} - export function setViewCoordinates(view: Cell): Action { return { type: 'SET_VIEW_COORDINATES', @@ -415,7 +282,6 @@ export function moveEast(): ThunkAction { }; } - export function setScale(scale: number, zoompoint: Cell): Action { return { type: 'SET_SCALE', @@ -484,7 +350,7 @@ export function receiveCoolDown( }; } -export function receivePixelUpdate( +export function updatePixel( i: number, j: number, offset: number, @@ -668,18 +534,6 @@ function getPendingActions(state): Array { else actions.push(endCoolDown()); } - if (pixelTimeout && now > pixelTimeout) { - actions.push(pixelFailure()); - pixelTimeout = null; - actions.push(setPlaceAllowed(true)); - actions.push(sweetAlert( - 'Error :(', - 'Didn\'t get an answer from pixelplanet. Maybe try to refresh?', - 'error', - 'OK', - )); - } - return actions; } @@ -849,6 +703,13 @@ export function startDm(query): PromiseAction { }; } +export function gotCoolDownDelta(delta: number) { + return { + type: 'COOLDOWN_DELTA', + delta, + }; +} + export function setUserBlock( userId: number, userName: string, diff --git a/src/actions/types.js b/src/actions/types.js index f2cebcd..e231141 100644 --- a/src/actions/types.js +++ b/src/actions/types.js @@ -39,12 +39,6 @@ export type Action = | { type: 'COOLDOWN_DELTA', delta: number } | { type: 'SELECT_COLOR', color: ColorIndex } | { type: 'SELECT_CANVAS', canvasId: number } - | { type: 'REQUEST_PLACE_PIXEL', - i: number, - j: number, - offset: number, - color: ColorIndex, - } | { type: 'PLACE_PIXEL' } | { type: 'PIXEL_WAIT' } | { type: 'PIXEL_FAILURE' } diff --git a/src/client.js b/src/client.js index 88d59a0..a2b75e6 100644 --- a/src/client.js +++ b/src/client.js @@ -7,7 +7,6 @@ import './styles/font.css'; import onKeyPress from './controls/keypress'; import { - receivePixelUpdate, fetchMe, fetchStats, initTimer, @@ -15,12 +14,14 @@ import { receiveOnline, receiveCoolDown, receiveChatMessage, - receivePixelReturn, addChatChannel, removeChatChannel, setMobile, - tryPlacePixel, } from './actions'; +import { + receivePixelUpdate, + receivePixelReturn, +} from './ui/placePixel'; import store from './ui/store'; @@ -33,15 +34,18 @@ function init() { initRenderer(store, false); ProtocolClient.on('pixelUpdate', ({ - i, j, offset, color, + i, j, pixels, }) => { - // remove protection - store.dispatch(receivePixelUpdate(i, j, offset, color & 0x7F)); + pixels.forEach((pxl) => { + const [offset, color] = pxl; + // remove protection + receivePixelUpdate(store, i, j, offset, color & 0x7F); + }); }); ProtocolClient.on('pixelReturn', ({ - retCode, wait, coolDownSeconds, + retCode, wait, coolDownSeconds, pxlCnt, }) => { - store.dispatch(receivePixelReturn(retCode, wait, coolDownSeconds)); + receivePixelReturn(store, retCode, wait, coolDownSeconds, pxlCnt); }); ProtocolClient.on('cooldownPacket', (coolDown) => { store.dispatch(receiveCoolDown(coolDown)); @@ -145,16 +149,13 @@ window.onCaptcha = async function onCaptcha(token: string) { 'Content-Type': 'application/json', }, body, - // https://github.com/github/fetch/issues/349 credentials: 'include', }); - if (window.pixel) { - const { - i, j, offset, color, - } = window.pixel; - store.dispatch(tryPlacePixel(i, j, offset, color)); - } + const { + i, j, pixels, + } = window.pixel; + ProtocolClient.requestPlacePixels(i, j, pixels); if (typeof window.hcaptcha !== 'undefined') { window.hcaptcha.reset(); diff --git a/src/controls/PixelPainterControls.js b/src/controls/PixelPainterControls.js index cefb972..8dcdbad 100644 --- a/src/controls/PixelPainterControls.js +++ b/src/controls/PixelPainterControls.js @@ -7,7 +7,6 @@ import keycode from 'keycode'; import { - tryPlacePixel, setHover, unsetHover, setViewCoordinates, @@ -21,6 +20,9 @@ import { moveEast, onViewFinishChange, } from '../actions'; +import { + tryPlacePixel, +} from '../ui/placePixel'; import { screenToWorld, getChunkOfPixel, @@ -162,7 +164,6 @@ class PixelPlainterControls { static placePixel(store, renderer, cell) { const state = store.getState(); const { autoZoomIn } = state.gui; - const { placeAllowed } = state.user; const { scale, isHistoricalView, @@ -180,16 +181,17 @@ class PixelPlainterControls { // allow placing of pixel just on low zoomlevels if (scale < 3) return; - if (!placeAllowed) return; - - if (selectedColor !== renderer.getColorIndexOfPixel(...cell)) { + const curColor = renderer.getColorIndexOfPixel(...cell); + if (selectedColor !== curColor) { const { canvasSize } = state.canvas; const [i, j] = getChunkOfPixel(canvasSize, ...cell); const offset = getOffsetOfPixel(canvasSize, ...cell); - store.dispatch(tryPlacePixel( + tryPlacePixel( + store, i, j, offset, selectedColor, - )); + curColor, + ); } } diff --git a/src/core/PixelCache.js b/src/core/PixelCache.js index eff9556..a19a4e7 100644 --- a/src/core/PixelCache.js +++ b/src/core/PixelCache.js @@ -54,7 +54,7 @@ class PixelCache { this.PXL_CACHE = new Map(); cache.forEach((pxls, chunkCanvasId) => { - const canvasId = chunkCanvasId & 0xFF0000 >> 16; + const canvasId = (chunkCanvasId & 0xFF0000) >> 16; const chunkId = chunkCanvasId & 0x00FFFF; webSockets.broadcastPixels(canvasId, chunkId, pxls); }); diff --git a/src/core/draw.js b/src/core/draw.js index 8e51283..ffff16a 100644 --- a/src/core/draw.js +++ b/src/core/draw.js @@ -51,6 +51,7 @@ export async function drawByOffsets( return { wait, coolDown, + pxlCnt, retCode: 1, }; } @@ -177,6 +178,7 @@ export async function drawByOffsets( return { wait, coolDown, + pxlCnt, retCode, }; } diff --git a/src/socket/ProtocolClient.js b/src/socket/ProtocolClient.js index c3378cb..1fc7000 100644 --- a/src/socket/ProtocolClient.js +++ b/src/socket/ProtocolClient.js @@ -128,7 +128,7 @@ class ProtocolClient extends EventEmitter { * @param i, j chunk coordinates * @param pixel Array of [[offset, color],...] pixels within chunk */ - requestPlacePixel( + requestPlacePixels( i: number, j: number, pixels: Array, ) { diff --git a/src/socket/SocketServer.js b/src/socket/SocketServer.js index 9d073bb..13fd1b9 100644 --- a/src/socket/SocketServer.js +++ b/src/socket/SocketServer.js @@ -390,6 +390,7 @@ class SocketServer extends WebSocketEvents { const { wait, coolDown, + pxlCnt, retCode, } = await drawSafeByOffsets( ws.user, @@ -397,7 +398,7 @@ class SocketServer extends WebSocketEvents { i, j, pixels, ); - ws.send(PixelReturn.dehydrate(retCode, wait, coolDown)); + ws.send(PixelReturn.dehydrate(retCode, wait, coolDown, pxlCnt)); break; } case RegisterCanvas.OP_CODE: { diff --git a/src/socket/packets/PixelReturn.js b/src/socket/packets/PixelReturn.js index 44002b7..53cf557 100644 --- a/src/socket/packets/PixelReturn.js +++ b/src/socket/packets/PixelReturn.js @@ -9,19 +9,22 @@ export default { const retCode = data.getUint8(1); const wait = data.getUint32(2); const coolDownSeconds = data.getInt16(6); + const pxlCnt = data.getUint8(8); return { retCode, wait, coolDownSeconds, + pxlCnt, }; }, - dehydrate(retCode, wait, coolDown): Buffer { - const buffer = Buffer.allocUnsafe(1 + 1 + 4 + 1 + 2); + dehydrate(retCode, wait, coolDown, pxlCnt): Buffer { + const buffer = Buffer.allocUnsafe(1 + 1 + 4 + 2 + 1); buffer.writeUInt8(OP_CODE, 0); buffer.writeUInt8(retCode, 1); buffer.writeUInt32BE(wait, 2); const coolDownSeconds = Math.round(coolDown / 1000); buffer.writeInt16BE(coolDownSeconds, 6); + buffer.writeUInt8(pxlCnt, 8); return buffer; }, }; diff --git a/src/socket/packets/PixelUpdateClient.js b/src/socket/packets/PixelUpdateClient.js index 2890c66..96b4790 100644 --- a/src/socket/packets/PixelUpdateClient.js +++ b/src/socket/packets/PixelUpdateClient.js @@ -29,7 +29,7 @@ export default { */ const pixels = []; let off = data.byteLength; - while (off >= 3) { + while (off > 3) { const color = data.getUint8(off -= 1); const offsetL = data.getUint16(off -= 2); const offsetH = data.getUint8(off -= 1) << 16; diff --git a/src/socket/packets/PixelUpdateServer.js b/src/socket/packets/PixelUpdateServer.js index 17344a1..ae83ca8 100644 --- a/src/socket/packets/PixelUpdateServer.js +++ b/src/socket/packets/PixelUpdateServer.js @@ -29,7 +29,7 @@ export default { * receive to 500 */ let pxlcnt = 0; - while (off >= 3 && pxlcnt < 500) { + while (off > 3 && pxlcnt < 500) { const color = data.readUInt8(off -= 1); const offsetL = data.readUInt16BE(off -= 2); const offsetH = data.readUInt8(off -= 1) << 16; @@ -46,7 +46,7 @@ export default { * @param pixels Buffer with offset and color of one or more pixels */ dehydrate(chunkId, pixels): Buffer { - const index = new Uint8Array([OP_CODE, chunkId >> 8, chunkId && 0xFF]); + const index = new Uint8Array([OP_CODE, chunkId >> 8, chunkId & 0xFF]); return Buffer.concat([index, pixels]); }, }; diff --git a/src/store/protocolClientHook.js b/src/store/protocolClientHook.js index 10485fe..16199a9 100644 --- a/src/store/protocolClientHook.js +++ b/src/store/protocolClientHook.js @@ -25,17 +25,6 @@ export default (store) => (next) => (action) => { break; } - case 'REQUEST_PLACE_PIXEL': { - const { - i, j, offset, color, - } = action; - ProtocolClient.requestPlacePixel( - i, j, offset, - color, - ); - break; - } - default: // nothing } diff --git a/src/ui/Renderer3D.js b/src/ui/Renderer3D.js index 0d3b2e5..f2704e1 100644 --- a/src/ui/Renderer3D.js +++ b/src/ui/Renderer3D.js @@ -19,9 +19,9 @@ import { } from '../core/constants'; import { setHover, - tryPlacePixel, selectColor, } from '../actions'; +import { tryPlacePixel } from './placePixel'; const renderDistance = 150; @@ -448,6 +448,8 @@ class Renderer { .add(intersect.face.normal.multiplyScalar(0.5)) .floor() .addScalar(0.5); + // TODO make rollOverMesh in a different color while placeAllowed false + // instead of hiding it.... we can now queue Voxels if (!placeAllowed || target.clone().sub(camera.position).length() > 50) { rollOverMesh.position.y = -10; @@ -470,9 +472,16 @@ class Renderer { selectedColor, } = state.canvas; const chClr = (color === null) ? selectedColor : color; + const curColor = (chClr === 0) ? this.chunkLoader.getVoxel(x, y, z) : 0; const [i, j] = getChunkOfPixel(canvasSize, x, y, z); const offset = getOffsetOfPixel(canvasSize, x, y, z); - store.dispatch(tryPlacePixel(i, j, offset, chClr)); + tryPlacePixel( + store, + i, j, + offset, + chClr, + curColor, + ); } multiTapEnd() { @@ -483,12 +492,6 @@ class Renderer { } = this; this.multitap = 0; const state = store.getState(); - const { - placeAllowed, - } = state.user; - if (!placeAllowed) { - return; - } const [px, py, pz] = mouseMoveStart; const [qx, qy, qz] = state.gui.hover; diff --git a/src/ui/placePixel.js b/src/ui/placePixel.js new file mode 100644 index 0000000..af8bb61 --- /dev/null +++ b/src/ui/placePixel.js @@ -0,0 +1,275 @@ +/* + * Place pixel via Websocket + * Always just one pixelrequest, queue additional requests to send later + * Pixels get predicted on the client and reset if server refused + * + * @flow + * */ +import { + notify, + setPlaceAllowed, + sweetAlert, + gotCoolDownDelta, + pixelFailure, + setWait, + placedPixel, + pixelWait, + updatePixel, +} from '../actions'; +import ProtocolClient from '../socket/ProtocolClient'; + +let pixelTimeout = null; +/* + * cache of pixels that still are to set + * [{i: i, j: j, pixels: [[offset, color],...]}, ...] + */ +let pixelQueue = []; +/* + * requests that got predicted on client and yet have to be + * received from the server + * [[i, j, offset, color], ...] + */ +let clientPredictions = []; +/* + * values of last request + * {i: i, j: j, pixels: [[offset, color], ...} + */ +let lastRequestValues = {}; + + +function requestFromQueue(store) { + if (!pixelQueue.length) { + pixelTimeout = null; + return; + } + + /* timeout to warn user when Websocket is dysfunctional */ + pixelTimeout = setTimeout(() => { + pixelQueue = []; + pixelTimeout = null; + store.dispatch(setPlaceAllowed(true)); + store.dispatch(sweetAlert( + 'Error :(', + 'Didn\'t get an answer from pixelplanet. Maybe try to refresh?', + 'error', + 'OK', + )); + }, 5000); + + lastRequestValues = pixelQueue.shift(); + const { i, j, pixels } = lastRequestValues; + ProtocolClient.requestPlacePixels(i, j, pixels); + store.dispatch(setPlaceAllowed(false)); + + // TODO: + // this is for resending after captcha returned + // window is ugly, put it into redux or something + window.pixel = { + i, + j, + pixels, + }; +} + +export function receivePixelUpdate( + store, + i: number, + j: number, + offset: number, + color: ColorIndex, +) { + for (let p = 0; p < clientPredictions; p += 1) { + const predPxl = clientPredictions[p]; + if (predPxl[0] === i + && predPxl[1] === j + && predPxl[2] === offset + ) { + clientPredictions.splice(i, 1); + return; + } + } + store.dispatch(updatePixel(i, j, offset, color)); +} + +/* + * Revert predictions starting at given pixel + * @param i, j, offset data of the first pixel that got rejected + */ +function revertPredictionsAt( + store, + sI: number, + sJ: number, + sOffset: number, +) { + let p = 0; + while (p < clientPredictions.length) { + const predPxl = clientPredictions[p]; + if (predPxl[0] === sI + && predPxl[1] === sJ + && predPxl[2] === sOffset + ) { + break; + } + p += 1; + } + + if (p >= clientPredictions.length) { + clientPredictions = []; + return; + } + + console.log( + `Reverting ${clientPredictions.length - p} client predictions`, + ); + + while (p < clientPredictions.length) { + const [i, j, offset, color] = clientPredictions[p]; + store.dispatch(updatePixel(i, j, offset, color)); + p += 1; + } + + clientPredictions = []; +} + +export function tryPlacePixel( + store, + i: number, + j: number, + offset: number, + color: ColorIndex, + curColor: ColorIndex, +) { + store.dispatch(updatePixel(i, j, offset, color)); + clientPredictions.push([i, j, offset, curColor]); + + if (pixelQueue.length) { + const lastReq = pixelQueue[pixelQueue.length - 1]; + const { i: lastI, j: lastJ } = lastReq; + if (i === lastI && j === lastJ) { + /* append to last request in queue if same chunk */ + lastReq.pixels.push([offset, color]); + } + return; + } + + pixelQueue.push({ + i, + j, + pixels: [[offset, color]], + }); + + if (!pixelTimeout) { + requestFromQueue(store); + } +} + +export function receivePixelReturn( + store, + retCode: number, + wait: number, + coolDownSeconds: number, + pxlCnt, +) { + clearTimeout(pixelTimeout); + + try { + /* + * the terms coolDown is used in a different meaning here + * coolDown is the delta seconds of the placed pixel + */ + if (wait) { + store.dispatch(setWait(wait)); + } + if (coolDownSeconds) { + store.dispatch(notify(coolDownSeconds)); + if (coolDownSeconds < 0) { + store.dispatch(gotCoolDownDelta(coolDownSeconds)); + } + } + + if (retCode) { + /* + * one or more pixels didn't get set, + * revert predictions and clean queue + */ + const { i, j, pixels } = lastRequestValues; + const [offset] = pixels[pxlCnt]; + revertPredictionsAt(store, i, j, offset); + pixelQueue = []; + } + + let errorTitle = null; + let msg = null; + switch (retCode) { + case 0: + store.dispatch(placedPixel()); + break; + case 1: + errorTitle = 'Invalid Canvas'; + msg = 'This canvas doesn\'t exist'; + break; + case 2: + errorTitle = 'Invalid Coordinates'; + msg = 'x out of bounds'; + break; + case 3: + errorTitle = 'Invalid Coordinates'; + msg = 'y out of bounds'; + break; + case 4: + errorTitle = 'Invalid Coordinates'; + msg = 'z out of bounds'; + break; + case 5: + errorTitle = 'Wrong Color'; + msg = 'Invalid color selected'; + break; + case 6: + errorTitle = 'Just for registered Users'; + msg = 'You have to be logged in to place on this canvas'; + break; + case 7: + errorTitle = 'Place more :)'; + // eslint-disable-next-line max-len + msg = 'You can not access this canvas yet. You need to place more pixels'; + break; + case 8: + store.dispatch(notify('Pixel protected!')); + break; + case 9: + // pixestack used up + store.dispatch(pixelWait()); + break; + case 10: + // captcha, reCaptcha or hCaptcha + if (typeof window.hcaptcha !== 'undefined') { + window.hcaptcha.execute(); + } else { + window.grecaptcha.execute(); + } + return; + case 11: + + errorTitle = 'No Proxies Allowed :('; + msg = 'You are using a Proxy.'; + break; + default: + errorTitle = 'Weird'; + msg = 'Couldn\'t set Pixel'; + } + if (msg) { + store.dispatch(pixelFailure()); + store.dispatch(sweetAlert( + (errorTitle || `Error ${retCode}`), + msg, + 'error', + 'OK', + )); + } + } finally { + store.dispatch(setPlaceAllowed(true)); + /* start next request if queue isn't empty */ + requestFromQueue(store); + } +} +