Initial version

This commit is contained in:
Knut Ahlers 2024-08-19 22:20:23 +02:00
commit fba48badf6
Signed by: luzifer
SSH Key Fingerprint: SHA256:/xtE5lCgiRDQr8SLxHMS92ZBlACmATUmF1crK16Ks4E
10 changed files with 5282 additions and 0 deletions

154
.eslintrc.js Normal file
View 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
View File

@ -0,0 +1,4 @@
dist/app.*
dist/*.ttf
dist/*.woff2
node_modules

16
Makefile Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

20
package.json Normal file
View 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
View 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
View 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
View 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')