13 Commits

Author SHA1 Message Date
91c0ab6e31 Dockerize 2024-02-03 21:33:12 +03:00
0b30c26d2b Fixed font sizes and added hurry message 2023-06-23 16:42:37 +03:00
e88bdf2b92 Style fixes 2023-06-19 00:41:57 +03:00
9adcd2ab62 Fix sorting 2023-06-18 20:52:59 +03:00
e364b7b82d End event 2023-06-18 20:49:46 +03:00
581b1b41fb Fix userlist 2023-06-18 20:41:27 +03:00
d79a82022b Add extension 2023-06-18 20:32:31 +03:00
210fd3f90e Fix answer counting 2023-06-18 20:29:31 +03:00
d6f1e9ef71 Мобильная верстка 2023-06-18 17:39:35 +03:00
08a77c48bf Added scores 2023-06-18 16:57:33 +03:00
fc6ae4e245 Redesign and fix dupe events 2023-06-18 01:49:43 +03:00
28ab992aa1 Game works 2023-06-17 23:46:44 +03:00
3cdd827708 Added sse and a new screen 2023-06-17 18:17:36 +03:00
51 changed files with 12612 additions and 8816 deletions

2
.gitignore vendored
View File

@@ -1,4 +1,4 @@
backend/names.json
backend/names.js
.DS_Store
node_modules

View File

@@ -1,11 +1,11 @@
version: '3'
services:
flexpatrol-bingo:
image: git.radner.ru/anatolykopyl/flexpatrol-bingo:latest
container_name: flexpatrol-bingo
party-flexpatrol-bingo:
image: git.radner.ru/anatolykopyl/party-flexpatrol-bingo:latest
container_name: party-flexpatrol-bingo
working_dir: /usr/node/app
ports:
- "3004:3000"
- "3003:3000"
command: >
sh -c "node index.js"

View File

