Storskærm og telefon-UI, 147 danske spørgsmål og drift

Vanilla JS uden build-trin: én WebSocket pr. skærm med automatisk
genforbindelse, tegner altid fra seneste snapshot. Storskærmen viser kode,
QR, nedtælling og en animeret afsløring; telefonen er bygget til store
knapper og genoptager sin plads efter genindlæsning. dev.html viser alle
faser uden server. deploy/ har systemd-unit, deploy-script og en bot-test,
der spiller et helt spil mod en kørende server.

Claude-Session: https://claude.ai/code/session_01JmhJG6JnhXfzNiKv8f2vHu
This commit is contained in:
agw
2026-10-01 20:25:49 +02:00
parent 9a53d8fe04
commit 10652ee219
16 changed files with 3299 additions and 133 deletions
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env python3
"""Spil et helt spil med bots mod en kørende server (røgtest).
Kør i CT143 (eller lokalt), hvor værts-headeren stoles på fra 127.0.0.1:
/opt/wpp/venv/bin/python deploy/bots.py --base http://127.0.0.1:8000 --players 3 --audience 1
Opretter et rum som vært, joiner N spillere og M publikummer, og lader dem lyve,
gætte og like tilfældigt, indtil spillet er slut. Afslutter med kode 0, hvis alle
faser blev nået og slutstillingen blev vist.
"""
import argparse
import asyncio
import http.cookiejar
import json
import random
import re
import sys
import urllib.parse
import urllib.request
import websockets
HOST_HEADERS = {"X-authentik-username": "bot-host", "X-authentik-groups": "Familie"}
def create_room(base: str) -> str:
jar = http.cookiejar.CookieJar()
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
req = urllib.request.Request(f"{base}/host/", headers=HOST_HEADERS)
html = opener.open(req).read().decode()
token = re.search(r'name="csrfmiddlewaretoken" value="([^"]+)"', html).group(1)
data = urllib.parse.urlencode({"csrfmiddlewaretoken": token}).encode()
req = urllib.request.Request(f"{base}/host/new", data=data, headers={**HOST_HEADERS, "Referer": f"{base}/host/"})
resp = opener.open(req)
return resp.geturl().rstrip("/").rsplit("/", 1)[-1]
async def host(ws_base, code, seen: list, done: asyncio.Event):
async with websockets.connect(f"{ws_base}/ws/host/{code}/", additional_headers=HOST_HEADERS) as ws:
async for raw in ws:
msg = json.loads(raw)
if msg["type"] != "state":
continue
st = msg["state"]
if not seen or seen[-1] != st["phase"]:
seen.append(st["phase"])
extra = st.get("question", {}).get("prompt", "") if st["phase"] == "lie" else ""
print(f"[vært] {st['phase']:8} {extra}")
if st["phase"] == "final":
for r in st["final"]["ranking"]:
name = next(p["name"] for p in st["players"] if p["id"] == r["player_id"])
print(f" {r['place']}. {name} {r['score']}")
for a in st["final"]["awards"]:
print(f" {a['title']}: {a['detail']}")
done.set()
return
async def player(ws_base, code, name, n_players, done: asyncio.Event, rng: random.Random):
async with websockets.connect(f"{ws_base}/ws/play/{code}/") as ws:
await ws.send(json.dumps({"action": "join", "name": name}))
acted = set()
while not done.is_set():
try:
raw = await asyncio.wait_for(ws.recv(), timeout=1)
except asyncio.TimeoutError:
continue
msg = json.loads(raw)
if msg["type"] == "error":
print(f"[{name}] fejl: {msg['code']} {msg['message']}")
continue
if msg["type"] != "state":
continue
st, me = msg["state"], msg["state"]["me"]
key = (st["phase"], (st.get("round") or {}).get("number"), (st.get("round") or {}).get("question_number"))
await asyncio.sleep(rng.uniform(0.05, 0.4))
if st["phase"] == "lobby" and me["vip"] and len(st["players"]) >= n_players and key not in acted:
acted.add(key)
await ws.send(json.dumps({"action": "start"}))
elif st["phase"] == "category" and me["is_chooser"] and key not in acted:
acted.add(key)
await ws.send(json.dumps({"action": "choose_category", "category_id": st["category"]["options"][0]["id"]}))
elif st["phase"] == "lie" and me["role"] == "player" and not me["lie"] and key not in acted:
acted.add(key)
if rng.random() < 0.3:
await ws.send(json.dumps({"action": "lie_for_me"}))
sug = json.loads(await ws.recv())
text = sug.get("text") if sug.get("type") == "suggestion" else None
else:
text = None
await ws.send(json.dumps({"action": "submit_lie", "text": text or f"{name}s løgn {rng.randint(1, 999)}"}))
elif st["phase"] == "guess" and not me["guess"] and key not in acted:
acted.add(key)
answer = rng.choice(st["answers"])
await ws.send(json.dumps({"action": "guess", "answer_id": answer["id"]}))
others = [a for a in st["answers"] if a["id"] != answer["id"] and a["id"] != me.get("own_answer_id")]
if others and rng.random() < 0.5:
await ws.send(json.dumps({"action": "like", "answer_id": rng.choice(others)["id"]}))
async def main():
ap = argparse.ArgumentParser()
ap.add_argument("--base", default="http://127.0.0.1:8000")
ap.add_argument("--players", type=int, default=3)
ap.add_argument("--audience", type=int, default=1)
ap.add_argument("--seed", type=int, default=None)
args = ap.parse_args()
rng = random.Random(args.seed)
code = create_room(args.base)
print(f"Rum {code}")
ws_base = args.base.replace("http", "ws", 1)
seen, done = [], asyncio.Event()
names = ["Asger", "Sus", "Jørn", "Mette", "Kaj", "Bodil", "Ib", "Lis"][: args.players]
tasks = [asyncio.create_task(host(ws_base, code, seen, done))]
await asyncio.sleep(0.2)
for n in names:
tasks.append(asyncio.create_task(player(ws_base, code, n, args.players, done, rng)))
await asyncio.sleep(0.1)
# Publikum joiner, når spillet er i gang (i lobbyen ville de blive spillere).
for _ in range(200):
if "category" in seen:
break
await asyncio.sleep(0.1)
for i in range(args.audience):
tasks.append(asyncio.create_task(player(ws_base, code, f"Publikum{i + 1}", args.players, done, rng)))
try:
await asyncio.wait_for(done.wait(), timeout=600)
except asyncio.TimeoutError:
print("Spillet blev ikke færdigt inden for 10 minutter", file=sys.stderr)
return 1
for t in tasks:
t.cancel()
needed = {"lobby", "category", "lie", "guess", "reveal", "scores", "final"}
missing = needed - set(seen)
if missing:
print(f"Faser der aldrig blev nået: {missing}", file=sys.stderr)
return 1
print("OK: helt spil gennemført")
return 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))
File diff suppressed because it is too large Load Diff
-50
View File
@@ -1,20 +1,4 @@
[
{
"category": "Danmark",
"prompt": "Navnet LEGO er dannet af de to danske ord _____.",
"answer": "leg godt",
"alternatives": [
"leg godt",
"\"leg godt\"",
"leggodt"
],
"decoys": [
"lille gode",
"lad os lege",
"let og god"
],
"note": "Ole Kirk Christiansen dannede i 1934 navnet LEGO af \"LEg GOdt\"."
},
{
"category": "Danmark",
"prompt": "Den Lille Havfrues krop er modelleret efter billedhuggerens _____.",
@@ -160,23 +144,6 @@
],
"note": "Præsten Rasmus Malling-Hansens skrivekugle var en af de første kommercielle skrivemaskiner; Nietzsche brugte en i 1882."
},
{
"category": "Danmark",
"prompt": "Den trådløse teknologi Bluetooth er opkaldt efter _____.",
"answer": "Harald Blåtand",
"alternatives": [
"harald blåtand",
"harald bluetooth",
"harald 1.",
"kong harald"
],
"decoys": [
"Gorm den Gamle",
"Svend Tveskæg",
"Knud den Store"
],
"note": "Navnet hylder vikingekongen der samlede Danmark, ligesom teknologien skulle samle enheder; logoet er hans runeinitialer."
},
{
"category": "Danmark",
"prompt": "Dyrehavsbakken, der regnes for verdens ældste forlystelsespark, går tilbage til år _____.",
@@ -1057,23 +1024,6 @@
],
"note": "Aleuterne krydser 180. længdegrad, så Alaskas yderste øer ligger på østlig længde."
},
{
"category": "Geografi",
"prompt": "Verdens største ørken er _____.",
"answer": "Antarktis",
"alternatives": [
"antarktis",
"antarktika",
"sydpolen",
"antarktisk ørken"
],
"decoys": [
"Gobi",
"Arabiske Ørken",
"Kalahari"
],
"note": "En ørken defineres ved lav nedbør; Antarktis er ca. 14 mio. km², større end Sahara."
},
{
"category": "Geografi",
"prompt": "Det sted i havet, der ligger længst fra land, kaldes Point _____.",
+322
View File
@@ -0,0 +1,322 @@
/* Fup og Fakta – fælles hjælpere (plain script, eksporterer window.WPP). */
(function () {
'use strict';
var WPP = (window.WPP = window.WPP || {});
/* ---------- tekst ---------- */
var ESC = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
WPP.esc = function (s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { return ESC[c]; });
};
/* "Asger, Bo og Sus" – input skal allerede være escaped HTML */
WPP.joinNames = function (arr) {
arr = (arr || []).filter(Boolean);
if (arr.length <= 1) return arr.join('');
return arr.slice(0, -1).join(', ') + ' og ' + arr[arr.length - 1];
};
/* Spørgsmål med pænt stylet blank. fill = tekst der indsættes (sandheden) */
WPP.promptHtml = function (prompt, fill) {
var html = WPP.esc(prompt);
var inner = fill ? '<span class="blank filled">' + WPP.esc(fill) + '</span>' : '<span class="blank"></span>';
if (/_{3,}/.test(html)) return html.replace(/_{3,}/, inner);
return html;
};
WPP.fmt = function (n) {
n = Math.round(Number(n) || 0);
return String(Math.abs(n)).replace(/\B(?=(\d{3})+(?!\d))/g, '.').replace(/^/, n < 0 ? '−' : '');
};
WPP.signed = function (n) { n = Number(n) || 0; return (n >= 0 ? '+' : '−') + WPP.fmt(Math.abs(n)); };
WPP.playerMap = function (state) {
var m = {};
((state && state.players) || []).forEach(function (p) { m[p.id] = p; });
return m;
};
WPP.chip = function (p, extra) {
if (!p) return '';
return '<span class="chip ' + (extra || '') + '" style="--pc:' + WPP.esc(p.color || '#888') + '">' +
'<span class="dot"></span>' + WPP.esc(p.name) + '</span>';
};
WPP.roundTitle = function (round) {
if (!round) return '';
var name = WPP.esc(round.name || ('Runde ' + (round.number || '')));
var m = round.multiplier || 1;
if (m === 2) return name + ' – dobbelt point!';
if (m === 3) return name + ' – tredobbelt point!';
if (m > 3) return name + ' – ×' + m + ' point!';
return name;
};
/* ---------- lokal lagring (kan kaste i privat-vindue) ---------- */
WPP.ls = {
get: function (k) { try { return localStorage.getItem(k); } catch (e) { return null; } },
set: function (k, v) { try { localStorage.setItem(k, v); } catch (e) { /* ignorér */ } },
del: function (k) { try { localStorage.removeItem(k); } catch (e) { /* ignorér */ } }
};
/* ---------- server-ur ---------- */
var offset = 0;
WPP.setServerTime = function (t) { if (typeof t === 'number' && isFinite(t)) offset = t - Date.now(); };
WPP.now = function () { return Date.now() + offset; };
WPP.secondsLeft = function (deadline) {
if (!deadline) return null;
return Math.max(0, Math.ceil((deadline - WPP.now()) / 1000));
};
var KNOWN = { category: 15000, lie: 60000, guess: 30000, scores: 10000 };
var seenTotals = {};
WPP.totalFor = function (phase, deadline) {
if (!deadline) return 0;
var left = deadline - WPP.now();
if (KNOWN[phase]) return Math.max(KNOWN[phase], left);
var k = phase + ':' + deadline;
if (!seenTotals[k]) seenTotals[k] = Math.max(1000, left);
return seenTotals[k];
};
/* Nedtællings-markup. Opdateres af den globale ticker. kind: 'pill' | 'ring' | 'bar' */
WPP.timerHtml = function (state, kind) {
if (!state || !state.deadline) return '';
var total = WPP.totalFor(state.phase, state.deadline);
var attrs = ' data-deadline="' + state.deadline + '" data-total="' + total + '"';
var s = ''; /* udfyldes af tick() – holder markup stabil mellem renders */
if (kind === 'ring') {
return '<div class="ring js-timer"' + attrs + '><svg viewBox="0 0 100 100" aria-hidden="true">' +
'<circle class="bg" cx="50" cy="50" r="44"/><circle class="fg" cx="50" cy="50" r="44" pathLength="100"/></svg>' +
'<span class="num">' + s + '</span></div>';
}
if (kind === 'bar') return '<div class="tbar js-timer"' + attrs + '><i></i></div>';
return '<span class="tpill js-timer"' + attrs + '><span class="num">' + s + '</span></span>';
};
var tickers = [];
WPP.onTick = function (fn) { tickers.push(fn); };
function tick() {
var els = document.querySelectorAll('.js-timer');
for (var i = 0; i < els.length; i++) {
var el = els[i];
var dl = Number(el.getAttribute('data-deadline'));
var total = Number(el.getAttribute('data-total')) || 1;
var leftMs = Math.max(0, dl - WPP.now());
var s = Math.ceil(leftMs / 1000);
var frac = Math.max(0, Math.min(1, leftMs / total));
var num = el.querySelector('.num');
if (num && num.textContent !== String(s)) num.textContent = s;
el.style.setProperty('--frac', frac.toFixed(4));
el.classList.toggle('urgent', s <= 5);
el.classList.toggle('over', leftMs <= 0);
}
for (var j = 0; j < tickers.length; j++) {
try { tickers[j](); } catch (e) { if (window.console) console.error(e); }
}
}
setInterval(tick, 200);
WPP.tick = tick;
/* ---------- DOM-patch: genbrug uændrede [data-k]-noder ----------
Top-level-børn med data-k sammenlignes på deres genererede HTML; uændrede noder
bevares (fokus, inputtekst og animationer overlever). Noder med data-patch
patches rekursivt i stedet for at blive erstattet. */
function syncAttrs(o, n) {
var i, a;
for (i = o.attributes.length - 1; i >= 0; i--) {
a = o.attributes[i].name;
if (!n.hasAttribute(a)) o.removeAttribute(a);
}
for (i = 0; i < n.attributes.length; i++) {
a = n.attributes[i];
if (o.getAttribute(a.name) !== a.value) o.setAttribute(a.name, a.value);
}
}
function patchChildren(target, newParent) {
var old = {}, keep = new Set(), desired = [];
Array.prototype.forEach.call(target.children, function (el) {
var k = el.getAttribute('data-k');
if (k && !old[k]) old[k] = el;
});
Array.prototype.slice.call(newParent.children).forEach(function (n) {
var k = n.getAttribute('data-k');
var o = k && old[k];
if (o && !keep.has(o)) {
if (o._h === n._h) { desired.push(o); keep.add(o); return; }
if (n.hasAttribute('data-patch') && o.hasAttribute('data-patch') && o.tagName === n.tagName) {
syncAttrs(o, n); patchChildren(o, n); o._h = n._h;
desired.push(o); keep.add(o); return;
}
}
desired.push(n);
});
Array.prototype.slice.call(target.childNodes).forEach(function (c) { if (!keep.has(c)) target.removeChild(c); });
var cur = target.firstChild;
desired.forEach(function (n) {
if (cur === n) { cur = n.nextSibling; return; }
target.insertBefore(n, cur);
});
}
WPP.patch = function (target, html) {
var tpl = document.createElement('template');
tpl.innerHTML = html;
Array.prototype.forEach.call(tpl.content.querySelectorAll('[data-k]'), function (e) { e._h = e.outerHTML; });
patchChildren(target, tpl.content);
tick();
};
/* Skal: skærm + toasts + forbindelses-overlay */
WPP.shell = function (root) {
if (root._shell) return root._shell;
root.classList.add('wpp-root');
root.innerHTML = '<div class="screen"></div><div class="toasts" aria-live="polite"></div>' +
'<div class="conn" role="status"><div class="conn-box"><span class="spinner"></span>Forbinder…</div></div>';
root._shell = {
screen: root.querySelector('.screen'),
toasts: root.querySelector('.toasts')
};
return root._shell;
};
WPP.toast = function (root, msg, kind) {
var sh = WPP.shell(root);
var t = document.createElement('div');
t.className = 'toast ' + (kind || 'err');
t.textContent = msg;
sh.toasts.appendChild(t);
while (sh.toasts.children.length > 3) sh.toasts.removeChild(sh.toasts.firstChild);
setTimeout(function () { t.classList.add('out'); }, 3600);
setTimeout(function () { if (t.parentNode) t.parentNode.removeChild(t); }, 4100);
};
/* Tæller-animation for [data-count-from][data-count-to] (kun nye noder) */
WPP.animateCounts = function (root) {
Array.prototype.forEach.call(root.querySelectorAll('[data-count-to]'), function (el) {
if (el._counted) return;
el._counted = true;
var from = Number(el.getAttribute('data-count-from')) || 0;
var to = Number(el.getAttribute('data-count-to')) || 0;
var delay = Number(el.getAttribute('data-count-delay')) || 0;
if (from === to) { el.textContent = WPP.fmt(to); return; }
el.textContent = WPP.fmt(from);
var dur = 1400, t0 = null;
function step(ts) {
if (!el.isConnected) return;
if (t0 === null) t0 = ts + delay;
var p = Math.max(0, Math.min(1, (ts - t0) / dur));
var e = 1 - Math.pow(1 - p, 3);
el.textContent = WPP.fmt(from + (to - from) * e);
if (p < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
});
};
/* ---------- WebSocket med auto-reconnect ---------- */
WPP.connect = function (path, opts) {
opts = opts || {};
var url = (location.protocol === 'https:' ? 'wss' : 'ws') + '://' + location.host + path;
var ws = null, closed = false, backoff = 500, retryTimer = null, downTimer = null;
var lastMsg = Date.now(), pingTimer = null;
var root = opts.root;
function setDown(down) {
if (!root) return;
if (down) {
if (!downTimer) downTimer = setTimeout(function () { root.classList.add('offline'); }, 1000);
} else {
clearTimeout(downTimer); downTimer = null;
root.classList.remove('offline');
}
}
function open() {
if (closed) return;
clearTimeout(retryTimer); retryTimer = null;
if (ws && (ws.readyState === 0 || ws.readyState === 1)) return;
setDown(true);
try { ws = new WebSocket(url); } catch (e) { schedule(); return; }
var me = ws;
ws.onopen = function () {
if (me !== ws) return;
backoff = 500; lastMsg = Date.now();
setDown(false);
if (opts.onOpen) opts.onOpen();
};
ws.onmessage = function (ev) {
if (me !== ws) return;
lastMsg = Date.now();
var msg;
try { msg = JSON.parse(ev.data); } catch (e) { return; }
if (msg && msg.type === 'state' && msg.state) WPP.setServerTime(msg.state.server_time);
if (opts.onMessage) opts.onMessage(msg);
};
ws.onclose = function () {
if (me !== ws) return;
ws = null;
if (!closed) { setDown(true); schedule(); }
};
ws.onerror = function () { /* onclose følger */ };
}
function schedule() {
if (closed || retryTimer) return;
retryTimer = setTimeout(function () { retryTimer = null; open(); }, backoff);
backoff = Math.min(5000, backoff * 2);
}
function now() {
if (closed) return;
backoff = 500;
if (!ws || ws.readyState > 1) { clearTimeout(retryTimer); retryTimer = null; open(); }
}
function onVis() { if (document.visibilityState === 'visible') now(); }
document.addEventListener('visibilitychange', onVis);
window.addEventListener('online', now);
pingTimer = setInterval(function () {
if (!ws || ws.readyState !== 1) return;
if (Date.now() - lastMsg > 50000) { try { ws.close(); } catch (e) { /* */ } return; }
try { ws.send(JSON.stringify({ action: 'ping' })); } catch (e) { /* */ }
}, 20000);
open();
return {
send: function (obj) {
if (!ws || ws.readyState !== 1) return false;
try { ws.send(JSON.stringify(obj)); return true; } catch (e) { return false; }
},
isOpen: function () { return !!ws && ws.readyState === 1; },
close: function () {
closed = true;
clearTimeout(retryTimer); clearInterval(pingTimer); setDown(false);
document.removeEventListener('visibilitychange', onVis);
window.removeEventListener('online', now);
if (ws) { var w = ws; ws = null; try { w.close(); } catch (e) { /* */ } }
}
};
};
/* ---------- lyd (WebAudio, syntetiseret) ---------- */
var actx = null;
var sound = (WPP.sound = {
muted: WPP.ls.get('wpp:mute') === '1',
unlock: function () {
if (actx) { if (actx.state === 'suspended') actx.resume(); return; }
var AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return;
try { actx = new AC(); } catch (e) { actx = null; }
},
setMuted: function (m) { sound.muted = !!m; WPP.ls.set('wpp:mute', m ? '1' : '0'); },
tone: function (freq, start, dur, type, vol) {
if (!actx || sound.muted || actx.state !== 'running') return;
var t = actx.currentTime + (start || 0);
var o = actx.createOscillator(), g = actx.createGain();
o.type = type || 'triangle';
o.frequency.setValueAtTime(freq, t);
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(vol || 0.18, t + 0.015);
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
o.connect(g); g.connect(actx.destination);
o.start(t); o.stop(t + dur + 0.05);
},
play: function (kind) {
if (kind === 'phase') { sound.tone(523, 0, 0.15); sound.tone(784, 0.09, 0.25); }
else if (kind === 'tick') sound.tone(1200, 0, 0.06, 'square', 0.06);
else if (kind === 'stamp') { sound.tone(110, 0, 0.35, 'sawtooth', 0.22); sound.tone(82, 0.02, 0.4, 'sine', 0.3); }
else if (kind === 'truth') { [523, 659, 784, 1047].forEach(function (f, i) { sound.tone(f, i * 0.09, 0.35); }); }
else if (kind === 'pop') sound.tone(880, 0, 0.08, 'sine', 0.12);
}
});
['pointerdown', 'keydown', 'touchstart'].forEach(function (ev) {
window.addEventListener(ev, function () { sound.unlock(); }, { passive: true });
});
})();
+166
View File
@@ -0,0 +1,166 @@
/* Fixture-snapshots til dev.html (ingen server). window.WPPFixtures.build(phase, opts) */
(function () {
'use strict';
var COLORS = ['#ff4d6d', '#3a86ff', '#ffbe0b', '#06d6a0', '#fb5607', '#8338ec', '#ff006e', '#00b4d8'];
var NAMES = ['Asger', 'Sus', 'Bo', 'Mette', 'Kasper', 'Line'];
var PHASES = ['join', 'lobby', 'category', 'lie', 'guess', 'reveal', 'scores', 'final'];
function players(n, extra) {
var out = [];
for (var i = 0; i < n; i++) {
out.push({ id: 'p' + (i + 1), name: NAMES[i], color: COLORS[i], score: 0, connected: i !== 5, vip: i === 0, done: false });
}
(extra || []).forEach(function (f, i) { if (out[i]) Object.assign(out[i], f); });
return out;
}
function clone(o) { return JSON.parse(JSON.stringify(o)); }
function fakeQr() {
var s = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 29 29" shape-rendering="crispEdges"><rect width="29" height="29" fill="#fff"/>';
var seed = 7;
function rnd() { seed = (seed * 9301 + 49297) % 233280; return seed / 233280; }
for (var y = 0; y < 29; y++) for (var x = 0; x < 29; x++) {
var finder = (x < 7 && y < 7) || (x > 21 && y < 7) || (x < 7 && y > 21);
if (finder) {
var fx = x > 21 ? x - 22 : x, fy = y > 21 ? y - 22 : y;
var on = fx === 0 || fx === 6 || fy === 0 || fy === 6 || (fx > 1 && fx < 5 && fy > 1 && fy < 5);
if (on) s += '<rect x="' + x + '" y="' + y + '" width="1" height="1"/>';
} else if (rnd() > 0.55) s += '<rect x="' + x + '" y="' + y + '" width="1" height="1"/>';
}
return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(s + '</svg>');
}
function build(phase, opts) {
opts = opts || {};
var now = Date.now();
var base = {
code: 'KLMT', phase: phase === 'join' ? 'lobby' : phase, server_time: now, deadline: null,
join_url: 'https://party.weircon.dk/?code=KLMT', min_players: 2, max_players: 8,
round: { number: 1, name: 'Runde 1', multiplier: 1, question_number: 2, questions_in_round: 3 },
players: players(6), audience_count: 3
};
var question = { category: 'Dyreriget', prompt: 'En flok _____ kaldes et parlament.' };
var answers = [
{ id: 'a1', text: 'ugler' }, { id: 'a2', text: 'krager' }, { id: 'a3', text: 'papegøjer' },
{ id: 'a4', text: 'flamingoer' }, { id: 'a5', text: 'pingviner' }, { id: 'a6', text: 'måger <b>&amp;</b> co' },
{ id: 'a7', text: 'skildpadder' }
];
var host, phones;
var me = function (o) { return Object.assign({ id: 'p3', role: 'player', vip: false, lie: null, guess: null, likes: [], is_chooser: false, last_delta: null }, o); };
var aud = { id: 'x9', role: 'audience', vip: false, lie: null, guess: null, likes: [], is_chooser: false, last_delta: null };
switch (phase) {
case 'join':
host = Object.assign(base, { round: null, players: players(1), audience_count: 0 });
phones = [
{ label: 'Ny spiller (tom formular)', join: { code: '', name: '' } },
{ label: 'Fra QR (?code=KLMT)', join: { code: 'KLMT', name: '' } },
{ label: 'VIP – for få spillere', state: Object.assign(clone(host), { me: me({ id: 'p1', vip: true }) }) }
];
break;
case 'lobby':
host = Object.assign(base, { round: null });
phones = [
{ label: 'Spiller', state: Object.assign(clone(host), { me: me({}) }) },
{ label: 'VIP', state: Object.assign(clone(host), { me: me({ id: 'p1', vip: true }) }) },
{ label: 'Publikum', state: Object.assign(clone(host), { me: aud }) }
];
break;
case 'category':
host = Object.assign(base, {
deadline: now + 11000,
round: { number: 2, name: 'Runde 2', multiplier: 2, question_number: 1, questions_in_round: 3 },
players: players(6, [{ score: 3500 }, { score: 1500 }, { score: 2000 }, { score: 500 }, { score: 0 }, { score: 1000 }]),
category: { chooser_id: 'p1', options: [{ id: 3, name: 'Dyreriget' }, { id: 7, name: 'Mærkelige love' }, { id: 9, name: 'Danmarkshistorie' }, { id: 12, name: 'Mad & drikke' }] }
});
phones = [
{ label: 'Spiller', state: Object.assign(clone(host), { me: me({}) }) },
{ label: 'VIP = vælger', state: Object.assign(clone(host), { me: me({ id: 'p1', vip: true, is_chooser: true }) }) },
{ label: 'Publikum', state: Object.assign(clone(host), { me: aud }) }
];
break;
case 'lie':
host = Object.assign(base, {
deadline: now + 42000, question: question,
players: players(6, [{ done: true }, {}, { done: true }, { done: true }])
});
phones = [
{ label: 'Spiller (skriver)', state: Object.assign(clone(host), { me: me({}) }) },
{ label: 'VIP (har løjet)', state: Object.assign(clone(host), { me: me({ id: 'p1', vip: true, lie: 'krager' }) }) },
{ label: 'Publikum', state: Object.assign(clone(host), { me: aud }) }
];
break;
case 'guess':
host = Object.assign(base, {
deadline: now + 4000, question: question, answers: answers,
players: players(6, [{ done: true }, { done: true }, {}, { done: true }])
});
var pAns = answers.filter(function (a) { return a.id !== 'a5'; });
var vAns = answers.filter(function (a) { return a.id !== 'a2'; });
phones = [
{ label: 'Spiller (gætter)', state: Object.assign(clone(host), { answers: pAns, me: me({ lie: 'pingviner' }) }) },
{ label: 'VIP (har gættet)', state: Object.assign(clone(host), { answers: vAns, me: me({ id: 'p1', vip: true, lie: 'krager', guess: 'a1', likes: ['a3'] }) }) },
{ label: 'Publikum', state: Object.assign(clone(host), { me: aud }) }
];
break;
case 'reveal':
var stepMs = 4000;
var steps = [
{ answer_id: 'a2', text: 'krager', is_truth: false, house: false, authors: ['p1'], guessed_by: ['p3', 'p4'], audience_votes: 2, likes: 1, points: [{ player_id: 'p1', delta: 1000 }] },
{ answer_id: 'a3', text: 'papegøjer', is_truth: false, house: false, authors: ['p2', 'p5'], guessed_by: ['p6'], audience_votes: 0, likes: 0, points: [{ player_id: 'p2', delta: 500 }, { player_id: 'p5', delta: 500 }] },
{ answer_id: 'a6', text: 'måger <b>&amp;</b> co', is_truth: false, house: true, authors: [], guessed_by: ['p5'], audience_votes: 1, likes: 2, points: [] },
{ answer_id: 'a1', text: 'ugler', is_truth: true, house: false, authors: [], guessed_by: ['p1', 'p2'], audience_votes: 0, likes: 0, points: [{ player_id: 'p1', delta: 1000 }, { player_id: 'p2', delta: 1000 }] }
];
var total = steps.length * stepMs + 3000;
var t = opts.t != null ? Number(opts.t) : stepMs * 0 + 2600;
host = Object.assign(base, {
deadline: now - t + total, question: question, answers: answers,
players: players(6, [{ score: 2000 }, { score: 1500 }, { score: 0 }, { score: 0 }, { score: 500 }, { score: 0 }]),
reveal: { steps: steps, step_ms: stepMs }
});
phones = [
{ label: 'Spiller (0 point)', state: Object.assign(clone(host), { me: me({ lie: 'pingviner', guess: 'a2', last_delta: 0 }) }) },
{ label: 'VIP (+2.000)', state: Object.assign(clone(host), { me: me({ id: 'p1', vip: true, lie: 'krager', guess: 'a1', likes: ['a3'], last_delta: 2000 }) }) },
{ label: 'Publikum', state: Object.assign(clone(host), { me: aud }) }
];
break;
case 'scores':
host = Object.assign(base, {
deadline: now + 8000, question: null,
players: players(6, [{ score: 4500 }, { score: 3000 }, { score: 1000 }, { score: 1500 }, { score: 2500 }, { score: 0 }]),
deltas: [{ player_id: 'p1', delta: 2000 }, { player_id: 'p2', delta: 1500 }, { player_id: 'p5', delta: 500 }, { player_id: 'p4', delta: 1000 }]
});
phones = [
{ label: 'Spiller', state: Object.assign(clone(host), { me: me({ last_delta: 0 }) }) },
{ label: 'VIP', state: Object.assign(clone(host), { me: me({ id: 'p1', vip: true, last_delta: 2000 }) }) },
{ label: 'Publikum', state: Object.assign(clone(host), { me: aud }) }
];
break;
case 'final':
var ps = players(6, [{ score: 12500 }, { score: 9000 }, { score: 4000 }, { score: 7500 }, { score: 9000 }, { score: 1500 }]);
host = Object.assign(base, {
round: null, players: ps,
final: {
ranking: [
{ player_id: 'p1', score: 12500, place: 1 }, { player_id: 'p2', score: 9000, place: 2 }, { player_id: 'p5', score: 9000, place: 2 },
{ player_id: 'p4', score: 7500, place: 4 }, { player_id: 'p3', score: 4000, place: 5 }, { player_id: 'p6', score: 1500, place: 6 }
],
awards: [
{ title: 'Største løgner', player_id: 'p1', detail: 'narrede 7 gange' },
{ title: 'Sandhedssiger', player_id: 'p4', detail: 'fandt sandheden 5 gange' },
{ title: 'Godtroende', player_id: 'p3', detail: 'faldt for 6 løgne' },
{ title: 'Publikumsfavorit', player_id: 'p2', detail: '9 hjerter' }
]
}
});
phones = [
{ label: 'Spiller (nr. 5)', state: Object.assign(clone(host), { me: me({}) }) },
{ label: 'VIP (vinder)', state: Object.assign(clone(host), { me: me({ id: 'p1', vip: true }) }) },
{ label: 'Publikum', state: Object.assign(clone(host), { me: aud }) }
];
break;
}
return { host: host, phones: phones };
}
window.WPPFixtures = { build: build, phases: PHASES, qr: fakeQr() };
})();
+96
View File
@@ -0,0 +1,96 @@
<!doctype html>
<html lang="da">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Fup og Fakta – dev-harness</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fredoka:wght@400;500;600;700&display=swap">
<link rel="stylesheet" href="party.css">
<style>
body.harness { background-attachment: scroll; }
.devbar { position: sticky; top: 0; z-index: 100; display: flex; flex-wrap: wrap; gap: 6px; align-items: center; padding: 10px 16px; background: #0b0220; border-bottom: 1px solid #ffffff22; font: 14px system-ui, sans-serif; }
.devbar button { font: 600 14px system-ui, sans-serif; padding: 6px 12px; border-radius: 8px; border: 1px solid #ffffff33; background: #ffffff14; color: #fff; cursor: pointer; }
.devbar button.on { background: #ffd23f; color: #1d0846; }
.devbar .log { margin-left: auto; color: #2de2e6; font-family: ui-monospace, monospace; max-width: 40ch; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.stage { display: flex; flex-wrap: wrap; gap: 24px; padding: 20px 16px; align-items: flex-start; }
.panel { display: flex; flex-direction: column; gap: 6px; }
.panel > .lbl { font: 600 13px system-ui, sans-serif; color: #fff9; }
.hostbox { width: 1280px; height: 720px; border-radius: 10px; box-shadow: 0 0 0 1px #ffffff33; background: var(--bg0); }
.phonebox { width: 390px; height: 844px; overflow: auto; border-radius: 28px; box-shadow: 0 0 0 8px #000, 0 0 0 9px #ffffff33; transform: translateZ(0); background: var(--bg0); background-image: radial-gradient(500px 400px at 10% -10%, rgba(255,61,139,.3), transparent 60%), linear-gradient(160deg, var(--bg1), var(--bg0) 70%); }
.harness .toasts, .harness .conn { position: absolute; }
.harness .phonebox .toasts { position: sticky; bottom: 16px; left: auto; transform: none; margin: 0 auto; }
body.only-host .devbar, body.only-host .lbl { display: none; }
body.only-host .stage { padding: 0; }
body.only-host .hostbox { width: 100vw; height: 100vh; border-radius: 0; box-shadow: none; }
body.only-phones .host-panel { display: none; }
</style>
</head>
<body class="harness">
<div class="devbar" id="bar"></div>
<div class="stage">
<div class="panel host-panel"><div class="lbl">Storskærm (1280×720)</div><div class="hostbox" id="hostroot"></div></div>
<div class="panel"><div class="lbl" id="l0"></div><div class="phonebox" id="ph0"></div></div>
<div class="panel"><div class="lbl" id="l1"></div><div class="phonebox" id="ph1"></div></div>
<div class="panel"><div class="lbl" id="l2"></div><div class="phonebox" id="ph2"></div></div>
</div>
<script>window.WPP_HARNESS = true;</script>
<script src="common.js"></script>
<script src="host.js"></script>
<script src="play.js"></script>
<script src="dev-fixtures.js"></script>
<script>
(function () {
var qs = new URLSearchParams(location.search);
var phase = qs.get('phase') || 'lobby';
var t = qs.get('t');
var only = qs.get('only');
if (only) document.body.classList.add('only-' + only);
if (qs.get('pw')) Array.prototype.forEach.call(document.querySelectorAll('.phonebox'), function (e) { e.style.width = qs.get('pw') + 'px'; });
var bar = document.getElementById('bar');
var logEl;
function log(m) { if (logEl) logEl.textContent = 'send → ' + JSON.stringify(m); if (window.console) console.log('[send]', m); return true; }
function draw() {
var fx = WPPFixtures.build(phase, { t: t });
var hostRoot = document.getElementById('hostroot');
WPPHost.renderHost(hostRoot, fx.host, { harness: true, send: log, qrSrc: WPPFixtures.qr });
fx.phones.forEach(function (ph, i) {
var root = document.getElementById('ph' + i);
document.getElementById('l' + i).textContent = 'Telefon: ' + ph.label + ' (390×844)';
var ctx = { harness: true, send: function (m) {
log(m);
if (m.action === 'lie_for_me') setTimeout(function () { WPPPlay.setSuggestion(root, 'kolibrier'); }, 150);
return true;
}, onJoin: function (c, n) { log({ action: 'join', code: c, name: n }); } };
if (ph.join) WPPPlay.renderJoin(root, ctx, ph.join);
else WPPPlay.renderPlay(root, ph.state, ctx);
});
}
WPPFixtures.phases.forEach(function (p) {
var b = document.createElement('button');
b.textContent = p; if (p === phase) b.className = 'on';
b.onclick = function () {
phase = p; t = null;
qs.set('phase', p); qs.delete('t'); history.replaceState(null, '', '?' + qs);
Array.prototype.forEach.call(bar.querySelectorAll('button'), function (x) { x.className = x === b ? 'on' : ''; });
draw();
};
bar.appendChild(b);
});
var again = document.createElement('button'); again.textContent = 'genafspil'; again.onclick = draw; again.style.marginLeft = '12px'; bar.appendChild(again);
var toast = document.createElement('button'); toast.textContent = 'toast'; toast.onclick = function () {
WPP.toast(document.getElementById('ph0'), 'Den løgn er for tæt på sandheden!');
WPP.toast(document.getElementById('hostroot'), 'Der skal være mindst 2 spillere.');
}; bar.appendChild(toast);
var off = document.createElement('button'); off.textContent = 'offline'; off.onclick = function () {
['hostroot', 'ph0', 'ph1', 'ph2'].forEach(function (id) { document.getElementById(id).classList.toggle('offline'); });
}; bar.appendChild(off);
logEl = document.createElement('span'); logEl.className = 'log'; bar.appendChild(logEl);
draw();
window.WPPDev = { draw: draw, setPhase: function (p, tt) { phase = p; t = tt; draw(); } };
})();
</script>
</body>
</html>
+370
View File
@@ -0,0 +1,370 @@
/* Fup og Fakta – storskærm. Eksporterer window.WPPHost = {renderHost, mount, revealProgress}. */
(function () {
'use strict';
var WPP = window.WPP, esc = WPP.esc;
var TIMED = { category: 1, lie: 1, guess: 1, reveal: 1, scores: 1 };
function ui(root) {
return root._ui || (root._ui = { lastPhase: null, revealKey: null, revealSeen: {}, lastTick: null, state: null, ctx: null });
}
/* ---------- hjælpere ---------- */
function joinHost(state) {
var u = state.join_url || '';
var m = /^[a-z]+:\/\/([^/?#]+)/i.exec(u);
return m ? m[1] : location.host || 'party.weircon.dk';
}
function chips(ids, players, cls) {
return ids.map(function (id) { return WPP.chip(players[id], cls || ''); }).join('');
}
function avatar(p) {
return '<span class="av" style="--pc:' + esc(p.color || '#888') + '">' + esc((p.name || '?').charAt(0).toUpperCase()) + '</span>';
}
function sortedPlayers(state) {
return (state.players || []).slice().sort(function (a, b) { return (b.score || 0) - (a.score || 0); });
}
/* Reveal-tidslinje beregnes fra snapshot: start = deadline − (trin × step_ms + 3 s) */
function revealProgress(state, u) {
var r = state.reveal || {};
var steps = r.steps || [];
var ms = r.step_ms || 4000;
var total = steps.length * ms + 3000;
var start;
var key = (state.round ? state.round.number + '.' + state.round.question_number : '') + ':' + ((state.question || {}).prompt || '');
if (state.deadline) start = state.deadline - total;
else {
if (!u.revealSeen[key]) u.revealSeen[key] = WPP.now();
start = u.revealSeen[key];
}
var el = Math.max(0, WPP.now() - start);
var i = Math.floor(el / ms);
if (i >= steps.length) return { done: true, i: steps.length, n: steps.length, key: key + '|done' };
var f = (el % ms) / ms;
var stage = f < 0.28 ? 0 : f < 0.55 ? 1 : 2;
return { done: false, i: i, stage: stage, n: steps.length, key: key + '|' + i + '.' + stage };
}
/* ---------- faste dele ---------- */
function topBar(state) {
var r = state.round;
var mid = '';
if (r && state.phase !== 'lobby' && state.phase !== 'final') {
mid = '<span class="rt-name">' + WPP.roundTitle(r) + '</span>' +
(r.questions_in_round > 1 && state.phase !== 'scores'
? '<span class="rt-q">Spørgsmål ' + (r.question_number || 1) + ' af ' + r.questions_in_round + '</span>' : '');
}
return '<header class="hs-top" data-k="top" data-patch>' +
'<div class="brand" data-k="brand"><span class="b-fup">Fup</span> <span class="b-og">og</span> <span class="b-fakta">Fakta</span></div>' +
'<div class="rt" data-k="rt:' + esc(mid) + '">' + mid + '</div>' +
(state.phase !== 'lobby'
? '<div class="joininfo" data-k="ji:' + (state.audience_count || 0) + '">' + esc(joinHost(state)) + ' <b>' + esc(state.code || '') + '</b>' +
(state.audience_count ? '<span class="audn">+' + state.audience_count + ' publikum</span>' : '') + '</div>'
: '<div data-k="ji-none"></div>') +
'</header>';
}
function footer(state, opts) {
opts = opts || {};
var ps = state.players || [];
var chipsHtml = '';
if (opts.doneChips) {
chipsHtml = ps.map(function (p) {
return '<span class="pchip' + (p.done ? ' done' : '') + (p.connected === false ? ' off' : '') + '" style="--pc:' + esc(p.color || '#888') + '">' +
'<span class="dot"></span>' + esc(p.name) + (p.done ? '<i class="ck" aria-label="klar"></i>' : '') + '</span>';
}).join('');
}
var skip = TIMED[state.phase] ? '<button class="hbtn" data-act="skip" title="Afslut fasen nu">Spring over »</button>' : '';
var mute = '<button class="hbtn ghost" data-act="mute" aria-pressed="' + (WPP.sound.muted ? 'true' : 'false') + '">' +
(WPP.sound.muted ? 'Lyd fra' : 'Lyd til') + '</button>';
return '<footer class="hs-foot" data-k="foot" data-patch>' +
'<div class="pchips" data-k="chips:' + esc(chipsHtml) + '">' + chipsHtml + '</div>' +
'<div class="ctl" data-k="ctl:' + state.phase + (WPP.sound.muted ? 1 : 0) + '">' + mute + skip + '</div></footer>';
}
/* ---------- faser ---------- */
function lobby(state, ctx) {
var ps = state.players || [];
var max = state.max_players || 8, min = state.min_players || 2;
var code = state.code || '????';
var qr = ctx.qrSrc || ('/qr/' + encodeURIComponent(code) + '.svg');
var tiles = code.split('').map(function (ch, i) { return '<span class="ctile" style="--i:' + i + '">' + esc(ch) + '</span>'; }).join('');
var cards = '';
for (var i = 0; i < max; i++) {
var p = ps[i];
if (p) {
cards += '<div class="pcard' + (p.connected === false ? ' off' : '') + '" style="--pc:' + esc(p.color || '#888') + '">' + avatar(p) +
'<span class="pname">' + esc(p.name) + '</span>' +
(p.vip ? '<span class="vip">VIP</span>' : '') +
(p.connected === false ? '<span class="offl">afbrudt</span>' : '') +
'<button class="kick" data-act="kick" data-arg="' + esc(p.id) + '" data-name="' + esc(p.name) + '" aria-label="Smid ' + esc(p.name) + ' ud">×</button></div>';
} else {
cards += '<div class="pcard empty"><span class="av">?</span><span class="pname">Ledig plads</span></div>';
}
}
var enough = ps.length >= min;
return '<section class="lobby-join" data-k="lj:' + esc(code) + '">' +
'<div class="lj-text"><p class="lj-go">Gå ind på</p><p class="lj-url">' + esc(joinHost(state)) + '</p><p class="lj-go">og skriv koden</p>' +
'<div class="ctiles">' + tiles + '</div></div>' +
'<div class="qr"><img src="' + esc(qr) + '" alt="QR-kode til at deltage" width="300" height="300"><span>…eller scan her</span></div></section>' +
'<section class="lobby-players" data-k="lp" data-patch>' +
'<h2 data-k="lph:' + ps.length + '">Spillere <span>' + ps.length + '/' + max + '</span></h2>' +
'<div class="pgrid" data-k="pg:' + esc(cards) + '">' + cards + '</div>' +
'<div class="lobby-start" data-k="ls:' + (enough ? 1 : 0) + ':' + (state.audience_count || 0) + '">' +
'<button class="btn btn-go btn-xl" data-act="start"' + (enough ? '' : ' disabled') + '>Start spillet</button>' +
'<div class="ls-hint">' + (enough ? 'VIP kan også starte fra telefonen' : 'Der skal være mindst ' + min + ' spillere') +
(state.audience_count ? '<br><b>+ ' + state.audience_count + ' publikum</b>' : '') + '</div></div>' +
'</section>';
}
function category(state) {
var cat = state.category || {};
var players = WPP.playerMap(state);
var ch = players[cat.chooser_id];
var r = state.round || {};
return '<section class="cat-banner" data-k="cb:' + esc(WPP.roundTitle(r)) + (r.question_number || '') + '">' +
'<div class="cb-round">' + WPP.roundTitle(r) + '</div>' +
(r.questions_in_round ? '<div class="cb-q">Spørgsmål ' + (r.question_number || 1) + ' af ' + r.questions_in_round + '</div>' : '') + '</section>' +
'<section class="cat-who" data-k="cw:' + esc(cat.chooser_id || '') + '">' + (ch ? WPP.chip(ch, 'xl') : 'Nogen') + ' <span>vælger kategori…</span></section>' +
'<section class="cat-opts" data-k="co:' + esc((cat.options || []).map(function (o) { return o.id; }).join(',')) + '">' +
(cat.options || []).map(function (o, i) {
return '<div class="cat-opt c' + (i % 4) + '" style="--i:' + i + '">' + esc(o.name) + '</div>';
}).join('') + '</section>' +
'<div class="hs-timer" data-k="tm:' + (state.deadline || 0) + '">' + WPP.timerHtml(state, 'ring') + '</div>';
}
function questionBlock(state, cls, fill) {
var q = state.question || {};
return '<section class="qbox ' + (cls || '') + '" data-k="qb:' + esc(cls || '') + ':' + esc(q.prompt || '') + ':' + esc(fill || '') + '">' +
(q.category ? '<div class="qcat">' + esc(q.category) + '</div>' : '') +
'<div class="qprompt">' + WPP.promptHtml(q.prompt || '', fill) + '</div></section>';
}
function lie(state) {
return questionBlock(state, 'lie') +
'<p class="instr" data-k="ins">Find på en <b>løgn</b> på din telefon, der kan narre de andre!</p>' +
'<div class="hs-timer" data-k="tm:' + (state.deadline || 0) + '">' + WPP.timerHtml(state, 'ring') + '</div>';
}
function guess(state) {
var ans = state.answers || [];
var cols = ans.length > 6 ? 3 : 2;
return questionBlock(state, 'small') +
'<section class="ans-grid cols' + cols + '" data-k="ag:' + esc(ans.map(function (a) { return a.id + a.text; }).join('|')) + '">' +
ans.map(function (a, i) {
return '<div class="ans-tile c' + (i % 6) + '" style="--i:' + i + '">' + esc(a.text) + '</div>';
}).join('') + '</section>' +
'<p class="instr" data-k="ins">Hvad er <b>sandheden</b>? Gæt på din telefon!</p>' +
'<div class="hs-timer" data-k="tm:' + (state.deadline || 0) + '">' + WPP.timerHtml(state, 'ring') + '</div>';
}
function reveal(state, u) {
var r = state.reveal || {};
var steps = r.steps || [];
var players = WPP.playerMap(state);
var pr = revealProgress(state, u);
var truth = steps.filter(function (s) { return s.is_truth; })[0];
var truthShown = truth && (pr.done || (steps[pr.i] === truth && pr.stage >= 2));
var out = questionBlock(state, 'small rv', truthShown ? truth.text : '');
if (!pr.done) {
var s = steps[pr.i];
var who = '', stamp = '';
if (pr.stage >= 1) {
var g = s.guessed_by || [];
who = '<div class="rv-who" data-k="w"><span class="lbl">' + (s.is_truth ? 'Gættede rigtigt:' : 'Faldt for den:') + '</span>' +
(g.length ? chips(g, players, 'pop') : (s.is_truth ? '<span class="none">Ingen…</span>' : '')) +
(s.audience_votes ? '<span class="audv">+' + s.audience_votes + ' fra publikum</span>' : '') + '</div>';
}
if (pr.stage >= 2) {
var pts = (s.points || []).map(function (p) {
return '<span class="pt">' + WPP.chip(players[p.player_id]) + '<b>' + WPP.signed(p.delta) + '</b></span>';
}).join('');
var likes = s.likes ? '<span class="likes">♥ ' + s.likes + '</span>' : '';
if (s.is_truth) {
stamp = '<div class="rv-res truth" data-k="s"><div class="truth-banner">SANDHEDEN</div>' +
(pts ? '<div class="pts">' + pts + '</div>' : '<div class="by">Ingen fandt sandheden!</div>') +
(state.reveal && state.reveal.note ? '<div class="note">' + esc(state.reveal.note) + '</div>' : '') + '</div>';
} else if (s.house) {
stamp = '<div class="rv-res house" data-k="s"><div class="stamp">HUSETS LØGN</div><div class="by">Huset narrede jer!</div>' + likes + '</div>';
} else {
var auth = (s.authors || []).map(function (id) { return players[id] ? esc(players[id].name) : '?'; });
stamp = '<div class="rv-res lie" data-k="s"><div class="stamp">LØGN</div><div class="by">af ' + WPP.joinNames(auth) + '</div>' +
(pts ? '<div class="pts">' + pts + '</div>' : '') + likes + '</div>';
}
}
out += '<section class="rv-stage' + (s.is_truth ? ' is-truth' : '') + '" data-k="rs:' + pr.i + ':' + esc(s.answer_id) + '" data-patch>' +
'<div class="rv-ans" data-k="a">' + esc(s.text) + '</div>' + who + stamp + '</section>';
} else {
/* opsummering: point i spørgsmålet */
var sum = {};
steps.forEach(function (s) { (s.points || []).forEach(function (p) { sum[p.player_id] = (sum[p.player_id] || 0) + (p.delta || 0); }); });
var ps = sortedPlayers(state);
out += '<section class="rv-sum" data-k="sum"><h2>Point i dette spørgsmål</h2><ol>' + ps.map(function (p, i) {
var d = sum[p.id] || 0;
return '<li style="--pc:' + esc(p.color || '#888') + ';--i:' + i + '">' + avatar(p) + '<span class="n">' + esc(p.name) + '</span>' +
'<span class="d' + (d ? ' plus' : '') + '">' + (d ? WPP.signed(d) : '–') + '</span><b>' + WPP.fmt(p.score) + '</b></li>';
}).join('') + '</ol></section>';
}
out += '<div class="rv-dots" data-k="dots:' + pr.i + '">' + steps.map(function (s, i) {
return '<i class="' + (i < pr.i ? 'past' : i === pr.i ? 'now' : '') + (s.is_truth ? ' t' : '') + '"></i>';
}).join('') + '</div>';
return out;
}
function scores(state) {
var ps = sortedPlayers(state);
var dmap = {};
(state.deltas || []).forEach(function (d) { dmap[d.player_id] = d.delta || 0; });
var max = Math.max.apply(null, ps.map(function (p) { return p.score || 0; }).concat([1]));
var r = state.round;
return '<h1 class="sc-title" data-k="st">Stillingen' + (r ? ' efter ' + esc(r.name || ('Runde ' + r.number)) : '') + '</h1>' +
'<ol class="board" data-k="board:' + esc(ps.map(function (p) { return p.id + ':' + p.score; }).join(',')) + '">' + ps.map(function (p, i) {
var d = dmap[p.id] || 0;
return '<li style="--pc:' + esc(p.color || '#888') + ';--i:' + i + ';--w:' + Math.max(4, Math.round(100 * (p.score || 0) / max)) + '%">' +
'<span class="rk">' + (i + 1) + '</span>' + avatar(p) + '<span class="n">' + esc(p.name) + '</span>' +
'<span class="bar"><i></i></span>' +
(d ? '<span class="d">' + WPP.signed(d) + '</span>' : '<span class="d zero"></span>') +
'<b class="sc" data-count-from="' + ((p.score || 0) - d) + '" data-count-to="' + (p.score || 0) + '" data-count-delay="' + (300 + i * 120) + '">' +
WPP.fmt(p.score) + '</b></li>';
}).join('') + '</ol>' +
'<div class="hs-timer small" data-k="tm:' + (state.deadline || 0) + '">' + WPP.timerHtml(state, 'ring') + '</div>';
}
function finalView(state) {
var fin = state.final || {};
var players = WPP.playerMap(state);
var rank = (fin.ranking || []).slice().sort(function (a, b) { return (a.place || 99) - (b.place || 99); });
var top = rank.slice(0, 3);
var order = [top[1], top[0], top[2]];
var pod = order.map(function (r, idx) {
if (!r) return '<div class="pod empty"></div>';
var p = players[r.player_id] || { name: '?', color: '#888' };
var cls = ['second', 'first', 'third'][idx];
return '<div class="pod ' + cls + '" style="--pc:' + esc(p.color || '#888') + '">' +
(r.place === 1 ? '<svg class="crown" viewBox="0 0 64 44" aria-hidden="true"><path d="M4 40 L0 8 L18 22 L32 0 L46 22 L64 8 L60 40 Z"/></svg>' : '') + avatar(p) + '<div class="pn">' + esc(p.name) + '</div>' +
'<div class="ps">' + WPP.fmt(r.score) + '</div><div class="block"><span>' + r.place + '</span></div></div>';
}).join('');
var rest = rank.slice(3).map(function (r) {
var p = players[r.player_id] || { name: '?' };
return '<li><span class="rk">' + r.place + '.</span>' + esc(p.name) + '<b>' + WPP.fmt(r.score) + '</b></li>';
}).join('');
var aw = (fin.awards || []).map(function (a, i) {
return '<li style="--i:' + i + '"><span class="at">' + esc(a.title) + '</span>' + WPP.chip(players[a.player_id]) +
(a.detail ? '<span class="ad">' + esc(a.detail) + '</span>' : '') + '</li>';
}).join('');
var w = top[0] && players[top[0].player_id];
return '<h1 class="fin-title" data-k="ft">' + (w ? esc(w.name) + ' vinder!' : 'Spillet er slut!') + '</h1>' +
'<section class="podium" data-k="pod">' + pod + '</section>' +
'<section class="fin-side" data-k="side">' +
(aw ? '<h2>Priser</h2><ul class="awards">' + aw + '</ul>' : '') +
(rest ? '<ol class="rest">' + rest + '</ol>' : '') +
'<button class="btn btn-go btn-xl" data-act="play_again">Spil igen</button></section>';
}
/* ---------- hændelser ---------- */
function bind(root) {
if (root._bound) return;
root._bound = true;
root.addEventListener('click', function (e) {
var b = e.target.closest('[data-act]');
if (!b || !root.contains(b) || b.disabled) return;
var u = ui(root), ctx = u.ctx || {};
var a = b.getAttribute('data-act');
WPP.sound.unlock();
if (a === 'mute') {
WPP.sound.setMuted(!WPP.sound.muted);
if (u.state) renderHost(root, u.state, ctx);
return;
}
if (!ctx.send) return;
if (a === 'kick') {
var nm = b.getAttribute('data-name') || '';
if (!ctx.harness && !window.confirm('Smid ' + nm + ' ud af spillet?')) return;
ctx.send({ action: 'kick', player_id: b.getAttribute('data-arg') });
return;
}
if (a === 'start' || a === 'skip' || a === 'play_again') {
ctx.send({ action: a });
b.disabled = true;
setTimeout(function () { b.disabled = false; }, 1500);
}
});
}
function hostTick(root) {
var u = ui(root), s = u.state;
if (!s || !root.isConnected) return;
if (s.phase === 'reveal') {
var pr = revealProgress(s, u);
if (pr.key !== u.revealKey) {
var prev = u.revealKey;
renderHost(root, s, u.ctx);
if (prev && !pr.done && pr.stage === 2) {
var st = (s.reveal.steps || [])[pr.i] || {};
WPP.sound.play(st.is_truth ? 'truth' : 'stamp');
} else if (prev && !pr.done && pr.stage === 0) WPP.sound.play('pop');
}
}
if (s.deadline && (s.phase === 'lie' || s.phase === 'guess' || s.phase === 'category')) {
var left = WPP.secondsLeft(s.deadline);
if (left !== u.lastTick) {
u.lastTick = left;
if (left > 0 && left <= 5) WPP.sound.play('tick');
}
}
}
/* ---------- offentlig render ---------- */
function renderHost(root, state, ctx) {
ctx = ctx || {};
state = state || {};
var u = ui(root);
u.state = state; u.ctx = ctx;
bind(root);
if (!root._ticking) { root._ticking = true; WPP.onTick(function () { hostTick(root); }); }
root.classList.add('host-root');
var sh = WPP.shell(root);
var phase = state.phase || 'lobby';
var main, foot = {};
switch (phase) {
case 'lobby': main = lobby(state, ctx); break;
case 'category': main = category(state); break;
case 'lie': main = lie(state); foot.doneChips = true; break;
case 'guess': main = guess(state); foot.doneChips = true; break;
case 'reveal': main = reveal(state, u); u.revealKey = revealProgress(state, u).key; break;
case 'scores': main = scores(state); break;
case 'final': main = finalView(state); break;
default: main = '<h1 data-k="x">Øjeblik…</h1>';
}
sh.screen.className = 'screen hs hs-' + phase;
WPP.patch(sh.screen, topBar(state) + '<main class="hs-main" data-k="main:' + phase + '" data-patch>' + main + '</main>' + footer(state, foot));
WPP.animateCounts(sh.screen);
if (u.lastPhase !== phase) {
if (u.lastPhase && !ctx.harness) WPP.sound.play('phase');
u.lastPhase = phase;
}
}
function mount(root) {
var code = (root.getAttribute('data-code') || '').toUpperCase();
var conn = null;
var ctx = { send: function (m) { return conn ? conn.send(m) : false; } };
renderHost(root, { phase: 'lobby', code: code, players: [], audience_count: 0, min_players: 2, max_players: 8, join_url: location.origin + '/?code=' + code }, ctx);
conn = WPP.connect('/ws/host/' + encodeURIComponent(code) + '/', {
root: root,
onMessage: function (msg) {
if (msg.type === 'state' && msg.state) renderHost(root, msg.state, ctx);
else if (msg.type === 'error') WPP.toast(root, msg.message || 'Ups, noget gik galt.');
}
});
return ctx;
}
window.WPPHost = { renderHost: renderHost, mount: mount, revealProgress: revealProgress };
window.renderHost = renderHost;
if (!window.WPP_HARNESS) {
var go = function () { var r = document.getElementById('host'); if (r) mount(r); };
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', go); else go();
}
})();
+500
View File
@@ -0,0 +1,500 @@
/* Fup og Fakta – party/game-show-tema. Ingen build-step. */
:root {
--bg0: #12042b;
--bg1: #2b0a5c;
--bg2: #4a0f7a;
--ink: #ffffff;
--muted: rgba(255, 255, 255, .72);
--faint: rgba(255, 255, 255, .14);
--card: rgba(255, 255, 255, .09);
--card-b: rgba(255, 255, 255, .18);
--pink: #ff3d8b;
--pink-d: #b5185a;
--yellow: #ffd23f;
--yellow-d: #c99a00;
--cyan: #2de2e6;
--cyan-d: #109ea2;
--green: #3ddc84;
--green-d: #1f9b55;
--orange: #ff8c42;
--orange-d: #c25a14;
--violet: #a56bff;
--violet-d: #6c35c9;
--red: #ff4757;
--navy: #1d0846;
--font: "Fredoka", "Baloo 2", ui-rounded, "SF Pro Rounded", "Segoe UI", system-ui, -apple-system, Roboto, sans-serif;
color-scheme: dark;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
font-family: var(--font);
color: var(--ink);
background: var(--bg0);
background-image:
radial-gradient(1200px 700px at 15% -10%, rgba(255, 61, 139, .35), transparent 60%),
radial-gradient(1000px 700px at 110% 10%, rgba(45, 226, 230, .22), transparent 60%),
radial-gradient(900px 900px at 50% 120%, rgba(255, 210, 63, .18), transparent 60%),
linear-gradient(160deg, var(--bg1), var(--bg0) 70%);
background-attachment: fixed;
min-height: 100vh;
min-height: 100dvh;
-webkit-font-smoothing: antialiased;
-webkit-tap-highlight-color: transparent;
}
button, input { font-family: inherit; }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.muted { color: var(--muted); }
.center { text-align: center; }
/* ---------- knapper ---------- */
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: .4em;
min-height: 52px; padding: .7em 1.2em;
font: 600 1.15rem/1.15 var(--font); letter-spacing: .01em;
color: var(--navy); background: var(--yellow);
border: 0; border-radius: 18px;
box-shadow: 0 6px 0 var(--yellow-d), 0 10px 24px rgba(0, 0, 0, .35);
cursor: pointer; user-select: none; touch-action: manipulation;
transition: transform .08s, box-shadow .08s, filter .15s, opacity .15s;
}
.btn:active:not(:disabled) { transform: translateY(5px); box-shadow: 0 1px 0 var(--yellow-d), 0 4px 10px rgba(0, 0, 0, .3); }
.btn:disabled { opacity: .45; cursor: not-allowed; filter: grayscale(.4); }
.btn:focus-visible { outline: 3px solid #fff; outline-offset: 3px; }
.btn-xl { width: 100%; min-height: 64px; font-size: 1.4rem; }
.btn-go { background: var(--green); box-shadow: 0 6px 0 var(--green-d), 0 10px 24px rgba(0, 0, 0, .35); }
.btn-go:active:not(:disabled) { box-shadow: 0 1px 0 var(--green-d); }
.btn-alt { background: var(--cyan); box-shadow: 0 6px 0 var(--cyan-d), 0 10px 24px rgba(0, 0, 0, .3); }
.btn-alt:active:not(:disabled) { box-shadow: 0 1px 0 var(--cyan-d); }
.c0 { --c: var(--pink); --cd: var(--pink-d); }
.c1 { --c: var(--cyan); --cd: var(--cyan-d); }
.c2 { --c: var(--yellow); --cd: var(--yellow-d); }
.c3 { --c: var(--green); --cd: var(--green-d); }
.c4 { --c: var(--orange); --cd: var(--orange-d); }
.c5 { --c: var(--violet); --cd: var(--violet-d); }
.busy [data-act]:not([data-act="like"]), .busy button[type="submit"] { opacity: .55; pointer-events: none; }
/* ---------- chips ---------- */
.chip {
display: inline-flex; align-items: center; gap: .4em;
padding: .25em .75em .25em .35em; border-radius: 999px;
background: color-mix(in srgb, var(--pc, #888) 28%, rgba(0, 0, 0, .25));
border: 2px solid var(--pc, #888);
font-weight: 600; white-space: nowrap; max-width: 100%; overflow: hidden; text-overflow: ellipsis;
}
.chip .dot, .dot { width: .85em; height: .85em; border-radius: 50%; background: var(--pc, #888); flex: none; box-shadow: 0 0 0 2px rgba(255, 255, 255, .5); }
.chip.off { opacity: .45; border-style: dashed; }
.chip.aud { --pc: #b9b2d6; }
/* ---------- toasts + forbindelse ---------- */
.wpp-root { position: relative; }
.toasts {
position: fixed; left: 50%; bottom: max(20px, env(safe-area-inset-bottom)); transform: translateX(-50%);
display: flex; flex-direction: column; gap: 8px; z-index: 50; width: min(92%, 480px); pointer-events: none;
}
.toast {
background: #fff; color: var(--navy); font-weight: 600; font-size: 1.05rem;
padding: 14px 18px; border-radius: 16px; border-left: 8px solid var(--pink);
box-shadow: 0 12px 30px rgba(0, 0, 0, .45); animation: toast-in .25s ease-out;
}
.toast.info { border-left-color: var(--cyan); }
.toast.out { opacity: 0; transform: translateY(8px); transition: .4s; }
@keyframes toast-in { from { opacity: 0; transform: translateY(16px) scale(.96); } }
.conn {
display: none; position: fixed; inset: 0; z-index: 60;
background: rgba(18, 4, 43, .72); backdrop-filter: blur(3px);
align-items: center; justify-content: center;
}
.offline > .conn { display: flex; }
.conn-box { display: flex; align-items: center; gap: 14px; font-size: 1.4rem; font-weight: 600; background: var(--bg1); padding: 18px 26px; border-radius: 20px; border: 2px solid var(--card-b); }
.spinner { width: 26px; height: 26px; border-radius: 50%; border: 4px solid var(--faint); border-top-color: var(--yellow); animation: spin .8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
/* ---------- nedtælling ---------- */
.tpill {
display: inline-flex; align-items: center; justify-content: center; min-width: 54px; height: 40px; padding: 0 12px;
border-radius: 999px; background: var(--yellow); color: var(--navy); font-weight: 700; font-size: 1.25rem;
background-image: linear-gradient(90deg, rgba(255, 255, 255, .45) calc(var(--frac, 1) * 100%), transparent 0);
font-variant-numeric: tabular-nums;
}
.tpill.urgent { background-color: var(--red); color: #fff; animation: pulse .5s ease-in-out infinite alternate; }
.ring { position: relative; width: 100%; aspect-ratio: 1; }
.ring svg { width: 100%; height: 100%; transform: rotate(-90deg); display: block; }
.ring circle { fill: none; stroke-width: 9; }
.ring .bg { stroke: rgba(255, 255, 255, .15); fill: rgba(0, 0, 0, .25); }
.ring .fg { stroke: var(--yellow); stroke-linecap: round; stroke-dasharray: 100 100; stroke-dashoffset: calc(100 - var(--frac, 1) * 100); transition: stroke-dashoffset .2s linear, stroke .3s; }
.ring .num { position: absolute; inset: 0; display: grid; place-items: center; font-weight: 700; font-size: 2.6em; font-variant-numeric: tabular-nums; }
.ring.urgent .fg { stroke: var(--red); }
.ring.urgent .num { color: var(--red); animation: pulse .5s ease-in-out infinite alternate; }
.tbar { height: 8px; border-radius: 4px; background: var(--faint); overflow: hidden; }
.tbar i { display: block; height: 100%; width: calc(var(--frac, 1) * 100%); background: var(--yellow); transition: width .2s linear; }
@keyframes pulse { to { transform: scale(1.08); } }
/* ---------- blank i spørgsmål ---------- */
.blank {
display: inline-block; min-width: 3.2em; height: .95em; vertical-align: -0.08em; margin: 0 .12em;
border-bottom: .12em solid var(--yellow); border-radius: .15em .15em .05em .05em;
background: rgba(255, 210, 63, .16);
}
.blank.filled {
min-width: 0; height: auto; padding: 0 .25em; border: 0; border-radius: .2em;
background: var(--green); color: var(--navy); animation: pop .5s cubic-bezier(.3, 1.6, .5, 1);
}
/* =====================================================================
TELEFON
===================================================================== */
.ph { max-width: 540px; margin: 0 auto; padding: 0 16px calc(28px + env(safe-area-inset-bottom)); font-size: 18px; }
.ph-top {
position: sticky; top: 0; z-index: 5; display: flex; align-items: center; gap: 10px;
padding: calc(10px + env(safe-area-inset-top)) 0 10px; margin-bottom: 8px;
background: linear-gradient(var(--bg0) 70%, transparent);
}
.ph-top > [data-k="who"] { flex: 1; min-width: 0; display: flex; }
.ph-code { font-weight: 700; letter-spacing: .12em; padding: 6px 10px; border-radius: 10px; background: var(--card); border: 1px solid var(--card-b); font-size: .95rem; }
.ph-main { display: flex; flex-direction: column; gap: 16px; }
.ph h2 { margin: 0; font-size: 1.7rem; line-height: 1.1; }
.ph h3 { margin: 0 0 10px; font-size: 1.05rem; color: var(--muted); font-weight: 600; }
.ph p { margin: 0; }
.hint { color: var(--muted); font-size: .98rem; line-height: 1.35; }
.card, .qcard, .plist, .hearts, .picked, .myawards {
background: var(--card); border: 2px solid var(--card-b); border-radius: 22px; padding: 18px;
}
.big { text-align: center; padding: 30px 10px 10px; }
.big h2 { font-size: 2rem; }
.big p { margin-top: 8px; color: var(--muted); }
.big.pop h2 { animation: pop .5s cubic-bezier(.3, 1.6, .5, 1); }
.big.pulse h2 { animation: breathe 1.6s ease-in-out infinite; }
.big.look h2 { font-size: 2.4rem; color: var(--yellow); animation: wiggle 1.4s ease-in-out infinite; }
@keyframes pop { from { transform: scale(.6); opacity: 0; } }
@keyframes breathe { 50% { opacity: .65; } }
@keyframes wiggle { 0%, 100% { transform: rotate(-2deg); } 50% { transform: rotate(2deg) translateY(-3px); } }
/* join */
.ph-join { padding-top: 24px; }
.ph-join .ph-main, .ph-join { display: flex; flex-direction: column; gap: 18px; }
.logo { text-align: center; line-height: .9; padding: 14px 0 4px; transform: rotate(-3deg); }
.logo-fup, .logo-fakta { font-weight: 700; font-size: 3.6rem; letter-spacing: .02em; text-shadow: 0 5px 0 rgba(0, 0, 0, .35); }
.logo-fup { color: var(--pink); }
.logo-fakta { color: var(--yellow); }
.logo-og { font-size: 1.3rem; color: var(--cyan); font-weight: 600; margin: 2px 0; }
.join-form { display: flex; flex-direction: column; gap: 16px; }
.join-form label { display: flex; flex-direction: column; gap: 6px; font-weight: 600; color: var(--muted); font-size: 1rem; }
.ph input[type="text"], .ph input:not([type]), .join-form input {
width: 100%; min-height: 56px; padding: 12px 16px; font-size: 1.3rem; font-weight: 600;
color: var(--navy); background: #fff; border: 3px solid transparent; border-radius: 16px; outline: none;
}
.ph input:focus { border-color: var(--yellow); box-shadow: 0 0 0 4px rgba(255, 210, 63, .3); }
.code-in { text-transform: uppercase; letter-spacing: .4em; text-align: center; font-size: 1.8rem !important; }
.join-err { background: var(--red); color: #fff; font-weight: 600; padding: 12px 16px; border-radius: 14px; text-align: center; }
/* lobby */
.hello { text-align: center; padding-top: 16px; }
.avatar {
width: 96px; height: 96px; margin: 0 auto 12px; border-radius: 50%; display: grid; place-items: center;
background: var(--pc); color: #fff; font-size: 3rem; font-weight: 700; text-shadow: 0 2px 0 rgba(0, 0, 0, .3);
box-shadow: 0 0 0 6px rgba(255, 255, 255, .2), 0 10px 30px color-mix(in srgb, var(--pc) 60%, transparent);
animation: pop .5s cubic-bezier(.3, 1.6, .5, 1);
}
.hello p { font-size: 1.3rem; color: var(--muted); margin-top: 4px; }
.chips { display: flex; flex-wrap: wrap; gap: 8px; }
.plist .muted { margin-top: 10px; }
.actions { display: flex; flex-direction: column; gap: 10px; margin-top: 6px; }
/* kategori */
.roundtag { align-self: center; font-weight: 700; color: var(--navy); background: var(--yellow); padding: 6px 16px; border-radius: 999px; font-size: 1rem; transform: rotate(-2deg); }
.ask { text-align: center; }
.opts { display: grid; gap: 14px; }
.btn-opt, .btn-ans {
width: 100%; min-height: 72px; font-size: 1.3rem; color: var(--navy);
background: var(--c); box-shadow: 0 6px 0 var(--cd), 0 10px 24px rgba(0, 0, 0, .3);
text-align: center; overflow-wrap: anywhere;
}
.btn-opt:active:not(:disabled), .btn-ans:active:not(:disabled) { box-shadow: 0 1px 0 var(--cd); }
.opts.ghost { opacity: .55; gap: 10px; }
.opt-ghost { padding: 14px; border-radius: 16px; border: 2px dashed var(--c); text-align: center; font-weight: 600; }
/* løgn */
.qcat { display: inline-block; font-size: .85rem; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; color: var(--cyan); margin-bottom: 6px; }
.qprompt { font-size: 1.35rem; font-weight: 600; line-height: 1.3; overflow-wrap: anywhere; }
.lie-form { display: flex; flex-direction: column; gap: 14px; }
.lie-form input.flash { animation: flash .6s; }
@keyframes flash { 0% { background: var(--yellow); } }
.mylie { text-align: center; color: var(--muted); overflow-wrap: anywhere; }
.mylie b { color: #fff; }
.progress { text-align: center; font-size: .95rem; }
/* gæt */
.answers { display: flex; flex-direction: column; gap: 14px; }
.btn-ans { min-height: 64px; font-size: 1.25rem; }
.picked { text-align: center; }
.picked p { color: var(--muted); }
.pick { font-size: 1.6rem; font-weight: 700; margin-top: 6px; color: var(--yellow); overflow-wrap: anywhere; }
.hearts .hrow { display: flex; align-items: center; gap: 12px; padding: 6px 0; border-top: 1px solid var(--faint); }
.hearts .hrow:first-of-type { border-top: 0; }
.hrow span { flex: 1; font-weight: 600; overflow-wrap: anywhere; }
.heart {
flex: none; width: 52px; height: 48px; border-radius: 14px; border: 2px solid var(--card-b);
background: rgba(0, 0, 0, .2); color: rgba(255, 255, 255, .45); font-size: 1.5rem; cursor: pointer; touch-action: manipulation;
}
.heart.on { color: #fff; background: var(--pink); border-color: var(--pink); animation: pop .35s cubic-bezier(.3, 1.6, .5, 1); }
/* reveal/score */
.delta { text-align: center; display: flex; flex-direction: column; align-items: center; }
.delta b { font-size: 3.4rem; line-height: 1; color: var(--muted); }
.delta.plus b { color: var(--green); animation: pop .6s cubic-bezier(.3, 1.6, .5, 1); }
.delta span { color: var(--muted); }
.place { text-align: center; padding: 10px 0; }
.pnum { font-size: 5rem; font-weight: 700; line-height: 1; color: var(--yellow); text-shadow: 0 6px 0 rgba(0, 0, 0, .3); animation: pop .6s cubic-bezier(.3, 1.6, .5, 1); }
.place .score { font-size: 1.5rem; font-weight: 700; margin-top: 6px; }
.place.win .pnum { color: var(--yellow); font-size: 6rem; }
.dchip { display: inline-block; margin-top: 8px; background: var(--green); color: var(--navy); font-weight: 700; padding: 4px 14px; border-radius: 999px; }
.mini-board { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.mini-board li { display: flex; align-items: center; gap: 10px; padding: 10px 14px; background: var(--card); border-radius: 14px; }
.mini-board li.mine { outline: 3px solid var(--pc); }
.mini-board .n { flex: 1; font-weight: 600; overflow: hidden; text-overflow: ellipsis; }
.award { display: flex; flex-direction: column; padding: 6px 0; }
.award b { color: var(--yellow); }
.award span { color: var(--muted); font-size: .95rem; }
/* =====================================================================
STORSKÆRM – alt i em, skaleret fra containerens størrelse
===================================================================== */
.host-root { container-type: size; overflow: hidden; }
#host { width: 100vw; height: 100vh; height: 100dvh; }
.hs {
position: absolute; inset: 0; display: grid; grid-template-rows: auto 1fr auto;
font-size: min(1.25cqw, 2.222cqh); padding: .9em 1.6em 1em; overflow: hidden;
background:
repeating-conic-gradient(from 0deg at 50% 115%, rgba(255, 255, 255, .035) 0 6deg, transparent 6deg 14deg),
radial-gradient(80% 60% at 50% 110%, rgba(255, 210, 63, .18), transparent 70%);
}
.hs .toasts, .host-root .toasts { font-size: min(1.25cqw, 2.222cqh); }
.hs-top { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 1em; min-height: 3em; }
.brand { font-weight: 700; font-size: 1.6em; transform: rotate(-2deg); white-space: nowrap; }
.b-fup { color: var(--pink); } .b-og { color: var(--cyan); font-size: .6em; } .b-fakta { color: var(--yellow); }
.rt { text-align: center; display: flex; flex-direction: column; align-items: center; }
.rt-name { font-weight: 700; font-size: 1.5em; background: var(--yellow); color: var(--navy); padding: .1em .8em; border-radius: 999px; transform: rotate(-1.5deg); white-space: nowrap; }
.rt-q { color: var(--muted); font-size: 1em; margin-top: .25em; }
.joininfo { justify-self: end; text-align: right; font-size: 1.1em; color: var(--muted); }
.joininfo b { color: #fff; letter-spacing: .12em; background: var(--card); padding: .1em .4em; border-radius: .3em; margin-left: .2em; }
.audn { display: block; font-size: .85em; margin-top: .2em; }
.hs-main { position: relative; min-height: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1.2em; }
.hs-foot { display: flex; align-items: flex-end; justify-content: space-between; gap: 1em; min-height: 2.6em; }
.pchips { display: flex; flex-wrap: wrap; gap: .6em; }
.pchip {
display: inline-flex; align-items: center; gap: .45em; font-size: 1.25em; font-weight: 600;
padding: .3em .9em .3em .45em; border-radius: 999px; border: .12em solid rgba(255, 255, 255, .25);
background: rgba(0, 0, 0, .25); opacity: .55; transition: all .35s;
}
.pchip.done { opacity: 1; border-color: var(--pc); background: color-mix(in srgb, var(--pc) 45%, transparent); box-shadow: 0 0 1.2em color-mix(in srgb, var(--pc) 70%, transparent); animation: pop .4s cubic-bezier(.3, 1.6, .5, 1); }
.pchip.off { border-style: dashed; }
.pchip .ck { display: inline-block; width: .35em; height: .7em; margin: 0 .15em -.05em .1em; border: solid #fff; border-width: 0 .14em .14em 0; transform: rotate(45deg) translateY(-.15em); }
.ctl { display: flex; gap: .6em; flex: none; }
.hbtn {
font: 600 1em/1 var(--font); color: #fff; background: rgba(255, 255, 255, .12); border: .1em solid var(--card-b);
padding: .55em 1em; border-radius: 999px; cursor: pointer; opacity: .75;
}
.hbtn:hover { opacity: 1; background: rgba(255, 255, 255, .22); }
.hbtn.ghost { background: transparent; }
.hbtn:disabled { opacity: .3; }
.hs .btn { font-size: 1.4em; min-height: 0; padding: .6em 1.4em; border-radius: .7em; box-shadow: 0 .25em 0 var(--green-d), 0 .4em 1em rgba(0, 0, 0, .35); }
.hs .btn-xl { width: auto; font-size: 1.8em; }
.hs-timer { position: absolute; top: 0; right: 0; width: 7.5em; }
.hs-timer.small { width: 5em; }
.hs-timer .ring .num { font-size: 2.6em; }
.hs-timer.small .ring .num { font-size: 1.7em; }
/* lobby */
.hs-lobby .hs-main { flex-direction: row; align-items: stretch; gap: 2.5em; }
.lobby-join { flex: 0 0 38%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1.2em; text-align: center; }
.lj-text p { margin: 0; }
.lj-go { font-size: 1.5em; color: var(--muted); }
.lj-url { font-size: 2.3em; font-weight: 700; color: var(--cyan); margin: .05em 0 .1em !important; white-space: nowrap; }
.ctiles { display: flex; gap: .35em; justify-content: center; margin-top: .4em; }
.ctile {
display: grid; place-items: center; width: 1.25em; height: 1.45em; font-size: 4.6em; font-weight: 700; line-height: 1;
color: var(--navy); background: var(--yellow); border-radius: .18em;
box-shadow: 0 .09em 0 var(--yellow-d), 0 .2em .4em rgba(0, 0, 0, .35);
animation: tile-in .6s cubic-bezier(.3, 1.6, .5, 1) both; animation-delay: calc(var(--i) * .1s);
transform: rotate(calc((var(--i) - 1.5) * 2deg));
}
@keyframes tile-in { from { transform: translateY(-1em) rotate(-20deg); opacity: 0; } }
.qr { display: flex; flex-direction: column; align-items: center; gap: .4em; color: var(--muted); }
.qr img { width: 10em; height: 10em; background: #fff; padding: .5em; border-radius: .6em; box-shadow: 0 .5em 1.5em rgba(0, 0, 0, .4); }
.lobby-players { flex: 1; display: flex; flex-direction: column; gap: 1em; min-width: 0; justify-content: center; }
.lobby-players h2 { margin: 0; font-size: 2em; }
.lobby-players h2 span { color: var(--muted); font-weight: 500; }
.pgrid { display: grid; grid-template-columns: repeat(4, 1fr); gap: .9em; }
.pcard {
position: relative; display: flex; flex-direction: column; align-items: center; gap: .4em; padding: 1em .5em .9em;
border-radius: 1em; background: color-mix(in srgb, var(--pc) 22%, rgba(0, 0, 0, .25)); border: .15em solid var(--pc);
animation: pop .45s cubic-bezier(.3, 1.6, .5, 1); min-width: 0;
}
.pcard.empty { --pc: rgba(255, 255, 255, .2); border-style: dashed; background: transparent; animation: none; opacity: .6; }
.pcard.empty .av { background: transparent; border: .1em dashed rgba(255, 255, 255, .3); color: rgba(255, 255, 255, .35); box-shadow: none; }
.pcard.off { opacity: .5; }
.av {
display: grid; place-items: center; width: 2.4em; height: 2.4em; border-radius: 50%; flex: none;
background: var(--pc); color: #fff; font-weight: 700; font-size: 1.6em; text-shadow: 0 .06em 0 rgba(0, 0, 0, .3);
box-shadow: 0 0 0 .12em rgba(255, 255, 255, .25);
}
.pname { font-size: 1.4em; font-weight: 600; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.vip { position: absolute; top: -.6em; left: 50%; transform: translateX(-50%) rotate(-4deg); background: var(--yellow); color: var(--navy); font-weight: 700; font-size: .9em; padding: .05em .6em; border-radius: .4em; }
.offl { font-size: .9em; color: var(--muted); }
.kick {
position: absolute; top: .3em; right: .3em; width: 1.5em; height: 1.5em; border-radius: 50%; border: 0;
background: rgba(0, 0, 0, .3); color: rgba(255, 255, 255, .6); font-size: 1em; line-height: 1; cursor: pointer; opacity: .35;
}
.pcard:hover .kick, .kick:focus-visible { opacity: 1; }
.kick:hover { background: var(--red); color: #fff; }
.lobby-start { display: flex; align-items: center; gap: 1.2em; margin-top: .4em; }
.ls-hint { color: var(--muted); font-size: 1.2em; line-height: 1.35; }
.ls-hint b { color: #fff; }
/* kategori */
.cat-banner { text-align: center; }
.cb-round { font-size: 3.6em; font-weight: 700; color: var(--yellow); text-shadow: 0 .08em 0 rgba(0, 0, 0, .35); animation: pop .6s cubic-bezier(.3, 1.6, .5, 1); line-height: 1.05; }
.cb-q { font-size: 1.4em; color: var(--muted); margin-top: .2em; }
.cat-who { font-size: 2em; display: flex; align-items: center; gap: .4em; }
.chip.xl { font-size: 1em; padding: .2em .8em .2em .3em; }
.cat-who > span { color: var(--muted); }
.cat-opts { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1em; width: 100%; max-width: 70em; }
.cat-opt {
display: grid; place-items: center; text-align: center; min-height: 5em; padding: .6em; border-radius: .7em;
font-size: 1.7em; font-weight: 700; color: var(--navy); background: var(--c); box-shadow: 0 .2em 0 var(--cd);
animation: tile-in .5s cubic-bezier(.3, 1.6, .5, 1) both; animation-delay: calc(var(--i) * .08s + .2s);
}
/* spørgsmål */
.qbox { text-align: center; max-width: 52em; width: 100%; }
.qbox .qcat { font-size: 1.3em; padding: .2em .8em; border-radius: 999px; background: rgba(45, 226, 230, .15); border: .08em solid var(--cyan); margin-bottom: .5em; }
.qbox .qprompt { font-size: 3.4em; line-height: 1.18; font-weight: 700; text-wrap: balance; text-shadow: 0 .05em 0 rgba(0, 0, 0, .3); }
.qbox.lie { animation: pop .5s cubic-bezier(.3, 1.6, .5, 1); padding: 0 6em; max-width: none; }
.qbox.small .qprompt { font-size: 2.2em; }
.qbox.small { padding: 0 7em; max-width: none; }
.instr { font-size: 1.6em; color: var(--muted); margin: 0; }
.instr b { color: var(--yellow); }
/* gæt */
.ans-grid { display: grid; gap: .9em; width: 100%; max-width: 72em; }
.ans-grid.cols2 { grid-template-columns: repeat(2, 1fr); }
.ans-grid.cols3 { grid-template-columns: repeat(3, 1fr); }
.ans-tile {
display: grid; place-items: center; text-align: center; min-height: 2.6em; padding: .35em .6em; border-radius: .55em;
font-size: 1.9em; font-weight: 700; color: var(--navy); background: var(--c); box-shadow: 0 .14em 0 var(--cd), 0 .3em .6em rgba(0, 0, 0, .25);
overflow-wrap: anywhere; animation: tile-in .45s cubic-bezier(.3, 1.6, .5, 1) both; animation-delay: calc(var(--i) * .07s);
}
/* reveal */
.hs-reveal .hs-main { justify-content: flex-start; gap: 1.4em; padding-top: .4em; }
.rv-stage { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1em; width: 100%; position: relative; }
.rv-ans {
font-size: 4em; font-weight: 700; color: var(--navy); background: #fff; padding: .25em .9em; border-radius: .35em;
box-shadow: 0 .12em 0 #c9c1e6, 0 .3em .8em rgba(0, 0, 0, .4); animation: tile-in .55s cubic-bezier(.3, 1.6, .5, 1);
max-width: 90%; text-align: center; overflow-wrap: anywhere;
}
.is-truth .rv-ans { box-shadow: 0 .12em 0 var(--green-d), 0 0 1.5em rgba(61, 220, 132, .6); }
.rv-who { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: .6em; font-size: 1.5em; animation: rise .4s ease-out; }
.rv-who .lbl { color: var(--muted); }
.rv-who .chip.pop { animation: pop .4s cubic-bezier(.3, 1.6, .5, 1) both; }
.rv-who .audv { color: var(--cyan); font-weight: 600; }
.rv-who .none { color: var(--muted); font-style: italic; }
@keyframes rise { from { transform: translateY(.6em); opacity: 0; } }
.rv-res { display: flex; flex-direction: column; align-items: center; gap: .5em; margin-top: .6em; }
.rv-res .stamp {
font-size: 4.6em; font-weight: 700; letter-spacing: .08em; color: var(--red); border: .1em solid var(--red); border-radius: .2em;
padding: 0 .35em; line-height: 1.15; transform: rotate(-8deg); background: rgba(255, 71, 87, .1);
text-shadow: 0 0 .3em rgba(255, 71, 87, .5); animation: stamp .35s cubic-bezier(.2, 1.4, .5, 1) both;
}
.rv-res.house .stamp { color: var(--orange); border-color: var(--orange); background: rgba(255, 140, 66, .12); font-size: 3.6em; }
@keyframes stamp { from { transform: scale(3) rotate(-20deg); opacity: 0; } }
.rv-stage:has(.rv-res) .rv-who { opacity: .8; }
.rv-res .by { font-size: 1.8em; font-weight: 600; animation: rise .4s .25s both; }
.rv-res .pts { display: flex; flex-wrap: wrap; justify-content: center; gap: .8em; font-size: 1.5em; animation: rise .4s .4s both; }
.rv-res .pt { display: inline-flex; align-items: center; gap: .3em; }
.rv-res .pt b { color: var(--green); }
.rv-res .likes { font-size: 1.3em; color: var(--pink); font-weight: 700; animation: rise .4s .5s both; }
.truth-banner {
font-size: 4em; font-weight: 700; letter-spacing: .1em; color: var(--navy); padding: .05em .7em; border-radius: .25em;
background: linear-gradient(90deg, var(--green), var(--yellow), var(--green)); background-size: 200% 100%;
box-shadow: 0 0 1.4em rgba(255, 210, 63, .6); animation: pop .5s cubic-bezier(.3, 1.6, .5, 1) both, shimmer 2s linear infinite;
}
@keyframes shimmer { to { background-position: -200% 0; } }
.rv-dots { display: flex; gap: .5em; }
.rv-dots i { width: .9em; height: .9em; border-radius: 50%; background: var(--faint); }
.rv-dots i.past { background: var(--muted); }
.rv-dots i.now { background: var(--yellow); transform: scale(1.3); }
.rv-dots i.t { outline: .12em solid var(--green); }
.rv-sum { width: 100%; max-width: 44em; flex: 1; display: flex; flex-direction: column; justify-content: center; }
.rv-sum h2 { text-align: center; margin: 0 0 .5em; font-size: 2em; }
.rv-sum ol, .board { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: .45em; }
.rv-sum li { display: flex; align-items: center; gap: .7em; font-size: 1.4em; background: var(--card); padding: .3em .7em; border-radius: .6em; animation: rise .35s both; animation-delay: calc(var(--i) * .06s); }
.rv-sum li .av { font-size: .8em; }
.rv-sum .n { flex: 1; font-weight: 600; }
.rv-sum .d { color: var(--muted); min-width: 4em; text-align: right; }
.rv-sum .d.plus { color: var(--green); font-weight: 700; }
.rv-sum li b { min-width: 4.5em; text-align: right; }
/* stilling */
.hs-scores .hs-main { justify-content: flex-start; padding-top: .5em; }
.sc-title { margin: 0; font-size: 3em; color: var(--yellow); text-shadow: 0 .08em 0 rgba(0, 0, 0, .35); animation: pop .5s cubic-bezier(.3, 1.6, .5, 1); }
.board { width: 100%; max-width: 60em; }
.board li {
display: grid; grid-template-columns: 1.6em auto 9em 1fr 4.5em 5em; align-items: center; gap: .7em; font-size: 1.5em;
padding: .35em .8em; border-radius: .7em; background: var(--card); border-left: .3em solid var(--pc);
animation: slide-in .5s cubic-bezier(.2, 1.2, .4, 1) both; animation-delay: calc(var(--i) * .12s);
}
.board li:first-child { background: color-mix(in srgb, var(--yellow) 18%, var(--card)); }
.board .rk { font-weight: 700; color: var(--muted); text-align: center; }
.board .av { font-size: .7em; }
.board .n { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.board .bar { height: .5em; background: rgba(255, 255, 255, .08); border-radius: .25em; overflow: hidden; }
.board .bar i { display: block; height: 100%; width: var(--w); background: var(--pc); border-radius: .25em; animation: grow 1.2s cubic-bezier(.2, .8, .3, 1) both; animation-delay: calc(var(--i) * .12s + .3s); }
.board .d { color: var(--green); font-weight: 700; text-align: right; animation: pop .4s both; animation-delay: calc(var(--i) * .12s + .5s); }
.board .sc { text-align: right; font-variant-numeric: tabular-nums; }
@keyframes slide-in { from { transform: translateX(-2em); opacity: 0; } }
@keyframes grow { from { width: 0; } }
/* finale */
.hs-final .hs-main { display: grid; grid-template-columns: 1.4fr 1fr; grid-template-rows: auto 1fr; gap: 1em 2.5em; align-items: end; justify-items: center; }
.fin-title { grid-column: 1 / -1; margin: 0; font-size: 3.4em; color: var(--yellow); text-shadow: 0 .08em 0 rgba(0, 0, 0, .35); animation: pop .6s cubic-bezier(.3, 1.6, .5, 1); }
.podium { display: flex; align-items: flex-end; justify-content: center; gap: 1em; width: 100%; height: 100%; }
.pod { flex: 1; max-width: 12em; display: flex; flex-direction: column; align-items: center; gap: .3em; animation: rise .7s both; }
.pod.second { animation-delay: .5s; } .pod.third { animation-delay: .2s; } .pod.first { animation-delay: .9s; }
.pod .av { font-size: 1.8em; }
.pod.first .av { font-size: 2.3em; box-shadow: 0 0 0 .1em var(--yellow), 0 0 1em rgba(255, 210, 63, .6); }
.pn { font-size: 1.5em; font-weight: 700; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ps { color: var(--muted); font-size: 1.2em; }
.crown { width: 4em; height: auto; fill: var(--yellow); filter: drop-shadow(0 .2em .4em rgba(255, 210, 63, .5)); animation: wiggle 1.4s ease-in-out infinite; }
.block { width: 100%; border-radius: .6em .6em 0 0; display: grid; place-items: start center; padding-top: .3em; background: linear-gradient(var(--pc), color-mix(in srgb, var(--pc) 40%, #000)); }
.block span { font-size: 3em; font-weight: 700; text-shadow: 0 .06em 0 rgba(0, 0, 0, .35); }
.pod.first .block { height: 9.5em; } .pod.second .block { height: 6.5em; } .pod.third .block { height: 4.5em; }
.pod.empty { visibility: hidden; }
.fin-side { align-self: stretch; width: 100%; display: flex; flex-direction: column; justify-content: center; gap: .7em; padding-bottom: .6em; }
.fin-side h2 { margin: 0; font-size: 2em; }
.awards { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: .6em; }
.awards li { display: flex; flex-wrap: wrap; align-items: center; gap: .2em .6em; font-size: 1.2em; background: var(--card); border: .08em solid var(--card-b); padding: .4em .8em; border-radius: .7em; animation: rise .5s both; animation-delay: calc(1.2s + var(--i) * .2s); }
.awards .at { font-weight: 700; color: var(--yellow); width: 100%; }
.awards .ad { color: var(--muted); }
.rest { margin: 0; padding: 0; list-style: none; display: flex; flex-wrap: wrap; gap: .3em 1.2em; font-size: 1.2em; color: var(--muted); }
.rest .rk { margin-right: .3em; }
.rest b { margin-left: .4em; color: #fff; font-weight: 600; }
.fin-side .btn { align-self: flex-start; margin-top: .3em; }
/* host_new */
.landing { min-height: 100vh; min-height: 100dvh; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 24px; text-align: center; padding: 24px 16px; }
.landing h1 { margin: 0; font-size: clamp(2.2rem, 7vw, 5.5rem); line-height: 1; letter-spacing: .01em; text-shadow: 0 6px 0 rgba(0, 0, 0, .35); }
.landing .sub { font-size: clamp(1.6rem, 5vw, 3.6rem); font-weight: 700; transform: rotate(-3deg); }
.landing .sub .b-og { font-size: .55em; }
.landing .btn { font-size: clamp(1.3rem, 3vw, 2rem); padding: .7em 1.6em; }
.landing .who { color: var(--muted); }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
}
.page-host { overflow: hidden; }
/* forklaringen under sandheden */
.rv-res.truth .note { margin: 18px auto 0; max-width: 1100px; font-size: clamp(18px, 1.6vw, 30px); line-height: 1.35; opacity: .85; font-weight: 500; }
+442
View File
@@ -0,0 +1,442 @@
/* Fup og Fakta – telefon. Eksporterer window.WPPPlay = {renderPlay, renderJoin, renderWaiting, mount}. */
(function () {
'use strict';
var WPP = window.WPP, esc = WPP.esc;
function ui(root) {
return root._ui || (root._ui = { busy: false, busyTimer: null, drafts: {}, lastPhase: null });
}
/* ---------- travlhed (forhindrer dobbelt-tryk) ---------- */
function setBusy(root, on) {
var u = ui(root);
u.busy = !!on;
root.classList.toggle('busy', u.busy);
clearTimeout(u.busyTimer);
if (on) u.busyTimer = setTimeout(function () { setBusy(root, false); }, 6000);
}
function act(root, msg) {
var ctx = root._ctx || {};
if (ui(root).busy) return;
if (!ctx.send) return;
var ok = ctx.send(msg);
if (ok !== false && !ctx.harness) setBusy(root, true);
}
/* ---------- hændelser (delegeret, bindes én gang pr. rod) ---------- */
function bind(root) {
if (root._bound) return;
root._bound = true;
root.addEventListener('click', function (e) {
var b = e.target.closest('[data-act]');
if (!b || !root.contains(b) || b.disabled) return;
e.preventDefault();
var a = b.getAttribute('data-act'), arg = b.getAttribute('data-arg');
if (a === 'choose_category') act(root, { action: a, category_id: isNaN(Number(arg)) ? arg : Number(arg) });
else if (a === 'guess' || a === 'like') {
if (a === 'like') { var c = root._ctx || {}; if (c.send) c.send({ action: 'like', answer_id: arg }); b.classList.toggle('on'); }
else act(root, { action: a, answer_id: arg });
}
else if (a === 'lie_for_me') act(root, { action: a });
else if (a === 'start' || a === 'play_again') act(root, { action: a });
else if (a === 'forget') { if (root._ctx && root._ctx.onForget) root._ctx.onForget(); }
});
root.addEventListener('input', function (e) {
var t = e.target;
if (t.id === 'wpp-lie') ui(root).drafts[t.getAttribute('data-q') || ''] = t.value;
if (t.name === 'code') {
var v = t.value.toUpperCase().replace(/[^A-Z]/g, '').slice(0, 4);
if (v !== t.value) t.value = v;
}
});
root.addEventListener('submit', function (e) {
var f = e.target;
e.preventDefault();
if (f.classList.contains('lie-form')) {
var inp = f.querySelector('#wpp-lie');
var text = (inp.value || '').trim();
if (!text) { WPP.toast(root, 'Skriv lige en løgn først.'); inp.focus(); return; }
act(root, { action: 'submit_lie', text: text.slice(0, 40) });
} else if (f.classList.contains('join-form')) {
var code = (f.code.value || '').toUpperCase().trim();
var name = (f.name.value || '').trim();
if (code.length !== 4) { WPP.toast(root, 'Koden er 4 bogstaver.'); f.code.focus(); return; }
if (!name) { WPP.toast(root, 'Skriv dit navn.'); f.name.focus(); return; }
if (root._ctx && root._ctx.onJoin) {
if (!root._ctx.harness) setBusy(root, true);
root._ctx.onJoin(code, name.slice(0, 12));
}
}
});
}
/* ---------- skærme ---------- */
function topBar(state, me, players) {
var p = me && players[me.id];
var who;
if (p) who = WPP.chip(p, 'me');
else if (me && me.role === 'audience') who = '<span class="chip aud"><span class="dot"></span>Publikum</span>';
else who = '';
return '<header class="ph-top" data-k="top" data-patch>' +
'<span class="ph-code" data-k="code">' + esc(state.code || '') + '</span>' +
'<span data-k="who">' + who + '</span>' +
'<span class="ph-timer" data-k="t' + (state.deadline || 0) + '">' + WPP.timerHtml(state, 'pill') + '</span>' +
'</header>';
}
function questionCard(state, k) {
var q = state.question || {};
return '<section class="qcard" data-k="' + esc(k) + '">' +
(q.category ? '<div class="qcat">' + esc(q.category) + '</div>' : '') +
'<div class="qprompt">' + WPP.promptHtml(q.prompt || '') + '</div></section>';
}
function big(title, sub, k, cls) {
return '<section class="big ' + (cls || '') + '" data-k="' + (k || 'big') + '"><h2>' + title + '</h2>' +
(sub ? '<p>' + sub + '</p>' : '') + '</section>';
}
function screenLobby(state, me, players) {
var list = state.players || [];
var out = '';
if (me.role === 'audience') {
out += big('Du er publikum!', 'Spillet er fyldt op eller allerede i gang. Du kan stadig gætte med og dele hjerter ud.', 'aud', 'pop');
} else {
var p = players[me.id];
out += '<section class="hello" data-k="hello"><div class="avatar" style="--pc:' + esc((p && p.color) || '#888') + '">' +
esc(((p && p.name) || '?').charAt(0).toUpperCase()) + '</div><h2>Du er med!</h2>' +
'<p>' + (p ? esc(p.name) : '') + '</p></section>';
}
out += '<section class="plist" data-k="plist"><h3>Spillere (' + list.length + '/' + (state.max_players || 8) + ')</h3><div class="chips">' +
list.map(function (q) { return WPP.chip(q, q.connected === false ? 'off' : ''); }).join('') +
'</div>' + (state.audience_count ? '<p class="muted">+ ' + state.audience_count + ' publikum</p>' : '') + '</section>';
var min = state.min_players || 2;
if (me.vip) {
var enough = list.length >= min;
out += '<section class="actions" data-k="start' + (enough ? 1 : 0) + '">' +
'<button class="btn btn-go btn-xl" data-act="start"' + (enough ? '' : ' disabled') + '>Alle er med – start!</button>' +
(enough ? '<p class="hint">Du er VIP, så du bestemmer, hvornår vi går i gang.</p>'
: '<p class="hint">Der skal være mindst ' + min + ' spillere. Få flere med!</p>') + '</section>';
} else {
var vip = list.filter(function (q) { return q.vip; })[0];
out += '<p class="hint center" data-k="wait">' + (vip ? esc(vip.name) + ' starter spillet, når alle er klar.' : 'Venter på at spillet starter…') + '</p>';
}
return out;
}
function screenCategory(state, me, players) {
var cat = state.category || {};
var opts = cat.options || [];
var chooser = players[cat.chooser_id];
var head = '<div class="roundtag" data-k="rt">' + WPP.roundTitle(state.round) + '</div>';
if (me.is_chooser) {
return head + '<h2 class="ask" data-k="ask">Vælg en kategori!</h2>' +
'<section class="opts" data-k="opts">' + opts.map(function (o, i) {
return '<button class="btn btn-opt c' + (i % 4) + '" data-act="choose_category" data-arg="' + esc(o.id) + '">' + esc(o.name) + '</button>';
}).join('') + '</section>';
}
return head + big((chooser ? esc(chooser.name) : 'Nogen') + ' vælger kategori…', 'Hold dig klar til at lyve!', 'wait', 'pulse') +
'<section class="opts ghost" data-k="opts">' + opts.map(function (o, i) {
return '<div class="opt-ghost c' + (i % 4) + '">' + esc(o.name) + '</div>';
}).join('') + '</section>';
}
function screenLie(state, me, players, root) {
var q = state.question || {};
var qk = (state.round ? state.round.number + '.' + state.round.question_number : '') + ':' + (q.prompt || '');
var out = questionCard(state, 'q:' + qk);
if (me.role === 'audience') return out + big('Spillerne finder på løgne…', 'Snart skal du gætte, hvad der er sandt.', 'aud', 'pulse');
if (me.lie) {
out += big('Godt løjet!', 'Venter på de andre…', 'done', 'pop') +
'<p class="mylie" data-k="mylie">Din løgn: <b>' + esc(me.lie) + '</b></p>';
return out + progress(state);
}
out += '<form class="lie-form" data-k="form:' + esc(qk) + '" autocomplete="off">' +
'<label for="wpp-lie" class="sr">Din løgn</label>' +
'<input id="wpp-lie" data-q="' + esc(qk) + '" type="text" maxlength="40" enterkeyhint="send" autocomplete="off" ' +
'autocorrect="off" spellcheck="false" placeholder="Skriv en troværdig løgn…">' +
'<button type="submit" class="btn btn-go btn-xl">Send løgn</button>' +
'<button type="button" class="btn btn-alt" data-act="lie_for_me">Lyv for mig</button>' +
'</form>';
return out + progress(state);
}
function progress(state) {
var ps = (state.players || []);
var done = ps.filter(function (p) { return p.done; }).length;
return '<p class="progress muted" data-k="prog">' + done + ' af ' + ps.length + ' er færdige</p>';
}
function heartList(state, me, excludeIds, k) {
var ans = (state.answers || []).filter(function (a) {
if (excludeIds.indexOf(a.id) >= 0) return false;
if (me.own_answer_id && a.id === me.own_answer_id) return false;
if (state.reveal && (state.reveal.steps || []).some(function (st) { return st.answer_id === a.id && st.is_truth; })) return false;
if (me.lie && String(a.text).trim().toLowerCase() === String(me.lie).trim().toLowerCase()) return false;
return true;
});
if (!ans.length) return '';
var likes = me.likes || [];
return '<section class="hearts" data-k="' + k + '"><h3>Giv et hjerte til de bedste løgne</h3>' +
ans.map(function (a) {
var on = likes.indexOf(a.id) >= 0;
return '<div class="hrow"><span>' + esc(a.text) + '</span><button class="heart' + (on ? ' on' : '') +
'" data-act="like" data-arg="' + esc(a.id) + '" aria-label="Hjerte" aria-pressed="' + on + '">♥</button></div>';
}).join('') + '</section>';
}
function screenGuess(state, me) {
var q = state.question || {};
var out = questionCard(state, 'gq:' + (q.prompt || ''));
var ans = state.answers || [];
if (me.guess) {
var picked = ans.filter(function (a) { return a.id === me.guess; })[0];
out += '<section class="picked" data-k="picked"><p>Du gættede på</p><div class="pick">' + esc(picked ? picked.text : '…') + '</div></section>';
out += heartList(state, me, [me.guess], 'hearts');
out += '<p class="hint center" data-k="w">Venter på de andre…</p>';
return out;
}
out += '<h2 class="ask" data-k="ask">Hvad er sandheden?</h2>';
out += '<section class="answers" data-k="ans:' + ans.map(function (a) { return a.id; }).join(',') + '">' +
ans.map(function (a, i) {
return '<button class="btn btn-ans c' + (i % 6) + '" data-act="guess" data-arg="' + esc(a.id) + '">' + esc(a.text) + '</button>';
}).join('') + '</section>';
return out;
}
function screenReveal(state, me) {
var out = big('Kig op på skærmen!', '', 'look', 'look');
if (me.role === 'player' && me.last_delta != null) {
out += '<section class="delta' + (me.last_delta > 0 ? ' plus' : '') + '" data-k="delta">' +
(me.last_delta > 0 ? '<b>' + WPP.signed(me.last_delta) + '</b><span>point til dig!</span>'
: '<b>0</b><span>point denne gang</span>') + '</section>';
}
out += heartList(state, me, [], 'hearts');
return out;
}
function rankOf(state, id) {
var ps = (state.players || []).slice().sort(function (a, b) { return (b.score || 0) - (a.score || 0); });
for (var i = 0; i < ps.length; i++) {
if (ps[i].id === id) {
var place = 1;
while (place - 1 < i && (ps[place - 1].score || 0) > (ps[i].score || 0)) place++;
return { place: place, of: ps.length, p: ps[i] };
}
}
return null;
}
function screenScores(state, me) {
var out = '<div class="roundtag" data-k="rt">Stillingen</div>';
var r = me.role === 'player' && rankOf(state, me.id);
if (r) {
out += '<section class="place" data-k="place"><div class="pnum">' + r.place + '.</div><p>plads af ' + r.of + '</p>' +
'<div class="score">' + WPP.fmt(r.p.score) + ' point</div>' +
(me.last_delta ? '<div class="dchip">' + WPP.signed(me.last_delta) + ' i runden</div>' : '') + '</section>';
}
var ps = (state.players || []).slice().sort(function (a, b) { return (b.score || 0) - (a.score || 0); });
out += '<ol class="mini-board" data-k="board">' + ps.map(function (p) {
return '<li' + (p.id === me.id ? ' class="mine"' : '') + ' style="--pc:' + esc(p.color || '#888') + '"><span class="dot"></span><span class="n">' +
esc(p.name) + '</span><b>' + WPP.fmt(p.score) + '</b></li>';
}).join('') + '</ol>';
out += '<p class="hint center" data-k="w">Kig op på skærmen!</p>';
return out;
}
function screenFinal(state, me, players) {
var fin = state.final || {};
var rank = fin.ranking || [];
var mine = rank.filter(function (r) { return r.player_id === me.id; })[0];
var out = '';
if (me.role === 'player' && mine) {
var title = mine.place === 1 ? 'Du vandt!' : 'Du blev nr. ' + mine.place + '!';
out += '<section class="place final' + (mine.place === 1 ? ' win' : '') + '" data-k="place">' +
'<div class="pnum">' + mine.place + '.</div><h2>' + title + '</h2><div class="score">' + WPP.fmt(mine.score) + ' point</div></section>';
} else {
var w = rank[0] && players[rank[0].player_id];
out += big('Spillet er slut!', w ? esc(w.name) + ' vandt med ' + WPP.fmt(rank[0].score) + ' point.' : '', 'over', 'pop');
}
var aw = (fin.awards || []).filter(function (a) { return a.player_id === me.id; });
if (aw.length) {
out += '<section class="myawards" data-k="aw"><h3>Dine priser</h3>' + aw.map(function (a) {
return '<div class="award"><b>' + esc(a.title) + '</b>' + (a.detail ? '<span>' + esc(a.detail) + '</span>' : '') + '</div>';
}).join('') + '</section>';
}
if (me.vip) {
out += '<section class="actions" data-k="again"><button class="btn btn-go btn-xl" data-act="play_again">Spil igen</button></section>';
} else {
var vip = (state.players || []).filter(function (q) { return q.vip; })[0];
out += '<p class="hint center" data-k="w">' + (vip ? 'Venter på at ' + esc(vip.name) + ' starter et nyt spil…' : 'Tak for spillet!') + '</p>';
}
return out;
}
/* ---------- offentlige render-funktioner ---------- */
function renderPlay(root, state, ctx) {
ctx = ctx || {};
root._ctx = ctx;
bind(root);
var sh = WPP.shell(root);
var u = ui(root);
if (!ctx.keepBusy) setBusy(root, false);
state = state || {};
var me = state.me || { role: 'audience' };
var players = WPP.playerMap(state);
var phase = state.phase || 'lobby';
var body;
switch (phase) {
case 'lobby': body = screenLobby(state, me, players); break;
case 'category': body = screenCategory(state, me, players); break;
case 'lie': body = screenLie(state, me, players, root); break;
case 'guess': body = screenGuess(state, me, players); break;
case 'reveal': body = screenReveal(state, me, players); break;
case 'scores': body = screenScores(state, me, players); break;
case 'final': body = screenFinal(state, me, players); break;
default: body = big('Øjeblik…', '', 'unk');
}
sh.screen.className = 'screen ph ph-' + phase;
WPP.patch(sh.screen, topBar(state, me, players) + '<main class="ph-main" data-k="main" data-patch>' + body + '</main>');
/* gendan kladde i løgne-feltet, hvis formularen er ny */
var inp = sh.screen.querySelector('#wpp-lie');
if (inp && !inp.value) {
var d = u.drafts[inp.getAttribute('data-q') || ''];
if (d) inp.value = d;
}
if (u.lastPhase !== phase) {
if (u.lastPhase && !ctx.harness) {
try { window.scrollTo(0, 0); } catch (e) { /* */ }
var needsAct = (phase === 'lie' && me.role === 'player') || phase === 'guess' || (phase === 'category' && me.is_chooser);
if (needsAct && navigator.vibrate) { try { navigator.vibrate(60); } catch (e) { /* */ } }
}
u.lastPhase = phase;
}
}
function renderJoin(root, ctx, opts) {
ctx = ctx || {}; opts = opts || {};
root._ctx = ctx;
bind(root);
setBusy(root, false);
var sh = WPP.shell(root);
sh.screen.className = 'screen ph ph-join';
var code = opts.code || '';
var name = opts.name != null ? opts.name : (WPP.ls.get('wpp:name') || '');
WPP.patch(sh.screen,
'<section class="logo" data-k="logo"><div class="logo-fup">Fup</div><div class="logo-og">og</div><div class="logo-fakta">Fakta</div></section>' +
'<form class="join-form card" data-k="join:' + esc(code) + '" autocomplete="off">' +
'<label>Rumkode<input name="code" value="' + esc(code) + '" maxlength="4" inputmode="text" autocapitalize="characters" ' +
'autocorrect="off" spellcheck="false" autocomplete="off" placeholder="ABCD" class="code-in"' + (code ? '' : ' autofocus') + '></label>' +
'<label>Dit navn<input name="name" value="' + esc(name) + '" maxlength="12" autocomplete="nickname" autocorrect="off" spellcheck="false" placeholder="Fx Asger"' +
(code && !name ? ' autofocus' : '') + '></label>' +
'<button type="submit" class="btn btn-go btn-xl">Hop ind!</button>' +
'<p class="hint center">Koden står på storskærmen.</p></form>' +
(opts.error ? '<p class="join-err" data-k="err">' + esc(opts.error) + '</p>' : ''));
}
function renderWaiting(root, text) {
var sh = WPP.shell(root);
sh.screen.className = 'screen ph ph-wait';
WPP.patch(sh.screen, '<section class="big pulse" data-k="w"><h2>' + esc(text || 'Forbinder…') + '</h2></section>');
}
function setSuggestion(root, text) {
setBusy(root, false);
var inp = root.querySelector('#wpp-lie');
if (!inp) return;
inp.value = String(text || '').slice(0, 40);
ui(root).drafts[inp.getAttribute('data-q') || ''] = inp.value;
inp.classList.remove('flash'); void inp.offsetWidth; inp.classList.add('flash');
}
/* ---------- live-forbindelse ---------- */
function mount(root) {
var qs = new URLSearchParams(location.search);
var clean = function (s) { return String(s || '').toUpperCase().replace(/[^A-Z]/g, '').slice(0, 4); };
var prefill = clean(root.getAttribute('data-prefill') || qs.get('code'));
var code = null, conn = null, token = null, pendingName = null, joinedRole = null;
var ctx = {
send: function (m) { return conn ? conn.send(m) : false; },
onJoin: function (c, name) {
WPP.ls.set('wpp:name', name);
pendingName = name;
if (conn && code === c) {
if (conn.send({ action: 'join', name: name })) return;
return; /* sendes ved onOpen */
}
start(c, false);
},
onForget: function () { forget(); showJoin(code); }
};
function forget() {
if (code) WPP.ls.del('wpp:' + code);
if (WPP.ls.get('wpp:last') === code) WPP.ls.del('wpp:last');
token = null;
}
function showJoin(c, err) {
if (conn && !token) { /* behold forbindelse til samme kode */ }
renderJoin(root, ctx, { code: c || prefill || '', error: err });
}
function start(c, resume) {
if (conn) { conn.close(); conn = null; }
code = c;
token = resume ? WPP.ls.get('wpp:' + c) : null;
conn = WPP.connect('/ws/play/' + c + '/', {
root: root,
onOpen: function () {
if (token) conn.send({ action: 'resume', token: token });
else if (pendingName) conn.send({ action: 'join', name: pendingName });
},
onMessage: onMessage
});
}
function onMessage(msg) {
switch (msg.type) {
case 'state':
if (!token) return;
renderPlay(root, msg.state, ctx);
break;
case 'joined':
token = msg.token; joinedRole = msg.role; pendingName = null;
WPP.ls.set('wpp:' + code, token);
WPP.ls.set('wpp:last', code);
renderWaiting(root, joinedRole === 'audience' ? 'Du er publikum!' : 'Du er med!');
break;
case 'suggestion':
setSuggestion(root, msg.text);
break;
case 'error':
setBusy(root, false);
if (msg.code === 'unknown_token' || msg.code === 'unknown_room') {
var c = code;
forget();
if (conn) { conn.close(); conn = null; }
pendingName = null;
showJoin(c, msg.code === 'unknown_room' ? (msg.message || 'Rummet findes ikke.') : '');
} else if (!token && (msg.code === 'name_taken' || msg.code === 'name_invalid')) {
pendingName = null;
WPP.toast(root, msg.message || 'Det navn kan ikke bruges.');
} else {
WPP.toast(root, msg.message || 'Ups, noget gik galt.');
}
break;
}
}
var last = WPP.ls.get('wpp:last');
if (last && WPP.ls.get('wpp:' + last) && (!prefill || prefill === last)) {
renderWaiting(root, 'Forbinder…');
start(last, true);
} else {
showJoin(prefill);
}
return ctx;
}
window.WPPPlay = { renderPlay: renderPlay, renderJoin: renderJoin, renderWaiting: renderWaiting, setSuggestion: setSuggestion, mount: mount };
window.renderPlay = renderPlay;
if (!window.WPP_HARNESS) {
var go = function () { var r = document.getElementById('play'); if (r) mount(r); };
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', go); else go();
}
})();
+21
View File
@@ -0,0 +1,21 @@
{% load static %}<!doctype html>
<html lang="da">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='.9em' font-size='90'%3E%F0%9F%8E%AD%3C/text%3E%3C/svg%3E">
<meta name="theme-color" content="#2b0a5c">
<meta name="color-scheme" content="dark">
<meta name="robots" content="noindex">
<title>{% block title %}Fup og Fakta{% endblock %}</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fredoka:wght@400;500;600;700&display=swap">
<link rel="stylesheet" href="{% static 'party/party.css' %}">
{% block head %}{% endblock %}
</head>
<body class="{% block body_class %}{% endblock %}">
{% block content %}{% endblock %}
{% block scripts %}{% endblock %}
</body>
</html>
+11
View File
@@ -0,0 +1,11 @@
{% extends "party/base.html" %}
{% load static %}
{% block title %}Fup og Fakta – {{ code }}{% endblock %}
{% block body_class %}page-host{% endblock %}
{% block content %}
<div id="host" data-code="{{ code }}"></div>
{% endblock %}
{% block scripts %}
<script src="{% static 'party/common.js' %}" defer></script>
<script src="{% static 'party/host.js' %}" defer></script>
{% endblock %}
+12
View File
@@ -0,0 +1,12 @@
{% extends "party/base.html" %}
{% block title %}Weirsøe Party Protocol{% endblock %}
{% block content %}
<main class="landing">
<h1>Weirsøe Party Protocol</h1>
<div class="sub"><span class="b-fup">Fup</span> <span class="b-og">og</span> <span class="b-fakta">Fakta</span></div>
<form method="post" action="/host/new">{% csrf_token %}
<button type="submit" class="btn btn-go">Start et nyt spil</button>
</form>
{% if username %}<p class="who">Logget ind som {{ username }}</p>{% endif %}
</main>
{% endblock %}
+13
View File
@@ -0,0 +1,13 @@
{% extends "party/base.html" %}
{% load static %}
{% block title %}Fup og Fakta{% endblock %}
{% block body_class %}page-play{% endblock %}
{% block content %}
<div id="play" data-prefill="{{ prefill_code|default:'' }}">
<noscript><p style="padding:24px;text-align:center">Fup og Fakta kræver JavaScript.</p></noscript>
</div>
{% endblock %}
{% block scripts %}
<script src="{% static 'party/common.js' %}" defer></script>
<script src="{% static 'party/play.js' %}" defer></script>
{% endblock %}
+1
View File
@@ -33,3 +33,4 @@ class ViewTests(TestCase):
r = self.client.get("/qr/KLMT.svg")
self.assertEqual(r["Content-Type"], "image/svg+xml")
self.assertIn(b"<svg", r.content)
self.assertIn(b'xmlns="http://www.w3.org/2000/svg"', r.content) # ellers vises den ikke som <img>
+5 -3
View File
@@ -1,3 +1,4 @@
import io
import re
import segno
@@ -56,9 +57,10 @@ def host(request, code):
def qr(request, code):
if not CODE_RE.match(code):
raise Http404
svg = segno.make(f"{settings.PUBLIC_URL}/?code={code.upper()}", error="m").svg_inline(
scale=10, border=2, dark="#1b1035", light="#ffffff")
return HttpResponse(svg, content_type="image/svg+xml")
buf = io.BytesIO()
segno.make(f"{settings.PUBLIC_URL}/?code={code.upper()}", error="m").save(
buf, kind="svg", scale=10, border=2, dark="#1b1035", light="#ffffff")
return HttpResponse(buf.getvalue(), content_type="image/svg+xml")
@require_GET
+31
View File
@@ -0,0 +1,31 @@
const puppeteer = require('/opt/isms/node_modules/puppeteer');
const base = 'file:///root/wppui/dev.html';
const WAIT = 2200;
const jobs = [];
const phases = ['join','lobby','category','lie','guess','reveal','scores','final'];
for (const p of phases) {
jobs.push({name:`all-${p}`, url:`${base}?phase=${p}`, w:2620, h:930});
jobs.push({name:`host-${p}`, url:`${base}?phase=${p}&only=host`, w:1920, h:1080});
}
for (const t of [500, 1700, 3200, 11200, 15200, 17500]) {
jobs.push({name:`host-reveal-t${t}`, url:`${base}?phase=reveal&only=host&t=${t - WAIT}`, w:1920, h:1080});
}
jobs.push({name:`phones360-lie`, url:`${base}?phase=lie&only=phones&pw=360`, w:1300, h:930});
jobs.push({name:`phones360-guess`, url:`${base}?phase=guess&only=phones&pw=360`, w:1300, h:930});
jobs.push({name:`host720-lobby`, url:`${base}?phase=lobby&only=host`, w:1280, h:720});
const only = process.argv[2];
(async () => {
const browser = await puppeteer.launch({args:['--no-sandbox']});
const page = await browser.newPage();
page.on('console', m => { if (m.type()==='error' || m.type()==='warning') console.log('CONSOLE', m.type(), m.text()); });
page.on('pageerror', e => console.log('PAGEERROR', e.message));
for (const j of jobs) {
if (only && !j.name.includes(only)) continue;
await page.setViewport({width:j.w, height:j.h});
await page.goto(j.url, {waitUntil:'load', timeout: 15000}).catch(e=>console.log('goto', e.message));
await new Promise(r=>setTimeout(r, WAIT));
await page.screenshot({path:`/root/wppui/shots/${j.name}.png`});
console.log('shot', j.name);
}
await browser.close();
})();