tetrissimo/tetrissimo.ts
2021-06-07 10:42:00 -07:00

765 lines
18 KiB
TypeScript

type GameState = 'running' | 'paused' | 'not-started' | 'finished';
const nope = (x: never): void => {};
type PieceCell = 0 | 1;
type PieceLayout = PieceCell[][];
interface UIElement {
render(context: CanvasRenderingContext2D, options: UIElementRenderOptions): void;
}
interface Dimensions {
width: number;
height: number;
}
interface GlobalUIOptions {
pixelSize: number;
padding: number;
canvas: HTMLCanvasElement;
context: CanvasRenderingContext2D;
gridLines: false | number;
borderWidth: number;
hud: Dimensions;
playArea: Dimensions;
}
interface UIElementRenderOptions extends GlobalUIOptions {
x: number;
y: number;
}
interface PieceTranslation {
xDelta: number;
yDelta: number;
}
interface TetrissimoCoordinates {
x: number;
y: number;
}
interface RectangleCoordinates extends TetrissimoCoordinates, Dimensions {}
type PieceActionType =
'moveLeft' |
'moveRight' |
'moveDown' |
'rotateLeft' |
'rotateRight';
interface PieceAction {
type: PieceActionType;
}
const getSize = (value: number, options: Pick<GlobalUIOptions, 'pixelSize'>): number => value * options.pixelSize;
const drawBorderedRect = (
position: Dimensions & TetrissimoCoordinates,
context: CanvasRenderingContext2D,
options: UIElementRenderOptions,
): void => {
const borderWidth = options.borderWidth;
if (!borderWidth) {
return;
}
const actualLineWidth = getSize(borderWidth, options);
context.lineWidth = actualLineWidth;
context.strokeStyle = 'white';
context.strokeRect(
getSize(position.x, options) - Math.round(actualLineWidth / 2),
getSize(position.y, options) - Math.round(actualLineWidth / 2),
getSize(position.width, options) + actualLineWidth,
getSize(position.height, options) + actualLineWidth,
);
};
class TetrissimoLevel {
public readonly order: number;
public readonly movementsPerSecond: number;
public static readonly level1 = new TetrissimoLevel(1, 0.5);
public static readonly level2 = new TetrissimoLevel(2, 0.75);
public static readonly level3 = new TetrissimoLevel(2, 1);
public static readonly level4 = new TetrissimoLevel(2, 1.5);
public static readonly level5 = new TetrissimoLevel(2, 2);
public static readonly level6 = new TetrissimoLevel(2, 3);
public static readonly level7 = new TetrissimoLevel(2, 4);
public static readonly level8 = new TetrissimoLevel(2, 6);
public static readonly level9 = new TetrissimoLevel(2, 8);
public static readonly level10 = new TetrissimoLevel(2, 10);
public constructor(order: number, speed: number) {
this.order = order;
this.movementsPerSecond = speed;
}
}
class TetrissimoGame {
private startedAt: Date | null = null;
private pausedAt: Date | null = null;
private finishedAt: Date | null = null;
private state: GameState = 'not-started';
private fps = 60;
private readonly ui: TetrissimoUI;
private lastFrameTimes: number[] = [];
private readonly pieces: TetrissimoPiece[] = [];
public currentLevel: TetrissimoLevel = TetrissimoLevel.level1;
private msUntilNextTick = 0;
private lastTick = new Date();
private lastUpdate = new Date();
public constructor(ui: TetrissimoUI) {
this.ui = ui;
}
public getState(): GameState {
return this.state;
}
public setTargetFps(fps: number) {
this.fps = fps;
}
public setLevel(level: TetrissimoLevel) {
this.currentLevel = level;
}
public getCurrentFps(): number {
switch (this.state) {
case 'running':
if (this.lastFrameTimes.length <= 1) {
return 0;
}
const firstFrame = this.lastFrameTimes[0];
const lastFrame = this.lastFrameTimes[this.lastFrameTimes.length - 1];
const elapsedMs = lastFrame - firstFrame;
const avgMsPerFrame = (elapsedMs / this.lastFrameTimes.length);
return 1000 / avgMsPerFrame;
case 'paused':
case 'not-started':
case 'finished':
return 0;
default:
nope(this.state);
throw new Error(`Invalid game state "${this.state}"`);
}
}
public performPieceAction(action: PieceAction) {
if (this.state !== 'running') {
return;
}
const piece = this.pieces.filter(piece => piece.state === 'in-play')[0];
if (!piece) {
// nothing to do, no pieces in play
return;
}
const movementIncrement = 1;
switch (action.type) {
case 'moveLeft':
this.moveIfValid(piece, -movementIncrement, 0);
break;
case 'moveRight':
this.moveIfValid(piece, movementIncrement, 0);
break;
case 'moveDown':
if (!this.moveIfValid(piece, 0, movementIncrement)) {
// make fixed to bottom of play area
piece.setFixed();
}
break;
case 'rotateLeft':
// TODO ensure it's a valid move
piece.rotateLeft();
break;
case 'rotateRight':
piece.rotateRight();
break;
default:
nope(action.type);
throw new Error(`invalid action "${action.type}"`);
}
}
public start() {
this.pausedAt = null;
switch (this.state) {
case 'running':
// no-op
break;
case 'paused':
case 'not-started':
this.lastTick = new Date();
this.lastUpdate = new Date();
this.startedAt = new Date();
this.state = 'running';
this.update();
break;
case 'finished':
throw new Error('Cannot start a finished game');
default:
nope(this.state);
throw new Error(`Invalid game state "${this.state}"`);
}
}
public pause() {
switch (this.state) {
case 'running':
case 'paused':
this.pausedAt = new Date();
this.state = 'paused';
this.lastFrameTimes = [];
break;
case 'not-started':
case 'finished':
throw new Error(`Cannot pause game while it's in state "${this.state}"`);
default:
nope(this.state);
throw new Error(`Invalid game state "${this.state}"`);
}
}
public update(): void {
const tickStart = Date.now();
this.lastFrameTimes.push(tickStart);
if (this.lastFrameTimes.length > 20) {
this.lastFrameTimes.shift();
}
switch (this.state) {
case 'running':
this.performUpdate();
break;
case 'paused':
case 'not-started':
case 'finished':
throw new Error(`Cannot advance game while in state "${this.state}"`);
default:
nope(this.state);
throw new Error(`Invalid game state "${this.state}"`);
}
const elapsed = Date.now() - tickStart;
const nextTickTimeout = Math.max(0, (1000 / this.fps) - elapsed);
setTimeout(() => {
switch (this.state) {
case 'running':
this.update();
break;
case 'paused':
case 'not-started':
case 'finished':
break;
default:
nope(this.state);
throw new Error(`Invalid game state "${this.state}"`);
}
}, nextTickTimeout);
}
private performUpdate(): void {
const sinceLastUpdate = Date.now() - this.lastUpdate.getTime();
this.msUntilNextTick -= sinceLastUpdate;
this.lastUpdate = new Date();
if (this.msUntilNextTick <= 0) {
this.tick();
}
// detect collision
// is every column filled? remove column
// is every row filled? end game
ui.update();
}
private moveIfValid(piece: TetrissimoPiece, xDelta: number, yDelta: number): boolean {
const translation: PieceTranslation = {
xDelta,
yDelta,
};
if (this.ui.canMovePiece(piece, translation)) {
piece.move(translation);
return true;
}
return false;
};
private tick(): void {
const inPlayPieces = this.pieces.filter(piece => piece.state === 'in-play');
if (!inPlayPieces.length) {
const newPiece = TetrissimoPiece.T.clone();
newPiece.state = 'in-play';
this.pieces.push(newPiece);
this.ui.addPiece(newPiece);
} else {
inPlayPieces.forEach((piece) => {
if (!this.moveIfValid(piece, 0, 1)) {
piece.setFixed();
}
});
}
this.msUntilNextTick = Math.round(1000 / this.currentLevel.movementsPerSecond);
this.lastTick = new Date();
}
}
class TetrissimoUI {
public readonly playArea: TetrissimoPlayArea;
private readonly pieces: Array<Readonly<TetrissimoPiece>> = [];
public readonly nextBox: TetrissimoNextBox;
public readonly hud: TetrissimoHUD;
public readonly options: GlobalUIOptions;
public constructor(
canvas: HTMLCanvasElement,
playArea: TetrissimoPlayArea,
// nextBox: TetrissimoNextBox,
hud: TetrissimoHUD,
) {
this.playArea = playArea;
// this.nextBox = nextBox;
this.hud = hud;
const context = canvas.getContext('2d');
if (!context) {
throw new Error('failed to get 2d context');
}
this.options = {
borderWidth: 1,
canvas: canvas,
context,
gridLines: 4,
hud: {
width: 32,
height: 12,
},
playArea: {
width: 68,
height: 128,
},
padding: 2,
pixelSize: 4,
};
}
public setOptions(options: Omit<GlobalUIOptions, 'canvas' | 'context'>) {
Object.keys(options).forEach((key) => {
this.options[key] = options[key];
});
}
public addPiece(piece: TetrissimoPiece) {
this.pieces.push(piece);
}
private getPiecePosition(piece: Readonly<TetrissimoPiece>): TetrissimoCoordinates {
const origin = this.getPieceOrigin(piece);
const offset = piece.getOffset();
return {
x: origin.x + offset.x,
y: origin.y + offset.y,
};
}
public canMovePiece(piece: Readonly<TetrissimoPiece>, delta: PieceTranslation): boolean {
// check each block in layout's edge to see if it's in a valid area?s
const layout = piece.getLayout();
const position = this.getPiecePosition(piece);
for (let i = 0; i < layout.length; i++) {
const blockTopEdge = position.y + (i * TetrissimoPiece.blockLength) + (delta.yDelta * TetrissimoPiece.blockLength);
for (let j = 0; j < layout[i].length; j++) {
if (layout[i][j] === 0) {
continue;
}
const blockLeftEdge = position.x + (j * TetrissimoPiece.blockLength) + (delta.xDelta * TetrissimoPiece.blockLength);
const isCollision = this.hasCollision({
x: blockLeftEdge,
y: blockTopEdge,
width: TetrissimoPiece.blockLength,
height: TetrissimoPiece.blockLength,
});
if (isCollision) {
return false;
}
}
}
return true;
}
private hasCollision(rect: RectangleCoordinates): boolean {
const playAreaLeftEdge = this.options.padding;
const playAreaRightEdge = playAreaLeftEdge + this.options.playArea.width;
const playAreaTopEdge = this.options.padding;
const playAreaBottomEdge = playAreaTopEdge + this.options.playArea.height;
const leftEdge = rect.x;
const rightEdge = rect.x + rect.width;
const topEdge = rect.y;
const bottomEdge = rect.y + rect.height;
const isInPlayArea =
leftEdge >= playAreaLeftEdge &&
rightEdge <= playAreaRightEdge &&
bottomEdge <= playAreaBottomEdge &&
topEdge >= playAreaTopEdge;
if (!isInPlayArea) {
return true;
}
const fixedPieces = this.pieces.filter(piece => piece.state === 'fixed');
for (const piece of fixedPieces) {
const position = this.getPiecePosition(piece);
const layout = piece.getLayout();
for (let i = 0; i < layout.length; i++) {
const blockTopEdge = position.y + (i * TetrissimoPiece.blockLength);
const blockBottomEdge = blockTopEdge + TetrissimoPiece.blockLength;
for (let j = 0; j < layout[i].length; j++) {
if (layout[i][j] === 0) {
continue;
}
const blockLeftEdge = position.x + (j * TetrissimoPiece.blockLength);
const blockRightEdge = blockLeftEdge + TetrissimoPiece.blockLength;
const isCollision =
leftEdge >= blockLeftEdge &&
rightEdge <= blockRightEdge &&
bottomEdge <= blockBottomEdge &&
topEdge >= blockTopEdge;
if (isCollision) {
return true;
}
}
}
}
return false;
}
public update() {
const options = this.options;
const context = this.options.context;
context.clearRect(0, 0, canvas.width, canvas.height);
// fill background
context.fillStyle = 'rgb(224,158,107)';
context.fillRect(0, 0, canvas.width, canvas.height);
// draw some grid lines
if (typeof(options.gridLines) === 'number') {
context.strokeStyle = 'rgba(255, 255, 255, 0.25)';
context.lineWidth = 1;
const increment = options.pixelSize * options.gridLines;
for (let i = 0; i < options.canvas.width; i += increment) {
// draw vertical line
context.beginPath();
context.moveTo(i, 0);
context.lineTo(i, options.canvas.height);
context.stroke();
}
for (let i = 0; i < options.canvas.height; i += increment) {
// draw horizontal lines
context.beginPath();
context.moveTo(0, i);
context.lineTo(options.canvas.width, i);
context.stroke();
}
}
const totalWidth = Math.floor(canvas.width / options.pixelSize);
const padding = options.padding;
// draw HUD in top right corner
this.hud.render(context, {
...options,
x: totalWidth - padding - options.hud.width,
y: padding,
});
// draw play area
this.playArea.render(context, {
...options,
x: padding,
y: padding,
});
this.pieces.forEach((piece, i) => {
piece.render(context, {
...options,
...this.getPieceOrigin(piece),
});
});
}
private getPieceOrigin(piece: Readonly<TetrissimoPiece>): TetrissimoCoordinates {
const length = TetrissimoPiece.blockLength;
// TODO rotation will cause problems, need a "PieceDefinition" or something with a static layout
let translateX = Math.floor((piece.getBlockWidth() * length) / 2);
// we can only perform movements in multiples of the blockLength, so we must clamp it
// to ensure it's not offset
translateX -= (translateX % length);
const padding = this.options.padding;
return {
x: padding + Math.round(this.options.playArea.width / 2) - translateX,
y: padding,
};
}
}
class TetrissimoNextBox implements UIElement {
public render(context: CanvasRenderingContext2D, options: UIElementRenderOptions) {
}
}
class TetrissimoHUD implements UIElement {
public render(context: CanvasRenderingContext2D, options: UIElementRenderOptions) {
const position = {
x: options.x,
y: options.y,
width: options.hud.width,
height: options.hud.height,
};
drawBorderedRect(position, context, options);
}
}
class TetrissimoPlayArea implements UIElement {
public render(context: CanvasRenderingContext2D, options: UIElementRenderOptions) {
const position = {
x: options.x,
y: options.y,
width: options.playArea.width,
height: options.playArea.height,
};
drawBorderedRect(position, context, options);
}
}
type PieceState = 'in-play' | 'fixed';
class TetrissimoPiece implements UIElement {
public readonly name: string;
private readonly originalLayout: Readonly<PieceLayout>;
private layout: PieceLayout;
private offset: TetrissimoCoordinates = { x: 0, y: 0 };
private readonly color: string;
public static readonly blockLength = 4;
public state: PieceState = 'in-play';
public static readonly J = new TetrissimoPiece(
'J',
[
[0, 1],
[0, 1],
[1, 1],
],
'black',
);
public static readonly S: TetrissimoPiece = new TetrissimoPiece(
'S',
[
[0, 1, 1],
[1, 1, 0],
],
'green',
);
public static readonly T: TetrissimoPiece = new TetrissimoPiece(
'T',
[
[1, 1, 1],
[0, 1, 0],
],
'blue',
);
public static readonly Z: TetrissimoPiece = new TetrissimoPiece(
'Z',
[
[1, 1, 0,],
[0, 1, 1,],
],
'yellow',
);
public static readonly I: TetrissimoPiece = new TetrissimoPiece(
'I',
[
[1, 1, 1, 1],
],
'magenta',
);
public static readonly L: TetrissimoPiece = new TetrissimoPiece(
'L',
[
[1],
[1],
[1, 1],
],
'cyan',
);
public static readonly O: TetrissimoPiece = new TetrissimoPiece(
'O',
[
[1, 1],
[1, 1],
],
'red',
);
public constructor(name: string, layout: PieceLayout, color: string) {
this.name = name;
this.originalLayout = layout.map(row => row.concat([]));
this.layout = layout.concat([]);
this.color = color;
}
public getOffset(): Readonly<TetrissimoCoordinates> {
return this.offset;
}
public getLayout(): Readonly<PieceLayout> {
return this.layout;
}
public move(translation: PieceTranslation) {
if (this.state !== 'in-play') {
return;
}
this.offset.x += (translation.xDelta * TetrissimoPiece.blockLength);
this.offset.y += (translation.yDelta * TetrissimoPiece.blockLength);
}
public rotateLeft() {
if (this.state !== 'in-play') {
return;
}
// clockwise turn
const newLayout: PieceLayout = [];
for (let i = 0; i < this.layout.length; i++) {
const col = this.layout.length - 1 - i;
for (let j = 0; j < this.layout[i].length; j++) {
const row = j;
if (!newLayout[row]) {
newLayout[row] = [];
}
newLayout[row][col] = this.layout[i][j];
}
}
this.layout = newLayout;
}
public rotateRight() {
if (this.state !== 'in-play') {
return;
}
const newLayout: PieceLayout = [];
for (let i = 0; i < this.layout.length; i++) {
const col = i;
for (let j = 0; j < this.layout[i].length; j++) {
const row = this.layout[i].length - 1 - j;
if (!newLayout[row]) {
newLayout[row] = [];
}
newLayout[row][col] = this.layout[i][j];
}
}
this.layout = newLayout;
}
public setFixed() {
this.state = 'fixed';
}
public getBlockWidth(): number {
return this.layout
.map(arr => arr.filter(value => value === 1).length)
.reduce((max, len) => Math.max(max, len), 0);
}
public getBlockHeight(): number {
const cols: PieceCell[][] = [];
for (let i = 0; i < this.layout.length; i++) {
for (let j = 0; j < this.layout[i].length; j++) {
if (!cols[j]) {
cols[j] = [];
}
cols[j].push(this.layout[i][j]);
}
}
return cols
.map(arr => arr.filter(value => value === 1).length)
.reduce((max, len) => Math.max(max, len), 0);
}
public clone(): TetrissimoPiece {
return new TetrissimoPiece(this.name, this.layout, this.color);
}
public render(context: CanvasRenderingContext2D, options: UIElementRenderOptions) {
this.layout.forEach((columns, rowNum) => {
columns.forEach((value, colNum) => {
switch (value) {
case 0:
break;
case 1:
context.fillStyle = this.color;
const length = TetrissimoPiece.blockLength;
const offsetX = this.offset.x;
const offsetY = this.offset.y;
const x = options.x + offsetX + (colNum * length);
const y = options.y + offsetY + (rowNum * length);
const actualLength = getSize(length, options);
context.fillRect(getSize(x, options), getSize(y, options), actualLength, actualLength);
break;
default:
nope(value);
throw new Error(`Invalid piece cell value "${value}"`);
}
});
});
}
}
// ---------------------------------------------
const hud = new TetrissimoHUD();
const playArea = new TetrissimoPlayArea();
const canvas = document.getElementsByTagName('canvas')[0];
if (!canvas) {
throw new Error('no canvas');
}
const ui = new TetrissimoUI(canvas, playArea, hud);
const game = new TetrissimoGame(ui);