commit ae741012345c5f72db19c2b43705f8cf811cc7cd Author: Felix Förtsch Date: Wed Feb 18 10:50:24 2026 +0100 snapshot current state before gitea sync diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..9abbf4d Binary files /dev/null and b/.DS_Store differ diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..93122a9 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,13 @@ +{ + "permissions": { + "allow": [ + "Bash(ssh:*)", + "Bash(sips:*)", + "Bash(python3:*)", + "WebFetch(domain:manual.uberspace.de)", + "WebFetch(domain:lab.uberspace.de)", + "Bash(curl:*)", + "Bash(rsync:*)" + ] + } +} diff --git a/app.js b/app.js new file mode 100644 index 0000000..b8f362f --- /dev/null +++ b/app.js @@ -0,0 +1,250 @@ +const words = [ + "Kekse", + "Schach", + "Giraffe", + "Spaghetti", + "Rakete", + "Blume", + "Kino", + "Drachen", + "Buch", + "Kaffee", + "Berg", + "Pizza", + "Wolke", + "Bus", + "Zitrone", + "Gitarre", + "Meer", + "Bananen", + "Loewe", + "Regen", +]; + +const state = { + players: [], + currentIndex: 0, + imposterIndex: -1, + secretWord: "", + revealed: false, + discussionIndex: 0, +}; + +const elements = { + playerInput: document.querySelector("#player-input"), + addPlayer: document.querySelector("#add-player"), + playerList: document.querySelector("#player-list"), + startGame: document.querySelector("#start-game"), + clearPlayers: document.querySelector("#clear-players"), + setupHint: document.querySelector("#setup-hint"), + setup: document.querySelector("#setup"), + round: document.querySelector("#round"), + discussion: document.querySelector("#discussion"), + done: document.querySelector("#done"), + roundCount: document.querySelector("#round-count"), + restart: document.querySelector("#restart"), + currentPlayer: document.querySelector("#current-player"), + roundScreen: document.querySelector("#round-screen"), + handoff: document.querySelector("#handoff"), + revealCard: document.querySelector("#reveal-card"), + revealWord: document.querySelector("#reveal-word"), + discussionCount: document.querySelector("#discussion-count"), + discussionPlayer: document.querySelector("#discussion-player"), + discussionNext: document.querySelector("#discussion-next"), + discussionRestart: document.querySelector("#discussion-restart"), + imposterFound: document.querySelector("#imposter-found"), + playAgain: document.querySelector("#play-again"), + editPlayers: document.querySelector("#edit-players"), +}; + +const MIN_PLAYERS = 3; +const STORAGE_KEY = "imposterPlayers"; + +const updatePlayerList = () => { + elements.playerList.innerHTML = ""; + state.players.forEach((player, index) => { + const item = document.createElement("li"); + const name = document.createElement("span"); + name.textContent = player; + const remove = document.createElement("button"); + remove.type = "button"; + remove.textContent = "Entfernen"; + remove.addEventListener("click", () => { + state.players.splice(index, 1); + updatePlayerList(); + }); + item.append(name, remove); + elements.playerList.appendChild(item); + }); + + const ready = state.players.length >= MIN_PLAYERS; + elements.startGame.disabled = !ready; + elements.setupHint.textContent = ready + ? `${state.players.length} Spieler bereit.` + : `Mindestens ${MIN_PLAYERS} Spieler.`; + localStorage.setItem(STORAGE_KEY, JSON.stringify(state.players)); +}; + +const addPlayer = () => { + const raw = elements.playerInput.value.trim(); + if (!raw) { + return; + } + state.players.push(raw); + elements.playerInput.value = ""; + updatePlayerList(); + elements.playerInput.focus(); +}; + +const startRound = () => { + state.currentIndex = 0; + state.imposterIndex = Math.floor(Math.random() * state.players.length); + state.secretWord = words[Math.floor(Math.random() * words.length)]; + state.revealed = false; + elements.revealCard.classList.add("flip-card--instant"); + elements.setup.classList.add("hidden"); + elements.discussion.classList.add("hidden"); + elements.done.classList.add("hidden"); + elements.round.classList.remove("hidden"); + updateRoundView(); + requestAnimationFrame(() => { + elements.revealCard.classList.remove("flip-card--instant"); + }); +}; + +const updateRoundView = () => { + const total = state.players.length; + const current = state.currentIndex + 1; + elements.roundCount.textContent = `Phase 1: Zuweisung \u2014 Spieler ${current} / ${total}`; + elements.currentPlayer.textContent = state.players[state.currentIndex]; + elements.roundScreen.classList.remove("hidden"); + const isImposter = state.currentIndex === state.imposterIndex; + elements.revealWord.textContent = isImposter ? "IMPOSTER" : state.secretWord; + elements.revealCard.classList.remove("flipped"); + state.revealed = false; +}; + +const toggleReveal = () => { + elements.revealCard.classList.toggle("flipped"); + state.revealed = !state.revealed; +}; + +const handoffPlayer = () => { + elements.revealCard.classList.add("flip-card--instant"); + elements.revealCard.classList.remove("flipped"); + state.currentIndex += 1; + if (state.currentIndex >= state.players.length) { + startDiscussion(); + return; + } + updateRoundView(); + requestAnimationFrame(() => { + elements.revealCard.classList.remove("flip-card--instant"); + }); +}; + +const startDiscussion = () => { + state.discussionIndex = 0; + elements.round.classList.add("hidden"); + elements.done.classList.add("hidden"); + elements.discussion.classList.remove("hidden"); + updateDiscussionView(); +}; + +const updateDiscussionView = () => { + const total = state.players.length; + const current = state.discussionIndex + 1; + elements.discussionCount.textContent = `Phase 2: Runde \u2014 Spieler ${current} / ${total}`; + elements.discussionPlayer.textContent = state.players[state.discussionIndex]; +}; + +const nextDiscussion = () => { + if (state.players.length === 0) { + return; + } + state.discussionIndex = (state.discussionIndex + 1) % state.players.length; + updateDiscussionView(); +}; + +const endDiscussion = () => { + elements.discussion.classList.add("hidden"); + elements.done.classList.remove("hidden"); +}; + +const resetToSetup = () => { + elements.round.classList.add("hidden"); + elements.discussion.classList.add("hidden"); + elements.done.classList.add("hidden"); + elements.setup.classList.remove("hidden"); +}; + +const clearPlayers = () => { + state.players = []; + updatePlayerList(); +}; + +const loadPlayers = () => { + const saved = localStorage.getItem(STORAGE_KEY); + if (!saved) { + return; + } + try { + const parsed = JSON.parse(saved); + if (Array.isArray(parsed)) { + state.players = parsed.filter((name) => typeof name === "string"); + } + } catch (error) { + localStorage.removeItem(STORAGE_KEY); + } +}; + +elements.addPlayer.addEventListener("click", addPlayer); +elements.playerInput.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + addPlayer(); + } +}); + +elements.clearPlayers.addEventListener("click", clearPlayers); +elements.startGame.addEventListener("click", startRound); +elements.handoff.addEventListener("click", handoffPlayer); +elements.revealCard.addEventListener("click", toggleReveal); +elements.discussionNext.addEventListener("click", nextDiscussion); +elements.discussionRestart.addEventListener("click", startRound); +elements.imposterFound.addEventListener("click", endDiscussion); +elements.restart.addEventListener("click", startRound); +elements.playAgain.addEventListener("click", startRound); +elements.editPlayers.addEventListener("click", resetToSetup); + +loadPlayers(); +updatePlayerList(); + +if ("serviceWorker" in navigator) { + window.addEventListener("load", async () => { + const reg = await navigator.serviceWorker.register("./sw.js"); + + const showUpdate = () => { + document.querySelector("#update-banner").classList.remove("hidden"); + document.querySelector("#update-btn").addEventListener("click", () => { + reg.waiting.postMessage("SKIP_WAITING"); + }); + }; + + if (reg.waiting) { + showUpdate(); + } + + reg.addEventListener("updatefound", () => { + const newSW = reg.installing; + newSW.addEventListener("statechange", () => { + if (newSW.state === "installed" && navigator.serviceWorker.controller) { + showUpdate(); + } + }); + }); + + navigator.serviceWorker.addEventListener("controllerchange", () => { + window.location.reload(); + }); + }); +} diff --git a/apple-touch-icon.png b/apple-touch-icon.png new file mode 100644 index 0000000..c76b857 Binary files /dev/null and b/apple-touch-icon.png differ diff --git a/icon-192.png b/icon-192.png new file mode 100644 index 0000000..44b6539 Binary files /dev/null and b/icon-192.png differ diff --git a/icon-512.png b/icon-512.png new file mode 100644 index 0000000..eeafb64 Binary files /dev/null and b/icon-512.png differ diff --git a/icon.svg b/icon.svg new file mode 100644 index 0000000..85799b5 --- /dev/null +++ b/icon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/index.html b/index.html new file mode 100644 index 0000000..9f8bf3a --- /dev/null +++ b/index.html @@ -0,0 +1,145 @@ + + + + + + + + Imposter + + + + + + + + +
+
+
+
Handy weiterreichen
+