@@ -1,24 +1,27 @@
const express = require('express');
const session = require('express-session');
import express from 'express';
import session from 'express-session';
import mongodb from 'mongodb';
import MongoStore from 'connect-mongo';
import cors from 'cors';
import { createNanoEvents } from 'nanoevents';
import "dotenv/config";
import names from './names.js'
const app = express();
const { MongoClient, ObjectId } = require('mongodb');
const MongoStore = require('connect-mongo');
const cors = require('cors');
require('dotenv').config();
const emitter = createNanoEvents();
const { MongoClient, ObjectId } = mongodb;
app.use(cors({
origin: [
process.env.FRONTEND,
],
origin: process.env.FRONTEND,
credentials: true,
exposedHeaders: ['set-cookie'],
}));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
const names = require('./names.json');
const client = new MongoClient(process.env.URI, { useUnifiedTopology: true });
(async () => {
@@ -81,49 +84,55 @@ const client = new MongoClient(process.env.URI, { useUnifiedTopology: true });
});
});
async function drawCard() {
let card;
// Тянем карты и отбрасываем их в соответствии с их вероятностью отбрасывания
do {
/* eslint-disable no-await-in-loop */
card = await cardsCollection.aggregate([
{
$sample: { size: 1 },
}, {
$unset: ['link', 'date'],
},
]).toArray();
/* eslint-enable no-await-in-loop */
[card] = card;
} while (Math.random() < dropProb[card.name]);
return card;
}
let players = {}
let score = {}
let card = await drawCard()
let oldCard = null;
let answers = 0
app.post('/api/auth', async (req, res) => {
if (req.session.loggedIn) {
res.status(200).send('Logged in');
const { pass, username } = req.body;
if (username && !Object.keys(players).includes(username)) {
players[username] = null;
emitter.emit('connection', Object.keys(players))
}
if (pass && pass.toLowerCase().trim() === process.env.PASSWORD) {
req.session.loggedIn = true;
return res.status(200).send('Logged in');
} else {
try {
const { pass } = req.body;
if (pass && pass.toLowerCase() === process.env.PASSWORD) {
req.session.loggedIn = true;
res.status(200).send('Logged in');
} else {
res.status(401).send('Wrong password');
}
} catch (e) {
console.log(`Error: ${e}`);
res.status(500).send();
}
return res.status(401).send('Wrong password');
}
});
app.get('/api/card', async (req, res) => {
async function drawCard() {
let card;
// Тянем карты и отбрасываем их в соответствии с их вероятностью отбрасывания
do {
/* eslint-disable no-await-in-loop */
card = await cardsCollection.aggregate([
{
$sample: { size: 1 },
}, {
$unset: ['name', 'link', 'date'],
},
]).toArray();
/* eslint-enable no-await-in-loop */
[card] = card;
} while (Math.random() < dropProb[card.name]);
return card;
if (!req.session.loggedIn) {
return res.status(403).send();
}
if (req.session.loggedIn) {
res.status(200).send(await drawCard());
} else {
res.status(403).send();
}
return res.status(200).send({
...card,
name: undefined
});
});
app.get('/api/meme', async (req, res) => {
@@ -140,33 +149,42 @@ const client = new MongoClient(process.env.URI, { useUnifiedTopology: true });
});
app.post('/api/answer', async (req, res) => {
if (req.session.loggedIn) {
if (req.body.data.id && req.body.data.name) {
const card = await cardsCollection.findOne({ _id: ObjectId(req.body.data.id) });
if (card) {
const correct = card.name === req.body.data.name;
if (correct) {
req.session.right += 1;
} else {
req.session.wrong += 1;
}
answersCollection.insertOne({
correct,
selected: req.body.data.name,
});
res.status(200).send({
correct,
card,
});
} else {
res.status(500).send();
}
} else {
res.status(400).send();
}
} else {
res.status(403).send();
if (!req.session.loggedIn) {
return res.status(403).send();
}
if (!req.body.data.id || !req.body.data.name || !req.body.data.username) {
return res.status(400).send();
}
const card = await cardsCollection.findOne({ _id: ObjectId(req.body.data.id) });
if (!card) {
return res.status(500).send();
}
const correct = card.name === req.body.data.name;
if (correct) {
req.session.right += 1;
} else {
req.session.wrong += 1;
}
answersCollection.insertOne({
correct,
selected: req.body.data.name,
});
players[req.body.data.username] = req.body.data.name;
answers += 1
emitter.emit('answer', {
username: req.body.data.username,
selected: req.body.data.name
});
// return res.status(200).send({
// correct,
// card,
// });
return res.status(200).send()
});
app.get('/api/score', (req, res) => {
@@ -184,31 +202,33 @@ const client = new MongoClient(process.env.URI, { useUnifiedTopology: true });
});
app.get('/api/stats', async (req, res) => {
if (req.session.loggedIn) {
answersCollection.aggregate([
{
$group: {
_id: '$selected',
correct: {
$sum: {
$cond: [
'$correct', 1, 0,
],
},
if (!req.session.loggedIn) {
return res.status(403).send();
}
const stats = await answersCollection.aggregate([
{
$group: {
_id: '$selected',
correct: {
$sum: {
$cond: [
'$correct', 1, 0,
],
},
wrong: {
$sum: {
$cond: [
'$correct', 0, 1,
],
},
},
wrong: {
$sum: {
$cond: [
'$correct', 0, 1,
],
},
},
},
]).toArray().then((stats) => res.status(200).send(stats));
} else {
res.status(403).send();
}
},
]).toArray();
return res.status(200).send(stats);
});
app.get('/api/options', async (req, res) => {
@@ -219,5 +239,88 @@ const client = new MongoClient(process.env.URI, { useUnifiedTopology: true });
}
});
app.get('/api/players', async (req, res) => {
if (!req.session.loggedIn) {
return res.status(403).send();
}
return res.status(200).send(Object.keys(players));
})
app.post('/api/end', async (req, res) => {
if (!req.session.loggedIn) {
return res.status(403).send();
}
players = {}
answers = 0
score = {}
emitter.emit("end")
return res.status(200).send();
});
app.get('/api/stream', async (req, res) => {
res.set({
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'Content-Type': 'text/event-stream',
});
res.flushHeaders();
emitter.on('connection', (players) => {
const data = players
res.write(`data: ${JSON.stringify(data)}\nevent: userlist\n\n`);
})
emitter.on('answer', async ({
username, selected
}) => {
const data = {
username,
players,
selected
}
res.write(`data: ${JSON.stringify(data)}\nevent: answer\n\n`);
if (answers === Object.keys(players).length) {
Object.keys(players).forEach((key) => {
if (card.name === players[key]) {
score[key] = (score[key] ?? 0) + 1
}
})
Object.keys(players).forEach((key) => {
players[key] = null
})
answers = 0
oldCard = { ...card }
card = await drawCard()
emitter.emit("allDone");
}
});
emitter.on("allDone", () => {
const data = {
correctAnswer: oldCard.name,
score
}
res.write(`data: ${JSON.stringify(data)}\nevent: reveal\n\n`);
});
emitter.on("end", () => {
res.write(`data: {}\nevent: end\n\n`);
res.end();
})
res.on('close', () => {
res.end();
});
});
app.listen(process.env.PORT, () => console.log(`Server started on ${process.env.PORT}`));
})();

View File

@@ -1,4 +1,4 @@
[
export default [
"Участник Беседы 1",
"Участник Беседы 2",
"Участник Беседы 3"

View File

@@ -3,6 +3,7 @@
"version": "1.0.0",
"description": "",
"main": "index.js",
"type": "module",
"scripts": {
"dev": "nodemon index.js"
},
@@ -19,7 +20,8 @@
"dotenv": "^8.2.0",
"express": "^4.17.1",
"express-session": "^1.17.1",
"mongodb": "^3.6.5"
"mongodb": "^3.6.5",
"nanoevents": "^7.0.1"
},
"eslintConfig": {
"extends": "airbnb-base",

View File

@@ -1193,6 +1193,11 @@ ms@2.1.3, ms@^2.1.1:
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
nanoevents@^7.0.1:
version "7.0.1"
resolved "https://registry.yarnpkg.com/nanoevents/-/nanoevents-7.0.1.tgz#181580b47787688d8cac775b977b1cf24e26e570"
integrity sha512-o6lpKiCxLeijK4hgsqfR6CNToPyRU3keKyyI6uwuHRvpRTbZ0wXw51WRgyldVugZqoJfkGFrjrIenYH3bfEO3Q==
natural-compare@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"

View File

@@ -1,5 +0,0 @@
module.exports = {
presets: [
'@vue/cli-plugin-babel/preset'
]
}

View File

@@ -7,13 +7,13 @@
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<link rel="manifest" href="<%= BASE_URL %>manifest.json">
<title><%= htmlWebpackPlugin.options.title %></title>
<link rel="icon" href="favicon.png">
<title>Флекспатруль мультиплеер</title>
<script type="module" src="/src/main.js"></script>
</head>
<body>
<noscript>
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
<strong>We're sorry but Флекспатруль мультиплеер doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->

View File

@@ -4,39 +4,46 @@
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint"
"dev": "vite dev",
"build": "vite build"
},
"dependencies": {
"axios": "^0.21.1",
"core-js": "^3.6.5",
"vue": "^3.0.0",
"pinia": "^2.1.4",
"pinia-plugin-persistedstate": "^3.1.0",
"vue": "^3.3.4",
"vue-peel": "^0.1.1",
"vue-router": "4",
"vue-spinner": "^1.0.4"
},
"devDependencies": {
"@vue/cli-plugin-babel": "~4.5.0",
"@vue/cli-plugin-eslint": "~4.5.0",
"@vue/cli-service": "~4.5.0",
"@vue/compiler-sfc": "^3.0.0",
"babel-eslint": "^10.1.0",
"@vitejs/plugin-vue": "^4.2.3",
"dotenv-webpack": "^7.0.2",
"eslint": "^6.7.2",
"eslint-plugin-vue": "^7.0.0"
"eslint": "8",
"eslint-plugin-vue": "8",
"sass": "^1.63.4",
"vite": "^4.3.9"
},
"eslintConfig": {
"root": true,
"env": {
"node": true
"node": true,
"es2022": true
},
"extends": [
"plugin:vue/vue3-essential",
"plugin:vue/vue3-recommended",
"eslint:recommended"
],
"parserOptions": {
"parser": "babel-eslint"
"rules": {
"vue/multi-word-component-names": 0,
"no-unused-vars": 0
},
"rules": {}
"globals": {
"defineProps": "readonly",
"defineEmits": "readonly",
"defineExpose": "readonly",
"withDefaults": "readonly"
}
},
"browserslist": [
"> 1%",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 667 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

BIN
frontend/public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -1,8 +0,0 @@
{
"name": "Vk Bingo",
"short_name": "Bingo",
"lang": "ru-RU",
"start_url": "/",
"scope": "/",
"display": "standalone"
}

View File

@@ -1,88 +1,54 @@
<template>
<h1>🎱 Флекспатрульное Бинго 🎱</h1>
<Login id="login" @loggedIn="login" v-show="!loggedIn" />
<Game id="game" v-if="loggedIn" :score="score" />
<a class="source" href="https://github.com/anatolykopyl/vk-bingo">Исходный код</a>
<router-view />
</template>
<script>
import axios from 'axios'
import Login from './components/Login.vue'
import Game from './components/Game.vue'
<script setup>
export default {
name: 'App',
components: {
Login,
Game
},
data() {
return {
loggedIn: null,
score: {
"right": 0,
"wrong": 0
}
}
},
methods: {
login: function(success) {
this.loggedIn = success
axios
.get(process.env.VUE_APP_BACKEND + '/score')
.then(response => {
if (Object.keys(response.data).length !== 0)
this.score = response.data
})
}
}
}
</script>
<style>
@font-face {
font-family: "Pangram Sans Rounded";
font-weight: bold;
src: local("Pangram Sans Rounded"), url("/fonts/PPPangramSansRounded-Bold.otf") format("opentype");
}
@font-face {
font-family: "Pangram Sans Rounded";
font-weight: 600;
src: local("Pangram Sans Rounded"), url("/fonts/PPPangramSansRounded-Semibold.otf") format("opentype");
}
@font-face {
font-family: "Pangram Sans Rounded";
font-weight: normal;
src: local("Pangram Sans Rounded"), url("/fonts/PPPangramSansRounded-Medium.otf") format("opentype");
}
:root {
--clr-bg: #ffd537;
--clr-bg-secondary: #fbf2cf;
--clr-accent: #37ffac;
--clr-text: #141414;
--clr-text-secondary: #14141480;
}
body {
margin: 0;
background-color: #5a5a5a;
background-color: var(--clr-bg);
}
input, button {
font: inherit;
}
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
font-family: "Pangram Sans Rounded", Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #f3f3f3;
margin-top: 60px;
}
#game {
margin-bottom: 100px;
}
.source {
color: #f3f3f350;
background-color: #12121250;
border-radius: 6px;
padding: 3px 6px;
position: fixed;
width: 20ch;
left: 0;
right: 0;
bottom: 10px;
text-align: center;
margin: 0 auto;
transition: all 0.3s;
cursor: pointer;
z-index: -1;
}
.source:hover {
color: #f3f3f3;
background-color: #121212;
box-shadow: 5px 5px 10px rgba(0, 0, 0, 0.6);
}
@media only screen and (max-width: 520px) {
.source {
display: none;
}
color: var(--clr-text);
min-height: 100vh;
box-sizing: border-box;
}
</style>

View File

@@ -0,0 +1,42 @@
<template>
<div class="bar">
<div
class="fill"
/>
</div>
</template>
<script setup>
</script>
<style scoped lang="scss">
.bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
width: 100%;
height: 16px;
}
.fill {
position: fixed;
left: 0;
right: 0;
width: 100%;
height: 16px;
background: white;
animation: move 5s linear;
}
@keyframes move {
from {
transform: translateX(-100%);
}
to {
transform: translateX(0);
}
}
</style>

View File

@@ -1,154 +0,0 @@
<template>
<div>
<div class="card" v-if="card !== null" v-on:click="nextCard()" v-bind:class="{clickable: showResult}">
<img class="meme" v-bind:src="card.image">
<h2>Кто скинул этот мем?</h2>
<div class="interactive">
<transition name="fade-answers">
<List v-if="selectedAnswer === null"
:options="options" @selectedAnswer="selectAnswer" />
</transition>
<transition name="spin-result">
<Result v-if="showResult"
:name="card.name" :selectedName="selectedAnswer" :date="card.date" :correct="correctAnswer" />
</transition>
</div>
</div>
<Score v-if="card !== null" :score="score" />
<square-loader v-if="card === null" :color="'#f3f3f3'" class="loader" />
<Stats />
</div>
</template>
<script>
import axios from 'axios'
import List from './List.vue'
import Result from './Result.vue'
import Score from './Score.vue'
import Stats from './Stats.vue'
import SquareLoader from 'vue-spinner/src/SquareLoader.vue'
export default {
name: 'Game',
components: {
List,
Result,
Score,
Stats,
SquareLoader
},
props: {
score: Object
},
data() {
return {
options: null,
card: null,
correctAnswer: null, // True or False
selectedAnswer: null, // Чье-то имя
showResult: false
}
},
methods: {
getCard: function() {
this.correctAnswer = null
this.selectedAnswer = null
this.showResult = false
axios
.get(process.env.VUE_APP_BACKEND + '/card')
.then(response => {
this.card = response.data
})
},
nextCard: function() {
if (this.showResult) {
this.card = null
this.getCard()
}
},
selectAnswer: function(selection) {
this.selectedAnswer = selection
let innerThis = this
setTimeout(function() {
innerThis.showResult = true
if (innerThis.correctAnswer) {
innerThis.score.right++
} else {
innerThis.score.wrong++
}
}, 805)
axios
.post(process.env.VUE_APP_BACKEND + '/answer', {
'data': {
'id': this.card._id,
'name': this.selectedAnswer
}
})
.then((response) => {
this.correctAnswer = response.data.correct
this.card = response.data.card
})
}
},
mounted() {
this.getCard()
axios
.get(process.env.VUE_APP_BACKEND + '/options')
.then(response => (this.options = response.data))
}
}
</script>
<style scoped>
.card {
width: 450px;
padding: 18px;
border-radius: 17px;
box-shadow: 5px 5px 10px rgba(0, 0, 0, 0.6);
background-color: #121212;
margin: auto;
}
.meme {
width: 100%;
border-radius: 8px;
}
.clickable {
cursor: pointer;
}
.interactive {
position: relative;
-webkit-perspective: 900000px;
perspective: 900000px;
}
.fade-answers-leave-active {
transition: all 0.8s ease;
}
.fade-answers-leave-to {
opacity: 0;
transform: scale(0.3);
}
.spin-result-enter-active {
transition: all 2s ease;
}
.spin-result-enter-from {
transform: scale(0.2);
transform: rotateY(120deg);
}
.loader {
margin-top: 100px;
}
@media screen and (max-width: 520px) {
.card {
width: 94%;
padding: 3%;
}
}
</style>

View File

@@ -0,0 +1,13 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
class="bi bi-info-circle"
viewBox="0 0 16 16"
>
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z" />
<path d="m8.93 6.588-2.29.287-.082.38.45.083c.294.07.352.176.288.469l-.738 3.468c-.194.897.105 1.319.808 1.319.545 0 1.178-.252 1.465-.598l.088-.416c-.2.176-.492.246-.686.246-.275 0-.375-.193-.304-.533L8.93 6.588zM9 4.5a1 1 0 1 1-2 0 1 1 0 0 1 2 0z" />
</svg>
</template>

View File

@@ -1,41 +0,0 @@
<template>
<div class="answers">
<span class="option" v-for="name in options" :key="name" v-on:click="selectAnswer(name)">
{{ name }}
</span>
</div>
</template>
<script>
export default {
name: 'List',
props: {
options: Array
},
methods: {
selectAnswer: function(selection) {
this.$emit('selectedAnswer', selection)
}
}
}
</script>
<style scoped>
.answers {
display: flex;
flex-wrap: wrap;
justify-content: space-around;
}
.option {
background-color: #5a5a5a;
border-radius: 6px;
margin: 3px;
padding: 5px 9px;
transition: transform 0.2s;
}
.option:hover {
transform: scale(1.06);
cursor: pointer;
}
</style>

View File

@@ -1,80 +0,0 @@
<template>
<div>
<h1>Авторизация:</h1>
<p>{{ question }}</p>
<input v-model="answer"><br>
<button v-on:click="login" v-bind:class="{wrong: loggedIn === false}">Ввод</button>
</div>
</template>
<script>
import axios from 'axios'
axios.defaults.withCredentials = true
export default {
name: 'Login',
data() {
return {
question: null,
answer: null,
loggedIn: null
}
},
methods: {
login: function() {
axios
.post(process.env.VUE_APP_BACKEND + '/auth', {
"pass": this.answer,
})
.then(response => {
this.loggedIn = response.status == "200"
this.$emit('loggedIn', this.loggedIn)
})
}
},
mounted() {
this.question = process.env.VUE_APP_QUESTION
this.login()
}
}
</script>
<style scoped>
div {
background-color: #121212;
width: 400px;
margin: auto;
border-radius: 18px;
padding: 40px 10px;
box-shadow: 5px 5px 10px rgba(0, 0, 0, 0.6);
}
input {
font-size: 1em;
text-align: center;
padding: 5px 8px;
margin-bottom: 1em;
border-radius: 6px;
border: none;
width: 20ch;
}
button {
color: white;
font-size: 1em;
box-sizing: content-box;
background-color: #5a5a5a;
border-radius: 6px;
border: none;
width: 20ch;
padding: 5px 8px;
cursor: pointer;
}
@media only screen and (max-width: 520px) {
div {
width: 100%;
padding: 40px 0;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,37 +0,0 @@
<template>
<div>
<span v-show="!correct" class="wrong">Нет, это был не {{ selectedName }} 😢</span>
<div class="result" v-bind:class="{correct: correct}">
{{ name }} {{ date }}
</div>
</div>
</template>
<script>
export default {
name: "Result",
props: {
name: String,
selectedName: String,
date: String,
correct: Boolean
}
}
</script>
<style scoped>
.result {
padding: 30px 40px;
border-radius: 8px;
background-color: #5a5a5a;
}
.correct {
color: #121212;
background-color: rgb(124, 230, 124);
}
.wrong {
color: rgb(255, 71, 71);
}
</style>

View File

@@ -0,0 +1,39 @@
<template>
<a
class="source"
href="https://github.com/anatolykopyl/vk-bingo"
target="_blank"
>
Исходный код
</a>
</template>
<style scoped>
.source {
color: #f3f3f350;
background-color: #12121250;
border-radius: 6px;
padding: 3px 6px;
position: fixed;
width: 20ch;
left: 0;
right: 0;
bottom: 10px;
text-align: center;
margin: 0 auto;
transition: all 0.3s;
cursor: pointer;
z-index: -1;
}
.source:hover {
color: #f3f3f3;
background-color: #121212;
box-shadow: 5px 5px 10px rgba(0, 0, 0, 0.6);
}
@media only screen and (max-width: 520px) {
.source {
display: none;
}
}
</style>

View File

@@ -0,0 +1,25 @@
// import useStore from '../store'
export default () => {
// const store = useStore()
const evtSource = new EventSource(`${import.meta.env.VITE_APP_BACKEND}/stream`);
function addAnswerListener(handler) {
evtSource.addEventListener('answer', (event) => handler(JSON.parse(event.data)))
}
function addUserlistListener(handler) {
evtSource.addEventListener('userlist', (event) => handler(JSON.parse(event.data)))
}
function addRevealListener(handler) {
evtSource.addEventListener('reveal', (event) => handler(JSON.parse(event.data)))
}
function addEndListener(handler) {
evtSource.addEventListener('end', (event) => handler(JSON.parse(event.data)))
}
return { addAnswerListener, addUserlistListener, addRevealListener, addEndListener }
}

View File

@@ -1,4 +1,13 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from "pinia-plugin-persistedstate";
import App from './App.vue'
import router from './router'
createApp(App).mount('#app')
const app = createApp(App)
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate);
app.use(router)
app.use(pinia)
app.mount('#app')

15
frontend/src/router.js Normal file
View File

@@ -0,0 +1,15 @@
import { createRouter, createWebHistory } from 'vue-router'
import Game from './views/Game/Index.vue'
import Login from './views/Login/Index.vue'
import Screen from './views/Screen/Index.vue'
const routes = [
{ path: '/game', component: Game },
{ path: '/', component: Login },
{ path: '/screen', component: Screen }
]
export default createRouter({
history: createWebHistory(),
routes,
})

10
frontend/src/store.js Normal file
View File

@@ -0,0 +1,10 @@
import { defineStore } from 'pinia'
export default defineStore({
id: 'store',
persist: true,
state: () => ({
username: null
}),
})

View File

@@ -0,0 +1,9 @@
@mixin filled-shadow($distance) {
$resulting-shadow: null;
@for $i from 1 through $distance {
$resulting-shadow: $resulting-shadow, #{$i}px #{$i}px 0 var(--clr-text);
}
box-shadow: $resulting-shadow;
}

View File

@@ -0,0 +1,43 @@
<template>
<div class="end">
<button
class="endButton"
@click="endGame"
>
Закончить игру
</button>
</div>
</template>
<script setup>
import axios from 'axios'
import { useRouter } from 'vue-router';
const router = useRouter()
function endGame() {
axios.post(import.meta.env.VITE_APP_BACKEND + '/end')
router.push('/')
}
</script>
<style scoped lang="scss">
.end {
// position: fixed;
// bottom: 8px;
// left: 50%;
// transform: translateX(-50%);
}
.endButton {
background: none;
border: none;
font: inherit;
color: var(--clr-text);
padding: 8px 12px;
border-bottom: 1px dotted var(--clr-text);
&:hover {
cursor: pointer;
}
}
</style>

View File

@@ -0,0 +1,168 @@
<template>
<h1 class="header">
Флекспатруль мультиплеер
</h1>
<div class="main">
<div
v-if="card"
class="card"
>
<img
class="meme"
:src="card.image"
>
<h2>Кто скинул этот мем?</h2>
<div class="interactive">
<transition
name="fade-answers"
mode="out-in"
>
<List
v-if="!showResult"
:options="options"
@selectedAnswer="selectAnswer"
/>
<Result
v-else
:selected-name="selectedAnswer"
:correct="correctAnswer"
/>
</transition>
</div>
</div>
<EndGame />
<Countdown v-if="correctAnswer" />
<square-loader
v-if="!card"
:color="'white'"
class="loader"
/>
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import axios from 'axios'
import List from './List.vue'
import Result from './Result.vue'
import EndGame from './EndGame.vue'
import useStore from '@/store'
import useServerEvents from '@/composables/useServerEvents'
import Countdown from '@/components/Countdown.vue'
import SquareLoader from 'vue-spinner/src/SquareLoader.vue'
const store = useStore()
const router = useRouter()
const { addRevealListener, addEndListener } = useServerEvents()
const options = ref()
const card = ref()
const correctAnswer = ref()
const selectedAnswer = ref()
const showResult = ref()
addRevealListener((data) => {
showResult.value = true
correctAnswer.value = data.correctAnswer
setTimeout(() => {
getCard()
}, 5000)
})
addEndListener(() => {
router.push('/')
})
async function getCard() {
correctAnswer.value = null
selectedAnswer.value = null
showResult.value = false
card.value = null
const response = await axios.get(import.meta.env.VITE_APP_BACKEND + '/card')
card.value = response.data
}
async function selectAnswer(selection) {
selectedAnswer.value = selection
await axios.post(import.meta.env.VITE_APP_BACKEND + '/answer', {
'data': {
'id': card.value._id,
'name': selectedAnswer.value,
'username': store.username
}
})
}
onMounted(async () => {
getCard()
const response = await axios.get(import.meta.env.VITE_APP_BACKEND + '/options')
options.value = response.data
})
</script>
<style scoped lang="scss">
.header {
padding: 60px;
margin: auto;
height: 42px;
}
.main {
position: relative;
min-height: calc(100vh - 162px);
box-sizing: border-box;
padding: 8px;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.card {
width: 450px;
margin: 0 auto;
min-height: 0;
}
.meme {
width: 100%;
border-radius: 32px;
border: 3px solid var(--clr-text);
@include filled-shadow(16);
transform: translateX(-8px);
}
.clickable {
cursor: pointer;
}
.interactive {
position: relative;
}
.fade-answers-leave-active {
transition: all 0.8s ease;
}
.fade-answers-leave-to {
opacity: 0;
transform: scale(0.3);
}
.loader {
margin-top: 100px;
}
@media screen and (max-width: 520px) {
.card {
width: 94%;
padding: 3%;
}
}
</style>

View File

@@ -0,0 +1,67 @@
<template>
<div
class="answers"
>
<span
v-for="name in options"
:key="name"
class="option"
:class="{
'-selected': selection === name
}"
@click="selectAnswer(name)"
>
{{ name }}
</span>
</div>
</template>
<script>
export default {
name: 'List',
props: {
options: Array
},
data() {
return {
selection: null
}
},
methods: {
selectAnswer: function(selection) {
if (this.selection) {
return
}
this.selection = selection
this.$emit('selectedAnswer', selection)
}
}
}
</script>
<style scoped>
.answers {
display: flex;
flex-wrap: wrap;
justify-content: space-around;
gap: 3px;
}
.option {
background-color: white;
border-radius: 6px;
padding: 2px 6px;
transition: transform 0.2s;
border: 3px dashed transparent;
}
.option.-selected {
border-color: var(--clr-accent);
}
.option:hover {
transform: scale(1.06);
cursor: pointer;
}
</style>

View File

@@ -0,0 +1,49 @@
<template>
<div>
<div
class="result"
:class="{correct: correct === selectedName}"
>
<div
v-show="correct !== selectedName"
class="wrong"
>
Нет, это был не {{ selectedName }} 😢
</div>
{{ correct }}
</div>
</div>
</template>
<script>
export default {
name: "Result",
props: {
name: String,
selectedName: String,
date: String,
correct: String
}
}
</script>
<style scoped lang="scss">
.result {
padding: 30px 40px;
border-radius: 32px;
background-color: white;
font-weight: 600;
border: 3px solid var(--clr-text);
@include filled-shadow(16);
}
.correct {
color: var(--clr-text);
background-color: var(--clr-accent);
}
.wrong {
color: rgb(255, 71, 71);
}
</style>

View File

@@ -45,7 +45,7 @@ export default {
},
mounted() {
axios
.get(process.env.VUE_APP_BACKEND + '/stats')
.get(import.meta.env.VITE_APP_BACKEND + '/stats')
.then(response => {
response.data.forEach(element => {
this.stats[element._id] = element.correct / element.wrong

View File

@@ -0,0 +1,275 @@
<template>
<div class="cardWrapper">
<div class="authCard">
<!-- <div
class="info"
@click="showQr = !showQr"
>
<IconInfo />
</div> -->
<h1>Авторизация:</h1>
<div class="auth">
<p>{{ question }}</p>
<input
v-model="answer"
placeholder="Ответ"
class="input"
:class="{
'-wrong': wrongPassword
}"
>
<input
v-if="mode === 'player'"
v-model="username"
placeholder="Ваше имя"
class="input"
>
<button
v-if="mode === 'player'"
class="login"
:disabled="!username || !answer"
@click="loginPlayer"
>
Войти как игрок
</button>
<button
v-else
class="login"
:disabled="!answer"
@click="loginScreen"
>
Войти как большой экран
</button>
<button
class="switchMode"
@click="switchMode"
>
Я не {{ mode === 'player' ? 'игрок' : 'большой экран' }}!
</button>
</div>
</div>
</div>
<!-- <a
class="author"
href="https://kopyl.dev"
target="_blank"
>
<img
src="kopyl_frame_white.png"
class="authorLogo"
>
</a> -->
</template>
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import useStore from '../../store'
import axios from 'axios'
import VuePeel from 'vue-peel'
import 'vue-peel/style.css'
import IconInfo from '@/components/IconInfo.vue'
axios.defaults.withCredentials = true
const question = import.meta.env.VITE_APP_QUESTION
const router = useRouter()
const store = useStore()
const mode = ref("player")
const answer = ref()
const username = ref()
const wrongPassword = ref()
const showQr = ref()
function switchMode() {
mode.value = mode.value === 'player' ? 'screen' : 'player'
}
async function loginPlayer() {
if (!answer.value || !username.value) {
return
}
store.username = username.value
try {
await axios
.post(import.meta.env.VITE_APP_BACKEND + '/auth', {
"pass": answer.value,
"username": username.value,
})
router.push('/game')
} catch {
wrongPassword.value = true
}
}
async function loginScreen() {
store.username = undefined
try {
await axios
.post(import.meta.env.VITE_APP_BACKEND + '/auth', {
"pass": answer.value,
})
router.push('/screen')
} catch {
wrongPassword.value = true
}
}
</script>
<style scoped lang="scss">
.auth {
display: flex;
gap: 16px;
flex-direction: column;
align-items: center;
}
.cardWrapper {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
gap: 64px;
flex-direction: column;
align-items: center;
justify-content: center;
}
.authCard {
position: relative;
color: var(--clr-text);
width: 400px;
margin: auto;
border-radius: 32px;
padding: 40px 40px;
box-sizing: border-box;
background-color: white;
border: 3px solid var(--clr-text);
@include filled-shadow(16);
}
.info {
position: absolute;
left: 0;
top: 0;
padding: 16px;
width: 20px;
height: 20px;
cursor: pointer;
svg {
width: 20px;
height: 20px;
color: var(--clr-text-secondary);
}
}
.input {
font-size: 16px;
padding: 16px;
border: 2px solid var(--clr-text);
width: 100%;
box-sizing: border-box;
@include filled-shadow(4);
border-radius: 12px;
&:focus {
outline: none;
}
&.-wrong {
border-color: red;
}
}
.login {
color: var(--clr-text);
font-size: 16px;
box-sizing: border-box;
background-color: var(--clr-bg);
border: none;
width: 100%;
padding: 16px;
cursor: pointer;
border: 2px solid var(--clr-text);
@include filled-shadow(4);
border-radius: 12px;
&:disabled {
cursor: default;
color: var(--clr-text-secondary);
}
}
.switchMode {
position: absolute;
color: var(--clr-text);
background: var(--clr-accent);
font-size: 12px;
width: 70px;
height: 70px;
border-radius: 100px;
border: 2px solid var(--clr-text);
left: 0;
bottom: 0;
transform: translate(-50%, 50%);
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
@include filled-shadow(4);
}
.author {
width: 64px;
height: 64px;
position: fixed;
right: 0;
bottom: 0;
padding: 16px;
}
.authorLogo {
width: 100%;
height: 100%;
}
@media only screen and (max-width: 520px) {
.authCard {
width: calc(100% - 90px);
margin: auto;
padding: 40px 20px;
}
.flag {
transform: translate(calc(-50vw + 70px), -175%) rotate(90deg);
&.-open {
transform: translate(calc(-50vw + 70px), -175%) rotate(0);
}
&.-short {
transform: translate(calc(-50vw + 70px), -160%) rotate(90deg);
&.-open {
transform: translate(calc(-50vw + 70px), -160%) rotate(0);
}
}
}
}
</style>

View File

@@ -0,0 +1,29 @@
<template>
<div
class="answer"
>
{{ props.answer }}
</div>
</template>
<script setup>
const props = defineProps(["answer"])
</script>
<style scoped lang="scss">
.answer {
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
width: 100%;
box-sizing: border-box;
background: var(--clr-accent);
border-radius: 32px;
font-size: 30px;
padding: 32px;
font-weight: 600;
border: 3px solid var(--clr-text);
@include filled-shadow(16);
}
</style>

View File

@@ -0,0 +1,28 @@
<template>
<div class="funFact">
{{ fact }}
</div>
</template>
<script setup>
const facts = [
"Дима главный источник мемов. Он скинул в конфу 2552 пикчи, это 47% от общего числа!",
"Всего в конфу было скинуто 5420 картинок!",
"Раньше флекспатруль носил название dayzspeak.ts3.pw",
"Конфе с мемами больше семи лет!"
]
const fact = facts[Math.floor(Math.random() * facts.length)];
</script>
<style scoped lang="scss">
.funFact {
position: fixed;
padding: 32px;
right: 0;
bottom: 0;
text-align: right;
width: 200px;
color: var(--clr-text-secondary);
}
</style>

View File

@@ -0,0 +1,381 @@
<template>
<div
v-if="card && !loading"
class="bigScreen"
>
<div class="leftWrapper">
<div class="left">
<Answer
v-if="correctAnswer"
:answer="correctAnswer"
/>
<img
class="image"
:class="{
'-scoot': correctAnswer
}"
:src="card.image"
>
</div>
</div>
<div class="answersState">
<div class="users -unanwsered">
<h2 class="usersTitle">
Не ответили
</h2>
<ul>
<li
v-for="user in unansweredPlayers"
:key="user.name"
class="user"
>
<span>
{{ user.name }}
</span>
<span
v-if="score[user.name]"
class="score"
:class="{
'-leader': score[user.name] === maxScore
}"
>
{{ score[user.name] }}
</span>
</li>
</ul>
</div>
<div class="users">
<h2 class="usersTitle">
Ответили
</h2>
<ul>
<li
v-for="user in answeredPlayers"
:key="user.name"
class="user"
:class="{
'-wrong': correctAnswer && user.selected !== correctAnswer,
'-correct': correctAnswer && user.selected === correctAnswer
}"
>
<span>
{{ user.name }}
</span>
<span
v-if="score[user.name]"
class="score"
:class="{
'-leader': score[user.name] === maxScore
}"
>
{{ score[user.name] }}
</span>
</li>
</ul>
</div>
</div>
</div>
<square-loader
v-else
:color="'white'"
class="loader"
/>
<Countdown v-if="correctAnswer" />
<!-- <FunFact /> -->
<Transition name="slide-out">
<div
v-if="hurryLast"
class="hurryLast"
>
{{ lastPlayer.name }}, поторопись!
</div>
</Transition>
</template>
<script setup>
import axios from 'axios'
import { ref, onMounted, computed, watch } from 'vue'
import { useRouter } from 'vue-router'
import useServerEvents from '@/composables/useServerEvents'
import Answer from './Answer.vue'
// import FunFact from './FunFact.vue'
import SquareLoader from 'vue-spinner/src/SquareLoader.vue'
import Countdown from '@/components/Countdown.vue'
const { addAnswerListener, addUserlistListener, addRevealListener, addEndListener } = useServerEvents()
const router = useRouter()
const card = ref()
const users = ref([])
const correctAnswer = ref()
const loading = ref()
const score = ref({})
const hurryLast = ref()
async function getCard() {
loading.value = true
const response = await axios.get(import.meta.env.VITE_APP_BACKEND + '/card')
loading.value = false
card.value = response.data
}
async function getPlayers() {
const response = await axios.get(import.meta.env.VITE_APP_BACKEND + '/players')
response.data.forEach((name) => {
users.value.push({
name,
selected: null
})
})
}
const answeredPlayers = computed(() => {
return users.value.filter((user) => user.selected)
})
const unansweredPlayers = computed(() => {
return users.value.filter((user) => !user.selected)
})
const maxScore = computed(() => {
const leader = Object.keys(score.value).sort((a, b) => {
return score.value[b] - score.value[a]
})[0]
return score.value[leader]
})
const lastPlayer = computed(() => {
if (unansweredPlayers.value.length === 1) {
return unansweredPlayers.value[0]
}
return null
})
let countdownToHurry = undefined
watch(lastPlayer, () => {
if (!lastPlayer.value) {
clearTimeout(countdownToHurry)
hurryLast.value = false
} else {
countdownToHurry = setTimeout(() => {
hurryLast.value = true
}, 5000)
}
})
addAnswerListener((data) => {
users.value = users.value.map((user) => {
if (user.name === data.username) {
return {
...user,
selected: data.selected
}
}
return user
})
})
addUserlistListener((data) => {
users.value = []
data.forEach((name) => {
users.value.push({
name,
selected: null
})
})
})
addRevealListener((data) => {
correctAnswer.value = data.correctAnswer
score.value = data.score
clearTimeout(countdownToHurry)
hurryLast.value = false
setTimeout(() => {
getCard()
correctAnswer.value = null
users.value = users.value.map((user) => ({
...user,
selected: null
}))
}, 5000)
})
addEndListener(() => {
router.push('/')
})
onMounted(() => {
getCard()
getPlayers()
})
</script>
<style scoped lang="scss">
.bigScreen {
position: absolute;
width: 100%;
height: 100vh;
left: 0;
top: 0;
display: flex;
justify-content: space-evenly;
gap: 16px;
align-items: center;
padding: 8px 8px 24px 8px;
box-sizing: border-box;
color: var(--clr-text);
}
.leftWrapper {
position: relative;
height: 100%;
max-width: 60%;
display: flex;
align-items: center;
flex-direction: column;
justify-content: center;
}
.left {
position: relative;
max-height: 100%;
width: 100%;
display: flex;
align-items: center;
flex-direction: column;
justify-content: center;
}
.image {
display: block;
max-width: 100%;
max-height: 100%;
object-fit: contain;
background: var(--clr-text);
border: 3px solid var(--clr-text);
@include filled-shadow(16);
border-radius: 16px;
// animation-name: rock;
// animation-duration: 5s;
// animation-direction: alternate;
// animation-iteration-count: infinite;
// animation-timing-function: ease-in-out;
transition: transform 1s;
&.-scoot {
transform: translateY(150px);
}
}
@keyframes rock {
from {
transform: rotate(-2deg);
}
to {
transform: rotate(2deg);
}
}
.users {
display: flex;
flex-direction: column;
gap: 8px;
height: 100%;
padding: 20px;
}
.users.-unanwsered {
border-right: 1px dashed var(--clr-text);
}
.user {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.user.-correct {
color: green;
}
.user.-wrong {
color: red;
}
.score {
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 12px;
width: 16px;
height: 16px;
border-radius: 64px;
border: 2px solid var(--clr-text);
background: var(--clr-bg);
color: var(--clr-text);
&.-leader {
background: var(--clr-accent);
}
}
.usersTitle {
margin: 0;
}
.answersState {
display: flex;
flex-shrink: 0;
height: 400px;
font-size: 32px;
}
.loader {
margin-top: 50%;
}
.hurryLast {
padding: 16px 32px;
border-radius: 16px;
font-size: 32px;
border: 3px solid var(--clr-text);
@include filled-shadow(16);
position: fixed;
right: 32px;
bottom: 32px;
background: var(--clr-bg-secondary);
animation-name: pulse;
animation-delay: 10s;
animation-duration: 100s;
animation-fill-mode: forwards;
transition: transform 1s;
}
.slide-out-enter-from,
.slide-out-leave-to {
transform: translateY(200px);
}
@keyframes pulse {
from {
background: var(--clr-bg-secondary);
}
to {
background: red;
}
}
</style>

26
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,26 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
const path = require("path");
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
css: {
preprocessorOptions: {
scss: {
additionalData: `
@use 'sass:math';
@import '@/styles/_prepend.scss';
`,
},
},
},
server: {
port: 8080
}
})

File diff suppressed because it is too large Load Diff