The server itself: repo now mirrors the machine it runs on, plus install.sh

The repository carried two different servers side by side - the early
`justrp` prototype with its Python auth API, and a snapshot of the real
one under `server-code/`. Only the second one is a server anyone should
start from, so the prototype is gone and the real one moved to the root.

Taken from the live box, so the UI is the finished version (the tokens,
the county-records sheets and the variable fonts landed after the last
snapshot was pushed):

  resources/[rp]/   rp_db, rp_core, rp_session, rp_loading, rp_ui,
                    rp_selftest, rp_dbtest
  bin/              supervise.sh (keep-alive + console FIFO), rcon, init script
  etc/schema.sql    accounts, characters, transactions, inventory, vehicles
  assets/fonts/     the bundled subsets
  docs/SERVER.md    how it is put together, resource by resource

install.sh turns a clean Ubuntu/Debian box into this server in one
command: recommended FXServer build, MariaDB with the schema, resources,
server.cfg + a generated database password, boot entry (systemd or
init.d), then it waits for the Cfx registration. --name/--port/--db-name
let a second server live on the same machine. Tested end to end on a
spare install root: build 25770 fetched, schema applied, boot entry
written, FXServer started and stopped exactly where a wrong licence key
should stop it.

No secrets travel with it: the licence key and the database password live
in data/secrets.cfg on the machine, and .gitignore now names it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Claude Opus 5
2026-08-12 23:23:33 +00:00
co-authored by Claude Opus 5
parent 80c1d75d2f
commit 71856b15e9
66 changed files with 2030 additions and 3814 deletions
+11 -4
View File
@@ -1,5 +1,12 @@
server-data/
cache/
*.log
# Anything holding a licence key or a password
secrets.cfg
*.env
.env
server.cfg.local
# Runtime state, never in the repo
cache/
logs/
*.log
/server/
/data/
/server.cfg
+97 -41
View File
@@ -4,24 +4,23 @@ An open-source **FiveM roleplay base written completely from scratch** — no ES
> 🇷🇺 Русская версия — ниже / Russian version below.
This repository is the server itself, exactly as it runs: the resources, the schema, the supervisor, the config. `sh install.sh` puts it on a clean Ubuntu box.
## Why another base?
Most FiveM projects start by dropping in a 200 MB framework, of which you use maybe 5%. You inherit somebody else's bugs, somebody else's dependencies, and you end up with the same server as a thousand other people — and you're afraid to touch any of it, because you don't know what breaks.
This is the opposite. It's small, it's readable, and it only contains what was actually asked for. If you don't understand a file, you can ask for it to be rewritten.
## What's in part 1
## What's in it
- Clean `fxserver` setup on Ubuntu
- MariaDB with tables for accounts and characters, hashed passwords
- Own Lua resource: registration, login, character creation, spawn
- NUI screens with real design work (not default template UI):
- cinematic loading screen with music and a real progress bar
- register / login with live validation
- character creation with an orbiting camera and live model updates
- spawn point selection
- Own MySQL driver (`rp_db`) — the wire protocol spoken directly over a TCP socket: handshake, `mysql_native_password`, text result sets, a connection pool, transactions. No npm packages, no third-party database resource.
- Accounts and characters (`rp_core`, `rp_session`): registration, login, scrypt password hashing, the connection gate (bans, capacity), character creation and spawn. Every decision is made server-side; the client can only ask.
- NUI screens with real design work (`rp_ui`, `rp_loading`), not a template: a loading screen with a procedural night-city flyover and synthesised ambience, sign-in / register with live validation, character creation with an orbiting camera and a live ped, and a spawn picker.
- A self-test (`rp_selftest`) that proves the account/character data path survived the last restart, and a driver test suite (`rp_dbtest`).
- `cfx-server-data` is not used at all — the player cap, chat, spawning and session handling are ours.
## Screens (written by the agent, part 1)
## Screens
The whole UI follows one theme the agent picked itself — **Los Santos County records**: your login is a residency file, character creation is an identity-record intake, spawn selection is a county survey map.
@@ -29,66 +28,121 @@ The whole UI follows one theme the agent picked itself — **Los Santos County r
|---|---|---|
| ![Auth](docs/screens/auth.png) | ![Character](docs/screens/character.png) | ![Spawn](docs/screens/spawn.png) |
## Requirements
- Ubuntu 22.04 / 24.04, 4 vCPU / 8 GB RAM recommended
- A free Cfx.re server registration key (`sv_licenseKey`)
- MariaDB
## Install
Setup instructions land here together with part 1 of the video.
Ubuntu 22.04 / 24.04 (or Debian 12), 4 vCPU / 8 GB RAM recommended, and a free server key from [keymaster.fivem.net](https://keymaster.fivem.net).
## Roadmap
```sh
git clone https://github.com/RedlHosting/redl-fivem-rp.git
cd redl-fivem-rp
sudo sh install.sh --licence cfxk_your_key_here
```
- **Part 1** — foundation: database, registration, login, character creation ← *you are here*
- **Part 2** — money, jobs, banking
The installer fetches the recommended FXServer build, installs MariaDB and applies the schema, copies the resources into `/opt/fivem`, writes `server.cfg` and a `secrets.cfg` with a generated database password, sets the server to start on boot, and waits until it has registered with Cfx. Then connect to `<your ip>:30120`.
Useful options: `--dir`, `--hostname`, `--port`, `--name` (run several servers on one box), `--db-name`, `--db-user`, `--no-start`. Run `sh install.sh --help` for the list.
Day to day:
```sh
service fivem start|stop|restart|status # systemd boxes: systemctl ...
rcon status # send a command to the live console
tail -f /opt/fivem/logs/server.log
```
## Layout
resources/[rp]/ the server: rp_db, rp_core, rp_session, rp_loading, rp_ui, rp_selftest, rp_dbtest
etc/schema.sql database schema (accounts, characters, transactions, inventory, vehicles)
bin/ supervise.sh (keep-alive + console FIFO), rcon, the init script
assets/fonts/ the bundled font subsets — a NUI page has no guaranteed internet
server.cfg.example main config
etc/secrets.cfg.example
docs/SERVER.md how it is put together, resource by resource
`docs/SERVER.md` is the design document: what each resource does, why the interface looks the way it does, how the database is protected.
## What's not built yet
The pre-spawn experience and the framework underneath it are done. Gameplay on top of it — chat, HUD, inventory, jobs, vehicles, policing, medical — is not. `characters`, `transactions`, `inventory` and `vehicles` already have their tables, and the money API in `rp_core/server/player.lua` is journalled and ready.
- **Part 1** — the foundation: database, registration, login, character creation ← *we are here*
- **Part 2** — money, jobs, the bank
- **Part 3** — inventory and phone
- **Part 4** — garages and vehicles
- **Part 5** — admin panel + project website on the same machine
- **Part 5** — an admin panel and the project website on the same machine
## How this was built
Issues and pull requests are welcome — this is meant to be used, not admired.
Everything is built on a [REDL](https://redl.io) VDS, where an AI agent lives on the server itself with root access. The task is given in plain English, the agent writes the code, fixes its own errors, and restarts the service. The full process is recorded — including the parts where it broke.
## How it was made
## License
Everything is built on a [REDL](https://redl.io) VDS, where an AI agent lives on the server itself with root access. The task is given in plain words, the agent writes the code, fixes its own mistakes and restarts the service. The whole process is recorded — including the parts where it broke.
MIT — take it, change it, ship it. Attribution appreciated, not required.
## Licence
MIT — take it, change it, ship it. Credit is nice but not required.
---
# 🇷🇺 redl-fivem-rp
# redl-fivem-rp (RU)
Открытая **ролевая база для FiveM, написанная полностью с нуля** — без ESX, без QBCore, без скачанных с форума скриптов. Каждая строчка здесь написана ИИ-агентом на живом сервере, в кадре, и выложена бесплатно для всех.
В репозитории лежит сам сервер, ровно в том виде, в каком он работает: ресурсы, схема базы, супервизор, конфиг. `sh install.sh` ставит его на чистую Ubuntu.
## Зачем ещё одна база?
Обычно проект начинается с того, что ставится сборка на 200 мегабайт, из которой реально используется процентов пять. Вместе с ней приезжают чужие баги, чужие зависимости, и в итоге у тебя такой же сервер, как ещё у тысячи человек, — и трогать его страшно, потому что непонятно, что отвалится.
Здесь наоборот. Мало кода, он читаемый, и в нём есть только то, что мы попросили. Не понял файл — попросил переписать.
## Что в первой части
## Что внутри
- Чистый `fxserver` на Ubuntu
- MariaDB, таблицы под аккаунты и персонажей, хешированные пароли
- Свой ресурс на Lua: регистрация, авторизация, создание персонажа, спавн
- Экраны на NUI с настоящей дизайн-проработкой (а не шаблонный интерфейс):
- кинематографичный загрузочный экран с музыкой и реальным прогрессом
- регистрация/вход с живой валидацией
- создание персонажа с облётом камеры и живым обновлением модели
- выбор точки спавна
- Свой драйвер MySQL (`rp_db`) — протокол реализован прямо поверх TCP-сокета: рукопожатие, `mysql_native_password`, разбор результатов, пул соединений, транзакции. Ни одного npm-пакета и ни одного чужого ресурса для базы.
- Аккаунты и персонажи (`rp_core`, `rp_session`): регистрация, вход, хеширование паролей scrypt, шлюз подключения (баны, вместимость), создание персонажа и спавн. Все решения принимает сервер, клиент может только попросить.
- Экраны NUI с настоящей дизайн-проработкой (`rp_ui`, `rp_loading`), а не шаблон: загрузочный экран с процедурным облётом ночного города и синтезированной атмосферой, вход/регистрация с живой валидацией, создание персонажа с облётом камеры и живой моделью, выбор точки спавна.
- Самотест (`rp_selftest`), который на каждом запуске доказывает, что данные аккаунтов и персонажей пережили перезапуск, и набор тестов драйвера (`rp_dbtest`).
- `cfx-server-data` не используется вообще — лимит игроков, чат, спавн и сессии свои.
## Требования
## Экраны
- Ubuntu 22.04 / 24.04, рекомендую 4 vCPU / 8 ГБ RAM
- Бесплатный ключ регистрации сервера Cfx.re (`sv_licenseKey`)
- MariaDB
Весь интерфейс сделан в одной теме, которую агент выбрал сам, — **архив округа Лос-Сантос**: вход это дело о проживании, создание персонажа — анкета учёта личности, выбор спавна — топографическая карта округа.
## Установка
Инструкция появится здесь вместе с первой частью видео.
Ubuntu 22.04 / 24.04 (или Debian 12), рекомендую 4 vCPU / 8 ГБ RAM, и бесплатный ключ сервера с [keymaster.fivem.net](https://keymaster.fivem.net).
## План частей
```sh
git clone https://github.com/RedlHosting/redl-fivem-rp.git
cd redl-fivem-rp
sudo sh install.sh --licence cfxk_ваш_ключ
```
Установщик сам скачает рекомендованную сборку FXServer, поставит MariaDB и накатит схему, разложит ресурсы в `/opt/fivem`, напишет `server.cfg` и `secrets.cfg` со сгенерированным паролем базы, поставит сервер в автозапуск и дождётся регистрации в Cfx. Дальше — `connect <ваш ip>:30120`.
Полезные параметры: `--dir`, `--hostname`, `--port`, `--name` (несколько серверов на одной машине), `--db-name`, `--db-user`, `--no-start`. Список — `sh install.sh --help`.
Повседневное:
```sh
service fivem start|stop|restart|status # на машинах с systemd — systemctl ...
rcon status # команда в живую консоль сервера
tail -f /opt/fivem/logs/server.log
```
## Что где лежит
resources/[rp]/ сам сервер: rp_db, rp_core, rp_session, rp_loading, rp_ui, rp_selftest, rp_dbtest
etc/schema.sql схема базы (accounts, characters, transactions, inventory, vehicles)
bin/ supervise.sh (перезапуск + FIFO консоли), rcon, init-скрипт
assets/fonts/ вшитые подмножества шрифтов — у страницы NUI нет гарантированного интернета
server.cfg.example основной конфиг
etc/secrets.cfg.example
docs/SERVER.md как всё устроено, ресурс за ресурсом
## Чего ещё нет
Всё до спавна и фундамент под этим — готово. Геймплей сверху — чат, HUD, инвентарь, работы, транспорт, полиция, медицина — нет. Таблицы `characters`, `transactions`, `inventory`, `vehicles` уже заведены, денежный API в `rp_core/server/player.lua` журналируемый и готов.
- **Часть 1** — фундамент: база, регистрация, авторизация, создание персонажа ← *мы здесь*
- **Часть 2** — деньги, работы, банк
@@ -96,6 +150,8 @@ MIT — take it, change it, ship it. Attribution appreciated, not required.
- **Часть 4** — гаражи и транспорт
- **Часть 5** — админка + сайт проекта на этой же машине
Issues и pull request'ы приветствуются — это сделано, чтобы этим пользовались.
## Как это сделано
Всё собирается на VDS [REDL](https://redl.io), где ИИ-агент живёт прямо на сервере с root-доступом. Задача ставится обычными словами, агент пишет код, чинит свои же ошибки и перезапускает сервис. Весь процесс записан — включая те моменты, где всё ломалось.
-340
View File
@@ -1,340 +0,0 @@
#!/usr/bin/env python3
"""JustRP Database API — internal HTTP service on port 8787"""
import json
import hashlib
import os
import socket
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse, parse_qs
try:
import pymysql
import pymysql.cursors
except ImportError:
import subprocess
subprocess.run(["pip3", "install", "pymysql"], check=True)
import pymysql
import pymysql.cursors
# Секреты берутся из окружения. Заведите их перед запуском, например в systemd-юните:
# Environment=JUSTRP_API_SECRET=... Environment=JUSTRP_DB_PASS=...
API_SECRET = os.environ.get("JUSTRP_API_SECRET", "change-me")
DB = {
"host": os.environ.get("JUSTRP_DB_HOST", "localhost"),
"user": os.environ.get("JUSTRP_DB_USER", "fivem"),
"password": os.environ.get("JUSTRP_DB_PASS", "change-me"),
"database": os.environ.get("JUSTRP_DB_NAME", "fivem"),
"charset": "utf8mb4",
"cursorclass": pymysql.cursors.DictCursor,
}
def db():
return pymysql.connect(**DB)
def hash_pw(password: str, salt: str) -> str:
return hashlib.scrypt(
password.encode(), salt=salt.encode(), n=16384, r=8, p=1
).hex()
def new_salt() -> str:
return os.urandom(16).hex()
class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt, *args):
pass # silence default logging
def send_json(self, code, data):
body = json.dumps(data).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def auth_check(self):
return self.headers.get("X-API-Key") == API_SECRET
def read_body(self):
n = int(self.headers.get("Content-Length", 0))
if n == 0:
return {}
raw = self.rfile.read(n)
try:
return json.loads(raw)
except Exception:
return {}
def do_POST(self):
if not self.auth_check():
return self.send_json(403, {"error": "forbidden"})
path = urlparse(self.path).path
body = self.read_body()
if path == "/auth/register":
self.handle_register(body)
elif path == "/auth/login":
self.handle_login(body)
elif path == "/character/create":
self.handle_char_create(body)
elif path == "/character/save":
self.handle_char_save(body)
elif path == "/account/ban":
self.handle_ban(body)
else:
self.send_json(404, {"error": "not found"})
def do_GET(self):
if not self.auth_check():
return self.send_json(403, {"error": "forbidden"})
parsed = urlparse(self.path)
path = parsed.path
qs = parse_qs(parsed.query)
if path == "/characters":
self.handle_chars_list(qs)
elif path == "/character":
self.handle_char_get(qs)
elif path == "/account":
self.handle_account_get(qs)
else:
self.send_json(404, {"error": "not found"})
# --- Auth ---
def handle_register(self, body):
license_ = body.get("license", "").strip()
username = body.get("username", "").strip()
password = body.get("password", "")
if not license_ or not username or not password:
return self.send_json(400, {"success": False, "error": "Missing fields"})
if len(username) < 3 or len(username) > 20:
return self.send_json(400, {"success": False, "error": "Username must be 320 characters"})
if len(password) < 6:
return self.send_json(400, {"success": False, "error": "Password must be at least 6 characters"})
salt = new_salt()
pw_hash = hash_pw(password, salt)
stored = f"{salt}:{pw_hash}"
try:
conn = db()
with conn.cursor() as cur:
cur.execute("SELECT id FROM accounts WHERE username = %s", (username,))
if cur.fetchone():
conn.close()
return self.send_json(409, {"success": False, "error": "Username already taken"})
cur.execute("SELECT id FROM accounts WHERE license = %s", (license_,))
if cur.fetchone():
conn.close()
return self.send_json(409, {"success": False, "error": "A JustRP account already exists on this device"})
cur.execute(
"INSERT INTO accounts (license, username, password_hash) VALUES (%s, %s, %s)",
(license_, username, stored),
)
account_id = cur.lastrowid
conn.commit()
conn.close()
return self.send_json(200, {"success": True, "account_id": account_id, "username": username})
except Exception as e:
return self.send_json(500, {"success": False, "error": str(e)})
def handle_login(self, body):
license_ = body.get("license", "").strip()
username = body.get("username", "").strip()
password = body.get("password", "")
if not license_ or not username or not password:
return self.send_json(400, {"success": False, "error": "Missing fields"})
try:
conn = db()
with conn.cursor() as cur:
cur.execute(
"SELECT id, password_hash, banned, ban_reason FROM accounts WHERE username = %s",
(username,),
)
row = cur.fetchone()
if not row:
conn.close()
return self.send_json(401, {"success": False, "error": "Invalid username or password"})
if row["banned"]:
conn.close()
return self.send_json(403, {"success": False, "error": f"Banned: {row['ban_reason'] or 'No reason given'}"})
stored = row["password_hash"]
salt, expected = stored.split(":", 1)
actual = hash_pw(password, salt)
if actual != expected:
conn.close()
return self.send_json(401, {"success": False, "error": "Invalid username or password"})
cur.execute("UPDATE accounts SET last_login = NOW() WHERE id = %s", (row["id"],))
conn.commit()
conn.close()
return self.send_json(200, {"success": True, "account_id": row["id"], "username": username})
except Exception as e:
return self.send_json(500, {"success": False, "error": str(e)})
# --- Characters ---
def handle_chars_list(self, qs):
account_id = int(qs.get("account_id", ["0"])[0])
if not account_id:
return self.send_json(400, {"error": "missing account_id"})
try:
conn = db()
with conn.cursor() as cur:
cur.execute(
"SELECT * FROM characters WHERE account_id = %s ORDER BY slot",
(account_id,),
)
rows = cur.fetchall()
conn.close()
return self.send_json(200, {"success": True, "characters": rows})
except Exception as e:
return self.send_json(500, {"success": False, "error": str(e)})
def handle_char_get(self, qs):
char_id = int(qs.get("id", ["0"])[0])
if not char_id:
return self.send_json(400, {"error": "missing id"})
try:
conn = db()
with conn.cursor() as cur:
cur.execute("SELECT * FROM characters WHERE id = %s", (char_id,))
row = cur.fetchone()
conn.close()
if not row:
return self.send_json(404, {"success": False, "error": "Character not found"})
return self.send_json(200, {"success": True, "character": row})
except Exception as e:
return self.send_json(500, {"success": False, "error": str(e)})
def handle_char_create(self, body):
required = ["account_id", "slot", "firstname", "lastname"]
for f in required:
if f not in body:
return self.send_json(400, {"success": False, "error": f"Missing field: {f}"})
fields = {
"account_id": body["account_id"],
"slot": body["slot"],
"firstname": body["firstname"][:32],
"lastname": body["lastname"][:32],
"backstory": body.get("backstory", ""),
"gender": body.get("gender", 0),
"face_blend": body.get("face_blend", 0.5),
"face_shape": body.get("face_shape", 0.5),
"skin_tone": body.get("skin_tone", 0.5),
"eye_color": body.get("eye_color", 0),
"hair_style": body.get("hair_style", 0),
"hair_color": body.get("hair_color", 0),
"body_weight": body.get("body_weight", 0.5),
"outfit_top": body.get("outfit_top", 0),
"outfit_pants": body.get("outfit_pants", 0),
"outfit_shoes": body.get("outfit_shoes", 0),
}
try:
conn = db()
with conn.cursor() as cur:
cur.execute(
"SELECT COUNT(*) as cnt FROM characters WHERE account_id = %s",
(fields["account_id"],),
)
if cur.fetchone()["cnt"] >= 3:
conn.close()
return self.send_json(400, {"success": False, "error": "Maximum 3 characters per account"})
cols = ", ".join(fields.keys())
placeholders = ", ".join(["%s"] * len(fields))
cur.execute(
f"INSERT INTO characters ({cols}) VALUES ({placeholders})",
list(fields.values()),
)
char_id = cur.lastrowid
conn.commit()
conn.close()
return self.send_json(200, {"success": True, "character_id": char_id})
except Exception as e:
return self.send_json(500, {"success": False, "error": str(e)})
def handle_char_save(self, body):
char_id = body.get("character_id")
if not char_id:
return self.send_json(400, {"success": False, "error": "Missing character_id"})
allowed = {
"last_x", "last_y", "last_z", "last_heading",
"cash", "bank", "playtime",
"face_blend", "face_shape", "skin_tone", "eye_color",
"hair_style", "hair_color", "body_weight",
"outfit_top", "outfit_pants", "outfit_shoes",
}
updates = {k: v for k, v in body.items() if k in allowed}
if not updates:
return self.send_json(200, {"success": True})
set_clause = ", ".join(f"{k} = %s" for k in updates)
values = list(updates.values()) + [char_id]
try:
conn = db()
with conn.cursor() as cur:
cur.execute(
f"UPDATE characters SET {set_clause} WHERE id = %s",
values,
)
conn.commit()
conn.close()
return self.send_json(200, {"success": True})
except Exception as e:
return self.send_json(500, {"success": False, "error": str(e)})
def handle_account_get(self, qs):
license_ = qs.get("license", [""])[0]
if not license_:
return self.send_json(400, {"error": "missing license"})
try:
conn = db()
with conn.cursor() as cur:
cur.execute(
"SELECT id, username, banned, ban_reason FROM accounts WHERE license = %s",
(license_,),
)
row = cur.fetchone()
conn.close()
if not row:
return self.send_json(404, {"success": False, "error": "not found"})
return self.send_json(200, {"success": True, "account": row})
except Exception as e:
return self.send_json(500, {"success": False, "error": str(e)})
def handle_ban(self, body):
account_id = body.get("account_id")
reason = body.get("reason", "No reason given")
if not account_id:
return self.send_json(400, {"success": False, "error": "missing account_id"})
try:
conn = db()
with conn.cursor() as cur:
cur.execute(
"UPDATE accounts SET banned = 1, ban_reason = %s WHERE id = %s",
(reason, account_id),
)
conn.commit()
conn.close()
return self.send_json(200, {"success": True})
except Exception as e:
return self.send_json(500, {"success": False, "error": str(e)})
if __name__ == "__main__":
server = HTTPServer(("127.0.0.1", 8787), Handler)
print("[JustRP API] Listening on 127.0.0.1:8787", flush=True)
server.serve_forever()
+30
View File
@@ -0,0 +1,30 @@
/* vietnamese */
@font-face {
font-family: 'Archivo';
font-style: normal;
font-weight: 100 900;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/archivo/v25/k3kPo8UDI-1M0wlSV9XAw6lQkqWY8Q82sLySOxK-vA.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Archivo';
font-style: normal;
font-weight: 100 900;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/archivo/v25/k3kPo8UDI-1M0wlSV9XAw6lQkqWY8Q82sLyTOxK-vA.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Archivo';
font-style: normal;
font-weight: 100 900;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/archivo/v25/k3kPo8UDI-1M0wlSV9XAw6lQkqWY8Q82sLydOxI.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
+90
View File
@@ -0,0 +1,90 @@
/* cyrillic-ext */
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(https://fonts.gstatic.com/s/ibmplexmono/v20/-F63fjptAgt5VM-kVkqdyU8n1iIq129k.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(https://fonts.gstatic.com/s/ibmplexmono/v20/-F63fjptAgt5VM-kVkqdyU8n1isq129k.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* vietnamese */
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(https://fonts.gstatic.com/s/ibmplexmono/v20/-F63fjptAgt5VM-kVkqdyU8n1iAq129k.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(https://fonts.gstatic.com/s/ibmplexmono/v20/-F63fjptAgt5VM-kVkqdyU8n1iEq129k.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(https://fonts.gstatic.com/s/ibmplexmono/v20/-F63fjptAgt5VM-kVkqdyU8n1i8q1w.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(https://fonts.gstatic.com/s/ibmplexmono/v20/-F6qfjptAgt5VM-kVkqdyU8n3twJwl1FgtIU.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(https://fonts.gstatic.com/s/ibmplexmono/v20/-F6qfjptAgt5VM-kVkqdyU8n3twJwlRFgtIU.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* vietnamese */
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(https://fonts.gstatic.com/s/ibmplexmono/v20/-F6qfjptAgt5VM-kVkqdyU8n3twJwl9FgtIU.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(https://fonts.gstatic.com/s/ibmplexmono/v20/-F6qfjptAgt5VM-kVkqdyU8n3twJwl5FgtIU.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(https://fonts.gstatic.com/s/ibmplexmono/v20/-F6qfjptAgt5VM-kVkqdyU8n3twJwlBFgg.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
Executable
+60
View File
@@ -0,0 +1,60 @@
#!/bin/sh
### BEGIN INIT INFO
# Provides: fivem
# Required-Start: $network mariadb
# Default-Start: 2 3 4 5
# Short-Description: Los Santos RP FXServer
### END INIT INFO
NAME=fivem
DATA=/opt/fivem/data
SERVER=/opt/fivem/server
LOG=/opt/fivem/logs/server.log
SUPPID=/run/$NAME.sup.pid
CHILDPID=/run/$NAME.child.pid
status() {
if [ -f $CHILDPID ] && kill -0 "$(cat $CHILDPID)" 2>/dev/null; then
echo "$NAME running (pid $(cat $CHILDPID))"
return 0
fi
echo "$NAME not running"
return 3
}
start() {
if status >/dev/null 2>&1; then echo "$NAME already running"; return 0; fi
# the database has to answer before resources start querying it
service mariadb start >/dev/null 2>&1
i=0
while ! mysqladmin ping >/dev/null 2>&1; do
i=$((i+1))
[ $i -gt 30 ] && echo "warning: mariadb did not come up" && break
sleep 1
done
cd $DATA || exit 1
setsid /opt/fivem/bin/supervise.sh "$NAME" "$LOG" \
$SERVER/run.sh +set citizen_dir $SERVER/alpine/opt/cfx-server/citizen/ \
+exec $DATA/server.cfg >/dev/null 2>&1 < /dev/null &
sleep 2
echo "$NAME started"
}
stop() {
[ -f $SUPPID ] && kill -TERM "$(cat $SUPPID)" 2>/dev/null
[ -f $CHILDPID ] && kill -TERM "$(cat $CHILDPID)" 2>/dev/null
sleep 2
[ -f $CHILDPID ] && kill -9 "$(cat $CHILDPID)" 2>/dev/null
rm -f $SUPPID $CHILDPID
echo "$NAME stopped"
}
case "$1" in
start) start ;;
stop) stop ;;
restart) stop; start ;;
status) status ;;
*) echo "usage: $0 {start|stop|restart|status}"; exit 1 ;;
esac
Executable
+7
View File
@@ -0,0 +1,7 @@
#!/bin/sh
# Send a command to the running FXServer console, e.g. rcon refresh
if [ ! -p /run/fivem.stdin ]; then
echo "server is not running (no console pipe)" >&2
exit 1
fi
printf '%s\n' "$*" > /run/fivem.stdin
+66
View File
@@ -0,0 +1,66 @@
#!/bin/sh
# ---------------------------------------------------------------------------
# supervise.sh <name> <logfile> <command...>
#
# Keeps one process alive. There is no systemd in this container, so this is
# what restarts FXServer if it crashes.
#
# Two details that matter for FXServer specifically:
#
# * It reads its console from stdin and quits the moment stdin reaches EOF,
# so it is given a FIFO whose write end this script holds open forever.
# That doubles as a command channel - see /opt/fivem/bin/rcon.
#
# * A server that dies immediately is usually failing for a reason that will
# not fix itself (bad licence key, database down, Cfx rate limiting). The
# restart delay backs off so a broken server does not hammer an upstream
# service, and resets once it has stayed up for a while.
# ---------------------------------------------------------------------------
NAME="$1"; LOG="$2"; shift 2
FIFO="${FIFO:-/run/$NAME.stdin}"
MIN_DELAY=5
MAX_DELAY=300
HEALTHY_AFTER=120 # seconds of uptime that count as "it actually started"
mkdir -p "$(dirname "$LOG")"
echo $$ > "/run/$NAME.sup.pid"
[ -p "$FIFO" ] || { rm -f "$FIFO"; mkfifo "$FIFO"; }
# read-write open: holds the pipe open without blocking and without a helper process
exec 9<> "$FIFO"
cleanup() {
kill "$CHILD" 2>/dev/null
rm -f "/run/$NAME.sup.pid" "/run/$NAME.child.pid"
exit 0
}
trap cleanup TERM INT
delay=$MIN_DELAY
while true; do
if [ -f "$LOG" ] && [ "$(stat -c %s "$LOG")" -gt 209715200 ]; then
mv "$LOG" "$LOG.1"
fi
echo "=== $(date -Is) starting $NAME ===" >> "$LOG"
started=$(date +%s)
"$@" >> "$LOG" 2>&1 <&9 &
CHILD=$!
echo $CHILD > "/run/$NAME.child.pid"
wait $CHILD
RC=$?
ran=$(( $(date +%s) - started ))
if [ "$ran" -ge "$HEALTHY_AFTER" ]; then
delay=$MIN_DELAY
else
delay=$(( delay * 2 ))
[ "$delay" -gt "$MAX_DELAY" ] && delay=$MAX_DELAY
fi
echo "=== $(date -Is) $NAME exited (rc=$RC) after ${ran}s, restarting in ${delay}s ===" >> "$LOG"
sleep "$delay"
done
+35
View File
@@ -27,6 +27,41 @@ resources: `cfx-server-data` is not used at all, and the bundled `chat` and
| `rp_selftest` | Checks the account/character data path on every boot and proves it survived the restart. |
| `rp_dbtest` | Driver test suite. Not started by default; `ensure rp_dbtest` to run it. |
## The look
The interface is the paperwork side of a life in Los Santos: every screen is a
sheet from the city's Office of Vital Records, lying on a dark desk while the
game runs on behind it. Paper is the only surface we draw; the game is the room
the desk is in. That is why the NUI body stays transparent and the sheets are
opaque - an opaque sheet stays legible over a bright daylight city, where a dark
glass panel would not.
`html/tokens.css` is shared verbatim by `rp_ui` and `rp_loading` and holds the
whole system. Three typographic voices, and the rule between them is literal:
| voice | face | what it is |
|-------|------|------------|
| preprinted | Archivo, expanded, caps | what the form was printed with: headings, field captions, buttons |
| typed | IBM Plex Mono | what somebody entered on it: values, serials, names, user input |
| prose | IBM Plex Sans | the plain-English notes in the margin: blurbs and hints |
Three inks, each with exactly one job and never used decoratively: `--canary`
(municipal form yellow) is what is selected right now, `--stamp` (oxblood) is
filed / refused / destroyed, `--verdi` (municipal teal) is checked and valid.
The one loud thing is the rubber stamp, and it only ever marks a real change of
state: the file opened, the application approved, the placement issued. It is
masked with SVG turbulence so the ink lands unevenly, and the paper on the desk
takes the hit with it.
Fonts are bundled, never fetched: a NUI page has no guaranteed internet. Any
glyph used in `content:` must exist in the shipped latin subset, which is why
the validation tick is drawn from borders rather than U+2713.
Screens can be looked at without launching the game: `python3 /opt/preview/build.py`
stubs the NUI bridge, pushes each screen into a representative state, and writes
standalone pages to `/opt/preview/out` for headless Chrome to shoot.
## Operating it
service fivem start|stop|restart|status
+12
View File
@@ -0,0 +1,12 @@
# Copy to /opt/fivem/data/secrets.cfg and chmod 600.
# This file is exec'd by server.cfg and must never be committed anywhere.
# Free key from https://keymaster.fivem.net
sv_licenseKey "cfxk_your_key_here"
# Database the rp_db driver connects to.
set rp_db_host "127.0.0.1"
set rp_db_port "3306"
set rp_db_user "rp"
set rp_db_pass "your_database_password"
set rp_db_name "rp"
Executable
+236
View File
@@ -0,0 +1,236 @@
#!/bin/sh
# ---------------------------------------------------------------------------
# Los Santos RP - installer
#
# Puts a working server on a clean Ubuntu/Debian box: FXServer artefacts,
# MariaDB with the schema, the resources from this repository, the keep-alive
# supervisor and a boot entry.
#
# sudo sh install.sh --licence cfxk_xxxxxxxx
#
# Options
# --licence KEY Cfx.re server key from https://keymaster.fivem.net
# --dir PATH install root (default /opt/fivem)
# --hostname NAME server name shown in the list (default from server.cfg.example)
# --port N game port (default 30120)
# --name NAME service name, one per server (default fivem)
# --db-name NAME database (default rp)
# --db-user USER database user (default rp)
# --db-pass PASS password for that user (default: generated)
# --build ID FXServer build to use (default: latest recommended)
# --no-start install everything, do not start the server
# ---------------------------------------------------------------------------
set -e
DIR=/opt/fivem
LICENCE=""
HOSTNAME_=""
BUILD=""
DB_PASS=""
DB_NAME=rp
DB_USER=rp
PORT=30120
SVC=fivem
START=1
ARTIFACTS="https://runtime.fivem.net/artifacts/fivem/build_proot_linux/master/"
SRC=$(cd "$(dirname "$0")" && pwd)
while [ $# -gt 0 ]; do
case "$1" in
--licence|--license) LICENCE="$2"; shift 2 ;;
--dir) DIR="$2"; shift 2 ;;
--hostname) HOSTNAME_="$2"; shift 2 ;;
--build) BUILD="$2"; shift 2 ;;
--db-pass) DB_PASS="$2"; shift 2 ;;
--db-name) DB_NAME="$2"; shift 2 ;;
--db-user) DB_USER="$2"; shift 2 ;;
--port) PORT="$2"; shift 2 ;;
--name) SVC="$2"; shift 2 ;;
--no-start) START=0; shift ;;
-h|--help) sed -n '2,20p' "$0"; exit 0 ;;
*) echo "unknown option: $1" >&2; exit 1 ;;
esac
done
say() { printf '\n\033[1;33m==\033[0m %s\n' "$*"; }
ok() { printf ' \033[32mok\033[0m %s\n' "$*"; }
die() { printf '\n\033[31mfailed:\033[0m %s\n' "$*" >&2; exit 1; }
[ "$(id -u)" = 0 ] || die "run as root (sudo sh install.sh ...)"
[ -f "$SRC/etc/schema.sql" ] || die "run this from a clone of the repository"
command -v apt-get >/dev/null || die "this installer is for Debian/Ubuntu"
# --- 1. packages -----------------------------------------------------------
say "Installing packages"
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq curl ca-certificates xz-utils mariadb-server >/dev/null
ok "curl, xz-utils, mariadb-server"
# --- 2. FXServer artefacts -------------------------------------------------
say "Fetching FXServer"
if [ -n "$BUILD" ]; then
REL=$(curl -fsSL "$ARTIFACTS" | tr '>' '\n' | grep -oE "\./$BUILD-[0-9a-f]+/fx\.tar\.xz" | head -1)
[ -n "$REL" ] || die "build $BUILD not found on $ARTIFACTS"
else
REL=$(curl -fsSL "$ARTIFACTS" | tr '>' '\n' | grep -B4 'LATEST RECOMMENDED' \
| grep -oE '\./[0-9]+-[0-9a-f]+/fx\.tar\.xz' | head -1)
[ -n "$REL" ] || die "could not find the recommended build on $ARTIFACTS"
fi
URL="$ARTIFACTS${REL#./}"
BUILD_ID=$(echo "$REL" | grep -oE '[0-9]+' | head -1)
mkdir -p "$DIR/server" "$DIR/data/resources" "$DIR/bin" "$DIR/logs" "$DIR/etc"
if [ -x "$DIR/server/run.sh" ] && [ -f "$DIR/server/.build" ] && [ "$(cat "$DIR/server/.build")" = "$BUILD_ID" ]; then
ok "build $BUILD_ID already installed"
else
TMP=$(mktemp -d)
curl -fL# "$URL" -o "$TMP/fx.tar.xz" || die "download failed: $URL"
rm -rf "$DIR/server"; mkdir -p "$DIR/server"
tar xJf "$TMP/fx.tar.xz" -C "$DIR/server"
rm -rf "$TMP"
chmod +x "$DIR/server/run.sh"
echo "$BUILD_ID" > "$DIR/server/.build"
ok "build $BUILD_ID unpacked into $DIR/server"
fi
# --- 3. database -----------------------------------------------------------
say "Preparing the database"
if [ ! -f /etc/init.d/mariadb ] && [ ! -d /run/systemd/system ]; then
die "no init system found for mariadb"
fi
if [ -d /run/systemd/system ]; then
systemctl enable --now mariadb >/dev/null 2>&1 || true
else
service mariadb start >/dev/null 2>&1 || true
fi
i=0
while ! mysqladmin ping >/dev/null 2>&1; do
i=$((i+1)); [ $i -gt 60 ] && die "mariadb did not start"
sleep 1
done
if [ -z "$DB_PASS" ]; then
if [ -f "$DIR/data/secrets.cfg" ]; then
DB_PASS=$(sed -n 's/^set rp_db_pass "\(.*\)"$/\1/p' "$DIR/data/secrets.cfg" | head -1)
fi
[ -n "$DB_PASS" ] || DB_PASS=$(head -c 24 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' | cut -c1-24)
fi
mysql -e "CREATE DATABASE IF NOT EXISTS \`$DB_NAME\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
mysql -e "CREATE USER IF NOT EXISTS '$DB_USER'@'127.0.0.1' IDENTIFIED BY '$DB_PASS';"
mysql -e "ALTER USER '$DB_USER'@'127.0.0.1' IDENTIFIED BY '$DB_PASS';"
mysql -e "GRANT SELECT, INSERT, UPDATE, DELETE ON \`$DB_NAME\`.* TO '$DB_USER'@'127.0.0.1'; FLUSH PRIVILEGES;"
mysql "$DB_NAME" < "$SRC/etc/schema.sql"
ok "database $DB_NAME, user $DB_USER@127.0.0.1, schema applied"
# --- 4. resources and configuration ---------------------------------------
say "Installing the server files"
rm -rf "$DIR/data/resources/[rp]"
mkdir -p "$DIR/data/resources"
cp -r "$SRC/resources/[rp]" "$DIR/data/resources/"
cp "$SRC/bin/supervise.sh" "$DIR/bin/"
sed "s|/run/fivem.stdin|/run/$SVC.stdin|" "$SRC/bin/rcon" > "$DIR/bin/rcon"
chmod +x "$DIR/bin/supervise.sh" "$DIR/bin/rcon"
cp "$SRC/etc/schema.sql" "$DIR/etc/schema.sql"
ln -sf "$DIR/bin/rcon" "/usr/local/bin/$([ "$SVC" = fivem ] && echo rcon || echo "rcon-$SVC")"
if [ -f "$DIR/data/server.cfg" ]; then
ok "server.cfg kept (already present)"
else
sed -e "s|exec /opt/fivem/data/secrets.cfg|exec $DIR/data/secrets.cfg|" \
-e "s|0.0.0.0:30120|0.0.0.0:$PORT|g" \
"$SRC/server.cfg.example" > "$DIR/data/server.cfg"
[ -n "$HOSTNAME_" ] && sed -i "s|^sv_hostname .*|sv_hostname \"$HOSTNAME_\"|;s|^sets sv_projectName .*|sets sv_projectName \"$HOSTNAME_\"|" "$DIR/data/server.cfg"
ok "server.cfg written"
fi
if [ -z "$LICENCE" ] && [ -f "$DIR/data/secrets.cfg" ]; then
LICENCE=$(sed -n 's/^sv_licenseKey "\(.*\)"$/\1/p' "$DIR/data/secrets.cfg" | head -1)
fi
cat > "$DIR/data/secrets.cfg" <<EOF
# Licence key and database credentials. Never commit this file.
sv_licenseKey "${LICENCE:-cfxk_your_key_here}"
set rp_db_host "127.0.0.1"
set rp_db_port "3306"
set rp_db_user "$DB_USER"
set rp_db_pass "$DB_PASS"
set rp_db_name "$DB_NAME"
EOF
chmod 600 "$DIR/data/secrets.cfg"
ok "secrets.cfg written (mode 600)"
# --- 5. boot entry ---------------------------------------------------------
say "Setting up start on boot"
sed -e "s|^DATA=.*|DATA=$DIR/data|" -e "s|^SERVER=.*|SERVER=$DIR/server|" \
-e "s|^LOG=.*|LOG=$DIR/logs/server.log|" -e "s|^NAME=.*|NAME=$SVC|" \
-e "s|/opt/fivem/bin/supervise.sh|$DIR/bin/supervise.sh|" \
"$SRC/bin/fivem.init" > "/etc/init.d/$SVC"
chmod +x "/etc/init.d/$SVC"
if [ -d /run/systemd/system ]; then
cat > "/etc/systemd/system/$SVC.service" <<EOF
[Unit]
Description=Los Santos RP (FXServer)
After=network-online.target mariadb.service
Wants=mariadb.service
[Service]
Type=simple
WorkingDirectory=$DIR/data
ExecStart=$DIR/bin/supervise.sh $SVC $DIR/logs/server.log $DIR/server/run.sh +set citizen_dir $DIR/server/alpine/opt/cfx-server/citizen/ +exec $DIR/data/server.cfg
KillMode=mixed
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable "$SVC" >/dev/null 2>&1
ok "systemd unit $SVC.service"
elif [ -d /etc/redl/enabled ]; then
# REDL VDS: no systemd, the platform starts whatever is marked here
touch "/etc/redl/enabled/$SVC" /etc/redl/enabled/mariadb
ok "marked in /etc/redl/enabled (REDL VDS autostart)"
elif [ -f /etc/redl/enabled ]; then
grep -qx "$SVC" /etc/redl/enabled || echo "$SVC" >> /etc/redl/enabled
grep -qx mariadb /etc/redl/enabled || echo mariadb >> /etc/redl/enabled
ok "marked in /etc/redl/enabled (REDL VDS autostart)"
else
command -v update-rc.d >/dev/null && update-rc.d "$SVC" defaults >/dev/null 2>&1 || true
ok "/etc/init.d/$SVC installed"
fi
# --- 6. start --------------------------------------------------------------
if [ "$START" = 0 ]; then
say "Done (not started, --no-start)"
exit 0
fi
if [ -z "$LICENCE" ]; then
say "Done"
echo " No licence key given. Get a free one at https://keymaster.fivem.net,"
echo " put it into $DIR/data/secrets.cfg and run: service $SVC start"
exit 0
fi
say "Starting the server"
if [ -d /run/systemd/system ]; then systemctl restart "$SVC"; else service "$SVC" restart >/dev/null; fi
i=0
while [ $i -lt 90 ]; do
if grep -q "Authenticated with cfx.re Nucleus" "$DIR/logs/server.log" 2>/dev/null; then
ok "server is up and registered with Cfx"
grep -q "\[selftest\] data path OK" "$DIR/logs/server.log" && ok "self-test passed: the data path works"
IP=$(curl -fsS --max-time 5 https://api.ipify.org 2>/dev/null || echo "<server ip>")
printf '\n connect %s:%s\n logs: tail -f %s\n console: rcon status\n\n' "$IP" "$PORT" "$DIR/logs/server.log"
exit 0
fi
if grep -qE "Could not authenticate server license key|invalid license key|Failed to verify" "$DIR/logs/server.log" 2>/dev/null; then
if [ -d /run/systemd/system ]; then systemctl stop "$SVC"; else service "$SVC" stop >/dev/null 2>&1; fi
die "the licence key was refused - get one at https://keymaster.fivem.net, put it into $DIR/data/secrets.cfg and run: service $SVC start"
fi
i=$((i+1)); sleep 2
done
die "the server did not come up in 3 minutes - see $DIR/logs/server.log"
@@ -20,7 +20,8 @@ files {
'html/tokens.css',
'html/app.css',
'html/app.js',
'html/fonts/archivo.woff2',
'html/fonts/archivo-var.woff2',
'html/fonts/plexsans-var.woff2',
'html/fonts/plex-400.woff2',
'html/fonts/plex-500.woff2',
}
@@ -46,7 +46,7 @@
.rail {
position: relative;
z-index: 2;
width: var(--rail);
width: var(--sheet);
height: 100%;
padding: var(--gut-y) var(--gut-x);
display: grid;
@@ -68,14 +68,14 @@
/* --- file header --------------------------------------------------------- */
.file {
border-top: 1px solid var(--sodium-line);
border-top: 1px solid rgba(217,178,60,.42);
padding-top: 14px;
}
.file .serial {
margin-top: 7px;
color: var(--paper-dim);
color: rgba(233,226,204,.62);
}
.file .serial span { color: var(--sodium); }
.file .serial span { color: var(--canary); }
/* --- wordmark ------------------------------------------------------------ */
@@ -108,13 +108,13 @@
font-size: 12px;
font-weight: 500;
letter-spacing: 0.42em;
color: var(--sodium);
color: var(--canary);
text-transform: uppercase;
}
.dash {
flex: 1;
height: 1px;
background: linear-gradient(90deg, var(--sodium-line), transparent);
background: linear-gradient(90deg, rgba(217,178,60,.42), transparent);
}
/* --- notices ------------------------------------------------------------- */
@@ -126,9 +126,9 @@
justify-content: space-between;
align-items: baseline;
padding-bottom: 10px;
border-bottom: 1px solid var(--line);
border-bottom: 1px solid rgba(233,226,204,.14);
}
.notice-index { color: var(--muted); }
.notice-index { color: rgba(233,226,204,.40); }
.notice { padding-top: 18px; min-height: 132px; }
@@ -140,7 +140,7 @@
}
.notice-body {
font-size: 14.5px;
color: var(--paper-dim);
color: rgba(233,226,204,.62);
max-width: 40ch;
}
@@ -168,7 +168,7 @@
gap: 16px;
}
.step {
color: var(--paper-dim);
color: rgba(233,226,204,.62);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -178,27 +178,27 @@
font-weight: 500;
font-size: 28px;
line-height: 1;
color: var(--paper);
color: var(--stock-hi);
font-variant-numeric: tabular-nums;
}
.pct i {
font-style: normal;
font-size: 13px;
color: var(--muted);
color: rgba(233,226,204,.40);
margin-left: 2px;
}
.track {
position: relative;
height: 2px;
background: var(--line);
background: rgba(233,226,204,.14);
overflow: hidden;
}
.bar {
display: block;
height: 100%;
width: 0%;
background: var(--sodium);
background: var(--canary);
transition: width 420ms var(--ease);
}
/* a light runs ahead of the fill so the bar reads as active even when the
@@ -227,7 +227,7 @@
gap: 14px;
margin-top: 26px;
padding-top: 18px;
border-top: 1px solid var(--line-soft);
border-top: 1px solid rgba(233,226,204,.07);
}
.mute {
@@ -236,15 +236,15 @@
display: grid;
place-items: center;
background: transparent;
border: 1px solid var(--line);
color: var(--paper-dim);
border: 1px solid rgba(233,226,204,.14);
color: rgba(233,226,204,.62);
cursor: pointer;
transition: color 180ms var(--ease), border-color 180ms var(--ease), transform 120ms var(--ease);
}
.mute:hover { color: var(--sodium); border-color: var(--sodium-line); }
.mute:hover { color: var(--canary); border-color: rgba(217,178,60,.42); }
.mute:active { transform: translateY(1px); }
.mute.off #wave { opacity: 0.18; }
.mute.off { color: var(--muted); }
.mute.off { color: rgba(233,226,204,.40); }
input[type='range'] {
flex: 1;
@@ -256,14 +256,14 @@ input[type='range'] {
}
input[type='range']::-webkit-slider-runnable-track {
height: 2px;
background: var(--line);
background: rgba(233,226,204,.14);
}
input[type='range']::-webkit-slider-thumb {
-webkit-appearance: none;
width: 3px;
height: 15px;
margin-top: -6.5px;
background: var(--sodium);
background: var(--canary);
border: 0;
border-radius: 0;
transition: height 160ms var(--ease), margin-top 160ms var(--ease);
@@ -274,11 +274,11 @@ input[type='range']:focus-visible::-webkit-slider-thumb {
margin-top: -9.5px;
}
.volval { width: 3ch; text-align: right; color: var(--muted); }
.volval { width: 3ch; text-align: right; color: rgba(233,226,204,.40); }
.audiohint {
margin-top: 10px;
color: var(--sodium);
color: var(--canary);
animation: pulse 2.4s ease-in-out infinite;
}
@keyframes pulse { 50% { opacity: 0.45; } }
@@ -300,3 +300,26 @@ body.handover .scrim { transition: opacity 1400ms var(--ease); opacity: 0; }
body.handover #sky { transition: opacity 1400ms var(--ease); opacity: 0; }
body.handover .grain { transition: opacity 1400ms var(--ease); opacity: 0; }
body.handover .rail { transition: opacity 900ms var(--ease), transform 1200ms var(--ease); opacity: 0; transform: translateY(-12px); }
/* ---------------------------------------------------------------------------
The loading screen is the only screen with no paper on it, so the three
voices have to be restated in their light-on-dark values.
--------------------------------------------------------------------------- */
.eyebrow { color: rgba(233,226,204,.42); }
.data { color: rgba(233,226,204,.60); }
body { color: var(--stock-hi); }
/* The posted notice is a real notice: a slip of the same stock the rest of the
product is printed on, taped to the window of a city that is still loading. */
.notice {
background: var(--stock);
color: var(--ink);
padding: 15px 17px 16px;
box-shadow: var(--lift-1);
border-left: 3px solid var(--canary);
}
.notice-head { border-bottom: 1px solid var(--rule); padding-bottom: 7px; margin-bottom: 9px; }
.notice-index { color: var(--ink-3); }
.notice-title { color: var(--ink); }
.notice-body { color: var(--ink-2); }
+182
View File
@@ -0,0 +1,182 @@
/* ---------------------------------------------------------------------------
Los Santos RP - design tokens.
The interface is a set of documents from the city's Office of Vital Records,
lying on a dark desk while the city runs on behind them. Paper is the only
surface; the game is the room the desk is in. Nothing here is a dark glass
panel, because a city clerk does not hand you one.
Three typographic voices, and the rule between them is literal:
preprinted Archivo, expanded, caps what the form was printed with
typed IBM Plex Mono what somebody entered on it
prose IBM Plex Sans plain-English notes in the margin
Shared verbatim by rp_loading and rp_ui so every screen reads as one product.
--------------------------------------------------------------------------- */
@font-face {
font-family: 'Archivo';
src: url('fonts/archivo-var.woff2') format('woff2-variations');
font-weight: 100 900;
font-stretch: 62% 125%;
font-display: block;
}
@font-face {
font-family: 'Plex Sans';
src: url('fonts/plexsans-var.woff2') format('woff2-variations');
font-weight: 100 700;
font-display: block;
}
@font-face {
font-family: 'Plex Mono';
src: url('fonts/plex-400.woff2') format('woff2');
font-weight: 400;
font-display: block;
}
@font-face {
font-family: 'Plex Mono';
src: url('fonts/plex-500.woff2') format('woff2');
font-weight: 500;
font-display: block;
}
:root {
/* --- the room ---------------------------------------------------------- */
--room: #0b0c0a; /* the dark the desk stands in, never pure black */
--desk: #1a1b16; /* desk surface where the lamp reaches it */
--lamp: rgba(224, 196, 128, 0.10);
/* --- the paper --------------------------------------------------------- */
--stock: #e3dbc2; /* manila card stock: warm, but grey-olive, not cream */
--stock-hi: #efe9d7; /* the top sheet, directly under the lamp */
--stock-2: #cbc09f; /* the sheet underneath, and every tab edge */
--stock-3: #b3a888; /* deepest fold */
/* --- what is written on it --------------------------------------------- */
--ink: #191c18; /* ballpoint black with a green cast */
--ink-2: #4e5449; /* second-rank text */
--ink-3: #7d8175; /* captions, disabled, ruled lines */
/* --- the three official inks, each with exactly one job ---------------- */
--canary: #d9b23c; /* municipal form yellow: what is selected, now */
--canary-dp: #a8801d;
--stamp: #7e2b26; /* oxblood rubber stamp: filed, refused, destroyed */
--verdi: #2e6155; /* municipal teal: checked, valid, approved */
/* rules printed on the form */
--rule: rgba(25, 28, 24, 0.22);
--rule-soft: rgba(25, 28, 24, 0.11);
--rule-firm: rgba(25, 28, 24, 0.55);
/* paper fibre, laid over every sheet at low opacity */
--fiber: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='f'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23f)'/%3E%3C/svg%3E");
/* uneven rubber-stamp ink: large soft blobs, not fine noise */
--inkmask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='200'%3E%3Cfilter id='r'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.028' numOctaves='4' seed='11'/%3E%3CfeColorMatrix type='matrix' values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 2.4 0 0 0 -0.32'/%3E%3C/filter%3E%3Crect width='400' height='200' filter='url(%23r)'/%3E%3C/svg%3E");
/* a sheet of paper casts a real shadow onto the desk */
--lift-1: 0 1px 0 rgba(255,255,255,.28) inset, 0 10px 22px -8px rgba(0,0,0,.7);
--lift-2: 0 1px 0 rgba(255,255,255,.34) inset, 0 26px 48px -18px rgba(0,0,0,.82);
--sheet: clamp(360px, 27vw, 460px);
--sheet-w: clamp(440px, 34vw, 580px);
--gut-x: clamp(26px, 2.4vw, 40px);
--gut-y: clamp(22px, 2.2vw, 34px);
--ease: cubic-bezier(0.22, 0.61, 0.36, 1);
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%;
overflow: hidden;
background: var(--room);
color: var(--ink);
font-family: 'Plex Sans', system-ui, sans-serif;
font-size: 15px;
line-height: 1.55;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
/* ---------------------------------------------------------------------------
The three voices.
--------------------------------------------------------------------------- */
/* preprinted: everything the form arrived with */
.pp,
.eyebrow,
.head,
label,
.btn,
.tab,
.ctrl-label,
.charname,
.spawnname,
.stagechip span {
font-family: 'Archivo', system-ui, sans-serif;
font-variation-settings: 'wdth' 112, 'wght' 700;
text-transform: uppercase;
letter-spacing: 0.07em;
}
/* typed: everything a person put on the form */
.data,
.typed,
input,
textarea,
.ctrl-val,
.stepval,
.charmeta,
.charmoney,
.counter,
.stagechip em,
.filetag {
font-family: 'Plex Mono', ui-monospace, monospace;
font-variation-settings: normal;
text-transform: none;
letter-spacing: 0.01em;
font-variant-numeric: tabular-nums;
}
/* prose: the plain-English notes, the only voice allowed sentence case */
.sub,
.hint,
.spawnblurb,
.switch {
font-family: 'Plex Sans', system-ui, sans-serif;
text-transform: none;
letter-spacing: 0;
}
.eyebrow {
font-size: 10px;
font-variation-settings: 'wdth' 104, 'wght' 600;
letter-spacing: 0.19em;
color: var(--ink-3);
}
.data {
font-size: 11.5px;
color: var(--ink-2);
}
.rule { height: 1px; background: var(--rule); border: 0; }
/* Focus is always the canary, always visible, and never subtle. */
:focus-visible {
outline: 2px solid var(--canary-dp);
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
}
}
@@ -26,7 +26,8 @@ files {
'html/tokens.css',
'html/app.css',
'html/app.js',
'html/fonts/archivo.woff2',
'html/fonts/archivo-var.woff2',
'html/fonts/plexsans-var.woff2',
'html/fonts/plex-400.woff2',
'html/fonts/plex-500.woff2',
}
+820
View File
@@ -0,0 +1,820 @@
/* ---------------------------------------------------------------------------
Los Santos RP - the pre-spawn screens.
Every screen is a sheet of paper from the Office of Vital Records, lying on a
dark desk with the city running behind it. The body stays transparent: the
game is the room, and the paper is the only thing we draw.
--------------------------------------------------------------------------- */
body {
background: transparent;
transition: opacity 420ms var(--ease);
}
body.hidden { opacity: 0; pointer-events: none; }
/* --- the room -------------------------------------------------------------
A lamp over the desk on the left, and the dark closing in everywhere else.
This is what makes an opaque sheet of paper look lit rather than pasted on. */
.edge {
position: fixed; inset: 0; z-index: 0; pointer-events: none;
background:
radial-gradient(58% 74% at 22% 46%, var(--lamp) 0%, transparent 62%),
radial-gradient(120% 96% at 26% 50%, transparent 38%, rgba(0,0,0,.58) 100%);
}
.grain {
position: fixed; inset: 0; z-index: 60; pointer-events: none;
opacity: .10; mix-blend-mode: overlay;
background-image: var(--fiber);
background-size: 160px 160px;
}
/* =========================================================================
persistent chrome: the edge of the desk
========================================================================= */
.topbar {
position: fixed; top: 0; left: 0; right: 0; z-index: 40;
display: flex; align-items: center; justify-content: space-between;
gap: 26px;
padding: 20px var(--gut-x) 26px;
background: linear-gradient(to bottom, rgba(6,7,5,.80), rgba(6,7,5,0));
pointer-events: none;
}
.mark { display: flex; align-items: center; gap: 12px; }
.seal { width: 32px; height: 32px; flex: none; opacity: .92; }
.seal-ring { fill: none; stroke: var(--stock-2); stroke-width: 3; }
.seal-ring.thin { stroke-width: 1.4; opacity: .6; }
.seal-ticks {
fill: none; stroke: var(--stock-2); stroke-width: 5; opacity: .5;
stroke-dasharray: 2 9;
}
.seal-star { fill: var(--canary); }
.mark-lines { display: flex; flex-direction: column; line-height: 1.1; }
.mark-name {
font-family: 'Archivo', sans-serif;
font-variation-settings: 'wdth' 118, 'wght' 800;
font-size: 15px; letter-spacing: .04em; text-transform: uppercase;
color: var(--stock-hi);
}
.mark-tag {
font-family: 'Plex Mono', monospace;
font-size: 9px; letter-spacing: .3em; text-transform: uppercase;
color: var(--canary);
}
/* the routing box: three stations, in the order the file moves through them */
.stages { display: flex; align-items: center; gap: 10px; }
.stagechip {
display: inline-flex; align-items: center; gap: 8px;
background: none; border: 0; padding: 0; cursor: default;
}
.stagechip em {
font-style: normal; font-size: 9px; line-height: 1;
width: 21px; height: 19px; display: grid; place-items: center;
border: 1px solid rgba(222,216,198,.26);
color: rgba(222,216,198,.45);
transition: all 320ms var(--ease);
}
.stagechip span {
font-size: 9.5px; letter-spacing: .17em;
color: rgba(222,216,198,.42);
transition: color 320ms var(--ease);
}
.stagechip.on em {
background: var(--canary); border-color: var(--canary); color: var(--ink);
}
.stagechip.on span { color: var(--stock-hi); }
.stagechip.done em { color: transparent; border-color: rgba(222,216,198,.4); position: relative; }
.stagechip.done em::after {
content: '\00D7';
position: absolute; inset: 0; display: grid; place-items: center;
font-size: 13px; color: var(--canary);
}
.stagelink { width: 26px; height: 1px; background: rgba(222,216,198,.2); }
.filetag { display: flex; align-items: baseline; gap: 8px; font-size: 11px; color: rgba(222,216,198,.55); }
.filetag-k {
font-family: 'Archivo', sans-serif;
font-variation-settings: 'wdth' 104, 'wght' 600;
font-size: 9px; letter-spacing: .19em; text-transform: uppercase;
color: rgba(222,216,198,.35);
}
/* =========================================================================
the sheet
========================================================================= */
.screen {
position: fixed; inset: 0; z-index: 10;
display: flex; align-items: center;
padding: 96px var(--gut-x) var(--gut-y);
}
.screen[hidden] { display: none; }
.rail {
position: relative;
width: var(--sheet);
max-height: 100%;
display: flex; flex-direction: column;
background: var(--stock);
box-shadow: var(--lift-2);
z-index: 2;
}
.rail.wide { width: var(--sheet-w); }
/* paper: fibre, a warmer lit edge on the lamp side, and a shaded far edge */
.rail-scrim {
position: absolute; inset: 0; z-index: 0; pointer-events: none;
background-image:
linear-gradient(to right, transparent 70%, rgba(25,28,24,.10) 100%),
var(--fiber);
background-size: auto, auto, 160px 160px;
opacity: 1;
mix-blend-mode: normal;
}
.rail-scrim::after {
content: ''; position: absolute; inset: 0;
background-image: var(--fiber);
background-size: 150px 150px;
opacity: .05;
mix-blend-mode: multiply;
}
.rail::after {
content: ''; position: absolute; inset: 0; z-index: 3; pointer-events: none;
background: radial-gradient(78% 62% at 16% 6%, rgba(255,240,196,.55) 0%, rgba(255,240,196,0) 64%);
mix-blend-mode: soft-light;
}
.rail-inner {
position: relative; z-index: 1;
padding: 22px var(--gut-x) var(--gut-y);
overflow-y: auto; overflow-x: hidden;
scrollbar-width: thin;
scrollbar-color: var(--stock-3) transparent;
}
.rail-inner::-webkit-scrollbar { width: 4px; }
.rail-inner::-webkit-scrollbar-thumb { background: var(--stock-3); }
.rail-inner::-webkit-scrollbar-track { background: transparent; }
/* --- letterhead ----------------------------------------------------------- */
.letterhead {
position: relative; z-index: 1; flex: none;
display: flex; align-items: flex-start; justify-content: space-between; gap: 16px;
padding: 20px var(--gut-x) 14px;
border-bottom: 2.5px solid var(--ink);
}
.letterhead::after {
content: ''; position: absolute; left: var(--gut-x); right: var(--gut-x); bottom: -5px;
height: 1px; background: var(--ink); opacity: .45;
}
.lh-dept { display: flex; flex-direction: column; gap: 2px; }
.lh-city {
font-family: 'Plex Mono', monospace;
font-size: 9.5px; letter-spacing: .22em; text-transform: uppercase;
color: var(--ink-3);
}
.lh-office {
font-family: 'Archivo', sans-serif;
font-variation-settings: 'wdth' 116, 'wght' 800;
font-size: 15px; letter-spacing: .015em; text-transform: uppercase;
color: var(--ink); line-height: 1.15;
}
.lh-form {
font-size: 10px; letter-spacing: .1em; color: var(--ink-2);
border: 1px solid var(--rule); padding: 3px 7px; white-space: nowrap;
}
/* --- headings ------------------------------------------------------------- */
.head {
font-size: clamp(23px, 2.05vw, 30px);
font-variation-settings: 'wdth' 108, 'wght' 800;
letter-spacing: .005em; line-height: 1.06;
color: var(--ink);
margin: 12px 0 10px;
text-wrap: balance;
}
.sub {
font-size: 13.5px; line-height: 1.6; color: var(--ink-2);
max-width: 46ch; margin-bottom: 20px;
}
.kicker { display: flex; align-items: center; gap: 12px; margin: 0 0 12px; }
.hair { flex: 1; height: 1px; background: var(--rule); }
/* =========================================================================
form fields - a printed box with its caption punched through the rule
========================================================================= */
.form { display: block; }
.field { position: relative; margin-top: 24px; }
.field:first-child { margin-top: 8px; }
.field label {
position: absolute; top: -7px; left: 11px; z-index: 2;
font-size: 9px; letter-spacing: .17em;
color: var(--ink-2);
background: var(--stock);
padding: 0 6px;
}
.field input,
.field textarea {
width: 100%; display: block;
border: 1.4px solid var(--rule-firm);
background: rgba(255,255,255,.34);
color: var(--ink);
font-size: 14px; line-height: 1.4;
padding: 14px 13px 11px;
transition: border-color 200ms var(--ease), background 200ms var(--ease);
}
.field textarea { min-height: 108px; resize: none; padding-top: 15px; }
.field input::placeholder,
.field textarea::placeholder { color: var(--ink-3); opacity: .65; }
.field input:focus,
.field textarea:focus {
outline: none;
border-color: var(--ink);
background: rgba(255,255,255,.6);
}
/* the clerk's tick in the margin of the box */
.field::after {
content: ''; position: absolute; top: 15px; right: 13px;
font-size: 14px; line-height: 1; pointer-events: none;
opacity: 0; transition: opacity 180ms var(--ease);
}
.field.good::after {
content: ''; opacity: 1;
width: 6px; height: 11px; top: 15px; right: 16px;
border: 2px solid var(--verdi); border-top: 0; border-left: 0;
transform: rotate(42deg);
}
.field.bad::after { content: '\00D7'; color: var(--stamp); opacity: 1; }
.field.good input { border-color: var(--verdi); }
.field.bad input { border-color: var(--stamp); }
.field.good .hint { color: var(--verdi); }
.field.bad .hint { color: var(--stamp); }
.hint {
font-size: 11.5px; line-height: 1.45; color: var(--ink-3);
margin-top: 7px;
}
.counter {
font-size: 10.5px; color: var(--ink-3); margin-top: 7px; text-align: right;
}
.withbtn { position: relative; }
.withbtn input { padding-right: 68px; }
.reveal-btn {
position: absolute; right: 12px; top: 50%; transform: translateY(-50%);
background: none; border: 0; cursor: pointer;
font-family: 'Archivo', sans-serif;
font-variation-settings: 'wdth' 104, 'wght' 600;
font-size: 9.5px; letter-spacing: .15em; text-transform: uppercase;
color: var(--ink-2);
border-bottom: 1px solid var(--rule-firm);
padding-bottom: 1px;
}
.reveal-btn:hover { color: var(--ink); border-bottom-color: var(--ink); }
.collapse { display: grid; grid-template-rows: 0fr; transition: grid-template-rows 380ms var(--ease); }
.collapse.open { grid-template-rows: 1fr; }
.collapse-inner { overflow: hidden; min-height: 0; }
.formerror {
margin-top: 16px;
font-family: 'Plex Sans', sans-serif;
font-size: 12.5px; line-height: 1.45;
color: var(--stamp);
border-left: 3px solid var(--stamp);
background: rgba(126,43,38,.07);
padding: 9px 12px;
animation: nudge 380ms var(--ease);
}
/* =========================================================================
buttons
========================================================================= */
.btn {
position: relative; overflow: hidden;
display: inline-flex; align-items: center; justify-content: center; gap: 10px;
font-size: 11px; letter-spacing: .15em;
padding: 15px 24px;
border: 1.4px solid var(--ink);
background: none; color: var(--ink);
cursor: pointer;
transition: color 260ms var(--ease), border-color 260ms var(--ease), opacity 200ms var(--ease);
}
.btn::before {
content: ''; position: absolute; inset: 0; z-index: 0;
background: var(--canary);
transform: scaleX(0); transform-origin: left center;
transition: transform 340ms var(--ease-out);
}
.btn:hover:not(:disabled)::before { transform: scaleX(1); }
.btn-label, .btn-spin { position: relative; z-index: 1; }
.btn.primary { background: var(--ink); color: var(--stock-hi); width: 100%; margin-top: 22px; }
.btn.primary:hover:not(:disabled) { color: var(--ink); }
.btn.ghost { background: rgba(255,255,255,.2); }
.btn.ghost:hover:not(:disabled) { color: var(--ink); }
.btn.danger { border-color: var(--stamp); color: var(--stamp); }
.btn.danger::before { background: var(--stamp); }
.btn.danger:hover:not(:disabled) { color: var(--stock-hi); }
.btn:disabled { opacity: .32; cursor: default; }
.btn-spin { display: none; width: 13px; height: 13px; }
.btn.busy .btn-label { opacity: .35; }
.btn.busy .btn-spin {
display: block;
border: 1.6px solid currentColor; border-top-color: transparent;
border-radius: 50%;
animation: spin 720ms linear infinite;
}
.switch { margin-top: 18px; font-size: 12.5px; color: var(--ink-2); }
.linkbtn {
background: none; border: 0; cursor: pointer; padding: 0;
font-family: inherit; font-size: inherit; color: var(--ink);
border-bottom: 1px solid var(--rule-firm);
}
.linkbtn:hover { border-bottom-color: var(--ink); }
.linkbtn.danger {
font-family: 'Archivo', sans-serif;
font-variation-settings: 'wdth' 104, 'wght' 600;
font-size: 9.5px; letter-spacing: .16em; text-transform: uppercase;
color: var(--stamp); border-bottom-color: rgba(126,43,38,.4);
}
.linkbtn.danger:hover { border-bottom-color: var(--stamp); }
/* =========================================================================
02 / the register of persons - ruled index cards in a drawer
========================================================================= */
.charlist { list-style: none; }
.charcard {
position: relative;
padding: 15px 18px 14px 52px;
margin-bottom: 9px;
background: var(--stock-hi);
border: 1px solid var(--rule);
cursor: pointer;
display: flex; align-items: center; justify-content: space-between; gap: 14px;
transition: transform 200ms var(--ease), box-shadow 200ms var(--ease), background 200ms var(--ease);
/* the faint ruling of a real index card */
background-image: repeating-linear-gradient(
to bottom, transparent 0 22px, rgba(46,97,85,.075) 22px 23px);
}
.charcard:hover { transform: translateY(-1px); box-shadow: 0 6px 14px -8px rgba(0,0,0,.55); }
.charslot {
position: absolute; left: 0; top: 0; bottom: 0; width: 36px;
display: grid; place-items: center;
background: var(--stock-2);
border-right: 1px solid var(--rule);
font-size: 10.5px; color: var(--ink-2);
transition: background 200ms var(--ease), color 200ms var(--ease);
}
.charname {
display: block; font-size: 14.5px; letter-spacing: .045em;
color: var(--ink); line-height: 1.2;
}
.charmeta { display: block; font-size: 10.5px; color: var(--ink-3); margin-top: 4px; }
.charmoney { font-size: 13px; color: var(--ink); white-space: nowrap; }
.charcard.on {
background-color: #f4f0e4;
border-color: var(--ink);
box-shadow: inset 4px 0 0 var(--canary), 0 8px 18px -8px rgba(0,0,0,.6);
}
.charcard.on .charslot { background: var(--canary); color: var(--ink); }
.charcard.empty {
background-color: transparent; background-image: none;
border: 1.4px dashed var(--rule-firm);
color: var(--ink-2);
justify-content: flex-start;
}
.charcard.empty .charslot { background: transparent; border-right-color: var(--rule-soft); font-size: 15px; }
.charcard.empty .charname { font-size: 11px; color: var(--ink-2); }
.charcard.empty:hover { background-color: rgba(255,255,255,.24); }
.charactions { display: flex; gap: 10px; align-items: stretch; margin-top: 20px; }
.charactions .btn.primary { margin-top: 0; }
.charactions .btn.ghost { width: auto; flex: none; margin-top: 0; }
/* =========================================================================
03 / application for identity
========================================================================= */
.tabs {
position: relative; z-index: 1; flex: none;
display: flex; gap: 2px;
padding: 14px var(--gut-x) 0;
border-bottom: 1.4px solid var(--rule-firm);
}
.tab {
padding: 8px 11px 7px;
font-size: 9.5px; letter-spacing: .12em;
background: var(--stock-2);
border: 1px solid var(--rule); border-bottom: 0;
color: var(--ink-2); cursor: pointer;
margin-bottom: -1.4px;
transition: background 200ms var(--ease), color 200ms var(--ease);
}
.tab:hover { background: var(--stock); color: var(--ink); }
.tab.is-on {
background: var(--stock-hi); color: var(--ink);
border-bottom: 1.4px solid var(--stock-hi);
}
.rail-inner.creator { padding-top: 20px; }
.pane { display: none; }
.pane.is-on {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18px 20px;
align-content: start;
}
.pane .sub { grid-column: 1 / -1; margin-bottom: 0; }
.pane .field { grid-column: 1 / -1; }
.ctrl { min-width: 0; }
.ctrl.span2 { grid-column: 1 / -1; }
.ctrl-head {
display: flex; align-items: baseline; justify-content: space-between; gap: 10px;
margin-bottom: 7px;
}
.ctrl-label { font-size: 9.5px; letter-spacing: .14em; color: var(--ink-2); }
.ctrl-val { font-size: 10.5px; color: var(--ink-3); white-space: nowrap; }
/* a measuring scale with a surveyor's marker riding on it */
.slider {
-webkit-appearance: none; appearance: none;
width: 100%; height: 24px; background: transparent; cursor: pointer;
}
.slider::-webkit-slider-runnable-track {
height: 24px;
background-image:
linear-gradient(var(--rule-firm), var(--rule-firm)),
repeating-linear-gradient(90deg, var(--rule) 0 1px, transparent 1px 10%);
background-size: 100% 1.4px, 100% 7px;
background-position: 0 12px, 0 13px;
background-repeat: no-repeat, repeat-x;
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none; appearance: none;
width: 11px; height: 18px; margin-top: 3px;
background: var(--ink); border: 0;
clip-path: polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%);
transition: background 160ms var(--ease);
}
.slider:hover::-webkit-slider-thumb { background: var(--canary-dp); }
.stepper {
display: grid; grid-template-columns: 32px 1fr 32px;
border: 1.4px solid var(--rule-firm);
background: rgba(255,255,255,.34);
}
.stepper button {
border: 0; background: none; height: 32px; cursor: pointer;
color: var(--ink-2); font-size: 14px; line-height: 1;
transition: background 160ms var(--ease), color 160ms var(--ease);
}
.stepper button:hover { background: var(--ink); color: var(--stock-hi); }
.stepval {
display: grid; place-items: center;
font-size: 11.5px; color: var(--ink);
border-left: 1px solid var(--rule); border-right: 1px solid var(--rule);
overflow: hidden; white-space: nowrap;
}
/* a printed colour chart: a fixed grid, so it can never wrap into orphans */
.swatches {
display: grid; grid-template-columns: repeat(16, 1fr);
grid-auto-rows: 17px;
gap: 1px; padding: 1px;
background: var(--rule-firm);
border: 1px solid var(--rule-firm);
}
.swatch {
border: 0; padding: 0; cursor: pointer;
position: relative; min-width: 0;
transition: transform 140ms var(--ease);
}
.swatch:hover { transform: scale(1.18); z-index: 3; box-shadow: 0 0 0 1px var(--ink); }
.swatch.on {
z-index: 4;
box-shadow: 0 0 0 2px var(--ink), 0 0 0 4px var(--canary);
}
/* the sex field, ticked the way a form is ticked */
.seg { display: flex; gap: 24px; grid-column: 1 / -1; margin-bottom: 2px; }
.seg button {
display: inline-flex; align-items: center; gap: 9px;
background: none; border: 0; cursor: pointer;
font-family: 'Archivo', sans-serif;
font-variation-settings: 'wdth' 108, 'wght' 700;
font-size: 10.5px; letter-spacing: .14em; text-transform: uppercase;
color: var(--ink-2);
}
.seg button::before {
content: ''; width: 15px; height: 15px; flex: none;
display: grid; place-items: center;
border: 1.4px solid var(--rule-firm);
background: rgba(255,255,255,.4);
font-size: 13px; line-height: 1; color: var(--stamp);
}
.seg button.on { color: var(--ink); }
.seg button.on::before { content: '\00D7'; border-color: var(--ink); }
.creator-foot {
position: relative; z-index: 1; flex: none;
display: flex; gap: 10px;
padding: 14px var(--gut-x) 18px;
border-top: 1px solid var(--rule);
}
.creator-foot .btn { margin-top: 0; }
.creator-foot .btn.ghost { flex: none; width: auto; }
.creator-foot .btn.primary { flex: 1; }
#screen-creator .formerror { margin: 0 var(--gut-x); }
/* --- the photograph plate ------------------------------------------------
The subject stands inside this frame, and the frame is also the drag
surface, so the thing you aim at is the thing that turns. It is drawn wide
on purpose: the ped is framed generously rather than pinned to a cut-out. */
.turntable {
position: absolute; left: 36%; right: 16%; top: 16%; bottom: 17%;
z-index: 5; cursor: grab;
}
.turntable.dragging { cursor: grabbing; }
.plate-corner {
position: absolute; width: 30px; height: 30px;
border: 2px solid rgba(233,226,204,.72);
transition: border-color 260ms var(--ease);
}
.plate-corner.tl { left: 0; top: 0; border-right: 0; border-bottom: 0; }
.plate-corner.tr { right: 0; top: 0; border-left: 0; border-bottom: 0; }
.plate-corner.bl { left: 0; bottom: 0; border-right: 0; border-top: 0; }
.plate-corner.br { right: 0; bottom: 0; border-left: 0; border-top: 0; }
.turntable.dragging .plate-corner { border-color: var(--canary); }
.plate-cap {
position: absolute; left: 0; right: 0; bottom: -28px;
display: flex; align-items: center; justify-content: space-between; gap: 14px;
}
.plate-cap-k {
font-family: 'Archivo', sans-serif;
font-variation-settings: 'wdth' 104, 'wght' 600;
font-size: 9px; letter-spacing: .22em; text-transform: uppercase;
color: rgba(222,216,198,.5);
}
.turnhint {
display: inline-flex; align-items: center; gap: 8px;
font-family: 'Plex Mono', monospace;
font-size: 10px; letter-spacing: .1em;
color: rgba(222,216,198,.45);
transition: opacity 200ms var(--ease);
}
.turntable.dragging .turnhint { opacity: 0; }
.turnicon {
width: 14px; height: 14px; display: block; flex: none;
border: 1px solid currentColor; border-radius: 50%;
border-right-color: transparent;
transform: rotate(-30deg);
}
/* =========================================================================
04 / notice of placement
========================================================================= */
.spawnlist { list-style: none; border-top: 1px solid var(--rule); }
.spawncard {
position: relative;
padding: 13px 14px 13px 44px;
border-bottom: 1px solid var(--rule);
cursor: pointer;
transition: background 180ms var(--ease);
}
.spawncard:hover { background: rgba(255,255,255,.28); }
.spawncard::before {
content: ''; position: absolute; left: 14px; top: 15px;
width: 15px; height: 15px;
display: grid; place-items: center;
border: 1.4px solid var(--rule-firm);
background: rgba(255,255,255,.45);
font-family: 'Plex Sans', sans-serif;
font-size: 13px; line-height: 1; color: var(--stamp);
}
.spawncard.on::before { content: '\00D7'; border-color: var(--ink); }
.spawncard.on { background: rgba(217,178,60,.13); }
.spawnhead { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
.spawnname { font-size: 12.5px; letter-spacing: .07em; color: var(--ink); }
.spawnarea {
font-family: 'Plex Mono', monospace;
font-size: 9.5px; letter-spacing: .11em; text-transform: uppercase;
color: var(--ink-3); white-space: nowrap;
}
.spawnblurb { font-size: 12px; line-height: 1.5; color: var(--ink-2); margin-top: 4px; }
/* --- the plan sheet ------------------------------------------------------- */
.chart {
position: absolute; right: 6%; top: 50%;
transform: translateY(-50%) rotate(.5deg);
width: min(33vw, 58vh); aspect-ratio: 1 / 1;
z-index: 1;
background: var(--stock);
box-shadow: var(--lift-2);
padding: 3%;
}
.chart-paper {
position: absolute; inset: 0; pointer-events: none;
background-image:
radial-gradient(80% 66% at 14% 8%, rgba(255,244,206,.26) 0%, rgba(255,244,206,0) 62%),
var(--fiber);
background-size: auto, 150px 150px;
opacity: .9;
}
.chart svg { position: relative; width: 100%; height: 100%; display: block; }
.chart-frame { fill: none; stroke: var(--rule); stroke-width: 1; }
.chart-ticks { fill: none; stroke: var(--ink); stroke-width: 2.5; opacity: .55; }
#landmass { fill: rgba(46,97,85,.07); stroke: var(--ink-2); stroke-width: 1.4; }
#coastline { fill: none; stroke: var(--ink); stroke-width: 2.6; }
.pins { position: absolute; inset: 3%; pointer-events: none; }
.pin {
position: absolute; transform: translate(-50%, -50%);
display: flex; align-items: center; gap: 7px;
}
.pin.flip { flex-direction: row-reverse; }
.pin-mark {
width: 9px; height: 9px; flex: none;
background: var(--stock);
border: 1.6px solid var(--ink);
transform: rotate(45deg);
transition: all 220ms var(--ease);
}
.pin-label {
font-family: 'Plex Mono', monospace;
font-size: 8.5px; letter-spacing: .12em; text-transform: uppercase;
color: var(--ink-2); white-space: nowrap;
}
.pin.on .pin-mark {
background: var(--canary); border-color: var(--ink);
width: 13px; height: 13px;
box-shadow: 0 0 0 3px rgba(217,178,60,.3);
}
.pin.on .pin-label { color: var(--ink); }
.titleblock {
position: absolute; right: 3%; bottom: 3%;
border: 1.4px solid var(--ink);
background: var(--stock-hi);
min-width: 44%;
}
.tb-row { display: flex; }
.tb-row + .tb-row { border-top: 1px solid var(--rule); }
.tb-k, .tb-v { padding: 4px 7px; font-size: 8px; line-height: 1.3; }
.tb-k {
font-family: 'Archivo', sans-serif;
font-variation-settings: 'wdth' 100, 'wght' 600;
letter-spacing: .14em; text-transform: uppercase;
color: var(--ink-3);
border-right: 1px solid var(--rule);
flex: none;
}
.tb-v {
font-family: 'Plex Mono', monospace;
color: var(--ink); flex: 1;
letter-spacing: .04em;
}
.tb-row + .tb-row .tb-k { border-left: 1px solid var(--rule); }
.tb-row + .tb-row .tb-k:first-child { border-left: 0; }
.tb-main .tb-v {
font-family: 'Archivo', sans-serif;
font-variation-settings: 'wdth' 112, 'wght' 700;
font-size: 10px; letter-spacing: .06em; text-transform: uppercase;
}
/* =========================================================================
the stamp - the one bold moment, and the only thing that moves loudly
========================================================================= */
.stamp {
position: fixed; left: 17%; top: 53%; z-index: 50;
transform: translate(-50%, -50%) rotate(-8deg) scale(1);
display: none;
flex-direction: column; align-items: center; gap: 3px;
padding: 13px 30px 11px;
border: 4px solid var(--stamp);
color: var(--stamp);
pointer-events: none;
/* real rubber never inks evenly */
-webkit-mask-image: var(--inkmask);
mask-image: var(--inkmask);
-webkit-mask-size: 100% 100%;
mask-size: 100% 100%;
}
.stamp::before {
content: ''; position: absolute; inset: 4px;
border: 1.4px solid var(--stamp);
}
.stamp-word {
font-family: 'Archivo', sans-serif;
font-variation-settings: 'wdth' 118, 'wght' 800;
font-size: 37px; letter-spacing: .1em; text-transform: uppercase;
line-height: 1;
}
.stamp-sub {
font-family: 'Plex Mono', monospace;
font-size: 8.5px; letter-spacing: .2em; text-transform: uppercase;
opacity: 1;
}
.stamp.show { display: flex; animation: stampdown 1700ms var(--ease-out) forwards; }
@keyframes stampdown {
0% { opacity: 0; transform: translate(-50%,-50%) rotate(-8deg) scale(2.3); filter: blur(4px); }
22% { opacity: 1; transform: translate(-50%,-50%) rotate(-8deg) scale(.93); filter: blur(0); }
32% { transform: translate(-50%,-50%) rotate(-8deg) scale(1.03); }
42% { transform: translate(-50%,-50%) rotate(-8deg) scale(1); }
78% { opacity: 1; }
100% { opacity: 0; transform: translate(-50%,-50%) rotate(-8deg) scale(1); }
}
/* the desk takes the hit when the stamp lands */
body.struck .rail, body.struck .chart { animation: jolt 260ms var(--ease); }
@keyframes jolt {
0% { transform: translate(0,0); }
18% { transform: translate(-1px, 2px); }
46% { transform: translate(1px, -1px); }
100% { transform: translate(0,0); }
}
/* the plan sheet keeps its rotation while it shakes */
body.struck .chart { animation-name: joltchart; }
@keyframes joltchart {
0% { transform: translateY(-50%) rotate(.5deg); }
18% { transform: translate(-1px, calc(-50% + 2px)) rotate(.5deg); }
46% { transform: translate(1px, calc(-50% - 1px)) rotate(.5deg); }
100% { transform: translateY(-50%) rotate(.5deg); }
}
/* =========================================================================
entrances - one orchestrated move per screen, not seven scattered ones
========================================================================= */
.screen.is-on .rail { animation: sheetdown 520ms var(--ease-out) both; }
.screen.is-on .chart { animation: chartin 620ms var(--ease-out) 120ms both; }
.screen.is-on .turntable { animation: platein 700ms var(--ease-out) 180ms both; }
.screen.is-on .rail-inner,
.screen.is-on .creator-foot { animation: settle 520ms var(--ease-out) 160ms both; }
@keyframes sheetdown {
from { opacity: 0; transform: translateY(-14px) scale(.995); }
to { opacity: 1; transform: none; }
}
@keyframes settle {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: none; }
}
@keyframes chartin {
from { opacity: 0; transform: translateY(-50%) rotate(.5deg) scale(.97); }
to { opacity: 1; transform: translateY(-50%) rotate(.5deg) scale(1); }
}
@keyframes platein {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes nudge {
0% { transform: translateX(0); }
22% { transform: translateX(-5px); }
44% { transform: translateX(4px); }
68% { transform: translateX(-2px); }
100% { transform: translateX(0); }
}
/* Small screens: the sheet takes the width, the plate and plan sheet stand down. */
@media (max-width: 1100px) {
.rail, .rail.wide { width: min(92vw, 560px); }
.chart { display: none; }
.turntable { left: auto; right: 4%; width: 34%; }
}
@@ -72,12 +72,18 @@ function paintChips(activeKey) {
});
}
/* The stamp is the only loud thing in the interface, so it only ever marks a
real change of state: the file opened, the application approved, the
placement issued. The paper on the desk takes the hit with it. */
function stamp(text) {
const el = $('stamp');
$('stampText').textContent = text;
el.classList.remove('show');
document.body.classList.remove('struck');
void el.offsetWidth;
el.classList.add('show');
setTimeout(() => document.body.classList.add('struck'), 170);
setTimeout(() => document.body.classList.remove('struck'), 600);
}
function busy(btn, on) {
@@ -491,7 +497,8 @@ const Creator = {
swatches(label, palette, value, onPick) {
const wrap = document.createElement('div');
wrap.className = 'ctrl';
/* A colour chart is 16 columns wide, so it always takes the full form. */
wrap.className = 'ctrl span2';
wrap.innerHTML = `
<div class="ctrl-head"><span class="ctrl-label"></span><span class="ctrl-val"></span></div>
<div class="swatches"></div>`;
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -14,10 +14,21 @@
<!-- ======================= persistent chrome ======================= -->
<header class="topbar">
<div class="mark">
<svg class="seal" viewBox="0 0 100 100" aria-hidden="true">
<circle cx="50" cy="50" r="46" class="seal-ring"/>
<circle cx="50" cy="50" r="37" class="seal-ring thin"/>
<circle cx="50" cy="50" r="41.5" class="seal-ticks"/>
<path class="seal-star" d="M50 30 L54.7 43.8 L69.4 44 L57.6 52.7 L62 66.6
L50 58.2 L38 66.6 L42.4 52.7 L30.6 44 L45.3 43.8 Z"/>
</svg>
<span class="mark-lines">
<span class="mark-name" id="markName">LOS SANTOS</span>
<span class="mark-tag" id="markTag">ROLEPLAY</span>
</span>
</div>
<!-- A real sequence: you cannot be placed before you have an identity, and you
cannot have an identity before the file exists. Printed as a routing box. -->
<nav class="stages" aria-label="Application progress">
<button class="stagechip" data-stage="auth" type="button" disabled>
<em>01</em><span>Credentials</span>
@@ -32,22 +43,26 @@
</button>
</nav>
<div class="filetag data">
FILE <span id="fileSerial">0000-000000</span>
<div class="filetag">
<span class="filetag-k">File</span>
<span id="fileSerial">0000-000000</span>
</div>
</header>
<!-- ======================= 02 / auth ======================= -->
<!-- ======================= 01 / auth ======================= -->
<section class="screen" id="screen-auth" hidden>
<div class="rail">
<div class="rail-scrim" aria-hidden="true"></div>
<div class="rail-inner">
<div class="kicker">
<span class="eyebrow">Office of Records</span>
<span class="hair" aria-hidden="true"></span>
<div class="letterhead">
<div class="lh-dept">
<span class="lh-city">City of Los Santos</span>
<span class="lh-office">Office of Vital Records</span>
</div>
<div class="lh-form data">VR&#8209;1</div>
</div>
<div class="rail-inner">
<h1 class="head" id="authTitle">Open a file</h1>
<p class="sub" id="authSub">
Everything you do in this city is recorded against this file. Choose a name you will
@@ -58,7 +73,7 @@
<div class="field" data-field="username">
<label for="username">Username</label>
<input id="username" name="username" type="text" autocomplete="username"
spellcheck="false" maxlength="20" placeholder="e.g. dlarue">
spellcheck="false" maxlength="20" placeholder="dlarue">
<p class="hint" id="hint-username">3&ndash;20 characters. Letters, numbers and underscore.</p>
</div>
@@ -66,7 +81,7 @@
<label for="password">Password</label>
<div class="withbtn">
<input id="password" name="password" type="password" autocomplete="current-password"
maxlength="72" placeholder="At least 8 characters">
maxlength="72" placeholder="&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;">
<button class="reveal-btn" id="revealPw" type="button" aria-label="Show password">Show</button>
</div>
<p class="hint" id="hint-password">At least 8 characters, with a letter and a number.</p>
@@ -78,7 +93,7 @@
<div class="field" data-field="confirm">
<label for="confirm">Repeat password</label>
<input id="confirm" name="confirm" type="password" autocomplete="new-password"
maxlength="72" placeholder="Type it once more">
maxlength="72" placeholder="&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;">
<p class="hint" id="hint-confirm">Both entries must match.</p>
</div>
</div>
@@ -96,16 +111,25 @@
<span id="switchText">Already have a file?</span>
<button class="linkbtn" id="switchMode" type="button">Sign in instead</button>
</p>
</div>
</div>
</section>
<!-- ======================= 03 / characters ======================= -->
<!-- ======================= 02 / characters ======================= -->
<section class="screen" id="screen-characters" hidden>
<div class="rail wide">
<div class="rail-scrim" aria-hidden="true"></div>
<div class="letterhead">
<div class="lh-dept">
<span class="lh-city">City of Los Santos</span>
<span class="lh-office">Register of Persons</span>
</div>
<div class="lh-form data">VR&#8209;4</div>
</div>
<div class="rail-inner">
<h1 class="head">Who are you today?</h1>
<div class="kicker">
<span class="eyebrow">Registered identities</span>
@@ -113,33 +137,32 @@
<span class="data" id="charCount">0 / 3</span>
</div>
<h1 class="head">Who are you today?</h1>
<ul class="charlist" id="charList"></ul>
<p class="formerror" id="charError" role="alert" aria-live="assertive" hidden></p>
<div class="charactions">
<button class="btn primary" id="charEnter" type="button" disabled>
<span class="btn-label">Enter the city</span>
<span class="btn-spin" aria-hidden="true"></span>
</button>
<button class="btn ghost" id="charDelete" type="button" disabled>Delete</button>
<button class="btn ghost danger" id="charDelete" type="button" disabled>Delete</button>
</div>
<p class="formerror" id="charError" role="alert" aria-live="assertive" hidden></p>
</div>
</div>
</section>
<!-- ======================= 04 / creator ======================= -->
<!-- ======================= 03 / creator ======================= -->
<section class="screen" id="screen-creator" hidden>
<div class="rail wide">
<div class="rail-scrim" aria-hidden="true"></div>
<div class="rail-inner creator">
<div class="kicker">
<span class="eyebrow">New identity</span>
<span class="hair" aria-hidden="true"></span>
<button class="linkbtn" id="creatorCancel" type="button">Discard</button>
<div class="letterhead">
<div class="lh-dept">
<span class="lh-city">City of Los Santos</span>
<span class="lh-office">Application for Identity</span>
</div>
<button class="linkbtn danger" id="creatorCancel" type="button">Discard</button>
</div>
<nav class="tabs" id="creatorTabs" aria-label="Editor sections">
@@ -151,6 +174,7 @@
<button class="tab" data-section="identity" type="button">Identity</button>
</nav>
<div class="rail-inner creator">
<div class="panes" id="creatorPanes">
<div class="pane is-on" data-section="heritage"></div>
<div class="pane" data-section="face"></div>
@@ -159,6 +183,9 @@
<div class="pane" data-section="clothing"></div>
<div class="pane" data-section="identity"></div>
</div>
</div>
<p class="formerror" id="creatorError" role="alert" aria-live="assertive" hidden></p>
<div class="creator-foot">
<button class="btn ghost" id="creatorPrev" type="button">Back</button>
@@ -167,74 +194,100 @@
<span class="btn-spin" aria-hidden="true"></span>
</button>
</div>
<p class="formerror" id="creatorError" role="alert" aria-live="assertive" hidden></p>
</div>
</div>
<!-- The plate the subject is photographed against. It is also the drag
surface, so the frame you aim at is the thing that turns. -->
<div class="turntable" id="turntable" title="Drag to turn">
<div class="turnhint data"><span class="turnicon" aria-hidden="true"></span>Drag to turn</div>
<span class="plate-corner tl" aria-hidden="true"></span>
<span class="plate-corner tr" aria-hidden="true"></span>
<span class="plate-corner bl" aria-hidden="true"></span>
<span class="plate-corner br" aria-hidden="true"></span>
<div class="plate-cap">
<span class="plate-cap-k">Subject</span>
<span class="turnhint"><span class="turnicon" aria-hidden="true"></span>Drag to turn</span>
</div>
</div>
</section>
<!-- ======================= 05 / spawn ======================= -->
<!-- ======================= 04 / spawn ======================= -->
<section class="screen" id="screen-spawn" hidden>
<div class="rail">
<div class="rail-scrim" aria-hidden="true"></div>
<div class="rail-inner">
<div class="kicker">
<span class="eyebrow">Placement</span>
<span class="hair" aria-hidden="true"></span>
<div class="letterhead">
<div class="lh-dept">
<span class="lh-city">City of Los Santos</span>
<span class="lh-office">Notice of Placement</span>
</div>
<div class="lh-form data">VR&#8209;9</div>
</div>
<div class="rail-inner">
<h1 class="head">Where does the day start?</h1>
<p class="sub" id="spawnSub">Pick a district. You can move once you are on the ground.</p>
<ul class="spawnlist" id="spawnList"></ul>
<p class="formerror" id="spawnError" role="alert" aria-live="assertive" hidden></p>
<button class="btn primary" id="spawnConfirm" type="button" disabled>
<span class="btn-label">Place me here</span>
<span class="btn-spin" aria-hidden="true"></span>
</button>
<p class="formerror" id="spawnError" role="alert" aria-live="assertive" hidden></p>
</div>
</div>
<!-- survey chart: a simplified plan of the county, not a screenshot -->
<!-- A plan sheet from the same office: a drawing of the county, not a screenshot. -->
<div class="chart" id="chart">
<div class="chart-paper" aria-hidden="true"></div>
<svg viewBox="0 0 1000 1000" preserveAspectRatio="xMidYMid meet" aria-hidden="true">
<defs>
<pattern id="grid" width="50" height="50" patternUnits="userSpaceOnUse">
<path d="M50 0 L0 0 0 50" fill="none" stroke="rgba(232,228,220,.055)" stroke-width="1"/>
<path d="M50 0 L0 0 0 50" fill="none" stroke="rgba(25,28,24,.10)" stroke-width="1"/>
</pattern>
<pattern id="grid10" width="250" height="250" patternUnits="userSpaceOnUse">
<path d="M250 0 L0 0 0 250" fill="none" stroke="rgba(25,28,24,.18)" stroke-width="1"/>
</pattern>
</defs>
<rect width="1000" height="1000" fill="url(#grid)"/>
<rect width="1000" height="1000" fill="url(#grid10)"/>
<!-- chart furniture: this is a survey sheet, not a screenshot -->
<rect class="chart-frame" x="34" y="34" width="932" height="932"/>
<rect class="chart-frame" x="30" y="30" width="940" height="940"/>
<path class="chart-ticks"
d="M34 96 L34 34 L96 34 M904 34 L966 34 L966 96
M966 904 L966 966 L904 966 M96 966 L34 966 L34 904"/>
<text class="chart-label" x="34" y="1000">Los Santos County</text>
d="M30 92 L30 30 L92 30 M908 30 L970 30 L970 92
M970 908 L970 970 L908 970 M92 970 L30 970 L30 908"/>
<path id="landmass"
d="M318 62 L470 44 L604 74 L712 66 L806 128 L858 248 L872 372
L842 468 L868 556 L826 664 L742 742 L648 812 L560 872
L462 918 L392 902 L342 836 L296 742 L262 640 L240 528
L206 424 L196 320 L232 196 L272 108 Z"
fill="rgba(255,159,69,.04)" stroke="rgba(255,159,69,.30)" stroke-width="1.5"/>
L206 424 L196 320 L232 196 L272 108 Z"/>
<!-- the western coastline is the one edge worth drawing emphatically -->
<path id="coastline"
d="M262 640 L240 528 L206 424 L196 320 L232 196 L272 108 L318 62"
fill="none" stroke="rgba(255,159,69,.58)" stroke-width="2.5"/>
d="M262 640 L240 528 L206 424 L196 320 L232 196 L272 108 L318 62"/>
</svg>
<div class="pins" id="pins"></div>
<!-- every drawing from this office carries a title block -->
<div class="titleblock">
<div class="tb-row tb-main">
<span class="tb-k">Sheet</span>
<span class="tb-v">Los Santos County</span>
</div>
<div class="tb-row">
<span class="tb-k">Scale</span><span class="tb-v">Not to scale</span>
<span class="tb-k">Sheet</span><span class="tb-v">1 of 1</span>
</div>
</div>
</div>
</section>
<!-- the one bold moment: the city stamps your file -->
<div class="stamp" id="stamp" aria-hidden="true"><span id="stampText">FILED</span></div>
<div class="stamp" id="stamp" aria-hidden="true">
<span class="stamp-word" id="stampText">Filed</span>
<span class="stamp-sub">Office of Vital Records</span>
</div>
<script src="app.js"></script>
</body>
+182
View File
@@ -0,0 +1,182 @@
/* ---------------------------------------------------------------------------
Los Santos RP - design tokens.
The interface is a set of documents from the city's Office of Vital Records,
lying on a dark desk while the city runs on behind them. Paper is the only
surface; the game is the room the desk is in. Nothing here is a dark glass
panel, because a city clerk does not hand you one.
Three typographic voices, and the rule between them is literal:
preprinted Archivo, expanded, caps what the form was printed with
typed IBM Plex Mono what somebody entered on it
prose IBM Plex Sans plain-English notes in the margin
Shared verbatim by rp_loading and rp_ui so every screen reads as one product.
--------------------------------------------------------------------------- */
@font-face {
font-family: 'Archivo';
src: url('fonts/archivo-var.woff2') format('woff2-variations');
font-weight: 100 900;
font-stretch: 62% 125%;
font-display: block;
}
@font-face {
font-family: 'Plex Sans';
src: url('fonts/plexsans-var.woff2') format('woff2-variations');
font-weight: 100 700;
font-display: block;
}
@font-face {
font-family: 'Plex Mono';
src: url('fonts/plex-400.woff2') format('woff2');
font-weight: 400;
font-display: block;
}
@font-face {
font-family: 'Plex Mono';
src: url('fonts/plex-500.woff2') format('woff2');
font-weight: 500;
font-display: block;
}
:root {
/* --- the room ---------------------------------------------------------- */
--room: #0b0c0a; /* the dark the desk stands in, never pure black */
--desk: #1a1b16; /* desk surface where the lamp reaches it */
--lamp: rgba(224, 196, 128, 0.10);
/* --- the paper --------------------------------------------------------- */
--stock: #e3dbc2; /* manila card stock: warm, but grey-olive, not cream */
--stock-hi: #efe9d7; /* the top sheet, directly under the lamp */
--stock-2: #cbc09f; /* the sheet underneath, and every tab edge */
--stock-3: #b3a888; /* deepest fold */
/* --- what is written on it --------------------------------------------- */
--ink: #191c18; /* ballpoint black with a green cast */
--ink-2: #4e5449; /* second-rank text */
--ink-3: #7d8175; /* captions, disabled, ruled lines */
/* --- the three official inks, each with exactly one job ---------------- */
--canary: #d9b23c; /* municipal form yellow: what is selected, now */
--canary-dp: #a8801d;
--stamp: #7e2b26; /* oxblood rubber stamp: filed, refused, destroyed */
--verdi: #2e6155; /* municipal teal: checked, valid, approved */
/* rules printed on the form */
--rule: rgba(25, 28, 24, 0.22);
--rule-soft: rgba(25, 28, 24, 0.11);
--rule-firm: rgba(25, 28, 24, 0.55);
/* paper fibre, laid over every sheet at low opacity */
--fiber: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='f'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23f)'/%3E%3C/svg%3E");
/* uneven rubber-stamp ink: large soft blobs, not fine noise */
--inkmask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='200'%3E%3Cfilter id='r'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.028' numOctaves='4' seed='11'/%3E%3CfeColorMatrix type='matrix' values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 2.4 0 0 0 -0.32'/%3E%3C/filter%3E%3Crect width='400' height='200' filter='url(%23r)'/%3E%3C/svg%3E");
/* a sheet of paper casts a real shadow onto the desk */
--lift-1: 0 1px 0 rgba(255,255,255,.28) inset, 0 10px 22px -8px rgba(0,0,0,.7);
--lift-2: 0 1px 0 rgba(255,255,255,.34) inset, 0 26px 48px -18px rgba(0,0,0,.82);
--sheet: clamp(360px, 27vw, 460px);
--sheet-w: clamp(440px, 34vw, 580px);
--gut-x: clamp(26px, 2.4vw, 40px);
--gut-y: clamp(22px, 2.2vw, 34px);
--ease: cubic-bezier(0.22, 0.61, 0.36, 1);
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%;
overflow: hidden;
background: var(--room);
color: var(--ink);
font-family: 'Plex Sans', system-ui, sans-serif;
font-size: 15px;
line-height: 1.55;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
/* ---------------------------------------------------------------------------
The three voices.
--------------------------------------------------------------------------- */
/* preprinted: everything the form arrived with */
.pp,
.eyebrow,
.head,
label,
.btn,
.tab,
.ctrl-label,
.charname,
.spawnname,
.stagechip span {
font-family: 'Archivo', system-ui, sans-serif;
font-variation-settings: 'wdth' 112, 'wght' 700;
text-transform: uppercase;
letter-spacing: 0.07em;
}
/* typed: everything a person put on the form */
.data,
.typed,
input,
textarea,
.ctrl-val,
.stepval,
.charmeta,
.charmoney,
.counter,
.stagechip em,
.filetag {
font-family: 'Plex Mono', ui-monospace, monospace;
font-variation-settings: normal;
text-transform: none;
letter-spacing: 0.01em;
font-variant-numeric: tabular-nums;
}
/* prose: the plain-English notes, the only voice allowed sentence case */
.sub,
.hint,
.spawnblurb,
.switch {
font-family: 'Plex Sans', system-ui, sans-serif;
text-transform: none;
letter-spacing: 0;
}
.eyebrow {
font-size: 10px;
font-variation-settings: 'wdth' 104, 'wght' 600;
letter-spacing: 0.19em;
color: var(--ink-3);
}
.data {
font-size: 11.5px;
color: var(--ink-2);
}
.rule { height: 1px; background: var(--rule); border: 0; }
/* Focus is always the canary, always visible, and never subtle. */
:focus-visible {
outline: 2px solid var(--canary-dp);
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
}
}
-343
View File
@@ -1,343 +0,0 @@
-- JustRP — Client-side core
-- Manages NUI screens, cameras, spawning and position sync
local inSession = false
local charLoaded = false
local camHandle = nil
local saveTimer = 0
-- ─── NUI helper ─────────────────────────────────────────────────────────────
local function nuiMsg(action, data)
SendNUIMessage({ action = action, data = data or {} })
end
-- ─── On resource start (loading screen is already showing) ──────────────────
AddEventHandler("onClientMapStart", function()
Wait(1500) -- give the game a moment to settle
TriggerServerEvent("justrp:clientReady")
end)
-- ─── Session start from server ───────────────────────────────────────────────
RegisterNetEvent("justrp:startSession")
AddEventHandler("justrp:startSession", function()
-- Freeze world while auth is happening
SetEntityVisible(PlayerPedId(), false, false)
FreezeEntityPosition(PlayerPedId(), true)
DisplayHud(false)
DisplayRadar(false)
-- Shut loading screen, then show auth UI
Wait(800)
ShutdownLoadingScreenNui()
Wait(300)
SetNuiFocus(true, true)
nuiMsg("showAuth")
inSession = true
end)
-- ─── Auth results ────────────────────────────────────────────────────────────
RegisterNetEvent("justrp:auth:ok")
AddEventHandler("justrp:auth:ok", function(accountId, username)
nuiMsg("authOk", { accountId = accountId, username = username })
TriggerServerEvent("justrp:chars:load")
end)
RegisterNetEvent("justrp:auth:fail")
AddEventHandler("justrp:auth:fail", function(err)
nuiMsg("authFail", { error = err })
end)
-- ─── Character list from server ──────────────────────────────────────────────
RegisterNetEvent("justrp:chars:list")
AddEventHandler("justrp:chars:list", function(chars)
nuiMsg("showChars", { characters = chars })
end)
-- ─── Character creation result ───────────────────────────────────────────────
RegisterNetEvent("justrp:char:created")
AddEventHandler("justrp:char:created", function(charId)
-- Reload character list so the new one shows
TriggerServerEvent("justrp:chars:load")
end)
RegisterNetEvent("justrp:char:createFail")
AddEventHandler("justrp:char:createFail", function(err)
nuiMsg("charCreateFail", { error = err })
end)
-- ─── Character selected → load appearance ────────────────────────────────────
RegisterNetEvent("justrp:char:loaded")
AddEventHandler("justrp:char:loaded", function(ch)
-- Apply appearance to player ped
local ped = PlayerPedId()
local model = ch.gender == 1 and GetHashKey("mp_f_freemode_01") or GetHashKey("mp_m_freemode_01")
RequestModel(model)
while not HasModelLoaded(model) do Wait(10) end
SetPlayerModel(PlayerId(), model)
SetModelAsNoLongerNeeded(model)
ped = PlayerPedId()
-- Face blend
SetPedHeadBlendData(
ped,
math.floor(ch.face_blend * 45), math.floor(ch.face_blend * 45),
0,
math.floor(ch.face_blend * 45), math.floor(ch.face_blend * 45),
0,
ch.face_blend, ch.face_blend, 0.0, false
)
-- Skin overlay (skin tone)
SetPedMicroblendData(ped, ch.skin_tone, ch.skin_tone, ch.skin_tone)
-- Eyes
SetPedEyeColor(ped, ch.eye_color)
-- Hair
SetPedComponentVariation(ped, 2, ch.hair_style, 0, 2)
SetPedHairColor(ped, ch.hair_color, 0)
-- Body shape
SetPedFaceFeature(ped, 16, (ch.body_weight - 0.5) * 2) -- body weight feature
-- Outfit
SetPedComponentVariation(ped, 11, ch.outfit_top, 0, 2)
SetPedComponentVariation(ped, 4, ch.outfit_pants, 0, 2)
SetPedComponentVariation(ped, 6, ch.outfit_shoes, 0, 2)
-- Tell NUI to show spawn screen
nuiMsg("showSpawn", {
name = ch.firstname .. " " .. ch.lastname,
})
end)
-- ─── Spawn ───────────────────────────────────────────────────────────────────
RegisterNetEvent("justrp:spawn:go")
AddEventHandler("justrp:spawn:go", function(pos)
local ped = PlayerPedId()
-- Teleport to position and unfreeze
SetEntityCoords(ped, pos.x, pos.y, pos.z, false, false, false, true)
SetEntityHeading(ped, pos.h)
-- Cinematic camera fly-down
local camX = pos.x + math.sin(math.rad(pos.h)) * 200
local camY = pos.y + math.cos(math.rad(pos.h)) * 200
camHandle = CreateCameraWithParams(
"DEFAULT_SCRIPTED_CAMERA",
camX, camY, pos.z + 180,
0.0, 0.0, 0.0,
60.0, false, 0
)
SetCamActive(camHandle, true)
RenderScriptCams(true, true, 1000, true, true)
-- Animate camera flying down to player
local steps = 80
Citizen.CreateThread(function()
for i = 1, steps do
local t = i / steps
local ease = 1 - math.pow(1 - t, 3) -- ease out cubic
local cx = camX + (pos.x - camX) * ease
local cy = camY + (pos.y - camY) * ease
local cz = (pos.z + 180) + ((pos.z + 2) - (pos.z + 180)) * ease
SetCamCoord(camHandle, cx, cy, cz)
PointCamAtCoord(camHandle, pos.x, pos.y, pos.z)
Wait(16)
end
-- Transition back to player cam
RenderScriptCams(false, true, 1200, true, true)
Wait(1300)
DestroyCam(camHandle, false)
camHandle = nil
-- Reveal player
SetEntityVisible(ped, true, false)
FreezeEntityPosition(ped, false)
DisplayHud(true)
DisplayRadar(true)
SetNuiFocus(false, false)
nuiMsg("hide")
charLoaded = true
end)
end)
-- ─── NUI Callbacks ───────────────────────────────────────────────────────────
-- Auth
RegisterNUICallback("authRegister", function(data, cb)
TriggerServerEvent("justrp:auth:register", data.username, data.password)
cb("ok")
end)
RegisterNUICallback("authLogin", function(data, cb)
TriggerServerEvent("justrp:auth:login", data.username, data.password)
cb("ok")
end)
-- Character management
RegisterNUICallback("charSelect", function(data, cb)
TriggerServerEvent("justrp:char:select", tonumber(data.id))
cb("ok")
end)
RegisterNUICallback("charCreate", function(data, cb)
TriggerServerEvent("justrp:char:create", data)
cb("ok")
end)
-- Spawn selection
RegisterNUICallback("spawnRequest", function(data, cb)
TriggerServerEvent("justrp:spawn:request", data.location)
cb("ok")
end)
-- Character creation — camera rotation control
RegisterNUICallback("rotateCam", function(data, cb)
if camHandle then
-- data.delta is horizontal rotation amount
local heading = GetEntityHeading(PlayerPedId())
SetEntityHeading(PlayerPedId(), heading - (data.delta or 0) * 0.4)
end
cb("ok")
end)
-- ─── Character creation preview camera ───────────────────────────────────────
local charCamActive = false
local charCamAngle = 0.0
RegisterNUICallback("startCharCam", function(data, cb)
if charCamActive then
cb("ok")
return
end
charCamActive = true
local ped = PlayerPedId()
SetEntityVisible(ped, true, false)
camHandle = CreateCamera("DEFAULT_SCRIPTED_CAMERA", false)
SetCamActive(camHandle, true)
RenderScriptCams(true, false, 0, true, true)
Citizen.CreateThread(function()
while charCamActive do
local pedPos = GetEntityCoords(ped)
local angle = charCamAngle
local dist = 1.6
local cx = pedPos.x + math.sin(math.rad(angle)) * dist
local cy = pedPos.y - math.cos(math.rad(angle)) * dist
local cz = pedPos.z + 0.7
SetCamCoord(camHandle, cx, cy, cz)
PointCamAtCoord(camHandle, pedPos.x, pedPos.y, pedPos.z + 0.7)
charCamAngle = charCamAngle + 0.15 -- slow auto-orbit
Wait(16)
end
end)
cb("ok")
end)
RegisterNUICallback("stopCharCam", function(data, cb)
charCamActive = false
if camHandle then
RenderScriptCams(false, true, 600, true, true)
Wait(700)
DestroyCam(camHandle, false)
camHandle = nil
end
cb("ok")
end)
RegisterNUICallback("charCamDrag", function(data, cb)
charCamAngle = charCamAngle + (data.delta or 0) * 0.5
charCamActive = true -- stop auto-orbit briefly
cb("ok")
end)
-- Apply live appearance updates during char creation
RegisterNUICallback("applyAppearance", function(data, cb)
local ped = PlayerPedId()
if data.gender ~= nil then
local model = data.gender == 1
and GetHashKey("mp_f_freemode_01")
or GetHashKey("mp_m_freemode_01")
if not HasModelLoaded(model) then
RequestModel(model)
while not HasModelLoaded(model) do Wait(10) end
end
SetPlayerModel(PlayerId(), model)
SetModelAsNoLongerNeeded(model)
ped = PlayerPedId()
end
if data.face_blend ~= nil then
SetPedHeadBlendData(
ped,
math.floor(data.face_blend * 45),
math.floor(data.face_blend * 45),
0,
math.floor(data.face_blend * 45),
math.floor(data.face_blend * 45),
0,
data.face_blend, data.face_blend, 0.0, false
)
end
if data.skin_tone ~= nil then
SetPedMicroblendData(ped, data.skin_tone, data.skin_tone, data.skin_tone)
end
if data.eye_color ~= nil then
SetPedEyeColor(ped, data.eye_color)
end
if data.hair_style ~= nil or data.hair_color ~= nil then
SetPedComponentVariation(ped, 2, data.hair_style or 0, 0, 2)
SetPedHairColor(ped, data.hair_color or 0, 0)
end
if data.body_weight ~= nil then
SetPedFaceFeature(ped, 16, (data.body_weight - 0.5) * 2)
end
if data.outfit_top ~= nil then
SetPedComponentVariation(ped, 11, data.outfit_top, 0, 2)
end
if data.outfit_pants ~= nil then
SetPedComponentVariation(ped, 4, data.outfit_pants, 0, 2)
end
if data.outfit_shoes ~= nil then
SetPedComponentVariation(ped, 6, data.outfit_shoes, 0, 2)
end
cb("ok")
end)
-- ─── Position auto-save ───────────────────────────────────────────────────────
Citizen.CreateThread(function()
while true do
Wait(60000)
if charLoaded then
local ped = PlayerPedId()
local pos = GetEntityCoords(ped)
local hdg = GetEntityHeading(ped)
TriggerServerEvent("justrp:savePosition", pos.x, pos.y, pos.z, hdg)
end
end
end)
print("[JustRP] Client core loaded.")
-25
View File
@@ -1,25 +0,0 @@
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
-469
View File
@@ -1,469 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Los Santos County — Resident Processing</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@500;600;700&family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500&display=swap" rel="stylesheet">
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
--ink: #10131A;
--ink-2: #171B24;
--paper: #E8E4DA;
--paper-2: #D6D0C2;
--stamp: #C2462E;
--rule: rgba(232,228,218,.14);
--rule-2: rgba(232,228,218,.07);
--dim: rgba(232,228,218,.42);
--dim-2: rgba(232,228,218,.24);
--sig: 'Barlow Condensed','Oswald',sans-serif;
--body: 'IBM Plex Sans',system-ui,sans-serif;
--mono: 'IBM Plex Mono',ui-monospace,monospace;
}
html,body{width:100%;height:100%;overflow:hidden;background:var(--ink);color:var(--paper);font-family:var(--body)}
/* ── Survey footage ─────────────────────────────── */
#flyover{position:fixed;inset:0;width:100%;height:100%;filter:saturate(.55) contrast(1.06)}
.footage-tint{position:fixed;inset:0;pointer-events:none;
background:
radial-gradient(ellipse 80% 60% at 50% 42%,transparent 0%,rgba(16,19,26,.5) 68%,rgba(16,19,26,.94) 100%),
linear-gradient(180deg,rgba(16,19,26,.88) 0%,transparent 22%,transparent 62%,rgba(16,19,26,.96) 100%);
}
.scanlines{position:fixed;inset:0;pointer-events:none;opacity:.5;
background:repeating-linear-gradient(180deg,rgba(0,0,0,.22) 0px,rgba(0,0,0,.22) 1px,transparent 1px,transparent 3px)}
.grain{position:fixed;inset:-50%;pointer-events:none;opacity:.19;
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='3'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
animation:grainshift .7s steps(3) infinite}
@keyframes grainshift{
0%{transform:translate(0,0)}33%{transform:translate(-3%,2%)}66%{transform:translate(2%,-3%)}100%{transform:translate(0,0)}}
/* ── Registration marks ─────────────────────────── */
.reg{position:fixed;width:22px;height:22px;pointer-events:none;opacity:0;
animation:regIn .5s steps(2) .3s forwards}
.reg::before,.reg::after{content:'';position:absolute;background:var(--dim-2)}
.reg::before{left:0;right:0;top:50%;height:1px}
.reg::after{top:0;bottom:0;left:50%;width:1px}
.reg-tl{top:26px;left:26px}.reg-tr{top:26px;right:26px}
.reg-bl{bottom:26px;left:26px}.reg-br{bottom:26px;right:26px}
@keyframes regIn{to{opacity:1}}
/* ── Header slate ───────────────────────────────── */
.slate{position:fixed;top:26px;left:64px;right:64px;display:flex;justify-content:space-between;
align-items:flex-start;gap:24px;opacity:0;animation:fadeDown .8s cubic-bezier(.2,.8,.25,1) .35s forwards}
.slate-id{font-family:var(--mono);font-size:10px;letter-spacing:.16em;color:var(--dim);line-height:2}
.slate-id b{color:var(--paper);font-weight:500}
.slate-read{font-family:var(--mono);font-size:10px;letter-spacing:.14em;color:var(--dim);
text-align:right;line-height:2}
.slate-read span{color:var(--stamp)}
@keyframes fadeDown{from{opacity:0;transform:translateY(-8px)}to{opacity:1;transform:none}}
/* ── Title block: left-anchored, not centered ───── */
.title{position:fixed;left:64px;top:50%;transform:translateY(-50%);max-width:620px}
.title-eyebrow{font-family:var(--mono);font-size:10px;letter-spacing:.3em;color:var(--stamp);
margin-bottom:14px;opacity:0;animation:riseIn .7s cubic-bezier(.2,.8,.25,1) .7s forwards}
.title-main{font-family:var(--sig);font-weight:700;font-size:clamp(64px,9.5vw,140px);
line-height:.83;letter-spacing:-.015em;text-transform:uppercase;
opacity:0;animation:riseIn .9s cubic-bezier(.2,.8,.25,1) .85s forwards}
.title-main em{font-style:normal;display:block;color:var(--stamp)}
.title-rule{height:1px;background:var(--rule);margin:26px 0 18px;transform:scaleX(0);
transform-origin:left;animation:ruleOut .9s cubic-bezier(.2,.8,.25,1) 1.25s forwards}
@keyframes ruleOut{to{transform:scaleX(1)}}
.title-sub{font-size:13px;line-height:1.75;color:var(--dim);max-width:390px;
opacity:0;animation:riseIn .7s cubic-bezier(.2,.8,.25,1) 1.4s forwards}
@keyframes riseIn{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:none}}
/* ── Ordinance list ─────────────────────────────── */
.ord{position:fixed;right:64px;top:50%;transform:translateY(-50%);width:290px;
opacity:0;transition:opacity .9s cubic-bezier(.2,.8,.25,1)}
.ord.on{opacity:1}
.ord-head{font-family:var(--mono);font-size:10px;letter-spacing:.2em;color:var(--dim-2);
padding-bottom:10px;border-bottom:1px solid var(--rule);margin-bottom:4px}
.ord-item{display:flex;gap:12px;padding:9px 0;border-bottom:1px solid var(--rule-2);
opacity:0;transform:translateX(12px)}
.ord.on .ord-item{animation:ordIn .55s cubic-bezier(.2,.8,.25,1) forwards}
.ord.on .ord-item:nth-child(2){animation-delay:.05s}
.ord.on .ord-item:nth-child(3){animation-delay:.12s}
.ord.on .ord-item:nth-child(4){animation-delay:.19s}
.ord.on .ord-item:nth-child(5){animation-delay:.26s}
.ord.on .ord-item:nth-child(6){animation-delay:.33s}
.ord.on .ord-item:nth-child(7){animation-delay:.40s}
.ord.on .ord-item:nth-child(8){animation-delay:.47s}
@keyframes ordIn{to{opacity:1;transform:none}}
.ord-no{font-family:var(--mono);font-size:10px;color:var(--stamp);flex-shrink:0;padding-top:2px}
.ord-tx{font-size:12px;line-height:1.55;color:var(--dim)}
/* ── Processing bar ─────────────────────────────── */
.proc{position:fixed;left:64px;right:64px;bottom:52px;
opacity:0;animation:fadeUp .8s cubic-bezier(.2,.8,.25,1) 1.6s forwards}
@keyframes fadeUp{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:none}}
.proc-top{display:flex;justify-content:space-between;align-items:baseline;
font-family:var(--mono);font-size:10px;letter-spacing:.16em;margin-bottom:9px}
.proc-stage{color:var(--dim)}
.proc-pct{color:var(--paper);font-size:13px;font-variant-numeric:tabular-nums}
.proc-track{height:2px;background:rgba(232,228,218,.09);position:relative;overflow:hidden}
.proc-fill{position:absolute;inset:0 auto 0 0;width:0%;background:var(--stamp);
transition:width .45s cubic-bezier(.2,.8,.25,1)}
.proc-fill::after{content:'';position:absolute;right:0;top:-3px;bottom:-3px;width:1px;
background:var(--paper);opacity:.85}
/* segment ticks encode the real load phases */
.proc-ticks{position:relative;height:8px;margin-top:6px}
.proc-tick{position:absolute;top:0;width:1px;height:4px;background:var(--rule)}
.proc-tick span{position:absolute;top:7px;left:0;transform:translateX(-50%);
font-family:var(--mono);font-size:8px;letter-spacing:.1em;color:var(--dim-2);white-space:nowrap}
/* ── Audio control ──────────────────────────────── */
.aud{position:fixed;right:64px;bottom:52px;display:flex;align-items:center;gap:11px;
opacity:0;animation:fadeUp .8s cubic-bezier(.2,.8,.25,1) 1.85s forwards}
.aud-lb{font-family:var(--mono);font-size:9px;letter-spacing:.18em;color:var(--dim-2)}
input[type=range]{-webkit-appearance:none;appearance:none;width:82px;height:1px;
background:var(--rule);outline:none;cursor:pointer}
input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;
width:9px;height:9px;background:var(--paper);cursor:pointer;
transition:transform .18s cubic-bezier(.2,.8,.25,1),background .18s}
input[type=range]:hover::-webkit-slider-thumb{transform:scale(1.45);background:var(--stamp)}
input[type=range]:focus-visible{outline:1px solid var(--stamp);outline-offset:6px}
@media (prefers-reduced-motion:reduce){
*{animation-duration:.01ms!important;transition-duration:.01ms!important}
.grain{animation:none}
}
@media (max-width:900px){
.ord{display:none}
.slate,.title,.proc{left:28px;right:28px}
.aud{right:28px}
}
</style>
</head>
<body>
<canvas id="flyover"></canvas>
<div class="footage-tint"></div>
<div class="scanlines"></div>
<div class="grain"></div>
<div class="reg reg-tl"></div><div class="reg reg-tr"></div>
<div class="reg reg-bl"></div><div class="reg reg-br"></div>
<div class="slate">
<div class="slate-id">
LOS SANTOS COUNTY · DEPT. OF RESIDENT AFFAIRS<br>
AERIAL SURVEY <b>LS-4471-A</b> · SECTOR <b>07</b>
</div>
<div class="slate-read">
ALT <span id="r-alt">1420</span> FT · HDG <span id="r-hdg">274</span>°<br>
<span id="r-clock">00:00:00</span> · REC <span id="r-rec"></span>
</div>
</div>
<div class="title">
<div class="title-eyebrow">INTAKE IN PROGRESS</div>
<h1 class="title-main">Just<em>RP</em></h1>
<div class="title-rule"></div>
<p class="title-sub">A persistent roleplay county. Your record begins the moment you land — every job, every debt, every name you make here is kept on file.</p>
</div>
<div class="ord" id="ord">
<div class="ord-head">COUNTY ORDINANCE · CONDITIONS OF RESIDENCY</div>
<div class="ord-item"><span class="ord-no">§1</span><span class="ord-tx">Remain in character within county limits.</span></div>
<div class="ord-item"><span class="ord-no">§2</span><span class="ord-tx">Your life has value. Act like it does.</span></div>
<div class="ord-item"><span class="ord-no">§3</span><span class="ord-tx">No violence without roleplay leading to it.</span></div>
<div class="ord-item"><span class="ord-no">§4</span><span class="ord-tx">Vehicles are not weapons.</span></div>
<div class="ord-item"><span class="ord-no">§5</span><span class="ord-tx">Knowledge your character never earned is knowledge they do not have.</span></div>
<div class="ord-item"><span class="ord-no">§6</span><span class="ord-tx">Losing is part of the story. Play it out.</span></div>
<div class="ord-item"><span class="ord-no">§7</span><span class="ord-tx">Staff rulings close the matter.</span></div>
</div>
<div class="proc">
<div class="proc-top">
<span class="proc-stage" id="stage">ESTABLISHING UPLINK</span>
<span class="proc-pct" id="pct">0%</span>
</div>
<div class="proc-track"><div class="proc-fill" id="fill"></div></div>
<div class="proc-ticks" id="ticks"></div>
</div>
<div class="aud">
<span class="aud-lb">ROOM TONE</span>
<input type="range" id="vol" min="0" max="100" value="35" aria-label="Ambient volume">
</div>
<script>
'use strict';
const $ = id => document.getElementById(id);
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
/* ══ Perspective city flyover ══════════════════════
Real 3D projection: blocks on a ground grid stream
toward the camera and are recycled behind it. */
const cv = $('flyover'), cx = cv.getContext('2d');
let W, H, FOCAL;
const CAM = { x: 0, y: 210, z: 0, speed: 1.42, drift: 0 };
const SPACING = 150, LANES = 11, DEPTH = 46, FAR = SPACING * DEPTH;
let blocks = [];
function seedBlocks () {
blocks = [];
for (let r = 0; r < DEPTH; r++) {
for (let l = 0; l < LANES; l++) {
// carve a boulevard down the middle lanes
if (l === Math.floor(LANES / 2)) continue;
if (Math.random() < 0.16) continue;
const gx = (l - (LANES - 1) / 2) * SPACING + (Math.random() - .5) * 34;
blocks.push({
x: gx,
z: r * SPACING + Math.random() * 60,
w: 46 + Math.random() * 60,
d: 46 + Math.random() * 60,
h: 34 + Math.pow(Math.random(), 2.1) * 330,
lit: Math.random()
});
}
}
}
function resize () {
W = cv.width = innerWidth;
H = cv.height = innerHeight;
FOCAL = W * 0.82;
seedBlocks();
}
addEventListener('resize', resize);
resize();
function project (x, y, z) {
const dz = z - CAM.z;
if (dz < 12) return null;
const s = FOCAL / dz;
return { sx: W / 2 + (x - CAM.x) * s, sy: H * 0.62 - (y - CAM.y) * s, s, dz };
}
function drawCity () {
// horizon haze
const hz = cx.createLinearGradient(0, H * 0.20, 0, H * 0.72);
hz.addColorStop(0, '#151A24');
hz.addColorStop(.55, '#1B212C');
hz.addColorStop(1, '#0D1016');
cx.fillStyle = hz;
cx.fillRect(0, 0, W, H);
// sun-scorched band on the horizon — the one warm note
const bd = cx.createLinearGradient(0, H * 0.44, 0, H * 0.64);
bd.addColorStop(0, 'rgba(194,70,46,0)');
bd.addColorStop(.5,'rgba(194,70,46,.13)');
bd.addColorStop(1, 'rgba(194,70,46,0)');
cx.fillStyle = bd;
cx.fillRect(0, 0, W, H);
// painter's algorithm: far blocks first
blocks.sort((a, b) => (b.z - CAM.z) - (a.z - CAM.z));
for (const b of blocks) {
const dz = b.z - CAM.z;
if (dz < 14 || dz > FAR) continue;
const hw = b.w / 2;
const bl = project(b.x - hw, 0, b.z);
const br = project(b.x + hw, 0, b.z);
const tl = project(b.x - hw, b.h, b.z);
if (!bl || !br || !tl) continue;
const fade = Math.max(0, 1 - dz / FAR);
const w = br.sx - bl.sx;
const h = bl.sy - tl.sy;
if (w < 0.6 || h < 0.6) continue;
// face
cx.globalAlpha = 0.30 + fade * 0.62;
cx.fillStyle = '#080A0F';
cx.fillRect(bl.sx, tl.sy, w, h);
// roof edge catches the light
cx.globalAlpha = 0.13 + fade * 0.30;
cx.fillStyle = '#39424F';
cx.fillRect(bl.sx, tl.sy, w, Math.max(0.7, h * 0.012));
// windows — only worth drawing when the block is near
if (dz < FAR * 0.42 && w > 7) {
const cols = Math.max(1, Math.floor(w / 7));
const rows = Math.max(1, Math.floor(h / 11));
cx.globalAlpha = fade * 0.5;
for (let c = 0; c < cols; c++) {
for (let r = 0; r < rows; r++) {
const k = (c * 37 + r * 71 + Math.floor(b.x)) % 100;
if (k / 100 > b.lit * 0.7) continue;
cx.fillStyle = k % 9 === 0 ? '#C2462E' : '#C9C2AE';
cx.fillRect(bl.sx + 2 + c * 7, tl.sy + 3 + r * 11, 2.4, 3.4);
}
}
}
}
cx.globalAlpha = 1;
}
let last = 0;
function frame (t) {
const dt = last ? Math.min((t - last) / 16.67, 3) : 1;
last = t;
CAM.z += CAM.speed * dt * 3.1;
CAM.drift += 0.0022 * dt;
CAM.x = Math.sin(CAM.drift) * 96;
CAM.y = 210 + Math.sin(CAM.drift * 0.62) * 34;
// recycle blocks that fell behind the camera
for (const b of blocks) if (b.z - CAM.z < 10) b.z += FAR;
drawCity();
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
/* ══ Instrument readouts ══════════════════════════ */
let clockSec = 0;
setInterval(() => {
clockSec++;
const hh = String(Math.floor(clockSec / 3600) % 24).padStart(2, '0');
const mm = String(Math.floor(clockSec / 60) % 60).padStart(2, '0');
const ss = String(clockSec % 60).padStart(2, '0');
$('r-clock').textContent = `${hh}:${mm}:${ss}`;
$('r-alt').textContent = Math.round(1420 + Math.sin(CAM.drift * 0.62) * 120);
$('r-hdg').textContent = String(Math.round((274 + Math.sin(CAM.drift) * 26 + 360) % 360)).padStart(3, '0');
$('r-rec').style.opacity = clockSec % 2 ? '1' : '.25';
}, 1000);
/* ══ Processing progress ══════════════════════════ */
const STAGES = [
[0, 'ESTABLISHING UPLINK'],
[12, 'RECEIVING COUNTY MAP DATA'],
[30, 'STREAMING STRUCTURES'],
[52, 'INDEXING RESIDENT RECORDS'],
[72, 'VERIFYING CREDENTIALS'],
[88, 'PREPARING INTAKE TERMINAL'],
[99, 'CLEARED FOR ENTRY'],
];
// ticks encode the real phase boundaries, not decoration
const ticksEl = $('ticks');
STAGES.slice(1).forEach(([at, label]) => {
const d = document.createElement('div');
d.className = 'proc-tick';
d.style.left = at + '%';
d.innerHTML = `<span>${label.split(' ')[0]}</span>`;
ticksEl.appendChild(d);
});
let shown = 0, target = 0, ordOn = false;
function setTarget (v) { target = Math.min(100, Math.max(target, v)); }
(function tick () {
if (shown < target) {
shown += Math.min((target - shown) * 0.07 + 0.09, target - shown);
const p = Math.min(100, Math.round(shown));
$('fill').style.width = p + '%';
$('pct').textContent = p + '%';
for (let i = STAGES.length - 1; i >= 0; i--) {
if (shown >= STAGES[i][0]) { $('stage').textContent = STAGES[i][1]; break; }
}
if (shown >= 46 && !ordOn) { ordOn = true; $('ord').classList.add('on'); }
}
setTimeout(tick, 40);
})();
[[700,13],[1500,29],[2700,49],[4400,67],[6400,80],[9000,91]]
.forEach(([ms, v]) => setTimeout(() => setTarget(v), ms));
addEventListener('message', e => {
const d = e.data;
if (!d) return;
if (d.eventName === 'loadingScreenCall') {
for (const c of (d.data || [])) {
if (c.functionName === 'shutdown' || c.type === 'shutdown') {
setTarget(100);
setTimeout(() => {
document.body.style.transition = 'opacity .9s cubic-bezier(.2,.8,.25,1)';
document.body.style.opacity = '0';
}, 900);
}
}
}
// FiveM streams real load progress through these events
if (d.eventName === 'loadProgress' && typeof d.loadFraction === 'number') {
setTarget(Math.round(d.loadFraction * 100));
}
});
/* ══ Room tone ════════════════════════════════════
Dispatch-room HVAC hum, not a music pad. */
const vol = $('vol');
let actx = null, master = null;
function initAudio () {
if (actx) return;
try { actx = new (window.AudioContext || window.webkitAudioContext)(); }
catch (e) { return; }
master = actx.createGain();
master.gain.value = vol.value / 100 * 0.6;
master.connect(actx.destination);
// low room hum
[[47.5,.20],[71.3,.10],[95,.055]].forEach(([f, g]) => {
const o = actx.createOscillator(), gn = actx.createGain(), lp = actx.createBiquadFilter();
const lfo = actx.createOscillator(), lg = actx.createGain();
lfo.frequency.value = 0.037 + Math.random() * 0.06;
lg.gain.value = f * 0.004;
lfo.connect(lg); lg.connect(o.frequency);
o.type = 'sine'; o.frequency.value = f;
lp.type = 'lowpass'; lp.frequency.value = f * 4; lp.Q.value = .4;
gn.gain.setValueAtTime(0, actx.currentTime);
gn.gain.linearRampToValueAtTime(g, actx.currentTime + 5);
o.connect(lp); lp.connect(gn); gn.connect(master);
o.start(); lfo.start();
});
// filtered air noise
const buf = actx.createBuffer(1, actx.sampleRate * 3, actx.sampleRate);
const dat = buf.getChannelData(0);
for (let i = 0; i < dat.length; i++) dat[i] = Math.random() * 2 - 1;
const ns = actx.createBufferSource(), bp = actx.createBiquadFilter(), ng = actx.createGain();
ns.buffer = buf; ns.loop = true;
bp.type = 'bandpass'; bp.frequency.value = 320; bp.Q.value = .35;
ng.gain.value = 0.02;
ns.connect(bp); bp.connect(ng); ng.connect(master);
ns.start();
}
addEventListener('click', initAudio, { once: true });
addEventListener('keydown', initAudio, { once: true });
setTimeout(initAudio, 1100);
vol.addEventListener('input', () => {
if (master) master.gain.linearRampToValueAtTime(vol.value / 100 * 0.6, actx.currentTime + .08);
});
</script>
</body>
</html>
-208
View File
@@ -1,208 +0,0 @@
-- JustRP — Server-side core
-- All DB operations go through the internal Python API on :8787
local API_BASE = "http://127.0.0.1:8787"
local API_KEY = "JustRP_Internal_2026_Secret"
-- 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,117 +0,0 @@
/* ---------------------------------------------------------------------------
Los Santos RP - design tokens.
The interface is styled as a municipal record being opened on you: strict
grid, hairline rules, monospaced serials, and one accent taken from the only
light source the city has at night - sodium street lamps.
Shared verbatim by rp_loading and rp_ui so the four screens read as one
product.
--------------------------------------------------------------------------- */
@font-face {
font-family: 'Archivo';
src: url('fonts/archivo.woff2') format('woff2-variations');
font-weight: 100 900;
font-display: block;
}
@font-face {
font-family: 'Plex Mono';
src: url('fonts/plex-400.woff2') format('woff2');
font-weight: 400;
font-display: block;
}
@font-face {
font-family: 'Plex Mono';
src: url('fonts/plex-500.woff2') format('woff2');
font-weight: 500;
font-display: block;
}
:root {
/* asphalt at night - cold, never pure black */
--ink-900: #08090b;
--ink-800: #0d0f13;
--ink-700: #14171d;
--ink-600: #1d212a;
--ink-500: #272c37;
/* document stock */
--paper: #e8e4dc;
--paper-dim: #a9a69f;
--muted: #6e747f;
/* the accent: sodium vapour, the colour of the city from the air */
--sodium: #ff9f45;
--sodium-soft: rgba(255, 159, 69, 0.14);
--sodium-line: rgba(255, 159, 69, 0.42);
--sodium-deep: #c4711f;
/* semantic only, never decorative */
--stamp: #c4462f;
--good: #7f9f5a;
--line: rgba(232, 228, 220, 0.13);
--line-soft: rgba(232, 228, 220, 0.07);
--rail: clamp(400px, 31vw, 520px);
--gut-x: clamp(32px, 3.4vw, 56px);
--gut-y: clamp(28px, 3vw, 48px);
--ease: cubic-bezier(0.22, 0.61, 0.36, 1);
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%;
overflow: hidden;
background: var(--ink-900);
color: var(--paper);
font-family: 'Archivo', system-ui, sans-serif;
font-weight: 400;
font-size: 15px;
line-height: 1.55;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
/* Typographic roles --------------------------------------------------------- */
.eyebrow {
font-family: 'Plex Mono', monospace;
font-size: 10.5px;
font-weight: 500;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--muted);
}
.data {
font-family: 'Plex Mono', monospace;
font-size: 12px;
letter-spacing: 0.04em;
color: var(--paper-dim);
font-variant-numeric: tabular-nums;
}
.rule {
height: 1px;
background: var(--line);
border: 0;
}
/* Focus is always visible and always the accent. */
:focus-visible {
outline: 2px solid var(--sodium);
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
}
}
@@ -1,839 +0,0 @@
/* ---------------------------------------------------------------------------
Pre-spawn interface.
One spatial rule holds across every screen: the instrument sits in a rail on
the left, the world stays visible on the right. Nothing is ever a floating
centred card, because nothing here is a dialog - it is a file being worked
on while the city carries on behind it.
--------------------------------------------------------------------------- */
body {
background: transparent;
opacity: 1;
transition: opacity 420ms var(--ease);
}
body.hidden { opacity: 0; pointer-events: none; }
/* Keeps interface text legible over whatever the camera happens to be on. */
.edge {
position: fixed;
inset: 0;
pointer-events: none;
background:
linear-gradient(90deg, rgba(8,9,11,.55) 0%, rgba(8,9,11,0) 55%),
radial-gradient(130% 100% at 70% 50%, transparent 45%, rgba(0,0,0,.6) 100%);
}
.grain {
position: fixed;
inset: 0;
pointer-events: none;
opacity: .1;
mix-blend-mode: overlay;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='3'/%3E%3C/filter%3E%3Crect width='140' height='140' filter='url(%23n)'/%3E%3C/svg%3E");
}
/* =========================================================================
chrome
========================================================================= */
.topbar {
position: fixed;
top: 0; left: 0; right: 0;
height: 74px;
padding: 0 var(--gut-x);
display: flex;
align-items: center;
justify-content: space-between;
gap: 28px;
z-index: 40;
pointer-events: none;
}
.mark { display: flex; align-items: baseline; gap: 11px; }
.mark-name {
font-weight: 800;
font-size: 17px;
letter-spacing: -.01em;
}
.mark-tag {
font-family: 'Plex Mono', monospace;
font-size: 9.5px;
letter-spacing: .34em;
color: var(--sodium);
text-transform: uppercase;
}
.stages { display: flex; align-items: center; gap: 10px; }
.stagechip {
display: flex;
align-items: center;
gap: 9px;
background: none;
border: 0;
padding: 6px 2px;
color: var(--muted);
font-family: inherit;
font-size: 12.5px;
letter-spacing: .01em;
cursor: default;
transition: color 300ms var(--ease);
}
.stagechip em {
font-family: 'Plex Mono', monospace;
font-style: normal;
font-size: 10px;
letter-spacing: .1em;
padding: 3px 5px;
border: 1px solid var(--line);
color: var(--muted);
transition: color 300ms var(--ease), border-color 300ms var(--ease), background 300ms var(--ease);
}
.stagechip.done { color: var(--paper-dim); }
.stagechip.done em { color: var(--paper-dim); border-color: var(--line); }
.stagechip.on { color: var(--paper); }
.stagechip.on em {
color: var(--ink-900);
background: var(--sodium);
border-color: var(--sodium);
}
.stagelink {
width: 26px;
height: 1px;
background: var(--line);
}
.filetag { color: var(--muted); }
.filetag span { color: var(--paper-dim); }
/* =========================================================================
screens + rail
========================================================================= */
.screen {
position: fixed;
inset: 0;
z-index: 20;
}
.screen[hidden] { display: none; }
.rail {
position: absolute;
top: 0; left: 0; bottom: 0;
width: var(--rail);
display: flex;
align-items: center;
pointer-events: auto;
}
.rail.wide { width: clamp(470px, 36vw, 640px); }
.rail-scrim {
position: absolute;
top: 0; bottom: 0; left: 0;
width: 168%;
pointer-events: none;
background: linear-gradient(
90deg,
rgba(8,9,11,.96) 0%,
rgba(8,9,11,.92) 42%,
rgba(8,9,11,.6) 72%,
rgba(8,9,11,0) 100%
);
}
.rail-inner {
position: relative;
width: 100%;
padding: 96px var(--gut-x) var(--gut-y);
max-height: 100vh;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
scrollbar-color: var(--ink-500) transparent;
}
.rail-inner::-webkit-scrollbar { width: 3px; }
.rail-inner::-webkit-scrollbar-thumb { background: var(--ink-500); }
/* staged entrance, replayed each time a screen becomes current */
.screen.is-on .rail-inner > * { animation: rise 720ms var(--ease-out) both; }
.screen.is-on .rail-inner > *:nth-child(1) { animation-delay: 40ms; }
.screen.is-on .rail-inner > *:nth-child(2) { animation-delay: 110ms; }
.screen.is-on .rail-inner > *:nth-child(3) { animation-delay: 175ms; }
.screen.is-on .rail-inner > *:nth-child(4) { animation-delay: 235ms; }
.screen.is-on .rail-inner > *:nth-child(5) { animation-delay: 290ms; }
.screen.is-on .rail-inner > *:nth-child(6) { animation-delay: 340ms; }
.screen.is-on .rail-inner > *:nth-child(7) { animation-delay: 385ms; }
@keyframes rise {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: none; }
}
/* =========================================================================
type blocks
========================================================================= */
.kicker {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 22px;
}
.kicker .hair {
flex: 1;
height: 1px;
background: linear-gradient(90deg, var(--line), transparent);
}
.head {
font-size: clamp(30px, 2.9vw, 44px);
font-weight: 700;
line-height: 1.02;
letter-spacing: -.032em;
margin-bottom: 14px;
}
.sub {
color: var(--paper-dim);
font-size: 14.5px;
max-width: 46ch;
margin-bottom: 30px;
}
/* =========================================================================
form
========================================================================= */
.form { display: block; }
.field { margin-bottom: 20px; }
.field label {
display: block;
font-family: 'Plex Mono', monospace;
font-size: 10.5px;
font-weight: 500;
letter-spacing: .18em;
text-transform: uppercase;
color: var(--muted);
margin-bottom: 9px;
transition: color 220ms var(--ease);
}
.field:focus-within label { color: var(--sodium); }
.field input {
width: 100%;
height: 46px;
padding: 0 14px;
background: rgba(20, 23, 29, .72);
border: 1px solid var(--line);
border-radius: 0;
color: var(--paper);
font-family: inherit;
font-size: 15px;
letter-spacing: .005em;
transition: border-color 220ms var(--ease), background 220ms var(--ease), box-shadow 220ms var(--ease);
}
.field input::placeholder { color: #565c66; }
.field input:hover { border-color: rgba(232,228,220,.22); }
.field input:focus {
outline: none;
border-color: var(--sodium);
background: rgba(29, 33, 42, .85);
box-shadow: inset 0 -2px 0 var(--sodium-soft);
}
.withbtn { position: relative; display: flex; }
.withbtn input { padding-right: 62px; }
.reveal-btn {
position: absolute;
right: 1px; top: 1px; bottom: 1px;
padding: 0 13px;
background: none;
border: 0;
border-left: 1px solid var(--line);
color: var(--muted);
font-family: 'Plex Mono', monospace;
font-size: 10px;
letter-spacing: .12em;
text-transform: uppercase;
cursor: pointer;
transition: color 200ms var(--ease);
}
.reveal-btn:hover { color: var(--sodium); }
.hint {
margin-top: 8px;
font-family: 'Plex Mono', monospace;
font-size: 11px;
line-height: 1.45;
letter-spacing: .01em;
color: var(--muted);
transition: color 220ms var(--ease), transform 220ms var(--ease);
}
.field.bad input { border-color: var(--stamp); }
.field.bad .hint { color: var(--stamp); }
.field.good input { border-color: rgba(127,159,90,.55); }
.field.good .hint { color: var(--good); }
/* register-only row: grid trick so height animates without a fixed value */
.collapse {
display: grid;
grid-template-rows: 0fr;
opacity: 0;
transition: grid-template-rows 460ms var(--ease-out), opacity 300ms var(--ease);
}
.collapse.open { grid-template-rows: 1fr; opacity: 1; }
.collapse-inner { overflow: hidden; min-height: 0; }
.formerror {
margin-bottom: 16px;
padding: 11px 13px;
border-left: 2px solid var(--stamp);
background: rgba(196,70,47,.09);
color: #e8a598;
font-size: 13.5px;
animation: shake 380ms var(--ease);
}
.formerror[hidden] { display: none; }
@keyframes shake {
0%,100% { transform: translateX(0); }
22% { transform: translateX(-5px); }
46% { transform: translateX(4px); }
72% { transform: translateX(-2px); }
}
.switch {
margin-top: 22px;
font-size: 13.5px;
color: var(--muted);
}
/* =========================================================================
buttons
========================================================================= */
.btn {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
height: 50px;
padding: 0 26px;
border: 1px solid transparent;
border-radius: 0;
font-family: inherit;
font-size: 13px;
font-weight: 600;
letter-spacing: .07em;
text-transform: uppercase;
cursor: pointer;
overflow: hidden;
transition: transform 110ms var(--ease), background 240ms var(--ease),
color 240ms var(--ease), border-color 240ms var(--ease), opacity 240ms var(--ease);
}
.btn:active { transform: translateY(1px); }
.btn[disabled] { opacity: .34; cursor: not-allowed; }
.btn[disabled]:active { transform: none; }
.btn.primary {
width: 100%;
background: var(--sodium);
color: #17120b;
}
.btn.primary:hover:not([disabled]) { background: #ffb063; }
/* a light sweeps across on hover - the only ornament on the button */
.btn.primary::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(105deg, transparent 30%, rgba(255,255,255,.42) 50%, transparent 70%);
transform: translateX(-130%);
transition: transform 720ms var(--ease);
}
.btn.primary:hover:not([disabled])::after { transform: translateX(130%); }
.btn.ghost {
background: transparent;
border-color: var(--line);
color: var(--paper-dim);
}
.btn.ghost:hover:not([disabled]) { border-color: var(--sodium-line); color: var(--sodium); }
.btn-spin {
width: 13px; height: 13px;
border: 1.5px solid rgba(23,18,11,.3);
border-top-color: #17120b;
border-radius: 50%;
display: none;
animation: spin 640ms linear infinite;
}
.btn.busy .btn-spin { display: block; }
.btn.busy .btn-label { opacity: .55; }
@keyframes spin { to { transform: rotate(360deg); } }
.linkbtn {
background: none;
border: 0;
padding: 0;
color: var(--sodium);
font-family: inherit;
font-size: inherit;
cursor: pointer;
position: relative;
}
.linkbtn::after {
content: '';
position: absolute;
left: 0; right: 0; bottom: -2px;
height: 1px;
background: var(--sodium);
transform: scaleX(0);
transform-origin: left;
transition: transform 280ms var(--ease-out);
}
.linkbtn:hover::after { transform: scaleX(1); }
/* =========================================================================
character list
========================================================================= */
.charlist { list-style: none; margin-bottom: 26px; }
.charcard {
position: relative;
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 16px;
padding: 17px 18px;
margin-bottom: 10px;
background: rgba(20,23,29,.6);
border: 1px solid var(--line);
cursor: pointer;
transition: border-color 240ms var(--ease), background 240ms var(--ease), transform 240ms var(--ease);
}
.charcard:hover { border-color: rgba(232,228,220,.24); transform: translateX(3px); }
.charcard.on {
border-color: var(--sodium);
background: rgba(255,159,69,.07);
}
/* the accent bar only exists on the selected row */
.charcard.on::before {
content: '';
position: absolute;
left: -1px; top: -1px; bottom: -1px;
width: 2px;
background: var(--sodium);
}
.charslot {
font-family: 'Plex Mono', monospace;
font-size: 10px;
letter-spacing: .1em;
color: var(--muted);
border: 1px solid var(--line);
padding: 4px 6px;
}
.charcard.on .charslot { color: var(--sodium); border-color: var(--sodium-line); }
.charname { display: block; font-size: 17px; font-weight: 600; letter-spacing: -.015em; }
.charmeta {
display: block;
font-family: 'Plex Mono', monospace;
font-size: 11px;
color: var(--muted);
margin-top: 4px;
letter-spacing: .02em;
}
.charmoney {
font-family: 'Plex Mono', monospace;
font-size: 12.5px;
color: var(--paper-dim);
text-align: right;
font-variant-numeric: tabular-nums;
}
.charcard.empty {
border-style: dashed;
background: transparent;
color: var(--muted);
grid-template-columns: auto 1fr;
}
.charcard.empty:hover { border-color: var(--sodium-line); color: var(--sodium); }
.charcard.empty .charname { font-size: 14px; font-weight: 500; color: inherit; }
.charactions { display: grid; grid-template-columns: 1fr auto; gap: 10px; }
/* =========================================================================
creator
========================================================================= */
.creator { padding-bottom: 26px; }
.tabs {
display: flex;
flex-wrap: wrap;
gap: 2px;
margin-bottom: 22px;
border-bottom: 1px solid var(--line);
}
.tab {
position: relative;
background: none;
border: 0;
padding: 10px 13px;
color: var(--muted);
font-family: inherit;
font-size: 12.5px;
letter-spacing: .01em;
cursor: pointer;
transition: color 220ms var(--ease);
}
.tab:hover { color: var(--paper-dim); }
.tab.is-on { color: var(--paper); }
.tab.is-on::after {
content: '';
position: absolute;
left: 0; right: 0; bottom: -1px;
height: 2px;
background: var(--sodium);
animation: tabin 320ms var(--ease-out);
}
@keyframes tabin { from { transform: scaleX(.2); } to { transform: scaleX(1); } }
.panes { min-height: 300px; }
.pane { display: none; }
.pane.is-on { display: block; animation: panein 420ms var(--ease-out); }
@keyframes panein {
from { opacity: 0; transform: translateX(10px); }
to { opacity: 1; transform: none; }
}
.ctrl { margin-bottom: 15px; }
.ctrl-head {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 6px;
gap: 12px;
}
.ctrl-label {
font-size: 13px;
color: var(--paper-dim);
}
.ctrl-val {
font-family: 'Plex Mono', monospace;
font-size: 11px;
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.slider {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 20px;
background: transparent;
cursor: pointer;
}
.slider::-webkit-slider-runnable-track { height: 2px; background: var(--line); }
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 3px;
height: 14px;
margin-top: -6px;
background: var(--sodium);
border: 0;
transition: height 150ms var(--ease), margin-top 150ms var(--ease);
}
.slider:hover::-webkit-slider-thumb,
.slider:focus-visible::-webkit-slider-thumb { height: 20px; margin-top: -9px; }
.stepper { display: flex; align-items: stretch; gap: 0; }
.stepper button {
width: 34px;
background: transparent;
border: 1px solid var(--line);
color: var(--paper-dim);
font-family: 'Plex Mono', monospace;
font-size: 14px;
cursor: pointer;
transition: color 180ms var(--ease), border-color 180ms var(--ease), background 180ms var(--ease);
}
.stepper button:hover { color: var(--sodium); border-color: var(--sodium-line); }
.stepper button:active { background: var(--sodium-soft); }
.stepper .stepval {
flex: 1;
display: grid;
place-items: center;
border: 1px solid var(--line);
border-left: 0; border-right: 0;
font-family: 'Plex Mono', monospace;
font-size: 12px;
color: var(--paper);
font-variant-numeric: tabular-nums;
}
/* 64 colours is a lot of surface; kept small so a palette never outweighs the
control it belongs to */
.swatches { display: flex; flex-wrap: wrap; gap: 4px; }
.swatch {
width: 13px; height: 13px;
border: 1px solid var(--line);
cursor: pointer;
padding: 0;
transition: transform 160ms var(--ease), border-color 160ms var(--ease);
}
.swatch:hover { transform: scale(1.3); }
.swatch.on { border-color: var(--sodium); transform: scale(1.3); }
.seg { display: flex; gap: 0; margin-bottom: 22px; }
.seg button {
flex: 1;
height: 42px;
background: transparent;
border: 1px solid var(--line);
color: var(--muted);
font-family: inherit;
font-size: 12.5px;
letter-spacing: .05em;
text-transform: uppercase;
cursor: pointer;
transition: color 220ms var(--ease), background 220ms var(--ease), border-color 220ms var(--ease);
}
.seg button + button { border-left: 0; }
.seg button.on {
color: var(--ink-900);
background: var(--sodium);
border-color: var(--sodium);
}
.seg button:not(.on):hover { color: var(--paper); }
.field textarea {
width: 100%;
min-height: 132px;
padding: 13px 14px;
background: rgba(20,23,29,.72);
border: 1px solid var(--line);
color: var(--paper);
font-family: inherit;
font-size: 14.5px;
line-height: 1.55;
resize: vertical;
transition: border-color 220ms var(--ease), background 220ms var(--ease);
}
.field textarea:focus {
outline: none;
border-color: var(--sodium);
background: rgba(29,33,42,.85);
}
.counter {
margin-top: 6px;
text-align: right;
font-family: 'Plex Mono', monospace;
font-size: 10.5px;
color: var(--muted);
}
.creator-foot { display: grid; grid-template-columns: auto 1fr; gap: 10px; margin-top: 24px; }
/* drag surface over the world half */
.turntable {
position: fixed;
top: 0; right: 0; bottom: 0;
left: clamp(470px, 36vw, 640px);
z-index: 15;
cursor: grab;
}
.turntable.dragging { cursor: grabbing; }
.turnhint {
position: absolute;
left: 50%;
bottom: 46px;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 9px;
padding: 7px 13px;
border: 1px solid var(--line);
background: rgba(8,9,11,.55);
color: var(--paper-dim);
letter-spacing: .1em;
text-transform: uppercase;
font-size: 10px;
transition: opacity 420ms var(--ease);
}
.turntable.dragging .turnhint { opacity: 0; }
.turnicon {
width: 13px; height: 13px;
border: 1px solid currentColor;
border-radius: 50%;
border-left-color: transparent;
border-right-color: transparent;
animation: turnspin 2.6s linear infinite;
}
@keyframes turnspin { to { transform: rotate(360deg); } }
/* =========================================================================
spawn
========================================================================= */
.spawnlist { list-style: none; margin-bottom: 24px; }
.spawncard {
position: relative;
padding: 15px 17px;
margin-bottom: 8px;
border: 1px solid var(--line);
background: rgba(20,23,29,.55);
cursor: pointer;
transition: border-color 240ms var(--ease), background 240ms var(--ease), transform 240ms var(--ease);
}
.spawncard:hover { border-color: rgba(232,228,220,.24); transform: translateX(3px); }
.spawncard.on { border-color: var(--sodium); background: rgba(255,159,69,.07); }
.spawncard.on::before {
content: '';
position: absolute;
left: -1px; top: -1px; bottom: -1px;
width: 2px;
background: var(--sodium);
}
.spawnhead { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; }
.spawnname { font-size: 15.5px; font-weight: 600; letter-spacing: -.01em; }
.spawnarea {
font-family: 'Plex Mono', monospace;
font-size: 10px;
letter-spacing: .14em;
text-transform: uppercase;
color: var(--muted);
}
.spawnblurb { margin-top: 5px; font-size: 13px; color: var(--paper-dim); max-width: 42ch; }
.chart {
position: fixed;
top: 50%;
right: clamp(40px, 7vw, 130px);
transform: translateY(-50%);
width: min(38vw, 44vh);
aspect-ratio: 1;
z-index: 18;
pointer-events: none;
opacity: 0;
animation: chartin 900ms var(--ease-out) 260ms forwards;
}
@keyframes chartin {
from { opacity: 0; transform: translateY(-50%) scale(.96); }
to { opacity: 1; transform: translateY(-50%) scale(1); }
}
.chart svg { width: 100%; height: 100%; display: block; }
/* the coastline draws itself once, then stays */
#coastline {
stroke-dasharray: 1400;
stroke-dashoffset: 1400;
animation: draw 2600ms var(--ease-out) 400ms forwards;
}
@keyframes draw { to { stroke-dashoffset: 0; } }
.pins { position: absolute; inset: 0; }
/* The marker rotates; the label must not, so they are separate elements
inside an unrotated anchor. */
.pin { position: absolute; width: 0; height: 0; }
.pin-mark {
position: absolute;
left: -6px; top: -6px;
width: 12px; height: 12px;
border: 1px solid var(--sodium-line);
background: rgba(255,159,69,.16);
transform: rotate(45deg);
transition: background 260ms var(--ease), border-color 260ms var(--ease), transform 260ms var(--ease);
}
.pin-label {
position: absolute;
left: 18px;
top: -7px;
white-space: nowrap;
font-family: 'Plex Mono', monospace;
font-size: 10px;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--muted);
transition: color 260ms var(--ease);
}
.pin.on .pin-mark {
background: var(--sodium);
border-color: var(--sodium);
transform: rotate(45deg) scale(1.35);
}
.pin.on .pin-label { color: var(--paper); }
/* pins on the eastern side label leftwards so nothing runs off the sheet */
.pin.flip .pin-label { left: auto; right: 18px; }
/* a single ping when selected, not a permanent pulse */
.pin.on .pin-mark::before {
content: '';
position: absolute;
inset: -6px;
border: 1px solid var(--sodium);
animation: ping 900ms var(--ease-out);
}
/* survey chart furniture */
.chart-frame { fill: none; stroke: rgba(232,228,220,.10); }
.chart-ticks { fill: none; stroke: rgba(255,159,69,.45); stroke-width: 2; }
.chart-label {
font-family: 'Plex Mono', monospace;
font-size: 19px;
letter-spacing: 3.4px;
fill: #6e747f;
text-transform: uppercase;
}
@keyframes ping {
from { opacity: .9; transform: scale(.6); }
to { opacity: 0; transform: scale(1.9); }
}
/* =========================================================================
the stamp
========================================================================= */
.stamp {
position: fixed;
top: 46%;
left: 62%;
z-index: 60;
padding: 14px 30px;
border: 3px solid var(--stamp);
color: var(--stamp);
font-family: 'Plex Mono', monospace;
font-weight: 500;
font-size: 30px;
letter-spacing: .22em;
text-transform: uppercase;
opacity: 0;
pointer-events: none;
transform: translate(-50%, -50%) rotate(-19deg) scale(2.6);
}
.stamp::before {
content: '';
position: absolute;
inset: 4px;
border: 1px solid var(--stamp);
opacity: .55;
}
.stamp.show { animation: stamp 1500ms var(--ease-out) forwards; }
@keyframes stamp {
0% { opacity: 0; transform: translate(-50%,-50%) rotate(-19deg) scale(2.6); }
38% { opacity: .95; transform: translate(-50%,-50%) rotate(-6deg) scale(.94); }
48% { transform: translate(-50%,-50%) rotate(-8deg) scale(1.04); }
58% { transform: translate(-50%,-50%) rotate(-7deg) scale(1); }
80% { opacity: .95; }
100% { opacity: 0; transform: translate(-50%,-50%) rotate(-7deg) scale(1); }
}
@@ -1,117 +0,0 @@
/* ---------------------------------------------------------------------------
Los Santos RP - design tokens.
The interface is styled as a municipal record being opened on you: strict
grid, hairline rules, monospaced serials, and one accent taken from the only
light source the city has at night - sodium street lamps.
Shared verbatim by rp_loading and rp_ui so the four screens read as one
product.
--------------------------------------------------------------------------- */
@font-face {
font-family: 'Archivo';
src: url('fonts/archivo.woff2') format('woff2-variations');
font-weight: 100 900;
font-display: block;
}
@font-face {
font-family: 'Plex Mono';
src: url('fonts/plex-400.woff2') format('woff2');
font-weight: 400;
font-display: block;
}
@font-face {
font-family: 'Plex Mono';
src: url('fonts/plex-500.woff2') format('woff2');
font-weight: 500;
font-display: block;
}
:root {
/* asphalt at night - cold, never pure black */
--ink-900: #08090b;
--ink-800: #0d0f13;
--ink-700: #14171d;
--ink-600: #1d212a;
--ink-500: #272c37;
/* document stock */
--paper: #e8e4dc;
--paper-dim: #a9a69f;
--muted: #6e747f;
/* the accent: sodium vapour, the colour of the city from the air */
--sodium: #ff9f45;
--sodium-soft: rgba(255, 159, 69, 0.14);
--sodium-line: rgba(255, 159, 69, 0.42);
--sodium-deep: #c4711f;
/* semantic only, never decorative */
--stamp: #c4462f;
--good: #7f9f5a;
--line: rgba(232, 228, 220, 0.13);
--line-soft: rgba(232, 228, 220, 0.07);
--rail: clamp(400px, 31vw, 520px);
--gut-x: clamp(32px, 3.4vw, 56px);
--gut-y: clamp(28px, 3vw, 48px);
--ease: cubic-bezier(0.22, 0.61, 0.36, 1);
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%;
overflow: hidden;
background: var(--ink-900);
color: var(--paper);
font-family: 'Archivo', system-ui, sans-serif;
font-weight: 400;
font-size: 15px;
line-height: 1.55;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
/* Typographic roles --------------------------------------------------------- */
.eyebrow {
font-family: 'Plex Mono', monospace;
font-size: 10.5px;
font-weight: 500;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--muted);
}
.data {
font-family: 'Plex Mono', monospace;
font-size: 12px;
letter-spacing: 0.04em;
color: var(--paper-dim);
font-variant-numeric: tabular-nums;
}
.rule {
height: 1px;
background: var(--line);
border: 0;
}
/* Focus is always visible and always the accent. */
:focus-visible {
outline: 2px solid var(--sodium);
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
}
}
-4
View File
@@ -1,4 +0,0 @@
RP_DB=/opt/fivem/datasvc/rp.db
RP_TOKEN=generate_your_own_random_token
RP_BIND=127.0.0.1
RP_PORT=7788
-1
View File
@@ -1 +0,0 @@
sv_licenseKey "CFX_LICENSE_KEY=cfxk_your_key_here"
+31 -17
View File
@@ -1,26 +1,40 @@
# JustRP — пример server.cfg
# Скопируйте в server.cfg и подставьте свои значения.
# ---------------------------------------------------------------------------
# Los Santos RP - server configuration
# Copy to /opt/fivem/data/server.cfg (install.sh does this for you).
# ---------------------------------------------------------------------------
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_maxclients 48
sv_hostname "Los Santos Roleplay"
sets sv_projectName "Los Santos Roleplay"
sets sv_projectDesc "Serious roleplay. Custom framework, written from scratch."
set onesync on
sv_enforceGameBuild 3095
sv_scriptHookAllowed 0
onesync on
# Ключ сервера с https://keymaster.fivem.net — НИКОГДА не публикуйте его
sv_licenseKey "СЮДА_ВАШ_КЛЮЧ"
# Licence key and database credentials. Mode 600, never echoed.
exec /opt/fivem/data/secrets.cfg
# Должен совпадать с JUSTRP_API_SECRET у Python-API
set justrp_api_key "СЮДА_СВОЙ_КЛЮЧ"
# --- resources -------------------------------------------------------------
# Nothing from cfx-server-data is used. The player cap, chat, spawning and
# session handling are all implemented in the resources below.
ensure rp_db
ensure rp_core
ensure rp_session
ensure rp_loading
ensure rp_ui
ensure mapmanager
ensure chat
ensure spawnmanager
ensure sessionmanager
ensure fivem-map-hipster
ensure basic-gamemode
ensure hardcap
# Diagnostics. Safe to leave on; they only touch their own marker rows.
ensure rp_selftest
ensure justrp
# Driver test suite, off by default:
# ensure rp_dbtest
# Admin access. Add real identifiers here, e.g.
# add_principal identifier.license:<licence hash> group.admin
# Deliberately empty: a placeholder like identifier.fivem:1 would grant admin
# to whoever actually owns that Cfx account.
add_ace group.admin command allow