Всё, что здесь лежит, написал ИИ-агент на живом сервере под запись — без 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>
599 lines
15 KiB
HTML
599 lines
15 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 — Loading</title>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600;700&family=Inter:wght@300;400;500&display=swap" rel="stylesheet">
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
|
|
:root {
|
|
--bg: #07070c;
|
|
--accent: #c8933f;
|
|
--accent2: #e0aa55;
|
|
--text: #ede9e4;
|
|
--text2: rgba(237,233,228,0.55);
|
|
--text3: rgba(237,233,228,0.28);
|
|
}
|
|
|
|
html, body {
|
|
width: 100%; height: 100%; overflow: hidden;
|
|
background: var(--bg);
|
|
font-family: 'Inter', sans-serif;
|
|
color: var(--text);
|
|
}
|
|
|
|
/* ── Canvas cityscape ── */
|
|
#city-canvas {
|
|
position: fixed; inset: 0;
|
|
width: 100%; height: 100%;
|
|
}
|
|
|
|
/* ── Vignette overlay ── */
|
|
.vignette {
|
|
position: fixed; inset: 0;
|
|
background: radial-gradient(ellipse at center,
|
|
transparent 30%,
|
|
rgba(7,7,12,0.55) 70%,
|
|
rgba(7,7,12,0.9) 100%
|
|
);
|
|
pointer-events: none;
|
|
}
|
|
|
|
/* ── Center brand ── */
|
|
.brand {
|
|
position: fixed;
|
|
top: 50%; left: 50%;
|
|
transform: translate(-50%, -50%);
|
|
text-align: center;
|
|
opacity: 0;
|
|
animation: fadeUp 1.2s cubic-bezier(0.16,1,0.3,1) 0.8s forwards;
|
|
}
|
|
|
|
.brand-title {
|
|
font-family: 'Cinzel', serif;
|
|
font-size: clamp(56px, 7vw, 96px);
|
|
font-weight: 700;
|
|
letter-spacing: 0.18em;
|
|
color: var(--text);
|
|
line-height: 1;
|
|
text-shadow: 0 0 80px rgba(200,147,63,0.35);
|
|
}
|
|
|
|
.brand-title span {
|
|
color: var(--accent);
|
|
}
|
|
|
|
.brand-rule {
|
|
width: 180px; height: 1px;
|
|
background: linear-gradient(90deg, transparent, var(--accent), transparent);
|
|
margin: 20px auto 18px;
|
|
}
|
|
|
|
.brand-sub {
|
|
font-size: 13px;
|
|
letter-spacing: 0.32em;
|
|
text-transform: uppercase;
|
|
color: var(--text2);
|
|
font-weight: 400;
|
|
}
|
|
|
|
/* ── Rules panel ── */
|
|
.rules-panel {
|
|
position: fixed;
|
|
top: 50%; right: 72px;
|
|
transform: translateY(-50%) translateX(40px);
|
|
width: 260px;
|
|
background: rgba(13,13,24,0.82);
|
|
border: 1px solid rgba(200,147,63,0.12);
|
|
border-radius: 10px;
|
|
padding: 24px 26px;
|
|
backdrop-filter: blur(12px);
|
|
opacity: 0;
|
|
transition: opacity 0.8s ease, transform 0.8s cubic-bezier(0.16,1,0.3,1);
|
|
}
|
|
|
|
.rules-panel.visible {
|
|
opacity: 1;
|
|
transform: translateY(-50%) translateX(0);
|
|
}
|
|
|
|
.rules-panel h3 {
|
|
font-family: 'Cinzel', serif;
|
|
font-size: 11px;
|
|
letter-spacing: 0.24em;
|
|
text-transform: uppercase;
|
|
color: var(--accent);
|
|
margin-bottom: 14px;
|
|
}
|
|
|
|
.rules-panel ol {
|
|
padding-left: 18px;
|
|
}
|
|
|
|
.rules-panel li {
|
|
font-size: 12px;
|
|
line-height: 1.9;
|
|
color: var(--text2);
|
|
padding-left: 4px;
|
|
}
|
|
|
|
.rules-panel li::marker {
|
|
color: var(--accent);
|
|
font-size: 10px;
|
|
}
|
|
|
|
/* ── Footer ── */
|
|
.footer {
|
|
position: fixed;
|
|
bottom: 0; left: 0; right: 0;
|
|
padding: 0 60px 42px;
|
|
display: flex;
|
|
align-items: flex-end;
|
|
justify-content: space-between;
|
|
gap: 32px;
|
|
}
|
|
|
|
.progress-wrap {
|
|
flex: 1;
|
|
max-width: 600px;
|
|
opacity: 0;
|
|
animation: fadeIn 0.8s ease 1.4s forwards;
|
|
}
|
|
|
|
.progress-label {
|
|
font-size: 11px;
|
|
letter-spacing: 0.06em;
|
|
color: var(--text3);
|
|
margin-bottom: 10px;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
}
|
|
|
|
.progress-label span:last-child {
|
|
color: var(--accent);
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
.progress-track {
|
|
height: 2px;
|
|
background: rgba(255,255,255,0.07);
|
|
border-radius: 2px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.progress-fill {
|
|
height: 100%;
|
|
width: 0%;
|
|
background: linear-gradient(90deg, var(--accent), var(--accent2));
|
|
border-radius: 2px;
|
|
transition: width 0.4s cubic-bezier(0.16,1,0.3,1);
|
|
box-shadow: 0 0 12px rgba(200,147,63,0.6);
|
|
}
|
|
|
|
/* ── Volume ── */
|
|
.vol-wrap {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
opacity: 0;
|
|
animation: fadeIn 0.8s ease 1.8s forwards;
|
|
}
|
|
|
|
.vol-icon {
|
|
font-size: 14px;
|
|
color: var(--text3);
|
|
user-select: none;
|
|
}
|
|
|
|
input[type=range] {
|
|
-webkit-appearance: none;
|
|
width: 90px; height: 2px;
|
|
background: rgba(255,255,255,0.1);
|
|
border-radius: 2px;
|
|
outline: none;
|
|
cursor: pointer;
|
|
}
|
|
|
|
input[type=range]::-webkit-slider-thumb {
|
|
-webkit-appearance: none;
|
|
width: 12px; height: 12px;
|
|
background: var(--accent);
|
|
border-radius: 50%;
|
|
box-shadow: 0 0 6px rgba(200,147,63,0.6);
|
|
transition: transform 0.15s ease;
|
|
}
|
|
|
|
input[type=range]:hover::-webkit-slider-thumb {
|
|
transform: scale(1.3);
|
|
}
|
|
|
|
/* ── Animations ── */
|
|
@keyframes fadeUp {
|
|
from { opacity: 0; transform: translate(-50%, calc(-50% + 20px)); }
|
|
to { opacity: 1; transform: translate(-50%, -50%); }
|
|
}
|
|
@keyframes fadeIn {
|
|
from { opacity: 0; }
|
|
to { opacity: 1; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<canvas id="city-canvas"></canvas>
|
|
<div class="vignette"></div>
|
|
|
|
<div class="brand">
|
|
<div class="brand-title">JUST<span>RP</span></div>
|
|
<div class="brand-rule"></div>
|
|
<div class="brand-sub">Los Santos Roleplay</div>
|
|
</div>
|
|
|
|
<div class="rules-panel" id="rules">
|
|
<h3>Server Rules</h3>
|
|
<ol>
|
|
<li>Respect every player</li>
|
|
<li>No metagaming or powergaming</li>
|
|
<li>No random deathmatch (RDM)</li>
|
|
<li>No vehicle deathmatch (VDM)</li>
|
|
<li>Value your character's life</li>
|
|
<li>Stay in character at all times</li>
|
|
<li>Admin decisions are final</li>
|
|
</ol>
|
|
</div>
|
|
|
|
<div class="footer">
|
|
<div class="progress-wrap">
|
|
<div class="progress-label">
|
|
<span id="prog-label">Connecting to server…</span>
|
|
<span id="prog-pct">0%</span>
|
|
</div>
|
|
<div class="progress-track">
|
|
<div class="progress-fill" id="prog-fill"></div>
|
|
</div>
|
|
</div>
|
|
<div class="vol-wrap">
|
|
<span class="vol-icon">♪</span>
|
|
<input type="range" id="vol" min="0" max="100" value="35">
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
// ── Cityscape canvas ─────────────────────────────────────────────────────────
|
|
|
|
const canvas = document.getElementById('city-canvas');
|
|
const ctx = canvas.getContext('2d');
|
|
|
|
let W, H, panX = 0;
|
|
const layers = [];
|
|
|
|
function randInt(min, max) {
|
|
return Math.floor(Math.random() * (max - min + 1)) + min;
|
|
}
|
|
|
|
function generateCity() {
|
|
layers.length = 0;
|
|
// 3 depth layers: far (small), mid, near (tall)
|
|
const configs = [
|
|
{ count: 80, minH: 0.06, maxH: 0.18, minW: 8, maxW: 30, baseY: 0.68, alpha: 0.18, drift: 0.015 },
|
|
{ count: 40, minH: 0.12, maxH: 0.28, minW: 14, maxW: 50, baseY: 0.72, alpha: 0.30, drift: 0.030 },
|
|
{ count: 22, minH: 0.18, maxH: 0.42, minW: 22, maxW: 80, baseY: 0.78, alpha: 0.55, drift: 0.055 },
|
|
];
|
|
for (const cfg of configs) {
|
|
const bldgs = [];
|
|
let x = -W * 0.3;
|
|
for (let i = 0; i < cfg.count; i++) {
|
|
const w = randInt(cfg.minW, cfg.maxW);
|
|
const h = H * (cfg.minH + Math.random() * (cfg.maxH - cfg.minH));
|
|
bldgs.push({ x, y: H * cfg.baseY - h, w, h });
|
|
x += w + randInt(1, 8);
|
|
}
|
|
layers.push({ bldgs, alpha: cfg.alpha, drift: cfg.drift });
|
|
}
|
|
}
|
|
|
|
// Particles
|
|
const particles = [];
|
|
|
|
function initParticles() {
|
|
particles.length = 0;
|
|
for (let i = 0; i < 60; i++) {
|
|
particles.push({
|
|
x: Math.random() * W,
|
|
y: Math.random() * H,
|
|
r: Math.random() * 1.5 + 0.3,
|
|
vy: -(Math.random() * 0.4 + 0.1),
|
|
vx: (Math.random() - 0.5) * 0.15,
|
|
alpha: Math.random() * 0.35 + 0.05,
|
|
});
|
|
}
|
|
}
|
|
|
|
function resize() {
|
|
W = canvas.width = window.innerWidth;
|
|
H = canvas.height = window.innerHeight;
|
|
generateCity();
|
|
initParticles();
|
|
}
|
|
|
|
window.addEventListener('resize', resize);
|
|
resize();
|
|
|
|
// Sky gradient colours cycling slowly
|
|
const SKY = [
|
|
{ t: 0, r: 7, g: 7, b: 12 },
|
|
{ t: 0.5, r: 9, g: 6, b: 18 },
|
|
{ t: 1, r: 12, g: 8, b: 20 },
|
|
];
|
|
let skyT = 0;
|
|
let skyDir = 0.0004;
|
|
|
|
function lerp(a, b, t) { return a + (b - a) * t; }
|
|
|
|
function getSky() {
|
|
const t = (Math.sin(skyT * Math.PI) + 1) / 2;
|
|
const s = SKY[0], e = SKY[2];
|
|
return `rgb(${Math.round(lerp(s.r,e.r,t))},${Math.round(lerp(s.g,e.g,t))},${Math.round(lerp(s.b,e.b,t))})`;
|
|
}
|
|
|
|
let lastTime = 0;
|
|
|
|
function drawFrame(ts) {
|
|
const dt = Math.min((ts - lastTime) / 16.67, 3);
|
|
lastTime = ts;
|
|
|
|
skyT += skyDir * dt;
|
|
if (skyT > 1 || skyT < 0) skyDir = -skyDir;
|
|
|
|
panX += 0.08 * dt;
|
|
|
|
// Sky
|
|
const skyCol = getSky();
|
|
const grad = ctx.createLinearGradient(0, 0, 0, H);
|
|
grad.addColorStop(0, skyCol);
|
|
grad.addColorStop(0.55, '#0a0814');
|
|
grad.addColorStop(1, '#040408');
|
|
ctx.fillStyle = grad;
|
|
ctx.fillRect(0, 0, W, H);
|
|
|
|
// Distant stars/glow
|
|
ctx.save();
|
|
for (let i = 0; i < 80; i++) {
|
|
const sx = ((i * 137.5 + panX * 0.3) % (W * 1.4)) - W * 0.2;
|
|
const sy = (i * 73.1) % (H * 0.55);
|
|
const s = 0.5 + (i % 3) * 0.4;
|
|
const a = 0.1 + (Math.sin(ts * 0.001 + i) + 1) * 0.08;
|
|
ctx.globalAlpha = a;
|
|
ctx.fillStyle = '#c8d4f0';
|
|
ctx.beginPath();
|
|
ctx.arc(sx, sy, s, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
ctx.restore();
|
|
|
|
// Horizon glow
|
|
const hGrad = ctx.createRadialGradient(W / 2, H * 0.75, 0, W / 2, H * 0.75, W * 0.6);
|
|
hGrad.addColorStop(0, 'rgba(200,147,63,0.07)');
|
|
hGrad.addColorStop(0.6, 'rgba(200,147,63,0.02)');
|
|
hGrad.addColorStop(1, 'rgba(200,147,63,0)');
|
|
ctx.fillStyle = hGrad;
|
|
ctx.fillRect(0, 0, W, H);
|
|
|
|
// City layers
|
|
for (const layer of layers) {
|
|
ctx.save();
|
|
ctx.globalAlpha = layer.alpha;
|
|
ctx.fillStyle = '#0a0a14';
|
|
const off = (panX * layer.drift) % W;
|
|
for (const b of layer.bldgs) {
|
|
const bx = ((b.x + off) % (W * 1.6)) - W * 0.3;
|
|
ctx.fillRect(bx, b.y, b.w, b.h);
|
|
// Windows
|
|
ctx.fillStyle = 'rgba(200,200,160,0.12)';
|
|
const cols = Math.max(1, Math.floor(b.w / 6));
|
|
const rows = Math.max(1, Math.floor(b.h / 10));
|
|
for (let c = 0; c < cols; c++) {
|
|
for (let r = 0; r < rows; r++) {
|
|
if (Math.random() < 0.35) continue;
|
|
ctx.fillRect(bx + 2 + c * 6, b.y + 3 + r * 10, 3, 4);
|
|
}
|
|
}
|
|
ctx.fillStyle = '#0a0a14';
|
|
}
|
|
ctx.restore();
|
|
}
|
|
|
|
// Ground fog
|
|
const fogGrad = ctx.createLinearGradient(0, H * 0.75, 0, H);
|
|
fogGrad.addColorStop(0, 'rgba(7,7,12,0)');
|
|
fogGrad.addColorStop(1, 'rgba(7,7,12,0.95)');
|
|
ctx.fillStyle = fogGrad;
|
|
ctx.fillRect(0, 0, W, H);
|
|
|
|
// Particles
|
|
for (const p of particles) {
|
|
p.x += p.vx * dt;
|
|
p.y += p.vy * dt;
|
|
if (p.y < -5) { p.y = H + 5; p.x = Math.random() * W; }
|
|
|
|
ctx.save();
|
|
ctx.globalAlpha = p.alpha;
|
|
ctx.fillStyle = '#c8a060';
|
|
ctx.beginPath();
|
|
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.restore();
|
|
}
|
|
|
|
requestAnimationFrame(drawFrame);
|
|
}
|
|
|
|
requestAnimationFrame(drawFrame);
|
|
|
|
// ── Progress simulation ───────────────────────────────────────────────────────
|
|
|
|
const fill = document.getElementById('prog-fill');
|
|
const pctEl = document.getElementById('prog-pct');
|
|
const labelEl = document.getElementById('prog-label');
|
|
const rules = document.getElementById('rules');
|
|
|
|
const LABELS = [
|
|
[0, 'Connecting to server…'],
|
|
[10, 'Loading world data…'],
|
|
[25, 'Streaming assets…'],
|
|
[45, 'Initialising resources…'],
|
|
[65, 'Authenticating session…'],
|
|
[80, 'Almost ready…'],
|
|
[95, 'Loading complete'],
|
|
];
|
|
|
|
let progress = 0;
|
|
let targetProg = 0;
|
|
let ruleShown = false;
|
|
let done = false;
|
|
|
|
function setProgress(pct) {
|
|
targetProg = Math.min(100, Math.max(progress, pct));
|
|
}
|
|
|
|
function animProgress() {
|
|
if (progress < targetProg) {
|
|
progress += Math.min((targetProg - progress) * 0.05 + 0.08, targetProg - progress);
|
|
const p = Math.min(100, Math.round(progress));
|
|
fill.style.width = p + '%';
|
|
pctEl.textContent = p + '%';
|
|
|
|
for (let i = LABELS.length - 1; i >= 0; i--) {
|
|
if (progress >= LABELS[i][0]) {
|
|
labelEl.textContent = LABELS[i][1];
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (progress >= 50 && !ruleShown) {
|
|
ruleShown = true;
|
|
rules.classList.add('visible');
|
|
}
|
|
}
|
|
setTimeout(animProgress, 40);
|
|
}
|
|
|
|
// Fake progress curve
|
|
function fakeProgress() {
|
|
const steps = [
|
|
[800, 12], [1600, 28], [2800, 48],
|
|
[4500, 68], [6500, 82], [9000, 91],
|
|
];
|
|
for (const [delay, pct] of steps) {
|
|
setTimeout(() => setProgress(pct), delay);
|
|
}
|
|
}
|
|
|
|
fakeProgress();
|
|
animProgress();
|
|
|
|
// Listen for real completion from Lua (via postMessage or window message)
|
|
window.addEventListener('message', function(e) {
|
|
if (!e.data) return;
|
|
// Standard FiveM loadingscreen shutdown via eventName
|
|
if (e.data.eventName === 'loadingScreenCall') {
|
|
const calls = e.data.data || [];
|
|
for (const call of calls) {
|
|
if (call.functionName === 'shutdown' || call.type === 'shutdown') {
|
|
setProgress(100);
|
|
setTimeout(() => { document.body.style.opacity = '0'; }, 1000);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// ── Ambient sound ────────────────────────────────────────────────────────────
|
|
|
|
const volSlider = document.getElementById('vol');
|
|
let audioCtx = null;
|
|
let masterGain = null;
|
|
|
|
function initAudio() {
|
|
if (audioCtx) return;
|
|
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
|
masterGain = audioCtx.createGain();
|
|
masterGain.gain.value = volSlider.value / 100 * 0.7;
|
|
masterGain.connect(audioCtx.destination);
|
|
|
|
// Drone frequencies: dark minor chord atmosphere
|
|
const freqs = [
|
|
{ f: 55.0, v: 0.18 }, // A1
|
|
{ f: 65.4, v: 0.10 }, // C2
|
|
{ f: 82.4, v: 0.12 }, // E2
|
|
{ f: 110.0, v: 0.07 }, // A2
|
|
{ f: 130.8, v: 0.04 }, // C3
|
|
];
|
|
|
|
for (const { f, v } of freqs) {
|
|
const osc = audioCtx.createOscillator();
|
|
const gain = audioCtx.createGain();
|
|
const filt = audioCtx.createBiquadFilter();
|
|
|
|
// Slow LFO for movement
|
|
const lfo = audioCtx.createOscillator();
|
|
const lfoGain = audioCtx.createGain();
|
|
lfo.frequency.value = 0.05 + Math.random() * 0.1;
|
|
lfoGain.gain.value = f * 0.003;
|
|
lfo.connect(lfoGain);
|
|
lfoGain.connect(osc.frequency);
|
|
|
|
osc.type = 'sine';
|
|
osc.frequency.value = f;
|
|
filt.type = 'lowpass';
|
|
filt.frequency.value = f * 3;
|
|
filt.Q.value = 0.5;
|
|
gain.gain.setValueAtTime(0, audioCtx.currentTime);
|
|
gain.gain.linearRampToValueAtTime(v, audioCtx.currentTime + 4);
|
|
|
|
osc.connect(filt);
|
|
filt.connect(gain);
|
|
gain.connect(masterGain);
|
|
osc.start();
|
|
lfo.start();
|
|
}
|
|
|
|
// Subtle reverb-like noise pad
|
|
const buf = audioCtx.createBuffer(1, audioCtx.sampleRate * 2, audioCtx.sampleRate);
|
|
const data = buf.getChannelData(0);
|
|
for (let i = 0; i < data.length; i++) data[i] = (Math.random() * 2 - 1);
|
|
const noise = audioCtx.createBufferSource();
|
|
const nFilt = audioCtx.createBiquadFilter();
|
|
const nGain = audioCtx.createGain();
|
|
noise.buffer = buf;
|
|
noise.loop = true;
|
|
nFilt.type = 'bandpass';
|
|
nFilt.frequency.value = 80;
|
|
nFilt.Q.value = 0.2;
|
|
nGain.gain.value = 0.018;
|
|
noise.connect(nFilt);
|
|
nFilt.connect(nGain);
|
|
nGain.connect(masterGain);
|
|
noise.start();
|
|
}
|
|
|
|
// Start audio on first interaction (browser policy)
|
|
document.addEventListener('click', initAudio, { once: true });
|
|
document.addEventListener('keydown', initAudio, { once: true });
|
|
// Also try after a moment — FiveM NUI may not block it
|
|
setTimeout(initAudio, 1200);
|
|
|
|
volSlider.addEventListener('input', () => {
|
|
if (masterGain) {
|
|
masterGain.gain.linearRampToValueAtTime(
|
|
volSlider.value / 100 * 0.7,
|
|
audioCtx.currentTime + 0.1
|
|
);
|
|
}
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|