This commit is contained in:
Alexander Bass 2023-07-28 01:38:12 -04:00
commit 9b528ab2e8
26 changed files with 4082 additions and 0 deletions

64
.eslintrc.json Normal file
View file

@ -0,0 +1,64 @@
{
"env": {
"browser": true,
"es2021": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"plugins": [
"@typescript-eslint"
],
"rules": {
"indent": [
"warn",
"tab",
{"SwitchCase": 1}
],
"linebreak-style": [
"warn",
"unix"
],
"quotes": [
"warn",
"double"
],
"semi": [
"warn",
"always"
],
"space-before-blocks":["warn","always"],
"quote-props" : ["warn","as-needed"],
"dot-notation":"warn",
"one-var":["warn","never"],
"no-use-before-define":"warn",
"no-multi-assign":"warn",
"no-else-return":"warn",
"spaced-comment":"warn",
"prefer-destructuring":"warn",
"no-restricted-globals":"warn",
"prefer-template":"warn",
"class-methods-use-this":"warn",
"template-curly-spacing": ["warn", "never"],
"no-useless-rename":"warn",
"no-useless-escape":"warn",
"no-duplicate-imports":"warn",
"no-useless-constructor":"warn",
"no-loop-func":"warn",
"no-param-reassign":"warn",
"prefer-arrow-callback":"warn",
"no-array-constructor": "warn",
"object-shorthand": "warn",
"no-empty": "off",
"no-self-compare": "warn",
"eqeqeq": "warn",
"@typescript-eslint/no-unused-vars": "off",
"@typescript-eslint/explicit-function-return-type": "warn"
}
}

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
node_modules
out/
dist/
makefile

8
.prettierrc.json Normal file
View file

@ -0,0 +1,8 @@
{
"trailingComma": "es5",
"useTabs": true,
"semi": true,
"endOfLine": "lf",
"singleQuote": false
}

2905
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

19
package.json Normal file
View file

@ -0,0 +1,19 @@
{
"private": true,
"devDependencies": {
"@tsconfig/node16": "^1.0.3",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"copy-webpack-plugin": "^11.0.0",
"eslint": "^8.44.0",
"events": "^3.3.0",
"ts-loader": "^9.4.4",
"typescript": "^5.1.6",
"webpack": "^5.88.2",
"webpack-cli": "^5.1.4"
},
"scripts": {
"build": "webpack --mode=production",
"watch": "webpack --mode=development --watch"
}
}

104
src/assets/froob.html Normal file
View file

@ -0,0 +1,104 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>froob</title>
<link rel="icon" href="data:," />
<style>
body {
margin-inline: auto;
background-color: green;
background-image: url("./background.png");
color: white;
font-family: Charter, bitstream charter, sitka text, Cambria, serif;
}
h1 {
margin: 0;
text-align: center;
}
h1 {
font-size: 3vw;
}
img,
canvas {
image-rendering: optimizeSpeed;
image-rendering: pixelated;
}
#sidebyside {
justify-content: center;
display: flex;
gap: 20px;
}
#gameOver {
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
user-select: none;
}
#overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(255, 255, 255, 0.9);
color: black;
}
#overlay:not(.hidden) {
animation: fadeInFromNone 2s ease-in-out;
}
@keyframes fadeInFromNone {
0% {
display: none;
opacity: 0;
}
1% {
display: block;
opacity: 0;
}
100% {
display: block;
opacity: 1;
}
}
#gameOver > h1 {
font-size: 6vw;
}
#tryAgain:hover {
cursor: pointer;
}
#scoreCounter {
margin-top: 2vw;
}
.hidden {
display: none;
}
</style>
<script type="module" src="main.js"></script>
</head>
<body>
<h1>Froobtris</h1>
<noscript
>This game requires javascript to do anything. You browser is either
incompatible with it, or you have disabled it.</noscript
>
<div id="sidebyside">
<div id="pfcontainer"></div>
<div id="sdcontainer"></div>
</div>
<div id="overlay" class="hidden">
<div id="gameOver">
<h1>You&rsquo;ve been froobed!</h1>
<div id="scoreCounter">With a score of</div>
<h2 id="tryAgain">try again?</h2>
</div>
</div>
</body>
</html>

