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:
co-authored by
Claude Opus 5
parent
968d047f87
commit
0144e83f05
+340
@@ -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 3–20 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()
|
||||
@@ -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.")
|
||||
@@ -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
@@ -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>
|
||||
@@ -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.")
|
||||
@@ -1,3 +0,0 @@
|
||||
# Part 1
|
||||
|
||||
Code for part 1 lands here after filming.
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user