Skærmen følger speakeren, opsummering, musik, lydeffekter og drillerier
CI / test (push) Successful in 28s

Faseskift venter, til speakeren er færdig med den aktuelle skærm, og
replikker til en skærm, der er væk, afspilles ikke. Ny kategori-intro med
en lyd pr. kategori, musik pr. fase med bløde overgange og lydeffekter til
alt, der flyver over skærmen. Efter afsløringen eksploderer løgne, ingen
valgte, og pointene tæller op. Hjerter gives kun under afsløringen og ses
live på storskærmen. Lyv for mig giver halve point. Narrede spilleres
telefon kan blive drillet med et fjollet billede og et buh. Alle lyd- og
billedfiler serveres med versionerede navne og caches af browseren.

Claude-Session: https://claude.ai/code/session_01JmhJG6JnhXfzNiKv8f2vHu
This commit is contained in:
agw
2026-10-01 21:59:40 +02:00
parent 2da781e361
commit 16054a9b67
66 changed files with 1741 additions and 253 deletions
+60 -15
View File
@@ -31,7 +31,7 @@ Alle beskeder er JSON `{"action": "...", ...}`.
| `submit_lie` | `text` (1-40 tegn) | fase `lie` |
| `lie_for_me` | | fase `lie`; svar: `suggestion` |
| `guess` | `answer_id` | fase `guess`; spillere og publikum, én gang |
| `like` | `answer_id` | fase `guess` (efter eget gæt) og `reveal`; slår til/fra; ikke eget svar |
| `like` | `answer_id` | kun fase `reveal`; slår til/fra; ikke eget svar og ikke sandheden (`answer_invalid`) |
| `play_again` | | VIP i fasen `final` |
| `ping` | | svar `pong` |
@@ -43,7 +43,8 @@ Alle beskeder er JSON `{"action": "...", ...}`.
| --- | --- |
| `state` | `state`: snapshot (se nedenfor) |
| `joined` | `token`, `player_id`, `role` (`player`/`audience`) |
| `suggestion` | `text` |
| `suggestion` | `text` (svar på `lie_for_me`) |
| `tease` | `text`, `image` (URL), `sound` (URL eller `null`) – kun til den spiller, der lige faldt for en løgn, i det øjeblik stemplet "LØGN" lander på storskærmen. Telefonen viser et fuldskærms-drilleri (~2,5 s, lukkes ved tryk), spiller `boo` og derefter `sound`, hvis lyden er låst op. Højst 2 pr. spørgsmål. |
| `error` | `code`, `message` (dansk, kan vises direkte) |
| `pong` | |
@@ -54,7 +55,7 @@ Fejlkoder: `unknown_room`, `unknown_token`, `name_taken`, `name_invalid`, `not_a
```jsonc
{
"code": "KLMT",
"phase": "lobby", // lobby | category | lie | guess | reveal | scores | final
"phase": "lobby", // lobby | category | intro | lie | guess | reveal | scores | final
"server_time": 1790000000000,// ms epoch; klienten udregner sin forskydning
"deadline": 1790000060000, // ms epoch eller null
"join_url": "https://party.weircon.dk/?code=KLMT",
@@ -66,20 +67,34 @@ Fejlkoder: `unknown_room`, `unknown_token`, `name_taken`, `name_invalid`, `not_a
"connected": true, "vip": true, "done": false} // done = har løjet/gættet i nuværende fase
],
"audience_count": 0,
"category": {"chooser_id": "p2", "options": [{"id": 3, "name": "Dyreriget"}]}, // kun i fase category
"question": {"category": "Dyreriget", "prompt": "En flok _____ kaldes en parlament."}, // lie/guess/reveal
"answers": [{"id": "a1", "text": "ugler"}], // guess + reveal; spillerens egen løgn er udeladt i guess
"category": {"chooser_id": "p2", "options": [{"id": 3, "name": "Dyreriget"}],
"chosen_id": null}, // kun i fase category; id når der er valgt (ellers null)
"intro": {"category": "Dyreriget", "slug": "dyreriget", "final": false}, // kun i fase intro
"question": {"category": "Dyreriget", "prompt": "En flok _____ kaldes et parlament.",
"slug": "dyreriget"}, // lie/guess/reveal
"answers": [{"id": "a1", "text": "ugler"}], // guess + reveal (i reveal med "likes"); egen løgn udeladt i guess
"reveal": { // kun i fase reveal
"steps": [ // løgne først (kun dem der blev valgt), sandheden sidst
{"answer_id": "a3", "text": "krager", "is_truth": false, "house": false,
"authors": ["p2"], "guessed_by": ["p1", "p3"], "audience_votes": 2, "likes": 1,
"points": [{"player_id": "p2", "delta": 1000}]},
"authors": ["p2", "p5"], "assisted": ["p5"], // assisted = forfattere der brugte "Lyv for mig" uændret
"guessed_by": ["p1", "p3"], "audience_votes": 2, "likes": 1,
"points": [{"player_id": "p2", "delta": 1000}, {"player_id": "p5", "delta": 500}],
"audio": {"url": "/media/tts/…mp3", "ms": 3100, "text": "…"}}, // speakerens replik til trinnet eller null
{"answer_id": "a1", "text": "ugler", "is_truth": true, "house": false,
"authors": [], "guessed_by": ["p4"], "audience_votes": 1, "likes": 0,
"points": [{"player_id": "p4", "delta": 1000}]}
"authors": [], "assisted": [], "guessed_by": ["p4"], "audience_votes": 1, "likes": 0,
"points": [{"player_id": "p4", "delta": 1000}], "audio": null}
],
"step_ms": 4000, // værtsskærmen viser ét trin pr. step_ms
"note": "Forklaring på faktummet" // vises under sandheden
"all": [ // opsummeringen: ALLE svar, også dem ingen valgte
{"id": "a7", "text": "skildpadder", "is_truth": false, "house": true,
"authors": [], "assisted": [], "picked": false, "likes": 0} // picked=false → svaret "eksploderer"
],
"deltas": [{"player_id": "p1", "delta": 1500}], // point i dette spørgsmål pr. spiller (inkl. hjerter)
"step_ms": 4000, // standardlængde pr. trin (bagudkompatibel)
"steps_ms": [4200, 3900], // faktisk længde pr. trin (følger speakerens replik)
"tail_ms": 8000, // opsummeringen efter sidste trin
"started_at": 1790000001000, // ms epoch; null = serveren laver stadig lyd (vis "trommehvirvel")
"note": "Forklaring på faktummet", // vises under sandheden
"note_audio": {"url": "/media/tts/…mp3", "ms": 5200} // forklaringen læst op (starter med sandhedens trin) eller null
},
"deltas": [{"player_id": "p1", "delta": 1500}], // fase scores: point i den netop afsluttede runde
"final": { // kun i fase final
@@ -98,7 +113,34 @@ Fejlkoder: `unknown_room`, `unknown_token`, `name_taken`, `name_invalid`, `not_a
}
```
Værten får samme snapshot uden `me`, men med `"host": true`.
Værten får samme snapshot uden `me`, men med `"host": true` og desuden:
```jsonc
{
"phase_key": "1.0.lie", // "<runde>.<spørgsmål>.<fase>" – identificerer den aktuelle skærm
"narration": [ // speakerens seneste replikker (rullende vindue)
{"id": 41, "url": "/media/tts/…mp3", "ms": 2300, "at": 1790000000000, "text": "…",
"key": "1.0.intro"} // den skærm (phase_key) replikken hører til; er skærmen væk, springes den over
]
}
```
Klienten afspiller nye replikker (`id` > sidst sete) i rækkefølge; ved første snapshot kun dem fra de sidste ~4 s.
Afsløringens replikker ligger i `reveal.steps[].audio` / `reveal.note_audio` og startes af tidslinjen.
### Afsløringens tidslinje
Trin `i` starter ved `started_at + sum(steps_ms[0..i-1])`. Inden for et trin vises først svaret, så hvem der valgte det
(~28 % / højst 1,1 s), så stemplet (~55 % / højst 2,3 s). Efter sidste trin kører opsummeringen i `tail_ms`:
alle svar (`all`), de ikke-valgte eksploderer, og `deltas` tælles op. `deadline` = slutningen af halen.
Mens `started_at` er `null`, venter serveren på speakerens lydfiler (højst ~10 s) og sætter så tiderne.
### Lyd- og billedfiler
Templates får `assets` = `{"sfx": {navn: url}, "tease": [url]}` indlejret med
`{{ assets|json_script:"wpp-assets" }}`. URL'erne er versionerede (hashede filnavne i drift → caches for altid);
klienten slår alle lyde op her og falder tilbage til `/static/party/sfx/<navn>.mp3`. Kategoriens jingle hedder
`cat_<slug>` (fx `cat_mad_og_drikke`); mangler den, bruges `category`.
## Spillets gang (Fibbage-klassisk)
@@ -108,11 +150,14 @@ Værten får samme snapshot uden `me`, men med `"host": true`.
| Runde 2 | 3 | 2000 / 1000 |
| Finale | 1 | 3000 / 1500 |
For hvert spørgsmål: `category` (15 s; skiftende spiller vælger blandt 4; i finalen springes fasen over) → `lie` (60 s, slutter når alle har løjet) → `guess` (30 s, slutter når alle spillere har gættet) → `reveal` (automatisk: antal trin × `step_ms` + 3 s; værten kan `skip`). Efter sidste spørgsmål i en runde: `scores` (10 s). Efter finalen: `final`.
For hvert spørgsmål: `category` (15 s; skiftende spiller vælger blandt 4; i finalen springes fasen over) → `intro` (≈5 s; storskærmen præsenterer kategorien med sin jingle, speakeren siger kategorien, og serveren venter til speakeren er færdig) → `lie` (60 s, speakeren læser spørgsmålet op; slutter når alle har løjet) → `guess` (30 s, slutter når alle spillere har gættet) → `reveal` (automatisk: `sum(steps_ms)` + `tail_ms`; værten kan `skip`). Efter sidste spørgsmål i en runde: `scores` (10 s). Efter finalen: `final`.
Rækkefølge: `category → intro → lie → guess → reveal (→ scores)`; i finalen `intro → lie → guess → reveal → final`.
- Løgn lig sandheden (eller næsten lig) afvises med `lie_is_truth`. Ens løgne slås sammen, og alle forfattere får point.
- Spillere, der ikke løj i tide, får ingen løgn; huset fylder op med lokkesvar, så der er mindst `max(4, spillere+1)` svar.
- Man kan ikke vælge sin egen løgn.
- Like: +100 til forfatteren pr. like (uden multiplikator).
- Hjerter (like) gives kun under `reveal`: +100 til forfatteren pr. hjerte (uden multiplikator); ikke til sandheden og ikke til eget svar.
- "Lyv for mig": bruger man forslaget uændret, får man kun halve point for at narre andre (forfatteren står i `assisted`).
- Publikum: forfatteren får `round(500 × multiplikator × andel af publikum narret)`.
- Spil igen: samme spillere, point nulstilles.
+100 -23
View File
@@ -24,11 +24,12 @@ POINTS_LIKE = 100
POINTS_AUDIENCE = 500
CATEGORY_SECONDS = 15
INTRO_SECONDS = 5 # kategorien præsenteres på storskærmen med sin egen lyd
LIE_SECONDS = 60
GUESS_SECONDS = 30
SCORES_SECONDS = 10
REVEAL_STEP_MS = 4000
REVEAL_TAIL_MS = 3000
REVEAL_TAIL_MS = 8000 # opsummeringen efter sidste trin: løgne der eksploderer, point der tæller op
MIN_PLAYERS = 2
MAX_PLAYERS = 8
@@ -38,7 +39,13 @@ CATEGORY_OPTIONS = 4
COLORS = ["#ff4d6d", "#4cc9f0", "#ffd166", "#06d6a0", "#b388ff", "#ff9f1c", "#f15bb5", "#90e0ef"]
TIMED_PHASES = {"category", "lie", "guess", "reveal", "scores"}
TIMED_PHASES = {"category", "intro", "lie", "guess", "reveal", "scores"}
def slugify(name: str) -> str:
"""Kategorinavn til filnavn: "Mad og drikke" -> "mad_og_drikke" (bruges til kategoriens lyd)."""
s = name.casefold().replace("æ", "ae").replace("ø", "oe").replace("å", "aa")
return re.sub(r"[^a-z0-9]+", "_", s).strip("_")
class GameError(Exception):
@@ -116,6 +123,13 @@ class Game:
self.reveal_steps_ms: list[int] = []
self.reveal_tail_ms = REVEAL_TAIL_MS
self.reveal_note_audio: dict | None = None
# Speakeren: så længe hold_until ligger i fremtiden, venter alle automatiske faseskift
# (tid udløbet, alle har svaret, kategori valgt). Rummet sætter den ud fra talens længde.
self.hold_until: float | None = None
self.chosen_category = None
# "Lyv for mig": forslag givet pr. spiller, og hvem der afleverede et uændret forslag (halve point)
self.suggestions: dict[str, set[str]] = {}
self.assisted: set[str] = set()
self.version = 0
# Begivenheder til speakeren (party.narration). Motoren lægger dem her;
# rummet tømmer listen efter hver handling.
@@ -141,6 +155,13 @@ class Game:
ranked = sorted(self.players.values(), key=lambda p: (-p.score, p.joined_order))
return (ranked[0], ranked[-1]) if ranked else (None, None)
def held(self, now: float) -> bool:
return self.hold_until is not None and now < self.hold_until
def phase_key(self) -> str:
"""Identificerer den aktuelle skærm; speakerens replikker mærkes med den."""
return f"{self.round_idx}.{self.q_idx}.{self.phase}"
def _member(self, member_id: str) -> Player | None:
return self.players.get(member_id) or self.audience.get(member_id)
@@ -293,6 +314,7 @@ class Game:
self.chooser_id = chooser.id
picks = self.rng.sample(list(cats.items()), min(CATEGORY_OPTIONS, len(cats)))
self.category_options = [{"id": cid, "name": name} for cid, name in picks]
self.chosen_category = None
self.phase = "category"
self.deadline = now + CATEGORY_SECONDS
self._emit("category", chooser=chooser.name)
@@ -309,7 +331,14 @@ class Game:
raise GameError("not_allowed", "Det er ikke dig, der vælger kategori.")
if category_id not in {c["id"] for c in self.category_options}:
raise GameError("answer_invalid", "Ukendt kategori.")
self._choose(category_id, now)
if self.chosen_category is not None:
raise GameError("already_done", "Kategorien er valgt.")
self.chosen_category = category_id
if self.held(now):
self.deadline = now # skiftet sker, når speakeren er færdig
self._changed()
else:
self._choose(category_id, now)
def _choose(self, category_id, now: float):
pool = [q for q in self.questions if q["category_id"] == category_id and q["id"] not in self.used_questions]
@@ -318,6 +347,8 @@ class Game:
self.question = self.rng.choice(pool)
self.used_questions.add(self.question["id"])
self.lies = {}
self.suggestions = {}
self.assisted = set()
self.guesses = {}
self.audience_votes = {}
self.likes = {}
@@ -327,6 +358,12 @@ class Game:
p.last_delta = None
self.reveal_started_at = None
self.reveal_note_audio = None
self.phase = "intro"
self.deadline = now + INTRO_SECONDS
self._emit("category_intro", category=self.question["category"], final=self.is_final_round)
self._changed()
def _start_lie(self, now: float):
self.phase = "lie"
self.deadline = now + LIE_SECONDS
self._emit("lie_start", category=self.question["category"], prompt=self.question["prompt"])
@@ -357,6 +394,8 @@ class Game:
self._emit("lie_was_truth")
raise GameError("lie_is_truth", "Det er faktisk sandheden! Find på en løgn i stedet.")
self.lies[member_id] = text
if normalize(text) in self.suggestions.get(member_id, ()):
self.assisted.add(member_id) # husets forslag: halve point for at narre
self._changed()
self._maybe_finish_early(now)
@@ -367,7 +406,9 @@ class Game:
options = [d for d in self.question.get("decoys", []) if normalize(d) not in taken]
if not options:
options = self.question.get("decoys", []) or ["noget helt andet"]
return self.rng.choice(options)
pick = self.rng.choice(options)
self.suggestions.setdefault(member_id, set()).add(normalize(pick))
return pick
def _start_guess(self, now: float):
grouped: dict[str, Answer] = {}
@@ -419,13 +460,13 @@ class Game:
self._maybe_finish_early(now)
def like(self, member_id: str, answer_id: str) -> None:
self._require_phase("guess", "reveal")
self._require_phase("reveal") # hjerter gives kun, mens løgnene afsløres
member = self._member(member_id)
if not member:
raise GameError("not_allowed", "Du er ikke med i spillet.")
if self.phase == "guess" and member.role == "player" and member_id not in self.guesses:
raise GameError("wrong_phase", "Gæt først, så kan du like bagefter.")
answer = self._answer(answer_id)
if answer.is_truth:
raise GameError("answer_invalid", "Hjerter er til løgnene.")
if member_id in answer.authors:
raise GameError("answer_invalid", "Du kan ikke like din egen løgn.")
voters = self.likes.setdefault(answer_id, set())
@@ -440,13 +481,22 @@ class Game:
a.last_delta = (a.last_delta or 0) + sign * POINTS_LIKE
self._changed()
def _maybe_finish_early(self, now: float):
def _all_done(self) -> bool:
active = self._active_players()
if self.phase == "lie" and active and all(p.id in self.lies for p in active):
self._emit("lie_all_done")
self._start_guess(now)
elif self.phase == "guess" and active and all(p.id in self.guesses for p in active):
self._start_reveal(now)
if self.phase == "lie":
return bool(active) and all(p.id in self.lies for p in active)
if self.phase == "guess":
return bool(active) and all(p.id in self.guesses for p in active)
return False
def _maybe_finish_early(self, now: float):
if not self._all_done():
return
if self.held(now):
self.deadline = now # alle er færdige; skiftet venter på speakeren
self._changed()
else:
self._advance(now)
def _award(self, pid: str, delta: int, step: dict):
p = self.players.get(pid)
@@ -468,7 +518,8 @@ class Game:
continue
step = {"answer_id": a.id, "text": a.text, "is_truth": a.is_truth, "house": a.house,
"authors": list(a.authors), "guessed_by": picked_by, "audience_votes": votes,
"likes": len(self.likes.get(a.id, ())), "points": []}
"likes": len(self.likes.get(a.id, ())), "points": [],
"assisted": [x for x in a.authors if x in self.assisted]}
if a.is_truth:
for pid in picked_by:
self.players[pid].correct += 1
@@ -480,6 +531,8 @@ class Game:
bonus = POINTS_FOOLED * m * len(picked_by)
if audience_total:
bonus += round(POINTS_AUDIENCE * m * votes / audience_total)
if author in self.assisted:
bonus //= 2 # løgnen kom fra "Lyv for mig"
self._award(author, bonus, step)
steps.append(step)
# Løgne først (den mindst valgte først), sandheden til sidst.
@@ -557,18 +610,25 @@ class Game:
def tick(self, now: float) -> bool:
"""Kaldes når deadline er nået. Returnerer True hvis noget ændrede sig."""
if self.deadline is None or now < self.deadline:
if self.deadline is None or now < self.deadline or self.held(now):
return False
self._advance(now)
return True
def _advance(self, now: float):
if self.phase == "category":
pick = self.rng.choice(self.category_options)
chooser = self.players.get(self.chooser_id)
self._emit("category_timeout", chooser=chooser.name if chooser else "", category=pick["name"])
self._choose(pick["id"], now)
if self.chosen_category is not None:
self._choose(self.chosen_category, now)
else:
pick = self.rng.choice(self.category_options)
chooser = self.players.get(self.chooser_id)
self._emit("category_timeout", chooser=chooser.name if chooser else "", category=pick["name"])
self._choose(pick["id"], now)
elif self.phase == "intro":
self._start_lie(now)
elif self.phase == "lie":
if self._all_done():
self._emit("lie_all_done")
self._start_guess(now)
elif self.phase == "guess":
self._start_reveal(now)
@@ -605,14 +665,31 @@ class Game:
state["round"] = {"number": self.round_idx + 1, "name": name, "multiplier": mult,
"question_number": self.q_idx + 1, "questions_in_round": count}
if self.phase == "category":
state["category"] = {"chooser_id": self.chooser_id, "options": self.category_options}
state["category"] = {"chooser_id": self.chooser_id, "options": self.category_options,
"chosen_id": self.chosen_category}
if self.phase == "intro" and self.question:
state["intro"] = {"category": self.question["category"], "slug": slugify(self.question["category"]),
"final": self.is_final_round}
if self.phase in ("lie", "guess", "reveal") and self.question:
state["question"] = {"category": self.question["category"], "prompt": self.question["prompt"]}
state["question"] = {"category": self.question["category"], "prompt": self.question["prompt"],
"slug": slugify(self.question["category"])}
if self.phase in ("guess", "reveal"):
hide = viewer.id if viewer and viewer.role == "player" and self.phase == "guess" else None
state["answers"] = [{"id": a.id, "text": a.text} for a in self.answers if hide not in a.authors]
state["answers"] = [{"id": a.id, "text": a.text, **({"likes": len(self.likes.get(a.id, ()))}
if self.phase == "reveal" else {})}
for a in self.answers if hide not in a.authors]
if self.phase == "reveal":
state["reveal"] = {"steps": self.reveal_steps, "step_ms": REVEAL_STEP_MS,
picked = set(self.guesses.values()) | set(self.audience_votes.values())
state["reveal"] = {
# trinene med live antal hjerter (der kan likes under hele afsløringen)
"steps": [{**st, "likes": len(self.likes.get(st["answer_id"], ()))} for st in self.reveal_steps],
# opsummeringen: alle svar, også dem ingen valgte (de "eksploderer")
"all": [{"id": a.id, "text": a.text, "is_truth": a.is_truth, "house": a.house,
"authors": list(a.authors), "assisted": [x for x in a.authors if x in self.assisted],
"picked": a.id in picked, "likes": len(self.likes.get(a.id, ()))} for a in self.answers],
# point i dette spørgsmål pr. spiller (inkl. hjerter), til optællingen
"deltas": [{"player_id": p.id, "delta": p.last_delta or 0} for p in self.players.values()],
"step_ms": REVEAL_STEP_MS,
"steps_ms": self.reveal_steps_ms, "tail_ms": self.reveal_tail_ms,
"started_at": int(self.reveal_started_at * 1000) if self.reveal_started_at else None,
"note": self.question.get("note", "") if self.question else "",
+1 -1
View File
@@ -109,7 +109,7 @@
"Find det rigtige svar. Virkeligheden har en ret løs opfattelse af, hvad der er rimeligt.",
"Ét af svarene passer. Vælg det. Resten sælger vi på næste loppemarked.",
"Vælg sandheden. Publikum gætter med. Nu kan endnu flere blande sig.",
"Find sandheden, og giv gerne sjove løgne et hjerte. Også bedragere har følelser.",
"Find sandheden. Bagefter må I gerne give hjerter til de sjoveste løgne.",
"[suspicious] Ét svar er sandt. Vælg med omhu. De andre har bare øvet sig."
],
"guess_hurry": [
+39 -6
View File
@@ -27,6 +27,9 @@ class GameTestCase(SimpleTestCase):
def through_category(self):
if self.g.phase == "category":
self.g.choose_category(self.g.chooser_id, self.g.category_options[0]["id"], self.now)
self.assertEqual(self.g.phase, "intro")
self.now += engine.INTRO_SECONDS
self.g.tick(self.now)
self.assertEqual(self.g.phase, "lie")
def truth_id(self):
@@ -120,17 +123,44 @@ class RoundTests(GameTestCase):
self.assertTrue(steps[-1]["is_truth"])
self.assertEqual(steps[0]["text"], "katte")
def test_like_gives_points_and_toggles(self):
def test_like_only_in_reveal_gives_points_and_toggles(self):
for p, lie in [(self.a, "katte"), (self.b, "hunde"), (self.c, "heste")]:
self.g.submit_lie(p.id, lie, self.now)
katte = next(a.id for a in self.g.answers if a.text == "katte")
with self.assertRaises(GameError): # skal gætte først
with self.assertRaises(GameError): # ikke i gættefasen
self.g.like(self.b.id, katte)
self.g.guess(self.b.id, self.truth_id(), self.now)
for p in (self.a, self.b, self.c):
self.g.guess(p.id, self.truth_id(), self.now)
self.assertEqual(self.g.phase, "reveal")
base = self.a.score
self.g.like(self.b.id, katte)
self.assertEqual(self.a.score, engine.POINTS_LIKE)
self.assertEqual(self.a.score, base + engine.POINTS_LIKE)
with self.assertRaises(GameError): # ikke sandheden
self.g.like(self.b.id, self.truth_id())
snap = self.g.snapshot(self.now, host=True)
self.assertEqual(next(x for x in snap["reveal"]["all"] if x["id"] == katte)["likes"], 1)
self.g.like(self.b.id, katte)
self.assertEqual(self.a.score, 0)
self.assertEqual(self.a.score, base)
def test_lie_for_me_gives_half_points(self):
suggestion = self.g.lie_for_me(self.a.id)
self.g.submit_lie(self.a.id, suggestion, self.now)
self.g.submit_lie(self.b.id, "hunde", self.now)
self.g.submit_lie(self.c.id, "heste", self.now)
mine = next(x.id for x in self.g.answers if self.a.id in x.authors)
hunde = next(x.id for x in self.g.answers if x.text == "hunde")
self.g.guess(self.b.id, mine, self.now)
self.g.guess(self.c.id, mine, self.now)
self.g.guess(self.a.id, hunde, self.now)
self.assertEqual(self.a.score, engine.POINTS_FOOLED * 2 // 2) # to narret, halve point
self.assertEqual(self.b.score, engine.POINTS_FOOLED) # egen løgn, fulde point
all_ = self.g.snapshot(self.now, host=True)["reveal"]["all"]
self.assertEqual(next(x for x in all_ if x["id"] == mine)["assisted"], [self.a.id])
def test_edited_suggestion_counts_as_own_lie(self):
suggestion = self.g.lie_for_me(self.a.id)
self.g.submit_lie(self.a.id, suggestion + " måske", self.now)
self.assertNotIn(self.a.id, self.g.assisted)
def test_timeouts_advance_and_fill_with_house_lies(self):
self.g.submit_lie(self.a.id, "katte", self.now)
@@ -180,6 +210,7 @@ class FullGameTests(GameTestCase):
self.g.tick(self.now)
self.assertEqual(self.g.phase, "final")
self.assertEqual(seen_phases.count("lie"), 7)
self.assertEqual(seen_phases.count("intro"), 7)
self.assertEqual(seen_phases.count("scores"), 2)
self.assertEqual(seen_phases.count("category"), 6) # finalen vælger selv
final = self.g.snapshot(self.now, host=True)["final"]
@@ -188,11 +219,13 @@ class FullGameTests(GameTestCase):
self.assertEqual(len(self.g.used_questions), 7)
self.g.play_again(None, self.now)
self.assertEqual(self.g.phase, "category")
self.assertEqual(engine.slugify("Mad og drikke"), "mad_og_drikke")
self.assertEqual(engine.slugify("Rummet og naturen"), "rummet_og_naturen")
self.assertTrue(all(p.score == 0 for p in players))
def test_kick_chooser_picks_random_category(self):
a, b, c = self.join("A", "B", "C")
self.g.start(None, self.now)
self.g.kick(self.g.chooser_id, self.now)
self.assertEqual(self.g.phase, "lie")
self.assertEqual(self.g.phase, "intro")
self.assertEqual(len(self.g.players), 2)
+24
View File
@@ -0,0 +1,24 @@
"""Lyd- og billedfiler med versionerede URL'er, så browseren kan gemme dem for altid.
Templates indlejrer `asset_map()` som JSON (window.WPP_ASSETS), og JS slår op i den i stedet for at
bygge stier selv. I drift giver {% static %} hashede filnavne (immutable cache); i udvikling de rå navne.
"""
from functools import lru_cache
from pathlib import Path
from django.templatetags.static import static
STATIC_DIR = Path(__file__).resolve().parent / "static" / "party"
@lru_cache(maxsize=1)
def asset_map() -> dict:
sfx = {p.stem: static(f"party/sfx/{p.name}") for p in sorted((STATIC_DIR / "sfx").glob("*.mp3"))}
tease = [static(f"party/tease/{p.name}") for p in sorted((STATIC_DIR / "tease").glob("*.*"))
if p.suffix.lower() in (".png", ".jpg", ".jpeg", ".webp", ".svg")]
return {"sfx": sfx, "tease": tease}
def tease_images() -> list[str]:
return asset_map()["tease"]
+5
View File
@@ -84,10 +84,13 @@ class HostConsumer(_RoomConsumer):
async def send_state(self):
state = self.room.game.snapshot(time.time(), host=True)
state["narration"] = self.room.narration
state["phase_key"] = self.room.game.phase_key()
await self.send_json({"type": "state", "state": state})
async def handle(self, action, content, now):
g = self.room.game
if action in ("start", "skip", "play_again"):
self.room.cut_speech() # værtens knap vinder over speakeren
if action == "start":
g.start(None, now)
elif action == "skip":
@@ -144,6 +147,8 @@ class PlayConsumer(_RoomConsumer):
if not self.member_id or not self._member():
raise GameError("unknown_token", "Join spillet først.")
mid = self.member_id
if action in ("start", "play_again") and mid == g.vip_id():
self.room.cut_speech()
if action == "start":
g.start(mid, now)
elif action == "choose_category":
+2 -1
View File
@@ -3,7 +3,7 @@ import re
from django.core.management.base import BaseCommand
from party import tts
from party.narrator import LINES
from party.narrator import LINES, TEASES
class Command(BaseCommand):
@@ -14,5 +14,6 @@ class Command(BaseCommand):
self.stdout.write("ElevenLabs er ikke sat op (ELEVENLABS_API_KEY/ELEVENLABS_VOICE_ID). Springer over.")
return
static = [line for variants in LINES.values() for line in variants if not re.search(r"\{\w+\}", line)]
static += TEASES
ok = sum(1 for line in static if tts.synth_sync(line))
self.stdout.write(self.style.SUCCESS(f"{ok} af {len(static)} faste replikker ligger i cachen."))
+8 -1
View File
@@ -58,6 +58,11 @@ def speakable_prompt(prompt: str) -> str:
return capitalize(re.sub(r"_{3,}", "blank", prompt))
# Drillerier på telefonen: kort tekst på skærmen og (hvis der er stemme) samme replik læst op.
TEASES = ["Buh!", "Hov hov, der røg du i!", "Avs, avs, avs!", "Snydt!", "Hihi, den var god!",
"Klassisk dig!", "Ups!", "Ha! Fanget!"]
class Narrator:
def __init__(self, rng: random.Random | None = None):
self.rng = rng or random.Random()
@@ -118,8 +123,10 @@ class Narrator:
return [self.line("category_choose", navn=ev["chooser"])]
if t == "category_timeout":
return [self.line("category_timeout", navn=ev["chooser"], kategori=ev["category"])]
if t == "category_intro":
return [self.line("lie_start", kategori=ev["category"])]
if t == "lie_start":
return [self.line("lie_start", kategori=ev["category"]), speakable_prompt(ev["prompt"])]
return [speakable_prompt(ev["prompt"])]
if t in ("lie_hurry", "lie_all_done", "lie_was_truth", "guess_start", "guess_hurry", "play_again"):
return [self.line(t)]
if t == "scores":
+105 -11
View File
@@ -13,10 +13,12 @@ import logging
import random
import time
from fupogfakta import engine
from fupogfakta.engine import Game
from . import tts
from .narrator import Narrator
from .assets import tease_images
from .narrator import TEASES, Narrator
log = logging.getLogger(__name__)
@@ -27,6 +29,12 @@ LOBBY_WAITING_AFTER = 40 # sekunder i lobbyen med nok spillere, før speaker
REVEAL_AUDIO_WAIT = 8 # længst vi venter på lyd til afsløringen
REVEAL_MIN_STEP_MS = 3500
REVEAL_PAD_MS = 900 # luft efter hver replik i afsløringen
SPEECH_GAP = 0.35 # pause mellem to replikker
MAX_HOLD = 20 # speakeren kan højst holde et faseskift tilbage så længe
PENDING_HOLD_MAX_AGE = 8 # en replik, der stadig genereres efter så længe, venter vi ikke på
TEASE_CHANCE = 0.45 # sandsynlighed for at en narret spillers telefon bliver drillet
TEASES_PER_QUESTION = 2
TEASE_AT_MS = 2400 # når stemplet lander i et trin (svar → hvem → stempel)
_rooms: dict[str, "Room"] = {}
@@ -48,6 +56,10 @@ class Room:
self._welcomed = False
self._last_join_line = 0.0
self._lobby_waiting_said = False
# Tale-tidslinje: hvornår speakeren forventes at være færdig med den aktuelle skærms replikker
self.speech_end = 0.0
self._phase_key = game.phase_key()
self._pending: dict[str, list[float]] = {} # fasenøgle -> starttider for replikker under generering
def touch(self):
self.last_activity = time.time()
@@ -55,10 +67,28 @@ class Room:
def changed(self):
"""Kald efter hver ændring: planlæg timer og send nyt snapshot til alle."""
self.touch()
self._phase_key = self.game.phase_key()
self.handle_events()
self._update_hold()
self._schedule_timer()
self.broadcast()
def cut_speech(self):
"""Værten sprang over, eller spillet blev startet: speakeren afbrydes, intet holdes tilbage."""
self.speech_end = time.time()
self._pending.clear()
def _update_hold(self):
"""Faseskift venter, til speakeren er færdig med den aktuelle skærm."""
now = time.time()
hold = self.speech_end if self.speech_end > now else None
started = [t for t in self._pending.get(self._phase_key, []) if now - t < PENDING_HOLD_MAX_AGE]
if started:
hold = max(hold or 0, now + 0.4) # en replik er ved at blive lavet; vent lidt endnu
if hold is not None:
hold = min(hold, now + MAX_HOLD)
self.game.hold_until = hold
def broadcast(self):
if not self._broadcast_pending:
self._broadcast_pending = True
@@ -79,8 +109,9 @@ class Room:
self._timer = None
deadline = self.game.deadline
if deadline is not None:
when = max(deadline, self.game.hold_until or 0)
loop = asyncio.get_running_loop()
delay = max(0.0, deadline - time.time()) + 0.05
delay = max(0.0, when - time.time()) + 0.05
self._timer = loop.call_later(delay, self._on_timer)
def _on_timer(self):
@@ -93,6 +124,7 @@ class Room:
if changed:
self.changed()
else:
self._update_hold()
self._schedule_timer()
def close(self):
@@ -106,6 +138,8 @@ class Room:
"""Tøm motorens begivenheder og lad speakeren reagere. Kaldes også efter afviste handlinger."""
events = self.game.drain_events()
if not tts.enabled():
if any(e["type"] == "reveal" for e in events):
self._schedule_teases() # uden stemme starter afsløringen med det samme
return
skip_start = any(e["type"] == "play_again" for e in events)
for ev in events:
@@ -143,20 +177,38 @@ class Room:
if self._voice_queue is None:
self._voice_queue = asyncio.Queue()
asyncio.get_running_loop().create_task(self._voice_worker())
key = self.game.phase_key()
started = time.time()
self._pending.setdefault(key, []).append(started)
task = asyncio.get_running_loop().create_task(tts.synth(text))
self._voice_queue.put_nowait((text, task))
self._voice_queue.put_nowait((text, task, key, started))
self._update_hold()
async def _voice_worker(self):
while True:
text, task = await self._voice_queue.get()
audio = await task
if audio:
self._publish(text, audio)
text, task, key, started = await self._voice_queue.get()
try:
audio = await task
except Exception: # noqa: BLE001
audio = None
pending = self._pending.get(key, [])
if started in pending:
pending.remove(started)
if not pending:
self._pending.pop(key, None)
# En replik til en skærm, der allerede er væk, afspilles ikke.
if audio and key == self.game.phase_key():
self._publish(text, audio, key)
self._update_hold()
self._schedule_timer()
def _publish(self, text: str, audio: dict):
def _publish(self, text: str, audio: dict, key: str):
now = time.time()
start = max(now, self.speech_end)
self.speech_end = start + audio["ms"] / 1000 + SPEECH_GAP
self._narr_seq += 1
self.narration.append({"id": self._narr_seq, "url": audio["url"], "ms": audio["ms"],
"at": int(time.time() * 1000), "text": text})
"at": int(now * 1000), "text": text, "key": key})
del self.narration[:-NARRATION_KEEP]
self.broadcast()
@@ -195,7 +247,7 @@ class Room:
"""Lav lyd til hvert trin, og lad afsløringens tempo følge replikkerne."""
g = self.game
ident = (g.round_idx, g.q_idx)
g.hold_reveal(time.time(), max_wait=REVEAL_AUDIO_WAIT + 2)
g.hold_reveal(time.time(), max_wait=REVEAL_AUDIO_WAIT + 2 + max(0.0, self.speech_end - time.time()))
self.changed()
names = {p.id: p.name for p in g.players.values()}
texts = [self.narrator.reveal_step(step, names, len(g.players)) for step in g.reveal_steps]
@@ -206,6 +258,9 @@ class Room:
results = await asyncio.wait_for(asyncio.gather(*jobs), timeout=REVEAL_AUDIO_WAIT)
except asyncio.TimeoutError:
results = [None] * len(jobs)
wait = min(self.speech_end - time.time(), MAX_HOLD)
if wait > 0: # lad speakeren tale færdig, før afsløringen går i gang
await asyncio.sleep(wait)
if g.phase != "reveal" or (g.round_idx, g.q_idx) != ident or g.reveal_started_at is not None:
return # værten sprang over, eller tiden løb ud
steps_ms = []
@@ -214,9 +269,48 @@ class Room:
steps_ms.append(max(REVEAL_MIN_STEP_MS, (audio["ms"] + REVEAL_PAD_MS) if audio else 0))
note_audio = results[-1]
g.reveal_note_audio = {"url": note_audio["url"], "ms": note_audio["ms"]} if note_audio else None
tail = 3000 + (note_audio["ms"] + REVEAL_PAD_MS if note_audio else 0)
# Forklaringen starter med sandhedens trin og må gerne fortsætte ind i opsummeringen,
# men opsummeringen skal have sin fulde tid, efter at speakeren er færdig.
truth_ms = next((ms for st, ms in zip(g.reveal_steps, steps_ms) if st["is_truth"]), 0)
tail = engine.REVEAL_TAIL_MS + max(0, (note_audio["ms"] + REVEAL_PAD_MS - truth_ms) if note_audio else 0)
g.start_reveal_timeline(time.time(), steps_ms, tail)
self.changed()
self._schedule_teases()
# ── drillerier på telefonen ──────────────────────────────────────────────
def _schedule_teases(self):
"""Når stemplet "LØGN" lander på TV'et, kan den narrede spillers telefon sige buh."""
g = self.game
if g.reveal_started_at is None or not tease_images():
return
ident, rng, chosen = (g.round_idx, g.q_idx), self.narrator.rng, set()
offset = 0
for step, ms in zip(g.reveal_steps, g.reveal_steps_ms):
if not step["is_truth"]:
victims = [p for p in step["guessed_by"] if p not in chosen and rng.random() < TEASE_CHANCE]
for pid in victims[: max(0, TEASES_PER_QUESTION - len(chosen))]:
chosen.add(pid)
at = g.reveal_started_at + (offset + min(TEASE_AT_MS, ms - 300)) / 1000
asyncio.get_running_loop().call_later(max(0, at - time.time()), self._fire_tease, pid, ident)
offset += ms
def _fire_tease(self, pid: str, ident):
g = self.game
if g.phase != "reveal" or (g.round_idx, g.q_idx) != ident:
return
text = self.narrator.rng.choice(TEASES)
asyncio.get_running_loop().create_task(self._send_tease(pid, text))
async def _send_tease(self, pid: str, text: str):
audio = await tts.synth(text) # forudgenereret af tts_warm, så det er straks
msg = {"type": "tease", "text": text, "image": self.narrator.rng.choice(tease_images()),
"sound": audio["url"] if audio else None}
for conn in list(self.connections):
if getattr(conn, "member_id", None) == pid:
try:
await conn.send_json(msg)
except Exception: # noqa: BLE001
pass
def new_code() -> str:
+323 -23
View File
@@ -61,7 +61,7 @@
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 KNOWN = { category: 15000, intro: 5000, lie: 60000, guess: 30000, scores: 10000 };
var seenTotals = {};
WPP.totalFor = function (phase, deadline) {
if (!deadline) return 0;
@@ -285,69 +285,369 @@
};
};
/* ---------- lyd (WebAudio, syntetiseret) ---------- */
/* ---------- afsløringens tidslinje (bruges af både storskærm og telefon) ----------
Serveren sender started_at og en længde pr. trin (speakerens replik styrer tempoet) + tail_ms
til opsummeringen. started_at = null betyder, at serveren stadig laver lyd ("trommehvirvel").
memo: et objekt, hvor starttiden huskes, hvis serveren ingen tider sender. */
WPP.revealProgress = function (state, memo) {
memo = memo || {};
var seen = memo.revealSeen || (memo.revealSeen = {});
var r = state.reveal || {};
var steps = r.steps || [];
var key = (state.round ? state.round.number + '.' + state.round.question_number : '') + ':' + ((state.question || {}).prompt || '');
if (state.reveal && r.started_at === null) return { pending: true, done: false, i: 0, stage: -1, n: steps.length, key: key + '|pending' };
var durs = steps.map(function (s, i) { return (r.steps_ms && r.steps_ms[i]) || r.step_ms || 4000; });
var tailMs = r.tail_ms == null ? 3000 : r.tail_ms;
var total = durs.reduce(function (a, b) { return a + b; }, 0) + tailMs;
var start;
if (r.started_at) start = r.started_at;
else if (state.deadline) start = state.deadline - total;
else {
if (!seen[key]) seen[key] = WPP.now();
start = seen[key];
}
var el = Math.max(0, WPP.now() - start), i = 0;
while (i < steps.length && el >= durs[i]) { el -= durs[i]; i++; }
if (i >= steps.length) return { done: true, i: steps.length, n: steps.length, tail: el, tailMs: tailMs, start: start, key: key + '|done' };
/* svaret først, så hvem der valgte det, så stemplet (faste tider, så lange replikker ikke forsinker stemplet) */
var stage = el < Math.min(1100, durs[i] * 0.28) ? 0 : el < Math.min(2300, durs[i] * 0.55) ? 1 : 2;
return { done: false, i: i, stage: stage, n: steps.length, start: start, key: key + '|' + i + '.' + stage };
};
WPP.reducedMotion = function () {
try { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; } catch (e) { return false; }
};
/* ---------- versionerede filer ----------
Templaten indlejrer {{ assets|json_script:"wpp-assets" }} = {sfx: {navn: url}, tease: [url]}.
I drift er URL'erne hashede (caches for altid); dev-harness har ingen template og bruger sfx/<navn>.mp3. */
var ASSETS = (function () {
var a = null;
try { var el = document.getElementById('wpp-assets'); if (el) a = JSON.parse(el.textContent); } catch (e) { a = null; }
if (!a && window.WPP_ASSETS) a = window.WPP_ASSETS;
a = a || {};
return { sfx: a.sfx || null, tease: a.tease || [] };
})();
WPP.assets = ASSETS;
/* ---------- lyd: én fælles AudioContext ----------
Browseren kræver et klik, før der må komme lyd. Første tryk/tast låser op,
afkoder lydeffekterne og starter baggrundsmusikken. */
var actx = null;
var sound = (WPP.sound = {
muted: WPP.ls.get('wpp:mute') === '1',
onchange: null,
ready: function () { return !!actx && actx.state === 'running'; },
ctx: function () { return actx; },
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; }
if (!actx) {
if (!AC) return;
try { actx = new AC(); } catch (e) { actx = null; return; }
actx.onstatechange = onReady;
}
if (actx.state !== 'running') { var p = actx.resume(); if (p && p.then) p.then(onReady, function () {}); }
if (!sound._primed) { /* iOS: en tavs lyd i selve klikket låser lyden op for resten af besøget */
try {
var s = actx.createBufferSource();
s.buffer = actx.createBuffer(1, 1, 22050);
s.connect(actx.destination); s.start(0);
sound._primed = true;
} catch (e) { /* */ }
}
onReady();
},
setMuted: function (m) { sound.muted = !!m; WPP.ls.set('wpp:mute', m ? '1' : '0'); },
tone: function (freq, start, dur, type, vol) {
/* cb(true) når lyden kører, cb(false) hvis browseren stadig blokerer efter ms */
whenReady: function (cb, ms) {
if (sound.ready()) { cb(true); return; }
if (!actx) { cb(false); return; }
var done = false;
var fin = function (ok) { if (!done) { done = true; cb(ok); } };
try { var p = actx.resume(); if (p && p.then) p.then(function () { fin(sound.ready()); }, function () { fin(false); }); } catch (e) { /* */ }
setTimeout(function () { fin(sound.ready()); }, ms || 400);
},
setMuted: function (m) {
sound.muted = !!m;
WPP.ls.set('wpp:mute', m ? '1' : '0');
music._apply();
},
/* syntetiseret tone – bruges kun, når en lydfil mangler */
tone: function (freq, start, dur, type, vol, freq2) {
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);
if (freq2) o.frequency.exponentialRampToValueAtTime(freq2, t + dur);
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);
play: function (kind) { WPP.sfx(kind); } /* gammelt navn */
});
var wasReady = false;
function onReady() {
if (!sound.ready()) return;
Object.keys(raw).forEach(decode);
music._kick();
if (!wasReady) { wasReady = true; if (sound.onchange) sound.onchange(); }
}
/* ---------- lydeffekter ---------- */
var CATS = ['dyreriget', 'kroppen', 'mad_og_drikke', 'rummet_og_naturen', 'opfindelser', 'danmark', 'sprog_og_ord', 'sport', 'historie', 'geografi'];
var SFX = ['whoosh', 'tick', 'timeup', 'submit', 'join', 'drumroll', 'stamp', 'truth', 'house', 'explode', 'coins', 'like', 'fanfare', 'category',
'whoosh_long', 'swoosh_up', 'pop_in', 'slide', 'boo'].concat(CATS.map(function (c) { return 'cat_' + c; }));
var MUSIC = ['music_lobby', 'music_choose', 'music_think', 'music_reveal', 'music_final'];
var VOL = { tick: 0.45, whoosh: 0.55, submit: 0.6, join: 0.7, like: 0.6, coins: 0.55, drumroll: 0.8,
whoosh_long: 0.6, swoosh_up: 0.5, pop_in: 0.38, slide: 0.55, boo: 0.9 };
var SFX_BASE = (function () {
var s = document.currentScript && document.currentScript.src;
return (s ? s.replace(/[^/]*$/, '') : '/static/party/') + 'sfx/';
})();
/* findes filen? Med asset-kortet ved vi det sikkert; uden (dev) gætter vi ud fra de kendte navne */
function known(name) {
if (ASSETS.sfx) return Object.prototype.hasOwnProperty.call(ASSETS.sfx, name);
return SFX.indexOf(name) >= 0 || MUSIC.indexOf(name) >= 0;
}
function urlOf(name) { return (ASSETS.sfx && ASSETS.sfx[name]) || SFX_BASE + name + '.mp3'; }
/* hver fil hentes én gang (raw) og afkodes én gang (bufs); nøglen er navnet eller "url:<url>" */
var raw = {}, bufs = {}, failed = {}, loading = {}, waiters = {};
function fetchRaw(key, url) {
if (loading[key]) return loading[key];
loading[key] = (window.fetch ? fetch(url) : Promise.reject(new Error('fetch')))
.then(function (r) { if (!r.ok) throw new Error(r.status); return r.arrayBuffer(); })
.then(function (ab) { raw[key] = ab; decode(key); })
.catch(function () { failed[key] = true; flush(key, null); }); /* filen mangler: syntetisk lyd i stedet */
return loading[key];
}
function load(name) {
if (!known(name)) { failed[name] = true; return Promise.resolve(); }
return fetchRaw(name, urlOf(name));
}
function decode(key) {
var ab = raw[key];
if (!ab || !actx || bufs[key]) return;
raw[key] = null;
var done = false;
var ok = function (b) { if (done) return; done = true; bufs[key] = b; flush(key, b); if (music.name === key) music._kick(); };
var bad = function () { if (done) return; done = true; failed[key] = true; flush(key, null); };
try { var p = actx.decodeAudioData(ab, ok, bad); if (p && p.catch) p.catch(bad); } catch (e) { bad(); }
}
function flush(key, b) {
var w = waiters[key];
delete waiters[key];
(w || []).forEach(function (f) { f(b); });
}
/* Promise<AudioBuffer|null> – kræver en kørende AudioContext for at afkode */
function bufferFor(key, url) {
if (bufs[key]) return Promise.resolve(bufs[key]);
if (failed[key]) return Promise.resolve(null);
return new Promise(function (res) {
(waiters[key] || (waiters[key] = [])).push(res);
if (url) fetchRaw(key, url); else load(key);
decode(key);
});
}
function playBuf(b, vol, opts) {
var delay = opts.at != null ? Math.max(0, opts.at - actx.currentTime) : (opts.delay || 0);
if (voice.active) vol *= 0.6; /* speakeren skal kunne høres */
var src = actx.createBufferSource(), g = actx.createGain();
src.buffer = b;
if (opts.rate) src.playbackRate.value = opts.rate;
g.gain.value = vol;
src.connect(g); g.connect(actx.destination);
src.start(actx.currentTime + delay);
return delay + b.duration / (opts.rate || 1);
}
var FALLBACK = {
whoosh: function (t) { t(523, 0, 0.15); t(784, 0.09, 0.25); },
whoosh_long: function (t) { t(200, 0, 0.6, 'sawtooth', 0.05, 900); },
swoosh_up: function (t) { t(300, 0, 0.35, 'sine', 0.1, 1400); },
pop_in: function (t) { t(700, 0, 0.07, 'sine', 0.1, 1100); },
slide: function (t) { t(900, 0, 0.5, 'triangle', 0.1, 220); },
boo: function (t) { t(220, 0, 0.5, 'sawtooth', 0.12, 140); t(165, 0.35, 0.6, 'sawtooth', 0.12, 110); },
tick: function (t) { t(1200, 0, 0.06, 'square', 0.06); },
timeup: function (t) { t(330, 0, 0.25, 'square', 0.12); t(220, 0.18, 0.45, 'square', 0.12); },
submit: function (t) { t(880, 0, 0.08, 'sine', 0.12); },
join: function (t) { t(660, 0, 0.1, 'sine', 0.14); t(990, 0.08, 0.15, 'sine', 0.14); },
drumroll: function (t) { for (var i = 0; i < 14; i++) t(140 + (i % 2) * 8, i * 0.07, 0.08, 'triangle', 0.1); },
stamp: function (t) { t(110, 0, 0.35, 'sawtooth', 0.22); t(82, 0.02, 0.4, 'sine', 0.3); },
house: function (t) { t(150, 0, 0.3, 'sawtooth', 0.2); t(98, 0.05, 0.4, 'sine', 0.28); },
truth: function (t) { [523, 659, 784, 1047].forEach(function (f, i) { t(f, i * 0.09, 0.35); }); },
explode: function (t) { t(70, 0, 0.35, 'sawtooth', 0.25); t(45, 0.03, 0.4, 'square', 0.15); },
coins: function (t) { [1319, 1568, 1976].forEach(function (f, i) { t(f, i * 0.06, 0.12, 'sine', 0.1); }); },
like: function (t) { t(988, 0, 0.1, 'sine', 0.12); t(1319, 0.07, 0.14, 'sine', 0.1); },
fanfare: function (t) { [523, 659, 784, 1047, 784, 1047].forEach(function (f, i) { t(f, i * 0.12, 0.3); }); },
category: function (t) { t(784, 0, 0.12, 'triangle', 0.15); t(1175, 0.1, 0.25, 'triangle', 0.15); },
pop: function (t) { t(880, 0, 0.08, 'sine', 0.12); },
phase: function (t) { t(523, 0, 0.15); t(784, 0.09, 0.25); }
};
var lastAt = {};
/* WPP.sfx(navn, {volume, rate, delay, fallback, gap}) – afspil en effekt (kan overlappe sig selv).
fallback: navn der bruges, hvis filen ikke findes (fx cat_<slug> → category).
gap: mindst så mange ms mellem to af samme navn; for tætte kald droppes (mod lyd-spam).
Returnerer sekunder til lyden er slut (0 hvis intet blev spillet). */
WPP.sfx = function (name, opts) {
opts = opts || {};
if (sound.muted || !sound.ready()) return 0;
if (!bufs[name] && opts.fallback) name = opts.fallback;
var delay = opts.delay || 0;
if (opts.gap) {
var at = actx.currentTime + delay;
if (at < (lastAt[name] || 0) - 0.005) return 0;
lastAt[name] = at + opts.gap / 1000;
}
var b = bufs[name];
if (!b) {
var f = FALLBACK[name] || FALLBACK[opts.fallback];
if (f) f(function (fr, st, d, ty, v, f2) { sound.tone(fr, (st || 0) + delay, d, ty, v, f2); });
return f ? delay + 0.5 : 0;
}
var vol = opts.volume != null ? opts.volume : (VOL[name] || (/^cat_/.test(name) ? 0.85 : 0.8));
return playBuf(b, vol, opts);
};
WPP.sfx.names = SFX;
WPP.sfx.has = function (name) { return known(name) && !failed[name]; };
/* Hent filerne. Uden liste: alt (kun værten); telefonerne henter kun det, de bruger (boo). */
WPP.sfx.preload = function (list) {
if (list) { list.forEach(load); return; }
if (WPP.sfx._preloaded) return;
WPP.sfx._preloaded = true;
(ASSETS.sfx ? Object.keys(ASSETS.sfx) : SFX.concat(MUSIC)).forEach(load);
};
/* Afspil en vilkårlig lydfil (fx serverens TTS) gennem AudioContext; caches pr. URL.
opts.at = AudioContext-tid, lyden tidligst må starte (afkodningen kan tage et øjeblik). */
WPP.playUrl = function (url, opts) {
opts = opts || {};
if (!url || sound.muted || !sound.ready()) return Promise.resolve(0);
return bufferFor('url:' + url, url).then(function (b) {
if (!sound.ready()) return 0;
if (b) return playBuf(b, opts.volume != null ? opts.volume : 1, opts);
try { var a = new Audio(url); var p = a.play(); if (p && p.catch) p.catch(function () {}); } catch (e) { /* */ }
return 0;
});
};
/* ---------- baggrundsmusik: sømløs loop, crossfade, dukker sig for speakeren ----------
Samme nummer på tværs af faser (fx lie → guess) fortsætter uden at starte forfra. */
var FADE = 0.8;
var MUSIC_GAIN = { music_reveal: 0.45 }; /* afsløringen: effekterne er i fokus */
var music = (WPP.music = {
name: null, volume: 0.18, duckVolume: 0.05, ducked: false, cur: null, bus: null, _fade: FADE, _gain: 1,
/* WPP.music.set('music_lobby') | WPP.music.set(null, {fade: 0.25}) – fade = udtoning af det gamle nummer (s) */
set: function (name, opts) {
opts = opts || {};
name = name || null;
if (name === music.name) return;
music.name = name;
music._fade = opts.fade != null ? opts.fade : FADE;
music._gain = opts.gain != null ? opts.gain : (MUSIC_GAIN[name] || 1);
if (name) load(name);
music._kick();
},
_target: function () { return sound.muted ? 0 : music.ducked ? music.duckVolume : music.volume; },
_apply: function () {
if (!music.bus || !actx) return;
var g = music.bus.gain, t = actx.currentTime;
g.cancelScheduledValues(t);
g.setValueAtTime(g.value, t);
g.setTargetAtTime(music._target(), t, music.ducked ? 0.12 : 0.35);
},
/* dukker sig straks, men slipper først efter en kort pause (ingen pumpen mellem to replikker) */
duck: function (on) {
on = !!on;
clearTimeout(music._duckT);
if (on) { if (!music.ducked) { music.ducked = true; music._apply(); } return; }
if (!music.ducked) return;
music._duckT = setTimeout(function () { music.ducked = false; music._apply(); }, 700);
},
/* skift til music.name, hvis lyden er klar */
_kick: function () {
if (!sound.ready()) return;
if (!music.bus) {
music.bus = actx.createGain();
music.bus.gain.value = music._target();
music.bus.connect(actx.destination);
}
var cur = music.cur, name = music.name, t = actx.currentTime;
if (cur && cur.name === name) return;
if (name && !bufs[name]) { if (raw[name]) decode(name); if (!bufs[name]) { fadeOut(cur, t, music._fade); music.cur = null; return; } }
fadeOut(cur, t, music._fade);
music.cur = null;
if (!name) return;
var src = actx.createBufferSource(), g = actx.createGain();
src.buffer = bufs[name]; src.loop = true;
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(music._gain || 1, t + FADE);
src.connect(g); g.connect(music.bus);
src.start(t);
music.cur = { name: name, src: src, g: g };
}
});
['pointerdown', 'keydown', 'touchstart'].forEach(function (ev) {
window.addEventListener(ev, function () { sound.unlock(); voice.unblock(); }, { passive: true });
function fadeOut(track, t, dur) {
if (!track) return;
dur = Math.max(0.05, dur == null ? FADE : dur);
try {
track.g.gain.cancelScheduledValues(t);
track.g.gain.setValueAtTime(Math.max(0.0001, track.g.gain.value), t);
track.g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
track.src.stop(t + dur + 0.05);
} catch (e) { /* allerede stoppet */ }
}
['pointerdown', 'keydown', 'touchstart', 'touchend', 'click'].forEach(function (ev) {
window.addEventListener(ev, function () { sound.unlock(); voice.unblock(); }, { passive: true, capture: true });
});
/* ---------- speakeren (lydfiler fra serveren, én ad gangen) ---------- */
/* ---------- speakeren (lydfiler fra serveren, én ad gangen) ----------
Elementer er {url, key}. key = den skærm (phase_key), replikken hører til; en replik til en
skærm, der er væk, springes over. Uden key (afsløringens trin) afspilles den altid. */
function item(x) { return typeof x === 'string' ? { url: x } : x; }
var voice = (WPP.voice = {
queue: [], current: null, blocked: false, onchange: null,
enqueue: function (url) { if (!url || sound.muted) return; voice.queue.push(url); if (!voice.current) voice._next(); },
now: function (urls) { voice.stop(); (urls || []).forEach(function (u) { if (u && !sound.muted) voice.queue.push(u); }); voice._next(); },
queue: [], current: null, blocked: false, active: false, phaseKey: null, onchange: null,
enqueue: function (x) {
x = item(x);
if (!x || !x.url || sound.muted) return;
voice.queue.push(x);
if (!voice.current) voice._next();
},
now: function (list) {
voice.stop();
(list || []).forEach(function (x) { x = item(x); if (x && x.url && !sound.muted) voice.queue.push(x); });
voice._next();
},
stop: function () {
voice.queue = [];
if (voice.current) { try { voice.current.pause(); } catch (e) {} voice.current = null; }
voice._active(false);
},
stale: function (x) { return !!(x.key && voice.phaseKey && x.key !== voice.phaseKey); },
unblock: function () {
if (!voice.blocked) return;
voice.blocked = false;
if (voice.onchange) voice.onchange();
if (!voice.current) voice._next();
},
_active: function (on) { if (voice.active !== !!on) { voice.active = !!on; music.duck(on); } },
_next: function () {
var url = voice.queue.shift();
if (!url) { voice.current = null; return; }
var a = new Audio(url);
var x = voice.queue.shift();
while (x && voice.stale(x)) x = voice.queue.shift(); /* forældet replik: spring over */
if (!x) { voice.current = null; voice._active(false); return; }
var a = new Audio(x.url);
voice.current = a;
voice._active(true);
a.onended = a.onerror = function () { if (voice.current === a) voice._next(); };
var p = a.play();
if (p && p.catch) p.catch(function (e) {
if (voice.current !== a) return;
voice.current = null;
if (e && e.name === 'NotAllowedError') { // browseren kræver et klik før lyd
voice.queue.unshift(url);
voice.queue.unshift(x);
voice.blocked = true;
voice._active(false);
if (voice.onchange) voice.onchange();
} else voice._next();
});
+87 -22
View File
@@ -3,7 +3,18 @@
'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'];
var PHASES = ['join', 'lobby', 'category', 'category-chosen', 'intro', 'intro-final', 'lie', 'guess', 'reveal-wait', 'reveal', 'tease', 'summary', 'scores', 'final'];
/* fixture-navn → spillets fase ("tease" = afsløringen, hvor telefon 1 bliver drillet) */
var REAL = { join: 'lobby', 'category-chosen': 'category', 'intro-final': 'intro', 'reveal-wait': 'reveal', summary: 'reveal', tease: 'reveal' };
/* drilleri-billede til harnessen (i drift: et tilfældigt billede fra static/party/tease/) */
var TEASE_IMG = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200"><rect width="200" height="200" fill="#ffd23f"/>' +
'<circle cx="100" cy="105" r="72" fill="#ff8c42" stroke="#1d0846" stroke-width="6"/>' +
'<ellipse cx="72" cy="88" rx="13" ry="17" fill="#fff" stroke="#1d0846" stroke-width="5"/><circle cx="76" cy="92" r="6" fill="#1d0846"/>' +
'<ellipse cx="128" cy="88" rx="13" ry="17" fill="#fff" stroke="#1d0846" stroke-width="5"/><circle cx="124" cy="92" r="6" fill="#1d0846"/>' +
'<path d="M62 128 Q100 160 138 128" fill="#1d0846"/><path d="M88 138 Q100 175 116 138 Z" fill="#ff3d8b" stroke="#1d0846" stroke-width="4"/>' +
'<path d="M52 62 L84 72 M148 62 L116 72" stroke="#1d0846" stroke-width="7" stroke-linecap="round"/></svg>');
var TEASE = { type: 'tease', text: 'Hov hov, der røg du i!', image: TEASE_IMG, sound: null };
function players(n, extra) {
var out = [];
@@ -29,11 +40,54 @@
return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(s + '</svg>');
}
/* Afsløring: 4 valgte svar (2 løgne, 1 husets, sandheden) + 3 ingen valgte (eksploderer i opsummeringen).
opts.t = ms siden afsløringen startede (reveal) eller ms inde i opsummeringen (summary). */
function revealFixture(now, phase, opts) {
var stepsMs = [4000, 4000, 4000, 5000], tail = 8000;
var sumSteps = stepsMs.reduce(function (a, b) { return a + b; }, 0);
var likes = { a2: 1, a3: 2, a6: 2 };
var steps = [
{ answer_id: 'a2', text: 'krager', is_truth: false, house: false, authors: ['p1'], assisted: [], guessed_by: ['p3', 'p4'], audience_votes: 2, points: [{ player_id: 'p1', delta: 1000 }] },
{ answer_id: 'a3', text: 'papegøjer', is_truth: false, house: false, authors: ['p2', 'p5'], assisted: ['p5'], guessed_by: ['p6'], audience_votes: 0, points: [{ player_id: 'p2', delta: 500 }, { player_id: 'p5', delta: 250 }] },
{ answer_id: 'a6', text: 'måger <b>&amp;</b> co', is_truth: false, house: true, authors: [], assisted: [], guessed_by: ['p5'], audience_votes: 1, points: [] },
{ answer_id: 'a1', text: 'ugler', is_truth: true, house: false, authors: [], assisted: [], guessed_by: ['p1', 'p2'], audience_votes: 0, points: [{ player_id: 'p1', delta: 1000 }, { player_id: 'p2', delta: 1000 }] }
];
var ans = [
{ id: 'a1', text: 'ugler', is_truth: true, house: false, authors: [] },
{ id: 'a2', text: 'krager', is_truth: false, house: false, authors: ['p1'] },
{ id: 'a3', text: 'papegøjer', is_truth: false, house: false, authors: ['p2', 'p5'], assisted: ['p5'] },
{ id: 'a4', text: 'flamingoer i smoking', is_truth: false, house: false, authors: ['p4'], assisted: ['p4'] },
{ id: 'a5', text: 'pingviner', is_truth: false, house: false, authors: ['p3'] },
{ id: 'a6', text: 'måger <b>&amp;</b> co', is_truth: false, house: true, authors: [] },
{ id: 'a7', text: 'skildpadder', is_truth: false, house: true, authors: [] }
];
var picked = { a1: 1, a2: 1, a3: 1, a6: 1 };
var t = opts.t != null ? Number(opts.t) : phase === 'summary' ? 300 : 6600;
if (phase === 'summary') t += sumSteps;
steps.forEach(function (st) { st.likes = likes[st.answer_id] || 0; });
return {
deadline: phase === 'reveal-wait' ? now + 9000 : now - t + sumSteps + tail,
question: { category: 'Dyreriget', prompt: 'En flok _____ kaldes et parlament.' },
answers: ans.map(function (a) { return { id: a.id, text: a.text, likes: likes[a.id] || 0 }; }),
players: players(6, [{ score: 3100 }, { score: 2200 }, { score: 0 }, { score: 500 }, { score: 750 }, { score: 0 }]),
reveal: {
steps: steps, step_ms: 4000, steps_ms: stepsMs, tail_ms: tail,
started_at: phase === 'reveal-wait' ? null : now - t,
note: 'Ugler kaldes et parlament – efter C.S. Lewis og gamle engelske jagtbøger.',
all: ans.map(function (a) {
return { id: a.id, text: a.text, is_truth: a.is_truth, house: a.house, authors: a.authors, assisted: a.assisted || [], picked: !!picked[a.id], likes: likes[a.id] || 0 };
}),
deltas: [{ player_id: 'p1', delta: 2100 }, { player_id: 'p2', delta: 1700 }, { player_id: 'p3', delta: 0 },
{ player_id: 'p4', delta: 0 }, { player_id: 'p5', delta: 450 }, { player_id: 'p6', delta: 0 }]
}
};
}
function build(phase, opts) {
opts = opts || {};
var now = Date.now();
var base = {
code: 'KLMT', phase: phase === 'join' ? 'lobby' : phase, server_time: now, deadline: null,
code: 'KLMT', phase: REAL[phase] || phase, server_time: now, deadline: null, phase_key: '0.1.' + (REAL[phase] || phase), narration: [],
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
@@ -66,15 +120,33 @@
];
break;
case 'category':
case 'category-chosen':
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' }] }
category: { chooser_id: 'p1', chosen_id: phase === 'category-chosen' ? 7 : null,
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: phase === 'category-chosen' ? 'VIP = vælger (har valgt)' : '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 'intro':
case 'intro-final':
var fin = phase === 'intro-final';
host = Object.assign(base, {
deadline: now + 5000,
round: fin ? { number: 3, name: 'Finale', multiplier: 3, question_number: 1, questions_in_round: 1 }
: { number: 1, name: 'Runde 1', multiplier: 1, question_number: 2, questions_in_round: 3 },
players: players(6, [{ score: 3500 }, { score: 1500 }, { score: 2000 }, { score: 500 }, { score: 0 }, { score: 1000 }]),
intro: fin ? { category: 'Rummet og naturen', slug: 'rummet_og_naturen', final: true } : { category: 'Dyreriget', slug: 'dyreriget', final: false }
});
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;
@@ -98,28 +170,21 @@
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: 'VIP (har gættet, ingen hjerter)', state: Object.assign(clone(host), { answers: vAns, me: me({ id: 'p1', vip: true, lie: 'krager', own_answer_id: 'a2', guess: 'a1' }) }) },
{ label: 'Publikum', state: Object.assign(clone(host), { me: aud }) }
];
break;
case 'reveal-wait':
case 'reveal':
var stepMs = 4000;
var steps = [
{ answer_id: 'a2', text: 'krager', is_truth: false, house: false, authors: ['p1'], guessed_by: ['p3', 'p4'], audience_votes: 2, likes: 1, points: [{ player_id: 'p1', delta: 1000 }] },
{ answer_id: 'a3', text: 'papegøjer', is_truth: false, house: false, authors: ['p2', 'p5'], guessed_by: ['p6'], audience_votes: 0, likes: 0, points: [{ player_id: 'p2', delta: 500 }, { player_id: 'p5', delta: 500 }] },
{ answer_id: 'a6', text: 'måger <b>&amp;</b> co', is_truth: false, house: true, authors: [], guessed_by: ['p5'], audience_votes: 1, likes: 2, points: [] },
{ answer_id: 'a1', text: 'ugler', is_truth: true, house: false, authors: [], guessed_by: ['p1', 'p2'], audience_votes: 0, likes: 0, points: [{ player_id: 'p1', delta: 1000 }, { player_id: 'p2', delta: 1000 }] }
];
var total = steps.length * stepMs + 3000;
var t = opts.t != null ? Number(opts.t) : stepMs * 0 + 2600;
host = Object.assign(base, {
deadline: now - t + total, question: question, answers: answers,
players: players(6, [{ score: 2000 }, { score: 1500 }, { score: 0 }, { score: 0 }, { score: 500 }, { score: 0 }]),
reveal: { steps: steps, step_ms: stepMs }
});
case 'tease':
case 'summary':
var rv = revealFixture(now, phase, opts);
host = Object.assign(base, rv);
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: phase === 'tease' ? 'Spiller (faldt for "krager": drillet)' : 'Spiller (løj "pingviner")',
tease: phase === 'tease' ? TEASE : null,
state: Object.assign(clone(host), { me: me({ lie: 'pingviner', own_answer_id: 'a5', guess: 'a2', last_delta: 0 }) }) },
{ label: 'VIP (+2.100)', state: Object.assign(clone(host), { me: me({ id: 'p1', vip: true, lie: 'krager', own_answer_id: 'a2', guess: 'a1', likes: ['a3'], last_delta: 2100 }) }) },
{ label: 'Publikum', state: Object.assign(clone(host), { me: aud }) }
];
break;
@@ -162,5 +227,5 @@
return { host: host, phones: phones };
}
window.WPPFixtures = { build: build, phases: PHASES, qr: fakeQr() };
window.WPPFixtures = { build: build, phases: PHASES, qr: fakeQr(), tease: TEASE };
})();
+23 -2
View File
@@ -50,10 +50,12 @@
var logEl;
function log(m) { if (logEl) logEl.textContent = 'send → ' + JSON.stringify(m); if (window.console) console.log('[send]', m); return true; }
var cur = null, hostCtx = null;
function draw() {
var fx = WPPFixtures.build(phase, { t: t });
var fx = cur = WPPFixtures.build(phase, { t: t });
var hostRoot = document.getElementById('hostroot');
WPPHost.renderHost(hostRoot, fx.host, { harness: true, send: log, qrSrc: WPPFixtures.qr });
hostCtx = { harness: true, sound: qs.get('sound') === '1', send: log, qrSrc: WPPFixtures.qr };
WPPHost.renderHost(hostRoot, fx.host, hostCtx);
fx.phones.forEach(function (ph, i) {
var root = document.getElementById('ph' + i);
document.getElementById('l' + i).textContent = 'Telefon: ' + ph.label + ' (390×844)';
@@ -64,6 +66,8 @@
}, 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);
/* "tease"-fasen: drilleriet bliver stående (ellers lukker det efter 2,5 s) */
if (ph.tease) WPPPlay.showTease(root, ph.tease, { ttl: 600000, silent: qs.get('sound') !== '1' });
});
}
@@ -86,6 +90,23 @@
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);
/* et hjerte til det svar, der er fremme (eller "papegøjer"): flyvende hjerter på storskærmen */
var heart = document.createElement('button'); heart.textContent = 'hjerte+'; heart.onclick = function () {
var h = JSON.parse(JSON.stringify(cur.host));
if (h.phase !== 'reveal' || !h.reveal) return;
var pr = WPP.revealProgress(h, {});
var st = !pr.done && h.reveal.steps[pr.i];
var id = st && !st.is_truth ? st.answer_id : 'a3';
[h.answers, h.reveal.steps, h.reveal.all].forEach(function (list) {
(list || []).forEach(function (a) { if ((a.id || a.answer_id) === id) a.likes = (a.likes || 0) + 1; });
});
cur.host = h;
WPPHost.renderHost(document.getElementById('hostroot'), h, hostCtx);
}; bar.appendChild(heart);
/* drilleri på telefon 1, som når serveren sender {"type":"tease"} */
var tz = document.createElement('button'); tz.textContent = 'drilleri'; tz.onclick = function () {
WPPPlay.showTease(document.getElementById('ph0'), WPPFixtures.tease, { silent: qs.get('sound') !== '1' });
}; bar.appendChild(tz);
logEl = document.createElement('span'); logEl.className = 'log'; bar.appendChild(logEl);
draw();
+425 -101
View File
@@ -1,13 +1,22 @@
/* Fup og Fakta – storskærm. Eksporterer window.WPPHost = {renderHost, mount, revealProgress}. */
/* Fup og Fakta – storskærm. Eksporterer window.WPPHost = {renderHost, mount, revealProgress, sumPlan}. */
(function () {
'use strict';
var WPP = window.WPP, esc = WPP.esc;
var TIMED = { category: 1, lie: 1, guess: 1, reveal: 1, scores: 1 };
var TIMED = { category: 1, intro: 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 });
return root._ui || (root._ui = { lastPhase: null, revealKey: null, revealSeen: {}, lastTick: null, state: null, ctx: null, sfxIds: {}, sfxKey: null });
}
/* ---------- lyd ---------- */
/* dev-harness er tavs, medmindre ctx.sound er sat (dev.html?sound=1) */
function audible(ctx) { return !(ctx && ctx.harness && !ctx.sound); }
function sfx(u, name, opts) { if (audible(u.ctx)) WPP.sfx(name, opts); }
/* musik pr. fase; samme nummer i to faser i træk (lie → guess) spiller videre uden at starte forfra.
intro har ingen musik: kategoriens jingle står alene. final: fanfare, så music_final. */
var MUSIC = { lobby: 'music_lobby', category: 'music_choose', lie: 'music_think', guess: 'music_think', reveal: 'music_reveal', scores: 'music_lobby' };
var STING_FADE = 0.25; /* kort udtoning før en jingle/fanfare */
/* ---------- hjælpere ---------- */
function joinHost(state) {
var u = state.join_url || '';
@@ -24,28 +33,21 @@
return (state.players || []).slice().sort(function (a, b) { return (b.score || 0) - (a.score || 0); });
}
/* Reveal-tidslinje: serveren sender started_at og en længde pr. trin (speakerens replik styrer tempoet).
started_at = null betyder, at serveren stadig laver lyd: vis en kort "trommehvirvel". */
function revealProgress(state, u) {
var r = state.reveal || {};
var steps = r.steps || [];
var key = (state.round ? state.round.number + '.' + state.round.question_number : '') + ':' + ((state.question || {}).prompt || '');
if (state.reveal && r.started_at === null) return { pending: true, done: false, i: 0, stage: -1, n: steps.length, key: key + '|pending' };
var durs = steps.map(function (s, i) { return (r.steps_ms && r.steps_ms[i]) || r.step_ms || 4000; });
var total = durs.reduce(function (a, b) { return a + b; }, 0) + (r.tail_ms == null ? 3000 : r.tail_ms);
var start;
if (r.started_at) start = r.started_at;
else 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), i = 0;
while (i < steps.length && el >= durs[i]) { el -= durs[i]; i++; }
if (i >= steps.length) return { done: true, i: steps.length, n: steps.length, key: key + '|done' };
/* svaret først, så hvem der valgte det, så stemplet (faste tider, så lange replikker ikke forsinker stemplet) */
var stage = el < Math.min(1100, durs[i] * 0.28) ? 0 : el < Math.min(2300, durs[i] * 0.55) ? 1 : 2;
return { done: false, i: i, stage: stage, n: steps.length, key: key + '|' + i + '.' + stage };
function revealProgress(state, u) { return WPP.revealProgress(state, u); }
/* Opsummeringen: hvornår hvert ikke-valgt svar eksploderer, og hvornår pointene tælles op (ms inde i halen). */
var BOOM0 = 1500, BOOM_GAP = 250, BOOM_MS = 900, BURST_MS = 520;
function sumPlan(state) {
var all = (state.reveal && state.reveal.all) || [];
var at = {}, k = 0;
all.forEach(function (a) { if (!a.picked && !a.is_truth) at[a.id] = BOOM0 + (k++) * BOOM_GAP; });
return { at: at, n: k, pointsAt: k ? BOOM0 + (k - 1) * BOOM_GAP + BOOM_MS + 100 : 1200 };
}
/* nøglen der styrer gen-tegning under afsløringen (opsummeringen har to trin: svar → point) */
function viewKey(state, u) {
var pr = revealProgress(state, u);
if (pr.done) return pr.key + (pr.tail >= sumPlan(state).pointsAt ? '.p' : '');
return pr.key;
}
/* ---------- faste dele ---------- */
@@ -67,23 +69,23 @@
'</header>';
}
function footer(state, opts) {
function footer(state, opts, ctx) {
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('');
}
/* hver chip har sin egen nøgle, så kun den, der lige blev klar, hopper */
var chipsHtml = !opts.doneChips ? '' : ps.map(function (p) {
return '<span class="pchip' + (p.done ? ' done' : '') + (p.connected === false ? ' off' : '') + '" data-k="pc:' + esc(p.id) + ':' + (p.done ? 1 : 0) + (p.connected === false ? 'x' : '') +
'" 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 = (WPP.voice.blocked ? '<button class="hbtn voice-blocked" data-act="unblock">Klik for at høre speakeren</button>' : '') +
/* lyden kræver ét klik på siden; indtil da en tydelig knap */
var needClick = !WPP.sound.muted && !(ctx && ctx.harness) && (WPP.voice.blocked || !WPP.sound.ready());
var mute = (needClick ? '<button class="hbtn voice-blocked" data-act="unblock">' + (WPP.voice.blocked ? 'Klik for at høre speakeren' : 'Klik for lyd') + '</button>' : '') +
'<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) + (WPP.voice.blocked ? 'b' : '') + '">' + mute + skip + '</div></footer>';
'<div class="pchips" data-k="chips" data-patch>' + chipsHtml + '</div>' +
'<div class="ctl" data-k="ctl:' + state.phase + (WPP.sound.muted ? 1 : 0) + (needClick ? 'b' + WPP.voice.blocked : '') + '">' + mute + skip + '</div></footer>';
}
/* ---------- faser ---------- */
@@ -126,17 +128,47 @@
var players = WPP.playerMap(state);
var ch = players[cat.chooser_id];
var r = state.round || {};
var chosen = cat.chosen_id != null ? (cat.options || []).filter(function (o) { return o.id === cat.chosen_id; })[0] : null;
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(',')) + '">' +
'<section class="cat-who' + (chosen ? ' picked' : '') + '" data-k="cw:' + esc(cat.chooser_id || '') + ':' + esc(chosen ? chosen.id : '') + '">' + (ch ? WPP.chip(ch, 'xl') : 'Nogen') +
(chosen ? ' <span>valgte</span> <b>' + esc(chosen.name) + '</b>' : ' <span>vælger kategori…</span>') + '</section>' +
/* sektionen bevares; kun det valgte felt skiftes ud (og popper), resten dæmpes via forælderens klasse */
'<section class="cat-opts' + (chosen ? ' has-chosen' : '') + '" data-k="co:' + esc((cat.options || []).map(function (o) { return o.id; }).join(',')) + '" data-patch>' +
(cat.options || []).map(function (o, i) {
return '<div class="cat-opt c' + (i % 4) + '" style="--i:' + i + '">' + esc(o.name) + '</div>';
var on = chosen && chosen.id === o.id;
return '<div class="cat-opt c' + (i % 4) + (on ? ' chosen' : '') + '" data-k="o:' + esc(o.id) + (on ? ':on' : '') + '" style="--i:' + i + '"' +
sfxAttr('pop_in', 200 + i * 80, 'co:' + o.id) + '>' + esc(o.name) + '</div>';
}).join('') + '</section>' +
'<div class="hs-timer" data-k="tm:' + (state.deadline || 0) + '">' + WPP.timerHtml(state, 'ring') + '</div>';
}
/* kategorien præsenteres: et stort kort flyver ind, hopper og gnistrer (finalen: et dramatisk FINALE-skilt først) */
function intro(state) {
var it = state.intro || {};
var r = state.round || {};
var fin = !!it.final;
var mult = (r.multiplier || 1) === 3 ? 'Tredobbelt point!' : (r.multiplier || 1) === 2 ? 'Dobbelt point!' : r.multiplier > 3 ? '×' + r.multiplier + ' point!' : '';
var sparks = '';
for (var i = 0; i < 12; i++) { /* stjerner rundt om kortets kant */
var a = (i * 30 + (i % 2) * 12) * Math.PI / 180;
sparks += '<i class="spk" style="--i:' + i + ';--r:' + (i % 3 === 0 ? 1.25 : i % 3 === 1 ? 0.8 : 1) +
';left:' + (50 + 53 * Math.cos(a)).toFixed(1) + '%;top:' + (50 + 60 * Math.sin(a)).toFixed(1) + '%"></i>';
}
return '<section class="intro' + (fin ? ' final' : '') + '" data-k="intro:' + esc(it.slug || it.category || '') + ':' + (fin ? 1 : 0) + '">' +
(fin ? '<div class="in-rays"></div><div class="in-finale">FINALE</div>' + (mult ? '<div class="in-mult">' + mult + '</div>' : '')
: '<div class="in-kicker">' + (r.questions_in_round ? 'Spørgsmål ' + (r.question_number || 1) + ' af ' + r.questions_in_round : 'Næste spørgsmål') + '</div>') +
'<div class="in-card"><div class="in-label">Kategorien er</div><div class="in-title">' + esc(it.category || '') + '</div>' + sparks + '</div>' +
'</section>';
}
/* lyd til en node, der lige er kommet ind på skærmen. id: spilles kun én gang pr. skærm,
selv om patcheren udskifter noden (fx når et svar eksploderer). delay i ms = animationens start. */
function sfxAttr(name, delayMs, id) {
return ' data-sfx="' + name + ':' + Math.round(delayMs || 0) + '"' + (id != null ? ' data-sfx-id="' + esc(String(id)) + '"' : '');
}
function questionBlock(state, cls, fill) {
var q = state.question || {};
return '<section class="qbox ' + (cls || '') + '" data-k="qb:' + esc(cls || '') + ':' + esc(q.prompt || '') + ':' + esc(fill || '') + '">' +
@@ -156,7 +188,7 @@
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>';
return '<div class="ans-tile c' + (i % 6) + '" style="--i:' + i + '"' + sfxAttr('pop_in', 150 + i * 80, 'at:' + a.id) + '>' + 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>';
@@ -174,61 +206,112 @@
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>';
if (pr.done) return out + summary(state, pr, players);
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>';
}
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>';
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('');
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>') +
(r.note ? '<div class="note">' + esc(r.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></div>';
} else {
var auth = (s.authors || []).map(function (id) {
return players[id] ? esc(players[id].name) + ((s.assisted || []).indexOf(id) >= 0 ? ' <small>(Lyv for mig)</small>' : '') : '?';
});
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>' : '') + '</div>';
}
}
/* hjerterne er live: mærket har sin egen nøgle, så kun det skiftes (og popper) */
var likes = s.is_truth ? 0 : (s.likes || 0);
out += '<section class="rv-stage' + (s.is_truth && pr.stage >= 2 ? ' is-truth' : '') + '" data-k="rs:' + pr.i + ':' + esc(s.answer_id) + '" data-patch>' +
'<div class="rv-card" data-k="card" data-patch><div class="rv-ans" data-k="a" data-aid="' + esc(s.answer_id) + '">' + esc(s.text) + '</div>' +
(likes ? '<span class="lk" data-k="lk:' + likes + '">♥ ' + likes + '</span>' : '') + '</div>' + who + stamp + '</section>';
out += '<div class="rv-dots" data-k="dots:' + pr.i + '">' + steps.map(function (st, i) {
return '<i class="' + (i < pr.i ? 'past' : i === pr.i ? 'now' : '') + (st.is_truth ? ' t' : '') + '"></i>';
}).join('') + '</div>';
return out;
}
/* ---------- opsummering: alle svar → de ikke-valgte eksploderer → point tælles op ---------- */
function summary(state, pr, players) {
var r = state.reveal || {};
var all = r.all || [];
var plan = sumPlan(state);
var fooled = {};
(r.steps || []).forEach(function (st) { fooled[st.answer_id] = (st.guessed_by || []).length + (st.audience_votes || 0); });
var cols = all.length <= 4 ? 2 : all.length <= 6 ? 3 : 4;
var tiles = all.map(function (a, i) { return sumTile(a, i, plan.at[a.id], pr.tail, fooled[a.id] || 0, players); }).join('');
var dmap = {};
(r.deltas || []).forEach(function (d) { dmap[d.player_id] = d.delta || 0; });
var ps = (state.players || []).slice().sort(function (a, b) {
return (dmap[b.id] || 0) - (dmap[a.id] || 0) || (b.score || 0) - (a.score || 0);
});
var live = pr.tail >= plan.pointsAt;
var rows = ps.map(function (p, i) { return sumRow(p, i, dmap[p.id] || 0, live); }).join('');
return '<section class="rv-sum" data-k="sum" data-patch>' +
'<div class="sum-l" data-k="l" data-patch><h2 data-k="h">Alle svarene</h2>' +
'<div class="sum-tiles cols' + cols + '" data-k="tiles:' + cols + '" data-patch>' + tiles + '</div></div>' +
'<div class="sum-r" data-k="r" data-patch><h2 data-k="h">Point i spørgsmålet</h2>' +
'<ol class="sum-pts' + (live ? ' live' : '') + '" data-k="pts" data-patch>' + rows + '</ol></div></section>';
}
/* boomAt = ms inde i halen hvor svaret eksploderer (kun ikke-valgte); tail = hvor langt vi er nu */
function sumTile(a, i, boomAt, tail, fooled, players) {
var st = '', late = '';
if (boomAt != null && tail >= boomAt + BOOM_MS) st = ' gone';
else if (boomAt != null && tail >= boomAt) { st = ' boom'; late = ';--late:-' + Math.round(tail - boomAt) + 'ms'; }
var kind = a.is_truth ? ' truth' : a.house ? ' house' : ' lie';
var tag = a.is_truth ? '<span class="st-tag">SANDHEDEN</span>' : a.house ? '<span class="st-tag">Husets løgn</span>' : '';
var by = (a.authors || []).map(function (id) {
var half = (a.assisted || []).indexOf(id) >= 0 ? '<span class="half">½ · Lyv for mig</span>' : '';
return '<span class="st-au">' + WPP.chip(players[id], 'sm') + half + '</span>';
}).join('');
var info = a.is_truth ? (fooled ? 'Fundet af ' + fooled : 'Ingen fandt den') : a.picked ? 'Narrede ' + fooled : 'Ingen faldt for den';
var likes = a.is_truth ? 0 : (a.likes || 0);
return '<div class="st c' + (i % 6) + kind + (a.picked || a.is_truth ? '' : ' unpicked') + st + '" data-k="t:' + esc(a.id) + ':' + likes + st +
'" data-aid="' + esc(a.id) + '"' + (boomAt != null ? ' data-at="' + boomAt + '"' : '') + ' style="--i:' + i + late + '"' +
sfxAttr('pop_in', i * 80, 'st:' + a.id) + '>' + tag +
'<div class="st-text">' + esc(a.text) + '</div>' + (by ? '<div class="st-by">' + by + '</div>' : '') +
'<div class="st-info">' + info + '</div>' + (likes ? '<span class="lk">♥ ' + likes + '</span>' : '') + '</div>';
}
function sumRow(p, i, d, live) {
var score = p.score || 0, prev = score - d, delay = 150 + i * 160;
var cls = !live ? 'wait' : d > 0 ? 'plus' : 'zero';
var dHtml = !live ? '<span class="sd"></span>'
: d > 0 ? '<span class="sd">+<b data-count-from="0" data-count-to="' + d + '" data-count-delay="' + delay + '">0</b></span>'
: '<span class="sd">0</span>';
var tot = live && d ? '<b class="tot" data-count-from="' + prev + '" data-count-to="' + score + '" data-count-delay="' + delay + '">' + WPP.fmt(prev) + '</b>'
: '<b class="tot">' + WPP.fmt(live ? score : prev) + '</b>';
return '<li class="' + cls + '" data-k="r:' + esc(p.id) + ':' + (live ? 1 : 0) + ':' + d + ':' + score + '" style="--pc:' + esc(p.color || '#888') + ';--i:' + i + '">' +
avatar(p) + '<span class="n">' + esc(p.name) + '</span>' + dHtml + tot + '</li>';
}
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;
/* rækkerne starter på den gamle plads (før runden) og glider op/ned, når pointene er talt op */
var before = ps.slice().sort(function (a, b) { return ((b.score || 0) - (dmap[b.id] || 0)) - ((a.score || 0) - (dmap[a.id] || 0)); });
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)) + '%">' +
return '<li style="--pc:' + esc(p.color || '#888') + ';--i:' + i + ';--mv:' + (before.indexOf(p) - i) + ';--w:' + Math.max(4, Math.round(100 * (p.score || 0) / max)) + '%"' +
sfxAttr('pop_in', i * 120, 'sc:' + p.id) + '>' +
'<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>') +
@@ -269,6 +352,214 @@
'<button class="btn btn-go btn-xl" data-act="play_again">Spil igen</button></section>';
}
/* ---------- effekter: et lag over skærmen, som patch ikke rører ---------- */
function fxLayer(root) {
if (!root._fx || !root._fx.isConnected) {
root._fx = document.createElement('div');
root._fx.className = 'fx';
root._fx.setAttribute('aria-hidden', 'true');
root.appendChild(root._fx);
}
return root._fx;
}
function spawn(root, cls, x, y, style, life) {
var e = document.createElement('i');
e.className = cls;
e.style.cssText = 'left:' + x + 'px;top:' + y + 'px;' + (style || '');
fxLayer(root).appendChild(e);
setTimeout(function () { if (e.parentNode) e.parentNode.removeChild(e); }, life || 2000);
return e;
}
/* midten af et element (eller af skærmen) i lagets koordinater */
function centerOf(root, el, fx, fy) {
var lr = fxLayer(root).getBoundingClientRect();
var r = el && el.getClientRects().length ? el.getBoundingClientRect() : lr;
return { x: r.left - lr.left + r.width * (fx == null ? 0.5 : fx), y: r.top - lr.top + r.height * (fy == null ? 0.5 : fy), w: r.width, h: r.height };
}
function byAid(root, id) {
var els = root.querySelectorAll('.screen [data-aid]');
for (var i = 0; i < els.length; i++) if (els[i].getAttribute('data-aid') === id && !els[i].classList.contains('gone')) return els[i];
return null;
}
function floatHearts(root, id, n) {
if (WPP.reducedMotion()) return;
var el = byAid(root, id);
var c = centerOf(root, el, el ? 0.85 : 0.5, el ? 0.1 : 0.5);
for (var k = 0; k < n; k++) {
spawn(root, 'fheart', c.x + (Math.random() - 0.5) * 40, c.y,
'--dx:' + Math.round((Math.random() - 0.5) * 120) + 'px;--s:' + (0.9 + Math.random() * 0.8).toFixed(2) + ';animation-delay:' + k * 140 + 'ms', 2400);
}
}
function burst(root, el) {
if (WPP.reducedMotion()) return;
var c = centerOf(root, el);
var col = getComputedStyle(el).backgroundColor;
for (var k = 0; k < 14; k++) {
var ang = (k / 14) * Math.PI * 2 + Math.random() * 0.4;
var dist = (0.6 + Math.random() * 0.6) * Math.max(c.w, 120);
spawn(root, 'frag', c.x + (Math.random() - 0.5) * c.w * 0.6, c.y + (Math.random() - 0.5) * c.h * 0.5,
'background:' + col + ';--dx:' + Math.round(Math.cos(ang) * dist) + 'px;--dy:' + Math.round(Math.sin(ang) * dist * 0.7) + 'px;--r:' +
Math.round((Math.random() - 0.5) * 720) + 'deg;--s:' + (0.6 + Math.random() * 0.9).toFixed(2), 1100);
}
spawn(root, 'flash', c.x, c.y, '', 700);
}
function confetti(root) {
if (WPP.reducedMotion()) return;
var lr = fxLayer(root).getBoundingClientRect();
var cols = ['#ff3d8b', '#ffd23f', '#2de2e6', '#3ddc84', '#ff8c42', '#a56bff', '#ffffff'];
for (var k = 0; k < 150; k++) {
spawn(root, 'conf', Math.random() * lr.width, -20,
'background:' + cols[k % cols.length] + ';--dx:' + Math.round((Math.random() - 0.5) * 300) + 'px;--r:' + Math.round(360 + Math.random() * 1080) + 'deg;' +
'animation-duration:' + (2.8 + Math.random() * 2.4).toFixed(2) + 's;animation-delay:' + Math.round(Math.random() * 1800) + 'ms;' +
(k % 3 ? '' : 'border-radius:50%;'), 7500);
}
}
/* stjerner, der springer ud fra kortet, når det lander */
function sparkle(root, el, n) {
if (WPP.reducedMotion() || !el) return;
var c = centerOf(root, el);
var cols = ['#ffd23f', '#ffffff', '#2de2e6', '#ff3d8b'];
for (var k = 0; k < n; k++) {
var ang = (k / n) * Math.PI * 2 + Math.random() * 0.3;
var dist = (0.55 + Math.random() * 0.5) * Math.max(c.w, 200) * 0.7;
spawn(root, 'fstar', c.x + Math.cos(ang) * c.w * 0.3, c.y + Math.sin(ang) * c.h * 0.3,
'color:' + cols[k % cols.length] + ';--dx:' + Math.round(Math.cos(ang) * dist) + 'px;--dy:' + Math.round(Math.sin(ang) * dist * 0.6) + 'px;--s:' +
(0.6 + Math.random() * 0.9).toFixed(2) + ';animation-delay:' + Math.round(Math.random() * 120) + 'ms', 1500);
}
}
function shake(root) {
if (WPP.reducedMotion()) return;
root.classList.remove('shake'); void root.offsetWidth; root.classList.add('shake');
clearTimeout(root._shakeT);
root._shakeT = setTimeout(function () { root.classList.remove('shake'); }, 900);
}
/* et ikke-valgt svar i opsummeringen eksploderer */
function boomTile(root, u, armKey, id) {
if (u.sumArm !== armKey) return;
var el = byAid(root, id);
if (!el || el.classList.contains('boom')) return;
el.classList.add('boom');
setTimeout(function () {
if (u.sumArm !== armKey) return;
var cur = el.isConnected ? el : byAid(root, id); /* noden kan være skiftet ud imens */
if (cur) burst(root, cur);
sfx(u, 'explode', { rate: 0.9 + Math.random() * 0.25 });
}, BURST_MS);
}
/* planlæg eksplosionerne, første gang opsummeringen vises */
function armSummary(root, u, state, pr) {
var armKey = pr.key + '@' + pr.start;
if (u.sumArm === armKey) return;
u.sumArm = armKey;
var at = sumPlan(state).at;
Object.keys(at).forEach(function (id) {
var wait = at[id] - pr.tail;
if (wait > -150) setTimeout(function () { boomTile(root, u, armKey, id); }, Math.max(0, wait));
});
/* svar, der allerede var på skærmen, skal ikke poppe ind igen, når de skiftes ud */
if (pr.tail > 1000) u.sumIntroDone = armKey;
}
function markStill(root, u) {
var arm = u.sumArm;
Array.prototype.forEach.call(root.querySelectorAll('.sum-tiles > .st'), function (el) {
if (el._seen) return;
el._seen = true;
if (u.sumIntroDone === arm) el.classList.add('still');
});
if (u.sumIntroDone !== arm) setTimeout(function () { if (u.sumArm === arm) u.sumIntroDone = arm; }, 1000);
}
/* ---------- lyd og effekter ud fra forskellen mellem forrige og nye snapshot ---------- */
function pkey(s) { return s.phase_key || (s.phase + ':' + (s.round ? s.round.number + '.' + s.round.question_number : '')); }
function likeMap(s) {
var m = {};
(s.answers || []).forEach(function (a) { if (a.likes != null) m[a.id] = a.likes; });
((s.reveal && s.reveal.all) || []).forEach(function (a) { if (m[a.id] == null) m[a.id] = a.likes || 0; });
return m;
}
function onChanges(root, u, state, prev) {
var phase = state.phase;
var same = prev && pkey(prev) === pkey(state);
if (!same) return;
var before = {};
(prev.players || []).forEach(function (p) { before[p.id] = p; });
if (phase === 'lobby' && (state.players || []).some(function (p) { return !before[p.id]; })) sfx(u, 'join');
if (phase === 'lie' || phase === 'guess') {
var lit = (state.players || []).filter(function (p) { return p.done && !(before[p.id] && before[p.id].done); }).length;
if (lit) sfx(u, 'submit');
}
if (phase === 'category' && state.category && state.category.chosen_id != null && !(prev.category && prev.category.chosen_id != null)) sfx(u, 'category');
if (phase === 'reveal') {
var a = likeMap(prev), b = likeMap(state), any = false;
Object.keys(b).forEach(function (id) {
var d = b[id] - (a[id] || 0);
if (d > 0) { floatHearts(root, id, Math.min(d * 2, 6)); any = true; }
});
if (any) sfx(u, 'like');
}
}
function onPhase(root, u, state, from) {
var ctx = u.ctx || {};
var phase = state.phase;
/* værten (eller VIP) startede spillet: speakerens lobby-snak afbrydes */
if (from === 'lobby' && phase !== 'lobby' && !ctx.harness) WPP.voice.stop();
clearTimeout(u.finalT); clearTimeout(u.introT);
u.finalMusic = false;
/* indgangslyd: lang whoosh for det, der flyver ind over hele skærmen, ellers en kort */
if (phase === 'intro') introCue(root, u, state);
else if (phase === 'lie') sfx(u, 'whoosh_long');
else if (from) sfx(u, 'whoosh');
if (phase === 'scores') {
/* rækkerne bytter plads efter 2,2 s (se .board li): op = swoosh, ned = komisk rutsjetur */
var dm = {};
(state.deltas || []).forEach(function (d) { dm[d.player_id] = d.delta || 0; });
var ps = sortedPlayers(state);
var before = ps.slice().sort(function (a, b) { return ((b.score || 0) - (dm[b.id] || 0)) - ((a.score || 0) - (dm[a.id] || 0)); });
var up = ps.some(function (p, i) { return before.indexOf(p) > i; });
var down = ps.some(function (p, i) { return before.indexOf(p) < i; });
if (up) sfx(u, 'swoosh_up', { delay: 2.2 });
if (down) sfx(u, 'slide', { delay: 2.35, volume: 0.45 });
}
if (phase === 'final') {
sfx(u, 'fanfare', { delay: from ? 0.3 : 0 });
sfx(u, 'swoosh_up', { delay: 0.9, volume: 0.4 }); /* vinderens søjle rejser sig */
confetti(root);
u.finalT = setTimeout(function () { u.finalMusic = true; if (audible(u.ctx)) WPP.music.set('music_final'); }, 3000);
}
}
/* kategorikortet: whoosh når det letter, kategoriens jingle når det lander (finalen: FINALE-skiltet smækker først) */
var INTRO_LAND = 0.55, FINAL_CARD = 0.75;
function introCue(root, u, state) {
var it = state.intro || {};
var t0 = it.final ? FINAL_CARD : 0;
if (it.final) sfx(u, 'stamp', { delay: 0.12, volume: 0.7 });
sfx(u, 'whoosh_long', { delay: t0 });
sfx(u, 'cat_' + (it.slug || ''), { delay: t0 + INTRO_LAND, fallback: 'category' });
var key = pkey(state);
u.introT = setTimeout(function () {
if (u.state && pkey(u.state) === key) sparkle(root, root.querySelector('.screen .in-card'), 22);
}, (t0 + INTRO_LAND) * 1000);
}
function musicFor(u, phase) { return phase === 'final' ? (u.finalMusic ? 'music_final' : null) : (MUSIC[phase] || null); }
/* nye noder med data-sfx får deres lyd (bevarede noder har allerede fået den) */
function nodeSfx(root, u, state, quiet) {
var key = pkey(state);
if (u.sfxKey !== key) { u.sfxKey = key; u.sfxIds = {}; }
var els = root.querySelectorAll('.screen [data-sfx]');
for (var i = 0; i < els.length; i++) {
var el = els[i];
if (el._sfx) continue;
el._sfx = true;
var id = el.getAttribute('data-sfx-id');
if (id) { if (u.sfxIds[id]) continue; u.sfxIds[id] = 1; }
if (quiet || el.classList.contains('still')) continue;
var spec = el.getAttribute('data-sfx').split(':');
sfx(u, spec[0], { delay: (Number(spec[1]) || 0) / 1000, gap: spec[0] === 'pop_in' ? 80 : 0 });
}
}
/* ---------- hændelser ---------- */
function bind(root) {
if (root._bound) return;
@@ -294,6 +585,7 @@
return;
}
if (a === 'start' || a === 'skip' || a === 'play_again') {
WPP.voice.stop(); /* værtens knap vinder over speakeren */
ctx.send({ action: a });
b.disabled = true;
setTimeout(function () { b.disabled = false; }, 1500);
@@ -317,26 +609,47 @@
if (urls[0] || urls[1]) WPP.voice.now(urls);
}
}
if (pr.key !== u.revealKey) {
var key = viewKey(s, u);
if (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 (prev) revealCue(root, u, s, pr, key);
}
}
if (s.deadline && (s.phase === 'lie' || s.phase === 'guess' || s.phase === 'category')) {
var left = WPP.secondsLeft(s.deadline);
if (left !== u.lastTick) {
var was = u.lastTick;
u.lastTick = left;
if (left > 0 && left <= 5) WPP.sound.play('tick');
if (left > 0 && left <= 5) sfx(u, 'tick');
else if (left === 0 && was > 0) sfx(u, 'timeup');
}
}
}
/* lyd/rystelse, når afsløringen skifter trin */
function revealCue(root, u, s, pr, key) {
if (pr.done) {
if (/\.p$/.test(key)) { /* pointene tælles op */
var n = ((s.reveal && s.reveal.deltas) || []).filter(function (d) { return d.delta > 0; }).length;
for (var k = 0; k < Math.min(n, 6); k++) sfx(u, 'coins', { delay: 0.15 + k * 0.16, volume: k ? 0.35 : 0.55 });
}
return;
}
var st = (s.reveal.steps || [])[pr.i] || {};
if (pr.stage === 2) {
sfx(u, st.is_truth ? 'truth' : st.house ? 'house' : 'stamp', { delay: st.is_truth ? 0 : 0.2 });
if (st.is_truth) sfx(u, 'swoosh_up', { volume: 0.4 }); /* sandheden løfter sig og gløder */
else shake(root);
} else if (pr.stage === 1) {
/* navnene popper ind ét ad gangen (samme takt som .rv-who .chip.pop) */
var n = Math.min((st.guessed_by || []).length, 6);
for (var k = 0; k < n; k++) sfx(u, 'pop_in', { delay: k * 0.09, gap: 80 });
} else if (pr.stage === 0) sfx(u, 'whoosh', { volume: 0.35 });
}
/* Speakerens replikker uden for afsløringen: afspil nye i rækkefølge. Ved første snapshot
(fx genindlæsning) afspilles kun det, der er sagt de sidste par sekunder. */
(fx genindlæsning) afspilles kun det, der er sagt de sidste par sekunder. Hver replik bærer
sin skærms nøgle, så voice-køen kan springe over replikker til en skærm, der er væk. */
function queueNarration(u, state, ctx) {
var items = state.narration;
if (!items || ctx.harness) return;
@@ -346,7 +659,7 @@
items.forEach(function (n) { if (n.at < cutoff) u.narrLast = Math.max(u.narrLast, n.id); });
}
items.forEach(function (n) {
if (n.id > u.narrLast) { u.narrLast = n.id; WPP.voice.enqueue(n.url); }
if (n.id > u.narrLast) { u.narrLast = n.id; WPP.voice.enqueue({ url: n.url, key: n.key || null }); }
});
}
@@ -355,30 +668,41 @@
ctx = ctx || {};
state = state || {};
var u = ui(root);
var prev = u.state;
u.state = state; u.ctx = ctx;
bind(root);
queueNarration(u, state, ctx);
if (!root._ticking) { root._ticking = true; WPP.onTick(function () { hostTick(root); }); }
var phase = state.phase || 'lobby';
var phaseChanged = u.lastPhase !== phase;
root.classList.add('host-root');
var sh = WPP.shell(root);
var phase = state.phase || 'lobby';
var main, foot = {};
fxLayer(root);
if (!ctx.harness) WPP.voice.phaseKey = state.phase_key || null;
if (phaseChanged) onPhase(root, u, state, u.lastPhase); /* før nye replikker lægges i kø */
queueNarration(u, state, ctx);
if (!root._ticking) { root._ticking = true; WPP.onTick(function () { hostTick(root); }); }
var main, foot = {}, pr = null;
switch (phase) {
case 'lobby': main = lobby(state, ctx); break;
case 'category': main = category(state); break;
case 'intro': main = intro(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 'reveal': main = reveal(state, u); pr = revealProgress(state, u); u.revealKey = viewKey(state, u); 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));
sh.screen.className = 'screen hs hs-' + phase + (pr && pr.done ? ' is-sum' : '');
WPP.patch(sh.screen, topBar(state) + '<main class="hs-main" data-k="main:' + phase + '" data-patch>' + main + '</main>' + footer(state, foot, ctx));
WPP.animateCounts(sh.screen);
if (u.lastPhase !== phase) {
if (u.lastPhase && !ctx.harness) WPP.sound.play('phase');
u.lastPhase = phase;
if (pr && pr.done) { armSummary(root, u, state, pr); markStill(root, u); }
nodeSfx(root, u, state, !prev); /* første snapshot (genindlæsning): ingen pop-salve */
if (pr && pr.pending && u.drumKey !== pr.key) { u.drumKey = pr.key; sfx(u, 'drumroll'); }
if (!phaseChanged) onChanges(root, u, state, prev);
u.lastPhase = phase;
if (audible(ctx)) {
WPP.sfx.preload();
WPP.music.set(musicFor(u, phase), { fade: phase === 'intro' || phase === 'final' ? STING_FADE : null });
}
}
@@ -387,7 +711,7 @@
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);
WPP.voice.onchange = function () { var u = ui(root); if (u.state) renderHost(root, u.state, u.ctx); };
WPP.voice.onchange = WPP.sound.onchange = function () { var u = ui(root); if (u.state) renderHost(root, u.state, u.ctx); };
conn = WPP.connect('/ws/host/' + encodeURIComponent(code) + '/', {
root: root,
onMessage: function (msg) {
@@ -398,7 +722,7 @@
return ctx;
}
window.WPPHost = { renderHost: renderHost, mount: mount, revealProgress: revealProgress };
window.WPPHost = { renderHost: renderHost, mount: mount, revealProgress: revealProgress, sumPlan: sumPlan };
window.renderHost = renderHost;
if (!window.WPP_HARNESS) {
+321 -17
View File
@@ -226,7 +226,7 @@ button, input { font-family: inherit; }
/* gæt */
.answers { display: flex; flex-direction: column; gap: 14px; }
.btn-ans { min-height: 64px; font-size: 1.25rem; }
.btn-ans { min-height: 64px; font-size: 1.25rem; animation: pop .4s cubic-bezier(.3, 1.6, .5, 1) both; animation-delay: calc(var(--i, 0) * 60ms); }
.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; }
@@ -287,7 +287,8 @@ button, input { font-family: inherit; }
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.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: bounce .7s cubic-bezier(.3, 1.6, .5, 1); }
@keyframes bounce { 0% { transform: scale(.7); } 30% { transform: translateY(-.7em) scale(1.12); } 55% { transform: translateY(0) scale(.95); } 75% { transform: translateY(-.2em); } }
.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; }
@@ -360,18 +361,35 @@ button, input { font-family: inherit; }
.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-who b { color: var(--yellow); animation: pop .5s cubic-bezier(.3, 1.6, .5, 1); }
.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);
transition: filter .4s, opacity .4s;
}
.cat-opts.has-chosen .cat-opt:not(.chosen) { filter: grayscale(.8) brightness(.55); opacity: .6; }
.cat-opt.chosen {
position: relative; z-index: 1; outline: .14em solid #fff; outline-offset: .12em;
box-shadow: 0 .2em 0 var(--cd), 0 0 1.6em var(--c), 0 0 3em color-mix(in srgb, var(--c) 60%, transparent);
animation: chosen-pop .7s cubic-bezier(.3, 1.6, .5, 1) both, glow 1.4s ease-in-out .7s infinite alternate;
}
@keyframes chosen-pop { 0% { transform: scale(1); } 35% { transform: scale(1.28) rotate(-3deg); } 100% { transform: scale(1.1) rotate(-1deg); } }
@keyframes glow { to { filter: brightness(1.15); } }
/* 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.lie { animation: q-fly .8s cubic-bezier(.2, .9, .3, 1.1) both; padding: 0 6em; max-width: none; }
/* spørgsmålskortet flyver ind fra højre (whoosh_long i host.js starter samtidig) */
@keyframes q-fly {
0% { transform: translateX(60em) rotate(8deg) scale(.7); opacity: 0; }
15% { opacity: 1; }
65% { transform: translateX(-1em) rotate(-1.5deg) scale(1.03); }
100% { transform: 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; }
@@ -384,7 +402,7 @@ button, input { font-family: inherit; }
.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);
overflow-wrap: anywhere; animation: tile-in .45s cubic-bezier(.3, 1.6, .5, 1) both; animation-delay: calc(var(--i) * .08s + .15s);
}
/* reveal */
@@ -399,6 +417,13 @@ button, input { font-family: inherit; }
.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; }
/* navnene popper ind ét ad gangen, 90 ms imellem (pop_in-lyden i host.js følger samme takt) */
.rv-who .chip.pop:nth-child(3) { animation-delay: 90ms; }
.rv-who .chip.pop:nth-child(4) { animation-delay: 180ms; }
.rv-who .chip.pop:nth-child(5) { animation-delay: 270ms; }
.rv-who .chip.pop:nth-child(6) { animation-delay: 360ms; }
.rv-who .chip.pop:nth-child(7) { animation-delay: 450ms; }
.rv-who .chip.pop:nth-child(n+8) { animation-delay: 540ms; }
.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; } }
@@ -415,7 +440,6 @@ button, input { font-family: inherit; }
.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%;
@@ -427,16 +451,6 @@ button, input { font-family: inherit; }
.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); }
@@ -444,7 +458,8 @@ button, input { font-family: inherit; }
.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);
animation: slide-in .5s cubic-bezier(.2, 1.2, .4, 1) both, reorder .8s cubic-bezier(.6, 0, .25, 1) both;
animation-delay: calc(var(--i) * .12s), 2.2s;
}
.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; }
@@ -454,7 +469,8 @@ button, input { font-family: inherit; }
.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 slide-in { from { translate: -2em 0; opacity: 0; } }
@keyframes reorder { from { transform: translateY(calc(var(--mv, 0) * (100% + .3em))); } }
@keyframes grow { from { width: 0; } }
/* finale */
@@ -491,8 +507,173 @@ button, input { font-family: inherit; }
.landing .btn { font-size: clamp(1.3rem, 3vw, 2rem); padding: .7em 1.6em; }
.landing .who { color: var(--muted); }
/* =====================================================================
ANIMATIONER (storskærm) – nye noder animerer; bevarede noder (samme data-k + HTML) gør ikke
===================================================================== */
/* faseskift: hovedområdet zoomer ind (hs-main er en ny node pr. fase) */
.hs-main { animation: main-in .6s cubic-bezier(.2, 1.1, .3, 1) both; }
@keyframes main-in { from { opacity: 0; transform: translateY(1.2em) scale(.94); filter: blur(.3em); } }
/* spørgsmålet toner frem */
.qbox .qcat { animation: drop .5s cubic-bezier(.3, 1.6, .5, 1) both; }
.qbox .qprompt { animation: q-in .8s cubic-bezier(.2, .9, .3, 1) .12s both; }
.qbox.rv .qprompt { animation: none; }
@keyframes q-in { from { opacity: 0; transform: translateY(.5em); filter: blur(.25em); letter-spacing: .04em; } }
@keyframes drop { from { opacity: 0; transform: translateY(-1em) scale(.8); } }
/* stemplet rammer: skærmen ryster */
.host-root.shake > .screen { animation: shake .5s .18s; }
@keyframes shake {
0%, 100% { transform: none; }
15% { transform: translate(-.5%, .4%) rotate(-.4deg); }
30% { transform: translate(.5%, -.3%) rotate(.4deg); }
45% { transform: translate(-.35%, .2%); }
60% { transform: translate(.25%, -.15%); }
80% { transform: translate(-.1%, 0); }
}
/* svarkortet i afsløringen + live hjerte-mærke */
.rv-card { position: relative; display: flex; justify-content: center; max-width: 90%; isolation: isolate; }
.rv-card .rv-ans { max-width: 100%; }
.lk {
position: absolute; top: -.7em; right: -.8em; z-index: 2; display: inline-flex; align-items: center; gap: .2em;
font-size: 1.6em; font-weight: 700; color: #fff; background: var(--pink); padding: .05em .55em; border-radius: 999px;
box-shadow: 0 .15em .5em rgba(0, 0, 0, .35), 0 0 0 .1em rgba(255, 255, 255, .7); animation: pop .45s cubic-bezier(.3, 1.6, .5, 1) both;
}
/* sandheden gløder og har stråler bag sig */
.is-truth .rv-card::before {
content: ""; position: absolute; left: 50%; top: 50%; width: 34em; height: 34em; margin: -17em 0 0 -17em; z-index: -1; border-radius: 50%;
background: repeating-conic-gradient(rgba(255, 210, 63, .22) 0 10deg, transparent 10deg 20deg);
-webkit-mask: radial-gradient(closest-side, #000 30%, transparent); mask: radial-gradient(closest-side, #000 30%, transparent);
animation: rays 14s linear infinite, fade-in .6s both;
}
.is-truth .rv-ans { animation: truth-glow 1.6s ease-in-out infinite alternate; }
@keyframes truth-glow { from { box-shadow: 0 .12em 0 var(--green-d), 0 0 .8em rgba(61, 220, 132, .5); } to { box-shadow: 0 .12em 0 var(--green-d), 0 0 2.4em rgba(61, 220, 132, .9); } }
@keyframes rays { to { transform: rotate(360deg); } }
@keyframes fade-in { from { opacity: 0; } }
/* ---------- opsummering ---------- */
.hs-reveal.is-sum .hs-main { gap: 1em; }
.hs-reveal.is-sum .qbox.small .qprompt { font-size: 1.8em; transition: font-size .4s; }
.rv-sum { flex: 1; min-height: 0; width: 100%; display: grid; grid-template-columns: 1fr 23em; gap: 2em; align-items: start; animation: rise .5s both; }
.rv-sum h2 { margin: 0 0 .6em; font-size: 1.35em; color: var(--yellow); text-transform: uppercase; letter-spacing: .08em; text-align: center; }
.sum-r h2 { text-align: left; }
.sum-tiles { --gap: .9em; display: flex; flex-wrap: wrap; justify-content: center; align-content: flex-start; gap: var(--gap); padding-top: .5em; }
.sum-tiles.cols2 { --cols: 2; } .sum-tiles.cols3 { --cols: 3; } .sum-tiles.cols4 { --cols: 4; }
.st {
position: relative; flex: none; width: calc((100% - (var(--cols) - 1) * var(--gap)) / var(--cols)); max-height: 12em;
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: .35em; text-align: center;
padding: .9em .7em .6em; border-radius: .7em; color: var(--navy); background: var(--c);
box-shadow: 0 .18em 0 var(--cd), 0 .4em .9em rgba(0, 0, 0, .3);
animation: tile-in .5s cubic-bezier(.3, 1.6, .5, 1) both; animation-delay: calc(var(--i) * 80ms);
}
.st.still:not(.boom) { animation: none; }
.st-text { font-size: 1.45em; font-weight: 700; line-height: 1.15; overflow-wrap: anywhere; }
.cols4 .st-text { font-size: 1.25em; }
.st-by { display: flex; flex-wrap: wrap; justify-content: center; gap: .3em; }
.st-au { display: inline-flex; align-items: center; gap: .25em; }
.st .chip { color: #fff; font-size: .85em; }
.half { font-size: .72em; font-weight: 700; color: var(--yellow); background: var(--navy); padding: .15em .5em; border-radius: .5em; white-space: nowrap; }
.st-info { font-size: .85em; font-weight: 600; opacity: .7; }
.st-tag {
position: absolute; top: -.75em; left: 50%; transform: translateX(-50%) rotate(-3deg); white-space: nowrap;
font-size: .9em; font-weight: 700; letter-spacing: .06em; padding: .1em .7em; border-radius: .4em; color: #fff; background: var(--orange);
box-shadow: 0 .15em .4em rgba(0, 0, 0, .35);
}
.st.truth {
--c: var(--green); --cd: var(--green-d); outline: .18em solid #fff; outline-offset: .12em;
animation: tile-in .5s cubic-bezier(.3, 1.6, .5, 1) both, truth-tile 1.6s ease-in-out infinite alternate;
animation-delay: calc(var(--i) * 80ms), .8s;
}
.st.truth.still { animation: truth-tile 1.6s ease-in-out infinite alternate; }
.st.truth .st-tag { background: var(--yellow); color: var(--navy); letter-spacing: .12em; }
@keyframes truth-tile { to { box-shadow: 0 .18em 0 var(--cd), 0 0 2em rgba(61, 220, 132, .85), 0 0 4em rgba(255, 210, 63, .35); } }
.st.house { outline: .14em dashed var(--orange); outline-offset: .12em; }
.st.unpicked { filter: saturate(.55) brightness(.92); }
.st .lk { font-size: 1.05em; top: -.6em; right: -.5em; }
/* eksplosionen: ryst → svulm → brag (fragmenterne tegnes i .fx) → hullet lukkes */
.st.boom {
animation: st-boom .9s var(--late, 0ms) both, st-gone .38s calc(var(--late, 0ms) + .58s) cubic-bezier(.5, 0, .7, .4) both;
pointer-events: none; overflow: hidden; z-index: 2;
}
.st.gone { display: none; }
@keyframes st-boom {
0% { transform: none; }
8% { transform: translateX(-.18em) rotate(-2.5deg); }
16% { transform: translateX(.18em) rotate(2.5deg); }
24% { transform: translateX(-.2em) rotate(-3deg); }
32% { transform: translateX(.2em) rotate(3deg); }
40% { transform: translateX(-.1em) rotate(-1deg); }
52% { transform: scale(1.25); filter: brightness(1.5) saturate(1.4); opacity: 1; }
60% { transform: scale(1.45); filter: brightness(2.5); opacity: 0; }
100% { transform: scale(1.45); opacity: 0; }
}
@keyframes st-gone { to { width: 0; max-height: 0; padding: 0; margin-right: calc(-1 * var(--gap)); } }
.sum-pts { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: .4em; }
.sum-pts li {
display: grid; grid-template-columns: auto 1fr 4.4em 4.6em; align-items: center; gap: .55em; font-size: 1.3em;
padding: .28em .7em .28em .35em; border-radius: .6em; background: var(--card); border-left: .25em solid var(--pc);
transition: opacity .4s;
}
.sum-pts li .av { font-size: .72em; }
.sum-pts .n { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sum-pts .sd { text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; }
.sum-pts .tot { text-align: right; font-variant-numeric: tabular-nums; }
.sum-pts li.wait { opacity: .5; }
.sum-pts li.plus, .sum-pts li.zero { animation: slide-in .45s cubic-bezier(.2, 1.2, .4, 1) both; animation-delay: calc(var(--i) * 160ms); }
.sum-pts li.plus { background: color-mix(in srgb, var(--pc) 24%, var(--card)); }
.sum-pts li.plus .sd { color: var(--green); font-size: 1.15em; animation: pop .5s cubic-bezier(.3, 1.6, .5, 1) both; animation-delay: calc(var(--i) * 160ms + .1s); }
.sum-pts li.plus .tot { color: var(--yellow); }
.sum-pts li.zero { opacity: .42; filter: grayscale(1); }
.sum-pts li.zero .sd { color: var(--muted); }
/* ---------- effektlaget: hjerter, fragmenter, konfetti ---------- */
.fx { position: absolute; inset: 0; z-index: 40; pointer-events: none; overflow: hidden; font-size: min(1.25cqw, 2.222cqh); }
.fx > i { position: absolute; display: block; font-style: normal; }
.fheart {
margin: -.6em 0 0 -.5em; font-size: calc(2.6em * var(--s, 1)); line-height: 1; color: var(--pink);
text-shadow: 0 0 .3em rgba(255, 61, 139, .8), 0 .05em 0 rgba(0, 0, 0, .3);
animation: heart-up 2.1s cubic-bezier(.2, .7, .4, 1) both;
}
.fheart::before { content: "♥"; }
@keyframes heart-up {
0% { transform: translate(0, 0) scale(.2); opacity: 0; }
12% { transform: translate(0, -.5em) scale(1.25); opacity: 1; }
100% { transform: translate(var(--dx), -3.6em) scale(.8) rotate(12deg); opacity: 0; }
}
.frag {
width: calc(1.3em * var(--s, 1)); height: calc(.9em * var(--s, 1)); margin: -.45em 0 0 -.65em; border-radius: .2em;
box-shadow: 0 0 0 .08em rgba(255, 255, 255, .6); animation: frag .95s cubic-bezier(.15, .7, .35, 1) both;
}
@keyframes frag {
from { transform: none; opacity: 1; }
70% { opacity: 1; }
to { transform: translate(var(--dx), calc(var(--dy) + 2.5em)) rotate(var(--r)) scale(.3); opacity: 0; }
}
.flash { width: 9em; height: 9em; margin: -4.5em 0 0 -4.5em; border-radius: 50%; background: radial-gradient(circle, #fff 0, rgba(255, 210, 63, .8) 30%, transparent 65%); animation: flash-out .45s ease-out both; }
@keyframes flash-out { from { transform: scale(.2); opacity: 1; } to { transform: scale(1.5); opacity: 0; } }
.conf { width: .55em; height: 1em; border-radius: .1em; animation: conf-fall 4s cubic-bezier(.25, .5, .5, 1) both; }
@keyframes conf-fall {
from { transform: translate(0, 0) rotate(0); }
to { transform: translate(var(--dx), 112cqh) rotate(var(--r)); }
}
/* =====================================================================
ANIMATIONER (telefon) – let
===================================================================== */
.ph-main { animation: ph-in .4s cubic-bezier(.2, 1, .3, 1) both; }
@keyframes ph-in { from { opacity: 0; transform: translateY(14px); } }
.opts.has-chosen .btn-opt:not(.chosen), .opts.ghost.has-chosen .opt-ghost:not(.chosen) { opacity: .3; filter: grayscale(.7); }
.btn-opt.chosen:disabled { opacity: 1; filter: none; outline: 3px solid #fff; outline-offset: 3px; animation: pop .5s cubic-bezier(.3, 1.6, .5, 1); }
.opts.ghost.has-chosen { opacity: 1; }
.opt-ghost.chosen { background: var(--c); color: var(--navy); border-style: solid; animation: pop .5s cubic-bezier(.3, 1.6, .5, 1); }
.ask b, .big h2 b { color: var(--yellow); }
.hearts { animation: rise .4s ease-out both; }
.heart.on { animation: heart-pop .45s cubic-bezier(.3, 1.6, .5, 1); }
@keyframes heart-pop { 0% { transform: scale(.6); } 50% { transform: scale(1.35); } }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
.fx { display: none; }
.is-truth .rv-card::before { display: none; }
}
.page-host { overflow: hidden; }
@@ -504,3 +685,126 @@ button, input { font-family: inherit; }
.rv-wait .drum { font-size: clamp(28px, 3.2vw, 56px); font-weight: 800; opacity: .9; animation: drumpulse .5s ease-in-out infinite alternate; }
@keyframes drumpulse { from { transform: scale(1); } to { transform: scale(1.04); } }
.hbtn.voice-blocked { background: #ffd166; color: #1b1035; font-weight: 800; animation: drumpulse .7s ease-in-out infinite alternate; }
/* =====================================================================
KATEGORI-INTRO (storskærm): kortet flyver ind, hopper og gnistrer.
Kortet lander ved 55 % af 1 s (= INTRO_LAND i host.js, hvor jinglen starter).
===================================================================== */
.hs-intro .hs-main { justify-content: center; }
.intro { position: relative; display: flex; flex-direction: column; align-items: center; gap: 1.1em; width: 100%; isolation: isolate; }
.in-kicker {
font-size: 1.5em; font-weight: 700; color: var(--cyan); letter-spacing: .08em; text-transform: uppercase;
animation: drop .5s cubic-bezier(.3, 1.6, .5, 1) .1s both;
}
.in-card {
position: relative; text-align: center; max-width: 60em; padding: .8em 2.2em 1em; border-radius: 1.1em;
background: linear-gradient(160deg, var(--pink), var(--violet-d) 85%); border: .25em solid #fff;
box-shadow: 0 .45em 0 rgba(0, 0, 0, .35), 0 0 4em rgba(255, 61, 139, .5);
animation: card-fly 1s cubic-bezier(.2, .8, .3, 1) both, card-float 2.6s ease-in-out 1.3s infinite alternate;
}
@keyframes card-fly {
0% { transform: translate(-70em, -8em) rotate(-35deg) scale(.5); opacity: 0; }
12% { opacity: 1; }
55% { transform: translate(1.2em, 0) rotate(5deg) scale(1.12); }
70% { transform: translate(-.5em, 0) rotate(-3.5deg) scale(.95); }
85% { transform: rotate(0) scale(1.03); }
100% { transform: rotate(-1deg) scale(1); }
}
@keyframes card-float { from { transform: rotate(-1deg); } to { transform: rotate(1deg) translateY(-.3em) scale(1.01); } }
.in-label { font-size: 1.7em; font-weight: 600; color: var(--yellow); letter-spacing: .04em; }
.in-title {
font-size: 6em; font-weight: 700; line-height: 1.02; color: #fff; text-wrap: balance; overflow-wrap: anywhere;
text-shadow: 0 .06em 0 var(--pink-d), 0 .12em .3em rgba(0, 0, 0, .35);
animation: title-bounce .7s cubic-bezier(.3, 1.6, .5, 1) .55s both;
}
@keyframes title-bounce { 0% { transform: none; } 30% { transform: scale(1.18, .84); } 60% { transform: scale(.95, 1.08); } 100% { transform: none; } }
.spk {
position: absolute; width: 1.5em; height: 1.5em; margin: -.75em 0 0 -.75em; background: var(--yellow);
clip-path: polygon(50% 0, 61% 39%, 100% 50%, 61% 61%, 50% 100%, 39% 61%, 0 50%, 39% 39%);
transform: scale(var(--r, 1));
animation: spk-in .45s cubic-bezier(.3, 1.6, .5, 1) calc(.55s + var(--i) * 40ms) both,
twinkle 1.1s ease-in-out calc(1.1s + var(--i) * 130ms) infinite alternate;
}
.spk:nth-of-type(3n) { background: #fff; }
.spk:nth-of-type(3n+1) { background: var(--cyan); }
@keyframes spk-in { from { transform: scale(0) rotate(-120deg); opacity: 0; } }
@keyframes twinkle {
from { transform: scale(calc(var(--r, 1) * .45)) rotate(0); opacity: .55; }
to { transform: scale(calc(var(--r, 1) * 1.2)) rotate(45deg); opacity: 1; }
}
/* finalen: FINALE smækker ned, stråler bag, kortet kommer bagefter */
.intro.final .in-finale {
font-size: 8.5em; font-weight: 700; letter-spacing: .08em; line-height: 1; padding: 0 .1em;
background: linear-gradient(180deg, #fff6c2, var(--yellow) 45%, var(--orange)); -webkit-background-clip: text; background-clip: text; color: transparent;
filter: drop-shadow(0 .05em 0 #7a1020) drop-shadow(0 0 .25em rgba(255, 71, 87, .75));
animation: finale-slam .55s cubic-bezier(.2, 1.4, .5, 1) both, finale-pulse 1.1s ease-in-out .9s infinite alternate;
}
@keyframes finale-slam { 0% { transform: scale(4) rotate(-14deg); opacity: 0; } 60% { transform: scale(.92) rotate(1deg); opacity: 1; } 100% { transform: rotate(-3deg); } }
@keyframes finale-pulse { from { transform: rotate(-3deg); } to { transform: rotate(-3deg) scale(1.05); } }
.in-mult {
font-size: 1.8em; font-weight: 700; color: var(--navy); background: var(--yellow); padding: .1em .8em; border-radius: 999px;
transform: rotate(-2deg); box-shadow: 0 .15em 0 var(--yellow-d); animation: drop .5s cubic-bezier(.3, 1.6, .5, 1) .45s both;
}
.in-rays {
position: absolute; left: 50%; top: 45%; width: 110em; height: 110em; margin: -55em 0 0 -55em; z-index: -1; border-radius: 50%;
background: repeating-conic-gradient(rgba(255, 71, 87, .2) 0 7deg, transparent 7deg 16deg);
-webkit-mask: radial-gradient(closest-side, #000 20%, transparent); mask: radial-gradient(closest-side, #000 20%, transparent);
animation: rays 24s linear infinite, fade-in 1s both;
}
.intro.final .in-card {
background: linear-gradient(160deg, var(--red), #6b0a2e 85%); box-shadow: 0 .45em 0 rgba(0, 0, 0, .35), 0 0 5em rgba(255, 71, 87, .6);
animation-delay: .75s, 2.05s;
}
.intro.final .in-title { font-size: 5em; animation-delay: 1.3s; }
.intro.final .spk { animation-delay: calc(1.3s + var(--i) * 40ms), calc(1.85s + var(--i) * 130ms); }
.fstar {
width: 1.4em; height: 1.4em; margin: -.7em 0 0 -.7em; font-size: calc(1.6em * var(--s, 1)); background: currentColor;
clip-path: polygon(50% 0, 61% 39%, 100% 50%, 61% 61%, 50% 100%, 39% 61%, 0 50%, 39% 39%);
animation: fstar 1.25s cubic-bezier(.15, .7, .35, 1) both;
}
@keyframes fstar {
0% { transform: scale(.2); opacity: 1; }
70% { opacity: 1; }
100% { transform: translate(var(--dx), var(--dy)) rotate(200deg) scale(.35); opacity: 0; }
}
/* ---------- telefon: kategori-intro (tavs) ---------- */
.ph-intro { text-align: center; padding: 26px 6px 6px; }
.ph-intro p { color: var(--muted); font-size: 1.2rem; font-weight: 600; animation: rise .4s both; }
.ph-intro h2 { margin-top: 8px; font-size: 2.6rem; line-height: 1.1; color: var(--yellow); text-shadow: 0 4px 0 rgba(0, 0, 0, .3); }
.ph-intro .w { display: inline-block; white-space: nowrap; }
.ph-intro .ch { display: inline-block; animation: ch-drop .5s cubic-bezier(.3, 1.6, .5, 1) both; animation-delay: calc(.25s + var(--i) * 45ms); }
@keyframes ch-drop { from { transform: translateY(-.9em) rotate(-18deg) scale(.4); opacity: 0; } }
.ph-intro.final h2 { color: #fff; }
.roundtag.final { background: var(--red); color: #fff; letter-spacing: .12em; animation: pop .5s cubic-bezier(.3, 1.6, .5, 1) both; }
/* ---------- telefon: drilleri, når man faldt for en løgn ---------- */
.tease {
position: fixed; inset: 0; z-index: 70; display: flex; align-items: center; justify-content: center; padding: 24px;
background: radial-gradient(circle at 50% 40%, rgba(255, 61, 139, .6), rgba(18, 4, 43, .94) 70%);
-webkit-backdrop-filter: blur(4px); backdrop-filter: blur(4px);
animation: fade-in .18s both; cursor: pointer; overflow: hidden; -webkit-user-select: none; user-select: none;
}
.tease.out { opacity: 0; transition: opacity .3s; }
.tz-box { display: flex; flex-direction: column; align-items: center; gap: 22px; animation: phone-shake .45s .45s; }
.tz-img {
display: block; width: min(70vw, 300px); aspect-ratio: 1; object-fit: cover; border-radius: 28px; border: 6px solid #fff; background: #fff;
box-shadow: 0 16px 40px rgba(0, 0, 0, .5); transform: rotate(-6deg); animation: tz-zoom .6s cubic-bezier(.3, 1.6, .5, 1) both;
}
@keyframes tz-zoom { 0% { transform: scale(.1) rotate(-40deg); opacity: 0; } 60% { transform: scale(1.12) rotate(4deg); opacity: 1; } 100% { transform: rotate(-6deg); } }
.tz-text {
max-width: 100%; font-size: clamp(3.2rem, 17vw, 5.5rem); font-weight: 700; line-height: 1; text-align: center; color: var(--yellow);
-webkit-text-stroke: 2px var(--navy); text-shadow: 0 6px 0 var(--pink-d), 0 0 30px rgba(255, 61, 139, .8); overflow-wrap: anywhere;
animation: tz-text .6s cubic-bezier(.3, 1.8, .5, 1) .15s both, tz-wobble .45s ease-in-out .8s infinite alternate;
}
.tz-text.long { font-size: clamp(2.4rem, 12vw, 3.8rem); line-height: 1.05; }
@keyframes tz-text { 0% { transform: scale(2.6) rotate(-12deg); opacity: 0; } 100% { transform: rotate(4deg); } }
@keyframes tz-wobble { from { transform: rotate(4deg); } to { transform: rotate(-3deg) scale(1.06); } }
.tz-shake > .screen { animation: phone-shake .45s .4s; }
@keyframes phone-shake {
0%, 100% { translate: 0 0; rotate: 0deg; }
20% { translate: -9px 3px; rotate: -1.5deg; }
40% { translate: 8px -3px; rotate: 1.5deg; }
60% { translate: -5px 2px; rotate: -.6deg; }
80% { translate: 3px -1px; rotate: 0deg; }
}
+126 -26
View File
@@ -4,7 +4,7 @@
var WPP = window.WPP, esc = WPP.esc;
function ui(root) {
return root._ui || (root._ui = { busy: false, busyTimer: null, drafts: {}, lastPhase: null });
return root._ui || (root._ui = { busy: false, busyTimer: null, drafts: {}, lastPhase: null, revealSeen: {}, state: null, revealKey: null });
}
/* ---------- travlhed (forhindrer dobbelt-tryk) ---------- */
@@ -62,6 +62,7 @@
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; }
WPP.sound.unlock(); /* "Hop ind!" er første tryk: lås lyden op til drillerierne */
if (root._ctx && root._ctx.onJoin) {
if (!root._ctx.harness) setBusy(root, true);
root._ctx.onJoin(code, name.slice(0, 12));
@@ -126,19 +127,36 @@
var cat = state.category || {};
var opts = cat.options || [];
var chooser = players[cat.chooser_id];
var chosen = cat.chosen_id != null ? opts.filter(function (o) { return o.id === cat.chosen_id; })[0] : null;
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>';
return head + '<h2 class="ask" data-k="ask:' + (chosen ? 1 : 0) + '">' + (chosen ? 'Du valgte <b>' + esc(chosen.name) + '</b> ✓' : 'Vælg en kategori!') + '</h2>' +
'<section class="opts' + (chosen ? ' has-chosen' : '') + '" data-k="opts:' + esc(chosen ? chosen.id : '') + '">' + opts.map(function (o, i) {
var on = chosen && chosen.id === o.id;
return '<button class="btn btn-opt c' + (i % 4) + (on ? ' chosen' : '') + '" data-act="choose_category" data-arg="' + esc(o.id) + '"' +
(chosen ? ' disabled' : '') + '>' + 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>';
var who = chooser ? esc(chooser.name) : 'Nogen';
return head + (chosen ? big(who + ' valgte <b>' + esc(chosen.name) + '</b>', 'Gør dig klar til at lyve!', 'chosen:' + esc(chosen.id), 'pop')
: big(who + ' vælger kategori…', 'Hold dig klar til at lyve!', 'wait', 'pulse')) +
'<section class="opts ghost' + (chosen ? ' has-chosen' : '') + '" data-k="opts:' + esc(chosen ? chosen.id : '') + '">' + opts.map(function (o, i) {
return '<div class="opt-ghost c' + (i % 4) + (chosen && chosen.id === o.id ? ' chosen' : '') + '">' + esc(o.name) + '</div>';
}).join('') + '</section>';
}
/* kategorien præsenteres på storskærmen; telefonen viser den stille med bogstaver, der falder på plads */
function screenIntro(state) {
var it = state.intro || {};
var i = 0;
var title = String(it.category || '').split(/\s+/).filter(Boolean).map(function (w) {
return '<span class="w">' + Array.from(w).map(function (ch) { return '<span class="ch" style="--i:' + (i++) + '">' + esc(ch) + '</span>'; }).join('') + '</span>';
}).join(' ');
return '<div class="roundtag' + (it.final ? ' final' : '') + '" data-k="rt:' + (it.final ? 1 : 0) + '">' + (it.final ? 'FINALE' : WPP.roundTitle(state.round)) + '</div>' +
'<section class="ph-intro' + (it.final ? ' final' : '') + '" data-k="pi:' + esc(it.slug || it.category || '') + '"><p>Kategorien er</p><h2>' + title + '</h2></section>' +
'<p class="hint center" data-k="w">' + (it.final ? 'Tredobbelt point – gør dig klar til at lyve!' : 'Gør dig klar til at lyve!') + '</p>';
}
function screenLie(state, me, players, root) {
var q = state.question || {};
var qk = (state.round ? state.round.number + '.' + state.round.question_number : '') + ':' + (q.prompt || '');
@@ -164,17 +182,29 @@
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;
});
/* Hjerter gives kun under afsløringen og kun til løgne, der allerede er afsløret på storskærmen
(ellers ville listen røbe sandheden). Aldrig sandheden, aldrig ens egen løgn. Nyeste øverst. */
function revealedLies(state, me, pr) {
var r = state.reveal || {};
if (pr.pending) return [];
var list;
if (pr.done) list = (r.all || []).map(function (a) { return { id: a.id, text: a.text, is_truth: a.is_truth }; });
else {
list = (r.steps || []).slice(0, pr.i + (pr.stage >= 2 ? 1 : 0))
.map(function (st) { return { id: st.answer_id, text: st.text, is_truth: st.is_truth }; });
}
var mine = String(me.lie || '').trim().toLowerCase();
return list.filter(function (a) {
if (a.is_truth || a.id === me.own_answer_id) return false;
return !(mine && String(a.text).trim().toLowerCase() === mine);
}).reverse();
}
function heartList(state, me, pr) {
var ans = revealedLies(state, me, pr);
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>' +
return '<section class="hearts" data-k="hearts:' + ans.map(function (a) { return a.id + (likes.indexOf(a.id) >= 0 ? '+' : ''); }).join(',') + '">' +
'<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' : '') +
@@ -189,26 +219,27 @@
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>';
out += '<p class="hint center" data-k="w">Venter på de andre…<br>Når løgnene afsløres, kan du give hjerter.</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>';
return '<button class="btn btn-ans c' + (i % 6) + '" data-act="guess" data-arg="' + esc(a.id) + '" style="--i:' + i + '">' + esc(a.text) + '</button>';
}).join('') + '</section>';
return out;
}
function screenReveal(state, me) {
function screenReveal(state, me, players, u) {
var pr = WPP.revealProgress(state, u);
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">' +
/* egne point først, når storskærmen er nået til opsummeringen */
if (pr.done && me.role === 'player' && me.last_delta != null) {
out += '<section class="delta' + (me.last_delta > 0 ? ' plus' : '') + '" data-k="delta:' + me.last_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');
out += heartList(state, me, pr);
return out;
}
@@ -285,15 +316,19 @@
switch (phase) {
case 'lobby': body = screenLobby(state, me, players); break;
case 'category': body = screenCategory(state, me, players); break;
case 'intro': body = screenIntro(state); 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 'reveal': body = screenReveal(state, me, players, u); u.revealKey = WPP.revealProgress(state, u).key; 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>');
/* ny main pr. fase: skærmen glider ind ved hvert faseskift */
WPP.patch(sh.screen, topBar(state, me, players) + '<main class="ph-main" data-k="main:' + phase + '" data-patch>' + body + '</main>');
u.state = state;
if (!root._ticking) { root._ticking = true; WPP.onTick(function () { playTick(root); }); }
/* gendan kladde i løgne-feltet, hvis formularen er ny */
var inp = sh.screen.querySelector('#wpp-lie');
@@ -311,11 +346,19 @@
}
}
/* afsløringen skrider frem uden nye snapshots: tegn igen, når storskærmen skifter trin */
function playTick(root) {
var u = ui(root), s = u.state;
if (!s || s.phase !== 'reveal' || !root.isConnected || !root._ctx) return;
if (WPP.revealProgress(s, u).key !== u.revealKey) renderPlay(root, s, Object.assign({}, root._ctx, { keepBusy: true }));
}
function renderJoin(root, ctx, opts) {
ctx = ctx || {}; opts = opts || {};
root._ctx = ctx;
bind(root);
setBusy(root, false);
ui(root).state = null;
var sh = WPP.shell(root);
sh.screen.className = 'screen ph ph-join';
var code = opts.code || '';
@@ -334,6 +377,7 @@
function renderWaiting(root, text) {
var sh = WPP.shell(root);
ui(root).state = null;
sh.screen.className = 'screen ph ph-wait';
WPP.patch(sh.screen, '<section class="big pulse" data-k="w"><h2>' + esc(text || 'Forbinder…') + '</h2></section>');
}
@@ -347,6 +391,56 @@
inp.classList.remove('flash'); void inp.offsetWidth; inp.classList.add('flash');
}
/* ---------- drilleri: "Buh!", når man lige er faldet for en løgn ----------
Fuldskærm med billede + tekst, ryst og vibration; boo og så serverens lyd (hvis lyden er låst op).
Lukker selv efter ~2,5 s eller ved tryk. opts: {ttl, silent} (dev-harness). */
function showTease(root, msg, opts) {
opts = opts || {};
msg = msg || {};
hideTease(root, true);
var ov = document.createElement('div');
ov.className = 'tease';
ov.setAttribute('role', 'alert');
ov.innerHTML = '<div class="tz-box">' + (msg.image ? '<img class="tz-img" alt="" src="' + esc(msg.image) + '">' : '') +
'<div class="tz-text' + (String(msg.text || '').length > 10 ? ' long' : '') + '">' + esc(msg.text || 'Buh!') + '</div></div>';
ov.addEventListener('click', function (e) { e.preventDefault(); e.stopPropagation(); hideTease(root); });
root.appendChild(ov);
root._tease = ov;
if (!WPP.reducedMotion()) { root.classList.remove('tz-shake'); void root.offsetWidth; root.classList.add('tz-shake'); }
/* vibration kræver, at siden har fået et tryk (ellers advarer browseren) */
var ua = navigator.userActivation;
try { if (navigator.vibrate && (!ua || ua.hasBeenActive)) navigator.vibrate(200); } catch (e) { /* */ }
if (!opts.silent) teaseSound(msg);
ov._t = setTimeout(function () { hideTease(root); }, opts.ttl || 2500);
}
function hideTease(root, now) {
var ov = root._tease;
if (!ov) return;
root._tease = null;
clearTimeout(ov._t);
root.classList.remove('tz-shake');
if (now) { if (ov.parentNode) ov.parentNode.removeChild(ov); return; }
ov.classList.add('out');
setTimeout(function () { if (ov.parentNode) ov.parentNode.removeChild(ov); }, 320);
}
function teaseSound(msg) {
WPP.sound.whenReady(function (ok) {
if (!ok) return; /* browseren har ikke fået et tryk endnu: kun det visuelle */
var d = WPP.sfx('boo');
if (msg.sound) WPP.playUrl(msg.sound, { at: WPP.sound.ctx().currentTime + Math.max(0.2, d * 0.75) });
}, 300);
}
/* billederne er versionerede (caches for altid): hent dem én gang, så drilleriet vises med det samme */
function preloadTease() {
if (preloadTease.done) return;
preloadTease.done = true;
(WPP.assets.tease || []).forEach(function (url) {
var img = new Image();
img.decoding = 'async';
img.src = url;
});
}
/* ---------- live-forbindelse ---------- */
function mount(root) {
var qs = new URLSearchParams(location.search);
@@ -394,6 +488,7 @@
case 'state':
if (!token) return;
renderPlay(root, msg.state, ctx);
preloadTease();
break;
case 'joined':
token = msg.token; joinedRole = msg.role; pendingName = null;
@@ -404,6 +499,9 @@
case 'suggestion':
setSuggestion(root, msg.text);
break;
case 'tease':
showTease(root, msg);
break;
case 'error':
setBusy(root, false);
if (msg.code === 'unknown_token' || msg.code === 'unknown_room') {
@@ -422,6 +520,8 @@
}
}
/* telefonen er ellers tavs; kun drilleriets "boo" hentes (afkodes, når første tryk låser lyden op) */
WPP.sfx.preload(['boo']);
var last = WPP.ls.get('wpp:last');
if (last && WPP.ls.get('wpp:' + last) && (!prefill || prefill === last)) {
renderWaiting(root, 'Forbinder…');
@@ -432,7 +532,7 @@
return ctx;
}
window.WPPPlay = { renderPlay: renderPlay, renderJoin: renderJoin, renderWaiting: renderWaiting, setSuggestion: setSuggestion, mount: mount };
window.WPPPlay = { renderPlay: renderPlay, renderJoin: renderJoin, renderWaiting: renderWaiting, setSuggestion: setSuggestion, showTease: showTease, mount: mount };
window.renderPlay = renderPlay;
if (!window.WPP_HARNESS) {
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

+1
View File
@@ -6,6 +6,7 @@
<div id="host" data-code="{{ code }}"></div>
{% endblock %}
{% block scripts %}
{{ assets|json_script:"wpp-assets" }}
<script src="{% static 'party/common.js' %}" defer></script>
<script src="{% static 'party/host.js' %}" defer></script>
{% endblock %}
+1
View File
@@ -8,6 +8,7 @@
</div>
{% endblock %}
{% block scripts %}
{{ assets|json_script:"wpp-assets" }}
<script src="{% static 'party/common.js' %}" defer></script>
<script src="{% static 'party/play.js' %}" defer></script>
{% endblock %}
+83 -2
View File
@@ -62,7 +62,9 @@ class EngineEventTests(SimpleTestCase):
self.assertEqual(types[:4], ["join", "join", "game_start", "round_intro"])
self.assertIn("category", types)
g.tick(100) # kategorien vælges af tiden
self.assertEqual([e["type"] for e in g.drain_events()], ["category_timeout", "lie_start"])
self.assertEqual([e["type"] for e in g.drain_events()], ["category_timeout", "category_intro"])
g.tick(100 + engine.INTRO_SECONDS)
self.assertEqual([e["type"] for e in g.drain_events()], ["lie_start"])
g.submit_lie(a.id, "katte", 100)
with self.assertRaises(engine.GameError):
g.submit_lie(b.id, g.question["answer"], 100)
@@ -82,7 +84,7 @@ class EngineEventTests(SimpleTestCase):
async def fake_synth(text):
return {"url": f"/tts/{abs(hash(text)) % 10**32:032d}.mp3", "ms": 2000 + len(text) * 10}
return {"url": f"/tts/{abs(hash(text)) % 10**32:032d}.mp3", "ms": 40}
@override_settings(TRUSTED_PROXIES=["127.0.0.1"], DEV_HOST_USER=None)
@@ -90,6 +92,9 @@ class NarrationOverWebSocketTests(TransactionTestCase):
def setUp(self):
rooms.all_rooms().clear()
make_bank()
from fupogfakta import engine as _engine
self._intro, _engine.INTRO_SECONDS = _engine.INTRO_SECONDS, 0.2
self.addCleanup(setattr, _engine, "INTRO_SECONDS", self._intro)
async def test_host_gets_narration_and_reveal_audio(self):
from asgiref.sync import sync_to_async
@@ -131,3 +136,79 @@ class NarrationOverWebSocketTests(TransactionTestCase):
for c in phones:
await c.disconnect()
await host.disconnect()
class SpeechHoldTests(SimpleTestCase):
def test_transitions_wait_for_speech(self):
from fupogfakta.tests.test_engine import bank
g = Game("TEST", bank(), rng=random.Random(5))
a, b = g.join("A"), g.join("B")
g.start(None, 0)
g.hold_until = 10 # speakeren taler til t=10
g.choose_category(g.chooser_id, g.category_options[0]["id"], 1)
self.assertEqual(g.phase, "category") # valgt, men skærmen skifter ikke endnu
self.assertFalse(g.tick(5))
g.hold_until = None
self.assertTrue(g.tick(10))
self.assertEqual(g.phase, "intro") # kategorien præsenteres
self.assertTrue(g.tick(10 + engine.INTRO_SECONDS))
self.assertEqual(g.phase, "lie")
g.hold_until = 20
g.submit_lie(a.id, "katte", 11)
g.submit_lie(b.id, "hunde", 11)
self.assertEqual(g.phase, "lie") # alle har løjet, men speakeren læser stadig op
self.assertFalse(g.tick(15))
self.assertTrue(g.tick(20))
self.assertEqual(g.phase, "guess")
self.assertIn("lie_all_done", [e["type"] for e in g.drain_events()])
g.hold_until = 99
g.skip(21) # værtens "spring over" venter ikke
self.assertEqual(g.phase, "reveal")
@override_settings(TRUSTED_PROXIES=["127.0.0.1"], DEV_HOST_USER=None)
class TeaseTests(TransactionTestCase):
def setUp(self):
rooms.all_rooms().clear()
make_bank()
from fupogfakta import engine as _engine
self._intro, _engine.INTRO_SECONDS = _engine.INTRO_SECONDS, 0.2
self.addCleanup(setattr, _engine, "INTRO_SECONDS", self._intro)
async def test_fooled_player_gets_teased_when_stamp_lands(self):
from asgiref.sync import sync_to_async
from fupogfakta.models import load_game_questions
room = rooms.create_room(await sync_to_async(load_game_questions)(), "agw", "http://testserver")
with mock.patch.object(rooms, "tease_images", return_value=["/static/party/tease/x.png"]), \
mock.patch.object(rooms, "TEASE_CHANCE", 1.0):
phones = []
for name in ["Asger", "Sus"]:
c = WebsocketCommunicator(application, f"/ws/play/{room.code}/")
await c.connect()
await c.send_json_to({"action": "join", "name": name})
phones.append((c, (await recv_until(c, "joined"))["player_id"]))
await phones[0][0].send_json_to({"action": "start"})
st = (await recv_until(phones[0][0], "state", "category"))["state"]
chooser = next(c for c, pid in phones if pid == st["category"]["chooser_id"])
await chooser.send_json_to({"action": "choose_category", "category_id": st["category"]["options"][0]["id"]})
for i, (c, _) in enumerate(phones):
await recv_until(c, "state", "lie")
await c.send_json_to({"action": "submit_lie", "text": f"løgn {i}"})
# Sus vælger Asgers løgn og bliver narret
(asger, a_id), (sus, s_id) = phones
st = (await recv_until(sus, "state", "guess"))["state"]
await recv_until(asger, "state", "guess")
asger_lie = next(a["id"] for a in st["answers"] if a["text"] == "løgn 0")
await sus.send_json_to({"action": "guess", "answer_id": asger_lie})
mine = (await recv_until(asger, "state", "guess"))["state"] if False else None
truth_or_other = next(a["id"] for a in st["answers"] if a["text"] not in ("løgn 0", "løgn 1"))
await asger.send_json_to({"action": "guess", "answer_id": truth_or_other})
for _ in range(40):
msg = await sus.receive_json_from(timeout=5)
if msg["type"] == "tease":
break
self.assertEqual(msg["type"], "tease")
self.assertTrue(msg["text"])
self.assertEqual(msg["image"], "/static/party/tease/x.png")
for c, _ in phones:
await c.disconnect()
+3
View File
@@ -30,6 +30,9 @@ class WebSocketGameTests(TransactionTestCase):
def setUp(self):
rooms.all_rooms().clear()
make_bank()
from fupogfakta import engine as _engine
self._intro, _engine.INTRO_SECONDS = _engine.INTRO_SECONDS, 0.2
self.addCleanup(setattr, _engine, "INTRO_SECONDS", self._intro)
def _room(self):
from fupogfakta.models import load_game_questions
+4 -2
View File
@@ -11,6 +11,7 @@ from django.views.decorators.http import require_GET, require_POST
from fupogfakta.models import load_game_questions
from . import tts
from .assets import asset_map
from .rooms import create_room, get_room
CODE_RE = re.compile(r"^[A-Za-z]{4}$")
@@ -23,7 +24,8 @@ def _forbidden():
@require_GET
def play(request):
code = request.GET.get("code", "")
return render(request, "party/play.html", {"prefill_code": code.upper() if CODE_RE.match(code) else ""})
return render(request, "party/play.html", {"prefill_code": code.upper() if CODE_RE.match(code) else "",
"assets": asset_map()})
@require_GET
@@ -51,7 +53,7 @@ def host(request, code):
return _forbidden()
if not get_room(code):
return redirect("party:host_new_page")
return render(request, "party/host.html", {"code": code.upper()})
return render(request, "party/host.html", {"code": code.upper(), "assets": asset_map()})
@require_GET