mirror of
https://github.com/Luzifer/stadt-land-fluss.git
synced 2024-12-20 14:51:16 +00:00
Initial version
This commit is contained in:
commit
fba48badf6
10 changed files with 5282 additions and 0 deletions
154
.eslintrc.js
Normal file
154
.eslintrc.js
Normal file
|
@ -0,0 +1,154 @@
|
||||||
|
/*
|
||||||
|
* Hack to automatically load globally installed eslint modules
|
||||||
|
* on Archlinux systems placed in /usr/lib/node_modules
|
||||||
|
*
|
||||||
|
* Source: https://github.com/eslint/eslint/issues/11914#issuecomment-569108633
|
||||||
|
*/
|
||||||
|
|
||||||
|
const Module = require('module')
|
||||||
|
|
||||||
|
const hacks = [
|
||||||
|
'@babel/eslint-parser',
|
||||||
|
'eslint-plugin-vue',
|
||||||
|
]
|
||||||
|
|
||||||
|
const ModuleFindPath = Module._findPath
|
||||||
|
Module._findPath = (request, paths, isMain) => {
|
||||||
|
const r = ModuleFindPath(request, paths, isMain)
|
||||||
|
if (!r && hacks.includes(request)) {
|
||||||
|
return require.resolve(`/usr/lib/node_modules/${request}`)
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* ESLint configuration derived as differences from eslint:recommended
|
||||||
|
* with changes I found useful to ensure code quality and equal formatting
|
||||||
|
* https://eslint.org/docs/user-guide/configuring
|
||||||
|
*/
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
env: {
|
||||||
|
browser: true,
|
||||||
|
node: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
extends: [
|
||||||
|
'plugin:vue/vue3-recommended',
|
||||||
|
'eslint:recommended', // https://eslint.org/docs/rules/
|
||||||
|
],
|
||||||
|
|
||||||
|
globals: {
|
||||||
|
process: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
parser: '@typescript-eslint/parser',
|
||||||
|
requireConfigFile: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
plugins: [
|
||||||
|
// required to lint *.vue files
|
||||||
|
'vue',
|
||||||
|
'@typescript-eslint',
|
||||||
|
],
|
||||||
|
|
||||||
|
reportUnusedDisableDirectives: true,
|
||||||
|
|
||||||
|
root: true,
|
||||||
|
|
||||||
|
rules: {
|
||||||
|
'array-bracket-newline': ['error', { multiline: true }],
|
||||||
|
'array-bracket-spacing': ['error'],
|
||||||
|
'arrow-body-style': ['error', 'as-needed'],
|
||||||
|
'arrow-parens': ['error', 'as-needed'],
|
||||||
|
'arrow-spacing': ['error', { after: true, before: true }],
|
||||||
|
'block-spacing': ['error'],
|
||||||
|
'brace-style': ['error', '1tbs'],
|
||||||
|
'camelcase': ['warn'],
|
||||||
|
'comma-dangle': ['error', 'always-multiline'],
|
||||||
|
'comma-spacing': ['error'],
|
||||||
|
'comma-style': ['error', 'last'],
|
||||||
|
'curly': ['error'],
|
||||||
|
'default-case-last': ['error'],
|
||||||
|
'default-param-last': ['error'],
|
||||||
|
'dot-location': ['error', 'property'],
|
||||||
|
'dot-notation': ['error'],
|
||||||
|
'eol-last': ['error', 'always'],
|
||||||
|
'eqeqeq': ['error', 'always', { null: 'ignore' }],
|
||||||
|
'func-call-spacing': ['error', 'never'],
|
||||||
|
'function-paren-newline': ['error', 'multiline'],
|
||||||
|
'generator-star-spacing': ['off'], // allow async-await
|
||||||
|
'implicit-arrow-linebreak': ['error'],
|
||||||
|
'indent': ['error', 2],
|
||||||
|
'key-spacing': ['error', { afterColon: true, beforeColon: false, mode: 'strict' }],
|
||||||
|
'keyword-spacing': ['error'],
|
||||||
|
'linebreak-style': ['error', 'unix'],
|
||||||
|
'lines-between-class-members': ['error'],
|
||||||
|
'multiline-comment-style': ['off'],
|
||||||
|
'newline-per-chained-call': ['error'],
|
||||||
|
'no-alert': ['error'],
|
||||||
|
'no-console': ['off'],
|
||||||
|
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off', // allow debugger during development
|
||||||
|
'no-duplicate-imports': ['error'],
|
||||||
|
'no-else-return': ['error'],
|
||||||
|
'no-empty-function': ['error'],
|
||||||
|
'no-extra-parens': ['error'],
|
||||||
|
'no-implicit-coercion': ['error'],
|
||||||
|
'no-lonely-if': ['error'],
|
||||||
|
'no-multi-spaces': ['error'],
|
||||||
|
'no-multiple-empty-lines': ['warn', { max: 2, maxBOF: 0, maxEOF: 0 }],
|
||||||
|
'no-promise-executor-return': ['error'],
|
||||||
|
'no-return-assign': ['error'],
|
||||||
|
'no-script-url': ['error'],
|
||||||
|
'no-template-curly-in-string': ['error'],
|
||||||
|
'no-trailing-spaces': ['error'],
|
||||||
|
'no-unneeded-ternary': ['error'],
|
||||||
|
'no-unreachable-loop': ['error'],
|
||||||
|
'no-unsafe-optional-chaining': ['error'],
|
||||||
|
'no-useless-return': ['error'],
|
||||||
|
'no-var': ['error'],
|
||||||
|
'no-warning-comments': ['error'],
|
||||||
|
'no-whitespace-before-property': ['error'],
|
||||||
|
'object-curly-newline': ['error', { consistent: true }],
|
||||||
|
'object-curly-spacing': ['error', 'always'],
|
||||||
|
'object-shorthand': ['error'],
|
||||||
|
'padded-blocks': ['error', 'never'],
|
||||||
|
'prefer-arrow-callback': ['error'],
|
||||||
|
'prefer-const': ['error'],
|
||||||
|
'prefer-object-spread': ['error'],
|
||||||
|
'prefer-rest-params': ['error'],
|
||||||
|
'prefer-template': ['error'],
|
||||||
|
'quote-props': ['error', 'consistent-as-needed', { keywords: false }],
|
||||||
|
'quotes': ['error', 'single', { allowTemplateLiterals: true }],
|
||||||
|
'require-atomic-updates': ['error'],
|
||||||
|
'require-await': ['error'],
|
||||||
|
'semi': ['error', 'never'],
|
||||||
|
'sort-imports': ['error', { ignoreCase: true, ignoreDeclarationSort: false, ignoreMemberSort: false }],
|
||||||
|
'sort-keys': ['error', 'asc', { caseSensitive: true, natural: false }],
|
||||||
|
'space-before-blocks': ['error', 'always'],
|
||||||
|
'space-before-function-paren': ['error', 'never'],
|
||||||
|
'space-in-parens': ['error', 'never'],
|
||||||
|
'space-infix-ops': ['error'],
|
||||||
|
'space-unary-ops': ['error', { nonwords: false, words: true }],
|
||||||
|
'spaced-comment': ['warn', 'always'],
|
||||||
|
'switch-colon-spacing': ['error'],
|
||||||
|
'template-curly-spacing': ['error', 'never'],
|
||||||
|
'unicode-bom': ['error', 'never'],
|
||||||
|
'vue/comment-directive': 'off',
|
||||||
|
'vue/new-line-between-multi-line-property': ['error'],
|
||||||
|
'vue/no-empty-component-block': ['error'],
|
||||||
|
'vue/no-reserved-component-names': ['error'],
|
||||||
|
'vue/no-template-target-blank': ['error'],
|
||||||
|
'vue/no-unused-properties': ['error'],
|
||||||
|
'vue/no-unused-refs': ['error'],
|
||||||
|
'vue/no-useless-mustaches': ['error'],
|
||||||
|
'vue/order-in-components': ['off'], // Collides with sort-keys
|
||||||
|
'vue/require-name-property': ['error'],
|
||||||
|
'vue/v-for-delimiter-style': ['error'],
|
||||||
|
'vue/v-on-function-call': ['error'],
|
||||||
|
'wrap-iife': ['error'],
|
||||||
|
'yoda': ['error'],
|
||||||
|
},
|
||||||
|
}
|
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
dist/app.*
|
||||||
|
dist/*.ttf
|
||||||
|
dist/*.woff2
|
||||||
|
node_modules
|
16
Makefile
Normal file
16
Makefile
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
default: frontend_lint
|
||||||
|
|
||||||
|
frontend_prod: export NODE_ENV=production
|
||||||
|
frontend_prod: frontend
|
||||||
|
|
||||||
|
frontend: node_modules
|
||||||
|
node ci/build.mjs
|
||||||
|
|
||||||
|
frontend_lint: node_modules
|
||||||
|
./node_modules/.bin/eslint \
|
||||||
|
--ext .ts,.vue \
|
||||||
|
--fix \
|
||||||
|
src
|
||||||
|
|
||||||
|
node_modules:
|
||||||
|
npm ci --include dev
|
34
ci/build.mjs
Normal file
34
ci/build.mjs
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
import esbuild from 'esbuild'
|
||||||
|
import { sassPlugin } from 'esbuild-sass-plugin'
|
||||||
|
import vuePlugin from 'esbuild-plugin-vue3'
|
||||||
|
|
||||||
|
const buildOpts = {
|
||||||
|
assetNames: '[name]-[hash]',
|
||||||
|
bundle: true,
|
||||||
|
define: {
|
||||||
|
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'dev'),
|
||||||
|
},
|
||||||
|
entryPoints: ['src/main.ts'],
|
||||||
|
legalComments: 'none',
|
||||||
|
loader: {
|
||||||
|
'.ttf': 'file',
|
||||||
|
'.woff2': 'file',
|
||||||
|
},
|
||||||
|
minify: true,
|
||||||
|
outfile: 'dist/app.js',
|
||||||
|
plugins: [
|
||||||
|
sassPlugin(),
|
||||||
|
vuePlugin(),
|
||||||
|
],
|
||||||
|
target: [
|
||||||
|
'chrome109',
|
||||||
|
'edge116',
|
||||||
|
'es2020',
|
||||||
|
'firefox115',
|
||||||
|
'safari15',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export { buildOpts }
|
||||||
|
|
||||||
|
esbuild.build(buildOpts)
|
22
dist/index.html
vendored
Normal file
22
dist/index.html
vendored
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en" data-bs-theme="dark">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
|
<title>Stadt-Land-Fluss</title>
|
||||||
|
|
||||||
|
<link href="app.css" rel="stylesheet">
|
||||||
|
|
||||||
|
<style>
|
||||||
|
[v-cloak] { display: none; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="app" v-cloak></div>
|
||||||
|
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
4597
package-lock.json
generated
Normal file
4597
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
20
package.json
Normal file
20
package.json
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"@fortawesome/fontawesome-free": "^6.6.0",
|
||||||
|
"bootstrap": "^5.3.3",
|
||||||
|
"vue": "^3.4.38"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/eslint-parser": "^7.25.1",
|
||||||
|
"@types/bootstrap": "^5.2.10",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
||||||
|
"@typescript-eslint/parser": "^7.18.0",
|
||||||
|
"@vue/tsconfig": "^0.5.1",
|
||||||
|
"esbuild": "^0.23.1",
|
||||||
|
"esbuild-plugin-vue3": "^0.4.2",
|
||||||
|
"esbuild-sass-plugin": "^3.3.1",
|
||||||
|
"eslint": "^8.57.0",
|
||||||
|
"eslint-plugin-vue": "^9.27.0",
|
||||||
|
"typescript": "^5.5.4"
|
||||||
|
}
|
||||||
|
}
|
379
src/app.vue
Normal file
379
src/app.vue
Normal file
|
@ -0,0 +1,379 @@
|
||||||
|
<template>
|
||||||
|
<div class="container my-3">
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col text-center">
|
||||||
|
<h1>Stadt-Land-Fluss</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-4 d-flex justify-content-center">
|
||||||
|
<div class="col-6 text-center">
|
||||||
|
<div class="input-group input-group-sm">
|
||||||
|
<span class="input-group-text">
|
||||||
|
<i class="fas fa-user fa-fw" />
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
v-model="name"
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
@keypress.enter="broadcastName"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="btn btn-success"
|
||||||
|
@click="broadcastName"
|
||||||
|
>
|
||||||
|
<i class="fas fa-pencil fa-fw me-1" />
|
||||||
|
Name ändern
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-warning"
|
||||||
|
@click="newGame"
|
||||||
|
>
|
||||||
|
<i class="fas fa-broom fa-fw me-1" />
|
||||||
|
Neues Spiel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="text-center">
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-success"
|
||||||
|
@click="addLetter"
|
||||||
|
>
|
||||||
|
<i class="fas fa-plus fa-fw" />
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
v-for="cat in gameState.categories"
|
||||||
|
:key="cat"
|
||||||
|
class="text-center align-content-center"
|
||||||
|
>
|
||||||
|
{{ cat }}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="letter in [...gameState.letters].reverse()"
|
||||||
|
:key="`letter_${letter}`"
|
||||||
|
>
|
||||||
|
<th class="text-center align-content-center">
|
||||||
|
{{ letter }}
|
||||||
|
</th>
|
||||||
|
<td
|
||||||
|
v-for="cat in gameState.categories"
|
||||||
|
:key="cat"
|
||||||
|
>
|
||||||
|
<template v-if="!hasOwnAnswer(cat, letter)">
|
||||||
|
<div class="input-group input-group-sm mt-2">
|
||||||
|
<input
|
||||||
|
v-model="localAnswers[generateKey(cat, letter)]"
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
@keypress.enter="answerFieldSubmit(cat, letter)"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-if="!localAnswers[generateKey(cat, letter)]"
|
||||||
|
class="btn btn-warning"
|
||||||
|
@click="answerFieldSubmit(cat, letter)"
|
||||||
|
>
|
||||||
|
<i class="fas fa-forward fa-fw" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
class="btn btn-success"
|
||||||
|
@click="answerFieldSubmit(cat, letter)"
|
||||||
|
>
|
||||||
|
<i class="fas fa-play fa-fw" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div
|
||||||
|
v-for="answer in answersGiven[generateKey(cat, letter)]"
|
||||||
|
:key="answer.name"
|
||||||
|
class="card mb-2 mt-2"
|
||||||
|
>
|
||||||
|
<div class="card-body">
|
||||||
|
<small class="answer-name">{{ answer.name }}</small>
|
||||||
|
<p
|
||||||
|
v-if="answer.answer"
|
||||||
|
class="my-0"
|
||||||
|
>
|
||||||
|
{{ answer.answer }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-else-if="answer.answer === undefined"
|
||||||
|
class="my-0 text-center"
|
||||||
|
>
|
||||||
|
<i class="fa-solid fa-circle-notch fa-spin" />
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-else
|
||||||
|
class="my-0 text-center"
|
||||||
|
>
|
||||||
|
<i class="fas fa-forward fa-fw" />
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { categories, gameSocketTemplate } from './config'
|
||||||
|
import { defineComponent } from 'vue'
|
||||||
|
|
||||||
|
const baseBackoff = 100 // ms
|
||||||
|
const maxBackoff = 10000 // ms
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
computed: {
|
||||||
|
answersGiven(): Object {
|
||||||
|
const keys: string[] = []
|
||||||
|
for (const cat of this.gameState.categories) {
|
||||||
|
for (const letter of this.gameState.letters) {
|
||||||
|
keys.push(this.generateKey(cat, letter))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.fromEntries(keys.map(key => [
|
||||||
|
key, Object.keys(this.knownInstances).map(instId => ({
|
||||||
|
answer: this.knownInstances[instId].answers[key],
|
||||||
|
name: this.knownInstances[instId].name,
|
||||||
|
})),
|
||||||
|
]))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
created(): void {
|
||||||
|
let instance = window.localStorage.getItem('io.luzifer.stadt-land-fluss.instance')
|
||||||
|
if (!instance) {
|
||||||
|
instance = window.crypto.randomUUID()
|
||||||
|
window.localStorage.setItem('io.luzifer.stadt-land-fluss.instance', instance)
|
||||||
|
}
|
||||||
|
this.instance = instance
|
||||||
|
this.name = window.localStorage.getItem('io.luzifer.stadt-land-fluss.name') || instance
|
||||||
|
|
||||||
|
this.knownInstances[this.instance] = {
|
||||||
|
answers: {},
|
||||||
|
name: this.name,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
data(): any {
|
||||||
|
return {
|
||||||
|
backoffCurrent: baseBackoff,
|
||||||
|
gameId: '',
|
||||||
|
gameSocket: null as WebSocket | null,
|
||||||
|
gameState: {
|
||||||
|
categories: [],
|
||||||
|
gameId: '',
|
||||||
|
letters: [],
|
||||||
|
version: 0,
|
||||||
|
},
|
||||||
|
|
||||||
|
instance: '',
|
||||||
|
knownInstances: {} as any,
|
||||||
|
localAnswers: {} as any,
|
||||||
|
name: '',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
addLetter(): void {
|
||||||
|
this.gameState = {
|
||||||
|
...this.gameState,
|
||||||
|
letters: [...this.gameState.letters, this.generateLetter()],
|
||||||
|
version: new Date().getTime(),
|
||||||
|
}
|
||||||
|
this.broadcastGameState()
|
||||||
|
},
|
||||||
|
|
||||||
|
answerFieldSubmit(category: string, letter: string): void {
|
||||||
|
this.sendAnswer(category, letter, this.localAnswers[this.generateKey(category, letter)] || '')
|
||||||
|
},
|
||||||
|
|
||||||
|
broadcastGameState(): void {
|
||||||
|
this.sendMessage({ state: this.gameState, type: 'state' })
|
||||||
|
},
|
||||||
|
|
||||||
|
broadcastName(): void {
|
||||||
|
window.localStorage.setItem('io.luzifer.stadt-land-fluss.name', this.name)
|
||||||
|
this.knownInstances[this.instance].name = this.name
|
||||||
|
this.sendPing()
|
||||||
|
},
|
||||||
|
|
||||||
|
connectToGame(): void {
|
||||||
|
if (this.gameSocket) {
|
||||||
|
this.gameSocket.close()
|
||||||
|
this.gameSocket = null
|
||||||
|
}
|
||||||
|
|
||||||
|
this.gameSocket = new WebSocket(gameSocketTemplate.replace('{gameId}', this.gameId))
|
||||||
|
|
||||||
|
this.gameSocket.addEventListener('close', () => {
|
||||||
|
this.backoffCurrent = Math.min(maxBackoff, this.backoffCurrent * 1.5)
|
||||||
|
window.setTimeout(() => this.connectToGame(), this.backoffCurrent)
|
||||||
|
})
|
||||||
|
|
||||||
|
this.gameSocket.addEventListener('open', () => {
|
||||||
|
this.sendMessage({ type: 'ohai' })
|
||||||
|
})
|
||||||
|
|
||||||
|
this.gameSocket.addEventListener('message', (evt: MessageEvent) => this.handleMessage(evt))
|
||||||
|
},
|
||||||
|
|
||||||
|
generateKey(category: string, letter: string): string {
|
||||||
|
return [
|
||||||
|
category.toLocaleLowerCase().replace(/[^a-z0-9]/g, '-'),
|
||||||
|
letter.toLocaleLowerCase(),
|
||||||
|
].join('::')
|
||||||
|
},
|
||||||
|
|
||||||
|
generateLetter(): string {
|
||||||
|
const letterSet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')
|
||||||
|
.filter(l => !this.gameState.letters.includes(l))
|
||||||
|
|
||||||
|
if (letterSet.length === 0) {
|
||||||
|
throw new Error('no more letter available')
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.shuffle(letterSet)[0]
|
||||||
|
},
|
||||||
|
|
||||||
|
handleMessage(evt: MessageEvent): void {
|
||||||
|
const data: any = JSON.parse(evt.data)
|
||||||
|
|
||||||
|
switch (data.type) {
|
||||||
|
case 'answer':
|
||||||
|
this.knownInstances[data.instance].answers[data.key] = data.answer
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'ohai':
|
||||||
|
this.sendPing()
|
||||||
|
this.broadcastGameState()
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'ping':
|
||||||
|
this.backoffCurrent = baseBackoff
|
||||||
|
this.knownInstances[data.instance] = data.instanceState
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'state':
|
||||||
|
if (data.state.version < this.gameState.version) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.gameState = data.state
|
||||||
|
window.localStorage.setItem('io.luzifer.stadt-land-fluss.state', JSON.stringify(this.gameState))
|
||||||
|
break
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.error(`received unhandled message: ${data.type}`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
hasOwnAnswer(category: string, letter: string): boolean {
|
||||||
|
return this.knownInstances[this.instance]
|
||||||
|
?.answers[this.generateKey(category, letter)] !== undefined
|
||||||
|
},
|
||||||
|
|
||||||
|
newGame(): void {
|
||||||
|
window.location.href = window.location.href.split('#')[0]
|
||||||
|
},
|
||||||
|
|
||||||
|
sendAnswer(category: string, letter: string, answer: string): void {
|
||||||
|
this.knownInstances[this.instance].answers[this.generateKey(category, letter)] = answer
|
||||||
|
this.sendPing()
|
||||||
|
},
|
||||||
|
|
||||||
|
sendMessage(data: any): void {
|
||||||
|
if (!this.gameSocket) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.gameSocket.send(JSON.stringify({
|
||||||
|
...data,
|
||||||
|
instance: this.instance,
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|
||||||
|
sendPing(): void {
|
||||||
|
this.sendMessage({ instanceState: this.knownInstances[this.instance], type: 'ping' })
|
||||||
|
},
|
||||||
|
|
||||||
|
shuffle(list: Array<any>): Array<any> {
|
||||||
|
let currentIndex = list.length
|
||||||
|
|
||||||
|
// While there remain elements to shuffle...
|
||||||
|
while (currentIndex !== 0) {
|
||||||
|
// Pick a remaining element...
|
||||||
|
const randomIndex = Math.floor(Math.random() * currentIndex)
|
||||||
|
currentIndex--;
|
||||||
|
|
||||||
|
// And swap it with the current element.
|
||||||
|
[list[currentIndex], list[randomIndex]] = [list[randomIndex], list[currentIndex]]
|
||||||
|
}
|
||||||
|
|
||||||
|
return list
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
const gameInfo: string = window.location.hash.substring(1)
|
||||||
|
|
||||||
|
// No game-id found, redirect to new game
|
||||||
|
if (gameInfo === '') {
|
||||||
|
const appBase: string = window.location.href.split('#')[0]
|
||||||
|
window.location.href = `${appBase}#${window.crypto.randomUUID()}:${this.instance}`
|
||||||
|
window.location.reload()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const gameId: string = gameInfo.split(':')[0]
|
||||||
|
const creator: string = gameInfo.split(':')[1]
|
||||||
|
|
||||||
|
const state: any = JSON.parse(window.localStorage.getItem('io.luzifer.stadt-land-fluss.state') || '{}')
|
||||||
|
|
||||||
|
if (state.gameId === gameId) {
|
||||||
|
this.gameState = state
|
||||||
|
} else if (this.instance === creator) {
|
||||||
|
this.gameState = {
|
||||||
|
categories: this.shuffle([...categories]).slice(0, 5),
|
||||||
|
gameId,
|
||||||
|
letters: [this.generateLetter()],
|
||||||
|
version: new Date().getTime(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Game-ID found, register on socket
|
||||||
|
this.gameId = gameId
|
||||||
|
this.connectToGame()
|
||||||
|
|
||||||
|
window.setInterval(() => this.sendPing(), 10000)
|
||||||
|
},
|
||||||
|
|
||||||
|
name: 'StadtLandFlussApp',
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.answer-name {
|
||||||
|
position: absolute;
|
||||||
|
top: -0.6rem;
|
||||||
|
right: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
40
src/config.ts
Normal file
40
src/config.ts
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
const categories: string[] = [
|
||||||
|
'Auf dem Rummel',
|
||||||
|
'Auf der Pizza',
|
||||||
|
'Baumarktartikel',
|
||||||
|
'Buchtitel',
|
||||||
|
'Charaktereigenschaft',
|
||||||
|
'Dinge, die blau sind',
|
||||||
|
'Dinge, die sich drehen',
|
||||||
|
'Erfindung',
|
||||||
|
'Etwas, das wächst',
|
||||||
|
'Fiktiver Charakter',
|
||||||
|
'Frauenthemen',
|
||||||
|
'Getränk',
|
||||||
|
'Gewässer',
|
||||||
|
'Himmelskörper',
|
||||||
|
'In der Kirche',
|
||||||
|
'In der Schule',
|
||||||
|
'Krankheit',
|
||||||
|
'Land',
|
||||||
|
'Lebender Promi',
|
||||||
|
'Luxusgut',
|
||||||
|
'Männerthemen',
|
||||||
|
'Musiker',
|
||||||
|
'Name mit 5 Buchstaben',
|
||||||
|
'Scheidungsgrund',
|
||||||
|
'Spielname',
|
||||||
|
'Stadt',
|
||||||
|
'Straftat',
|
||||||
|
'Studienfach',
|
||||||
|
'Todesursache',
|
||||||
|
'Typisch deutsch',
|
||||||
|
'Wort mit 8 Buchstaben',
|
||||||
|
]
|
||||||
|
|
||||||
|
const gameSocketTemplate: string = 'wss://tools.hub.luzifer.io/ws/slf-{gameId}'
|
||||||
|
|
||||||
|
export {
|
||||||
|
categories,
|
||||||
|
gameSocketTemplate,
|
||||||
|
}
|
16
src/main.ts
Normal file
16
src/main.ts
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import 'bootstrap/dist/css/bootstrap.css' // Bootstrap 5 Styles
|
||||||
|
import '@fortawesome/fontawesome-free/css/all.css' // All FA free icons
|
||||||
|
|
||||||
|
import { createApp, h } from 'vue'
|
||||||
|
|
||||||
|
import App from './app.vue'
|
||||||
|
|
||||||
|
const app = createApp({
|
||||||
|
name: 'StadtLandFluss',
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return h(App)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
app.mount('#app')
|
Loading…
Reference in a new issue