tetris?
This commit is contained in:
commit
491e5b97bb
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
.idea
|
||||
|
225
index.html
Normal file
225
index.html
Normal file
@ -0,0 +1,225 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Tetrissimo</title>
|
||||
<style>
|
||||
body {
|
||||
background-color: lightgray;
|
||||
}
|
||||
</style>
|
||||
<link rel="shortcut icon" type="image/gif" href="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"/>
|
||||
</head>
|
||||
<body>
|
||||
<div style="display: flex">
|
||||
<canvas id="canvas" width="480" height="600"></canvas>
|
||||
|
||||
<div style="margin-left: 20px">
|
||||
<table>
|
||||
<tr>
|
||||
<th style="text-align: right"><label for="pixelSize">Pixel size:</label></th>
|
||||
<td><input type="range" step="1" id="pixelSize" value="4" min="1" max="16"/></td>
|
||||
<td><code id="pixelSizeValue"></code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th style="text-align: right"><label for="borderWidth">Border width:</label></th>
|
||||
<td><input type="range" step="1" id="borderWidth" value="2" min="0" max="8"/></td>
|
||||
<td><code id="borderWidthValue"></code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th style="text-align: right"><label for="padding">Padding:</label></th>
|
||||
<td><input type="range" step="1" id="padding" value="4" min="0" max="16"/></td>
|
||||
<td><code id="paddingValue"></code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th style="text-align: right"><label for="gridLines">Grid lines:</label></th>
|
||||
<td><input type="checkbox" id="gridLines" checked/></td>
|
||||
<td><code id="gridLinesValue"></code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th style="text-align: right"><label for="gridLineSize">Grid line size:</label></th>
|
||||
<td><input type="range" id="gridLineSize" value="4" step="1" min="1" max="16"/></td>
|
||||
<td><code id="gridLineSizeValue"></code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th style="text-align: right"><label for="hudWidth">HUD width:</label></th>
|
||||
<td><input type="range" id="hudWidth" value="32" step="4" min="4" max="64"/></td>
|
||||
<td><code id="hudWidthValue"></code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th style="text-align: right"><label for="hudHeight">HUD height:</label></th>
|
||||
<td><input type="range" id="hudHeight" value="12" step="4" min="4" max="64"/></td>
|
||||
<td><code id="hudHeightValue"></code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th style="text-align: right"><label for="playAreaWidth">Play Area width:</label></th>
|
||||
<td><input type="range" id="playAreaWidth" value="64" step="4" min="16" max="80"/></td>
|
||||
<td><code id="playAreaWidthValue"></code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th style="text-align: right"><label for="playAreaHeight">Play Area height:</label></th>
|
||||
<td><input type="range" id="playAreaHeight" value="128" step="4" min="16" max="256"/></td>
|
||||
<td><code id="playAreaHeightValue"></code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th style="text-align: right"><label for="fps">FPS:</label></th>
|
||||
<td><input type="range" id="fps" value="60" step="1" min="1" max="120"/></td>
|
||||
<td><code id="fpsValue"></code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th style="text-align: right"><label for="level">Level:</label></th>
|
||||
<td><input type="range" id="level" value="1" step="1" min="1" max="10"/></td>
|
||||
<td><code id="levelValue"></code></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<br />
|
||||
|
||||
<button id="playButton">Play</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<script src="requirejs-polyfill.js"></script>
|
||||
<script src="tetrissimo.js"></script>
|
||||
<script>
|
||||
(function() {
|
||||
const playBtn = document.getElementById('playButton');
|
||||
|
||||
const pixelSizeRange = document.getElementById('pixelSize');
|
||||
const pixelSizeValue = document.getElementById('pixelSizeValue');
|
||||
const paddingRange = document.getElementById('padding');
|
||||
const paddingValue = document.getElementById('paddingValue');
|
||||
const gridLinesCheckbox = document.getElementById('gridLines');
|
||||
const gridLinesValue = document.getElementById('gridLinesValue');
|
||||
const gridLineSizeRange = document.getElementById('gridLineSize');
|
||||
const gridLineSizeValue = document.getElementById('gridLineSizeValue');
|
||||
const borderWidthRange = document.getElementById('borderWidth');
|
||||
const borderWidthValue = document.getElementById('borderWidthValue');
|
||||
const hudWidthRange = document.getElementById('hudWidth');
|
||||
const hudWidthValue = document.getElementById('hudWidthValue');
|
||||
const hudHeightRange = document.getElementById('hudHeight');
|
||||
const hudHeightValue = document.getElementById('hudHeightValue');
|
||||
const playAreaWidthRange = document.getElementById('playAreaWidth');
|
||||
const playAreaWidthValue = document.getElementById('playAreaWidthValue');
|
||||
const playAreaHeightRange = document.getElementById('playAreaHeight');
|
||||
const playAreaHeightValue = document.getElementById('playAreaHeightValue');
|
||||
const fpsRange = document.getElementById('fps');
|
||||
const fpsValue = document.getElementById('fpsValue');
|
||||
const levelRange = document.getElementById('level');
|
||||
const levelValue = document.getElementById('levelValue');
|
||||
|
||||
const updateCanvas = () => {
|
||||
const pixelSize = Number(pixelSizeRange.value);
|
||||
const padding = Number(paddingRange.value);
|
||||
const gridLineSize = Number(gridLineSizeRange.value);
|
||||
const gridLines = gridLinesCheckbox.checked;
|
||||
const borderWidth = Number(borderWidthRange.value);
|
||||
const hudWidth = Number(hudWidthRange.value);
|
||||
const hudHeight = Number(hudHeightRange.value);
|
||||
const playAreaWidth = Number(playAreaWidthRange.value);
|
||||
const playAreaHeight = Number(playAreaHeightRange.value);
|
||||
|
||||
pixelSizeValue.innerHTML = pixelSize.toString();
|
||||
paddingValue.innerHTML = padding.toString();
|
||||
gridLinesValue.innerHTML = gridLines ? 'on' : 'off';
|
||||
gridLineSizeValue.innerHTML = gridLineSize.toString()
|
||||
borderWidthValue.innerHTML = borderWidth.toString();
|
||||
hudWidthValue.innerHTML = hudWidth.toString();
|
||||
hudHeightValue.innerHTML = hudHeight.toString();
|
||||
playAreaWidthValue.innerHTML = playAreaWidth.toString();
|
||||
playAreaHeightValue.innerHTML = playAreaHeight.toString();
|
||||
|
||||
ui.setOptions({
|
||||
pixelSize,
|
||||
x: 0,
|
||||
y: 0,
|
||||
padding,
|
||||
gridLines: gridLines ? gridLineSize : false,
|
||||
borderWidth,
|
||||
hud: {
|
||||
width: hudWidth,
|
||||
height: hudHeight,
|
||||
},
|
||||
playArea: {
|
||||
width: playAreaWidth,
|
||||
height: playAreaHeight,
|
||||
}
|
||||
});
|
||||
|
||||
ui.update();
|
||||
};
|
||||
|
||||
pixelSizeRange.addEventListener('change', updateCanvas);
|
||||
paddingRange.addEventListener('change', updateCanvas);
|
||||
gridLineSizeRange.addEventListener('change', updateCanvas);
|
||||
borderWidthRange.addEventListener('change', updateCanvas);
|
||||
gridLinesCheckbox.addEventListener('input', updateCanvas);
|
||||
hudWidthRange.addEventListener('change', updateCanvas);
|
||||
hudHeightRange.addEventListener('change', updateCanvas);
|
||||
playAreaWidthRange.addEventListener('change', updateCanvas);
|
||||
playAreaHeightRange.addEventListener('change', updateCanvas);
|
||||
|
||||
playBtn.addEventListener('click', () => {
|
||||
if (game.getState() === 'running') {
|
||||
game.pause();
|
||||
} else {
|
||||
game.start();
|
||||
}
|
||||
});
|
||||
|
||||
const updateFps = () => {
|
||||
const fps = Number(fpsRange.value);
|
||||
fpsValue.innerHTML = fps.toString();
|
||||
game.setTargetFps(fps);
|
||||
};
|
||||
|
||||
fpsRange.addEventListener('change', updateFps);
|
||||
|
||||
const updateLevel = () => {
|
||||
const level = Number(levelRange.value);
|
||||
levelValue.innerHTML = level.toString();
|
||||
|
||||
const tetrissimoLevel = TetrissimoLevel['level' + level];
|
||||
if (!tetrissimoLevel) {
|
||||
throw new Error(`invalid level number "${level}"`);
|
||||
}
|
||||
|
||||
game.setLevel(tetrissimoLevel);
|
||||
};
|
||||
|
||||
levelRange.addEventListener('change', updateLevel);
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
const performAction = (type) => {
|
||||
e.preventDefault();
|
||||
game.performPieceAction({ type });
|
||||
};
|
||||
|
||||
switch (e.code) {
|
||||
case 'KeyA':
|
||||
performAction('moveLeft');
|
||||
break;
|
||||
case 'KeyS':
|
||||
performAction('moveDown');
|
||||
break;
|
||||
case 'KeyD':
|
||||
performAction('moveRight');
|
||||
break;
|
||||
case 'Space':
|
||||
performAction('rotateRight');
|
||||
break;
|
||||
case 'ShiftLeft':
|
||||
performAction('rotateLeft');
|
||||
break;
|
||||
}
|
||||
})
|
||||
|
||||
updateCanvas();
|
||||
updateFps();
|
||||
updateLevel();
|
||||
}());
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
14
package.json
Normal file
14
package.json
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "tetrissimo",
|
||||
"version": "0.0.0",
|
||||
"license": "WTFPL",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"watch": "tsc --watch --preserveWatchOutput",
|
||||
"start": "serve -l 19000 ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"serve": "*",
|
||||
"typescript": "4.0.3"
|
||||
}
|
||||
}
|
52
requirejs-polyfill.js
Normal file
52
requirejs-polyfill.js
Normal file
@ -0,0 +1,52 @@
|
||||
((window) => {
|
||||
const moduleCache = {};
|
||||
const exportsCache = {};
|
||||
const exportsPlaceholder = '!exports';
|
||||
|
||||
function define(name, deps, fn) {
|
||||
if (Array.isArray(name)) {
|
||||
if (typeof (deps) === 'function') {
|
||||
const resolvedDeps = require(name);
|
||||
deps.apply(null, resolvedDeps);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
moduleCache[name] = {deps, fn};
|
||||
}
|
||||
|
||||
function require(names) {
|
||||
return (names || [])
|
||||
.map((name) => {
|
||||
if (name in exportsCache) {
|
||||
return exportsCache[name];
|
||||
}
|
||||
if (name === 'exports') {
|
||||
return exportsPlaceholder;
|
||||
}
|
||||
|
||||
const mod = moduleCache[name];
|
||||
if (!mod) {
|
||||
throw new Error(`Unknown module "${name}"`);
|
||||
}
|
||||
|
||||
exportsCache[name] = {};
|
||||
const args = require(mod.deps);
|
||||
const index = args.findIndex(x => x === exportsPlaceholder);
|
||||
if (index !== -1) {
|
||||
args[index] = exportsCache[name];
|
||||
}
|
||||
mod.fn.apply(null, args);
|
||||
const moduleKey = '__$module';
|
||||
if (exportsCache[name][moduleKey]) {
|
||||
exportsCache[name] = exportsCache[name][moduleKey];
|
||||
}
|
||||
return exportsCache[name];
|
||||
});
|
||||
}
|
||||
|
||||
window.require = exportsCache.require = require;
|
||||
window.define = define;
|
||||
window.exportsCache = exportsCache;
|
||||
window.moduleCache = moduleCache;
|
||||
})(window);
|
555
tetrissimo.js
Normal file
555
tetrissimo.js
Normal file
File diff suppressed because one or more lines are too long
764
tetrissimo.ts
Normal file
764
tetrissimo.ts
Normal file
@ -0,0 +1,764 @@
|
||||
type GameState = 'running' | 'paused' | 'not-started' | 'finished';
|
||||
|
||||
const nope = (x: never): void => {};
|
||||
|
||||
type PieceCell = 0 | 1;
|
||||
type PieceLayout = PieceCell[][];
|
||||
|
||||
interface UIElement {
|
||||
render(context: CanvasRenderingContext2D, options: UIElementRenderOptions): void;
|
||||
}
|
||||
|
||||
interface Dimensions {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface GlobalUIOptions {
|
||||
pixelSize: number;
|
||||
padding: number;
|
||||
canvas: HTMLCanvasElement;
|
||||
context: CanvasRenderingContext2D;
|
||||
gridLines: false | number;
|
||||
borderWidth: number;
|
||||
hud: Dimensions;
|
||||
playArea: Dimensions;
|
||||
}
|
||||
|
||||
interface UIElementRenderOptions extends GlobalUIOptions {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface PieceTranslation {
|
||||
xDelta: number;
|
||||
yDelta: number;
|
||||
}
|
||||
|
||||
interface TetrissimoCoordinates {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface RectangleCoordinates extends TetrissimoCoordinates, Dimensions {}
|
||||
|
||||
type PieceActionType =
|
||||
'moveLeft' |
|
||||
'moveRight' |
|
||||
'moveDown' |
|
||||
'rotateLeft' |
|
||||
'rotateRight';
|
||||
|
||||
interface PieceAction {
|
||||
type: PieceActionType;
|
||||
}
|
||||
|
||||
const getSize = (value: number, options: Pick<GlobalUIOptions, 'pixelSize'>): number => value * options.pixelSize;
|
||||
|
||||
const drawBorderedRect = (
|
||||
position: Dimensions & TetrissimoCoordinates,
|
||||
context: CanvasRenderingContext2D,
|
||||
options: UIElementRenderOptions,
|
||||
): void => {
|
||||
const borderWidth = options.borderWidth;
|
||||
if (!borderWidth) {
|
||||
return;
|
||||
}
|
||||
|
||||
const actualLineWidth = getSize(borderWidth, options);
|
||||
context.lineWidth = actualLineWidth;
|
||||
context.strokeStyle = 'white';
|
||||
context.strokeRect(
|
||||
getSize(position.x, options) - Math.round(actualLineWidth / 2),
|
||||
getSize(position.y, options) - Math.round(actualLineWidth / 2),
|
||||
getSize(position.width, options) + actualLineWidth,
|
||||
getSize(position.height, options) + actualLineWidth,
|
||||
);
|
||||
};
|
||||
|
||||
class TetrissimoLevel {
|
||||
public readonly order: number;
|
||||
public readonly movementsPerSecond: number;
|
||||
|
||||
public static readonly level1 = new TetrissimoLevel(1, 0.5);
|
||||
public static readonly level2 = new TetrissimoLevel(2, 0.75);
|
||||
public static readonly level3 = new TetrissimoLevel(2, 1);
|
||||
public static readonly level4 = new TetrissimoLevel(2, 1.5);
|
||||
public static readonly level5 = new TetrissimoLevel(2, 2);
|
||||
public static readonly level6 = new TetrissimoLevel(2, 3);
|
||||
public static readonly level7 = new TetrissimoLevel(2, 4);
|
||||
public static readonly level8 = new TetrissimoLevel(2, 6);
|
||||
public static readonly level9 = new TetrissimoLevel(2, 8);
|
||||
public static readonly level10 = new TetrissimoLevel(2, 10);
|
||||
|
||||
public constructor(order: number, speed: number) {
|
||||
this.order = order;
|
||||
this.movementsPerSecond = speed;
|
||||
}
|
||||
}
|
||||
|
||||
class TetrissimoGame {
|
||||
private startedAt: Date | null = null;
|
||||
private pausedAt: Date | null = null;
|
||||
private finishedAt: Date | null = null;
|
||||
private state: GameState = 'not-started';
|
||||
private fps = 60;
|
||||
private readonly ui: TetrissimoUI;
|
||||
private lastFrameTimes: number[] = [];
|
||||
private readonly pieces: TetrissimoPiece[] = [];
|
||||
public currentLevel: TetrissimoLevel = TetrissimoLevel.level1;
|
||||
private msUntilNextTick = 0;
|
||||
private lastTick = new Date();
|
||||
private lastUpdate = new Date();
|
||||
|
||||
public constructor(ui: TetrissimoUI) {
|
||||
this.ui = ui;
|
||||
}
|
||||
|
||||
public getState(): GameState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
public setTargetFps(fps: number) {
|
||||
this.fps = fps;
|
||||
}
|
||||
|
||||
public setLevel(level: TetrissimoLevel) {
|
||||
this.currentLevel = level;
|
||||
}
|
||||
|
||||
public getCurrentFps(): number {
|
||||
switch (this.state) {
|
||||
case 'running':
|
||||
if (this.lastFrameTimes.length <= 1) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const firstFrame = this.lastFrameTimes[0];
|
||||
const lastFrame = this.lastFrameTimes[this.lastFrameTimes.length - 1];
|
||||
const elapsedMs = lastFrame - firstFrame;
|
||||
const avgMsPerFrame = (elapsedMs / this.lastFrameTimes.length);
|
||||
return 1000 / avgMsPerFrame;
|
||||
case 'paused':
|
||||
case 'not-started':
|
||||
case 'finished':
|
||||
return 0;
|
||||
default:
|
||||
nope(this.state);
|
||||
throw new Error(`Invalid game state "${this.state}"`);
|
||||
}
|
||||
}
|
||||
|
||||
public performPieceAction(action: PieceAction) {
|
||||
if (this.state !== 'running') {
|
||||
return;
|
||||
}
|
||||
|
||||
const piece = this.pieces.filter(piece => piece.state === 'in-play')[0];
|
||||
if (!piece) {
|
||||
// nothing to do, no pieces in play
|
||||
return;
|
||||
}
|
||||
|
||||
const movementIncrement = 1;
|
||||
|
||||
switch (action.type) {
|
||||
case 'moveLeft':
|
||||
this.moveIfValid(piece, -movementIncrement, 0);
|
||||
break;
|
||||
case 'moveRight':
|
||||
this.moveIfValid(piece, movementIncrement, 0);
|
||||
break;
|
||||
case 'moveDown':
|
||||
if (!this.moveIfValid(piece, 0, movementIncrement)) {
|
||||
// make fixed to bottom of play area
|
||||
piece.setFixed();
|
||||
}
|
||||
break;
|
||||
case 'rotateLeft':
|
||||
// TODO ensure it's a valid move
|
||||
piece.rotateLeft();
|
||||
break;
|
||||
case 'rotateRight':
|
||||
piece.rotateRight();
|
||||
break;
|
||||
default:
|
||||
nope(action.type);
|
||||
throw new Error(`invalid action "${action.type}"`);
|
||||
}
|
||||
}
|
||||
|
||||
public start() {
|
||||
this.pausedAt = null;
|
||||
|
||||
switch (this.state) {
|
||||
case 'running':
|
||||
// no-op
|
||||
break;
|
||||
case 'paused':
|
||||
case 'not-started':
|
||||
this.lastTick = new Date();
|
||||
this.lastUpdate = new Date();
|
||||
this.startedAt = new Date();
|
||||
this.state = 'running';
|
||||
this.update();
|
||||
break;
|
||||
case 'finished':
|
||||
throw new Error('Cannot start a finished game');
|
||||
default:
|
||||
nope(this.state);
|
||||
throw new Error(`Invalid game state "${this.state}"`);
|
||||
}
|
||||
}
|
||||
|
||||
public pause() {
|
||||
switch (this.state) {
|
||||
case 'running':
|
||||
case 'paused':
|
||||
this.pausedAt = new Date();
|
||||
this.state = 'paused';
|
||||
this.lastFrameTimes = [];
|
||||
break;
|
||||
case 'not-started':
|
||||
case 'finished':
|
||||
throw new Error(`Cannot pause game while it's in state "${this.state}"`);
|
||||
default:
|
||||
nope(this.state);
|
||||
throw new Error(`Invalid game state "${this.state}"`);
|
||||
}
|
||||
}
|
||||
|
||||
public update(): void {
|
||||
const tickStart = Date.now();
|
||||
this.lastFrameTimes.push(tickStart);
|
||||
if (this.lastFrameTimes.length > 20) {
|
||||
this.lastFrameTimes.shift();
|
||||
}
|
||||
|
||||
switch (this.state) {
|
||||
case 'running':
|
||||
this.performUpdate();
|
||||
break;
|
||||
case 'paused':
|
||||
case 'not-started':
|
||||
case 'finished':
|
||||
throw new Error(`Cannot advance game while in state "${this.state}"`);
|
||||
default:
|
||||
nope(this.state);
|
||||
throw new Error(`Invalid game state "${this.state}"`);
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - tickStart;
|
||||
const nextTickTimeout = Math.max(0, (1000 / this.fps) - elapsed);
|
||||
setTimeout(() => {
|
||||
switch (this.state) {
|
||||
case 'running':
|
||||
this.update();
|
||||
break;
|
||||
case 'paused':
|
||||
case 'not-started':
|
||||
case 'finished':
|
||||
break;
|
||||
default:
|
||||
nope(this.state);
|
||||
throw new Error(`Invalid game state "${this.state}"`);
|
||||
}
|
||||
}, nextTickTimeout);
|
||||
}
|
||||
|
||||
private performUpdate(): void {
|
||||
const sinceLastUpdate = Date.now() - this.lastUpdate.getTime();
|
||||
this.msUntilNextTick -= sinceLastUpdate;
|
||||
this.lastUpdate = new Date();
|
||||
|
||||
if (this.msUntilNextTick <= 0) {
|
||||
this.tick();
|
||||
}
|
||||
|
||||
// detect collision
|
||||
// is every column filled? remove column
|
||||
// is every row filled? end game
|
||||
|
||||
ui.update();
|
||||
}
|
||||
|
||||
private moveIfValid(piece: TetrissimoPiece, xDelta: number, yDelta: number): boolean {
|
||||
const translation: PieceTranslation = {
|
||||
xDelta,
|
||||
yDelta,
|
||||
};
|
||||
if (this.ui.canMovePiece(piece, translation)) {
|
||||
piece.move(translation);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
private tick(): void {
|
||||
const inPlayPieces = this.pieces.filter(piece => piece.state === 'in-play');
|
||||
if (!inPlayPieces.length) {
|
||||
const newPiece = TetrissimoPiece.T.clone();
|
||||
newPiece.state = 'in-play';
|
||||
this.pieces.push(newPiece);
|
||||
this.ui.addPiece(newPiece);
|
||||
} else {
|
||||
inPlayPieces.forEach((piece) => {
|
||||
if (!this.moveIfValid(piece, 0, 1)) {
|
||||
piece.setFixed();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.msUntilNextTick = Math.round(1000 / this.currentLevel.movementsPerSecond);
|
||||
this.lastTick = new Date();
|
||||
}
|
||||
}
|
||||
|
||||
class TetrissimoUI {
|
||||
public readonly playArea: TetrissimoPlayArea;
|
||||
private readonly pieces: Array<Readonly<TetrissimoPiece>> = [];
|
||||
public readonly nextBox: TetrissimoNextBox;
|
||||
public readonly hud: TetrissimoHUD;
|
||||
public readonly options: GlobalUIOptions;
|
||||
|
||||
public constructor(
|
||||
canvas: HTMLCanvasElement,
|
||||
playArea: TetrissimoPlayArea,
|
||||
// nextBox: TetrissimoNextBox,
|
||||
hud: TetrissimoHUD,
|
||||
) {
|
||||
this.playArea = playArea;
|
||||
// this.nextBox = nextBox;
|
||||
this.hud = hud;
|
||||
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
throw new Error('failed to get 2d context');
|
||||
}
|
||||
this.options = {
|
||||
borderWidth: 1,
|
||||
canvas: canvas,
|
||||
context,
|
||||
gridLines: 4,
|
||||
hud: {
|
||||
width: 32,
|
||||
height: 12,
|
||||
},
|
||||
playArea: {
|
||||
width: 68,
|
||||
height: 128,
|
||||
},
|
||||
padding: 2,
|
||||
pixelSize: 4,
|
||||
};
|
||||
}
|
||||
|
||||
public setOptions(options: Omit<GlobalUIOptions, 'canvas' | 'context'>) {
|
||||
Object.keys(options).forEach((key) => {
|
||||
this.options[key] = options[key];
|
||||
});
|
||||
}
|
||||
|
||||
public addPiece(piece: TetrissimoPiece) {
|
||||
this.pieces.push(piece);
|
||||
}
|
||||
|
||||
private getPiecePosition(piece: Readonly<TetrissimoPiece>): TetrissimoCoordinates {
|
||||
const origin = this.getPieceOrigin(piece);
|
||||
const offset = piece.getOffset();
|
||||
|
||||
return {
|
||||
x: origin.x + offset.x,
|
||||
y: origin.y + offset.y,
|
||||
};
|
||||
}
|
||||
|
||||
public canMovePiece(piece: Readonly<TetrissimoPiece>, delta: PieceTranslation): boolean {
|
||||
// check each block in layout's edge to see if it's in a valid area?s
|
||||
|
||||
const layout = piece.getLayout();
|
||||
const position = this.getPiecePosition(piece);
|
||||
|
||||
for (let i = 0; i < layout.length; i++) {
|
||||
const blockTopEdge = position.y + (i * TetrissimoPiece.blockLength) + (delta.yDelta * TetrissimoPiece.blockLength);
|
||||
for (let j = 0; j < layout[i].length; j++) {
|
||||
if (layout[i][j] === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const blockLeftEdge = position.x + (j * TetrissimoPiece.blockLength) + (delta.xDelta * TetrissimoPiece.blockLength);
|
||||
|
||||
const isCollision = this.hasCollision({
|
||||
x: blockLeftEdge,
|
||||
y: blockTopEdge,
|
||||
width: TetrissimoPiece.blockLength,
|
||||
height: TetrissimoPiece.blockLength,
|
||||
});
|
||||
|
||||
if (isCollision) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private hasCollision(rect: RectangleCoordinates): boolean {
|
||||
const playAreaLeftEdge = this.options.padding;
|
||||
const playAreaRightEdge = playAreaLeftEdge + this.options.playArea.width;
|
||||
const playAreaTopEdge = this.options.padding;
|
||||
const playAreaBottomEdge = playAreaTopEdge + this.options.playArea.height;
|
||||
|
||||
const leftEdge = rect.x;
|
||||
const rightEdge = rect.x + rect.width;
|
||||
const topEdge = rect.y;
|
||||
const bottomEdge = rect.y + rect.height;
|
||||
|
||||
const isInPlayArea =
|
||||
leftEdge >= playAreaLeftEdge &&
|
||||
rightEdge <= playAreaRightEdge &&
|
||||
bottomEdge <= playAreaBottomEdge &&
|
||||
topEdge >= playAreaTopEdge;
|
||||
|
||||
if (!isInPlayArea) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const fixedPieces = this.pieces.filter(piece => piece.state === 'fixed');
|
||||
for (const piece of fixedPieces) {
|
||||
const position = this.getPiecePosition(piece);
|
||||
const layout = piece.getLayout();
|
||||
for (let i = 0; i < layout.length; i++) {
|
||||
const blockTopEdge = position.y + (i * TetrissimoPiece.blockLength);
|
||||
const blockBottomEdge = blockTopEdge + TetrissimoPiece.blockLength;
|
||||
for (let j = 0; j < layout[i].length; j++) {
|
||||
if (layout[i][j] === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const blockLeftEdge = position.x + (j * TetrissimoPiece.blockLength);
|
||||
const blockRightEdge = blockLeftEdge + TetrissimoPiece.blockLength;
|
||||
|
||||
const isCollision =
|
||||
leftEdge >= blockLeftEdge &&
|
||||
rightEdge <= blockRightEdge &&
|
||||
bottomEdge <= blockBottomEdge &&
|
||||
topEdge >= blockTopEdge;
|
||||
|
||||
if (isCollision) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public update() {
|
||||
const options = this.options;
|
||||
const context = this.options.context;
|
||||
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// fill background
|
||||
context.fillStyle = 'rgb(224,158,107)';
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// draw some grid lines
|
||||
if (typeof(options.gridLines) === 'number') {
|
||||
context.strokeStyle = 'rgba(255, 255, 255, 0.25)';
|
||||
context.lineWidth = 1;
|
||||
const increment = options.pixelSize * options.gridLines;
|
||||
for (let i = 0; i < options.canvas.width; i += increment) {
|
||||
// draw vertical line
|
||||
context.beginPath();
|
||||
context.moveTo(i, 0);
|
||||
context.lineTo(i, options.canvas.height);
|
||||
context.stroke();
|
||||
}
|
||||
for (let i = 0; i < options.canvas.height; i += increment) {
|
||||
// draw horizontal lines
|
||||
context.beginPath();
|
||||
context.moveTo(0, i);
|
||||
context.lineTo(options.canvas.width, i);
|
||||
context.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
const totalWidth = Math.floor(canvas.width / options.pixelSize);
|
||||
const padding = options.padding;
|
||||
|
||||
// draw HUD in top right corner
|
||||
this.hud.render(context, {
|
||||
...options,
|
||||
x: totalWidth - padding - options.hud.width,
|
||||
y: padding,
|
||||
});
|
||||
|
||||
// draw play area
|
||||
this.playArea.render(context, {
|
||||
...options,
|
||||
x: padding,
|
||||
y: padding,
|
||||
});
|
||||
|
||||
this.pieces.forEach((piece, i) => {
|
||||
piece.render(context, {
|
||||
...options,
|
||||
...this.getPieceOrigin(piece),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private getPieceOrigin(piece: Readonly<TetrissimoPiece>): TetrissimoCoordinates {
|
||||
const length = TetrissimoPiece.blockLength;
|
||||
// TODO rotation will cause problems, need a "PieceDefinition" or something with a static layout
|
||||
let translateX = Math.floor((piece.getBlockWidth() * length) / 2);
|
||||
|
||||
// we can only perform movements in multiples of the blockLength, so we must clamp it
|
||||
// to ensure it's not offset
|
||||
translateX -= (translateX % length);
|
||||
const padding = this.options.padding;
|
||||
return {
|
||||
x: padding + Math.round(this.options.playArea.width / 2) - translateX,
|
||||
y: padding,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class TetrissimoNextBox implements UIElement {
|
||||
public render(context: CanvasRenderingContext2D, options: UIElementRenderOptions) {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
class TetrissimoHUD implements UIElement {
|
||||
public render(context: CanvasRenderingContext2D, options: UIElementRenderOptions) {
|
||||
const position = {
|
||||
x: options.x,
|
||||
y: options.y,
|
||||
width: options.hud.width,
|
||||
height: options.hud.height,
|
||||
};
|
||||
drawBorderedRect(position, context, options);
|
||||
}
|
||||
}
|
||||
|
||||
class TetrissimoPlayArea implements UIElement {
|
||||
public render(context: CanvasRenderingContext2D, options: UIElementRenderOptions) {
|
||||
const position = {
|
||||
x: options.x,
|
||||
y: options.y,
|
||||
width: options.playArea.width,
|
||||
height: options.playArea.height,
|
||||
};
|
||||
drawBorderedRect(position, context, options);
|
||||
}
|
||||
}
|
||||
|
||||
type PieceState = 'in-play' | 'fixed';
|
||||
|
||||
class TetrissimoPiece implements UIElement {
|
||||
public readonly name: string;
|
||||
private readonly originalLayout: Readonly<PieceLayout>;
|
||||
private layout: PieceLayout;
|
||||
private offset: TetrissimoCoordinates = { x: 0, y: 0 };
|
||||
private readonly color: string;
|
||||
public static readonly blockLength = 4;
|
||||
public state: PieceState = 'in-play';
|
||||
|
||||
public static readonly J = new TetrissimoPiece(
|
||||
'J',
|
||||
[
|
||||
[0, 1],
|
||||
[0, 1],
|
||||
[1, 1],
|
||||
],
|
||||
'black',
|
||||
);
|
||||
public static readonly S: TetrissimoPiece = new TetrissimoPiece(
|
||||
'S',
|
||||
[
|
||||
[0, 1, 1],
|
||||
[1, 1, 0],
|
||||
],
|
||||
'green',
|
||||
);
|
||||
public static readonly T: TetrissimoPiece = new TetrissimoPiece(
|
||||
'T',
|
||||
[
|
||||
[1, 1, 1],
|
||||
[0, 1, 0],
|
||||
],
|
||||
'blue',
|
||||
);
|
||||
public static readonly Z: TetrissimoPiece = new TetrissimoPiece(
|
||||
'Z',
|
||||
[
|
||||
[1, 1, 0,],
|
||||
[0, 1, 1,],
|
||||
],
|
||||
'yellow',
|
||||
);
|
||||
public static readonly I: TetrissimoPiece = new TetrissimoPiece(
|
||||
'I',
|
||||
[
|
||||
[1, 1, 1, 1],
|
||||
],
|
||||
'magenta',
|
||||
);
|
||||
public static readonly L: TetrissimoPiece = new TetrissimoPiece(
|
||||
'L',
|
||||
[
|
||||
[1],
|
||||
[1],
|
||||
[1, 1],
|
||||
],
|
||||
'cyan',
|
||||
);
|
||||
public static readonly O: TetrissimoPiece = new TetrissimoPiece(
|
||||
'O',
|
||||
[
|
||||
[1, 1],
|
||||
[1, 1],
|
||||
],
|
||||
'red',
|
||||
);
|
||||
|
||||
public constructor(name: string, layout: PieceLayout, color: string) {
|
||||
this.name = name;
|
||||
this.originalLayout = layout.map(row => row.concat([]));
|
||||
this.layout = layout.concat([]);
|
||||
this.color = color;
|
||||
}
|
||||
|
||||
public getOffset(): Readonly<TetrissimoCoordinates> {
|
||||
return this.offset;
|
||||
}
|
||||
|
||||
public getLayout(): Readonly<PieceLayout> {
|
||||
return this.layout;
|
||||
}
|
||||
|
||||
public move(translation: PieceTranslation) {
|
||||
if (this.state !== 'in-play') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.offset.x += (translation.xDelta * TetrissimoPiece.blockLength);
|
||||
this.offset.y += (translation.yDelta * TetrissimoPiece.blockLength);
|
||||
}
|
||||
|
||||
public rotateLeft() {
|
||||
if (this.state !== 'in-play') {
|
||||
return;
|
||||
}
|
||||
|
||||
// clockwise turn
|
||||
const newLayout: PieceLayout = [];
|
||||
for (let i = 0; i < this.layout.length; i++) {
|
||||
const col = this.layout.length - 1 - i;
|
||||
for (let j = 0; j < this.layout[i].length; j++) {
|
||||
const row = j;
|
||||
if (!newLayout[row]) {
|
||||
newLayout[row] = [];
|
||||
}
|
||||
newLayout[row][col] = this.layout[i][j];
|
||||
}
|
||||
}
|
||||
|
||||
this.layout = newLayout;
|
||||
}
|
||||
|
||||
public rotateRight() {
|
||||
if (this.state !== 'in-play') {
|
||||
return;
|
||||
}
|
||||
|
||||
const newLayout: PieceLayout = [];
|
||||
for (let i = 0; i < this.layout.length; i++) {
|
||||
const col = i;
|
||||
for (let j = 0; j < this.layout[i].length; j++) {
|
||||
const row = this.layout[i].length - 1 - j;
|
||||
if (!newLayout[row]) {
|
||||
newLayout[row] = [];
|
||||
}
|
||||
newLayout[row][col] = this.layout[i][j];
|
||||
}
|
||||
}
|
||||
|
||||
this.layout = newLayout;
|
||||
}
|
||||
|
||||
public setFixed() {
|
||||
this.state = 'fixed';
|
||||
}
|
||||
|
||||
public getBlockWidth(): number {
|
||||
return this.layout
|
||||
.map(arr => arr.filter(value => value === 1).length)
|
||||
.reduce((max, len) => Math.max(max, len), 0);
|
||||
}
|
||||
|
||||
public getBlockHeight(): number {
|
||||
const cols: PieceCell[][] = [];
|
||||
for (let i = 0; i < this.layout.length; i++) {
|
||||
for (let j = 0; j < this.layout[i].length; j++) {
|
||||
if (!cols[j]) {
|
||||
cols[j] = [];
|
||||
}
|
||||
cols[j].push(this.layout[i][j]);
|
||||
}
|
||||
}
|
||||
|
||||
return cols
|
||||
.map(arr => arr.filter(value => value === 1).length)
|
||||
.reduce((max, len) => Math.max(max, len), 0);
|
||||
}
|
||||
|
||||
public clone(): TetrissimoPiece {
|
||||
return new TetrissimoPiece(this.name, this.layout, this.color);
|
||||
}
|
||||
|
||||
public render(context: CanvasRenderingContext2D, options: UIElementRenderOptions) {
|
||||
this.layout.forEach((columns, rowNum) => {
|
||||
columns.forEach((value, colNum) => {
|
||||
switch (value) {
|
||||
case 0:
|
||||
break;
|
||||
case 1:
|
||||
context.fillStyle = this.color;
|
||||
const length = TetrissimoPiece.blockLength;
|
||||
const offsetX = this.offset.x;
|
||||
const offsetY = this.offset.y;
|
||||
|
||||
const x = options.x + offsetX + (colNum * length);
|
||||
const y = options.y + offsetY + (rowNum * length);
|
||||
const actualLength = getSize(length, options);
|
||||
context.fillRect(getSize(x, options), getSize(y, options), actualLength, actualLength);
|
||||
break;
|
||||
default:
|
||||
nope(value);
|
||||
throw new Error(`Invalid piece cell value "${value}"`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------
|
||||
|
||||
const hud = new TetrissimoHUD();
|
||||
const playArea = new TetrissimoPlayArea();
|
||||
|
||||
const canvas = document.getElementsByTagName('canvas')[0];
|
||||
if (!canvas) {
|
||||
throw new Error('no canvas');
|
||||
}
|
||||
|
||||
const ui = new TetrissimoUI(canvas, playArea, hud);
|
||||
const game = new TetrissimoGame(ui);
|
||||
|
24
tsconfig.json
Normal file
24
tsconfig.json
Normal file
@ -0,0 +1,24 @@
|
||||
{
|
||||
"compileOnSave": false,
|
||||
"compilerOptions": {
|
||||
"target": "es6",
|
||||
"module": "amd",
|
||||
"lib": [
|
||||
"es6",
|
||||
"dom"
|
||||
],
|
||||
"moduleResolution": "node",
|
||||
"noImplicitAny": false,
|
||||
"allowJs": false,
|
||||
"inlineSourceMap": true,
|
||||
"inlineSources": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"allowUnreachableCode": true,
|
||||
"noImplicitReturns": false,
|
||||
"preserveConstEnums": true,
|
||||
"strictNullChecks": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user