aether-framework/resources/[aether]/aether-inventory/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

773 lines
41 KiB
HTML
Raw Permalink 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 — Inventar</title>
<style>
/* ===================== Design-Tokens ===================== */
:root {
--page-bg: #0a0b10;
--fg: #e9ecf4;
--muted: #838aa0;
--surface: rgba(24,26,36,0.72);
--surface2: rgba(36,39,53,0.8);
--slot: #14161f;
--slot-line: rgba(255,255,255,0.07);
--border: rgba(255,255,255,0.09);
--accent: #f5a623; /* warmer "Loot"-Akzent */
--accent-soft: rgba(245,166,35,0.16);
--red: #f0506e;
--green: #34d39a;
--amber: #f5b23d;
/* Seltenheits-Farben */
--r-common: #8b93a1;
--r-uncommon: #34d39a;
--r-rare: #4aa8ff;
--r-epic: #a56bff;
--r-legendary: #f5b23d;
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
body {
min-height: 100%;
background:
radial-gradient(1100px 700px at 12% 8%, rgba(245,166,35,0.08), transparent 55%),
radial-gradient(1000px 800px at 88% 92%, rgba(74,168,255,0.07), transparent 55%),
var(--page-bg);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
color: var(--fg);
display: flex; align-items: center; justify-content: center;
padding: 32px 18px; overflow-x: hidden;
}
/* ===================== Fenster ===================== */
.inv {
position: relative;
width: min(1200px, 96vw);
aspect-ratio: 16 / 10;
background:
radial-gradient(120% 80% at 0% 0%, rgba(245,166,35,0.05), transparent 45%),
linear-gradient(160deg, rgba(18,20,28,0.92), rgba(10,11,16,0.95));
backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
border: 1px solid var(--border); border-radius: 22px;
box-shadow: 0 50px 100px -30px rgba(0,0,0,0.8), 0 0 0 1px rgba(255,255,255,0.03) inset;
display: flex; flex-direction: column; overflow: hidden;
}
.inv-head { display: flex; align-items: center; gap: 18px; padding: 16px 22px; border-bottom: 1px solid var(--border); }
.inv-head .logo { width: 34px; height: 34px; border-radius: 9px; display: grid; place-items: center;
background: linear-gradient(150deg, #f5a623, #c9791a); color: #1a1206; font-weight: 800; }
.inv-head .title { font-size: 19px; font-weight: 800; letter-spacing: -0.02em; }
.inv-head .title small { display: block; font-size: 11.5px; font-weight: 500; color: var(--muted); letter-spacing: 0; }
.weight { margin-left: auto; display: flex; align-items: center; gap: 12px; }
.weight-bar { width: 200px; height: 9px; border-radius: 5px; background: var(--slot); overflow: hidden; border: 1px solid var(--slot-line); }
.weight-fill { height: 100%; border-radius: 5px; background: var(--accent); transition: width .3s ease, background .3s ease; }
.weight-txt { font-size: 13px; font-weight: 700; font-variant-numeric: tabular-nums; min-width: 96px; text-align: right; }
.inv-body { flex: 1; display: grid; grid-template-columns: 232px 1fr 300px; gap: 0; min-height: 0; }
.col { padding: 16px 18px; min-height: 0; display: flex; flex-direction: column; }
.col.umgebung { border-right: 1px solid var(--border); }
.col.avatar-col { border-left: 1px solid var(--border); }
.panel-h { font-size: 11.5px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.07em;
color: var(--muted); margin: 4px 2px 10px; display: flex; align-items: center; gap: 8px; }
.panel-h:not(:first-child) { margin-top: 18px; }
/* ===================== Slots & Items ===================== */
.slots { display: grid; grid-template-columns: repeat(5, 1fr); gap: 10px; overflow-y: auto; padding-right: 4px; align-content: start; }
.slots::-webkit-scrollbar { width: 7px; } .slots::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.08); border-radius: 4px; }
.slot {
aspect-ratio: 1; border-radius: 12px; background: var(--slot); border: 1px solid var(--slot-line);
position: relative; display: grid; place-items: center; transition: border-color .12s, background .12s;
}
.slot.over { border-color: var(--accent); background: var(--accent-soft); box-shadow: 0 0 0 2px var(--accent-soft); }
.slot .num { position: absolute; top: 5px; left: 7px; font-size: 11px; font-weight: 800; color: var(--muted); }
.item {
width: 100%; height: 100%; border-radius: 12px; position: relative; cursor: grab;
display: grid; place-items: center; background: linear-gradient(160deg, rgba(255,255,255,0.05), rgba(255,255,255,0.01));
border: 1.5px solid var(--rar, var(--r-common));
box-shadow: 0 0 14px -6px var(--rar, transparent) inset;
transition: transform .1s ease, box-shadow .12s;
}
.item:hover { box-shadow: 0 0 0 1px var(--rar), 0 8px 20px -8px rgba(0,0,0,0.6); }
.item.dragging { opacity: 0.35; }
.item .ic { font-size: 30px; line-height: 1; filter: drop-shadow(0 3px 5px rgba(0,0,0,0.5)); }
.item .qty { position: absolute; right: 5px; bottom: 4px; font-size: 12px; font-weight: 800;
background: rgba(8,9,13,0.85); border-radius: 6px; padding: 1px 6px; font-variant-numeric: tabular-nums; }
.item .wt { position: absolute; left: 5px; bottom: 4px; font-size: 9.5px; color: var(--muted); font-weight: 600; font-variant-numeric: tabular-nums; }
.item .asset { position: absolute; top: 5px; right: 6px; width: 8px; height: 8px; border-radius: 50%;
background: var(--accent); box-shadow: 0 0 8px var(--accent); }
.rar-common { --rar: var(--r-common); } .rar-uncommon { --rar: var(--r-uncommon); }
.rar-rare { --rar: var(--r-rare); } .rar-epic { --rar: var(--r-epic); } .rar-legendary { --rar: var(--r-legendary); }
/* ===================== Umgebung (links) ===================== */
.player { display: flex; align-items: center; gap: 10px; padding: 9px 11px; border-radius: 12px;
background: var(--surface); border: 1px solid var(--border); margin-bottom: 8px; transition: .12s; }
.player.over { border-color: var(--green); background: rgba(52,211,154,0.12); }
.player .pa { width: 34px; height: 34px; border-radius: 50%; display: grid; place-items: center; font-weight: 800; color: #fff; flex-shrink: 0; }
.player .pn { font-size: 13.5px; font-weight: 700; }
.player .pr { font-size: 11.5px; color: var(--muted); }
.player .give { margin-left: auto; font-size: 15px; opacity: 0.5; }
.boden { flex: 1; border: 1.5px dashed var(--border); border-radius: 14px; padding: 10px; overflow-y: auto;
display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; align-content: start; transition: .12s; min-height: 90px; }
.boden.over { border-color: var(--accent); background: var(--accent-soft); }
.boden .hint { grid-column: 1 / -1; color: var(--muted); font-size: 12px; text-align: center; align-self: center; padding: 18px 8px; line-height: 1.5; }
/* ===================== Avatar (rechts) ===================== */
.avatar-stage { position: relative; flex: 1; min-height: 0; display: grid; place-items: center; perspective: 900px; }
.mannequin { width: 130px; height: 240px; animation: turn 7s ease-in-out infinite; transform-style: preserve-3d; }
@keyframes turn { 0%,100% { transform: rotateY(-13deg); } 50% { transform: rotateY(13deg); } }
@keyframes floaty { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-6px); } }
@media (prefers-reduced-motion: reduce) { .mannequin { animation: none; } }
.platform { position: absolute; bottom: 8px; left: 50%; transform: translateX(-50%); width: 150px; height: 30px;
border-radius: 50%; background: radial-gradient(ellipse, rgba(245,166,35,0.22), transparent 70%); }
.eq-slot { position: absolute; width: 58px; height: 58px; border-radius: 12px; background: var(--slot);
border: 1px solid var(--slot-line); display: grid; place-items: center; transition: .12s; }
.eq-slot.over { border-color: var(--accent); background: var(--accent-soft); }
.eq-slot .hintico { font-size: 22px; opacity: 0.28; }
.eq-slot .eqlabel { position: absolute; bottom: -16px; left: 0; right: 0; text-align: center; font-size: 9.5px;
color: var(--muted); font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; white-space: nowrap; }
.eq-slot .item { border-radius: 11px; }
.eq-helm { top: 4px; left: 50%; transform: translateX(-50%); }
.eq-schmuck{ top: 40px; right: 6px; }
.eq-brust { top: 40px; left: 6px; }
.eq-hose { top: 116px; left: 6px; }
.eq-schuhe { bottom: 30px; left: 50%; transform: translateX(-50%); }
.hotbar { display: grid; grid-template-columns: repeat(5, 1fr); gap: 8px; }
.hot { aspect-ratio: 1; border-radius: 11px; background: var(--slot); border: 1px solid var(--slot-line);
position: relative; display: grid; place-items: center; transition: .12s; }
.hot.over { border-color: var(--accent); background: var(--accent-soft); }
.hot .num { position: absolute; top: 4px; left: 6px; font-size: 10px; font-weight: 800; color: var(--accent); }
/* ===================== Tooltip ===================== */
.tooltip { position: fixed; z-index: 200; pointer-events: none; display: none; max-width: 268px;
background: rgba(12,14,20,0.97); border: 1px solid var(--border); border-radius: 14px; padding: 13px 15px;
box-shadow: 0 20px 50px -12px rgba(0,0,0,0.8); }
.tt-name { font-size: 15px; font-weight: 800; letter-spacing: -0.01em; }
.tt-kat { font-size: 11.5px; color: var(--muted); font-weight: 600; margin-top: 1px; }
.tt-desc { font-size: 12.5px; color: #b9c0cf; line-height: 1.45; margin: 9px 0; }
.tt-row { display: flex; justify-content: space-between; gap: 12px; font-size: 12.5px; padding: 3px 0; }
.tt-row span:first-child { color: var(--muted); }
.tt-row .mono { font-family: ui-monospace, monospace; font-size: 11.5px; letter-spacing: 0.03em; }
.tt-assets { margin-top: 9px; border-top: 1px solid var(--border); padding-top: 9px; }
.tt-assets-h { font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); font-weight: 800; margin-bottom: 6px; }
.chip { display: inline-flex; align-items: center; gap: 5px; font-size: 11.5px; font-weight: 600;
background: var(--surface2); border: 1px solid var(--border); border-radius: 999px; padding: 3px 9px; margin: 0 5px 5px 0; }
.chip::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--accent); }
.tt-fg-common { color: var(--r-common); } .tt-fg-uncommon { color: var(--r-uncommon); }
.tt-fg-rare { color: var(--r-rare); } .tt-fg-epic { color: var(--r-epic); } .tt-fg-legendary { color: var(--r-legendary); }
/* ===================== Rechtsklick-Menü ===================== */
.ctx { position: absolute; z-index: 210; min-width: 168px; background: rgba(14,16,22,0.98);
border: 1px solid var(--border); border-radius: 12px; padding: 5px; display: none;
box-shadow: 0 20px 50px -12px rgba(0,0,0,0.8); }
.ctx.show { display: block; animation: ctxin .12s ease; }
@keyframes ctxin { from { opacity: 0; transform: scale(.96); } to { opacity: 1; transform: none; } }
.ctx-head { padding: 8px 10px 6px; font-size: 12.5px; font-weight: 800; border-bottom: 1px solid var(--border); margin-bottom: 4px; }
.ctx button { display: flex; align-items: center; gap: 10px; width: 100%; text-align: left; border: none;
background: none; color: var(--fg); font-family: inherit; font-size: 13.5px; font-weight: 600; padding: 9px 10px;
border-radius: 8px; cursor: pointer; }
.ctx button:hover { background: var(--surface2); }
.ctx button.danger { color: var(--red); }
.ctx button .k { margin-left: auto; font-size: 16px; opacity: 0.7; }
/* ===================== Split-Modal ===================== */
.overlay { position: absolute; inset: 0; z-index: 220; background: rgba(6,7,11,0.62); backdrop-filter: blur(4px);
display: none; align-items: center; justify-content: center; }
.overlay.show { display: flex; }
.modal { width: 360px; max-width: 90%; background: rgba(18,20,28,0.98); border: 1px solid var(--border);
border-radius: 18px; padding: 22px; box-shadow: 0 30px 70px -18px rgba(0,0,0,0.8); }
.modal h3 { margin: 0 0 4px; font-size: 18px; }
.modal p { margin: 0 0 18px; color: var(--muted); font-size: 13px; }
.split-val { text-align: center; font-size: 34px; font-weight: 800; font-variant-numeric: tabular-nums; margin-bottom: 4px; }
.split-sub { text-align: center; color: var(--muted); font-size: 12.5px; margin-bottom: 16px; }
input[type=range] { width: 100%; accent-color: var(--accent); height: 26px; }
.modal-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 18px; }
.btn { border: none; border-radius: 12px; padding: 12px; font-size: 14.5px; font-weight: 700; cursor: pointer; font-family: inherit; }
.btn.primary { background: var(--accent); color: #1a1206; }
.btn.ghost { background: var(--surface2); color: var(--fg); border: 1px solid var(--border); }
.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: 13px; font-size: 13.5px; font-weight: 600; z-index: 230; opacity: 0; pointer-events: none;
transition: .3s; box-shadow: 0 14px 34px -10px rgba(0,0,0,0.7); }
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
.hint-bar { position: fixed; bottom: 14px; left: 50%; transform: translateX(-50%); z-index: 5;
font-size: 12px; color: #6f7789; text-align: center; }
.hint-bar b { color: #9aa2b4; }
</style>
<div class="inv" id="inv">
<div class="inv-head">
<span class="logo">Æ</span>
<div class="title">Inventar<small>Max Weber · Rucksack</small></div>
<div class="weight">
<div class="weight-bar"><div class="weight-fill" id="weight-fill"></div></div>
<div class="weight-txt" id="weight-txt">0 / 30 kg</div>
</div>
</div>
<div class="inv-body">
<!-- Links: Umgebung -->
<div class="col umgebung">
<div class="panel-h">Spieler in der Nähe</div>
<div id="players"></div>
<div class="panel-h">Boden</div>
<div class="boden" id="boden"></div>
</div>
<!-- Mitte: Inventar-Grid -->
<div class="col grid-col">
<div class="panel-h">Tasche <span style="margin-left:auto;font-weight:600;text-transform:none;letter-spacing:0" id="slots-count"></span></div>
<div class="slots" id="slots"></div>
</div>
<!-- Rechts: Avatar + Ausrüstung + Hotbar -->
<div class="col avatar-col">
<div class="panel-h">Charakter &amp; Ausrüstung</div>
<div class="avatar-stage">
<div class="platform"></div>
<svg class="mannequin" viewBox="0 0 130 240" fill="none">
<defs>
<linearGradient id="body" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#3a4152"/><stop offset="55%" stop-color="#262b38"/><stop offset="100%" stop-color="#171a24"/>
</linearGradient>
</defs>
<ellipse cx="65" cy="34" rx="19" ry="21" fill="url(#body)" stroke="rgba(255,255,255,0.08)"/>
<path d="M46 66 q19 -10 38 0 l6 60 q-25 12 -50 0 Z" fill="url(#body)" stroke="rgba(255,255,255,0.08)"/>
<path d="M46 68 q-14 6 -18 46 l12 5 q6 -34 12 -44 Z" fill="url(#body)" stroke="rgba(255,255,255,0.06)"/>
<path d="M84 68 q14 6 18 46 l-12 5 q-6 -34 -12 -44 Z" fill="url(#body)" stroke="rgba(255,255,255,0.06)"/>
<path d="M50 128 q15 8 30 0 l-2 82 l-11 0 l-3 -60 l-3 60 l-11 0 Z" fill="url(#body)" stroke="rgba(255,255,255,0.07)"/>
</svg>
<div class="eq-slot eq-helm" data-eq="helm"><span class="eqlabel">Helm</span></div>
<div class="eq-slot eq-schmuck" data-eq="schmuck"><span class="eqlabel">Schmuck</span></div>
<div class="eq-slot eq-brust" data-eq="brust"><span class="eqlabel">Brust</span></div>
<div class="eq-slot eq-hose" data-eq="hose"><span class="eqlabel">Hose</span></div>
<div class="eq-slot eq-schuhe" data-eq="schuhe"><span class="eqlabel">Schuhe</span></div>
</div>
<div class="panel-h">Schnellzugriff (Hotbar)</div>
<div class="hotbar" id="hotbar"></div>
</div>
</div>
<!-- Overlays -->
<div class="ctx" id="ctx"></div>
<div class="overlay" id="overlay"></div>
<div class="toast" id="toast"></div>
</div>
<div class="hint-bar">
<b>Ziehen</b> zum Verschieben · auf <b>Boden</b> = fallenlassen · auf <b>Spieler</b> = geben · <b>Rechtsklick</b> für Menü &amp; Splitten · <b>Hover</b> für Details
</div>
<div class="tooltip" id="tooltip"></div>
<script>
"use strict";
/* =====================================================================
ITEM-DEFINITIONEN (Registry) — Name, Icon, Kategorie, Gewicht, Seltenheit …
komponenten = fest verbaute Assets (z. B. Handy → SIM + Akku)
waffe = besitzt Seriennummer & Aufsätze (Attachments)
slot = ausrüstbar in Ausrüstungs-Slot; verbrauchbar = wird bei Nutzung -1
===================================================================== */
/* Item-Registry. In FiveM wird sie vom Server überschrieben, damit Server und
Oberfläche garantiert dieselben Werte verwenden. */
let itemDefs = {
pistole: { name: "Pistole P99", icon: "🔫", kat: "Waffe", gewicht: 1.2, rar: "epic", waffe: true,
beschreibung: "Halbautomatische 9mm-Pistole. Registrierte Feuerwaffe." },
munition9: { name: "9mm Munition", icon: "🧨", kat: "Munition", gewicht: 0.01, rar: "common", stapelbar: true,
beschreibung: "Standard-Patronen für 9mm-Waffen." },
handy: { name: "Smartphone", icon: "📱", kat: "Elektronik", gewicht: 0.3, rar: "rare",
komponenten: ["SIM-Karte", "Akku"], nutzbar: true,
beschreibung: "Modernes Smartphone. Enthält verbaute Komponenten." },
wasser: { name: "Wasserflasche", icon: "🥤", kat: "Nahrung", gewicht: 0.5, rar: "common", stapelbar: true, verbrauchbar: true, nutzbar: true,
beschreibung: "Löscht den Durst. +25 Durst." },
sandwich: { name: "Sandwich", icon: "🥪", kat: "Nahrung", gewicht: 0.2, rar: "common", stapelbar: true, verbrauchbar: true, nutzbar: true,
beschreibung: "Stillt den Hunger. +30 Hunger." },
verband: { name: "Verband", icon: "🩹", kat: "Medizin", gewicht: 0.1, rar: "uncommon", stapelbar: true, verbrauchbar: true, nutzbar: true,
beschreibung: "Stoppt Blutungen und heilt leichte Wunden." },
tablette: { name: "Schmerztabletten", icon: "💊", kat: "Medizin", gewicht: 0.02, rar: "common", stapelbar: true, verbrauchbar: true, nutzbar: true,
beschreibung: "Lindert Schmerzen kurzfristig." },
funk: { name: "Funkgerät", icon: "📻", kat: "Elektronik", gewicht: 0.4, rar: "uncommon", nutzbar: true,
beschreibung: "Kurzstrecken-Funk für die Kommunikation." },
dietrich: { name: "Dietrich", icon: "🗝️", kat: "Werkzeug", gewicht: 0.05, rar: "uncommon", stapelbar: true,
beschreibung: "Zum Öffnen einfacher Schlösser. Kann brechen." },
reparaturkit:{ name: "Reparaturkit", icon: "🧰", kat: "Werkzeug", gewicht: 2.0, rar: "uncommon", nutzbar: true,
beschreibung: "Repariert Fahrzeuge notdürftig." },
bargeld: { name: "Bargeld", icon: "💵", kat: "Wertsache", gewicht: 0, rar: "legendary", stapelbar: true,
beschreibung: "Sauberes Bargeld." },
goldkette: { name: "Goldkette", icon: "📿", kat: "Schmuck", gewicht: 0.15, rar: "epic", slot: "schmuck",
beschreibung: "Massive Goldkette. Ein echter Hingucker." },
helm: { name: "Ballistikhelm", icon: "⛑️", kat: "Ausrüstung", gewicht: 1.0, rar: "uncommon", slot: "helm",
beschreibung: "Schützt den Kopf vor Treffern." },
weste: { name: "Schutzweste", icon: "🦺", kat: "Ausrüstung", gewicht: 3.5, rar: "rare", slot: "brust",
beschreibung: "Kugelsichere Weste. Reduziert Schaden am Oberkörper." },
jeans: { name: "Jeans", icon: "👖", kat: "Kleidung", gewicht: 0.6, rar: "common", slot: "hose",
beschreibung: "Robuste Jeans." },
sneaker: { name: "Sneaker", icon: "👟", kat: "Kleidung", gewicht: 0.8, rar: "common", slot: "schuhe",
beschreibung: "Bequeme Sneaker." }
};
// Icon-Hinweise für leere Ausrüstungs-Slots
const slotHinweis = { helm: "⛑️", brust: "🦺", hose: "👖", schuhe: "👟", schmuck: "💍" };
const slotNamen = { helm: "Helm", brust: "Brust", hose: "Hose", schuhe: "Schuhe", schmuck: "Schmuck" };
const rarNamen = { common: "Gewöhnlich", uncommon: "Ungewöhnlich", rare: "Selten", epic: "Episch", legendary: "Legendär" };
let MAX_GEWICHT = 30; // kg — kommt vom Server
const SLOTS = 25; // Größe der Tasche
/* =====================================================================
ZUSTAND — die verschiedenen "Orte", an denen Items liegen können
===================================================================== */
/* Alle Behälter werden vom Server befüllt — die Oberfläche hält nur eine
Anzeigekopie und entscheidet selbst nichts. */
let inventar = new Array(SLOTS).fill(null);
let ausruestung = { helm: null, brust: null, hose: null, schuhe: null, schmuck: null };
let hotbar = new Array(5).fill(null);
let boden = []; // [{ bodenId, def, menge, meta }]
let spielerNah = []; // [{ id, bekannt }]
let gewichtVomServer = 0;
// Slotnummern der Ausrüstungsplätze (kommen vom Server)
let EQUIP_SLOTS = { helm: 200, brust: 201, hose: 202, schuhe: 203, schmuck: 204 };
const HOTBAR_BASIS = 100;
/* Anzeige-Daten eines Spielers in der Nähe.
WICHTIG (RP): Der Server schickt bewusst keine Namen mit — im Rollenspiel
kennt man eine Person erst nach Ausweis oder Visitenkarte. */
function spielerAnzeige(sp) {
return sp.bekannt
? { label: sp.name, sub: sp.rolle || "", av: (sp.name || "?").substring(0, 1), farbe: "#4aa8ff" }
: { label: "Unbekannt", sub: "ID #" + sp.id, av: "?", farbe: "#3a3f4d" };
}
/* =====================================================================
HILFSFUNKTIONEN
===================================================================== */
const $ = id => document.getElementById(id);
function sicher(t) { const d = document.createElement("div"); d.textContent = t; return d.innerHTML; }
// Gewicht deutsch als kg
function kg(x) { return x.toLocaleString("de-DE", { minimumFractionDigits: x < 10 ? 1 : 0, maximumFractionDigits: 2 }) + " kg"; }
let toastTimer = null;
function toast(text) {
const t = $("toast"); t.textContent = text; t.classList.add("show");
clearTimeout(toastTimer); toastTimer = setTimeout(() => t.classList.remove("show"), 2000);
}
/* =====================================================================
ORTE LESEN / SCHREIBEN (inv | equip | hotbar | boden)
===================================================================== */
function leseOrt(o) {
if (o.typ === "inv") return inventar[o.i];
if (o.typ === "equip") return ausruestung[o.key];
if (o.typ === "hotbar") return hotbar[o.i];
if (o.typ === "boden") return boden[o.i];
return null;
}
/* Schreibende Helfer gibt es bewusst nicht mehr: Der Zustand wird
ausschließlich vom Server gesetzt (siehe setzeZustand). */
function gleicherOrt(a, b) { return a.typ === b.typ && a.i === b.i && a.key === b.key; }
// erster freier Tasche-Slot (für Splitten)
function freierSlot() { return inventar.findIndex(s => s === null); }
/* =====================================================================
VERSCHIEBEN (Drag & Drop-Kernlogik)
===================================================================== */
let zieht = null; // aktuell gezogener Ort-Deskriptor
/* Rechnet einen Ort-Deskriptor in die Slotnummer des Servers um.
Tasche 0–24, Hotbar 100–104, Ausrüstung 200–204. */
function ortZuSlot(o) {
if (o.typ === "inv") return o.i;
if (o.typ === "hotbar") return HOTBAR_BASIS + o.i;
if (o.typ === "equip") return EQUIP_SLOTS[o.key];
return null;
}
/* Verschieben: Die Oberfläche entscheidet nichts selbst, sondern schickt den
Wunsch an den Server. Dieser prüft und antwortet mit dem neuen Zustand. */
function verschiebe(quelle, ziel) {
if (gleicherOrt(quelle, ziel)) return;
const a = leseOrt(quelle);
if (!a) return;
// Vom Boden in die Tasche ziehen = aufheben
if (quelle.typ === "boden") {
nui("aufheben", { bodenId: a.bodenId });
return;
}
// Auf den Boden ziehen = fallenlassen
if (ziel.typ === "boden") {
nui("fallenlassen", { slot: ortZuSlot(quelle), menge: a.menge });
return;
}
// Auf einen Spieler ziehen = geben
if (ziel.typ === "spieler") {
nui("geben", { slot: ortZuSlot(quelle), menge: a.menge, zielId: ziel.id });
return;
}
const vonSlot = ortZuSlot(quelle), nachSlot = ortZuSlot(ziel);
if (vonSlot === null || nachSlot === null) return;
nui("verschieben", { von: vonSlot, nach: nachSlot });
}
/* =====================================================================
RENDERN
===================================================================== */
function render() {
// Gewicht: in FiveM rechnet der Server, im Browser rechnen wir selbst
let gewicht = gewichtVomServer;
if (!IST_FIVEM) {
gewicht = 0;
const zaehle = it => { if (it) gewicht += it.def.gewicht * it.menge; };
inventar.forEach(zaehle); Object.values(ausruestung).forEach(zaehle); hotbar.forEach(zaehle);
}
const proz = Math.min(100, gewicht / MAX_GEWICHT * 100);
const fill = $("weight-fill");
fill.style.width = proz + "%";
fill.style.background = proz > 90 ? "var(--red)" : (proz > 70 ? "var(--amber)" : "var(--accent)");
$("weight-txt").textContent = gewicht.toLocaleString("de-DE", { maximumFractionDigits: 1 }) + " / " + MAX_GEWICHT + " kg";
$("slots-count").textContent = inventar.filter(Boolean).length + " / " + SLOTS;
// Tasche
const slots = $("slots"); slots.innerHTML = "";
for (let i = 0; i < SLOTS; i++) {
const slot = document.createElement("div");
slot.className = "slot";
dropZiel(slot, { typ: "inv", i });
if (inventar[i]) slot.appendChild(baueItem(inventar[i], { typ: "inv", i }));
slots.appendChild(slot);
}
// Ausrüstung (Slots sind persistent -> Drop wird einmalig in initDrop() verdrahtet)
document.querySelectorAll(".eq-slot").forEach(el => {
const key = el.dataset.eq;
el.querySelectorAll(".item").forEach(n => n.remove());
const alt = el.querySelector(".hintico"); if (alt) alt.remove();
if (ausruestung[key]) el.insertBefore(baueItem(ausruestung[key], { typ: "equip", key }), el.firstChild);
else { const h = document.createElement("span"); h.className = "hintico"; h.textContent = slotHinweis[key]; el.insertBefore(h, el.firstChild); }
});
// Hotbar
const hb = $("hotbar"); hb.innerHTML = "";
for (let i = 0; i < 5; i++) {
const h = document.createElement("div"); h.className = "hot";
h.innerHTML = '<span class="num">' + (i + 1) + "</span>";
dropZiel(h, { typ: "hotbar", i });
if (hotbar[i]) h.appendChild(baueItem(hotbar[i], { typ: "hotbar", i }));
hb.appendChild(h);
}
// Boden (Container ist persistent -> Drop wird einmalig in initDrop() verdrahtet)
const bd = $("boden"); bd.innerHTML = "";
if (boden.length === 0) bd.innerHTML = '<div class="hint">Items hierher ziehen,<br>um sie fallenzulassen</div>';
else boden.forEach((it, i) => bd.appendChild(baueItem(it, { typ: "boden", i })));
// Spieler (anonymisiert, solange unbekannt)
const pl = $("players"); pl.innerHTML = "";
spielerNah.forEach(sp => {
const a = spielerAnzeige(sp);
const row = document.createElement("div"); row.className = "player";
row.innerHTML = '<span class="pa" style="background:' + a.farbe + '">' + a.av + '</span>' +
'<div><div class="pn">' + sicher(a.label) + '</div><div class="pr">' + sicher(a.sub) + '</div></div>' +
'<span class="give">🤝</span>';
dropZiel(row, { typ: "spieler", id: sp.id });
pl.appendChild(row);
});
}
// Drop-Ziel verdrahten
function dropZiel(el, ziel) {
el.addEventListener("dragover", e => { e.preventDefault(); el.classList.add("over"); });
el.addEventListener("dragleave", () => el.classList.remove("over"));
el.addEventListener("drop", e => {
e.preventDefault(); el.classList.remove("over");
if (zieht) { verschiebe(zieht, ziel); zieht = null; }
});
}
// Ein Item-Element (draggable, mit Hover-Tooltip & Rechtsklick) bauen
function baueItem(item, ort) {
const d = item.def;
const el = document.createElement("div");
el.className = "item rar-" + (d.rar || "common");
el.draggable = true;
const hatAssets = (d.komponenten && d.komponenten.length) || (item.meta && item.meta.attachments && item.meta.attachments.length);
el.innerHTML =
'<span class="ic">' + d.icon + "</span>" +
(item.menge > 1 ? '<span class="qty">' + item.menge + "</span>" : "") +
'<span class="wt">' + kg(d.gewicht * item.menge) + "</span>" +
(hatAssets ? '<span class="asset" title="Enthält Komponenten"></span>' : "");
el.addEventListener("dragstart", e => { zieht = ort; el.classList.add("dragging"); versteckeTooltip(); e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", d.name); });
el.addEventListener("dragend", () => el.classList.remove("dragging"));
// Doppelklick: benutzen (bzw. Ausrüstung anlegen)
el.addEventListener("dblclick", () => {
versteckeTooltip();
if (d.nutzbar) benutze(item, ort);
else if (d.slot && ort.typ !== "equip") verschiebe(ort, { typ: "equip", key: d.slot });
else toast(d.name + " lässt sich nicht benutzen");
});
el.addEventListener("mouseenter", e => zeigeTooltip(item, e));
el.addEventListener("mousemove", bewegeTooltip);
el.addEventListener("mouseleave", versteckeTooltip);
el.addEventListener("contextmenu", e => { e.preventDefault(); versteckeTooltip(); zeigeKontext(item, ort, e); });
return el;
}
/* =====================================================================
TOOLTIP (alle Details + Gewicht + Asset-Komponenten / Waffen-Infos)
===================================================================== */
const tooltip = $("tooltip");
function zeigeTooltip(item, e) {
const d = item.def;
let h = '<div class="tt-name tt-fg-' + (d.rar || "common") + '">' + sicher(d.name) + "</div>";
h += '<div class="tt-kat">' + sicher(d.kat) + " · " + (rarNamen[d.rar] || "Gewöhnlich") + "</div>";
h += '<div class="tt-desc">' + sicher(d.beschreibung) + "</div>";
h += '<div class="tt-row"><span>Gewicht</span><span>' + kg(d.gewicht) + " / Stück</span></div>";
if (item.menge > 1) h += '<div class="tt-row"><span>Gesamt (' + item.menge + "×)</span><span>" + kg(d.gewicht * item.menge) + "</span></div>";
// Assets / verbaute Komponenten (nur was da ist, kein Status)
if (d.komponenten && d.komponenten.length)
h += '<div class="tt-assets"><div class="tt-assets-h">Verbaute Komponenten</div>' + d.komponenten.map(k => '<span class="chip">' + sicher(k) + "</span>").join("") + "</div>";
// Waffen-Infos: Seriennummer + Aufsätze
if (d.waffe) {
h += '<div class="tt-row"><span>Seriennummer</span><span class="mono">' + sicher(item.meta && item.meta.serie ? item.meta.serie : "unbekannt") + "</span></div>";
if (item.meta && item.meta.attachments && item.meta.attachments.length)
h += '<div class="tt-assets"><div class="tt-assets-h">Aufsätze</div>' + item.meta.attachments.map(a => '<span class="chip">' + sicher(a) + "</span>").join("") + "</div>";
}
tooltip.innerHTML = h; tooltip.style.display = "block"; bewegeTooltip(e);
}
function bewegeTooltip(e) {
const pad = 16, w = tooltip.offsetWidth, hgt = tooltip.offsetHeight;
let x = e.clientX + pad, y = e.clientY + pad;
if (x + w > window.innerWidth - 8) x = e.clientX - w - pad;
if (y + hgt > window.innerHeight - 8) y = e.clientY - hgt - pad;
tooltip.style.left = x + "px"; tooltip.style.top = y + "px";
}
function versteckeTooltip() { tooltip.style.display = "none"; }
/* =====================================================================
RECHTSKLICK-MENÜ (Benutzen, Ausrüsten, Splitten, Geben, Fallenlassen)
===================================================================== */
const ctx = $("ctx");
function zeigeKontext(item, ort, e) {
const d = item.def;
const aktionen = [];
if (d.nutzbar) aktionen.push({ label: "Benutzen", icon: "▶", fn: () => benutze(item, ort) });
if (d.slot) aktionen.push({ label: "Ausrüsten", icon: "🛡", fn: () => verschiebe(ort, { typ: "equip", key: d.slot }) });
if (ort.typ === "equip") aktionen.push({ label: "Ablegen", icon: "↩", fn: () => ablegen(ort) });
if (item.menge > 1) aktionen.push({ label: "Splitten…", icon: "⇔", fn: () => oeffneSplit(item, ort) });
aktionen.push({ label: "Geben…", icon: "🤝", fn: () => oeffneGeben(item, ort) });
aktionen.push({ label: "Fallenlassen", icon: "🗑", danger: true, fn: () => verschiebe(ort, { typ: "boden" }) });
ctx.innerHTML = '<div class="ctx-head">' + sicher(d.name) + "</div>" +
aktionen.map((a, i) => '<button data-i="' + i + '"' + (a.danger ? ' class="danger"' : "") + ">" +
sicher(a.label) + '<span class="k">' + a.icon + "</span></button>").join("");
// Position relativ zum Inventar-Fenster
const box = $("inv").getBoundingClientRect();
let x = e.clientX - box.left, y = e.clientY - box.top;
ctx.classList.add("show");
x = Math.min(x, box.width - ctx.offsetWidth - 8);
y = Math.min(y, box.height - ctx.offsetHeight - 8);
ctx.style.left = x + "px"; ctx.style.top = y + "px";
ctx.querySelectorAll("button").forEach(b => b.addEventListener("click", () => {
schliesseKontext(); aktionen[+b.dataset.i].fn();
}));
}
function schliesseKontext() { ctx.classList.remove("show"); }
document.addEventListener("click", schliesseKontext);
document.addEventListener("scroll", schliesseKontext, true);
/* =====================================================================
AKTIONEN
===================================================================== */
// Item benutzen — Wirkung entscheidet der Server
function benutze(item, ort) {
nui("benutzen", { slot: ortZuSlot(ort) });
}
// Ausrüstung ablegen -> in ersten freien Tasche-Slot
function ablegen(ort) {
const frei = freierSlot();
if (frei < 0) { toast("Kein Platz in der Tasche"); return; }
verschiebe(ort, { typ: "inv", i: frei });
}
// Splitten-Modal
function oeffneSplit(item, ort) {
if (item.menge < 2) return;
const mitte = Math.floor(item.menge / 2);
$("overlay").innerHTML =
'<div class="modal"><h3>Stapel teilen</h3><p>' + sicher(item.def.name) + " · " + item.menge + " Stück</p>" +
'<div class="split-val" id="sv">' + mitte + "</div>" +
'<div class="split-sub" id="ss">' + mitte + " abtrennen · " + (item.menge - mitte) + " bleibt</div>" +
'<input type="range" id="sr" min="1" max="' + (item.menge - 1) + '" value="' + mitte + '">' +
'<div class="modal-actions"><button class="btn ghost" id="sc">Abbrechen</button>' +
'<button class="btn primary" id="sok">Teilen</button></div></div>';
$("overlay").classList.add("show");
const sr = $("sr");
sr.addEventListener("input", () => {
$("sv").textContent = sr.value;
$("ss").textContent = sr.value + " abtrennen · " + (item.menge - sr.value) + " bleibt";
});
$("sc").addEventListener("click", schliesseOverlay);
$("sok").addEventListener("click", () => {
// Teilen übernimmt der Server (prüft freien Platz selbst)
nui("splitten", { slot: ortZuSlot(ort), menge: parseInt(sr.value, 10) });
schliesseOverlay();
});
}
// Geben-Modal (Spieler in der Nähe auswählen)
function oeffneGeben(item, ort) {
$("overlay").innerHTML =
'<div class="modal"><h3>Item geben</h3><p>' + sicher(item.def.name) + (item.menge > 1 ? " ×" + item.menge : "") + " an wen?</p>" +
'<div id="glist">' + spielerNah.map((sp, i) => { const a = spielerAnzeige(sp); return
'<button class="btn ghost" data-g="' + i + '" style="width:100%;display:flex;align-items:center;gap:10px;justify-content:flex-start;margin-bottom:8px">' +
'<span class="pa" style="width:28px;height:28px;background:' + a.farbe + '">' + a.av + "</span>" +
sicher(a.label) + " · " + sicher(a.sub) + "</button>"; }).join("") + "</div>" +
'<button class="btn ghost" id="gc" style="width:100%">Abbrechen</button></div>';
$("overlay").classList.add("show");
$("gc").addEventListener("click", schliesseOverlay);
$("glist").querySelectorAll("[data-g]").forEach(b => b.addEventListener("click", () => {
const sp = spielerNah[+b.dataset.g];
schliesseOverlay();
verschiebe(ort, { typ: "spieler", id: sp.id });
}));
}
function schliesseOverlay() { $("overlay").classList.remove("show"); $("overlay").innerHTML = ""; }
$("overlay").addEventListener("click", e => { if (e.target === $("overlay")) schliesseOverlay(); });
/* =====================================================================
START
===================================================================== */
// Drop-Ziele der persistenten Elemente (Ausrüstung + Boden) einmalig verdrahten
function initDrop() {
document.querySelectorAll(".eq-slot").forEach(el => dropZiel(el, { typ: "equip", key: el.dataset.eq }));
dropZiel($("boden"), { typ: "boden" });
}
/* =====================================================================
NUI-BRÜCKE — Verbindung zum FiveM-Client
===================================================================== */
const IST_FIVEM = (typeof GetParentResourceName === "function");
const RESSOURCE = IST_FIVEM ? GetParentResourceName() : "aether-inventory";
/* Schickt eine Aktion an den Client; der reicht sie an den Server weiter. */
function nui(aktion, daten) {
if (!IST_FIVEM) { toast("(Browser-Ansicht — keine Serververbindung)"); return Promise.resolve(); }
return fetch(`https://${RESSOURCE}/${aktion}`, {
method: "POST",
headers: { "Content-Type": "application/json; charset=UTF-8" },
body: JSON.stringify(daten || {}),
}).catch(() => {});
}
/* Wandelt einen Servereintrag in ein Anzeige-Item um */
function baueEintrag(eintrag) {
const def = itemDefs[eintrag.item];
if (!def) return null; // unbekanntes Item überspringen
return { def, menge: eintrag.menge, meta: eintrag.meta || null, kennung: eintrag.item };
}
/* Übernimmt den kompletten Zustand vom Server */
function setzeZustand(daten) {
if (daten.items) itemDefs = daten.items;
if (daten.equipSlots) EQUIP_SLOTS = daten.equipSlots;
if (daten.maxGewicht) MAX_GEWICHT = daten.maxGewicht;
gewichtVomServer = daten.gewicht || 0;
// Behälter leeren und aus der flachen Slot-Tabelle neu aufbauen
inventar = new Array(SLOTS).fill(null);
hotbar = new Array(5).fill(null);
ausruestung = { helm: null, brust: null, hose: null, schuhe: null, schmuck: null };
const roh = daten.inventar || {};
for (const schluessel of Object.keys(roh)) {
const slot = parseInt(schluessel, 10);
const item = baueEintrag(roh[schluessel]);
if (!item) continue;
if (slot >= 200) {
// Ausrüstung: Slotnummer zurück auf den Schlüssel abbilden
const key = Object.keys(EQUIP_SLOTS).find(k => EQUIP_SLOTS[k] === slot);
if (key) ausruestung[key] = item;
} else if (slot >= HOTBAR_BASIS) {
hotbar[slot - HOTBAR_BASIS] = item;
} else if (slot >= 0 && slot < SLOTS) {
inventar[slot] = item;
}
}
// Boden-Items (jedes mit seiner Server-ID zum Aufheben)
boden = (daten.boden || []).map(b => {
const item = baueEintrag(b);
if (item) item.bodenId = b.id;
return item;
}).filter(Boolean);
spielerNah = daten.spieler || [];
render();
if (daten.hinweis) toast(daten.hinweis);
}
/* Nachrichten vom Client */
window.addEventListener("message", (ereignis) => {
const n = ereignis.data || {};
if (n.aktion === "oeffnen") {
document.body.style.display = "";
setzeZustand(n);
} else if (n.aktion === "zustand") {
setzeZustand(n);
} else if (n.aktion === "hinweis") {
toast(n.text || "");
} else if (n.aktion === "schliessen") {
document.body.style.display = "none";
schliesseOverlay(); schliesseKontext();
}
});
/* ESC schließt das Inventar */
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && IST_FIVEM) nui("schliessen", {});
});
/* =====================================================================
START
===================================================================== */
initDrop();
if (IST_FIVEM) {
document.body.style.display = "none"; // wartet auf "oeffnen"
render();
} else {
// Eigenständige Browser-Ansicht: Beispieldaten zum Prüfen des Layouts
const bsp = (id, menge, meta) => ({ def: itemDefs[id], menge, meta: meta || null, kennung: id });
inventar[0] = bsp("pistole", 1, { serie: "P99-8842-LS", attachments: ["Schalldämpfer", "Laservisier"] });
inventar[1] = bsp("munition9", 64);
inventar[2] = bsp("handy", 1);
inventar[3] = bsp("wasser", 3);
inventar[4] = bsp("sandwich", 2);
inventar[5] = bsp("verband", 5);
inventar[10] = bsp("bargeld", 1450);
ausruestung.brust = bsp("weste", 1);
ausruestung.hose = bsp("jeans", 1);
ausruestung.schuhe = bsp("sneaker", 1);
spielerNah = [{ id: 4821, bekannt: false }, { id: 1093, bekannt: false }];
render();
}
</script>
</body>
</html>