feat(pong): reworked pong to use sane system as ttt

This commit is contained in:
Maieul BOYER 2026-01-02 15:53:54 +01:00 committed by Maix0
parent afd79e334c
commit 0b15fd897b
20 changed files with 637 additions and 707 deletions

View file

@ -1,19 +0,0 @@
import { color } from './pong';
/**
* function adds a message to the frontend pongMessage
* ATTENTION send inner HTML ******
* @param text
* @returns
*/
export function addPongMessage(text: string) {
const pongMessage = document.getElementById("system-box") as HTMLDivElement;
if (!pongMessage) return;
const messageElement = document.createElement("div-test");
messageElement.innerHTML = text;
pongMessage.appendChild(messageElement);
pongMessage.scrollTop = pongMessage.scrollHeight;
console.log(`%c DEBUG LOG: Added PONG new message:%c ${text}`, color.red, color.reset);
return ;
};

View file

@ -1,28 +0,0 @@
import { addPongMessage } from "./addPongMessage";
import { Socket } from 'socket.io-client';
import { getUser } from "@app/auth";
/**
* function sends socket.emit to the backend to active and a broadcast message to all sockets
* echos addPongMessage() the message with addMessage to the sender
* @param socket
* @param msgCommand
*/
export function broadcastMsg (socket: Socket, msgCommand: string[]): void {
let msgText = msgCommand[1] ?? "";
addPongMessage(msgText);
const user = getUser();
if (user && socket?.connected) {
const message = {
command: msgCommand,
destination: '',
type: "chat",
user: user.name,
token: document.cookie,
text: msgText,
timestamp: Date.now(),
SenderWindowID: socket.id,
};
socket.emit('message', JSON.stringify(message));
}
};

View file

@ -1,9 +0,0 @@
import { getUser } from "@app/auth";
import type { User } from '@app/auth'
/**
* function checks if logged in
* @returns either user | null
*/
export function isLoggedIn(): User | null {
return getUser() || null;
};

View file

@ -30,7 +30,7 @@
<div id="batleft" class="pong-batleft bg-amber-400 top-0"></div> <div id="batleft" class="pong-batleft bg-amber-400 top-0"></div>
<div class="pong-center-line"></div> <div class="pong-center-line"></div>
<div id="batright" class="pong-batright bg-amber-400 top-0"></div> <div id="batright" class="pong-batright bg-amber-400 top-0"></div>
<div id="ball" class="w-8 h-8 rounded-full border-4 bg-white border-gray-400"></div> <div id="ball" class="rounded-full border-4 bg-white border-gray-400"></div>
</div> </div>
</div> </div>
</div> </div>

View file

