aboutsummaryrefslogtreecommitdiffstats
path: root/frontend/src
diff options
context:
space:
mode:
Diffstat (limited to 'frontend/src')
-rw-r--r--frontend/src/App.vue10
-rw-r--r--frontend/src/assets/logo.svg1
-rw-r--r--frontend/src/components/Board/TetrisBoard.vue162
-rw-r--r--frontend/src/main.ts11
-rw-r--r--frontend/src/model/board.ts1
-rw-r--r--frontend/src/model/tetrominoes.ts230
-rw-r--r--frontend/src/router/index.ts15
-rw-r--r--frontend/src/util/tetris.ts40
-rw-r--r--frontend/src/views/HomeView.vue10
9 files changed, 480 insertions, 0 deletions
diff --git a/frontend/src/App.vue b/frontend/src/App.vue
new file mode 100644
index 0000000..e2ce8ee
--- /dev/null
+++ b/frontend/src/App.vue
@@ -0,0 +1,10 @@
+<script setup lang="ts">
+import { RouterView } from 'vue-router'
+</script>
+
+<template>
+ <RouterView />
+</template>
+
+<style scoped>
+</style> \ No newline at end of file
diff --git a/frontend/src/assets/logo.svg b/frontend/src/assets/logo.svg
new file mode 100644
index 0000000..7565660
--- /dev/null
+++ b/frontend/src/assets/logo.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>
diff --git a/frontend/src/components/Board/TetrisBoard.vue b/frontend/src/components/Board/TetrisBoard.vue
new file mode 100644
index 0000000..d1f6557
--- /dev/null
+++ b/frontend/src/components/Board/TetrisBoard.vue
@@ -0,0 +1,162 @@
+<script setup lang="ts">
+import { onMounted, ref, type Ref } from 'vue';
+import { mergeTetrominoWithBoard, tetrominoCollidesWithBoard } from '@/util/tetris';
+import { type Tetromino, Tetrominoes } from '@/model/tetrominoes';
+import { type Board } from '@/model/board';
+
+const renderBoard: Ref<Board> = ref([[]]);
+let gameBoard: Board = [
+ [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
+ [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
+ [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
+ [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
+ [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
+ [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
+ [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
+ [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
+ [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
+ [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
+ [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
+ [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
+ [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
+ [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
+ [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
+ [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
+ [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
+ [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
+ [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
+ [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
+]
+let currentTetromino: Tetromino | null = null;
+let nextTetromino: Ref<Tetromino | undefined> = ref();
+let gameInterval: ReturnType<typeof setInterval>;
+let gameInProgress = false;
+
+function updateRender() {
+ if (currentTetromino) {
+ renderBoard.value = mergeTetrominoWithBoard(gameBoard, currentTetromino);
+ } else {
+ renderBoard.value = gameBoard;
+ }
+}
+
+const tick = () => {
+ if (!currentTetromino) {
+ currentTetromino = { ...nextTetromino.value! };
+ if (tetrominoCollidesWithBoard(gameBoard, currentTetromino)) {
+ clearInterval(gameInterval);
+ alert('Game over!');
+ return;
+ }
+
+ nextTetromino.value = Object.values(Tetrominoes)[Math.floor(Math.random() * 7)];
+ }
+
+ updateRender();
+
+ if (tetrominoCollidesWithBoard(gameBoard, { ...currentTetromino, row: currentTetromino.row + 1})) {
+ gameBoard = mergeTetrominoWithBoard(gameBoard, currentTetromino);
+ currentTetromino = null;
+ return;
+ }
+
+ ++currentTetromino.row;
+};
+
+function startGame() {
+ gameInterval = setInterval(tick, 250);
+ nextTetromino.value = Object.values(Tetrominoes)[Math.floor(Math.random() * 7)];
+ gameInProgress = true;
+}
+
+document.addEventListener('keydown', event => {
+ event.preventDefault();
+ if (currentTetromino && event.key === 'r') {
+ if (!tetrominoCollidesWithBoard(gameBoard, { ...currentTetromino, rotation: (currentTetromino.rotation + 1) % 4})) {
+ currentTetromino!.rotation = (currentTetromino!.rotation + 1) % 4;
+ }
+
+ updateRender();
+ }
+ if (currentTetromino && ['ArrowLeft', 'h'].includes(event.key)) {
+ if (!tetrominoCollidesWithBoard(gameBoard, { ...currentTetromino, col: currentTetromino.col - 1})) {
+ --currentTetromino.col;
+ }
+
+ updateRender();
+ }
+ if (currentTetromino && ['ArrowRight', 'l'].includes(event.key)) {
+ if (!tetrominoCollidesWithBoard(gameBoard, { ...currentTetromino, col: currentTetromino.col + 1})) {
+ ++currentTetromino.col;
+ }
+
+ updateRender();
+ }
+ if (currentTetromino && ['ArrowDown', 'j'].includes(event.key)) {
+ if (!tetrominoCollidesWithBoard(gameBoard, { ...currentTetromino, row: currentTetromino.row + 1})) {
+ ++currentTetromino.row;
+ }
+
+ updateRender();
+ }
+});
+</script>
+
+<template>
+ <main>
+ <h1>Tetris</h1>
+ <button v-if="!gameInProgress" @click="startGame">Start game</button>
+ <div class="cols">
+ <div class="tetris-board">
+ <div v-for="(row, rowIndex) of renderBoard" class="tetris-row">
+ <div v-for="(col, colIndex) of row" class="tetris-cell"
+ :style="{ 'background-color': col ? `rgb(${Tetrominoes[col].color})` : 'white' }">
+
+ </div>
+ </div>
+ </div>
+ <div>
+ <p>Next up</p>
+ <div class="next-tetromino">
+ <div v-for="(row, rowIndex) of nextTetromino?.shapes[0]" class="tetris-row">
+ <div v-for="(col, colIndex) of row" class="tetris-cell"
+ :style="{ 'background-color': col ? `rgb(${nextTetromino?.color})` : 'white' }">
+
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ </main>
+</template>
+
+<style scoped>
+.cols {
+ display: flex;
+ flex-direction: row;
+ gap: 20px;
+}
+
+.tetris-board {
+ width: calc(32px * 10);
+ height: calc(32px * 20);
+ border: 1px solid #dfdfdf;
+}
+
+.next-tetromino {
+ width: calc(32px * 4);
+ height: calc(32px * 4);
+}
+
+.tetris-row {
+ display: grid;
+ grid-template-columns: repeat(10, 1fr);
+}
+
+.tetris-cell {
+ width: 30px;
+ height: 30px;
+ border: 1px solid #dfdfdf;
+}
+</style> \ No newline at end of file
diff --git a/frontend/src/main.ts b/frontend/src/main.ts
new file mode 100644
index 0000000..934b583
--- /dev/null
+++ b/frontend/src/main.ts
@@ -0,0 +1,11 @@
+import { createApp } from 'vue'
+import App from './App.vue'
+import router from './router'
+import TetrisBoard from './components/Board/TetrisBoard.vue'
+
+const app = createApp(App)
+
+app.use(router)
+app.component('TetrisBoard', TetrisBoard)
+
+app.mount('#app')
diff --git a/frontend/src/model/board.ts b/frontend/src/model/board.ts
new file mode 100644
index 0000000..c69befe
--- /dev/null
+++ b/frontend/src/model/board.ts
@@ -0,0 +1 @@
+export type Board = Array<Array<string | number>>; \ No newline at end of file
diff --git a/frontend/src/model/tetrominoes.ts b/frontend/src/model/tetrominoes.ts
new file mode 100644
index 0000000..1d91c10
--- /dev/null
+++ b/frontend/src/model/tetrominoes.ts
@@ -0,0 +1,230 @@
+export type Tetromino = {
+ id: string;
+ shapes: Array<Array<Array<string | number>>>;
+ color: string;
+ rotation: number;
+ col: number;
+ row: number;
+ width: number;
+ height: number;
+};
+
+export const Tetrominoes: { [key: string]: Tetromino } = {
+ I: {
+ id: "I",
+ shapes: [
+ [
+ [0, "I", 0, 0],
+ [0, "I", 0, 0],
+ [0, "I", 0, 0],
+ [0, "I", 0, 0],
+ ],
+ [
+ [0, 0, 0, 0],
+ ["I", "I", "I", "I"],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ ],
+ [
+ [0, "I", 0, 0],
+ [0, "I", 0, 0],
+ [0, "I", 0, 0],
+ [0, "I", 0, 0],
+ ],
+ [
+ [0, 0, 0, 0],
+ ["I", "I", "I", "I"],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ ],
+ ],
+ color: "80, 227, 230",
+ rotation: 0,
+ col: 0,
+ row: 0,
+ width: 4,
+ height: 4,
+ },
+ J: {
+ id: "J",
+ shapes: [
+ [
+ [0, "J", 0],
+ [0, "J", 0],
+ ["J", "J", 0],
+ ],
+ [
+ ["J", 0, 0],
+ ["J", "J", "J"],
+ [0, 0, 0],
+ ],
+ [
+ [0, "J", "J"],
+ [0, "J", 0],
+ [0, "J", 0],
+ ],
+ [
+ [0, 0, 0],
+ ["J", "J", "J"],
+ [0, 0, "J"],
+ ],
+ ],
+ color: "36, 95, 223",
+ rotation: 0,
+ col: 0,
+ row: 0,
+ width: 3,
+ height: 3,
+ },
+ L: {
+ id: "L",
+ shapes: [
+ [
+ [0, "L", 0],
+ [0, "L", 0],
+ [0, "L", "L"],
+ ],
+ [
+ [0, 0, 0],
+ ["L", "L", "L"],
+ ["L", 0, 0],
+ ],
+ [
+ ["L", "L", 0],
+ [0, "L", 0],
+ [0, "L", 0],
+ ],
+ [
+ [0, 0, "L"],
+ ["L", "L", "L"],
+ [0, 0, 0],
+ ],
+ ],
+ color: "223, 173, 36",
+ rotation: 0,
+ col: 0,
+ row: 0,
+ width: 3,
+ height: 3,
+ },
+ O: {
+ id: "O",
+ shapes: [
+ [
+ ["O", "O"],
+ ["O", "O"],
+ ],
+ [
+ ["O", "O"],
+ ["O", "O"],
+ ],
+ [
+ ["O", "O"],
+ ["O", "O"],
+ ],
+ [
+ ["O", "O"],
+ ["O", "O"],
+ ],
+ ],
+ color: "223, 217, 36",
+ rotation: 0,
+ col: 0,
+ row: 0,
+ width: 2,
+ height: 2,
+ },
+ S: {
+ id: "S",
+ shapes: [
+ [
+ [0, "S", "S"],
+ ["S", "S", 0],
+ [0, 0, 0],
+ ],
+ [
+ [0, "S", 0],
+ [0, "S", "S"],
+ [0, 0, "S"],
+ ],
+ [
+ [0, 0, 0],
+ [0, "S", "S"],
+ ["S", "S", 0],
+ ],
+ [
+ ["S", 0, 0],
+ ["S", "S", 0],
+ [0, "S", 0],
+ ],
+ ],
+ color: "48, 211, 56",
+ rotation: 0,
+ col: 0,
+ row: 0,
+ width: 3,
+ height: 3,
+ },
+ T: {
+ id: "T",
+ shapes: [
+ [
+ [0, 0, 0],
+ ["T", "T", "T"],
+ [0, "T", 0],
+ ],
+ [
+ [0, "T", 0],
+ ["T", "T", 0],
+ [0, "T", 0],
+ ],
+ [
+ [0, "T", 0],
+ ["T", "T", "T"],
+ [0, 0, 0],
+ ],
+ [
+ [0, "T", 0],
+ [0, "T", "T"],
+ [0, "T", 0],
+ ],
+ ],
+ color: "132, 61, 198",
+ rotation: 0,
+ col: 0,
+ row: 0,
+ width: 3,
+ height: 3,
+ },
+ Z: {
+ id: "Z",
+ shapes: [
+ [
+ ["Z", "Z", 0],
+ [0, "Z", "Z"],
+ [0, 0, 0],
+ ],
+ [
+ [0, 0, "Z"],
+ [0, "Z", "Z"],
+ [0, "Z", 0],
+ ],
+ [
+ [0, 0, 0],
+ ["Z", "Z", 0],
+ [0, "Z", "Z"],
+ ],
+ [
+ [0, "Z", 0],
+ ["Z", "Z", 0],
+ ["Z", 0, 0],
+ ],
+ ],
+ color: "227, 78, 78",
+ rotation: 0,
+ col: 0,
+ row: 0,
+ width: 3,
+ height: 3,
+ },
+};
diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts
new file mode 100644
index 0000000..e58491f
--- /dev/null
+++ b/frontend/src/router/index.ts
@@ -0,0 +1,15 @@
+import { createRouter, createWebHistory } from 'vue-router'
+import HomeView from '../views/HomeView.vue'
+
+const router = createRouter({
+ history: createWebHistory(import.meta.env.BASE_URL),
+ routes: [
+ {
+ path: '/',
+ name: 'home',
+ component: HomeView
+ }
+ ]
+})
+
+export default router
diff --git a/frontend/src/util/tetris.ts b/frontend/src/util/tetris.ts
new file mode 100644
index 0000000..3547108
--- /dev/null
+++ b/frontend/src/util/tetris.ts
@@ -0,0 +1,40 @@
+import type { Board } from "@/model/board";
+import type { Tetromino } from "@/model/tetrominoes";
+
+export function mergeTetrominoWithBoard(board: Board, tetromino: Tetromino): Board {
+ return board.map((row, rowIndex) => {
+ if (rowIndex >= tetromino.row && rowIndex < tetromino.row + tetromino.shapes[tetromino.rotation].length) {
+ return row.map((col, colIndex) => {
+ if (colIndex >= tetromino.col && colIndex < tetromino.col + tetromino.shapes[tetromino.rotation][0].length) {
+ return tetromino.shapes[tetromino.rotation][rowIndex - tetromino.row][colIndex - tetromino.col] || col;
+ }
+ return col;
+ });
+ }
+ return row;
+ });
+}
+
+export function tetrominoCollidesWithBoard(board: Board, tetromino: Tetromino): boolean {
+ return tetromino.shapes[tetromino.rotation].some((row, rowIndex) => {
+ return row.some((col, colIndex) => {
+ if (col) {
+ const boardRow = board[rowIndex + tetromino.row];
+ if (!boardRow) {
+ return true;
+ }
+ const boardCol = boardRow[colIndex + tetromino.col];
+ if (boardCol) {
+ return true;
+ }
+ if (colIndex + tetromino.col < 0 || colIndex + tetromino.col >= boardRow.length) {
+ return true;
+ }
+ if (rowIndex + tetromino.row < 0 || rowIndex + tetromino.row >= board.length) {
+ return true;
+ }
+ }
+ return false;
+ });
+ });
+}
diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue
new file mode 100644
index 0000000..09d319e
--- /dev/null
+++ b/frontend/src/views/HomeView.vue
@@ -0,0 +1,10 @@
+<script setup lang="ts">
+import type TetrisBoard from '../components/Board/TetrisBoard.vue';
+
+</script>
+
+<template>
+ <main>
+ <TetrisBoard></TetrisBoard>
+ </main>
+</template>