init
This commit is contained in:
commit
9b528ab2e8
64
.eslintrc.json
Normal file
64
.eslintrc.json
Normal 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
4
.gitignore
vendored
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
node_modules
|
||||||
|
out/
|
||||||
|
dist/
|
||||||
|
makefile
|
8
.prettierrc.json
Normal file
8
.prettierrc.json
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"useTabs": true,
|
||||||
|
"semi": true,
|
||||||
|
"endOfLine": "lf",
|
||||||
|
"singleQuote": false
|
||||||
|
|
||||||
|
}
|
2905
package-lock.json
generated
Normal file
2905
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
19
package.json
Normal file
19
package.json
Normal 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
104
src/assets/froob.html
Normal 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’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
9
src/constants.ts
Normal 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
234
src/gameState.ts
Normal 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
8
src/heldPiece.ts
Normal 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
BIN
src/images/background.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 839 B |
BIN
src/images/numbers.png
Normal file
BIN
src/images/numbers.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 243 B |
BIN
src/images/playfield.png
Normal file
BIN
src/images/playfield.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 230 B |
BIN
src/images/side.png
Normal file
BIN
src/images/side.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 249 B |
BIN
src/images/tiles.png
Normal file
BIN
src/images/tiles.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 512 B |
93
src/index.ts
Normal file
93
src/index.ts
Normal 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
6
src/keyEvent.ts
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
export const enum KeyEvent {
|
||||||
|
Up,
|
||||||
|
Down,
|
||||||
|
Left,
|
||||||
|
Right,
|
||||||
|
}
|
115
src/lib/controller.ts
Normal file
115
src/lib/controller.ts
Normal 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
75
src/lib/option.ts
Normal 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
31
src/lib/spriteSlicer.ts
Normal 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
124
src/lib/util.ts
Normal 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
77
src/sidebar.ts
Normal 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
105
src/tetrisPieces.ts
Normal 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
33
src/textureStore.ts
Normal 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
BIN
tetris_mockup.xcf
Normal file
Binary file not shown.
23
tsconfig.json
Normal file
23
tsconfig.json
Normal 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
45
webpack.config.js
Normal 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;
|
||||||
|
};
|
Loading…
Reference in a new issue