mirror of
				https://github.com/thib8956/tic-tac-toe-ws.git
				synced 2025-10-31 09:09:21 +00:00 
			
		
		
		
	
		
			
				
	
	
		
			272 lines
		
	
	
		
			8.5 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			272 lines
		
	
	
		
			8.5 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| import { Request, Response, Message, Hello, EndGame } from "common.mjs";
 | |
| 
 | |
| const CELL_SIZE = 150;
 | |
| const GRID_SIZE = CELL_SIZE * 3;
 | |
| const SHAPE_SIZE = 100;
 | |
| const ANIMATE_DURATION = 500; // ms
 | |
| 
 | |
| const ws = new WebSocket("ws://localhost:1234");
 | |
| 
 | |
| interface Point {
 | |
|     x: number;
 | |
|     y: number;
 | |
| }
 | |
| 
 | |
| interface Shape {
 | |
|     kind: "o" | "x";
 | |
|     pos: Point;
 | |
|     hue: number;
 | |
|     time: number | null;
 | |
| }
 | |
| 
 | |
| let grid = [0, 0, 0, 0, 0, 0, 0, 0, 0];
 | |
| let pendingEvts: Point[] = [];
 | |
| let pendingShapes: Shape[] = [];
 | |
| let myId: number | null = null;
 | |
| let mySymbol: "x" | "o" | null = null;
 | |
| let canvasMsg: string = "Offline";
 | |
| 
 | |
| function drawGridBackground(ctx: CanvasRenderingContext2D, origin: Point) {
 | |
|     ctx.strokeStyle = "white";
 | |
|     ctx.lineWidth = 5;
 | |
| 
 | |
|     for (let x = 1; x < 3; ++x) {
 | |
|         ctx.moveTo(origin.x + x * CELL_SIZE, 0 + origin.y);
 | |
|         ctx.lineTo(origin.x + x * CELL_SIZE, origin.y + GRID_SIZE);
 | |
|     }
 | |
| 
 | |
|     for (let y = 1; y < 3; ++y) {
 | |
|         ctx.moveTo(origin.x + 0, origin.y + y * CELL_SIZE);
 | |
|         ctx.lineTo(origin.x + GRID_SIZE, origin.y + y * CELL_SIZE);
 | |
|     }
 | |
| 
 | |
|     ctx.stroke();
 | |
| }
 | |
| 
 | |
| 
 | |
| function resizeCanvas(ctx: CanvasRenderingContext2D) {
 | |
|     ctx.canvas.width  = window.innerWidth;
 | |
|     ctx.canvas.height = window.innerHeight;
 | |
| }
 | |
| 
 | |
| 
 | |
| function coordToGridIndex(origin: Point, clientPos: Point): [number, number] | undefined {
 | |
|     // Coord relative to origin of the grid (origin)
 | |
|     const pt = { x: clientPos.x - origin.x, y: clientPos.y - origin.y };
 | |
|     const gridIndex: [number, number] = [Math.floor(3 * pt.x / GRID_SIZE), Math.floor(3 * pt.y / GRID_SIZE)];
 | |
|     if (gridIndex[0] >= 0 && gridIndex[0] <= 2 && gridIndex[1] >= 0 && gridIndex[1] <= 2) {
 | |
|         return gridIndex;
 | |
|     }
 | |
|     return undefined;
 | |
| }
 | |
| 
 | |
| function handlePendingEvts(ws: WebSocket, gridOrigin: Point) {
 | |
|     for (const evt of pendingEvts) {
 | |
|         const gridIndex = coordToGridIndex(gridOrigin, evt);
 | |
|         if (gridIndex) {
 | |
|             const [x, y] = gridIndex;
 | |
|             const msg: Request = { x, y };
 | |
|             ws.send(JSON.stringify(msg));
 | |
|         }
 | |
|     }
 | |
|     pendingEvts = [];
 | |
| }
 | |
| 
 | |
| 
 | |
| function handlePendingShapes(ctx: CanvasRenderingContext2D, gridOrigin: Point, time: number) {
 | |
|     const shapes = [];
 | |
|     for (let shape of pendingShapes) {
 | |
|         if (shape.time === null) {
 | |
|             shape.time = time;
 | |
|         }
 | |
|         const dt = time - shape.time;
 | |
|         const p = gridIndexToCoords(gridOrigin, shape.pos.x, shape.pos.y);
 | |
|         switch (shape.kind) {
 | |
|             case "o": 
 | |
|                 drawAnimatedCircle(ctx, dt, p.x, p.y, shape.hue);
 | |
|                 break;
 | |
|             case "x":
 | |
|                 drawAnimatedCross(ctx, dt, p.x, p.y, shape.hue);
 | |
|                 break;
 | |
|         }
 | |
|         if (dt <= ANIMATE_DURATION) {
 | |
|             shapes.push(shape);
 | |
|         }
 | |
|     }
 | |
|     pendingShapes = shapes;
 | |
| }
 | |
