aether-framework/resources/[aether]/aether-character/html/index.html
Jerrit Fritzsche e4b82dd685 Aether-Framework: Fachmodule, Admin-Panel und Docker-Datenbank
- 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>
2026-07-27 18:55:32 +02:00

520 lines
38 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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">
</head>
<body>
<title>Aether — Charaktererstellung</title>
<style>
:root{
--bg:#07080c;--fg:#eef0f6;--muted:#8a8fa3;--surface:rgba(22,24,33,0.72);--surface2:rgba(36,39,52,0.85);
--slot:#12141c;--border:rgba(255,255,255,0.1);--accent:#c9a24a;--ink:#241a06;--accent-soft:rgba(201,162,74,0.16);
--red:#f0506e;--green:#34d39a;
}
*{box-sizing:border-box;} html,body{margin:0;height:100%;}
/* In FiveM ist der Hintergrund transparent — dahinter steht der echte 3D-Ped. */
body{min-height:100%;background:transparent;
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;color:var(--fg);
display:flex;align-items:center;justify-content:center;overflow:hidden;}
.stage{position:relative;width:100%;height:100%;overflow:hidden;}
.view{position:absolute;inset:0;display:none;} .view.active{display:flex;}
.fade{animation:fin .35s ease;} @keyframes fin{from{opacity:0;transform:translateY(8px);}to{opacity:1;transform:none;}}
/* Vorschau-Bühne: transparentes Fenster auf den 3D-Ped */
.preview{position:relative;display:flex;align-items:center;justify-content:center;overflow:hidden;
background:transparent;}
.preview .floor{display:none;} /* Boden kommt aus der Spielwelt */
.preview svg{position:relative;z-index:1;height:88%;max-width:92%;filter:drop-shadow(0 30px 40px rgba(0,0,0,0.55));}
/* Drehsteuerung unter dem Ped */
.turnbar{position:absolute;bottom:22px;left:50%;transform:translateX(-50%);z-index:3;display:flex;gap:8px;
background:rgba(0,0,0,0.45);border:1px solid var(--border);border-radius:12px;padding:6px;backdrop-filter:blur(6px);}
.turnbar button{border:none;background:rgba(255,255,255,0.06);color:var(--fg);border-radius:8px;
width:44px;height:34px;font-size:15px;cursor:pointer;font-family:inherit;transition:.12s;}
.turnbar button:hover{background:var(--accent);color:var(--ink);}
.turnbar .hint{color:var(--muted);font-size:11px;display:flex;align-items:center;padding:0 8px;font-weight:600;}
/* ============ Eigenständige Browser-Ansicht (ohne FiveM) ============
Stellt den früheren Kasten-Look samt SVG-Vorschau wieder her. */
body.browser{background:radial-gradient(1200px 800px at 50% -10%,rgba(201,162,74,0.1),transparent 55%),var(--bg);
padding:24px 16px;}
body.browser .stage{width:min(1280px,97vw);height:auto;aspect-ratio:16/10;border-radius:22px;
background:radial-gradient(120% 90% at 50% 0%,#171a24,#0a0c11 70%);border:1px solid var(--border);
box-shadow:0 50px 100px -30px rgba(0,0,0,0.85);}
body.browser .preview{background:radial-gradient(70% 60% at 50% 40%,rgba(201,162,74,0.08),transparent 70%),linear-gradient(180deg,#12141c,#0a0c11);}
body.browser .preview .floor{display:block;position:absolute;bottom:6%;left:50%;transform:translateX(-50%);
width:52%;height:5%;border-radius:50%;background:radial-gradient(ellipse,rgba(201,162,74,0.25),transparent 70%);}
body.browser .turnbar{display:none;} /* Drehen gibt es nur mit echtem Ped */
.view-toggle{position:absolute;top:16px;left:50%;transform:translateX(-50%);z-index:3;display:flex;background:rgba(0,0,0,0.4);border:1px solid var(--border);border-radius:11px;padding:4px;gap:2px;}
.view-toggle button{border:none;background:none;color:var(--muted);font-family:inherit;font-weight:700;font-size:12.5px;padding:7px 14px;border-radius:8px;cursor:pointer;}
.view-toggle button.active{background:var(--accent);color:var(--ink);}
.turn{transform-box:fill-box;transform-origin:center;animation:turn 8s ease-in-out infinite;}
@keyframes turn{0%,100%{transform:rotateY(-16deg);}50%{transform:rotateY(16deg);}}
@media (prefers-reduced-motion:reduce){.turn{animation:none;}}
/* ============ Auswahl ============ */
.sel-wrap{width:100%;display:grid;grid-template-columns:340px 1fr;}
.sel-list{padding:26px 22px;border-right:1px solid var(--border);display:flex;flex-direction:column;overflow-y:auto;
background:rgba(10,12,17,0.92);backdrop-filter:blur(10px);}
.sel-list h1{font-size:24px;margin:0 0 3px;font-weight:800;letter-spacing:-0.02em;} .sel-list .sub{color:var(--muted);font-size:13px;margin-bottom:18px;}
.char-slot{display:flex;align-items:center;gap:13px;padding:13px;border-radius:15px;background:var(--surface);border:1px solid var(--border);cursor:pointer;margin-bottom:10px;transition:.14s;}
.char-slot:hover{border-color:var(--accent);transform:translateX(2px);} .char-slot.active{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent) inset;background:var(--accent-soft);}
.char-slot .thumb{width:52px;height:52px;border-radius:12px;overflow:hidden;flex-shrink:0;background:#0c0e14;display:grid;place-items:center;}
.char-slot .thumb svg{height:96%;} .char-slot .cn{font-size:15px;font-weight:800;} .char-slot .cm{font-size:12px;color:var(--muted);}
/* Initialen als Slot-Vorschau (in FiveM statt SVG-Gesicht) */
.char-slot .thumb .ini{font-size:18px;font-weight:800;color:var(--accent);letter-spacing:0.03em;}
.char-slot.empty{border-style:dashed;justify-content:center;color:var(--muted);font-weight:700;font-size:14px;} .char-slot.empty:hover{color:var(--accent);}
.char-slot.empty .plus{font-size:20px;margin-right:6px;}
.sel-detail{position:relative;display:grid;grid-template-rows:1fr auto;}
.sel-info{position:absolute;bottom:0;left:0;right:0;z-index:2;padding:26px 30px;background:linear-gradient(0deg,rgba(8,9,13,0.92),transparent);}
.sel-info h2{font-size:32px;margin:0;font-weight:800;letter-spacing:-0.02em;} .sel-info .role{color:var(--accent);font-weight:700;font-size:14px;margin-top:2px;}
.sel-stats{display:flex;gap:24px;margin:14px 0 18px;flex-wrap:wrap;} .sel-stats .st .l{font-size:10.5px;color:var(--muted);text-transform:uppercase;letter-spacing:0.05em;font-weight:800;} .sel-stats .st .v{font-size:17px;font-weight:800;font-variant-numeric:tabular-nums;}
.sel-actions{display:flex;gap:10px;}
/* ============ Creator ============ */
.cr-wrap{width:100%;display:grid;grid-template-columns:1fr 400px;}
.cr-panel{border-left:1px solid var(--border);display:grid;grid-template-rows:auto auto 1fr auto;min-height:0;
background:rgba(10,12,17,0.94);backdrop-filter:blur(10px);}
.cr-head{padding:20px 22px 12px;} .cr-head h1{font-size:21px;margin:0;font-weight:800;letter-spacing:-0.02em;} .cr-head p{margin:2px 0 0;color:var(--muted);font-size:12.5px;}
.cr-tabs{display:flex;gap:3px;padding:0 22px;flex-wrap:wrap;} .cr-tabs button{border:none;background:none;color:var(--muted);font-family:inherit;font-weight:700;font-size:12.5px;padding:9px 12px;border-radius:9px 9px 0 0;cursor:pointer;border-bottom:2px solid transparent;}
.cr-tabs button.active{color:var(--accent);border-bottom-color:var(--accent);}
.cr-body{overflow-y:auto;padding:18px 22px;} .cr-body::-webkit-scrollbar{width:7px;} .cr-body::-webkit-scrollbar-thumb{background:rgba(255,255,255,0.1);border-radius:4px;}
.cr-foot{padding:14px 22px;border-top:1px solid var(--border);display:flex;gap:10px;}
.flabel{font-size:11px;color:var(--muted);font-weight:800;text-transform:uppercase;letter-spacing:0.05em;margin:16px 2px 8px;display:block;} .flabel:first-child{margin-top:0;}
.input,select.input{width:100%;background:var(--slot);border:1px solid var(--border);border-radius:10px;padding:11px 12px;color:var(--fg);font-size:14px;font-family:inherit;outline:none;} .input:focus{border-color:var(--accent);}
.swatches{display:flex;flex-wrap:wrap;gap:8px;}
.sw{width:34px;height:34px;border-radius:9px;cursor:pointer;border:2px solid transparent;position:relative;} .sw.sel{border-color:#fff;box-shadow:0 0 0 2px var(--accent);}
.sw[title]:hover::after{content:attr(title);position:absolute;bottom:120%;left:50%;transform:translateX(-50%);background:#000;color:#fff;font-size:10px;padding:2px 6px;border-radius:5px;white-space:nowrap;z-index:5;}
.opts{display:flex;flex-wrap:wrap;gap:7px;} .opt{padding:9px 14px;border-radius:9px;background:var(--slot);border:1px solid var(--border);color:var(--fg);font-family:inherit;font-weight:700;font-size:12.5px;cursor:pointer;} .opt.sel{background:var(--accent);color:var(--ink);border-color:var(--accent);}
.slider{display:flex;align-items:center;gap:12px;margin-bottom:12px;} .slider .sl-l{width:110px;font-size:12.5px;font-weight:600;} .slider input[type=range]{flex:1;accent-color:var(--accent);}
.slider .sl-v{width:34px;text-align:right;font-size:12px;color:var(--muted);font-variant-numeric:tabular-nums;}
.btn{border:none;border-radius:11px;padding:12px 18px;font-size:14px;font-weight:800;cursor:pointer;font-family:inherit;transition:.12s;}
.btn.primary{background:var(--accent);color:var(--ink);flex:1;} .btn.primary:hover{filter:brightness(1.08);}
.btn.ghost{background:var(--surface2);color:var(--fg);border:1px solid var(--border);} .btn.red{background:var(--red);color:#2a0910;} .btn.green{background:var(--green);color:#06231a;}
.btn:disabled{opacity:.4;cursor:not-allowed;} .btn:active{transform:scale(0.98);}
.overlay{position:absolute;inset:0;z-index:40;background:rgba(5,6,10,0.7);backdrop-filter:blur(6px);display:none;align-items:center;justify-content:center;} .overlay.show{display:flex;}
.modal{width:360px;max-width:92%;background:rgba(18,20,28,0.98);border:1px solid var(--border);border-radius:17px;padding:22px;text-align:center;box-shadow:0 30px 70px -18px rgba(0,0,0,0.8);}
.modal .mic{font-size:38px;} .modal h3{margin:8px 0 4px;font-size:19px;} .modal p{color:var(--muted);font-size:13px;margin:0 0 18px;} .modal-actions{display:grid;grid-template-columns:1fr 1fr;gap:9px;}
.toast{position:absolute;bottom:20px;left:50%;transform:translateX(-50%) translateY(18px);background:rgba(12,14,20,0.97);border:1px solid var(--border);color:var(--fg);padding:11px 20px;border-radius:12px;font-size:13.5px;font-weight:600;z-index:50;opacity:0;pointer-events:none;transition:.3s;} .toast.show{opacity:1;transform:translateX(-50%) translateY(0);}
</style>
<div class="stage">
<!-- AUSWAHL -->
<div class="view active" id="v-select"><div class="sel-wrap">
<div class="sel-list"><h1>Charaktere</h1><div class="sub">Wähle einen Charakter oder erstelle einen neuen.</div><div id="slot-list"></div></div>
<div class="sel-detail preview" id="sel-preview">
<div class="floor"></div>
<div id="sel-avatar"></div>
<div class="turnbar">
<button data-drehen="-30" title="Nach links drehen">◀</button>
<span class="hint">Drehen</span>
<button data-drehen="30" title="Nach rechts drehen">▶</button>
</div>
<div class="sel-info" id="sel-info"></div>
</div>
</div></div>
<!-- CREATOR -->
<div class="view" id="v-create"><div class="cr-wrap">
<div class="preview" id="cr-preview">
<div class="view-toggle" id="cr-viewtoggle"><button data-m="gesicht" class="active">Gesicht</button><button data-m="koerper">Körper</button></div>
<div class="floor"></div>
<div id="cr-avatar"></div>
<div class="turnbar">
<button data-drehen="-30" title="Nach links drehen">◀</button>
<span class="hint">Drehen</span>
<button data-drehen="30" title="Nach rechts drehen">▶</button>
</div>
</div>
<div class="cr-panel">
<div class="cr-head"><h1 id="cr-title">Charakter erstellen</h1><p>Gestalte Aussehen und Identität</p></div>
<div class="cr-tabs" id="cr-tabs"></div>
<div class="cr-body" id="cr-body"></div>
<div class="cr-foot"><button class="btn ghost" id="cr-cancel">Abbrechen</button><button class="btn primary" id="cr-save">Charakter erstellen</button></div>
</div>
</div></div>
<div class="overlay" id="overlay"></div><div class="toast" id="toast"></div>
</div>
<script>
"use strict";
let MAX_SLOTS=5; // wird vom Server aus Config.MaxCharaktere gesetzt
/* =====================================================================
PALETTEN & OPTIONEN
===================================================================== */
const hautToene=[{n:"Hell",c:"#f1cba6"},{n:"Rosé",c:"#e8b48f"},{n:"Gebräunt",c:"#d29b6f"},{n:"Oliv",c:"#b98552"},{n:"Braun",c:"#9a6a42"},{n:"Dunkelbraun",c:"#7a5232"},{n:"Tief",c:"#5c3d26"},{n:"Ebenholz",c:"#43291a"}];
const haarFarben=[{n:"Schwarz",c:"#1c1a1e"},{n:"Dunkelbraun",c:"#3a2417"},{n:"Braun",c:"#5a3a22"},{n:"Kastanie",c:"#7a3f2a"},{n:"Blond",c:"#c9a24a"},{n:"Hellblond",c:"#e3ca88"},{n:"Rot",c:"#a3441f"},{n:"Grau",c:"#9a9aa0"},{n:"Weiß",c:"#e8e8ec"},{n:"Petrol (gefärbt)",c:"#2f8f9a"}];
const augenFarben=[{n:"Braun",c:"#6b4423"},{n:"Haselnuss",c:"#8a6b3a"},{n:"Blau",c:"#3f7ac4"},{n:"Grün",c:"#4a8a5a"},{n:"Grau",c:"#7a8a95"},{n:"Bernstein",c:"#b07a2a"}];
const kleidFarben=[{n:"Schwarz",c:"#20222a"},{n:"Weiß",c:"#e6e8ee"},{n:"Grau",c:"#6b7180"},{n:"Navy",c:"#27324f"},{n:"Rot",c:"#b23a3a"},{n:"Grün",c:"#2f6b4a"},{n:"Blau",c:"#3563a8"},{n:"Beige",c:"#c7b18a"},{n:"Bordeaux",c:"#6b2740"},{n:"Oliv",c:"#5a5a32"}];
const haarStile=["Kurz","Mittel","Undercut","Lang","Zopf","Glatze"];
const bartStile=["Rasiert","Stoppeln","Kurzbart","Vollbart"];
const augenbrauen=["Dünn","Normal","Dicht"];
const herkuenfte=["Los Santos","Vice City","Liberty City","San Fierro","Europa","Südamerika","Asien","Afrika"];
/* Standard-Aussehen für einen neuen Charakter */
function standardAussehen(g){return {geschlecht:g||"männlich",skin:1,hairColor:1,hairStyle:g==="weiblich"?3:0,augen:0,bart:0,augenbraue:1,gesichtBreite:50,kiefer:g==="weiblich"?40:55,nase:50,lippen:g==="weiblich"?62:45,koerper:g==="weiblich"?42:55,top:3,hose:0,schuhe:0};}
/* =====================================================================
GESPEICHERTE CHARAKTERE (kommen vom Server per NUI)
===================================================================== */
let charaktere=[]; // wird beim Öffnen vom Server befüllt
let auswahlIndex=0; // aktuell markierter Slot in der Auswahl
let entwurf=null; // Arbeitskopie im Creator
let editIndex=null; // Index beim Bearbeiten (null = neu)
let crModus="gesicht"; // Vorschau-Modus im Creator
let crTab="identitaet"; // aktiver Reiter
/* =====================================================================
HELFER
===================================================================== */
const $=id=>document.getElementById(id);
function sicher(t){const d=document.createElement("div");d.textContent=t==null?"":t;return d.innerHTML;}
const euro=n=>n.toLocaleString("de-DE")+" €";
let toastTimer=null; function toast(t){const e=$("toast");e.textContent=t;e.classList.add("show");clearTimeout(toastTimer);toastTimer=setTimeout(()=>e.classList.remove("show"),2400);}
function modal(html){$("overlay").innerHTML=`<div class="modal">${html}</div>`;$("overlay").classList.add("show");}
function closeModal(){$("overlay").classList.remove("show");$("overlay").innerHTML="";}
/* Farbe abdunkeln/aufhellen (f<1 dunkler, >1 heller) */
function schattiere(hex,f){const n=parseInt(hex.slice(1),16);let r=(n>>16)&255,g=(n>>8)&255,b=n&255;r=Math.max(0,Math.min(255,Math.round(r*f)));g=Math.max(0,Math.min(255,Math.round(g*f)));b=Math.max(0,Math.min(255,Math.round(b*f)));return "#"+((1<<24)+(r<<16)+(g<<8)+b).toString(16).slice(1);}
/* =====================================================================
AVATAR — parametrische Vektor-Darstellung (Gesicht & Körper)
===================================================================== */
function haarPfad(stil,cx,cy,fw,haar,haarD){
// gibt {hinten, vorne} Haar-SVG zurück
const oben=cy-66;
if(stil==="Glatze")return {hinten:"",vorne:`<path d="M ${cx-fw+4} ${cy-14} C ${cx-fw+2} ${cy-52} ${cx+fw-2} ${cy-52} ${cx+fw-4} ${cy-14} C ${cx+fw-16} ${cy-40} ${cx-fw+16} ${cy-40} ${cx-fw+4} ${cy-14} Z" fill="${schattiere(haar,1.05)}" opacity="0.12"/>`};
if(stil==="Lang"||stil==="Zopf"){
const hinten=`<path d="M ${cx-fw-6} ${cy-20} C ${cx-fw-14} ${cy+60} ${cx-fw-6} ${cy+110} ${cx-fw+10} ${cy+130} L ${cx+fw-10} ${cy+130} C ${cx+fw+6} ${cy+110} ${cx+fw+14} ${cy+60} ${cx+fw+6} ${cy-20} C ${cx+fw} ${oben} ${cx-fw} ${oben} ${cx-fw-6} ${cy-20} Z" fill="${haarD}"/>`;
const vorne=`<path d="M ${cx-fw-2} ${cy-6} C ${cx-fw-4} ${cy-58} ${cx+fw+4} ${cy-58} ${cx+fw+2} ${cy-6} C ${cx+fw-6} ${cy-30} ${cx+16} ${cy-40} ${cx+6} ${cy-24} C ${cx} ${cy-40} ${cx-16} ${cy-38} ${cx-fw+6} ${cy-30} Z" fill="${haar}"/>`+(stil==="Zopf"?`<ellipse cx="${cx}" cy="${cy+118}" rx="10" ry="16" fill="${haarD}"/>`:"");
return {hinten,vorne};
}
if(stil==="Undercut"){
return {hinten:"",vorne:`<path d="M ${cx-fw+6} ${cy-18} C ${cx-fw+2} ${cy-56} ${cx+fw-2} ${cy-56} ${cx+fw-6} ${cy-18} C ${cx+fw-8} ${cy-36} ${cx+8} ${cy-44} ${cx-4} ${cy-30} C ${cx-10} ${cy-40} ${cx-fw+4} ${cy-38} ${cx-fw+6} ${cy-18} Z" fill="${haar}"/>`};
}
if(stil==="Mittel"){
return {hinten:`<path d="M ${cx-fw-4} ${cy-12} C ${cx-fw-6} ${cy+30} ${cx-fw+2} ${cy+44} ${cx-fw+8} ${cy+48} L ${cx+fw-8} ${cy+48} C ${cx+fw-2} ${cy+44} ${cx+fw+6} ${cy+30} ${cx+fw+4} ${cy-12} C ${cx+fw} ${oben} ${cx-fw} ${oben} ${cx-fw-4} ${cy-12} Z" fill="${haarD}"/>`,
vorne:`<path d="M ${cx-fw} ${cy-8} C ${cx-fw-2} ${cy-56} ${cx+fw+2} ${cy-56} ${cx+fw} ${cy-8} C ${cx+fw-8} ${cy-32} ${cx+12} ${cy-42} ${cx+2} ${cy-26} C ${cx-4} ${cy-42} ${cx-12} ${cy-40} ${cx-fw+8} ${cy-30} Z" fill="${haar}"/>`};
}
// Kurz (Standard)
return {hinten:"",vorne:`<path d="M ${cx-fw+4} ${cy-16} C ${cx-fw+2} ${cy-54} ${cx+fw-2} ${cy-54} ${cx+fw-4} ${cy-16} C ${cx+fw-8} ${cy-36} ${cx+10} ${cy-42} ${cx} ${cy-28} C ${cx-10} ${cy-42} ${cx-fw+8} ${cy-36} ${cx-fw+4} ${cy-16} Z" fill="${haar}"/>`};
}
function bartPfad(stil,cx,cy,fw,jawW,chinY,haar){
if(stil==="Rasiert")return "";
const bC=schattiere(haar,0.92);
if(stil==="Stoppeln")return `<path d="M ${cx-jawW+2} ${cy+30} C ${cx-jawW} ${chinY-14} ${cx} ${chinY} ${cx} ${chinY} C ${cx} ${chinY} ${cx+jawW} ${chinY-14} ${cx+jawW-2} ${cy+30} C ${cx+jawW-10} ${cy+52} ${cx-jawW+10} ${cy+52} ${cx-jawW+2} ${cy+30} Z" fill="${bC}" opacity="0.28"/>`;
if(stil==="Kurzbart")return `<path d="M ${cx-jawW+3} ${cy+26} C ${cx-jawW+1} ${chinY-12} ${cx} ${chinY} ${cx} ${chinY} C ${cx} ${chinY} ${cx+jawW-1} ${chinY-12} ${cx+jawW-3} ${cy+26} C ${cx+jawW-12} ${cy+50} ${cx-jawW+12} ${cy+50} ${cx-jawW+3} ${cy+26} Z" fill="${bC}" opacity="0.7"/><rect x="${cx-11}" y="${cy+14}" width="22" height="6" rx="3" fill="${bC}" opacity="0.7"/>`;
// Vollbart
return `<path d="M ${cx-jawW+1} ${cy+20} C ${cx-jawW-2} ${chinY-6} ${cx} ${chinY+6} ${cx} ${chinY+6} C ${cx} ${chinY+6} ${cx+jawW+2} ${chinY-6} ${cx+jawW-1} ${cy+20} C ${cx+jawW-6} ${cy+56} ${cx-jawW+6} ${cy+56} ${cx-jawW+1} ${cy+20} Z" fill="${bC}"/><rect x="${cx-12}" y="${cy+12}" width="24" height="7" rx="3" fill="${bC}"/>`;
}
function faceSvg(a){
const skin=hautToene[a.skin].c, skinD=schattiere(skin,0.86), skinL=schattiere(skin,1.07);
const haar=haarFarben[a.hairColor].c, haarD=schattiere(haar,0.8);
const auge=augenFarben[a.augen].c, brow=schattiere(haar,0.85), lip=schattiere(skin,0.7);
const topC=kleidFarben[a.top].c;
const cx=110, cy=104, chinY=176;
const fw=38+a.gesichtBreite/100*16; // Gesichts-Halbbreite
const jawW=fw*(0.56+a.kiefer/100*0.34); // Kieferbreite
const noseW=5+a.nase/100*8;
const lipW=13+a.lippen/100*10, lipY=chinY-30;
const browT=2.4+a.augenbraue/100*3.2, eyeY=cy+2, eyeDx=18;
const kopf=`M ${cx-fw} ${cy} C ${cx-fw} ${cy-74} ${cx+fw} ${cy-74} ${cx+fw} ${cy} C ${cx+fw} ${cy+38} ${cx+jawW} ${chinY-18} ${cx} ${chinY} C ${cx-jawW} ${chinY-18} ${cx-fw} ${cy+38} ${cx-fw} ${cy} Z`;
const haare=haarPfad(a.hairStyle,cx,cy,fw,haar,haarD);
const bart=bartPfad(a.bart,cx,cy,fw,jawW,chinY,haar);
return `<svg viewBox="0 0 220 260" class="${crModus==='gesicht'?'':''}"><defs>
<linearGradient id="skinG" x1="0" y1="0" x2="1" y2="0"><stop offset="0%" stop-color="${skinL}"/><stop offset="60%" stop-color="${skin}"/><stop offset="100%" stop-color="${skinD}"/></linearGradient></defs>
<!-- Schultern / Oberteil -->
<path d="M ${cx-96} 260 C ${cx-96} 214 ${cx-40} 196 ${cx} 196 C ${cx+40} 196 ${cx+96} 214 ${cx+96} 260 Z" fill="${topC}"/>
<path d="M ${cx-14} 196 h28 v-24 h-28 Z" fill="${skinD}"/>
${haare.hinten}
<!-- Ohren -->
<ellipse cx="${cx-fw+2}" cy="${cy+8}" rx="8" ry="12" fill="${skinD}"/><ellipse cx="${cx+fw-2}" cy="${cy+8}" rx="8" ry="12" fill="${skinD}"/>
<!-- Kopf -->
<path d="${kopf}" fill="url(#skinG)"/>
<!-- Augenbrauen -->
<path d="M ${cx-eyeDx-9} ${eyeY-13} q 9 -${browT+3} 18 0" stroke="${brow}" stroke-width="${browT}" fill="none" stroke-linecap="round"/>
<path d="M ${cx+eyeDx-9} ${eyeY-13} q 9 -${browT+3} 18 0" stroke="${brow}" stroke-width="${browT}" fill="none" stroke-linecap="round"/>
<!-- Augen -->
<g><ellipse cx="${cx-eyeDx}" cy="${eyeY}" rx="8" ry="5" fill="#fff"/><circle cx="${cx-eyeDx}" cy="${eyeY}" r="3.6" fill="${auge}"/><circle cx="${cx-eyeDx}" cy="${eyeY}" r="1.7" fill="#12100e"/>
<ellipse cx="${cx+eyeDx}" cy="${eyeY}" rx="8" ry="5" fill="#fff"/><circle cx="${cx+eyeDx}" cy="${eyeY}" r="3.6" fill="${auge}"/><circle cx="${cx+eyeDx}" cy="${eyeY}" r="1.7" fill="#12100e"/>
<path d="M ${cx-eyeDx-8} ${eyeY-3} q 8 -4 16 0" stroke="${skinD}" stroke-width="1.4" fill="none"/><path d="M ${cx+eyeDx-8} ${eyeY-3} q 8 -4 16 0" stroke="${skinD}" stroke-width="1.4" fill="none"/></g>
<!-- Nase -->
<path d="M ${cx} ${eyeY+4} C ${cx-2} ${eyeY+18} ${cx-noseW} ${eyeY+26} ${cx-noseW+1} ${eyeY+30} q ${noseW-1} 5 ${2*(noseW-1)} 0 C ${cx+noseW} ${eyeY+26} ${cx+2} ${eyeY+18} ${cx} ${eyeY+4}" fill="${schattiere(skin,0.93)}"/>
<!-- Lippen -->
<path d="M ${cx-lipW/2} ${lipY} q ${lipW/2} -4 ${lipW} 0 q -${lipW/2} 8 -${lipW} 0 Z" fill="${lip}"/><path d="M ${cx-lipW/2} ${lipY} q ${lipW/2} 3 ${lipW} 0" stroke="${schattiere(lip,0.7)}" stroke-width="1" fill="none"/>
${bart}
${haare.vorne}
</svg>`;
}
function bodySvg(a){
const skin=hautToene[a.skin].c, skinD=schattiere(skin,0.86);
const haar=haarFarben[a.hairColor].c;
const topC=kleidFarben[a.top].c, hoseC=kleidFarben[a.hose].c, schuhC=kleidFarben[a.schuhe].c;
const cx=110, torsoW=30+a.koerper/100*20; // Rumpfbreite je Körperbau
return `<svg viewBox="0 0 220 330"><g class="turn">
<!-- Kopf + Haare -->
<ellipse cx="${cx}" cy="42" rx="21" ry="24" fill="${skin}"/>
<path d="M ${cx-21} 34 C ${cx-22} 8 ${cx+22} 8 ${cx+21} 34 C ${cx+14} 20 ${cx-14} 20 ${cx-21} 34 Z" fill="${haar}"/>
<rect x="${cx-8}" y="62" width="16" height="14" fill="${skinD}"/>
<!-- Oberkörper (Oberteil) -->
<path d="M ${cx-torsoW} 78 Q ${cx} 70 ${cx+torsoW} 78 L ${cx+torsoW-4} 176 Q ${cx} 186 ${cx-torsoW+4} 176 Z" fill="${topC}"/>
<!-- Arme -->
<path d="M ${cx-torsoW} 80 q -18 6 -22 54 l 10 4 q 6 -40 16 -50 Z" fill="${topC}"/><path d="M ${cx+torsoW} 80 q 18 6 22 54 l -10 4 q -6 -40 -16 -50 Z" fill="${topC}"/>
<ellipse cx="${cx-torsoW-14}" cy="140" rx="6" ry="8" fill="${skin}"/><ellipse cx="${cx+torsoW+14}" cy="140" rx="6" ry="8" fill="${skin}"/>
<!-- Beine (Hose) -->
<path d="M ${cx-torsoW+6} 174 L ${cx-6} 176 L ${cx-8} 288 L ${cx-24} 288 Z" fill="${hoseC}"/><path d="M ${cx+torsoW-6} 174 L ${cx+6} 176 L ${cx+8} 288 L ${cx+24} 288 Z" fill="${hoseC}"/>
<!-- Schuhe -->
<path d="M ${cx-26} 288 h20 l2 14 h-30 Z" fill="${schuhC}"/><path d="M ${cx+26} 288 h-20 l-2 14 h30 Z" fill="${schuhC}"/>
</g></svg>`;
}
function avatarSvg(a,modus){return modus==="koerper"?bodySvg(a):faceSvg(a);}
/* =====================================================================
AUSWAHL-BILDSCHIRM
===================================================================== */
function renderAuswahl(){
$("v-create").classList.remove("active");$("v-select").classList.add("active","fade");setTimeout(()=>$("v-select").classList.remove("fade"),350);
const list=$("slot-list");
let html=charaktere.map((c,i)=>`<div class="char-slot ${i===auswahlIndex?'active':''}" data-slot="${i}">
<div class="thumb">${slotThumb(c)}</div><div class="grow"><div class="cn">${sicher(c.vorname+' '+c.nachname)}</div><div class="cm">${sicher(c.job)} · ${c.stunden} Std</div></div></div>`).join("");
if(charaktere.length<MAX_SLOTS)html+=`<div class="char-slot empty" data-neu="1"><span class="plus">+</span> Neuen Charakter erstellen</div>`;
list.innerHTML=html;
list.querySelectorAll("[data-slot]").forEach(s=>s.addEventListener("click",()=>{auswahlIndex=+s.dataset.slot;renderAuswahl();}));
const neu=list.querySelector("[data-neu]");if(neu)neu.addEventListener("click",()=>starteCreator(null));
renderAuswahlDetail();
}
/* Kleines Vorschaubild im Slot.
In FiveM Initialen (passt besser zum realistischen 3D-Ped),
im Browser die stilisierte SVG-Zeichnung. */
function slotThumb(c){
if(!IST_FIVEM) return faceSvg(c.aussehen);
const initialen=((c.vorname||"?")[0]+(c.nachname||"?")[0]).toUpperCase();
return `<span class="ini">${sicher(initialen)}</span>`;
}
function renderAuswahlDetail(){
const c=charaktere[auswahlIndex];
if(!c){$("sel-avatar").innerHTML="";$("sel-info").innerHTML='<div class="empty">Kein Charakter.</div>';return;}
// 3D-Ped auf den markierten Charakter umstellen (bzw. SVG im Browser)
if(IST_FIVEM){ $("sel-avatar").innerHTML=""; nui("vorschau",{aussehen:c.aussehen,modus:"koerper"}); }
else $("sel-avatar").innerHTML=faceSvg(c.aussehen);
$("sel-info").innerHTML=`<h2>${sicher(c.vorname+' '+c.nachname)}</h2><div class="role">${sicher(c.job)} · ${sicher(c.herkunft)}</div>
<div class="sel-stats"><div class="st"><div class="l">Vermögen</div><div class="v">${euro(c.geld)}</div></div>
<div class="st"><div class="l">Spielzeit</div><div class="v">${c.stunden} Std</div></div>
<div class="st"><div class="l">Geboren</div><div class="v">${sicher(c.geburt)}</div></div>
<div class="st"><div class="l">Zuletzt</div><div class="v">${sicher(c.zuletzt)}</div></div></div>
<div class="sel-actions"><button class="btn primary" id="d-play">▶ Spielen</button><button class="btn red" id="d-del">Löschen</button></div>`;
$("d-play").addEventListener("click",()=>{
// Auswahl an den Server melden — dieser spawnt den Charakter
$("d-play").disabled=true;
toast("Lade "+c.vorname+" ...");
nui("spielen",{charakterId:c.id});
});
$("d-del").addEventListener("click",()=>loeschenFrage(auswahlIndex));
}
function loeschenFrage(i){const c=charaktere[i];
modal(`<div class="mic">⚠️</div><h3>${sicher(c.vorname+' '+c.nachname)} löschen?</h3><p>Dieser Charakter wird unwiderruflich entfernt.</p>
<div class="modal-actions"><button class="btn ghost" id="ld-cancel">Abbrechen</button><button class="btn red" id="ld-ok">Löschen</button></div>`);
$("ld-cancel").addEventListener("click",closeModal);
$("ld-ok").addEventListener("click",()=>{
// Löschen übernimmt der Server; die neue Liste kommt per Nachricht zurück
closeModal();
nui("loeschen",{charakterId:c.id});
});
}
/* =====================================================================
CREATOR
===================================================================== */
function starteCreator(index){
editIndex=index;
if(index===null){entwurf={vorname:"",nachname:"",geschlecht:"männlich",geburt:"",herkunft:herkuenfte[0],aussehen:standardAussehen("männlich")};}
else{const c=charaktere[index];entwurf={vorname:c.vorname,nachname:c.nachname,geschlecht:c.geschlecht,geburt:c.geburt,herkunft:c.herkunft,aussehen:{...c.aussehen}};}
crModus="gesicht";crTab="identitaet";
$("v-select").classList.remove("active");$("v-create").classList.add("active","fade");setTimeout(()=>$("v-create").classList.remove("fade"),350);
$("cr-title").textContent=index===null?"Charakter erstellen":"Charakter bearbeiten";
$("cr-save").textContent=index===null?"Charakter erstellen":"Änderungen speichern";
document.querySelectorAll("#cr-viewtoggle button").forEach(b=>b.classList.toggle("active",b.dataset.m===crModus));
renderTabs();renderControls();
// Vorschau auf den Entwurf umstellen und ins Gesicht zoomen
if(IST_FIVEM) nui("vorschau",{aussehen:entwurf.aussehen,modus:"gesicht"});
else updatePreview();
}
/* Vorschau aktualisieren.
In FiveM: Aussehen an den Client schicken, der es live auf den 3D-Ped überträgt.
Im Browser: die stilisierte SVG-Vorschau als Fallback zeichnen. */
let vorschauTimer=null;
function updatePreview(){
if(!IST_FIVEM){ $("cr-avatar").innerHTML=avatarSvg(entwurf.aussehen,crModus); return; }
// Slider feuern sehr oft — Aufrufe kurz bündeln
clearTimeout(vorschauTimer);
vorschauTimer=setTimeout(()=>nui("vorschau",{aussehen:entwurf.aussehen}),60);
}
const crTabsDef=[["identitaet","Identität"],["gesicht","Gesicht"],["merkmale","Merkmale"],["haare","Haare & Bart"],["kleidung","Kleidung"]];
function renderTabs(){
$("cr-tabs").innerHTML=crTabsDef.map(t=>`<button data-t="${t[0]}" class="${t[0]===crTab?'active':''}">${t[1]}</button>`).join("");
$("cr-tabs").querySelectorAll("[data-t]").forEach(b=>b.addEventListener("click",()=>{crTab=b.dataset.t;if(crTab==="kleidung")setModus("koerper");else if(crModus==="koerper"&&crTab!=="merkmale")setModus("gesicht");renderTabs();renderControls();}));
}
function setModus(m){
crModus=m;
document.querySelectorAll("#cr-viewtoggle button").forEach(b=>b.classList.toggle("active",b.dataset.m===m));
if(IST_FIVEM) nui("vorschauModus",{modus:m==="koerper"?"koerper":"gesicht"});
else updatePreview();
}
/* Bausteine für Controls */
function swGroup(label,pal,curr,cb){const id="g"+Math.random().toString(36).slice(2,7);
setTimeout(()=>{const el=$(id);if(el)el.querySelectorAll("[data-i]").forEach(s=>s.addEventListener("click",()=>{el.querySelectorAll("[data-i]").forEach(x=>x.classList.remove("sel"));s.classList.add("sel");cb(+s.dataset.i);}));},0);
return `<label class="flabel">${label}</label><div class="swatches" id="${id}">${pal.map((p,i)=>`<div class="sw ${i===curr?'sel':''}" data-i="${i}" style="background:${p.c}" title="${sicher(p.n)}"></div>`).join("")}</div>`;
}
function optGroup(label,opts,curr,cb){const id="o"+Math.random().toString(36).slice(2,7);
setTimeout(()=>{const el=$(id);if(el)el.querySelectorAll("[data-i]").forEach(s=>s.addEventListener("click",()=>{el.querySelectorAll("[data-i]").forEach(x=>x.classList.remove("sel"));s.classList.add("sel");cb(+s.dataset.i);}));},0);
return `<label class="flabel">${label}</label><div class="opts" id="${id}">${opts.map((o,i)=>`<button class="opt ${i===curr?'sel':''}" data-i="${i}">${sicher(o)}</button>`).join("")}</div>`;
}
function sliderRow(label,val,cb){const id="s"+Math.random().toString(36).slice(2,7);
setTimeout(()=>{const el=$(id);if(el)el.addEventListener("input",()=>{$(id+"v").textContent=el.value;cb(+el.value);});},0);
return `<div class="slider"><span class="sl-l">${label}</span><input type="range" id="${id}" min="0" max="100" value="${val}"><span class="sl-v" id="${id}v">${val}</span></div>`;
}
function renderControls(){
const a=entwurf.aussehen, body=$("cr-body");
if(crTab==="identitaet"){
body.innerHTML=`<label class="flabel">Vorname</label><input class="input" id="i-vor" value="${sicher(entwurf.vorname)}" placeholder="Vorname">
<label class="flabel">Nachname</label><input class="input" id="i-nach" value="${sicher(entwurf.nachname)}" placeholder="Nachname">
<label class="flabel">Geschlecht</label><div class="opts" id="i-geschlecht">${["männlich","weiblich","divers"].map(g=>`<button class="opt ${entwurf.geschlecht===g?'sel':''}" data-g="${g}">${g}</button>`).join("")}</div>
<label class="flabel">Geburtsdatum</label><input class="input" id="i-geb" value="${sicher(entwurf.geburt)}" placeholder="TT.MM.JJJJ">
<label class="flabel">Herkunft</label><select class="input" id="i-herk">${herkuenfte.map(h=>`<option ${entwurf.herkunft===h?'selected':''}>${sicher(h)}</option>`).join("")}</select>`;
$("i-vor").addEventListener("input",e=>entwurf.vorname=e.target.value);
$("i-nach").addEventListener("input",e=>entwurf.nachname=e.target.value);
$("i-geb").addEventListener("input",e=>entwurf.geburt=e.target.value);
$("i-herk").addEventListener("change",e=>entwurf.herkunft=e.target.value);
$("i-geschlecht").querySelectorAll("[data-g]").forEach(b=>b.addEventListener("click",()=>{entwurf.geschlecht=b.dataset.g;a.geschlecht=b.dataset.g;$("i-geschlecht").querySelectorAll("[data-g]").forEach(x=>x.classList.remove("sel"));b.classList.add("sel");}));
}
else if(crTab==="gesicht"){
body.innerHTML=swGroup("Hautton",hautToene,a.skin,i=>{a.skin=i;updatePreview();})+swGroup("Augenfarbe",augenFarben,a.augen,i=>{a.augen=i;updatePreview();});
}
else if(crTab==="merkmale"){
body.innerHTML=`<div style="font-size:12px;color:var(--muted);margin-bottom:10px">Feinjustierung der Gesichtszüge &amp; Statur.</div>`
+sliderRow("Gesichtsbreite",a.gesichtBreite,v=>{a.gesichtBreite=v;updatePreview();})
+sliderRow("Kiefer",a.kiefer,v=>{a.kiefer=v;updatePreview();})
+sliderRow("Nase",a.nase,v=>{a.nase=v;updatePreview();})
+sliderRow("Lippen",a.lippen,v=>{a.lippen=v;updatePreview();})
+sliderRow("Körperbau",a.koerper,v=>{a.koerper=v;if(crModus!=='koerper')setModus('koerper');else updatePreview();});
}
else if(crTab==="haare"){
body.innerHTML=optGroup("Frisur",haarStile,a.hairStyle,i=>{a.hairStyle=i;updatePreview();})
+swGroup("Haarfarbe",haarFarben,a.hairColor,i=>{a.hairColor=i;updatePreview();})
+optGroup("Augenbrauen",augenbrauen,Math.round(a.augenbraue/50),i=>{a.augenbraue=i*50;updatePreview();})
+optGroup("Bart",bartStile,a.bart,i=>{a.bart=i;updatePreview();});
}
else if(crTab==="kleidung"){
body.innerHTML=swGroup("Oberteil",kleidFarben,a.top,i=>{a.top=i;updatePreview();})
+swGroup("Hose",kleidFarben,a.hose,i=>{a.hose=i;updatePreview();})
+swGroup("Schuhe",kleidFarben,a.schuhe,i=>{a.schuhe=i;updatePreview();});
}
}
/* Speichern */
function speichereCharakter(){
if(!entwurf.vorname.trim()||!entwurf.nachname.trim()){crTab="identitaet";renderTabs();renderControls();toast("Bitte Vor- und Nachnamen angeben");return;}
if(!entwurf.geburt.trim()){crTab="identitaet";renderTabs();renderControls();toast("Bitte Geburtsdatum angeben (TT.MM.JJJJ)");return;}
entwurf.aussehen.geschlecht=entwurf.geschlecht;
// Anlegen übernimmt der Server; die neue Liste kommt per Nachricht zurück
$("cr-save").disabled=true;
nui("erstellen",{
vorname:entwurf.vorname.trim(),
nachname:entwurf.nachname.trim(),
geschlecht:entwurf.geschlecht,
geburt:entwurf.geburt.trim(),
herkunft:entwurf.herkunft,
aussehen:entwurf.aussehen,
});
}
/* Events */
document.querySelectorAll("#cr-viewtoggle button").forEach(b=>b.addEventListener("click",()=>setModus(b.dataset.m)));
/* Drehknöpfe unter dem Ped (beide Ansichten) */
document.querySelectorAll("[data-drehen]").forEach(b=>
b.addEventListener("click",()=>nui("vorschauDrehen",{grad:+b.dataset.drehen})));
$("cr-cancel").addEventListener("click",renderAuswahl);
$("cr-save").addEventListener("click",speichereCharakter);
$("overlay").addEventListener("click",e=>{if(e.target===$("overlay"))closeModal();});
/* =====================================================================
NUI-BRÜCKE — Verbindung zum FiveM-Client
===================================================================== */
/* Läuft die Seite in FiveM (3D-Ped) oder eigenständig im Browser (SVG-Fallback)? */
const IST_FIVEM = (typeof GetParentResourceName === "function");
/* Name dieser Ressource (für die Callback-Adresse) */
const RESSOURCE = IST_FIVEM ? GetParentResourceName() : "aether-character";
/* Schickt eine Aktion an den Client (client/main.lua) */
function nui(aktion, daten){
return fetch(`https://${RESSOURCE}/${aktion}`, {
method: "POST",
headers: { "Content-Type": "application/json; charset=UTF-8" },
body: JSON.stringify(daten || {}),
}).catch(() => {}); // Im Browser (ohne FiveM) still fehlschlagen lassen
}
/* Übernimmt eine Charakterliste vom Server und zeigt die Auswahl */
function setzeCharaktere(liste, maxSlots){
charaktere = Array.isArray(liste) ? liste : [];
if (maxSlots) MAX_SLOTS = maxSlots;
auswahlIndex = Math.max(0, Math.min(auswahlIndex, charaktere.length - 1));
// Noch kein Charakter vorhanden? Dann direkt in den Ersteller springen
if (charaktere.length === 0) starteCreator(null);
else renderAuswahl();
}
/* Nachrichten vom Client entgegennehmen */
window.addEventListener("message", (ereignis) => {
const nachricht = ereignis.data || {};
if (nachricht.aktion === "oeffnen") {
document.body.style.display = "";
setzeCharaktere(nachricht.charaktere, nachricht.maxSlots);
}
else if (nachricht.aktion === "liste") {
// Aktualisierte Liste nach Erstellen/Löschen
const saveBtn = $("cr-save"); if (saveBtn) saveBtn.disabled = false;
setzeCharaktere(nachricht.charaktere, nachricht.maxSlots);
if (nachricht.hinweis) toast(nachricht.hinweis);
}
else if (nachricht.aktion === "schliessen") {
document.body.style.display = "none";
}
else if (nachricht.aktion === "fehler") {
// Serverseitige Ablehnung: Bedienelemente wieder freigeben
const saveBtn = $("cr-save"); if (saveBtn) saveBtn.disabled = false;
const playBtn = $("d-play"); if (playBtn) playBtn.disabled = false;
toast(nachricht.text || "Aktion fehlgeschlagen");
}
});
/* START — im Browser direkt anzeigen, in FiveM wartet die Seite auf "oeffnen" */
if (IST_FIVEM) {
document.body.style.display = "none"; // In FiveM zunächst verstecken
} else {
// Eigenständige Ansicht: Kasten-Layout und SVG-Vorschau aktivieren
document.body.classList.add("browser");
// Eigenständige Vorschau im Browser: Beispieldaten zum Ansehen des Layouts
charaktere = [
{id:1,vorname:"Max",nachname:"Weber",geschlecht:"männlich",geburt:"14.03.1996",herkunft:"Los Santos",
job:"Spediteur",geld:48320,stunden:142,zuletzt:"vor 2 Std",
aussehen:{geschlecht:"männlich",skin:1,hairColor:2,hairStyle:0,augen:2,bart:1,augenbraue:1,
gesichtBreite:55,kiefer:60,nase:52,lippen:45,koerper:58,top:3,hose:0,schuhe:0}},
];
renderAuswahl();
}
</script>
</body>
</html>