diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 86c632b..2840227 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -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", // ".." – 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/.mp3`. Kategoriens jingle hedder +`cat_` (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. diff --git a/fupogfakta/engine.py b/fupogfakta/engine.py index 1956b4c..10e2be2 100644 --- a/fupogfakta/engine.py +++ b/fupogfakta/engine.py @@ -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 "", diff --git a/fupogfakta/narrator/lines.json b/fupogfakta/narrator/lines.json index caf3aa7..6ba7d0e 100644 --- a/fupogfakta/narrator/lines.json +++ b/fupogfakta/narrator/lines.json @@ -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": [ diff --git a/fupogfakta/tests/test_engine.py b/fupogfakta/tests/test_engine.py index 0c8c186..853c8df 100644 --- a/fupogfakta/tests/test_engine.py +++ b/fupogfakta/tests/test_engine.py @@ -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) diff --git a/party/assets.py b/party/assets.py new file mode 100644 index 0000000..1491603 --- /dev/null +++ b/party/assets.py @@ -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"] diff --git a/party/consumers.py b/party/consumers.py index 36cd1a2..0700986 100644 --- a/party/consumers.py +++ b/party/consumers.py @@ -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": diff --git a/party/management/commands/tts_warm.py b/party/management/commands/tts_warm.py index 57b9e2d..cc96c34 100644 --- a/party/management/commands/tts_warm.py +++ b/party/management/commands/tts_warm.py @@ -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.")) diff --git a/party/narrator.py b/party/narrator.py index e22a215..2993a73 100644 --- a/party/narrator.py +++ b/party/narrator.py @@ -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": diff --git a/party/rooms.py b/party/rooms.py index d27bfa4..84b5577 100644 --- a/party/rooms.py +++ b/party/rooms.py @@ -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: diff --git a/party/static/party/common.js b/party/static/party/common.js index 1ee5606..18fb205 100644 --- a/party/static/party/common.js +++ b/party/static/party/common.js @@ -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/.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:" */ + 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 – 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_ → 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(); }); diff --git a/party/static/party/dev-fixtures.js b/party/static/party/dev-fixtures.js index 632c099..6b0bc42 100644 --- a/party/static/party/dev-fixtures.js +++ b/party/static/party/dev-fixtures.js @@ -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( + '' + + '' + + '' + + '' + + '' + + ''); + 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 + ''); } + /* 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 & 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 & 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 & 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 }; })(); diff --git a/party/static/party/dev.html b/party/static/party/dev.html index fe17254..b5b93c2 100644 --- a/party/static/party/dev.html +++ b/party/static/party/dev.html @@ -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(); diff --git a/party/static/party/host.js b/party/static/party/host.js index eb2bfdf..1979551 100644 --- a/party/static/party/host.js +++ b/party/static/party/host.js @@ -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 @@ ''; } - 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 '' + - '' + esc(p.name) + (p.done ? '' : '') + ''; - }).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 '' + esc(p.name) + (p.done ? '' : '') + ''; + }).join(''); var skip = TIMED[state.phase] ? '' : ''; - var mute = (WPP.voice.blocked ? '' : '') + + /* 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 ? '' : '') + ''; return '
' + - '
' + chipsHtml + '
' + - '
' + mute + skip + '
'; + '
' + chipsHtml + '
' + + '
' + mute + skip + '
'; } /* ---------- 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 '
' + '
' + WPP.roundTitle(r) + '
' + (r.questions_in_round ? '
Spørgsmål ' + (r.question_number || 1) + ' af ' + r.questions_in_round + '
' : '') + '
' + - '
' + (ch ? WPP.chip(ch, 'xl') : 'Nogen') + ' vælger kategori…
' + - '
' + + '
' + (ch ? WPP.chip(ch, 'xl') : 'Nogen') + + (chosen ? ' valgte ' + esc(chosen.name) + '' : ' vælger kategori…') + '
' + + /* sektionen bevares; kun det valgte felt skiftes ud (og popper), resten dæmpes via forælderens klasse */ + '
' + (cat.options || []).map(function (o, i) { - return '
' + esc(o.name) + '
'; + var on = chosen && chosen.id === o.id; + return '
' + esc(o.name) + '
'; }).join('') + '
' + '
' + WPP.timerHtml(state, 'ring') + '
'; } + /* 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 += ''; + } + return '
' + + (fin ? '
FINALE
' + (mult ? '
' + mult + '
' : '') + : '
' + (r.questions_in_round ? 'Spørgsmål ' + (r.question_number || 1) + ' af ' + r.questions_in_round : 'Næste spørgsmål') + '
') + + '
Kategorien er
' + esc(it.category || '') + '
' + sparks + '
' + + '
'; + } + + /* 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 '
' + @@ -156,7 +188,7 @@ return questionBlock(state, 'small') + '
' + ans.map(function (a, i) { - return '
' + esc(a.text) + '
'; + return '
' + esc(a.text) + '
'; }).join('') + '
' + '