@ -1,276 +1,36 @@
import { addRoute, setTitle, type RouteHandlerParams, type RouteHandlerReturn } from "@app/routing"; import { addRoute, setTitle, type RouteHandlerParams, type RouteHandlerReturn } from "@app/routing";
import { showError } from "@app/toast";
import authHtml from './pong.html?raw'; import authHtml from './pong.html?raw';
import client from '@app/api' import io from 'socket.io-client';
import { getUser, updateUser } from "@app/auth"; import type { CSocket, GameMove, GameUpdate } from "./socket";
import io, { Socket } from 'socket.io-client'; import { showError, showInfo } from "@app/toast";
import { addPongMessage } from './addPongMessage';
import { isLoggedIn } from './isLoggedIn';
import type { ClientMessage, ClientProfil } from './types_front';
import { isNullish } from "@app/utils";
export const color = {
red: 'color: red;',
green: 'color: green;',
yellow: 'color: orange;',
blue: 'color: blue;',
reset: '',
};
// TODO: local game (2player -> server -> 2player : current setup) // TODO: local game (2player -> server -> 2player : current setup)
// TODO: tournament via remote (dedicated queu? idk) // TODO: tournament via remote (dedicated queu? idk)
// //
// get the name of the machine used to connect // get the name of the machine used to connect
const machineHostName = window.location.hostname; declare module 'ft_state' {
console.log('connect to login at %chttps://' + machineHostName + ':8888/app/login',color.yellow); interface State {
pongSock?: CSocket;
}
}
export let __socket: Socket | undefined = undefined; document.addEventListener("ft:pageChange", () => {
if (window.__state.pongSock !== undefined) window.__state.pongSock.close();
document.addEventListener('ft:pageChange', () => { // dont regen socket on page change from forward/backward navigation arrows window.__state.pongSock = undefined;
if (__socket !== undefined)
__socket.close();
__socket = undefined;
console.log("Page changed");
}); });
/** export function getSocket(): CSocket {
* @returns the initialized socket if (window.__state.pongSock === undefined)
*/ window.__state.pongSock = io(window.location.host, { path: "/api/pong/socket.io/" }) as any as CSocket;
export function getSocket(): Socket { return window.__state.pongSock;
let addressHost = `wss://${machineHostName}:8888`;
if (__socket === undefined)
__socket = io(addressHost, {
path: "/api/pong/socket.io/",
secure: false,
transports: ["websocket"],
});
return __socket;
};
/**
*
* @param socket The socket to wait for
* @returns voir or a promise<void>
*/
function waitSocketConnected(socket: Socket): Promise<void> {
return new Promise(resolve => {
if (socket.connected) return resolve();
socket.on("connect", () => resolve());
});
};
/**
*
* @param socket The socket to communicat
* @returns nothing
*/
async function whoami(socket: Socket) {
try {
const chatWindow = document.getElementById("t-chatbox") as HTMLDivElement;
const res = await client.guestLogin();
switch (res.kind) {
case 'success': {
let user = await updateUser();
if (chatWindow) {
socket.emit('updateClientName', {
oldUser: '',
user: user?.name
});
} }
if (user === null)
return showError('Failed to get user: no user ?');
setTitle(`Welcome ${user.guest ? '[GUEST] ' : ''}${user.name}`);
break;
}
case 'failed': {
showError(`Failed to login: ${res.msg}`);
}
}
} catch (e) {
console.error("Login error:", e);
showError('Failed to login: Unknown error');
}
};
function pongClient(_url: string, _args: RouteHandlerParams): RouteHandlerReturn { function pongClient(_url: string, _args: RouteHandlerParams): RouteHandlerReturn {
let socket = getSocket();
socket.on("connect", async () => {
const systemWindow = document.getElementById('system-box') as HTMLDivElement;
await waitSocketConnected(socket);
console.log("I AM Connected to the server:", socket.id);
// const message = {
// command: "",
// destination: 'system-info',
// type: "chat",
// user: getUser()?.name,
// token: document.cookie ?? "",
// text: " has Just ARRIVED in the chat",
// timestamp: Date.now(),
// SenderWindowID: socket.id,
// };
// socket.emit('message', JSON.stringify(message));
const messageElement = document.createElement("div");
// messageElement.textContent = `${message.user}: is connected au server`;
messageElement.textContent = `${getUser()?.name ?? "unkown user"}: is connected au server`;
systemWindow.appendChild(messageElement);
systemWindow.scrollTop = systemWindow.scrollHeight;
});
// Queu handler
async function joinQueu(socket : Socket) {
try {
const res = await client.guestLogin();
switch (res.kind) {
case 'success': {
let user = await updateUser();
if (user === null)
return showError('Failed to get user: no user ?');
socket.emit('queuJoin', user.id);
console.log('queu join sent for : ', user.id);
break;
}
case 'failed': {
showError(`Failed to Join Game Queu: ${res.msg}`);
console.log('Failed to Join Game Queu');
}
}
} catch (err ) {
showError(`Failed to Join Game Queu`);
console.log('Failed to Join Game Queu');
}
}
// keys handler
const keys: Record<string, boolean> = {};
document.addEventListener("keydown", (e) => {
keys[e.key.toLowerCase()] = true;
});
document.addEventListener("keyup", (e) => {
keys[e.key.toLowerCase()] = false;
});
setInterval(() => { // key sender
if ((keys['w'] || keys['s']) && !(keys['w'] && keys['s'])) { // exclusive or to filter requests
if (keys['w']) {
socket.emit("batmove_Left", "up");
console.log('north key pressed - emit batmove_Left up');
}
if (keys['s']) {
socket.emit("batmove_Left", "down");
console.log('south key pressed - emit batmove_Left down');
}
}
if ((keys['p'] || keys['l']) && !(keys['p'] && keys['l'])) { // exclusive or to filter requests
if (keys['p']) {
socket.emit("batmove_Right", "up");
console.log('north key pressed - emit batmove_Right up');
}
if (keys['l']) {
socket.emit("batmove_Right", "down");
console.log('south key pressed - emit batmove_Right down');
}
}
}, 16);
// Pong Objects updators
socket.on("batLeft_update", (y: number) => {
console.log('batLeft_update received y: ', y);
const bat = document.getElementById("batleft") as HTMLDivElement | null;
if (!bat) {
console.error("FATAL ERROR: Bat element with ID 'bat-left' not found. Check HTML.");
return ;
}
if (typeof y === 'number' && !isNaN(y)) {
bat.style.transform = `translateY(${y}px)`;
} else {
console.warn(`Received invalid Y value: ${y}`);
}
});
socket.on("batRight_update", (y: number) => {
console.log('batRight_update received y: ', y);
const bat = document.getElementById("batright") as HTMLDivElement | null;
if (!bat) {
console.error("FATAL ERROR: Bat element with ID 'bat-Right' not found. Check HTML.");
return ;
}
if (typeof y === 'number' && !isNaN(y)) {
bat.style.transform = `translateY(${y}px)`;
} else {
console.warn(`Received invalid Y value: ${y}`);
}
});
socket.on("ballPos_update", (x:number, y : number) => {
console.log('ballPos_update recieved');
const ball = document.getElementById("ball") as HTMLDivElement | null;
if (!ball) {
console.error("FATAL ERROR: Bat element with ID 'bat-Right' not found. Check HTML.");
return ;
}
if (typeof y !== 'number' || isNaN(y) || typeof x !== 'number' || isNaN(x)) {
console.warn(`Received invalid X/Y value: ${x} / ${y}`);
return ;
}
ball.style.transform = `translateY(${y}px)`;
ball.style.transform += `translateX(${x}px)`;
});
// socket.once('welcome', (data) => {
// console.log('%cWelcome PONG PAGE', color.yellow );
// addPongMessage('socket.once \'Welcome\' called')
// });
// Listen for messages from the server "MsgObjectServer"
socket.on("MsgObjectServer", (data: { message: ClientMessage}) => {
// Display the message in the chat window
console.log("message recieved : ", data.message.text);
const systemWindow = document.getElementById('system-box') as HTMLDivElement;
const MAX_SYSTEM_MESSAGES = 10;
if (systemWindow && data.message.destination === "system-info") {
const messageElement = document.createElement("div");
messageElement.textContent = `${data.message.user}: ${data.message.text}`;
systemWindow.appendChild(messageElement);
// keep only last 10
while (systemWindow.children.length > MAX_SYSTEM_MESSAGES) {
systemWindow.removeChild(systemWindow.firstChild!);
}
systemWindow.scrollTop = systemWindow.scrollHeight;
}
if (systemWindow && data.message.destination === "score-info") {
console.log("score update:", data.message.text);
const scoreboard = document.getElementById('score-board') as HTMLHeadingElement ;
if (!scoreboard) {
console.log("update score failed :(");
return ;
}
scoreboard.textContent = `${data.message.text}`;
}
// console.log("Getuser():", getUser());
});
setTitle('Pong Game Page'); setTitle('Pong Game Page');
return { return {
html: authHtml, postInsert: async (app) => { html: authHtml, postInsert: async (app) => {
const bwhoami = document.getElementById('b-whoami') as HTMLButtonElement;
const bqueu = document.getElementById('b-joinQueu') as HTMLButtonElement;
bwhoami?.addEventListener('click', async () => {
whoami(socket);
});
bqueu?.addEventListener('click', async () => {
joinQueu(socket);
});
const checkbox = document.getElementById("modeToggle") as HTMLInputElement; const checkbox = document.getElementById("modeToggle") as HTMLInputElement;
const label = document.getElementById("toggleLabel") as HTMLSpanElement; const label = document.getElementById("toggleLabel") as HTMLSpanElement;
const track = document.getElementById("toggleTrack") as HTMLDivElement; const track = document.getElementById("toggleTrack") as HTMLDivElement;
@ -287,7 +47,67 @@ function pongClient(_url: string, _args: RouteHandlerParams): RouteHandlerReturn
knob.classList.remove("translate-x-7"); knob.classList.remove("translate-x-7");
} }
}); });
const batLeft = document.querySelector<HTMLDivElement>("#batleft");
const batRight = document.querySelector<HTMLDivElement>("#batright");
const ball = document.querySelector<HTMLDivElement>("#ball");
const score = document.querySelector<HTMLDivElement>("#score-board");
if (!batLeft || !batRight || !ball || !score)
return showError('fatal error');
let socket = getSocket();
// keys handler
const keys: Record<string, boolean> = {};
document.addEventListener("keydown", (e) => {
keys[e.key.toLowerCase()] = true;
});
document.addEventListener("keyup", (e) => {
keys[e.key.toLowerCase()] = false;
});
setInterval(() => { // key sender
let packet: GameMove = {
move: null,
}
if ((keys['w'] !== keys['s'])) {
packet.move = keys['w'] ? 'up' : 'down';
}
socket.emit('gameMove', packet);
}, 1000 / 60);
const render = (state: GameUpdate) => {
//batLeft.style.transform = `translateY(${state.left.paddle.y}px) translateX(${state.left.paddle.x}px)`;
batLeft.style.top = `${state.left.paddle.y}px`;
batLeft.style.left = `${state.left.paddle.x}px`;
batLeft.style.width = `${state.left.paddle.width}px`;
batLeft.style.height = `${state.left.paddle.height}px`;
//batRight.style.transform = `translateY(${state.right.paddle.y}px) translateX(-${state.left.paddle.x}px)`;
batRight.style.top = `${state.right.paddle.y}px`;
batRight.style.left = `${state.right.paddle.x}px`;
batRight.style.width = `${state.right.paddle.width}px`;
batRight.style.height = `${state.right.paddle.height}px`;
ball.style.transform = `translateX(${state.ball.x - state.ball.size}px) translateY(${state.ball.y - state.ball.size}px)`;
ball.style.height = `${state.ball.size * 2}px`;
ball.style.width = `${state.ball.size * 2}px`;
score.innerText = `${state.left.score} | ${state.right.score}`
}
socket.on('gameUpdate', (state: GameUpdate) => render(state));
socket.on('newGame', (state) => render(state));
socket.on('updateInformation', (e) => showInfo(`UpdateInformation: t=${e.totalUser};q=${e.inQueue}`));
socket.on('queueEvent', (e) => showInfo(`QueueEvent: ${e}`));
socket.emit('enqueue');
} }
} }
}; };
addRoute('/pong', pongClient, { bypass_auth: true }); addRoute('/pong', pongClient);

