pixel burst client side

add client prediction
This commit is contained in:
HF 2021-01-26 22:25:13 +01:00
parent 277568fc9c
commit 1735643b32
14 changed files with 333 additions and 202 deletions

View File

@ -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 { export function setViewCoordinates(view: Cell): Action {
return { return {
type: 'SET_VIEW_COORDINATES', type: 'SET_VIEW_COORDINATES',
@ -415,7 +282,6 @@ export function moveEast(): ThunkAction {
}; };
} }
export function setScale(scale: number, zoompoint: Cell): Action { export function setScale(scale: number, zoompoint: Cell): Action {
return { return {
type: 'SET_SCALE', type: 'SET_SCALE',
@ -484,7 +350,7 @@ export function receiveCoolDown(
}; };
} }
export function receivePixelUpdate( export function updatePixel(
i: number, i: number,
j: number, j: number,
offset: number, offset: number,
@ -668,18 +534,6 @@ function getPendingActions(state): Array<Action> {
else actions.push(endCoolDown()); 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; return actions;
} }
@ -849,6 +703,13 @@ export function startDm(query): PromiseAction {
}; };
} }
export function gotCoolDownDelta(delta: number) {
return {
type: 'COOLDOWN_DELTA',
delta,
};
}
export function setUserBlock( export function setUserBlock(
userId: number, userId: number,
userName: string, userName: string,

View File

@ -39,12 +39,6 @@ export type Action =
| { type: 'COOLDOWN_DELTA', delta: number } | { type: 'COOLDOWN_DELTA', delta: number }
| { type: 'SELECT_COLOR', color: ColorIndex } | { type: 'SELECT_COLOR', color: ColorIndex }
| { type: 'SELECT_CANVAS', canvasId: number } | { type: 'SELECT_CANVAS', canvasId: number }
| { type: 'REQUEST_PLACE_PIXEL',
i: number,
j: number,
offset: number,
color: ColorIndex,
}
| { type: 'PLACE_PIXEL' } | { type: 'PLACE_PIXEL' }
| { type: 'PIXEL_WAIT' } | { type: 'PIXEL_WAIT' }
| { type: 'PIXEL_FAILURE' } | { type: 'PIXEL_FAILURE' }

View File

@ -7,7 +7,6 @@ import './styles/font.css';
import onKeyPress from './controls/keypress'; import onKeyPress from './controls/keypress';
import { import {
receivePixelUpdate,
fetchMe, fetchMe,
fetchStats, fetchStats,
initTimer, initTimer,
@ -15,12 +14,14 @@ import {
receiveOnline, receiveOnline,
receiveCoolDown, receiveCoolDown,
receiveChatMessage, receiveChatMessage,
receivePixelReturn,
addChatChannel, addChatChannel,
removeChatChannel, removeChatChannel,
setMobile, setMobile,
tryPlacePixel,
} from './actions'; } from './actions';
import {
receivePixelUpdate,
receivePixelReturn,
} from './ui/placePixel';
import store from './ui/store'; import store from './ui/store';
@ -33,15 +34,18 @@ function init() {
initRenderer(store, false); initRenderer(store, false);
ProtocolClient.on('pixelUpdate', ({ ProtocolClient.on('pixelUpdate', ({
i, j, offset, color, i, j, pixels,
}) => { }) => {
// remove protection pixels.forEach((pxl) => {
store.dispatch(receivePixelUpdate(i, j, offset, color & 0x7F)); const [offset, color] = pxl;
// remove protection
receivePixelUpdate(store, i, j, offset, color & 0x7F);
});
}); });
ProtocolClient.on('pixelReturn', ({ 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) => { ProtocolClient.on('cooldownPacket', (coolDown) => {
store.dispatch(receiveCoolDown(coolDown)); store.dispatch(receiveCoolDown(coolDown));
@ -145,16 +149,13 @@ window.onCaptcha = async function onCaptcha(token: string) {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body, body,
// https://github.com/github/fetch/issues/349
credentials: 'include', credentials: 'include',
}); });
if (window.pixel) { const {
const { i, j, pixels,
i, j, offset, color, } = window.pixel;
} = window.pixel; ProtocolClient.requestPlacePixels(i, j, pixels);
store.dispatch(tryPlacePixel(i, j, offset, color));
}
if (typeof window.hcaptcha !== 'undefined') { if (typeof window.hcaptcha !== 'undefined') {
window.hcaptcha.reset(); window.hcaptcha.reset();

View File

@ -7,7 +7,6 @@
import keycode from 'keycode'; import keycode from 'keycode';
import { import {
tryPlacePixel,
setHover, setHover,
unsetHover, unsetHover,
setViewCoordinates, setViewCoordinates,
@ -21,6 +20,9 @@ import {
moveEast, moveEast,
onViewFinishChange, onViewFinishChange,
} from '../actions'; } from '../actions';
import {
tryPlacePixel,
} from '../ui/placePixel';
import { import {
screenToWorld, screenToWorld,
getChunkOfPixel, getChunkOfPixel,
@ -162,7 +164,6 @@ class PixelPlainterControls {
static placePixel(store, renderer, cell) { static placePixel(store, renderer, cell) {
const state = store.getState(); const state = store.getState();
const { autoZoomIn } = state.gui; const { autoZoomIn } = state.gui;
const { placeAllowed } = state.user;
const { const {
scale, scale,
isHistoricalView, isHistoricalView,
@ -180,16 +181,17 @@ class PixelPlainterControls {
// allow placing of pixel just on low zoomlevels // allow placing of pixel just on low zoomlevels
if (scale < 3) return; if (scale < 3) return;
if (!placeAllowed) return; const curColor = renderer.getColorIndexOfPixel(...cell);
if (selectedColor !== curColor) {
if (selectedColor !== renderer.getColorIndexOfPixel(...cell)) {
const { canvasSize } = state.canvas; const { canvasSize } = state.canvas;
const [i, j] = getChunkOfPixel(canvasSize, ...cell); const [i, j] = getChunkOfPixel(canvasSize, ...cell);
const offset = getOffsetOfPixel(canvasSize, ...cell); const offset = getOffsetOfPixel(canvasSize, ...cell);
store.dispatch(tryPlacePixel( tryPlacePixel(
store,
i, j, offset, i, j, offset,
selectedColor, selectedColor,
)); curColor,
);
} }
} }

View File

@ -54,7 +54,7 @@ class PixelCache {
this.PXL_CACHE = new Map(); this.PXL_CACHE = new Map();
cache.forEach((pxls, chunkCanvasId) => { cache.forEach((pxls, chunkCanvasId) => {
const canvasId = chunkCanvasId & 0xFF0000 >> 16; const canvasId = (chunkCanvasId & 0xFF0000) >> 16;
const chunkId = chunkCanvasId & 0x00FFFF; const chunkId = chunkCanvasId & 0x00FFFF;
webSockets.broadcastPixels(canvasId, chunkId, pxls); webSockets.broadcastPixels(canvasId, chunkId, pxls);
}); });

View File

@ -51,6 +51,7 @@ export async function drawByOffsets(
return { return {
wait, wait,
coolDown, coolDown,
pxlCnt,
retCode: 1, retCode: 1,
}; };
} }
@ -177,6 +178,7 @@ export async function drawByOffsets(
return { return {
wait, wait,
coolDown, coolDown,
pxlCnt,
retCode, retCode,
}; };
} }

View File

@ -128,7 +128,7 @@ class ProtocolClient extends EventEmitter {
* @param i, j chunk coordinates * @param i, j chunk coordinates
* @param pixel Array of [[offset, color],...] pixels within chunk * @param pixel Array of [[offset, color],...] pixels within chunk
*/ */
requestPlacePixel( requestPlacePixels(
i: number, j: number, i: number, j: number,
pixels: Array, pixels: Array,
) { ) {

View File

@ -390,6 +390,7 @@ class SocketServer extends WebSocketEvents {
const { const {
wait, wait,
coolDown, coolDown,
pxlCnt,
retCode, retCode,
} = await drawSafeByOffsets( } = await drawSafeByOffsets(
ws.user, ws.user,
@ -397,7 +398,7 @@ class SocketServer extends WebSocketEvents {
i, j, i, j,
pixels, pixels,
); );
ws.send(PixelReturn.dehydrate(retCode, wait, coolDown)); ws.send(PixelReturn.dehydrate(retCode, wait, coolDown, pxlCnt));
break; break;
} }
case RegisterCanvas.OP_CODE: { case RegisterCanvas.OP_CODE: {

View File

@ -9,19 +9,22 @@ export default {
const retCode = data.getUint8(1); const retCode = data.getUint8(1);
const wait = data.getUint32(2); const wait = data.getUint32(2);
const coolDownSeconds = data.getInt16(6); const coolDownSeconds = data.getInt16(6);
const pxlCnt = data.getUint8(8);
return { return {
retCode, retCode,
wait, wait,
coolDownSeconds, coolDownSeconds,
pxlCnt,
}; };
}, },
dehydrate(retCode, wait, coolDown): Buffer { dehydrate(retCode, wait, coolDown, pxlCnt): Buffer {
const buffer = Buffer.allocUnsafe(1 + 1 + 4 + 1 + 2); const buffer = Buffer.allocUnsafe(1 + 1 + 4 + 2 + 1);
buffer.writeUInt8(OP_CODE, 0); buffer.writeUInt8(OP_CODE, 0);
buffer.writeUInt8(retCode, 1); buffer.writeUInt8(retCode, 1);
buffer.writeUInt32BE(wait, 2); buffer.writeUInt32BE(wait, 2);
const coolDownSeconds = Math.round(coolDown / 1000); const coolDownSeconds = Math.round(coolDown / 1000);
buffer.writeInt16BE(coolDownSeconds, 6); buffer.writeInt16BE(coolDownSeconds, 6);
buffer.writeUInt8(pxlCnt, 8);
return buffer; return buffer;
}, },
}; };

View File

@ -29,7 +29,7 @@ export default {
*/ */
const pixels = []; const pixels = [];
let off = data.byteLength; let off = data.byteLength;
while (off >= 3) { while (off > 3) {
const color = data.getUint8(off -= 1); const color = data.getUint8(off -= 1);
const offsetL = data.getUint16(off -= 2); const offsetL = data.getUint16(off -= 2);
const offsetH = data.getUint8(off -= 1) << 16; const offsetH = data.getUint8(off -= 1) << 16;

View File

@ -29,7 +29,7 @@ export default {
* receive to 500 * receive to 500
*/ */
let pxlcnt = 0; let pxlcnt = 0;
while (off >= 3 && pxlcnt < 500) { while (off > 3 && pxlcnt < 500) {
const color = data.readUInt8(off -= 1); const color = data.readUInt8(off -= 1);
const offsetL = data.readUInt16BE(off -= 2); const offsetL = data.readUInt16BE(off -= 2);
const offsetH = data.readUInt8(off -= 1) << 16; 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 * @param pixels Buffer with offset and color of one or more pixels
*/ */
dehydrate(chunkId, pixels): Buffer { 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]); return Buffer.concat([index, pixels]);
}, },
}; };

View File

@ -25,17 +25,6 @@ export default (store) => (next) => (action) => {
break; break;
} }
case 'REQUEST_PLACE_PIXEL': {
const {
i, j, offset, color,
} = action;
ProtocolClient.requestPlacePixel(
i, j, offset,
color,
);
break;
}
default: default:
// nothing // nothing
} }

View File

@ -19,9 +19,9 @@ import {
} from '../core/constants'; } from '../core/constants';
import { import {
setHover, setHover,
tryPlacePixel,
selectColor, selectColor,
} from '../actions'; } from '../actions';
import { tryPlacePixel } from './placePixel';
const renderDistance = 150; const renderDistance = 150;
@ -448,6 +448,8 @@ class Renderer {
.add(intersect.face.normal.multiplyScalar(0.5)) .add(intersect.face.normal.multiplyScalar(0.5))
.floor() .floor()
.addScalar(0.5); .addScalar(0.5);
// TODO make rollOverMesh in a different color while placeAllowed false
// instead of hiding it.... we can now queue Voxels
if (!placeAllowed if (!placeAllowed
|| target.clone().sub(camera.position).length() > 50) { || target.clone().sub(camera.position).length() > 50) {
rollOverMesh.position.y = -10; rollOverMesh.position.y = -10;
@ -470,9 +472,16 @@ class Renderer {
selectedColor, selectedColor,
} = state.canvas; } = state.canvas;
const chClr = (color === null) ? selectedColor : color; 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 [i, j] = getChunkOfPixel(canvasSize, x, y, z);
const offset = getOffsetOfPixel(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() { multiTapEnd() {
@ -483,12 +492,6 @@ class Renderer {
} = this; } = this;
this.multitap = 0; this.multitap = 0;
const state = store.getState(); const state = store.getState();
const {
placeAllowed,
} = state.user;
if (!placeAllowed) {
return;
}
const [px, py, pz] = mouseMoveStart; const [px, py, pz] = mouseMoveStart;
const [qx, qy, qz] = state.gui.hover; const [qx, qy, qz] = state.gui.hover;

275
src/ui/placePixel.js Normal file
View File

@ -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);
}
}