9
src/constants.ts Normal file
View file

@ -0,0 +1,9 @@
export const TEXTURE_SCALE = 2;
export const TILE_SIZE = 16 * TEXTURE_SCALE;
export const BORDER_THICKNESS = 4 * TEXTURE_SCALE;
export const SIDEBAR_WIDTH = 80 * TEXTURE_SCALE;
export const SIDEBAR_HEIGHT = 117 * TEXTURE_SCALE;
export const GRID_WIDTH = 10;
export const GRID_HEIGHT = 24;
export const PLAYFIELD_WIDTH = TILE_SIZE * GRID_WIDTH + BORDER_THICKNESS * 2;
export const PLAYFIELD_HEIGHT = TILE_SIZE * GRID_HEIGHT + BORDER_THICKNESS * 2;

234
src/gameState.ts Normal file
View file

@ -0,0 +1,234 @@
import { RotationStyle, TileColor } from "./tetrisPieces";
import { Option, ifSome, isNone, isSome } from "./lib/option";
import { Point, addPoint } from "./lib/util";
import { TextureStore } from "./textureStore";
import { Sidebar } from "./sidebar";
import { HeldPiece } from "./heldPiece";
import { KeyEvent } from "./keyEvent";
import {
BORDER_THICKNESS,
GRID_HEIGHT,
GRID_WIDTH,
PLAYFIELD_HEIGHT,
PLAYFIELD_WIDTH,
TILE_SIZE,
} from "./constants";
function gridIndexToPos(index: number): Point {
const x = index % GRID_WIDTH;
const y = GRID_HEIGHT - Math.floor(index / GRID_WIDTH);
return [x, y];
}
function posToGridIndex(x: number, y: number): number {
return x + (GRID_HEIGHT - y) * GRID_WIDTH;
}
export class GameState {
reDraw: boolean;
gameOver: boolean;
heldPiece: Option<HeldPiece>;
fallTimer: number;
movementTimer: number;
grid: Array<Option<TileColor>>;
sidebar: Sidebar;
constructor(sidebar: Sidebar) {
this.reDraw = true;
this.gameOver = false;
this.sidebar = sidebar;
this.heldPiece = null;
this.fallTimer = 0;
this.movementTimer = 0;
this.sidebar.getNextPiece();
this.grid = Array(GRID_WIDTH * GRID_HEIGHT).fill(null);
}
drawPlayfield(ctx: CanvasRenderingContext2D, textures: TextureStore): void {
ctx.drawImage(
textures.playfieldBg,
0,
0,
PLAYFIELD_WIDTH,
PLAYFIELD_HEIGHT
);
for (const [i, tile] of this.grid.entries()) {
if (isNone(tile)) {
continue;
}
ifSome(tile, (tile) => {
GameState.drawTile(...gridIndexToPos(i), tile, ctx, textures);
});
}
ifSome(this.heldPiece, (held) => {
for (const tileOffset of held.tiles) {
const pos = addPoint(held.pos, tileOffset);
GameState.drawTile(...pos, held.schema.color, ctx, textures);
}
});
this.reDraw = false;
}
fallTick(): void | boolean {
if (isNone(this.heldPiece)) {
this.heldPiece = this.sidebar.getNextPiece();
if (this.allPointsInEmptyGridSpace(this.heldPieceTilesAdjusted())) {
this.reDraw = true;
} else {
this.heldPiece = null;
return true;
}
return;
}
if (!this.moveHeld(0, 1)) {
this.flashHeldToGrid();
}
}
inputTick(pressed: Array<KeyEvent>): void {
if (isNone(this.heldPiece)) return;
for (const key of pressed) {
switch (key) {
case KeyEvent.Up:
this.rotate();
return;
case KeyEvent.Down:
if (!this.moveHeld(0, 1)) {
this.flashHeldToGrid();
} else {
this.fallTimer = Date.now();
}
break;
case KeyEvent.Left:
this.moveHeld(-1, 0);
break;
case KeyEvent.Right:
this.moveHeld(1, 0);
break;
}
}
}
rotate(): void {
ifSome(this.heldPiece, (held) => {
const style = held.schema.rotation;
if (style === RotationStyle.None) {
return;
}
let rotationFunc: (p: Point) => Point;
if (style === RotationStyle.Center) {
// For 3x2
rotationFunc = (p): Point => [-p[1], p[0]];
} else {
// For 1x4
rotationFunc = (p): Point => [1 - p[1], p[0]];
}
const rotated = held.tiles.map(rotationFunc);
const rotatedAdjusted = rotated.map((p) => addPoint(p, held.pos));
if (this.allPointsInEmptyGridSpace(rotatedAdjusted)) {
held.tiles = rotated;
this.reDraw = true;
}
});
}
private moveHeld(dx: number, dy: number): boolean {
let success = false;
ifSome(this.heldPiece, (held) => {
const newPos: Point = addPoint(held.pos, [dx, dy]);
if (
this.allPointsInEmptyGridSpace(this.heldPieceTilesAdjusted([dx, dy]))
) {
held.pos = newPos;
this.reDraw = true;
success = true;
}
});
return success;
}
private allPointsInEmptyGridSpace(points: Array<Point>): boolean {
return (
points
.map((p): boolean => this.isGridSquareFilled(...p))
.find((v) => v) === undefined
);
}
// Should only be called when held piece is non none
private heldPieceTilesAdjusted(offset: Point = [0, 0]): Array<Point> {
const held = this.heldPiece as HeldPiece;
const adjusted: Array<Point> = held.tiles.map((t): Point => {
return addPoint(addPoint(t, offset), held.pos);
});
return adjusted;
}
private flashHeldToGrid(): void {
ifSome(this.heldPiece, (held) => {
this.heldPieceTilesAdjusted().forEach((pos) => {
const index = posToGridIndex(...pos);
this.grid[index] = held.schema.color;
});
this.heldPiece = null;
this.reDraw = true;
this.checkForTetris();
});
}
private checkForTetris(): void {
// Iterate through rows, starting at bottom and going to top
for (let i = 0; i < this.grid.length - GRID_WIDTH; i += GRID_WIDTH) {
// While the current row is completely filled, thus tetris
while (this.grid.slice(i, i + GRID_WIDTH).every((t) => isSome(t))) {
// Remove all of the tiles from the current row.
this.grid.splice(i, GRID_WIDTH);
// Then fill the empty space with new tiles
this.grid.push(...Array(GRID_WIDTH).fill(null));
// Seven seems like a good number
this.sidebar.score += 7;
}
}
}
private isGridSquareFilled(x: number, y: number): boolean {
// If invalid position, return true
if (x >= GRID_WIDTH || x < 0 || y >= GRID_HEIGHT) return true;
const index = posToGridIndex(x, y);
if (isSome(this.grid[index])) {
return true;
}
return false;
}
private static drawTile(
x: number,
y: number,
tileColor: TileColor,
ctx: CanvasRenderingContext2D,
textures: TextureStore
): void {
if (y < 0) return;
ctx.drawImage(
textures.tile[tileColor],
x * TILE_SIZE + BORDER_THICKNESS,
y * TILE_SIZE + BORDER_THICKNESS,
TILE_SIZE,
TILE_SIZE
);
}
reset(): void {
this.sidebar.reset();
this.reDraw = true;
this.gameOver = false;
this.heldPiece = this.sidebar.getNextPiece();
[this.fallTimer, this.movementTimer] = [0, 0];
this.grid.fill(null);
}
}

