diff options
Diffstat (limited to 'frontend/src')
| -rw-r--r-- | frontend/src/App.vue | 10 | ||||
| -rw-r--r-- | frontend/src/assets/logo.svg | 1 | ||||
| -rw-r--r-- | frontend/src/components/Board/TetrisBoard.vue | 162 | ||||
| -rw-r--r-- | frontend/src/main.ts | 11 | ||||
| -rw-r--r-- | frontend/src/model/board.ts | 1 | ||||
| -rw-r--r-- | frontend/src/model/tetrominoes.ts | 230 | ||||
| -rw-r--r-- | frontend/src/router/index.ts | 15 | ||||
| -rw-r--r-- | frontend/src/util/tetris.ts | 40 | ||||
| -rw-r--r-- | frontend/src/views/HomeView.vue | 10 |
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> |