Imposter

+

+ Trage Spieler ein, starte die Runde, und zeige jedem sein + Wort oder "IMPOSTER". Danach findet ihr heraus, wer das Wort + nicht kennt. +

+
+
+

Spieler

+
+ + +
+
    +
    + + +
    +

    Mindestens 3 Spieler.

    +
    +
    + + + + + + +
    + + + + + + diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..9c66184 --- /dev/null +++ b/manifest.json @@ -0,0 +1,22 @@ +{ + "name": "Imposter", + "short_name": "Imposter", + "start_url": ".", + "display": "standalone", + "background_color": "#f8f3e7", + "theme_color": "#f8f3e7", + "icons": [ + { + "src": "icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + } + ] +} diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..de5f5df --- /dev/null +++ b/styles.css @@ -0,0 +1,371 @@ +:root { + color-scheme: light; + --bg: #f8f3e7; + --bg-alt: #f0e6d1; + --ink: #1a1a1a; + --accent: #ff7b54; + --accent-dark: #d95c38; + --muted: #5b5b5b; + --card: #fffaf0; + --shadow: 0 20px 45px rgba(26, 26, 26, 0.12); +} + +html { + background-color: var(--bg); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: "Space Grotesk", "Helvetica Neue", Arial, sans-serif; + font-size: 16px; + color: var(--ink); + background-color: var(--bg); + background: radial-gradient(circle at top, #fff4dc, var(--bg)); + min-height: 100vh; + display: flex; + flex-direction: column; + align-items: center; +} + +.app { + width: 100%; +} + +.screen { + min-height: 100vh; + min-height: 100dvh; + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 24px; + gap: 24px; +} + +.screen > .card { + width: min(560px, 100%); +} + +#round > .card, +#discussion > .card, +#done > .card { + min-height: 480px; + display: flex; + flex-direction: column; +} + +#setup { + justify-content: flex-start; + padding-top: 40px; +} + +.hero { + display: grid; + gap: 12px; + width: min(560px, 100%); +} + +.hero__tag { + display: inline-flex; + align-items: center; + justify-self: start; + padding: 6px 12px; + border-radius: 999px; + background: var(--accent); + color: #fffaf0; + font-weight: 600; + letter-spacing: 0.02em; +} + +h1, +h2 { + font-family: "Unbounded", "Trebuchet MS", sans-serif; + margin: 0; + line-height: 1.08; +} + +h1 { + font-size: clamp(2.2rem, 5vw, 3.2rem); +} + +h2 { + font-size: clamp(1.5rem, 3.5vw, 2rem); +} + +p { + margin: 0; + color: var(--muted); + line-height: 1.5; + font-size: 1rem; +} + +.card { + background: var(--card); + border-radius: 24px; + padding: 24px; + box-shadow: var(--shadow); + border: 1px solid rgba(26, 26, 26, 0.08); +} + +.input-row { + display: grid; + grid-template-columns: 1fr auto; + gap: 12px; + margin-top: 16px; +} + +input { + padding: 12px 14px; + border-radius: 12px; + border: 1px solid rgba(26, 26, 26, 0.2); + font-size: 1rem; + background: #fff; +} + +.btn { + border: none; + padding: 14px 20px; + border-radius: 999px; + font-weight: 600; + cursor: pointer; + background: var(--bg-alt); + color: var(--ink); + transition: box-shadow 0.2s ease; + font-family: "Space Grotesk", sans-serif; + min-width: 0; + min-height: 52px; +} + +.btn:hover { + box-shadow: 0 10px 20px rgba(26, 26, 26, 0.15); +} + +.btn--primary { + background: var(--accent); + color: #fffaf0; +} + +.btn--ghost { + background: transparent; + border: 1px dashed rgba(26, 26, 26, 0.25); +} + +.btn--big { + padding: 14px 20px; + font-size: 1rem; +} + +.player-list { + list-style: none; + padding: 0; + margin: 16px 0 0; + display: grid; + gap: 10px; +} + +.player-list li { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + border-radius: 12px; + background: #fff; + border: 1px solid rgba(26, 26, 26, 0.08); +} + +.player-list button { + border: none; + background: transparent; + color: var(--accent-dark); + font-weight: 600; + cursor: pointer; +} + +.actions { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + width: 100%; + margin-top: 20px; + flex-wrap: wrap; +} + +.actions--end { + justify-content: flex-end; +} + +.round-header { + display: flex; + gap: 12px; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; +} + +.hint { + margin-top: 12px; + font-size: 0.95rem; +} + +.round-count { + font-weight: 600; +} + +.pass-screen { + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + margin-top: 16px; + text-align: center; + flex: 1; +} + +.pass-screen .actions { + margin-top: auto; +} + +.player-chip { + padding: 10px 20px; + border-radius: 999px; + background: var(--bg-alt); + font-weight: 600; + font-size: clamp(1.2rem, 3.8vw, 1.8rem); + text-align: center; +} + +.player-chip--large { + font-size: clamp(1.8rem, 5vw, 2.8rem); + letter-spacing: 0.01em; +} + +.flip-card { + perspective: 600px; + width: min(320px, 80vw); + height: 180px; + cursor: pointer; + -webkit-tap-highlight-color: transparent; +} + +.flip-card__inner { + position: relative; + width: 100%; + height: 100%; + transition: transform 0.5s ease; + transform-style: preserve-3d; +} + +.flip-card.flipped .flip-card__inner { + transform: rotateY(180deg); +} + +.flip-card--instant .flip-card__inner { + transition: none; +} + +.flip-card__front, +.flip-card__back { + position: absolute; + inset: 0; + backface-visibility: hidden; + -webkit-backface-visibility: hidden; + border-radius: 18px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 32px; + background: linear-gradient(135deg, #fff4dc, #ffe2c0); + box-shadow: inset 0 0 0 1px rgba(26, 26, 26, 0.08); +} + +.flip-card__back { + transform: rotateY(180deg); +} + +.reveal-label { + font-size: 0.95rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--muted); +} + +.reveal-word { + font-family: "Unbounded", sans-serif; + font-size: clamp(1.8rem, 6vw, 3rem); + margin-top: 10px; + text-align: center; +} + +.footer { + padding: 16px 0 32px; + color: var(--muted); + font-size: 0.9rem; +} + +.hidden { + display: none !important; +} + +@media (max-width: 640px) { + .input-row { + grid-template-columns: 1fr; + } + + .actions { + flex-direction: column-reverse; + align-items: stretch; + } + + .round-header { + flex-direction: column; + align-items: stretch; + } +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.card { + animation: fadeIn 0.35s ease; +} + + +.pass-screen .btn { + min-width: 180px; +} + +.update-banner { + position: fixed; + bottom: 0; + left: 0; + right: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 16px; + padding: 16px 24px; + background: var(--ink); + color: #fffaf0; + font-weight: 600; + z-index: 100; +} + +.update-banner .btn { + min-height: 40px; + padding: 8px 20px; +} diff --git a/sw.js b/sw.js new file mode 100644 index 0000000..c1535c5 --- /dev/null +++ b/sw.js @@ -0,0 +1,45 @@ +const CACHE_NAME = "imposter-static-v4"; +const ASSETS = [ + "./", + "./index.html", + "./styles.css", + "./app.js", + "./manifest.json", + "./icon-192.png", + "./icon-512.png", + "./apple-touch-icon.png", +]; + +self.addEventListener("install", (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => cache.addAll(ASSETS)), + ); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches + .keys() + .then((keys) => + Promise.all( + keys + .filter((key) => key !== CACHE_NAME) + .map((key) => caches.delete(key)), + ), + ), + ); +}); + +self.addEventListener("message", (event) => { + if (event.data === "SKIP_WAITING") { + self.skipWaiting(); + } +}); + +self.addEventListener("fetch", (event) => { + event.respondWith( + caches + .match(event.request) + .then((response) => response || fetch(event.request)), + ); +});