View file

@ -0,0 +1,47 @@
import { Socket } from 'socket.io-client';
export type UpdateInfo = {
inQueue: number,
totalUser: number,
}
export type PaddleData = {
x: number,
y: number,
width: number,
height: number,
};
export type GameUpdate = {
gameId: string;
left: { id: string, paddle: PaddleData, score: number };
right: { id: string, paddle: PaddleData, score: number };
ball: { x: number, y: number, size: number };
}
export type GameMove = {
move: 'up' | 'down' | null,
}
export interface ClientToServer {
enqueue: () => void;
dequeue: () => void;
debugInfo: () => void;
gameMove: (up: GameMove) => void;
connectedToGame: (gameId: string) => void;
};
export interface ServerToClient {
forceDisconnect: (reason: string) => void;
queueEvent: (msg: 'registered' | 'unregistered') => void;
updateInformation: (info: UpdateInfo) => void,
newGame: (initState: GameUpdate) => void,
gameUpdate: (state: GameUpdate) => void,
gameEnd: () => void;
};
export type SSocket = Socket<ClientToServer, ServerToClient>;
export type CSocket = Socket<ServerToClient, ClientToServer>;

View file

@ -1,22 +0,0 @@
export type ClientMessage = {
command: string
destination: string;
user: string;
text: string;
SenderWindowID: string;
};
export type ClientProfil = {
command: string,
destination: string,
type: string,
user: string,
loginName: string,
userID: string,
text: string,
timestamp: number,
SenderWindowID:string,
SenderName: string,
Sendertext: string,
innerHtml?: string,
};

View file

@ -1,8 +0,0 @@
#!/bin/sh
set -e
set -x
# do anything here
# run the CMD [ ... ] from the dockerfile
exec "$@"

12
src/pong/src/@types/socket.io.d.ts vendored Normal file
View file

@ -0,0 +1,12 @@
import { type UserId } from '@shared/database/mixin/user';
declare module 'socket.io'
{
interface Socket {
authUser: {
id: UserId;
name: string;
guest: boolean;
}
}
};

View file

