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