willem-truyen-network-site/src/js/game-tetris-context2d.js
2024-08-07 16:42:31 +02:00

567 lines
18 KiB
JavaScript
Executable File

/* About: Tetris for HTML, you can insert it in anny Div tagged with an id
* This is a simple elegant sample for how to work with a Canvas
*/
class Tetris_CTX2D {
static get DEBUG_PARENT_OBJECT() { return true; };
static get AUTO_CONTINUE_ON_FOCUS() { return false; };
static get SHOW_FPS_INTERVAL() { return 1000 / 4; }; // four times per second
// static get TICK_TIME() { return 1 / 4; };
static get STATE_RUNNING() { return 0; };
static get STATE_ENDED() { return 1; };
static get GRID_COLUMS() { return 10; };
static get GRID_ROWS() { return 20; };
static get SHAPES() {
return [
[0, 0, 0, 0,
1, 1, 1, 1],
[0, 0, 0, 0,
1, 1, 1, 0,
1],
[0, 0, 0, 0,
1, 1, 1, 0,
0, 0, 1],
[0, 0, 0, 0,
0, 1, 1, 0,
0, 1, 1],
[0, 0, 0, 0,
1, 1, 0, 0,
0, 1, 1],
[0, 0, 0, 0,
0, 1, 1, 0,
1, 1],
[0, 0, 0, 0,
0, 1, 0, 0,
1, 1, 1]
];
};
static get COLORS() {
return [
'#74A5FD', 'orange', 'blue', '#FFD700', 'red', '#22AB22', '#BA55D3'
];
};
static get KEY_ARROW_UP() { return 'ArrowUp'; };
static get KEY_ARROW_DOWN() { return 'ArrowDown'; };
static get KEY_ARROW_LEFT() { return 'ArrowLeft'; };
static get KEY_ARROW_RIGHT() { return 'ArrowRight'; };
static get KEY_W() { return 'w'; };
static get KEY_S() { return 's'; };
static get KEY_A() { return 'a'; };
static get KEY_D() { return 'd'; };
static get KEY_ENTER() { return 'Enter'; };
static get KEY_SPACEBAR() { return ' '; };
static get KEY_ESCAPE() { return 'Escape'; };
running = false;
animationRequestId = 0;
constructor(divId, width, height, difficulty = 1, showFps = false) {
this.createCanvas(divId, width, height);
this.canvasEl.title = "Playing: Tetris";
this.ctx = this.canvasEl.getContext("2d");
this.ctx.textAlign = "center";
this.difficulty = difficulty;
this.BLOCK_WIDTH = this.width / Tetris_CTX2D.GRID_COLUMS;
this.BLOCK_HEIGHT = this.height / Tetris_CTX2D.GRID_ROWS;
this.audioCtx = new AudioContext();
this.sounds = [];
this.sounds[0] = new Audio('./snd/pop.mp3');
this.audioCtx.createMediaElementSource(this.sounds[0]).connect(this.audioCtx.destination);
this.sounds[1] = new Audio('./snd/click-button.mp3');
this.audioCtx.createMediaElementSource(this.sounds[1]).connect(this.audioCtx.destination);
this.sounds[2] = new Audio('./snd/short-success.mp3');
this.audioCtx.createMediaElementSource(this.sounds[2]).connect(this.audioCtx.destination);
this.showFps = showFps;
if (showFps) {
// add framecounter and fps variables and html
this.frameCounter = 0;
this.initTime = performance.now();
const el = document.createElement('p');
this.frameLabel = document.createElement('span');
const floatRight = document.createElement('span');
floatRight.style = "float: right;";
this.fpsCounter = 0;
this.fpsLabel = document.createElement('span');
el.appendChild(document.createTextNode('fps: '));
el.appendChild(this.fpsLabel);
floatRight.appendChild(document.createTextNode('frame: '));
floatRight.appendChild(this.frameLabel);
el.appendChild(floatRight);
this.divEl.appendChild(el);
}
this.newGame();
this.canvasEl.addEventListener("keydown", (e) => this.onKeyDown(e));
// this.canvasEl.addEventListener("keyup", (e) => this.onKeyUp(e));
this.canvasEl.addEventListener("blur", (e) => this.onBlur(e));
this.canvasEl.addEventListener("focus", (e) => this.onFocus(e));
if (Tetris_CTX2D.DEBUG_PARENT_OBJECT) window.game = this;
if (typeof terminalAddCommand === "function") terminalAddCommand("restartgame", (t) => this.terminalRestartGame(t));
if (typeof terminalPrintLn === "function") terminalPrintLn("new Tetris_CTX2D: @", divId, ' ', width, 'x', height, ' difficulty=', difficulty, ' showFps=', showFps);
this.drawCanvas();
}
terminalRestartGame(term) {
term.terminalClose();
this.restartGame();
// this.canvasEl.focus();
setTimeout(() => { this.canvasEl.focus(); this.pausePlayGame(); }, 200);
}
createCanvas(divId, width = 0, height = 0, zoom = 1) {
this.divEl = document.getElementById(divId);
if (this.divEl === null) throw new Error("elementId not found: " + divId);
while (this.divEl.firstChild) {
this.divEl.removeChild(this.divEl.lastChild);
}
this.canvasEl = this.divEl.appendChild(document.createElement("canvas"));
const c = this.canvasEl;
this.width = width;
this.height = height;
if (width > 0 && height > 0) {
c.style.width = width * zoom + 'px';
c.style.height = height * zoom + 'px';
c.width = width;
c.height = height;
}
c.tabIndex = 0; // improtant for keyboard focus!
// c.style.imageRendering = 'pixelated';
}
// creates a new 4x4 shape in global variable 'current'
// 4x4 so as to cover the size when the shape is rotated
newCurrentShape() {
this.current = this.nextShape;
this.nextShape = this.newShape();
this.currentX = Tetris_CTX2D.GRID_COLUMS / 2 - 4 / 2;
this.currentY = 0;
}
newShape() {
let shapeId = Math.floor(Math.random() * Tetris_CTX2D.SHAPES.length);
for (let i = 0; shapeId == this.nextShapeId && i < 3; i++) {
shapeId = Math.floor(Math.random() * Tetris_CTX2D.SHAPES.length);
}
this.nextShapeId = shapeId;
const reference = Tetris_CTX2D.SHAPES[shapeId];
let newShape = [];
for (let y = 0; y < 4; ++y) {
newShape[y] = [];
for (let x = 0; x < 4; ++x) {
let i = 4 * y + x;
if (typeof reference[i] != 'undefined' && reference[i]) {
newShape[y][x] = shapeId + 1;
}
else {
newShape[y][x] = 0;
}
}
}
return newShape;
}
// clears the board
initBoard() {
this.board = [];
for (let y = 0; y < Tetris_CTX2D.GRID_ROWS; ++y) {
this.board[y] = [];
for (let x = 0; x < Tetris_CTX2D.GRID_COLUMS; ++x) {
this.board[y][x] = 0;
}
}
}
// keep the element moving down, creating new shapes and clearing lines
tick() {
if (this.isValidMove(0, 1)) {
++this.currentY;
} else if (this.currentY == 0) {
this.endGame();
} else {
this.freezeCurrentShapeToBoard();
this.clearLines();
this.newCurrentShape();
}
}
// stop shape at its position and fix it to board
freezeCurrentShapeToBoard() {
if (this.currentY == 0) {
this.lose = true;
return;
}
this.playSound(0);
for (let y = 0; y < 4; ++y) {
for (let x = 0; x < 4; ++x) {
if (this.current[y][x]) {
this.board[y + this.currentY][x + this.currentX] = this.current[y][x];
}
}
}
}
// returns the rotated shape 'current' perpendicularly anticlockwise
rotateShape(current) {
let newCurrent = [];
for (let y = 0; y < 4; ++y) {
newCurrent[y] = [];
for (let x = 0; x < 4; ++x) {
newCurrent[y][x] = current[3 - x][y];
}
}
return newCurrent;
}
// check if any lines are filled and clear them
clearLines() {
let rowsFilled = 0;
for (let y = Tetris_CTX2D.GRID_ROWS - 1; y >= 0; --y) {
let rowFilled = true;
for (let x = 0; x < Tetris_CTX2D.GRID_COLUMS; ++x) {
if (this.board[y][x] == 0) {
rowFilled = false;
break;
}
}
if (rowFilled) {
rowsFilled++;
this.lose = false;
for (let yy = y; yy > 0; --yy) {
for (let x = 0; x < Tetris_CTX2D.GRID_COLUMS; ++x) {
this.board[yy][x] = this.board[yy - 1][x];
}
}
++y;
}
}
if (rowsFilled > 0) {
let points = 10 * rowsFilled * rowsFilled;
this.score += points;
this.playSound(2);
if (typeof terminalPrintLn === "function") {
if (rowsFilled > 1) terminalPrintLn("Cleared ", rowsFilled, " lines! ", points, " points earned. Score=", this.score);
else terminalPrintLn("Cleared a line! ", points, " points earned. Score=", this.score);
}
}
}
playSound(index) {
try {
const snd = this.sounds[index];
snd.currentTime = 0;
snd.play();
} catch (e) {
console.log(`Failed to play sound '${index}': ${e}}`)
}
}
// checks if the resulting position of current shape will be feasible
isValidMove(offsetX = 0, offsetY = 0, newCurrent = this.current) {
offsetX = this.currentX + offsetX;
offsetY = this.currentY + offsetY;
for (let y = 0; y < 4; ++y) {
for (let x = 0; x < 4; ++x) {
if (newCurrent[y][x]) {
if (typeof this.board[y + offsetY] == 'undefined'
|| typeof this.board[y + offsetY][x + offsetX] == 'undefined'
|| this.board[y + offsetY][x + offsetX]
|| x + offsetX < 0
|| y + offsetY >= Tetris_CTX2D.GRID_ROWS
|| x + offsetX >= Tetris_CTX2D.GRID_COLUMS) {
return false;
}
}
}
}
return true;
}
newGame() {
this.gameState = Tetris_CTX2D.STATE_RUNNING;
this.tickTime = 0;
this.stepTime = 1/4;
if(this.difficulty == 0){
this.stepTime = 1/2;
}else{
this.stepTime = this.stepTime/ this.difficulty;
}
this.initBoard();
this.score = 0;
this.nextShape = this.newShape();
this.newCurrentShape();
this.lose = false;
}
restartGame() {
this.newGame();
}
endGame() {
if (typeof terminalPrintLn === "function") terminalPrintLn("Ending tetris with " + this.score + " points.");
// console.log("Ending with Score" + this.score + " points");
this.running = false;
this.gameState = Tetris_CTX2D.STATE_ENDED;
}
startRunning(fn) {
if (this.animationRequestId != 0) cancelAnimationFrame(this.animationRequestId);
this.running = true;
this.prevNow = performance.now();
if (this.showFps) {
this.initTime = performance.now();
}
this.animationRequestId = requestAnimationFrame(fn);
}
close() {
if (this.animationRequestId != 0) {
cancelAnimationFrame(this.animationRequestId);
this.animationRequestId = 0;
}
this.running = false;
}
pausePlayGame() {
if (this.gameState == Tetris_CTX2D.STATE_ENDED) return;
this.running = !this.running;
if (this.running) {
this.startRunning(() => this.updateCanvas());
}
}
updateCanvas() {
const now = performance.now();
const timeDelta = (now - this.prevNow) / 1000; //timeDelta = (milli - milli) / toSeconds
this.prevNow = now;
//#state switch
if (this.gameState == Tetris_CTX2D.STATE_RUNNING) {
this.tickTime += timeDelta;
if (this.tickTime >= this.stepTime) {
this.tickTime -= this.stepTime;
this.tick();
}
}
this.drawCanvas();
if (this.running) { // loop
this.animationRequestId = requestAnimationFrame(() => this.updateCanvas());
} else { // not looping
this.animationRequestId = 0;
}
}
// draws the board and the moving shape
drawCanvas() {
if (this.showFps) {
this.frameCounter++;
this.fpsCounter++;
const now = performance.now();
const diff = now - this.initTime;
if (Tetris_CTX2D.SHOW_FPS_INTERVAL < diff || !this.running) {
this.initTime = now;
const seconds = diff / 1000;
const fps = this.fpsCounter / seconds;
this.fpsCounter = 0;
if (this.frameLabel) this.frameLabel.innerHTML = this.frameCounter;
if (this.fpsLabel) {
this.fpsLabel.innerHTML = Math.round((fps + Number.EPSILON) * 100) / 100;
if (!this.running) this.fpsLabel.innerHTML += " (not running)";
}
}
}
// draw game
const ctx = this.ctx;
ctx.clearRect(0, 0, this.width, this.height);
//# grid
ctx.strokeStyle = 'lightgrey';
for (let x = 0; x < Tetris_CTX2D.GRID_COLUMS; ++x) {
ctx.beginPath();
ctx.moveTo(x * this.BLOCK_WIDTH, 0);
ctx.lineTo(x * this.BLOCK_WIDTH, Tetris_CTX2D.GRID_ROWS * this.BLOCK_HEIGHT);
ctx.stroke();
}
for (let y = 0; y < Tetris_CTX2D.GRID_ROWS; ++y) {
ctx.beginPath();
ctx.moveTo(0, y * this.BLOCK_HEIGHT);
ctx.lineTo(Tetris_CTX2D.GRID_COLUMS * this.BLOCK_WIDTH, y * this.BLOCK_HEIGHT);
ctx.stroke();
}
// board
ctx.strokeStyle = '#404040';
for (let x = 0; x < Tetris_CTX2D.GRID_COLUMS; ++x) {
for (let y = 0; y < Tetris_CTX2D.GRID_ROWS; ++y) {
if (this.board[y][x]) {
ctx.fillStyle = Tetris_CTX2D.COLORS[this.board[y][x] - 1];
ctx.fillRect(1 + this.BLOCK_WIDTH * (x), 1 + this.BLOCK_HEIGHT * (y), this.BLOCK_WIDTH - 1, this.BLOCK_HEIGHT - 1);
ctx.strokeRect(1 + this.BLOCK_WIDTH * (x), 1 + this.BLOCK_HEIGHT * (y), this.BLOCK_WIDTH - 1, this.BLOCK_HEIGHT - 1);
}
}
}
// next shape
const offX = 8, offY = 0;
for (let y = 0; y < 4; ++y) {
for (let x = 0; x < 4; ++x) {
if (this.nextShape[y][x]) {
ctx.fillStyle = Tetris_CTX2D.COLORS[this.nextShape[y][x] - 1];
ctx.fillRect(1 + this.BLOCK_WIDTH * offX + x * this.BLOCK_WIDTH / 2, 1 + this.BLOCK_HEIGHT * offY + y * this.BLOCK_HEIGHT / 2, this.BLOCK_WIDTH / 2 - 1, this.BLOCK_HEIGHT / 2 - 1);
ctx.strokeRect(1 + this.BLOCK_WIDTH * offX + x * this.BLOCK_WIDTH / 2, 1 + this.BLOCK_HEIGHT * offY + y * this.BLOCK_HEIGHT / 2, this.BLOCK_WIDTH / 2 - 1, this.BLOCK_HEIGHT / 2 - 1);
}
}
}
// tickTime
ctx.strokeStyle = '#8080B0';
const rotation = this.tickTime / this.stepTime * Math.PI / 2;
for (let i = 0; i < 4; i++) {
const r = rotation + i * Math.PI / 2;
ctx.beginPath();
ctx.moveTo(this.BLOCK_WIDTH, this.BLOCK_HEIGHT);
ctx.lineTo(this.BLOCK_WIDTH + this.BLOCK_WIDTH * Math.cos(r) / 2, this.BLOCK_HEIGHT + this.BLOCK_HEIGHT * Math.sin(r) / 2);
ctx.stroke();
}
// current shape
//# shadow
ctx.save();
ctx.shadowColor = '#000000bf';
ctx.shadowBlur = 3;
ctx.shadowOffsetX = 2;
ctx.shadowOffsetY = 2;
for (let y = 0; y < 4; ++y) {
for (let x = 0; x < 4; ++x) {
if (this.current[y][x]) {
ctx.fillStyle = Tetris_CTX2D.COLORS[this.current[y][x] - 1];
ctx.fillRect(1 + this.BLOCK_WIDTH * (this.currentX + x), 1 + this.BLOCK_HEIGHT * (this.currentY + y), this.BLOCK_WIDTH - 1, this.BLOCK_HEIGHT - 1);
}
}
}
ctx.restore(); //end shadow
ctx.strokeStyle = '#00000060';
for (let y = 0; y < 4; ++y) {
for (let x = 0; x < 4; ++x) {
if (this.current[y][x]) {
ctx.strokeRect(1 + this.BLOCK_WIDTH * (this.currentX + x), 1 + this.BLOCK_HEIGHT * (this.currentY + y), this.BLOCK_WIDTH - 1, this.BLOCK_HEIGHT - 1);
}
}
}
const FONT_SIZE = this.width > 440 ? 24 : 16;
ctx.font = (1.5 * FONT_SIZE) + "px serif";
ctx.fillStyle = '#408040';
ctx.fillText("score: " + this.score, this.width / 2, FONT_SIZE * 1.5);
ctx.font = FONT_SIZE + "px serif";
// unfocused & paused banner
if (!this.running) {
const x = this.width / 2;
const y = this.height / 2;
const y2 = y - FONT_SIZE / 2;
ctx.strokeStyle = '#404040';
const g = ctx.createLinearGradient(0, 0, this.width, 0);
g.addColorStop(0, '#404040a0');
g.addColorStop(0.2, '#404040df');
g.addColorStop(0.8, '#404040df');
g.addColorStop(1, '#404040a0');
ctx.fillStyle = g;
ctx.fillRect(0, y2 - 2, this.width + 1, FONT_SIZE + 5 * 2);
ctx.strokeRect(0, y2 - 2, this.width + 1, FONT_SIZE + 5 * 2);
ctx.fillStyle = (this.gameState === Tetris_CTX2D.STATE_ENDED) ? 'red' : 'gray';
ctx.font = 3 * FONT_SIZE + "px serif";
let text = (this.gameState === Tetris_CTX2D.STATE_ENDED) ? "Game over!" : "Paused";
ctx.fillText(text, x, y - 2 * FONT_SIZE);
ctx.strokeText(text, x, y - 2 * FONT_SIZE);
ctx.fillStyle = this.isFocused ? 'goldenrod' : 'lightgray';
ctx.font = FONT_SIZE + "px serif";
text = this.isFocused ? `Press space or enter to ${(this.gameState === Tetris_CTX2D.STATE_ENDED) ? "start next round" : "continue"}.` : "Click here to continue. (unfocused)";
ctx.strokeText(text, x, y2 + FONT_SIZE);
ctx.fillText(text, x, y2 + FONT_SIZE);
}
}
onKeyDown(e) {
if (e.key == Tetris_CTX2D.KEY_ESCAPE) {
if (this.running) this.pausePlayGame()
e.preventDefault();
return false;
}
if (this.running && this.gameState == Tetris_CTX2D.STATE_RUNNING) {
if (e.key == Tetris_CTX2D.KEY_ARROW_UP || e.key == Tetris_CTX2D.KEY_W) {
let rotatedShape = this.rotateShape(this.current);
if (this.isValidMove(0, 0, rotatedShape)) {
this.current = rotatedShape;
this.playSound(1);
}
e.preventDefault();
return false;
} else if (e.key == Tetris_CTX2D.KEY_ARROW_DOWN || e.key == Tetris_CTX2D.KEY_S) {
this.tick();
this.tickTime = 0;
e.preventDefault();
return false;
} else if (e.key == Tetris_CTX2D.KEY_ARROW_LEFT || e.key == Tetris_CTX2D.KEY_A) {
if (this.isValidMove(-1)) {
this.currentX--;
}
e.preventDefault();
return false;
} else if (e.key == Tetris_CTX2D.KEY_ARROW_RIGHT || e.key == Tetris_CTX2D.KEY_D) {
if (this.isValidMove(1)) {
this.currentX++;
}
e.preventDefault();
return false;
}
} else if (this.gameState == Tetris_CTX2D.STATE_ENDED) {
if (e.key == Tetris_CTX2D.KEY_SPACEBAR || e.key == Tetris_CTX2D.KEY_ENTER) {
this.restartGame();
this.startRunning(() => this.updateCanvas());
e.preventDefault();
return false;
}
}
if (e.key == Tetris_CTX2D.KEY_SPACEBAR || e.key == Tetris_CTX2D.KEY_ENTER) {
this.pausePlayGame();
e.preventDefault();
return false;
}
return true;
}
onBlur() {
this.audioCtx.suspend();
this.isFocused = false;
this.canvasEl.style.borderColor = null;
if (this.running) {
this.pausePlayGame();
} else {
this.drawCanvas();
}
}
onFocus() {
this.audioCtx.resume();
this.isFocused = true;
this.canvasEl.style.borderColor = "red";
if (!this.running && Tetris_CTX2D.AUTO_CONTINUE_ON_FOCUS) {
this.pausePlayGame();
} else {
this.drawCanvas();
}
}
}
function startTetris(divId, width = 300, height = 600, difficulty = 1, showFps = true) {
return new Tetris_CTX2D(divId, width, height, difficulty, showFps);
}