8
src/heldPiece.ts Normal file
View file

@ -0,0 +1,8 @@
import { Point } from "./lib/util";
import { PieceSchema } from "./tetrisPieces";
export type HeldPiece = {
schema: PieceSchema;
pos: Point;
tiles: Array<Point>;
};

BIN
src/images/background.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 839 B

BIN
src/images/numbers.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 B

BIN
src/images/playfield.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 B

BIN
src/images/side.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 B

BIN
src/images/tiles.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 512 B

93
src/index.ts Normal file
View file

@ -0,0 +1,93 @@
import { KeyEventType, Keyboard } from "./lib/controller";
import { TextureStore } from "./textureStore";
import { $, createCanvas } from "./lib/util";
import { expect, unwrap } from "./lib/option";
import { GameState } from "./gameState";
import { Sidebar } from "./sidebar";
import { KeyEvent } from "./keyEvent";
import {
PLAYFIELD_HEIGHT,
PLAYFIELD_WIDTH,
SIDEBAR_HEIGHT,
SIDEBAR_WIDTH,
} from "./constants";
async function main(): Promise<void> {
const { canvas: playfieldCanvas, ctx: playfieldContext } = expect(
createCanvas(PLAYFIELD_WIDTH, PLAYFIELD_HEIGHT),
"Could not create playfield canvas"
);
expect(
$("pfcontainer"),
"Could not insert playfield canvas into DOM"
).appendChild(playfieldCanvas);
const { canvas: sidebarCanvas, ctx: sidebarContext } = expect(
createCanvas(SIDEBAR_WIDTH, SIDEBAR_HEIGHT),
"Could not create sidebar canvas"
);
expect(
$("sdcontainer"),
"Could not insert sidebar canvas into DOM"
).appendChild(sidebarCanvas);
const textures = await TextureStore.new();
const sidebar = new Sidebar();
const game = new GameState(sidebar);
const controller = new Keyboard<KeyEvent>();
controller.registerKeyEvent(["ArrowLeft", "a"], KeyEvent.Left);
controller.registerKeyEvent(["ArrowDown", "s"], KeyEvent.Down);
controller.registerKeyEvent(["ArrowRight", "d"], KeyEvent.Right);
controller.registerKeyEvent(
["ArrowUp", "w"],
KeyEvent.Up,
KeyEventType.OnlyOnce
);
// Main loop
function frame(): void {
const now = Date.now();
let reset = false;
controller.pollSpecificKey(KeyEvent.Up, () => game.rotate());
if (now - game.movementTimer > 80) {
game.movementTimer = now;
game.inputTick(controller.poll());
} else if (now - game.fallTimer > 500) {
game.fallTimer = now;
if (game.fallTick() === true) {
reset = true;
}
}
if (game.reDraw) {
game.drawPlayfield(playfieldContext, textures);
}
if (sidebar.reDraw) {
sidebar.draw(sidebarContext, textures);
}
if (!reset) {
requestAnimationFrame(frame);
} else {
// Reset game
controller.clear();
const overlay = unwrap($("overlay"));
overlay.classList.remove("hidden");
const scoreCounter = unwrap($("scoreCounter"));
scoreCounter.textContent = `With a score of ${sidebar.score}`;
const tryAgain = unwrap($("tryAgain"));
tryAgain.addEventListener(
"click",
() => {
overlay.classList.add("hidden");
game.reset();
requestAnimationFrame(frame);
},
{ once: true }
);
}
}
requestAnimationFrame(frame);
}
document.addEventListener("DOMContentLoaded", main);

