Всё, что здесь лежит, написал ИИ-агент на живом сервере под запись — без ESX, без QBCore, без скачанных с форума скриптов. - resources/justrp — свой ресурс на Lua: регистрация, вход, создание персонажа, спавн - resources/justrp/html — экраны NUI: кинематографичная загрузка, вход, редактор персонажа - api/api.py — внутренний HTTP-API к MariaDB, пароли через scrypt - server.cfg.example — пример конфига Секреты (пароль БД, внутренний ключ API) вынесены в переменные окружения и convar. Ключ Cfx.re в репозиторий не попадает. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1434 lines
38 KiB
HTML
1434 lines
38 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>JustRP</title>
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600;700&family=Inter:ital,wght@0,300;0,400;0,500;0,600;1,400&display=swap" rel="stylesheet">
|
||
<style>
|
||
/* ── Reset & tokens ── */
|
||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||
|
||
:root {
|
||
--bg: #07070c;
|
||
--bg1: #0d0d18;
|
||
--bg2: #131320;
|
||
--bg3: #1a1a28;
|
||
--border: rgba(255,255,255,0.06);
|
||
--border2: rgba(255,255,255,0.11);
|
||
--accent: #c8933f;
|
||
--accent-l: #e0aa55;
|
||
--glow: rgba(200,147,63,0.18);
|
||
--glow2: rgba(200,147,63,0.08);
|
||
--text: #ede9e4;
|
||
--text2: rgba(237,233,228,0.55);
|
||
--text3: rgba(237,233,228,0.28);
|
||
--text4: rgba(237,233,228,0.14);
|
||
--ok: #4ade80;
|
||
--err: #f87171;
|
||
--r: 10px;
|
||
--r2: 6px;
|
||
--ease: cubic-bezier(0.16,1,0.3,1);
|
||
}
|
||
|
||
html, body {
|
||
width: 100%; height: 100%; overflow: hidden;
|
||
background: var(--bg);
|
||
font-family: 'Inter', sans-serif;
|
||
color: var(--text);
|
||
-webkit-font-smoothing: antialiased;
|
||
}
|
||
|
||
/* ── Screen system ── */
|
||
.screen {
|
||
position: fixed; inset: 0;
|
||
display: flex;
|
||
opacity: 0;
|
||
pointer-events: none;
|
||
transition: opacity 0.5s var(--ease);
|
||
}
|
||
|
||
.screen.active {
|
||
opacity: 1;
|
||
pointer-events: all;
|
||
}
|
||
|
||
/* ── Auth ── */
|
||
#s-auth {
|
||
align-items: stretch;
|
||
}
|
||
|
||
.auth-left {
|
||
flex: 0 0 42%;
|
||
display: flex; flex-direction: column;
|
||
justify-content: flex-end;
|
||
padding: 64px;
|
||
background: linear-gradient(160deg, #0a0a18 0%, #07070c 100%);
|
||
border-right: 1px solid var(--border);
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.auth-left::before {
|
||
content: '';
|
||
position: absolute; inset: 0;
|
||
background:
|
||
radial-gradient(ellipse 60% 50% at 20% 80%, var(--glow2) 0%, transparent 70%);
|
||
pointer-events: none;
|
||
}
|
||
|
||
.auth-logo {
|
||
font-family: 'Cinzel', serif;
|
||
font-size: 48px; font-weight: 700;
|
||
letter-spacing: 0.14em;
|
||
line-height: 1;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.auth-logo span { color: var(--accent); }
|
||
|
||
.auth-tagline {
|
||
font-size: 13px;
|
||
letter-spacing: 0.25em;
|
||
text-transform: uppercase;
|
||
color: var(--text2);
|
||
margin-bottom: 40px;
|
||
}
|
||
|
||
.auth-rule {
|
||
width: 56px; height: 2px;
|
||
background: var(--accent);
|
||
margin-bottom: 28px;
|
||
}
|
||
|
||
.auth-info {
|
||
font-size: 13px;
|
||
line-height: 1.8;
|
||
color: var(--text3);
|
||
max-width: 260px;
|
||
}
|
||
|
||
.auth-right {
|
||
flex: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 64px;
|
||
background: var(--bg1);
|
||
}
|
||
|
||
.auth-box {
|
||
width: 100%;
|
||
max-width: 380px;
|
||
}
|
||
|
||
/* Tabs */
|
||
.auth-tabs {
|
||
display: flex;
|
||
gap: 0;
|
||
margin-bottom: 36px;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
|
||
.auth-tab {
|
||
flex: 1;
|
||
padding: 0 0 14px;
|
||
text-align: center;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
letter-spacing: 0.1em;
|
||
text-transform: uppercase;
|
||
color: var(--text3);
|
||
cursor: pointer;
|
||
border-bottom: 2px solid transparent;
|
||
margin-bottom: -1px;
|
||
transition: color 0.2s ease, border-color 0.2s ease;
|
||
user-select: none;
|
||
}
|
||
|
||
.auth-tab.active {
|
||
color: var(--text);
|
||
border-bottom-color: var(--accent);
|
||
}
|
||
|
||
.auth-tab:hover:not(.active) {
|
||
color: var(--text2);
|
||
}
|
||
|
||
/* Form panels */
|
||
.auth-form-panel {
|
||
display: none;
|
||
}
|
||
|
||
.auth-form-panel.active {
|
||
display: block;
|
||
animation: slideIn 0.35s var(--ease);
|
||
}
|
||
|
||
@keyframes slideIn {
|
||
from { opacity: 0; transform: translateY(10px); }
|
||
to { opacity: 1; transform: translateY(0); }
|
||
}
|
||
|
||
/* Fields */
|
||
.field {
|
||
margin-bottom: 18px;
|
||
}
|
||
|
||
.field label {
|
||
display: block;
|
||
font-size: 11px;
|
||
font-weight: 500;
|
||
letter-spacing: 0.1em;
|
||
text-transform: uppercase;
|
||
color: var(--text3);
|
||
margin-bottom: 8px;
|
||
transition: color 0.2s ease;
|
||
}
|
||
|
||
.field:focus-within label {
|
||
color: var(--accent);
|
||
}
|
||
|
||
.field input, .field textarea {
|
||
width: 100%;
|
||
background: var(--bg2);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--r2);
|
||
padding: 12px 16px;
|
||
font-size: 14px;
|
||
font-family: inherit;
|
||
color: var(--text);
|
||
outline: none;
|
||
transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
|
||
resize: vertical;
|
||
}
|
||
|
||
.field input::placeholder, .field textarea::placeholder {
|
||
color: var(--text4);
|
||
}
|
||
|
||
.field input:focus, .field textarea:focus {
|
||
border-color: var(--accent);
|
||
background: var(--bg3);
|
||
box-shadow: 0 0 0 3px var(--glow2);
|
||
}
|
||
|
||
.field input.error {
|
||
border-color: var(--err);
|
||
box-shadow: 0 0 0 3px rgba(248,113,113,0.08);
|
||
}
|
||
|
||
.field input.ok {
|
||
border-color: rgba(74,222,128,0.35);
|
||
}
|
||
|
||
.field-error {
|
||
font-size: 11px;
|
||
color: var(--err);
|
||
margin-top: 6px;
|
||
min-height: 16px;
|
||
transition: opacity 0.2s ease;
|
||
}
|
||
|
||
/* Password toggle */
|
||
.pw-wrap { position: relative; }
|
||
.pw-toggle {
|
||
position: absolute; right: 14px; top: 50%; transform: translateY(-50%);
|
||
background: none; border: none; cursor: pointer;
|
||
color: var(--text3); font-size: 13px;
|
||
transition: color 0.2s ease;
|
||
padding: 2px;
|
||
}
|
||
.pw-toggle:hover { color: var(--text2); }
|
||
|
||
/* Password strength */
|
||
.pw-strength {
|
||
display: flex; gap: 4px;
|
||
margin-top: 8px; height: 3px;
|
||
}
|
||
.pw-strength-bar {
|
||
flex: 1; border-radius: 2px;
|
||
background: var(--border);
|
||
transition: background 0.3s ease;
|
||
}
|
||
|
||
/* Buttons */
|
||
.btn {
|
||
display: flex; align-items: center; justify-content: center;
|
||
width: 100%; padding: 13px 20px;
|
||
border: none; border-radius: var(--r2);
|
||
font-size: 13px; font-family: inherit;
|
||
font-weight: 500; letter-spacing: 0.08em;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
user-select: none;
|
||
}
|
||
|
||
.btn-primary {
|
||
background: var(--accent);
|
||
color: #07070c;
|
||
}
|
||
|
||
.btn-primary:hover {
|
||
background: var(--accent-l);
|
||
box-shadow: 0 4px 20px rgba(200,147,63,0.4);
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
.btn-primary:active {
|
||
transform: translateY(0);
|
||
box-shadow: none;
|
||
}
|
||
|
||
.btn-primary:disabled {
|
||
opacity: 0.4;
|
||
cursor: not-allowed;
|
||
transform: none;
|
||
box-shadow: none;
|
||
}
|
||
|
||
.btn-ghost {
|
||
background: transparent;
|
||
border: 1px solid var(--border2);
|
||
color: var(--text2);
|
||
}
|
||
|
||
.btn-ghost:hover {
|
||
border-color: var(--accent);
|
||
color: var(--accent);
|
||
background: var(--glow2);
|
||
}
|
||
|
||
/* Global error banner */
|
||
.alert {
|
||
padding: 12px 16px;
|
||
border-radius: var(--r2);
|
||
font-size: 13px;
|
||
line-height: 1.5;
|
||
margin-bottom: 20px;
|
||
opacity: 0;
|
||
height: 0;
|
||
overflow: hidden;
|
||
transition: opacity 0.3s ease, height 0.3s ease, padding 0.3s ease;
|
||
}
|
||
|
||
.alert.show {
|
||
opacity: 1;
|
||
height: auto;
|
||
}
|
||
|
||
.alert-error {
|
||
background: rgba(248,113,113,0.08);
|
||
border: 1px solid rgba(248,113,113,0.2);
|
||
color: var(--err);
|
||
}
|
||
|
||
.auth-footer {
|
||
margin-top: 24px;
|
||
text-align: center;
|
||
font-size: 12px;
|
||
color: var(--text3);
|
||
}
|
||
|
||
/* ── Character select ── */
|
||
#s-chars {
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex-direction: column;
|
||
background: var(--bg);
|
||
}
|
||
|
||
.chars-header {
|
||
text-align: center;
|
||
margin-bottom: 48px;
|
||
animation: fadeUp 0.6s var(--ease) forwards;
|
||
}
|
||
|
||
.chars-header h1 {
|
||
font-family: 'Cinzel', serif;
|
||
font-size: 28px; font-weight: 600;
|
||
letter-spacing: 0.1em;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.chars-header p {
|
||
font-size: 13px; color: var(--text3);
|
||
letter-spacing: 0.05em;
|
||
}
|
||
|
||
.chars-grid {
|
||
display: flex;
|
||
gap: 20px;
|
||
animation: fadeUp 0.6s var(--ease) 0.1s both;
|
||
}
|
||
|
||
.char-card {
|
||
width: 220px;
|
||
background: var(--bg1);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--r);
|
||
padding: 28px 24px;
|
||
cursor: pointer;
|
||
transition: all 0.25s var(--ease);
|
||
text-align: center;
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.char-card::before {
|
||
content: '';
|
||
position: absolute; inset: 0;
|
||
background: radial-gradient(ellipse at top, var(--glow2), transparent 70%);
|
||
opacity: 0;
|
||
transition: opacity 0.3s ease;
|
||
}
|
||
|
||
.char-card:hover {
|
||
border-color: var(--accent);
|
||
transform: translateY(-4px);
|
||
box-shadow: 0 12px 40px rgba(0,0,0,0.4), 0 0 0 1px var(--glow);
|
||
}
|
||
|
||
.char-card:hover::before { opacity: 1; }
|
||
|
||
.char-card.empty {
|
||
border-style: dashed;
|
||
border-color: var(--border);
|
||
}
|
||
|
||
.char-card.empty:hover {
|
||
border-color: var(--accent);
|
||
border-style: dashed;
|
||
}
|
||
|
||
.char-avatar {
|
||
width: 64px; height: 64px;
|
||
border-radius: 50%;
|
||
background: var(--bg3);
|
||
border: 2px solid var(--border2);
|
||
margin: 0 auto 16px;
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-size: 24px;
|
||
color: var(--text3);
|
||
transition: border-color 0.3s ease;
|
||
}
|
||
|
||
.char-card:hover .char-avatar {
|
||
border-color: var(--accent);
|
||
}
|
||
|
||
.char-name {
|
||
font-size: 15px; font-weight: 500;
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.char-sub {
|
||
font-size: 12px; color: var(--text3);
|
||
}
|
||
|
||
.char-plus {
|
||
font-size: 28px;
|
||
color: var(--text4);
|
||
line-height: 1;
|
||
margin-bottom: 12px;
|
||
transition: color 0.3s ease;
|
||
}
|
||
|
||
.char-card.empty:hover .char-plus {
|
||
color: var(--accent);
|
||
}
|
||
|
||
/* ── Character creation ── */
|
||
#s-char-create {
|
||
align-items: stretch;
|
||
}
|
||
|
||
.cc-preview {
|
||
flex: 1;
|
||
display: flex; flex-direction: column;
|
||
align-items: center; justify-content: flex-end;
|
||
padding: 40px;
|
||
background: rgba(0,0,0,0.3);
|
||
position: relative;
|
||
cursor: grab;
|
||
user-select: none;
|
||
}
|
||
|
||
.cc-preview:active { cursor: grabbing; }
|
||
|
||
.cc-hint {
|
||
padding: 10px 16px;
|
||
background: rgba(7,7,12,0.7);
|
||
border: 1px solid var(--border);
|
||
border-radius: 20px;
|
||
font-size: 11px; color: var(--text3);
|
||
letter-spacing: 0.05em;
|
||
backdrop-filter: blur(8px);
|
||
animation: pulse 2s ease-in-out infinite;
|
||
}
|
||
|
||
@keyframes pulse {
|
||
0%, 100% { opacity: 0.6; }
|
||
50% { opacity: 1; }
|
||
}
|
||
|
||
.cc-panel {
|
||
width: 400px;
|
||
background: var(--bg1);
|
||
border-left: 1px solid var(--border);
|
||
display: flex; flex-direction: column;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.cc-header {
|
||
padding: 28px 32px 20px;
|
||
border-bottom: 1px solid var(--border);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.cc-header h2 {
|
||
font-family: 'Cinzel', serif;
|
||
font-size: 18px; font-weight: 600;
|
||
letter-spacing: 0.08em;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.cc-header p { font-size: 12px; color: var(--text3); }
|
||
|
||
.cc-steps {
|
||
display: flex; gap: 6px; margin-top: 14px;
|
||
}
|
||
|
||
.cc-step {
|
||
flex: 1; height: 2px;
|
||
border-radius: 2px;
|
||
background: var(--border);
|
||
transition: background 0.3s ease;
|
||
}
|
||
|
||
.cc-step.done { background: var(--accent); }
|
||
.cc-step.active { background: rgba(200,147,63,0.5); }
|
||
|
||
.cc-body {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 24px 32px;
|
||
scrollbar-width: thin;
|
||
scrollbar-color: var(--border2) transparent;
|
||
}
|
||
|
||
.cc-body::-webkit-scrollbar { width: 4px; }
|
||
.cc-body::-webkit-scrollbar-track { background: transparent; }
|
||
.cc-body::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 2px; }
|
||
|
||
.cc-section {
|
||
display: none;
|
||
animation: slideIn 0.3s var(--ease);
|
||
}
|
||
|
||
.cc-section.active { display: block; }
|
||
|
||
.cc-section-title {
|
||
font-size: 11px; font-weight: 500;
|
||
letter-spacing: 0.15em; text-transform: uppercase;
|
||
color: var(--accent);
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
/* Sliders */
|
||
.slider-row {
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.slider-row label {
|
||
display: flex; justify-content: space-between;
|
||
font-size: 12px;
|
||
color: var(--text2);
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.slider-row label span {
|
||
color: var(--text3);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
input[type=range] {
|
||
-webkit-appearance: none;
|
||
width: 100%; height: 3px;
|
||
background: var(--bg3);
|
||
border-radius: 3px;
|
||
outline: none;
|
||
cursor: pointer;
|
||
position: relative;
|
||
}
|
||
|
||
input[type=range]::-webkit-slider-thumb {
|
||
-webkit-appearance: none;
|
||
width: 14px; height: 14px;
|
||
background: var(--accent);
|
||
border-radius: 50%;
|
||
box-shadow: 0 0 6px rgba(200,147,63,0.5);
|
||
transition: transform 0.15s ease;
|
||
}
|
||
|
||
input[type=range]:hover::-webkit-slider-thumb,
|
||
input[type=range]:focus::-webkit-slider-thumb {
|
||
transform: scale(1.25);
|
||
}
|
||
|
||
/* Color grid */
|
||
.color-grid {
|
||
display: flex; flex-wrap: wrap; gap: 6px;
|
||
margin-top: 4px;
|
||
}
|
||
|
||
.color-dot {
|
||
width: 24px; height: 24px;
|
||
border-radius: 50%;
|
||
cursor: pointer;
|
||
border: 2px solid transparent;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.color-dot:hover, .color-dot.selected {
|
||
border-color: var(--accent);
|
||
transform: scale(1.15);
|
||
}
|
||
|
||
/* Gender pills */
|
||
.gender-row {
|
||
display: flex; gap: 12px;
|
||
margin-bottom: 24px;
|
||
}
|
||
|
||
.gender-pill {
|
||
flex: 1; padding: 10px;
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--r2);
|
||
cursor: pointer;
|
||
text-align: center;
|
||
font-size: 13px;
|
||
color: var(--text2);
|
||
transition: all 0.2s ease;
|
||
user-select: none;
|
||
}
|
||
|
||
.gender-pill:hover { border-color: var(--border2); }
|
||
.gender-pill.selected {
|
||
border-color: var(--accent);
|
||
background: var(--glow2);
|
||
color: var(--accent);
|
||
}
|
||
|
||
/* Style options */
|
||
.style-grid {
|
||
display: grid; grid-template-columns: repeat(5, 1fr);
|
||
gap: 6px; margin-top: 8px;
|
||
}
|
||
|
||
.style-opt {
|
||
aspect-ratio: 1;
|
||
background: var(--bg2);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--r2);
|
||
cursor: pointer;
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-size: 11px; color: var(--text3);
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.style-opt:hover { border-color: var(--border2); }
|
||
.style-opt.selected {
|
||
border-color: var(--accent);
|
||
background: var(--glow2);
|
||
color: var(--accent);
|
||
}
|
||
|
||
.cc-footer {
|
||
padding: 20px 32px;
|
||
border-top: 1px solid var(--border);
|
||
display: flex; gap: 12px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
/* ── Spawn ── */
|
||
#s-spawn {
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex-direction: column;
|
||
background: var(--bg);
|
||
}
|
||
|
||
.spawn-header {
|
||
text-align: center;
|
||
margin-bottom: 40px;
|
||
animation: fadeUp 0.6s var(--ease) forwards;
|
||
}
|
||
|
||
.spawn-header h2 {
|
||
font-family: 'Cinzel', serif;
|
||
font-size: 24px; font-weight: 600;
|
||
letter-spacing: 0.12em;
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.spawn-header p { font-size: 13px; color: var(--text3); }
|
||
|
||
.spawn-grid {
|
||
display: flex; gap: 16px;
|
||
flex-wrap: wrap;
|
||
justify-content: center;
|
||
max-width: 900px;
|
||
animation: fadeUp 0.6s var(--ease) 0.1s both;
|
||
}
|
||
|
||
.spawn-card {
|
||
width: 160px;
|
||
background: var(--bg1);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--r);
|
||
padding: 24px 20px 20px;
|
||
cursor: pointer;
|
||
text-align: center;
|
||
transition: all 0.25s var(--ease);
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.spawn-card::before {
|
||
content: '';
|
||
position: absolute; inset: 0;
|
||
background: linear-gradient(180deg, var(--glow2) 0%, transparent 60%);
|
||
opacity: 0;
|
||
transition: opacity 0.3s ease;
|
||
}
|
||
|
||
.spawn-card:hover {
|
||
border-color: var(--accent);
|
||
transform: translateY(-4px);
|
||
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
||
}
|
||
|
||
.spawn-card:hover::before { opacity: 1; }
|
||
|
||
.spawn-card.selected {
|
||
border-color: var(--accent);
|
||
background: linear-gradient(180deg, rgba(200,147,63,0.06) 0%, var(--bg1) 100%);
|
||
}
|
||
|
||
.spawn-icon {
|
||
font-size: 28px;
|
||
margin-bottom: 12px;
|
||
display: block;
|
||
}
|
||
|
||
.spawn-name {
|
||
font-size: 13px; font-weight: 500;
|
||
margin-bottom: 4px;
|
||
line-height: 1.3;
|
||
}
|
||
|
||
.spawn-desc {
|
||
font-size: 11px; color: var(--text3);
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.spawn-footer {
|
||
margin-top: 36px;
|
||
animation: fadeUp 0.6s var(--ease) 0.2s both;
|
||
}
|
||
|
||
/* ── Misc ── */
|
||
@keyframes fadeUp {
|
||
from { opacity: 0; transform: translateY(16px); }
|
||
to { opacity: 1; transform: translateY(0); }
|
||
}
|
||
|
||
.spinner {
|
||
display: inline-block;
|
||
width: 14px; height: 14px;
|
||
border: 2px solid rgba(7,7,12,0.4);
|
||
border-top-color: #07070c;
|
||
border-radius: 50%;
|
||
animation: spin 0.7s linear infinite;
|
||
margin-right: 8px;
|
||
vertical-align: middle;
|
||
}
|
||
|
||
@keyframes spin { to { transform: rotate(360deg); } }
|
||
|
||
.divider {
|
||
height: 1px;
|
||
background: var(--border);
|
||
margin: 28px 0;
|
||
}
|
||
|
||
/* ── Username display ── */
|
||
.top-bar {
|
||
position: fixed; top: 0; left: 0; right: 0;
|
||
height: 48px;
|
||
display: flex; align-items: center; justify-content: flex-end;
|
||
padding: 0 24px;
|
||
background: linear-gradient(180deg, rgba(7,7,12,0.95) 0%, transparent 100%);
|
||
z-index: 100;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.top-user {
|
||
font-size: 12px; color: var(--text3);
|
||
letter-spacing: 0.06em;
|
||
}
|
||
|
||
.top-user span { color: var(--accent); }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<!-- ═════════════ AUTH ═════════════ -->
|
||
<div id="s-auth" class="screen">
|
||
<div class="auth-left">
|
||
<div class="auth-logo">JUST<span>RP</span></div>
|
||
<div class="auth-tagline">Los Santos Roleplay</div>
|
||
<div class="auth-rule"></div>
|
||
<div class="auth-info">
|
||
A persistent roleplaying community set in Los Santos.<br><br>
|
||
Every character, every decision, every story — yours to write.
|
||
</div>
|
||
</div>
|
||
|
||
<div class="auth-right">
|
||
<div class="auth-box">
|
||
<div class="auth-tabs">
|
||
<div class="auth-tab active" data-tab="login">Login</div>
|
||
<div class="auth-tab" data-tab="register">Register</div>
|
||
</div>
|
||
|
||
<div id="auth-alert" class="alert alert-error"></div>
|
||
|
||
<!-- LOGIN -->
|
||
<div class="auth-form-panel active" id="panel-login">
|
||
<div class="field">
|
||
<label>Username</label>
|
||
<input id="l-username" type="text" placeholder="Enter your username" autocomplete="off" spellcheck="false">
|
||
<div class="field-error" id="l-username-err"></div>
|
||
</div>
|
||
<div class="field">
|
||
<label>Password</label>
|
||
<div class="pw-wrap">
|
||
<input id="l-password" type="password" placeholder="Enter your password" autocomplete="off">
|
||
<button class="pw-toggle" data-target="l-password">👁</button>
|
||
</div>
|
||
<div class="field-error" id="l-password-err"></div>
|
||
</div>
|
||
<button class="btn btn-primary" id="btn-login">Sign In</button>
|
||
<div class="auth-footer">
|
||
No account? <a href="#" class="link" data-tab="register">Create one</a>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- REGISTER -->
|
||
<div class="auth-form-panel" id="panel-register">
|
||
<div class="field">
|
||
<label>Username</label>
|
||
<input id="r-username" type="text" placeholder="Choose a username (3–20 chars)" autocomplete="off" spellcheck="false" maxlength="20">
|
||
<div class="field-error" id="r-username-err"></div>
|
||
</div>
|
||
<div class="field">
|
||
<label>Password</label>
|
||
<div class="pw-wrap">
|
||
<input id="r-password" type="password" placeholder="Choose a strong password" autocomplete="off" minlength="6">
|
||
<button class="pw-toggle" data-target="r-password">👁</button>
|
||
</div>
|
||
<div class="pw-strength" id="pw-strength">
|
||
<div class="pw-strength-bar" id="ps1"></div>
|
||
<div class="pw-strength-bar" id="ps2"></div>
|
||
<div class="pw-strength-bar" id="ps3"></div>
|
||
<div class="pw-strength-bar" id="ps4"></div>
|
||
</div>
|
||
<div class="field-error" id="r-password-err"></div>
|
||
</div>
|
||
<div class="field">
|
||
<label>Confirm Password</label>
|
||
<div class="pw-wrap">
|
||
<input id="r-confirm" type="password" placeholder="Repeat your password" autocomplete="off">
|
||
<button class="pw-toggle" data-target="r-confirm">👁</button>
|
||
</div>
|
||
<div class="field-error" id="r-confirm-err"></div>
|
||
</div>
|
||
<button class="btn btn-primary" id="btn-register">Create Account</button>
|
||
<div class="auth-footer">
|
||
Already have one? <a href="#" class="link" data-tab="login">Sign in</a>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ═════════════ CHARACTERS ═════════════ -->
|
||
<div id="s-chars" class="screen">
|
||
<div class="top-bar"><div class="top-user">Signed in as <span id="top-username">—</span></div></div>
|
||
<div class="chars-header">
|
||
<h1>Choose Your Character</h1>
|
||
<p>Select a character to continue or create a new one</p>
|
||
</div>
|
||
<div class="chars-grid" id="chars-grid">
|
||
<!-- populated by JS -->
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ═════════════ CHARACTER CREATION ═════════════ -->
|
||
<div id="s-char-create" class="screen">
|
||
<div class="cc-preview" id="cc-preview">
|
||
<div class="cc-hint">Click · drag to rotate · Scroll to zoom</div>
|
||
</div>
|
||
|
||
<div class="cc-panel">
|
||
<div class="cc-header">
|
||
<h2>New Character</h2>
|
||
<p>Customise your appearance</p>
|
||
<div class="cc-steps">
|
||
<div class="cc-step active" id="ccs0"></div>
|
||
<div class="cc-step" id="ccs1"></div>
|
||
<div class="cc-step" id="ccs2"></div>
|
||
<div class="cc-step" id="ccs3"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="cc-body">
|
||
|
||
<!-- STEP 0: Name -->
|
||
<div class="cc-section active" id="ccs-name">
|
||
<div class="cc-section-title">Identity</div>
|
||
<div class="gender-row">
|
||
<div class="gender-pill selected" data-gender="0">♂ Male</div>
|
||
<div class="gender-pill" data-gender="1">♀ Female</div>
|
||
</div>
|
||
<div class="field">
|
||
<label>First Name</label>
|
||
<input id="cc-fname" type="text" placeholder="e.g. Marcus" maxlength="32" autocomplete="off">
|
||
<div class="field-error" id="cc-fname-err"></div>
|
||
</div>
|
||
<div class="field">
|
||
<label>Last Name</label>
|
||
<input id="cc-lname" type="text" placeholder="e.g. Rivera" maxlength="32" autocomplete="off">
|
||
<div class="field-error" id="cc-lname-err"></div>
|
||
</div>
|
||
<div class="field">
|
||
<label>Backstory <small style="color:var(--text4);font-weight:400">(optional)</small></label>
|
||
<textarea id="cc-backstory" placeholder="A few lines about your character's past…" rows="4"></textarea>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- STEP 1: Face -->
|
||
<div class="cc-section" id="ccs-face">
|
||
<div class="cc-section-title">Face</div>
|
||
<div class="slider-row">
|
||
<label>Face Shape <span id="v-face-shape">50</span></label>
|
||
<input type="range" id="sl-face-shape" min="0" max="100" value="50" data-key="face_shape">
|
||
</div>
|
||
<div class="slider-row">
|
||
<label>Face Blend <span id="v-face-blend">50</span></label>
|
||
<input type="range" id="sl-face-blend" min="0" max="100" value="50" data-key="face_blend">
|
||
</div>
|
||
<div class="slider-row">
|
||
<label>Skin Tone <span id="v-skin-tone">50</span></label>
|
||
<input type="range" id="sl-skin-tone" min="0" max="100" value="50" data-key="skin_tone">
|
||
</div>
|
||
<div class="divider"></div>
|
||
<div class="cc-section-title">Eye Colour</div>
|
||
<div class="color-grid" id="eye-colors"></div>
|
||
</div>
|
||
|
||
<!-- STEP 2: Body & Hair -->
|
||
<div class="cc-section" id="ccs-body">
|
||
<div class="cc-section-title">Body</div>
|
||
<div class="slider-row">
|
||
<label>Body Weight <span id="v-body-weight">50</span></label>
|
||
<input type="range" id="sl-body-weight" min="0" max="100" value="50" data-key="body_weight">
|
||
</div>
|
||
<div class="divider"></div>
|
||
<div class="cc-section-title">Hair Style</div>
|
||
<div class="style-grid" id="hair-styles"></div>
|
||
<div class="divider"></div>
|
||
<div class="cc-section-title">Hair Colour</div>
|
||
<div class="color-grid" id="hair-colors"></div>
|
||
</div>
|
||
|
||
<!-- STEP 3: Outfit -->
|
||
<div class="cc-section" id="ccs-outfit">
|
||
<div class="cc-section-title">Top</div>
|
||
<div class="style-grid" id="outfit-tops"></div>
|
||
<div class="divider"></div>
|
||
<div class="cc-section-title">Pants</div>
|
||
<div class="style-grid" id="outfit-pants"></div>
|
||
<div class="divider"></div>
|
||
<div class="cc-section-title">Shoes</div>
|
||
<div class="style-grid" id="outfit-shoes"></div>
|
||
</div>
|
||
|
||
</div><!-- /cc-body -->
|
||
|
||
<div class="cc-footer">
|
||
<button class="btn btn-ghost" id="cc-back" style="flex:0 0 auto;width:auto;padding:13px 20px">← Back</button>
|
||
<button class="btn btn-primary" id="cc-next">Continue →</button>
|
||
</div>
|
||
|
||
<div style="padding:0 32px 16px;text-align:center">
|
||
<div id="cc-error" class="field-error" style="min-height:0"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ═════════════ SPAWN ═════════════ -->
|
||
<div id="s-spawn" class="screen">
|
||
<div class="spawn-header">
|
||
<h2>Choose Spawn Location</h2>
|
||
<p>Where does your story begin today?</p>
|
||
</div>
|
||
|
||
<div class="spawn-grid" id="spawn-grid">
|
||
<div class="spawn-card" data-loc="legion">
|
||
<span class="spawn-icon">🏙</span>
|
||
<div class="spawn-name">Legion Square</div>
|
||
<div class="spawn-desc">Downtown Los Santos. Busy streets, city life.</div>
|
||
</div>
|
||
<div class="spawn-card" data-loc="vespucci">
|
||
<span class="spawn-icon">🌊</span>
|
||
<div class="spawn-name">Vespucci Beach</div>
|
||
<div class="spawn-desc">Laid-back beach district. Casual start.</div>
|
||
</div>
|
||
<div class="spawn-card" data-loc="sandy">
|
||
<span class="spawn-icon">🏜</span>
|
||
<div class="spawn-name">Sandy Shores</div>
|
||
<div class="spawn-desc">Rural desert town. Off the beaten path.</div>
|
||
</div>
|
||
<div class="spawn-card" data-loc="paleto">
|
||
<span class="spawn-icon">🌲</span>
|
||
<div class="spawn-name">Paleto Bay</div>
|
||
<div class="spawn-desc">Quiet northern town. Far from the chaos.</div>
|
||
</div>
|
||
<div class="spawn-card" data-loc="airport">
|
||
<span class="spawn-icon">✈</span>
|
||
<div class="spawn-name">LSIA Airport</div>
|
||
<div class="spawn-desc">Just arrived in the city. Fresh start.</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="spawn-footer">
|
||
<button class="btn btn-primary" id="btn-spawn" disabled style="width:200px">Select a Location</button>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
// ── Utilities ──────────────────────────────────────────────────────────────
|
||
|
||
function el(id) { return document.getElementById(id); }
|
||
|
||
function showScreen(id) {
|
||
document.querySelectorAll('.screen').forEach(s => {
|
||
s.classList.toggle('active', s.id === id);
|
||
});
|
||
}
|
||
|
||
function setAlert(msg) {
|
||
const a = el('auth-alert');
|
||
if (msg) {
|
||
a.textContent = msg;
|
||
a.classList.add('show');
|
||
} else {
|
||
a.textContent = '';
|
||
a.classList.remove('show');
|
||
}
|
||
}
|
||
|
||
async function callLua(cb, data) {
|
||
try {
|
||
const r = await fetch(`https://justrp/${cb}`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(data || {}),
|
||
});
|
||
return await r.json();
|
||
} catch (e) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// ── NUI message router ─────────────────────────────────────────────────────
|
||
|
||
window.addEventListener('message', function(e) {
|
||
const { action, data } = e.data || {};
|
||
switch (action) {
|
||
case 'showAuth': showScreen('s-auth'); break;
|
||
case 'authOk': onAuthOk(data); break;
|
||
case 'authFail': onAuthFail(data); break;
|
||
case 'showChars': onShowChars(data); break;
|
||
case 'charCreateFail': onCharCreateFail(data); break;
|
||
case 'showSpawn': onShowSpawn(data); break;
|
||
case 'hide': document.body.style.display = 'none'; break;
|
||
}
|
||
});
|
||
|
||
// ── AUTH ────────────────────────────────────────────────────────────────────
|
||
|
||
// Tab switching
|
||
document.querySelectorAll('.auth-tab, .link[data-tab]').forEach(el_ => {
|
||
el_.addEventListener('click', e => {
|
||
e.preventDefault();
|
||
const tab = el_.dataset.tab;
|
||
document.querySelectorAll('.auth-tab').forEach(t =>
|
||
t.classList.toggle('active', t.dataset.tab === tab));
|
||
document.querySelectorAll('.auth-form-panel').forEach(p => {
|
||
p.classList.toggle('active', p.id === 'panel-' + tab);
|
||
});
|
||
setAlert('');
|
||
});
|
||
});
|
||
|
||
// Password toggles
|
||
document.querySelectorAll('.pw-toggle').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const inp = el(btn.dataset.target);
|
||
inp.type = inp.type === 'password' ? 'text' : 'password';
|
||
btn.textContent = inp.type === 'password' ? '👁' : '🙈';
|
||
});
|
||
});
|
||
|
||
// Password strength
|
||
el('r-password').addEventListener('input', function() {
|
||
const v = this.value;
|
||
let score = 0;
|
||
if (v.length >= 6) score++;
|
||
if (v.length >= 10) score++;
|
||
if (/[A-Z]/.test(v) && /[a-z]/.test(v)) score++;
|
||
if (/[0-9!@#$%^&*]/.test(v)) score++;
|
||
const colors = ['', '#ef4444', '#f59e0b', '#4ade80', '#22d3ee'];
|
||
for (let i = 1; i <= 4; i++) {
|
||
el('ps' + i).style.background = i <= score ? colors[score] : 'var(--border)';
|
||
}
|
||
});
|
||
|
||
// Live validation
|
||
el('r-username').addEventListener('input', function() {
|
||
const v = this.value.trim();
|
||
const err = el('r-username-err');
|
||
if (v && (v.length < 3 || v.length > 20)) {
|
||
err.textContent = 'Must be 3–20 characters';
|
||
this.classList.add('error');
|
||
} else if (v && !/^[a-zA-Z0-9_]+$/.test(v)) {
|
||
err.textContent = 'Only letters, numbers and underscores';
|
||
this.classList.add('error');
|
||
} else {
|
||
err.textContent = '';
|
||
this.classList.remove('error');
|
||
if (v.length >= 3) this.classList.add('ok');
|
||
else this.classList.remove('ok');
|
||
}
|
||
});
|
||
|
||
el('r-confirm').addEventListener('input', function() {
|
||
const err = el('r-confirm-err');
|
||
if (this.value && this.value !== el('r-password').value) {
|
||
err.textContent = 'Passwords do not match';
|
||
this.classList.add('error');
|
||
} else {
|
||
err.textContent = '';
|
||
this.classList.remove('error');
|
||
}
|
||
});
|
||
|
||
function setLoading(btn, loading, text) {
|
||
if (loading) {
|
||
btn.disabled = true;
|
||
btn.innerHTML = '<span class="spinner"></span>' + text;
|
||
} else {
|
||
btn.disabled = false;
|
||
btn.textContent = text;
|
||
}
|
||
}
|
||
|
||
el('btn-login').addEventListener('click', async () => {
|
||
const username = el('l-username').value.trim();
|
||
const password = el('l-password').value;
|
||
setAlert('');
|
||
if (!username || !password) {
|
||
setAlert('Please fill in all fields.');
|
||
return;
|
||
}
|
||
setLoading(el('btn-login'), true, 'Signing in…');
|
||
await callLua('authLogin', { username, password });
|
||
});
|
||
|
||
el('btn-register').addEventListener('click', async () => {
|
||
const username = el('r-username').value.trim();
|
||
const password = el('r-password').value;
|
||
const confirm = el('r-confirm').value;
|
||
setAlert('');
|
||
|
||
if (!username || !password || !confirm) {
|
||
setAlert('Please fill in all fields.');
|
||
return;
|
||
}
|
||
if (username.length < 3 || username.length > 20) {
|
||
setAlert('Username must be 3–20 characters.');
|
||
return;
|
||
}
|
||
if (!/^[a-zA-Z0-9_]+$/.test(username)) {
|
||
setAlert('Username: letters, numbers and underscores only.');
|
||
return;
|
||
}
|
||
if (password.length < 6) {
|
||
setAlert('Password must be at least 6 characters.');
|
||
return;
|
||
}
|
||
if (password !== confirm) {
|
||
setAlert('Passwords do not match.');
|
||
return;
|
||
}
|
||
|
||
setLoading(el('btn-register'), true, 'Creating account…');
|
||
await callLua('authRegister', { username, password });
|
||
});
|
||
|
||
function onAuthOk(data) {
|
||
el('top-username').textContent = data.username;
|
||
setLoading(el('btn-login'), false, 'Sign In');
|
||
setLoading(el('btn-register'), false, 'Create Account');
|
||
}
|
||
|
||
function onAuthFail(data) {
|
||
setAlert(data.error || 'Authentication failed.');
|
||
setLoading(el('btn-login'), false, 'Sign In');
|
||
setLoading(el('btn-register'), false, 'Create Account');
|
||
}
|
||
|
||
// ── CHARACTER SELECT ────────────────────────────────────────────────────────
|
||
|
||
function onShowChars(data) {
|
||
const chars = data.characters || [];
|
||
const grid = el('chars-grid');
|
||
grid.innerHTML = '';
|
||
|
||
for (let slot = 0; slot < 3; slot++) {
|
||
const ch = chars.find(c => c.slot === slot);
|
||
const card = document.createElement('div');
|
||
card.className = 'char-card' + (ch ? '' : ' empty');
|
||
|
||
if (ch) {
|
||
card.innerHTML = `
|
||
<div class="char-avatar">👤</div>
|
||
<div class="char-name">${ch.firstname} ${ch.lastname}</div>
|
||
<div class="char-sub">Slot ${slot + 1}</div>
|
||
`;
|
||
card.addEventListener('click', () => callLua('charSelect', { id: ch.id }));
|
||
} else {
|
||
card.innerHTML = `
|
||
<div class="char-plus">+</div>
|
||
<div class="char-name" style="color:var(--text3)">Empty Slot</div>
|
||
<div class="char-sub">Create new character</div>
|
||
`;
|
||
card.addEventListener('click', () => openCharCreate(slot));
|
||
}
|
||
|
||
grid.appendChild(card);
|
||
}
|
||
|
||
showScreen('s-chars');
|
||
}
|
||
|
||
// ── CHARACTER CREATION ──────────────────────────────────────────────────────
|
||
|
||
let ccStep = 0;
|
||
let ccSlot = 0;
|
||
const CC_STEPS = ['ccs-name', 'ccs-face', 'ccs-body', 'ccs-outfit'];
|
||
const CC_LABELS = ['Continue →', 'Continue →', 'Continue →', 'Create Character'];
|
||
|
||
const charData = {
|
||
slot: 0, gender: 0,
|
||
firstname: '', lastname: '', backstory: '',
|
||
face_blend: 0.5, face_shape: 0.5, skin_tone: 0.5, eye_color: 0,
|
||
hair_style: 0, hair_color: 0, body_weight: 0.5,
|
||
outfit_top: 0, outfit_pants: 0, outfit_shoes: 0,
|
||
};
|
||
|
||
function openCharCreate(slot) {
|
||
ccSlot = slot;
|
||
ccStep = 0;
|
||
charData.slot = slot;
|
||
updateCCStep();
|
||
showScreen('s-char-create');
|
||
callLua('startCharCam', {});
|
||
}
|
||
|
||
function updateCCStep() {
|
||
CC_STEPS.forEach((id, i) => {
|
||
el(id).classList.toggle('active', i === ccStep);
|
||
el('ccs' + i).className = 'cc-step' +
|
||
(i < ccStep ? ' done' : i === ccStep ? ' active' : '');
|
||
});
|
||
el('cc-next').textContent = CC_LABELS[ccStep];
|
||
el('cc-back').style.visibility = ccStep === 0 ? 'hidden' : 'visible';
|
||
el('cc-error').textContent = '';
|
||
}
|
||
|
||
// Populate color/style grids
|
||
const EYE_COLORS = [
|
||
'#3a5f8a','#5a8a3a','#8a3a3a','#3a3a3a','#8a703a',
|
||
'#5a3a8a','#3a8a8a','#8a8a3a','#c0c0c0','#1a1a1a',
|
||
];
|
||
const HAIR_COLORS = [
|
||
'#1a0a00','#3d1f00','#5c3317','#7a4f2a','#b8860b',
|
||
'#f5deb3','#c0c0c0','#808080','#d4a017','#ff4500',
|
||
'#8b0000','#000000',
|
||
];
|
||
|
||
function buildColorGrid(containerId, colors, dataKey) {
|
||
const wrap = el(containerId);
|
||
wrap.innerHTML = '';
|
||
colors.forEach((color, i) => {
|
||
const dot = document.createElement('div');
|
||
dot.className = 'color-dot' + (i === 0 ? ' selected' : '');
|
||
dot.style.background = color;
|
||
dot.title = `Option ${i + 1}`;
|
||
dot.addEventListener('click', () => {
|
||
wrap.querySelectorAll('.color-dot').forEach(d => d.classList.remove('selected'));
|
||
dot.classList.add('selected');
|
||
charData[dataKey] = i;
|
||
callLua('applyAppearance', { [dataKey]: i });
|
||
});
|
||
wrap.appendChild(dot);
|
||
});
|
||
}
|
||
|
||
function buildStyleGrid(containerId, count, dataKey, label) {
|
||
const wrap = el(containerId);
|
||
wrap.innerHTML = '';
|
||
for (let i = 0; i < count; i++) {
|
||
const opt = document.createElement('div');
|
||
opt.className = 'style-opt' + (i === 0 ? ' selected' : '');
|
||
opt.textContent = i + 1;
|
||
opt.title = label + ' ' + (i + 1);
|
||
opt.addEventListener('click', () => {
|
||
wrap.querySelectorAll('.style-opt').forEach(o => o.classList.remove('selected'));
|
||
opt.classList.add('selected');
|
||
charData[dataKey] = i;
|
||
callLua('applyAppearance', { [dataKey]: i });
|
||
});
|
||
wrap.appendChild(opt);
|
||
}
|
||
}
|
||
|
||
buildColorGrid('eye-colors', EYE_COLORS, 'eye_color');
|
||
buildColorGrid('hair-colors', HAIR_COLORS, 'hair_color');
|
||
buildStyleGrid('hair-styles', 24, 'hair_style', 'Hair');
|
||
buildStyleGrid('outfit-tops', 20, 'outfit_top', 'Top');
|
||
buildStyleGrid('outfit-pants',18, 'outfit_pants', 'Pants');
|
||
buildStyleGrid('outfit-shoes',10, 'outfit_shoes', 'Shoes');
|
||
|
||
// Gender pills
|
||
document.querySelectorAll('.gender-pill').forEach(pill => {
|
||
pill.addEventListener('click', () => {
|
||
document.querySelectorAll('.gender-pill').forEach(p => p.classList.remove('selected'));
|
||
pill.classList.add('selected');
|
||
charData.gender = parseInt(pill.dataset.gender);
|
||
callLua('applyAppearance', { gender: charData.gender });
|
||
});
|
||
});
|
||
|
||
// Sliders with live preview
|
||
document.querySelectorAll('.cc-body input[type=range]').forEach(sl => {
|
||
const key = sl.dataset.key;
|
||
const disp = el('v-' + key.replace('_', '-'));
|
||
sl.addEventListener('input', () => {
|
||
const val = sl.value / 100;
|
||
charData[key] = val;
|
||
if (disp) disp.textContent = sl.value;
|
||
callLua('applyAppearance', { [key]: val });
|
||
});
|
||
});
|
||
|
||
// Drag to rotate
|
||
let dragging = false, lastDragX = 0;
|
||
el('cc-preview').addEventListener('mousedown', e => { dragging = true; lastDragX = e.clientX; });
|
||
document.addEventListener('mousemove', e => {
|
||
if (!dragging) return;
|
||
const delta = e.clientX - lastDragX;
|
||
lastDragX = e.clientX;
|
||
callLua('charCamDrag', { delta });
|
||
});
|
||
document.addEventListener('mouseup', () => { dragging = false; });
|
||
|
||
el('cc-next').addEventListener('click', async () => {
|
||
el('cc-error').textContent = '';
|
||
|
||
if (ccStep === 0) {
|
||
const fn = el('cc-fname').value.trim();
|
||
const ln = el('cc-lname').value.trim();
|
||
if (!fn || !ln) {
|
||
el('cc-error').textContent = 'Please enter a first and last name.';
|
||
return;
|
||
}
|
||
charData.firstname = fn;
|
||
charData.lastname = ln;
|
||
charData.backstory = el('cc-backstory').value.trim();
|
||
}
|
||
|
||
if (ccStep < CC_STEPS.length - 1) {
|
||
ccStep++;
|
||
updateCCStep();
|
||
return;
|
||
}
|
||
|
||
// Final step — submit
|
||
el('cc-next').disabled = true;
|
||
el('cc-next').innerHTML = '<span class="spinner"></span>Creating…';
|
||
await callLua('charCreate', charData);
|
||
});
|
||
|
||
el('cc-back').addEventListener('click', () => {
|
||
if (ccStep === 0) {
|
||
callLua('stopCharCam', {});
|
||
showScreen('s-chars');
|
||
return;
|
||
}
|
||
ccStep--;
|
||
updateCCStep();
|
||
});
|
||
|
||
function onCharCreateFail(data) {
|
||
el('cc-next').disabled = false;
|
||
el('cc-next').textContent = CC_LABELS[ccStep];
|
||
el('cc-error').textContent = data.error || 'Failed to create character.';
|
||
}
|
||
|
||
// ── SPAWN ──────────────────────────────────────────────────────────────────
|
||
|
||
let selectedSpawn = null;
|
||
|
||
function onShowSpawn(data) {
|
||
callLua('stopCharCam', {});
|
||
showScreen('s-spawn');
|
||
}
|
||
|
||
document.querySelectorAll('.spawn-card').forEach(card => {
|
||
card.addEventListener('click', () => {
|
||
document.querySelectorAll('.spawn-card').forEach(c => c.classList.remove('selected'));
|
||
card.classList.add('selected');
|
||
selectedSpawn = card.dataset.loc;
|
||
el('btn-spawn').disabled = false;
|
||
el('btn-spawn').textContent = 'Spawn Here →';
|
||
});
|
||
});
|
||
|
||
el('btn-spawn').addEventListener('click', async () => {
|
||
if (!selectedSpawn) return;
|
||
setLoading(el('btn-spawn'), true, 'Spawning…');
|
||
await callLua('spawnRequest', { location: selectedSpawn });
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|