Skip to content

Commit

Permalink
Replace chessboard-element with a custom canvas renderer for the main…
Browse files Browse the repository at this point in the history
… page
  • Loading branch information
StanislavNikolov committed Aug 22, 2023
1 parent 38f69b5 commit 555aad1
Show file tree
Hide file tree
Showing 4 changed files with 210 additions and 30 deletions.
160 changes: 160 additions & 0 deletions frontend/canvas-chess-renderer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
const atlas = new Image();
atlas.src = "/public/chess-pieces.png";
const ASS = 240; // Atlas Sprite Size

type PieceType = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11;
type Position = [ number, number ];
type PieceAnimation = [PieceType , Position, Position | null];

export default class CanvasChessRenderer {
canvas: HTMLCanvasElement;
ctx: CanvasRenderingContext2D;
pieceAnimations: PieceAnimation[] = [];
animationBegin: number;
ANIMATION_LENGTH = 500;
animationRequest: number | null = null;

constructor(canvas: HTMLCanvasElement) {
this.canvas = canvas;
this.ctx = this.canvas.getContext('2d')!;
this.resize();

const resizeObserver = new ResizeObserver(() => this.resize());
resizeObserver.observe(canvas);
}

resize() {
if (this.canvas.clientWidth === 0) { // Why is that needed?
console.log("refusing resize")
return;
}
this.canvas.width = this.canvas.clientWidth * window.devicePixelRatio;
this.canvas.height = this.canvas.clientHeight * window.devicePixelRatio;
this.#moarAnimationNeeded();
}

setPosition(fen: string) {
let idx = 0;
const newPieces: [PieceType, Position][] = [];
for(const c of fen.split(' ')[0]) {
if(c === '/') { continue; }
if(c === '1') { idx += 1; continue; }
if(c === '2') { idx += 2; continue; }
if(c === '3') { idx += 3; continue; }
if(c === '4') { idx += 4; continue; }
if(c === '5') { idx += 5; continue; }
if(c === '6') { idx += 6; continue; }
if(c === '7') { idx += 7; continue; }
if(c === '8') { idx += 8; continue; }
const pt = {p:0,P:1,n:2,N:3,b:4,B:5,r:6,R:7,q:8,Q:9,k:10,K:11}[c];
newPieces.push([pt, [Math.floor(idx / 8), idx % 8]]);
idx += 1;
}

// Match newPieces with this.pieces so that we know what moved where and animate
// properly.
const newPieceAnimations: PieceAnimation[] = [];

// 0. Remove piece animations that are already done.
this.pieceAnimations = this.pieceAnimations.filter(([, , currPos]) => currPos != null);

// 1. Match new pieces that didn't move at all.
for(let i = 0;i < newPieces.length;i ++) {
for(let j = 0;j < this.pieceAnimations.length;j ++) {
if (newPieces[i][0] !== this.pieceAnimations[j][0]) continue;
if (newPieces[i][1][0] !== this.pieceAnimations[j][2]![0]) continue;
if (newPieces[i][1][1] !== this.pieceAnimations[j][2]![1]) continue;

// Matched i and j, yey!
newPieceAnimations.push([newPieces[i][0], newPieces[i][1], newPieces[i][1]]);
newPieces.splice(i, 1);
this.pieceAnimations.splice(j, 1);
i --;
break;
}
}

// 2. Match new pieces that moved.
for(let i = 0;i < newPieces.length;i ++) {
for(let j = 0;j < this.pieceAnimations.length;j ++) {
if (newPieces[i][0] !== this.pieceAnimations[j][0]) continue;

// Matched i and j, yey!
newPieceAnimations.push([newPieces[i][0], this.pieceAnimations[j][2]!, newPieces[i][1]]);
newPieces.splice(i, 1);
this.pieceAnimations.splice(j, 1);
i --;
break;
}
}

// 3. Make brand new pieces appear.
for (const np of newPieces) {
newPieceAnimations.push([np[0], null, np[1]]);
}

// 4. Make old pieces disappear.
for(const pa of this.pieceAnimations) {
newPieceAnimations.push([pa[0], pa[2]!, null]);
}

this.animationBegin = Date.now();
this.pieceAnimations = newPieceAnimations;

this.#moarAnimationNeeded();
}

#moarAnimationNeeded() {
if (this.animationRequest != null) return; // Already animation requested.
this.animationRequest = window.requestAnimationFrame(() => this.#draw());
}

#draw() {
const drawSize = this.canvas.width / 8;

let T = (Date.now() - this.animationBegin) / this.ANIMATION_LENGTH;
if (T > 1) T = 1;
// https://math.stackexchange.com/questions/121720/ease-in-out-function/121755#121755
const animT = Math.pow(T, 3) / (Math.pow(T, 3) + Math.pow(1-T, 3));

// Draw the background checkerboard pattern.
this.ctx.fillStyle = '#b18a66';
this.ctx.globalAlpha = 1;
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
this.ctx.fillStyle = '#eedab8';
for(let row = 0;row < 8; row ++) {
for(let col = 0;col < 8; col ++) {
if(row % 2 !== col % 2) continue;
this.ctx.fillRect(col * drawSize, row * drawSize, drawSize, drawSize);
}
}

for(const [pt, lastPos, currPos] of this.pieceAnimations) {
let dx = 0, dy = 0;
if (currPos == null) {
// Animate by fading out.
this.ctx.globalAlpha = 1 - animT;
dy = lastPos[0];
dx = lastPos[1];
} else if (lastPos == null) {
// Animate by fading in.
this.ctx.globalAlpha = animT;
dy = currPos[0];
dx = currPos[1];
} else {
// Animate by moving the piece.
this.ctx.globalAlpha = 1;
dy = lastPos[0] + (currPos[0] - lastPos[0]) * animT;
dx = lastPos[1] + (currPos[1] - lastPos[1]) * animT;
}

const cutX = Math.floor(pt / 2);
const cutY = pt % 2; // White pieces are on the top row of the atlas.

this.ctx.drawImage(atlas, cutX * ASS, cutY * ASS, ASS, ASS, dx * drawSize, dy * drawSize, drawSize, drawSize);
}

this.animationRequest = null;
if (T < 1) this.#moarAnimationNeeded();
}
};
74 changes: 45 additions & 29 deletions frontend/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import "chessboard-element";
import CanvasChessRenderer from './canvas-chess-renderer';

