| Webtris |
|
|
Canvas element
Canvas context Transformations Compositing Colours Context styles Gradients Patterns Line styles Shadows Simple shapes Paths Text Images Pixel manipulation Example: Webtris
Source (click to expand)// Constants
const dir0 = 0;
const dir90 = 1;
const dir180 = 2;
const dir270 = 3;
const dir360 = 4;
const backColour = '#000000';
const version = 'v0.2';
const cellWidth = 16;
const cellHeight = 16;
const blockWidth = cellWidth * 4;
const blockHeight = cellHeight * 4;
const blockSize = blockWidth;
const defaultDelayGravity = 60;
const sNone = 0;
const sTitleScreen = 1;
const sPlaying = 2;
const sPaused = 3;
const sScoring = 4;
const sGameOver = 5;
// Cache objects
var objCanvas = document.getElementById('canvas');
var objBuffer = document.getElementById('buffer');
var objBlockBuffer = document.getElementById('blockbuffer');
var objFrameBuffer = document.getElementById('framebuffer');
var ctxCanvas = objCanvas.getContext('2d');
var ctxBuffer = objBuffer.getContext('2d');
var ctxBlockBuffer = objBlockBuffer.getContext('2d');
var ctxFrameBuffer = objFrameBuffer.getContext('2d');
// Gamve variables
var fps = 0; // The actual fps the game is running at
var fpsTarget = 30; // Targeted fps
var fpsLimit = true; // Attempt to limit the frames per second
var framePrev = 0; // The time the previous frame started
var frameStart = 0; // The time the current frame started
var frameCount = 0; // The number of frames rendered so far
var frameDelta = 0; // The time difference between this and the previous frame
var frameDelay = 1000 / this.fpsTarget;
var gameStart = 0; // The time the game started
var state = sNone; // The current game state
var testing = false; // When true, display the test info
var quit = false; // When true the game is over
var score = 0; // The current player's score
var scoreMult = 0; // Score multiplier for clearing more than one row at the same time
var level = 1; // The current play level
var rowsCleared = 0; // Total number of rows cleared for the session
// Returns a random number between 0 and n - 1
function rnd(n) {
return Math.ceil(Math.random() * n) - 1;
}
// Fill the rectangle with the current colour
function fillRect(x, y, w, h, ctx) {
ctx.fillRect(x, y, w, h);
}
// Outline a rectangle
function drawRect(x, y, w, h, ctx) {
ctx.strokeRect(x, y, w, h);
}
//=================================================================
// cRect
//=================================================================
function cRect(x, y, w, h) {
this.x = 0;
this.y = 0;
this.w = 0;
this.h = 0;
if (typeof(x) != 'undefined') this.x = x;
if (typeof(y) != 'undefined') this.y = y;
if (typeof(w) != 'undefined') this.w = w;
if (typeof(h) != 'undefined') this.h = h;
}
//=================================================================
// cBlock
//=================================================================
function cBlock(colour, src) {
this.colour = colour;
this.data = new Array(new Array(new Array(),new Array(),new Array(),new Array()),
new Array(new Array(),new Array(),new Array(),new Array()),
new Array(new Array(),new Array(),new Array(),new Array()),
new Array(new Array(),new Array(),new Array(),new Array()));
this.bounds = new Array(new Array(),new Array(),new Array(),new Array());
// Is this shape 2x2, 3x3 or 4x4
var size = 0;
for (var y = 0; y < 4; y++)
for (var x = 0; x < 4; x++)
if (src[y][x] == 1) {
if (y > size) size = y;
if (x > size) size = x;
}
size++;
// Calculate the four rotations for the shape
for (var dir = dir0; dir < dir360; dir++) {
var data = this.data[dir];
// Zero the data array for this rotation
for (var y = 0; y < 4; y++)
for (var x = 0; x < 4; x++)
data[y][x] = 0;
for (var y = 0; y < size; y++)
for (var x = 0; x < size; x++) {
switch (dir) {
case dir0: data[y][x] = src[y][x]; break;
case dir90: data[y][x] = src[(size - 1) - x][y]; break;
case dir180: data[y][x] = src[(size - 1) - y][(size - 1) - x]; break;
case dir270: data[y][x] = src[x][(size - 1) - y]; break;
}
}
}
// Calculate the dimensions for each rotation
for (var dir = dir0; dir < dir360; dir++) {
this.bounds[dir] = new cRect();
var bounds = this.bounds[dir];
bounds.x = size;
bounds.y = size;
for (var y = 0; y < size; y++)
for (var x = 0; x < size; x++)
if (this.data[dir][y][x] != 0) {
if (x < bounds.x) bounds.x = x;
if (y < bounds.y) bounds.y = y;
if (x > bounds.w) bounds.w = x;
if (y > bounds.h) bounds.h = y;
}
// Add 1 to width and height because X and Y start counting at 0
bounds.w += 1;
bounds.h += 1;
// Calculate the shape dimensions in terms of block size, used when drawing the shape
bounds.w -= bounds.x;
bounds.h -= bounds.y;
bounds.x *= cellWidth;
bounds.y *= cellHeight;
bounds.w *= cellWidth;
bounds.h *= cellHeight;
}
this.draw = function(dx, dy, dir, ctx) {
ctx.fillStyle = this.colour;
var data = this.data[dir];
for (var y = 0; y < 4; y++)
for (var x = 0; x < 4; x++) {
if (data[y][x] == 1)
fillRect(dx + (x * cellWidth), dy + (y * cellHeight), cellWidth, cellHeight, ctx);
}
}
// Draws all the rotations for testing
this.test = function(x, y) {
for (var dir = dir0; dir < dir360; dir++)
this.draw(x + (dir * this.w), y, dir, ctxBuffer);
}
// Render the four rotations of the shape and snapshot it
this.genFrames = function(y) {
for (var dir = dir0; dir < dir360; dir++)
this.draw(dir * blockWidth, y, dir, ctxFrameBuffer);
}
// Draws the buffered image data for the shape
this.drawFrame = function(dx, dy, dir, ctx) {
//ctx.drawImage(objFrameBuffer, blockWidth * dir, 0, blockWidth, blockHeight, dx, dy, blockWidth, blockHeight);
this.draw(dx, dy, dir, ctx);
//clipX = dx;
//clipY = dy;
//clipW = 8;
//clipH = 8;
//ctx.putImageData(this.frames[dir], dx, dy);
}
}
//=================================================================
// blocks
//=================================================================
var blocks = new function() {
this.count = 7;
this.block = new Array();
this.block[0] = new cBlock('#FF0000', new Array(new Array(0,0,0,0), new Array(1,1,1,1), new Array(0,0,0,0), new Array(0,0,0,0)));
this.block[1] = new cBlock('#FFFFFF', new Array(new Array(1,0,0,0), new Array(1,1,1,0), new Array(0,0,0,0), new Array(0,0,0,0)));
this.block[2] = new cBlock('#FF00FF', new Array(new Array(0,0,1,0), new Array(1,1,1,0), new Array(0,0,0,0), new Array(0,0,0,0)));
this.block[3] = new cBlock('#0000FF', new Array(new Array(1,1,0,0), new Array(1,1,0,0), new Array(0,0,0,0), new Array(0,0,0,0)));
this.block[4] = new cBlock('#008000', new Array(new Array(0,1,1,0), new Array(1,1,0,0), new Array(0,0,0,0), new Array(0,0,0,0)));
this.block[5] = new cBlock('#A52A2A', new Array(new Array(0,1,0,0), new Array(1,1,1,0), new Array(0,0,0,0), new Array(0,0,0,0)));
this.block[6] = new cBlock('#00FFFF', new Array(new Array(1,1,0,0), new Array(0,1,1,0), new Array(0,0,0,0), new Array(0,0,0,0)));
// Generate the shape frames
fillRect(0, 0, blockWidth * dir360, blockHeight * this.count, ctxFrameBuffer);
for (var i = 0; i < this.count; i++)
this.block[i].genFrames(i * blockHeight);
// Draws all the rotations of all blocks for testing
this.test = function(x, y) {
for (var i = 0; i < this.count; i++)
this.block[i].test(x, y + (i * this.block[i].h));
}
}
//=================================================================
// board
//=================================================================
var board = new function() {
this.cols = 12;
this.rows = 20;
this.x = 8;
this.y = 8;
this.w = this.cols * cellWidth;
this.h = this.rows * cellHeight;
this.cells = new Array();
this.scoreTime = 0;
this.scoreRate = 0.25 * 1000;
this.gameOverTime = 0;
this.gameOverRate = 2 * 1000;
this.drawFrame = function() {
ctxBuffer.strokeStyle = "#A0A0A0";
drawRect(this.x - 2, this.y - 2, this.w + 4, this.h + 4, ctxBuffer);
}
// Draw text centered into the board
this.centerText = function(text, y) {
ctxBuffer.fillText(text, this.x + ((board.w - ctxBuffer.measureText(text).width) / 2), this.y + y);
}
// Initialise the array holding dropped blocks
for (var y = 0; y < this.rows; y++)
this.cells[y] = new Array();
// Fill the canvas with the backColour
this.clearBuffer = function() {
ctxBuffer.fillStyle = backColour;
ctxBuffer.clearRect(this.x, this.y, this.w, this.h);
fillRect(this.x, this.y, this.w, this.h, ctxBuffer);
}
this.clearBlockBuffer = function() {
ctxBlockBuffer.fillStyle = backColour;
ctxBlockBuffer.clearRect(this.x, this.y, this.w, this.h);
fillRect(this.x, this.y, this.w, this.h, ctxBlockBuffer);
}
// Fill the board with 0's
this.clearBoard = function() {
for (var y = 0; y < this.rows; y++)
for (var x = 0; x < this.cols; x++)
this.cells[y][x] = 0;
}
this.clearBoard();
// Draw 1's and 0's over the board cells on screen to
// test how the buffer is working
this.test = function() {
ctxBuffer.fillStyle = '#FFFFFF';
ctxBuffer.font = "10px sans-serif";
for (var y = 0; y < this.rows; y++)
for (var x = 0; x < this.cols; x++)
ctxBuffer.fillText(this.cells[y][x], this.x + (x * cellWidth), this.y + (y * cellHeight) + 9);
}
// Setup a test for a nearly full board
this.testFull = function() {
for (var y = 4; y < this.rows; y++)
for (var x = 0; x < this.cols; x++)
this.cells[y][x] = 1;
}
// Display paused in the middle of the screen
this.gameOver = function(time) {
if (this.gameOverTime == 0) {
this.gameOverTime = time;
return;
}
// A little delay for the game over message to sink in before returning to the title screen
var delta = time - this.gameOverTime;
if (delta < this.gameOverRate) return;
this.gameOverTime = 0;
setState(sTitleScreen);
}
// Returns -1 if no rows are full, or the index of the first full row
this.anyRowFull = function() {
var hit = 0;
for (var y = 0; y < this.rows; y++) {
hit = 0;
for (var x = 0; x < this.cols; x++)
if (this.cells[y][x] != 0) hit++;
if (hit == this.cols) return y;
}
return -1;
}
// Called during a scoring loop
this.score = function(time) {
// If this is the first time through scoring then start the timer
if (this.scoreTime == 0) {
this.scoreTime = time;
scoreMult = 0;
return true;
}
// A little delay to make each row being removed take more than one frame
var delta = time - this.scoreTime;
if (delta < this.scoreRate) return true;
this.scoreTime = time;
// Increment the score multiplier once through each loop in each scoring session
scoreMult++;
// Calculate the score so far
score += scoreMult * 10;
// Fetch the first row that is full
var row = this.anyRowFull();
// Copy the top part of the board, up to the row that is to be removed
clip = ctxBlockBuffer.getImageData(this.x, this.y, this.w, row * cellHeight);
// Clear the top row of the board
ctxBlockBuffer.fillStyle = "#000000";
fillRect(this.x, this.y, this.w, cellHeight, ctxBlockBuffer);
// Paste the clipped rect one line down to erase the full row
ctxBlockBuffer.putImageData(clip, this.x, this.y + cellHeight);
// Move all the rows of blocks down one line above the specified row
for (var y = row; y > 0; y--)
for (var x = 0; x < this.cols; x++)
this.cells[y][x] = this.cells[y - 1][x];
// Clear the cells in the first row
for (var x = 0; x < this.cols; x++)
this.cells[0][x] = 0;
if (this.anyRowFull() == -1) {
this.scoreTime = 0;
return false;
} else return true;
return (this.anyRowFull() >= 0);
}
}
//=================================================================
// player
//=================================================================
var player = new function() {
this.x = 0; // X position of the block
this.y = 0; // Y position of the block
this.shape = 0; // The block the player is currently using
this.dir = dir0; // The direction the block is facing
this.nextShape = 0; // The next block the player will have
this.nextDir = dir0; // The direction of the next block the player will have
this.shapeTime = 0; // The time the new shape was assigned
this.shapeDelay = 0.5 * 1000; // Number of seconds before fast fall affects a new shape
this.fallTime = new Date().getTime(); // The time the last fall occurred
this.fallRate = 2 * 1000;
this.fallRateMax = 10; // The fastest rate at which the block can fall
this.fallFast = false; // When true, the block will fall at max speed
this.drawStats = function(time, x, y) {
ctxBuffer.fillStyle = "#A0A0A0";
fillRect(x, y, blockWidth, 128, ctxBuffer);
ctxBuffer.shadowBlur = 0;
ctxBuffer.shadowOffsetX = 0;
ctxBuffer.shadowOffsetY = 0;
ctxBuffer.shadowColor = "#FFFFFF";
ctxBuffer.fillStyle = "#000000";
ctxBuffer.font = "10px sans-serif";
ctxBuffer.fillText("Next Block", x, y + 8);
fillRect(x, y + 10, blockWidth, blockHeight, ctxBuffer);
blocks.block[this.nextShape].draw(x, y + 10, this.nextDir, ctxBuffer);
ctxBuffer.fillStyle = "#000000";
ctxBuffer.fillText("Score", x, y + blockHeight + 10 + 8);
var s = parseInt(score);
w = ctxBuffer.measureText(s).width;
ctxBuffer.fillText(s, x + blockWidth - w, y + blockHeight + 20 + 8);
ctxBuffer.fillText("FPS", x, y + blockHeight + 20 + 20 + 8);
var fps = Math.round(frameCount / ((time - gameStart) / 1000));
var s = parseInt(fps);
w = ctxBuffer.measureText(s).width;
ctxBuffer.fillText(s, x + blockWidth - w, y + blockHeight + 20 + 20 + 8);
}
// Assign the NextBlock to the player, then randomly select a new block
this.selectNextShape = function() {
this.shape = this.nextShape;
this.shapeTime = new Date().getTime();
this.dir = this.nextDir;
this.nextShape = rnd(7);
this.nextDir = rnd(4);
// Position the block at the top of the board, centered horizontally
this.y = -blocks.block[this.shape].bounds[this.dir].y;
this.x = ((board.w - blockWidth) / 2);
// Reset fall speed and start the fall timer over
this.fallFast = false;
this.fallTime = new Date().getTime();
// Make sure that it's actually possible to place the block, if not, then it's game over
if (!this.canMove(this.x, this.y, this.dir))
setState(sGameOver);
}
// Draw the player
this.draw = function() {
blocks.block[this.shape].drawFrame(board.x + this.x, board.y + this.y, this.dir, ctxBuffer);
}
// Gravity is always affecting the block, but it is variable depending on the level
// so that Y position is incremented faster
this.fall = function(time) {
if (state == sPlaying) {
var delta = time - this.fallTime;
if (delta > (this.fallFast && ((time - this.shapeTime) > this.shapeDelay) ? this.fallRateMax : this.fallRate))
if (this.canMove(this.x, this.y + cellHeight, this.dir)) {
this.y += cellHeight;
this.fallTime = time;
} else this.endMove();
}
}
// Rotate the block
this.rotate = function() {
if ((state == sPlaying) && (this.canMove(this.x, this.y, (this.dir + 1) % dir360)))
this.dir = (this.dir + 1) % dir360;
}
// Move the block to the left
this.left = function() {
if ((state == sPlaying) && (this.canMove(this.x - cellWidth, this.y, this.dir)))
this.x -= cellWidth;
}
// Move the block to the right
this.right = function() {
if ((state == sPlaying) && (this.canMove(this.x + cellWidth, this.y, this.dir)))
this.x += cellWidth;
}
// Returns true if the block can be moved to the specified location
this.canMove = function(x, y, dir) {
var bounds = blocks.block[this.shape].bounds[dir];
// If the block has hit the bottom row of the board then return true
if (y > (board.h - bounds.y - bounds.h)) return false;
// Make sure that the block doesn't hang over the edge of the board
if (x < -bounds.x) return false;
if (x > board.w - (bounds.x + bounds.w)) return false;
// Make sure that a rotation at the top of the screen doesn't overlap the block
if ((y + bounds.y) < 0) return false;
// Compare the board against the block to see if there are any overlaps
var boardX = x / cellWidth;
var boardY = y / cellHeight;
var data = blocks.block[this.shape].data[dir];
for (var y = Math.max(boardY, 0); y < Math.min(boardY + 4, board.rows); y++)
for (var x = Math.max(boardX, 0); x < Math.min(boardX + 4, board.cols); x++)
if ((board.cells[y][x] != 0) && (data[y - boardY][x - boardX] != 0))
return false;
// Nothing hit, OK to make the move
return true;
}
// If a move has been found to result in the block being unable to fall further than
// it's last position, then the block is frozen and a new round can begin.
this.endMove = function() {
// Draw the block onto the block buffer
this.draw();
blocks.block[this.shape].draw(board.x + this.x, board.y + this.y, this.dir, ctxBlockBuffer);
// Fill in the cells on the board that are occupied by block pieces
var boardX = this.x / cellWidth;
var boardY = this.y / cellHeight;
var data = blocks.block[this.shape].data[this.dir];
for (var y = Math.max(boardY, 0); y < Math.min(boardY + 4, board.rows); y++)
for (var x = Math.max(boardX, 0); x < Math.min(boardX + 4, board.cols); x++)
board.cells[y][x] = board.cells[y][x] || data[y - boardY][x - boardX];
// If there are any full rows on the board, then animate removing them
if (board.anyRowFull() >= 0) {
setState(sScoring);
} else this.selectNextShape();
}
}
//=================================================================
// game
//=================================================================
// Set the current game state. To improve performance, changing the state
// to titlescreen, paused or game over will simply render the screen once
// then update it once per frame, rather than repeatedly drawing it each time
function setState(newState) {
if (newState != state) {
state = newState;
switch (state) {
case sTitleScreen:
var x = board.x;
var y = board.y;
board.clearBuffer();
ctxBuffer.fillStyle = "#FFFFFF";
ctxBuffer.font = "24px sans-serif";
board.centerText("Webtris v0.2", 40);
ctxBuffer.font = "12px sans-serif";
board.centerText("(c) 2009 By Neil Burlock", 55);
ctxBuffer.font = "14px sans-serif";
board.centerText("A HTML5 demonstration", 100);
ctxBuffer.fillStyle = "#B0B0B0";
ctxBuffer.font = "10px sans-serif";
board.centerText("Press any key to play", 150);
board.centerText("W to rotate", 165);
board.centerText("A & D to move", 180);
board.centerText("S to fall fast", 195);
board.centerText("Escape to quit", 210);
board.centerText("P to toggle pause", 240);
board.centerText("T to toggle debug", 255);
break;
case sGameOver:
ctxBuffer.fillStyle = "#FFFFFF";
ctxBuffer.font = "30px sans-serif";
board.centerText("Game Over", ((board.h - 10) / 2));
break;
case sPaused:
ctxBuffer.fillStyle = "#FFFFFF";
ctxBuffer.font = "30px sans-serif";
board.centerText("Paused", ((board.h - 10) / 2));
break;
}
}
}
// Initialise the game
function initGame() {
initSession();
setState(sTitleScreen);
}
// Initialise a play session
function initSession() {
quit = false;
score = 0;
delayCount = 0;
level = 1;
setState(sPlaying);
rowscleared = 0;
player.nextShape = rnd(7);
player.nextDir = rnd(4);
board.clearBoard();
board.clearBuffer();
board.clearBlockBuffer();
player.selectNextShape();
}
function mainLoop () {
frameStart = new Date().getTime();
frameDelta = frameStart - framePrev;
switch (state) {
case sTitleScreen:
break;
case sPaused:
break;
case sPlaying:
ctxBuffer.drawImage(objBlockBuffer, 0, 0);
if (testing) board.test();
player.fall(frameStart);
if (state == sPlaying) player.draw();
break;
case sScoring:
if (!board.score(frameStart)) {
player.selectNextShape();
setState(sPlaying);
}
break;
case sGameOver:
board.gameOver(frameStart);
break;
}
player.drawStats(frameStart, board.x + board.w + 6, board.y);
// Delay to the start of the next frame
frameCount++;
if (fpsLimit) {
frameDelay = fpsTarget;
if (frameDelta > frameDelay) {
frameDelay = Math.max(20, frameDelay - (frameDelta - frameDelay))
}
this.framePrev = frameStart;
setTimeout(mainLoop, frameDelay);
} else {
setTimeout(mainLoop, 1);
}
// Canvas flip
ctxCanvas.drawImage(objBuffer, 0, 0);
}
// bind keyboard events to game functions
function bindKeys() {
// For each of these keyboard events, return false to suppress normal browser
// events, such as scrolling the page when the cursor keys are pressed. A
// single key press will trigger all three events, so the same key will still
// have to be suppressed in events that aren't used.
const keyEscape = 27;
const keyLeft = 37;
const keyUp = 38;
const keyRight = 39;
const keyDown = 40;
const keyP = 112;
const keyT = 116;
const keySpace = 32;
const keyW = 119;
const keyA = 97;
const keyS = 115;
const keyD = 100;
// The down key needs to act like a toggle, so use keyup & keydown
document.onkeydown = function(e) {
e = e || window.event;
switch (e.keyCode) {
case keyDown: // Bring block home
player.fallFast = true;
return false;
break;
default:
switch (e.which) {
case 83: // Bring block home
player.fallFast = true;
return false;
break;
}
}
}
document.onkeyup = function(e) {
e = e || window.event;
switch (e.keyCode) {
case keyDown: // Bring block home
player.fallFast = false;
return false;
break;
case keyEscape: // Quit the current game
if (state == sPlaying) setState(sTitleScreen);
break;
default:
switch (e.which) {
case 83: // Bring block home
player.fallFast = false;
return false;
break;
}
}
}
// The rest of the keyboard events can be handled as presses
document.onkeypress = function(e) {
e = e || window.event;
switch (e.keyCode) {
case keyUp: // Rotate block
player.rotate();
return false;
break;
case keyLeft: // Move block left
player.left();
return false;
break;
case keyRight: // Move block right
player.right();
return false;
break;
case keyDown: // Do nothing
return false;
break;
default:
switch (e.which) {
case keyW: // Rotate block
player.rotate();
return false;
break;
case keyA: // Move block left
player.left();
return false;
break;
case keyD: // Move block right
player.right();
return false;
break;
case keyEscape: // Quit
return false;
break;
case keyP: // Pause
setState(state == sPaused ? sPlaying : sPaused);
return false;
break;
case keyT:// Test mode
testing = !testing;
break;
default:
//alert(e.which);
if (state == sTitleScreen) {
initSession();
}
}
}
}
}
function init() {
initGame();
bindKeys();
board.drawFrame();
gameStart = new Date().getTime();
mainLoop();
}
setTimeout(init, 10);
|
