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:
+144
@@ -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()))
|
||||
+1165
-80
File diff suppressed because it is too large
Load Diff
@@ -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 _____.",
|
||||
|
||||
@@ -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 = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
||||
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 });
|
||||
});
|
||||
})();
|
||||
@@ -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>&</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>&</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() };
|
||||
})();
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
})();
|
||||
@@ -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; }
|
||||
@@ -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();
|
||||
}
|
||||
})();
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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();
|
||||
})();
|
||||
Reference in New Issue
Block a user