6
src/keyEvent.ts Normal file
View file

@ -0,0 +1,6 @@
export const enum KeyEvent {
Up,
Down,
Left,
Right,
}

115
src/lib/controller.ts Normal file
View file

@ -0,0 +1,115 @@
import { ifSome } from "./option";
export const enum KeyEventType {
IfCaught,
OnlyOnce,
AtLeastOnce,
}
type QueueItem<T> = {
outputValue: T;
eventType: KeyEventType;
polled: boolean;
};
export class Keyboard<T> {
private keysPressed: Set<T>;
private keyQueue: Map<T, QueueItem<T>>;
private keyMap: Map<string, QueueItem<T>>;
constructor() {
this.keyQueue = new Map();
this.keyMap = new Map();
this.keysPressed = new Set();
document.addEventListener("keydown", (ke) => {
this.keyDown(ke.key);
});
document.addEventListener("keyup", (ke) => {
this.keyUp(ke.key);
});
}
registerKeyEvent(
event: string | Array<string>,
outputValue: T,
type: KeyEventType = KeyEventType.AtLeastOnce
): void {
const addKey = (v: string): void => {
this.keyMap.set(v, {
outputValue,
eventType: type,
polled: false,
});
};
if (typeof event === "string") {
addKey(event);
} else {
event.forEach((e) => {
addKey(e);
});
}
}
clear(): void {
this.keyQueue.clear();
}
reset(): void {
this.clear();
this.keyMap.clear();
}
pollSpecificKey(key: T, callback: () => void): void {
const v = this.keyQueue.get(key);
ifSome(v, (vv) => {
if (!this.keysPressed.has(vv.outputValue)) {
if (vv.eventType === KeyEventType.IfCaught || vv.polled) {
vv.polled = false;
this.keyQueue.delete(vv.outputValue);
return;
}
}
if (!vv.polled && vv.eventType === KeyEventType.OnlyOnce) {
callback();
} else if (vv.eventType !== KeyEventType.OnlyOnce) {
callback();
}
vv.polled = true;
});
}
poll(): Array<T> {
const keys: Array<T> = [];
for (const [_, event] of this.keyQueue) {
// Check if key in queue is still being pressed
if (!this.keysPressed.has(event.outputValue)) {
if (event.eventType === KeyEventType.IfCaught || event.polled) {
event.polled = false;
this.keyQueue.delete(event.outputValue);
continue;
}
}
if (!event.polled && event.eventType === KeyEventType.OnlyOnce) {
keys.push(event.outputValue);
}
if (event.eventType !== KeyEventType.OnlyOnce) {
keys.push(event.outputValue);
}
event.polled = true;
}
return keys;
}
private keyDown(key: string): void {
const mapped = this.keyMap.get(key);
ifSome(mapped, (m) => {
this.keysPressed.add(m.outputValue);
if (!this.keyQueue.has(m.outputValue))
this.keyQueue.set(m.outputValue, m);
});
}
private keyUp(key: string): void {
const mapped = this.keyMap.get(key);
ifSome(mapped, (m) => {
this.keysPressed.delete(m.outputValue);
});
}
}

