Part 1: registration, login and character creation written from scratch

Всё, что здесь лежит, написал ИИ-агент на живом сервере под запись — без 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>
This commit is contained in:
RedlHosting
2026-08-06 03:28:58 +00:00
co-authored by Claude Opus 5
parent 968d047f87
commit 0144e83f05
8 changed files with 2975 additions and 3 deletions
+340
View File
@@ -0,0 +1,340 @@
#!/usr/bin/env python3
"""JustRP Database API — internal HTTP service on port 8787"""
import json
import hashlib
import os
import socket
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse, parse_qs
try:
import pymysql
import pymysql.cursors
except ImportError:
import subprocess
subprocess.run(["pip3", "install", "pymysql"], check=True)
import pymysql
import pymysql.cursors
# Секреты берутся из окружения. Заведите их перед запуском, например в systemd-юните:
# Environment=JUSTRP_API_SECRET=... Environment=JUSTRP_DB_PASS=...
API_SECRET = os.environ.get("JUSTRP_API_SECRET", "change-me")
DB = {
"host": os.environ.get("JUSTRP_DB_HOST", "localhost"),
"user": os.environ.get("JUSTRP_DB_USER", "fivem"),
"password": os.environ.get("JUSTRP_DB_PASS", "change-me"),
"database": os.environ.get("JUSTRP_DB_NAME", "fivem"),
"charset": "utf8mb4",
"cursorclass": pymysql.cursors.DictCursor,
}
def db():
return pymysql.connect(**DB)
def hash_pw(password: str, salt: str) -> str:
return hashlib.scrypt(
password.encode(), salt=salt.encode(), n=16384, r=8, p=1
).hex()
def new_salt() -> str:
return os.urandom(16).hex()
class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt, *args):
pass # silence default logging
def send_json(self, code, data):
body = json.dumps(data).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def auth_check(self):
return self.headers.get("X-API-Key") == API_SECRET
def read_body(self):
n = int(self.headers.get("Content-Length", 0))
if n == 0:
return {}
raw = self.rfile.read(n)
try:
return json.loads(raw)
except Exception:
return {}
def do_POST(self):
if not self.auth_check():
return self.send_json(403, {"error": "forbidden"})
path = urlparse(self.path).path
body = self.read_body()
if path == "/auth/register":
self.handle_register(body)
elif path == "/auth/login":
self.handle_login(body)
elif path == "/character/create":
self.handle_char_create(body)
elif path == "/character/save":
self.handle_char_save(body)
elif path == "/account/ban":
self.handle_ban(body)
else:
self.send_json(404, {"error": "not found"})
def do_GET(self):
if not self.auth_check():
return self.send_json(403, {"error": "forbidden"})
parsed = urlparse(self.path)
path = parsed.path
qs = parse_qs(parsed.query)
if path == "/characters":
self.handle_chars_list(qs)
elif path == "/character":
self.handle_char_get(qs)
elif path == "/account":
self.handle_account_get(qs)
else:
self.send_json(404, {"error": "not found"})
# --- Auth ---
def handle_register(self, body):
license_ = body.get("license", "").strip()
username = body.get("username", "").strip()
password = body.get("password", "")
if not license_ or not username or not password:
return self.send_json(400, {"success": False, "error": "Missing fields"})
if len(username) < 3 or len(username) > 20:
return self.send_json(400, {"success": False, "error": "Username must be 320 characters"})
if len(password) < 6:
return self.send_json(400, {"success": False, "error": "Password must be at least 6 characters"})
salt = new_salt()
pw_hash = hash_pw(password, salt)
stored = f"{salt}:{pw_hash}"
try:
conn = db()
with conn.cursor() as cur:
cur.execute("SELECT id FROM accounts WHERE username = %s", (username,))
if cur.fetchone():
conn.close()
return self.send_json(409, {"success": False, "error": "Username already taken"})
cur.execute("SELECT id FROM accounts WHERE license = %s", (license_,))
if cur.fetchone():
conn.close()
return self.send_json(409, {"success": False, "error": "A JustRP account already exists on this device"})
cur.execute(
"INSERT INTO accounts (license, username, password_hash) VALUES (%s, %s, %s)",
(license_, username, stored),
)
account_id = cur.lastrowid
conn.commit()
conn.close()
return self.send_json(200, {"success": True, "account_id": account_id, "username": username})
except Exception as e:
return self.send_json(500, {"success": False, "error": str(e)})
def handle_login(self, body):
license_ = body.get("license", "").strip()
username = body.get("username", "").strip()
password = body.get("password", "")
if not license_ or not username or not password:
return self.send_json(400, {"success": False, "error": "Missing fields"})
try:
conn = db()
with conn.cursor() as cur:
cur.execute(
"SELECT id, password_hash, banned, ban_reason FROM accounts WHERE username = %s",
(username,),
)
row = cur.fetchone()
if not row:
conn.close()
return self.send_json(401, {"success": False, "error": "Invalid username or password"})
if row["banned"]:
conn.close()
return self.send_json(403, {"success": False, "error": f"Banned: {row['ban_reason'] or 'No reason given'}"})
stored = row["password_hash"]
salt, expected = stored.split(":", 1)
actual = hash_pw(password, salt)
if actual != expected:
conn.close()
return self.send_json(401, {"success": False, "error": "Invalid username or password"})
cur.execute("UPDATE accounts SET last_login = NOW() WHERE id = %s", (row["id"],))
conn.commit()
conn.close()
return self.send_json(200, {"success": True, "account_id": row["id"], "username": username})
except Exception as e:
return self.send_json(500, {"success": False, "error": str(e)})
# --- Characters ---
def handle_chars_list(self, qs):
account_id = int(qs.get("account_id", ["0"])[0])
if not account_id:
return self.send_json(400, {"error": "missing account_id"})
try:
conn = db()
with conn.cursor() as cur:
cur.execute(
"SELECT * FROM characters WHERE account_id = %s ORDER BY slot",
(account_id,),
)
rows = cur.fetchall()
conn.close()
return self.send_json(200, {"success": True, "characters": rows})
except Exception as e:
return self.send_json(500, {"success": False, "error": str(e)})
def handle_char_get(self, qs):
char_id = int(qs.get("id", ["0"])[0])
if not char_id:
return self.send_json(400, {"error": "missing id"})
try:
conn = db()
with conn.cursor() as cur:
cur.execute("SELECT * FROM characters WHERE id = %s", (char_id,))
row = cur.fetchone()
conn.close()
if not row:
return self.send_json(404, {"success": False, "error": "Character not found"})
return self.send_json(200, {"success": True, "character": row})
except Exception as e:
return self.send_json(500, {"success": False, "error": str(e)})
def handle_char_create(self, body):
required = ["account_id", "slot", "firstname", "lastname"]
for f in required:
if f not in body:
return self.send_json(400, {"success": False, "error": f"Missing field: {f}"})
fields = {
"account_id": body["account_id"],
"slot": body["slot"],
"firstname": body["firstname"][:32],
"lastname": body["lastname"][:32],
"backstory": body.get("backstory", ""),
"gender": body.get("gender", 0),
"face_blend": body.get("face_blend", 0.5),
"face_shape": body.get("face_shape", 0.5),
"skin_tone": body.get("skin_tone", 0.5),
"eye_color": body.get("eye_color", 0),
"hair_style": body.get("hair_style", 0),
"hair_color": body.get("hair_color", 0),
"body_weight": body.get("body_weight", 0.5),
"outfit_top": body.get("outfit_top", 0),
"outfit_pants": body.get("outfit_pants", 0),
"outfit_shoes": body.get("outfit_shoes", 0),
}
try:
conn = db()
with conn.cursor() as cur:
cur.execute(
"SELECT COUNT(*) as cnt FROM characters WHERE account_id = %s",
(fields["account_id"],),
)
if cur.fetchone()["cnt"] >= 3:
conn.close()
return self.send_json(400, {"success": False, "error": "Maximum 3 characters per account"})
cols = ", ".join(fields.keys())
placeholders = ", ".join(["%s"] * len(fields))
cur.execute(
f"INSERT INTO characters ({cols}) VALUES ({placeholders})",
list(fields.values()),
)
char_id = cur.lastrowid
conn.commit()
conn.close()
return self.send_json(200, {"success": True, "character_id": char_id})
except Exception as e:
return self.send_json(500, {"success": False, "error": str(e)})
def handle_char_save(self, body):
char_id = body.get("character_id")
if not char_id:
return self.send_json(400, {"success": False, "error": "Missing character_id"})
allowed = {
"last_x", "last_y", "last_z", "last_heading",
"cash", "bank", "playtime",
"face_blend", "face_shape", "skin_tone", "eye_color",
"hair_style", "hair_color", "body_weight",
"outfit_top", "outfit_pants", "outfit_shoes",
}
updates = {k: v for k, v in body.items() if k in allowed}
if not updates:
return self.send_json(200, {"success": True})
set_clause = ", ".join(f"{k} = %s" for k in updates)
values = list(updates.values()) + [char_id]
try:
conn = db()
with conn.cursor() as cur:
cur.execute(
f"UPDATE characters SET {set_clause} WHERE id = %s",
values,
)
conn.commit()
conn.close()
return self.send_json(200, {"success": True})
except Exception as e:
return self.send_json(500, {"success": False, "error": str(e)})
def handle_account_get(self, qs):
license_ = qs.get("license", [""])[0]
if not license_:
return self.send_json(400, {"error": "missing license"})
try:
conn = db()
with conn.cursor() as cur:
cur.execute(
"SELECT id, username, banned, ban_reason FROM accounts WHERE license = %s",
(license_,),
)
row = cur.fetchone()
conn.close()
if not row:
return self.send_json(404, {"success": False, "error": "not found"})
return self.send_json(200, {"success": True, "account": row})
except Exception as e:
return self.send_json(500, {"success": False, "error": str(e)})
def handle_ban(self, body):
account_id = body.get("account_id")
reason = body.get("reason", "No reason given")
if not account_id:
return self.send_json(400, {"success": False, "error": "missing account_id"})
try:
conn = db()
with conn.cursor() as cur:
cur.execute(
"UPDATE accounts SET banned = 1, ban_reason = %s WHERE id = %s",
(reason, account_id),
)
conn.commit()
conn.close()
return self.send_json(200, {"success": True})
except Exception as e:
return self.send_json(500, {"success": False, "error": str(e)})
if __name__ == "__main__":
server = HTTPServer(("127.0.0.1", 8787), Handler)
print("[JustRP API] Listening on 127.0.0.1:8787", flush=True)
server.serve_forever()
+343
View File
@@ -0,0 +1,343 @@
-- JustRP — Client-side core
-- Manages NUI screens, cameras, spawning and position sync
local inSession = false
local charLoaded = false
local camHandle = nil
local saveTimer = 0
-- ─── NUI helper ─────────────────────────────────────────────────────────────
local function nuiMsg(action, data)
SendNUIMessage({ action = action, data = data or {} })
end
-- ─── On resource start (loading screen is already showing) ──────────────────
AddEventHandler("onClientMapStart", function()
Wait(1500) -- give the game a moment to settle
TriggerServerEvent("justrp:clientReady")
end)
-- ─── Session start from server ───────────────────────────────────────────────
RegisterNetEvent("justrp:startSession")
AddEventHandler("justrp:startSession", function()
-- Freeze world while auth is happening
SetEntityVisible(PlayerPedId(), false, false)
FreezeEntityPosition(PlayerPedId(), true)
DisplayHud(false)
DisplayRadar(false)
-- Shut loading screen, then show auth UI
Wait(800)
ShutdownLoadingScreenNui()
Wait(300)
SetNuiFocus(true, true)
nuiMsg("showAuth")
inSession = true
end)
-- ─── Auth results ────────────────────────────────────────────────────────────
RegisterNetEvent("justrp:auth:ok")
AddEventHandler("justrp:auth:ok", function(accountId, username)
nuiMsg("authOk", { accountId = accountId, username = username })
TriggerServerEvent("justrp:chars:load")
end)
RegisterNetEvent("justrp:auth:fail")
AddEventHandler("justrp:auth:fail", function(err)
nuiMsg("authFail", { error = err })
end)
-- ─── Character list from server ──────────────────────────────────────────────
RegisterNetEvent("justrp:chars:list")
AddEventHandler("justrp:chars:list", function(chars)
nuiMsg("showChars", { characters = chars })
end)
-- ─── Character creation result ───────────────────────────────────────────────
RegisterNetEvent("justrp:char:created")
AddEventHandler("justrp:char:created", function(charId)
-- Reload character list so the new one shows
TriggerServerEvent("justrp:chars:load")
end)
RegisterNetEvent("justrp:char:createFail")
AddEventHandler("justrp:char:createFail", function(err)
nuiMsg("charCreateFail", { error = err })
end)
-- ─── Character selected → load appearance ────────────────────────────────────
RegisterNetEvent("justrp:char:loaded")
AddEventHandler("justrp:char:loaded", function(ch)
-- Apply appearance to player ped
local ped = PlayerPedId()
local model = ch.gender == 1 and GetHashKey("mp_f_freemode_01") or GetHashKey("mp_m_freemode_01")
RequestModel(model)
while not HasModelLoaded(model) do Wait(10) end
SetPlayerModel(PlayerId(), model)
SetModelAsNoLongerNeeded(model)
ped = PlayerPedId()
-- Face blend
SetPedHeadBlendData(
ped,
math.floor(ch.face_blend * 45), math.floor(ch.face_blend * 45),
0,
math.floor(ch.face_blend * 45), math.floor(ch.face_blend * 45),
0,
ch.face_blend, ch.face_blend, 0.0, false
)
-- Skin overlay (skin tone)
SetPedMicroblendData(ped, ch.skin_tone, ch.skin_tone, ch.skin_tone)
-- Eyes
SetPedEyeColor(ped, ch.eye_color)
-- Hair
SetPedComponentVariation(ped, 2, ch.hair_style, 0, 2)
SetPedHairColor(ped, ch.hair_color, 0)
-- Body shape
SetPedFaceFeature(ped, 16, (ch.body_weight - 0.5) * 2) -- body weight feature
-- Outfit
SetPedComponentVariation(ped, 11, ch.outfit_top, 0, 2)
SetPedComponentVariation(ped, 4, ch.outfit_pants, 0, 2)
SetPedComponentVariation(ped, 6, ch.outfit_shoes, 0, 2)
-- Tell NUI to show spawn screen
nuiMsg("showSpawn", {
name = ch.firstname .. " " .. ch.lastname,
})
end)
-- ─── Spawn ───────────────────────────────────────────────────────────────────
RegisterNetEvent("justrp:spawn:go")
AddEventHandler("justrp:spawn:go", function(pos)
local ped = PlayerPedId()
-- Teleport to position and unfreeze
SetEntityCoords(ped, pos.x, pos.y, pos.z, false, false, false, true)
SetEntityHeading(ped, pos.h)
-- Cinematic camera fly-down
local camX = pos.x + math.sin(math.rad(pos.h)) * 200
local camY = pos.y + math.cos(math.rad(pos.h)) * 200
camHandle = CreateCameraWithParams(
"DEFAULT_SCRIPTED_CAMERA",
camX, camY, pos.z + 180,
0.0, 0.0, 0.0,
60.0, false, 0
)
SetCamActive(camHandle, true)
RenderScriptCams(true, true, 1000, true, true)
-- Animate camera flying down to player
local steps = 80
Citizen.CreateThread(function()
for i = 1, steps do
local t = i / steps
local ease = 1 - math.pow(1 - t, 3) -- ease out cubic
local cx = camX + (pos.x - camX) * ease
local cy = camY + (pos.y - camY) * ease
local cz = (pos.z + 180) + ((pos.z + 2) - (pos.z + 180)) * ease
SetCamCoord(camHandle, cx, cy, cz)
PointCamAtCoord(camHandle, pos.x, pos.y, pos.z)
Wait(16)
end
-- Transition back to player cam
RenderScriptCams(false, true, 1200, true, true)
Wait(1300)
DestroyCam(camHandle, false)
camHandle = nil
-- Reveal player
SetEntityVisible(ped, true, false)
FreezeEntityPosition(ped, false)
DisplayHud(true)
DisplayRadar(true)
SetNuiFocus(false, false)
nuiMsg("hide")
charLoaded = true
end)
end)
-- ─── NUI Callbacks ───────────────────────────────────────────────────────────
-- Auth
RegisterNUICallback("authRegister", function(data, cb)
TriggerServerEvent("justrp:auth:register", data.username, data.password)
cb("ok")
end)
RegisterNUICallback("authLogin", function(data, cb)
TriggerServerEvent("justrp:auth:login", data.username, data.password)
cb("ok")
end)
-- Character management
RegisterNUICallback("charSelect", function(data, cb)
TriggerServerEvent("justrp:char:select", tonumber(data.id))
cb("ok")
end)
RegisterNUICallback("charCreate", function(data, cb)
TriggerServerEvent("justrp:char:create", data)
cb("ok")
end)
-- Spawn selection
RegisterNUICallback("spawnRequest", function(data, cb)
TriggerServerEvent("justrp:spawn:request", data.location)
cb("ok")
end)
-- Character creation — camera rotation control
RegisterNUICallback("rotateCam", function(data, cb)
if camHandle then
-- data.delta is horizontal rotation amount
local heading = GetEntityHeading(PlayerPedId())
SetEntityHeading(PlayerPedId(), heading - (data.delta or 0) * 0.4)
end
cb("ok")
end)
-- ─── Character creation preview camera ───────────────────────────────────────
local charCamActive = false
local charCamAngle = 0.0
RegisterNUICallback("startCharCam", function(data, cb)
if charCamActive then
cb("ok")
return
end
charCamActive = true
local ped = PlayerPedId()
SetEntityVisible(ped, true, false)
camHandle = CreateCamera("DEFAULT_SCRIPTED_CAMERA", false)
SetCamActive(camHandle, true)
RenderScriptCams(true, false, 0, true, true)
Citizen.CreateThread(function()
while charCamActive do
local pedPos = GetEntityCoords(ped)
local angle = charCamAngle
local dist = 1.6
local cx = pedPos.x + math.sin(math.rad(angle)) * dist
local cy = pedPos.y - math.cos(math.rad(angle)) * dist
local cz = pedPos.z + 0.7
SetCamCoord(camHandle, cx, cy, cz)
PointCamAtCoord(camHandle, pedPos.x, pedPos.y, pedPos.z + 0.7)
charCamAngle = charCamAngle + 0.15 -- slow auto-orbit
Wait(16)
end
end)
cb("ok")
end)
RegisterNUICallback("stopCharCam", function(data, cb)
charCamActive = false
if camHandle then
RenderScriptCams(false, true, 600, true, true)
Wait(700)
DestroyCam(camHandle, false)
camHandle = nil
end
cb("ok")
end)
RegisterNUICallback("charCamDrag", function(data, cb)
charCamAngle = charCamAngle + (data.delta or 0) * 0.5
charCamActive = true -- stop auto-orbit briefly
cb("ok")
end)
-- Apply live appearance updates during char creation
RegisterNUICallback("applyAppearance", function(data, cb)
local ped = PlayerPedId()
if data.gender ~= nil then
local model = data.gender == 1
and GetHashKey("mp_f_freemode_01")
or GetHashKey("mp_m_freemode_01")
if not HasModelLoaded(model) then
RequestModel(model)
while not HasModelLoaded(model) do Wait(10) end
end
SetPlayerModel(PlayerId(), model)
SetModelAsNoLongerNeeded(model)
ped = PlayerPedId()
end
if data.face_blend ~= nil then
SetPedHeadBlendData(
ped,
math.floor(data.face_blend * 45),
math.floor(data.face_blend * 45),
0,
math.floor(data.face_blend * 45),
math.floor(data.face_blend * 45),
0,
data.face_blend, data.face_blend, 0.0, false
)
end
if data.skin_tone ~= nil then
SetPedMicroblendData(ped, data.skin_tone, data.skin_tone, data.skin_tone)
end
if data.eye_color ~= nil then
SetPedEyeColor(ped, data.eye_color)
end
if data.hair_style ~= nil or data.hair_color ~= nil then
SetPedComponentVariation(ped, 2, data.hair_style or 0, 0, 2)
SetPedHairColor(ped, data.hair_color or 0, 0)
end
if data.body_weight ~= nil then
SetPedFaceFeature(ped, 16, (data.body_weight - 0.5) * 2)
end
if data.outfit_top ~= nil then
SetPedComponentVariation(ped, 11, data.outfit_top, 0, 2)
end
if data.outfit_pants ~= nil then
SetPedComponentVariation(ped, 4, data.outfit_pants, 0, 2)
end
if data.outfit_shoes ~= nil then
SetPedComponentVariation(ped, 6, data.outfit_shoes, 0, 2)
end
cb("ok")
end)
-- ─── Position auto-save ───────────────────────────────────────────────────────
Citizen.CreateThread(function()
while true do
Wait(60000)
if charLoaded then
local ped = PlayerPedId()
local pos = GetEntityCoords(ped)
local hdg = GetEntityHeading(ped)
TriggerServerEvent("justrp:savePosition", pos.x, pos.y, pos.z, hdg)
end
end
end)
print("[JustRP] Client core loaded.")
+25
View File
@@ -0,0 +1,25 @@
fx_version 'cerulean'
game 'gta5'
author 'JustRP'
description 'JustRP — Core authentication, character management and session control'
version '1.0.0'
loadscreen 'html/loading.html'
loadscreen_manual_shutdown 'yes'
loadscreen_cursor_shown 'yes'
ui_page 'html/app.html'
client_scripts {
'client/main.lua',
}
server_scripts {
'server/main.lua',
}
files {
'html/loading.html',
'html/app.html',
}
File diff suppressed because it is too large Load Diff
+598
View File
@@ -0,0 +1,598 @@
<!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>
+210
View File
@@ -0,0 +1,210 @@
-- JustRP — Server-side core
-- All DB operations go through the internal Python API on :8787
local API_BASE = "http://127.0.0.1:8787"
-- Должен совпадать с JUSTRP_API_SECRET у Python-API. Задаётся в server.cfg:
-- set justrp_api_key "ваш-ключ"
local API_KEY = GetConvar("justrp_api_key", "change-me")
-- Per-player session state
local Sessions = {}
-- ─── HTTP helper ────────────────────────────────────────────────────────────
local function apiRequest(method, path, data, cb)
PerformHttpRequest(
API_BASE .. path,
function(status, body, _headers)
local ok, result = pcall(json.decode, body or "{}")
if ok then
cb(status, result)
else
cb(status, { success = false, error = "parse error" })
end
end,
method,
data and json.encode(data) or "",
{
["Content-Type"] = "application/json",
["X-API-Key"] = API_KEY,
}
)
end
-- ─── Player connecting ───────────────────────────────────────────────────────
AddEventHandler("playerConnecting", function(name, setKickReason, deferrals)
local src = source
local license = GetPlayerIdentifierByType(src, "license") or
GetPlayerIdentifierByType(src, "license2") or
"license:" .. tostring(src)
deferrals.defer()
Wait(0)
deferrals.update("Checking account…")
apiRequest("GET", "/account?license=" .. license, nil, function(status, res)
if status == 200 and res.success then
if res.account.banned == 1 then
deferrals.done("You are banned from JustRP.\nReason: " .. (res.account.ban_reason or "No reason given"))
else
deferrals.done()
end
else
-- No account yet — let them through, they'll register in-game
deferrals.done()
end
end)
end)
-- ─── Client ready → start session ───────────────────────────────────────────
RegisterNetEvent("justrp:clientReady")
AddEventHandler("justrp:clientReady", function()
local src = source
local license = GetPlayerIdentifierByType(src, "license") or
GetPlayerIdentifierByType(src, "license2") or
"license:" .. tostring(src)
Sessions[src] = { license = license, accountId = nil, charId = nil }
TriggerClientEvent("justrp:startSession", src)
end)
-- ─── Auth events ────────────────────────────────────────────────────────────
RegisterNetEvent("justrp:auth:register")
AddEventHandler("justrp:auth:register", function(username, password)
local src = source
local ses = Sessions[src]
if not ses then return end
apiRequest("POST", "/auth/register", {
license = ses.license,
username = username,
password = password,
}, function(status, res)
if res.success then
ses.accountId = res.account_id
TriggerClientEvent("justrp:auth:ok", src, res.account_id, res.username)
else
TriggerClientEvent("justrp:auth:fail", src, res.error or "Unknown error")
end
end)
end)
RegisterNetEvent("justrp:auth:login")
AddEventHandler("justrp:auth:login", function(username, password)
local src = source
local ses = Sessions[src]
if not ses then return end
apiRequest("POST", "/auth/login", {
license = ses.license,
username = username,
password = password,
}, function(status, res)
if res.success then
ses.accountId = res.account_id
TriggerClientEvent("justrp:auth:ok", src, res.account_id, res.username)
else
TriggerClientEvent("justrp:auth:fail", src, res.error or "Unknown error")
end
end)
end)
-- ─── Character events ────────────────────────────────────────────────────────
RegisterNetEvent("justrp:chars:load")
AddEventHandler("justrp:chars:load", function()
local src = source
local ses = Sessions[src]
if not ses or not ses.accountId then
TriggerClientEvent("justrp:chars:list", src, {})
return
end
apiRequest("GET", "/characters?account_id=" .. ses.accountId, nil, function(status, res)
if res.success then
TriggerClientEvent("justrp:chars:list", src, res.characters or {})
else
TriggerClientEvent("justrp:chars:list", src, {})
end
end)
end)
RegisterNetEvent("justrp:char:create")
AddEventHandler("justrp:char:create", function(data)
local src = source
local ses = Sessions[src]
if not ses or not ses.accountId then return end
data.account_id = ses.accountId
apiRequest("POST", "/character/create", data, function(status, res)
if res.success then
ses.charId = res.character_id
TriggerClientEvent("justrp:char:created", src, res.character_id)
else
TriggerClientEvent("justrp:char:createFail", src, res.error or "Unknown error")
end
end)
end)
RegisterNetEvent("justrp:char:select")
AddEventHandler("justrp:char:select", function(charId)
local src = source
local ses = Sessions[src]
if not ses or not ses.accountId then return end
apiRequest("GET", "/character?id=" .. tostring(charId), nil, function(status, res)
if res.success and res.character then
local ch = res.character
if ch.account_id ~= ses.accountId then
return -- character doesn't belong to this account
end
ses.charId = charId
TriggerClientEvent("justrp:char:loaded", src, ch)
end
end)
end)
-- ─── Spawn ───────────────────────────────────────────────────────────────────
RegisterNetEvent("justrp:spawn:request")
AddEventHandler("justrp:spawn:request", function(spawnKey)
local src = source
local ses = Sessions[src]
if not ses or not ses.charId then return end
local spawns = {
legion = { x = -166.7, y = -928.9, z = 31.4, h = 120.0 },
vespucci = { x = -1380.0, y = -1520.0, z = 4.9, h = 75.0 },
sandy = { x = 1843.4, y = 3683.0, z = 34.3, h = 200.0 },
paleto = { x = -165.8, y = 6330.0, z = 31.5, h = 270.0 },
airport = { x = -1037.0, y = -2738.0, z = 20.0, h = 330.0 },
}
local pos = spawns[spawnKey] or spawns["legion"]
TriggerClientEvent("justrp:spawn:go", src, pos)
end)
-- ─── Auto-save position every 60s ────────────────────────────────────────────
RegisterNetEvent("justrp:savePosition")
AddEventHandler("justrp:savePosition", function(x, y, z, h)
local src = source
local ses = Sessions[src]
if not ses or not ses.charId then return end
apiRequest("POST", "/character/save", {
character_id = ses.charId,
last_x = x,
last_y = y,
last_z = z,
last_heading = h,
}, function() end)
end)
-- ─── Disconnect ──────────────────────────────────────────────────────────────
AddEventHandler("playerDropped", function()
local src = source
Sessions[src] = nil
end)
print("[JustRP] Server core loaded.")
-3
View File
@@ -1,3 +0,0 @@
# Part 1
Code for part 1 lands here after filming.
+26
View File
@@ -0,0 +1,26 @@
# JustRP — пример server.cfg
# Скопируйте в server.cfg и подставьте свои значения.
endpoint_add_tcp "0.0.0.0:30120"
endpoint_add_udp "0.0.0.0:30120"
sv_hostname "JustRP | Los Santos Roleplay"
sv_maxclients 64
sv_scriptHookAllowed 0
onesync on
# Ключ сервера с https://keymaster.fivem.net — НИКОГДА не публикуйте его
sv_licenseKey "СЮДА_ВАШ_КЛЮЧ"
# Должен совпадать с JUSTRP_API_SECRET у Python-API
set justrp_api_key "СЮДА_СВОЙ_КЛЮЧ"
ensure mapmanager
ensure chat
ensure spawnmanager
ensure sessionmanager
ensure fivem-map-hipster
ensure basic-gamemode
ensure hardcap
ensure justrp