add layers for new image and new game

This commit is contained in:
Zutatensuppe 2021-05-21 00:43:02 +02:00
parent e9b209edf1
commit bdd061dd1a
18 changed files with 551 additions and 99 deletions

View file

@ -1 +0,0 @@
:root{--main-color:#c1b19f;--link-color:#808db0;--link-hover-color:#c5cfeb;--highlight-color:#dd7e7e;--input-bg-color:#262523;--bg-color:rgba(0,0,0,.7)}body,html{margin:0;background:#2b2b2b;color:var(--main-color);height:100%}*{font-family:monospace;font-size:15px}h1,h2,h3,h4{font-size:20px}a{color:var(--link-color);text-decoration:none}a:hover{color:var(--link-hover-color)}td,th{vertical-align:top}.btn{display:inline-block;background:var(--input-bg-color);color:var(--link-color);border:solid 1px #000;padding:5px 10px;box-shadow:1px 1px 2px rgba(0,0,0,.5),0 0 1px rgba(150,150,150,.4) inset;border-radius:4px;user-select:none}.btn:hover{background:#2f2e2c;color:var(--link-hover-color);border:solid 1px #111;box-shadow:0 0 1px rgba(150,150,150,.4) inset;cursor:pointer}.btn:disabled{background:#2f2e2c;color:#8c4747!important;border:solid 1px #111;box-shadow:0 0 1px rgba(150,150,150,.4) inset;cursor:not-allowed}input{background:#333230;border-radius:4px;color:var(--main-color);padding:6px 10px;border:solid 1px #000;box-shadow:0 0 3px rgba(0,0,0,.3) inset}input:focus{border:solid 1px #686767;background:var(--input-bg-color)}.scores{position:absolute;right:0;top:0;background:var(--bg-color);padding:5px;border:solid 1px #000;box-shadow:0 0 10px 0 rgba(0,0,0,.7)}.timer{position:absolute;left:0;top:0;background:var(--bg-color);padding:5px;border:solid 1px #000;box-shadow:0 0 10px 0 rgba(0,0,0,.7)}.menu{position:absolute;top:0;left:50%;transform:translateX(-50%);background:var(--bg-color);padding:5px;border:solid 1px #000;box-shadow:0 0 10px 0 rgba(0,0,0,.7);z-index:2}.closed{display:none}.overlay{position:absolute;top:0;left:0;right:0;bottom:0;z-index:10;background:var(--bg-color)}.overlay.transparent{background:0 0}.overlay-content{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);background:var(--bg-color);padding:5px;border:solid 1px #000;box-shadow:0 0 10px 0 rgba(0,0,0,.7);z-index:1}.connection-lost .overlay-content{padding:20px;text-align:center}.preview{position:absolute;top:20px;left:20px;bottom:20px;right:20px}.preview .img{height:100%;width:100%;position:absolute;background-repeat:no-repeat;background-position:center;background-size:contain}.menu .opener{display:inline-block;margin-right:10px;color:var(--link-color)}.menu .opener:last-child{margin-right:0}.menu .opener:hover{color:var(--link-hover-color);cursor:pointer}canvas.loaded{cursor:none}kbd{background-color:#eee;border-radius:3px;border:1px solid #b4b4b4;box-shadow:0 1px 1px rgba(0,0,0,.2),0 2px 0 0 rgba(255,255,255,.7) inset;color:#333;display:inline-block;font-size:.85em;font-weight:700;line-height:1;padding:2px 4px;white-space:nowrap}.nav{list-style:none;padding:0}.nav li{display:inline-block;margin-right:1em}.image-list{overflow:scroll}.image-list-inner{white-space:nowrap}.imageteaser{width:150px;height:100px;display:inline-block;margin:5px;background-size:contain;background-position:center;background-repeat:no-repeat;background-color:#222;cursor:pointer}.game-teaser-wrap{display:inline-block;width:20%;padding:5px;box-sizing:border-box}.game-teaser{display:block;background-repeat:no-repeat;background-position:center;background-size:contain;position:relative;padding-top:56.25%;width:100%;background-color:#222}.game-info{position:absolute;top:0;left:0;right:0;bottom:0;width:100%;height:100%}.game-info-text{position:absolute;top:0;background:var(--bg-color);padding:5px}.game-replay{position:absolute;top:0;right:0}html.view-game{overflow:hidden}html.view-game body{overflow:hidden}html.view-replay{overflow:hidden}html.view-replay body{overflow:hidden}html.view-replay canvas{cursor:grab}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -3,9 +3,9 @@
<head>
<title>🧩 jigsaw.hyottoko.club</title>
<script type="module" crossorigin src="/assets/index.85898c1b.js"></script>
<link rel="modulepreload" href="/assets/vendor.00b608ff.js">
<link rel="stylesheet" href="/assets/index.421011a7.css">
<script type="module" crossorigin src="/assets/index.b49dadca.js"></script>
<link rel="modulepreload" href="/assets/vendor.8616a479.js">
<link rel="stylesheet" href="/assets/index.6c4f6859.css">
</head>
<body>
<div id="app"></div>

View file

@ -181,6 +181,17 @@ const hash = (str) => {
}
return hash;
};
function asQueryArgs(data) {
const q = [];
for (let k in data) {
const pair = [k, data[k]].map(encodeURIComponent);
q.push(pair.join('='));
}
if (q.length === 0) {
return '';
}
return `?${q.join('&')}`;
}
var Util = {
hash,
uniqId,
@ -193,6 +204,7 @@ var Util = {
encodeGame,
decodeGame,
coordByTileIdx,
asQueryArgs,
};
const log$4 = logger('WebSocketServer.js');
@ -1236,18 +1248,40 @@ async function getExifOrientation(imagePath) {
});
});
}
const allImages = () => {
const images = fs.readdirSync(UPLOAD_DIR)
const allImages = (sort) => {
let images = fs.readdirSync(UPLOAD_DIR)
.filter(f => f.toLowerCase().match(/\.(jpe?g|webp|png)$/))
.map(f => ({
filename: f,
file: `${UPLOAD_DIR}/${f}`,
url: `${UPLOAD_URL}/${encodeURIComponent(f)}`,
}))
.sort((a, b) => {
return fs.statSync(b.file).mtime.getTime() -
fs.statSync(a.file).mtime.getTime();
});
title: '',
category: '',
ts: fs.statSync(`${UPLOAD_DIR}/${f}`).mtime.getTime(),
}));
switch (sort) {
case 'alpha_asc':
images = images.sort((a, b) => {
return a.file > b.file ? 1 : -1;
});
break;
case 'alpha_desc':
images = images.sort((a, b) => {
return a.file < b.file ? 1 : -1;
});
break;
case 'date_asc':
images = images.sort((a, b) => {
return a.ts > b.ts ? 1 : -1;
});
break;
case 'date_desc':
default:
images = images.sort((a, b) => {
return a.ts < b.ts ? 1 : -1;
});
break;
}
return images;
};
async function getDimensions(imagePath) {
@ -1650,8 +1684,10 @@ app.get('/api/conf', (req, res) => {
});
});
app.get('/api/newgame-data', (req, res) => {
const q = req.query;
res.send({
images: Images.allImages(),
images: Images.allImages(q.sort),
categories: [],
});
});
app.get('/api/index-data', (req, res) => {
@ -1695,11 +1731,12 @@ app.post('/upload', (req, res) => {
});
});
app.post('/newgame', bodyParser.json(), async (req, res) => {
log.log(req.body.tiles, req.body.image);
const gameSettings = req.body;
log.log(gameSettings);
const gameId = Util.uniqId();
if (!Game.exists(gameId)) {
const ts = Time.timestamp();
await Game.createGame(gameId, req.body.tiles, req.body.image, ts, req.body.scoreMode);
await Game.createGame(gameId, gameSettings.tiles, gameSettings.image, ts, gameSettings.scoreMode);
}
res.send({ id: gameId });
});