@ -6,32 +6,11 @@ import * as auth from '@shared/auth';
import * as swagger from '@shared/swagger'; import * as swagger from '@shared/swagger';
import * as utils from '@shared/utils'; import * as utils from '@shared/utils';
import { Server, Socket } from 'socket.io'; import { Server, Socket } from 'socket.io';
import { broadcast } from './broadcast'; import { newState, State } from './state';
import type { ClientProfil, ClientMessage } from './chat_types'; import { ClientToServer, ServerToClient } from './socket';
import { sendInvite } from './sendInvite';
import { setGameLink } from './setGameLink';
import { UserId } from '@shared/database/mixin/user';
// colors for console.log
export const color = {
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
reset: '\x1b[0m',
};
declare const __SERVICE_NAME: string; declare const __SERVICE_NAME: string;
// Global map of clients
// key = socket, value = clientname
interface ClientInfo {
user: string;
lastSeen: number;
}
export const clientChat = new Map<string, ClientInfo>();
// @ts-expect-error: import.meta.glob is a vite thing. Typescript doesn't know this... // @ts-expect-error: import.meta.glob is a vite thing. Typescript doesn't know this...
const plugins = import.meta.glob('./plugins/**/*.ts', { eager: true }); const plugins = import.meta.glob('./plugins/**/*.ts', { eager: true });
// @ts-expect-error: import.meta.glob is a vite thing. Typescript doesn't know this... // @ts-expect-error: import.meta.glob is a vite thing. Typescript doesn't know this...
@ -60,6 +39,7 @@ const app: FastifyPluginAsync = async (fastify, opts): Promise<void> => {
fastify.ready((err) => { fastify.ready((err) => {
if (err) throw err; if (err) throw err;
newState(fastify);
onReady(fastify); onReady(fastify);
}); });
}; };
@ -69,266 +49,13 @@ export { app };
// When using .decorate you have to specify added properties for Typescript // When using .decorate you have to specify added properties for Typescript
declare module 'fastify' { declare module 'fastify' {
interface FastifyInstance { interface FastifyInstance {
io: Server<{ io: Server<ClientToServer, ServerToClient>;
inviteGame: (data: ClientProfil) => void;
message: (msg: string) => void;
batmove_Left: (direction: 'up' | 'down') => void;
batmove_Right: (direction: 'up' | 'down') => void;
batLeft_update: (y: number) => void;
batRight_update: (y: number) => void;
ballPos_update: (x: number, y: number) => void;
MsgObjectServer: (data: { message: ClientMessage }) => void;
queuJoin: (userID: UserId) => void;
}>;
} }
} }
function isInRange(x: number, low: number, high: number) {
if (x >= low && x <= high) return true;
return false;
}
async function sendScore(
socket: Socket,
scoreLeft: number,
scoreRight: number,
) {
// idk why, sometimes... it fails?
const msg: ClientMessage = {
destination: 'score-info',
command: '',
user: '',
text: scoreLeft.toString() + ':' + scoreRight.toString(),
SenderWindowID: '',
};
socket.emit('MsgObjectServer', { message: msg });
}
async function onReady(fastify: FastifyInstance) { async function onReady(fastify: FastifyInstance) {
// shows address for connection au server transcendance
const session = process.env.SESSION_MANAGER ?? '';
if (session) {
const part = session.split('/')[1];
const machineName = part.split('.')[0];
console.log(
color.yellow,
'Connect at : https://' + machineName + ':8888/app/login',
);
}
// DRAW AREA
// top edge of the field
const TOP_EDGE = 0;
// bottom edge of the field;
const BOTTOM_EDGE = 450;
const LEFT_EDGE = 0;
const RIGHT_EDGE = 800;
void LEFT_EDGE;
// PADDLEs
const PADDLE_HEIGHT = 80;
const PADDLE_WIDTH = 12;
const PADDLE_SPEED = 20;
const PADDLE_X_OFFSET = 4;
// 370
const MAX_PADDLE_Y = BOTTOM_EDGE - PADDLE_HEIGHT;
// 185
const PADDLE_START = BOTTOM_EDGE / 2 - PADDLE_HEIGHT / 2;
// BALL
// widht times 2 bc rounded on moth sides + 4 for border
const BALL_SIZE = 8 * 2 + 4;
const START_BALLX = RIGHT_EDGE / 2 - BALL_SIZE;
const START_BALLY = BOTTOM_EDGE / 2 - BALL_SIZE;
const ACCELERATION_FACTOR = 1.15;
const ABS_MAX_BALL_SPEED = 3;
// val inits
// shared start bat position
let paddleLeft = PADDLE_START;
// shared start bat position
let paddleRight = PADDLE_START;
let ballPosX = START_BALLX;
let ballPosY = START_BALLY;
let ballSpeedX = -1;
let ballSpeedY = -1;
let scoreL = 0;
let scoreR = 0;
// uuid, game uid - if not in game empty string
const games: Record<UserId, string> = {};
fastify.io.on('connection', (socket: Socket) => { fastify.io.on('connection', (socket: Socket) => {
socket.emit('batLeft_update', paddleLeft); fastify.log.info(`Client connected: ${socket.id}`);
socket.emit('batRight_update', paddleRight); State.registerUser(socket);
socket.emit('ballPos_update', ballPosX, ballPosY);
sendScore(socket, scoreL, scoreR);
// GAME
// paddle handling
socket.on('batmove_Left', (direction: 'up' | 'down') => {
if (direction === 'up') {
paddleLeft -= PADDLE_SPEED;
}
if (direction === 'down') {
paddleLeft += PADDLE_SPEED;
}
// position of bat leftplokoplpl
paddleLeft = Math.max(TOP_EDGE, Math.min(MAX_PADDLE_Y, paddleLeft));
console.log('batLeft_update:', paddleLeft);
socket.emit('batLeft_update', paddleLeft);
});
socket.on('batmove_Right', (direction: 'up' | 'down') => {
if (direction === 'up') {
paddleRight -= PADDLE_SPEED;
}
if (direction === 'down') {
paddleRight += PADDLE_SPEED;
}
// position of bat left
paddleRight = Math.max(
TOP_EDGE,
Math.min(MAX_PADDLE_Y, paddleRight),
);
socket.emit('batRight_update', paddleRight);
});
// ball handling:
setInterval(async () => {
const new_ballPosX = ballPosX + ballSpeedX;
const new_ballPosY = ballPosY + ballSpeedY;
if (
((isInRange(
new_ballPosY,
paddleLeft,
paddleLeft + PADDLE_HEIGHT,
) ||
isInRange(
new_ballPosY + BALL_SIZE * 2,
paddleLeft,
paddleLeft + PADDLE_HEIGHT,
)) &&
// y ok ?
isInRange(
new_ballPosX,
PADDLE_X_OFFSET,
PADDLE_X_OFFSET + PADDLE_WIDTH,
) &&
ballSpeedX < 0) ||
// x ok? && ball going toward paddle?
((isInRange(
new_ballPosY,
paddleRight,
paddleRight + PADDLE_HEIGHT,
) ||
isInRange(
new_ballPosY + BALL_SIZE * 2,
paddleRight,
paddleRight + PADDLE_HEIGHT,
)) &&
// right side equations
isInRange(
new_ballPosX + BALL_SIZE * 2,
RIGHT_EDGE - PADDLE_X_OFFSET - PADDLE_WIDTH,
RIGHT_EDGE - PADDLE_X_OFFSET,
) &&
ballSpeedX > 0)
) {
ballSpeedX *= -1;
ballSpeedX *= ACCELERATION_FACTOR;
ballSpeedY *= ACCELERATION_FACTOR;
console.log('bat colision');
}
else if (
new_ballPosX < 0 ||
new_ballPosX + BALL_SIZE * 2 > RIGHT_EDGE
) {
ballPosX = START_BALLX;
ballPosY = START_BALLY;
ballSpeedX = Math.random() - 0.5 < 0 ? -1 : 1;
if (new_ballPosX < 0) {
scoreR += 1;
ballSpeedY = -1;
}
else {
scoreL += 1;
ballSpeedY = 1;
}
if (scoreL >= 5 || scoreR >= 5) {
console.log('game should stop + board reset');
// temp solution
ballSpeedX = 0;
ballSpeedY = 0;
// reset board :D
}
console.log('point scored');
sendScore(socket, scoreL, scoreR);
// TODO: score point + ball reset + spd reset
}
else if (
new_ballPosY < 0 ||
new_ballPosY + BALL_SIZE * 2 > BOTTOM_EDGE
) {
ballSpeedY *= -1;
ballSpeedX *= ACCELERATION_FACTOR;
ballSpeedY *= ACCELERATION_FACTOR;
}
ballSpeedX = Math.max(
-ABS_MAX_BALL_SPEED,
Math.min(ballSpeedX, ABS_MAX_BALL_SPEED),
);
ballSpeedY = Math.max(
-ABS_MAX_BALL_SPEED,
Math.min(ballSpeedY, ABS_MAX_BALL_SPEED),
);
ballPosX += ballSpeedX;
ballPosY += ballSpeedY;
socket.emit('ballPos_update', ballPosX, ballPosY);
}, 16);
// QUEUE HANDL
socket.on('queuJoin', async (uuid: UserId) => {
console.log('queu join recieved for : ', uuid);
if (!(uuid in games.hasOwnProperty)) {
console.log('new user in game search queu');
games[uuid] = '';
}
else if (uuid in games && games[uuid] == '') {
console.log('already searching for game');
}
else {
// (games.hasOwnProperty(uuid) && games[uuid] != "") {
console.log('user alredy in game');
return;
}
// TODO: step2 : sesrch in record<> find guid w/ "" &/ pair them up
// TODO: step3 : move game logic to lifecycle of queu'ed game
});
// other:
socket.on('message', (message: string) => {
const obj: ClientMessage = JSON.parse(message) as ClientMessage;
clientChat.set(socket.id, { user: obj.user, lastSeen: Date.now() });
socket.emit('welcome', { msg: 'Welcome to the chat! : ' });
broadcast(fastify, obj, obj.SenderWindowID);
});
socket.on('inviteGame', async (data: string) => {
const clientName: string = clientChat.get(socket.id)?.user || '';
const profilInvite: ClientProfil = JSON.parse(data) || '';
const inviteHtml: string =
'invites you to a game ' + setGameLink('');
if (clientName !== null) {
sendInvite(fastify, inviteHtml, profilInvite);
}
});
}); });
} }

