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 {
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<Action> {
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,

View File

@ -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' }

View File

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

View File

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

View File

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

View File

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

View File

@ -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,
) {

View File

@ -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: {

View File

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

View File

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

View File

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

View File

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

View File

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

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