Hvad er sandheden? Gæt på din telefon!

' + '
' + WPP.timerHtml(state, 'ring') + '
'; @@ -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 = '
' + (s.is_truth ? 'Gættede rigtigt:' : 'Faldt for den:') + '' + - (g.length ? chips(g, players, 'pop') : (s.is_truth ? 'Ingen…' : '')) + - (s.audience_votes ? '+' + s.audience_votes + ' fra publikum' : '') + '
'; - } - if (pr.stage >= 2) { - var pts = (s.points || []).map(function (p) { - return '' + WPP.chip(players[p.player_id]) + '' + WPP.signed(p.delta) + ''; - }).join(''); - var likes = s.likes ? '' : ''; - if (s.is_truth) { - stamp = '
SANDHEDEN
' + - (pts ? '
' + pts + '
' : '
Ingen fandt sandheden!
') + - (state.reveal && state.reveal.note ? '
' + esc(state.reveal.note) + '
' : '') + '
'; - } else if (s.house) { - stamp = '
HUSETS LØGN
Huset narrede jer!
' + likes + '
'; - } else { - var auth = (s.authors || []).map(function (id) { return players[id] ? esc(players[id].name) : '?'; }); - stamp = '
LØGN
af ' + WPP.joinNames(auth) + '
' + - (pts ? '
' + pts + '
' : '') + likes + '
'; - } - } - out += '
' + - '
' + esc(s.text) + '
' + who + stamp + '
'; - } 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 += '

Point i dette spørgsmål

    ' + ps.map(function (p, i) { - var d = sum[p.id] || 0; - return '
  1. ' + avatar(p) + '' + esc(p.name) + '' + - '' + (d ? WPP.signed(d) : '–') + '' + WPP.fmt(p.score) + '
  2. '; - }).join('') + '
'; + 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 = '
' + (s.is_truth ? 'Gættede rigtigt:' : 'Faldt for den:') + '' + + (g.length ? chips(g, players, 'pop') : (s.is_truth ? 'Ingen…' : '')) + + (s.audience_votes ? '+' + s.audience_votes + ' fra publikum' : '') + '
'; } - out += '
' + steps.map(function (s, i) { - return ''; + if (pr.stage >= 2) { + var pts = (s.points || []).map(function (p) { + return '' + WPP.chip(players[p.player_id]) + '' + WPP.signed(p.delta) + ''; + }).join(''); + if (s.is_truth) { + stamp = '
SANDHEDEN
' + + (pts ? '
' + pts + '
' : '
Ingen fandt sandheden!
') + + (r.note ? '
' + esc(r.note) + '
' : '') + '
'; + } else if (s.house) { + stamp = '
HUSETS LØGN
Huset narrede jer!
'; + } else { + var auth = (s.authors || []).map(function (id) { + return players[id] ? esc(players[id].name) + ((s.assisted || []).indexOf(id) >= 0 ? ' (Lyv for mig)' : '') : '?'; + }); + stamp = '
LØGN
af ' + WPP.joinNames(auth) + '
' + + (pts ? '
' + pts + '
' : '') + '
'; + } + } + /* 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 += '
' + + '
' + esc(s.text) + '
' + + (likes ? '♥ ' + likes + '' : '') + '
' + who + stamp + '
'; + out += '
' + steps.map(function (st, i) { + return ''; }).join('') + '
'; 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 '
' + + '

Alle svarene

' + + '
' + tiles + '
' + + '

Point i spørgsmålet

' + + '
    ' + rows + '