View file

@ -1,22 +0,0 @@
import type { ClientMessage } from './chat_types';
import { clientChat, color } from './app';
import { FastifyInstance } from 'fastify';
export function broadcast(fastify: FastifyInstance, data: ClientMessage, sender?: string) {
fastify.io.fetchSockets().then((sockets) => {
for (const socket of sockets) {
// Skip sender's own socket
if (socket.id === sender) continue;
// Get client name from map
const clientInfo = clientChat.get(socket.id);
if (!clientInfo?.user) {
console.log(color.yellow, `Skipping socket ${socket.id} (no user found)`);
continue;
}
// Emit structured JSON object
socket.emit('MsgObjectServer', { message: data });
// Debug logs
// console.log(color.green, `'DEBUG LOG: Broadcast to:', ${data.command} message: ${data.text}`);
}
});
}

View file

@ -1,23 +0,0 @@
export type ClientMessage = {
command: string
destination: string;
user: string;
text: string;
SenderWindowID: string;
};
export type ClientProfil = {
command: string,
destination: string,
type: string,
user: string,
loginName: string,
userID: string,
text: string,
timestamp: number,
SenderWindowID: string,
SenderName: string,
Sendertext: string,
innerHtml?: string,
};

216
src/pong/src/game.ts Normal file
View file

