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