75
src/lib/option.ts Normal file
View file

@ -0,0 +1,75 @@
//
// Options (inspired by rust)
//
export type Option<T> = null | undefined | T;
export function isNone<T>(input: Option<T>): boolean {
if (input === null || input === undefined) {
return true;
}
return false;
}
export function isSome<T>(input: Option<T>): boolean {
if (input === null || input === undefined) {
return false;
}
return true;
}
type SingleArgCallback<T> = (v: T) => void;
type EmptyCallback = () => void;
/**
* If the input `Option<T>` is some, then run the `doAfter` callback.
* Intended to mimic rust's conditional enum destructuring pattern: `if let Some(v) = opt {<do things with value v>}`
*/
export function ifSome<T>(
input: Option<T>,
doAfter: SingleArgCallback<T>
): void {
if (isSome(input)) {
doAfter(input as T);
}
}
export function ifNone<T>(input: Option<T>, doAfter: EmptyCallback): void {
if (isNone(input)) {
doAfter();
}
}
// Not sure how ergonomic this is in actual use. It may get axed.
export function ifEither<T>(
input: Option<T>,
doIfSome: SingleArgCallback<T>,
doIfNone: EmptyCallback
): void {
if (isSome(input)) {
doIfSome(input as T);
return;
}
doIfNone();
}
/**
Unwrap option of `null|undefined|T` to `T` throw error if value is not `T`.
`expect()` is preferred to this function as it gives better error messages
*/
export function unwrap<T>(input: Option<T>): T {
if (isNone(input)) {
throw new TypeError("Unwrap called on null/undefined value");
}
return input as T;
}
/**
Unwrap option of `null|undefined|T` to `T` throw error with `exceptionMessage` if value is not `T`
*/
export function expect<T>(input: Option<T>, exceptionMessage: string): T {
if (isNone(input)) {
throw new TypeError(exceptionMessage);
}
return input as T;
}

