server reconciliation OK (a little rubberbanding)

This commit is contained in:
LuckyLaszlo
2022-11-23 10:43:12 +01:00
parent 2b9058ad49
commit 7d5895a6cc
10 changed files with 110 additions and 33 deletions

View File

@@ -2,12 +2,12 @@
import * as c from ".././constants.js" import * as c from ".././constants.js"
class GameArea { class GameArea {
keys: string[]; keys: string[] = [];
interval: number = 0; handleInputInterval: number = 0;
gameLoopInterval: number = 0;
canvas: HTMLCanvasElement; canvas: HTMLCanvasElement;
ctx: CanvasRenderingContext2D; ctx: CanvasRenderingContext2D;
constructor() { constructor() {
this.keys = [];
this.canvas = document.createElement("canvas"); this.canvas = document.createElement("canvas");
this.ctx = this.canvas.getContext("2d") as CanvasRenderingContext2D; this.ctx = this.canvas.getContext("2d") as CanvasRenderingContext2D;
this.canvas.width = c.CanvasWidth; this.canvas.width = c.CanvasWidth;

View File

@@ -7,6 +7,8 @@ export const scoreSize = Math.floor(w/16);
export const gridSize = Math.floor(w/500); export const gridSize = Math.floor(w/500);
// min interval on Firefox seems to be 15. Chrome can go lower. // min interval on Firefox seems to be 15. Chrome can go lower.
export const handleInputIntervalMS = 15; // millisecond
export const gameLoopIntervalMS = 15; // millisecond export const gameLoopIntervalMS = 15; // millisecond
export const soundRobloxVolume = 0.3; // between 0 and 1
export const soundPongVolume = 0.3; // between 0 and 1 export const soundRobloxVolume = 0; // between 0 and 1
export const soundPongVolume = 0; // between 0 and 1

View File

@@ -1,6 +1,5 @@
import {gc} from "./global.js"; import {gc} from "./global.js";
import * as d from "./draw.js"; import * as d from "./draw.js";
import {handleInput} from "./handleInput.js";
let actual_time: number = Date.now(); let actual_time: number = Date.now();
let last_time: number; let last_time: number;
@@ -17,9 +16,7 @@ function gameLoop()
actual_time = Date.now(); actual_time = Date.now();
delta_time = (actual_time - last_time) / 1000; delta_time = (actual_time - last_time) / 1000;
handleInput(delta_time); // client prediction
// prediction
gc.ball.moveAndBounce(delta_time, [gc.wallTop, gc.wallBottom, gc.playerLeft, gc.playerRight]); gc.ball.moveAndBounce(delta_time, [gc.wallTop, gc.wallBottom, gc.playerLeft, gc.playerRight]);
d.draw(); d.draw();

View File

@@ -1,13 +1,42 @@
import {pong, gc, clientInfo} from "./global.js" import {pong, gc, clientInfo} from "./global.js"
import * as d from "./draw.js"; import * as d from "./draw.js";
import { socket } from "./ws.js"; import { socket } from "./ws.js";
import {InputEnum} from "../shared_js/enums.js" import * as ev from "../shared_js/class/Event.js"
import {EventInput} from "../shared_js/class/Event.js" import * as en from "../shared_js/enums.js"
export let gridDisplay = false; export let gridDisplay = false;
function handleInput(delta: number) let actual_time: number = Date.now();
let last_time: number;
let delta_time: number;
class InputHistory {
input: en.InputEnum;
inputId: number;
deltaTime: number;
constructor(input: en.InputEnum, inputId: number, deltaTime: number) {
this.input = input;
this.inputId = inputId;
this.deltaTime = deltaTime;
}
}
const inputHistoryArr: InputHistory[] = [];
let id = 0;
/* idMax should be high enough to prevent duplicate "id" in "inputHistoryArr".
In theory a little more than (1000/handleInputIntervalMS) should be enough. */
const idMax = 999; // 999 arbitrary
function handleInput()
{ {
console.log("handleInput");
last_time = actual_time;
actual_time = Date.now();
delta_time = (actual_time - last_time) / 1000;
if (id > idMax) {
id = 0;
}
var keys = pong.keys; var keys = pong.keys;
if (keys.length == 0) if (keys.length == 0)
return; return;
@@ -22,28 +51,63 @@ function handleInput(delta: number)
gridDisplay = !gridDisplay; gridDisplay = !gridDisplay;
pong.deleteKey("g"); pong.deleteKey("g");
} }
playerMove(delta, keys); playerMove(delta_time, keys);
} }
function playerMove(delta: number, keys: string[]) function playerMove(delta: number, keys: string[])
{ {
if (keys.indexOf("w") != -1 || keys.indexOf("ArrowUp".toLowerCase()) != -1) { if (keys.indexOf("w") !== -1 || keys.indexOf("ArrowUp".toLowerCase()) !== -1) {
socket.send(JSON.stringify(new EventInput(InputEnum.up))); if (keys.indexOf("s") === -1 && keys.indexOf("ArrowDown".toLowerCase()) === -1) {
const input = new ev.EventInput(en.InputEnum.up, ++id);
inputHistoryArr.push(new InputHistory(input.input, input.inputId, delta));
socket.send(JSON.stringify(input));
playerMovePrediction(delta, input.input); // client prediction
}
} }
if (keys.indexOf("s") != -1 || keys.indexOf("ArrowDown".toLowerCase()) != -1) { else if (keys.indexOf("s") !== -1 || keys.indexOf("ArrowDown".toLowerCase()) !== -1) {
socket.send(JSON.stringify(new EventInput(InputEnum.down))); const input = new ev.EventInput(en.InputEnum.down, ++id);
inputHistoryArr.push(new InputHistory(input.input, input.inputId, delta));
socket.send(JSON.stringify(input));
playerMovePrediction(delta, input.input); // client prediction
} }
}
// prediction function playerMovePrediction(delta: number, input: en.InputEnum)
{
// client prediction
const racket = clientInfo.racket; const racket = clientInfo.racket;
racket.dir.y = 0; racket.dir.y = 0;
if (keys.indexOf("w") != -1 || keys.indexOf("ArrowUp".toLowerCase()) != -1) { if (input === en.InputEnum.up) {
racket.dir.y += -1; racket.dir.y += -1;
} }
if (keys.indexOf("s") != -1 || keys.indexOf("ArrowDown".toLowerCase()) != -1) { else if (input === en.InputEnum.down) {
racket.dir.y += 1; racket.dir.y += 1;
} }
racket.moveAndCollide(delta, [gc.wallTop, gc.wallBottom]); racket.moveAndCollide(delta, [gc.wallTop, gc.wallBottom]);
} }
export {handleInput} function repeatInput(lastInputId: number)
{
// server reconciliation
let i = inputHistoryArr.findIndex((value: InputHistory) => {
if (value.inputId === lastInputId) {
return true;
}
return false;
});
// console.log("repeatInput, lastInputId = " + lastInputId);
// console.log("repeatInput, before splice up to index " + i);
// console.log(inputHistoryArr);
inputHistoryArr.splice(0, i+1);
// console.log("repeatInput, after splice");
// console.log(inputHistoryArr);
inputHistoryArr.forEach((value: InputHistory) => {
playerMovePrediction(value.deltaTime, value.input);
});
}
export {handleInput, repeatInput}

View File

@@ -5,6 +5,7 @@ import {gameLoop} from "./gameLoop.js"
import * as c from "./constants.js" import * as c from "./constants.js"
import { GameComponentsClient } from "./class/GameComponentsClient.js"; import { GameComponentsClient } from "./class/GameComponentsClient.js";
import {countdown} from "./utils.js"; import {countdown} from "./utils.js";
import {handleInput} from "./handleInput.js";
import {socket} from "./ws.js"; socket; // no-op import {socket} from "./ws.js"; socket; // no-op
@@ -38,7 +39,8 @@ function resumeGame()
window.addEventListener('keyup', function (e) { window.addEventListener('keyup', function (e) {
pong.deleteKey(e.key); pong.deleteKey(e.key);
}); });
pong.interval = window.setInterval(gameLoop, c.gameLoopIntervalMS); pong.handleInputInterval = window.setInterval(handleInput, c.handleInputIntervalMS);
pong.gameLoopInterval = window.setInterval(gameLoop, c.gameLoopIntervalMS);
} }