'; + } + /* 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 ? 'SANDHEDEN' : a.house ? 'Husets løgn' : ''; + var by = (a.authors || []).map(function (id) { + var half = (a.assisted || []).indexOf(id) >= 0 ? '½ · Lyv for mig' : ''; + return '' + WPP.chip(players[id], 'sm') + half + ''; + }).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 '
' + tag + + '
' + esc(a.text) + '
' + (by ? '
' + by + '
' : '') + + '
' + info + '
' + (likes ? '♥ ' + likes + '' : '') + '
'; + } + 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 ? '' + : d > 0 ? '+0' + : '0'; + var tot = live && d ? '' + WPP.fmt(prev) + '' + : '' + WPP.fmt(live ? score : prev) + ''; + return '
  • ' + + avatar(p) + '' + esc(p.name) + '' + dHtml + tot + '
  • '; + } + 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 '

    Stillingen' + (r ? ' efter ' + esc(r.name || ('Runde ' + r.number)) : '') + '

    ' + '
      ' + ps.map(function (p, i) { var d = dmap[p.id] || 0; - return '
    1. ' + + return '
    2. ' + '' + (i + 1) + '' + avatar(p) + '' + esc(p.name) + '' + '' + (d ? '' + WPP.signed(d) + '' : '') + @@ -269,6 +352,214 @@ '
    '; } + /* ---------- 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 = '

    Øjeblik…

    '; } - sh.screen.className = 'screen hs hs-' + phase; - WPP.patch(sh.screen, topBar(state) + '
    ' + main + '
    ' + footer(state, foot)); + sh.screen.className = 'screen hs hs-' + phase + (pr && pr.done ? ' is-sum' : ''); + WPP.patch(sh.screen, topBar(state) + '
    ' + 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) { diff --git a/party/static/party/party.css b/party/static/party/party.css index d08fcbe..9d0551c 100644 --- a/party/static/party/party.css +++ b/party/static/party/party.css @@ -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; } +} diff --git a/party/static/party/play.js b/party/static/party/play.js index e9d4b5f..83d2e69 100644 --- a/party/static/party/play.js +++ b/party/static/party/play.js @@ -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 = '
    ' + WPP.roundTitle(state.round) + '
    '; if (me.is_chooser) { - return head + '

    Vælg en kategori!

    ' + - '
    ' + opts.map(function (o, i) { - return ''; + return head + '

    ' + (chosen ? 'Du valgte ' + esc(chosen.name) + ' ✓' : 'Vælg en kategori!') + '

    ' + + '
    ' + opts.map(function (o, i) { + var on = chosen && chosen.id === o.id; + return ''; }).join('') + '
    '; } - return head + big((chooser ? esc(chooser.name) : 'Nogen') + ' vælger kategori…', 'Hold dig klar til at lyve!', 'wait', 'pulse') + - '
    ' + opts.map(function (o, i) { - return '
    ' + esc(o.name) + '
    '; + var who = chooser ? esc(chooser.name) : 'Nogen'; + return head + (chosen ? big(who + ' valgte ' + esc(chosen.name) + '', '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')) + + '
    ' + opts.map(function (o, i) { + return '
    ' + esc(o.name) + '
    '; }).join('') + '
    '; } + /* 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 '' + Array.from(w).map(function (ch) { return '' + esc(ch) + ''; }).join('') + ''; + }).join(' '); + return '
    ' + (it.final ? 'FINALE' : WPP.roundTitle(state.round)) + '
    ' + + '

    Kategorien er

    ' + title + '

    ' + + '

    ' + (it.final ? 'Tredobbelt point – gør dig klar til at lyve!' : 'Gør dig klar til at lyve!') + '

    '; + } + 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 '

    ' + done + ' af ' + ps.length + ' er færdige

    '; } - 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 '

    Giv et hjerte til de bedste løgne

    ' + + return '
    ' + + '

    Giv et hjerte til de bedste løgne

    ' + ans.map(function (a) { var on = likes.indexOf(a.id) >= 0; return '
    ' + esc(a.text) + '
    '; - out += heartList(state, me, [me.guess], 'hearts'); - out += '

    Venter på de andre…

    '; + out += '

    Venter på de andre…
    Når løgnene afsløres, kan du give hjerter.

    '; return out; } out += '

    Hvad er sandheden?

    '; out += '
    ' + ans.map(function (a, i) { - return ''; + return ''; }).join('') + '
    '; 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 += '
    ' + + /* egne point først, når storskærmen er nået til opsummeringen */ + if (pr.done && me.role === 'player' && me.last_delta != null) { + out += '
    ' + (me.last_delta > 0 ? '' + WPP.signed(me.last_delta) + 'point til dig!' : '0point denne gang') + '
    '; } - 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) + '
    ' + body + '
    '); + /* ny main pr. fase: skærmen glider ind ved hvert faseskift */ + WPP.patch(sh.screen, topBar(state, me, players) + '
    ' + body + '
    '); + 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, '

    ' + esc(text || 'Forbinder…') + '

    '); } @@ -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 = '
    ' + (msg.image ? '' : '') + + '
    ' + esc(msg.text || 'Buh!') + '
    '; + 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) { diff --git a/party/static/party/sfx/boo.mp3 b/party/static/party/sfx/boo.mp3 new file mode 100644 index 0000000..648b827 Binary files /dev/null and b/party/static/party/sfx/boo.mp3 differ diff --git a/party/static/party/sfx/cat_danmark.mp3 b/party/static/party/sfx/cat_danmark.mp3 new file mode 100644 index 0000000..9955ced Binary files /dev/null and b/party/static/party/sfx/cat_danmark.mp3 differ diff --git a/party/static/party/sfx/cat_dyreriget.mp3 b/party/static/party/sfx/cat_dyreriget.mp3 new file mode 100644 index 0000000..26637da Binary files /dev/null and b/party/static/party/sfx/cat_dyreriget.mp3 differ diff --git a/party/static/party/sfx/cat_geografi.mp3 b/party/static/party/sfx/cat_geografi.mp3 new file mode 100644 index 0000000..f39ef52 Binary files /dev/null and b/party/static/party/sfx/cat_geografi.mp3 differ diff --git a/party/static/party/sfx/cat_historie.mp3 b/party/static/party/sfx/cat_historie.mp3 new file mode 100644 index 0000000..991b8b9 Binary files /dev/null and b/party/static/party/sfx/cat_historie.mp3 differ diff --git a/party/static/party/sfx/cat_kroppen.mp3 b/party/static/party/sfx/cat_kroppen.mp3 new file mode 100644 index 0000000..207af54 Binary files /dev/null and b/party/static/party/sfx/cat_kroppen.mp3 differ diff --git a/party/static/party/sfx/cat_mad_og_drikke.mp3 b/party/static/party/sfx/cat_mad_og_drikke.mp3 new file mode 100644 index 0000000..f19ffca Binary files /dev/null and b/party/static/party/sfx/cat_mad_og_drikke.mp3 differ diff --git a/party/static/party/sfx/cat_opfindelser.mp3 b/party/static/party/sfx/cat_opfindelser.mp3 new file mode 100644 index 0000000..4dcd907 Binary files /dev/null and b/party/static/party/sfx/cat_opfindelser.mp3 differ diff --git a/party/static/party/sfx/cat_rummet_og_naturen.mp3 b/party/static/party/sfx/cat_rummet_og_naturen.mp3 new file mode 100644 index 0000000..14de3c2 Binary files /dev/null and b/party/static/party/sfx/cat_rummet_og_naturen.mp3 differ diff --git a/party/static/party/sfx/cat_sport.mp3 b/party/static/party/sfx/cat_sport.mp3 new file mode 100644 index 0000000..0f615fb Binary files /dev/null and b/party/static/party/sfx/cat_sport.mp3 differ diff --git a/party/static/party/sfx/cat_sprog_og_ord.mp3 b/party/static/party/sfx/cat_sprog_og_ord.mp3 new file mode 100644 index 0000000..f84107d Binary files /dev/null and b/party/static/party/sfx/cat_sprog_og_ord.mp3 differ diff --git a/party/static/party/sfx/category.mp3 b/party/static/party/sfx/category.mp3 new file mode 100644 index 0000000..b1b6dc1 Binary files /dev/null and b/party/static/party/sfx/category.mp3 differ diff --git a/party/static/party/sfx/coins.mp3 b/party/static/party/sfx/coins.mp3 new file mode 100644 index 0000000..b189aba Binary files /dev/null and b/party/static/party/sfx/coins.mp3 differ diff --git a/party/static/party/sfx/drumroll.mp3 b/party/static/party/sfx/drumroll.mp3 new file mode 100644 index 0000000..47f5812 Binary files /dev/null and b/party/static/party/sfx/drumroll.mp3 differ diff --git a/party/static/party/sfx/explode.mp3 b/party/static/party/sfx/explode.mp3 new file mode 100644 index 0000000..a766145 Binary files /dev/null and b/party/static/party/sfx/explode.mp3 differ diff --git a/party/static/party/sfx/fanfare.mp3 b/party/static/party/sfx/fanfare.mp3 new file mode 100644 index 0000000..0ee1afa Binary files /dev/null and b/party/static/party/sfx/fanfare.mp3 differ diff --git a/party/static/party/sfx/house.mp3 b/party/static/party/sfx/house.mp3 new file mode 100644 index 0000000..f325d55 Binary files /dev/null and b/party/static/party/sfx/house.mp3 differ diff --git a/party/static/party/sfx/join.mp3 b/party/static/party/sfx/join.mp3 new file mode 100644 index 0000000..b6bb04f Binary files /dev/null and b/party/static/party/sfx/join.mp3 differ diff --git a/party/static/party/sfx/like.mp3 b/party/static/party/sfx/like.mp3 new file mode 100644 index 0000000..5fa60cb Binary files /dev/null and b/party/static/party/sfx/like.mp3 differ diff --git a/party/static/party/sfx/music_choose.mp3 b/party/static/party/sfx/music_choose.mp3 new file mode 100644 index 0000000..1e3bfaa Binary files /dev/null and b/party/static/party/sfx/music_choose.mp3 differ diff --git a/party/static/party/sfx/music_final.mp3 b/party/static/party/sfx/music_final.mp3 new file mode 100644 index 0000000..c3154a9 Binary files /dev/null and b/party/static/party/sfx/music_final.mp3 differ diff --git a/party/static/party/sfx/music_lobby.mp3 b/party/static/party/sfx/music_lobby.mp3 new file mode 100644 index 0000000..3d5ae78 Binary files /dev/null and b/party/static/party/sfx/music_lobby.mp3 differ diff --git a/party/static/party/sfx/music_reveal.mp3 b/party/static/party/sfx/music_reveal.mp3 new file mode 100644 index 0000000..f1ccd97 Binary files /dev/null and b/party/static/party/sfx/music_reveal.mp3 differ diff --git a/party/static/party/sfx/music_think.mp3 b/party/static/party/sfx/music_think.mp3 new file mode 100644 index 0000000..ecaf6e0 Binary files /dev/null and b/party/static/party/sfx/music_think.mp3 differ diff --git a/party/static/party/sfx/pop_in.mp3 b/party/static/party/sfx/pop_in.mp3 new file mode 100644 index 0000000..accc30d Binary files /dev/null and b/party/static/party/sfx/pop_in.mp3 differ diff --git a/party/static/party/sfx/slide.mp3 b/party/static/party/sfx/slide.mp3 new file mode 100644 index 0000000..3dda3c4 Binary files /dev/null and b/party/static/party/sfx/slide.mp3 differ diff --git a/party/static/party/sfx/stamp.mp3 b/party/static/party/sfx/stamp.mp3 new file mode 100644 index 0000000..ac4279d Binary files /dev/null and b/party/static/party/sfx/stamp.mp3 differ diff --git a/party/static/party/sfx/submit.mp3 b/party/static/party/sfx/submit.mp3 new file mode 100644 index 0000000..c7ba030 Binary files /dev/null and b/party/static/party/sfx/submit.mp3 differ diff --git a/party/static/party/sfx/swoosh_up.mp3 b/party/static/party/sfx/swoosh_up.mp3 new file mode 100644 index 0000000..297524e Binary files /dev/null and b/party/static/party/sfx/swoosh_up.mp3 differ diff --git a/party/static/party/sfx/tick.mp3 b/party/static/party/sfx/tick.mp3 new file mode 100644 index 0000000..8201f4c Binary files /dev/null and b/party/static/party/sfx/tick.mp3 differ diff --git a/party/static/party/sfx/timeup.mp3 b/party/static/party/sfx/timeup.mp3 new file mode 100644 index 0000000..0cc3bd4 Binary files /dev/null and b/party/static/party/sfx/timeup.mp3 differ diff --git a/party/static/party/sfx/truth.mp3 b/party/static/party/sfx/truth.mp3 new file mode 100644 index 0000000..29cdfdc Binary files /dev/null and b/party/static/party/sfx/truth.mp3 differ diff --git a/party/static/party/sfx/whoosh.mp3 b/party/static/party/sfx/whoosh.mp3 new file mode 100644 index 0000000..594ea0e Binary files /dev/null and b/party/static/party/sfx/whoosh.mp3 differ diff --git a/party/static/party/sfx/whoosh_long.mp3 b/party/static/party/sfx/whoosh_long.mp3 new file mode 100644 index 0000000..da810cd Binary files /dev/null and b/party/static/party/sfx/whoosh_long.mp3 differ diff --git a/party/static/party/tease/banan.webp b/party/static/party/tease/banan.webp new file mode 100644 index 0000000..28f15d7 Binary files /dev/null and b/party/static/party/tease/banan.webp differ diff --git a/party/static/party/tease/fisk.webp b/party/static/party/tease/fisk.webp new file mode 100644 index 0000000..73dc898 Binary files /dev/null and b/party/static/party/tease/fisk.webp differ diff --git a/party/static/party/tease/gummikylling.webp b/party/static/party/tease/gummikylling.webp new file mode 100644 index 0000000..5a82bb7 Binary files /dev/null and b/party/static/party/tease/gummikylling.webp differ diff --git a/party/static/party/tease/kartoffel.webp b/party/static/party/tease/kartoffel.webp new file mode 100644 index 0000000..ae799f0 Binary files /dev/null and b/party/static/party/tease/kartoffel.webp differ diff --git a/party/static/party/tease/kasse.webp b/party/static/party/tease/kasse.webp new file mode 100644 index 0000000..a89e53e Binary files /dev/null and b/party/static/party/tease/kasse.webp differ diff --git a/party/static/party/tease/kat.webp b/party/static/party/tease/kat.webp new file mode 100644 index 0000000..5d2542a Binary files /dev/null and b/party/static/party/tease/kat.webp differ diff --git a/party/static/party/tease/lama.webp b/party/static/party/tease/lama.webp new file mode 100644 index 0000000..e82247e Binary files /dev/null and b/party/static/party/tease/lama.webp differ diff --git a/party/static/party/tease/monster.webp b/party/static/party/tease/monster.webp new file mode 100644 index 0000000..77cf1b2 Binary files /dev/null and b/party/static/party/tease/monster.webp differ diff --git a/party/static/party/tease/mops.webp b/party/static/party/tease/mops.webp new file mode 100644 index 0000000..d9bafad Binary files /dev/null and b/party/static/party/tease/mops.webp differ diff --git a/party/static/party/tease/pingvin.webp b/party/static/party/tease/pingvin.webp new file mode 100644 index 0000000..284d8c5 Binary files /dev/null and b/party/static/party/tease/pingvin.webp differ diff --git a/party/static/party/tease/spoegelse.webp b/party/static/party/tease/spoegelse.webp new file mode 100644 index 0000000..9153ccd Binary files /dev/null and b/party/static/party/tease/spoegelse.webp differ diff --git a/party/static/party/tease/ugle.webp b/party/static/party/tease/ugle.webp new file mode 100644 index 0000000..ddbebea Binary files /dev/null and b/party/static/party/tease/ugle.webp differ diff --git a/party/templates/party/host.html b/party/templates/party/host.html index c00a0ca..1ddc759 100644 --- a/party/templates/party/host.html +++ b/party/templates/party/host.html @@ -6,6 +6,7 @@
    {% endblock %} {% block scripts %} +{{ assets|json_script:"wpp-assets" }} {% endblock %} diff --git a/party/templates/party/play.html b/party/templates/party/play.html index 549ea23..0a84b92 100644 --- a/party/templates/party/play.html +++ b/party/templates/party/play.html @@ -8,6 +8,7 @@ {% endblock %} {% block scripts %} +{{ assets|json_script:"wpp-assets" }} {% endblock %} diff --git a/party/tests/test_narration.py b/party/tests/test_narration.py index a0dca50..a66c894 100644 --- a/party/tests/test_narration.py +++ b/party/tests/test_narration.py @@ -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() diff --git a/party/tests/test_ws.py b/party/tests/test_ws.py index b74d571..a8d43d8 100644 --- a/party/tests/test_ws.py +++ b/party/tests/test_ws.py @@ -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 diff --git a/party/views.py b/party/views.py index e790d09..d7bb436 100644 --- a/party/views.py +++ b/party/views.py @@ -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