31
src/lib/spriteSlicer.ts Normal file
View file

@ -0,0 +1,31 @@
import { unwrap } from "./option";
// Split a tiled spritesheet image into many different images.
// Useful to reduce http requests from loading many tiny images.
export abstract class SpriteSlicer {
static slice(
spriteSheet: HTMLImageElement,
tileSize: [number, number]
): Array<HTMLImageElement> {
const canvas = unwrap(document.createElement("canvas"));
canvas.style.display = "none";
[canvas.width, canvas.height] = tileSize;
document.body.appendChild(canvas);
const ctx = unwrap(canvas.getContext("2d"));
const tiles = [];
const tilesWide = spriteSheet.width / tileSize[0];
const tilesTall = spriteSheet.height / tileSize[1];
for (let y = 0; y < tilesTall; y++) {
for (let x = 0; x < tilesWide; x++) {
ctx.drawImage(spriteSheet, -x * tileSize[0], -y * tileSize[1]);
const tmp = new Image(...tileSize);
tmp.src = canvas.toDataURL();
ctx.clearRect(0, 0, ...tileSize);
tiles.push(tmp);
}
}
canvas.remove();
return tiles;
}
}

124
src/lib/util.ts Normal file
View file

@ -0,0 +1,124 @@
import { Option, ifSome } from "./option";
export type Point = [number, number];
export function addPoint(p1: Point, p2: Point): Point {
return [p1[0] + p2[0], p1[1] + p2[1]];
}
//
// DOM manipulation
//
// Who needs Jquery?
export function $(elementId: string): Option<HTMLElement> {
return document.getElementById(elementId);
}
export function createCanvas(
width: number,
height: number
): Option<{ canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D }> {
let ret = null;
ifSome(document.createElement("canvas"), (canvas) => {
canvas.width = width;
canvas.height = height;
ifSome(canvas.getContext("2d"), (ctx) => {
ctx.imageSmoothingEnabled = false;
ret = { canvas, ctx };
});
});
return ret;
}
export function clearBody(): void {
Array.from(document.body.children).forEach((c) => {
c.remove();
});
}
//
// Mathematical Functions
//
export const TAU = Math.PI * 2;
const PI_OVER_ONE_EIGHTY = Math.PI / 180;
const ONE_EIGHTY_OVER_PI = 180 / Math.PI;
export function degreeToRadian(degree: number): number {
return degree * PI_OVER_ONE_EIGHTY;
}
export function radianToDegree(radian: number): number {
return radian * ONE_EIGHTY_OVER_PI;
}
export function rngRange(low: number, high: number): number {
const range = high - low;
const rand = Math.random();
return rand * range + low;
}
export function rngRangeInt(low: number, high: number): number {
return Math.floor(rngRange(low, high));
}
//
// Array Methods
//
export function pickRandom<T>(a: Array<T>): T {
const index = rngRangeInt(0, a.length);
return a[index];
}
/**
Find the smallest and largest number in an array of numbers
*/
export function minMax(numbers: Array<number>): [number, number] {
const smallest = numbers.reduce((v, o) => {
return Math.min(v, o);
});
const biggest = numbers.reduce((v, o) => {
return Math.max(v, o);
});
return [smallest, biggest];
}
//
// Callback -> Promise Wrappers
//
export function loadImage(url: string): Promise<HTMLImageElement> {
return new Promise<HTMLImageElement>((resolve, reject) => {
const img = new Image();
img.addEventListener("load", () => resolve(img), { once: true });
img.addEventListener(
"error",
(err) => {
reject(err);
},
{ once: true }
);
img.src = url;
});
}
//
// Development aid errors
//
// Intended for if else if blocks which are unreachable, but that the type system might not be able to recognize as unreachable
export class Unreachable extends Error {
constructor() {
super("Code marked unreachable was run");
this.name = "Unreachable";
}
}
// Allows mocking out methods when the return type hasn't been satisfied yet
export class Unimplemented extends Error {
constructor() {
super("Code marked unimplemented was run");
this.name = "Unimplemented";
}
}

