- aether-medical: Stationen/Belegung, Intensivstation, Blutbank, Fuhrpark, Nachrichten/Aushänge, Berichte und Textbausteine (server + NUI) - aether-admin: vollständiges Server-Admin-Panel als echtes FiveM-Resource, an aether-core gekoppelt (Rechte über aether_users.admin_group), mit WebRTC-Live-Kameras (Bildschirmfreigabe) und separatem Media-Server (SFU) - Prototypen admin/ vervollständigt - SQL-Schema aether_admin.sql - Docker-Compose mit MariaDB und idempotenter Auto-Migration (schema_migrations) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
381 lines
20 KiB
HTML
381 lines
20 KiB
HTML
<!doctype html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<meta name="color-scheme" content="dark">
|
||
<title>Aether — Ladebildschirm</title>
|
||
<style>
|
||
:root{
|
||
--bg:#07070c; /* Grundhintergrund (sehr dunkel) */
|
||
--fg:#ece9f6; /* Standard-Textfarbe */
|
||
--muted:#8b86a6; /* gedämpfter Text (Labels/Sekundär) */
|
||
--surface:rgba(24,22,38,0.72); /* Panel-Hintergrund */
|
||
--surface2:rgba(38,34,60,0.85); /* hellere Panel-Fläche */
|
||
--border:rgba(255,255,255,0.08); /* dezente Rahmen */
|
||
--accent:#7c5cf6; /* Aether-Akzent (Violett) */
|
||
--accent2:#5b8cff; /* zweiter Akzent (Blau) für Verläufe */
|
||
--accent-soft:rgba(124,92,246,0.16); /* Akzent als Fläche */
|
||
--good:#34d39a; /* Erfolg/Bereit */
|
||
--ink:#160c2e; /* Text auf hellem Akzent */
|
||
}
|
||
*{box-sizing:border-box;}
|
||
html,body{margin:0;height:100%;}
|
||
body{
|
||
height:100%;background:var(--bg);color:var(--fg);overflow:hidden;
|
||
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
|
||
}
|
||
|
||
/* Vollflächiger Partikel-/Aurora-Hintergrund */
|
||
#bg{position:fixed;inset:0;z-index:0;}
|
||
.aurora{position:fixed;inset:-20% ;z-index:0;filter:blur(60px);opacity:0.55;pointer-events:none;
|
||
background:
|
||
radial-gradient(40% 40% at 20% 30%,rgba(124,92,246,0.55),transparent 60%),
|
||
radial-gradient(35% 45% at 80% 25%,rgba(91,140,255,0.45),transparent 60%),
|
||
radial-gradient(45% 40% at 60% 85%,rgba(124,92,246,0.35),transparent 60%);
|
||
animation:auroraMove 18s ease-in-out infinite alternate;}
|
||
@keyframes auroraMove{from{transform:translate3d(-2%,-1%,0) scale(1);}to{transform:translate3d(3%,2%,0) scale(1.08);}}
|
||
/* Dunkler Vignetten-Verlauf für Lesbarkeit über dem Hintergrund */
|
||
.vignette{position:fixed;inset:0;z-index:1;pointer-events:none;
|
||
background:radial-gradient(120% 90% at 50% 40%,transparent 30%,rgba(4,4,9,0.75) 100%);}
|
||
|
||
/* Zentrales Layout-Raster: Kopf · Mitte · Fuß */
|
||
.screen{position:relative;z-index:2;height:100%;display:grid;
|
||
grid-template-rows:auto 1fr auto;padding:34px clamp(24px,5vw,72px);gap:18px;}
|
||
|
||
/* Kopfzeile: Marke links, Server-Infos rechts */
|
||
.top{display:flex;align-items:center;justify-content:space-between;gap:20px;}
|
||
.brand{display:flex;align-items:center;gap:14px;}
|
||
.brand .mark{width:52px;height:52px;border-radius:15px;display:grid;place-items:center;font-size:26px;
|
||
background:linear-gradient(150deg,var(--accent),var(--accent2));box-shadow:0 12px 30px -8px rgba(124,92,246,0.6);}
|
||
.brand .name{font-size:24px;font-weight:800;letter-spacing:-0.02em;line-height:1;}
|
||
.brand .name span{background:linear-gradient(90deg,var(--accent),var(--accent2));-webkit-background-clip:text;background-clip:text;color:transparent;}
|
||
.brand .name small{display:block;font-size:11px;color:var(--muted);font-weight:600;margin-top:5px;letter-spacing:0.14em;text-transform:uppercase;}
|
||
|
||
.serverinfo{display:flex;gap:10px;}
|
||
.stat{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:9px 14px;text-align:right;backdrop-filter:blur(8px);}
|
||
.stat .l{font-size:9.5px;color:var(--muted);text-transform:uppercase;letter-spacing:0.08em;font-weight:800;}
|
||
.stat .v{font-size:16px;font-weight:800;font-variant-numeric:tabular-nums;}
|
||
.stat .v .on{color:var(--good);}
|
||
|
||
/* Mittelbereich: großer Titel + rotierendes Info-Karussell */
|
||
.middle{display:grid;grid-template-columns:1.1fr 0.9fr;align-items:center;gap:40px;}
|
||
.hero .kicker{color:var(--accent);font-weight:800;letter-spacing:0.18em;text-transform:uppercase;font-size:12px;margin-bottom:14px;}
|
||
.hero h1{font-size:clamp(46px,7vw,86px);margin:0;line-height:0.95;font-weight:900;letter-spacing:-0.03em;
|
||
background:linear-gradient(120deg,#fff 20%,var(--accent) 60%,var(--accent2));-webkit-background-clip:text;background-clip:text;color:transparent;}
|
||
.hero .sub{color:var(--muted);font-size:16px;margin-top:16px;max-width:460px;line-height:1.5;}
|
||
|
||
/* Karussell für News / Regeln / Tipps */
|
||
.carousel{background:var(--surface);border:1px solid var(--border);border-radius:20px;padding:24px;backdrop-filter:blur(10px);min-height:196px;
|
||
box-shadow:0 30px 60px -30px rgba(0,0,0,0.8);display:flex;flex-direction:column;}
|
||
.carousel .tag{display:inline-flex;align-items:center;gap:7px;font-size:10.5px;font-weight:800;text-transform:uppercase;letter-spacing:0.06em;
|
||
color:var(--accent);background:var(--accent-soft);padding:5px 11px;border-radius:999px;align-self:flex-start;margin-bottom:14px;}
|
||
.carousel h3{margin:0 0 8px;font-size:20px;font-weight:800;letter-spacing:-0.01em;}
|
||
.carousel p{margin:0;color:var(--muted);font-size:14px;line-height:1.55;}
|
||
.carousel .slide{animation:fadeUp .5s ease;flex:1;}
|
||
@keyframes fadeUp{from{opacity:0;transform:translateY(10px);}to{opacity:1;transform:none;}}
|
||
.dots{display:flex;gap:7px;margin-top:16px;}
|
||
.dots i{width:7px;height:7px;border-radius:50%;background:var(--border);transition:.25s;cursor:pointer;}
|
||
.dots i.active{background:var(--accent);width:22px;border-radius:4px;}
|
||
|
||
/* Fußbereich: Ladefortschritt + Musik-Player */
|
||
.bottom{display:flex;flex-direction:column;gap:14px;}
|
||
.loadrow{display:flex;align-items:center;gap:16px;}
|
||
.loadinfo{flex:1;min-width:0;}
|
||
.loadhead{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:9px;}
|
||
.loadhead .status{font-size:13.5px;font-weight:600;color:var(--fg);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||
.loadhead .status .file{color:var(--muted);font-weight:500;}
|
||
.loadhead .pct{font-size:15px;font-weight:800;font-variant-numeric:tabular-nums;color:var(--accent);}
|
||
.bar{height:8px;border-radius:99px;background:rgba(255,255,255,0.07);overflow:hidden;position:relative;}
|
||
.bar .fill{height:100%;width:0;border-radius:99px;background:linear-gradient(90deg,var(--accent),var(--accent2));
|
||
box-shadow:0 0 16px rgba(124,92,246,0.7);transition:width .35s cubic-bezier(.4,0,.2,1);position:relative;}
|
||
/* Wandernder Glanz auf dem Balken */
|
||
.bar .fill::after{content:"";position:absolute;inset:0;background:linear-gradient(90deg,transparent,rgba(255,255,255,0.4),transparent);
|
||
transform:translateX(-100%);animation:sheen 1.6s linear infinite;}
|
||
@keyframes sheen{to{transform:translateX(100%);}}
|
||
|
||
/* Musik-Player (WebAudio, ohne externe Datei) */
|
||
.player{display:flex;align-items:center;gap:12px;background:var(--surface);border:1px solid var(--border);
|
||
border-radius:14px;padding:9px 12px 9px 9px;backdrop-filter:blur(8px);min-width:270px;}
|
||
.player .cover{width:40px;height:40px;border-radius:10px;flex:none;display:grid;place-items:center;font-size:19px;
|
||
background:linear-gradient(150deg,var(--accent),var(--accent2));position:relative;overflow:hidden;}
|
||
.player .cover.playing::before{content:"";position:absolute;inset:0;background:radial-gradient(circle,rgba(255,255,255,0.35),transparent 70%);animation:pulse 1.4s ease-in-out infinite;}
|
||
@keyframes pulse{50%{opacity:0.4;}}
|
||
.player .meta{min-width:0;flex:1;}
|
||
.player .meta .t{font-size:12.5px;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||
.player .meta .a{font-size:10.5px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||
.pbtn{width:34px;height:34px;flex:none;border-radius:50%;border:1px solid var(--border);background:var(--surface2);color:var(--fg);
|
||
cursor:pointer;display:grid;place-items:center;font-size:14px;transition:.12s;}
|
||
.pbtn:hover{border-color:var(--accent);color:var(--accent);}
|
||
.pbtn.play{background:var(--accent);color:var(--ink);border-color:transparent;}
|
||
.vol{width:66px;accent-color:var(--accent);cursor:pointer;}
|
||
|
||
/* „Bereit"-Zustand, wenn das Laden 100 % erreicht */
|
||
.ready-glow{animation:readyPulse 1.6s ease-in-out infinite;}
|
||
@keyframes readyPulse{50%{text-shadow:0 0 22px rgba(52,211,153,0.7);}}
|
||
|
||
/* Kompakteres Layout auf schmalen Fenstern */
|
||
@media (max-width:900px){
|
||
.middle{grid-template-columns:1fr;gap:22px;}
|
||
.serverinfo .stat:nth-child(3){display:none;}
|
||
.loadrow{flex-direction:column;align-items:stretch;}
|
||
.player{min-width:0;}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<canvas id="bg"></canvas>
|
||
<div class="aurora"></div>
|
||
<div class="vignette"></div>
|
||
|
||
<div class="screen">
|
||
<!-- Kopfzeile: Marke + Server-Statistiken -->
|
||
<header class="top">
|
||
<div class="brand">
|
||
<span class="mark">◆</span>
|
||
<div class="name"><span>AETHER</span><small>Los Santos Roleplay</small></div>
|
||
</div>
|
||
<div class="serverinfo" id="serverinfo"></div>
|
||
</header>
|
||
|
||
<!-- Mittelbereich: Titel + rotierendes Karussell -->
|
||
<main class="middle">
|
||
<div class="hero">
|
||
<div class="kicker">Willkommen zurück</div>
|
||
<h1>Deine<br>Geschichte<br>beginnt.</h1>
|
||
<p class="sub">Steig ein in das Leben von Los Santos — dein Charakter, deine Entscheidungen, deine Regeln. Bitte warte, während die Welt geladen wird.</p>
|
||
</div>
|
||
<div class="carousel" id="carousel"></div>
|
||
</main>
|
||
|
||
<!-- Fußbereich: Fortschrittsbalken + Musik-Player -->
|
||
<footer class="bottom">
|
||
<div class="loadrow">
|
||
<div class="loadinfo">
|
||
<div class="loadhead">
|
||
<div class="status" id="status">Verbinde mit dem Server…</div>
|
||
<div class="pct" id="pct">0%</div>
|
||
</div>
|
||
<div class="bar"><div class="fill" id="fill"></div></div>
|
||
</div>
|
||
<div class="player" id="player">
|
||
<div class="cover" id="cover">♪</div>
|
||
<div class="meta">
|
||
<div class="t" id="trackTitle">Aether Ambient</div>
|
||
<div class="a" id="trackArtist">Lobby · Loop</div>
|
||
</div>
|
||
<button class="pbtn play" id="playBtn" title="Wiedergabe / Pause">▶</button>
|
||
<button class="pbtn" id="nextBtn" title="Nächster Titel">⏭</button>
|
||
<input class="vol" id="vol" type="range" min="0" max="100" value="45" title="Lautstärke">
|
||
</div>
|
||
</div>
|
||
</footer>
|
||
</div>
|
||
|
||
<script>
|
||
"use strict";
|
||
/* Kurzer Zugriffshelfer auf Elemente per ID */
|
||
const $=id=>document.getElementById(id);
|
||
|
||
/* =====================================================================
|
||
SERVER-DATEN (im echten Betrieb vom Server geliefert / per NUI gesetzt)
|
||
Bewusst vom UI getrennt gehalten.
|
||
===================================================================== */
|
||
const server={
|
||
spielerAktuell:184, // aktuell verbundene Spieler
|
||
spielerMax:256, // Slot-Limit des Servers
|
||
voiceKanal:"TeamSpeak/PMA-Voice", // genutztes Voice-System
|
||
discord:"discord.gg/aether", // Community-Link (nur Anzeige)
|
||
};
|
||
|
||
/* Karussell-Inhalte: Nachrichten, Regeln und Spieltipps im Wechsel */
|
||
const karussell=[
|
||
{tag:"📰 News", titel:"Neues Immobilien-Update", text:"Ab sofort lassen sich Wohnungen möbeln und Hausschlüssel beim Schlüsseldienst nachmachen. Schau im Markt (AetherImmos) vorbei."},
|
||
{tag:"📜 Regel", titel:"Kein RDM / VDM", text:"Töten oder Anfahren ohne Rollenspiel-Grund ist verboten. Jede Gewalt braucht eine nachvollziehbare Vorgeschichte."},
|
||
{tag:"💡 Tipp", titel:"Ausweis dabeihaben", text:"Öffne deine Börse und zeig Behörden bei Kontrollen deinen Personalausweis — ohne Papiere wird's teuer."},
|
||
{tag:"📜 Regel", titel:"Bleib in der Rolle", text:"Metagaming und Powergaming sind untersagt. Wissen aus Discord oder Stream gehört nicht in die Spielwelt."},
|
||
{tag:"💡 Tipp", titel:"Jobs bringen Startgeld", text:"Im Jobcenter findest du 8 legale Jobs. Mit steigendem Level steigt auch dein Lohn — ideal für den Einstieg."},
|
||
{tag:"📰 News", titel:"Aether Casino eröffnet", text:"Slots, Blackjack, Roulette, Glücksrad & Münzwurf — teste dein Glück, aber spiel mit Verantwortung."},
|
||
];
|
||
|
||
/* Musik-Titel für den Player (synthetisch erzeugt, ohne externe Datei) */
|
||
const playlist=[
|
||
{titel:"Aether Ambient", artist:"Lobby · Loop", akkord:[220.00,277.18,329.63]}, // A-Dur-artiges Pad
|
||
{titel:"Neon Nights", artist:"Downtown · Loop", akkord:[196.00,246.94,293.66]}, // G-Dur
|
||
{titel:"Midnight Drive", artist:"Vinewood · Loop", akkord:[174.61,220.00,261.63]}, // F-Dur
|
||
];
|
||
|
||
/* =====================================================================
|
||
SERVER-INFO & KARUSSELL RENDERN
|
||
===================================================================== */
|
||
/* Baut die Statistik-Kacheln in der Kopfzeile auf */
|
||
function renderServerInfo(){
|
||
$("serverinfo").innerHTML=
|
||
`<div class="stat"><div class="l">Spieler online</div><div class="v"><span class="on">${server.spielerAktuell}</span> / ${server.spielerMax}</div></div>
|
||
<div class="stat"><div class="l">Voice</div><div class="v" style="font-size:12.5px">${server.voiceKanal}</div></div>
|
||
<div class="stat"><div class="l">Discord</div><div class="v" style="font-size:12.5px">${server.discord}</div></div>`;
|
||
}
|
||
|
||
let slideIndex=0; // aktuell sichtbare Karussell-Folie
|
||
let slideTimer=null; // Timer für den automatischen Wechsel
|
||
/* Zeigt eine bestimmte Karussell-Folie und baut die Punkt-Navigation */
|
||
function zeigeSlide(i){
|
||
slideIndex=(i+karussell.length)%karussell.length;
|
||
const k=karussell[slideIndex];
|
||
$("carousel").innerHTML=
|
||
`<div class="slide"><span class="tag">${k.tag}</span><h3>${k.titel}</h3><p>${k.text}</p></div>
|
||
<div class="dots">${karussell.map((_,j)=>`<i class="${j===slideIndex?'active':''}" data-dot="${j}"></i>`).join('')}</div>`;
|
||
// Klick auf einen Punkt springt zur jeweiligen Folie
|
||
$("carousel").querySelectorAll("[data-dot]").forEach(d=>d.addEventListener("click",()=>{starteSlideAuto(+d.dataset.dot);}));
|
||
}
|
||
/* Startet den automatischen Folienwechsel (alle 5 s) ab einem Index */
|
||
function starteSlideAuto(i){
|
||
zeigeSlide(i);
|
||
clearInterval(slideTimer);
|
||
slideTimer=setInterval(()=>zeigeSlide(slideIndex+1),5000);
|
||
}
|
||
|
||
/* =====================================================================
|
||
LADEFORTSCHRITT (Simulation; in FiveM von echten Lade-Events getrieben)
|
||
===================================================================== */
|
||
/* Ressourcen-Namen, die als "wird geladen"-Status durchlaufen */
|
||
const ressourcen=["aether-core","aether-phone","aether-inventory","aether-bank","aether-hud",
|
||
"aether-character","aether-garage","aether-jobs","aether-police","aether-ems",
|
||
"aether-clothing","aether-immobilien","aether-documents","aether-shop","aether-admin","map-lospm"];
|
||
let fortschritt=0; // aktueller Fortschritt in Prozent (0–100)
|
||
let resIndex=0; // Index der gerade "geladenen" Ressource
|
||
|
||
/* Erhöht den Fortschritt schrittweise und aktualisiert Balken + Status */
|
||
function ladeSchritt(){
|
||
// Zufällige, gegen Ende kleinere Sprünge für einen realistischen Verlauf
|
||
const rest=100-fortschritt;
|
||
const zuwachs=Math.max(0.6,Math.min(rest,(Math.random()*9+2)*(rest/100)+0.6));
|
||
fortschritt=Math.min(100,fortschritt+zuwachs);
|
||
$("fill").style.width=fortschritt+"%";
|
||
$("pct").textContent=Math.round(fortschritt)+"%";
|
||
|
||
// Status-Text an die aktuelle Ressource koppeln
|
||
const zielIndex=Math.min(ressourcen.length-1,Math.floor(fortschritt/100*ressourcen.length));
|
||
if(zielIndex!==resIndex){resIndex=zielIndex;
|
||
$("status").innerHTML=`Lade Ressource <span class="file">${ressourcen[resIndex]}</span>`;}
|
||
|
||
if(fortschritt>=100){ladenFertig();return;}
|
||
// Nächster Schritt nach zufälliger kurzer Pause
|
||
setTimeout(ladeSchritt,Math.random()*260+120);
|
||
}
|
||
|
||
/* Wird aufgerufen, sobald der Fortschritt 100 % erreicht */
|
||
function ladenFertig(){
|
||
$("status").innerHTML=`<span class="ready-glow" style="color:var(--good);font-weight:800">✓ Bereit — willkommen in Los Santos</span>`;
|
||
$("pct").style.color="var(--good)";
|
||
$("fill").style.background="linear-gradient(90deg,var(--good),var(--accent2))";
|
||
}
|
||
|
||
/* =====================================================================
|
||
MUSIK-PLAYER (WebAudio — sanftes Ambient-Pad, kein externes Audio)
|
||
===================================================================== */
|
||
let audioCtx=null; // Web-Audio-Kontext (erst bei Nutzer-Interaktion erlaubt)
|
||
let masterGain=null; // Haupt-Lautstärke-Regler
|
||
let aktiveOsz=[]; // aktuell klingende Oszillatoren des Akkords
|
||
let spielt=false; // läuft die Wiedergabe gerade?
|
||
let trackIndex=0; // aktueller Titel aus der Playlist
|
||
|
||
/* Erzeugt für den aktuellen Titel ein weiches Akkord-Pad */
|
||
function starteTon(){
|
||
if(!audioCtx){
|
||
audioCtx=new (window.AudioContext||window.webkitAudioContext)();
|
||
masterGain=audioCtx.createGain();
|
||
masterGain.gain.value=(+$("vol").value/100)*0.18; // insgesamt dezent halten
|
||
masterGain.connect(audioCtx.destination);
|
||
}
|
||
stoppeOsz();
|
||
// Für jede Frequenz des Akkords einen sanften Sinus-Oszillator anlegen
|
||
playlist[trackIndex].akkord.forEach((freq,i)=>{
|
||
const osz=audioCtx.createOscillator();
|
||
const g=audioCtx.createGain();
|
||
osz.type="sine";
|
||
osz.frequency.value=freq;
|
||
// Leichtes Verstimmen für einen volleren, wärmeren Klang
|
||
osz.detune.value=(i-1)*4;
|
||
g.gain.value=0;
|
||
osz.connect(g); g.connect(masterGain);
|
||
osz.start();
|
||
// Weich einblenden
|
||
g.gain.linearRampToValueAtTime(0.33,audioCtx.currentTime+1.2);
|
||
// Langsames Schweben der Lautstärke (LFO) für lebendiges Pad
|
||
const lfo=audioCtx.createOscillator(), lfoG=audioCtx.createGain();
|
||
lfo.frequency.value=0.06+i*0.02; lfoG.gain.value=0.12;
|
||
lfo.connect(lfoG); lfoG.connect(g.gain); lfo.start();
|
||
aktiveOsz.push(osz,lfo);
|
||
});
|
||
}
|
||
/* Stoppt und verwirft alle laufenden Oszillatoren */
|
||
function stoppeOsz(){aktiveOsz.forEach(o=>{try{o.stop();}catch(e){}});aktiveOsz=[];}
|
||
|
||
/* Schaltet zwischen Wiedergabe und Pause um */
|
||
function togglePlay(){
|
||
spielt=!spielt;
|
||
if(spielt){starteTon();$("playBtn").textContent="⏸";$("cover").classList.add("playing");}
|
||
else {stoppeOsz();$("playBtn").textContent="▶";$("cover").classList.remove("playing");}
|
||
}
|
||
/* Wechselt zum nächsten Titel der Playlist */
|
||
function naechsterTitel(){
|
||
trackIndex=(trackIndex+1)%playlist.length;
|
||
$("trackTitle").textContent=playlist[trackIndex].titel;
|
||
$("trackArtist").textContent=playlist[trackIndex].artist;
|
||
if(spielt)starteTon(); // bei laufender Wiedergabe direkt neu anspielen
|
||
}
|
||
|
||
/* Player-Bedienelemente verknüpfen */
|
||
$("playBtn").addEventListener("click",togglePlay);
|
||
$("nextBtn").addEventListener("click",naechsterTitel);
|
||
$("vol").addEventListener("input",()=>{if(masterGain)masterGain.gain.value=(+$("vol").value/100)*0.18;});
|
||
|
||
/* =====================================================================
|
||
START
|
||
===================================================================== */
|
||
renderServerInfo();
|
||
starteSlideAuto(0);
|
||
// Kurze Verzögerung, damit der leere Balken sichtbar ist, bevor er wächst
|
||
setTimeout(ladeSchritt,600);
|
||
|
||
/* Hintergrund-Partikel: langsam aufsteigende Lichtpunkte auf dem Canvas */
|
||
const cv=$("bg"), ctx=cv.getContext("2d");
|
||
let partikel=[]; // Liste aller Partikel mit Position/Geschwindigkeit
|
||
/* Passt die Canvas-Größe an das Fenster an und erzeugt neue Partikel */
|
||
function initCanvas(){
|
||
cv.width=innerWidth; cv.height=innerHeight;
|
||
partikel=Array.from({length:Math.round(innerWidth/22)},()=>neuerPartikel(true));
|
||
}
|
||
/* Erzeugt einen einzelnen Partikel (optional an zufälliger Höhe) */
|
||
function neuerPartikel(zufallY){
|
||
return {
|
||
x:Math.random()*cv.width,
|
||
y:zufallY?Math.random()*cv.height:cv.height+10,
|
||
r:Math.random()*1.8+0.4, // Radius
|
||
v:Math.random()*0.4+0.15, // Aufstiegsgeschwindigkeit
|
||
a:Math.random()*0.4+0.15, // Deckkraft
|
||
drift:(Math.random()-0.5)*0.3, // seitliches Driften
|
||
};
|
||
}
|
||
/* Zeichnet einen Animationsschritt aller Partikel */
|
||
function zeichne(){
|
||
ctx.clearRect(0,0,cv.width,cv.height);
|
||
for(const p of partikel){
|
||
p.y-=p.v; p.x+=p.drift;
|
||
if(p.y<-10)Object.assign(p,neuerPartikel(false)); // oben raus → unten neu
|
||
ctx.beginPath();
|
||
ctx.arc(p.x,p.y,p.r,0,Math.PI*2);
|
||
ctx.fillStyle=`rgba(150,130,255,${p.a})`;
|
||
ctx.fill();
|
||
}
|
||
requestAnimationFrame(zeichne);
|
||
}
|
||
addEventListener("resize",initCanvas);
|
||
initCanvas(); zeichne();
|
||
</script>
|
||
|
||
</body>
|
||
</html>
|