| 
 | |
| function drawCircle(ctx: CanvasRenderingContext2D, center: Point) {
 | |
|     const radius = SHAPE_SIZE/2;
 | |
|     ctx.beginPath();
 | |
|     ctx.arc(center.x, center.y, radius, 0, Math.PI * 2);
 | |
|     ctx.stroke();
 | |
| }
 | |
| 
 | |
| function drawCross(ctx: CanvasRenderingContext2D, center: Point) {
 | |
|     const startPoint = { x: center.x-SHAPE_SIZE/2, y: center.y-SHAPE_SIZE/2 };
 | |
|     ctx.beginPath();
 | |
|     ctx.moveTo(startPoint.x, startPoint.y);
 | |
|     ctx.lineTo(startPoint.x + 100, startPoint.y + 100);
 | |
|     ctx.moveTo(startPoint.x + 100, startPoint.y);
 | |
|     ctx.lineTo(startPoint.x, startPoint.y + 100);
 | |
|     ctx.stroke();
 | |
| }
 | |
| 
 | |
| function drawAnimatedCircle(ctx: CanvasRenderingContext2D, dt: number, x: number, y: number, hue: number) {
 | |
|     const radius = SHAPE_SIZE / 2;
 | |
|     const end = dt*2*Math.PI/ANIMATE_DURATION;
 | |
|     ctx.save();
 | |
|     ctx.beginPath();
 | |
|     ctx.arc(x, y, radius, 0, Math.min(end, 2*Math.PI));
 | |
|     const percent = Math.trunc(100*Math.min(end, 2*Math.PI)/(2*Math.PI));
 | |
|     ctx.strokeStyle = `hsla(${hue}, ${percent}%, 50%, 1)`;
 | |
|     ctx.lineWidth = 5;
 | |
|     ctx.stroke();
 | |
|     ctx.restore();
 | |
| }
 | |
| 
 | |
| function drawAnimatedCross(ctx: CanvasRenderingContext2D, dt: number, x: number, y: number, hue: number) {
 | |
|     const startPoint: Point = { x: x-SHAPE_SIZE/2, y: y-SHAPE_SIZE/2 };
 | |
|     const halfAnim = ANIMATE_DURATION/2;
 | |
| 
 | |
|     ctx.save();
 | |
|     ctx.beginPath();
 | |
|     ctx.moveTo(startPoint.x, startPoint.y);
 | |
| 
 | |
|     const delta = SHAPE_SIZE*dt/halfAnim;
 | |
|     if (delta < SHAPE_SIZE) { // draw \
 | |
|         const d = Math.min(delta, SHAPE_SIZE);
 | |
|         ctx.lineTo(startPoint.x + d, startPoint.y + d);
 | |
|     } else { // draw /
 | |
|         ctx.lineTo(startPoint.x + SHAPE_SIZE, startPoint.y + SHAPE_SIZE); // keep \ drawn
 | |
|         ctx.moveTo(startPoint.x + SHAPE_SIZE, startPoint.y);
 | |
|         const d = Math.min(delta - SHAPE_SIZE, SHAPE_SIZE);
 | |
|         ctx.lineTo(startPoint.x + SHAPE_SIZE - d, startPoint.y + d);
 | |
|     }
 | |
| 
 | |
|     ctx.lineWidth = 5;
 | |
|     const percent = Math.trunc(100*Math.min(delta, SHAPE_SIZE)/SHAPE_SIZE);
 | |
|     ctx.strokeStyle = `hsla(${hue}, ${percent}%, 50%, 1)`;
 | |
|     ctx.stroke();
 | |
|     ctx.restore();
 | |
| }
 | |
| 
 | |
| function gridIndexToCoords(gridOrigin: Point, x: number, y: number): Point {
 | |
|     const center = {
 | |
|         x: gridOrigin.x + x * CELL_SIZE + CELL_SIZE/2,
 | |
|         y: gridOrigin.y + y * CELL_SIZE + CELL_SIZE/2
 | |
|     };
 | |
|     return center;
 | |
| }
 | |
| 
 | |