const $ = s => document.querySelector(s);

Expand All @@ -8,13 +8,19 @@ function sanitizeHTML(text) {
return element.innerHTML;
}

function html2element(html: string) {
const template = document.createElement("template");
template.innerHTML = html.trim();
return template.content.firstChild as HTMLElement;
}

interface MyBots {
id?: number;
name?: string;
email?: string;
bots: number[];
}
let globalMyBots: MyBots = { id: null, name: null, bots: [] };
let globalMyBots: MyBots = { bots: [] };

interface Dev {
id: number;
Expand All @@ -32,6 +38,19 @@ interface OldGame {
reason: string;
};

interface LiveGame {
id: number;
wid: number;
wname: string;
welo: number;
bid: number;
bname: string;
belo: number;
initial_position: string;
fen: string;
};
const livesGames: Record<number, {ccr: CanvasChessRenderer, el: HTMLElement}> = [];

interface Bot {
id: number;
name: string;
Expand Down Expand Up @@ -72,7 +91,7 @@ function renderEditableLeaderboardItem(place: number, name: string, elo: number,
`;
}

function renderOldGame(g: OldGame) {
function renderOldGame(g: OldGame): HTMLElement {
return `
<a class="game" href="/game/${g.id}/">
<span class="bot white">
Expand All @@ -87,11 +106,11 @@ function renderOldGame(g: OldGame) {
`;
}

function renderLiveGame(g) {
function renderLiveGame(g: LiveGame) {
return `
<div data-game-id=${g.id} class="live-game">
<div class="live-game used">
<div class="name">${sanitizeHTML(g.bname)} <span class="elo">${g.belo.toFixed(0)}</span></div>
<chess-board position=${g.fen}></chess-board>
<canvas></canvas>
<div class="name">${sanitizeHTML(g.wname)} <span class="elo">${g.welo.toFixed(0)}</span></div>
</div>
`;
Expand Down Expand Up @@ -137,41 +156,39 @@ async function updateOldGames() {
}

async function updateLiveGames() {
const req = await fetch('/api/live-games/')
const games = await req.json();
const req = await fetch("https://chess.stjo.dev/api/live-games/")
const games = await req.json() as LiveGame[];

for (const drawnGame of document.querySelectorAll("[data-game-id]")) {
const gameId = Number(drawnGame.getAttribute("data-game-id"));
if (!games.find(g => g.id === gameId)) drawnGame.removeAttribute("data-game-id");
for (const gameId in livesGames) {
if (games.find(g => g.id === Number(gameId))) continue;
livesGames[gameId].el.classList.remove("used");
delete livesGames[gameId];
}

for (const g of games) {
const existingEl = document.querySelector(`[data-game-id="${g.id}"]`);
// Try to update the already rendered board.
if (existingEl) {
existingEl.querySelector('chess-board')!.setPosition(g.fen);
const existingGame = livesGames[g.id];
if (existingGame) {
existingGame.ccr.setPosition(g.fen);
continue;
}

// Try to find an empty board to connect to - we do that to minimize the flashes
const candidate = document.querySelector(".live-game:not([data-game-id])");
// Try to find an empty board to "connect to" - we do that to minimize the flashes
const candidate = $(".live-game:not(.used)");
if (candidate) {
candidate.outerHTML = renderLiveGame(g);
const newEl = html2element(renderLiveGame(g));
candidate.replaceWith(newEl);
const canvas = newEl.querySelector('canvas')!;
livesGames[g.id] = { el: newEl, ccr: new CanvasChessRenderer(canvas) };
continue;
}

// Last resort - make a new square.
$('#live-games').innerHTML += renderLiveGame(g);
const el = html2element(renderLiveGame(g));
$('#live-games').appendChild(el);
console.log(el);
livesGames[g.id] = { el, ccr: new CanvasChessRenderer(el.querySelector('canvas')!) };
}

// There is a bug with the chess-board library. It has an element
// taking space that shouldn't exist.
setTimeout(() => {
for (const cb of document.querySelectorAll('chess-board')) {
cb.shadowRoot.querySelector('#dragged-pieces')?.remove();
}
}, 0);

setTimeout(updateLiveGames, 500);
};

Expand All @@ -195,14 +212,13 @@ async function updateMyBots() {
}
}


updateMyBots();
updateLiveGames();
updateOldGames();
updateDevLeaderboard();
updateBotLeaderboard();

$("#timer-content").innerHTML = ((new Date('2023-10-01') - new Date()) / (1000 * 60 * 60 * 24)).toFixed(0);
$("#timer-content").innerHTML = ((new Date('2023-10-01').getTime() - Date.now()) / (1000 * 60 * 60 * 24)).toFixed(0);

$("#open-upload-dialog").addEventListener("click", () => {
$("#compilation-message").classList.toggle('hidden', true);
Expand Down
Binary file added public/chess-pieces.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 5 additions & 1 deletion public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -180,8 +180,12 @@
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
overflow-y: auto;
}
canvas {
aspect-ratio: 1;
width: 100%;
}

.live-game:not([data-game-id]) {
.live-game:not(.used) {
opacity: 0;
}

Expand Down

0 comments on commit 555aad1

Please sign in to comment.