update
This commit is contained in:
@@ -260,7 +260,7 @@ def drehen():
|
|||||||
"""Einer dreht, alle sehen dasselbe Ergebnis."""
|
"""Einer dreht, alle sehen dasselbe Ergebnis."""
|
||||||
laeuft = (
|
laeuft = (
|
||||||
letzter_dreh["ergebnis"]
|
letzter_dreh["ergebnis"]
|
||||||
and time.monotonic() - letzter_dreh["start"] < suggestions.DREH_DAUER
|
and time.monotonic() - letzter_dreh["start"] < letzter_dreh["ergebnis"]["dauer"]
|
||||||
)
|
)
|
||||||
if laeuft:
|
if laeuft:
|
||||||
return {"started": False, "reason": "dreht bereits"}
|
return {"started": False, "reason": "dreht bereits"}
|
||||||
@@ -283,7 +283,7 @@ def letzter():
|
|||||||
return {"running": False, "result": None}
|
return {"running": False, "result": None}
|
||||||
vergangen = time.monotonic() - letzter_dreh["start"]
|
vergangen = time.monotonic() - letzter_dreh["start"]
|
||||||
return {
|
return {
|
||||||
"running": vergangen < suggestions.DREH_DAUER,
|
"running": vergangen < ergebnis["dauer"],
|
||||||
# Wer später dazukommt, überspringt den bereits gelaufenen Teil
|
# Wer später dazukommt, überspringt den bereits gelaufenen Teil
|
||||||
"elapsed": round(vergangen, 2),
|
"elapsed": round(vergangen, 2),
|
||||||
"result": ergebnis,
|
"result": ergebnis,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Filmvorschläge fürs Rad.
|
"""Filmvorschläge für die Ziehung.
|
||||||
|
|
||||||
Jede Sitzung darf einen Film vorschlagen. Die Sitzung erkennt ein Cookie —
|
Jede Sitzung darf einen Film vorschlagen. Die Sitzung erkennt ein Cookie —
|
||||||
ohne Anmeldung geht es nicht genauer, und für einen Filmabend im Freundeskreis
|
ohne Anmeldung geht es nicht genauer, und für einen Filmabend im Freundeskreis
|
||||||
@@ -37,7 +37,19 @@ COOKIE = "filmabend_sid"
|
|||||||
# So lange läuft die Animation im Browser. Der Server merkt sich das
|
# So lange läuft die Animation im Browser. Der Server merkt sich das
|
||||||
# Ergebnis mit, damit eine Seite, die mitten im Dreh geladen wird, es
|
# Ergebnis mit, damit eine Seite, die mitten im Dreh geladen wird, es
|
||||||
# trotzdem bekommt.
|
# trotzdem bekommt.
|
||||||
DREH_DAUER = 4.2
|
DREH_DAUER = 11.2
|
||||||
|
|
||||||
|
# Die Ziehung läuft wie das Öffnen einer Lootbox: ein Streifen aus
|
||||||
|
# Postern rast vorbei und bremst auf dem Gewinner aus. Der Streifen ist
|
||||||
|
# so lang, und an dieser Stelle steht der Treffer.
|
||||||
|
STREIFEN_LAENGE = 64
|
||||||
|
STREIFEN_ZIEL = 56
|
||||||
|
|
||||||
|
# Manchmal bricht nach dem Halt die Halterung: die Bahn rutscht ein paar
|
||||||
|
# Poster zurück, und ein anderer gewinnt. So lange dauert das im Browser
|
||||||
|
# zusätzlich (Ruhe plus Rückfahrt — die Werte stehen auch in WheelTab.vue).
|
||||||
|
BRUCH_DAUER = 3.7
|
||||||
|
BRUCH_ANTEIL = 0.5
|
||||||
|
|
||||||
|
|
||||||
def init():
|
def init():
|
||||||
@@ -112,11 +124,19 @@ def ersetzen(session_id, movie, name=None):
|
|||||||
return anlegen(session_id, movie, name)
|
return anlegen(session_id, movie, name)
|
||||||
|
|
||||||
|
|
||||||
|
def _versatz():
|
||||||
|
"""Wo genau im Poster die Marke stehen bleibt — nie exakt mittig.
|
||||||
|
Jedes zweite Mal ganz knapp an der Kante: fast der andere."""
|
||||||
|
if random.random() < 0.5:
|
||||||
|
return round(random.choice((-1, 1)) * random.uniform(0.34, 0.46), 3)
|
||||||
|
return round(random.uniform(-0.3, 0.3), 3)
|
||||||
|
|
||||||
|
|
||||||
def ziehen():
|
def ziehen():
|
||||||
"""Server lost aus, damit alle Seiten dasselbe Ergebnis sehen.
|
"""Server lost aus, damit alle Seiten dasselbe Ergebnis sehen.
|
||||||
|
|
||||||
Der Zielwinkel gehört dazu: sonst dreht jede Seite woanders hin und
|
Der Streifen gehört dazu: sonst zeigt jede Seite andere Poster
|
||||||
der Zeiger stünde am Ende auf einem anderen Segment.
|
vorbeirauschen, und der Treffer stünde am Ende woanders.
|
||||||
"""
|
"""
|
||||||
eintraege = alle()
|
eintraege = alle()
|
||||||
if not eintraege:
|
if not eintraege:
|
||||||
@@ -130,26 +150,38 @@ def ziehen():
|
|||||||
}
|
}
|
||||||
frisch = [i for i, e in enumerate(eintraege) if e["movie_id"] not in gesehen]
|
frisch = [i for i, e in enumerate(eintraege) if e["movie_id"] not in gesehen]
|
||||||
treffer = random.choice(frisch) if frisch else random.randrange(len(eintraege))
|
treffer = random.choice(frisch) if frisch else random.randrange(len(eintraege))
|
||||||
schritt = 360 / len(eintraege)
|
|
||||||
runden = 5 + random.randrange(3)
|
# Blockweise gemischt: jeder Vorschlag kommt gleich oft vorbei
|
||||||
# Der Zeiger steht oben, also muss die Mitte des Zielsegments dorthin.
|
streifen = []
|
||||||
# Ein einzelner Vorschlag ist ein Vollkreis mit Cover — der soll nach
|
while len(streifen) < STREIFEN_LAENGE:
|
||||||
# dem Dreh aufrecht stehen, also bei 0° enden statt bei 180°.
|
block = list(range(len(eintraege)))
|
||||||
if len(eintraege) == 1:
|
random.shuffle(block)
|
||||||
winkel = runden * 360
|
streifen.extend(block)
|
||||||
else:
|
streifen = streifen[:STREIFEN_LAENGE]
|
||||||
winkel = runden * 360 + (360 - (treffer * schritt + schritt / 2))
|
|
||||||
|
# Wo die Bahn hält — beim Bruch zweimal, und erst der zweite Halt zählt
|
||||||
|
stopps = [{"position": STREIFEN_ZIEL, "versatz": _versatz()}]
|
||||||
|
ziel = STREIFEN_ZIEL
|
||||||
|
if random.random() < BRUCH_ANTEIL:
|
||||||
|
ziel = STREIFEN_ZIEL - random.randint(1, 4)
|
||||||
|
stopps.append({"position": ziel, "versatz": _versatz()})
|
||||||
|
# Der erste Halt zeigt einen anderen — sonst wäre der Bruch egal
|
||||||
|
andere = [i for i in range(len(eintraege)) if i != treffer]
|
||||||
|
streifen[STREIFEN_ZIEL] = random.choice(andere) if andere else treffer
|
||||||
|
streifen[ziel] = treffer
|
||||||
|
|
||||||
|
def karte(e):
|
||||||
|
return {k: v for k, v in e.items() if k not in ("session_id", "user_id")}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"index": treffer,
|
"index": treffer,
|
||||||
"winkel": winkel,
|
"streifen": streifen,
|
||||||
"dauer": DREH_DAUER,
|
"stopps": stopps,
|
||||||
"gewinner": {
|
"dauer": DREH_DAUER + (BRUCH_DAUER if len(stopps) > 1 else 0),
|
||||||
k: v
|
# Der Streifen zeigt diesen Stand, auch wenn danach jemand einen
|
||||||
for k, v in eintraege[treffer].items()
|
# Vorschlag dazutut
|
||||||
if k not in ("session_id", "user_id")
|
"karten": [karte(e) for e in eintraege],
|
||||||
},
|
"gewinner": karte(eintraege[treffer]),
|
||||||
"anzahl": len(eintraege),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { posterUrl } from '../images'
|
import { posterUrl } from '../images'
|
||||||
import { abonnieren } from '../live'
|
import { abonnieren } from '../live'
|
||||||
|
|
||||||
@@ -16,17 +16,114 @@ const items = ref([])
|
|||||||
const laden = ref(true)
|
const laden = ref(true)
|
||||||
const fehler = ref('')
|
const fehler = ref('')
|
||||||
|
|
||||||
const winkel = ref(0)
|
// Die Ziehung läuft wie das Öffnen einer Lootbox: ein Streifen aus
|
||||||
|
// Postern rast unter der Marke durch und bremst auf dem Gewinner aus.
|
||||||
|
const KACHEL = 220
|
||||||
|
const LUECKE = 10
|
||||||
|
const SCHRITT = KACHEL + LUECKE
|
||||||
|
// Die Ziehung läuft in Phasen: Anlauf (die Marke pulst), Fahrt (die Bahn
|
||||||
|
// rast los und kriecht am Ende). Jedes zweite Mal bricht danach die
|
||||||
|
// Halterung: kurze Ruhe, dann rollt die Bahn ein paar Poster zurück. Der
|
||||||
|
// Server nennt die Gesamtdauer und die Halte, die Phasenlängen stehen
|
||||||
|
// hier — Ruhe plus Rückfahrt müssen zu BRUCH_DAUER im Backend passen.
|
||||||
|
const ANLAUF = 1.0
|
||||||
|
const BRUCH_RUHE = 1.2
|
||||||
|
const BRUCH_FAHRT = 2.5
|
||||||
|
// Kurze Ruhe zwischen Stillstand und Enthüllung — der Moment vor dem Glow
|
||||||
|
const ENTHUELLUNG = 0.6
|
||||||
|
|
||||||
|
const ergebnis = ref(null)
|
||||||
const dreht = ref(false)
|
const dreht = ref(false)
|
||||||
const gewinner = ref(null)
|
const gewinner = ref(null)
|
||||||
const dauer = ref(4.2)
|
const dauer = ref(0)
|
||||||
|
const x = ref(0)
|
||||||
|
const anlauf = ref(false)
|
||||||
|
const bruch = ref(false)
|
||||||
|
// Fahrt: schnell weg, lange kriechen. Bruch: sanft ausrollen.
|
||||||
|
const KURVEN = {
|
||||||
|
fahrt: 'cubic-bezier(0.15, 0.7, 0.3, 1)',
|
||||||
|
bruch: 'cubic-bezier(0.2, 0.6, 0.3, 1)',
|
||||||
|
}
|
||||||
|
const kurve = ref(KURVEN.fahrt)
|
||||||
|
const bahn = ref(null)
|
||||||
|
let timer = null
|
||||||
|
let enthuellung = null
|
||||||
|
|
||||||
const GROESSE = 420
|
// ── Ton ─────────────────────────────────────────────────────────────
|
||||||
const MITTE = GROESSE / 2
|
// Ein Tick je Poster, das unter der Marke durchläuft, und ein Klang zur
|
||||||
const RADIUS = MITTE - 6
|
// Enthüllung. Alles synthetisch, keine Dateien. Der Browser gibt Ton erst
|
||||||
|
// nach einer Eingabe frei — wer nur zuschaut und nie geklickt hat, hört
|
||||||
|
// nichts, sieht aber alles.
|
||||||
|
let audio = null
|
||||||
|
|
||||||
|
function klang() {
|
||||||
|
if (!audio) audio = new AudioContext()
|
||||||
|
if (audio.state === 'suspended') audio.resume()
|
||||||
|
return audio
|
||||||
|
}
|
||||||
|
|
||||||
|
function ton(frequenz, dauer, lautstaerke, art = 'triangle', start = 0, bis = null) {
|
||||||
|
const a = klang()
|
||||||
|
const t = a.currentTime + start
|
||||||
|
const osc = a.createOscillator()
|
||||||
|
const gain = a.createGain()
|
||||||
|
osc.type = art
|
||||||
|
osc.frequency.value = frequenz
|
||||||
|
if (bis) osc.frequency.linearRampToValueAtTime(bis, t + dauer)
|
||||||
|
gain.gain.setValueAtTime(lautstaerke, t)
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.0005, t + dauer)
|
||||||
|
osc.connect(gain).connect(a.destination)
|
||||||
|
osc.start(t)
|
||||||
|
osc.stop(t + dauer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schnell: hell und knapp. Beim Kriechen: tiefer, leiser, länger —
|
||||||
|
// man hört das Bremsen.
|
||||||
|
function tick(tempo) {
|
||||||
|
const a = Math.min(1, tempo / 3000)
|
||||||
|
ton(700 + 800 * a, 0.04 + 0.06 * (1 - a), 0.05 + 0.07 * a)
|
||||||
|
}
|
||||||
|
|
||||||
|
function anlaufTon(sekunden) {
|
||||||
|
ton(150, sekunden, 0.08, 'sine', 0, 520)
|
||||||
|
}
|
||||||
|
|
||||||
|
function knacken() {
|
||||||
|
ton(120, 0.12, 0.25, 'square')
|
||||||
|
ton(80, 0.3, 0.2, 'sawtooth', 0.08)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fanfare() {
|
||||||
|
ton(660, 0.5, 0.12, 'sine')
|
||||||
|
ton(990, 0.7, 0.1, 'sine', 0.12)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Liest die laufende Position aus dem Übergang und tickt bei jedem
|
||||||
|
// Kachelwechsel unter der Marke. Das Tempo kommt aus zwei Frames.
|
||||||
|
let zuletzt = -1
|
||||||
|
let letzteX = 0
|
||||||
|
let letzteZeit = 0
|
||||||
|
|
||||||
|
function ticken(zeit) {
|
||||||
|
if (!dreht.value || !bahn.value) return
|
||||||
|
const m = new DOMMatrixReadOnly(getComputedStyle(bahn.value).transform)
|
||||||
|
const idx = Math.floor((-m.m41 + LUECKE / 2) / SCHRITT)
|
||||||
|
const dt = (zeit - letzteZeit) / 1000
|
||||||
|
const tempo = letzteZeit && dt > 0 ? Math.abs(m.m41 - letzteX) / dt : 0
|
||||||
|
letzteX = m.m41
|
||||||
|
letzteZeit = zeit
|
||||||
|
if (zuletzt === -1) {
|
||||||
|
// Die Startkachel zählt nicht — sonst tickt es vor dem Losfahren
|
||||||
|
zuletzt = idx
|
||||||
|
} else if (idx !== zuletzt) {
|
||||||
|
zuletzt = idx
|
||||||
|
tick(tempo)
|
||||||
|
}
|
||||||
|
requestAnimationFrame(ticken)
|
||||||
|
}
|
||||||
|
|
||||||
// Kräftige, gut unterscheidbare Töne — nicht die Akzentfarbe, sonst
|
// Kräftige, gut unterscheidbare Töne — nicht die Akzentfarbe, sonst
|
||||||
// verschwimmt der Zeiger mit dem Rad
|
// verschwimmt die Marke mit dem Rand
|
||||||
const FARBEN = [
|
const FARBEN = [
|
||||||
'#b3202a', '#1f6f8b', '#c9772b', '#4c6b2f', '#6d3f8f',
|
'#b3202a', '#1f6f8b', '#c9772b', '#4c6b2f', '#6d3f8f',
|
||||||
'#9c2d5c', '#2b7a6f', '#8a6a1f', '#3a4a8f', '#7a3520',
|
'#9c2d5c', '#2b7a6f', '#8a6a1f', '#3a4a8f', '#7a3520',
|
||||||
@@ -43,25 +140,114 @@ function verbinden() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function abspielen(ergebnis, uebersprungen = 0) {
|
// Die Bahn hängt mit ihrem Anfang an der Fenstermitte. So landet die
|
||||||
|
// Zielkachel unter der Marke, egal wie breit das Fenster ist.
|
||||||
|
function stoppX(stopp) {
|
||||||
|
return -(stopp.position * SCHRITT + KACHEL / 2 + stopp.versatz * KACHEL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vor dem ersten Dreh stehen die Vorschläge einfach in einer Reihe
|
||||||
|
function ruheX() {
|
||||||
|
return -(items.value.length * SCHRITT - LUECKE) / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
function fahren(sekunden, k, ziel) {
|
||||||
|
kurve.value = k
|
||||||
|
dauer.value = sekunden
|
||||||
|
x.value = ziel
|
||||||
|
}
|
||||||
|
|
||||||
|
function ticksStarten() {
|
||||||
|
zuletzt = -1
|
||||||
|
letzteZeit = 0
|
||||||
|
requestAnimationFrame(ticken)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jede Phase hat eine Dauer, einen Start (mit der Restzeit, die sie noch
|
||||||
|
// hat) und ein Ende. Wer später dazukommt, überspringt die vorderen.
|
||||||
|
function phasen(erg) {
|
||||||
|
const erst = erg.stopps[0]
|
||||||
|
const ende = erg.stopps.at(-1)
|
||||||
|
const extra = erg.stopps.length > 1 ? BRUCH_RUHE + BRUCH_FAHRT : 0
|
||||||
|
const fahrt = erg.dauer - ANLAUF - extra
|
||||||
|
const liste = [
|
||||||
|
{
|
||||||
|
d: ANLAUF,
|
||||||
|
start(rest) {
|
||||||
|
anlauf.value = true
|
||||||
|
if (rest > 0) anlaufTon(rest)
|
||||||
|
},
|
||||||
|
ende() {
|
||||||
|
anlauf.value = false
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
d: fahrt,
|
||||||
|
start(rest) {
|
||||||
|
fahren(rest, KURVEN.fahrt, stoppX(erst))
|
||||||
|
ticksStarten()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
if (erg.stopps.length > 1) {
|
||||||
|
liste.push(
|
||||||
|
{ d: BRUCH_RUHE },
|
||||||
|
{
|
||||||
|
d: BRUCH_FAHRT,
|
||||||
|
start(rest) {
|
||||||
|
bruch.value = true
|
||||||
|
if (rest > 0) knacken()
|
||||||
|
fahren(rest, KURVEN.bruch, stoppX(ende))
|
||||||
|
},
|
||||||
|
ende() {
|
||||||
|
bruch.value = false
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return liste
|
||||||
|
}
|
||||||
|
|
||||||
|
function ablauf(erg, liste, i, uebersprungen) {
|
||||||
|
if (i >= liste.length) {
|
||||||
|
dreht.value = false
|
||||||
|
// Erst Stille, dann der Glow
|
||||||
|
enthuellung = window.setTimeout(() => {
|
||||||
|
gewinner.value = erg.gewinner
|
||||||
|
fanfare()
|
||||||
|
}, ENTHUELLUNG * 1000)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const ph = liste[i]
|
||||||
|
if (uebersprungen >= ph.d) {
|
||||||
|
// Schon vorbei: nur den Endzustand setzen
|
||||||
|
ph.start?.(0)
|
||||||
|
ph.ende?.()
|
||||||
|
ablauf(erg, liste, i + 1, uebersprungen - ph.d)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const rest = ph.d - uebersprungen
|
||||||
|
ph.start?.(rest)
|
||||||
|
timer = window.setTimeout(() => {
|
||||||
|
ph.ende?.()
|
||||||
|
ablauf(erg, liste, i + 1, 0)
|
||||||
|
}, rest * 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function abspielen(erg, uebersprungen = 0) {
|
||||||
|
clearTimeout(timer)
|
||||||
|
clearTimeout(enthuellung)
|
||||||
gewinner.value = null
|
gewinner.value = null
|
||||||
|
ergebnis.value = erg
|
||||||
dreht.value = true
|
dreht.value = true
|
||||||
|
|
||||||
const rest = Math.max(0, ergebnis.dauer - uebersprungen)
|
// Erst ohne Übergang an den Anfang, sonst fährt die Bahn vom letzten
|
||||||
if (uebersprungen > 0) {
|
// Ergebnis aus los
|
||||||
// Wer mitten im Dreh dazukommt, springt an die passende Stelle
|
fahren(0, KURVEN.fahrt, -3 * SCHRITT)
|
||||||
dauer.value = rest
|
await nextTick()
|
||||||
} else {
|
void bahn.value?.offsetWidth
|
||||||
dauer.value = ergebnis.dauer
|
|
||||||
}
|
|
||||||
|
|
||||||
// Immer vorwärts drehen, egal wie oft schon gedreht wurde
|
ablauf(erg, phasen(erg), 0, uebersprungen)
|
||||||
winkel.value = winkel.value - (winkel.value % 360) + ergebnis.winkel
|
|
||||||
|
|
||||||
window.setTimeout(() => {
|
|
||||||
dreht.value = false
|
|
||||||
gewinner.value = ergebnis.gewinner
|
|
||||||
}, rest * 1000)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function laufendenDrehHolen() {
|
async function laufendenDrehHolen() {
|
||||||
@@ -73,10 +259,10 @@ async function laufendenDrehHolen() {
|
|||||||
if (daten.running) {
|
if (daten.running) {
|
||||||
abspielen(daten.result, daten.elapsed)
|
abspielen(daten.result, daten.elapsed)
|
||||||
} else {
|
} else {
|
||||||
// Dreh ist durch: Rad ohne Animation auf die Endstellung setzen,
|
// Dreh ist durch: Bahn ohne Animation auf die Endstellung setzen,
|
||||||
// sonst zeigt der Zeiger woanders hin als die Gewinnerkarte
|
// sonst steht die Marke woanders als die Gewinnerkarte
|
||||||
dauer.value = 0
|
ergebnis.value = daten.result
|
||||||
winkel.value = daten.result.winkel
|
fahren(0, KURVEN.fahrt, stoppX(daten.result.stopps.at(-1)))
|
||||||
gewinner.value = daten.result.gewinner
|
gewinner.value = daten.result.gewinner
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -92,6 +278,10 @@ async function laden_() {
|
|||||||
if (!resp.ok) throw new Error('Vorschläge konnten nicht geladen werden')
|
if (!resp.ok) throw new Error('Vorschläge konnten nicht geladen werden')
|
||||||
const daten = await resp.json()
|
const daten = await resp.json()
|
||||||
items.value = daten.items
|
items.value = daten.items
|
||||||
|
if (!ergebnis.value) {
|
||||||
|
dauer.value = 0
|
||||||
|
x.value = ruheX()
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
fehler.value = err.message
|
fehler.value = err.message
|
||||||
} finally {
|
} finally {
|
||||||
@@ -105,91 +295,52 @@ onMounted(async () => {
|
|||||||
verbinden()
|
verbinden()
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => abbestellen?.())
|
onUnmounted(() => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
clearTimeout(enthuellung)
|
||||||
|
abbestellen?.()
|
||||||
|
})
|
||||||
|
|
||||||
defineExpose({ neu: laden_ })
|
defineExpose({ neu: laden_ })
|
||||||
|
|
||||||
const segmente = computed(() => {
|
// Läuft eine Ziehung, zeigt die Bahn deren Streifen — sonst die Vorschläge
|
||||||
const n = items.value.length
|
const kacheln = computed(() => {
|
||||||
if (!n) return []
|
const erg = ergebnis.value
|
||||||
const schritt = 360 / n
|
if (erg) {
|
||||||
return items.value.map((item, i) => {
|
const ziel = erg.stopps.at(-1).position
|
||||||
const start = i * schritt
|
return erg.streifen.map((i, k) => ({
|
||||||
const ende = start + schritt
|
key: k,
|
||||||
const mitte = start + schritt / 2
|
film: erg.karten[i],
|
||||||
// Auf der linken Hälfte stünde die Schrift kopfüber — dort läuft sie
|
|
||||||
// von außen nach innen
|
|
||||||
const gespiegelt = mitte > 180
|
|
||||||
// Das Cover liegt als Rechteck radial im Stück: von der Nabe nach
|
|
||||||
// außen, so breit wie die Sehne am Rand. So landet die Postermitte
|
|
||||||
// auf halbem Radius — dort, wo das Stück am besten zu sehen ist.
|
|
||||||
// Ein einziger Vorschlag ist ein Vollkreis — da gibt es keine Sehne,
|
|
||||||
// das Bild deckt dann den ganzen Kreis als Quadrat ab.
|
|
||||||
const voll = n === 1
|
|
||||||
const sehne = 2 * RADIUS * Math.sin(((schritt / 2) * Math.PI) / 180)
|
|
||||||
const bildBreite = voll ? 2 * RADIUS : Math.max(sehne + 12, 60)
|
|
||||||
return {
|
|
||||||
item,
|
|
||||||
farbe: FARBEN[i % FARBEN.length],
|
farbe: FARBEN[i % FARBEN.length],
|
||||||
pfad: kreisstueck(start, ende),
|
treffer: k === ziel,
|
||||||
bild: posterUrl(item.poster_path, 'w342'),
|
}))
|
||||||
bildX: MITTE - bildBreite / 2,
|
|
||||||
bildY: MITTE - RADIUS - (voll ? 0 : 6),
|
|
||||||
bildBreite,
|
|
||||||
bildHoehe: voll ? 2 * RADIUS : RADIUS + 6,
|
|
||||||
// Im Vollkreis läge die Segmentmitte bei 180° — das Cover stünde
|
|
||||||
// kopfüber. Ohne Drehung bleibt es aufrecht.
|
|
||||||
bildDreh: voll ? 0 : mitte,
|
|
||||||
mitte,
|
|
||||||
textDreh: gespiegelt ? mitte + 90 : mitte - 90,
|
|
||||||
gespiegelt,
|
|
||||||
// Unter dieser Segmentbreite passt kein lesbares Wort mehr hinein
|
|
||||||
breit: schritt > 9,
|
|
||||||
kurz: kuerzen(item.title, schritt),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
function kuerzen(titel, schritt) {
|
|
||||||
// Je schmaler das Segment, desto weniger Zeichen bleiben lesbar
|
|
||||||
const max = schritt > 30 ? 24 : schritt > 18 ? 18 : 13
|
|
||||||
return titel.length > max ? titel.slice(0, max - 1) + '…' : titel
|
|
||||||
}
|
|
||||||
|
|
||||||
function punkt(grad) {
|
|
||||||
const rad = ((grad - 90) * Math.PI) / 180
|
|
||||||
return [MITTE + RADIUS * Math.cos(rad), MITTE + RADIUS * Math.sin(rad)]
|
|
||||||
}
|
|
||||||
|
|
||||||
function kreisstueck(von, bis) {
|
|
||||||
// Ein einzelner Vorschlag ergäbe einen Vollkreis, den ein Pfad nicht
|
|
||||||
// zeichnen kann — dann bleibt es bei zwei Halbkreisen
|
|
||||||
if (bis - von >= 360) {
|
|
||||||
return `M ${MITTE} ${MITTE - RADIUS} A ${RADIUS} ${RADIUS} 0 1 1 ${MITTE} ${
|
|
||||||
MITTE + RADIUS
|
|
||||||
} A ${RADIUS} ${RADIUS} 0 1 1 ${MITTE} ${MITTE - RADIUS} Z`
|
|
||||||
}
|
}
|
||||||
const [x1, y1] = punkt(von)
|
return items.value.map((item, i) => ({
|
||||||
const [x2, y2] = punkt(bis)
|
key: i,
|
||||||
const gross = bis - von > 180 ? 1 : 0
|
film: item,
|
||||||
return `M ${MITTE} ${MITTE} L ${x1} ${y1} A ${RADIUS} ${RADIUS} 0 ${gross} 1 ${x2} ${y2} Z`
|
farbe: FARBEN[i % FARBEN.length],
|
||||||
}
|
treffer: false,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
async function drehen() {
|
async function drehen() {
|
||||||
if (dreht.value || !items.value.length) return
|
if (dreht.value || !items.value.length) return
|
||||||
|
klang()
|
||||||
// Der Server lost aus und schickt das Ergebnis an alle — auch an uns
|
// Der Server lost aus und schickt das Ergebnis an alle — auch an uns
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/spin', { method: 'POST' })
|
const resp = await fetch('/api/spin', { method: 'POST' })
|
||||||
if (!resp.ok) fehler.value = 'Drehen hat nicht geklappt.'
|
if (!resp.ok) fehler.value = 'Ziehen hat nicht geklappt.'
|
||||||
} catch {
|
} catch {
|
||||||
fehler.value = 'Drehen hat nicht geklappt.'
|
fehler.value = 'Ziehen hat nicht geklappt.'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function zurueckgesetzt() {
|
function zurueckgesetzt() {
|
||||||
// Ohne Dauer 0 würde das Rad die Strecke zurück sichtbar abfahren
|
clearTimeout(timer)
|
||||||
dauer.value = 0
|
clearTimeout(enthuellung)
|
||||||
winkel.value = 0
|
anlauf.value = false
|
||||||
|
bruch.value = false
|
||||||
|
ergebnis.value = null
|
||||||
gewinner.value = null
|
gewinner.value = null
|
||||||
dreht.value = false
|
dreht.value = false
|
||||||
emit('reset')
|
emit('reset')
|
||||||
@@ -251,70 +402,45 @@ async function oeffnen(item) {
|
|||||||
<div class="buehne">
|
<div class="buehne">
|
||||||
<p v-if="fehler" class="note error">{{ fehler }}</p>
|
<p v-if="fehler" class="note error">{{ fehler }}</p>
|
||||||
|
|
||||||
<div class="rad-halter">
|
<div class="fenster">
|
||||||
<div class="zeiger"></div>
|
<div class="marke" :class="{ anlauf, bruch }"></div>
|
||||||
<svg
|
<div
|
||||||
class="rad"
|
ref="bahn"
|
||||||
|
class="bahn"
|
||||||
:class="{ dreht }"
|
:class="{ dreht }"
|
||||||
:viewBox="`0 0 ${GROESSE} ${GROESSE}`"
|
|
||||||
:style="{
|
:style="{
|
||||||
transform: `rotate(${winkel}deg)`,
|
transform: `translateX(${x}px)`,
|
||||||
transitionDuration: `${dauer}s`,
|
transitionDuration: `${dauer}s`,
|
||||||
|
transitionTimingFunction: kurve,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<defs>
|
<div
|
||||||
<!-- Zur Nabe hin wird es dunkel, damit der Titel lesbar bleibt -->
|
v-for="k in kacheln"
|
||||||
<radialGradient
|
:key="k.key"
|
||||||
id="rad-schatten"
|
class="kachel"
|
||||||
gradientUnits="userSpaceOnUse"
|
:class="{
|
||||||
:cx="MITTE"
|
treffer: k.treffer && !dreht && gewinner,
|
||||||
:cy="MITTE"
|
gesehen: gesehenIds.has(k.film.movie_id),
|
||||||
:r="RADIUS"
|
}"
|
||||||
>
|
:style="{ borderColor: k.farbe }"
|
||||||
<stop offset="0" stop-color="#0a0a0c" stop-opacity="0.92" />
|
>
|
||||||
<stop offset="0.45" stop-color="#0a0a0c" stop-opacity="0.55" />
|
<img
|
||||||
<stop offset="1" stop-color="#0a0a0c" stop-opacity="0.1" />
|
v-if="posterUrl(k.film.poster_path, 'w342')"
|
||||||
</radialGradient>
|
:src="posterUrl(k.film.poster_path, 'w342')"
|
||||||
<clipPath v-for="seg in segmente" :id="`seg-${seg.item.id}`" :key="seg.item.id">
|
:alt="k.film.title"
|
||||||
<path :d="seg.pfad" />
|
draggable="false"
|
||||||
</clipPath>
|
/>
|
||||||
</defs>
|
<span v-else class="ohne-cover">{{ k.film.title }}</span>
|
||||||
<circle :cx="MITTE" :cy="MITTE" :r="RADIUS" fill="var(--bg-soft)" />
|
</div>
|
||||||
<g v-for="(seg, i) in segmente" :key="seg.item.id">
|
</div>
|
||||||
<g :clip-path="`url(#seg-${seg.item.id})`">
|
<p v-if="!items.length && !laden && !ergebnis" class="leer-bahn">
|
||||||
<path :d="seg.pfad" :fill="seg.farbe" />
|
Noch nichts vorgeschlagen.
|
||||||
<image
|
</p>
|
||||||
v-if="seg.bild"
|
|
||||||
:href="seg.bild"
|
|
||||||
:x="seg.bildX"
|
|
||||||
:y="seg.bildY"
|
|
||||||
:width="seg.bildBreite"
|
|
||||||
:height="seg.bildHoehe"
|
|
||||||
preserveAspectRatio="xMidYMid slice"
|
|
||||||
:transform="`rotate(${seg.bildDreh} ${MITTE} ${MITTE})`"
|
|
||||||
/>
|
|
||||||
<path :d="seg.pfad" fill="url(#rad-schatten)" />
|
|
||||||
</g>
|
|
||||||
<!-- Der farbige Rand ist das Gegenstück zum Punkt in der Liste -->
|
|
||||||
<path :d="seg.pfad" fill="none" :stroke="seg.farbe" stroke-width="3" />
|
|
||||||
<text
|
|
||||||
v-if="seg.breit"
|
|
||||||
:transform="`rotate(${seg.textDreh} ${MITTE} ${MITTE})`"
|
|
||||||
:x="seg.gespiegelt ? MITTE - 40 : MITTE + 40"
|
|
||||||
:y="MITTE + 4"
|
|
||||||
:text-anchor="seg.gespiegelt ? 'end' : 'start'"
|
|
||||||
class="beschriftung"
|
|
||||||
>
|
|
||||||
{{ seg.kurz }}
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
<circle :cx="MITTE" :cy="MITTE" r="26" fill="#0a0a0c" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="host" class="knoepfe">
|
<div v-if="host" class="knoepfe">
|
||||||
<button class="dreh" type="button" :disabled="dreht || !items.length" @click="drehen">
|
<button class="dreh" type="button" :disabled="dreht || !items.length" @click="drehen">
|
||||||
{{ dreht ? 'dreht…' : 'Drehen' }}
|
{{ dreht ? 'läuft…' : 'Ziehen' }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="reset"
|
class="reset"
|
||||||
@@ -413,43 +539,162 @@ async function oeffnen(item) {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1.25rem;
|
gap: 1.25rem;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rad-halter {
|
/* ── Die Bahn ────────────────────────────────────────────────────── */
|
||||||
|
.fenster {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: min(420px, 80vw);
|
align-self: stretch;
|
||||||
|
height: 370px;
|
||||||
|
overflow: hidden;
|
||||||
|
/* Links und rechts läuft der Streifen ins Dunkel aus */
|
||||||
|
mask-image: linear-gradient(to right, transparent, #000 12%, #000 88%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rad {
|
.bahn {
|
||||||
|
position: absolute;
|
||||||
|
top: 20px;
|
||||||
|
left: 50%;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
will-change: transform;
|
||||||
|
/* Die Kurve setzt das Skript je Phase */
|
||||||
|
transition-property: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kachel {
|
||||||
|
position: relative;
|
||||||
|
flex: 0 0 220px;
|
||||||
|
height: 330px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-soft);
|
||||||
|
border: 2px solid;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: transform 0.4s, box-shadow 0.4s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kachel img {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
/* Ohne Übergang springt das Rad einfach zum Ergebnis */
|
height: 100%;
|
||||||
transition-property: transform;
|
object-fit: cover;
|
||||||
transition-timing-function: cubic-bezier(0.17, 0.67, 0.12, 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.beschriftung {
|
.ohne-cover {
|
||||||
fill: #fff;
|
display: grid;
|
||||||
font-size: 13px;
|
height: 100%;
|
||||||
|
padding: 0.6rem;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
/* Unter der Schrift liegt jetzt ein Cover statt einer Fläche — die
|
text-align: center;
|
||||||
Kontur muss auch auf hellen Bildstellen tragen */
|
|
||||||
paint-order: stroke;
|
|
||||||
stroke: rgba(0, 0, 0, 0.75);
|
|
||||||
stroke-width: 3.5px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.zeiger {
|
/* Schon gelaufen — die Ziehung überspringt diesen Film */
|
||||||
|
.kachel.gesehen {
|
||||||
|
opacity: 0.45;
|
||||||
|
filter: grayscale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Der Gewinner blitzt auf, sobald die Bahn steht */
|
||||||
|
.kachel.treffer {
|
||||||
|
z-index: 1;
|
||||||
|
transform: scale(1.06);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent), 0 0 34px var(--accent);
|
||||||
|
animation: aufblitzen 0.7s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes aufblitzen {
|
||||||
|
0% {
|
||||||
|
transform: scale(1);
|
||||||
|
box-shadow: 0 0 0 0 #fff, 0 0 0 #fff;
|
||||||
|
}
|
||||||
|
35% {
|
||||||
|
transform: scale(1.14);
|
||||||
|
box-shadow: 0 0 0 4px #fff, 0 0 60px #fff;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1.06);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent), 0 0 34px var(--accent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.marke {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -6px;
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
|
width: 3px;
|
||||||
|
margin-left: -1.5px;
|
||||||
|
background: var(--accent);
|
||||||
|
box-shadow: 0 0 10px var(--accent);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Im Anlauf pulst die Marke dreimal — gleich geht es los */
|
||||||
|
.marke.anlauf {
|
||||||
|
animation: puls 0.33s ease-in-out 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes puls {
|
||||||
|
50% {
|
||||||
|
transform: scaleX(2.5);
|
||||||
|
box-shadow: 0 0 26px var(--accent), 0 0 6px #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Die Halterung bricht: die Marke schlackert kurz */
|
||||||
|
.marke.bruch {
|
||||||
|
transform-origin: top center;
|
||||||
|
animation: wackeln 0.6s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes wackeln {
|
||||||
|
20% {
|
||||||
|
transform: rotate(9deg);
|
||||||
|
}
|
||||||
|
45% {
|
||||||
|
transform: rotate(-7deg);
|
||||||
|
}
|
||||||
|
70% {
|
||||||
|
transform: rotate(4deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.marke::before,
|
||||||
|
.marke::after {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
width: 0;
|
width: 0;
|
||||||
height: 0;
|
height: 0;
|
||||||
margin-left: -11px;
|
margin-left: -9px;
|
||||||
border-top: 20px solid var(--text);
|
content: '';
|
||||||
border-right: 11px solid transparent;
|
border-right: 9px solid transparent;
|
||||||
border-left: 11px solid transparent;
|
border-left: 9px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marke::before {
|
||||||
|
top: 0;
|
||||||
|
border-top: 14px solid var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.marke::after {
|
||||||
|
bottom: 0;
|
||||||
|
border-bottom: 14px solid var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.leer-bahn {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.knoepfe {
|
.knoepfe {
|
||||||
@@ -547,13 +792,25 @@ async function oeffnen(item) {
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
max-width: 420px;
|
max-width: 640px;
|
||||||
padding: 0.9rem;
|
padding: 0.9rem;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: var(--bg-soft);
|
background: var(--bg-soft);
|
||||||
border: 1px solid var(--accent);
|
border: 1px solid var(--accent);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
|
animation: aufsteigen 0.45s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes aufsteigen {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(14px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.gewinner img {
|
.gewinner img {
|
||||||
@@ -630,7 +887,7 @@ async function oeffnen(item) {
|
|||||||
background: var(--bg-soft);
|
background: var(--bg-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Der farbige Rand verbindet den Eintrag mit seinem Stück auf dem Rad */
|
/* Der farbige Rand verbindet den Eintrag mit seinen Kacheln auf der Bahn */
|
||||||
.cover {
|
.cover {
|
||||||
display: block;
|
display: block;
|
||||||
width: 46px;
|
width: 46px;
|
||||||
|
|||||||
Reference in New Issue
Block a user