@ -0,0 +1,216 @@
import { UserId } from '@shared/database/mixin/user';
export class Paddle {
public static readonly DEFAULT_SPEED = 20;
public static readonly DEFAULT_HEIGHT = 80;
public static readonly DEFAULT_WIDTH = 12;
public height: number = Paddle.DEFAULT_HEIGHT;
public width: number = Paddle.DEFAULT_WIDTH;
public speed: number = Paddle.DEFAULT_SPEED;
constructor(
// these coordiantes are the topleft corordinates
public x: number,
public y: number,
) { }
public move(dir: 'up' | 'down') {
this.y += (dir === 'up' ? -1 : 1) * this.speed;
}
public clamp(bottom: number, top: number) {
if (this.y <= bottom) this.y = bottom;
if (this.y + this.height >= top) this.y = top - this.height;
}
}
class Ball {
public static readonly DEFAULT_SPEED = 1;
public static readonly DEFAULT_SIZE = 16;
public static readonly DEFAULT_MAX_SPEED = 30;
public static readonly DEFAULT_MIN_SPEED = Ball.DEFAULT_SPEED;
public static readonly DEFAULT_ACCEL_FACTOR = 1.2;
public speed: number = Ball.DEFAULT_SPEED;
public size: number = Ball.DEFAULT_SIZE;
public accel_factor: number = Ball.DEFAULT_ACCEL_FACTOR;
public max_speed: number = Ball.DEFAULT_MAX_SPEED;
public min_speed: number = Ball.DEFAULT_MIN_SPEED;
constructor(
// these coordiantes are the center coordinates
public x: number,
public y: number,
public angle: number,
) { }
public collided(
side: 'left' | 'right' | 'top' | 'bottom',
walls: { [k in typeof side]: number },
) {
// this.speed *= this.accel_factor;
this.speed = Math.max(
Math.min(this.speed, this.max_speed),
this.min_speed,
);
let c: 'x' | 'y' = 'x';
if (side === 'top' || side === 'bottom') {
this.angle = -this.angle;
c = 'y';
}
else {
this.angle = -this.angle + Math.PI;
c = 'x';
}
this[c] =
walls[side] +
this.size * (side === 'right' || side === 'bottom' ? -1 : 1);
while (this.angle >= Math.PI) {
this.angle -= 2 * Math.PI;
}
while (this.angle < -Math.PI) {
this.angle += 2 * Math.PI;
}
}
public tick() {
this.x += Math.cos(this.angle) * this.speed;
this.y += Math.sin(this.angle) * this.speed;
}
}
function makeAngle(i: number): [number, number, number, number] {
return [
Math.PI / i,
Math.PI / i + Math.PI,
-Math.PI / i,
-Math.PI / i + Math.PI,
];
}
export class Pong {
public gameUpdate: NodeJS.Timeout | null = null;
public static readonly BALL_START_ANGLES: number[] = [
...makeAngle(4),
...makeAngle(6),
];
public ballAngleIdx: number = 0;
public static readonly GAME_WIDTH: number = 800;
public static readonly GAME_HEIGHT: number = 450;
public static readonly PADDLE_OFFSET: number = 40;
public leftPaddle: Paddle = new Paddle(
Pong.PADDLE_OFFSET,
(Pong.GAME_HEIGHT - Paddle.DEFAULT_HEIGHT) / 2,
);
public rightPaddle: Paddle = new Paddle(
Pong.GAME_WIDTH - Pong.PADDLE_OFFSET - Paddle.DEFAULT_WIDTH,
(Pong.GAME_HEIGHT - Paddle.DEFAULT_HEIGHT) / 2,
);
public ball: Ball = new Ball(Pong.GAME_WIDTH / 2, Pong.GAME_HEIGHT / 2, Pong.BALL_START_ANGLES[this.ballAngleIdx++]);
public score: [number, number] = [0, 0];
constructor(
public userLeft: UserId,
public userRight: UserId,
) {
}
public tick() {
if (this.paddleCollision(this.leftPaddle, 'left')) {
this.ball.collided('left', {
left: this.leftPaddle.x + this.leftPaddle.width,
right: 0,
top: 0,
bottom: 0,
});
return;
}
if (this.paddleCollision(this.rightPaddle, 'right')) {
this.ball.collided('right', {
right: this.rightPaddle.x,
left: 0,
top: 0,
bottom: 0,
});
return;
}
const wallCollision = this.boxCollision();
if (wallCollision === 'top' || wallCollision === 'bottom') {
this.ball.collided(wallCollision, {
left: 0,
top: 0,
bottom: Pong.GAME_HEIGHT,
right: Pong.GAME_WIDTH,
});
}
else if (wallCollision !== null) {
const idx = wallCollision === 'left' ? 1 : 0;
this.score[idx] += 1;
this.ball = new Ball(
Pong.GAME_WIDTH / 2,
Pong.GAME_HEIGHT / 2,
Pong.BALL_START_ANGLES[this.ballAngleIdx++],
);
this.ballAngleIdx %= Pong.BALL_START_ANGLES.length;
}
this.ball.tick();
}
// This function will return which side the ball collided, if any
private boxCollision(): 'top' | 'bottom' | 'left' | 'right' | null {
if (this.ball.y - this.ball.size <= 0) return 'top';
if (this.ball.y + this.ball.size >= Pong.GAME_HEIGHT) return 'bottom';
if (this.ball.x - this.ball.size <= 0) return 'left';
if (this.ball.x + this.ball.size >= Pong.GAME_WIDTH) return 'right';
return null;
}
private paddleCollision(paddle: Paddle, side: 'left' | 'right'): boolean {
// now we check only if the ball is near enought in the y axis to permform the collision
if (!(
// check if ball is bellow the top of the paddle
paddle.y - this.ball.size < this.ball.y &&
// check if ball is above the bottom of the paddle
this.ball.y < paddle.y + paddle.height + this.ball.size)) return false;
// so we know that the y is close enougth to be a bit, so we check the X. are we closer than the ball size ? if yes -> hit
if (
// check if the paddle.x is at most ball.size away from the center of the ball => we have a hit houston
// call he pentagon, 9 11
Math.abs(
paddle.x + paddle.width * (side === 'left' ? 1 : 0)
- this.ball.x)
< this.ball.size
) return true;
return false;
}
public checkWinner(): 'left' | 'right' | null {
if (this.score[0] >= 5) return 'left';
if (this.score[1] >= 5) return 'right';
return null;
}
public movePaddle(user: UserId, dir: 'up' | 'down') {
const paddle =
user === this.userLeft
? this.leftPaddle
: user == this.userRight
? this.rightPaddle
: null;
if (paddle === null) return;
paddle.move(dir);
paddle.clamp(0, Pong.GAME_HEIGHT);
}
}