View File

@@ -3,10 +3,11 @@ import {pong, gc} from "./global.js"
import * as ev from "../shared_js/class/Event.js" import * as ev from "../shared_js/class/Event.js"
import {matchmaking, matchmakingComplete, startGame} from "./pong.js"; import {matchmaking, matchmakingComplete, startGame} from "./pong.js";
import * as en from "../shared_js/enums.js" import * as en from "../shared_js/enums.js"
import { Racket } from "../shared_js/class/Rectangle.js"; import { RacketClient } from "./class/RectangleClient.js";
import { sleep } from "./utils.js"; import { sleep } from "./utils.js";
import * as c from "./constants.js" import * as c from "./constants.js"
import {soundRoblox} from "./audio.js" import {soundRoblox} from "./audio.js"
import { repeatInput } from "./handleInput.js";
const wsPort = 8042; const wsPort = 8042;
const wsUrl = "ws://" + document.location.hostname + ":" + wsPort + "/pong"; const wsUrl = "ws://" + document.location.hostname + ":" + wsPort + "/pong";
@@ -15,7 +16,7 @@ const socket = new WebSocket(wsUrl, "json");
class ClientInfo { class ClientInfo {
id = ""; id = "";
side: en.PlayerSide; side: en.PlayerSide;
racket: Racket; racket: RacketClient;
} }
export const clientInfo = new ClientInfo(); export const clientInfo = new ClientInfo();
@@ -24,7 +25,7 @@ socket.addEventListener("open", (event) => {
socket.send(JSON.stringify( new ev.ClientAnnounce(en.ClientRole.player, clientInfo.id) )); socket.send(JSON.stringify( new ev.ClientAnnounce(en.ClientRole.player, clientInfo.id) ));
}); });
socket.addEventListener("message", logListener); // socket.addEventListener("message", logListener); // for testing purpose
socket.addEventListener("message", preMatchListener); socket.addEventListener("message", preMatchListener);
function logListener(this: WebSocket, event: MessageEvent) { function logListener(this: WebSocket, event: MessageEvent) {
@@ -48,6 +49,7 @@ function preMatchListener(this: WebSocket, event: MessageEvent) {
else if (clientInfo.side === en.PlayerSide.right) { else if (clientInfo.side === en.PlayerSide.right) {
clientInfo.racket = gc.playerRight; clientInfo.racket = gc.playerRight;
} }
clientInfo.racket.color = "darkgreen"; // for testing purpose
socket.send(JSON.stringify( new ev.ClientEvent(en.EventTypes.clientPlayerReady) )); socket.send(JSON.stringify( new ev.ClientEvent(en.EventTypes.clientPlayerReady) ));
matchmakingComplete(); matchmakingComplete();
break; break;
@@ -65,6 +67,7 @@ function inGameListener(event: MessageEvent)
switch (data.type) { switch (data.type) {
case en.EventTypes.gameUpdate: case en.EventTypes.gameUpdate:
console.log("gameUpdate"); console.log("gameUpdate");
// setTimeout(gameUpdate, 1000, data as ev.EventGameUpdate); // artificial latency for testing purpose
gameUpdate(data as ev.EventGameUpdate); gameUpdate(data as ev.EventGameUpdate);
break; break;
case en.EventTypes.scoreUpdate: case en.EventTypes.scoreUpdate:
@@ -78,14 +81,15 @@ function inGameListener(event: MessageEvent)
} }
} }
async function gameUpdate(data: ev.EventGameUpdate) function gameUpdate(data: ev.EventGameUpdate)
{ {
// await sleep(1000); // artificial latency for testing purpose
gc.playerLeft.pos.y = Math.floor(data.playerLeft.y); gc.playerLeft.pos.y = Math.floor(data.playerLeft.y);
gc.playerRight.pos.y = Math.floor(data.playerRight.y); gc.playerRight.pos.y = Math.floor(data.playerRight.y);
gc.ball.pos.x = Math.floor(data.ball.x); gc.ball.pos.x = Math.floor(data.ball.x);
gc.ball.pos.y = Math.floor(data.ball.y); gc.ball.pos.y = Math.floor(data.ball.y);
gc.ball.speed = Math.floor(data.ball.speed); gc.ball.speed = Math.floor(data.ball.speed);
repeatInput(data.lastInputId); // server reconciliation
} }
function scoreUpdate(data: ev.EventScoreUpdate) function scoreUpdate(data: ev.EventScoreUpdate)

View File

@@ -6,12 +6,12 @@ import { GameSession } from "./GameSession.js";
class Client { class Client {
socket: WebSocket; socket: WebSocket;
id: string; // Pas indispensable si "socket" a une copie de "id" id: string; // Pas indispensable si "socket" a une copie de "id"
isAlive: boolean; lastInputId: number = 0;
isAlive: boolean = true;
gameSession: GameSession; gameSession: GameSession;
constructor(socket: WebSocket, id: string) { constructor(socket: WebSocket, id: string) {
this.socket = socket; this.socket = socket;
this.id = id; this.id = id;
this.isAlive = true;
} }
} }

View File

@@ -50,8 +50,11 @@ class GameSession {
clearInterval(s.gameLoopInterval); clearInterval(s.gameLoopInterval);
clearInterval(s.clientsUpdateInterval); clearInterval(s.clientsUpdateInterval);
} }
handleInput(client: ClientPlayer, input: en.InputEnum) { handleInput(client: ClientPlayer, inputEvent: ev.EventInput) {
const gc = this.components; const gc = this.components;
const input = inputEvent.input;
client.lastInputId = inputEvent.inputId;
client.racket.dir.y = 0; client.racket.dir.y = 0;
if (input === en.InputEnum.up) { if (input === en.InputEnum.up) {
client.racket.dir.y += -1; client.racket.dir.y += -1;
@@ -96,9 +99,11 @@ class GameSession {
type: en.EventTypes.gameUpdate, type: en.EventTypes.gameUpdate,
playerLeft: {y: gc.playerLeft.pos.y}, playerLeft: {y: gc.playerLeft.pos.y},
playerRight: {y: gc.playerRight.pos.y}, playerRight: {y: gc.playerRight.pos.y},
ball: {x: gc.ball.pos.x, y: gc.ball.pos.y, speed: gc.ball.speed} ball: {x: gc.ball.pos.x, y: gc.ball.pos.y, speed: gc.ball.speed},
lastInputId: 0
}; };
s.playersMap.forEach( (client) => { s.playersMap.forEach( (client) => {
update.lastInputId = client.lastInputId;
client.socket.send(JSON.stringify(update)); client.socket.send(JSON.stringify(update));
}); });
} }

View File

@@ -151,7 +151,7 @@ export function clientInputListener(this: WebSocket, data: string)
const input: ev.EventInput = JSON.parse(data); const input: ev.EventInput = JSON.parse(data);
if (input.type === en.EventTypes.clientInput) { if (input.type === en.EventTypes.clientInput) {
const client = clientsMap.get(this.id); const client = clientsMap.get(this.id);
client.gameSession.handleInput(client as ClientPlayer, input.input); client.gameSession.handleInput(client as ClientPlayer, input);
} }
else { else {
console.log("Invalid clientInput"); console.log("Invalid clientInput");

View File

@@ -29,6 +29,7 @@ class EventGameUpdate extends ServerEvent {
playerLeft = {y: 0}; playerLeft = {y: 0};
playerRight = {y: 0}; playerRight = {y: 0};
ball = {x: 0, y: 0, speed: 0}; ball = {x: 0, y: 0, speed: 0};
lastInputId = 0;
constructor() { // TODO: constructor that take GameComponentsServer maybe ? constructor() { // TODO: constructor that take GameComponentsServer maybe ?
super(en.EventTypes.gameUpdate); super(en.EventTypes.gameUpdate);
} }
@@ -73,9 +74,11 @@ class ClientAnnounce extends ClientEvent {
class EventInput extends ClientEvent { class EventInput extends ClientEvent {
input: en.InputEnum; input: en.InputEnum;
constructor(input: en.InputEnum = 0) { inputId: number;
constructor(input: en.InputEnum = 0, inputId: number = 0) {
super(en.EventTypes.clientInput); super(en.EventTypes.clientInput);
this.input = input; this.input = input;
this.inputId = inputId;
} }
} }