This commit is contained in:
team3
2026-09-16 15:03:56 +02:00
parent a639e40a23
commit bfaf0313c1
3 changed files with 484 additions and 195 deletions

View File

@@ -1,5 +1,5 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import { posterUrl } from '../images'
import { abonnieren } from '../live'
@@ -16,17 +16,114 @@ const items = ref([])
const laden = ref(true)
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 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
const MITTE = GROESSE / 2
const RADIUS = MITTE - 6
// ── Ton ─────────────────────────────────────────────────────────────
// Ein Tick je Poster, das unter der Marke durchläuft, und ein Klang zur
// 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
// verschwimmt der Zeiger mit dem Rad
// verschwimmt die Marke mit dem Rand
const FARBEN = [
'#b3202a', '#1f6f8b', '#c9772b', '#4c6b2f', '#6d3f8f',
'#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
ergebnis.value = erg
dreht.value = true
const rest = Math.max(0, ergebnis.dauer - uebersprungen)
if (uebersprungen > 0) {
// Wer mitten im Dreh dazukommt, springt an die passende Stelle
dauer.value = rest
} else {
dauer.value = ergebnis.dauer
}
// Erst ohne Übergang an den Anfang, sonst fährt die Bahn vom letzten
// Ergebnis aus los
fahren(0, KURVEN.fahrt, -3 * SCHRITT)
await nextTick()
void bahn.value?.offsetWidth
// Immer vorwärts drehen, egal wie oft schon gedreht wurde
winkel.value = winkel.value - (winkel.value % 360) + ergebnis.winkel
window.setTimeout(() => {
dreht.value = false
gewinner.value = ergebnis.gewinner
}, rest * 1000)
ablauf(erg, phasen(erg), 0, uebersprungen)
}
async function laufendenDrehHolen() {
@@ -73,10 +259,10 @@ async function laufendenDrehHolen() {
if (daten.running) {
abspielen(daten.result, daten.elapsed)
} else {
// Dreh ist durch: Rad ohne Animation auf die Endstellung setzen,
// sonst zeigt der Zeiger woanders hin als die Gewinnerkarte
dauer.value = 0
winkel.value = daten.result.winkel
// Dreh ist durch: Bahn ohne Animation auf die Endstellung setzen,
// sonst steht die Marke woanders als die Gewinnerkarte
ergebnis.value = daten.result
fahren(0, KURVEN.fahrt, stoppX(daten.result.stopps.at(-1)))
gewinner.value = daten.result.gewinner
}
} catch {
@@ -92,6 +278,10 @@ async function laden_() {
if (!resp.ok) throw new Error('Vorschläge konnten nicht geladen werden')
const daten = await resp.json()
items.value = daten.items
if (!ergebnis.value) {
dauer.value = 0
x.value = ruheX()
}
} catch (err) {
fehler.value = err.message
} finally {
@@ -105,91 +295,52 @@ onMounted(async () => {
verbinden()
})
onUnmounted(() => abbestellen?.())
onUnmounted(() => {
clearTimeout(timer)
clearTimeout(enthuellung)
abbestellen?.()
})
defineExpose({ neu: laden_ })
const segmente = computed(() => {
const n = items.value.length
if (!n) return []
const schritt = 360 / n
return items.value.map((item, i) => {
const start = i * schritt
const ende = start + schritt
const mitte = start + schritt / 2
// 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,
// Läuft eine Ziehung, zeigt die Bahn deren Streifen — sonst die Vorschläge
const kacheln = computed(() => {
const erg = ergebnis.value
if (erg) {
const ziel = erg.stopps.at(-1).position
return erg.streifen.map((i, k) => ({
key: k,
film: erg.karten[i],
farbe: FARBEN[i % FARBEN.length],
pfad: kreisstueck(start, ende),
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`
treffer: k === ziel,
}))
}
const [x1, y1] = punkt(von)
const [x2, y2] = punkt(bis)
const gross = bis - von > 180 ? 1 : 0
return `M ${MITTE} ${MITTE} L ${x1} ${y1} A ${RADIUS} ${RADIUS} 0 ${gross} 1 ${x2} ${y2} Z`
}
return items.value.map((item, i) => ({
key: i,
film: item,
farbe: FARBEN[i % FARBEN.length],
treffer: false,
}))
})
async function drehen() {
if (dreht.value || !items.value.length) return
klang()
// Der Server lost aus und schickt das Ergebnis an alle — auch an uns
try {
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 {
fehler.value = 'Drehen hat nicht geklappt.'
fehler.value = 'Ziehen hat nicht geklappt.'
}
}
function zurueckgesetzt() {
// Ohne Dauer 0 würde das Rad die Strecke zurück sichtbar abfahren
dauer.value = 0
winkel.value = 0
clearTimeout(timer)
clearTimeout(enthuellung)
anlauf.value = false
bruch.value = false
ergebnis.value = null
gewinner.value = null
dreht.value = false
emit('reset')
@@ -251,70 +402,45 @@ async function oeffnen(item) {
<div class="buehne">
<p v-if="fehler" class="note error">{{ fehler }}</p>
<div class="rad-halter">
<div class="zeiger"></div>
<svg
class="rad"
<div class="fenster">
<div class="marke" :class="{ anlauf, bruch }"></div>
<div
ref="bahn"
class="bahn"
:class="{ dreht }"
:viewBox="`0 0 ${GROESSE} ${GROESSE}`"
:style="{
transform: `rotate(${winkel}deg)`,
transform: `translateX(${x}px)`,
transitionDuration: `${dauer}s`,
transitionTimingFunction: kurve,
}"
>
<defs>
<!-- Zur Nabe hin wird es dunkel, damit der Titel lesbar bleibt -->
<radialGradient
id="rad-schatten"
gradientUnits="userSpaceOnUse"
:cx="MITTE"
:cy="MITTE"
:r="RADIUS"
>
<stop offset="0" stop-color="#0a0a0c" stop-opacity="0.92" />
<stop offset="0.45" stop-color="#0a0a0c" stop-opacity="0.55" />
<stop offset="1" stop-color="#0a0a0c" stop-opacity="0.1" />
</radialGradient>
<clipPath v-for="seg in segmente" :id="`seg-${seg.item.id}`" :key="seg.item.id">
<path :d="seg.pfad" />
</clipPath>
</defs>
<circle :cx="MITTE" :cy="MITTE" :r="RADIUS" fill="var(--bg-soft)" />
<g v-for="(seg, i) in segmente" :key="seg.item.id">
<g :clip-path="`url(#seg-${seg.item.id})`">
<path :d="seg.pfad" :fill="seg.farbe" />
<image
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
v-for="k in kacheln"
:key="k.key"
class="kachel"
:class="{
treffer: k.treffer && !dreht && gewinner,
gesehen: gesehenIds.has(k.film.movie_id),
}"
:style="{ borderColor: k.farbe }"
>
<img
v-if="posterUrl(k.film.poster_path, 'w342')"
:src="posterUrl(k.film.poster_path, 'w342')"
:alt="k.film.title"
draggable="false"
/>
<span v-else class="ohne-cover">{{ k.film.title }}</span>
</div>
</div>
<p v-if="!items.length && !laden && !ergebnis" class="leer-bahn">
Noch nichts vorgeschlagen.
</p>
</div>
<div v-if="host" class="knoepfe">
<button class="dreh" type="button" :disabled="dreht || !items.length" @click="drehen">
{{ dreht ? 'dreht…' : 'Drehen' }}
{{ dreht ? 'läuft…' : 'Ziehen' }}
</button>
<button
class="reset"
@@ -413,43 +539,162 @@ async function oeffnen(item) {
flex-direction: column;
align-items: center;
gap: 1.25rem;
min-width: 0;
}
.rad-halter {
/* ── Die Bahn ────────────────────────────────────────────────────── */
.fenster {
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;
width: 100%;
/* Ohne Übergang springt das Rad einfach zum Ergebnis */
transition-property: transform;
transition-timing-function: cubic-bezier(0.17, 0.67, 0.12, 1);
height: 100%;
object-fit: cover;
}
.beschriftung {
fill: #fff;
font-size: 13px;
.ohne-cover {
display: grid;
height: 100%;
padding: 0.6rem;
place-items: center;
color: var(--muted);
font-size: 0.85rem;
font-weight: 600;
/* Unter der Schrift liegt jetzt ein Cover statt einer Fläche — die
Kontur muss auch auf hellen Bildstellen tragen */
paint-order: stroke;
stroke: rgba(0, 0, 0, 0.75);
stroke-width: 3.5px;
text-align: center;
}
.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;
top: -6px;
top: 0;
bottom: 0;
left: 50%;
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;
height: 0;
margin-left: -11px;
border-top: 20px solid var(--text);
border-right: 11px solid transparent;
border-left: 11px solid transparent;
margin-left: -9px;
content: '';
border-right: 9px 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 {
@@ -547,13 +792,25 @@ async function oeffnen(item) {
display: flex;
gap: 1rem;
align-items: center;
max-width: 420px;
max-width: 640px;
padding: 0.9rem;
text-align: left;
cursor: pointer;
background: var(--bg-soft);
border: 1px solid var(--accent);
border-radius: 12px;
animation: aufsteigen 0.45s ease-out;
}
@keyframes aufsteigen {
from {
opacity: 0;
transform: translateY(14px);
}
to {
opacity: 1;
transform: none;
}
}
.gewinner img {
@@ -630,7 +887,7 @@ async function oeffnen(item) {
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 {
display: block;
width: 46px;