77
src/sidebar.ts Normal file
View file

@ -0,0 +1,77 @@
import { SIDEBAR_HEIGHT, SIDEBAR_WIDTH, TILE_SIZE } from "./constants";
import { HeldPiece } from "./heldPiece";
import { addPoint, minMax, pickRandom } from "./lib/util";
import { TETRIS_PIECE, TETRIS_PIECE_LIST } from "./tetrisPieces";
import { TextureStore } from "./textureStore";
const DIGIT_HEIGHT = 8;
const DIGIT_WIDTH = 6;
export class Sidebar {
private scoreNum: number;
private nextPiece: HeldPiece;
reDraw: boolean;
constructor() {
this.scoreNum = 0;
this.reDraw = true;
this.nextPiece = { schema: TETRIS_PIECE.O, pos: [0, 0], tiles: [] };
this.getNextPiece();
}
getNextPiece(): HeldPiece {
const old = this.nextPiece;
const type = pickRandom(TETRIS_PIECE_LIST);
this.nextPiece = {
schema: type,
pos: [4, 0],
tiles: structuredClone(type.tiles),
};
this.reDraw = true;
return old;
}
set score(s: number) {
this.scoreNum = s;
this.reDraw = true;
}
get score(): number {
return this.scoreNum;
}
draw(ctx: CanvasRenderingContext2D, textures: TextureStore): void {
ctx.drawImage(textures.scoreboardBg, 0, 0, SIDEBAR_WIDTH, SIDEBAR_HEIGHT);
// Draw Next Up tetrimino
const tex = textures.tile[this.nextPiece.schema.color];
const xOffsets = this.nextPiece.tiles.map((p) => p[0]);
const [smallest, biggest] = minMax(xOffsets);
const width = (biggest - smallest + 1) * TILE_SIZE;
const widthOffset = (SIDEBAR_WIDTH - width) / 2;
for (const tile of this.nextPiece.tiles) {
ctx.drawImage(
tex,
(tile[0] - smallest) * TILE_SIZE + widthOffset,
(tile[1] + 3.75) * TILE_SIZE,
TILE_SIZE,
TILE_SIZE
);
}
// Draw score
const digits = this.scoreNum.toString().split("");
const digitsWidth = digits.length * DIGIT_WIDTH;
const offset = (SIDEBAR_WIDTH - digitsWidth * 2) / 2;
for (const [i, digit] of digits.entries()) {
ctx.drawImage(
textures.numbers[Number.parseInt(digit)],
2 * DIGIT_WIDTH * i + offset,
16 * 2,
DIGIT_WIDTH * 2,
DIGIT_HEIGHT * 2
);
}
this.reDraw = false;
}
reset(): void {
this.scoreNum = 0;
this.reDraw = true;
this.getNextPiece();
}
}

105
src/tetrisPieces.ts Normal file
View file