View file

@ -1,3 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { JwtType } from '@shared/auth';
import { UserId } from '@shared/database/mixin/user';
import { isNullish } from '@shared/utils';
import type { import type {
FastifyInstance, FastifyInstance,
FastifyPluginAsync, FastifyPluginAsync,
@ -11,6 +15,21 @@ const F: (
) => Omit<FastifyInstance, 'io'> & { io: Server } = (f) => ) => Omit<FastifyInstance, 'io'> & { io: Server } = (f) =>
f as Omit<FastifyInstance, 'io'> & { io: Server }; f as Omit<FastifyInstance, 'io'> & { io: Server };
function authenticateToken(
fastify: FastifyInstance,
token: string,
): { id: UserId; name: string; guest: boolean } {
const tok = fastify.jwt.verify<JwtType>(token);
if (tok.kind != 'auth') {
throw new Error('Token isn\'t correct type');
}
const user = fastify.db.getUser(tok.who);
if (isNullish(user)) {
throw new Error('User not found');
}
return { id: user.id, name: user.name, guest: user.guest };
}
const fastifySocketIO: FastifyPluginAsync = fp(async (fastify) => { const fastifySocketIO: FastifyPluginAsync = fp(async (fastify) => {
function defaultPreClose(done: HookHandlerDoneFunction) { function defaultPreClose(done: HookHandlerDoneFunction) {
F(fastify).io.local.disconnectSockets(true); F(fastify).io.local.disconnectSockets(true);
@ -20,6 +39,36 @@ const fastifySocketIO: FastifyPluginAsync = fp(async (fastify) => {
'io', 'io',
new Server(fastify.server, { path: '/api/pong/socket.io' }), new Server(fastify.server, { path: '/api/pong/socket.io' }),
); );
F(fastify).io.use((socket, next) => {
const cookieHeader = socket.request.headers.cookie;
if (!cookieHeader) {
throw new Error('Missing token cookie');
}
const cookies = Object.fromEntries(
cookieHeader.split(';').map((c) => {
const [k, v] = c.trim().split('=');
return [k, v];
}),
);
if (!cookies.token) {
throw new Error('Missing token cookie');
}
try {
socket.authUser = authenticateToken(fastify, cookies.token);
next();
}
catch (e: any) {
next({
name: 'Unauthorized',
message: e.message,
data: { status: 401 },
});
}
});
fastify.addHook('preClose', defaultPreClose); fastify.addHook('preClose', defaultPreClose);
fastify.addHook('onClose', (instance: FastifyInstance, done) => { fastify.addHook('onClose', (instance: FastifyInstance, done) => {
F(instance).io.close(); F(instance).io.close();
@ -28,4 +77,3 @@ const fastifySocketIO: FastifyPluginAsync = fp(async (fastify) => {
}); });
export default fastifySocketIO; export default fastifySocketIO;

View file

@ -1,6 +1,5 @@
import { FastifyPluginAsync } from 'fastify'; import { FastifyPluginAsync } from 'fastify';
import { Static, Type } from 'typebox'; import { Static, Type } from 'typebox';
import { broadcast } from '../broadcast';
export const PongReq = Type.Object({ export const PongReq = Type.Object({
message: Type.String(), message: Type.String(),
@ -19,7 +18,6 @@ const route: FastifyPluginAsync = async (fastify): Promise<void> => {
config: { requireAuth: false }, config: { requireAuth: false },
}, },
async function(req, res) { async function(req, res) {
broadcast(this, { command: '', destination: 'system-info', user: 'CMwaLeSever!!', text: req.body.message, SenderWindowID: 'server' });
void res; void res;
}, },
); );

View file

@ -1,30 +0,0 @@
import type { ClientProfil } from './chat_types';
import { clientChat, color } from './app';
import { FastifyInstance } from 'fastify';
/**
* function looks for the user online in the chat
* and sends emit to invite - format HTML to make clickable
* message appears in chat window text area
* @param fastify
* @param innerHtml
* @param profil
*/
export function sendInvite(fastify: FastifyInstance, innerHtml: string, profil: ClientProfil) {
fastify.io.fetchSockets().then((sockets) => {
let targetSocket;
for (const socket of sockets) {
const clientInfo: string = clientChat.get(socket.id)?.user || '';
if (clientInfo === profil.user) {
console.log(color.yellow, 'DEBUG LOG: user online found', profil.user);
targetSocket = socket || '';
break;
}
}
profil.innerHtml = innerHtml ?? '';
if (targetSocket) {
targetSocket.emit('inviteGame', profil);
}
});
}

View file

@ -1,6 +0,0 @@
export function setGameLink(link: string): string {
if (!link) {
link = '<a href=\'https://google.com\' style=\'color: blue; text-decoration: underline; cursor: pointer;\'>Click me</a>';
}
return link;
};

47
src/pong/src/socket.ts Normal file
View file

@ -0,0 +1,47 @@
import { Socket } from 'socket.io';
export type UpdateInfo = {
inQueue: number,
totalUser: number,
}
export type PaddleData = {
x: number,
y: number,
width: number,
height: number,
};
export type GameUpdate = {
gameId: string;
left: { id: string, paddle: PaddleData, score: number };
right: { id: string, paddle: PaddleData, score: number };
ball: { x: number, y: number, size: number };
}
export type GameMove = {
move: 'up' | 'down' | null,
}
export interface ClientToServer {
enqueue: () => void;
dequeue: () => void;
debugInfo: () => void;
gameMove: (up: GameMove) => void;
connectedToGame: (gameId: string) => void;
};
export interface ServerToClient {
forceDisconnect: (reason: string) => void;
queueEvent: (msg: 'registered' | 'unregistered') => void;
updateInformation: (info: UpdateInfo) => void,
newGame: (initState: GameUpdate) => void,
gameUpdate: (state: GameUpdate) => void,
gameEnd: () => void;
};
export type SSocket = Socket<ClientToServer, ServerToClient>;
export type CSocket = Socket<ServerToClient, ClientToServer>;

182
src/pong/src/state.ts Normal file
View file

@ -0,0 +1,182 @@
import { UserId } from '@shared/database/mixin/user';
import { newUUID } from '@shared/utils/uuid';
import { FastifyInstance } from 'fastify';
import { Pong } from './game';
import { GameMove, GameUpdate, SSocket } from './socket';
import { isNullish } from '@shared/utils';
type PUser = {
id: UserId;
currentGame: null | GameId;
socket: SSocket,
windowId: string,
updateInterval: NodeJS.Timeout,
};
type GameId = string & { readonly __brand: unique symbol };
class StateI {
public static readonly UPDATE_INTERVAL_FRAMES: number = 60;
private users: Map<UserId, PUser> = new Map();
private queue: Set<UserId> = new Set();
private queueInterval: NodeJS.Timeout;
private games: Map<GameId, Pong> = new Map();
public constructor(private fastify: FastifyInstance) {
this.queueInterval = setInterval(() => this.queuerFunction());
void this.queueInterval;
}
private static getGameUpdateData(id: GameId, g: Pong): GameUpdate {
return {
gameId: id,
left: { id: g.userLeft, score: g.score[0], paddle: { x: g.leftPaddle.x, y: g.leftPaddle.y, width: g.leftPaddle.width, height: g.leftPaddle.height } },
right: { id: g.userRight, score: g.score[1], paddle: { x: g.rightPaddle.x, y: g.rightPaddle.y, width: g.rightPaddle.width, height: g.rightPaddle.height } },
ball: { x: g.ball.x, y: g.ball.y, size: g.ball.size },
};
}
private queuerFunction(): void {
const values = Array.from(this.queue.values());
while (values.length >= 2) {
const id1 = values.pop();
const id2 = values.pop();
if (isNullish(id1) || isNullish(id2)) {
continue;
}
const u1 = this.users.get(id1);
const u2 = this.users.get(id2);
if (isNullish(u1) || isNullish(u2)) {
continue;
}
this.queue.delete(id1);
this.queue.delete(id2);
const gameId = newUUID() as unknown as GameId;
const g = new Pong(u1.id, u2.id);
const iState: GameUpdate = StateI.getGameUpdateData(gameId, g);
u1.socket.emit('newGame', iState);
u2.socket.emit('newGame', iState);
this.games.set(gameId, g);
u1.currentGame = gameId;
u2.currentGame = gameId;
g.gameUpdate = setInterval(() => {
g.tick();
this.gameUpdate(gameId, u1.socket);
this.gameUpdate(gameId, u2.socket);
if (g.checkWinner() !== null) { this.cleanupGame(gameId, g); }
}, 1000 / StateI.UPDATE_INTERVAL_FRAMES);
}
}
private gameUpdate(id: GameId, sock: SSocket) {
// does the game we want to update the client exists ?
if (!this.games.has(id)) return;
// is the client someone we know ?
if (!this.users.has(sock.authUser.id)) return;
// is the client associated with that game ?
if (this.users.get(sock.authUser.id)!.currentGame !== id) return;
sock.emit('gameUpdate', StateI.getGameUpdateData(id, this.games.get(id)!));
}
private gameMove(socket: SSocket, u: GameMove) {
// do we know this user ?
if (!this.users.has(socket.authUser.id)) return;
const user = this.users.get(socket.authUser.id)!;
// does the user have a game and do we know such game ?
if (user.currentGame === null || !this.games.has(user.currentGame)) return;
const game = this.games.get(user.currentGame)!;
if (u.move !== null) { game.movePaddle(user.id, u.move); }
}
public registerUser(socket: SSocket): void {
this.fastify.log.info('Registering new user');
if (this.users.has(socket.authUser.id)) {
socket.emit('forceDisconnect', 'Already Connected');
socket.disconnect();
return;
}
this.users.set(socket.authUser.id, {
socket,
id: socket.authUser.id,
windowId: socket.id,
updateInterval: setInterval(() => this.updateClient(socket), 3000),
currentGame: null,
});
this.fastify.log.info('Registered new user');
socket.on('disconnect', () => this.cleanupUser(socket));
socket.on('enqueue', () => this.enqueueUser(socket));
socket.on('dequeue', () => this.dequeueUser(socket));
socket.on('gameMove', (e) => this.gameMove(socket, e));
}
private updateClient(socket: SSocket): void {
socket.emit('updateInformation', {
inQueue: this.queue.size,
totalUser: this.users.size,
});
}
private cleanupUser(socket: SSocket): void {
if (!this.users.has(socket.authUser.id)) return;
clearInterval(this.users.get(socket.authUser.id)?.updateInterval);
this.users.delete(socket.authUser.id);
this.queue.delete(socket.authUser.id);
}
private cleanupGame(gameId: GameId, game: Pong): void {
clearInterval(game.gameUpdate ?? undefined);
this.games.delete(gameId);
let player: PUser | undefined = undefined;
if ((player = this.users.get(game.userLeft)) !== undefined) {
player.currentGame = null;
player.socket.emit('gameEnd');
}
if ((player = this.users.get(game.userRight)) !== undefined) {
player.currentGame = null;
player.socket.emit('gameEnd');
}
// do something here with the game result before deleting the game at the end
}
private enqueueUser(socket: SSocket): void {
if (!this.users.has(socket.authUser.id)) return;
if (this.queue.has(socket.authUser.id)) return;
if (this.users.get(socket.authUser.id)?.currentGame !== null) return;
this.queue.add(socket.authUser.id);
socket.emit('queueEvent', 'registered');
}
private dequeueUser(socket: SSocket): void {
if (!this.users.has(socket.authUser.id)) return;
if (!this.queue.has(socket.authUser.id)) return;
this.queue.delete(socket.authUser.id);
socket.emit('queueEvent', 'unregistered');
}
}
export let State: StateI = undefined as unknown as StateI;
export function newState(f: FastifyInstance) {
State = new StateI(f);
}