| function updateGridState(ctx: CanvasRenderingContext2D, gridOrigin: Point) {
 | |
|     for (let y = 0; y < 3; ++y) {
 | |
|         for (let x = 0; x < 3; ++x) {
 | |
|             if (pendingShapes.some(s => s.pos.x === x && s.pos.y === y)) {
 | |
|                 continue;
 | |
|             }
 | |
| 
 | |
|             switch (grid[y*3+x]) {
 | |
|                 case 0: break;
 | |
|                 case myId: {
 | |
|                     const p = gridIndexToCoords(gridOrigin, x, y);
 | |
|                     mySymbol == "o" ? drawCircle(ctx, p) : drawCross(ctx, p);
 | |
|                     break;
 | |
| 
 | |
|                 }
 | |
|                 default: {
 | |
|                     const p = gridIndexToCoords(gridOrigin, x, y);
 | |
|                     mySymbol == "o" ? drawCross(ctx, p) : drawCircle(ctx, p);
 | |
|                     break;
 | |
|                 }
 | |
|             }
 | |
|         }
 | |
|     }
 | |
| }
 | |
| 
 | |
| // update loop, called every frame
 | |
| function update(ctx: CanvasRenderingContext2D, time: number, ws: WebSocket) {
 | |
|     const gridOrigin: Point = { 
 | |
|         x: ctx.canvas.width / 2 - GRID_SIZE / 2,
 | |
|         y: ctx.canvas.height / 2 - GRID_SIZE / 2
 | |
|     };
 | |
|     ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
 | |
| 
 | |
|     ctx.fillStyle = "white";
 | |
|     ctx.font = "24px sans-serif";
 | |
|     ctx.fillText(canvasMsg, 10, 30);
 | |
| 
 | |
|     drawGridBackground(ctx, gridOrigin);
 | |
|     handlePendingEvts(ws, gridOrigin);
 | |
|     handlePendingShapes(ctx, gridOrigin, time);
 | |
|     updateGridState(ctx, gridOrigin);
 | |
| 
 | |
|     window.requestAnimationFrame(t => update(ctx, t, ws));
 | |
| }
 | |
| 
 | |
| function init() {
 | |
|     // canvas stuff
 | |
|     const canvas = document.getElementById("game") as HTMLCanvasElement | null;
 | |
|     if (!canvas) throw new Error("unable to get canvas HTML element");
 | |
|     const ctx = canvas.getContext("2d") as CanvasRenderingContext2D | null;
 | |
|     if (!ctx) throw new Error("unable to get canvas 2D context");
 | |
|     resizeCanvas(ctx); // Init canvas
 | |
| 
 | |
|     canvas.addEventListener("click", (evt) => {
 | |
|         const {clientX, clientY} = evt;
 | |
|         pendingEvts.push({x: clientX, y: clientY});
 | |
|     });
 | |
| 
 | |
|     // websocket stuff
 | |
|     ws.onopen = (e) => {
 | |
|         console.log("connected to websocket");
 | |
|     };
 | |
| 
 | |
|     ws.onmessage = (evt) => {
 | |
|         const msg: Message = JSON.parse(evt.data);
 | |
|         console.log(msg);
 | |
|         switch (msg.kind) {
 | |
|             case "hello": {
 | |
|                 myId = (msg.data as Hello).id;
 | |
|                 mySymbol = (msg.data as Hello).symbol;
 | |
|                 canvasMsg = `connected to server with id ${myId}, ${mySymbol}`;
 | |
|                 console.log(canvasMsg);
 | |
|                 break;
 | |
|             }
 | |
|             case "update": {
 | |
|         const res = msg.data as Response;
 | |
|         const shape: Shape = {
 | |
|             kind: res.last.symbol,
 | |
|             pos: { x: res.last.x, y: res.last.y },
 | |
|             hue: Math.floor(Math.random() * 255),
 | |
|             time: null,
 | |
|         };
 | |
|                 grid = res.grid;
 | |
|         pendingShapes.push(shape);
 | |
|                 console.log(grid);
 | |
|                 break;
 | |
|             }
 | |
|             case "endgame": {
 | |
|                 const issue = (msg.data as EndGame).issue;
 | |
|                 switch (issue) {
 | |
|                     case "win": canvasMsg = "you won"; break;
 | |
|                     case "lose": canvasMsg = "you lose"; break;
 | |
|                     case "draw": canvasMsg = "it's a draw!"; break;
 | |
|                     default: throw new Error(`unexpected ${issue}`);
 | |
|                 }
 | |
|                 break;
 | |
|             }
 | |
|             default: {
 | |
|                 console.warn("unhandled message kind:", msg.kind);
 | |
|                 break;
 | |
|             }
 | |
|         }
 | |
|     };
 | |
| 
 | |
|     //window.addEventListener('resize', () => resizeCanvas(ctx));
 | |
|     window.requestAnimationFrame(t => update(ctx, t, ws));
 | |
| }
 | |
| 
 | |
| init();
 |