@ -0,0 +1,105 @@
import { Point } from "./lib/util";
export const enum RotationStyle {
None,
Center,
BetweenCenter,
}
export const enum TileColor {
Red,
Green,
Blue,
Purple,
Orange,
Cyan,
Yellow,
}
export type PieceSchema = {
tiles: [Point, Point, Point, Point];
rotation: RotationStyle;
color: TileColor;
};
export const TETRIS_PIECE: Record<string, PieceSchema> = {
O: {
tiles: [
[0, 0],
[0, 1],
[1, 1],
[1, 0],
],
rotation: RotationStyle.None,
color: TileColor.Red,
},
L: {
tiles: [
[1, 0],
[0, 0],
[-1, 0],
[-1, 1],
],
rotation: RotationStyle.Center,
color: TileColor.Green,
},
J: {
tiles: [
[1, 0],
[1, 1],
[0, 0],
[-1, 0],
],
rotation: RotationStyle.Center,
color: TileColor.Blue,
},
I: {
tiles: [
[0, 0],
[-1, 0],
[1, 0],
[2, 0],
],
rotation: RotationStyle.BetweenCenter,
color: TileColor.Purple,
},
T: {
tiles: [
[1, 0],
[0, 0],
[-1, 0],
[0, 1],
],
rotation: RotationStyle.Center,
color: TileColor.Orange,
},
S: {
tiles: [
[1, 0],
[0, 0],
[0, 1],
[-1, 1],
],
rotation: RotationStyle.Center,
color: TileColor.Cyan,
},
Z: {
tiles: [
[1, 1],
[0, 0],
[0, 1],
[-1, 0],
],
rotation: RotationStyle.Center,
color: TileColor.Yellow,
},
};
export const TETRIS_PIECE_LIST: Array<PieceSchema> = [
TETRIS_PIECE.O,
TETRIS_PIECE.S,
TETRIS_PIECE.Z,
TETRIS_PIECE.L,
TETRIS_PIECE.J,
TETRIS_PIECE.T,
TETRIS_PIECE.I,
];

33
src/textureStore.ts Normal file
View file

@ -0,0 +1,33 @@
import { SpriteSlicer } from "./lib/spriteSlicer";
import { loadImage } from "./lib/util";
export type Image = HTMLImageElement;
export class TextureStore {
readonly tile: Array<Image>;
readonly numbers: Array<Image>;
readonly playfieldBg: Image;
readonly scoreboardBg: Image;
// Private constructor which is only called by the `new()` method because constructors can not be async.
// Using `await TextureStore.new()` is a somewhat clean workaround.
private constructor(
img: Array<Image>,
numbers: Array<Image>,
tile: Array<Image>
) {
[this.playfieldBg, this.scoreboardBg] = img;
this.tile = tile;
this.numbers = numbers;
}
static async new(): Promise<TextureStore> {
const playfield = await loadImage("./playfield.png");
const colors = SpriteSlicer.slice(await loadImage("./tiles.png"), [16, 16]);
const numbers = SpriteSlicer.slice(
await loadImage("./numbers.png"),
[6, 8]
);
const score = await loadImage("./side.png");
return new TextureStore([playfield, score], numbers, colors);
}
}

BIN
tetris_mockup.xcf Normal file

Binary file not shown.

23
tsconfig.json Normal file
View file

@ -0,0 +1,23 @@
{
"extends": "@tsconfig/node16/tsconfig.json",
"compilerOptions": {
"lib": [
"esnext",
"dom",
"DOM.Iterable"
],
"isolatedModules": true,
"target": "ES6",
"module": "ES6",
"rootDir": "src/",
"outDir": "out/",
"sourceMap": true,
"strict": true
},
"include": [
"src"
],
"exclude": [
"node_modules"
]
}

45
webpack.config.js Normal file
View file

@ -0,0 +1,45 @@
const path = require("path");
const CopyPlugin = require("copy-webpack-plugin");
const config = {
entry: "./src/index.ts",
plugins: [
new CopyPlugin({
patterns: [
{ from: "src/assets", to: "" },
{ from: "src/images", to: "" },
],
}),
],
module: {
rules: [
{
test: /\.ts$/,
use: "ts-loader",
exclude: /node_modules/,
},
{
test: /\.html/,
type: "asset/resource"
}
],
},
resolve: {
extensions: [".ts"],
},
output: {
filename: "main.js",
path: path.resolve(__dirname, "dist"),
},
};
module.exports = (env, argv) => {
if (argv.mode === "development") {
config.devtool = "source-map";
}
if (argv.mode === "production") {
}
return config;
};