View file

@ -8,6 +8,9 @@ export type EncodedPlayer = Array<any>
export type EncodedPiece = Array<any>
export type EncodedPieceShape = number
// TODO: maybe something other than string in the future
export type Category = string
interface GameRng {
obj: Rng
type?: string
@ -22,6 +25,19 @@ interface Game {
rng: GameRng
}
export interface Image {
file: string
url: string
category: Category
title: string
}
export interface GameSettings {
tiles: number
image: Image
scoreMode: ScoreMode
}
export interface Puzzle {
tiles: Array<EncodedPiece>
data: PuzzleData

View file

@ -148,6 +148,18 @@ const hash = (str: string): number => {
return hash;
}
function asQueryArgs(data: any) {
const q = []
for (let k in data) {
const pair = [k, data[k]].map(encodeURIComponent)
q.push(pair.join('='))
}
if (q.length === 0) {
return ''
}
return `?${q.join('&')}`
}
export default {
hash,
uniqId,
@ -165,4 +177,6 @@ export default {
decodeGame,
coordByTileIdx,
asQueryArgs,
}

View file

@ -0,0 +1,32 @@
<template>
<div>
<image-teaser v-for="(i,idx) in images" :image="i" @click="imageClicked(i)" :key="idx" />
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import { Image } from '../../common/GameCommon'
import ImageTeaser from './ImageTeaser.vue'
export default defineComponent({
name: 'image-library',
components: {
ImageTeaser,
},
props: {
images: {
type: Array,
required: true,
},
},
emits: {
imageClicked: null,
},
methods: {
imageClicked (image: Image) {
this.$emit('imageClicked', image)
},
},
})
</script>

View file

@ -1,92 +1,86 @@
<template>
<div>
<h1>New game</h1>
<table>
<tr>
<td><label>Pieces: </label></td>
<td><input type="text" v-model="tiles" /></td>
</tr>
<tr>
<td><label>Scoring: </label></td>
<td>
<label><input type="radio" v-model="scoreMode" value="1" /> Any (Score when pieces are connected to each other or on final location)</label>
<br />
<label><input type="radio" v-model="scoreMode" value="0" /> Final (Score when pieces are put to their final location)</label>
</td>
</tr>
<tr>
<td><label>Image: </label></td>
<td>
<span v-if="image">
<img :src="image.url" style="width: 150px;" />
or
<upload @uploaded="mediaImgUploaded($event)" accept="image/*" label="Upload an image" />
</span>
<span v-else>
<upload @uploaded="mediaImgUploaded($event)" accept="image/*" label="Upload an image" />
(or select from below)
</span>
</td>
</tr>
<tr>
<td colspan="2">
<button class="btn" :disabled="!canStartNewGame" @click="onNewGameClick">Start new game</button>
</td>
</tr>
</table>
<div class="overlay new-game-dialog" @click="$emit('bgclick')">
<div class="overlay-content" @click.stop="">
<h1>Image lib</h1>
<div>
<image-teaser v-for="(i,idx) in images" :image="i" @click="image = i" :key="idx" />
<div class="area-image">
<div class="has-image">
<responsive-image :src="image.url" :title="image.title" />
</div>
</div>
<div class="area-settings">
<table>
<tr>
<td><label>Pieces</label></td>
<td><input type="text" v-model="tiles" /></td>
</tr>
<tr>
<td><label>Scoring: </label></td>
<td>
<label><input type="radio" v-model="scoreMode" value="1" /> Any (Score when pieces are connected to each other or on final location)</label>
<br />
<label><input type="radio" v-model="scoreMode" value="0" /> Final (Score when pieces are put to their final location)</label>
</td>
</tr>
</table>
</div>
<div class="area-buttons">
<button class="btn" :disabled="!canStartNewGame" @click="onNewGameClick">
🧩 Generate Puzzle
</button>
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import { ScoreMode } from './../../common/GameCommon'
import Upload from './../components/Upload.vue'
import ImageTeaser from './../components/ImageTeaser.vue'
import { GameSettings, ScoreMode } from './../../common/GameCommon'
import ResponsiveImage from './ResponsiveImage.vue'
export default defineComponent({
name: 'new-game-dialog',
components: {
Upload,
ImageTeaser,
ResponsiveImage,
},
props: {
images: Array,
image: {
type: Object,
required: true,
},
},
emits: {
newGame: null,
bgclick: null,
},
data() {
return {
tiles: 1000,
image: '',
scoreMode: ScoreMode.ANY,
}
},
methods: {
// TODO: ts type UploadedImage
mediaImgUploaded(data: any) {
this.image = data.image
},
canStartNewGame () {
if (!this.tilesInt || !this.image || ![0, 1].includes(this.scoreModeInt)) {
return false
}
return true
},
onNewGameClick() {
onNewGameClick () {
this.$emit('newGame', {
tiles: this.tilesInt,
image: this.image,
scoreMode: this.scoreModeInt,
})
} as GameSettings)
},
},
computed: {
canStartNewGame () {
if (
!this.tilesInt
|| !this.image
|| !this.image.url
|| ![0, 1].includes(this.scoreModeInt)
) {
return false
}
return true
},
scoreModeInt (): number {
return parseInt(`${this.scoreMode}`, 10)
},
@ -96,3 +90,46 @@ export default defineComponent({
},
})
</script>
// TODO: scoped vs .new-game-dialog
<style>
.new-game-dialog .overlay-content {
display: grid;
grid-template-columns: auto 450px;
grid-template-rows: auto;
grid-template-areas:
"image settings"
"image buttons";
height: 90%;
width: 80%;
}
.new-game-dialog .area-image {
grid-area: image;
}
.new-game-dialog .area-settings {
grid-area: settings;
}
.new-game-dialog .area-settings table input[type="text"] {
width: 100%;
box-sizing: border-box;
}
.new-game-dialog .area-buttons {
align-self: end;
grid-area: buttons;
}
.new-game-dialog .area-buttons button {
width: 100%;
}
.new-game-dialog .has-image {
position: relative;
width: 100%;
height: 100%;
}
.new-game-dialog .has-image .remove {
position: absolute;
top: .5em;
left: .5em;
}
</style>

View file

@ -0,0 +1,163 @@
"Upload image" clicked: what it looks like when the image was uploaded.
Probably needs a (x) in the upper right corner of the image to allow the
user to remove the image if a wrong one was selected. The image should
be uploaded to the actual gallery only when the user presses "post to
gallery", if possible!
<template>
<div class="overlay new-image-dialog" @click="$emit('bgclick')">
<div class="overlay-content" @click.stop="">
<div class="area-image" :class="{'has-image': !!image.url, 'no-image': !image.url}">
<!-- TODO: ... -->
<div v-if="image.url" class="has-image">
<span class="remove btn" @click="image.url=''">X</span>
<responsive-image :src="image.url" />
</div>
<div v-else>
<!-- TODO: drop area for image -->
<upload class="upload" @uploaded="mediaImgUploaded($event)" accept="image/*" label="Upload an image" />
</div>
</div>
<div class="area-settings">
<table>
<tr>
<td><label>Title</label></td>
<td><input type="text" v-model="image.title" placeholder="Flower by @artist" /></td>
</tr>
<tr>
<td colspan="2">
<div class="hint">Feel free to leave a credit to the artist/photographer in the title :)</div>
</td>
</tr>
<tr>
<!-- TODO: autocomplete category -->
<td><label>Category</label></td>
<td><input type="text" v-model="image.category" placeholder="Plants" /></td>
</tr>
</table>
</div>
<div class="area-buttons">
<!-- <button class="btn" :disabled="!canPostToGallery" @click="postToGallery">🖼 Post to gallery</button> -->
<button class="btn" :disabled="!canSetupGameClick" @click="setupGameClick">🧩 Post to gallery <br /> + set up game</button>
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import Upload from './Upload.vue'
import ResponsiveImage from './ResponsiveImage.vue'
import { Image } from '../../common/GameCommon'
export default defineComponent({
name: 'new-image-dialog',
components: {
Upload,
ResponsiveImage,
},
emits: {
bgclick: null,
setupGameClick: null,
},
data () {
return {
image: {
file: '',
url: '',
title: '',
category: '',
} as Image,
}
},
computed: {
canPostToGallery () {
return !!this.image.url
},
canSetupGameClick () {
return !!this.image.url
},
},
methods: {
mediaImgUploaded(data: any) {
this.image.file = data.image.file
this.image.url = data.image.url
},
postToGallery () {
this.$emit('postToGallery', this.image)
},
setupGameClick () {
this.$emit('setupGameClick', this.image)
},
},
})
</script>
// TODO: scoped vs .new-image-dialog
<style>
.new-image-dialog .overlay-content {
display: grid;
grid-template-columns: auto 450px;
grid-template-rows: auto;
grid-template-areas:
"image settings"
"image buttons";
height: 90%;
width: 80%;
}
.new-image-dialog .area-image {
grid-area: image;
}
.new-image-dialog .area-image.no-image {
align-content: center;
display: grid;
text-align: center;
margin: 20px;
border: dashed 6px;
position: relative;
}
.new-image-dialog .area-image .has-image {
position: relative;
width: 100%;
height: 100%;
}
.new-image-dialog .area-image .has-image .remove {
position: absolute;
top: .5em;
left: .5em;
}
.new-image-dialog .area-settings {
grid-area: settings;
}
.new-image-dialog .area-settings table input[type="text"] {
width: 100%;
box-sizing: border-box;
}
.new-image-dialog .area-buttons {
align-self: end;
grid-area: buttons;
}
.new-image-dialog .area-buttons button {
width: 100%;
}
.new-image-dialog .upload {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
cursor: pointer;
}
.new-image-dialog .upload .btn {
position: absolute;
top: 50%;
transform: translate(-50%,-50%);
}
</style>

View file

@ -0,0 +1,37 @@
<template>
<div :style="style" :title="title"></div>
</template>
<script>
export default {
name: 'responsive-image',
props: {
src: String,
title: {
type: String,
default: '',
},
height: {
type: String,
default: '100%',
},
width: {
type: String,
default: '100%',
},
},
computed: {
style() {
return {
display: 'inline-block',
verticalAlign: 'text-bottom',
backgroundImage: `url('${this.src}')`,
backgroundRepeat: 'no-repeat',
backgroundSize: 'contain',
backgroundPosition: 'center',
width: this.width,
height: this.height,
}
},
},
}
</script>

View file

@ -2,6 +2,7 @@
:root {
--main-color: #c1b19f;
--main-darker-color: #4f4e4c;
--link-color: #808db0;
--link-hover-color: #c5cfeb;
--highlight-color: #dd7e7e;
@ -48,6 +49,11 @@ td, th {
user-select: none;
}
.btn-big {
font-size: 1.5em;
padding: 10px 20px;
}
.btn:hover {
background: #2f2e2c;
color: var(--link-hover-color);
@ -202,6 +208,22 @@ kbd {
/* pre-game stuff */
.hint {
color: var(--main-darker-color);
}
.upload-image-teaser {
text-align: center;
}
.upload-image-teaser .btn {
margin-bottom: .5em;
}
table label {
line-height: 32px;
}
.nav {
list-style: none;
padding: 0;

View file

@ -1,32 +1,97 @@
"New Game" page: Upload button big, centered, at the top of the page, as
visible as possible. Upload button has a warning that the image will
be added to public gallery, just so noone uploads anything naughty on
accident. The page can show all the images by default, or one of the categories
of images. Instead of categories, you can make the system tag-based, like
in jigsawpuzzles.io
<template>
<div>
<new-game-dialog :images="images" @newGame="onNewGame" />
<div class="upload-image-teaser">
<div class="btn btn-big" @click="dialog='new-image'">Upload your image</div>
<div class="hint">(The image you upload will be added to the public gallery.)</div>
</div>
<div>
<label v-if="categories.length > 0">
Category:
<select v-model="filters.category" @change="filtersChanged">
<option value="">All</option>
<option v-for="(c, idx) in categories" :key="idx" :value="c">{{c}}</option>
</select>
</label>
<label>
Sort by:
<select v-model="filters.sort" @change="filtersChanged">
<option value="date_desc">Newest first</option>
<option value="date_asc">Oldest first</option>
<option value="alpha_asc">A-Z</option>
<option value="alpha_desc">Z-A</option>
</select>
</label>
</div>
<image-library :images="images" :categories="categories" @imageClicked="imageClicked" />
<new-image-dialog v-if="dialog==='new-image'" @bgclick="dialog=''" @setupGameClick="setupGameClick" />
<new-game-dialog v-if="image && dialog==='new-game'" @bgclick="dialog=''" @newGame="onNewGame" :image="image" />
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
// TODO: maybe move dialog back, now that this is a view on its own
import ImageLibrary from './../components/ImageLibrary.vue'
import NewImageDialog from './../components/NewImageDialog.vue'
import NewGameDialog from './../components/NewGameDialog.vue'
import { GameSettings, Image } from '../../common/GameCommon'
import Util from '../../common/Util'
export default defineComponent({
components: {
ImageLibrary,
NewImageDialog,
NewGameDialog,
},
data() {
return {
filters: {
sort: 'date_desc',
category: '',
},
images: [],
categories: [],
image: {
url: '',
file: '',
title: '',
category: '',
} as Image,
dialog: '',
}
},
async created() {
const res = await fetch('/api/newgame-data')
const json = await res.json()
this.images = json.images
await this.loadImages()
},
methods: {
// TODO: ts GameSettings type
async onNewGame(gameSettings: any) {
async loadImages () {
const res = await fetch(`/api/newgame-data${Util.asQueryArgs(this.filters)}`)
const json = await res.json()
this.images = json.images
this.categories = json.categories
},
async filtersChanged () {
await this.loadImages()
},
imageClicked (image: Image) {
this.image = image
this.dialog = 'new-game'
},
setupGameClick (image: Image) {
this.image = image
this.dialog = 'new-game'
},
async onNewGame(gameSettings: GameSettings) {
const res = await fetch('/newgame', {
method: 'post',
headers: {
@ -39,7 +104,7 @@ export default defineComponent({
const game = await res.json()
this.$router.push({ name: 'game', params: { id: game.id } })
}
}
}
},
},
})
</script>

View file

@ -46,18 +46,44 @@ async function getExifOrientation(imagePath: string) {
})
}
const allImages = () => {
const images = fs.readdirSync(UPLOAD_DIR)
const allImages = (sort: string) => {
let images = fs.readdirSync(UPLOAD_DIR)
.filter(f => f.toLowerCase().match(/\.(jpe?g|webp|png)$/))
.map(f => ({
filename: f,
file: `${UPLOAD_DIR}/${f}`,
url: `${UPLOAD_URL}/${encodeURIComponent(f)}`,
title: '',
category: '',
ts: fs.statSync(`${UPLOAD_DIR}/${f}`).mtime.getTime(),
}))
.sort((a, b) => {
return fs.statSync(b.file).mtime.getTime() -
fs.statSync(a.file).mtime.getTime()
})
switch (sort) {
case 'alpha_asc':
images = images.sort((a, b) => {
return a.file > b.file ? 1 : -1
})
break;
case 'alpha_desc':
images = images.sort((a, b) => {
return a.file < b.file ? 1 : -1
})
break;
case 'date_asc':
images = images.sort((a, b) => {
return a.ts > b.ts ? 1 : -1
})
break;
case 'date_desc':
default:
images = images.sort((a, b) => {
return a.ts < b.ts ? 1 : -1
})
break;
}
return images
}

View file

@ -13,7 +13,7 @@ import GameSockets from './GameSockets'
import Time from './../common/Time'
import Images from './Images'
import { UPLOAD_DIR, UPLOAD_URL, PUBLIC_DIR } from './Dirs'
import GameCommon, { ScoreMode } from '../common/GameCommon'
import { GameSettings, ScoreMode } from '../common/GameCommon'
import GameStorage from './GameStorage'
let configFile = ''
@ -52,8 +52,10 @@ app.get('/api/conf', (req, res) => {
})
app.get('/api/newgame-data', (req, res) => {
const q = req.query as any
res.send({
images: Images.allImages(),
images: Images.allImages(q.sort),
categories: [],
})
})
@ -102,16 +104,17 @@ app.post('/upload', (req, res) => {
})
app.post('/newgame', bodyParser.json(), async (req, res) => {
log.log(req.body.tiles, req.body.image)
const gameSettings = req.body as GameSettings
log.log(gameSettings)
const gameId = Util.uniqId()
if (!Game.exists(gameId)) {
const ts = Time.timestamp()
await Game.createGame(
gameId,
req.body.tiles,
req.body.image,
gameSettings.tiles,
gameSettings.image,
ts,
req.body.scoreMode
gameSettings.scoreMode
)
}
res.send({ id: gameId })