const eCurve = (v, p = 2) => v <= 0 ? 0 : v >= 1 ? 1 : v ** p / (v ** p + (1 - v) ** p);
const bellCurve = (v, p = 2) => v <= 0 ? 0 : v >= 1 ? 0 : (v *= 2, (v = v > 1 ? 2 - v : v), v ** p / (v ** p + (1 - v) ** p));
const bCurve = (v, a, b) => v <= 0 ? 0 : v >= 1 ? 1 : 3*v*(1-v)*(a*(1-v)+b*v)+v*v*v; // cubic bezier curve
Math.PI90 = Math.PI / 2;
Math.TAU = Math.PI * 2;
Math.PI270 = Math.PI * (3 / 2);
const randUI = (min, max) => (max !== undefined ? Math.random() * (max - min) + min : Math.random() * min) | 0;
const randPick = arr => arr.splice(randUI(arr.length), 1)[0];
const randItem = arr => arr[randUI(arr.length)];
const tag = (tag, props = {}) => Object.assign(document.createElement(tag), props);
canvas.width = innerWidth;
canvas.height = innerHeight;
const ctx = canvas.getContext("2d");
const keys = (()=>{
const keys = {
ArrowLeft: false,
ArrowRight: false,
ArrowUp: false,
ArrowDown: false
};
document.addEventListener("keydown", keyEvent);
document.addEventListener("keyup", keyEvent);
function keyEvent(e) {
if (keys[e.code] !== undefined) { keys[e.code] = e.type === "keydown"; e.preventDefault() }
}
return keys;
})();
var pageResized = false, inGame = false, instructionsKnown = 0, gameType = 1;
addEventListener("resize", () => { if (inGame) { pageResized = true } });
sel.addEventListener("click", (e) => {
if (!inGame && e.target.dataset.game !== undefined) {
gameType = e.target.dataset.game;
sel.style.display = "none";
playGame(gameType);
}
});
const pathRoundBox = (ctx, x, y, w, h, r) => {
const w2 = w / 2, h2 = h / 2;
ctx.arc(x - w2 + r, y - h2 + r, r, Math.PI, Math.PI270);
ctx.arc(x + w2 - r, y - h2 + r, r, Math.PI270, Math.TAU);
ctx.arc(x + w2 - r, y + h2 - r, r, 0, Math.PI90);
ctx.arc(x - w2 + r, y + h2 - r, r, Math.PI90, Math.PI);
}
function Game(size, winTile, speed = 20, tilesPerMove = 2) {
var state;
var moveCount = 0, displayDirty = true;
const animRate = 1 / speed;
const tileCount = size * size;
const board = new Array(tileCount).fill(0);
const mergingTiles = [];
const freeTiles = [];
const newTileVal = [];
const newTileSet = [2,4,2,4,2,4,2,4,2,4,2,4];
const states = {
over: 1,
inPlay: 2,
animating: 3,
win: 4,
};
state = states.inPlay;
const moves = {
up: 0,
right: 1,
down: 2,
left: 3,
};
const moveTransform = [
(x, y) => y + x * size,
(x, y) => (size - 1) - x + y * size,
(x, y) => y + ((size - 1) - x) * size,
(x, y) => x + y * size,
];
function Tile(val, idx, growDelay = 0) {
this.nx = this.x = idx % size;
this.ny = this.y = idx / size | 0;
this.val = val;
this.anim = 0;
this.alpha = 1;
this.animate = true;
this.merging = false;
this.grow = true;
this.size = -growDelay;
this.flash = false;
this.flashAlpha = 0;
}
Tile.prototype = {
newPos(idx) {
this.nx = idx % size;
this.ny = idx / size | 0;
this.ax = this.x;
this.ay = this.y;
this.anim = 1;
this.animate = true;
},
merge(idx) {
this.newPos(idx);
this.merging = true;
return this;
},
mergeFlash(idx) {
this.animate = true;
this.flash = true;
this.flashAlpha = 1;
this.val *= 2;
return this;
},
update() {
if (this.merging) {
this.alpha -= animRate;
if (this.alpha <= 0) {
this.alpha = 0;
this.merging = false;
}
}
if (this.anim > 0) {
this.anim -= animRate;
if (this.anim <= 0) { this.anim = 0 }
}
if (this.grow) {
this.size += animRate;
if (this.size > 1) {
this.size = 1;
this.grow = false;
}
}
if (this.flash) {
this.flashAlpha -= animRate * 2;
if (this.flashAlpha < 0) {
this.flashAlpha = 0;
this.flash = false;
}
}
this.animate = this.flash || this.grow || this.merging || this.anim > 0;
displayDirty = this.animate ? true : displayDirty;
if (this.anim > 0) {
const p = eCurve(1 - this.anim);
this.x = (this.nx - this.ax) * p + this.ax;
this.y = (this.ny - this.ay) * p + this.ay;
} else {
this.x = this.nx;
this.y = this.ny;
}
},
render(target) {
const {ctx, scale, radius, cols, textCol, pad} = target;
ctx.save()
const hS = scale / 2;
var x = this.x * scale;
var y = this.y * scale;
const inScale = this.flash ? 1 + bellCurve(this.flashAlpha) * 0.1 : bCurve(this.size, 0, 1.6);
ctx.transform(inScale, 0, 0 , inScale, x + hS, y + hS);
ctx.globalAlpha = eCurve(this.alpha);
ctx.fillStyle = cols[this.val];
ctx.beginPath();
pathRoundBox(ctx, 0, 0, scale - pad * 2, scale - pad * 2, radius);
ctx.fill();
if (this.flash) {
ctx.globalCompositeOperation = "lighter";
ctx.globalAlpha = eCurve(this.flashAlpha);
ctx.fillStyle = "#FFF";
ctx.fill();
ctx.globalCompositeOperation = "source-over";
ctx.globalAlpha = 1;
}
ctx.fillStyle = textCol;
ctx.fillText(this.val, 0, 0 + scale * 0.05);
ctx.restore()
}
}
function addTile(growDelay) {
const pos = randPick(freeTiles);
if (newTileVal.length === 0) { newTileVal.push(...newTileSet) }
board[pos] = new Tile(randPick(newTileVal), pos, growDelay);
}
function checkBoard() {
var i = tileCount, s = state;
freeTiles.length = 0;
while (i--) {
const t = board[i];
if (t?.val === winTile) {
s = states.win;
} else if (t === 0) {
freeTiles.push(i);
}
}
if (freeTiles.length < tilesPerMove && s !== states.win) { s = states.over }
API.state = s;
}
function move(dir) {
moveCount ++;
var x, y = 0, f, prev;
const move = moveTransform[dir];
while (y < size) {
f = x = 0;
prev = -1;
while (x < size) {
const idx = move(x, y);
const tile = board[idx];
board[idx] = 0;
if (tile) {
const idxf = move(f, y);
board[idxf] = tile;
idxf !== idx && tile.newPos(idxf);
if (prev > -1) {
if (tile.val === board[prev].val) {
board[prev].mergeFlash();
mergingTiles.push(tile.merge(prev));
board[idxf] = 0;
f--;
prev = -1;
} else { prev = idxf }
} else { prev = idxf }
f++;
}
x++;
}
y++;
}
}
const API = {
states: Object.freeze(states),
moves: Object.freeze(moves),
get displayDirty() {
if (state === states.animating) { displayDirty = true }
const res = displayDirty;
displayDirty = false;
return res;
},
get moveCount() { return moveCount },
get state() { return state },
set state(s) {
if (s !== state) {
if (state === states.animating && s === states.inPlay) {
state = s;
checkBoard();
if (state === states.inPlay) {
let i = 0
while (i < tilesPerMove) { addTile(i++ / 2) }
displayDirty = true;
}
} else {
state = s;
displayDirty = true;
}
}
},
set move(dir) {
if (state === states.inPlay) {
if (dir >= 0 && dir < 4) {
move(dir);
API.state = states.animating;
}
}
},
reset() {
board.fill(0);
API.state = states.animating;
moveCount = 0;
checkBoard();
},
render(target) {
var i = 0, tail = 0, animating = false;
board.forEach(tile => {
if (tile !== 0) {
tile.animate && (animating = true, tile.update());
tile.render(target);
}
});
while (i < mergingTiles.length) {
const tile = mergingTiles[i];
tile.update();
tile.render(target);
if (tile.animate) {
animating = true
mergingTiles[tail++] = tile
}
i++;
}
mergingTiles.length = tail;
!animating && state === states.animating && (API.state = states.inPlay);
},
}
return Object.freeze(API);
}
function createBGImage(img, tiles, pad, radius) {
const ctx = img.getContext("2d");
ctx.fillStyle = "#222";
ctx.beginPath();
pathRoundBox(ctx, img.width / 2, img.height / 2, img.width, img.height, radius + pad * 2);
ctx.fill();
var i = 0;
const size = img.width / tiles;
ctx.fillStyle = "#666";
while (i < tiles * tiles) {
const x = (i % tiles) + 0.5;
const y = (i / tiles | 0) + 0.5;
ctx.beginPath();
pathRoundBox(ctx, x * size, y * size, size - pad * 2, size - pad * 2, radius);
ctx.fill();
i++;
}
return img;
}
function playGame(game = gameType) {
inGame = true;
const GAMES = [
[4, 512, 20, 2],
[5, 2048, 20, 2],
[6, 2048, 20, 2],
[7, 1024, 20, 3],
[8, 2048, 16, 4],
];
const SIZE = GAMES[game][0];
const g = Game(...GAMES[game]);
g.reset();
ctx.canvas.width = innerWidth;
ctx.canvas.height = innerHeight;
requestAnimationFrame(animLoop);
const minRes = Math.min(ctx.canvas.width, ctx.canvas.height);
const unit = minRes / 64;
const RADIUS = minRes / SIZE / 8, PAD = minRes / SIZE / 16;
const bgImage = createBGImage(tag("canvas", {width: minRes, height: minRes}), SIZE, PAD / 2, RADIUS + PAD / 2);
const renderTarget = {
ctx,
scale: minRes / SIZE,
radius: RADIUS,
pad: PAD,
textCol: "#000",
cols: {
[2]: "#F40",
[4]: "#F80",
[8]: "#FA0",
[16]: "#FC0",
[32]: "#FF0",
[64]: "#CF0",
[128]: "#8F0",
[256]: "#0F0",
[512]: "#0F8",
[1024]: "#0FF",
[2048]: "#FFF",
}
}
ctx.font = (renderTarget.scale * 0.7 | 0) + "px Arial";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
function animLoop() {
var m = -1;
// Rather than dynamic resize this just restarts on resize
if (pageResized) {
pageResized = false;
setTimeout(playGame, 0);
inGame = false;
return;
}
if (keys.ArrowUp) { keys.ArrowUp = false; m = g.moves.up }
if (keys.ArrowRight) { keys.ArrowRight = false; m = g.moves.right }
if (keys.ArrowDown) { keys.ArrowDown = false; m = g.moves.down }
if (keys.ArrowLeft) { keys.ArrowLeft = false; m = g.moves.left }
if (m > -1) {
g.move = m;
instructionsKnown++;
}
const state = g.state;
if (g.displayDirty) {
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.setTransform(1,0,0,1,0,0);
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
ctx.font = (renderTarget.scale * 0.45 | 0) + "px Arial";
ctx.setTransform(1,0,0,1,(canvas.width - SIZE * renderTarget.scale) / 2, 0);
ctx.drawImage(bgImage, 0, 0);
g.render(renderTarget);
if (!(state === g.states.inPlay || state === g.states.animating)) {
ctx.fillStyle = "#0008";
ctx.setTransform(1,0,0,1,0,0);
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
ctx.font = (renderTarget.scale * 0.7 | 0) + "px Arial";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillStyle = "#FFF";
ctx.strokeStyle = "#000";
ctx.lineWidth = unit * 2;
ctx.lineJoin = "round";
const cx = ctx.canvas.width * 0.5;
const cy = ctx.canvas.height * 0.3
if (state === g.states.win) {
ctx.strokeText("WINNER!", cx + unit / 2, cy + unit / 2);
ctx.fillText("WINNER!",cx, cy)
ctx.strokeStyle = "#FED";
ctx.lineWidth = unit / 2;
ctx.lineJoin = "round";
ctx.strokeText("WINNER!", cx, cy)
ctx.font = (renderTarget.scale * 0.4 | 0) + "px Arial";
ctx.fillStyle = "#FFF";
ctx.strokeStyle = "#000";
ctx.lineWidth = unit;
const mC = g.moveCount;
ctx.strokeText("Moves: " + mC, cx + unit / 4, cy + unit * SIZE * 3 + unit / 4);
ctx.fillText("Moves: " + mC, cx, cy + unit * SIZE * 3);
} else {
ctx.strokeText("GAME OVER", cx + unit / 2, cy + unit / 2);
ctx.fillText("GAME OVER", cx, cy)
ctx.strokeStyle = "#DEF";
ctx.lineWidth = unit / 2;
ctx.lineJoin = "round";
ctx.strokeText("GAME OVER", cx, cy);
}
inGame = false;
return;
}
if (instructionsKnown < 2) {
ctx.setTransform(1,0,0,1,0,0);
ctx.fillStyle = "#FFF";
ctx.font = "16px Arial";
ctx.fillText("Use arrow keys to play", ctx.canvas.width / 2, 20);
}
}
requestAnimationFrame(animLoop);
}
}
canvas {
position: absolute;
top: 0px;
left: 0px;
pointer-events: none;
}
#sel {
font-family: arial;
display: flex;
}
.bt {
flex-wrap: row;
cursor: pointer;
width: 50px;
text-align: center;
border: 1px solid black;
margin: 4px;
padding-top: 3px;
}
.bt:hover {
background: #D92;
}
.txt {
margin-top: 8px;
}
<canvas id="canvas"></canvas>
<div id="sel">
<div class="txt">Select grid size:</div>
<div class="bt" data-game="0">4 by 4</div>
<div class="bt" data-game="1">5 by 5</div>
<div class="bt" data-game="2">6 by 6</div>
<div class="bt" data-game="3">7 by 7</div>
<div class="bt" data-game="4">8 by 8</div>
</div>