Skærmen følger speakeren, opsummering, musik, lydeffekter og drillerier
CI / test (push) Successful in 28s
Faseskift venter, til speakeren er færdig med den aktuelle skærm, og replikker til en skærm, der er væk, afspilles ikke. Ny kategori-intro med en lyd pr. kategori, musik pr. fase med bløde overgange og lydeffekter til alt, der flyver over skærmen. Efter afsløringen eksploderer løgne, ingen valgte, og pointene tæller op. Hjerter gives kun under afsløringen og ses live på storskærmen. Lyv for mig giver halve point. Narrede spilleres telefon kan blive drillet med et fjollet billede og et buh. Alle lyd- og billedfiler serveres med versionerede navne og caches af browseren. Claude-Session: https://claude.ai/code/session_01JmhJG6JnhXfzNiKv8f2vHu
@@ -31,7 +31,7 @@ Alle beskeder er JSON `{"action": "...", ...}`.
|
||||
| `submit_lie` | `text` (1-40 tegn) | fase `lie` |
|
||||
| `lie_for_me` | | fase `lie`; svar: `suggestion` |
|
||||
| `guess` | `answer_id` | fase `guess`; spillere og publikum, én gang |
|
||||
| `like` | `answer_id` | fase `guess` (efter eget gæt) og `reveal`; slår til/fra; ikke eget svar |
|
||||
| `like` | `answer_id` | kun fase `reveal`; slår til/fra; ikke eget svar og ikke sandheden (`answer_invalid`) |
|
||||
| `play_again` | | VIP i fasen `final` |
|
||||
| `ping` | | svar `pong` |
|
||||
|
||||
@@ -43,7 +43,8 @@ Alle beskeder er JSON `{"action": "...", ...}`.
|
||||
| --- | --- |
|
||||
| `state` | `state`: snapshot (se nedenfor) |
|
||||
| `joined` | `token`, `player_id`, `role` (`player`/`audience`) |
|
||||
| `suggestion` | `text` |
|
||||
| `suggestion` | `text` (svar på `lie_for_me`) |
|
||||
| `tease` | `text`, `image` (URL), `sound` (URL eller `null`) – kun til den spiller, der lige faldt for en løgn, i det øjeblik stemplet "LØGN" lander på storskærmen. Telefonen viser et fuldskærms-drilleri (~2,5 s, lukkes ved tryk), spiller `boo` og derefter `sound`, hvis lyden er låst op. Højst 2 pr. spørgsmål. |
|
||||
| `error` | `code`, `message` (dansk, kan vises direkte) |
|
||||
| `pong` | |
|
||||
|
||||
@@ -54,7 +55,7 @@ Fejlkoder: `unknown_room`, `unknown_token`, `name_taken`, `name_invalid`, `not_a
|
||||
```jsonc
|
||||
{
|
||||
"code": "KLMT",
|
||||
"phase": "lobby", // lobby | category | lie | guess | reveal | scores | final
|
||||
"phase": "lobby", // lobby | category | intro | lie | guess | reveal | scores | final
|
||||
"server_time": 1790000000000,// ms epoch; klienten udregner sin forskydning
|
||||
"deadline": 1790000060000, // ms epoch eller null
|
||||
"join_url": "https://party.weircon.dk/?code=KLMT",
|
||||
@@ -66,20 +67,34 @@ Fejlkoder: `unknown_room`, `unknown_token`, `name_taken`, `name_invalid`, `not_a
|
||||
"connected": true, "vip": true, "done": false} // done = har løjet/gættet i nuværende fase
|
||||
],
|
||||
"audience_count": 0,
|
||||
"category": {"chooser_id": "p2", "options": [{"id": 3, "name": "Dyreriget"}]}, // kun i fase category
|
||||
"question": {"category": "Dyreriget", "prompt": "En flok _____ kaldes en parlament."}, // lie/guess/reveal
|
||||
"answers": [{"id": "a1", "text": "ugler"}], // guess + reveal; spillerens egen løgn er udeladt i guess
|
||||
"category": {"chooser_id": "p2", "options": [{"id": 3, "name": "Dyreriget"}],
|
||||
"chosen_id": null}, // kun i fase category; id når der er valgt (ellers null)
|
||||
"intro": {"category": "Dyreriget", "slug": "dyreriget", "final": false}, // kun i fase intro
|
||||
"question": {"category": "Dyreriget", "prompt": "En flok _____ kaldes et parlament.",
|
||||
"slug": "dyreriget"}, // lie/guess/reveal
|
||||
"answers": [{"id": "a1", "text": "ugler"}], // guess + reveal (i reveal med "likes"); egen løgn udeladt i guess
|
||||
"reveal": { // kun i fase reveal
|
||||
"steps": [ // løgne først (kun dem der blev valgt), sandheden sidst
|
||||
{"answer_id": "a3", "text": "krager", "is_truth": false, "house": false,
|
||||
"authors": ["p2"], "guessed_by": ["p1", "p3"], "audience_votes": 2, "likes": 1,
|
||||
"points": [{"player_id": "p2", "delta": 1000}]},
|
||||
"authors": ["p2", "p5"], "assisted": ["p5"], // assisted = forfattere der brugte "Lyv for mig" uændret
|
||||
"guessed_by": ["p1", "p3"], "audience_votes": 2, "likes": 1,
|
||||
"points": [{"player_id": "p2", "delta": 1000}, {"player_id": "p5", "delta": 500}],
|
||||
"audio": {"url": "/media/tts/…mp3", "ms": 3100, "text": "…"}}, // speakerens replik til trinnet eller null
|
||||
{"answer_id": "a1", "text": "ugler", "is_truth": true, "house": false,
|
||||
"authors": [], "guessed_by": ["p4"], "audience_votes": 1, "likes": 0,
|
||||
"points": [{"player_id": "p4", "delta": 1000}]}
|
||||
"authors": [], "assisted": [], "guessed_by": ["p4"], "audience_votes": 1, "likes": 0,
|
||||
"points": [{"player_id": "p4", "delta": 1000}], "audio": null}
|
||||
],
|
||||
"step_ms": 4000, // værtsskærmen viser ét trin pr. step_ms
|
||||
"note": "Forklaring på faktummet" // vises under sandheden
|
||||
"all": [ // opsummeringen: ALLE svar, også dem ingen valgte
|
||||
{"id": "a7", "text": "skildpadder", "is_truth": false, "house": true,
|
||||
"authors": [], "assisted": [], "picked": false, "likes": 0} // picked=false → svaret "eksploderer"
|
||||
],
|
||||
"deltas": [{"player_id": "p1", "delta": 1500}], // point i dette spørgsmål pr. spiller (inkl. hjerter)
|
||||
"step_ms": 4000, // standardlængde pr. trin (bagudkompatibel)
|
||||
"steps_ms": [4200, 3900], // faktisk længde pr. trin (følger speakerens replik)
|
||||
"tail_ms": 8000, // opsummeringen efter sidste trin
|
||||
"started_at": 1790000001000, // ms epoch; null = serveren laver stadig lyd (vis "trommehvirvel")
|
||||
"note": "Forklaring på faktummet", // vises under sandheden
|
||||
"note_audio": {"url": "/media/tts/…mp3", "ms": 5200} // forklaringen læst op (starter med sandhedens trin) eller null
|
||||
},
|
||||
"deltas": [{"player_id": "p1", "delta": 1500}], // fase scores: point i den netop afsluttede runde
|
||||
"final": { // kun i fase final
|
||||
@@ -98,7 +113,34 @@ Fejlkoder: `unknown_room`, `unknown_token`, `name_taken`, `name_invalid`, `not_a
|
||||
}
|
||||
```
|
||||
|
||||
Værten får samme snapshot uden `me`, men med `"host": true`.
|
||||
Værten får samme snapshot uden `me`, men med `"host": true` og desuden:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"phase_key": "1.0.lie", // "<runde>.<spørgsmål>.<fase>" – identificerer den aktuelle skærm
|
||||
"narration": [ // speakerens seneste replikker (rullende vindue)
|
||||
{"id": 41, "url": "/media/tts/…mp3", "ms": 2300, "at": 1790000000000, "text": "…",
|
||||
"key": "1.0.intro"} // den skærm (phase_key) replikken hører til; er skærmen væk, springes den over
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Klienten afspiller nye replikker (`id` > sidst sete) i rækkefølge; ved første snapshot kun dem fra de sidste ~4 s.
|
||||
Afsløringens replikker ligger i `reveal.steps[].audio` / `reveal.note_audio` og startes af tidslinjen.
|
||||
|
||||
### Afsløringens tidslinje
|
||||
|
||||
Trin `i` starter ved `started_at + sum(steps_ms[0..i-1])`. Inden for et trin vises først svaret, så hvem der valgte det
|
||||
(~28 % / højst 1,1 s), så stemplet (~55 % / højst 2,3 s). Efter sidste trin kører opsummeringen i `tail_ms`:
|
||||
alle svar (`all`), de ikke-valgte eksploderer, og `deltas` tælles op. `deadline` = slutningen af halen.
|
||||
Mens `started_at` er `null`, venter serveren på speakerens lydfiler (højst ~10 s) og sætter så tiderne.
|
||||
|
||||
### Lyd- og billedfiler
|
||||
|
||||
Templates får `assets` = `{"sfx": {navn: url}, "tease": [url]}` indlejret med
|
||||
`{{ assets|json_script:"wpp-assets" }}`. URL'erne er versionerede (hashede filnavne i drift → caches for altid);
|
||||
klienten slår alle lyde op her og falder tilbage til `/static/party/sfx/<navn>.mp3`. Kategoriens jingle hedder
|
||||
`cat_<slug>` (fx `cat_mad_og_drikke`); mangler den, bruges `category`.
|
||||
|
||||
## Spillets gang (Fibbage-klassisk)
|
||||
|
||||
@@ -108,11 +150,14 @@ Værten får samme snapshot uden `me`, men med `"host": true`.
|
||||
| Runde 2 | 3 | 2000 / 1000 |
|
||||
| Finale | 1 | 3000 / 1500 |
|
||||
|
||||
For hvert spørgsmål: `category` (15 s; skiftende spiller vælger blandt 4; i finalen springes fasen over) → `lie` (60 s, slutter når alle har løjet) → `guess` (30 s, slutter når alle spillere har gættet) → `reveal` (automatisk: antal trin × `step_ms` + 3 s; værten kan `skip`). Efter sidste spørgsmål i en runde: `scores` (10 s). Efter finalen: `final`.
|
||||
For hvert spørgsmål: `category` (15 s; skiftende spiller vælger blandt 4; i finalen springes fasen over) → `intro` (≈5 s; storskærmen præsenterer kategorien med sin jingle, speakeren siger kategorien, og serveren venter til speakeren er færdig) → `lie` (60 s, speakeren læser spørgsmålet op; slutter når alle har løjet) → `guess` (30 s, slutter når alle spillere har gættet) → `reveal` (automatisk: `sum(steps_ms)` + `tail_ms`; værten kan `skip`). Efter sidste spørgsmål i en runde: `scores` (10 s). Efter finalen: `final`.
|
||||
|
||||
Rækkefølge: `category → intro → lie → guess → reveal (→ scores)`; i finalen `intro → lie → guess → reveal → final`.
|
||||
|
||||
- Løgn lig sandheden (eller næsten lig) afvises med `lie_is_truth`. Ens løgne slås sammen, og alle forfattere får point.
|
||||
- Spillere, der ikke løj i tide, får ingen løgn; huset fylder op med lokkesvar, så der er mindst `max(4, spillere+1)` svar.
|
||||
- Man kan ikke vælge sin egen løgn.
|
||||
- Like: +100 til forfatteren pr. like (uden multiplikator).
|
||||
- Hjerter (like) gives kun under `reveal`: +100 til forfatteren pr. hjerte (uden multiplikator); ikke til sandheden og ikke til eget svar.
|
||||
- "Lyv for mig": bruger man forslaget uændret, får man kun halve point for at narre andre (forfatteren står i `assisted`).
|
||||
- Publikum: forfatteren får `round(500 × multiplikator × andel af publikum narret)`.
|
||||
- Spil igen: samme spillere, point nulstilles.
|
||||
|
||||
@@ -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 "",
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
@@ -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":
|
||||
|
||||
@@ -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."))
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
if (!deadline) return null;
|
||||
return Math.max(0, Math.ceil((deadline - WPP.now()) / 1000));
|
||||
};
|
||||
var KNOWN = { category: 15000, lie: 60000, guess: 30000, scores: 10000 };
|
||||
var KNOWN = { category: 15000, intro: 5000, lie: 60000, guess: 30000, scores: 10000 };
|
||||
var seenTotals = {};
|
||||
WPP.totalFor = function (phase, deadline) {
|
||||
if (!deadline) return 0;
|
||||
@@ -285,69 +285,369 @@
|
||||
};
|
||||
};
|
||||
|
||||
/* ---------- lyd (WebAudio, syntetiseret) ---------- */
|
||||
|
||||
/* ---------- afsløringens tidslinje (bruges af både storskærm og telefon) ----------
|
||||
Serveren sender started_at og en længde pr. trin (speakerens replik styrer tempoet) + tail_ms
|
||||
til opsummeringen. started_at = null betyder, at serveren stadig laver lyd ("trommehvirvel").
|
||||
memo: et objekt, hvor starttiden huskes, hvis serveren ingen tider sender. */
|
||||
WPP.revealProgress = function (state, memo) {
|
||||
memo = memo || {};
|
||||
var seen = memo.revealSeen || (memo.revealSeen = {});
|
||||
var r = state.reveal || {};
|
||||
var steps = r.steps || [];
|
||||
var key = (state.round ? state.round.number + '.' + state.round.question_number : '') + ':' + ((state.question || {}).prompt || '');
|
||||
if (state.reveal && r.started_at === null) return { pending: true, done: false, i: 0, stage: -1, n: steps.length, key: key + '|pending' };
|
||||
var durs = steps.map(function (s, i) { return (r.steps_ms && r.steps_ms[i]) || r.step_ms || 4000; });
|
||||
var tailMs = r.tail_ms == null ? 3000 : r.tail_ms;
|
||||
var total = durs.reduce(function (a, b) { return a + b; }, 0) + tailMs;
|
||||
var start;
|
||||
if (r.started_at) start = r.started_at;
|
||||
else if (state.deadline) start = state.deadline - total;
|
||||
else {
|
||||
if (!seen[key]) seen[key] = WPP.now();
|
||||
start = seen[key];
|
||||
}
|
||||
var el = Math.max(0, WPP.now() - start), i = 0;
|
||||
while (i < steps.length && el >= durs[i]) { el -= durs[i]; i++; }
|
||||
if (i >= steps.length) return { done: true, i: steps.length, n: steps.length, tail: el, tailMs: tailMs, start: start, key: key + '|done' };
|
||||
/* svaret først, så hvem der valgte det, så stemplet (faste tider, så lange replikker ikke forsinker stemplet) */
|
||||
var stage = el < Math.min(1100, durs[i] * 0.28) ? 0 : el < Math.min(2300, durs[i] * 0.55) ? 1 : 2;
|
||||
return { done: false, i: i, stage: stage, n: steps.length, start: start, key: key + '|' + i + '.' + stage };
|
||||
};
|
||||
WPP.reducedMotion = function () {
|
||||
try { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; } catch (e) { return false; }
|
||||
};
|
||||
|
||||
/* ---------- versionerede filer ----------
|
||||
Templaten indlejrer {{ assets|json_script:"wpp-assets" }} = {sfx: {navn: url}, tease: [url]}.
|
||||
I drift er URL'erne hashede (caches for altid); dev-harness har ingen template og bruger sfx/<navn>.mp3. */
|
||||
var ASSETS = (function () {
|
||||
var a = null;
|
||||
try { var el = document.getElementById('wpp-assets'); if (el) a = JSON.parse(el.textContent); } catch (e) { a = null; }
|
||||
if (!a && window.WPP_ASSETS) a = window.WPP_ASSETS;
|
||||
a = a || {};
|
||||
return { sfx: a.sfx || null, tease: a.tease || [] };
|
||||
})();
|
||||
WPP.assets = ASSETS;
|
||||
|
||||
/* ---------- lyd: én fælles AudioContext ----------
|
||||
Browseren kræver et klik, før der må komme lyd. Første tryk/tast låser op,
|
||||
afkoder lydeffekterne og starter baggrundsmusikken. */
|
||||
var actx = null;
|
||||
var sound = (WPP.sound = {
|
||||
muted: WPP.ls.get('wpp:mute') === '1',
|
||||
onchange: null,
|
||||
ready: function () { return !!actx && actx.state === 'running'; },
|
||||
ctx: function () { return actx; },
|
||||
unlock: function () {
|
||||
if (actx) { if (actx.state === 'suspended') actx.resume(); return; }
|
||||
var AC = window.AudioContext || window.webkitAudioContext;
|
||||
if (!AC) return;
|
||||
try { actx = new AC(); } catch (e) { actx = null; }
|
||||
if (!actx) {
|
||||
if (!AC) return;
|
||||
try { actx = new AC(); } catch (e) { actx = null; return; }
|
||||
actx.onstatechange = onReady;
|
||||
}
|
||||
if (actx.state !== 'running') { var p = actx.resume(); if (p && p.then) p.then(onReady, function () {}); }
|
||||
if (!sound._primed) { /* iOS: en tavs lyd i selve klikket låser lyden op for resten af besøget */
|
||||
try {
|
||||
var s = actx.createBufferSource();
|
||||
s.buffer = actx.createBuffer(1, 1, 22050);
|
||||
s.connect(actx.destination); s.start(0);
|
||||
sound._primed = true;
|
||||
} catch (e) { /* */ }
|
||||
}
|
||||
onReady();
|
||||
},
|
||||
setMuted: function (m) { sound.muted = !!m; WPP.ls.set('wpp:mute', m ? '1' : '0'); },
|
||||
tone: function (freq, start, dur, type, vol) {
|
||||
/* cb(true) når lyden kører, cb(false) hvis browseren stadig blokerer efter ms */
|
||||
whenReady: function (cb, ms) {
|
||||
if (sound.ready()) { cb(true); return; }
|
||||
if (!actx) { cb(false); return; }
|
||||
var done = false;
|
||||
var fin = function (ok) { if (!done) { done = true; cb(ok); } };
|
||||
try { var p = actx.resume(); if (p && p.then) p.then(function () { fin(sound.ready()); }, function () { fin(false); }); } catch (e) { /* */ }
|
||||
setTimeout(function () { fin(sound.ready()); }, ms || 400);
|
||||
},
|
||||
setMuted: function (m) {
|
||||
sound.muted = !!m;
|
||||
WPP.ls.set('wpp:mute', m ? '1' : '0');
|
||||
music._apply();
|
||||
},
|
||||
/* syntetiseret tone – bruges kun, når en lydfil mangler */
|
||||
tone: function (freq, start, dur, type, vol, freq2) {
|
||||
if (!actx || sound.muted || actx.state !== 'running') return;
|
||||
var t = actx.currentTime + (start || 0);
|
||||
var o = actx.createOscillator(), g = actx.createGain();
|
||||
o.type = type || 'triangle';
|
||||
o.frequency.setValueAtTime(freq, t);
|
||||
if (freq2) o.frequency.exponentialRampToValueAtTime(freq2, t + dur);
|
||||
g.gain.setValueAtTime(0.0001, t);
|
||||
g.gain.exponentialRampToValueAtTime(vol || 0.18, t + 0.015);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
|
||||
o.connect(g); g.connect(actx.destination);
|
||||
o.start(t); o.stop(t + dur + 0.05);
|
||||
},
|
||||
play: function (kind) {
|
||||
if (kind === 'phase') { sound.tone(523, 0, 0.15); sound.tone(784, 0.09, 0.25); }
|
||||
else if (kind === 'tick') sound.tone(1200, 0, 0.06, 'square', 0.06);
|
||||
else if (kind === 'stamp') { sound.tone(110, 0, 0.35, 'sawtooth', 0.22); sound.tone(82, 0.02, 0.4, 'sine', 0.3); }
|
||||
else if (kind === 'truth') { [523, 659, 784, 1047].forEach(function (f, i) { sound.tone(f, i * 0.09, 0.35); }); }
|
||||
else if (kind === 'pop') sound.tone(880, 0, 0.08, 'sine', 0.12);
|
||||
play: function (kind) { WPP.sfx(kind); } /* gammelt navn */
|
||||
});
|
||||
var wasReady = false;
|
||||
function onReady() {
|
||||
if (!sound.ready()) return;
|
||||
Object.keys(raw).forEach(decode);
|
||||
music._kick();
|
||||
if (!wasReady) { wasReady = true; if (sound.onchange) sound.onchange(); }
|
||||
}
|
||||
|
||||
/* ---------- lydeffekter ---------- */
|
||||
var CATS = ['dyreriget', 'kroppen', 'mad_og_drikke', 'rummet_og_naturen', 'opfindelser', 'danmark', 'sprog_og_ord', 'sport', 'historie', 'geografi'];
|
||||
var SFX = ['whoosh', 'tick', 'timeup', 'submit', 'join', 'drumroll', 'stamp', 'truth', 'house', 'explode', 'coins', 'like', 'fanfare', 'category',
|
||||
'whoosh_long', 'swoosh_up', 'pop_in', 'slide', 'boo'].concat(CATS.map(function (c) { return 'cat_' + c; }));
|
||||
var MUSIC = ['music_lobby', 'music_choose', 'music_think', 'music_reveal', 'music_final'];
|
||||
var VOL = { tick: 0.45, whoosh: 0.55, submit: 0.6, join: 0.7, like: 0.6, coins: 0.55, drumroll: 0.8,
|
||||
whoosh_long: 0.6, swoosh_up: 0.5, pop_in: 0.38, slide: 0.55, boo: 0.9 };
|
||||
var SFX_BASE = (function () {
|
||||
var s = document.currentScript && document.currentScript.src;
|
||||
return (s ? s.replace(/[^/]*$/, '') : '/static/party/') + 'sfx/';
|
||||
})();
|
||||
/* findes filen? Med asset-kortet ved vi det sikkert; uden (dev) gætter vi ud fra de kendte navne */
|
||||
function known(name) {
|
||||
if (ASSETS.sfx) return Object.prototype.hasOwnProperty.call(ASSETS.sfx, name);
|
||||
return SFX.indexOf(name) >= 0 || MUSIC.indexOf(name) >= 0;
|
||||
}
|
||||
function urlOf(name) { return (ASSETS.sfx && ASSETS.sfx[name]) || SFX_BASE + name + '.mp3'; }
|
||||
|
||||
/* hver fil hentes én gang (raw) og afkodes én gang (bufs); nøglen er navnet eller "url:<url>" */
|
||||
var raw = {}, bufs = {}, failed = {}, loading = {}, waiters = {};
|
||||
function fetchRaw(key, url) {
|
||||
if (loading[key]) return loading[key];
|
||||
loading[key] = (window.fetch ? fetch(url) : Promise.reject(new Error('fetch')))
|
||||
.then(function (r) { if (!r.ok) throw new Error(r.status); return r.arrayBuffer(); })
|
||||
.then(function (ab) { raw[key] = ab; decode(key); })
|
||||
.catch(function () { failed[key] = true; flush(key, null); }); /* filen mangler: syntetisk lyd i stedet */
|
||||
return loading[key];
|
||||
}
|
||||
function load(name) {
|
||||
if (!known(name)) { failed[name] = true; return Promise.resolve(); }
|
||||
return fetchRaw(name, urlOf(name));
|
||||
}
|
||||
function decode(key) {
|
||||
var ab = raw[key];
|
||||
if (!ab || !actx || bufs[key]) return;
|
||||
raw[key] = null;
|
||||
var done = false;
|
||||
var ok = function (b) { if (done) return; done = true; bufs[key] = b; flush(key, b); if (music.name === key) music._kick(); };
|
||||
var bad = function () { if (done) return; done = true; failed[key] = true; flush(key, null); };
|
||||
try { var p = actx.decodeAudioData(ab, ok, bad); if (p && p.catch) p.catch(bad); } catch (e) { bad(); }
|
||||
}
|
||||
function flush(key, b) {
|
||||
var w = waiters[key];
|
||||
delete waiters[key];
|
||||
(w || []).forEach(function (f) { f(b); });
|
||||
}
|
||||
/* Promise<AudioBuffer|null> – kræver en kørende AudioContext for at afkode */
|
||||
function bufferFor(key, url) {
|
||||
if (bufs[key]) return Promise.resolve(bufs[key]);
|
||||
if (failed[key]) return Promise.resolve(null);
|
||||
return new Promise(function (res) {
|
||||
(waiters[key] || (waiters[key] = [])).push(res);
|
||||
if (url) fetchRaw(key, url); else load(key);
|
||||
decode(key);
|
||||
});
|
||||
}
|
||||
function playBuf(b, vol, opts) {
|
||||
var delay = opts.at != null ? Math.max(0, opts.at - actx.currentTime) : (opts.delay || 0);
|
||||
if (voice.active) vol *= 0.6; /* speakeren skal kunne høres */
|
||||
var src = actx.createBufferSource(), g = actx.createGain();
|
||||
src.buffer = b;
|
||||
if (opts.rate) src.playbackRate.value = opts.rate;
|
||||
g.gain.value = vol;
|
||||
src.connect(g); g.connect(actx.destination);
|
||||
src.start(actx.currentTime + delay);
|
||||
return delay + b.duration / (opts.rate || 1);
|
||||
}
|
||||
var FALLBACK = {
|
||||
whoosh: function (t) { t(523, 0, 0.15); t(784, 0.09, 0.25); },
|
||||
whoosh_long: function (t) { t(200, 0, 0.6, 'sawtooth', 0.05, 900); },
|
||||
swoosh_up: function (t) { t(300, 0, 0.35, 'sine', 0.1, 1400); },
|
||||
pop_in: function (t) { t(700, 0, 0.07, 'sine', 0.1, 1100); },
|
||||
slide: function (t) { t(900, 0, 0.5, 'triangle', 0.1, 220); },
|
||||
boo: function (t) { t(220, 0, 0.5, 'sawtooth', 0.12, 140); t(165, 0.35, 0.6, 'sawtooth', 0.12, 110); },
|
||||
tick: function (t) { t(1200, 0, 0.06, 'square', 0.06); },
|
||||
timeup: function (t) { t(330, 0, 0.25, 'square', 0.12); t(220, 0.18, 0.45, 'square', 0.12); },
|
||||
submit: function (t) { t(880, 0, 0.08, 'sine', 0.12); },
|
||||
join: function (t) { t(660, 0, 0.1, 'sine', 0.14); t(990, 0.08, 0.15, 'sine', 0.14); },
|
||||
drumroll: function (t) { for (var i = 0; i < 14; i++) t(140 + (i % 2) * 8, i * 0.07, 0.08, 'triangle', 0.1); },
|
||||
stamp: function (t) { t(110, 0, 0.35, 'sawtooth', 0.22); t(82, 0.02, 0.4, 'sine', 0.3); },
|
||||
house: function (t) { t(150, 0, 0.3, 'sawtooth', 0.2); t(98, 0.05, 0.4, 'sine', 0.28); },
|
||||
truth: function (t) { [523, 659, 784, 1047].forEach(function (f, i) { t(f, i * 0.09, 0.35); }); },
|
||||
explode: function (t) { t(70, 0, 0.35, 'sawtooth', 0.25); t(45, 0.03, 0.4, 'square', 0.15); },
|
||||
coins: function (t) { [1319, 1568, 1976].forEach(function (f, i) { t(f, i * 0.06, 0.12, 'sine', 0.1); }); },
|
||||
like: function (t) { t(988, 0, 0.1, 'sine', 0.12); t(1319, 0.07, 0.14, 'sine', 0.1); },
|
||||
fanfare: function (t) { [523, 659, 784, 1047, 784, 1047].forEach(function (f, i) { t(f, i * 0.12, 0.3); }); },
|
||||
category: function (t) { t(784, 0, 0.12, 'triangle', 0.15); t(1175, 0.1, 0.25, 'triangle', 0.15); },
|
||||
pop: function (t) { t(880, 0, 0.08, 'sine', 0.12); },
|
||||
phase: function (t) { t(523, 0, 0.15); t(784, 0.09, 0.25); }
|
||||
};
|
||||
var lastAt = {};
|
||||
/* WPP.sfx(navn, {volume, rate, delay, fallback, gap}) – afspil en effekt (kan overlappe sig selv).
|
||||
fallback: navn der bruges, hvis filen ikke findes (fx cat_<slug> → category).
|
||||
gap: mindst så mange ms mellem to af samme navn; for tætte kald droppes (mod lyd-spam).
|
||||
Returnerer sekunder til lyden er slut (0 hvis intet blev spillet). */
|
||||
WPP.sfx = function (name, opts) {
|
||||
opts = opts || {};
|
||||
if (sound.muted || !sound.ready()) return 0;
|
||||
if (!bufs[name] && opts.fallback) name = opts.fallback;
|
||||
var delay = opts.delay || 0;
|
||||
if (opts.gap) {
|
||||
var at = actx.currentTime + delay;
|
||||
if (at < (lastAt[name] || 0) - 0.005) return 0;
|
||||
lastAt[name] = at + opts.gap / 1000;
|
||||
}
|
||||
var b = bufs[name];
|
||||
if (!b) {
|
||||
var f = FALLBACK[name] || FALLBACK[opts.fallback];
|
||||
if (f) f(function (fr, st, d, ty, v, f2) { sound.tone(fr, (st || 0) + delay, d, ty, v, f2); });
|
||||
return f ? delay + 0.5 : 0;
|
||||
}
|
||||
var vol = opts.volume != null ? opts.volume : (VOL[name] || (/^cat_/.test(name) ? 0.85 : 0.8));
|
||||
return playBuf(b, vol, opts);
|
||||
};
|
||||
WPP.sfx.names = SFX;
|
||||
WPP.sfx.has = function (name) { return known(name) && !failed[name]; };
|
||||
/* Hent filerne. Uden liste: alt (kun værten); telefonerne henter kun det, de bruger (boo). */
|
||||
WPP.sfx.preload = function (list) {
|
||||
if (list) { list.forEach(load); return; }
|
||||
if (WPP.sfx._preloaded) return;
|
||||
WPP.sfx._preloaded = true;
|
||||
(ASSETS.sfx ? Object.keys(ASSETS.sfx) : SFX.concat(MUSIC)).forEach(load);
|
||||
};
|
||||
/* Afspil en vilkårlig lydfil (fx serverens TTS) gennem AudioContext; caches pr. URL.
|
||||
opts.at = AudioContext-tid, lyden tidligst må starte (afkodningen kan tage et øjeblik). */
|
||||
WPP.playUrl = function (url, opts) {
|
||||
opts = opts || {};
|
||||
if (!url || sound.muted || !sound.ready()) return Promise.resolve(0);
|
||||
return bufferFor('url:' + url, url).then(function (b) {
|
||||
if (!sound.ready()) return 0;
|
||||
if (b) return playBuf(b, opts.volume != null ? opts.volume : 1, opts);
|
||||
try { var a = new Audio(url); var p = a.play(); if (p && p.catch) p.catch(function () {}); } catch (e) { /* */ }
|
||||
return 0;
|
||||
});
|
||||
};
|
||||
|
||||
/* ---------- baggrundsmusik: sømløs loop, crossfade, dukker sig for speakeren ----------
|
||||
Samme nummer på tværs af faser (fx lie → guess) fortsætter uden at starte forfra. */
|
||||
var FADE = 0.8;
|
||||
var MUSIC_GAIN = { music_reveal: 0.45 }; /* afsløringen: effekterne er i fokus */
|
||||
var music = (WPP.music = {
|
||||
name: null, volume: 0.18, duckVolume: 0.05, ducked: false, cur: null, bus: null, _fade: FADE, _gain: 1,
|
||||
/* WPP.music.set('music_lobby') | WPP.music.set(null, {fade: 0.25}) – fade = udtoning af det gamle nummer (s) */
|
||||
set: function (name, opts) {
|
||||
opts = opts || {};
|
||||
name = name || null;
|
||||
if (name === music.name) return;
|
||||
music.name = name;
|
||||
music._fade = opts.fade != null ? opts.fade : FADE;
|
||||
music._gain = opts.gain != null ? opts.gain : (MUSIC_GAIN[name] || 1);
|
||||
if (name) load(name);
|
||||
music._kick();
|
||||
},
|
||||
_target: function () { return sound.muted ? 0 : music.ducked ? music.duckVolume : music.volume; },
|
||||
_apply: function () {
|
||||
if (!music.bus || !actx) return;
|
||||
var g = music.bus.gain, t = actx.currentTime;
|
||||
g.cancelScheduledValues(t);
|
||||
g.setValueAtTime(g.value, t);
|
||||
g.setTargetAtTime(music._target(), t, music.ducked ? 0.12 : 0.35);
|
||||
},
|
||||
/* dukker sig straks, men slipper først efter en kort pause (ingen pumpen mellem to replikker) */
|
||||
duck: function (on) {
|
||||
on = !!on;
|
||||
clearTimeout(music._duckT);
|
||||
if (on) { if (!music.ducked) { music.ducked = true; music._apply(); } return; }
|
||||
if (!music.ducked) return;
|
||||
music._duckT = setTimeout(function () { music.ducked = false; music._apply(); }, 700);
|
||||
},
|
||||
/* skift til music.name, hvis lyden er klar */
|
||||
_kick: function () {
|
||||
if (!sound.ready()) return;
|
||||
if (!music.bus) {
|
||||
music.bus = actx.createGain();
|
||||
music.bus.gain.value = music._target();
|
||||
music.bus.connect(actx.destination);
|
||||
}
|
||||
var cur = music.cur, name = music.name, t = actx.currentTime;
|
||||
if (cur && cur.name === name) return;
|
||||
if (name && !bufs[name]) { if (raw[name]) decode(name); if (!bufs[name]) { fadeOut(cur, t, music._fade); music.cur = null; return; } }
|
||||
fadeOut(cur, t, music._fade);
|
||||
music.cur = null;
|
||||
if (!name) return;
|
||||
var src = actx.createBufferSource(), g = actx.createGain();
|
||||
src.buffer = bufs[name]; src.loop = true;
|
||||
g.gain.setValueAtTime(0.0001, t);
|
||||
g.gain.exponentialRampToValueAtTime(music._gain || 1, t + FADE);
|
||||
src.connect(g); g.connect(music.bus);
|
||||
src.start(t);
|
||||
music.cur = { name: name, src: src, g: g };
|
||||
}
|
||||
});
|
||||
['pointerdown', 'keydown', 'touchstart'].forEach(function (ev) {
|
||||
window.addEventListener(ev, function () { sound.unlock(); voice.unblock(); }, { passive: true });
|
||||
function fadeOut(track, t, dur) {
|
||||
if (!track) return;
|
||||
dur = Math.max(0.05, dur == null ? FADE : dur);
|
||||
try {
|
||||
track.g.gain.cancelScheduledValues(t);
|
||||
track.g.gain.setValueAtTime(Math.max(0.0001, track.g.gain.value), t);
|
||||
track.g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
|
||||
track.src.stop(t + dur + 0.05);
|
||||
} catch (e) { /* allerede stoppet */ }
|
||||
}
|
||||
|
||||
['pointerdown', 'keydown', 'touchstart', 'touchend', 'click'].forEach(function (ev) {
|
||||
window.addEventListener(ev, function () { sound.unlock(); voice.unblock(); }, { passive: true, capture: true });
|
||||
});
|
||||
|
||||
/* ---------- speakeren (lydfiler fra serveren, én ad gangen) ---------- */
|
||||
/* ---------- speakeren (lydfiler fra serveren, én ad gangen) ----------
|
||||
Elementer er {url, key}. key = den skærm (phase_key), replikken hører til; en replik til en
|
||||
skærm, der er væk, springes over. Uden key (afsløringens trin) afspilles den altid. */
|
||||
function item(x) { return typeof x === 'string' ? { url: x } : x; }
|
||||
var voice = (WPP.voice = {
|
||||
queue: [], current: null, blocked: false, onchange: null,
|
||||
enqueue: function (url) { if (!url || sound.muted) return; voice.queue.push(url); if (!voice.current) voice._next(); },
|
||||
now: function (urls) { voice.stop(); (urls || []).forEach(function (u) { if (u && !sound.muted) voice.queue.push(u); }); voice._next(); },
|
||||
queue: [], current: null, blocked: false, active: false, phaseKey: null, onchange: null,
|
||||
enqueue: function (x) {
|
||||
x = item(x);
|
||||
if (!x || !x.url || sound.muted) return;
|
||||
voice.queue.push(x);
|
||||
if (!voice.current) voice._next();
|
||||
},
|
||||
now: function (list) {
|
||||
voice.stop();
|
||||
(list || []).forEach(function (x) { x = item(x); if (x && x.url && !sound.muted) voice.queue.push(x); });
|
||||
voice._next();
|
||||
},
|
||||
stop: function () {
|
||||
voice.queue = [];
|
||||
if (voice.current) { try { voice.current.pause(); } catch (e) {} voice.current = null; }
|
||||
voice._active(false);
|
||||
},
|
||||
stale: function (x) { return !!(x.key && voice.phaseKey && x.key !== voice.phaseKey); },
|
||||
unblock: function () {
|
||||
if (!voice.blocked) return;
|
||||
voice.blocked = false;
|
||||
if (voice.onchange) voice.onchange();
|
||||
if (!voice.current) voice._next();
|
||||
},
|
||||
_active: function (on) { if (voice.active !== !!on) { voice.active = !!on; music.duck(on); } },
|
||||
_next: function () {
|
||||
var url = voice.queue.shift();
|
||||
if (!url) { voice.current = null; return; }
|
||||
var a = new Audio(url);
|
||||
var x = voice.queue.shift();
|
||||
while (x && voice.stale(x)) x = voice.queue.shift(); /* forældet replik: spring over */
|
||||
if (!x) { voice.current = null; voice._active(false); return; }
|
||||
var a = new Audio(x.url);
|
||||
voice.current = a;
|
||||
voice._active(true);
|
||||
a.onended = a.onerror = function () { if (voice.current === a) voice._next(); };
|
||||
var p = a.play();
|
||||
if (p && p.catch) p.catch(function (e) {
|
||||
if (voice.current !== a) return;
|
||||
voice.current = null;
|
||||
if (e && e.name === 'NotAllowedError') { // browseren kræver et klik før lyd
|
||||
voice.queue.unshift(url);
|
||||
voice.queue.unshift(x);
|
||||
voice.blocked = true;
|
||||
voice._active(false);
|
||||
if (voice.onchange) voice.onchange();
|
||||
} else voice._next();
|
||||
});
|
||||
|
||||
@@ -3,7 +3,18 @@
|
||||
'use strict';
|
||||
var COLORS = ['#ff4d6d', '#3a86ff', '#ffbe0b', '#06d6a0', '#fb5607', '#8338ec', '#ff006e', '#00b4d8'];
|
||||
var NAMES = ['Asger', 'Sus', 'Bo', 'Mette', 'Kasper', 'Line'];
|
||||
var PHASES = ['join', 'lobby', 'category', 'lie', 'guess', 'reveal', 'scores', 'final'];
|
||||
var PHASES = ['join', 'lobby', 'category', 'category-chosen', 'intro', 'intro-final', 'lie', 'guess', 'reveal-wait', 'reveal', 'tease', 'summary', 'scores', 'final'];
|
||||
/* fixture-navn → spillets fase ("tease" = afsløringen, hvor telefon 1 bliver drillet) */
|
||||
var REAL = { join: 'lobby', 'category-chosen': 'category', 'intro-final': 'intro', 'reveal-wait': 'reveal', summary: 'reveal', tease: 'reveal' };
|
||||
/* drilleri-billede til harnessen (i drift: et tilfældigt billede fra static/party/tease/) */
|
||||
var TEASE_IMG = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200"><rect width="200" height="200" fill="#ffd23f"/>' +
|
||||
'<circle cx="100" cy="105" r="72" fill="#ff8c42" stroke="#1d0846" stroke-width="6"/>' +
|
||||
'<ellipse cx="72" cy="88" rx="13" ry="17" fill="#fff" stroke="#1d0846" stroke-width="5"/><circle cx="76" cy="92" r="6" fill="#1d0846"/>' +
|
||||
'<ellipse cx="128" cy="88" rx="13" ry="17" fill="#fff" stroke="#1d0846" stroke-width="5"/><circle cx="124" cy="92" r="6" fill="#1d0846"/>' +
|
||||
'<path d="M62 128 Q100 160 138 128" fill="#1d0846"/><path d="M88 138 Q100 175 116 138 Z" fill="#ff3d8b" stroke="#1d0846" stroke-width="4"/>' +
|
||||
'<path d="M52 62 L84 72 M148 62 L116 72" stroke="#1d0846" stroke-width="7" stroke-linecap="round"/></svg>');
|
||||
var TEASE = { type: 'tease', text: 'Hov hov, der røg du i!', image: TEASE_IMG, sound: null };
|
||||
|
||||
function players(n, extra) {
|
||||
var out = [];
|
||||
@@ -29,11 +40,54 @@
|
||||
return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(s + '</svg>');
|
||||
}
|
||||
|
||||
/* Afsløring: 4 valgte svar (2 løgne, 1 husets, sandheden) + 3 ingen valgte (eksploderer i opsummeringen).
|
||||
opts.t = ms siden afsløringen startede (reveal) eller ms inde i opsummeringen (summary). */
|
||||
function revealFixture(now, phase, opts) {
|
||||
var stepsMs = [4000, 4000, 4000, 5000], tail = 8000;
|
||||
var sumSteps = stepsMs.reduce(function (a, b) { return a + b; }, 0);
|
||||
var likes = { a2: 1, a3: 2, a6: 2 };
|
||||
var steps = [
|
||||
{ answer_id: 'a2', text: 'krager', is_truth: false, house: false, authors: ['p1'], assisted: [], guessed_by: ['p3', 'p4'], audience_votes: 2, points: [{ player_id: 'p1', delta: 1000 }] },
|
||||
{ answer_id: 'a3', text: 'papegøjer', is_truth: false, house: false, authors: ['p2', 'p5'], assisted: ['p5'], guessed_by: ['p6'], audience_votes: 0, points: [{ player_id: 'p2', delta: 500 }, { player_id: 'p5', delta: 250 }] },
|
||||
{ answer_id: 'a6', text: 'måger <b>&</b> co', is_truth: false, house: true, authors: [], assisted: [], guessed_by: ['p5'], audience_votes: 1, points: [] },
|
||||
{ answer_id: 'a1', text: 'ugler', is_truth: true, house: false, authors: [], assisted: [], guessed_by: ['p1', 'p2'], audience_votes: 0, points: [{ player_id: 'p1', delta: 1000 }, { player_id: 'p2', delta: 1000 }] }
|
||||
];
|
||||
var ans = [
|
||||
{ id: 'a1', text: 'ugler', is_truth: true, house: false, authors: [] },
|
||||
{ id: 'a2', text: 'krager', is_truth: false, house: false, authors: ['p1'] },
|
||||
{ id: 'a3', text: 'papegøjer', is_truth: false, house: false, authors: ['p2', 'p5'], assisted: ['p5'] },
|
||||
{ id: 'a4', text: 'flamingoer i smoking', is_truth: false, house: false, authors: ['p4'], assisted: ['p4'] },
|
||||
{ id: 'a5', text: 'pingviner', is_truth: false, house: false, authors: ['p3'] },
|
||||
{ id: 'a6', text: 'måger <b>&</b> co', is_truth: false, house: true, authors: [] },
|
||||
{ id: 'a7', text: 'skildpadder', is_truth: false, house: true, authors: [] }
|
||||
];
|
||||
var picked = { a1: 1, a2: 1, a3: 1, a6: 1 };
|
||||
var t = opts.t != null ? Number(opts.t) : phase === 'summary' ? 300 : 6600;
|
||||
if (phase === 'summary') t += sumSteps;
|
||||
steps.forEach(function (st) { st.likes = likes[st.answer_id] || 0; });
|
||||
return {
|
||||
deadline: phase === 'reveal-wait' ? now + 9000 : now - t + sumSteps + tail,
|
||||
question: { category: 'Dyreriget', prompt: 'En flok _____ kaldes et parlament.' },
|
||||
answers: ans.map(function (a) { return { id: a.id, text: a.text, likes: likes[a.id] || 0 }; }),
|
||||
players: players(6, [{ score: 3100 }, { score: 2200 }, { score: 0 }, { score: 500 }, { score: 750 }, { score: 0 }]),
|
||||
reveal: {
|
||||
steps: steps, step_ms: 4000, steps_ms: stepsMs, tail_ms: tail,
|
||||
started_at: phase === 'reveal-wait' ? null : now - t,
|
||||
note: 'Ugler kaldes et parlament – efter C.S. Lewis og gamle engelske jagtbøger.',
|
||||
all: ans.map(function (a) {
|
||||
return { id: a.id, text: a.text, is_truth: a.is_truth, house: a.house, authors: a.authors, assisted: a.assisted || [], picked: !!picked[a.id], likes: likes[a.id] || 0 };
|
||||
}),
|
||||
deltas: [{ player_id: 'p1', delta: 2100 }, { player_id: 'p2', delta: 1700 }, { player_id: 'p3', delta: 0 },
|
||||
{ player_id: 'p4', delta: 0 }, { player_id: 'p5', delta: 450 }, { player_id: 'p6', delta: 0 }]
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function build(phase, opts) {
|
||||
opts = opts || {};
|
||||
var now = Date.now();
|
||||
var base = {
|
||||
code: 'KLMT', phase: phase === 'join' ? 'lobby' : phase, server_time: now, deadline: null,
|
||||
code: 'KLMT', phase: REAL[phase] || phase, server_time: now, deadline: null, phase_key: '0.1.' + (REAL[phase] || phase), narration: [],
|
||||
join_url: 'https://party.weircon.dk/?code=KLMT', min_players: 2, max_players: 8,
|
||||
round: { number: 1, name: 'Runde 1', multiplier: 1, question_number: 2, questions_in_round: 3 },
|
||||
players: players(6), audience_count: 3
|
||||
@@ -66,15 +120,33 @@
|
||||
];
|
||||
break;
|
||||
case 'category':
|
||||
case 'category-chosen':
|
||||
host = Object.assign(base, {
|
||||
deadline: now + 11000,
|
||||
round: { number: 2, name: 'Runde 2', multiplier: 2, question_number: 1, questions_in_round: 3 },
|
||||
players: players(6, [{ score: 3500 }, { score: 1500 }, { score: 2000 }, { score: 500 }, { score: 0 }, { score: 1000 }]),
|
||||
category: { chooser_id: 'p1', options: [{ id: 3, name: 'Dyreriget' }, { id: 7, name: 'Mærkelige love' }, { id: 9, name: 'Danmarkshistorie' }, { id: 12, name: 'Mad & drikke' }] }
|
||||
category: { chooser_id: 'p1', chosen_id: phase === 'category-chosen' ? 7 : null,
|
||||
options: [{ id: 3, name: 'Dyreriget' }, { id: 7, name: 'Mærkelige love' }, { id: 9, name: 'Danmarkshistorie' }, { id: 12, name: 'Mad & drikke' }] }
|
||||
});
|
||||
phones = [
|
||||
{ label: 'Spiller', state: Object.assign(clone(host), { me: me({}) }) },
|
||||
{ label: 'VIP = vælger', state: Object.assign(clone(host), { me: me({ id: 'p1', vip: true, is_chooser: true }) }) },
|
||||
{ label: phase === 'category-chosen' ? 'VIP = vælger (har valgt)' : 'VIP = vælger', state: Object.assign(clone(host), { me: me({ id: 'p1', vip: true, is_chooser: true }) }) },
|
||||
{ label: 'Publikum', state: Object.assign(clone(host), { me: aud }) }
|
||||
];
|
||||
break;
|
||||
case 'intro':
|
||||
case 'intro-final':
|
||||
var fin = phase === 'intro-final';
|
||||
host = Object.assign(base, {
|
||||
deadline: now + 5000,
|
||||
round: fin ? { number: 3, name: 'Finale', multiplier: 3, question_number: 1, questions_in_round: 1 }
|
||||
: { number: 1, name: 'Runde 1', multiplier: 1, question_number: 2, questions_in_round: 3 },
|
||||
players: players(6, [{ score: 3500 }, { score: 1500 }, { score: 2000 }, { score: 500 }, { score: 0 }, { score: 1000 }]),
|
||||
intro: fin ? { category: 'Rummet og naturen', slug: 'rummet_og_naturen', final: true } : { category: 'Dyreriget', slug: 'dyreriget', final: false }
|
||||
});
|
||||
phones = [
|
||||
{ label: 'Spiller', state: Object.assign(clone(host), { me: me({}) }) },
|
||||
{ label: 'VIP', state: Object.assign(clone(host), { me: me({ id: 'p1', vip: true }) }) },
|
||||
{ label: 'Publikum', state: Object.assign(clone(host), { me: aud }) }
|
||||
];
|
||||
break;
|
||||
@@ -98,28 +170,21 @@
|
||||
var vAns = answers.filter(function (a) { return a.id !== 'a2'; });
|
||||
phones = [
|
||||
{ label: 'Spiller (gætter)', state: Object.assign(clone(host), { answers: pAns, me: me({ lie: 'pingviner' }) }) },
|
||||
{ label: 'VIP (har gættet)', state: Object.assign(clone(host), { answers: vAns, me: me({ id: 'p1', vip: true, lie: 'krager', guess: 'a1', likes: ['a3'] }) }) },
|
||||
{ label: 'VIP (har gættet, ingen hjerter)', state: Object.assign(clone(host), { answers: vAns, me: me({ id: 'p1', vip: true, lie: 'krager', own_answer_id: 'a2', guess: 'a1' }) }) },
|
||||
{ label: 'Publikum', state: Object.assign(clone(host), { me: aud }) }
|
||||
];
|
||||
break;
|
||||
case 'reveal-wait':
|
||||
case 'reveal':
|
||||
var stepMs = 4000;
|
||||
var steps = [
|
||||
{ answer_id: 'a2', text: 'krager', is_truth: false, house: false, authors: ['p1'], guessed_by: ['p3', 'p4'], audience_votes: 2, likes: 1, points: [{ player_id: 'p1', delta: 1000 }] },
|
||||
{ answer_id: 'a3', text: 'papegøjer', is_truth: false, house: false, authors: ['p2', 'p5'], guessed_by: ['p6'], audience_votes: 0, likes: 0, points: [{ player_id: 'p2', delta: 500 }, { player_id: 'p5', delta: 500 }] },
|
||||
{ answer_id: 'a6', text: 'måger <b>&</b> co', is_truth: false, house: true, authors: [], guessed_by: ['p5'], audience_votes: 1, likes: 2, points: [] },
|
||||
{ answer_id: 'a1', text: 'ugler', is_truth: true, house: false, authors: [], guessed_by: ['p1', 'p2'], audience_votes: 0, likes: 0, points: [{ player_id: 'p1', delta: 1000 }, { player_id: 'p2', delta: 1000 }] }
|
||||
];
|
||||
var total = steps.length * stepMs + 3000;
|
||||
var t = opts.t != null ? Number(opts.t) : stepMs * 0 + 2600;
|
||||
host = Object.assign(base, {
|
||||
deadline: now - t + total, question: question, answers: answers,
|
||||
players: players(6, [{ score: 2000 }, { score: 1500 }, { score: 0 }, { score: 0 }, { score: 500 }, { score: 0 }]),
|
||||
reveal: { steps: steps, step_ms: stepMs }
|
||||
});
|
||||
case 'tease':
|
||||
case 'summary':
|
||||
var rv = revealFixture(now, phase, opts);
|
||||
host = Object.assign(base, rv);
|
||||
phones = [
|
||||
{ label: 'Spiller (0 point)', state: Object.assign(clone(host), { me: me({ lie: 'pingviner', guess: 'a2', last_delta: 0 }) }) },
|
||||
{ label: 'VIP (+2.000)', state: Object.assign(clone(host), { me: me({ id: 'p1', vip: true, lie: 'krager', guess: 'a1', likes: ['a3'], last_delta: 2000 }) }) },
|
||||
{ label: phase === 'tease' ? 'Spiller (faldt for "krager": drillet)' : 'Spiller (løj "pingviner")',
|
||||
tease: phase === 'tease' ? TEASE : null,
|
||||
state: Object.assign(clone(host), { me: me({ lie: 'pingviner', own_answer_id: 'a5', guess: 'a2', last_delta: 0 }) }) },
|
||||
{ label: 'VIP (+2.100)', state: Object.assign(clone(host), { me: me({ id: 'p1', vip: true, lie: 'krager', own_answer_id: 'a2', guess: 'a1', likes: ['a3'], last_delta: 2100 }) }) },
|
||||
{ label: 'Publikum', state: Object.assign(clone(host), { me: aud }) }
|
||||
];
|
||||
break;
|
||||
@@ -162,5 +227,5 @@
|
||||
return { host: host, phones: phones };
|
||||
}
|
||||
|
||||
window.WPPFixtures = { build: build, phases: PHASES, qr: fakeQr() };
|
||||
window.WPPFixtures = { build: build, phases: PHASES, qr: fakeQr(), tease: TEASE };
|
||||
})();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
/* Fup og Fakta – storskærm. Eksporterer window.WPPHost = {renderHost, mount, revealProgress}. */
|
||||
/* Fup og Fakta – storskærm. Eksporterer window.WPPHost = {renderHost, mount, revealProgress, sumPlan}. */
|
||||
(function () {
|
||||
'use strict';
|
||||
var WPP = window.WPP, esc = WPP.esc;
|
||||
var TIMED = { category: 1, lie: 1, guess: 1, reveal: 1, scores: 1 };
|
||||
var TIMED = { category: 1, intro: 1, lie: 1, guess: 1, reveal: 1, scores: 1 };
|
||||
|
||||
function ui(root) {
|
||||
return root._ui || (root._ui = { lastPhase: null, revealKey: null, revealSeen: {}, lastTick: null, state: null, ctx: null });
|
||||
return root._ui || (root._ui = { lastPhase: null, revealKey: null, revealSeen: {}, lastTick: null, state: null, ctx: null, sfxIds: {}, sfxKey: null });
|
||||
}
|
||||
|
||||
/* ---------- lyd ---------- */
|
||||
/* dev-harness er tavs, medmindre ctx.sound er sat (dev.html?sound=1) */
|
||||
function audible(ctx) { return !(ctx && ctx.harness && !ctx.sound); }
|
||||
function sfx(u, name, opts) { if (audible(u.ctx)) WPP.sfx(name, opts); }
|
||||
/* musik pr. fase; samme nummer i to faser i træk (lie → guess) spiller videre uden at starte forfra.
|
||||
intro har ingen musik: kategoriens jingle står alene. final: fanfare, så music_final. */
|
||||
var MUSIC = { lobby: 'music_lobby', category: 'music_choose', lie: 'music_think', guess: 'music_think', reveal: 'music_reveal', scores: 'music_lobby' };
|
||||
var STING_FADE = 0.25; /* kort udtoning før en jingle/fanfare */
|
||||
|
||||
/* ---------- hjælpere ---------- */
|
||||
function joinHost(state) {
|
||||
var u = state.join_url || '';
|
||||
@@ -24,28 +33,21 @@
|
||||
return (state.players || []).slice().sort(function (a, b) { return (b.score || 0) - (a.score || 0); });
|
||||
}
|
||||
|
||||
/* Reveal-tidslinje: serveren sender started_at og en længde pr. trin (speakerens replik styrer tempoet).
|
||||
started_at = null betyder, at serveren stadig laver lyd: vis en kort "trommehvirvel". */
|
||||
function revealProgress(state, u) {
|
||||
var r = state.reveal || {};
|
||||
var steps = r.steps || [];
|
||||
var key = (state.round ? state.round.number + '.' + state.round.question_number : '') + ':' + ((state.question || {}).prompt || '');
|
||||
if (state.reveal && r.started_at === null) return { pending: true, done: false, i: 0, stage: -1, n: steps.length, key: key + '|pending' };
|
||||
var durs = steps.map(function (s, i) { return (r.steps_ms && r.steps_ms[i]) || r.step_ms || 4000; });
|
||||
var total = durs.reduce(function (a, b) { return a + b; }, 0) + (r.tail_ms == null ? 3000 : r.tail_ms);
|
||||
var start;
|
||||
if (r.started_at) start = r.started_at;
|
||||
else if (state.deadline) start = state.deadline - total;
|
||||
else {
|
||||
if (!u.revealSeen[key]) u.revealSeen[key] = WPP.now();
|
||||
start = u.revealSeen[key];
|
||||
}
|
||||
var el = Math.max(0, WPP.now() - start), i = 0;
|
||||
while (i < steps.length && el >= durs[i]) { el -= durs[i]; i++; }
|
||||
if (i >= steps.length) return { done: true, i: steps.length, n: steps.length, key: key + '|done' };
|
||||
/* svaret først, så hvem der valgte det, så stemplet (faste tider, så lange replikker ikke forsinker stemplet) */
|
||||
var stage = el < Math.min(1100, durs[i] * 0.28) ? 0 : el < Math.min(2300, durs[i] * 0.55) ? 1 : 2;
|
||||
return { done: false, i: i, stage: stage, n: steps.length, key: key + '|' + i + '.' + stage };
|
||||
function revealProgress(state, u) { return WPP.revealProgress(state, u); }
|
||||
|
||||
/* Opsummeringen: hvornår hvert ikke-valgt svar eksploderer, og hvornår pointene tælles op (ms inde i halen). */
|
||||
var BOOM0 = 1500, BOOM_GAP = 250, BOOM_MS = 900, BURST_MS = 520;
|
||||
function sumPlan(state) {
|
||||
var all = (state.reveal && state.reveal.all) || [];
|
||||
var at = {}, k = 0;
|
||||
all.forEach(function (a) { if (!a.picked && !a.is_truth) at[a.id] = BOOM0 + (k++) * BOOM_GAP; });
|
||||
return { at: at, n: k, pointsAt: k ? BOOM0 + (k - 1) * BOOM_GAP + BOOM_MS + 100 : 1200 };
|
||||
}
|
||||
/* nøglen der styrer gen-tegning under afsløringen (opsummeringen har to trin: svar → point) */
|
||||
function viewKey(state, u) {
|
||||
var pr = revealProgress(state, u);
|
||||
if (pr.done) return pr.key + (pr.tail >= sumPlan(state).pointsAt ? '.p' : '');
|
||||
return pr.key;
|
||||
}
|
||||
|
||||
/* ---------- faste dele ---------- */
|
||||
@@ -67,23 +69,23 @@
|
||||
'</header>';
|
||||
}
|
||||
|
||||
function footer(state, opts) {
|
||||
function footer(state, opts, ctx) {
|
||||
opts = opts || {};
|
||||
var ps = state.players || [];
|
||||
var chipsHtml = '';
|
||||
if (opts.doneChips) {
|
||||
chipsHtml = ps.map(function (p) {
|
||||
return '<span class="pchip' + (p.done ? ' done' : '') + (p.connected === false ? ' off' : '') + '" style="--pc:' + esc(p.color || '#888') + '">' +
|
||||
'<span class="dot"></span>' + esc(p.name) + (p.done ? '<i class="ck" aria-label="klar"></i>' : '') + '</span>';
|
||||
}).join('');
|
||||
}
|
||||
/* hver chip har sin egen nøgle, så kun den, der lige blev klar, hopper */
|
||||
var chipsHtml = !opts.doneChips ? '' : ps.map(function (p) {
|
||||
return '<span class="pchip' + (p.done ? ' done' : '') + (p.connected === false ? ' off' : '') + '" data-k="pc:' + esc(p.id) + ':' + (p.done ? 1 : 0) + (p.connected === false ? 'x' : '') +
|
||||
'" style="--pc:' + esc(p.color || '#888') + '"><span class="dot"></span>' + esc(p.name) + (p.done ? '<i class="ck" aria-label="klar"></i>' : '') + '</span>';
|
||||
}).join('');
|
||||
var skip = TIMED[state.phase] ? '<button class="hbtn" data-act="skip" title="Afslut fasen nu">Spring over »</button>' : '';
|
||||
var mute = (WPP.voice.blocked ? '<button class="hbtn voice-blocked" data-act="unblock">Klik for at høre speakeren</button>' : '') +
|
||||
/* lyden kræver ét klik på siden; indtil da en tydelig knap */
|
||||
var needClick = !WPP.sound.muted && !(ctx && ctx.harness) && (WPP.voice.blocked || !WPP.sound.ready());
|
||||
var mute = (needClick ? '<button class="hbtn voice-blocked" data-act="unblock">' + (WPP.voice.blocked ? 'Klik for at høre speakeren' : 'Klik for lyd') + '</button>' : '') +
|
||||
'<button class="hbtn ghost" data-act="mute" aria-pressed="' + (WPP.sound.muted ? 'true' : 'false') + '">' +
|
||||
(WPP.sound.muted ? 'Lyd fra' : 'Lyd til') + '</button>';
|
||||
return '<footer class="hs-foot" data-k="foot" data-patch>' +
|
||||
'<div class="pchips" data-k="chips:' + esc(chipsHtml) + '">' + chipsHtml + '</div>' +
|
||||
'<div class="ctl" data-k="ctl:' + state.phase + (WPP.sound.muted ? 1 : 0) + (WPP.voice.blocked ? 'b' : '') + '">' + mute + skip + '</div></footer>';
|
||||
'<div class="pchips" data-k="chips" data-patch>' + chipsHtml + '</div>' +
|
||||
'<div class="ctl" data-k="ctl:' + state.phase + (WPP.sound.muted ? 1 : 0) + (needClick ? 'b' + WPP.voice.blocked : '') + '">' + mute + skip + '</div></footer>';
|
||||
}
|
||||
|
||||
/* ---------- faser ---------- */
|
||||
@@ -126,17 +128,47 @@
|
||||
var players = WPP.playerMap(state);
|
||||
var ch = players[cat.chooser_id];
|
||||
var r = state.round || {};
|
||||
var chosen = cat.chosen_id != null ? (cat.options || []).filter(function (o) { return o.id === cat.chosen_id; })[0] : null;
|
||||
return '<section class="cat-banner" data-k="cb:' + esc(WPP.roundTitle(r)) + (r.question_number || '') + '">' +
|
||||
'<div class="cb-round">' + WPP.roundTitle(r) + '</div>' +
|
||||
(r.questions_in_round ? '<div class="cb-q">Spørgsmål ' + (r.question_number || 1) + ' af ' + r.questions_in_round + '</div>' : '') + '</section>' +
|
||||
'<section class="cat-who" data-k="cw:' + esc(cat.chooser_id || '') + '">' + (ch ? WPP.chip(ch, 'xl') : 'Nogen') + ' <span>vælger kategori…</span></section>' +
|
||||
'<section class="cat-opts" data-k="co:' + esc((cat.options || []).map(function (o) { return o.id; }).join(',')) + '">' +
|
||||
'<section class="cat-who' + (chosen ? ' picked' : '') + '" data-k="cw:' + esc(cat.chooser_id || '') + ':' + esc(chosen ? chosen.id : '') + '">' + (ch ? WPP.chip(ch, 'xl') : 'Nogen') +
|
||||
(chosen ? ' <span>valgte</span> <b>' + esc(chosen.name) + '</b>' : ' <span>vælger kategori…</span>') + '</section>' +
|
||||
/* sektionen bevares; kun det valgte felt skiftes ud (og popper), resten dæmpes via forælderens klasse */
|
||||
'<section class="cat-opts' + (chosen ? ' has-chosen' : '') + '" data-k="co:' + esc((cat.options || []).map(function (o) { return o.id; }).join(',')) + '" data-patch>' +
|
||||
(cat.options || []).map(function (o, i) {
|
||||
return '<div class="cat-opt c' + (i % 4) + '" style="--i:' + i + '">' + esc(o.name) + '</div>';
|
||||
var on = chosen && chosen.id === o.id;
|
||||
return '<div class="cat-opt c' + (i % 4) + (on ? ' chosen' : '') + '" data-k="o:' + esc(o.id) + (on ? ':on' : '') + '" style="--i:' + i + '"' +
|
||||
sfxAttr('pop_in', 200 + i * 80, 'co:' + o.id) + '>' + esc(o.name) + '</div>';
|
||||
}).join('') + '</section>' +
|
||||
'<div class="hs-timer" data-k="tm:' + (state.deadline || 0) + '">' + WPP.timerHtml(state, 'ring') + '</div>';
|
||||
}
|
||||
|
||||
/* kategorien præsenteres: et stort kort flyver ind, hopper og gnistrer (finalen: et dramatisk FINALE-skilt først) */
|
||||
function intro(state) {
|
||||
var it = state.intro || {};
|
||||
var r = state.round || {};
|
||||
var fin = !!it.final;
|
||||
var mult = (r.multiplier || 1) === 3 ? 'Tredobbelt point!' : (r.multiplier || 1) === 2 ? 'Dobbelt point!' : r.multiplier > 3 ? '×' + r.multiplier + ' point!' : '';
|
||||
var sparks = '';
|
||||
for (var i = 0; i < 12; i++) { /* stjerner rundt om kortets kant */
|
||||
var a = (i * 30 + (i % 2) * 12) * Math.PI / 180;
|
||||
sparks += '<i class="spk" style="--i:' + i + ';--r:' + (i % 3 === 0 ? 1.25 : i % 3 === 1 ? 0.8 : 1) +
|
||||
';left:' + (50 + 53 * Math.cos(a)).toFixed(1) + '%;top:' + (50 + 60 * Math.sin(a)).toFixed(1) + '%"></i>';
|
||||
}
|
||||
return '<section class="intro' + (fin ? ' final' : '') + '" data-k="intro:' + esc(it.slug || it.category || '') + ':' + (fin ? 1 : 0) + '">' +
|
||||
(fin ? '<div class="in-rays"></div><div class="in-finale">FINALE</div>' + (mult ? '<div class="in-mult">' + mult + '</div>' : '')
|
||||
: '<div class="in-kicker">' + (r.questions_in_round ? 'Spørgsmål ' + (r.question_number || 1) + ' af ' + r.questions_in_round : 'Næste spørgsmål') + '</div>') +
|
||||
'<div class="in-card"><div class="in-label">Kategorien er</div><div class="in-title">' + esc(it.category || '') + '</div>' + sparks + '</div>' +
|
||||
'</section>';
|
||||
}
|
||||
|
||||
/* lyd til en node, der lige er kommet ind på skærmen. id: spilles kun én gang pr. skærm,
|
||||
selv om patcheren udskifter noden (fx når et svar eksploderer). delay i ms = animationens start. */
|
||||
function sfxAttr(name, delayMs, id) {
|
||||
return ' data-sfx="' + name + ':' + Math.round(delayMs || 0) + '"' + (id != null ? ' data-sfx-id="' + esc(String(id)) + '"' : '');
|
||||
}
|
||||
|
||||
function questionBlock(state, cls, fill) {
|
||||
var q = state.question || {};
|
||||
return '<section class="qbox ' + (cls || '') + '" data-k="qb:' + esc(cls || '') + ':' + esc(q.prompt || '') + ':' + esc(fill || '') + '">' +
|
||||
@@ -156,7 +188,7 @@
|
||||
return questionBlock(state, 'small') +
|
||||
'<section class="ans-grid cols' + cols + '" data-k="ag:' + esc(ans.map(function (a) { return a.id + a.text; }).join('|')) + '">' +
|
||||
ans.map(function (a, i) {
|
||||
return '<div class="ans-tile c' + (i % 6) + '" style="--i:' + i + '">' + esc(a.text) + '</div>';
|
||||
return '<div class="ans-tile c' + (i % 6) + '" style="--i:' + i + '"' + sfxAttr('pop_in', 150 + i * 80, 'at:' + a.id) + '>' + esc(a.text) + '</div>';
|
||||
}).join('') + '</section>' +
|
||||
'<p class="instr" data-k="ins">Hvad er <b>sandheden</b>? Gæt på din telefon!</p>' +
|
||||
'<div class="hs-timer" data-k="tm:' + (state.deadline || 0) + '">' + WPP.timerHtml(state, 'ring') + '</div>';
|
||||
@@ -174,61 +206,112 @@
|
||||
var truth = steps.filter(function (s) { return s.is_truth; })[0];
|
||||
var truthShown = truth && (pr.done || (steps[pr.i] === truth && pr.stage >= 2));
|
||||
var out = questionBlock(state, 'small rv', truthShown ? truth.text : '');
|
||||
if (!pr.done) {
|
||||
var s = steps[pr.i];
|
||||
var who = '', stamp = '';
|
||||
if (pr.stage >= 1) {
|
||||
var g = s.guessed_by || [];
|
||||
who = '<div class="rv-who" data-k="w"><span class="lbl">' + (s.is_truth ? 'Gættede rigtigt:' : 'Faldt for den:') + '</span>' +
|
||||
(g.length ? chips(g, players, 'pop') : (s.is_truth ? '<span class="none">Ingen…</span>' : '')) +
|
||||
(s.audience_votes ? '<span class="audv">+' + s.audience_votes + ' fra publikum</span>' : '') + '</div>';
|
||||
}
|
||||
if (pr.stage >= 2) {
|
||||
var pts = (s.points || []).map(function (p) {
|
||||
return '<span class="pt">' + WPP.chip(players[p.player_id]) + '<b>' + WPP.signed(p.delta) + '</b></span>';
|
||||
}).join('');
|
||||
var likes = s.likes ? '<span class="likes">♥ ' + s.likes + '</span>' : '';
|
||||
if (s.is_truth) {
|
||||
stamp = '<div class="rv-res truth" data-k="s"><div class="truth-banner">SANDHEDEN</div>' +
|
||||
(pts ? '<div class="pts">' + pts + '</div>' : '<div class="by">Ingen fandt sandheden!</div>') +
|
||||
(state.reveal && state.reveal.note ? '<div class="note">' + esc(state.reveal.note) + '</div>' : '') + '</div>';
|
||||
} else if (s.house) {
|
||||
stamp = '<div class="rv-res house" data-k="s"><div class="stamp">HUSETS LØGN</div><div class="by">Huset narrede jer!</div>' + likes + '</div>';
|
||||
} else {
|
||||
var auth = (s.authors || []).map(function (id) { return players[id] ? esc(players[id].name) : '?'; });
|
||||
stamp = '<div class="rv-res lie" data-k="s"><div class="stamp">LØGN</div><div class="by">af ' + WPP.joinNames(auth) + '</div>' +
|
||||
(pts ? '<div class="pts">' + pts + '</div>' : '') + likes + '</div>';
|
||||
}
|
||||
}
|
||||
out += '<section class="rv-stage' + (s.is_truth ? ' is-truth' : '') + '" data-k="rs:' + pr.i + ':' + esc(s.answer_id) + '" data-patch>' +
|
||||
'<div class="rv-ans" data-k="a">' + esc(s.text) + '</div>' + who + stamp + '</section>';
|
||||
} else {
|
||||
/* opsummering: point i spørgsmålet */
|
||||
var sum = {};
|
||||
steps.forEach(function (s) { (s.points || []).forEach(function (p) { sum[p.player_id] = (sum[p.player_id] || 0) + (p.delta || 0); }); });
|
||||
var ps = sortedPlayers(state);
|
||||
out += '<section class="rv-sum" data-k="sum"><h2>Point i dette spørgsmål</h2><ol>' + ps.map(function (p, i) {
|
||||
var d = sum[p.id] || 0;
|
||||
return '<li style="--pc:' + esc(p.color || '#888') + ';--i:' + i + '">' + avatar(p) + '<span class="n">' + esc(p.name) + '</span>' +
|
||||
'<span class="d' + (d ? ' plus' : '') + '">' + (d ? WPP.signed(d) : '–') + '</span><b>' + WPP.fmt(p.score) + '</b></li>';
|
||||
}).join('') + '</ol></section>';
|
||||
if (pr.done) return out + summary(state, pr, players);
|
||||
var s = steps[pr.i];
|
||||
var who = '', stamp = '';
|
||||
if (pr.stage >= 1) {
|
||||
var g = s.guessed_by || [];
|
||||
who = '<div class="rv-who" data-k="w"><span class="lbl">' + (s.is_truth ? 'Gættede rigtigt:' : 'Faldt for den:') + '</span>' +
|
||||
(g.length ? chips(g, players, 'pop') : (s.is_truth ? '<span class="none">Ingen…</span>' : '')) +
|
||||
(s.audience_votes ? '<span class="audv">+' + s.audience_votes + ' fra publikum</span>' : '') + '</div>';
|
||||
}
|
||||
out += '<div class="rv-dots" data-k="dots:' + pr.i + '">' + steps.map(function (s, i) {
|
||||
return '<i class="' + (i < pr.i ? 'past' : i === pr.i ? 'now' : '') + (s.is_truth ? ' t' : '') + '"></i>';
|
||||
if (pr.stage >= 2) {
|
||||
var pts = (s.points || []).map(function (p) {
|
||||
return '<span class="pt">' + WPP.chip(players[p.player_id]) + '<b>' + WPP.signed(p.delta) + '</b></span>';
|
||||
}).join('');
|
||||
if (s.is_truth) {
|
||||
stamp = '<div class="rv-res truth" data-k="s"><div class="truth-banner">SANDHEDEN</div>' +
|
||||
(pts ? '<div class="pts">' + pts + '</div>' : '<div class="by">Ingen fandt sandheden!</div>') +
|
||||
(r.note ? '<div class="note">' + esc(r.note) + '</div>' : '') + '</div>';
|
||||
} else if (s.house) {
|
||||
stamp = '<div class="rv-res house" data-k="s"><div class="stamp">HUSETS LØGN</div><div class="by">Huset narrede jer!</div></div>';
|
||||
} else {
|
||||
var auth = (s.authors || []).map(function (id) {
|
||||
return players[id] ? esc(players[id].name) + ((s.assisted || []).indexOf(id) >= 0 ? ' <small>(Lyv for mig)</small>' : '') : '?';
|
||||
});
|
||||
stamp = '<div class="rv-res lie" data-k="s"><div class="stamp">LØGN</div><div class="by">af ' + WPP.joinNames(auth) + '</div>' +
|
||||
(pts ? '<div class="pts">' + pts + '</div>' : '') + '</div>';
|
||||
}
|
||||
}
|
||||
/* hjerterne er live: mærket har sin egen nøgle, så kun det skiftes (og popper) */
|
||||
var likes = s.is_truth ? 0 : (s.likes || 0);
|
||||
out += '<section class="rv-stage' + (s.is_truth && pr.stage >= 2 ? ' is-truth' : '') + '" data-k="rs:' + pr.i + ':' + esc(s.answer_id) + '" data-patch>' +
|
||||
'<div class="rv-card" data-k="card" data-patch><div class="rv-ans" data-k="a" data-aid="' + esc(s.answer_id) + '">' + esc(s.text) + '</div>' +
|
||||
(likes ? '<span class="lk" data-k="lk:' + likes + '">♥ ' + likes + '</span>' : '') + '</div>' + who + stamp + '</section>';
|
||||
out += '<div class="rv-dots" data-k="dots:' + pr.i + '">' + steps.map(function (st, i) {
|
||||
return '<i class="' + (i < pr.i ? 'past' : i === pr.i ? 'now' : '') + (st.is_truth ? ' t' : '') + '"></i>';
|
||||
}).join('') + '</div>';
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ---------- opsummering: alle svar → de ikke-valgte eksploderer → point tælles op ---------- */
|
||||
function summary(state, pr, players) {
|
||||
var r = state.reveal || {};
|
||||
var all = r.all || [];
|
||||
var plan = sumPlan(state);
|
||||
var fooled = {};
|
||||
(r.steps || []).forEach(function (st) { fooled[st.answer_id] = (st.guessed_by || []).length + (st.audience_votes || 0); });
|
||||
var cols = all.length <= 4 ? 2 : all.length <= 6 ? 3 : 4;
|
||||
var tiles = all.map(function (a, i) { return sumTile(a, i, plan.at[a.id], pr.tail, fooled[a.id] || 0, players); }).join('');
|
||||
|
||||
var dmap = {};
|
||||
(r.deltas || []).forEach(function (d) { dmap[d.player_id] = d.delta || 0; });
|
||||
var ps = (state.players || []).slice().sort(function (a, b) {
|
||||
return (dmap[b.id] || 0) - (dmap[a.id] || 0) || (b.score || 0) - (a.score || 0);
|
||||
});
|
||||
var live = pr.tail >= plan.pointsAt;
|
||||
var rows = ps.map(function (p, i) { return sumRow(p, i, dmap[p.id] || 0, live); }).join('');
|
||||
|
||||
return '<section class="rv-sum" data-k="sum" data-patch>' +
|
||||
'<div class="sum-l" data-k="l" data-patch><h2 data-k="h">Alle svarene</h2>' +
|
||||
'<div class="sum-tiles cols' + cols + '" data-k="tiles:' + cols + '" data-patch>' + tiles + '</div></div>' +
|
||||
'<div class="sum-r" data-k="r" data-patch><h2 data-k="h">Point i spørgsmålet</h2>' +
|
||||
'<ol class="sum-pts' + (live ? ' live' : '') + '" data-k="pts" data-patch>' + rows + '</ol></div></section>';
|
||||
}
|
||||
/* boomAt = ms inde i halen hvor svaret eksploderer (kun ikke-valgte); tail = hvor langt vi er nu */
|
||||
function sumTile(a, i, boomAt, tail, fooled, players) {
|
||||
var st = '', late = '';
|
||||
if (boomAt != null && tail >= boomAt + BOOM_MS) st = ' gone';
|
||||
else if (boomAt != null && tail >= boomAt) { st = ' boom'; late = ';--late:-' + Math.round(tail - boomAt) + 'ms'; }
|
||||
var kind = a.is_truth ? ' truth' : a.house ? ' house' : ' lie';
|
||||
var tag = a.is_truth ? '<span class="st-tag">SANDHEDEN</span>' : a.house ? '<span class="st-tag">Husets løgn</span>' : '';
|
||||
var by = (a.authors || []).map(function (id) {
|
||||
var half = (a.assisted || []).indexOf(id) >= 0 ? '<span class="half">½ · Lyv for mig</span>' : '';
|
||||
return '<span class="st-au">' + WPP.chip(players[id], 'sm') + half + '</span>';
|
||||
}).join('');
|
||||
var info = a.is_truth ? (fooled ? 'Fundet af ' + fooled : 'Ingen fandt den') : a.picked ? 'Narrede ' + fooled : 'Ingen faldt for den';
|
||||
var likes = a.is_truth ? 0 : (a.likes || 0);
|
||||
return '<div class="st c' + (i % 6) + kind + (a.picked || a.is_truth ? '' : ' unpicked') + st + '" data-k="t:' + esc(a.id) + ':' + likes + st +
|
||||
'" data-aid="' + esc(a.id) + '"' + (boomAt != null ? ' data-at="' + boomAt + '"' : '') + ' style="--i:' + i + late + '"' +
|
||||
sfxAttr('pop_in', i * 80, 'st:' + a.id) + '>' + tag +
|
||||
'<div class="st-text">' + esc(a.text) + '</div>' + (by ? '<div class="st-by">' + by + '</div>' : '') +
|
||||
'<div class="st-info">' + info + '</div>' + (likes ? '<span class="lk">♥ ' + likes + '</span>' : '') + '</div>';
|
||||
}
|
||||
function sumRow(p, i, d, live) {
|
||||
var score = p.score || 0, prev = score - d, delay = 150 + i * 160;
|
||||
var cls = !live ? 'wait' : d > 0 ? 'plus' : 'zero';
|
||||
var dHtml = !live ? '<span class="sd"></span>'
|
||||
: d > 0 ? '<span class="sd">+<b data-count-from="0" data-count-to="' + d + '" data-count-delay="' + delay + '">0</b></span>'
|
||||
: '<span class="sd">0</span>';
|
||||
var tot = live && d ? '<b class="tot" data-count-from="' + prev + '" data-count-to="' + score + '" data-count-delay="' + delay + '">' + WPP.fmt(prev) + '</b>'
|
||||
: '<b class="tot">' + WPP.fmt(live ? score : prev) + '</b>';
|
||||
return '<li class="' + cls + '" data-k="r:' + esc(p.id) + ':' + (live ? 1 : 0) + ':' + d + ':' + score + '" style="--pc:' + esc(p.color || '#888') + ';--i:' + i + '">' +
|
||||
avatar(p) + '<span class="n">' + esc(p.name) + '</span>' + dHtml + tot + '</li>';
|
||||
}
|
||||
|
||||
function scores(state) {
|
||||
var ps = sortedPlayers(state);
|
||||
var dmap = {};
|
||||
(state.deltas || []).forEach(function (d) { dmap[d.player_id] = d.delta || 0; });
|
||||
var max = Math.max.apply(null, ps.map(function (p) { return p.score || 0; }).concat([1]));
|
||||
var r = state.round;
|
||||
/* rækkerne starter på den gamle plads (før runden) og glider op/ned, når pointene er talt op */
|
||||
var before = ps.slice().sort(function (a, b) { return ((b.score || 0) - (dmap[b.id] || 0)) - ((a.score || 0) - (dmap[a.id] || 0)); });
|
||||
return '<h1 class="sc-title" data-k="st">Stillingen' + (r ? ' efter ' + esc(r.name || ('Runde ' + r.number)) : '') + '</h1>' +
|
||||
'<ol class="board" data-k="board:' + esc(ps.map(function (p) { return p.id + ':' + p.score; }).join(',')) + '">' + ps.map(function (p, i) {
|
||||
var d = dmap[p.id] || 0;
|
||||
return '<li style="--pc:' + esc(p.color || '#888') + ';--i:' + i + ';--w:' + Math.max(4, Math.round(100 * (p.score || 0) / max)) + '%">' +
|
||||
return '<li style="--pc:' + esc(p.color || '#888') + ';--i:' + i + ';--mv:' + (before.indexOf(p) - i) + ';--w:' + Math.max(4, Math.round(100 * (p.score || 0) / max)) + '%"' +
|
||||
sfxAttr('pop_in', i * 120, 'sc:' + p.id) + '>' +
|
||||
'<span class="rk">' + (i + 1) + '</span>' + avatar(p) + '<span class="n">' + esc(p.name) + '</span>' +
|
||||
'<span class="bar"><i></i></span>' +
|
||||
(d ? '<span class="d">' + WPP.signed(d) + '</span>' : '<span class="d zero"></span>') +
|
||||
@@ -269,6 +352,214 @@
|
||||
'<button class="btn btn-go btn-xl" data-act="play_again">Spil igen</button></section>';
|
||||
}
|
||||
|
||||
/* ---------- effekter: et lag over skærmen, som patch ikke rører ---------- */
|
||||
function fxLayer(root) {
|
||||
if (!root._fx || !root._fx.isConnected) {
|
||||
root._fx = document.createElement('div');
|
||||
root._fx.className = 'fx';
|
||||
root._fx.setAttribute('aria-hidden', 'true');
|
||||
root.appendChild(root._fx);
|
||||
}
|
||||
return root._fx;
|
||||
}
|
||||
function spawn(root, cls, x, y, style, life) {
|
||||
var e = document.createElement('i');
|
||||
e.className = cls;
|
||||
e.style.cssText = 'left:' + x + 'px;top:' + y + 'px;' + (style || '');
|
||||
fxLayer(root).appendChild(e);
|
||||
setTimeout(function () { if (e.parentNode) e.parentNode.removeChild(e); }, life || 2000);
|
||||
return e;
|
||||
}
|
||||
/* midten af et element (eller af skærmen) i lagets koordinater */
|
||||
function centerOf(root, el, fx, fy) {
|
||||
var lr = fxLayer(root).getBoundingClientRect();
|
||||
var r = el && el.getClientRects().length ? el.getBoundingClientRect() : lr;
|
||||
return { x: r.left - lr.left + r.width * (fx == null ? 0.5 : fx), y: r.top - lr.top + r.height * (fy == null ? 0.5 : fy), w: r.width, h: r.height };
|
||||
}
|
||||
function byAid(root, id) {
|
||||
var els = root.querySelectorAll('.screen [data-aid]');
|
||||
for (var i = 0; i < els.length; i++) if (els[i].getAttribute('data-aid') === id && !els[i].classList.contains('gone')) return els[i];
|
||||
return null;
|
||||
}
|
||||
function floatHearts(root, id, n) {
|
||||
if (WPP.reducedMotion()) return;
|
||||
var el = byAid(root, id);
|
||||
var c = centerOf(root, el, el ? 0.85 : 0.5, el ? 0.1 : 0.5);
|
||||
for (var k = 0; k < n; k++) {
|
||||
spawn(root, 'fheart', c.x + (Math.random() - 0.5) * 40, c.y,
|
||||
'--dx:' + Math.round((Math.random() - 0.5) * 120) + 'px;--s:' + (0.9 + Math.random() * 0.8).toFixed(2) + ';animation-delay:' + k * 140 + 'ms', 2400);
|
||||
}
|
||||
}
|
||||
function burst(root, el) {
|
||||
if (WPP.reducedMotion()) return;
|
||||
var c = centerOf(root, el);
|
||||
var col = getComputedStyle(el).backgroundColor;
|
||||
for (var k = 0; k < 14; k++) {
|
||||
var ang = (k / 14) * Math.PI * 2 + Math.random() * 0.4;
|
||||
var dist = (0.6 + Math.random() * 0.6) * Math.max(c.w, 120);
|
||||
spawn(root, 'frag', c.x + (Math.random() - 0.5) * c.w * 0.6, c.y + (Math.random() - 0.5) * c.h * 0.5,
|
||||
'background:' + col + ';--dx:' + Math.round(Math.cos(ang) * dist) + 'px;--dy:' + Math.round(Math.sin(ang) * dist * 0.7) + 'px;--r:' +
|
||||
Math.round((Math.random() - 0.5) * 720) + 'deg;--s:' + (0.6 + Math.random() * 0.9).toFixed(2), 1100);
|
||||
}
|
||||
spawn(root, 'flash', c.x, c.y, '', 700);
|
||||
}
|
||||
function confetti(root) {
|
||||
if (WPP.reducedMotion()) return;
|
||||
var lr = fxLayer(root).getBoundingClientRect();
|
||||
var cols = ['#ff3d8b', '#ffd23f', '#2de2e6', '#3ddc84', '#ff8c42', '#a56bff', '#ffffff'];
|
||||
for (var k = 0; k < 150; k++) {
|
||||
spawn(root, 'conf', Math.random() * lr.width, -20,
|
||||
'background:' + cols[k % cols.length] + ';--dx:' + Math.round((Math.random() - 0.5) * 300) + 'px;--r:' + Math.round(360 + Math.random() * 1080) + 'deg;' +
|
||||
'animation-duration:' + (2.8 + Math.random() * 2.4).toFixed(2) + 's;animation-delay:' + Math.round(Math.random() * 1800) + 'ms;' +
|
||||
(k % 3 ? '' : 'border-radius:50%;'), 7500);
|
||||
}
|
||||
}
|
||||
/* stjerner, der springer ud fra kortet, når det lander */
|
||||
function sparkle(root, el, n) {
|
||||
if (WPP.reducedMotion() || !el) return;
|
||||
var c = centerOf(root, el);
|
||||
var cols = ['#ffd23f', '#ffffff', '#2de2e6', '#ff3d8b'];
|
||||
for (var k = 0; k < n; k++) {
|
||||
var ang = (k / n) * Math.PI * 2 + Math.random() * 0.3;
|
||||
var dist = (0.55 + Math.random() * 0.5) * Math.max(c.w, 200) * 0.7;
|
||||
spawn(root, 'fstar', c.x + Math.cos(ang) * c.w * 0.3, c.y + Math.sin(ang) * c.h * 0.3,
|
||||
'color:' + cols[k % cols.length] + ';--dx:' + Math.round(Math.cos(ang) * dist) + 'px;--dy:' + Math.round(Math.sin(ang) * dist * 0.6) + 'px;--s:' +
|
||||
(0.6 + Math.random() * 0.9).toFixed(2) + ';animation-delay:' + Math.round(Math.random() * 120) + 'ms', 1500);
|
||||
}
|
||||
}
|
||||
function shake(root) {
|
||||
if (WPP.reducedMotion()) return;
|
||||
root.classList.remove('shake'); void root.offsetWidth; root.classList.add('shake');
|
||||
clearTimeout(root._shakeT);
|
||||
root._shakeT = setTimeout(function () { root.classList.remove('shake'); }, 900);
|
||||
}
|
||||
|
||||
/* et ikke-valgt svar i opsummeringen eksploderer */
|
||||
function boomTile(root, u, armKey, id) {
|
||||
if (u.sumArm !== armKey) return;
|
||||
var el = byAid(root, id);
|
||||
if (!el || el.classList.contains('boom')) return;
|
||||
el.classList.add('boom');
|
||||
setTimeout(function () {
|
||||
if (u.sumArm !== armKey) return;
|
||||
var cur = el.isConnected ? el : byAid(root, id); /* noden kan være skiftet ud imens */
|
||||
if (cur) burst(root, cur);
|
||||
sfx(u, 'explode', { rate: 0.9 + Math.random() * 0.25 });
|
||||
}, BURST_MS);
|
||||
}
|
||||
/* planlæg eksplosionerne, første gang opsummeringen vises */
|
||||
function armSummary(root, u, state, pr) {
|
||||
var armKey = pr.key + '@' + pr.start;
|
||||
if (u.sumArm === armKey) return;
|
||||
u.sumArm = armKey;
|
||||
var at = sumPlan(state).at;
|
||||
Object.keys(at).forEach(function (id) {
|
||||
var wait = at[id] - pr.tail;
|
||||
if (wait > -150) setTimeout(function () { boomTile(root, u, armKey, id); }, Math.max(0, wait));
|
||||
});
|
||||
/* svar, der allerede var på skærmen, skal ikke poppe ind igen, når de skiftes ud */
|
||||
if (pr.tail > 1000) u.sumIntroDone = armKey;
|
||||
}
|
||||
function markStill(root, u) {
|
||||
var arm = u.sumArm;
|
||||
Array.prototype.forEach.call(root.querySelectorAll('.sum-tiles > .st'), function (el) {
|
||||
if (el._seen) return;
|
||||
el._seen = true;
|
||||
if (u.sumIntroDone === arm) el.classList.add('still');
|
||||
});
|
||||
if (u.sumIntroDone !== arm) setTimeout(function () { if (u.sumArm === arm) u.sumIntroDone = arm; }, 1000);
|
||||
}
|
||||
|
||||
/* ---------- lyd og effekter ud fra forskellen mellem forrige og nye snapshot ---------- */
|
||||
function pkey(s) { return s.phase_key || (s.phase + ':' + (s.round ? s.round.number + '.' + s.round.question_number : '')); }
|
||||
function likeMap(s) {
|
||||
var m = {};
|
||||
(s.answers || []).forEach(function (a) { if (a.likes != null) m[a.id] = a.likes; });
|
||||
((s.reveal && s.reveal.all) || []).forEach(function (a) { if (m[a.id] == null) m[a.id] = a.likes || 0; });
|
||||
return m;
|
||||
}
|
||||
function onChanges(root, u, state, prev) {
|
||||
var phase = state.phase;
|
||||
var same = prev && pkey(prev) === pkey(state);
|
||||
if (!same) return;
|
||||
var before = {};
|
||||
(prev.players || []).forEach(function (p) { before[p.id] = p; });
|
||||
if (phase === 'lobby' && (state.players || []).some(function (p) { return !before[p.id]; })) sfx(u, 'join');
|
||||
if (phase === 'lie' || phase === 'guess') {
|
||||
var lit = (state.players || []).filter(function (p) { return p.done && !(before[p.id] && before[p.id].done); }).length;
|
||||
if (lit) sfx(u, 'submit');
|
||||
}
|
||||
if (phase === 'category' && state.category && state.category.chosen_id != null && !(prev.category && prev.category.chosen_id != null)) sfx(u, 'category');
|
||||
if (phase === 'reveal') {
|
||||
var a = likeMap(prev), b = likeMap(state), any = false;
|
||||
Object.keys(b).forEach(function (id) {
|
||||
var d = b[id] - (a[id] || 0);
|
||||
if (d > 0) { floatHearts(root, id, Math.min(d * 2, 6)); any = true; }
|
||||
});
|
||||
if (any) sfx(u, 'like');
|
||||
}
|
||||
}
|
||||
function onPhase(root, u, state, from) {
|
||||
var ctx = u.ctx || {};
|
||||
var phase = state.phase;
|
||||
/* værten (eller VIP) startede spillet: speakerens lobby-snak afbrydes */
|
||||
if (from === 'lobby' && phase !== 'lobby' && !ctx.harness) WPP.voice.stop();
|
||||
clearTimeout(u.finalT); clearTimeout(u.introT);
|
||||
u.finalMusic = false;
|
||||
/* indgangslyd: lang whoosh for det, der flyver ind over hele skærmen, ellers en kort */
|
||||
if (phase === 'intro') introCue(root, u, state);
|
||||
else if (phase === 'lie') sfx(u, 'whoosh_long');
|
||||
else if (from) sfx(u, 'whoosh');
|
||||
if (phase === 'scores') {
|
||||
/* rækkerne bytter plads efter 2,2 s (se .board li): op = swoosh, ned = komisk rutsjetur */
|
||||
var dm = {};
|
||||
(state.deltas || []).forEach(function (d) { dm[d.player_id] = d.delta || 0; });
|
||||
var ps = sortedPlayers(state);
|
||||
var before = ps.slice().sort(function (a, b) { return ((b.score || 0) - (dm[b.id] || 0)) - ((a.score || 0) - (dm[a.id] || 0)); });
|
||||
var up = ps.some(function (p, i) { return before.indexOf(p) > i; });
|
||||
var down = ps.some(function (p, i) { return before.indexOf(p) < i; });
|
||||
if (up) sfx(u, 'swoosh_up', { delay: 2.2 });
|
||||
if (down) sfx(u, 'slide', { delay: 2.35, volume: 0.45 });
|
||||
}
|
||||
if (phase === 'final') {
|
||||
sfx(u, 'fanfare', { delay: from ? 0.3 : 0 });
|
||||
sfx(u, 'swoosh_up', { delay: 0.9, volume: 0.4 }); /* vinderens søjle rejser sig */
|
||||
confetti(root);
|
||||
u.finalT = setTimeout(function () { u.finalMusic = true; if (audible(u.ctx)) WPP.music.set('music_final'); }, 3000);
|
||||
}
|
||||
}
|
||||
/* kategorikortet: whoosh når det letter, kategoriens jingle når det lander (finalen: FINALE-skiltet smækker først) */
|
||||
var INTRO_LAND = 0.55, FINAL_CARD = 0.75;
|
||||
function introCue(root, u, state) {
|
||||
var it = state.intro || {};
|
||||
var t0 = it.final ? FINAL_CARD : 0;
|
||||
if (it.final) sfx(u, 'stamp', { delay: 0.12, volume: 0.7 });
|
||||
sfx(u, 'whoosh_long', { delay: t0 });
|
||||
sfx(u, 'cat_' + (it.slug || ''), { delay: t0 + INTRO_LAND, fallback: 'category' });
|
||||
var key = pkey(state);
|
||||
u.introT = setTimeout(function () {
|
||||
if (u.state && pkey(u.state) === key) sparkle(root, root.querySelector('.screen .in-card'), 22);
|
||||
}, (t0 + INTRO_LAND) * 1000);
|
||||
}
|
||||
function musicFor(u, phase) { return phase === 'final' ? (u.finalMusic ? 'music_final' : null) : (MUSIC[phase] || null); }
|
||||
|
||||
/* nye noder med data-sfx får deres lyd (bevarede noder har allerede fået den) */
|
||||
function nodeSfx(root, u, state, quiet) {
|
||||
var key = pkey(state);
|
||||
if (u.sfxKey !== key) { u.sfxKey = key; u.sfxIds = {}; }
|
||||
var els = root.querySelectorAll('.screen [data-sfx]');
|
||||
for (var i = 0; i < els.length; i++) {
|
||||
var el = els[i];
|
||||
if (el._sfx) continue;
|
||||
el._sfx = true;
|
||||
var id = el.getAttribute('data-sfx-id');
|
||||
if (id) { if (u.sfxIds[id]) continue; u.sfxIds[id] = 1; }
|
||||
if (quiet || el.classList.contains('still')) continue;
|
||||
var spec = el.getAttribute('data-sfx').split(':');
|
||||
sfx(u, spec[0], { delay: (Number(spec[1]) || 0) / 1000, gap: spec[0] === 'pop_in' ? 80 : 0 });
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- hændelser ---------- */
|
||||
function bind(root) {
|
||||
if (root._bound) return;
|
||||
@@ -294,6 +585,7 @@
|
||||
return;
|
||||
}
|
||||
if (a === 'start' || a === 'skip' || a === 'play_again') {
|
||||
WPP.voice.stop(); /* værtens knap vinder over speakeren */
|
||||
ctx.send({ action: a });
|
||||
b.disabled = true;
|
||||
setTimeout(function () { b.disabled = false; }, 1500);
|
||||
@@ -317,26 +609,47 @@
|
||||
if (urls[0] || urls[1]) WPP.voice.now(urls);
|
||||
}
|
||||
}
|
||||
if (pr.key !== u.revealKey) {
|
||||
var key = viewKey(s, u);
|
||||
if (key !== u.revealKey) {
|
||||
var prev = u.revealKey;
|
||||
renderHost(root, s, u.ctx);
|
||||
if (prev && !pr.done && pr.stage === 2) {
|
||||
var st = (s.reveal.steps || [])[pr.i] || {};
|
||||
WPP.sound.play(st.is_truth ? 'truth' : 'stamp');
|
||||
} else if (prev && !pr.done && pr.stage === 0) WPP.sound.play('pop');
|
||||
if (prev) revealCue(root, u, s, pr, key);
|
||||
}
|
||||
}
|
||||
if (s.deadline && (s.phase === 'lie' || s.phase === 'guess' || s.phase === 'category')) {
|
||||
var left = WPP.secondsLeft(s.deadline);
|
||||
if (left !== u.lastTick) {
|
||||
var was = u.lastTick;
|
||||
u.lastTick = left;
|
||||
if (left > 0 && left <= 5) WPP.sound.play('tick');
|
||||
if (left > 0 && left <= 5) sfx(u, 'tick');
|
||||
else if (left === 0 && was > 0) sfx(u, 'timeup');
|
||||
}
|
||||
}
|
||||
}
|
||||
/* lyd/rystelse, når afsløringen skifter trin */
|
||||
function revealCue(root, u, s, pr, key) {
|
||||
if (pr.done) {
|
||||
if (/\.p$/.test(key)) { /* pointene tælles op */
|
||||
var n = ((s.reveal && s.reveal.deltas) || []).filter(function (d) { return d.delta > 0; }).length;
|
||||
for (var k = 0; k < Math.min(n, 6); k++) sfx(u, 'coins', { delay: 0.15 + k * 0.16, volume: k ? 0.35 : 0.55 });
|
||||
}
|
||||
return;
|
||||
}
|
||||
var st = (s.reveal.steps || [])[pr.i] || {};
|
||||
if (pr.stage === 2) {
|
||||
sfx(u, st.is_truth ? 'truth' : st.house ? 'house' : 'stamp', { delay: st.is_truth ? 0 : 0.2 });
|
||||
if (st.is_truth) sfx(u, 'swoosh_up', { volume: 0.4 }); /* sandheden løfter sig og gløder */
|
||||
else shake(root);
|
||||
} else if (pr.stage === 1) {
|
||||
/* navnene popper ind ét ad gangen (samme takt som .rv-who .chip.pop) */
|
||||
var n = Math.min((st.guessed_by || []).length, 6);
|
||||
for (var k = 0; k < n; k++) sfx(u, 'pop_in', { delay: k * 0.09, gap: 80 });
|
||||
} else if (pr.stage === 0) sfx(u, 'whoosh', { volume: 0.35 });
|
||||
}
|
||||
|
||||
/* Speakerens replikker uden for afsløringen: afspil nye i rækkefølge. Ved første snapshot
|
||||
(fx genindlæsning) afspilles kun det, der er sagt de sidste par sekunder. */
|
||||
(fx genindlæsning) afspilles kun det, der er sagt de sidste par sekunder. Hver replik bærer
|
||||
sin skærms nøgle, så voice-køen kan springe over replikker til en skærm, der er væk. */
|
||||
function queueNarration(u, state, ctx) {
|
||||
var items = state.narration;
|
||||
if (!items || ctx.harness) return;
|
||||
@@ -346,7 +659,7 @@
|
||||
items.forEach(function (n) { if (n.at < cutoff) u.narrLast = Math.max(u.narrLast, n.id); });
|
||||
}
|
||||
items.forEach(function (n) {
|
||||
if (n.id > u.narrLast) { u.narrLast = n.id; WPP.voice.enqueue(n.url); }
|
||||
if (n.id > u.narrLast) { u.narrLast = n.id; WPP.voice.enqueue({ url: n.url, key: n.key || null }); }
|
||||
});
|
||||
}
|
||||
|
||||
@@ -355,30 +668,41 @@
|
||||
ctx = ctx || {};
|
||||
state = state || {};
|
||||
var u = ui(root);
|
||||
var prev = u.state;
|
||||
u.state = state; u.ctx = ctx;
|
||||
bind(root);
|
||||
queueNarration(u, state, ctx);
|
||||
if (!root._ticking) { root._ticking = true; WPP.onTick(function () { hostTick(root); }); }
|
||||
var phase = state.phase || 'lobby';
|
||||
var phaseChanged = u.lastPhase !== phase;
|
||||
root.classList.add('host-root');
|
||||
var sh = WPP.shell(root);
|
||||
var phase = state.phase || 'lobby';
|
||||
var main, foot = {};
|
||||
fxLayer(root);
|
||||
if (!ctx.harness) WPP.voice.phaseKey = state.phase_key || null;
|
||||
if (phaseChanged) onPhase(root, u, state, u.lastPhase); /* før nye replikker lægges i kø */
|
||||
queueNarration(u, state, ctx);
|
||||
if (!root._ticking) { root._ticking = true; WPP.onTick(function () { hostTick(root); }); }
|
||||
var main, foot = {}, pr = null;
|
||||
switch (phase) {
|
||||
case 'lobby': main = lobby(state, ctx); break;
|
||||
case 'category': main = category(state); break;
|
||||
case 'intro': main = intro(state); break;
|
||||
case 'lie': main = lie(state); foot.doneChips = true; break;
|
||||
case 'guess': main = guess(state); foot.doneChips = true; break;
|
||||
case 'reveal': main = reveal(state, u); u.revealKey = revealProgress(state, u).key; break;
|
||||
case 'reveal': main = reveal(state, u); pr = revealProgress(state, u); u.revealKey = viewKey(state, u); break;
|
||||
case 'scores': main = scores(state); break;
|
||||
case 'final': main = finalView(state); break;
|
||||
default: main = '<h1 data-k="x">Øjeblik…</h1>';
|
||||
}
|
||||
sh.screen.className = 'screen hs hs-' + phase;
|
||||
WPP.patch(sh.screen, topBar(state) + '<main class="hs-main" data-k="main:' + phase + '" data-patch>' + main + '</main>' + footer(state, foot));
|
||||
sh.screen.className = 'screen hs hs-' + phase + (pr && pr.done ? ' is-sum' : '');
|
||||
WPP.patch(sh.screen, topBar(state) + '<main class="hs-main" data-k="main:' + phase + '" data-patch>' + main + '</main>' + footer(state, foot, ctx));
|
||||
WPP.animateCounts(sh.screen);
|
||||
if (u.lastPhase !== phase) {
|
||||
if (u.lastPhase && !ctx.harness) WPP.sound.play('phase');
|
||||
u.lastPhase = phase;
|
||||
if (pr && pr.done) { armSummary(root, u, state, pr); markStill(root, u); }
|
||||
nodeSfx(root, u, state, !prev); /* første snapshot (genindlæsning): ingen pop-salve */
|
||||
if (pr && pr.pending && u.drumKey !== pr.key) { u.drumKey = pr.key; sfx(u, 'drumroll'); }
|
||||
if (!phaseChanged) onChanges(root, u, state, prev);
|
||||
u.lastPhase = phase;
|
||||
if (audible(ctx)) {
|
||||
WPP.sfx.preload();
|
||||
WPP.music.set(musicFor(u, phase), { fade: phase === 'intro' || phase === 'final' ? STING_FADE : null });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -387,7 +711,7 @@
|
||||
var conn = null;
|
||||
var ctx = { send: function (m) { return conn ? conn.send(m) : false; } };
|
||||
renderHost(root, { phase: 'lobby', code: code, players: [], audience_count: 0, min_players: 2, max_players: 8, join_url: location.origin + '/?code=' + code }, ctx);
|
||||
WPP.voice.onchange = function () { var u = ui(root); if (u.state) renderHost(root, u.state, u.ctx); };
|
||||
WPP.voice.onchange = WPP.sound.onchange = function () { var u = ui(root); if (u.state) renderHost(root, u.state, u.ctx); };
|
||||
conn = WPP.connect('/ws/host/' + encodeURIComponent(code) + '/', {
|
||||
root: root,
|
||||
onMessage: function (msg) {
|
||||
@@ -398,7 +722,7 @@
|
||||
return ctx;
|
||||
}
|
||||
|
||||
window.WPPHost = { renderHost: renderHost, mount: mount, revealProgress: revealProgress };
|
||||
window.WPPHost = { renderHost: renderHost, mount: mount, revealProgress: revealProgress, sumPlan: sumPlan };
|
||||
window.renderHost = renderHost;
|
||||
|
||||
if (!window.WPP_HARNESS) {
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
var WPP = window.WPP, esc = WPP.esc;
|
||||
|
||||
function ui(root) {
|
||||
return root._ui || (root._ui = { busy: false, busyTimer: null, drafts: {}, lastPhase: null });
|
||||
return root._ui || (root._ui = { busy: false, busyTimer: null, drafts: {}, lastPhase: null, revealSeen: {}, state: null, revealKey: null });
|
||||
}
|
||||
|
||||
/* ---------- travlhed (forhindrer dobbelt-tryk) ---------- */
|
||||
@@ -62,6 +62,7 @@
|
||||
var name = (f.name.value || '').trim();
|
||||
if (code.length !== 4) { WPP.toast(root, 'Koden er 4 bogstaver.'); f.code.focus(); return; }
|
||||
if (!name) { WPP.toast(root, 'Skriv dit navn.'); f.name.focus(); return; }
|
||||
WPP.sound.unlock(); /* "Hop ind!" er første tryk: lås lyden op til drillerierne */
|
||||
if (root._ctx && root._ctx.onJoin) {
|
||||
if (!root._ctx.harness) setBusy(root, true);
|
||||
root._ctx.onJoin(code, name.slice(0, 12));
|
||||
@@ -126,19 +127,36 @@
|
||||
var cat = state.category || {};
|
||||
var opts = cat.options || [];
|
||||
var chooser = players[cat.chooser_id];
|
||||
var chosen = cat.chosen_id != null ? opts.filter(function (o) { return o.id === cat.chosen_id; })[0] : null;
|
||||
var head = '<div class="roundtag" data-k="rt">' + WPP.roundTitle(state.round) + '</div>';
|
||||
if (me.is_chooser) {
|
||||
return head + '<h2 class="ask" data-k="ask">Vælg en kategori!</h2>' +
|
||||
'<section class="opts" data-k="opts">' + opts.map(function (o, i) {
|
||||
return '<button class="btn btn-opt c' + (i % 4) + '" data-act="choose_category" data-arg="' + esc(o.id) + '">' + esc(o.name) + '</button>';
|
||||
return head + '<h2 class="ask" data-k="ask:' + (chosen ? 1 : 0) + '">' + (chosen ? 'Du valgte <b>' + esc(chosen.name) + '</b> ✓' : 'Vælg en kategori!') + '</h2>' +
|
||||
'<section class="opts' + (chosen ? ' has-chosen' : '') + '" data-k="opts:' + esc(chosen ? chosen.id : '') + '">' + opts.map(function (o, i) {
|
||||
var on = chosen && chosen.id === o.id;
|
||||
return '<button class="btn btn-opt c' + (i % 4) + (on ? ' chosen' : '') + '" data-act="choose_category" data-arg="' + esc(o.id) + '"' +
|
||||
(chosen ? ' disabled' : '') + '>' + esc(o.name) + '</button>';
|
||||
}).join('') + '</section>';
|
||||
}
|
||||
return head + big((chooser ? esc(chooser.name) : 'Nogen') + ' vælger kategori…', 'Hold dig klar til at lyve!', 'wait', 'pulse') +
|
||||
'<section class="opts ghost" data-k="opts">' + opts.map(function (o, i) {
|
||||
return '<div class="opt-ghost c' + (i % 4) + '">' + esc(o.name) + '</div>';
|
||||
var who = chooser ? esc(chooser.name) : 'Nogen';
|
||||
return head + (chosen ? big(who + ' valgte <b>' + esc(chosen.name) + '</b>', 'Gør dig klar til at lyve!', 'chosen:' + esc(chosen.id), 'pop')
|
||||
: big(who + ' vælger kategori…', 'Hold dig klar til at lyve!', 'wait', 'pulse')) +
|
||||
'<section class="opts ghost' + (chosen ? ' has-chosen' : '') + '" data-k="opts:' + esc(chosen ? chosen.id : '') + '">' + opts.map(function (o, i) {
|
||||
return '<div class="opt-ghost c' + (i % 4) + (chosen && chosen.id === o.id ? ' chosen' : '') + '">' + esc(o.name) + '</div>';
|
||||
}).join('') + '</section>';
|
||||
}
|
||||
|
||||
/* kategorien præsenteres på storskærmen; telefonen viser den stille med bogstaver, der falder på plads */
|
||||
function screenIntro(state) {
|
||||
var it = state.intro || {};
|
||||
var i = 0;
|
||||
var title = String(it.category || '').split(/\s+/).filter(Boolean).map(function (w) {
|
||||
return '<span class="w">' + Array.from(w).map(function (ch) { return '<span class="ch" style="--i:' + (i++) + '">' + esc(ch) + '</span>'; }).join('') + '</span>';
|
||||
}).join(' ');
|
||||
return '<div class="roundtag' + (it.final ? ' final' : '') + '" data-k="rt:' + (it.final ? 1 : 0) + '">' + (it.final ? 'FINALE' : WPP.roundTitle(state.round)) + '</div>' +
|
||||
'<section class="ph-intro' + (it.final ? ' final' : '') + '" data-k="pi:' + esc(it.slug || it.category || '') + '"><p>Kategorien er</p><h2>' + title + '</h2></section>' +
|
||||
'<p class="hint center" data-k="w">' + (it.final ? 'Tredobbelt point – gør dig klar til at lyve!' : 'Gør dig klar til at lyve!') + '</p>';
|
||||
}
|
||||
|
||||
function screenLie(state, me, players, root) {
|
||||
var q = state.question || {};
|
||||
var qk = (state.round ? state.round.number + '.' + state.round.question_number : '') + ':' + (q.prompt || '');
|
||||
@@ -164,17 +182,29 @@
|
||||
return '<p class="progress muted" data-k="prog">' + done + ' af ' + ps.length + ' er færdige</p>';
|
||||
}
|
||||
|
||||
function heartList(state, me, excludeIds, k) {
|
||||
var ans = (state.answers || []).filter(function (a) {
|
||||
if (excludeIds.indexOf(a.id) >= 0) return false;
|
||||
if (me.own_answer_id && a.id === me.own_answer_id) return false;
|
||||
if (state.reveal && (state.reveal.steps || []).some(function (st) { return st.answer_id === a.id && st.is_truth; })) return false;
|
||||
if (me.lie && String(a.text).trim().toLowerCase() === String(me.lie).trim().toLowerCase()) return false;
|
||||
return true;
|
||||
});
|
||||
/* Hjerter gives kun under afsløringen og kun til løgne, der allerede er afsløret på storskærmen
|
||||
(ellers ville listen røbe sandheden). Aldrig sandheden, aldrig ens egen løgn. Nyeste øverst. */
|
||||
function revealedLies(state, me, pr) {
|
||||
var r = state.reveal || {};
|
||||
if (pr.pending) return [];
|
||||
var list;
|
||||
if (pr.done) list = (r.all || []).map(function (a) { return { id: a.id, text: a.text, is_truth: a.is_truth }; });
|
||||
else {
|
||||
list = (r.steps || []).slice(0, pr.i + (pr.stage >= 2 ? 1 : 0))
|
||||
.map(function (st) { return { id: st.answer_id, text: st.text, is_truth: st.is_truth }; });
|
||||
}
|
||||
var mine = String(me.lie || '').trim().toLowerCase();
|
||||
return list.filter(function (a) {
|
||||
if (a.is_truth || a.id === me.own_answer_id) return false;
|
||||
return !(mine && String(a.text).trim().toLowerCase() === mine);
|
||||
}).reverse();
|
||||
}
|
||||
function heartList(state, me, pr) {
|
||||
var ans = revealedLies(state, me, pr);
|
||||
if (!ans.length) return '';
|
||||
var likes = me.likes || [];
|
||||
return '<section class="hearts" data-k="' + k + '"><h3>Giv et hjerte til de bedste løgne</h3>' +
|
||||
return '<section class="hearts" data-k="hearts:' + ans.map(function (a) { return a.id + (likes.indexOf(a.id) >= 0 ? '+' : ''); }).join(',') + '">' +
|
||||
'<h3>Giv et hjerte til de bedste løgne</h3>' +
|
||||
ans.map(function (a) {
|
||||
var on = likes.indexOf(a.id) >= 0;
|
||||
return '<div class="hrow"><span>' + esc(a.text) + '</span><button class="heart' + (on ? ' on' : '') +
|
||||
@@ -189,26 +219,27 @@
|
||||
if (me.guess) {
|
||||
var picked = ans.filter(function (a) { return a.id === me.guess; })[0];
|
||||
out += '<section class="picked" data-k="picked"><p>Du gættede på</p><div class="pick">' + esc(picked ? picked.text : '…') + '</div></section>';
|
||||
out += heartList(state, me, [me.guess], 'hearts');
|
||||
out += '<p class="hint center" data-k="w">Venter på de andre…</p>';
|
||||
out += '<p class="hint center" data-k="w">Venter på de andre…<br>Når løgnene afsløres, kan du give hjerter.</p>';
|
||||
return out;
|
||||
}
|
||||
out += '<h2 class="ask" data-k="ask">Hvad er sandheden?</h2>';
|
||||
out += '<section class="answers" data-k="ans:' + ans.map(function (a) { return a.id; }).join(',') + '">' +
|
||||
ans.map(function (a, i) {
|
||||
return '<button class="btn btn-ans c' + (i % 6) + '" data-act="guess" data-arg="' + esc(a.id) + '">' + esc(a.text) + '</button>';
|
||||
return '<button class="btn btn-ans c' + (i % 6) + '" data-act="guess" data-arg="' + esc(a.id) + '" style="--i:' + i + '">' + esc(a.text) + '</button>';
|
||||
}).join('') + '</section>';
|
||||
return out;
|
||||
}
|
||||
|
||||
function screenReveal(state, me) {
|
||||
function screenReveal(state, me, players, u) {
|
||||
var pr = WPP.revealProgress(state, u);
|
||||
var out = big('Kig op på skærmen!', '', 'look', 'look');
|
||||
if (me.role === 'player' && me.last_delta != null) {
|
||||
out += '<section class="delta' + (me.last_delta > 0 ? ' plus' : '') + '" data-k="delta">' +
|
||||
/* egne point først, når storskærmen er nået til opsummeringen */
|
||||
if (pr.done && me.role === 'player' && me.last_delta != null) {
|
||||
out += '<section class="delta' + (me.last_delta > 0 ? ' plus' : '') + '" data-k="delta:' + me.last_delta + '">' +
|
||||
(me.last_delta > 0 ? '<b>' + WPP.signed(me.last_delta) + '</b><span>point til dig!</span>'
|
||||
: '<b>0</b><span>point denne gang</span>') + '</section>';
|
||||
}
|
||||
out += heartList(state, me, [], 'hearts');
|
||||
out += heartList(state, me, pr);
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -285,15 +316,19 @@
|
||||
switch (phase) {
|
||||
case 'lobby': body = screenLobby(state, me, players); break;
|
||||
case 'category': body = screenCategory(state, me, players); break;
|
||||
case 'intro': body = screenIntro(state); break;
|
||||
case 'lie': body = screenLie(state, me, players, root); break;
|
||||
case 'guess': body = screenGuess(state, me, players); break;
|
||||
case 'reveal': body = screenReveal(state, me, players); break;
|
||||
case 'reveal': body = screenReveal(state, me, players, u); u.revealKey = WPP.revealProgress(state, u).key; break;
|
||||
case 'scores': body = screenScores(state, me, players); break;
|
||||
case 'final': body = screenFinal(state, me, players); break;
|
||||
default: body = big('Øjeblik…', '', 'unk');
|
||||
}
|
||||
sh.screen.className = 'screen ph ph-' + phase;
|
||||
WPP.patch(sh.screen, topBar(state, me, players) + '<main class="ph-main" data-k="main" data-patch>' + body + '</main>');
|
||||
/* ny main pr. fase: skærmen glider ind ved hvert faseskift */
|
||||
WPP.patch(sh.screen, topBar(state, me, players) + '<main class="ph-main" data-k="main:' + phase + '" data-patch>' + body + '</main>');
|
||||
u.state = state;
|
||||
if (!root._ticking) { root._ticking = true; WPP.onTick(function () { playTick(root); }); }
|
||||
|
||||
/* gendan kladde i løgne-feltet, hvis formularen er ny */
|
||||
var inp = sh.screen.querySelector('#wpp-lie');
|
||||
@@ -311,11 +346,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* afsløringen skrider frem uden nye snapshots: tegn igen, når storskærmen skifter trin */
|
||||
function playTick(root) {
|
||||
var u = ui(root), s = u.state;
|
||||
if (!s || s.phase !== 'reveal' || !root.isConnected || !root._ctx) return;
|
||||
if (WPP.revealProgress(s, u).key !== u.revealKey) renderPlay(root, s, Object.assign({}, root._ctx, { keepBusy: true }));
|
||||
}
|
||||
|
||||
function renderJoin(root, ctx, opts) {
|
||||
ctx = ctx || {}; opts = opts || {};
|
||||
root._ctx = ctx;
|
||||
bind(root);
|
||||
setBusy(root, false);
|
||||
ui(root).state = null;
|
||||
var sh = WPP.shell(root);
|
||||
sh.screen.className = 'screen ph ph-join';
|
||||
var code = opts.code || '';
|
||||
@@ -334,6 +377,7 @@
|
||||
|
||||
function renderWaiting(root, text) {
|
||||
var sh = WPP.shell(root);
|
||||
ui(root).state = null;
|
||||
sh.screen.className = 'screen ph ph-wait';
|
||||
WPP.patch(sh.screen, '<section class="big pulse" data-k="w"><h2>' + esc(text || 'Forbinder…') + '</h2></section>');
|
||||
}
|
||||
@@ -347,6 +391,56 @@
|
||||
inp.classList.remove('flash'); void inp.offsetWidth; inp.classList.add('flash');
|
||||
}
|
||||
|
||||
/* ---------- drilleri: "Buh!", når man lige er faldet for en løgn ----------
|
||||
Fuldskærm med billede + tekst, ryst og vibration; boo og så serverens lyd (hvis lyden er låst op).
|
||||
Lukker selv efter ~2,5 s eller ved tryk. opts: {ttl, silent} (dev-harness). */
|
||||
function showTease(root, msg, opts) {
|
||||
opts = opts || {};
|
||||
msg = msg || {};
|
||||
hideTease(root, true);
|
||||
var ov = document.createElement('div');
|
||||
ov.className = 'tease';
|
||||
ov.setAttribute('role', 'alert');
|
||||
ov.innerHTML = '<div class="tz-box">' + (msg.image ? '<img class="tz-img" alt="" src="' + esc(msg.image) + '">' : '') +
|
||||
'<div class="tz-text' + (String(msg.text || '').length > 10 ? ' long' : '') + '">' + esc(msg.text || 'Buh!') + '</div></div>';
|
||||
ov.addEventListener('click', function (e) { e.preventDefault(); e.stopPropagation(); hideTease(root); });
|
||||
root.appendChild(ov);
|
||||
root._tease = ov;
|
||||
if (!WPP.reducedMotion()) { root.classList.remove('tz-shake'); void root.offsetWidth; root.classList.add('tz-shake'); }
|
||||
/* vibration kræver, at siden har fået et tryk (ellers advarer browseren) */
|
||||
var ua = navigator.userActivation;
|
||||
try { if (navigator.vibrate && (!ua || ua.hasBeenActive)) navigator.vibrate(200); } catch (e) { /* */ }
|
||||
if (!opts.silent) teaseSound(msg);
|
||||
ov._t = setTimeout(function () { hideTease(root); }, opts.ttl || 2500);
|
||||
}
|
||||
function hideTease(root, now) {
|
||||
var ov = root._tease;
|
||||
if (!ov) return;
|
||||
root._tease = null;
|
||||
clearTimeout(ov._t);
|
||||
root.classList.remove('tz-shake');
|
||||
if (now) { if (ov.parentNode) ov.parentNode.removeChild(ov); return; }
|
||||
ov.classList.add('out');
|
||||
setTimeout(function () { if (ov.parentNode) ov.parentNode.removeChild(ov); }, 320);
|
||||
}
|
||||
function teaseSound(msg) {
|
||||
WPP.sound.whenReady(function (ok) {
|
||||
if (!ok) return; /* browseren har ikke fået et tryk endnu: kun det visuelle */
|
||||
var d = WPP.sfx('boo');
|
||||
if (msg.sound) WPP.playUrl(msg.sound, { at: WPP.sound.ctx().currentTime + Math.max(0.2, d * 0.75) });
|
||||
}, 300);
|
||||
}
|
||||
/* billederne er versionerede (caches for altid): hent dem én gang, så drilleriet vises med det samme */
|
||||
function preloadTease() {
|
||||
if (preloadTease.done) return;
|
||||
preloadTease.done = true;
|
||||
(WPP.assets.tease || []).forEach(function (url) {
|
||||
var img = new Image();
|
||||
img.decoding = 'async';
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- live-forbindelse ---------- */
|
||||
function mount(root) {
|
||||
var qs = new URLSearchParams(location.search);
|
||||
@@ -394,6 +488,7 @@
|
||||
case 'state':
|
||||
if (!token) return;
|
||||
renderPlay(root, msg.state, ctx);
|
||||
preloadTease();
|
||||
break;
|
||||
case 'joined':
|
||||
token = msg.token; joinedRole = msg.role; pendingName = null;
|
||||
@@ -404,6 +499,9 @@
|
||||
case 'suggestion':
|
||||
setSuggestion(root, msg.text);
|
||||
break;
|
||||
case 'tease':
|
||||
showTease(root, msg);
|
||||
break;
|
||||
case 'error':
|
||||
setBusy(root, false);
|
||||
if (msg.code === 'unknown_token' || msg.code === 'unknown_room') {
|
||||
@@ -422,6 +520,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* telefonen er ellers tavs; kun drilleriets "boo" hentes (afkodes, når første tryk låser lyden op) */
|
||||
WPP.sfx.preload(['boo']);
|
||||
var last = WPP.ls.get('wpp:last');
|
||||
if (last && WPP.ls.get('wpp:' + last) && (!prefill || prefill === last)) {
|
||||
renderWaiting(root, 'Forbinder…');
|
||||
@@ -432,7 +532,7 @@
|
||||
return ctx;
|
||||
}
|
||||
|
||||
window.WPPPlay = { renderPlay: renderPlay, renderJoin: renderJoin, renderWaiting: renderWaiting, setSuggestion: setSuggestion, mount: mount };
|
||||
window.WPPPlay = { renderPlay: renderPlay, renderJoin: renderJoin, renderWaiting: renderWaiting, setSuggestion: setSuggestion, showTease: showTease, mount: mount };
|
||||
window.renderPlay = renderPlay;
|
||||
|
||||
if (!window.WPP_HARNESS) {
|
||||
|
||||
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 73 KiB |
@@ -6,6 +6,7 @@
|
||||
<div id="host" data-code="{{ code }}"></div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
{{ assets|json_script:"wpp-assets" }}
|
||||
<script src="{% static 'party/common.js' %}" defer></script>
|
||||
<script src="{% static 'party/host.js' %}" defer></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
{{ assets|json_script:"wpp-assets" }}
|
||||
<script src="{% static 'party/common.js' %}" defer></script>
|
||||
<script src="{% static 'party/play.js' %}" defer></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||