aboutsummaryrefslogtreecommitdiffstats
path: root/frontend
diff options
context:
space:
mode:
Diffstat (limited to 'frontend')
-rw-r--r--frontend/.gitignore2
-rw-r--r--frontend/Dockerfile20
-rw-r--r--frontend/package-lock.json11
-rw-r--r--frontend/package.json3
-rw-r--r--frontend/src/components/Home.vue15
-rw-r--r--frontend/src/components/TetrisBoard.vue (renamed from frontend/src/components/Board/TetrisBoard.vue)174
-rw-r--r--frontend/src/main.ts6
-rw-r--r--frontend/src/router/index.ts15
-rw-r--r--frontend/src/views/HomeView.vue5
-rw-r--r--frontend/src/views/HostView.vue75
-rw-r--r--frontend/src/views/JoinView.vue60
-rw-r--r--frontend/src/views/PlayView.vue10
12 files changed, 338 insertions, 58 deletions
diff --git a/frontend/.gitignore b/frontend/.gitignore
index 38adffa..cd1e201 100644
--- a/frontend/.gitignore
+++ b/frontend/.gitignore
@@ -26,3 +26,5 @@ coverage
*.njsproj
*.sln
*.sw?
+
+.env \ No newline at end of file
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
new file mode 100644
index 0000000..723e97e
--- /dev/null
+++ b/frontend/Dockerfile
@@ -0,0 +1,20 @@
+FROM node:18 as build
+
+WORKDIR /app
+
+COPY package.json ./
+
+RUN npm install
+
+COPY . .
+
+RUN npm run build
+
+
+FROM nginx:1.21.1-alpine
+
+COPY --from=build /app/dist /usr/share/nginx/html
+
+EXPOSE 80
+
+CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 8c8ea96..99dddae 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -8,12 +8,13 @@
"name": "frontend",
"version": "0.0.0",
"dependencies": {
+ "qrcode.vue": "^3.4.1",
"vue": "^3.3.4",
"vue-router": "^4.2.5"
},
"devDependencies": {
"@tsconfig/node18": "^18.2.2",
- "@types/node": "^18.18.5",
+ "@types/node": "^18.18.8",
"@vitejs/plugin-vue": "^4.4.0",
"@vue/tsconfig": "^0.4.0",
"npm-run-all2": "^6.1.1",
@@ -1094,6 +1095,14 @@
"node": "^10 || ^12 || >=14"
}
},
+ "node_modules/qrcode.vue": {
+ "version": "3.4.1",
+ "resolved": "https://registry.npmjs.org/qrcode.vue/-/qrcode.vue-3.4.1.tgz",
+ "integrity": "sha512-wq/zHsifH4FJ1GXQi8/wNxD1KfQkckIpjK1KPTc/qwYU5/Bkd4me0w4xZSg6EXk6xLBkVDE0zxVagewv5EMAVA==",
+ "peerDependencies": {
+ "vue": "^3.0.0"
+ }
+ },
"node_modules/read-pkg": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-8.1.0.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 05e0af5..97c0345 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -10,12 +10,13 @@
"type-check": "vue-tsc --noEmit -p tsconfig.app.json --composite false"
},
"dependencies": {
+ "qrcode.vue": "^3.4.1",
"vue": "^3.3.4",
"vue-router": "^4.2.5"
},
"devDependencies": {
"@tsconfig/node18": "^18.2.2",
- "@types/node": "^18.18.5",
+ "@types/node": "^18.18.8",
"@vitejs/plugin-vue": "^4.4.0",
"@vue/tsconfig": "^0.4.0",
"npm-run-all2": "^6.1.1",
diff --git a/frontend/src/components/Home.vue b/frontend/src/components/Home.vue
new file mode 100644
index 0000000..3dc0341
--- /dev/null
+++ b/frontend/src/components/Home.vue
@@ -0,0 +1,15 @@
+<template>
+ <h1>Tetris</h1>
+ <p>Pick an option</p>
+ <ul>
+ <li>
+ <RouterLink to="/play">Play solo game</RouterLink>
+ </li>
+ <li>
+ <RouterLink to="/host">Host networked game</RouterLink>
+ </li>
+ <li>
+ <RouterLink to="/join">Join networked game</RouterLink>
+ </li>
+ </ul>
+</template> \ No newline at end of file
diff --git a/frontend/src/components/Board/TetrisBoard.vue b/frontend/src/components/TetrisBoard.vue
index 7528ad7..4e5911b 100644
--- a/frontend/src/components/Board/TetrisBoard.vue
+++ b/frontend/src/components/TetrisBoard.vue
@@ -1,9 +1,14 @@
<script setup lang="ts">
-import { ref, type Ref } from 'vue';
+import { onMounted, ref, type Ref } from 'vue';
import { mergeTetrominoWithBoard, randomiseNextTetrominoes, tetrominoCollidesWithBoard } from '@/util/tetris';
import { type Tetromino, allTetrominoes } from '@/model/tetrominoes';
import { type Board } from '@/model/board';
+const props = defineProps({
+ networked: Boolean,
+ inputQueue: Array<string>
+});
+
const renderBoard: Ref<Board> = ref([[]]);
let gameBoard: Board = [
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
@@ -33,6 +38,7 @@ let gameInterval: ReturnType<typeof setInterval>;
let gameSpeed = 1000;
let elapsedBlocks = 0;
let gameInProgress = false;
+let freezeTick = false;
let points = ref(0);
let notificationMessage = ref('Press start to begin!');
let notificationBg = ref('\#dfdfdf');
@@ -74,6 +80,11 @@ const tick = () => {
updateRender();
if (tetrominoCollidesWithBoard(gameBoard, { ...currentTetromino, row: currentTetromino.row + 1 })) {
+ if (!freezeTick) {
+ freezeTick = true;
+ return;
+ }
+
gameBoard = mergeTetrominoWithBoard(gameBoard, currentTetromino);
currentTetromino = null;
++elapsedBlocks;
@@ -86,7 +97,7 @@ const tick = () => {
gameBoard.unshift([0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
}
}
-
+
switch (clears) {
case 1:
points.value += 40;
@@ -106,11 +117,14 @@ const tick = () => {
gameSpeed = Math.max(150, gameSpeed - (gameSpeed > 300 ? 75 : 10));
rescheduleTickTimer();
}
+ freezeTick = false;
return;
+ } else {
+ freezeTick = false;
}
++currentTetromino.row;
-
+
};
function startGame() {
@@ -119,55 +133,108 @@ function startGame() {
nextTetrominoes.value = randomiseNextTetrominoes();
gameInProgress = true;
notificationMessage.value = '';
-
+
updateRender();
rescheduleTickTimer();
}
-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;
- }
+function rotateTetronimo() {
+ if (currentTetromino && !tetrominoCollidesWithBoard(gameBoard, { ...currentTetromino, rotation: (currentTetromino.rotation + 1) % 4 })) {
+ currentTetromino!.rotation = (currentTetromino!.rotation + 1) % 4;
+ }
+
+ updateRender();
+}
- updateRender();
+function moveTetrominoLeft() {
+ if (currentTetromino && !tetrominoCollidesWithBoard(gameBoard, { ...currentTetromino, col: currentTetromino.col - 1 })) {
+ --currentTetromino.col;
}
- if (currentTetromino && ['ArrowLeft', 'h'].includes(event.key)) {
- if (!tetrominoCollidesWithBoard(gameBoard, { ...currentTetromino, col: currentTetromino.col - 1 })) {
- --currentTetromino.col;
- }
- updateRender();
+ updateRender();
+}
+
+function moveTetrominoRight() {
+ if (currentTetromino && !tetrominoCollidesWithBoard(gameBoard, { ...currentTetromino, col: currentTetromino.col + 1 })) {
+ ++currentTetromino.col;
}
- if (currentTetromino && ['ArrowRight', 'l'].includes(event.key)) {
- if (!tetrominoCollidesWithBoard(gameBoard, { ...currentTetromino, col: currentTetromino.col + 1 })) {
- ++currentTetromino.col;
- }
- updateRender();
+ updateRender();
+}
+
+function dropTetromino() {
+ let rowsDropped = 0;
+ while (currentTetromino && !tetrominoCollidesWithBoard(gameBoard, { ...currentTetromino, row: currentTetromino.row + 1 })) {
+ ++currentTetromino.row;
+ ++rowsDropped;
}
- if (currentTetromino && ['ArrowDown', 'j', 'z'].includes(event.key)) {
- if (!tetrominoCollidesWithBoard(gameBoard, { ...currentTetromino, row: currentTetromino.row + 1 })) {
- ++currentTetromino.row;
- }
- updateRender();
+ if (rowsDropped === 0) {
+ return;
}
- if (currentTetromino && ['ArrowUp', 'k', 'c'].includes(event.key)) {
- let rowsDropped = 0;
- while (!tetrominoCollidesWithBoard(gameBoard, { ...currentTetromino, row: currentTetromino.row + 1 })) {
- ++currentTetromino.row;
- ++rowsDropped;
+
+ points.value += rowsDropped + 1;
+
+ tick();
+}
+
+function softDropTetromino() {
+ if (currentTetromino && !tetrominoCollidesWithBoard(gameBoard, { ...currentTetromino, row: currentTetromino.row + 1 })) {
+ ++currentTetromino.row;
+ }
+
+ updateRender();
+}
+
+function processInputQueue() {
+ if (!props.networked) {
+ return;
+ }
+
+ if (props.inputQueue!.length === 0) {
+ return;
+ }
+
+ let move = props.inputQueue!.shift();
+ if (move === 'left') {
+ moveTetrominoLeft();
+ } else if (move === 'right') {
+ moveTetrominoRight();
+ } else if (move === 'rotate') {
+ rotateTetronimo();
+ }
+}
+
+if (!props.networked) {
+ document.addEventListener('keydown', event => {
+ event.preventDefault();
+ if (currentTetromino && event.key === 'r') {
+ rotateTetronimo();
}
-
- if (rowsDropped === 0) {
- return;
+ if (currentTetromino && ['ArrowLeft', 'h'].includes(event.key)) {
+ moveTetrominoLeft();
+ }
+ if (currentTetromino && ['ArrowRight', 'l'].includes(event.key)) {
+ moveTetrominoRight();
+ }
+ if (currentTetromino && ['ArrowDown', 'j', 'z'].includes(event.key)) {
+ softDropTetromino();
}
-
- points.value += rowsDropped + 1;
+ if (currentTetromino && ['ArrowUp', 'k', 'c'].includes(event.key)) {
+ dropTetromino();
+ }
+ });
+}
+
+if (props.networked) {
+ onMounted(() => {
+ setInterval(processInputQueue, 100);
+ });
+}
- tick();
+onMounted(() => {
+ if (!gameInProgress) {
+ startGame();
}
});
</script>
@@ -177,7 +244,8 @@ document.addEventListener('keydown', event => {
<h1>Tetris</h1>
<div class="cols">
<div>
- <div v-if="notificationMessage" class="notification-banner" :style="{ 'background-color': notificationBg, 'color': notificationFg}">
+ <div v-if="notificationMessage" class="notification-banner"
+ :style="{ 'background-color': notificationBg, 'color': notificationFg }">
{{ notificationMessage }}
</div>
<div class="tetris-board">
@@ -200,28 +268,30 @@ document.addEventListener('keydown', event => {
</div>
</div>
</div>
-
+
<h2>Score</h2>
<h3>
{{ points }}
</h3>
- <h2>Controls</h2>
- <p>
- To move left and right, use the arrow keys or <kbd>H</kbd> and <kbd>L</kbd>.
- </p>
+ <div v-if="!networked">
+ <h2>Controls</h2>
+ <p>
+ To move left and right, use the arrow keys or <kbd>H</kbd> and <kbd>L</kbd>.
+ </p>
- <p>
- To rotate, use <kbd>R</kbd>.
- </p>
+ <p>
+ To rotate, use <kbd>R</kbd>.
+ </p>
- <p>
- To do a soft drop, use the arrow down key or <kbd>J</kbd>.
- </p>
+ <p>
+ To do a soft drop, use the arrow down key or <kbd>J</kbd>.
+ </p>
- <p>
- To do a hard drop, use the arrow up key or <kbd>K</kbd>.
- </p>
+ <p>
+ To do a hard drop, use the arrow up key or <kbd>K</kbd>.
+ </p>
+ </div>
</div>
</div>
diff --git a/frontend/src/main.ts b/frontend/src/main.ts
index a4db798..7f87494 100644
--- a/frontend/src/main.ts
+++ b/frontend/src/main.ts
@@ -3,11 +3,15 @@ import './assets/main.css'
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
-import TetrisBoard from './components/Board/TetrisBoard.vue'
+import TetrisBoard from './components/TetrisBoard.vue'
+import Home from './components/Home.vue'
+import QrcodeVue from 'qrcode.vue'
const app = createApp(App)
app.use(router)
app.component('TetrisBoard', TetrisBoard)
+app.component('Home', Home)
+app.component('QRCode', QrcodeVue)
app.mount('#app')
diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts
index e58491f..fe575db 100644
--- a/frontend/src/router/index.ts
+++ b/frontend/src/router/index.ts
@@ -8,6 +8,21 @@ const router = createRouter({
path: '/',
name: 'home',
component: HomeView
+ },
+ {
+ path: '/host',
+ name: 'host',
+ component: () => import('../views/HostView.vue')
+ },
+ {
+ path: '/play',
+ name: 'play',
+ component: () => import('../views/PlayView.vue')
+ },
+ {
+ path: '/join/:id',
+ name: 'join',
+ component: () => import('../views/JoinView.vue')
}
]
})
diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue
index 09d319e..0b9fe32 100644
--- a/frontend/src/views/HomeView.vue
+++ b/frontend/src/views/HomeView.vue
@@ -1,10 +1,9 @@
<script setup lang="ts">
-import type TetrisBoard from '../components/Board/TetrisBoard.vue';
-
+import type Home from '../components/Home.vue';
</script>
<template>
<main>
- <TetrisBoard></TetrisBoard>
+ <Home></Home>
</main>
</template>
diff --git a/frontend/src/views/HostView.vue b/frontend/src/views/HostView.vue
new file mode 100644
index 0000000..5b29289
--- /dev/null
+++ b/frontend/src/views/HostView.vue
@@ -0,0 +1,75 @@
+<script setup lang="ts">
+import { ref, type Ref } from 'vue';
+import type TetrisBoard from '../components/TetrisBoard.vue';
+
+let started = ref(false)
+let waitingForCode = ref(true)
+let code = ref('')
+let url = ref('');
+let numClients = ref(0);
+let inputQueue: Ref<Array<string>> = ref([]);
+
+let socket: WebSocket;
+
+function startGame() {
+ if (!socket) {
+ return;
+ }
+ socket.send(JSON.stringify({
+ action: 'start',
+ sessionId: code.value,
+ }));
+}
+
+fetch(`${import.meta.env.VITE_BACKEND_BASE_URL}/session`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ }
+).then(async (response) => {
+ const data: any = await response.json();
+ code.value = data.id;
+ url.value = `${import.meta.env.VITE_FRONTEND_BASE_URL}/join/${data.id}`;
+ waitingForCode.value = false;
+ openSocket(data.id);
+});
+
+function openSocket(id: string) {
+ socket = new WebSocket(`${import.meta.env.VITE_BACKEND_WS_URL}`);
+
+ socket.onopen = () => {
+ socket.send(JSON.stringify({
+ action: 'host',
+ sessionId: id,
+ }));
+ };
+ socket.onmessage = (event) => {
+ const data = JSON.parse(event.data);
+ if (data.action === 'join') {
+ numClients.value = data.clients;
+ } else if (data.action === 'state' && data.state === 'playing') {
+ started.value = true;
+ } else if (data.action === 'move') {
+ inputQueue.value.push(data.move);
+ }
+ };
+}
+</script>
+
+<template>
+ <main>
+ <template v-if="waitingForCode">
+ <h1>Requesting session...</h1>
+ </template>
+ <template v-if="!started && !waitingForCode">
+ <h1>Join this game</h1>
+ <QRCode size=500 level="M" :value="url"></QRCode>
+ <h2><a :href="url">{{ url }}</a></h2>
+ <p>Connected clients: {{ numClients }}</p>
+ <button @click="startGame">Start game</button>
+ </template>
+ <TetrisBoard v-if="started" :networked="true" :input-queue="inputQueue"></TetrisBoard>
+ </main>
+</template>
diff --git a/frontend/src/views/JoinView.vue b/frontend/src/views/JoinView.vue
new file mode 100644
index 0000000..abfbd1a
--- /dev/null
+++ b/frontend/src/views/JoinView.vue
@@ -0,0 +1,60 @@
+<script setup lang="ts">
+import { ref } from 'vue';
+import { useRoute } from 'vue-router';
+
+const route = useRoute();
+const sessionId = route.params.id;
+
+const joiningGame = ref(true);
+const connected = ref(false);
+const gameState = ref('');
+
+const socket = new WebSocket(`${import.meta.env.VITE_BACKEND_WS_URL}`);
+socket.onopen = () => {
+ socket.send(JSON.stringify({
+ action: 'join',
+ sessionId: sessionId,
+ }));
+};
+socket.onmessage = (event) => {
+ const data = JSON.parse(event.data);
+ if (data.action === 'state') {
+ gameState.value = data.state;
+ joiningGame.value = false;
+ connected.value = true;
+ }
+};
+
+function sendMove(move: string) {
+ socket.send(JSON.stringify({
+ action: 'move',
+ sessionId: sessionId,
+ move: move,
+ }));
+}
+
+function sendLeft() {
+ sendMove('left');
+}
+
+function sendRotate() {
+ sendMove('rotate');
+}
+
+function sendRight() {
+ sendMove('right');
+}
+</script>
+
+<template>
+ <main>
+ <h1 v-if="joiningGame">Joining game "{{ sessionId }}"...</h1>
+ <h1 v-if="connected">Connected to "{{ sessionId }}"</h1>
+ <h1 v-if="gameState === 'waiting'">Waiting for host to start the game...</h1>
+ <div v-if="gameState === 'playing'">
+ <button @click="sendLeft">Left</button>
+ <button @click="sendRotate">Rotate</button>
+ <button @click="sendRight">Right</button>
+ </div>
+ </main>
+</template>
diff --git a/frontend/src/views/PlayView.vue b/frontend/src/views/PlayView.vue
new file mode 100644
index 0000000..8c00589
--- /dev/null
+++ b/frontend/src/views/PlayView.vue
@@ -0,0 +1,10 @@
+<script setup lang="ts">
+import type TetrisBoard from '../components/TetrisBoard.vue';
+
+</script>
+
+<template>
+ <main>
+ <TetrisBoard :networked="false"></TetrisBoard>
+ </main>
+</template>