FiveRP: the server from the hosting account, not the earlier one
Wrong server was published before. This is the one that runs on the hosting account the video was made on: FiveRP - two-step registration (account, then an identity printed onto a passport), login that returns you to your resident, and a loading screen driven by the game's own streaming events. resources/[local]/fiverp-auth NUI + scrypt hashes + oxmysql resources/[local]/fiverp-characters identity, character load, spawn resources/[local]/fiverp-loadscreen loading screen sql/schema.sql accounts, characters docs/DESIGN.md the design system every screen follows docs/screens/ shot from the live server Only our own code is in here. cfx-server-data and oxmysql are fetched by install.sh instead of being vendored, which keeps the repo at 3.7 MB. install.sh does the whole box in one command: recommended FXServer build, cfx-server-data, oxmysql, MariaDB with the schema, resources, server.cfg (mode 600 - it carries the key and the database password), boot entry, then it waits for the Cfx registration. Tested end to end on a spare install root: 29 resources scanned, database and schema created, and it stopped exactly where a wrong licence key should stop it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@@ -1,12 +1,11 @@
|
||||
# Anything holding a licence key or a password
|
||||
server.cfg
|
||||
secrets.cfg
|
||||
*.env
|
||||
.env
|
||||
|
||||
# Runtime state, never in the repo
|
||||
# Runtime state
|
||||
cache/
|
||||
logs/
|
||||
*.log
|
||||
/server/
|
||||
/data/
|
||||
/server.cfg
|
||||
server/
|
||||
server-data/
|
||||
|
||||
@@ -1,36 +1,28 @@
|
||||
# redl-fivem-rp
|
||||
# FiveRP
|
||||
|
||||
An open-source **FiveM roleplay base written completely from scratch** — no ESX, no QBCore, no scripts downloaded from a forum. Every line here was written by an AI agent on a live server, on camera, and it is free for anyone to take and build on.
|
||||
An open **FiveM roleplay base** — account registration, login and character identity, written by an AI agent on a live server, on camera, and free for anyone to take and build on.
|
||||
|
||||
> 🇷🇺 Русская версия — ниже / 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.
|
||||
Not a framework fork: `fiverp-auth`, `fiverp-characters` and `fiverp-loadscreen` are written for this server. No ESX, no QBCore. The only things that come from outside are the stock Cfx resources (`mapmanager`, `chat`, `spawnmanager`, `sessionmanager`, `hardcap`) and `oxmysql` — the installer fetches both, so this repository stays small and readable.
|
||||
|
||||
## Why another base?
|
||||
## What it does today
|
||||
|
||||
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.
|
||||
- **Two-step registration** — account first (email, username, password), identity second (first and last name, issued once, printed onto a passport).
|
||||
- **Login** that returns you to your resident, with the character loaded from the database.
|
||||
- **Loading screen** with real progress from the game's own streaming events.
|
||||
- **Passwords hashed with scrypt**, per-password salt, stored as `scrypt$salt$hash`. The client never decides anything: registration, login and spawning are all server side.
|
||||
- **`basic-gamemode` is deliberately off** — it force-respawns on map start, which would drop a player into the world behind the auth screen. `fiverp-auth` owns spawning instead.
|
||||
|
||||
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.
|
||||
| Loading | Sign in | Register | Identity |
|
||||
|---|---|---|---|
|
||||
|  |  |  |  |
|
||||
|
||||
## What's in it
|
||||
|
||||
- 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
|
||||
|
||||
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.
|
||||
|
||||
| Auth — RESIDENT INTAKE | Character — identity record | Spawn — county survey grid |
|
||||
|---|---|---|
|
||||
|  |  |  |
|
||||
The whole interface follows one design system — a light, glassy theme on **Unbounded**, with the city visible behind the panels. Every colour, radius and font is a token; the rules are in [`docs/DESIGN.md`](docs/DESIGN.md), and the fonts ship with the resources (a NUI page has no guaranteed internet).
|
||||
|
||||
## Install
|
||||
|
||||
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).
|
||||
Ubuntu 22.04 / 24.04 or Debian 12, and a free server key from [keymaster.fivem.net](https://keymaster.fivem.net).
|
||||
|
||||
```sh
|
||||
git clone https://github.com/RedlHosting/redl-fivem-rp.git
|
||||
@@ -38,79 +30,63 @@ cd redl-fivem-rp
|
||||
sudo sh install.sh --licence cfxk_your_key_here
|
||||
```
|
||||
|
||||
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`.
|
||||
The installer fetches the recommended FXServer build, clones `cfx-server-data`, installs `oxmysql`, sets up MariaDB with the schema and a generated password, copies the FiveRP resources in, writes `server.cfg`, adds a boot entry and waits until the server 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.
|
||||
Options: `--dir`, `--hostname`, `--port`, `--name` (a second server on the same box), `--db-name`, `--db-user`, `--db-pass`, `--build`, `--no-start`. Run `sh install.sh --help`.
|
||||
|
||||
Day to day:
|
||||
|
||||
```sh
|
||||
service fivem start|stop|restart|status # systemd boxes: systemctl ...
|
||||
rcon status # send a command to the live console
|
||||
service fiverp start|stop|restart|status # systemd boxes: systemctl ...
|
||||
rcon status # command into 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
|
||||
resources/[local]/fiverp-auth registration and login (NUI + scrypt + oxmysql)
|
||||
resources/[local]/fiverp-characters identity, character load and spawn
|
||||
resources/[local]/fiverp-loadscreen loading screen
|
||||
sql/schema.sql accounts, characters
|
||||
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.
|
||||
bin/ keep-alive supervisor, rcon, init script
|
||||
docs/DESIGN.md the design system every screen follows
|
||||
|
||||
## 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.
|
||||
Everything after the spawn: chat, HUD, inventory, money, jobs, vehicles. The `characters` table already carries model, appearance, position and heading, so gameplay has somewhere to write.
|
||||
|
||||
- **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** — an admin panel and the project website on the same machine
|
||||
|
||||
Issues and pull requests are welcome — this is meant to be used, not admired.
|
||||
Issues and pull requests welcome — this is meant to be used, not admired.
|
||||
|
||||
## How it was made
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Licence
|
||||
|
||||
MIT — take it, change it, ship it. Credit is nice but not required.
|
||||
MIT for the FiveRP code. `cfx-server-data` and `oxmysql` keep their own licences; Unbounded is under the SIL Open Font License.
|
||||
|
||||
---
|
||||
|
||||
# redl-fivem-rp (RU)
|
||||
# FiveRP (RU)
|
||||
|
||||
Открытая **ролевая база для FiveM, написанная полностью с нуля** — без ESX, без QBCore, без скачанных с форума скриптов. Каждая строчка здесь написана ИИ-агентом на живом сервере, в кадре, и выложена бесплатно для всех.
|
||||
Открытая **ролевая база для FiveM** — регистрация аккаунта, вход и личность персонажа, написанные ИИ-агентом на живом сервере, в кадре, и выложенные бесплатно для всех.
|
||||
|
||||
В репозитории лежит сам сервер, ровно в том виде, в каком он работает: ресурсы, схема базы, супервизор, конфиг. `sh install.sh` ставит его на чистую Ubuntu.
|
||||
Это не форк фреймворка: `fiverp-auth`, `fiverp-characters` и `fiverp-loadscreen` написаны под этот сервер. Никакого ESX и QBCore. Извне берутся только штатные ресурсы Cfx (`mapmanager`, `chat`, `spawnmanager`, `sessionmanager`, `hardcap`) и `oxmysql` — установщик скачивает их сам, поэтому репозиторий остаётся маленьким и читаемым.
|
||||
|
||||
## Зачем ещё одна база?
|
||||
## Что уже работает
|
||||
|
||||
Обычно проект начинается с того, что ставится сборка на 200 мегабайт, из которой реально используется процентов пять. Вместе с ней приезжают чужие баги, чужие зависимости, и в итоге у тебя такой же сервер, как ещё у тысячи человек, — и трогать его страшно, потому что непонятно, что отвалится.
|
||||
- **Регистрация в два шага** — сначала аккаунт (почта, логин, пароль), потом личность (имя и фамилия, выдаются один раз и печатаются на паспорте).
|
||||
- **Вход**, который возвращает к своему персонажу — данные поднимаются из базы.
|
||||
- **Загрузочный экран** с настоящим прогрессом из событий стриминга игры.
|
||||
- **Пароли на scrypt**, соль на каждый пароль, хранение в виде `scrypt$соль$хеш`. Клиент ничего не решает: регистрация, вход и спавн — на сервере.
|
||||
- **`basic-gamemode` намеренно выключен** — он делает принудительный респавн при старте карты и выбрасывал бы игрока в мир за экраном авторизации. Спавном занимается `fiverp-auth`.
|
||||
|
||||
Здесь наоборот. Мало кода, он читаемый, и в нём есть только то, что мы попросили. Не понял файл — попросил переписать.
|
||||
|
||||
## Что внутри
|
||||
|
||||
- Свой драйвер 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` не используется вообще — лимит игроков, чат, спавн и сессии свои.
|
||||
|
||||
## Экраны
|
||||
|
||||
Весь интерфейс сделан в одной теме, которую агент выбрал сам, — **архив округа Лос-Сантос**: вход это дело о проживании, создание персонажа — анкета учёта личности, выбор спавна — топографическая карта округа.
|
||||
Весь интерфейс живёт по одной дизайн-системе — светлая «стеклянная» тема на шрифте **Unbounded**, город виден за панелями. Каждый цвет, радиус и шрифт — токен; правила в [`docs/DESIGN.md`](docs/DESIGN.md), шрифты лежат внутри ресурсов (у страницы NUI нет гарантированного интернета).
|
||||
|
||||
## Установка
|
||||
|
||||
Ubuntu 22.04 / 24.04 (или Debian 12), рекомендую 4 vCPU / 8 ГБ RAM, и бесплатный ключ сервера с [keymaster.fivem.net](https://keymaster.fivem.net).
|
||||
Ubuntu 22.04 / 24.04 или Debian 12 и бесплатный ключ сервера с [keymaster.fivem.net](https://keymaster.fivem.net).
|
||||
|
||||
```sh
|
||||
git clone https://github.com/RedlHosting/redl-fivem-rp.git
|
||||
@@ -118,44 +94,38 @@ cd redl-fivem-rp
|
||||
sudo sh install.sh --licence cfxk_ваш_ключ
|
||||
```
|
||||
|
||||
Установщик сам скачает рекомендованную сборку FXServer, поставит MariaDB и накатит схему, разложит ресурсы в `/opt/fivem`, напишет `server.cfg` и `secrets.cfg` со сгенерированным паролем базы, поставит сервер в автозапуск и дождётся регистрации в Cfx. Дальше — `connect <ваш ip>:30120`.
|
||||
Установщик скачает рекомендованную сборку FXServer, склонирует `cfx-server-data`, поставит `oxmysql`, поднимет MariaDB со схемой и сгенерированным паролем, положит ресурсы FiveRP, напишет `server.cfg`, пропишет автозапуск и дождётся регистрации сервера в Cfx. Дальше — `connect <ваш ip>:30120`.
|
||||
|
||||
Полезные параметры: `--dir`, `--hostname`, `--port`, `--name` (несколько серверов на одной машине), `--db-name`, `--db-user`, `--no-start`. Список — `sh install.sh --help`.
|
||||
Параметры: `--dir`, `--hostname`, `--port`, `--name` (второй сервер на той же машине), `--db-name`, `--db-user`, `--db-pass`, `--build`, `--no-start`. Список — `sh install.sh --help`.
|
||||
|
||||
Повседневное:
|
||||
|
||||
```sh
|
||||
service fivem start|stop|restart|status # на машинах с systemd — systemctl ...
|
||||
rcon status # команда в живую консоль сервера
|
||||
service fiverp 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 нет гарантированного интернета
|
||||
resources/[local]/fiverp-auth регистрация и вход (NUI + scrypt + oxmysql)
|
||||
resources/[local]/fiverp-characters личность, загрузка персонажа и спавн
|
||||
resources/[local]/fiverp-loadscreen загрузочный экран
|
||||
sql/schema.sql accounts, characters
|
||||
server.cfg.example основной конфиг
|
||||
etc/secrets.cfg.example
|
||||
docs/SERVER.md как всё устроено, ресурс за ресурсом
|
||||
bin/ супервизор, rcon, init-скрипт
|
||||
docs/DESIGN.md дизайн-система, по которой сделаны все экраны
|
||||
|
||||
## Чего ещё нет
|
||||
|
||||
Всё до спавна и фундамент под этим — готово. Геймплей сверху — чат, HUD, инвентарь, работы, транспорт, полиция, медицина — нет. Таблицы `characters`, `transactions`, `inventory`, `vehicles` уже заведены, денежный API в `rp_core/server/player.lua` журналируемый и готов.
|
||||
|
||||
- **Часть 1** — фундамент: база, регистрация, авторизация, создание персонажа ← *мы здесь*
|
||||
- **Часть 2** — деньги, работы, банк
|
||||
- **Часть 3** — инвентарь и телефон
|
||||
- **Часть 4** — гаражи и транспорт
|
||||
- **Часть 5** — админка + сайт проекта на этой же машине
|
||||
Всего, что после спавна: чат, HUD, инвентарь, деньги, работы, транспорт. В таблице `characters` уже есть модель, внешность, позиция и поворот — геймплею есть куда писать.
|
||||
|
||||
Issues и pull request'ы приветствуются — это сделано, чтобы этим пользовались.
|
||||
|
||||
## Как это сделано
|
||||
|
||||
Всё собирается на VDS [REDL](https://redl.io), где ИИ-агент живёт прямо на сервере с root-доступом. Задача ставится обычными словами, агент пишет код, чинит свои же ошибки и перезапускает сервис. Весь процесс записан — включая те моменты, где всё ломалось.
|
||||
Собрано на VDS [REDL](https://redl.io), где ИИ-агент живёт прямо на сервере с root-доступом. Задача ставится обычными словами, агент пишет код, чинит свои же ошибки и перезапускает сервис. Весь процесс записан — включая моменты, где всё ломалось.
|
||||
|
||||
## Лицензия
|
||||
|
||||
MIT — берите, меняйте, используйте. Упоминание приятно, но не обязательно.
|
||||
MIT на код FiveRP. У `cfx-server-data` и `oxmysql` свои лицензии; Unbounded — под SIL Open Font License.
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
/* 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;
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
/* 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;
|
||||
}
|
||||
@@ -1,43 +1,33 @@
|
||||
#!/bin/sh
|
||||
### BEGIN INIT INFO
|
||||
# Provides: fivem
|
||||
# Provides: fiverp
|
||||
# Required-Start: $network mariadb
|
||||
# Default-Start: 2 3 4 5
|
||||
# Short-Description: Los Santos RP FXServer
|
||||
# Short-Description: FiveRP (FXServer)
|
||||
### END INIT INFO
|
||||
|
||||
NAME=fivem
|
||||
DATA=/opt/fivem/data
|
||||
SERVER=/opt/fivem/server
|
||||
NAME=fiverp
|
||||
DIR=/opt/fivem
|
||||
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
|
||||
echo "$NAME running (pid $(cat $CHILDPID))"; return 0
|
||||
fi
|
||||
echo "$NAME not running"
|
||||
return 3
|
||||
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
|
||||
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 &
|
||||
setsid $DIR/bin/run-server.sh $NAME $DIR $LOG >/dev/null 2>&1 < /dev/null &
|
||||
sleep 2
|
||||
echo "$NAME started"
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
#!/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
|
||||
# Send a command to the running FXServer console, e.g. rcon status
|
||||
FIFO=${FIFO:-/run/fiverp.stdin}
|
||||
if [ ! -p "$FIFO" ]; then
|
||||
echo "server is not running (no console pipe at $FIFO)" >&2
|
||||
exit 1
|
||||
fi
|
||||
printf '%s\n' "$*" > /run/fivem.stdin
|
||||
printf '%s\n' "$*" > "$FIFO"
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/bin/sh
|
||||
# ---------------------------------------------------------------------------
|
||||
# run-server.sh <name> <dir> <logfile>
|
||||
#
|
||||
# Keeps FXServer alive and gives it a console you can talk to.
|
||||
#
|
||||
# * FXServer reads its console from stdin and quits the moment stdin hits
|
||||
# EOF, so it gets a FIFO whose write end this script holds open. That FIFO
|
||||
# doubles as the command channel used by bin/rcon.
|
||||
# * A server that dies instantly is usually failing for a reason that will
|
||||
# not fix itself (bad licence key, database down). The restart delay backs
|
||||
# off to 5 minutes so a broken server does not hammer Cfx, and resets once
|
||||
# it has stayed up for a while.
|
||||
# ---------------------------------------------------------------------------
|
||||
NAME="$1"; DIR="$2"; LOG="$3"
|
||||
FIFO="/run/$NAME.stdin"
|
||||
MIN=5; MAX=300; HEALTHY=120
|
||||
|
||||
mkdir -p "$(dirname "$LOG")"
|
||||
echo $$ > "/run/$NAME.sup.pid"
|
||||
[ -p "$FIFO" ] || { rm -f "$FIFO"; mkfifo "$FIFO"; }
|
||||
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
|
||||
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)
|
||||
|
||||
( cd "$DIR/server-data" && exec "$DIR/server/run.sh" +exec server.cfg ) >> "$LOG" 2>&1 <&9 &
|
||||
CHILD=$!
|
||||
echo $CHILD > "/run/$NAME.child.pid"
|
||||
wait $CHILD
|
||||
RC=$?
|
||||
|
||||
ran=$(( $(date +%s) - started ))
|
||||
if [ "$ran" -ge "$HEALTHY" ]; then delay=$MIN; else delay=$(( delay * 2 )); [ "$delay" -gt "$MAX" ] && delay=$MAX; fi
|
||||
echo "=== $(date -Is) $NAME exited (rc=$RC) after ${ran}s, restarting in ${delay}s ===" >> "$LOG"
|
||||
sleep "$delay"
|
||||
done
|
||||
@@ -1,66 +0,0 @@
|
||||
#!/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
|
||||
@@ -0,0 +1,303 @@
|
||||
# FiveRP — Server Design System
|
||||
|
||||
Единый стиль для **всего** сервера: loading screen, авторизация, будущие меню
|
||||
(инвентарь, телефон, банк, HUD). Основан на белой «стеклянной» теме Filmorum
|
||||
(`fiveM_design.md`), адаптирован под CEF/NUI FiveM.
|
||||
|
||||
> Правило номер один: **ни один компонент не хардкодит цвет, радиус или шрифт.**
|
||||
> Только `var(--…)` из блока токенов ниже. Смена темы = одна строка.
|
||||
|
||||
---
|
||||
|
||||
## 0. Шрифт — Unbounded
|
||||
|
||||
**Unbounded — основной шрифт всего сервера.** Геометрический дисплейный гротеск,
|
||||
переменная ось веса 300–800. Никаких других сансов в интерфейсе.
|
||||
|
||||
Файлы лежат в каждом ресурсе локально (`html/fonts/`), а НЕ грузятся с
|
||||
`fonts.googleapis.com`: у части игроков CEF стартует раньше сети, и веб-шрифт
|
||||
успевает не приехать — интерфейс мигает системным шрифтом. Локальный woff2
|
||||
рисуется с первого кадра.
|
||||
|
||||
```css
|
||||
@font-face {
|
||||
font-family: 'Unbounded';
|
||||
font-style: normal;
|
||||
font-weight: 300 800; /* вариативный: один файл на все веса */
|
||||
font-display: block; /* block, не swap — подмены шрифта быть не должно */
|
||||
src: url('fonts/unbounded-latin.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;
|
||||
}
|
||||
/* + unbounded-latin-ext.woff2 и unbounded-cyrillic.woff2 теми же блоками */
|
||||
```
|
||||
|
||||
```css
|
||||
--sans: 'Unbounded', -apple-system, BlinkMacSystemFont, 'Segoe UI',
|
||||
Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
--mono: ui-monospace, 'SF Mono', SFMono-Regular, Menlo, Consolas,
|
||||
'Liberation Mono', 'DejaVu Sans Mono', monospace;
|
||||
```
|
||||
|
||||
**Как набирать Unbounded.** Шрифт широкий и характерный — он требует другой
|
||||
типографики, чем SF Pro:
|
||||
|
||||
| Роль | Размер | Вес | Трекинг |
|
||||
|---|---|---|---|
|
||||
| Display (лого, экран загрузки) | 44–72px | 800 | `-0.04em` |
|
||||
| H1 (заголовок панели) | 26–30px | 700 | `-0.035em` |
|
||||
| H2 (секция) | 18–20px | 600 | `-0.03em` |
|
||||
| Кнопка / линк | 14px | 600 | `-0.01em` |
|
||||
| Body / инпут | 14–15px | 400 | `-0.005em` |
|
||||
| Лейбл поля | 11px | 600 | `+0.09em`, `uppercase` |
|
||||
| Eyebrow / надзаголовок | 10px | 600 | `+0.20em`, `uppercase` |
|
||||
| Метаданные | 11–12px | 400 | `0` |
|
||||
|
||||
Правила:
|
||||
- **Крупный кегль = минусовой трекинг.** У Unbounded широкие апроши; без
|
||||
`letter-spacing: -0.03em` заголовок расползается.
|
||||
- **Мелкий кегль = плюсовой трекинг + uppercase.** Ниже 12px строчные буквы
|
||||
Unbounded слипаются — все подписи только капслоком с разрядкой.
|
||||
- **Веса 300 и 800 — крайности**, в интерфейсе живут 400/500/600/700.
|
||||
- **Не набирать Unbounded длинный текст.** Абзац больше двух строк — читать
|
||||
тяжело. Для многострочных описаний кегль 13px/1.65 и вес 400, не больше.
|
||||
- **Цифры документов, ID, MRZ — только `--mono`.** Unbounded для них слишком
|
||||
«дизайнерский», а нужен вид машинного считывания.
|
||||
|
||||
---
|
||||
|
||||
## 1. Философия
|
||||
|
||||
- **Air, light, frosted** — полупрозрачные стеклянные панели поверх живой игры.
|
||||
- **Кислород** — между карточками 12px, между секциями 40–48px.
|
||||
- **Один акцент** — синий `#0071e3`, дозированно: кнопка, ссылка, фокус.
|
||||
- **Сдержанное движение** — fade и подъём на 4px. Анимации ради анимации нет.
|
||||
- **Один герой на экран** — крупный объект (паспорт, лого), остальное тихое.
|
||||
|
||||
---
|
||||
|
||||
## 2. Токены
|
||||
|
||||
```css
|
||||
:root {
|
||||
--bg: #f5f5f7;
|
||||
--bg-deep: #e8e8ed;
|
||||
--surface: rgba(255, 255, 255, 0.72);
|
||||
--surface-solid:#ffffff;
|
||||
--panel: rgba(255, 255, 255, 0.80);
|
||||
--header-bg: rgba(255, 255, 255, 0.70);
|
||||
--field-bg: rgba(0, 0, 0, 0.04);
|
||||
--field-bg-focus:#ffffff;
|
||||
|
||||
--fg: #1d1d1f;
|
||||
--fg-secondary: #6e6e73;
|
||||
--fg-tertiary: #a1a1a6;
|
||||
|
||||
--accent: #0071e3;
|
||||
--accent-strong:#0077ed;
|
||||
--accent-soft: rgba(0, 113, 227, 0.08);
|
||||
--ring: rgba(0, 113, 227, 0.18);
|
||||
|
||||
--line: rgba(0, 0, 0, 0.06);
|
||||
--line-strong: rgba(0, 0, 0, 0.10);
|
||||
--hover: rgba(0, 0, 0, 0.04);
|
||||
|
||||
--success: #34c759;
|
||||
--danger: #ff3b30;
|
||||
|
||||
--radius-lg: 1.5rem; /* 24px — крупные панели */
|
||||
--radius-md: 1rem; /* 16px — карточки, инпуты */
|
||||
--radius-sm: 0.7rem; /* 11px — мелочь */
|
||||
|
||||
--ease: cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
color-scheme: light;
|
||||
}
|
||||
```
|
||||
|
||||
Тёмная и бежевая темы — через `data-theme` на `html`, переопределением тех же
|
||||
токенов (`--bg`, `--surface`, `--fg`, `--accent`, `--success`, `--danger`).
|
||||
|
||||
---
|
||||
|
||||
## 3. КРИТИЧНО: особенности CEF во FiveM
|
||||
|
||||
Это не «хорошо бы», а условия, при нарушении которых интерфейс ломается
|
||||
в игре, оставаясь идеальным в браузере.
|
||||
|
||||
1. **`backdrop-filter` и `box-shadow` не живут на одном элементе.**
|
||||
Композитор CEF рисует тень такого элемента непрозрачным прямоугольником —
|
||||
в игре под панелью появляется чёрный квадрат. Если нужны и блюр, и тень —
|
||||
тень вешать на элемент-обёртку, а блюр на внутренний. По умолчанию
|
||||
**у стеклянных панелей тени нет вообще**: их отделяет от игры блюр и
|
||||
светлая граница, этого достаточно.
|
||||
|
||||
2. **`html, body { background: transparent }`** — иначе NUI закрашивает игру.
|
||||
|
||||
3. **Никаких затемняющих подложек.** Экран за панелью — это игра, её видно
|
||||
и она красивая. Контраст добирается блюром и белой заливкой стекла, а не
|
||||
`rgba(0,0,0,…)` поверх кадра. Допустим только **осветляющий** вуаль:
|
||||
`radial-gradient(…, rgba(255,255,255,0.28), transparent)`.
|
||||
|
||||
4. **Шрифты — локальные woff2.** См. раздел 0.
|
||||
|
||||
5. **`prefers-reduced-motion`** — обязательный блок с самого начала.
|
||||
|
||||
6. **Курсор и фокус** — `SetNuiFocus(true, true)` только когда панель открыта,
|
||||
и обязательно `SetNuiFocus(false, false)` при закрытии, иначе игрок
|
||||
остаётся без управления.
|
||||
|
||||
---
|
||||
|
||||
## 4. Стеклянная панель
|
||||
|
||||
```css
|
||||
.glass {
|
||||
background: var(--surface);
|
||||
backdrop-filter: saturate(180%) blur(24px);
|
||||
-webkit-backdrop-filter: saturate(180%) blur(24px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.55);
|
||||
border-radius: var(--radius-lg);
|
||||
/* без box-shadow — см. раздел 3.1 */
|
||||
}
|
||||
```
|
||||
|
||||
Тень разрешена только элементам **без** `backdrop-filter` (карточки на
|
||||
непрозрачном фоне, экран загрузки):
|
||||
|
||||
```css
|
||||
.shadow-soft { box-shadow: 0 1px 2px rgba(0,0,0,.03), 0 12px 40px -12px rgba(0,0,0,.12); }
|
||||
.shadow-lift { box-shadow: 0 2px 4px rgba(0,0,0,.04), 0 24px 50px -16px rgba(0,0,0,.20); }
|
||||
```
|
||||
|
||||
Ховер кликабельной карточки: `transform: translateY(-4px)`, 0.4s `--ease`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Кнопки
|
||||
|
||||
```css
|
||||
.btn-primary {
|
||||
border-radius: 999px;
|
||||
padding: 12px 24px;
|
||||
font: 600 14px var(--sans);
|
||||
letter-spacing: -0.01em;
|
||||
color: #fff;
|
||||
background-image: linear-gradient(180deg, var(--accent-strong), var(--accent));
|
||||
box-shadow: 0 1px 1px rgba(0,0,0,.10), inset 0 1px 0 rgba(255,255,255,.20);
|
||||
}
|
||||
.btn-primary:hover { filter: brightness(1.08); }
|
||||
.btn-primary:active { transform: scale(0.97); }
|
||||
.btn-primary:disabled { opacity: .5; pointer-events: none; }
|
||||
|
||||
.btn-ghost {
|
||||
border-radius: 999px;
|
||||
padding: 10px 20px;
|
||||
font: 600 14px var(--sans);
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.btn-ghost:hover { background: rgba(0,113,227,.14); }
|
||||
```
|
||||
|
||||
Ровно два стиля кнопок на весь сервер. Третий не заводить.
|
||||
|
||||
---
|
||||
|
||||
## 6. Поля ввода
|
||||
|
||||
```css
|
||||
.field {
|
||||
width: 100%;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 13px 14px;
|
||||
font: 400 15px var(--sans);
|
||||
letter-spacing: -0.005em;
|
||||
color: var(--fg);
|
||||
background: var(--field-bg);
|
||||
border: 1px solid transparent;
|
||||
outline: none;
|
||||
transition: background-color .2s, border-color .2s, box-shadow .2s;
|
||||
}
|
||||
.field::placeholder { color: var(--fg-tertiary); }
|
||||
.field:focus {
|
||||
background: var(--field-bg-focus);
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 4px var(--ring); /* можно: у инпута нет backdrop-filter */
|
||||
}
|
||||
.field.is-bad { border-color: var(--danger); box-shadow: 0 0 0 4px rgba(255,59,48,.14); }
|
||||
```
|
||||
|
||||
Лейбл над полем — 11px/600/`+0.09em`/uppercase/`--fg-secondary`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Компоновка
|
||||
|
||||
- Панель по центру экрана, ширина `min(940px, 92vw)`, радиус `--radius-lg`.
|
||||
- Внутренние отступы панели 32–40px.
|
||||
- Между полями формы 16px, между группами 24px.
|
||||
- Двухколоночная схема «форма + герой»: `grid-template-columns: minmax(0,1fr) 420px`.
|
||||
- Сворачивание в одну колонку — анимацией `grid-template-columns` и `width`,
|
||||
620ms `--ease`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Motion
|
||||
|
||||
```css
|
||||
.fade-up { animation: fadeUp .6s cubic-bezier(.22,1,.36,1) both; }
|
||||
.delay-1 { animation-delay: .06s } .delay-2 { animation-delay: .12s }
|
||||
.delay-3 { animation-delay: .18s } .delay-4 { animation-delay: .24s }
|
||||
|
||||
@keyframes fadeUp {
|
||||
from { opacity: 0; transform: translateY(18px) scale(.995); filter: blur(6px); }
|
||||
to { opacity: 1; transform: none; filter: blur(0); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: .001ms !important;
|
||||
transition-duration: .001ms !important;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Фокус и доступность
|
||||
|
||||
```css
|
||||
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 6px; }
|
||||
::selection { background: var(--accent-soft); color: var(--accent-strong); }
|
||||
```
|
||||
|
||||
Контраст текста к стеклу — не ниже 4.5:1. `--fg-tertiary` только для
|
||||
плейсхолдеров и декоративных подписей, никогда для смысловых.
|
||||
|
||||
---
|
||||
|
||||
## 10. Существующие интерфейсы
|
||||
|
||||
| Ресурс | Что это | Файлы |
|
||||
|---|---|---|
|
||||
| `fiverp-loadscreen` | экран загрузки сервера | `html/index.html`, `style.css`, `app.js` |
|
||||
| `fiverp-auth` | регистрация и вход, паспорт США | `html/index.html`, `style.css`, `app.js` |
|
||||
|
||||
`fiverp-auth` использует один «герой» — разворот паспорта США (страница с
|
||||
данными: фото, поля, MRZ). Паспорт — единственный насыщенный цветом объект
|
||||
на экране; форма рядом с ним остаётся полностью нейтральной.
|
||||
|
||||
---
|
||||
|
||||
## 11. Чек-лист нового интерфейса
|
||||
|
||||
1. Скопировать `html/fonts/` и блок `:root` из `fiverp-auth`.
|
||||
2. `html, body { background: transparent }`.
|
||||
3. Панель — `.glass`, **без** `box-shadow`.
|
||||
4. Ни одного затемняющего слоя поверх игры.
|
||||
5. Шрифт Unbounded, трекинг по таблице из раздела 0.
|
||||
6. Акцент только `--accent`; кнопки только `.btn-primary` / `.btn-ghost`.
|
||||
7. Подключить `prefers-reduced-motion`.
|
||||
8. `SetNuiFocus(false, false)` на закрытии — проверить руками.
|
||||
9. Отрендерить в headless Chrome и **посмотреть глазами** до выката.
|
||||
@@ -1,99 +0,0 @@
|
||||
# Los Santos RP — server notes
|
||||
|
||||
Everything here was written for this server. No ESX, no QBCore, no downloaded
|
||||
resources: `cfx-server-data` is not used at all, and the bundled `chat` and
|
||||
`monitor` resources are not started.
|
||||
|
||||
## Layout
|
||||
|
||||
/opt/fivem/server FXServer artefacts (build 25770, the recommended one)
|
||||
/opt/fivem/data server-data root; run FXServer with this as cwd
|
||||
server.cfg main config
|
||||
secrets.cfg licence key + DB credentials, mode 600
|
||||
resources/[rp]/ our resources
|
||||
/opt/fivem/bin supervise.sh (keep-alive), rcon (console pipe)
|
||||
/opt/fivem/logs server.log, rotated at 200MB
|
||||
/opt/fivem/etc/schema.sql the database schema
|
||||
|
||||
## Resources
|
||||
|
||||
| resource | what it is |
|
||||
|---------------|------------|
|
||||
| `rp_db` | MariaDB client speaking the MySQL wire protocol directly over a TCP socket — handshake, `mysql_native_password`, text result sets, connection pool, transactions. No npm packages. Lua front end in `lib/db.lua`. |
|
||||
| `rp_core` | Config, shared validation, appearance model, scrypt password hashing, the player registry, and the client/server request bridge. |
|
||||
| `rp_session` | Connection gate (bans, capacity), accounts, characters, spawn. Every decision is made here; the client can only ask. |
|
||||
| `rp_loading` | Loading screen: procedural night-city flyover on canvas, progress bound to the game's real load events, ambience synthesised with the Web Audio API. |
|
||||
| `rp_ui` | Sign in / register, character creation with a live ped preview, spawn picker. Scripted cameras live in `client/camera.lua`. |
|
||||
| `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
|
||||
rcon <command> # e.g. rcon refresh, rcon status
|
||||
tail -f /opt/fivem/logs/server.log
|
||||
|
||||
`supervise.sh` restarts FXServer if it dies, backing off from 5s up to 5min so
|
||||
a server that cannot start (bad key, database down) does not hammer Cfx. It
|
||||
also holds a FIFO open on the server's stdin — FXServer quits the moment stdin
|
||||
reaches EOF, and that FIFO is what `rcon` writes to.
|
||||
|
||||
## Starting on boot
|
||||
|
||||
There is no systemd in this container. The platform runs a login shell as PID 1,
|
||||
which sources `/etc/profile.d/redl-autostart.sh`, which starts everything marked
|
||||
in `/etc/redl/enabled`. Both `mariadb` and `fivem` are marked, and `/etc/init.d/fivem`
|
||||
waits for MariaDB to answer before starting the server.
|
||||
|
||||
## Database
|
||||
|
||||
MariaDB, database `rp`, application user `rp@127.0.0.1` with only
|
||||
SELECT/INSERT/UPDATE/DELETE. Passwords are scrypt with a per-password salt and
|
||||
the cost parameters stored in the hash. Tables: `accounts`, `auth_attempts`,
|
||||
`characters`, `transactions`, `inventory`, `vehicles`.
|
||||
|
||||
Every query goes through `DB.Query`/`DB.Insert`/... with `?` parameters.
|
||||
`CLIENT_MULTI_STATEMENTS` is deliberately not negotiated, so even a failure of
|
||||
the escaper could not turn a parameter into a second statement.
|
||||
|
||||
## Not yet built
|
||||
|
||||
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.
|
||||
|
Before Width: | Height: | Size: 169 KiB |
|
Before Width: | Height: | Size: 390 KiB |
|
After Width: | Height: | Size: 823 KiB |
|
After Width: | Height: | Size: 645 KiB |
|
After Width: | Height: | Size: 790 KiB |
|
After Width: | Height: | Size: 669 KiB |
|
Before Width: | Height: | Size: 122 KiB |
@@ -1,105 +0,0 @@
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Los Santos RP - authoritative schema
|
||||
-- Everything the server considers truth lives here. Nothing gameplay-relevant
|
||||
-- is stored client-side.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE IF NOT EXISTS accounts (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
username VARCHAR(24) NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL, -- scrypt$N$r$p$salt_b64$hash_b64
|
||||
role ENUM('player','mod','admin','owner') NOT NULL DEFAULT 'player',
|
||||
license VARCHAR(64) NULL, -- Cfx license: bound on first login
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_login_at DATETIME NULL,
|
||||
last_ip VARCHAR(45) NULL,
|
||||
banned TINYINT(1) NOT NULL DEFAULT 0,
|
||||
ban_reason VARCHAR(255) NULL,
|
||||
ban_until DATETIME NULL, -- NULL + banned=1 => permanent
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_accounts_username (username),
|
||||
KEY idx_accounts_license (license)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Brute-force throttling. One row per (identity, window); pruned by the server.
|
||||
CREATE TABLE IF NOT EXISTS auth_attempts (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
identity VARCHAR(64) NOT NULL, -- license or username, lowercased
|
||||
ip VARCHAR(45) NULL,
|
||||
success TINYINT(1) NOT NULL DEFAULT 0,
|
||||
at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_attempts_identity_at (identity, at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS characters (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
account_id INT UNSIGNED NOT NULL,
|
||||
slot TINYINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
first_name VARCHAR(24) NOT NULL,
|
||||
last_name VARCHAR(24) NOT NULL,
|
||||
dob DATE NOT NULL,
|
||||
gender ENUM('m','f') NOT NULL DEFAULT 'm',
|
||||
backstory TEXT NULL,
|
||||
appearance MEDIUMTEXT NULL, -- JSON: model, face blend, features, overlays, clothing
|
||||
position VARCHAR(128) NULL, -- JSON: {x,y,z,h}
|
||||
cash BIGINT NOT NULL DEFAULT 500,
|
||||
bank BIGINT NOT NULL DEFAULT 2500,
|
||||
health SMALLINT NOT NULL DEFAULT 200,
|
||||
armour SMALLINT NOT NULL DEFAULT 0,
|
||||
job VARCHAR(32) NOT NULL DEFAULT 'unemployed',
|
||||
job_grade TINYINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
needs VARCHAR(128) NULL, -- JSON: {hunger,thirst,stress}
|
||||
playtime INT UNSIGNED NOT NULL DEFAULT 0, -- seconds
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_played_at DATETIME NULL,
|
||||
deleted TINYINT(1) NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_char_name (first_name, last_name),
|
||||
KEY idx_char_account (account_id, deleted),
|
||||
CONSTRAINT fk_char_account FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Every money movement is journalled; cash/bank on `characters` is the running total.
|
||||
CREATE TABLE IF NOT EXISTS transactions (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
character_id INT UNSIGNED NOT NULL,
|
||||
account_kind ENUM('cash','bank') NOT NULL,
|
||||
delta BIGINT NOT NULL,
|
||||
balance_after BIGINT NOT NULL,
|
||||
reason VARCHAR(64) NOT NULL,
|
||||
at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_tx_character (character_id, at),
|
||||
CONSTRAINT fk_tx_character FOREIGN KEY (character_id) REFERENCES characters(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS inventory (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
owner_kind ENUM('character','stash','ground','vehicle') NOT NULL,
|
||||
owner_id VARCHAR(64) NOT NULL, -- character id, stash key, drop uuid, plate
|
||||
slot SMALLINT UNSIGNED NOT NULL,
|
||||
item VARCHAR(48) NOT NULL,
|
||||
qty INT UNSIGNED NOT NULL,
|
||||
meta VARCHAR(512) NULL, -- JSON: durability, serial, ...
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_inv_slot (owner_kind, owner_id, slot),
|
||||
KEY idx_inv_owner (owner_kind, owner_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS vehicles (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
character_id INT UNSIGNED NOT NULL,
|
||||
plate VARCHAR(12) NOT NULL,
|
||||
model VARCHAR(48) NOT NULL,
|
||||
props MEDIUMTEXT NULL, -- JSON: colours, mods, damage
|
||||
fuel FLOAT NOT NULL DEFAULT 100,
|
||||
garage VARCHAR(32) NOT NULL DEFAULT 'legion',
|
||||
stored TINYINT(1) NOT NULL DEFAULT 1,
|
||||
impounded TINYINT(1) NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_veh_plate (plate),
|
||||
KEY idx_veh_character (character_id),
|
||||
CONSTRAINT fk_veh_character FOREIGN KEY (character_id) REFERENCES characters(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -1,12 +0,0 @@
|
||||
# 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"
|
||||
@@ -1,38 +1,40 @@
|
||||
#!/bin/sh
|
||||
# ---------------------------------------------------------------------------
|
||||
# Los Santos RP - installer
|
||||
# FiveRP - 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.
|
||||
# Turns a clean Ubuntu/Debian box into this server: FXServer artefacts,
|
||||
# cfx-server-data, oxmysql, MariaDB with the schema, the FiveRP resources,
|
||||
# a 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)
|
||||
# --hostname NAME server name in the list (default FiveRP)
|
||||
# --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)
|
||||
# --name NAME service name, one per server (default fiverp)
|
||||
# --db-name NAME database (default fiverp)
|
||||
# --db-user USER database user (default fiverp)
|
||||
# --db-pass PASS password for that user (default: generated)
|
||||
# --build ID FXServer build to use (default: latest recommended)
|
||||
# --build ID FXServer build (default: latest recommended)
|
||||
# --no-start install everything, do not start the server
|
||||
# ---------------------------------------------------------------------------
|
||||
set -e
|
||||
|
||||
DIR=/opt/fivem
|
||||
LICENCE=""
|
||||
HOSTNAME_=""
|
||||
HOSTNAME_="FiveRP"
|
||||
BUILD=""
|
||||
DB_NAME=fiverp
|
||||
DB_USER=fiverp
|
||||
DB_PASS=""
|
||||
DB_NAME=rp
|
||||
DB_USER=rp
|
||||
PORT=30120
|
||||
SVC=fivem
|
||||
SVC=fiverp
|
||||
START=1
|
||||
ARTIFACTS="https://runtime.fivem.net/artifacts/fivem/build_proot_linux/master/"
|
||||
OXMYSQL="https://github.com/overextended/oxmysql/releases/latest/download/oxmysql.zip"
|
||||
SERVER_DATA_REPO="https://github.com/citizenfx/cfx-server-data"
|
||||
SRC=$(cd "$(dirname "$0")" && pwd)
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
@@ -41,33 +43,33 @@ while [ $# -gt 0 ]; do
|
||||
--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 ;;
|
||||
--db-pass) DB_PASS="$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 ;;
|
||||
-h|--help) sed -n '2,25p' "$0"; exit 0 ;;
|
||||
*) echo "unknown option: $1" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
say() { printf '\n\033[1;33m==\033[0m %s\n' "$*"; }
|
||||
say() { printf '\n\033[1;34m==\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"
|
||||
[ -f "$SRC/sql/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"
|
||||
apt-get install -y -qq curl ca-certificates wget xz-utils git unzip mariadb-server >/dev/null
|
||||
ok "curl, git, unzip, xz-utils, mariadb-server"
|
||||
|
||||
# --- 2. FXServer artefacts -------------------------------------------------
|
||||
# --- 2. FXServer -----------------------------------------------------------
|
||||
say "Fetching FXServer"
|
||||
if [ -n "$BUILD" ]; then
|
||||
REL=$(curl -fsSL "$ARTIFACTS" | tr '>' '\n' | grep -oE "\./$BUILD-[0-9a-f]+/fx\.tar\.xz" | head -1)
|
||||
@@ -80,8 +82,8 @@ 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
|
||||
mkdir -p "$DIR/server" "$DIR/bin" "$DIR/logs"
|
||||
if [ -x "$DIR/server/run.sh" ] && [ "$(cat "$DIR/server/.build" 2>/dev/null)" = "$BUILD_ID" ]; then
|
||||
ok "build $BUILD_ID already installed"
|
||||
else
|
||||
TMP=$(mktemp -d)
|
||||
@@ -94,11 +96,31 @@ else
|
||||
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"
|
||||
# --- 3. server-data + oxmysql ---------------------------------------------
|
||||
say "Fetching the base resources"
|
||||
if [ -d "$DIR/server-data/resources" ]; then
|
||||
ok "server-data already present"
|
||||
else
|
||||
git clone --depth=1 -q "$SERVER_DATA_REPO" "$DIR/server-data" || die "could not clone cfx-server-data"
|
||||
ok "cfx-server-data cloned (mapmanager, chat, spawnmanager, sessionmanager, hardcap)"
|
||||
fi
|
||||
|
||||
if [ -f "$DIR/server-data/resources/[system]/oxmysql/fxmanifest.lua" ]; then
|
||||
ok "oxmysql already present"
|
||||
else
|
||||
TMP=$(mktemp -d)
|
||||
curl -fsSL "$OXMYSQL" -o "$TMP/oxmysql.zip" || die "could not download oxmysql"
|
||||
mkdir -p "$DIR/server-data/resources/[system]"
|
||||
unzip -q -o "$TMP/oxmysql.zip" -d "$TMP"
|
||||
rm -rf "$DIR/server-data/resources/[system]/oxmysql"
|
||||
mv "$(find "$TMP" -maxdepth 2 -name fxmanifest.lua | head -1 | xargs dirname)" \
|
||||
"$DIR/server-data/resources/[system]/oxmysql"
|
||||
rm -rf "$TMP"
|
||||
ok "oxmysql installed"
|
||||
fi
|
||||
|
||||
# --- 4. database -----------------------------------------------------------
|
||||
say "Preparing the database"
|
||||
if [ -d /run/systemd/system ]; then
|
||||
systemctl enable --now mariadb >/dev/null 2>&1 || true
|
||||
else
|
||||
@@ -111,75 +133,63 @@ while ! mysqladmin ping >/dev/null 2>&1; do
|
||||
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)
|
||||
if [ -f "$DIR/server-data/server.cfg" ]; then
|
||||
DB_PASS=$(sed -n "s|^set mysql_connection_string \"mysql://[^:]*:\([^@]*\)@.*|\1|p" "$DIR/server-data/server.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"
|
||||
mysql -e "CREATE USER IF NOT EXISTS '$DB_USER'@'localhost' IDENTIFIED BY '$DB_PASS';"
|
||||
mysql -e "ALTER USER '$DB_USER'@'localhost' IDENTIFIED BY '$DB_PASS';"
|
||||
mysql -e "GRANT ALL PRIVILEGES ON \`$DB_NAME\`.* TO '$DB_USER'@'localhost'; FLUSH PRIVILEGES;"
|
||||
mysql "$DB_NAME" < "$SRC/sql/schema.sql"
|
||||
ok "database $DB_NAME, user $DB_USER@localhost, 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")"
|
||||
# --- 5. FiveRP resources and configuration --------------------------------
|
||||
say "Installing FiveRP"
|
||||
mkdir -p "$DIR/server-data/resources/[local]"
|
||||
for r in fiverp-auth fiverp-characters fiverp-loadscreen; do
|
||||
rm -rf "$DIR/server-data/resources/[local]/$r"
|
||||
cp -r "$SRC/resources/[local]/$r" "$DIR/server-data/resources/[local]/"
|
||||
done
|
||||
ok "fiverp-auth, fiverp-characters, fiverp-loadscreen"
|
||||
|
||||
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"
|
||||
cp "$SRC/bin/run-server.sh" "$DIR/bin/"
|
||||
sed "s|/run/fiverp.stdin|/run/$SVC.stdin|" "$SRC/bin/rcon" > "$DIR/bin/rcon"
|
||||
chmod +x "$DIR/bin/run-server.sh" "$DIR/bin/rcon"
|
||||
ln -sf "$DIR/bin/rcon" "/usr/local/bin/$([ "$SVC" = fiverp ] && echo rcon || echo "rcon-$SVC")"
|
||||
|
||||
CFG="$DIR/server-data/server.cfg"
|
||||
if [ -z "$LICENCE" ] && [ -f "$CFG" ]; then
|
||||
LICENCE=$(sed -n 's/^sv_licenseKey "\(.*\)"$/\1/p' "$CFG" | head -1)
|
||||
fi
|
||||
sed -e "s|0.0.0.0:30120|0.0.0.0:$PORT|g" \
|
||||
-e "s|^sv_hostname .*|sv_hostname \"$HOSTNAME_\"|" \
|
||||
-e "s|^sets sv_projectName .*|sets sv_projectName \"$HOSTNAME_\"|" \
|
||||
-e "s|^sv_licenseKey .*|sv_licenseKey \"${LICENCE:-cfxk_your_key_here}\"|" \
|
||||
-e "s|^set mysql_connection_string .*|set mysql_connection_string \"mysql://$DB_USER:$DB_PASS@localhost/$DB_NAME?charset=utf8mb4\"|" \
|
||||
"$SRC/server.cfg.example" > "$CFG"
|
||||
chmod 600 "$CFG"
|
||||
ok "server.cfg written (mode 600 - it holds the key and the password)"
|
||||
|
||||
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 ---------------------------------------------------------
|
||||
# --- 6. 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"
|
||||
sed -e "s|^NAME=.*|NAME=$SVC|" -e "s|^DIR=.*|DIR=$DIR|" -e "s|^LOG=.*|LOG=$DIR/logs/server.log|" \
|
||||
"$SRC/bin/fiverp.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)
|
||||
Description=FiveRP (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
|
||||
WorkingDirectory=$DIR/server-data
|
||||
ExecStart=$DIR/bin/run-server.sh $SVC $DIR $DIR/logs/server.log
|
||||
KillMode=mixed
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
@@ -194,43 +204,38 @@ 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
|
||||
# --- 7. 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"
|
||||
echo " put it into $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
|
||||
|
||||
LOG="$DIR/logs/server.log"
|
||||
i=0
|
||||
while [ $i -lt 90 ]; do
|
||||
if grep -q "Authenticated with cfx.re Nucleus" "$DIR/logs/server.log" 2>/dev/null; then
|
||||
if grep -q "Authenticated with cfx.re Nucleus" "$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"
|
||||
grep -q "fiverp-auth\] server ready" "$LOG" && ok "fiverp-auth ready"
|
||||
grep -q "Database server connection established" "$LOG" && ok "oxmysql connected to the database"
|
||||
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"
|
||||
printf '\n connect %s:%s\n logs: tail -f %s\n console: rcon status\n\n' "$IP" "$PORT" "$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 grep -qE "Could not authenticate server license key|invalid license key" "$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"
|
||||
die "the licence key was refused - get one at https://keymaster.fivem.net, put it into $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"
|
||||
die "the server did not come up in 3 minutes - see $LOG"
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
local isOpen = false
|
||||
local cam = nil
|
||||
local authDone = false
|
||||
|
||||
-- A slow drift over the city gives the glass something to sit on.
|
||||
local CAM_POS = vector3(-1030.0, -2730.0, 320.0)
|
||||
local CAM_LOOK = vector3(-75.0, -815.0, 220.0)
|
||||
local SPAWN_POS = vector3(195.17, -889.36, 30.69)
|
||||
local SPAWN_HDG = 145.0
|
||||
|
||||
local function startCamera()
|
||||
cam = CreateCamWithParams('DEFAULT_SCRIPTED_CAMERA',
|
||||
CAM_POS.x, CAM_POS.y, CAM_POS.z, 0.0, 0.0, 0.0, 55.0, false, 0)
|
||||
PointCamAtCoord(cam, CAM_LOOK.x, CAM_LOOK.y, CAM_LOOK.z)
|
||||
SetCamActive(cam, true)
|
||||
RenderScriptCams(true, false, 0, true, true)
|
||||
|
||||
-- Drift the camera so the backdrop is never a frozen still.
|
||||
CreateThread(function()
|
||||
local angle = 0.0
|
||||
while cam and DoesCamExist(cam) and not authDone do
|
||||
angle = angle + 0.02
|
||||
local radius = 260.0
|
||||
SetCamCoord(cam,
|
||||
CAM_POS.x + math.cos(math.rad(angle)) * radius,
|
||||
CAM_POS.y + math.sin(math.rad(angle)) * radius,
|
||||
CAM_POS.z)
|
||||
PointCamAtCoord(cam, CAM_LOOK.x, CAM_LOOK.y, CAM_LOOK.z)
|
||||
Wait(20)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
local function stopCamera()
|
||||
if cam and DoesCamExist(cam) then
|
||||
RenderScriptCams(false, true, 900, true, true)
|
||||
DestroyCam(cam, false)
|
||||
cam = nil
|
||||
end
|
||||
end
|
||||
|
||||
local function openAuth()
|
||||
isOpen = true
|
||||
SetNuiFocus(true, true)
|
||||
SendNUIMessage({ action = 'open' })
|
||||
DoScreenFadeIn(600)
|
||||
end
|
||||
|
||||
local function closeAuth()
|
||||
isOpen = false
|
||||
SetNuiFocus(false, false)
|
||||
SendNUIMessage({ action = 'close' })
|
||||
end
|
||||
|
||||
-- Keep the unauthenticated player parked and invisible behind the glass.
|
||||
CreateThread(function()
|
||||
while not authDone do
|
||||
local ped = PlayerPedId()
|
||||
SetEntityVisible(ped, false, false)
|
||||
SetEntityCollision(ped, false, false)
|
||||
FreezeEntityPosition(ped, true)
|
||||
SetPlayerInvincible(PlayerId(), true)
|
||||
DisableAllControlActions(0)
|
||||
Wait(0)
|
||||
end
|
||||
end)
|
||||
|
||||
-- Any map resource can re-enable auto spawn when it starts, so hold the gate
|
||||
-- shut every time that happens until the player actually has an identity.
|
||||
AddEventHandler('onClientMapStart', function()
|
||||
if not authDone then
|
||||
exports.spawnmanager:setAutoSpawn(false)
|
||||
end
|
||||
end)
|
||||
|
||||
CreateThread(function()
|
||||
-- Take spawning away from spawnmanager until the player has an identity.
|
||||
exports.spawnmanager:setAutoSpawn(false)
|
||||
|
||||
DoScreenFadeOut(0)
|
||||
while not NetworkIsSessionStarted() do Wait(100) end
|
||||
Wait(500)
|
||||
|
||||
startCamera()
|
||||
openAuth()
|
||||
end)
|
||||
|
||||
-- The account is known; fiverp-characters owns everything from here — the
|
||||
-- slot screen, the appearance creator and the spawn itself. Auth only has to
|
||||
-- fade out, release its camera and step aside.
|
||||
local function enterCity()
|
||||
authDone = true
|
||||
closeAuth()
|
||||
DoScreenFadeOut(500)
|
||||
Wait(600)
|
||||
stopCamera()
|
||||
exports.spawnmanager:setAutoSpawn(false)
|
||||
TriggerEvent('fiverp-chars:begin')
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------- NUI callbacks
|
||||
RegisterNUICallback('registerAccount', function(data, cb)
|
||||
TriggerServerEvent('fiverp-auth:register:account', data)
|
||||
cb({})
|
||||
end)
|
||||
|
||||
RegisterNUICallback('registerIdentity', function(data, cb)
|
||||
TriggerServerEvent('fiverp-auth:register:identity', data)
|
||||
cb({})
|
||||
end)
|
||||
|
||||
RegisterNUICallback('resumeIdentity', function(data, cb)
|
||||
TriggerServerEvent('fiverp-auth:resume:identity', data)
|
||||
cb({})
|
||||
end)
|
||||
|
||||
RegisterNUICallback('login', function(data, cb)
|
||||
TriggerServerEvent('fiverp-auth:login', data)
|
||||
cb({})
|
||||
end)
|
||||
|
||||
-- --------------------------------------------------------------- server said
|
||||
RegisterNetEvent('fiverp-auth:register:accountResult', function(result)
|
||||
SendNUIMessage({ action = 'accountResult', payload = result })
|
||||
end)
|
||||
|
||||
RegisterNetEvent('fiverp-auth:register:identityResult', function(result)
|
||||
SendNUIMessage({ action = 'identityResult', payload = result })
|
||||
if result.ok then
|
||||
CreateThread(function()
|
||||
Wait(1600) -- let the permit finish stamping before the fade
|
||||
enterCity(result.firstName, result.lastName)
|
||||
end)
|
||||
end
|
||||
end)
|
||||
|
||||
RegisterNetEvent('fiverp-auth:loginResult', function(result)
|
||||
SendNUIMessage({ action = 'loginResult', payload = result })
|
||||
if result.ok and not result.needsIdentity then
|
||||
CreateThread(function()
|
||||
Wait(1200)
|
||||
enterCity(result.firstName, result.lastName)
|
||||
end)
|
||||
end
|
||||
end)
|
||||
@@ -0,0 +1,24 @@
|
||||
fx_version 'cerulean'
|
||||
game 'gta5'
|
||||
node_version '22'
|
||||
|
||||
name 'fiverp-auth'
|
||||
author 'FiveRP'
|
||||
description 'Two-step account registration and login for FiveRP'
|
||||
version '1.0.0'
|
||||
|
||||
ui_page 'html/index.html'
|
||||
|
||||
client_script 'client/cl_auth.lua'
|
||||
server_script 'server/sv_auth.js'
|
||||
|
||||
files {
|
||||
'html/index.html',
|
||||
'html/style.css',
|
||||
'html/app.js',
|
||||
'html/fonts/unbounded-latin.woff2',
|
||||
'html/fonts/unbounded-latin-ext.woff2',
|
||||
'html/fonts/unbounded-cyrillic.woff2'
|
||||
}
|
||||
|
||||
dependency 'oxmysql'
|
||||
@@ -0,0 +1,391 @@
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
const RESOURCE = 'fiverp-auth';
|
||||
|
||||
const stage = document.getElementById('stage');
|
||||
const slab = document.getElementById('slab');
|
||||
const railFill = document.getElementById('railFill');
|
||||
const permit = document.getElementById('permit');
|
||||
const sheen = document.getElementById('sheen');
|
||||
const seal = document.getElementById('seal');
|
||||
|
||||
const cardNo = document.getElementById('cardNo');
|
||||
const cardFirst = document.getElementById('cardFirst');
|
||||
const cardLast = document.getElementById('cardLast');
|
||||
const cardDob = document.getElementById('cardDob');
|
||||
const cardIssue = document.getElementById('cardIssue');
|
||||
const cardExpiry = document.getElementById('cardExpiry');
|
||||
const cardSign = document.getElementById('cardSign');
|
||||
const mrz1 = document.getElementById('mrz1');
|
||||
const mrz2 = document.getElementById('mrz2');
|
||||
const stampDate = document.getElementById('stampDate');
|
||||
|
||||
const views = {
|
||||
login: document.getElementById('formLogin'),
|
||||
account: document.getElementById('formAccount'),
|
||||
identity: document.getElementById('formIdentity')
|
||||
};
|
||||
|
||||
let current = 'login';
|
||||
let busy = false;
|
||||
// Set when sign-in finds an account whose registration never reached step 2.
|
||||
let resuming = false;
|
||||
|
||||
// ------------------------------------------------------------------ post
|
||||
function post(endpoint, data) {
|
||||
return fetch(`https://${RESOURCE}/${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json; charset=UTF-8' },
|
||||
body: JSON.stringify(data || {})
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ views
|
||||
function show(view) {
|
||||
current = view;
|
||||
slab.dataset.view = view;
|
||||
|
||||
Object.entries(views).forEach(([name, form]) => {
|
||||
form.classList.toggle('is-current', name === view);
|
||||
});
|
||||
|
||||
document.querySelectorAll('.rail-step').forEach((step) => {
|
||||
const which = step.dataset.step;
|
||||
step.classList.toggle('is-active', which === view);
|
||||
step.classList.toggle('is-done', which === 'account' && view === 'identity');
|
||||
});
|
||||
railFill.classList.toggle('is-full', view === 'identity');
|
||||
|
||||
const first = views[view].querySelector('input');
|
||||
if (first) setTimeout(() => first.focus(), 240);
|
||||
}
|
||||
|
||||
function notice(view, message, good) {
|
||||
const el = views[view].querySelector('[data-notice]');
|
||||
if (!message) {
|
||||
el.classList.remove('is-shown', 'is-good');
|
||||
el.textContent = '';
|
||||
return;
|
||||
}
|
||||
el.textContent = message;
|
||||
el.classList.add('is-shown');
|
||||
el.classList.toggle('is-good', !!good);
|
||||
}
|
||||
|
||||
function setBusy(view, state) {
|
||||
busy = state;
|
||||
const btn = views[view].querySelector('.btn');
|
||||
btn.classList.toggle('is-busy', state);
|
||||
btn.disabled = state;
|
||||
}
|
||||
|
||||
function values(view) {
|
||||
const out = {};
|
||||
views[view].querySelectorAll('input').forEach((input) => {
|
||||
out[input.name] = input.value.trim();
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function markBad(view, names) {
|
||||
views[view].querySelectorAll('input').forEach((input) => {
|
||||
input.classList.toggle('is-bad', names.includes(input.name));
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================== the passport
|
||||
// Everything on the data page is derived from the name, so the same resident
|
||||
// always gets the same document — it reads as a record, not a random mock-up.
|
||||
|
||||
function seedOf(text) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < text.length; i++) hash = (hash * 31 + text.charCodeAt(i)) >>> 0;
|
||||
return hash;
|
||||
}
|
||||
|
||||
function passportNumber(seed) {
|
||||
return String(seed % 1000000000).padStart(9, '0');
|
||||
}
|
||||
|
||||
const MONTHS = ['JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC'];
|
||||
|
||||
function human(date) {
|
||||
return String(date.getDate()).padStart(2, '0') + ' ' +
|
||||
MONTHS[date.getMonth()] + ' ' + date.getFullYear();
|
||||
}
|
||||
|
||||
// MRZ dates are YYMMDD.
|
||||
function mrzDate(date) {
|
||||
return String(date.getFullYear() % 100).padStart(2, '0') +
|
||||
String(date.getMonth() + 1).padStart(2, '0') +
|
||||
String(date.getDate()).padStart(2, '0');
|
||||
}
|
||||
|
||||
// ICAO 9303 check digit: weights cycle 7-3-1 over the field.
|
||||
function checkDigit(field) {
|
||||
const WEIGHTS = [7, 3, 1];
|
||||
let sum = 0;
|
||||
for (let i = 0; i < field.length; i++) {
|
||||
const c = field[i];
|
||||
let v;
|
||||
if (c >= '0' && c <= '9') v = c.charCodeAt(0) - 48;
|
||||
else if (c >= 'A' && c <= 'Z') v = c.charCodeAt(0) - 55;
|
||||
else v = 0; // '<' filler
|
||||
sum += v * WEIGHTS[i % 3];
|
||||
}
|
||||
return String(sum % 10);
|
||||
}
|
||||
|
||||
function pad(text, length) {
|
||||
return (text + '<'.repeat(length)).slice(0, length);
|
||||
}
|
||||
|
||||
function buildDocument(firstName, lastName) {
|
||||
const seed = seedOf((firstName + lastName).toLowerCase());
|
||||
const number = passportNumber(seed);
|
||||
|
||||
const now = new Date();
|
||||
const issue = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const expiry = new Date(issue.getFullYear() + 10, issue.getMonth(), issue.getDate());
|
||||
// A plausible adult birth date, stable for this name.
|
||||
const dob = new Date(
|
||||
now.getFullYear() - (21 + (seed % 34)),
|
||||
(seed >>> 7) % 12,
|
||||
1 + ((seed >>> 13) % 28)
|
||||
);
|
||||
|
||||
const surname = lastName.toUpperCase();
|
||||
const given = firstName.toUpperCase();
|
||||
|
||||
const line1 = pad('P<USA' + surname + '<<' + given, 44);
|
||||
|
||||
const numCd = checkDigit(number);
|
||||
const dobCd = checkDigit(mrzDate(dob));
|
||||
const expCd = checkDigit(mrzDate(expiry));
|
||||
const personal = '<'.repeat(14);
|
||||
const personalCd = checkDigit(personal);
|
||||
const composite = number + numCd + mrzDate(dob) + dobCd +
|
||||
mrzDate(expiry) + expCd + personal + personalCd;
|
||||
|
||||
const line2 = number + numCd + 'USA' + mrzDate(dob) + dobCd + 'X' +
|
||||
mrzDate(expiry) + expCd + personal + personalCd +
|
||||
checkDigit(composite);
|
||||
|
||||
return { number, issue, expiry, dob, line1, line2 };
|
||||
}
|
||||
|
||||
function printName(el, text) {
|
||||
if (!text) { el.textContent = ''; return; }
|
||||
if (el.textContent === text) return;
|
||||
el.textContent = '';
|
||||
const span = document.createElement('span');
|
||||
span.className = 'printed';
|
||||
span.textContent = text;
|
||||
el.appendChild(span);
|
||||
}
|
||||
|
||||
const BLANK1 = 'P<USA' + '<'.repeat(39);
|
||||
const BLANK2 = '<'.repeat(10) + 'USA' + '<'.repeat(31);
|
||||
|
||||
function fillPassport(firstName, lastName) {
|
||||
printName(cardFirst, firstName.toUpperCase());
|
||||
printName(cardLast, lastName.toUpperCase());
|
||||
|
||||
// Below two letters there is not enough to derive a document from.
|
||||
if (firstName.length < 2 || lastName.length < 2) {
|
||||
cardNo.textContent = '—';
|
||||
cardDob.textContent = '—';
|
||||
cardIssue.textContent = '—';
|
||||
cardExpiry.textContent = '—';
|
||||
cardSign.textContent = '';
|
||||
mrz1.textContent = BLANK1;
|
||||
mrz2.textContent = BLANK2;
|
||||
return;
|
||||
}
|
||||
|
||||
const doc = buildDocument(firstName, lastName);
|
||||
cardNo.textContent = doc.number;
|
||||
cardDob.textContent = human(doc.dob);
|
||||
cardIssue.textContent = human(doc.issue);
|
||||
cardExpiry.textContent = human(doc.expiry);
|
||||
cardSign.textContent = firstName + ' ' + lastName;
|
||||
mrz1.textContent = doc.line1;
|
||||
mrz2.textContent = doc.line2;
|
||||
stampDate.textContent = human(doc.issue);
|
||||
}
|
||||
|
||||
function syncPassport() {
|
||||
const { firstName, lastName } = values('identity');
|
||||
fillPassport(firstName, lastName);
|
||||
}
|
||||
|
||||
function issuePassport() {
|
||||
permit.classList.add('is-issued');
|
||||
seal.classList.add('is-stamped');
|
||||
sheen.style.opacity = '0.85';
|
||||
}
|
||||
|
||||
// Laminate glare tracks the cursor across the page.
|
||||
permit.addEventListener('pointermove', (e) => {
|
||||
const box = permit.getBoundingClientRect();
|
||||
sheen.style.setProperty('--mx', `${((e.clientX - box.left) / box.width) * 100}%`);
|
||||
sheen.style.setProperty('--my', `${((e.clientY - box.top) / box.height) * 100}%`);
|
||||
});
|
||||
|
||||
// --------------------------------------------------------- password meter
|
||||
const pwInput = views.account.querySelector('input[name="password"]');
|
||||
const meter = views.account.querySelector('[data-meter]');
|
||||
|
||||
pwInput.addEventListener('input', () => {
|
||||
const v = pwInput.value;
|
||||
meter.classList.toggle('is-live', v.length > 0);
|
||||
let level = 1;
|
||||
if (v.length >= 8 && /[A-Za-z]/.test(v) && /\d/.test(v)) level = 2;
|
||||
if (v.length >= 12 && /[A-Za-z]/.test(v) && /\d/.test(v) && /[^A-Za-z0-9]/.test(v)) level = 3;
|
||||
meter.dataset.level = String(level);
|
||||
meter.querySelector('i').style.width = `${level * 33.4}%`;
|
||||
});
|
||||
|
||||
views.identity.querySelectorAll('input').forEach((input) => {
|
||||
input.addEventListener('input', () => {
|
||||
// Keep the document honest: only English letters ever reach it.
|
||||
input.value = input.value.replace(/[^A-Za-z]/g, '');
|
||||
syncPassport();
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------ validation
|
||||
const RE_EMAIL = /^[^\s@]+@[^\s@]+\.[a-z]{2,}$/i;
|
||||
const RE_USERNAME = /^[A-Za-z0-9_]{3,32}$/;
|
||||
const RE_NAME = /^[A-Za-z]{2,24}$/;
|
||||
|
||||
function checkAccount(v) {
|
||||
if (!RE_EMAIL.test(v.email)) return ['Enter a valid email address.', ['email']];
|
||||
if (!RE_USERNAME.test(v.username)) return ['Username must be 3–32 characters, letters, numbers or underscore.', ['username']];
|
||||
if (v.password.length < 8) return ['Password must be at least 8 characters.', ['password']];
|
||||
return null;
|
||||
}
|
||||
|
||||
function checkIdentity(v) {
|
||||
if (!RE_NAME.test(v.firstName)) return ['First name must be 2–24 English letters.', ['firstName']];
|
||||
if (!RE_NAME.test(v.lastName)) return ['Last name must be 2–24 English letters.', ['lastName']];
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- submits
|
||||
views.login.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
if (busy) return;
|
||||
const v = values('login');
|
||||
if (!v.username || !v.password) {
|
||||
markBad('login', [!v.username ? 'username' : 'password']);
|
||||
return notice('login', 'Enter your username and password.');
|
||||
}
|
||||
markBad('login', []);
|
||||
notice('login', '');
|
||||
setBusy('login', true);
|
||||
post('login', v);
|
||||
});
|
||||
|
||||
views.account.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
if (busy) return;
|
||||
const v = values('account');
|
||||
const bad = checkAccount(v);
|
||||
if (bad) { markBad('account', bad[1]); return notice('account', bad[0]); }
|
||||
markBad('account', []);
|
||||
notice('account', '');
|
||||
setBusy('account', true);
|
||||
post('registerAccount', v);
|
||||
});
|
||||
|
||||
views.identity.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
if (busy) return;
|
||||
const v = values('identity');
|
||||
const bad = checkIdentity(v);
|
||||
if (bad) { markBad('identity', bad[1]); return notice('identity', bad[0]); }
|
||||
markBad('identity', []);
|
||||
notice('identity', '');
|
||||
setBusy('identity', true);
|
||||
post(resuming ? 'resumeIdentity' : 'registerIdentity', v);
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-goto]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
if (busy) return;
|
||||
notice(current, '');
|
||||
show(btn.dataset.goto);
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------ messages from Lua
|
||||
window.addEventListener('message', (event) => {
|
||||
const msg = event.data || {};
|
||||
const p = msg.payload || {};
|
||||
|
||||
switch (msg.action) {
|
||||
case 'open':
|
||||
stage.classList.add('is-open');
|
||||
stage.setAttribute('aria-hidden', 'false');
|
||||
show('login');
|
||||
break;
|
||||
|
||||
case 'close':
|
||||
stage.classList.remove('is-open');
|
||||
stage.setAttribute('aria-hidden', 'true');
|
||||
break;
|
||||
|
||||
case 'accountResult':
|
||||
setBusy('account', false);
|
||||
if (p.ok) {
|
||||
notice('account', '');
|
||||
show('identity');
|
||||
syncPassport();
|
||||
} else {
|
||||
notice('account', p.error || 'Something went wrong.');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'identityResult':
|
||||
setBusy('identity', false);
|
||||
if (p.ok) {
|
||||
fillPassport(String(p.firstName), String(p.lastName));
|
||||
issuePassport();
|
||||
notice('identity', `Passport issued. Welcome to Los Santos, ${p.firstName}.`, true);
|
||||
views.identity.querySelector('.btn span').textContent = 'Entering the city…';
|
||||
views.identity.querySelector('.btn').disabled = true;
|
||||
// Nothing left to go back to once the document exists.
|
||||
views.identity.querySelector('.switch').style.display = 'none';
|
||||
} else {
|
||||
if (p.reset) { resuming = false; show('account'); }
|
||||
notice(p.reset ? 'account' : 'identity', p.error || 'Something went wrong.');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'loginResult':
|
||||
setBusy('login', false);
|
||||
if (p.ok && p.needsIdentity) {
|
||||
// Account is real but has no resident yet — finish step 2.
|
||||
resuming = true;
|
||||
show('identity');
|
||||
syncPassport();
|
||||
notice('identity', 'Your file is open. Name your resident to finish.', true);
|
||||
} else if (p.ok) {
|
||||
notice('login', `Signed in. Returning to the city, ${p.firstName}.`, true);
|
||||
views.login.querySelector('.btn span').textContent = 'Entering the city…';
|
||||
views.login.querySelector('.btn').disabled = true;
|
||||
} else {
|
||||
markBad('login', ['username', 'password']);
|
||||
notice('login', p.error || 'Something went wrong.');
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// Clear the error ring as soon as the player starts fixing the field.
|
||||
document.querySelectorAll('input').forEach((input) => {
|
||||
input.addEventListener('input', () => input.classList.remove('is-bad'));
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,217 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>FiveRP — Passport Office</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="stage" id="stage" aria-hidden="true">
|
||||
<section class="slab" id="slab" data-view="login">
|
||||
|
||||
<!-- ------------------------------------------------------------- form -->
|
||||
<div class="pane pane-form">
|
||||
<p class="eyebrow">Los Santos <span class="dot"></span> Passport Office</p>
|
||||
|
||||
<div class="rail" id="rail" role="list">
|
||||
<div class="rail-step is-active" data-step="account" role="listitem">
|
||||
<span class="rail-mark"></span>
|
||||
<span class="rail-label">Account</span>
|
||||
</div>
|
||||
<span class="rail-line"><i id="railFill"></i></span>
|
||||
<div class="rail-step" data-step="identity" role="listitem">
|
||||
<span class="rail-mark"></span>
|
||||
<span class="rail-label">Identity</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sign in -->
|
||||
<form class="view" data-view="login" id="formLogin" novalidate>
|
||||
<h1 class="title">Welcome back</h1>
|
||||
<p class="sub">Sign in to return to the city.</p>
|
||||
|
||||
<label class="field">
|
||||
<span class="label">Username</span>
|
||||
<input type="text" name="username" autocomplete="username" spellcheck="false" placeholder="marcus_r">
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span class="label">Password</span>
|
||||
<input type="password" name="password" autocomplete="current-password" placeholder="••••••••">
|
||||
</label>
|
||||
|
||||
<p class="notice" data-notice></p>
|
||||
<button class="btn" type="submit"><span>Sign in</span></button>
|
||||
|
||||
<p class="switch">New to Los Santos? <button type="button" class="link" data-goto="account">Create an account</button></p>
|
||||
</form>
|
||||
|
||||
<!-- Register step 1 -->
|
||||
<form class="view" data-view="account" id="formAccount" novalidate>
|
||||
<h1 class="title">Open your file</h1>
|
||||
<p class="sub">The registry keeps this private. It is how you sign back in.</p>
|
||||
|
||||
<label class="field">
|
||||
<span class="label">Email</span>
|
||||
<input type="email" name="email" autocomplete="email" spellcheck="false" placeholder="you@example.com">
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span class="label">Username</span>
|
||||
<input type="text" name="username" autocomplete="username" spellcheck="false" placeholder="marcus_r">
|
||||
<span class="hint">3–32 characters. Letters, numbers, underscore.</span>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span class="label">Password</span>
|
||||
<input type="password" name="password" autocomplete="new-password" placeholder="At least 8 characters">
|
||||
<span class="pwmeter" data-meter><i></i></span>
|
||||
</label>
|
||||
|
||||
<p class="notice" data-notice></p>
|
||||
<button class="btn" type="submit"><span>Continue</span></button>
|
||||
|
||||
<p class="switch">Already registered? <button type="button" class="link" data-goto="login">Sign in</button></p>
|
||||
</form>
|
||||
|
||||
<!-- Register step 2 -->
|
||||
<form class="view" data-view="identity" id="formIdentity" novalidate>
|
||||
<h1 class="title">Name your resident</h1>
|
||||
<p class="sub">This is the name the city will know you by. English letters only.</p>
|
||||
|
||||
<div class="row">
|
||||
<label class="field">
|
||||
<span class="label">First name</span>
|
||||
<input type="text" name="firstName" autocomplete="off" spellcheck="false" maxlength="24" placeholder="Marcus">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="label">Last name</span>
|
||||
<input type="text" name="lastName" autocomplete="off" spellcheck="false" maxlength="24" placeholder="Reyes">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p class="notice" data-notice></p>
|
||||
<button class="btn" type="submit"><span>Issue passport</span></button>
|
||||
|
||||
<p class="switch"><button type="button" class="link" data-goto="account">Back to account details</button></p>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- ---------------------------------------------------------- passport -->
|
||||
<div class="pane pane-card" id="pane-card">
|
||||
<div class="passport" id="permit">
|
||||
<div class="pp-guilloche" aria-hidden="true"></div>
|
||||
<div class="pp-sheen" id="sheen" aria-hidden="true"></div>
|
||||
|
||||
<header class="pp-head">
|
||||
<span class="pp-seal" aria-hidden="true">
|
||||
<svg viewBox="0 0 64 64">
|
||||
<circle class="ring-out" cx="32" cy="32" r="30"/>
|
||||
<circle class="ring-in" cx="32" cy="32" r="26"/>
|
||||
<!-- 13 stars, arced over the shield -->
|
||||
<g class="stars">
|
||||
<circle cx="32" cy="14" r="1.5"/>
|
||||
<circle cx="25" cy="15.3" r="1.5"/><circle cx="39" cy="15.3" r="1.5"/>
|
||||
<circle cx="19" cy="19" r="1.5"/><circle cx="45" cy="19" r="1.5"/>
|
||||
<circle cx="15" cy="25" r="1.5"/><circle cx="49" cy="25" r="1.5"/>
|
||||
</g>
|
||||
<!-- shield: blue chief over 13 pales -->
|
||||
<path class="shield-chief" d="M17 28h30v7H17z"/>
|
||||
<g class="shield-pales">
|
||||
<path d="M17 35h3v9.5c0 .6.1 1.2.2 1.8H17z"/>
|
||||
<path d="M23 35h3v13h-3z"/>
|
||||
<path d="M29 35h3v13h-3z"/>
|
||||
<path d="M35 35h3v13h-3z"/>
|
||||
<path d="M41 35h3v13h-3z"/>
|
||||
</g>
|
||||
<path class="shield-edge" d="M17 28h30v16.5c0 5.5-6.5 10.5-15 13.5-8.5-3-15-8-15-13.5z"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="pp-head-text">
|
||||
<span class="pp-kind">Passport</span>
|
||||
<span class="pp-country">United States of America</span>
|
||||
</span>
|
||||
<span class="pp-chip" aria-hidden="true">
|
||||
<svg viewBox="0 0 20 16">
|
||||
<rect x="0.6" y="0.6" width="18.8" height="14.8" rx="2.4"/>
|
||||
<path d="M7 .6v15M13 .6v15M.6 5.4h18.8M.6 10.6h18.8"/>
|
||||
</svg>
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div class="pp-body">
|
||||
<div class="pp-left">
|
||||
<div class="pp-photo">
|
||||
<svg class="pp-bust" viewBox="0 0 48 60" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id="bust" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#9aa7b8"/>
|
||||
<stop offset="1" stop-color="#5d6b7e"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="24" cy="18" r="11" fill="url(#bust)"/>
|
||||
<path d="M3 60c0-11.5 9.4-20 21-20s21 8.5 21 20z" fill="url(#bust)"/>
|
||||
</svg>
|
||||
<span class="pp-photo-label">Photo</span>
|
||||
</div>
|
||||
|
||||
<div class="pp-sign">
|
||||
<span class="pp-sign-ink" id="cardSign"></span>
|
||||
<span class="pp-sign-rule"></span>
|
||||
<span class="pp-sign-label">Signature</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl class="pp-fields">
|
||||
<div class="pp-f pp-f--type"><dt>Type</dt><dd>P</dd></div>
|
||||
<div class="pp-f pp-f--code"><dt>Code</dt><dd>USA</dd></div>
|
||||
<div class="pp-f pp-f--no"><dt>Passport No.</dt><dd id="cardNo" class="mono">—</dd></div>
|
||||
|
||||
<div class="pp-f pp-f--wide"><dt>Surname</dt>
|
||||
<dd class="pp-name" id="cardLast" data-empty="—"></dd></div>
|
||||
<div class="pp-f pp-f--wide"><dt>Given names</dt>
|
||||
<dd class="pp-name" id="cardFirst" data-empty="—"></dd></div>
|
||||
<div class="pp-f pp-f--wide"><dt>Nationality</dt>
|
||||
<dd>United States of America</dd></div>
|
||||
|
||||
<div class="pp-f"><dt>Date of birth</dt><dd class="mono" id="cardDob">—</dd></div>
|
||||
<div class="pp-f"><dt>Sex</dt><dd>X</dd></div>
|
||||
<div class="pp-f pp-f--wide"><dt>Place of birth</dt><dd>Los Santos, San Andreas</dd></div>
|
||||
<div class="pp-f"><dt>Date of issue</dt><dd class="mono" id="cardIssue">—</dd></div>
|
||||
<div class="pp-f"><dt>Date of expiration</dt><dd class="mono" id="cardExpiry">—</dd></div>
|
||||
|
||||
<!-- Spans 4 of 6 columns, leaving the bottom-right corner clear so
|
||||
the entry stamp lands on paper rather than on top of data. -->
|
||||
<div class="pp-f pp-f--auth"><dt>Authority</dt>
|
||||
<dd>United States Department of State</dd></div>
|
||||
</dl>
|
||||
|
||||
<div class="pp-ghost" aria-hidden="true">
|
||||
<svg viewBox="0 0 48 60">
|
||||
<circle cx="24" cy="20" r="12"/>
|
||||
<path d="M2 60c0-12 10-21 22-21s22 9 22 21z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pp-mrz mono">
|
||||
<span id="mrz1">P<USA<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<</span>
|
||||
<span id="mrz2"><<<<<<<<<<USA<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<</span>
|
||||
</div>
|
||||
|
||||
<div class="pp-stamp" id="seal" aria-hidden="true">
|
||||
<span class="pp-stamp-top">Admitted</span>
|
||||
<span class="pp-stamp-mid">Los Santos</span>
|
||||
<span class="pp-stamp-bot" id="stampDate">— — —</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="pp-caption">Issued once. The city will know you by this name.</p>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
</div>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,727 @@
|
||||
/* FiveRP — passport office (registration + sign in).
|
||||
Design system: /opt/fivem/server-data/CLAUDE.md
|
||||
|
||||
Deliberately zero box-shadow on panels. CEF composites the shadow of a
|
||||
backdrop-filtered element as an opaque rectangle, which is what put a black
|
||||
square behind this panel in game. Depth here comes from blur, hairlines and
|
||||
inner highlights only. */
|
||||
|
||||
@font-face {
|
||||
font-family: 'Unbounded';
|
||||
font-style: normal;
|
||||
font-weight: 300 800;
|
||||
font-display: block;
|
||||
src: url('fonts/unbounded-latin.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;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Unbounded';
|
||||
font-style: normal;
|
||||
font-weight: 300 800;
|
||||
font-display: block;
|
||||
src: url('fonts/unbounded-latin-ext.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;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Unbounded';
|
||||
font-style: normal;
|
||||
font-weight: 300 800;
|
||||
font-display: block;
|
||||
src: url('fonts/unbounded-cyrillic.woff2') format('woff2');
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
|
||||
:root {
|
||||
--surface: rgba(255, 255, 255, 0.72);
|
||||
--panel: rgba(255, 255, 255, 0.80);
|
||||
--field-bg: rgba(0, 0, 0, 0.04);
|
||||
--field-bg-focus:#ffffff;
|
||||
|
||||
--fg: #1d1d1f;
|
||||
--fg-secondary: #6e6e73;
|
||||
--fg-tertiary: #a1a1a6;
|
||||
|
||||
--accent: #0071e3;
|
||||
--accent-strong: #0077ed;
|
||||
--accent-soft: rgba(0, 113, 227, 0.08);
|
||||
--ring: rgba(0, 113, 227, 0.18);
|
||||
|
||||
--line: rgba(0, 0, 0, 0.06);
|
||||
--line-strong: rgba(0, 0, 0, 0.10);
|
||||
--hover: rgba(0, 0, 0, 0.04);
|
||||
|
||||
--success: #34c759;
|
||||
--danger: #ff3b30;
|
||||
|
||||
--radius-lg: 1.5rem;
|
||||
--radius-md: 1rem;
|
||||
--radius-sm: 0.7rem;
|
||||
|
||||
/* passport ink */
|
||||
--pp-navy: #10315e;
|
||||
--pp-label: #46719f;
|
||||
--pp-value: #14202f;
|
||||
|
||||
--sans: 'Unbounded', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
||||
'Helvetica Neue', Arial, sans-serif;
|
||||
--mono: ui-monospace, 'SF Mono', SFMono-Regular, Menlo, Consolas,
|
||||
'Liberation Mono', 'DejaVu Sans Mono', monospace;
|
||||
--ease: cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html, body {
|
||||
width: 100%; height: 100%;
|
||||
background: transparent; /* the game is behind this page */
|
||||
overflow: hidden;
|
||||
font-family: var(--sans);
|
||||
letter-spacing: -0.005em;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------- stage */
|
||||
.stage {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 32px;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 460ms var(--ease), visibility 0s linear 460ms;
|
||||
/* Lightening only. Never darken the game to make the panel readable —
|
||||
that is what produced the grey box before. */
|
||||
background: radial-gradient(90% 70% at 50% 50%,
|
||||
rgba(255,255,255,0.22) 0%, rgba(255,255,255,0) 72%);
|
||||
}
|
||||
.stage.is-open {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transition: opacity 460ms var(--ease), visibility 0s;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------- slab */
|
||||
.slab {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 520px;
|
||||
width: min(1080px, 94vw);
|
||||
background: var(--surface);
|
||||
border: 1px solid rgba(255, 255, 255, 0.55);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
transform: translateY(14px) scale(0.985);
|
||||
opacity: 0;
|
||||
transition:
|
||||
width 620ms var(--ease),
|
||||
grid-template-columns 620ms var(--ease),
|
||||
transform 620ms var(--ease),
|
||||
opacity 480ms var(--ease);
|
||||
}
|
||||
.stage.is-open .slab { transform: none; opacity: 1; }
|
||||
|
||||
/* Signing in needs no passport — collapse to a single column. */
|
||||
.slab[data-view="login"] {
|
||||
width: min(460px, 94vw);
|
||||
grid-template-columns: minmax(0, 1fr) 0px;
|
||||
}
|
||||
.slab[data-view="login"] .pane-card {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateX(24px);
|
||||
}
|
||||
|
||||
.pane { min-width: 0; }
|
||||
|
||||
.pane-form {
|
||||
padding: 40px 40px 34px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.pane-card {
|
||||
padding: 32px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
border-left: 1px solid var(--line);
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.34), rgba(255,255,255,0.14));
|
||||
transition: opacity 420ms var(--ease), transform 620ms var(--ease);
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------- eyebrow */
|
||||
.eyebrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.20em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-tertiary);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.eyebrow .dot {
|
||||
width: 3px; height: 3px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------- rail */
|
||||
.rail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 0;
|
||||
opacity: 0;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
transition: opacity 360ms var(--ease);
|
||||
}
|
||||
/* Signing in has no steps, so the rail collapses margin and all. */
|
||||
.slab[data-view="account"] .rail,
|
||||
.slab[data-view="identity"] .rail { opacity: 1; height: auto; margin-bottom: 30px; }
|
||||
|
||||
.rail-step { display: flex; align-items: center; gap: 8px; }
|
||||
.rail-mark {
|
||||
position: relative;
|
||||
width: 11px; height: 11px;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid var(--line-strong);
|
||||
background: transparent;
|
||||
transition: background-color .3s var(--ease), border-color .3s var(--ease);
|
||||
}
|
||||
.rail-step.is-active .rail-mark {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent);
|
||||
}
|
||||
.rail-step.is-done .rail-mark {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent);
|
||||
}
|
||||
/* Done and active must not look identical — done gets a tick. */
|
||||
.rail-step.is-done .rail-mark::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 2.4px; top: 1px;
|
||||
width: 3px; height: 5.5px;
|
||||
border: solid #fff;
|
||||
border-width: 0 1.5px 1.5px 0;
|
||||
transform: rotate(42deg);
|
||||
}
|
||||
.rail-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.09em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-tertiary);
|
||||
transition: color .3s var(--ease);
|
||||
}
|
||||
.rail-step.is-active .rail-label,
|
||||
.rail-step.is-done .rail-label { color: var(--fg-secondary); }
|
||||
|
||||
.rail-line {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
height: 1.5px;
|
||||
border-radius: 2px;
|
||||
background: var(--line-strong);
|
||||
overflow: hidden;
|
||||
}
|
||||
.rail-line i {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: 0;
|
||||
background: var(--accent);
|
||||
transition: width 560ms var(--ease);
|
||||
}
|
||||
.rail-line i.is-full { width: 100%; }
|
||||
|
||||
/* ------------------------------------------------------------------- views */
|
||||
.view {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
}
|
||||
.view.is-current { display: flex; animation: viewIn .5s var(--ease) both; }
|
||||
|
||||
@keyframes viewIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.035em;
|
||||
line-height: 1.1;
|
||||
color: var(--fg);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.sub {
|
||||
font-size: 13.5px;
|
||||
font-weight: 400;
|
||||
line-height: 1.55;
|
||||
color: var(--fg-secondary);
|
||||
margin-bottom: 26px;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ fields */
|
||||
.field {
|
||||
display: block;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.09em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-secondary);
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
.field input {
|
||||
width: 100%;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 13px 14px;
|
||||
font-family: var(--sans);
|
||||
font-size: 14.5px;
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.005em;
|
||||
color: var(--fg);
|
||||
background: var(--field-bg);
|
||||
border: 1px solid transparent;
|
||||
outline: none;
|
||||
transition: background-color .2s, border-color .2s, box-shadow .2s;
|
||||
}
|
||||
.field input::placeholder { color: var(--fg-tertiary); }
|
||||
.field input:focus {
|
||||
background: var(--field-bg-focus);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.field input.is-bad {
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.hint {
|
||||
display: block;
|
||||
margin-top: 7px;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: var(--fg-tertiary);
|
||||
}
|
||||
|
||||
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
||||
.row .field { margin-bottom: 16px; }
|
||||
|
||||
/* password strength */
|
||||
.pwmeter {
|
||||
display: block;
|
||||
height: 3px;
|
||||
margin-top: 9px;
|
||||
border-radius: 2px;
|
||||
background: var(--line);
|
||||
opacity: 0;
|
||||
transition: opacity .25s ease;
|
||||
}
|
||||
.pwmeter.is-live { opacity: 1; }
|
||||
.pwmeter i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 0;
|
||||
border-radius: 2px;
|
||||
background: var(--danger);
|
||||
transition: width .3s var(--ease), background-color .3s ease;
|
||||
}
|
||||
.pwmeter[data-level="2"] i { background: #ff9f0a; }
|
||||
.pwmeter[data-level="3"] i { background: var(--success); }
|
||||
|
||||
/* ------------------------------------------------------------------ notice */
|
||||
.notice {
|
||||
font-size: 12.5px;
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
color: var(--danger);
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height .3s var(--ease), opacity .3s ease, margin .3s var(--ease);
|
||||
}
|
||||
.notice.is-shown {
|
||||
max-height: 60px;
|
||||
opacity: 1;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.notice.is-good { color: #1f8f3d; }
|
||||
|
||||
/* ----------------------------------------------------------------- buttons */
|
||||
.btn {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
padding: 14px 24px;
|
||||
font-family: var(--sans);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
background-image: linear-gradient(180deg, var(--accent-strong), var(--accent));
|
||||
transition: filter .2s ease, transform .12s var(--ease), opacity .2s ease;
|
||||
}
|
||||
.btn:hover:not(:disabled) { filter: brightness(1.08); }
|
||||
.btn:active:not(:disabled) { transform: scale(0.978); }
|
||||
.btn:disabled { opacity: .55; cursor: default; }
|
||||
|
||||
.btn.is-busy span { opacity: 0; }
|
||||
.btn.is-busy::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%; left: 50%;
|
||||
width: 16px; height: 16px;
|
||||
margin: -8px 0 0 -8px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(255, 255, 255, 0.35);
|
||||
border-top-color: #fff;
|
||||
animation: spin .7s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.switch {
|
||||
margin-top: 18px;
|
||||
text-align: center;
|
||||
font-size: 12.5px;
|
||||
font-weight: 400;
|
||||
color: var(--fg-secondary);
|
||||
}
|
||||
.link {
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 0;
|
||||
font-family: var(--sans);
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
.link:hover { text-decoration: underline; }
|
||||
|
||||
/* ==================================================================== PASSPORT
|
||||
A United States passport data page. This is the one saturated object on the
|
||||
screen; the form beside it stays neutral so the document reads as the hero. */
|
||||
|
||||
.passport {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 125 / 88; /* ID-3, the real bio-page proportion */
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
padding: 11px 15px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: var(--pp-value);
|
||||
background:
|
||||
radial-gradient(78% 66% at 16% 14%, rgba(255,255,255,0.92), rgba(255,255,255,0) 62%),
|
||||
linear-gradient(155deg, #f8fbff 0%, #e9f1fa 46%, #f5f1e7 100%);
|
||||
border: 1px solid rgba(16, 49, 94, 0.16);
|
||||
transition: transform 700ms var(--ease);
|
||||
}
|
||||
.passport.is-issued { transform: translateY(-3px); }
|
||||
|
||||
/* Engine-turned security print. Three cheap repeating gradients read
|
||||
convincingly as guilloche at this scale. */
|
||||
.pp-guilloche {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
opacity: .55;
|
||||
background:
|
||||
repeating-radial-gradient(circle at 20% 44%, rgba(16,49,94,.055) 0 1px, transparent 1px 7px),
|
||||
repeating-radial-gradient(circle at 80% 62%, rgba(16,49,94,.05) 0 1px, transparent 1px 9px),
|
||||
repeating-linear-gradient(58deg, rgba(16,49,94,.032) 0 1px, transparent 1px 6px);
|
||||
}
|
||||
|
||||
/* Laminate glare that follows the cursor. */
|
||||
.pp-sheen {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
opacity: .55;
|
||||
background: radial-gradient(circle at var(--mx, 78%) var(--my, 10%),
|
||||
rgba(255,255,255,0.75) 0%, rgba(255,255,255,0) 46%);
|
||||
transition: opacity .5s ease;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ passport head */
|
||||
.pp-head {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-bottom: 9px;
|
||||
border-bottom: 1px solid rgba(16, 49, 94, 0.18);
|
||||
}
|
||||
.pp-seal { flex: none; width: 30px; height: 30px; display: block; }
|
||||
.pp-seal svg { width: 100%; height: 100%; display: block; }
|
||||
.pp-seal .ring-out,
|
||||
.pp-seal .ring-in { fill: none; stroke: rgba(16,49,94,.55); stroke-width: 1.1; }
|
||||
.pp-seal .ring-in { stroke-width: .6; }
|
||||
.pp-seal .stars { fill: rgba(16,49,94,.6); }
|
||||
.pp-seal .shield-chief { fill: rgba(16,49,94,.75); }
|
||||
.pp-seal .shield-pales { fill: rgba(16,49,94,.28); }
|
||||
.pp-seal .shield-edge { fill: none; stroke: rgba(16,49,94,.65); stroke-width: 1.2; }
|
||||
|
||||
.pp-head-text { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
|
||||
.pp-kind {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.30em;
|
||||
text-transform: uppercase;
|
||||
color: var(--pp-navy);
|
||||
}
|
||||
.pp-country {
|
||||
font-size: 8px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.15em;
|
||||
text-transform: uppercase;
|
||||
color: var(--pp-label);
|
||||
}
|
||||
.pp-chip { margin-left: auto; width: 19px; height: 15px; opacity: .6; }
|
||||
.pp-chip svg { width: 100%; height: 100%; fill: none; stroke: rgba(16,49,94,.6); stroke-width: 1; }
|
||||
|
||||
/* ------------------------------------------------------------ passport body */
|
||||
.pp-body {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 84px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.pp-left { display: flex; flex-direction: column; gap: 9px; }
|
||||
|
||||
.pp-photo {
|
||||
position: relative;
|
||||
align-self: start;
|
||||
border: 1px solid rgba(16, 49, 94, 0.22);
|
||||
border-radius: 3px;
|
||||
background: linear-gradient(180deg, #eef2f7, #dde5ee);
|
||||
aspect-ratio: 35 / 45; /* passport photo proportion */
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
place-items: end center;
|
||||
}
|
||||
.pp-bust { width: 76%; height: 88%; align-self: end; }
|
||||
.pp-photo-label {
|
||||
position: absolute;
|
||||
top: 4px; left: 5px;
|
||||
font-size: 6px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(16, 49, 94, 0.42);
|
||||
}
|
||||
|
||||
/* The secondary faded portrait real passports carry to the right of the data. */
|
||||
.pp-ghost {
|
||||
position: absolute;
|
||||
right: 2px; top: 34%;
|
||||
width: 50px;
|
||||
opacity: .1;
|
||||
pointer-events: none;
|
||||
}
|
||||
.pp-ghost svg { width: 100%; display: block; fill: var(--pp-navy); }
|
||||
|
||||
.pp-fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 4px 10px;
|
||||
align-content: start;
|
||||
min-width: 0;
|
||||
}
|
||||
.pp-f { grid-column: span 2; min-width: 0; }
|
||||
.pp-f--type { grid-column: span 1; }
|
||||
.pp-f--code { grid-column: span 1; }
|
||||
.pp-f--no { grid-column: span 4; }
|
||||
.pp-f--wide { grid-column: span 6; }
|
||||
|
||||
.pp-f dt {
|
||||
font-size: 6.5px;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
letter-spacing: 0.13em;
|
||||
text-transform: uppercase;
|
||||
color: var(--pp-label);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pp-f dd {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
line-height: 1.3;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--pp-value);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.pp-f dd.mono {
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.pp-f--auth { grid-column: span 4; }
|
||||
.pp-f--auth dd { font-size: 8.5px; font-weight: 400; color: var(--pp-label); }
|
||||
|
||||
/* The two fields the player actually controls — set larger than the rest. */
|
||||
.pp-name {
|
||||
font-size: 15px !important;
|
||||
font-weight: 700 !important;
|
||||
line-height: 1.25 !important;
|
||||
letter-spacing: -0.02em !important;
|
||||
text-transform: uppercase;
|
||||
color: var(--pp-navy) !important;
|
||||
min-height: 19px;
|
||||
}
|
||||
.pp-name:empty::before {
|
||||
content: attr(data-empty);
|
||||
color: rgba(16, 49, 94, 0.25);
|
||||
}
|
||||
/* Each keystroke re-prints the value; the fade makes it feel typeset. */
|
||||
.printed { display: inline-block; animation: printIn .4s var(--ease) both; }
|
||||
@keyframes printIn {
|
||||
from { opacity: 0; transform: translateY(3px); filter: blur(3px); }
|
||||
to { opacity: 1; transform: none; filter: blur(0); }
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------- signature */
|
||||
.pp-sign {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.pp-sign-ink {
|
||||
font-family: 'Segoe Script', 'Bradley Hand', 'Snell Roundhand', cursive;
|
||||
font-size: 11px;
|
||||
color: rgba(16, 49, 94, 0.75);
|
||||
height: 14px;
|
||||
line-height: 14px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
transform: rotate(-1.5deg);
|
||||
transform-origin: left bottom;
|
||||
}
|
||||
.pp-sign-rule { height: 1px; background: rgba(16, 49, 94, 0.28); }
|
||||
.pp-sign-label {
|
||||
font-size: 6px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--pp-label);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------------- MRZ */
|
||||
.pp-mrz {
|
||||
margin: 6px -15px 0;
|
||||
margin-top: auto; /* pinned to the foot of the page */
|
||||
padding: 6px 15px 7px;
|
||||
border-top: 1px solid rgba(16, 49, 94, 0.18);
|
||||
background: rgba(255, 255, 255, 0.62);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.pp-mrz span {
|
||||
display: block;
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
line-height: 1.25;
|
||||
letter-spacing: 0.055em;
|
||||
color: var(--pp-navy);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------- stamp */
|
||||
.pp-stamp {
|
||||
position: absolute;
|
||||
right: 13px; bottom: 50px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
padding: 7px 13px;
|
||||
border: 2px solid rgba(176, 42, 42, 0.62);
|
||||
border-radius: 4px;
|
||||
color: rgba(176, 42, 42, 0.78);
|
||||
/* multiply lets the guilloche show through, like real stamp ink */
|
||||
mix-blend-mode: multiply;
|
||||
transform: rotate(-13deg) scale(1.5);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 420ms var(--ease), transform 520ms cubic-bezier(.2,1.4,.4,1);
|
||||
}
|
||||
.pp-stamp.is-stamped { opacity: 1; transform: rotate(-13deg) scale(1); }
|
||||
|
||||
.pp-stamp-top, .pp-stamp-bot {
|
||||
font-size: 6.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.pp-stamp-mid {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.pp-caption {
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: var(--fg-tertiary);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------- motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: .001ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: .001ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 6px; }
|
||||
::selection { background: var(--accent-soft); color: var(--accent-strong); }
|
||||
|
||||
/* ---------------------------------------------------------------- NUI final
|
||||
CEF turns backdrop-filter and box-shadow into opaque black plates at the
|
||||
element bounds, so both are gone from this file entirely. Panels earn their
|
||||
separation from opacity and hairlines instead of blur, which means the
|
||||
surface has to carry more white than it did when a blur sat behind it.
|
||||
Appended last so it wins over anything above. */
|
||||
:root {
|
||||
--surface: rgba(255, 255, 255, 0.95);
|
||||
--panel: rgba(255, 255, 255, 0.97);
|
||||
}
|
||||
.slab {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
.stage { background: none; }
|
||||
.field:focus,
|
||||
.field:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
-- Character slots, live appearance creator and spawn.
|
||||
-- The preview ped IS the player ped, so what the player sculpts is literally
|
||||
-- the body they walk away with — no separate preview entity to keep in sync.
|
||||
|
||||
local isOpen = false
|
||||
local inEditor = false
|
||||
local activeChar = nil -- id of the character being played
|
||||
local editSlot = nil -- slot being filled
|
||||
local editId = nil -- id when finishing an existing draft
|
||||
local cam = nil
|
||||
local zoom = 'head'
|
||||
|
||||
local PREVIEW_POS = vector3(-1042.0, -2745.0, 21.36)
|
||||
local PREVIEW_HDG = 315.0
|
||||
local SPAWN_POS = vector3(195.17, -889.36, 30.69)
|
||||
local SPAWN_HDG = 145.0
|
||||
|
||||
-- Camera framing per zoom step: forward distance, height above the ped root,
|
||||
-- and the height it looks at.
|
||||
local FRAMES = {
|
||||
head = { dist = 0.85, height = 0.70, look = 0.68 },
|
||||
body = { dist = 2.30, height = 0.30, look = 0.25 },
|
||||
legs = { dist = 2.10, height = -0.35, look = -0.45 },
|
||||
}
|
||||
|
||||
-- Fallback counts, used only if the native that reports them is unavailable.
|
||||
local OVERLAY_FALLBACK = { 24, 29, 34, 15, 75, 7, 12, 11, 10, 18, 17, 12 }
|
||||
|
||||
-- ------------------------------------------------------------------- helpers
|
||||
local function loadModel(name)
|
||||
local hash = GetHashKey(name)
|
||||
if not IsModelInCdimage(hash) then return nil end
|
||||
RequestModel(hash)
|
||||
local timeout = GetGameTimer() + 10000
|
||||
while not HasModelLoaded(hash) and GetGameTimer() < timeout do Wait(0) end
|
||||
return HasModelLoaded(hash) and hash or nil
|
||||
end
|
||||
|
||||
local function num(v, fallback)
|
||||
v = tonumber(v)
|
||||
if v == nil then return fallback end
|
||||
return v + 0.0
|
||||
end
|
||||
|
||||
-- Overlays 1/2/10 take hair colours, 4/5/8 take makeup colours, the rest none.
|
||||
local function overlayColourType(index)
|
||||
if index == 1 or index == 2 or index == 10 then return 1 end
|
||||
if index == 4 or index == 5 or index == 8 then return 2 end
|
||||
return 0
|
||||
end
|
||||
|
||||
local function applyAppearance(app)
|
||||
if type(app) ~= 'table' then return end
|
||||
|
||||
local wanted = GetHashKey(app.model or 'mp_m_freemode_01')
|
||||
if GetEntityModel(PlayerPedId()) ~= wanted then
|
||||
local hash = loadModel(app.model or 'mp_m_freemode_01')
|
||||
if hash then
|
||||
SetPlayerModel(PlayerId(), hash)
|
||||
SetModelAsNoLongerNeeded(hash)
|
||||
SetPedDefaultComponentVariation(PlayerPedId())
|
||||
end
|
||||
end
|
||||
|
||||
local ped = PlayerPedId()
|
||||
local b = app.blend or {}
|
||||
SetPedHeadBlendData(ped,
|
||||
math.floor(num(b.shapeFirst, 0)), math.floor(num(b.shapeSecond, 0)), 0,
|
||||
math.floor(num(b.skinFirst, 0)), math.floor(num(b.skinSecond, 0)), 0,
|
||||
num(b.shapeMix, 0.5), num(b.skinMix, 0.5), 0.0, false)
|
||||
|
||||
local features = app.features or {}
|
||||
for i = 0, 19 do
|
||||
SetPedFaceFeature(ped, i, num(features[i + 1], 0.0))
|
||||
end
|
||||
|
||||
local overlays = app.overlays or {}
|
||||
for i = 0, 11 do
|
||||
local o = overlays[i + 1] or {}
|
||||
local value = math.floor(num(o.v, 255))
|
||||
SetPedHeadOverlay(ped, i, value, num(o.o, 1.0))
|
||||
local kind = overlayColourType(i)
|
||||
if kind > 0 then
|
||||
SetPedHeadOverlayColor(ped, i, kind, math.floor(num(o.c1, 0)), math.floor(num(o.c2, 0)))
|
||||
end
|
||||
end
|
||||
|
||||
local hair = app.hair or {}
|
||||
SetPedComponentVariation(ped, 2, math.floor(num(hair.style, 0)), 0, 2)
|
||||
SetPedHairColor(ped, math.floor(num(hair.color, 0)), math.floor(num(hair.highlight, 0)))
|
||||
SetPedEyeColor(ped, math.floor(num(app.eye, 0)))
|
||||
|
||||
local fit = app.outfit or {}
|
||||
SetPedComponentVariation(ped, 11, math.floor(num(fit.torso, 0)), 0, 2) -- jacket / top
|
||||
SetPedComponentVariation(ped, 8, math.floor(num(fit.undershirt, 0)), 0, 2)
|
||||
SetPedComponentVariation(ped, 4, math.floor(num(fit.legs, 0)), 0, 2)
|
||||
SetPedComponentVariation(ped, 6, math.floor(num(fit.shoes, 0)), 0, 2)
|
||||
SetPedComponentVariation(ped, 3, 0, 0, 2) -- bare arms/torso
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------- camera
|
||||
local function updateCamera()
|
||||
if not cam or not DoesCamExist(cam) then return end
|
||||
local ped = PlayerPedId()
|
||||
local f = FRAMES[zoom] or FRAMES.head
|
||||
local pos = GetOffsetFromEntityInWorldCoords(ped, 0.0, f.dist, f.height)
|
||||
local look = GetOffsetFromEntityInWorldCoords(ped, 0.0, 0.0, f.look)
|
||||
SetCamCoord(cam, pos.x, pos.y, pos.z)
|
||||
PointCamAtCoord(cam, look.x, look.y, look.z)
|
||||
end
|
||||
|
||||
local function startCamera()
|
||||
if cam and DoesCamExist(cam) then DestroyCam(cam, false) end
|
||||
cam = CreateCamWithParams('DEFAULT_SCRIPTED_CAMERA', 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 32.0, false, 0)
|
||||
SetCamActive(cam, true)
|
||||
RenderScriptCams(true, false, 0, true, true)
|
||||
updateCamera()
|
||||
end
|
||||
|
||||
local function stopCamera()
|
||||
if cam and DoesCamExist(cam) then
|
||||
RenderScriptCams(false, true, 700, true, true)
|
||||
DestroyCam(cam, false)
|
||||
end
|
||||
cam = nil
|
||||
end
|
||||
|
||||
-- Park the ped on the creator stage: visible, lit, frozen, facing the camera.
|
||||
local function stagePed()
|
||||
local ped = PlayerPedId()
|
||||
RequestCollisionAtCoord(PREVIEW_POS.x, PREVIEW_POS.y, PREVIEW_POS.z)
|
||||
SetEntityCoordsNoOffset(ped, PREVIEW_POS.x, PREVIEW_POS.y, PREVIEW_POS.z, false, false, false)
|
||||
SetEntityHeading(ped, PREVIEW_HDG)
|
||||
FreezeEntityPosition(ped, true)
|
||||
SetEntityInvincible(ped, true)
|
||||
SetEntityVisible(ped, true, false)
|
||||
SetEntityCollision(ped, true, true)
|
||||
ClearPedTasksImmediately(ped)
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------- NUI
|
||||
local function sendLimits()
|
||||
local ped = PlayerPedId()
|
||||
local overlays = {}
|
||||
for i = 0, 11 do
|
||||
local ok, count = pcall(GetNumHeadOverlayValues, i)
|
||||
overlays[i + 1] = (ok and count and count > 0) and count or OVERLAY_FALLBACK[i + 1]
|
||||
end
|
||||
SendNUIMessage({ action = 'limits', payload = {
|
||||
overlays = overlays,
|
||||
hair = GetNumberOfPedDrawableVariations(ped, 2),
|
||||
torso = GetNumberOfPedDrawableVariations(ped, 11),
|
||||
undershirt = GetNumberOfPedDrawableVariations(ped, 8),
|
||||
legs = GetNumberOfPedDrawableVariations(ped, 4),
|
||||
shoes = GetNumberOfPedDrawableVariations(ped, 6),
|
||||
}})
|
||||
end
|
||||
|
||||
local function openUI(view)
|
||||
isOpen = true
|
||||
SetNuiFocus(true, true)
|
||||
SendNUIMessage({ action = 'open', view = view })
|
||||
end
|
||||
|
||||
local function closeUI()
|
||||
isOpen = false
|
||||
SetNuiFocus(false, false)
|
||||
SendNUIMessage({ action = 'close' })
|
||||
end
|
||||
|
||||
-- Hold the player still and silent while the slots or the creator are up.
|
||||
CreateThread(function()
|
||||
while true do
|
||||
if isOpen then
|
||||
DisableAllControlActions(0)
|
||||
-- Mouse look would fight the scripted camera.
|
||||
DisableControlAction(0, 1, true)
|
||||
DisableControlAction(0, 2, true)
|
||||
SetPlayerInvincible(PlayerId(), true)
|
||||
Wait(0)
|
||||
else
|
||||
Wait(300)
|
||||
end
|
||||
end
|
||||
end)
|
||||
|
||||
-- --------------------------------------------------------------- the flow
|
||||
local function beginSelection()
|
||||
exports.spawnmanager:setAutoSpawn(false)
|
||||
|
||||
-- A clear midday sky so nobody sculpts a face in the dark.
|
||||
NetworkOverrideClockTime(13, 0, 0)
|
||||
SetWeatherTypeNowPersist('EXTRASUNNY')
|
||||
|
||||
local ped = PlayerPedId()
|
||||
SetEntityVisible(ped, false, false)
|
||||
stagePed()
|
||||
SetEntityVisible(ped, false, false)
|
||||
|
||||
startCamera()
|
||||
zoom = 'body'
|
||||
updateCamera()
|
||||
|
||||
openUI('select')
|
||||
TriggerServerEvent('fiverp-chars:request')
|
||||
DoScreenFadeIn(600)
|
||||
end
|
||||
|
||||
-- fiverp-auth hands over with a local TriggerEvent once the account is known.
|
||||
RegisterNetEvent('fiverp-chars:begin', beginSelection)
|
||||
|
||||
AddEventHandler('onClientMapStart', function()
|
||||
if not activeChar then exports.spawnmanager:setAutoSpawn(false) end
|
||||
end)
|
||||
|
||||
RegisterNetEvent('fiverp-chars:slots', function(slots)
|
||||
SendNUIMessage({ action = 'slots', payload = slots })
|
||||
end)
|
||||
|
||||
RegisterNetEvent('fiverp-chars:saveResult', function(result)
|
||||
SendNUIMessage({ action = 'saveResult', payload = result })
|
||||
end)
|
||||
|
||||
local function enterCity(data)
|
||||
activeChar = data.id
|
||||
inEditor = false
|
||||
closeUI()
|
||||
DoScreenFadeOut(500)
|
||||
Wait(600)
|
||||
stopCamera()
|
||||
|
||||
NetworkClearClockTimeOverride()
|
||||
ClearWeatherTypePersist()
|
||||
|
||||
local pos = data.position
|
||||
local x, y, z, h
|
||||
if pos and pos.x and pos.x ~= 0 then
|
||||
x, y, z, h = pos.x + 0.0, pos.y + 0.0, pos.z + 0.0, (pos.heading or 0.0) + 0.0
|
||||
else
|
||||
x, y, z, h = SPAWN_POS.x, SPAWN_POS.y, SPAWN_POS.z, SPAWN_HDG
|
||||
end
|
||||
|
||||
exports.spawnmanager:spawnPlayer({
|
||||
x = x, y = y, z = z, heading = h,
|
||||
model = data.model or 'mp_m_freemode_01',
|
||||
skipFade = true
|
||||
}, function()
|
||||
-- spawnPlayer resets the model, so the face has to go back on afterwards.
|
||||
applyAppearance(data.appearance)
|
||||
local ped = PlayerPedId()
|
||||
FreezeEntityPosition(ped, false)
|
||||
SetEntityInvincible(ped, false)
|
||||
SetEntityVisible(ped, true, false)
|
||||
SetEntityCollision(ped, true, true)
|
||||
EnableAllControlActions(0)
|
||||
SetPlayerInvincible(PlayerId(), false)
|
||||
DoScreenFadeIn(800)
|
||||
|
||||
TriggerEvent('chat:addMessage', {
|
||||
color = { 0, 113, 227 },
|
||||
multiline = true,
|
||||
args = { 'Los Santos', ('Welcome back, %s %s.'):format(data.firstName, data.lastName) }
|
||||
})
|
||||
end)
|
||||
end
|
||||
|
||||
RegisterNetEvent('fiverp-chars:spawn', function(data)
|
||||
if not data.ok then
|
||||
return SendNUIMessage({ action = 'saveResult', payload = { ok = false, error = data.error } })
|
||||
end
|
||||
enterCity(data)
|
||||
end)
|
||||
|
||||
-- Persist where the player stood, so the next session resumes there.
|
||||
CreateThread(function()
|
||||
while true do
|
||||
Wait(60000)
|
||||
if activeChar then
|
||||
local c = GetEntityCoords(PlayerPedId())
|
||||
TriggerServerEvent('fiverp-chars:savePosition', {
|
||||
id = activeChar, x = c.x, y = c.y, z = c.z, heading = GetEntityHeading(PlayerPedId())
|
||||
})
|
||||
end
|
||||
end
|
||||
end)
|
||||
|
||||
AddEventHandler('onResourceStop', function(resource)
|
||||
if resource ~= GetCurrentResourceName() then return end
|
||||
if isOpen then SetNuiFocus(false, false) end
|
||||
stopCamera()
|
||||
end)
|
||||
|
||||
-- --------------------------------------------------------------- callbacks
|
||||
RegisterNUICallback('startEditor', function(data, cb)
|
||||
inEditor = true
|
||||
editSlot = data.slot
|
||||
editId = data.id
|
||||
zoom = 'head'
|
||||
|
||||
local ped = PlayerPedId()
|
||||
SetEntityVisible(ped, true, false)
|
||||
stagePed()
|
||||
applyAppearance(data.appearance)
|
||||
stagePed()
|
||||
sendLimits()
|
||||
updateCamera()
|
||||
cb({ ok = true })
|
||||
end)
|
||||
|
||||
RegisterNUICallback('preview', function(data, cb)
|
||||
if inEditor then
|
||||
local before = GetEntityModel(PlayerPedId())
|
||||
applyAppearance(data)
|
||||
if GetEntityModel(PlayerPedId()) ~= before then
|
||||
-- The model swap hands back a fresh ped: re-stage it and re-measure the
|
||||
-- wardrobe, whose drawable counts differ between the two bodies.
|
||||
stagePed()
|
||||
applyAppearance(data)
|
||||
sendLimits()
|
||||
end
|
||||
updateCamera()
|
||||
end
|
||||
cb({ ok = true })
|
||||
end)
|
||||
|
||||
RegisterNUICallback('rotate', function(data, cb)
|
||||
local ped = PlayerPedId()
|
||||
SetEntityHeading(ped, (GetEntityHeading(ped) + (tonumber(data.by) or 0.0)) % 360.0)
|
||||
updateCamera()
|
||||
cb({ ok = true })
|
||||
end)
|
||||
|
||||
RegisterNUICallback('zoom', function(data, cb)
|
||||
zoom = data.part or 'head'
|
||||
updateCamera()
|
||||
cb({ ok = true })
|
||||
end)
|
||||
|
||||
RegisterNUICallback('createChar', function(data, cb)
|
||||
TriggerServerEvent('fiverp-chars:create', data)
|
||||
cb({ ok = true })
|
||||
end)
|
||||
|
||||
RegisterNUICallback('finishChar', function(data, cb)
|
||||
TriggerServerEvent('fiverp-chars:finish', data)
|
||||
cb({ ok = true })
|
||||
end)
|
||||
|
||||
RegisterNUICallback('deleteChar', function(data, cb)
|
||||
TriggerServerEvent('fiverp-chars:delete', data)
|
||||
cb({ ok = true })
|
||||
end)
|
||||
|
||||
RegisterNUICallback('playChar', function(data, cb)
|
||||
TriggerServerEvent('fiverp-chars:play', data)
|
||||
cb({ ok = true })
|
||||
end)
|
||||
|
||||
RegisterNUICallback('backToSelect', function(data, cb)
|
||||
inEditor = false
|
||||
editSlot, editId = nil, nil
|
||||
zoom = 'body'
|
||||
local ped = PlayerPedId()
|
||||
SetEntityVisible(ped, false, false)
|
||||
stagePed()
|
||||
SetEntityVisible(ped, false, false)
|
||||
updateCamera()
|
||||
TriggerServerEvent('fiverp-chars:request')
|
||||
cb({ ok = true })
|
||||
end)
|
||||
@@ -0,0 +1,25 @@
|
||||
fx_version 'cerulean'
|
||||
game 'gta5'
|
||||
node_version '22'
|
||||
|
||||
name 'fiverp-characters'
|
||||
author 'FiveRP'
|
||||
description 'Character slots, appearance creator and spawn for FiveRP'
|
||||
version '1.0.0'
|
||||
|
||||
ui_page 'html/index.html'
|
||||
|
||||
client_script 'client/cl_chars.lua'
|
||||
server_script 'server/sv_chars.js'
|
||||
|
||||
files {
|
||||
'html/index.html',
|
||||
'html/style.css',
|
||||
'html/app.js',
|
||||
'html/fonts/unbounded-latin.woff2',
|
||||
'html/fonts/unbounded-latin-ext.woff2',
|
||||
'html/fonts/unbounded-cyrillic.woff2'
|
||||
}
|
||||
|
||||
dependency 'oxmysql'
|
||||
dependency 'fiverp-auth'
|
||||
@@ -0,0 +1,512 @@
|
||||
/* FiveRP — character slots and appearance creator.
|
||||
Every control writes into one `look` object; that object is what the ped is
|
||||
rebuilt from on the client and what the row stores in the database, so the
|
||||
preview and the saved character can never drift apart. */
|
||||
|
||||
const RES = 'fiverp-characters';
|
||||
|
||||
function post(name, data) {
|
||||
return fetch(`https://${RES}/${name}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json; charset=UTF-8' },
|
||||
body: JSON.stringify(data || {})
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const root = $('root');
|
||||
const screens = { select: $('screenSelect'), editor: $('screenEditor') };
|
||||
|
||||
// ------------------------------------------------------------------ state
|
||||
// Parent heads: the game keeps the male faces at the bottom of the list and
|
||||
// the female ones above them.
|
||||
const FATHERS = 21; // head ids 0..20
|
||||
const MOTHERS = 24; // head ids 21..44
|
||||
|
||||
const FEATURES = [
|
||||
'Nose width', 'Nose height', 'Nose length', 'Nose bridge', 'Nose tip',
|
||||
'Nose bridge shift', 'Brow height', 'Brow width', 'Cheekbone height',
|
||||
'Cheekbone width', 'Cheek width', 'Eye opening', 'Lip thickness',
|
||||
'Jaw width', 'Jaw length', 'Chin height', 'Chin length', 'Chin width',
|
||||
'Chin dimple', 'Neck thickness'
|
||||
];
|
||||
|
||||
let limits = {
|
||||
overlays: [24, 29, 34, 15, 75, 7, 12, 11, 10, 18, 17, 12],
|
||||
hair: 1, torso: 1, undershirt: 1, legs: 1, shoes: 1
|
||||
};
|
||||
|
||||
let slots = [];
|
||||
let ctx = { slot: 1, id: null, mode: 'create' };
|
||||
let tab = 'heritage';
|
||||
let look = blankLook();
|
||||
|
||||
function blankLook() {
|
||||
return {
|
||||
model: 'mp_m_freemode_01',
|
||||
blend: { shapeFirst: 0, shapeSecond: 21, shapeMix: 0.5, skinFirst: 0, skinSecond: 21, skinMix: 0.5 },
|
||||
features: new Array(20).fill(0),
|
||||
overlays: Array.from({ length: 12 }, () => ({ v: 255, o: 1, c1: 0, c2: 0 })),
|
||||
hair: { style: 0, color: 0, highlight: 0 },
|
||||
eye: 0,
|
||||
outfit: { torso: 0, undershirt: 0, legs: 0, shoes: 0 }
|
||||
};
|
||||
}
|
||||
|
||||
// The ped rebuild is cheap but not free; coalesce a slider drag into one call
|
||||
// per frame-ish so dragging stays smooth.
|
||||
let pushTimer = null;
|
||||
function pushLook() {
|
||||
clearTimeout(pushTimer);
|
||||
pushTimer = setTimeout(() => post('preview', look), 45);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------- slot screen
|
||||
function show(name) {
|
||||
Object.entries(screens).forEach(([key, el]) => el.classList.toggle('is-active', key === name));
|
||||
}
|
||||
|
||||
function when(value) {
|
||||
if (!value) return null;
|
||||
const d = new Date(String(value).replace(' ', 'T'));
|
||||
if (isNaN(d)) return null;
|
||||
return d.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||
}
|
||||
|
||||
function renderSlots() {
|
||||
const host = $('slots');
|
||||
host.innerHTML = '';
|
||||
|
||||
slots.forEach((s, i) => {
|
||||
const card = document.createElement('article');
|
||||
card.className = 'slot fade-up delay-' + Math.min(i + 1, 3);
|
||||
|
||||
if (!s.id) {
|
||||
card.classList.add('slot--empty');
|
||||
card.innerHTML = `
|
||||
<div class="plus">+</div>
|
||||
<div class="label">Create character</div>
|
||||
<div class="hint">Slot ${s.slot} is free</div>`;
|
||||
card.addEventListener('click', () => openEditor({ slot: s.slot, mode: 'create' }));
|
||||
} else {
|
||||
const played = when(s.lastPlayed);
|
||||
card.innerHTML = `
|
||||
<p class="slot-no">Slot ${s.slot}</p>
|
||||
<h2 class="slot-name">${s.firstName}<br>${s.lastName}</h2>
|
||||
<p class="slot-meta">${played ? 'Last seen ' + played : 'Never played'}</p>
|
||||
<span class="slot-badge ${s.ready ? '' : 'is-draft'}">${s.ready ? 'Resident' : 'Needs appearance'}</span>
|
||||
<div class="slot-actions"></div>`;
|
||||
|
||||
const actions = card.querySelector('.slot-actions');
|
||||
|
||||
const primary = document.createElement('button');
|
||||
primary.className = 'btn-primary';
|
||||
primary.textContent = s.ready ? 'Enter the city' : 'Finish character';
|
||||
primary.addEventListener('click', () => {
|
||||
if (s.ready) post('playChar', { id: s.id });
|
||||
else openEditor({ slot: s.slot, id: s.id, mode: 'finish', first: s.firstName, last: s.lastName });
|
||||
});
|
||||
|
||||
// Deleting is permanent, so the button asks once before it fires.
|
||||
const remove = document.createElement('button');
|
||||
remove.className = 'btn-ghost btn-danger';
|
||||
remove.textContent = 'Delete';
|
||||
let armed = false;
|
||||
remove.addEventListener('click', () => {
|
||||
if (!armed) {
|
||||
armed = true;
|
||||
remove.textContent = 'Confirm delete';
|
||||
setTimeout(() => { armed = false; remove.textContent = 'Delete'; }, 4000);
|
||||
return;
|
||||
}
|
||||
post('deleteChar', { id: s.id });
|
||||
});
|
||||
|
||||
actions.append(primary, remove);
|
||||
}
|
||||
|
||||
host.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- controls
|
||||
function control(label, opts) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'ctl';
|
||||
|
||||
const top = document.createElement('div');
|
||||
top.className = 'ctl-top';
|
||||
const name = document.createElement('span');
|
||||
name.className = 'ctl-name';
|
||||
name.textContent = label;
|
||||
const val = document.createElement('span');
|
||||
val.className = 'ctl-val';
|
||||
top.append(name, val);
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'range';
|
||||
input.min = opts.min;
|
||||
input.max = opts.max;
|
||||
input.step = opts.step || 1;
|
||||
input.value = opts.get();
|
||||
|
||||
const paint = () => { val.textContent = (opts.fmt || String)(Number(input.value)); };
|
||||
paint();
|
||||
|
||||
input.addEventListener('input', () => {
|
||||
opts.set(Number(input.value));
|
||||
paint();
|
||||
pushLook();
|
||||
});
|
||||
|
||||
wrap.append(top, input);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function groupLabel(text) {
|
||||
const p = document.createElement('p');
|
||||
p.className = 'group-label';
|
||||
p.textContent = text;
|
||||
return p;
|
||||
}
|
||||
|
||||
// An overlay slider where the first stop means "none" (the game uses 255).
|
||||
function overlayControl(host, label, index, colour) {
|
||||
const count = limits.overlays[index] || 1;
|
||||
host.appendChild(control(label, {
|
||||
min: 0, max: count, step: 1,
|
||||
get: () => (look.overlays[index].v === 255 ? 0 : look.overlays[index].v + 1),
|
||||
set: (v) => { look.overlays[index].v = v === 0 ? 255 : v - 1; },
|
||||
fmt: (v) => (v === 0 ? 'None' : String(v))
|
||||
}));
|
||||
|
||||
host.appendChild(control(label + ' — strength', {
|
||||
min: 0, max: 1, step: 0.05,
|
||||
get: () => look.overlays[index].o,
|
||||
set: (v) => { look.overlays[index].o = v; },
|
||||
fmt: (v) => Math.round(v * 100) + '%'
|
||||
}));
|
||||
|
||||
if (colour) {
|
||||
host.appendChild(control(label + ' — colour', {
|
||||
min: 0, max: 63, step: 1,
|
||||
get: () => look.overlays[index].c1,
|
||||
set: (v) => { look.overlays[index].c1 = v; look.overlays[index].c2 = v; },
|
||||
fmt: (v) => '#' + v
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
const TABS = [
|
||||
{
|
||||
id: 'heritage', name: 'Heritage',
|
||||
build(host) {
|
||||
const toggle = document.createElement('div');
|
||||
toggle.className = 'toggle';
|
||||
[['mp_m_freemode_01', 'Male'], ['mp_f_freemode_01', 'Female']].forEach(([model, label]) => {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.textContent = label;
|
||||
b.className = look.model === model ? 'is-on' : '';
|
||||
b.addEventListener('click', () => {
|
||||
if (look.model === model) return;
|
||||
look.model = model;
|
||||
// A new body resets the wardrobe indices; the old ones may not exist.
|
||||
look.outfit = { torso: 0, undershirt: 0, legs: 0, shoes: 0 };
|
||||
look.hair.style = 0;
|
||||
post('preview', look);
|
||||
renderTab();
|
||||
});
|
||||
toggle.appendChild(b);
|
||||
});
|
||||
host.append(groupLabel('Body'), toggle);
|
||||
|
||||
host.appendChild(groupLabel('Parents'));
|
||||
host.appendChild(control('Father', {
|
||||
min: 0, max: FATHERS - 1,
|
||||
get: () => look.blend.shapeFirst,
|
||||
set: (v) => { look.blend.shapeFirst = v; look.blend.skinFirst = v; },
|
||||
fmt: (v) => String(v + 1)
|
||||
}));
|
||||
host.appendChild(control('Mother', {
|
||||
min: 21, max: 21 + MOTHERS - 1,
|
||||
get: () => look.blend.shapeSecond,
|
||||
set: (v) => { look.blend.shapeSecond = v; look.blend.skinSecond = v; },
|
||||
fmt: (v) => String(v - 20)
|
||||
}));
|
||||
host.appendChild(control('Resemblance', {
|
||||
min: 0, max: 1, step: 0.02,
|
||||
get: () => look.blend.shapeMix,
|
||||
set: (v) => { look.blend.shapeMix = v; },
|
||||
fmt: (v) => v <= 0.5
|
||||
? 'Father ' + Math.round((1 - v) * 100) + '%'
|
||||
: 'Mother ' + Math.round(v * 100) + '%'
|
||||
}));
|
||||
host.appendChild(control('Skin tone', {
|
||||
min: 0, max: 1, step: 0.02,
|
||||
get: () => look.blend.skinMix,
|
||||
set: (v) => { look.blend.skinMix = v; },
|
||||
fmt: (v) => Math.round(v * 100) + '%'
|
||||
}));
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'face', name: 'Face',
|
||||
build(host) {
|
||||
host.appendChild(groupLabel('Structure'));
|
||||
FEATURES.forEach((label, i) => {
|
||||
host.appendChild(control(label, {
|
||||
min: -1, max: 1, step: 0.05,
|
||||
get: () => look.features[i],
|
||||
set: (v) => { look.features[i] = v; },
|
||||
fmt: (v) => (v > 0 ? '+' : '') + v.toFixed(2)
|
||||
}));
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'hair', name: 'Hair',
|
||||
build(host) {
|
||||
host.appendChild(groupLabel('Hair'));
|
||||
host.appendChild(control('Style', {
|
||||
min: 0, max: Math.max(0, limits.hair - 1),
|
||||
get: () => look.hair.style,
|
||||
set: (v) => { look.hair.style = v; },
|
||||
fmt: (v) => (v === 0 ? 'Bald' : String(v))
|
||||
}));
|
||||
host.appendChild(control('Colour', {
|
||||
min: 0, max: 63,
|
||||
get: () => look.hair.color,
|
||||
set: (v) => { look.hair.color = v; },
|
||||
fmt: (v) => '#' + v
|
||||
}));
|
||||
host.appendChild(control('Highlights', {
|
||||
min: 0, max: 63,
|
||||
get: () => look.hair.highlight,
|
||||
set: (v) => { look.hair.highlight = v; },
|
||||
fmt: (v) => '#' + v
|
||||
}));
|
||||
|
||||
host.appendChild(groupLabel('Eyebrows'));
|
||||
overlayControl(host, 'Eyebrows', 2, 'hair');
|
||||
|
||||
host.appendChild(groupLabel('Facial hair'));
|
||||
overlayControl(host, 'Beard', 1, 'hair');
|
||||
|
||||
host.appendChild(groupLabel('Body hair'));
|
||||
overlayControl(host, 'Chest hair', 10, 'hair');
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'skin', name: 'Skin',
|
||||
build(host) {
|
||||
host.appendChild(groupLabel('Complexion'));
|
||||
overlayControl(host, 'Complexion', 6, null);
|
||||
overlayControl(host, 'Blemishes', 0, null);
|
||||
overlayControl(host, 'Ageing', 3, null);
|
||||
overlayControl(host, 'Sun damage', 7, null);
|
||||
overlayControl(host, 'Freckles', 9, null);
|
||||
overlayControl(host, 'Body blemishes', 11, null);
|
||||
|
||||
host.appendChild(groupLabel('Eyes'));
|
||||
host.appendChild(control('Eye colour', {
|
||||
min: 0, max: 31,
|
||||
get: () => look.eye,
|
||||
set: (v) => { look.eye = v; },
|
||||
fmt: (v) => '#' + v
|
||||
}));
|
||||
|
||||
host.appendChild(groupLabel('Makeup'));
|
||||
overlayControl(host, 'Makeup', 4, 'makeup');
|
||||
overlayControl(host, 'Blush', 5, 'makeup');
|
||||
overlayControl(host, 'Lipstick', 8, 'makeup');
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'style', name: 'Clothes',
|
||||
build(host) {
|
||||
host.appendChild(groupLabel('Outfit'));
|
||||
const rows = [
|
||||
['Top', 'torso', 'torso'],
|
||||
['Undershirt', 'undershirt', 'undershirt'],
|
||||
['Legs', 'legs', 'legs'],
|
||||
['Shoes', 'shoes', 'shoes']
|
||||
];
|
||||
rows.forEach(([label, key, limitKey]) => {
|
||||
host.appendChild(control(label, {
|
||||
min: 0, max: Math.max(0, (limits[limitKey] || 1) - 1),
|
||||
get: () => look.outfit[key],
|
||||
set: (v) => { look.outfit[key] = v; },
|
||||
fmt: (v) => String(v + 1)
|
||||
}));
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'identity', name: 'Identity',
|
||||
build(host) {
|
||||
host.appendChild(groupLabel('Legal name'));
|
||||
|
||||
const row = document.createElement('div');
|
||||
row.className = 'field-row';
|
||||
|
||||
[['first', 'First name', 'John'], ['last', 'Last name', 'Doe']].forEach(([key, label, hint]) => {
|
||||
const cell = document.createElement('div');
|
||||
const lab = document.createElement('label');
|
||||
lab.className = 'field-label';
|
||||
lab.textContent = label;
|
||||
const input = document.createElement('input');
|
||||
input.className = 'field';
|
||||
input.id = 'name-' + key;
|
||||
input.maxLength = 24;
|
||||
input.placeholder = hint;
|
||||
input.value = ctx[key] || '';
|
||||
input.disabled = ctx.mode === 'finish';
|
||||
input.addEventListener('input', () => { ctx[key] = input.value.trim(); });
|
||||
cell.append(lab, input);
|
||||
row.appendChild(cell);
|
||||
});
|
||||
|
||||
host.appendChild(row);
|
||||
|
||||
const note = document.createElement('p');
|
||||
note.className = 'foot-note';
|
||||
note.style.marginTop = '12px';
|
||||
note.textContent = ctx.mode === 'finish'
|
||||
? 'This name was registered with your passport and cannot be changed.'
|
||||
: 'English letters only. This is the name Los Santos will know you by.';
|
||||
host.appendChild(note);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
function renderTabs() {
|
||||
const host = $('tabs');
|
||||
host.innerHTML = '';
|
||||
TABS.forEach((t) => {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.className = 'tab' + (t.id === tab ? ' is-on' : '');
|
||||
b.textContent = t.name;
|
||||
b.addEventListener('click', () => { tab = t.id; renderTabs(); renderTab(); });
|
||||
host.appendChild(b);
|
||||
});
|
||||
}
|
||||
|
||||
function renderTab() {
|
||||
const host = $('controls');
|
||||
host.innerHTML = '';
|
||||
host.scrollTop = 0;
|
||||
const def = TABS.find((t) => t.id === tab) || TABS[0];
|
||||
def.build(host);
|
||||
renderTabs();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- editor
|
||||
function openEditor(options) {
|
||||
ctx = {
|
||||
slot: options.slot,
|
||||
id: options.id || null,
|
||||
mode: options.mode,
|
||||
first: options.first || '',
|
||||
last: options.last || ''
|
||||
};
|
||||
look = blankLook();
|
||||
tab = 'heritage';
|
||||
notice('');
|
||||
|
||||
$('editorSlot').textContent = 'Slot ' + ctx.slot;
|
||||
$('btnSave').textContent = 'Save & enter city';
|
||||
|
||||
show('editor');
|
||||
renderTab();
|
||||
post('startEditor', { slot: ctx.slot, id: ctx.id, appearance: look });
|
||||
}
|
||||
|
||||
function notice(text) {
|
||||
const el = $('notice');
|
||||
el.textContent = text || '';
|
||||
el.classList.toggle('is-shown', !!text);
|
||||
}
|
||||
|
||||
const RE_NAME = /^[A-Za-z]{2,24}$/;
|
||||
|
||||
function save() {
|
||||
if (ctx.mode === 'create') {
|
||||
if (!RE_NAME.test(ctx.first || '') || !RE_NAME.test(ctx.last || '')) {
|
||||
tab = 'identity';
|
||||
renderTab();
|
||||
return notice('Enter a first and last name — 2 to 24 English letters each.');
|
||||
}
|
||||
}
|
||||
|
||||
notice('');
|
||||
$('btnSave').disabled = true;
|
||||
|
||||
if (ctx.mode === 'finish') post('finishChar', { id: ctx.id, appearance: look });
|
||||
else post('createChar', { slot: ctx.slot, firstName: ctx.first, lastName: ctx.last, appearance: look });
|
||||
}
|
||||
|
||||
// Randomise heritage and the obvious surface details, leaving the 20 face
|
||||
// sliders alone — random values there produce a melted face, not a person.
|
||||
function randomise() {
|
||||
const r = (n) => Math.floor(Math.random() * n);
|
||||
look.blend.shapeFirst = look.blend.skinFirst = r(FATHERS);
|
||||
look.blend.shapeSecond = look.blend.skinSecond = 21 + r(MOTHERS);
|
||||
look.blend.shapeMix = Math.round(Math.random() * 50) / 50;
|
||||
look.blend.skinMix = Math.round(Math.random() * 50) / 50;
|
||||
look.hair.style = r(Math.max(1, limits.hair));
|
||||
look.hair.color = look.hair.highlight = r(24);
|
||||
look.eye = r(12);
|
||||
look.overlays[2].v = r(limits.overlays[2] || 1); // eyebrows
|
||||
look.overlays[2].c1 = look.overlays[2].c2 = look.hair.color;
|
||||
look.outfit.torso = r(Math.max(1, limits.torso));
|
||||
look.outfit.legs = r(Math.max(1, limits.legs));
|
||||
look.outfit.shoes = r(Math.max(1, limits.shoes));
|
||||
post('preview', look);
|
||||
renderTab();
|
||||
}
|
||||
|
||||
$('btnSave').addEventListener('click', save);
|
||||
$('btnRandom').addEventListener('click', randomise);
|
||||
$('btnCancel').addEventListener('click', () => {
|
||||
show('select');
|
||||
post('backToSelect', {});
|
||||
});
|
||||
$('rotLeft').addEventListener('click', () => post('rotate', { by: -20 }));
|
||||
$('rotRight').addEventListener('click', () => post('rotate', { by: 20 }));
|
||||
|
||||
document.querySelectorAll('.chip[data-zoom]').forEach((chip) => {
|
||||
chip.addEventListener('click', () => {
|
||||
document.querySelectorAll('.chip[data-zoom]').forEach((c) => c.classList.remove('is-on'));
|
||||
chip.classList.add('is-on');
|
||||
post('zoom', { part: chip.dataset.zoom });
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------ from client
|
||||
window.addEventListener('message', (event) => {
|
||||
const msg = event.data || {};
|
||||
|
||||
if (msg.action === 'open') {
|
||||
root.classList.add('is-open');
|
||||
show(msg.view || 'select');
|
||||
} else if (msg.action === 'close') {
|
||||
root.classList.remove('is-open');
|
||||
} else if (msg.action === 'slots') {
|
||||
slots = msg.payload || [];
|
||||
renderSlots();
|
||||
} else if (msg.action === 'limits') {
|
||||
limits = Object.assign(limits, msg.payload || {});
|
||||
if (screens.editor.classList.contains('is-active')) renderTab();
|
||||
} else if (msg.action === 'saveResult') {
|
||||
const p = msg.payload || {};
|
||||
$('btnSave').disabled = false;
|
||||
if (p.ok) {
|
||||
// Straight into the world with the character just written to the row.
|
||||
post('playChar', { id: p.id || ctx.id });
|
||||
} else {
|
||||
notice(p.error || 'Something went wrong. Try again.');
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>FiveRP — Characters</title>
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="root" id="root">
|
||||
|
||||
<!-- ------------------------------------------------ character slots -->
|
||||
<section class="screen screen--select" id="screenSelect">
|
||||
<header class="head fade-up">
|
||||
<p class="eyebrow">Los Santos · Residency Office</p>
|
||||
<h1 class="title">Choose your character</h1>
|
||||
<p class="sub">Three residency slots are available on this account.</p>
|
||||
</header>
|
||||
|
||||
<div class="slots" id="slots"></div>
|
||||
|
||||
<p class="foot-note fade-up delay-3">Deleting a resident is permanent — the slot is freed for a new file.</p>
|
||||
</section>
|
||||
|
||||
<!-- ---------------------------------------------- appearance editor -->
|
||||
<section class="screen screen--editor" id="screenEditor">
|
||||
<aside class="panel">
|
||||
<header class="panel-head">
|
||||
<p class="eyebrow" id="editorSlot">Slot 1</p>
|
||||
<h2 class="panel-title">Create your resident</h2>
|
||||
</header>
|
||||
|
||||
<nav class="tabs" id="tabs"></nav>
|
||||
|
||||
<div class="controls" id="controls"></div>
|
||||
|
||||
<p class="notice" id="notice"></p>
|
||||
|
||||
<footer class="panel-foot">
|
||||
<button class="btn-ghost" id="btnCancel" type="button">Back</button>
|
||||
<button class="btn-ghost btn-dice" id="btnRandom" type="button">Randomise</button>
|
||||
<button class="btn-primary" id="btnSave" type="button">Save & enter city</button>
|
||||
</footer>
|
||||
</aside>
|
||||
|
||||
<div class="stagebar">
|
||||
<button class="round" id="rotLeft" type="button" aria-label="Rotate left">‹</button>
|
||||
<div class="chips">
|
||||
<button class="chip is-on" data-zoom="head" type="button">Head</button>
|
||||
<button class="chip" data-zoom="body" type="button">Body</button>
|
||||
<button class="chip" data-zoom="legs" type="button">Legs</button>
|
||||
</div>
|
||||
<button class="round" id="rotRight" type="button" aria-label="Rotate right">›</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,297 @@
|
||||
/* FiveRP — character slots and appearance creator.
|
||||
Follows CLAUDE.md. Two hard rules from that document apply everywhere here:
|
||||
no backdrop-filter and no box-shadow. CEF composites both as opaque black
|
||||
plates the size of the element, which is the black-square artefact. Depth
|
||||
comes from opacity, hairlines and inner borders instead. */
|
||||
|
||||
@font-face {
|
||||
font-family: 'Unbounded';
|
||||
src: url('fonts/unbounded-latin.woff2') format('woff2');
|
||||
font-weight: 300 800; font-display: block;
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+2000-206F, U+2122, U+2191, U+2193, U+2212;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Unbounded';
|
||||
src: url('fonts/unbounded-latin-ext.woff2') format('woff2');
|
||||
font-weight: 300 800; font-display: block;
|
||||
unicode-range: U+0100-02AF, U+0304, U+0308, U+1E00-1EFF, U+2020, U+20A0-20AB;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Unbounded';
|
||||
src: url('fonts/unbounded-cyrillic.woff2') format('woff2');
|
||||
font-weight: 300 800; font-display: block;
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
|
||||
:root {
|
||||
--surface: rgba(255, 255, 255, 0.95);
|
||||
--surface-soft: rgba(255, 255, 255, 0.88);
|
||||
--fg: #1d1d1f;
|
||||
--fg-secondary: #6e6e73;
|
||||
--fg-tertiary: #a1a1a6;
|
||||
--accent: #0071e3;
|
||||
--accent-strong: #0077ed;
|
||||
--accent-soft: rgba(0, 113, 227, 0.08);
|
||||
--line: rgba(0, 0, 0, 0.06);
|
||||
--line-strong: rgba(0, 0, 0, 0.10);
|
||||
--field-bg: rgba(0, 0, 0, 0.04);
|
||||
--danger: #ff3b30;
|
||||
--radius-lg: 1.5rem;
|
||||
--radius-md: 1rem;
|
||||
--radius-sm: 0.7rem;
|
||||
--ease: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html, body {
|
||||
width: 100%; height: 100%;
|
||||
background: transparent; /* the game is the backdrop */
|
||||
overflow: hidden;
|
||||
font-family: 'Unbounded', -apple-system, 'Segoe UI', sans-serif;
|
||||
color: var(--fg);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.root { position: fixed; inset: 0; display: none; }
|
||||
.root.is-open { display: block; }
|
||||
|
||||
.screen { position: absolute; inset: 0; display: none; }
|
||||
.screen.is-active { display: flex; }
|
||||
|
||||
/* --------------------------------------------------------- shared bits */
|
||||
.eyebrow {
|
||||
font-size: 10px; font-weight: 600; letter-spacing: 0.20em;
|
||||
text-transform: uppercase; color: var(--fg-tertiary);
|
||||
}
|
||||
.title {
|
||||
font-size: 30px; font-weight: 700; letter-spacing: -0.035em;
|
||||
line-height: 1.1; margin-top: 10px;
|
||||
}
|
||||
.sub { margin-top: 8px; font-size: 13px; font-weight: 400; color: var(--fg-secondary); }
|
||||
|
||||
.btn-primary {
|
||||
border: none; border-radius: 999px; padding: 11px 22px;
|
||||
font-family: inherit; font-size: 13px; font-weight: 600; letter-spacing: -0.01em;
|
||||
color: #fff; cursor: pointer;
|
||||
background-image: linear-gradient(180deg, var(--accent-strong), var(--accent));
|
||||
transition: filter .2s var(--ease), transform .2s var(--ease);
|
||||
}
|
||||
.btn-primary:hover { filter: brightness(1.08); }
|
||||
.btn-primary:active { transform: scale(0.97); }
|
||||
.btn-primary:disabled { opacity: .45; pointer-events: none; }
|
||||
|
||||
.btn-ghost {
|
||||
border: none; border-radius: 999px; padding: 11px 18px;
|
||||
font-family: inherit; font-size: 13px; font-weight: 600;
|
||||
color: var(--accent); background-color: var(--accent-soft); cursor: pointer;
|
||||
transition: background-color .2s var(--ease);
|
||||
}
|
||||
.btn-ghost:hover { background-color: rgba(0, 113, 227, 0.14); }
|
||||
.btn-danger { color: var(--danger); background-color: rgba(255, 59, 48, 0.08); }
|
||||
.btn-danger:hover { background-color: rgba(255, 59, 48, 0.15); }
|
||||
|
||||
/* ---------------------------------------------------------- slot screen */
|
||||
.screen--select { flex-direction: column; align-items: center; justify-content: center; gap: 34px; }
|
||||
.head { text-align: center; }
|
||||
|
||||
.slots { display: flex; gap: 16px; }
|
||||
|
||||
.slot {
|
||||
position: relative;
|
||||
width: 268px; min-height: 330px;
|
||||
display: flex; flex-direction: column;
|
||||
padding: 26px 22px 22px;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--line-strong);
|
||||
background: var(--surface);
|
||||
transition: transform .35s var(--ease), border-color .35s var(--ease);
|
||||
}
|
||||
.slot:hover { transform: translateY(-4px); border-color: rgba(0, 0, 0, 0.14); }
|
||||
|
||||
.slot-no {
|
||||
font-size: 10px; font-weight: 600; letter-spacing: 0.20em;
|
||||
text-transform: uppercase; color: var(--fg-tertiary);
|
||||
}
|
||||
.slot-name {
|
||||
margin-top: 14px;
|
||||
font-size: 21px; font-weight: 700; letter-spacing: -0.03em; line-height: 1.2;
|
||||
}
|
||||
.slot-meta { margin-top: 6px; font-size: 11px; font-weight: 400; color: var(--fg-secondary); }
|
||||
|
||||
.slot-badge {
|
||||
display: inline-block; margin-top: 12px; align-self: flex-start;
|
||||
padding: 4px 11px; border-radius: 999px;
|
||||
font-size: 10px; font-weight: 600; letter-spacing: 0.09em; text-transform: uppercase;
|
||||
color: var(--accent); background: var(--accent-soft);
|
||||
}
|
||||
.slot-badge.is-draft { color: #b25000; background: rgba(255, 149, 0, 0.12); }
|
||||
|
||||
.slot-actions { margin-top: auto; display: flex; flex-direction: column; gap: 8px; }
|
||||
.slot-actions button { width: 100%; }
|
||||
|
||||
/* Empty slot: one big invitation, centred. */
|
||||
.slot--empty {
|
||||
align-items: center; justify-content: center; text-align: center;
|
||||
gap: 14px; cursor: pointer;
|
||||
background: var(--surface-soft);
|
||||
border-style: dashed; border-color: rgba(0, 0, 0, 0.14);
|
||||
}
|
||||
.slot--empty:hover { border-color: var(--accent); background: rgba(255, 255, 255, 0.97); }
|
||||
.plus {
|
||||
width: 54px; height: 54px; border-radius: 50%;
|
||||
display: grid; place-items: center;
|
||||
font-size: 26px; font-weight: 300; line-height: 1;
|
||||
color: var(--accent); background: var(--accent-soft);
|
||||
}
|
||||
.slot--empty .label { font-size: 14px; font-weight: 600; letter-spacing: -0.02em; }
|
||||
.slot--empty .hint { font-size: 11px; font-weight: 400; color: var(--fg-tertiary); }
|
||||
|
||||
.foot-note { font-size: 11px; font-weight: 400; color: var(--fg-tertiary); }
|
||||
|
||||
/* -------------------------------------------------------- editor screen */
|
||||
.screen--editor { pointer-events: none; } /* the ped stays clickable-through */
|
||||
.screen--editor .panel,
|
||||
.screen--editor .stagebar { pointer-events: auto; }
|
||||
|
||||
.panel {
|
||||
position: absolute; top: 24px; right: 24px; bottom: 24px;
|
||||
width: 372px;
|
||||
display: flex; flex-direction: column;
|
||||
padding: 24px 0 20px;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--line-strong);
|
||||
background: var(--surface);
|
||||
}
|
||||
.panel-head { padding: 0 24px 16px; border-bottom: 1px solid var(--line); }
|
||||
.panel-title { margin-top: 8px; font-size: 20px; font-weight: 700; letter-spacing: -0.03em; }
|
||||
|
||||
.tabs {
|
||||
display: flex; flex-wrap: wrap; gap: 6px;
|
||||
padding: 14px 24px; border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.tab {
|
||||
border: none; border-radius: 999px; padding: 7px 13px;
|
||||
font-family: inherit; font-size: 10px; font-weight: 600;
|
||||
letter-spacing: 0.06em; text-transform: uppercase;
|
||||
color: var(--fg-secondary); background: var(--field-bg); cursor: pointer;
|
||||
transition: color .2s var(--ease), background-color .2s var(--ease);
|
||||
}
|
||||
.tab:hover { color: var(--fg); }
|
||||
.tab.is-on { color: #fff; background: var(--accent); }
|
||||
|
||||
.controls { flex: 1; overflow-y: auto; padding: 18px 24px 8px; }
|
||||
.controls::-webkit-scrollbar { width: 5px; }
|
||||
.controls::-webkit-scrollbar-thumb { background: rgba(0, 0, 0, 0.14); border-radius: 3px; }
|
||||
|
||||
.group-label {
|
||||
margin: 16px 0 10px;
|
||||
font-size: 10px; font-weight: 600; letter-spacing: 0.14em;
|
||||
text-transform: uppercase; color: var(--fg-tertiary);
|
||||
}
|
||||
.group-label:first-child { margin-top: 0; }
|
||||
|
||||
.ctl { margin-bottom: 13px; }
|
||||
.ctl-top {
|
||||
display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 6px;
|
||||
}
|
||||
.ctl-name { font-size: 11px; font-weight: 500; letter-spacing: -0.01em; color: var(--fg); }
|
||||
.ctl-val { font-size: 10px; font-weight: 600; color: var(--fg-tertiary); font-variant-numeric: tabular-nums; }
|
||||
|
||||
input[type="range"] {
|
||||
-webkit-appearance: none; width: 100%; height: 4px; border-radius: 2px;
|
||||
background: rgba(0, 0, 0, 0.09); cursor: pointer; outline: none;
|
||||
}
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none; width: 15px; height: 15px; border-radius: 50%;
|
||||
background: #fff; border: 1px solid rgba(0, 0, 0, 0.16);
|
||||
transition: transform .15s var(--ease), border-color .15s var(--ease);
|
||||
}
|
||||
input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.12); border-color: var(--accent); }
|
||||
|
||||
/* Two-state toggle (male / female) */
|
||||
.toggle { display: flex; gap: 6px; margin-bottom: 16px; }
|
||||
.toggle button {
|
||||
flex: 1; border: 1px solid var(--line-strong); border-radius: var(--radius-sm);
|
||||
padding: 10px; font-family: inherit; font-size: 12px; font-weight: 600;
|
||||
letter-spacing: -0.01em; color: var(--fg-secondary);
|
||||
background: rgba(255, 255, 255, 0.7); cursor: pointer;
|
||||
transition: all .2s var(--ease);
|
||||
}
|
||||
.toggle button.is-on { color: #fff; background: var(--accent); border-color: var(--accent); }
|
||||
|
||||
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
.field-label {
|
||||
display: block; margin-bottom: 6px;
|
||||
font-size: 10px; font-weight: 600; letter-spacing: 0.09em;
|
||||
text-transform: uppercase; color: var(--fg-tertiary);
|
||||
}
|
||||
.field {
|
||||
width: 100%; border: 1px solid transparent; border-radius: var(--radius-sm);
|
||||
padding: 11px 12px; font-family: inherit; font-size: 13px; font-weight: 500;
|
||||
color: var(--fg); background: var(--field-bg); outline: none;
|
||||
transition: background-color .2s, border-color .2s;
|
||||
}
|
||||
.field::placeholder { color: var(--fg-tertiary); font-weight: 400; }
|
||||
.field:focus { background: #fff; border-color: var(--accent); }
|
||||
|
||||
.notice {
|
||||
margin: 0 24px; min-height: 0; font-size: 11px; font-weight: 500; color: var(--danger);
|
||||
opacity: 0; transition: opacity .2s var(--ease);
|
||||
}
|
||||
.notice.is-shown { opacity: 1; margin-bottom: 10px; }
|
||||
|
||||
.panel-foot {
|
||||
display: flex; gap: 8px; padding: 16px 24px 0;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.panel-foot .btn-primary { flex: 1; }
|
||||
.btn-dice { padding: 11px 14px; }
|
||||
|
||||
/* Camera controls, centred under the ped. */
|
||||
.stagebar {
|
||||
position: absolute; left: calc(50% - 210px); bottom: 40px;
|
||||
transform: translateX(-50%);
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 8px; border-radius: 999px;
|
||||
border: 1px solid var(--line-strong); background: var(--surface);
|
||||
}
|
||||
.round {
|
||||
width: 34px; height: 34px; border: none; border-radius: 50%;
|
||||
font-family: inherit; font-size: 17px; font-weight: 500; line-height: 1;
|
||||
color: var(--fg-secondary); background: var(--field-bg); cursor: pointer;
|
||||
transition: color .2s, background-color .2s;
|
||||
}
|
||||
.round:hover { color: #fff; background: var(--accent); }
|
||||
.chips { display: flex; gap: 4px; }
|
||||
.chip {
|
||||
border: none; border-radius: 999px; padding: 8px 14px;
|
||||
font-family: inherit; font-size: 10px; font-weight: 600;
|
||||
letter-spacing: 0.09em; text-transform: uppercase;
|
||||
color: var(--fg-secondary); background: transparent; cursor: pointer;
|
||||
transition: color .2s, background-color .2s;
|
||||
}
|
||||
.chip:hover { color: var(--fg); }
|
||||
.chip.is-on { color: #fff; background: var(--accent); }
|
||||
|
||||
/* ---------------------------------------------------------------- motion */
|
||||
.fade-up { animation: fadeUp .6s var(--ease) both; }
|
||||
.delay-1 { animation-delay: .06s; }
|
||||
.delay-2 { animation-delay: .12s; }
|
||||
.delay-3 { animation-delay: .18s; }
|
||||
|
||||
@keyframes fadeUp {
|
||||
from { opacity: 0; transform: translateY(18px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: .001ms !important;
|
||||
transition-duration: .001ms !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
fx_version 'cerulean'
|
||||
game 'gta5'
|
||||
|
||||
name 'fiverp-loadscreen'
|
||||
author 'FiveRP'
|
||||
description 'Loading screen for FiveRP'
|
||||
version '1.0.0'
|
||||
|
||||
loadscreen 'html/index.html'
|
||||
|
||||
files {
|
||||
'html/index.html',
|
||||
'html/style.css',
|
||||
'html/app.js',
|
||||
'html/fonts/unbounded-latin.woff2',
|
||||
'html/fonts/unbounded-latin-ext.woff2',
|
||||
'html/fonts/unbounded-cyrillic.woff2',
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/* FiveRP — loading screen behaviour.
|
||||
|
||||
The game drives this page by posting messages at `window`. The only field
|
||||
that is a genuine 0..1 measure of progress is `loadProgress.loadFraction`;
|
||||
everything else is narration we turn into the phase and detail lines. */
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const elPct = document.getElementById('pct');
|
||||
const elFill = document.getElementById('fill');
|
||||
const elPhase = document.getElementById('phase');
|
||||
const elDetail = document.getElementById('detail');
|
||||
const elTip = document.getElementById('tip');
|
||||
|
||||
/* ------------------------------------------------------------- progress */
|
||||
|
||||
// Shown and target values are kept apart so the number eases toward the
|
||||
// truth instead of jumping. The game reports progress in coarse steps.
|
||||
let shown = 0;
|
||||
let target = 0;
|
||||
|
||||
function setTarget(fraction) {
|
||||
const pct = Math.max(0, Math.min(100, fraction * 100));
|
||||
// Never walk backwards: a later phase reporting a lower fraction would
|
||||
// otherwise make the bar visibly retreat.
|
||||
if (pct > target) target = pct;
|
||||
}
|
||||
|
||||
function tick() {
|
||||
const gap = target - shown;
|
||||
if (gap > 0.05) {
|
||||
shown += Math.max(gap * 0.08, 0.05);
|
||||
if (shown > target) shown = target;
|
||||
elFill.style.width = shown.toFixed(2) + '%';
|
||||
elPct.textContent = Math.floor(shown);
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
|
||||
/* ---------------------------------------------------------------- phases */
|
||||
|
||||
function phase(text) {
|
||||
if (elPhase.textContent !== text) elPhase.textContent = text;
|
||||
}
|
||||
|
||||
function detail(text) {
|
||||
const clean = String(text || '').trim();
|
||||
if (!clean) return;
|
||||
elDetail.textContent = clean.length > 88 ? clean.slice(0, 87) + '…' : clean;
|
||||
}
|
||||
|
||||
// Init function names arrive as engine internals ("CPathServer::InitSession").
|
||||
// Strip the noise so the line reads as a status, not a stack trace.
|
||||
function humanise(name) {
|
||||
return String(name || '')
|
||||
.replace(/^dlc_?/i, 'DLC ')
|
||||
.replace(/::.*$/, '')
|
||||
.replace(/^C(?=[A-Z])/, '')
|
||||
.replace(/([a-z])([A-Z])/g, '$1 $2');
|
||||
}
|
||||
|
||||
window.addEventListener('message', function (event) {
|
||||
const data = event.data || {};
|
||||
|
||||
switch (data.eventName) {
|
||||
case 'loadProgress':
|
||||
setTarget(Number(data.loadFraction) || 0);
|
||||
break;
|
||||
|
||||
case 'startInitFunctionOrder':
|
||||
phase('Loading game');
|
||||
break;
|
||||
|
||||
case 'startInitFunction':
|
||||
phase('Loading game');
|
||||
break;
|
||||
|
||||
case 'initFunctionInvoking':
|
||||
phase('Loading game');
|
||||
detail(humanise(data.name));
|
||||
// The engine gives idx/count per order; use it when loadProgress is
|
||||
// quiet so the bar keeps creeping during long orders.
|
||||
if (data.count) setTarget(0.1 + 0.5 * (Number(data.idx) / Number(data.count)));
|
||||
break;
|
||||
|
||||
case 'startDataFileEntries':
|
||||
phase('Streaming content');
|
||||
detail('Mounting ' + (data.count || 0) + ' data files');
|
||||
break;
|
||||
|
||||
case 'performMappedDataFileEntry':
|
||||
phase('Streaming content');
|
||||
detail(String(data.name || '').split('/').pop());
|
||||
break;
|
||||
|
||||
case 'onLogLine':
|
||||
detail(data.message);
|
||||
break;
|
||||
|
||||
case 'onDataFileEntry':
|
||||
phase('Streaming content');
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ tips */
|
||||
|
||||
const TIPS = [
|
||||
'Your account and your character are separate — one login can hold your identity for good.',
|
||||
'Names are issued once. Pick a first and last name you want to be known by.',
|
||||
'Press F8 at any time to open the console if something looks wrong.',
|
||||
'Your passport is proof of residency — officers may ask to see it.',
|
||||
'Use a real email address. It is the only way to recover an account.',
|
||||
'Speak in character. The city is more fun when everyone stays in the story.',
|
||||
'Lost? The Legion Square spawn is the heart of downtown Los Santos.'
|
||||
];
|
||||
|
||||
let tipIndex = 0;
|
||||
setInterval(function () {
|
||||
tipIndex = (tipIndex + 1) % TIPS.length;
|
||||
elTip.classList.add('is-swapping');
|
||||
setTimeout(function () {
|
||||
elTip.textContent = TIPS[tipIndex];
|
||||
elTip.classList.remove('is-swapping');
|
||||
}, 450);
|
||||
}, 7000);
|
||||
|
||||
/* Nothing has reported in yet — creep to a believable floor so the screen
|
||||
never sits at a dead 0% while the engine warms up. */
|
||||
setTimeout(function () { setTarget(0.04); }, 400);
|
||||
})();
|
||||
@@ -0,0 +1,53 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>FiveRP</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Slow drifting colour behind frosted white. Nothing here is interactive. -->
|
||||
<div class="sky" aria-hidden="true">
|
||||
<span class="blob blob-a"></span>
|
||||
<span class="blob blob-b"></span>
|
||||
<span class="blob blob-c"></span>
|
||||
<span class="blob blob-d"></span>
|
||||
</div>
|
||||
<div class="veil" aria-hidden="true"></div>
|
||||
|
||||
<main class="stage">
|
||||
<div class="mark fade-up">
|
||||
<span class="mark-glyph">FR</span>
|
||||
</div>
|
||||
|
||||
<p class="eyebrow fade-up delay-1">San Andreas · Los Santos</p>
|
||||
<h1 class="wordmark fade-up delay-1">FiveRP</h1>
|
||||
<p class="tagline fade-up delay-2">Preparing the city for your arrival</p>
|
||||
|
||||
<section class="meter fade-up delay-3" aria-label="Loading progress">
|
||||
<div class="meter-top">
|
||||
<span class="phase" id="phase">Starting up</span>
|
||||
<span class="pct"><span id="pct">0</span><i>%</i></span>
|
||||
</div>
|
||||
<div class="track">
|
||||
<div class="fill" id="fill"></div>
|
||||
</div>
|
||||
<p class="detail" id="detail">Waiting for the game to hand over…</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="dock">
|
||||
<div class="tip fade-up delay-4">
|
||||
<span class="tip-label">Tip</span>
|
||||
<span class="tip-body" id="tip">Your account and your character are separate — one login can hold your identity for good.</span>
|
||||
</div>
|
||||
<div class="badge fade-up delay-4">
|
||||
<span class="dot"></span>
|
||||
<span>192.227.168.73<i>:30120</i></span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,346 @@
|
||||
/* FiveRP — loading screen.
|
||||
Design system: /opt/fivem/server-data/CLAUDE.md
|
||||
This page owns the whole screen (no game behind it), so unlike the in-game
|
||||
panels it is allowed an opaque background and real shadows. */
|
||||
|
||||
@font-face {
|
||||
font-family: 'Unbounded';
|
||||
font-style: normal;
|
||||
font-weight: 300 800;
|
||||
font-display: block;
|
||||
src: url('fonts/unbounded-latin.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;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Unbounded';
|
||||
font-style: normal;
|
||||
font-weight: 300 800;
|
||||
font-display: block;
|
||||
src: url('fonts/unbounded-latin-ext.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;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Unbounded';
|
||||
font-style: normal;
|
||||
font-weight: 300 800;
|
||||
font-display: block;
|
||||
src: url('fonts/unbounded-cyrillic.woff2') format('woff2');
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg: #f5f5f7;
|
||||
--bg-deep: #e8e8ed;
|
||||
--surface: rgba(255, 255, 255, 0.72);
|
||||
--surface-solid: #ffffff;
|
||||
|
||||
--fg: #1d1d1f;
|
||||
--fg-secondary: #6e6e73;
|
||||
--fg-tertiary: #a1a1a6;
|
||||
|
||||
--accent: #0071e3;
|
||||
--accent-strong: #0077ed;
|
||||
--accent-soft: rgba(0, 113, 227, 0.08);
|
||||
|
||||
--line: rgba(0, 0, 0, 0.06);
|
||||
--line-strong: rgba(0, 0, 0, 0.10);
|
||||
|
||||
--radius-lg: 1.5rem;
|
||||
--radius-md: 1rem;
|
||||
--radius-sm: 0.7rem;
|
||||
|
||||
--sans: 'Unbounded', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
||||
'Helvetica Neue', Arial, sans-serif;
|
||||
--mono: ui-monospace, 'SF Mono', SFMono-Regular, Menlo, Consolas,
|
||||
'Liberation Mono', 'DejaVu Sans Mono', monospace;
|
||||
--ease: cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html, body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: var(--sans);
|
||||
letter-spacing: -0.005em;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
cursor: none;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------- sky */
|
||||
/* Four wide, heavily blurred washes drifting on long loops. Kept pale so the
|
||||
page still reads as white — colour is a hint, not a gradient poster. */
|
||||
.sky { position: fixed; inset: -20%; z-index: 0; filter: blur(90px); }
|
||||
|
||||
.blob {
|
||||
position: absolute;
|
||||
display: block;
|
||||
border-radius: 50%;
|
||||
will-change: transform;
|
||||
}
|
||||
.blob-a {
|
||||
width: 46vw; height: 46vw; top: 4%; left: 8%;
|
||||
background: radial-gradient(circle, rgba(0,113,227,0.30), rgba(0,113,227,0) 70%);
|
||||
animation: driftA 34s var(--ease) infinite alternate;
|
||||
}
|
||||
.blob-b {
|
||||
width: 40vw; height: 40vw; top: 32%; left: 52%;
|
||||
background: radial-gradient(circle, rgba(90,200,250,0.32), rgba(90,200,250,0) 70%);
|
||||
animation: driftB 41s var(--ease) infinite alternate;
|
||||
}
|
||||
.blob-c {
|
||||
width: 34vw; height: 34vw; top: 56%; left: 18%;
|
||||
background: radial-gradient(circle, rgba(201,182,255,0.28), rgba(201,182,255,0) 70%);
|
||||
animation: driftC 47s var(--ease) infinite alternate;
|
||||
}
|
||||
.blob-d {
|
||||
width: 30vw; height: 30vw; top: 6%; left: 68%;
|
||||
background: radial-gradient(circle, rgba(255,214,165,0.30), rgba(255,214,165,0) 70%);
|
||||
animation: driftD 38s var(--ease) infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes driftA { to { transform: translate3d(9vw, 7vh, 0) scale(1.14); } }
|
||||
@keyframes driftB { to { transform: translate3d(-11vw, -6vh, 0) scale(1.08); } }
|
||||
@keyframes driftC { to { transform: translate3d(7vw, -9vh, 0) scale(1.18); } }
|
||||
@keyframes driftD { to { transform: translate3d(-8vw, 8vh, 0) scale(1.10); } }
|
||||
|
||||
/* A milky sheet over the colour: this is what turns four blobs into frosted
|
||||
white rather than a lava lamp. */
|
||||
.veil {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
background:
|
||||
radial-gradient(110% 85% at 50% 42%, rgba(255,255,255,0.40) 0%, rgba(255,255,255,0.72) 46%, rgba(255,255,255,0.88) 100%),
|
||||
linear-gradient(180deg, rgba(245,245,247,0.20), rgba(232,232,237,0.42));
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------- stage */
|
||||
.stage {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 32px 96px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* App-icon style badge: the one saturated object on an otherwise white page. */
|
||||
.mark {
|
||||
width: 84px;
|
||||
height: 84px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 24px;
|
||||
background-image: linear-gradient(160deg, #35a0ff 0%, var(--accent) 52%, #0059b8 100%);
|
||||
margin-bottom: 26px;
|
||||
}
|
||||
.mark-glyph {
|
||||
font-size: 27px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.045em;
|
||||
color: #fff;
|
||||
text-shadow: 0 1px 2px rgba(0,0,0,0.22);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.20em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-tertiary);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.wordmark {
|
||||
font-size: clamp(52px, 6.4vw, 84px);
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.045em;
|
||||
line-height: 1;
|
||||
color: var(--fg);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.tagline {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: var(--fg-secondary);
|
||||
margin-bottom: 46px;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------- meter */
|
||||
/* Left-aligned: the phase and detail lines change constantly, and centred text
|
||||
that reflows on every update reads as jitter. */
|
||||
.meter { width: min(560px, 84vw); text-align: left; }
|
||||
|
||||
.meter-top {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.phase {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.09em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-secondary);
|
||||
}
|
||||
|
||||
.pct {
|
||||
font-size: 34px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.045em;
|
||||
color: var(--fg);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.pct i {
|
||||
font-style: normal;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--fg-tertiary);
|
||||
margin-left: 3px;
|
||||
}
|
||||
|
||||
.track {
|
||||
position: relative;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
/* Inset rather than flat grey: on a near-white page a 7%-black bar reads as
|
||||
a smudge, a hairline-bordered groove reads as a control. */
|
||||
background: rgba(0, 0, 0, 0.055);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fill {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: 0%;
|
||||
border-radius: 999px;
|
||||
background-image: linear-gradient(90deg, #5ac8fa, var(--accent));
|
||||
transition: width 520ms var(--ease);
|
||||
}
|
||||
/* Travelling highlight so a stalled bar still looks alive. */
|
||||
.fill::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(90deg,
|
||||
rgba(255,255,255,0) 0%, rgba(255,255,255,0.55) 50%, rgba(255,255,255,0) 100%);
|
||||
transform: translateX(-100%);
|
||||
animation: sweep 1.9s var(--ease) infinite;
|
||||
}
|
||||
@keyframes sweep { to { transform: translateX(100%); } }
|
||||
|
||||
.detail {
|
||||
margin-top: 14px;
|
||||
height: 14px;
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0;
|
||||
color: var(--fg-tertiary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
transition: opacity .3s ease;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------- dock */
|
||||
.dock {
|
||||
position: fixed;
|
||||
z-index: 2;
|
||||
left: 0; right: 0; bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 26px 32px;
|
||||
}
|
||||
|
||||
.tip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
max-width: 62vw;
|
||||
padding: 12px 18px 12px 12px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line-strong);
|
||||
}
|
||||
.tip-label {
|
||||
flex: none;
|
||||
padding: 5px 12px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.tip-body {
|
||||
font-size: 12.5px;
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
color: var(--fg-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
transition: opacity .45s ease, transform .45s var(--ease);
|
||||
}
|
||||
.tip-body.is-swapping { opacity: 0; transform: translateY(-6px); }
|
||||
|
||||
.badge {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 11px 18px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line-strong);
|
||||
font-family: var(--mono);
|
||||
font-size: 11.5px;
|
||||
color: var(--fg-secondary);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.badge i { font-style: normal; color: var(--fg-tertiary); }
|
||||
.badge .dot {
|
||||
width: 7px; height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--success, #34c759);
|
||||
animation: pulse 2.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse { 50% { opacity: .45; } }
|
||||
|
||||
/* ------------------------------------------------------------------- motion */
|
||||
.fade-up { animation: fadeUp .7s cubic-bezier(.22,1,.36,1) both; }
|
||||
.delay-1 { animation-delay: .07s; }
|
||||
.delay-2 { animation-delay: .14s; }
|
||||
.delay-3 { animation-delay: .21s; }
|
||||
.delay-4 { animation-delay: .30s; }
|
||||
|
||||
@keyframes fadeUp {
|
||||
from { opacity: 0; transform: translateY(18px) scale(.995); filter: blur(6px); }
|
||||
to { opacity: 1; transform: none; filter: blur(0); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: .001ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: .001ms !important;
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Client mirror of this player's character state.
|
||||
-- Read-only: the server pushes it, nothing here writes back.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
Core = Core or {}
|
||||
|
||||
local character = nil
|
||||
|
||||
RegisterNetEvent('rp:core:character', function(data)
|
||||
character = data
|
||||
TriggerEvent('rp:core:characterChanged', character)
|
||||
end)
|
||||
|
||||
--- Current character, or nil while the player is still at the auth screens.
|
||||
function Core.Character()
|
||||
return character
|
||||
end
|
||||
|
||||
function Core.HasCharacter()
|
||||
return character ~= nil
|
||||
end
|
||||
|
||||
exports('getCharacter', Core.Character)
|
||||
@@ -1,32 +0,0 @@
|
||||
fx_version 'cerulean'
|
||||
game 'gta5'
|
||||
|
||||
name 'rp_core'
|
||||
description 'Framework core: config, validation, player registry, request bridge'
|
||||
author 'Los Santos RP'
|
||||
version '1.0.0'
|
||||
|
||||
shared_scripts {
|
||||
'shared/config.lua',
|
||||
'shared/util.lua',
|
||||
'shared/appearance.lua',
|
||||
}
|
||||
|
||||
server_scripts {
|
||||
'@rp_db/lib/db.lua',
|
||||
'server/crypto.js',
|
||||
'server/player.lua',
|
||||
}
|
||||
|
||||
client_scripts {
|
||||
'client/state.lua',
|
||||
}
|
||||
|
||||
-- Consumed by other resources via '@rp_core/...'
|
||||
files {
|
||||
'shared/config.lua',
|
||||
'shared/util.lua',
|
||||
'shared/appearance.lua',
|
||||
'lib/callbacks_client.lua',
|
||||
'lib/callbacks_server.lua',
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Client half of the request/response bridge.
|
||||
-- Include with: client_scripts { '@rp_core/lib/callbacks_client.lua', ... }
|
||||
--
|
||||
-- Usage (inside a thread):
|
||||
-- local result, err = Core.Callback('rp_session', 'auth:login', { ... })
|
||||
--
|
||||
-- Reply events are namespaced by the *calling* resource, so two resources
|
||||
-- can never resolve each other's tokens.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
Core = Core or {}
|
||||
|
||||
local RES = GetCurrentResourceName()
|
||||
local pending = {}
|
||||
local seq = 0
|
||||
|
||||
RegisterNetEvent('rp:cb:res:' .. RES, function(token, result, err)
|
||||
local p = pending[token]
|
||||
if not p then return end -- already timed out
|
||||
pending[token] = nil
|
||||
p:resolve({ result = result, err = err })
|
||||
end)
|
||||
|
||||
--- Blocking request to a server callback. Returns result, err.
|
||||
--- Always returns; a lost reply surfaces as an error rather than a hang.
|
||||
function Core.Callback(targetResource, name, payload, timeoutMs)
|
||||
seq = seq + 1
|
||||
local token = seq
|
||||
local p = promise.new()
|
||||
pending[token] = p
|
||||
|
||||
TriggerServerEvent('rp:cb:' .. targetResource, RES, name, token, payload)
|
||||
|
||||
CreateThread(function()
|
||||
Wait(timeoutMs or 15000)
|
||||
local waiting = pending[token]
|
||||
if waiting then
|
||||
pending[token] = nil
|
||||
waiting:resolve({ result = nil, err = 'no response from ' .. targetResource .. '/' .. name })
|
||||
end
|
||||
end)
|
||||
|
||||
local r = Citizen.Await(p)
|
||||
return r.result, r.err
|
||||
end
|
||||
@@ -1,78 +0,0 @@
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Server half of the request/response bridge.
|
||||
-- Include with: server_scripts { '@rp_core/lib/callbacks_server.lua', ... }
|
||||
--
|
||||
-- Core.RegisterCallback('auth:login', function(src, payload)
|
||||
-- return result, err -- err ~= nil is reported to the client
|
||||
-- end)
|
||||
--
|
||||
-- Handlers run in their own thread so they may block on the database.
|
||||
-- Every handler is rate limited per player; a client that floods is ignored
|
||||
-- rather than allowed to queue unbounded database work.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
Core = Core or {}
|
||||
|
||||
local RES = GetCurrentResourceName()
|
||||
local handlers = {}
|
||||
local buckets = {} -- [src] = { tokens = n, last = ms }
|
||||
|
||||
local BUCKET_MAX = 15 -- burst
|
||||
local BUCKET_RATE = 5 -- refilled per second
|
||||
|
||||
local function allow(src)
|
||||
local now = GetGameTimer()
|
||||
local b = buckets[src]
|
||||
if not b then
|
||||
b = { tokens = BUCKET_MAX, last = now }
|
||||
buckets[src] = b
|
||||
end
|
||||
local elapsed = (now - b.last) / 1000
|
||||
b.last = now
|
||||
b.tokens = math.min(BUCKET_MAX, b.tokens + elapsed * BUCKET_RATE)
|
||||
if b.tokens < 1 then return false end
|
||||
b.tokens = b.tokens - 1
|
||||
return true
|
||||
end
|
||||
|
||||
AddEventHandler('playerDropped', function()
|
||||
buckets[source] = nil
|
||||
end)
|
||||
|
||||
function Core.RegisterCallback(name, fn)
|
||||
if handlers[name] then
|
||||
print(('^3[rp_core]^7 callback %q registered twice in %s'):format(name, RES))
|
||||
end
|
||||
handlers[name] = fn
|
||||
end
|
||||
|
||||
RegisterNetEvent('rp:cb:' .. RES, function(fromResource, name, token, payload)
|
||||
local src = source
|
||||
|
||||
-- A client controls fromResource/token, so they are only ever echoed back to
|
||||
-- that same client. They are never used to look anything up on the server.
|
||||
if type(fromResource) ~= 'string' or type(name) ~= 'string' then return end
|
||||
|
||||
local reply = function(result, err)
|
||||
TriggerClientEvent('rp:cb:res:' .. fromResource, src, token, result, err)
|
||||
end
|
||||
|
||||
if not allow(src) then
|
||||
return reply(nil, 'slow down')
|
||||
end
|
||||
|
||||
local fn = handlers[name]
|
||||
if not fn then
|
||||
print(('^3[rp_core]^7 %s: unknown callback %q from %d'):format(RES, name, src))
|
||||
return reply(nil, 'unknown request')
|
||||
end
|
||||
|
||||
CreateThread(function()
|
||||
local ok, result, err = pcall(fn, src, payload)
|
||||
if not ok then
|
||||
print(('^1[rp_core]^7 callback %q errored: %s'):format(name, tostring(result)))
|
||||
return reply(nil, 'internal error')
|
||||
end
|
||||
reply(result, err)
|
||||
end)
|
||||
end)
|
||||
@@ -1,62 +0,0 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// Password hashing for rp_auth.
|
||||
//
|
||||
// scrypt with per-password random salt. The cost parameters are stored inside
|
||||
// the hash string, so they can be raised later without invalidating existing
|
||||
// accounts - verify() always uses the parameters the hash was made with.
|
||||
//
|
||||
// Format: scrypt$N$r$p$<salt b64>$<derived key b64>
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
const N = 16384; // CPU/memory cost
|
||||
const R = 8; // block size
|
||||
const P = 1; // parallelisation
|
||||
const KEYLEN = 64;
|
||||
const MAXMEM = 96 * 1024 * 1024; // scrypt needs ~128*N*r bytes = 16MB here
|
||||
|
||||
function derive(password, salt, n, r, p, cb) {
|
||||
crypto.scrypt(password, salt, KEYLEN, { N: n, r, p, maxmem: MAXMEM }, cb);
|
||||
}
|
||||
|
||||
global.exports('hashPassword', (password, cb) => {
|
||||
if (typeof password !== 'string' || password.length === 0 || password.length > 200) {
|
||||
return cb(null, 'invalid password');
|
||||
}
|
||||
const salt = crypto.randomBytes(16);
|
||||
derive(password, salt, N, R, P, (err, dk) => {
|
||||
if (err) return cb(null, err.message);
|
||||
cb(`scrypt$${N}$${R}$${P}$${salt.toString('base64')}$${dk.toString('base64')}`, null);
|
||||
});
|
||||
});
|
||||
|
||||
global.exports('verifyPassword', (password, stored, cb) => {
|
||||
if (typeof password !== 'string' || typeof stored !== 'string') return cb(false, null);
|
||||
|
||||
const parts = stored.split('$');
|
||||
if (parts.length !== 6 || parts[0] !== 'scrypt') return cb(false, 'unrecognised hash format');
|
||||
|
||||
const n = parseInt(parts[1], 10);
|
||||
const r = parseInt(parts[2], 10);
|
||||
const p = parseInt(parts[3], 10);
|
||||
if (!n || !r || !p) return cb(false, 'corrupt hash parameters');
|
||||
|
||||
let salt, expected;
|
||||
try {
|
||||
salt = Buffer.from(parts[4], 'base64');
|
||||
expected = Buffer.from(parts[5], 'base64');
|
||||
} catch (e) {
|
||||
return cb(false, 'corrupt hash encoding');
|
||||
}
|
||||
|
||||
derive(password, salt, n, r, p, (err, dk) => {
|
||||
if (err) return cb(false, err.message);
|
||||
// constant time: a wrong password must not be distinguishable by timing
|
||||
const ok = dk.length === expected.length && crypto.timingSafeEqual(dk, expected);
|
||||
cb(ok, null);
|
||||
});
|
||||
});
|
||||
|
||||
// Opaque tokens (character session handles, drop ids, ...)
|
||||
global.exports('randomToken', (bytes) => crypto.randomBytes(Math.min(Math.max(bytes || 16, 8), 64)).toString('hex'));
|
||||
@@ -1,264 +0,0 @@
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Authoritative player registry.
|
||||
--
|
||||
-- While a player is online their state lives in this table and nowhere else.
|
||||
-- Balance changes are applied in memory *synchronously* (no yield between
|
||||
-- read and write, so two concurrent handlers cannot both spend the same
|
||||
-- money) and then mirrored to MariaDB asynchronously alongside a row in
|
||||
-- `transactions`, which is the audit trail.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
Core = Core or {}
|
||||
|
||||
local players = {} -- [source] = player table
|
||||
local byChar = {} -- [character id] = source
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Lookup
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
function Core.Get(src) return players[src] end
|
||||
function Core.GetByChar(charId) return players[byChar[charId] or -1] end
|
||||
function Core.All() return players end
|
||||
|
||||
local function requireChar(src)
|
||||
local p = players[src]
|
||||
if not p or not p.char then return nil end
|
||||
return p
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Lifecycle
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
--- Called by rp_session once credentials have been accepted.
|
||||
function Core.AttachAccount(src, account)
|
||||
players[src] = {
|
||||
source = src,
|
||||
license = account.license,
|
||||
account = { id = account.id, username = account.username, role = account.role },
|
||||
char = nil,
|
||||
spawned = false,
|
||||
joinedAt = os.time(),
|
||||
}
|
||||
return players[src]
|
||||
end
|
||||
|
||||
--- Called once a character has been chosen. `row` is the DB row.
|
||||
function Core.AttachCharacter(src, row)
|
||||
local p = players[src]
|
||||
if not p then return nil end
|
||||
|
||||
p.char = {
|
||||
id = row.id,
|
||||
firstName = row.first_name,
|
||||
lastName = row.last_name,
|
||||
dob = row.dob,
|
||||
gender = row.gender,
|
||||
backstory = row.backstory,
|
||||
cash = math.floor(tonumber(row.cash) or 0),
|
||||
bank = math.floor(tonumber(row.bank) or 0),
|
||||
health = tonumber(row.health) or 200,
|
||||
armour = tonumber(row.armour) or 0,
|
||||
job = row.job or 'unemployed',
|
||||
jobGrade = tonumber(row.job_grade) or 0,
|
||||
playtime = tonumber(row.playtime) or 0,
|
||||
appearance = row.appearance and json.decode(row.appearance) or nil,
|
||||
position = row.position and json.decode(row.position) or nil,
|
||||
needs = row.needs and json.decode(row.needs) or { hunger = 100, thirst = 100, stress = 0 },
|
||||
}
|
||||
p.sessionStart = os.time()
|
||||
byChar[row.id] = src
|
||||
|
||||
Core.PushCharacter(src)
|
||||
Core.PublishState(src)
|
||||
return p.char
|
||||
end
|
||||
|
||||
function Core.Detach(src)
|
||||
local p = players[src]
|
||||
if not p then return end
|
||||
if p.char then byChar[p.char.id] = nil end
|
||||
players[src] = nil
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Synchronisation
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
--- Private state: only the owning client receives this.
|
||||
function Core.PushCharacter(src)
|
||||
local p = requireChar(src)
|
||||
if not p then return end
|
||||
TriggerClientEvent('rp:core:character', src, p.char)
|
||||
end
|
||||
|
||||
--- Public state: what every other client is allowed to know about this player.
|
||||
function Core.PublishState(src)
|
||||
local p = requireChar(src)
|
||||
if not p then return end
|
||||
local st = Player(src).state
|
||||
st:set('rp:name', p.char.firstName .. ' ' .. p.char.lastName, true)
|
||||
st:set('rp:job', p.char.job, true)
|
||||
st:set('rp:charId', p.char.id, true)
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Money
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local VALID_ACCOUNTS = { cash = true, bank = true }
|
||||
|
||||
local function journal(charId, kind, delta, balanceAfter, reason)
|
||||
DB.TransactionAsync({
|
||||
{
|
||||
query = ('UPDATE characters SET %s = ? WHERE id = ?'):format(kind),
|
||||
values = { balanceAfter, charId },
|
||||
},
|
||||
{
|
||||
query = 'INSERT INTO transactions (character_id, account_kind, delta, balance_after, reason) VALUES (?, ?, ?, ?, ?)',
|
||||
values = { charId, kind, delta, balanceAfter, reason },
|
||||
},
|
||||
}, function(ok, err)
|
||||
if not ok then
|
||||
print(('^1[rp_core]^7 failed to journal %s %+d for character %d: %s')
|
||||
:format(kind, delta, charId, tostring(err)))
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
--- Returns true on success. Amounts are always positive integers.
|
||||
function Core.AddMoney(src, kind, amount, reason)
|
||||
local p = requireChar(src)
|
||||
if not p or not VALID_ACCOUNTS[kind] then return false end
|
||||
amount = math.floor(tonumber(amount) or 0)
|
||||
if amount <= 0 then return false end
|
||||
|
||||
p.char[kind] = p.char[kind] + amount -- no yield: cannot interleave
|
||||
local after = p.char[kind]
|
||||
|
||||
Core.PushCharacter(src)
|
||||
journal(p.char.id, kind, amount, after, reason or 'unspecified')
|
||||
return true
|
||||
end
|
||||
|
||||
--- Returns false (and changes nothing) when the player cannot afford it.
|
||||
function Core.RemoveMoney(src, kind, amount, reason)
|
||||
local p = requireChar(src)
|
||||
if not p or not VALID_ACCOUNTS[kind] then return false end
|
||||
amount = math.floor(tonumber(amount) or 0)
|
||||
if amount <= 0 then return false end
|
||||
if p.char[kind] < amount then return false end
|
||||
|
||||
p.char[kind] = p.char[kind] - amount
|
||||
local after = p.char[kind]
|
||||
|
||||
Core.PushCharacter(src)
|
||||
journal(p.char.id, kind, -amount, after, reason or 'unspecified')
|
||||
return true
|
||||
end
|
||||
|
||||
function Core.GetMoney(src, kind)
|
||||
local p = requireChar(src)
|
||||
if not p or not VALID_ACCOUNTS[kind] then return 0 end
|
||||
return p.char[kind]
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Persistence
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
--- Reads position and health from the server's own copy of the entity rather
|
||||
--- than asking the client, so a modified client cannot lie about either.
|
||||
local function snapshot(p)
|
||||
local ped = GetPlayerPed(p.source)
|
||||
if ped and ped ~= 0 and DoesEntityExist(ped) then
|
||||
local c = GetEntityCoords(ped)
|
||||
local h = GetEntityHeading(ped)
|
||||
if c and c.x and not (c.x == 0.0 and c.y == 0.0) then
|
||||
p.char.position = { x = c.x, y = c.y, z = c.z, h = h }
|
||||
end
|
||||
local hp = GetEntityHealth(ped)
|
||||
if hp and hp > 0 then p.char.health = hp end
|
||||
end
|
||||
if p.sessionStart then
|
||||
local now = os.time()
|
||||
p.char.playtime = p.char.playtime + (now - p.sessionStart)
|
||||
p.sessionStart = now
|
||||
end
|
||||
end
|
||||
|
||||
function Core.Save(src, reason)
|
||||
local p = requireChar(src)
|
||||
if not p or not p.spawned then return end
|
||||
snapshot(p)
|
||||
local c = p.char
|
||||
|
||||
DB.UpdateAsync([[
|
||||
UPDATE characters
|
||||
SET cash = ?, bank = ?, health = ?, armour = ?, job = ?, job_grade = ?,
|
||||
position = ?, needs = ?, appearance = ?, playtime = ?, last_played_at = NOW()
|
||||
WHERE id = ?
|
||||
]], {
|
||||
c.cash, c.bank, c.health, c.armour, c.job, c.jobGrade,
|
||||
json.encode(c.position or {}), json.encode(c.needs or {}),
|
||||
c.appearance and json.encode(c.appearance) or DB.NULL,
|
||||
c.playtime, c.id,
|
||||
}, function(_, err)
|
||||
if err then
|
||||
print(('^1[rp_core]^7 save failed for character %d (%s): %s'):format(c.id, tostring(reason), err))
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
function Core.SaveAll(reason)
|
||||
local n = 0
|
||||
for src in pairs(players) do
|
||||
if players[src].char and players[src].spawned then
|
||||
Core.Save(src, reason)
|
||||
n = n + 1
|
||||
end
|
||||
end
|
||||
return n
|
||||
end
|
||||
|
||||
CreateThread(function()
|
||||
local interval = (Config.Session.autosaveSeconds or 300) * 1000
|
||||
while true do
|
||||
Wait(interval)
|
||||
local n = Core.SaveAll('autosave')
|
||||
if n > 0 then print(('^5[rp_core]^7 autosaved %d character(s)'):format(n)) end
|
||||
end
|
||||
end)
|
||||
|
||||
AddEventHandler('playerDropped', function(reason)
|
||||
local src = source
|
||||
Core.Save(src, 'disconnect: ' .. tostring(reason))
|
||||
Core.Detach(src)
|
||||
end)
|
||||
|
||||
AddEventHandler('onResourceStop', function(res)
|
||||
if res ~= GetCurrentResourceName() then return end
|
||||
local n = Core.SaveAll('resource stop')
|
||||
print(('^5[rp_core]^7 saving %d character(s) on shutdown'):format(n))
|
||||
end)
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Exports for the other resources. None of these yield.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
exports('getPlayer', function(src) return players[src] end)
|
||||
exports('getCharacter', function(src) local p = players[src]; return p and p.char or nil end)
|
||||
exports('attachAccount', Core.AttachAccount)
|
||||
exports('attachCharacter',Core.AttachCharacter)
|
||||
exports('detach', Core.Detach)
|
||||
exports('addMoney', Core.AddMoney)
|
||||
exports('removeMoney', Core.RemoveMoney)
|
||||
exports('getMoney', Core.GetMoney)
|
||||
exports('save', Core.Save)
|
||||
exports('pushCharacter', Core.PushCharacter)
|
||||
exports('publishState', Core.PublishState)
|
||||
exports('setSpawned', function(src, v)
|
||||
local p = players[src]
|
||||
if p then p.spawned = v and true or false end
|
||||
end)
|
||||
@@ -1,185 +0,0 @@
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Freemode ped appearance model.
|
||||
--
|
||||
-- The client builds a ped from this table and the server stores it verbatim,
|
||||
-- so it is sanitised here - once - and both sides use the same code. Anything
|
||||
-- the client sends that is not described below is dropped.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
Appearance = {}
|
||||
|
||||
Appearance.MODELS = {
|
||||
m = 'mp_m_freemode_01',
|
||||
f = 'mp_f_freemode_01',
|
||||
}
|
||||
|
||||
-- SetPedHeadBlendData: 46 heritage faces per parent.
|
||||
Appearance.PARENT_MAX = 45
|
||||
|
||||
-- SetPedFaceFeature indices, in the order the creator shows them.
|
||||
Appearance.FEATURES = {
|
||||
{ id = 0, label = 'Nose width' },
|
||||
{ id = 1, label = 'Nose height' },
|
||||
{ id = 2, label = 'Nose length' },
|
||||
{ id = 3, label = 'Nose bridge' },
|
||||
{ id = 4, label = 'Nose tip' },
|
||||
{ id = 5, label = 'Nose shift' },
|
||||
{ id = 6, label = 'Brow height' },
|
||||
{ id = 7, label = 'Brow depth' },
|
||||
{ id = 8, label = 'Cheekbone height' },
|
||||
{ id = 9, label = 'Cheekbone width' },
|
||||
{ id = 10, label = 'Cheek width' },
|
||||
{ id = 11, label = 'Eye opening' },
|
||||
{ id = 12, label = 'Lip thickness' },
|
||||
{ id = 13, label = 'Jaw width' },
|
||||
{ id = 14, label = 'Jaw length' },
|
||||
{ id = 15, label = 'Chin height' },
|
||||
{ id = 16, label = 'Chin length' },
|
||||
{ id = 17, label = 'Chin width' },
|
||||
{ id = 18, label = 'Chin dimple' },
|
||||
{ id = 19, label = 'Neck thickness' },
|
||||
}
|
||||
|
||||
-- SetPedHeadOverlay slots that the creator exposes.
|
||||
Appearance.OVERLAYS = {
|
||||
{ id = 2, key = 'eyebrows', label = 'Eyebrows', max = 33, tint = 'hair' },
|
||||
{ id = 1, key = 'beard', label = 'Facial hair', max = 28, tint = 'hair' },
|
||||
{ id = 0, key = 'blemishes', label = 'Blemishes', max = 23, tint = false },
|
||||
{ id = 3, key = 'ageing', label = 'Ageing', max = 14, tint = false },
|
||||
{ id = 6, key = 'complexion',label = 'Complexion', max = 11, tint = false },
|
||||
{ id = 7, key = 'sundamage', label = 'Sun damage', max = 10, tint = false },
|
||||
{ id = 9, key = 'freckles', label = 'Freckles', max = 17, tint = false },
|
||||
{ id = 5, key = 'blush', label = 'Blush', max = 6, tint = 'makeup' },
|
||||
{ id = 8, key = 'lipstick', label = 'Lipstick', max = 9, tint = 'makeup' },
|
||||
{ id = 4, key = 'makeup', label = 'Make-up', max = 74, tint = 'makeup' },
|
||||
}
|
||||
|
||||
-- Clothing components the creator exposes (0-11 exist; these are the useful ones).
|
||||
Appearance.COMPONENTS = {
|
||||
{ id = 11, key = 'jacket', label = 'Top' },
|
||||
{ id = 8, key = 'undershirt',label = 'Undershirt' },
|
||||
{ id = 4, key = 'legs', label = 'Legs' },
|
||||
{ id = 6, key = 'shoes', label = 'Shoes' },
|
||||
{ id = 3, key = 'torso', label = 'Arms' },
|
||||
{ id = 1, key = 'mask', label = 'Mask' },
|
||||
{ id = 9, key = 'vest', label = 'Vest' },
|
||||
{ id = 7, key = 'accessory', label = 'Accessory' },
|
||||
{ id = 10, key = 'decal', label = 'Decal' },
|
||||
{ id = 5, key = 'bag', label = 'Bag' },
|
||||
}
|
||||
|
||||
Appearance.PROPS = {
|
||||
{ id = 0, key = 'hat', label = 'Hat' },
|
||||
{ id = 1, key = 'glasses', label = 'Glasses' },
|
||||
{ id = 2, key = 'ear', label = 'Earrings' },
|
||||
}
|
||||
|
||||
local function num(v, lo, hi, fallback)
|
||||
v = tonumber(v)
|
||||
if not v or v ~= v then return fallback end -- also rejects NaN
|
||||
if v < lo then return lo end
|
||||
if v > hi then return hi end
|
||||
return v
|
||||
end
|
||||
|
||||
local function int(v, lo, hi, fallback)
|
||||
return math.floor(num(v, lo, hi, fallback) + 0.0)
|
||||
end
|
||||
|
||||
function Appearance.Default(gender)
|
||||
local a = {
|
||||
model = (gender == 'f') and 'f' or 'm',
|
||||
parents = { father = 0, mother = 21, shapeMix = 0.5, skinMix = 0.5 },
|
||||
features = {},
|
||||
overlays = {},
|
||||
hair = { style = 0, colour = 0, highlight = 0 },
|
||||
eyeColour = 0,
|
||||
components = {},
|
||||
props = {},
|
||||
}
|
||||
for _, f in ipairs(Appearance.FEATURES) do a.features[tostring(f.id)] = 0.0 end
|
||||
for _, o in ipairs(Appearance.OVERLAYS) do
|
||||
a.overlays[o.key] = { index = -1, opacity = 1.0, colour = 0 }
|
||||
end
|
||||
for _, c in ipairs(Appearance.COMPONENTS) do
|
||||
a.components[c.key] = { drawable = 0, texture = 0 }
|
||||
end
|
||||
for _, p in ipairs(Appearance.PROPS) do
|
||||
a.props[p.key] = { drawable = -1, texture = 0 }
|
||||
end
|
||||
-- a plain default outfit rather than the naked base ped
|
||||
a.components.jacket = { drawable = 15, texture = 0 }
|
||||
a.components.undershirt = { drawable = 15, texture = 0 }
|
||||
a.components.torso = { drawable = 15, texture = 0 }
|
||||
a.components.legs = { drawable = 21, texture = 0 }
|
||||
a.components.shoes = { drawable = 34, texture = 0 }
|
||||
return a
|
||||
end
|
||||
|
||||
--- Returns a clean appearance table built only from known keys.
|
||||
function Appearance.Sanitise(input)
|
||||
local gender = (type(input) == 'table' and input.model == 'f') and 'f' or 'm'
|
||||
local out = Appearance.Default(gender)
|
||||
if type(input) ~= 'table' then return out end
|
||||
|
||||
if type(input.parents) == 'table' then
|
||||
out.parents.father = int(input.parents.father, 0, Appearance.PARENT_MAX, 0)
|
||||
out.parents.mother = int(input.parents.mother, 0, Appearance.PARENT_MAX, 21)
|
||||
out.parents.shapeMix = num(input.parents.shapeMix, 0.0, 1.0, 0.5)
|
||||
out.parents.skinMix = num(input.parents.skinMix, 0.0, 1.0, 0.5)
|
||||
end
|
||||
|
||||
if type(input.features) == 'table' then
|
||||
for _, f in ipairs(Appearance.FEATURES) do
|
||||
local k = tostring(f.id)
|
||||
out.features[k] = num(input.features[k], -1.0, 1.0, 0.0)
|
||||
end
|
||||
end
|
||||
|
||||
if type(input.overlays) == 'table' then
|
||||
for _, o in ipairs(Appearance.OVERLAYS) do
|
||||
local given = input.overlays[o.key]
|
||||
if type(given) == 'table' then
|
||||
out.overlays[o.key] = {
|
||||
index = int(given.index, -1, o.max, -1),
|
||||
opacity = num(given.opacity, 0.0, 1.0, 1.0),
|
||||
colour = int(given.colour, 0, 63, 0),
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if type(input.hair) == 'table' then
|
||||
out.hair.style = int(input.hair.style, 0, 80, 0)
|
||||
out.hair.colour = int(input.hair.colour, 0, 63, 0)
|
||||
out.hair.highlight = int(input.hair.highlight, 0, 63, 0)
|
||||
end
|
||||
|
||||
out.eyeColour = int(input.eyeColour, 0, 31, 0)
|
||||
|
||||
if type(input.components) == 'table' then
|
||||
for _, c in ipairs(Appearance.COMPONENTS) do
|
||||
local given = input.components[c.key]
|
||||
if type(given) == 'table' then
|
||||
out.components[c.key] = {
|
||||
drawable = int(given.drawable, 0, 511, 0),
|
||||
texture = int(given.texture, 0, 63, 0),
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if type(input.props) == 'table' then
|
||||
for _, p in ipairs(Appearance.PROPS) do
|
||||
local given = input.props[p.key]
|
||||
if type(given) == 'table' then
|
||||
out.props[p.key] = {
|
||||
drawable = int(given.drawable, -1, 255, -1),
|
||||
texture = int(given.texture, 0, 63, 0),
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return out
|
||||
end
|
||||
@@ -1,99 +0,0 @@
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Shared configuration. Loaded on both server and client.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
Config = {}
|
||||
|
||||
Config.ServerName = 'LOS SANTOS'
|
||||
Config.ServerTag = 'ROLEPLAY'
|
||||
Config.MaxChars = 3
|
||||
|
||||
-- Shown on the loading screen while the world streams in.
|
||||
Config.Rules = {
|
||||
{ title = 'Stay in character', body = 'Your character does not know what you know. Breaking character in the world is the fastest way to lose the story.' },
|
||||
{ title = 'Value your life', body = 'Act like the consequences are permanent. A gun in your face changes what you are willing to do.' },
|
||||
{ title = 'No random deathmatch', body = 'Violence needs a reason the other person can understand. Escalate, do not detonate.' },
|
||||
{ title = 'Do not power game', body = 'Give people a fair chance to react. Winning is not the point of a scene.' },
|
||||
{ title = 'Leave the scene alive', body = 'If you die, your character forgets the thirty minutes before it. No revenge from the grave.' },
|
||||
}
|
||||
|
||||
-- Spawn choices offered after character select. Coordinates are real map
|
||||
-- locations; `cam` is where the establishing shot sits before it flies down.
|
||||
Config.SpawnPoints = {
|
||||
{
|
||||
id = 'legion',
|
||||
label = 'Legion Square',
|
||||
area = 'Downtown',
|
||||
blurb = 'Concrete, noise and opportunity. Everything starts here eventually.',
|
||||
coords = vector4(195.12, -933.75, 30.69, 145.0),
|
||||
cam = { pos = vector3(214.0, -880.0, 90.0), look = vector3(195.0, -933.0, 32.0) },
|
||||
map = { x = 0.512, y = 0.548 },
|
||||
},
|
||||
{
|
||||
id = 'vespucci',
|
||||
label = 'Vespucci Beach',
|
||||
area = 'West coast',
|
||||
blurb = 'Sand, boardwalk hustlers and the smell of two-stroke engines.',
|
||||
coords = vector4(-1223.45, -1490.32, 4.38, 125.0),
|
||||
cam = { pos = vector3(-1250.0, -1440.0, 60.0), look = vector3(-1223.0, -1490.0, 6.0) },
|
||||
map = { x = 0.318, y = 0.700 },
|
||||
},
|
||||
{
|
||||
id = 'sandy',
|
||||
label = 'Sandy Shores',
|
||||
area = 'Blaine County',
|
||||
blurb = 'Dust, trailers and people who would rather not be found.',
|
||||
coords = vector4(1961.29, 3740.68, 32.34, 300.0),
|
||||
cam = { pos = vector3(1995.0, 3700.0, 80.0), look = vector3(1961.0, 3740.0, 34.0) },
|
||||
map = { x = 0.760, y = 0.245 },
|
||||
},
|
||||
{
|
||||
id = 'paleto',
|
||||
label = 'Paleto Bay',
|
||||
area = 'North coast',
|
||||
blurb = 'A town small enough that everyone will know your name by Friday.',
|
||||
coords = vector4(-108.71, 6467.32, 31.63, 225.0),
|
||||
cam = { pos = vector3(-160.0, 6420.0, 80.0), look = vector3(-108.0, 6467.0, 33.0) },
|
||||
map = { x = 0.470, y = 0.075 },
|
||||
},
|
||||
{
|
||||
id = 'mirror',
|
||||
label = 'Mirror Park',
|
||||
area = 'East Vinewood',
|
||||
blurb = 'Quiet streets, loud neighbours, and rent you cannot quite afford.',
|
||||
coords = vector4(1050.12, -720.45, 57.05, 90.0),
|
||||
cam = { pos = vector3(1100.0, -680.0, 100.0), look = vector3(1050.0, -720.0, 58.0) },
|
||||
map = { x = 0.640, y = 0.505 },
|
||||
},
|
||||
}
|
||||
|
||||
-- Establishing shots used behind the login / character screens. Each entry is
|
||||
-- a slow dolly from `from` to `to` while looking at `look`.
|
||||
Config.CinematicShots = {
|
||||
{ from = vector3(-1900.0, -600.0, 130.0), to = vector3(-1700.0, -500.0, 120.0), look = vector3(-1450.0, -400.0, 60.0) },
|
||||
{ from = vector3(-75.0, -818.0, 320.0), to = vector3(30.0, -700.0, 290.0), look = vector3(-200.0, -1000.0, 60.0) },
|
||||
{ from = vector3(1200.0, -1600.0, 160.0),to = vector3(1000.0, -1450.0, 150.0), look = vector3(500.0, -1200.0, 60.0) },
|
||||
{ from = vector3(-1550.0, -1450.0, 90.0),to = vector3(-1400.0, -1550.0, 80.0), look = vector3(-1100.0, -1700.0, 20.0) },
|
||||
{ from = vector3(730.0, 1200.0, 400.0), to = vector3(640.0, 1100.0, 380.0), look = vector3(300.0, 800.0, 200.0) },
|
||||
}
|
||||
|
||||
-- Where a character stands while being edited or previewed. Flat, empty apron
|
||||
-- at the airport: always streamed, no props to clip through, and dark enough
|
||||
-- at the overridden clock time to light the ped cleanly.
|
||||
Config.CreatorScene = {
|
||||
ped = vector4(-1037.20, -2738.60, 20.17, 328.0),
|
||||
}
|
||||
|
||||
Config.Money = {
|
||||
startingCash = 500,
|
||||
startingBank = 2500,
|
||||
}
|
||||
|
||||
Config.Session = {
|
||||
-- how long a client may sit at the auth screen before it is dropped
|
||||
authTimeoutMs = 300000,
|
||||
-- failed logins allowed per identity within the window
|
||||
maxAttempts = 6,
|
||||
attemptWindowS = 900,
|
||||
autosaveSeconds = 300,
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Small shared helpers. Kept deliberately thin.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
Util = {}
|
||||
|
||||
function Util.trim(s)
|
||||
if type(s) ~= 'string' then return '' end
|
||||
return (s:gsub('^%s+', ''):gsub('%s+$', ''))
|
||||
end
|
||||
|
||||
function Util.clamp(v, lo, hi)
|
||||
if v < lo then return lo end
|
||||
if v > hi then return hi end
|
||||
return v
|
||||
end
|
||||
|
||||
function Util.round(v, places)
|
||||
local m = 10 ^ (places or 0)
|
||||
return math.floor(v * m + 0.5) / m
|
||||
end
|
||||
|
||||
--- Deep copy; cycles are not expected in config/state tables.
|
||||
function Util.copy(t)
|
||||
if type(t) ~= 'table' then return t end
|
||||
local out = {}
|
||||
for k, v in pairs(t) do out[k] = Util.copy(v) end
|
||||
return out
|
||||
end
|
||||
|
||||
function Util.count(t)
|
||||
local n = 0
|
||||
for _ in pairs(t) do n = n + 1 end
|
||||
return n
|
||||
end
|
||||
|
||||
--- Group separated money, e.g. 1234567 -> "1,234,567"
|
||||
function Util.money(n)
|
||||
local s = tostring(math.floor(math.abs(n or 0)))
|
||||
local out = s:reverse():gsub('(%d%d%d)', '%1,'):reverse()
|
||||
out = out:gsub('^,', '')
|
||||
return (n or 0) < 0 and ('-' .. out) or out
|
||||
end
|
||||
|
||||
--- Names are stored capitalised regardless of how they were typed.
|
||||
function Util.properName(s)
|
||||
s = Util.trim(s):lower()
|
||||
return (s:gsub("([^%s'-]+)", function(word)
|
||||
return word:sub(1, 1):upper() .. word:sub(2)
|
||||
end))
|
||||
end
|
||||
|
||||
--- Validation shared by client (live feedback) and server (enforcement), so
|
||||
--- the two can never disagree about what is acceptable.
|
||||
Rules = {}
|
||||
|
||||
Rules.username = {
|
||||
min = 3, max = 20,
|
||||
pattern = '^[%w_]+$',
|
||||
hint = '3-20 characters, letters, numbers and underscore only',
|
||||
}
|
||||
|
||||
Rules.password = {
|
||||
min = 8, max = 72,
|
||||
hint = 'at least 8 characters, with a letter and a number',
|
||||
}
|
||||
|
||||
Rules.name = {
|
||||
min = 2, max = 20,
|
||||
pattern = "^[%a][%a'%-]*$",
|
||||
hint = "letters, apostrophes and hyphens",
|
||||
}
|
||||
|
||||
function Rules.checkUsername(v)
|
||||
v = Util.trim(v or '')
|
||||
if #v < Rules.username.min then return false, 'Too short - ' .. Rules.username.hint end
|
||||
if #v > Rules.username.max then return false, 'Too long - ' .. Rules.username.hint end
|
||||
if not v:match(Rules.username.pattern) then return false, 'Only letters, numbers and underscore' end
|
||||
return true
|
||||
end
|
||||
|
||||
function Rules.checkPassword(v)
|
||||
v = v or ''
|
||||
if #v < Rules.password.min then return false, 'At least 8 characters' end
|
||||
if #v > Rules.password.max then return false, 'Too long' end
|
||||
if not v:match('%a') then return false, 'Needs at least one letter' end
|
||||
if not v:match('%d') then return false, 'Needs at least one number' end
|
||||
return true
|
||||
end
|
||||
|
||||
function Rules.checkName(v)
|
||||
v = Util.trim(v or '')
|
||||
if #v < Rules.name.min then return false, 'Too short' end
|
||||
if #v > Rules.name.max then return false, 'Too long' end
|
||||
if not v:match(Rules.name.pattern) then return false, "Letters, ' and - only" end
|
||||
return true
|
||||
end
|
||||
|
||||
--- Date of birth: accepts YYYY-MM-DD, must be a real date and an adult age.
|
||||
function Rules.checkDob(v)
|
||||
v = Util.trim(v or '')
|
||||
local y, m, d = v:match('^(%d%d%d%d)%-(%d%d)%-(%d%d)$')
|
||||
if not y then return false, 'Use YYYY-MM-DD' end
|
||||
y, m, d = tonumber(y), tonumber(m), tonumber(d)
|
||||
if m < 1 or m > 12 then return false, 'Month must be 01-12' end
|
||||
local mdays = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }
|
||||
if (y % 4 == 0 and y % 100 ~= 0) or y % 400 == 0 then mdays[2] = 29 end
|
||||
if d < 1 or d > mdays[m] then return false, 'That day does not exist' end
|
||||
if y < 1930 or y > 2010 then return false, 'Year must be between 1930 and 2010' end
|
||||
return true
|
||||
end
|
||||
@@ -1,15 +0,0 @@
|
||||
fx_version 'cerulean'
|
||||
game 'gta5'
|
||||
|
||||
name 'rp_db'
|
||||
description 'MariaDB client speaking the MySQL wire protocol directly - no external packages'
|
||||
author 'Los Santos RP'
|
||||
version '1.0.0'
|
||||
|
||||
server_scripts {
|
||||
'server/mysql.js',
|
||||
}
|
||||
|
||||
files {
|
||||
'lib/db.lua',
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- DB - Lua front end for rp_db.
|
||||
--
|
||||
-- Loaded *into* the consuming resource with:
|
||||
-- server_scripts { '@rp_db/lib/db.lua', 'server/whatever.lua' }
|
||||
--
|
||||
-- so that Citizen.Await suspends the caller's own coroutine instead of trying
|
||||
-- to yield across a cross-resource export call.
|
||||
--
|
||||
-- Every function comes in two flavours:
|
||||
-- DB.Query(sql, params) -> rows, err (blocking, inside a thread)
|
||||
-- DB.QueryAsync(sql, params, cb) -> cb(rows, err) (callback, anywhere)
|
||||
--
|
||||
-- Values are always passed as `?` parameters. Never build SQL by concatenation.
|
||||
-- Use DB.NULL where you need a literal SQL NULL (Lua nil cannot survive a table).
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
DB = {}
|
||||
|
||||
DB.NULL = 'RP_NULL'
|
||||
|
||||
local backend = exports.rp_db
|
||||
|
||||
local function async(method)
|
||||
return function(sql, params, cb)
|
||||
backend[method](backend, sql, params or {}, cb)
|
||||
end
|
||||
end
|
||||
|
||||
local function blocking(method)
|
||||
return function(sql, params)
|
||||
local p = promise.new()
|
||||
backend[method](backend, sql, params or {}, function(result, err)
|
||||
-- wrapped: a promise resolved with nil would never wake the coroutine
|
||||
p:resolve({ result = result, err = err })
|
||||
end)
|
||||
local r = Citizen.Await(p)
|
||||
if r.err then return nil, r.err end
|
||||
return r.result
|
||||
end
|
||||
end
|
||||
|
||||
--- Returns all rows as an array of tables (empty table when nothing matched).
|
||||
DB.Query = blocking('query')
|
||||
--- Returns the first row as a table, or nil.
|
||||
DB.Single = blocking('single')
|
||||
--- Returns the first column of the first row, or nil.
|
||||
DB.Scalar = blocking('scalar')
|
||||
--- Returns the AUTO_INCREMENT id produced by an INSERT.
|
||||
DB.Insert = blocking('insert')
|
||||
--- Returns the number of rows changed by an UPDATE/DELETE.
|
||||
DB.Update = blocking('update')
|
||||
|
||||
DB.QueryAsync = async('query')
|
||||
DB.SingleAsync = async('single')
|
||||
DB.ScalarAsync = async('scalar')
|
||||
DB.InsertAsync = async('insert')
|
||||
DB.UpdateAsync = async('update')
|
||||
|
||||
--- All statements commit together or none of them do.
|
||||
--- statements: { { query = 'UPDATE ..', values = { 1, 2 } }, ... }
|
||||
function DB.Transaction(statements)
|
||||
local p = promise.new()
|
||||
backend:transaction(statements, function(ok, err)
|
||||
p:resolve({ ok = ok, err = err })
|
||||
end)
|
||||
local r = Citizen.Await(p)
|
||||
if not r.ok then return false, r.err end
|
||||
return true
|
||||
end
|
||||
|
||||
function DB.TransactionAsync(statements, cb)
|
||||
backend:transaction(statements, cb)
|
||||
end
|
||||
|
||||
--- Blocks until the connection pool has at least one live connection.
|
||||
--- Returns false if the database never came up within `timeout` ms.
|
||||
function DB.WaitReady(timeout)
|
||||
local waited, step = 0, 100
|
||||
timeout = timeout or 30000
|
||||
while not backend:isReady() do
|
||||
if waited >= timeout then return false end
|
||||
Citizen.Wait(step)
|
||||
waited = waited + step
|
||||
end
|
||||
return true
|
||||
end
|
||||
@@ -1,746 +0,0 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// rp_db - a MySQL/MariaDB client written for this server.
|
||||
//
|
||||
// Speaks the MySQL client/server protocol directly over a TCP socket: framing,
|
||||
// handshake, mysql_native_password / caching_sha2_password (fast path), and
|
||||
// the text result-set protocol. No external packages - only node built-ins.
|
||||
//
|
||||
// Deliberate choices:
|
||||
// * CLIENT_MULTI_STATEMENTS is NOT negotiated, so a stacked-query injection
|
||||
// ("'; DROP TABLE ..") is rejected by the server even if escaping failed.
|
||||
// * CLIENT_LOCAL_FILES is NOT negotiated, so the server can never ask us to
|
||||
// read a local file.
|
||||
// * sql_mode is pinned at connect time, which also guarantees backslash
|
||||
// escaping semantics that format() relies on.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const net = require('net');
|
||||
const crypto = require('crypto');
|
||||
|
||||
// -- capability flags --------------------------------------------------------
|
||||
const CAP = {
|
||||
LONG_PASSWORD: 0x00000001,
|
||||
FOUND_ROWS: 0x00000002,
|
||||
LONG_FLAG: 0x00000004,
|
||||
CONNECT_WITH_DB: 0x00000008,
|
||||
LOCAL_FILES: 0x00000080,
|
||||
PROTOCOL_41: 0x00000200,
|
||||
TRANSACTIONS: 0x00002000,
|
||||
SECURE_CONNECTION: 0x00008000,
|
||||
MULTI_STATEMENTS: 0x00010000,
|
||||
MULTI_RESULTS: 0x00020000,
|
||||
PLUGIN_AUTH: 0x00080000,
|
||||
CONNECT_ATTRS: 0x00100000,
|
||||
PLUGIN_AUTH_LENENC: 0x00200000,
|
||||
};
|
||||
|
||||
const CLIENT_CAPS =
|
||||
CAP.LONG_PASSWORD | CAP.LONG_FLAG | CAP.CONNECT_WITH_DB | CAP.PROTOCOL_41 |
|
||||
CAP.TRANSACTIONS | CAP.SECURE_CONNECTION | CAP.PLUGIN_AUTH | CAP.PLUGIN_AUTH_LENENC;
|
||||
|
||||
const COM = { QUIT: 0x01, QUERY: 0x03, PING: 0x0e };
|
||||
|
||||
// MySQL column type codes we care about when coercing text-protocol values.
|
||||
const T_INT = new Set([1, 2, 3, 8, 9, 13]); // TINY SHORT LONG LONGLONG INT24 YEAR
|
||||
const T_FLOAT = new Set([0, 4, 5, 246]); // DECIMAL FLOAT DOUBLE NEWDECIMAL
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Buffer helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class Reader {
|
||||
constructor(buf) { this.b = buf; this.o = 0; }
|
||||
u8() { return this.b[this.o++]; }
|
||||
u16() { const v = this.b.readUInt16LE(this.o); this.o += 2; return v; }
|
||||
u32() { const v = this.b.readUInt32LE(this.o); this.o += 4; return v; }
|
||||
skip(n) { this.o += n; return this; }
|
||||
bytes(n) { const v = this.b.subarray(this.o, this.o + n); this.o += n; return v; }
|
||||
nulStr() {
|
||||
let i = this.b.indexOf(0, this.o);
|
||||
if (i < 0) i = this.b.length;
|
||||
const s = this.b.toString('utf8', this.o, i);
|
||||
this.o = i + 1;
|
||||
return s;
|
||||
}
|
||||
// length-encoded integer; returns null for the 0xfb NULL marker
|
||||
lenenc() {
|
||||
const f = this.b[this.o++];
|
||||
if (f < 0xfb) return f;
|
||||
if (f === 0xfb) return null;
|
||||
if (f === 0xfc) { const v = this.b.readUInt16LE(this.o); this.o += 2; return v; }
|
||||
if (f === 0xfd) { const v = this.b.readUIntLE(this.o, 3); this.o += 3; return v; }
|
||||
const v = this.b.readBigUInt64LE(this.o); this.o += 8;
|
||||
return Number(v);
|
||||
}
|
||||
lenencStr() {
|
||||
const n = this.lenenc();
|
||||
if (n === null) return null;
|
||||
return this.bytes(n).toString('utf8');
|
||||
}
|
||||
remaining() { return this.b.length - this.o; }
|
||||
}
|
||||
|
||||
class Writer {
|
||||
constructor() { this.parts = []; }
|
||||
u8(v) { const b = Buffer.alloc(1); b[0] = v; this.parts.push(b); return this; }
|
||||
u16(v) { const b = Buffer.alloc(2); b.writeUInt16LE(v); this.parts.push(b); return this; }
|
||||
u32(v) { const b = Buffer.alloc(4); b.writeUInt32LE(v >>> 0); this.parts.push(b); return this; }
|
||||
zeros(n) { this.parts.push(Buffer.alloc(n)); return this; }
|
||||
raw(b) { this.parts.push(b); return this; }
|
||||
nulStr(s) { this.parts.push(Buffer.from(s, 'utf8')); return this.u8(0); }
|
||||
lenencBuf(b) {
|
||||
if (b.length < 0xfb) this.u8(b.length);
|
||||
else if (b.length < 0x10000) { this.u8(0xfc).u16(b.length); }
|
||||
else { const t = Buffer.alloc(4); t.writeUIntLE(b.length, 0, 3); this.u8(0xfd).raw(t.subarray(0, 3)); }
|
||||
return this.raw(b);
|
||||
}
|
||||
done() { return Buffer.concat(this.parts); }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Authentication
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const sha1 = (d) => crypto.createHash('sha1').update(d).digest();
|
||||
const sha256 = (d) => crypto.createHash('sha256').update(d).digest();
|
||||
|
||||
function xorBuf(a, b) {
|
||||
const out = Buffer.alloc(a.length);
|
||||
for (let i = 0; i < a.length; i++) out[i] = a[i] ^ b[i % b.length];
|
||||
return out;
|
||||
}
|
||||
|
||||
// SHA1(pw) XOR SHA1( scramble + SHA1(SHA1(pw)) )
|
||||
function authNative(password, scramble) {
|
||||
if (!password) return Buffer.alloc(0);
|
||||
const s1 = sha1(Buffer.from(password, 'utf8'));
|
||||
const s2 = sha1(s1);
|
||||
return xorBuf(s1, sha1(Buffer.concat([scramble, s2])));
|
||||
}
|
||||
|
||||
// SHA256(pw) XOR SHA256( SHA256(SHA256(pw)) + scramble ) [fast path only]
|
||||
function authCachingSha2(password, scramble) {
|
||||
if (!password) return Buffer.alloc(0);
|
||||
const s1 = sha256(Buffer.from(password, 'utf8'));
|
||||
const s2 = sha256(s1);
|
||||
return xorBuf(s1, sha256(Buffer.concat([s2, scramble])));
|
||||
}
|
||||
|
||||
function authResponse(plugin, password, scramble) {
|
||||
switch (plugin) {
|
||||
case 'mysql_native_password': return authNative(password, scramble);
|
||||
case 'caching_sha2_password': return authCachingSha2(password, scramble);
|
||||
case 'mysql_clear_password': return Buffer.concat([Buffer.from(password, 'utf8'), Buffer.alloc(1)]);
|
||||
default: return null; // unsupported -> connection fails loudly
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Value escaping. Every query goes through format(); values are never
|
||||
// concatenated into SQL anywhere else in this codebase.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ESCAPE_MAP = {
|
||||
'\0': '\\0', '\b': '\\b', '\t': '\\t', '\n': '\\n',
|
||||
'\r': '\\r', '\x1a': '\\Z', '"': '\\"', "'": "\\'", '\\': '\\\\',
|
||||
};
|
||||
|
||||
function escapeString(s) {
|
||||
return "'" + s.replace(/[\0\b\t\n\r\x1a"'\\]/g, (c) => ESCAPE_MAP[c]) + "'";
|
||||
}
|
||||
|
||||
function pad2(n) { return n < 10 ? '0' + n : '' + n; }
|
||||
|
||||
function escapeValue(v) {
|
||||
if (v === null || v === undefined) return 'NULL';
|
||||
switch (typeof v) {
|
||||
case 'boolean': return v ? '1' : '0';
|
||||
case 'number':
|
||||
if (!Number.isFinite(v)) throw new Error('cannot bind non-finite number');
|
||||
return String(v);
|
||||
case 'bigint': return String(v);
|
||||
case 'string': return escapeString(v);
|
||||
case 'object':
|
||||
if (v instanceof Date) {
|
||||
return escapeString(
|
||||
`${v.getFullYear()}-${pad2(v.getMonth() + 1)}-${pad2(v.getDate())} ` +
|
||||
`${pad2(v.getHours())}:${pad2(v.getMinutes())}:${pad2(v.getSeconds())}`
|
||||
);
|
||||
}
|
||||
if (Buffer.isBuffer(v)) return 'x' + escapeString(v.toString('hex'));
|
||||
if (Array.isArray(v)) return v.map(escapeValue).join(', ');
|
||||
return escapeString(JSON.stringify(v)); // plain objects -> JSON columns
|
||||
default:
|
||||
throw new Error('cannot bind value of type ' + typeof v);
|
||||
}
|
||||
}
|
||||
|
||||
// Replace `?` placeholders, skipping any that appear inside string/identifier
|
||||
// literals or comments so that user data containing '?' can never shift binding.
|
||||
function format(sql, params) {
|
||||
if (!params || (Array.isArray(params) && params.length === 0)) return sql;
|
||||
const list = Array.isArray(params) ? params : [params];
|
||||
let out = '';
|
||||
let pi = 0;
|
||||
let i = 0;
|
||||
const n = sql.length;
|
||||
|
||||
while (i < n) {
|
||||
const c = sql[i];
|
||||
|
||||
if (c === "'" || c === '"' || c === '`') {
|
||||
const quote = c;
|
||||
out += c; i++;
|
||||
while (i < n) {
|
||||
if (sql[i] === '\\' && quote !== '`') { out += sql[i] + (sql[i + 1] || ''); i += 2; continue; }
|
||||
if (sql[i] === quote) {
|
||||
if (sql[i + 1] === quote) { out += quote + quote; i += 2; continue; } // doubled quote
|
||||
out += quote; i++; break;
|
||||
}
|
||||
out += sql[i]; i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (c === '-' && sql[i + 1] === '-') { while (i < n && sql[i] !== '\n') out += sql[i++]; continue; }
|
||||
if (c === '#') { while (i < n && sql[i] !== '\n') out += sql[i++]; continue; }
|
||||
if (c === '/' && sql[i + 1] === '*') {
|
||||
out += '/*'; i += 2;
|
||||
while (i < n && !(sql[i] === '*' && sql[i + 1] === '/')) out += sql[i++];
|
||||
out += '*/'; i += 2; continue;
|
||||
}
|
||||
if (c === '?') {
|
||||
if (pi >= list.length) throw new Error('not enough parameters for query');
|
||||
out += escapeValue(list[pi++]);
|
||||
i++; continue;
|
||||
}
|
||||
out += c; i++;
|
||||
}
|
||||
|
||||
if (pi !== list.length) throw new Error(`parameter count mismatch (query takes ${pi}, got ${list.length})`);
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Connection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MAX_PAYLOAD = 0xffffff;
|
||||
|
||||
class Connection {
|
||||
constructor(opts, id) {
|
||||
this.opts = opts;
|
||||
this.id = id;
|
||||
this.socket = null;
|
||||
this.buf = Buffer.alloc(0);
|
||||
this.seq = 0;
|
||||
this.state = 'offline';
|
||||
this.busy = false;
|
||||
this.pinned = false; // reserved by a transaction; pump() must skip it
|
||||
this.pending = null; // {resolve, reject, sql}
|
||||
this.rs = null; // in-flight result set
|
||||
this.multi = null; // reassembly buffer for >16MB payloads
|
||||
this.serverCaps = 0;
|
||||
this.onIdle = null;
|
||||
this.retryDelay = 1000;
|
||||
}
|
||||
|
||||
log(msg) { console.log(`^5[rp_db]^7 conn#${this.id} ${msg}`); }
|
||||
err(msg) { console.log(`^1[rp_db]^7 conn#${this.id} ${msg}`); }
|
||||
|
||||
connect() {
|
||||
if (this.state !== 'offline') return;
|
||||
this.state = 'connecting';
|
||||
this.buf = Buffer.alloc(0);
|
||||
this.seq = 0;
|
||||
|
||||
const sock = net.createConnection({ host: this.opts.host, port: this.opts.port });
|
||||
this.socket = sock;
|
||||
sock.setNoDelay(true);
|
||||
|
||||
sock.on('data', (d) => {
|
||||
this.buf = this.buf.length ? Buffer.concat([this.buf, d]) : d;
|
||||
this.drainPackets();
|
||||
});
|
||||
sock.on('error', (e) => this.fail(e.message));
|
||||
sock.on('close', () => { if (this.state !== 'offline') this.fail('connection closed'); });
|
||||
}
|
||||
|
||||
fail(reason) {
|
||||
const wasReady = this.state === 'ready' || this.state === 'query';
|
||||
this.state = 'offline';
|
||||
if (this.socket) { this.socket.destroy(); this.socket = null; }
|
||||
if (this.pending) {
|
||||
const p = this.pending; this.pending = null; this.busy = false;
|
||||
p.reject(new Error(reason));
|
||||
}
|
||||
this.busy = false;
|
||||
this.pinned = false; // a dropped connection must not stay reserved
|
||||
this.rs = null;
|
||||
if (wasReady) this.err(`lost connection: ${reason}`);
|
||||
else this.err(`connect failed: ${reason}`);
|
||||
// exponential backoff, capped
|
||||
setTimeout(() => this.connect(), this.retryDelay);
|
||||
this.retryDelay = Math.min(this.retryDelay * 2, 30000);
|
||||
}
|
||||
|
||||
// Split the stream into protocol packets, reassembling 16MB-split payloads.
|
||||
drainPackets() {
|
||||
for (;;) {
|
||||
if (this.buf.length < 4) return;
|
||||
const len = this.buf.readUIntLE(0, 3);
|
||||
if (this.buf.length < 4 + len) return;
|
||||
const seq = this.buf[3];
|
||||
const payload = this.buf.subarray(4, 4 + len);
|
||||
this.buf = this.buf.subarray(4 + len);
|
||||
this.seq = (seq + 1) & 0xff;
|
||||
|
||||
if (len === MAX_PAYLOAD) { // more to come
|
||||
this.multi = this.multi ? Buffer.concat([this.multi, payload]) : payload;
|
||||
continue;
|
||||
}
|
||||
let full = payload;
|
||||
if (this.multi) { full = Buffer.concat([this.multi, payload]); this.multi = null; }
|
||||
|
||||
try { this.onPacket(full); }
|
||||
catch (e) { this.err(`packet handling error: ${e.message}`); this.fail(e.message); return; }
|
||||
}
|
||||
}
|
||||
|
||||
send(payload) {
|
||||
if (!this.socket) return;
|
||||
const chunks = [];
|
||||
let off = 0;
|
||||
do {
|
||||
const slice = payload.subarray(off, off + MAX_PAYLOAD);
|
||||
const head = Buffer.alloc(4);
|
||||
head.writeUIntLE(slice.length, 0, 3);
|
||||
head[3] = this.seq & 0xff;
|
||||
this.seq = (this.seq + 1) & 0xff;
|
||||
chunks.push(head, slice);
|
||||
off += slice.length;
|
||||
} while (off < payload.length);
|
||||
this.socket.write(Buffer.concat(chunks));
|
||||
}
|
||||
|
||||
sendCommand(cmd, text) {
|
||||
this.seq = 0; // every command starts a new sequence
|
||||
const body = text ? Buffer.from(text, 'utf8') : Buffer.alloc(0);
|
||||
this.send(Buffer.concat([Buffer.from([cmd]), body]));
|
||||
}
|
||||
|
||||
onPacket(p) {
|
||||
switch (this.state) {
|
||||
case 'connecting': return this.onHandshake(p);
|
||||
case 'auth': return this.onAuthResult(p);
|
||||
case 'init': return this.onInitResult(p);
|
||||
case 'query': return this.onQueryPacket(p);
|
||||
default:
|
||||
this.err(`unexpected packet in state ${this.state}`);
|
||||
}
|
||||
}
|
||||
|
||||
// -- handshake -------------------------------------------------------------
|
||||
onHandshake(p) {
|
||||
const r = new Reader(p);
|
||||
const protocol = r.u8();
|
||||
if (protocol === 0xff) return this.fail(this.readError(p).message);
|
||||
if (protocol !== 10) return this.fail(`unsupported protocol version ${protocol}`);
|
||||
|
||||
this.serverVersion = r.nulStr();
|
||||
r.u32(); // connection id
|
||||
const scramble1 = r.bytes(8);
|
||||
r.skip(1); // filler
|
||||
let caps = r.u16();
|
||||
let scramble2 = Buffer.alloc(0);
|
||||
let plugin = 'mysql_native_password';
|
||||
|
||||
if (r.remaining() > 0) {
|
||||
r.u8(); // charset
|
||||
r.u16(); // status flags
|
||||
caps |= r.u16() << 16;
|
||||
const authDataLen = r.u8();
|
||||
r.skip(10);
|
||||
if (caps & CAP.SECURE_CONNECTION) {
|
||||
const n = Math.max(13, authDataLen - 8);
|
||||
scramble2 = r.bytes(n);
|
||||
// trailing NUL is part of the padding, not the scramble
|
||||
if (scramble2.length && scramble2[scramble2.length - 1] === 0) {
|
||||
scramble2 = scramble2.subarray(0, scramble2.length - 1);
|
||||
}
|
||||
}
|
||||
if (caps & CAP.PLUGIN_AUTH) plugin = r.nulStr();
|
||||
}
|
||||
|
||||
this.serverCaps = caps >>> 0;
|
||||
this.scramble = Buffer.concat([scramble1, scramble2]);
|
||||
|
||||
const resp = authResponse(plugin, this.opts.password, this.scramble);
|
||||
if (resp === null) return this.fail(`server requested unsupported auth plugin '${plugin}'`);
|
||||
|
||||
const w = new Writer();
|
||||
w.u32(CLIENT_CAPS).u32(MAX_PAYLOAD).u8(45 /* utf8mb4_general_ci */).zeros(23);
|
||||
w.nulStr(this.opts.user);
|
||||
w.lenencBuf(resp);
|
||||
w.nulStr(this.opts.database);
|
||||
w.nulStr(plugin);
|
||||
|
||||
this.authPlugin = plugin;
|
||||
this.state = 'auth';
|
||||
this.send(w.done());
|
||||
}
|
||||
|
||||
onAuthResult(p) {
|
||||
const marker = p[0];
|
||||
|
||||
if (marker === 0x00) return this.afterAuth();
|
||||
if (marker === 0xff) return this.fail(this.readError(p).message);
|
||||
|
||||
if (marker === 0xfe) { // AuthSwitchRequest
|
||||
const r = new Reader(p); r.u8();
|
||||
const plugin = r.nulStr();
|
||||
let scramble = r.bytes(r.remaining());
|
||||
if (scramble.length && scramble[scramble.length - 1] === 0) scramble = scramble.subarray(0, scramble.length - 1);
|
||||
const resp = authResponse(plugin, this.opts.password, scramble);
|
||||
if (resp === null) return this.fail(`server switched to unsupported auth plugin '${plugin}'`);
|
||||
this.scramble = scramble;
|
||||
this.authPlugin = plugin;
|
||||
return this.send(resp);
|
||||
}
|
||||
|
||||
if (marker === 0x01) { // caching_sha2 extra auth data
|
||||
const status = p[1];
|
||||
if (status === 0x03) return; // fast auth ok -> OK packet follows
|
||||
if (status === 0x04) {
|
||||
return this.fail(
|
||||
'caching_sha2_password requires a full authentication exchange (TLS or the ' +
|
||||
'server public key), which this driver does not implement. Give the database ' +
|
||||
"user mysql_native_password, e.g. ALTER USER .. IDENTIFIED VIA mysql_native_password."
|
||||
);
|
||||
}
|
||||
return this.fail(`unexpected auth continuation 0x${status.toString(16)}`);
|
||||
}
|
||||
|
||||
this.fail(`unexpected auth response 0x${marker.toString(16)}`);
|
||||
}
|
||||
|
||||
// Pin session settings, then announce readiness.
|
||||
afterAuth() {
|
||||
this.retryDelay = 1000;
|
||||
this.state = 'init';
|
||||
this.sendCommand(
|
||||
COM.QUERY,
|
||||
"SET NAMES utf8mb4, sql_mode='STRICT_TRANS_TABLES,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION', " +
|
||||
"time_zone='+00:00', autocommit=1"
|
||||
);
|
||||
}
|
||||
|
||||
onInitResult(p) {
|
||||
if (p[0] === 0xff) return this.fail(this.readError(p).message);
|
||||
this.state = 'ready';
|
||||
this.busy = false;
|
||||
this.log(`ready (server ${this.serverVersion}, auth ${this.authPlugin})`);
|
||||
if (this.onIdle) this.onIdle(this);
|
||||
}
|
||||
|
||||
// -- errors ----------------------------------------------------------------
|
||||
readError(p) {
|
||||
const r = new Reader(p);
|
||||
r.u8();
|
||||
const code = r.u16();
|
||||
let sqlState = '';
|
||||
if (p[3] === 0x23) { r.skip(1); sqlState = r.bytes(5).toString('ascii'); }
|
||||
const message = p.subarray(r.o).toString('utf8');
|
||||
const e = new Error(`${message} (errno ${code}${sqlState ? ', sqlstate ' + sqlState : ''})`);
|
||||
e.errno = code; e.sqlState = sqlState;
|
||||
return e;
|
||||
}
|
||||
|
||||
readOk(p) {
|
||||
const r = new Reader(p);
|
||||
r.u8();
|
||||
const affectedRows = r.lenenc() || 0;
|
||||
const insertId = r.lenenc() || 0;
|
||||
return { affectedRows, insertId };
|
||||
}
|
||||
|
||||
isEof(p) { return p[0] === 0xfe && p.length < 9; }
|
||||
|
||||
// -- queries ---------------------------------------------------------------
|
||||
query(sql) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (this.state !== 'ready') return reject(new Error('connection not ready'));
|
||||
this.busy = true;
|
||||
this.state = 'query';
|
||||
this.pending = { resolve, reject, sql };
|
||||
this.rs = { stage: 'head', columns: [], rows: [], colCount: 0 };
|
||||
this.sendCommand(COM.QUERY, sql);
|
||||
});
|
||||
}
|
||||
|
||||
finish(value, error) {
|
||||
const p = this.pending;
|
||||
this.pending = null;
|
||||
this.rs = null;
|
||||
this.state = 'ready';
|
||||
this.busy = false;
|
||||
if (p) { error ? p.reject(error) : p.resolve(value); }
|
||||
if (this.onIdle) this.onIdle(this);
|
||||
}
|
||||
|
||||
onQueryPacket(p) {
|
||||
const rs = this.rs;
|
||||
|
||||
if (rs.stage === 'head') {
|
||||
if (p[0] === 0x00) return this.finish(this.readOk(p));
|
||||
if (p[0] === 0xff) return this.finish(null, this.readError(p));
|
||||
if (p[0] === 0xfb) return this.finish(null, new Error('LOCAL INFILE requested but not enabled'));
|
||||
const r = new Reader(p);
|
||||
rs.colCount = r.lenenc();
|
||||
rs.stage = 'columns';
|
||||
return;
|
||||
}
|
||||
|
||||
if (rs.stage === 'columns') {
|
||||
if (this.isEof(p)) { rs.stage = 'rows'; return; }
|
||||
const r = new Reader(p);
|
||||
r.lenencStr(); r.lenencStr(); r.lenencStr(); r.lenencStr(); // catalog, schema, table, org_table
|
||||
const name = r.lenencStr();
|
||||
r.lenencStr(); // org_name
|
||||
r.lenenc(); // length of fixed fields
|
||||
r.u16(); // charset
|
||||
r.u32(); // column length
|
||||
const type = r.u8();
|
||||
rs.columns.push({ name, type });
|
||||
return;
|
||||
}
|
||||
|
||||
// stage === 'rows'
|
||||
if (p[0] === 0xff) return this.finish(null, this.readError(p));
|
||||
if (this.isEof(p)) return this.finish(rs.rows);
|
||||
|
||||
const r = new Reader(p);
|
||||
const row = {};
|
||||
for (let i = 0; i < rs.colCount; i++) {
|
||||
const col = rs.columns[i];
|
||||
const raw = r.lenencStr();
|
||||
row[col.name] = raw === null ? null : coerce(raw, col.type);
|
||||
}
|
||||
rs.rows.push(row);
|
||||
}
|
||||
|
||||
ping() {
|
||||
if (this.state !== 'ready') return;
|
||||
this.busy = true;
|
||||
this.state = 'query';
|
||||
this.pending = {
|
||||
resolve: () => {}, reject: (e) => this.err(`ping failed: ${e.message}`), sql: 'PING',
|
||||
};
|
||||
this.rs = { stage: 'head', columns: [], rows: [], colCount: 0 };
|
||||
this.sendCommand(COM.PING, '');
|
||||
}
|
||||
}
|
||||
|
||||
// Text protocol hands us strings; give Lua sensible types back.
|
||||
function coerce(raw, type) {
|
||||
if (T_INT.has(type)) {
|
||||
const n = Number(raw);
|
||||
return Number.isSafeInteger(n) ? n : raw; // keep huge BIGINTs exact as strings
|
||||
}
|
||||
if (T_FLOAT.has(type)) {
|
||||
const n = Number(raw);
|
||||
return Number.isNaN(n) ? raw : n;
|
||||
}
|
||||
return raw; // strings, dates, JSON, blobs
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pool
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class Pool {
|
||||
constructor(opts) {
|
||||
this.opts = opts;
|
||||
this.queue = [];
|
||||
this.conns = [];
|
||||
for (let i = 0; i < opts.size; i++) {
|
||||
const c = new Connection(opts, i + 1);
|
||||
c.onIdle = () => this.pump();
|
||||
this.conns.push(c);
|
||||
}
|
||||
}
|
||||
|
||||
start() {
|
||||
this.conns.forEach((c, i) => setTimeout(() => c.connect(), i * 120));
|
||||
setInterval(() => {
|
||||
for (const c of this.conns) if (c.state === 'ready' && !c.busy) c.ping();
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
get ready() { return this.conns.some((c) => c.state === 'ready'); }
|
||||
|
||||
free() { return this.conns.find((c) => c.state === 'ready' && !c.busy && !c.pinned); }
|
||||
|
||||
pump() {
|
||||
while (this.queue.length) {
|
||||
const conn = this.free();
|
||||
if (!conn) return;
|
||||
const job = this.queue.shift();
|
||||
clearTimeout(job.timer);
|
||||
if (job.settled) continue;
|
||||
job.settled = true;
|
||||
conn.query(job.sql).then(job.resolve, job.reject);
|
||||
}
|
||||
}
|
||||
|
||||
exec(sql) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const job = { sql, resolve, reject, settled: false };
|
||||
// Each job owns its own deadline, so a query still fails loudly when the
|
||||
// database is down and no connection ever becomes free to trigger pump().
|
||||
job.timer = setTimeout(() => {
|
||||
if (job.settled) return;
|
||||
job.settled = true;
|
||||
const i = this.queue.indexOf(job);
|
||||
if (i >= 0) this.queue.splice(i, 1);
|
||||
reject(new Error('timed out waiting for a database connection'));
|
||||
}, this.opts.queueTimeout);
|
||||
this.queue.push(job);
|
||||
this.pump();
|
||||
});
|
||||
}
|
||||
|
||||
// Pins one connection for the whole transaction so no other query can
|
||||
// interleave between START TRANSACTION and COMMIT.
|
||||
async transaction(statements) {
|
||||
const held = await this.acquire();
|
||||
try {
|
||||
await held.query('START TRANSACTION');
|
||||
for (const st of statements) await held.query(st);
|
||||
await held.query('COMMIT');
|
||||
return true;
|
||||
} catch (e) {
|
||||
try { await held.query('ROLLBACK'); } catch (_) { /* connection already gone */ }
|
||||
throw e;
|
||||
} finally {
|
||||
held.release();
|
||||
}
|
||||
}
|
||||
|
||||
acquire() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const started = Date.now();
|
||||
const attempt = () => {
|
||||
const conn = this.free();
|
||||
if (conn) {
|
||||
conn.pinned = true;
|
||||
return resolve({
|
||||
query: (sql) => conn.query(sql),
|
||||
release: () => { conn.pinned = false; this.pump(); },
|
||||
});
|
||||
}
|
||||
if (Date.now() - started > this.opts.queueTimeout) {
|
||||
return reject(new Error('timed out waiting for a database connection'));
|
||||
}
|
||||
setTimeout(attempt, 25);
|
||||
};
|
||||
attempt();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FiveM bridge - exposes the pool to the Lua resources as exports.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Lua cannot put nil inside a table without creating a hole, so callers pass
|
||||
// this sentinel when they need a real SQL NULL. rp_db exposes it as DB.NULL.
|
||||
const NULL_SENTINEL = 'RP_NULL';
|
||||
|
||||
function unsentinel(v) { return v === NULL_SENTINEL ? null : v; }
|
||||
|
||||
function normalizeParams(p) {
|
||||
if (p === null || p === undefined) return [];
|
||||
if (Array.isArray(p)) return p.map(unsentinel);
|
||||
if (typeof p === 'object') {
|
||||
const keys = Object.keys(p);
|
||||
if (keys.length === 0) return []; // Lua {} arrives as an empty map
|
||||
if (keys.every((k) => /^[0-9]+$/.test(k))) { // Lua array table
|
||||
return keys.sort((a, b) => a - b).map((k) => unsentinel(p[k]));
|
||||
}
|
||||
return [p]; // a real object -> JSON column
|
||||
}
|
||||
return [unsentinel(p)];
|
||||
}
|
||||
|
||||
const pool = new Pool({
|
||||
host: GetConvar('rp_db_host', '127.0.0.1'),
|
||||
port: parseInt(GetConvar('rp_db_port', '3306'), 10),
|
||||
user: GetConvar('rp_db_user', 'rp'),
|
||||
password: GetConvar('rp_db_pass', ''),
|
||||
database: GetConvar('rp_db_name', 'rp'),
|
||||
size: parseInt(GetConvar('rp_db_pool', '4'), 10),
|
||||
queueTimeout: 20000,
|
||||
});
|
||||
|
||||
// Run a query and hand the result to a Lua callback as (result, err).
|
||||
// The SQL *template* is logged on failure, never the formatted text, so bound
|
||||
// values (password hashes and the like) stay out of the console.
|
||||
function run(sql, params, cb, shape) {
|
||||
const done = (result, err) => {
|
||||
if (err) {
|
||||
console.log(`^1[rp_db]^7 query failed: ${err}`);
|
||||
console.log(`^1[rp_db]^7 in: ${String(sql).trim().slice(0, 200)}`);
|
||||
}
|
||||
if (typeof cb === 'function') {
|
||||
try { cb(result, err || null); }
|
||||
catch (e) { console.log(`^1[rp_db]^7 callback threw: ${e.message}`); }
|
||||
}
|
||||
};
|
||||
|
||||
let text;
|
||||
try { text = format(sql, normalizeParams(params)); }
|
||||
catch (e) { return done(null, e.message); }
|
||||
|
||||
pool.exec(text).then(
|
||||
(res) => { try { done(shape(res), null); } catch (e) { done(null, e.message); } },
|
||||
(e) => done(null, e.message)
|
||||
);
|
||||
}
|
||||
|
||||
const rowsOf = (res) => (Array.isArray(res) ? res : []);
|
||||
|
||||
global.exports('query', (sql, params, cb) => run(sql, params, cb, (r) => r));
|
||||
global.exports('single', (sql, params, cb) => run(sql, params, cb, (r) => rowsOf(r)[0] || null));
|
||||
global.exports('scalar', (sql, params, cb) => run(sql, params, cb, (r) => {
|
||||
const row = rowsOf(r)[0];
|
||||
if (!row) return null;
|
||||
const k = Object.keys(row)[0];
|
||||
return k === undefined ? null : row[k];
|
||||
}));
|
||||
global.exports('insert', (sql, params, cb) => run(sql, params, cb, (r) => (r && r.insertId) || 0));
|
||||
global.exports('update', (sql, params, cb) => run(sql, params, cb, (r) => (r && r.affectedRows) || 0));
|
||||
|
||||
// statements: array of {query = ..., values = {...}} tables from Lua
|
||||
global.exports('transaction', (statements, cb) => {
|
||||
let list;
|
||||
try {
|
||||
const arr = Array.isArray(statements) ? statements : Object.values(statements || {});
|
||||
list = arr.map((s) => format(s.query, normalizeParams(s.values)));
|
||||
} catch (e) {
|
||||
console.log(`^1[rp_db]^7 transaction rejected: ${e.message}`);
|
||||
if (typeof cb === 'function') cb(false, e.message);
|
||||
return;
|
||||
}
|
||||
pool.transaction(list).then(
|
||||
() => { if (typeof cb === 'function') cb(true, null); },
|
||||
(e) => {
|
||||
console.log(`^1[rp_db]^7 transaction rolled back: ${e.message}`);
|
||||
if (typeof cb === 'function') cb(false, e.message);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
global.exports('isReady', () => pool.ready);
|
||||
global.exports('nullSentinel', () => NULL_SENTINEL);
|
||||
|
||||
pool.start();
|
||||
@@ -1,11 +0,0 @@
|
||||
fx_version 'cerulean'
|
||||
game 'gta5'
|
||||
|
||||
name 'rp_dbtest'
|
||||
description 'Self-test for the rp_db driver. Not started in production.'
|
||||
version '1.0.0'
|
||||
|
||||
server_scripts {
|
||||
'@rp_db/lib/db.lua',
|
||||
'server/test.lua',
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
-- Self-test for the hand-written MySQL driver. Run with: ensure rp_dbtest
|
||||
|
||||
local passed, failed = 0, 0
|
||||
|
||||
local function check(name, ok, detail)
|
||||
if ok then
|
||||
passed = passed + 1
|
||||
print(('^2 PASS^7 %s'):format(name))
|
||||
else
|
||||
failed = failed + 1
|
||||
print(('^1 FAIL^7 %s^1 %s^7'):format(name, detail or ''))
|
||||
end
|
||||
end
|
||||
|
||||
CreateThread(function()
|
||||
print('^5[dbtest]^7 waiting for the pool...')
|
||||
if not DB.WaitReady(30000) then
|
||||
print('^1[dbtest] database never became ready^7')
|
||||
return
|
||||
end
|
||||
|
||||
-- 1. round trip through the text protocol -------------------------------
|
||||
local two = DB.Scalar('SELECT 1 + 1')
|
||||
check('scalar arithmetic', two == 2, ('got %s (%s)'):format(tostring(two), type(two)))
|
||||
|
||||
local row = DB.Single('SELECT ? AS s, ? AS n, ? AS f', { 'hello', 42, 1.5 })
|
||||
check('typed columns', row and row.s == 'hello' and row.n == 42 and row.f == 1.5,
|
||||
row and json.encode(row) or 'nil')
|
||||
|
||||
-- 2. hostile strings must survive verbatim ------------------------------
|
||||
DB.Update("DELETE FROM accounts WHERE username LIKE 'ztest%'")
|
||||
|
||||
local nasty = [[O'Brien \ "quoted" ]] .. '\n\t' .. [[ 100% ` ; -- /* */ ?]]
|
||||
local id = DB.Insert(
|
||||
'INSERT INTO accounts (username, password_hash) VALUES (?, ?)',
|
||||
{ 'ztest_a', nasty })
|
||||
check('insert returns id', type(id) == 'number' and id > 0, tostring(id))
|
||||
|
||||
local back = DB.Scalar('SELECT password_hash FROM accounts WHERE id = ?', { id })
|
||||
check('hostile string round trip', back == nasty,
|
||||
('stored %q'):format(tostring(back)))
|
||||
|
||||
-- 3. a parameter can never become SQL -----------------------------------
|
||||
local inject = "'); DROP TABLE accounts; -- "
|
||||
DB.Insert('INSERT INTO accounts (username, password_hash) VALUES (?, ?)',
|
||||
{ 'ztest_b', inject })
|
||||
local stillThere = DB.Scalar(
|
||||
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'accounts'")
|
||||
check('injection is inert', stillThere == 1, 'accounts table missing!')
|
||||
local storedInject = DB.Scalar("SELECT password_hash FROM accounts WHERE username = 'ztest_b'")
|
||||
check('injection stored literally', storedInject == inject, tostring(storedInject))
|
||||
|
||||
-- 4. question marks inside literals must not shift binding ---------------
|
||||
local q = DB.Single("SELECT 'what? really?' AS lit, ? AS bound", { 'bound-value' })
|
||||
check('literal ? not treated as placeholder',
|
||||
q and q.lit == 'what? really?' and q.bound == 'bound-value',
|
||||
q and json.encode(q) or 'nil')
|
||||
|
||||
-- 5. NULL handling -------------------------------------------------------
|
||||
DB.Update('UPDATE accounts SET last_ip = ? WHERE id = ?', { DB.NULL, id })
|
||||
local isNull = DB.Scalar('SELECT last_ip IS NULL FROM accounts WHERE id = ?', { id })
|
||||
check('DB.NULL writes a real NULL', isNull == 1, tostring(isNull))
|
||||
local nilRow = DB.Single('SELECT last_ip FROM accounts WHERE id = ?', { id })
|
||||
check('NULL reads back as nil', nilRow ~= nil and nilRow.last_ip == nil,
|
||||
nilRow and json.encode(nilRow) or 'nil')
|
||||
|
||||
-- 6. unicode -------------------------------------------------------------
|
||||
local uni = 'Ünïcødé — 日本語 — 🚓'
|
||||
DB.Update('UPDATE accounts SET password_hash = ? WHERE id = ?', { uni, id })
|
||||
check('utf8mb4 round trip', DB.Scalar('SELECT password_hash FROM accounts WHERE id = ?', { id }) == uni)
|
||||
|
||||
-- 7. empty result sets ---------------------------------------------------
|
||||
local none = DB.Query('SELECT * FROM accounts WHERE id = ?', { -1 })
|
||||
check('empty select returns empty table', type(none) == 'table' and #none == 0)
|
||||
check('missing single returns nil', DB.Single('SELECT * FROM accounts WHERE id = ?', { -1 }) == nil)
|
||||
|
||||
-- 8. transactions --------------------------------------------------------
|
||||
local ok = DB.Transaction({
|
||||
{ query = 'UPDATE accounts SET role = ? WHERE id = ?', values = { 'admin', id } },
|
||||
{ query = 'UPDATE accounts SET banned = ? WHERE id = ?', values = { 1, id } },
|
||||
})
|
||||
local after = DB.Single('SELECT role, banned FROM accounts WHERE id = ?', { id })
|
||||
check('transaction commits', ok and after.role == 'admin' and after.banned == 1,
|
||||
json.encode(after or {}))
|
||||
|
||||
local bad, err = DB.Transaction({
|
||||
{ query = 'UPDATE accounts SET role = ? WHERE id = ?', values = { 'mod', id } },
|
||||
{ query = 'UPDATE accounts SET nonexistent_column = 1 WHERE id = ?', values = { id } },
|
||||
})
|
||||
local rolled = DB.Scalar('SELECT role FROM accounts WHERE id = ?', { id })
|
||||
check('failed transaction rolls back', bad == false and rolled == 'admin',
|
||||
('role is now %s'):format(tostring(rolled)))
|
||||
|
||||
-- 9. errors surface instead of failing silently --------------------------
|
||||
local res, qerr = DB.Query('SELECT * FROM table_that_does_not_exist')
|
||||
check('bad query returns an error', res == nil and type(qerr) == 'string' and #qerr > 0,
|
||||
tostring(qerr))
|
||||
|
||||
-- 10. wrong parameter count is caught, not guessed -----------------------
|
||||
local _, cerr = DB.Query('SELECT ?, ?', { 1 })
|
||||
check('parameter count mismatch reported', cerr ~= nil, tostring(cerr))
|
||||
|
||||
-- 11. a large payload exercises multi-packet framing in both directions --
|
||||
local big = string.rep('x', 700000)
|
||||
check('700KB payload round trip', DB.Scalar('SELECT ? AS big', { big }) == big)
|
||||
|
||||
-- 12. concurrency: many queries in flight across the pool ----------------
|
||||
local done, results = 0, {}
|
||||
for i = 1, 40 do
|
||||
DB.ScalarAsync('SELECT ?', { i }, function(v) done = done + 1; results[i] = v end)
|
||||
end
|
||||
local waited = 0
|
||||
while done < 40 and waited < 10000 do Wait(50); waited = waited + 50 end
|
||||
local allGood = done == 40
|
||||
for i = 1, 40 do if results[i] ~= i then allGood = false end end
|
||||
check('40 concurrent queries all answered correctly', allGood, ('done=%d'):format(done))
|
||||
|
||||
DB.Update("DELETE FROM accounts WHERE username LIKE 'ztest%'")
|
||||
|
||||
print(('^5[dbtest]^7 ==== %d passed, %d failed ===='):format(passed, failed))
|
||||
if failed == 0 then print('^2[dbtest] driver OK^7') else print('^1[dbtest] DRIVER HAS PROBLEMS^7') end
|
||||
end)
|
||||
@@ -1,27 +0,0 @@
|
||||
fx_version 'cerulean'
|
||||
game 'gta5'
|
||||
|
||||
name 'rp_loading'
|
||||
description 'Loading screen: procedural night-city flyover, real load progress, synthesised ambience'
|
||||
author 'Los Santos RP'
|
||||
version '1.0.0'
|
||||
|
||||
loadscreen 'html/index.html'
|
||||
|
||||
-- The screen stays up after the game has finished loading so the client can
|
||||
-- hand over to the in-game camera without a black frame in between.
|
||||
loadscreen_manual_shutdown 'yes'
|
||||
|
||||
-- Needed for the volume fader to be usable.
|
||||
loadscreen_cursor 'yes'
|
||||
|
||||
files {
|
||||
'html/index.html',
|
||||
'html/tokens.css',
|
||||
'html/app.css',
|
||||
'html/app.js',
|
||||
'html/fonts/archivo-var.woff2',
|
||||
'html/fonts/plexsans-var.woff2',
|
||||
'html/fonts/plex-400.woff2',
|
||||
'html/fonts/plex-500.woff2',
|
||||
}
|
||||
@@ -1,325 +0,0 @@
|
||||
/* ---------------------------------------------------------------------------
|
||||
Loading screen.
|
||||
Layout: the city runs full bleed; the record sits in a rail on the left,
|
||||
held there by a gradient scrim rather than a panel, so the two never feel
|
||||
like separate windows.
|
||||
--------------------------------------------------------------------------- */
|
||||
|
||||
#sky, .grain, .scrim, .vignette {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#sky { width: 100%; height: 100%; display: block; }
|
||||
|
||||
.scrim {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(8, 9, 11, 0.97) 0%,
|
||||
rgba(8, 9, 11, 0.93) 34%,
|
||||
rgba(8, 9, 11, 0.72) 58%,
|
||||
rgba(8, 9, 11, 0.15) 82%,
|
||||
rgba(8, 9, 11, 0) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.vignette {
|
||||
background: radial-gradient(120% 90% at 62% 45%, transparent 40%, rgba(0, 0, 0, 0.55) 100%);
|
||||
}
|
||||
|
||||
/* film grain, drawn once as an SVG turbulence tile */
|
||||
.grain {
|
||||
opacity: 0.16;
|
||||
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='0.85' numOctaves='3'/%3E%3C/filter%3E%3Crect width='140' height='140' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
animation: grainshift 640ms steps(2) infinite;
|
||||
}
|
||||
@keyframes grainshift {
|
||||
0% { transform: translate(0, 0); }
|
||||
50% { transform: translate(-3px, 2px); }
|
||||
100% { transform: translate(2px, -2px); }
|
||||
}
|
||||
|
||||
/* --- rail ---------------------------------------------------------------- */
|
||||
|
||||
.rail {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
width: var(--sheet);
|
||||
height: 100%;
|
||||
padding: var(--gut-y) var(--gut-x);
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto auto;
|
||||
gap: clamp(20px, 2.4vh, 34px);
|
||||
}
|
||||
|
||||
/* staged entrance; each element declares its own delay */
|
||||
.reveal {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
animation: rise 900ms var(--ease-out) forwards;
|
||||
animation-delay: var(--d, 0ms);
|
||||
}
|
||||
@keyframes rise {
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
/* --- file header --------------------------------------------------------- */
|
||||
|
||||
.file {
|
||||
border-top: 1px solid rgba(217,178,60,.42);
|
||||
padding-top: 14px;
|
||||
}
|
||||
.file .serial {
|
||||
margin-top: 7px;
|
||||
color: rgba(233,226,204,.62);
|
||||
}
|
||||
.file .serial span { color: var(--canary); }
|
||||
|
||||
/* --- wordmark ------------------------------------------------------------ */
|
||||
|
||||
.brand { align-self: end; }
|
||||
|
||||
.wordmark {
|
||||
font-size: clamp(46px, 5.2vw, 82px);
|
||||
font-weight: 800;
|
||||
line-height: 0.86;
|
||||
letter-spacing: 0.3em;
|
||||
text-indent: 0.3em; /* keeps the wide tracking optically centred */
|
||||
opacity: 0;
|
||||
animation: settle 1500ms var(--ease-out) 180ms forwards;
|
||||
}
|
||||
/* the name tightens into place, like a stamp being pressed */
|
||||
@keyframes settle {
|
||||
0% { opacity: 0; letter-spacing: 0.3em; text-indent: 0.3em; filter: blur(6px); }
|
||||
60% { opacity: 1; }
|
||||
100% { opacity: 1; letter-spacing: -0.028em; text-indent: 0; filter: blur(0); }
|
||||
}
|
||||
|
||||
.tagrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.tagline {
|
||||
font-family: 'Plex Mono', monospace;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.42em;
|
||||
color: var(--canary);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.dash {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, rgba(217,178,60,.42), transparent);
|
||||
}
|
||||
|
||||
/* --- notices ------------------------------------------------------------- */
|
||||
|
||||
.notices { align-self: center; }
|
||||
|
||||
.notice-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid rgba(233,226,204,.14);
|
||||
}
|
||||
.notice-index { color: rgba(233,226,204,.40); }
|
||||
|
||||
.notice { padding-top: 18px; min-height: 132px; }
|
||||
|
||||
.notice-title {
|
||||
font-size: 21px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.015em;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.notice-body {
|
||||
font-size: 14.5px;
|
||||
color: rgba(233,226,204,.62);
|
||||
max-width: 40ch;
|
||||
}
|
||||
|
||||
.notice.swap .notice-title,
|
||||
.notice.swap .notice-body { animation: none; }
|
||||
|
||||
.notice-title, .notice-body {
|
||||
animation: noticein 620ms var(--ease-out) both;
|
||||
}
|
||||
.notice-body { animation-delay: 70ms; }
|
||||
@keyframes noticein {
|
||||
from { opacity: 0; transform: translateY(7px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
/* --- progress ------------------------------------------------------------ */
|
||||
|
||||
.load { align-self: end; }
|
||||
|
||||
.loadhead {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 11px;
|
||||
gap: 16px;
|
||||
}
|
||||
.step {
|
||||
color: rgba(233,226,204,.62);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.pct {
|
||||
font-family: 'Plex Mono', monospace;
|
||||
font-weight: 500;
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
color: var(--stock-hi);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.pct i {
|
||||
font-style: normal;
|
||||
font-size: 13px;
|
||||
color: rgba(233,226,204,.40);
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.track {
|
||||
position: relative;
|
||||
height: 2px;
|
||||
background: rgba(233,226,204,.14);
|
||||
overflow: hidden;
|
||||
}
|
||||
.bar {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background: var(--canary);
|
||||
transition: width 420ms var(--ease);
|
||||
}
|
||||
/* a light runs ahead of the fill so the bar reads as active even when the
|
||||
game stalls on a long step */
|
||||
.bar::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 64px;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 159, 69, 0.85));
|
||||
animation: crawl 1900ms linear infinite;
|
||||
}
|
||||
@keyframes crawl {
|
||||
0% { transform: translateX(-64px); opacity: 0; }
|
||||
35% { opacity: 1; }
|
||||
100% { transform: translateX(0); opacity: 0; }
|
||||
}
|
||||
|
||||
/* --- volume fader -------------------------------------------------------- */
|
||||
|
||||
.fader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-top: 26px;
|
||||
padding-top: 18px;
|
||||
border-top: 1px solid rgba(233,226,204,.07);
|
||||
}
|
||||
|
||||
.mute {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: transparent;
|
||||
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(--canary); border-color: rgba(217,178,60,.42); }
|
||||
.mute:active { transform: translateY(1px); }
|
||||
.mute.off #wave { opacity: 0.18; }
|
||||
.mute.off { color: rgba(233,226,204,.40); }
|
||||
|
||||
input[type='range'] {
|
||||
flex: 1;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
height: 22px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
input[type='range']::-webkit-slider-runnable-track {
|
||||
height: 2px;
|
||||
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(--canary);
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
transition: height 160ms var(--ease), margin-top 160ms var(--ease);
|
||||
}
|
||||
input[type='range']:hover::-webkit-slider-thumb,
|
||||
input[type='range']:focus-visible::-webkit-slider-thumb {
|
||||
height: 21px;
|
||||
margin-top: -9.5px;
|
||||
}
|
||||
|
||||
.volval { width: 3ch; text-align: right; color: rgba(233,226,204,.40); }
|
||||
|
||||
.audiohint {
|
||||
margin-top: 10px;
|
||||
color: var(--canary);
|
||||
animation: pulse 2.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse { 50% { opacity: 0.45; } }
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px; height: 1px;
|
||||
padding: 0; margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Once the world is ready the rail steps aside and reveals the live camera.
|
||||
The page background has to go transparent too, otherwise the fade reveals
|
||||
nothing but our own backdrop colour. */
|
||||
body.handover, body.handover .rail { background: transparent !important; }
|
||||
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); }
|
||||
@@ -1,567 +0,0 @@
|
||||
/* ---------------------------------------------------------------------------
|
||||
Loading screen behaviour.
|
||||
|
||||
Three jobs:
|
||||
1. draw the city - a procedural night skyline flown over on canvas
|
||||
2. report progress - bound to the load events the game actually emits
|
||||
3. play ambience - synthesised in the browser, nothing is downloaded
|
||||
--------------------------------------------------------------------------- */
|
||||
|
||||
'use strict';
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
/* =========================================================================
|
||||
1. The city
|
||||
========================================================================= */
|
||||
|
||||
/* Small deterministic PRNG so the skyline is stable across a resize. */
|
||||
function rng(seed) {
|
||||
let s = seed >>> 0;
|
||||
return () => {
|
||||
s = (s * 1664525 + 1013904223) >>> 0;
|
||||
return s / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
const SKY = {
|
||||
canvas: $('sky'),
|
||||
ctx: null,
|
||||
layers: [],
|
||||
w: 0,
|
||||
h: 0,
|
||||
dpr: 1,
|
||||
t0: performance.now(),
|
||||
};
|
||||
|
||||
/* Depth layers, far to near. Every layer is darker than the hazy sky behind
|
||||
it, which is what makes a silhouette read at all; the far ones are lifted
|
||||
towards the sky colour to stand in for atmosphere. `base` is the ground line
|
||||
as a fraction of the viewport, so near towers rise right through the frame.
|
||||
The difference in scroll rate is what reads as flight. */
|
||||
const LAYER_SPEC = [
|
||||
{ depth: 0.00, speed: 4, minW: 28, maxW: 66, minH: 0.09, maxH: 0.21, base: 0.76, tone: '#20263a', lit: 0.16, win: 0.30, haze: 0.17 },
|
||||
{ depth: 0.25, speed: 11, minW: 36, maxW: 90, minH: 0.13, maxH: 0.30, base: 0.83, tone: '#171c2b', lit: 0.20, win: 0.42, haze: 0.14 },
|
||||
{ depth: 0.50, speed: 24, minW: 50, maxW: 122, minH: 0.19, maxH: 0.42, base: 0.91, tone: '#10141f', lit: 0.24, win: 0.58, haze: 0.10 },
|
||||
{ depth: 0.75, speed: 50, minW: 74, maxW: 178, minH: 0.28, maxH: 0.62, base: 1.02, tone: '#090c14', lit: 0.26, win: 0.74, haze: 0.06 },
|
||||
{ depth: 1.00, speed: 100, minW: 104, maxW: 262, minH: 0.44, maxH: 0.98, base: 1.20, tone: '#04060b', lit: 0.19, win: 0.90, haze: 0.00 },
|
||||
];
|
||||
|
||||
function buildLayer(spec, index, w, h) {
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = Math.ceil(w * 2);
|
||||
cv.height = h;
|
||||
const c = cv.getContext('2d');
|
||||
const rand = rng(9173 + index * 7717);
|
||||
|
||||
const base = h * spec.base;
|
||||
const towers = [];
|
||||
|
||||
let x = -60;
|
||||
while (x < cv.width + 60) {
|
||||
const bw = spec.minW + rand() * (spec.maxW - spec.minW);
|
||||
const bh = h * (spec.minH + rand() * (spec.maxH - spec.minH));
|
||||
const top = base - bh;
|
||||
|
||||
c.fillStyle = spec.tone;
|
||||
c.fillRect(Math.round(x), Math.round(top), Math.ceil(bw), Math.ceil(h - top));
|
||||
|
||||
/* a hairline of sodium bounce along the roof edge */
|
||||
if (rand() < 0.55) {
|
||||
c.fillStyle = `rgba(255,159,69,${0.05 + spec.glow * 0.16})`;
|
||||
c.fillRect(Math.round(x), Math.round(top), Math.ceil(bw), 1);
|
||||
}
|
||||
|
||||
/* windows */
|
||||
const cell = 6 + spec.depth * 8;
|
||||
const pad = Math.max(2, cell * 0.34);
|
||||
for (let wy = top + pad * 2; wy < base - pad; wy += cell) {
|
||||
for (let wx = x + pad; wx < x + bw - pad; wx += cell) {
|
||||
if (rand() > spec.lit) continue;
|
||||
const warm = rand();
|
||||
/* mostly sodium/tungsten, a few cold offices; distant windows are
|
||||
dimmed so they read as depth rather than as static */
|
||||
const col = warm < 0.72
|
||||
? `rgba(255,${150 + Math.floor(rand() * 50)},${70 + Math.floor(rand() * 40)},${(0.35 + rand() * 0.5) * spec.win})`
|
||||
: `rgba(190,205,225,${(0.18 + rand() * 0.3) * spec.win})`;
|
||||
c.fillStyle = col;
|
||||
c.fillRect(Math.round(wx), Math.round(wy), Math.max(1, cell * 0.34), Math.max(1, cell * 0.42));
|
||||
}
|
||||
}
|
||||
|
||||
/* tall towers get an aviation light */
|
||||
if (bh > h * 0.5 && rand() < 0.5) {
|
||||
towers.push({ x: x + bw / 2, y: top - 2, phase: rand() * Math.PI * 2 });
|
||||
}
|
||||
|
||||
x += bw + 2 + rand() * 16;
|
||||
}
|
||||
|
||||
return { cv, spec, towers, base };
|
||||
}
|
||||
|
||||
function resizeSky() {
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
|
||||
SKY.dpr = dpr;
|
||||
SKY.w = window.innerWidth;
|
||||
SKY.h = window.innerHeight;
|
||||
SKY.canvas.width = Math.floor(SKY.w * dpr);
|
||||
SKY.canvas.height = Math.floor(SKY.h * dpr);
|
||||
SKY.ctx = SKY.canvas.getContext('2d');
|
||||
SKY.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
SKY.layers = LAYER_SPEC.map((s, i) => buildLayer(s, i, SKY.w, SKY.h));
|
||||
}
|
||||
|
||||
function drawSky(now) {
|
||||
const { ctx, w, h } = SKY;
|
||||
if (!ctx) return;
|
||||
const t = (now - SKY.t0) / 1000;
|
||||
|
||||
/* Sky: light pollution rather than stars. It has to stay brighter than the
|
||||
buildings all the way down, otherwise there is no silhouette to see. */
|
||||
const g = ctx.createLinearGradient(0, 0, 0, h);
|
||||
g.addColorStop(0.00, '#080b14');
|
||||
g.addColorStop(0.34, '#121627');
|
||||
g.addColorStop(0.60, '#23202f');
|
||||
g.addColorStop(0.78, '#412c27');
|
||||
g.addColorStop(0.90, '#6b4527');
|
||||
g.addColorStop(1.00, '#8a5726');
|
||||
ctx.fillStyle = g;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
/* the haze dome over downtown */
|
||||
const halo = ctx.createRadialGradient(w * 0.6, h * 0.9, 0, w * 0.6, h * 0.9, h * 0.85);
|
||||
halo.addColorStop(0, 'rgba(255,159,69,0.34)');
|
||||
halo.addColorStop(0.45, 'rgba(255,140,60,0.12)');
|
||||
halo.addColorStop(1, 'rgba(255,140,60,0)');
|
||||
ctx.fillStyle = halo;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
/* a slow descent: everything drifts down and grows a touch over time */
|
||||
const descend = Math.sin(t * 0.06) * 10 + t * 0.9;
|
||||
|
||||
for (const layer of SKY.layers) {
|
||||
const { cv, spec } = layer;
|
||||
const span = cv.width / 2;
|
||||
let ox = -((t * spec.speed) % span);
|
||||
const oy = descend * (0.25 + spec.depth * 0.9);
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.drawImage(cv, ox, oy, cv.width, h);
|
||||
|
||||
/* aviation lights blink on their own phase */
|
||||
for (const tw of layer.towers) {
|
||||
const blink = Math.sin(t * 1.5 + tw.phase);
|
||||
if (blink < 0.72) continue;
|
||||
const px = tw.x + ox;
|
||||
const py = tw.y + oy - h * 0.06;
|
||||
ctx.fillStyle = `rgba(226,72,52,${(blink - 0.72) / 0.28})`;
|
||||
ctx.beginPath();
|
||||
ctx.arc(px, py, 1.6, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
if (px > span) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(px - span, py, 1.6, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
/* Aerial perspective: a warm veil laid over everything drawn so far, so
|
||||
each nearer layer sits in front of progressively more atmosphere. */
|
||||
if (spec.haze > 0) {
|
||||
ctx.fillStyle = `rgba(104,72,50,${spec.haze})`;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
}
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
2. Progress, bound to the game's own load events
|
||||
========================================================================= */
|
||||
|
||||
const Progress = {
|
||||
shown: 0,
|
||||
target: 0,
|
||||
step: 'Waiting for the game',
|
||||
|
||||
set(fraction, step) {
|
||||
if (typeof fraction === 'number' && isFinite(fraction)) {
|
||||
/* never let it walk backwards - it reads as a fault */
|
||||
this.target = Math.max(this.target, Math.min(1, Math.max(0, fraction)));
|
||||
}
|
||||
if (step) this.step = step;
|
||||
},
|
||||
|
||||
tick() {
|
||||
/* ease towards the real figure so long steps still feel alive */
|
||||
this.shown += (this.target - this.shown) * 0.08;
|
||||
const pct = Math.min(100, Math.round(this.shown * 100));
|
||||
$('bar').style.width = (this.shown * 100).toFixed(2) + '%';
|
||||
$('pct').firstChild.nodeValue = String(pct);
|
||||
if ($('step').textContent !== this.step) $('step').textContent = this.step;
|
||||
},
|
||||
};
|
||||
|
||||
/* Turns the game's internal init-function names into something a player can read. */
|
||||
const STEP_NAMES = {
|
||||
MAP: 'Loading the map',
|
||||
BEFORE_MAP_LOADED: 'Preparing the world',
|
||||
AFTER_MAP_LOADED: 'Placing the world',
|
||||
SESSION_INIT: 'Joining the session',
|
||||
INIT_BEFORE_MAP_LOADED: 'Starting up',
|
||||
INIT_AFTER_MAP_LOADED: 'Finishing up',
|
||||
INIT_SESSION: 'Joining the session',
|
||||
};
|
||||
|
||||
window.addEventListener('message', (event) => {
|
||||
const d = event.data || {};
|
||||
switch (d.eventName) {
|
||||
case 'loadProgress':
|
||||
Progress.set(d.loadFraction);
|
||||
break;
|
||||
|
||||
case 'startInitFunction':
|
||||
Progress.set(null, STEP_NAMES[d.type] || 'Starting up');
|
||||
break;
|
||||
|
||||
case 'initFunctionInvoking':
|
||||
if (typeof d.idx === 'number' && typeof d.count === 'number' && d.count > 0) {
|
||||
Progress.set(null, `${STEP_NAMES[d.type] || 'Starting up'} ${d.idx}/${d.count}`);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'startDataFileEntries':
|
||||
Progress.set(null, `Streaming ${d.count} asset packs`);
|
||||
break;
|
||||
|
||||
case 'performMapLoadFunction':
|
||||
Progress.set(null, 'Building the map');
|
||||
break;
|
||||
|
||||
case 'startWarning':
|
||||
case 'onLogLine':
|
||||
if (d.message) Progress.set(null, String(d.message).slice(0, 70));
|
||||
break;
|
||||
|
||||
/* --- our own messages, sent from rp_ui once the client is running --- */
|
||||
case 'rp:rules':
|
||||
if (Array.isArray(d.rules) && d.rules.length) {
|
||||
Notices.load(d.rules);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'rp:server':
|
||||
if (d.name) document.querySelector('.wordmark').textContent = d.name;
|
||||
if (d.serial) $('serial').textContent = d.serial;
|
||||
if (d.status) $('hostcount').textContent = d.status;
|
||||
break;
|
||||
|
||||
case 'rp:progress':
|
||||
Progress.set(d.fraction, d.step);
|
||||
break;
|
||||
|
||||
/* the world is ready: dissolve into the live camera behind us */
|
||||
case 'rp:handover':
|
||||
Progress.set(1, 'Ready');
|
||||
document.body.classList.add('handover');
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
/* =========================================================================
|
||||
3. Posted notices
|
||||
========================================================================= */
|
||||
|
||||
const Notices = {
|
||||
items: [
|
||||
{ title: 'Stay in character', body: 'Your character does not know what you know. Breaking character in the world is the fastest way to lose the story.' },
|
||||
{ title: 'Value your life', body: 'Act like the consequences are permanent. A gun in your face changes what you are willing to do.' },
|
||||
{ title: 'No random deathmatch', body: 'Violence needs a reason the other person can understand. Escalate, do not detonate.' },
|
||||
{ title: 'Do not power game', body: 'Give people a fair chance to react. Winning is not the point of a scene.' },
|
||||
{ title: 'Leave the scene alive', body: 'If you die, your character forgets the thirty minutes before it. No revenge from the grave.' },
|
||||
],
|
||||
i: 0,
|
||||
timer: null,
|
||||
|
||||
load(items) {
|
||||
this.items = items;
|
||||
this.i = 0;
|
||||
this.render();
|
||||
this.schedule();
|
||||
},
|
||||
|
||||
render() {
|
||||
const item = this.items[this.i % this.items.length];
|
||||
const title = $('noticeTitle');
|
||||
const body = $('noticeBody');
|
||||
|
||||
/* restart the entry animation by reflowing the nodes */
|
||||
title.style.animation = 'none';
|
||||
body.style.animation = 'none';
|
||||
void title.offsetWidth;
|
||||
title.style.animation = '';
|
||||
body.style.animation = '';
|
||||
|
||||
title.textContent = item.title;
|
||||
body.textContent = item.body;
|
||||
$('noticeIndex').textContent = `${(this.i % this.items.length) + 1} / ${this.items.length}`;
|
||||
},
|
||||
|
||||
schedule() {
|
||||
clearInterval(this.timer);
|
||||
this.timer = setInterval(() => {
|
||||
this.i += 1;
|
||||
this.render();
|
||||
}, 7600);
|
||||
},
|
||||
};
|
||||
|
||||
/* =========================================================================
|
||||
4. Ambience - synthesised, never downloaded
|
||||
========================================================================= */
|
||||
|
||||
class Ambience {
|
||||
constructor() {
|
||||
this.ctx = null;
|
||||
this.master = null;
|
||||
this.volume = 0.45;
|
||||
this.muted = false;
|
||||
this.started = false;
|
||||
}
|
||||
|
||||
/* A short noise burst with exponential decay makes a serviceable hall. */
|
||||
makeImpulse(seconds, decay) {
|
||||
const rate = this.ctx.sampleRate;
|
||||
const len = Math.floor(rate * seconds);
|
||||
const buf = this.ctx.createBuffer(2, len, rate);
|
||||
for (let ch = 0; ch < 2; ch++) {
|
||||
const data = buf.getChannelData(ch);
|
||||
for (let i = 0; i < len; i++) {
|
||||
data[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / len, decay);
|
||||
}
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
noiseBuffer(seconds) {
|
||||
const rate = this.ctx.sampleRate;
|
||||
const buf = this.ctx.createBuffer(1, rate * seconds, rate);
|
||||
const d = buf.getChannelData(0);
|
||||
let last = 0;
|
||||
for (let i = 0; i < d.length; i++) {
|
||||
/* brown-ish noise: closer to distant traffic than white hiss */
|
||||
const white = Math.random() * 2 - 1;
|
||||
last = (last + 0.02 * white) / 1.02;
|
||||
d[i] = last * 3.2;
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.started) return;
|
||||
const Ctor = window.AudioContext || window.webkitAudioContext;
|
||||
if (!Ctor) return;
|
||||
this.ctx = new Ctor();
|
||||
this.started = true;
|
||||
|
||||
const ctx = this.ctx;
|
||||
this.master = ctx.createGain();
|
||||
this.master.gain.value = 0;
|
||||
this.master.connect(ctx.destination);
|
||||
|
||||
const verb = ctx.createConvolver();
|
||||
verb.buffer = this.makeImpulse(4.2, 2.6);
|
||||
const verbGain = ctx.createGain();
|
||||
verbGain.gain.value = 0.55;
|
||||
verb.connect(verbGain).connect(this.master);
|
||||
|
||||
const dry = ctx.createGain();
|
||||
dry.gain.value = 0.75;
|
||||
dry.connect(this.master);
|
||||
|
||||
const bus = ctx.createGain();
|
||||
bus.connect(dry);
|
||||
bus.connect(verb);
|
||||
|
||||
/* --- the pad: a minor triad that never quite resolves --------------- */
|
||||
const lp = ctx.createBiquadFilter();
|
||||
lp.type = 'lowpass';
|
||||
lp.frequency.value = 420;
|
||||
lp.Q.value = 0.7;
|
||||
lp.connect(bus);
|
||||
|
||||
const lfo = ctx.createOscillator();
|
||||
lfo.frequency.value = 0.031;
|
||||
const lfoAmt = ctx.createGain();
|
||||
lfoAmt.gain.value = 210;
|
||||
lfo.connect(lfoAmt).connect(lp.frequency);
|
||||
lfo.start();
|
||||
|
||||
[55, 82.41, 110, 130.81, 164.81].forEach((freq, i) => {
|
||||
const osc = ctx.createOscillator();
|
||||
osc.type = i % 2 ? 'sine' : 'triangle';
|
||||
osc.frequency.value = freq;
|
||||
osc.detune.value = (i - 2) * 4;
|
||||
|
||||
const g = ctx.createGain();
|
||||
g.gain.value = 0.16 / (1 + i * 0.35);
|
||||
|
||||
/* each voice breathes on its own slow cycle */
|
||||
const breath = ctx.createOscillator();
|
||||
breath.frequency.value = 0.017 + i * 0.009;
|
||||
const breathAmt = ctx.createGain();
|
||||
breathAmt.gain.value = g.gain.value * 0.6;
|
||||
breath.connect(breathAmt).connect(g.gain);
|
||||
breath.start();
|
||||
|
||||
osc.connect(g).connect(lp);
|
||||
osc.start();
|
||||
});
|
||||
|
||||
/* --- distant traffic ------------------------------------------------ */
|
||||
const noise = ctx.createBufferSource();
|
||||
noise.buffer = this.noiseBuffer(8);
|
||||
noise.loop = true;
|
||||
const nf = ctx.createBiquadFilter();
|
||||
nf.type = 'bandpass';
|
||||
nf.frequency.value = 240;
|
||||
nf.Q.value = 0.55;
|
||||
const ng = ctx.createGain();
|
||||
ng.gain.value = 0.09;
|
||||
noise.connect(nf).connect(ng).connect(bus);
|
||||
noise.start();
|
||||
|
||||
const wind = ctx.createOscillator();
|
||||
wind.frequency.value = 0.043;
|
||||
const windAmt = ctx.createGain();
|
||||
windAmt.gain.value = 130;
|
||||
wind.connect(windAmt).connect(nf.frequency);
|
||||
wind.start();
|
||||
|
||||
/* --- a siren, far away, every now and then -------------------------- */
|
||||
const siren = () => {
|
||||
if (!this.ctx || this.ctx.state === 'closed') return;
|
||||
const t = ctx.currentTime;
|
||||
const o = ctx.createOscillator();
|
||||
o.type = 'triangle';
|
||||
const g = ctx.createGain();
|
||||
const pan = ctx.createStereoPanner ? ctx.createStereoPanner() : null;
|
||||
g.gain.value = 0;
|
||||
o.frequency.setValueAtTime(620, t);
|
||||
o.frequency.linearRampToValueAtTime(880, t + 0.7);
|
||||
o.frequency.linearRampToValueAtTime(620, t + 1.4);
|
||||
g.gain.linearRampToValueAtTime(0.014, t + 0.6);
|
||||
g.gain.linearRampToValueAtTime(0.0, t + 2.6);
|
||||
if (pan) {
|
||||
pan.pan.value = Math.random() * 1.6 - 0.8;
|
||||
o.connect(g).connect(pan).connect(verb);
|
||||
} else {
|
||||
o.connect(g).connect(verb);
|
||||
}
|
||||
o.start(t);
|
||||
o.stop(t + 2.8);
|
||||
setTimeout(siren, 24000 + Math.random() * 40000);
|
||||
};
|
||||
setTimeout(siren, 12000 + Math.random() * 12000);
|
||||
|
||||
this.applyVolume(1.8);
|
||||
}
|
||||
|
||||
/* perceptual curve: a linear fader sounds wrong */
|
||||
applyVolume(rampSeconds) {
|
||||
if (!this.master) return;
|
||||
const target = this.muted ? 0 : Math.pow(this.volume, 2.2) * 0.9;
|
||||
const now = this.ctx.currentTime;
|
||||
this.master.gain.cancelScheduledValues(now);
|
||||
this.master.gain.setValueAtTime(this.master.gain.value, now);
|
||||
this.master.gain.linearRampToValueAtTime(target, now + (rampSeconds || 0.12));
|
||||
}
|
||||
|
||||
setVolume(v) {
|
||||
this.volume = Math.min(1, Math.max(0, v));
|
||||
if (this.volume > 0) this.muted = false;
|
||||
this.applyVolume();
|
||||
}
|
||||
|
||||
toggleMute() {
|
||||
this.muted = !this.muted;
|
||||
this.applyVolume();
|
||||
return this.muted;
|
||||
}
|
||||
}
|
||||
|
||||
const ambience = new Ambience();
|
||||
|
||||
function wireAudio() {
|
||||
const vol = $('vol');
|
||||
const volval = $('volval');
|
||||
const mute = $('mute');
|
||||
const hint = $('audiohint');
|
||||
|
||||
const saved = parseInt(window.localStorage.getItem('rp.volume') || '45', 10);
|
||||
vol.value = String(isFinite(saved) ? saved : 45);
|
||||
volval.textContent = vol.value;
|
||||
ambience.volume = Number(vol.value) / 100;
|
||||
|
||||
const tryStart = () => {
|
||||
ambience.start();
|
||||
if (ambience.ctx && ambience.ctx.state === 'suspended') {
|
||||
hint.hidden = false;
|
||||
return false;
|
||||
}
|
||||
hint.hidden = true;
|
||||
return true;
|
||||
};
|
||||
|
||||
/* Autoplay may be blocked; the first input of any kind releases it. */
|
||||
if (!tryStart()) {
|
||||
const release = () => {
|
||||
if (ambience.ctx) ambience.ctx.resume();
|
||||
hint.hidden = true;
|
||||
window.removeEventListener('pointerdown', release);
|
||||
window.removeEventListener('keydown', release);
|
||||
};
|
||||
window.addEventListener('pointerdown', release);
|
||||
window.addEventListener('keydown', release);
|
||||
}
|
||||
|
||||
vol.addEventListener('input', () => {
|
||||
volval.textContent = vol.value;
|
||||
ambience.setVolume(Number(vol.value) / 100);
|
||||
mute.classList.toggle('off', Number(vol.value) === 0);
|
||||
window.localStorage.setItem('rp.volume', vol.value);
|
||||
});
|
||||
|
||||
mute.addEventListener('click', () => {
|
||||
const muted = ambience.toggleMute();
|
||||
mute.classList.toggle('off', muted);
|
||||
});
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
boot
|
||||
========================================================================= */
|
||||
|
||||
function serial() {
|
||||
const n = Math.floor(Math.random() * 9000 + 1000);
|
||||
const m = Math.floor(Math.random() * 900 + 100);
|
||||
return `${new Date().getFullYear()}-${n}${m}`;
|
||||
}
|
||||
|
||||
function frame(now) {
|
||||
drawSky(now);
|
||||
Progress.tick();
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
window.addEventListener('resize', resizeSky);
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
$('serial').textContent = serial();
|
||||
resizeSky();
|
||||
Notices.render();
|
||||
Notices.schedule();
|
||||
wireAudio();
|
||||
requestAnimationFrame(frame);
|
||||
});
|
||||
@@ -1,72 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Los Santos Roleplay</title>
|
||||
<link rel="stylesheet" href="tokens.css">
|
||||
<link rel="stylesheet" href="app.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- procedural night-city flyover; no external assets -->
|
||||
<canvas id="sky" aria-hidden="true"></canvas>
|
||||
<div class="grain" aria-hidden="true"></div>
|
||||
<div class="scrim" aria-hidden="true"></div>
|
||||
<div class="vignette" aria-hidden="true"></div>
|
||||
|
||||
<main class="rail">
|
||||
|
||||
<header class="file reveal" style="--d:0ms">
|
||||
<div class="eyebrow">City of Los Santos · Office of Records</div>
|
||||
<div class="serial data">
|
||||
FILE <span id="serial">0000-0000</span> / RESIDENCY APPLICATION
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="brand">
|
||||
<h1 class="wordmark" id="wordmark">LOS SANTOS</h1>
|
||||
<div class="tagrow reveal" style="--d:520ms">
|
||||
<span class="tagline">ROLEPLAY</span>
|
||||
<span class="dash" aria-hidden="true"></span>
|
||||
<span class="data" id="hostcount">Connecting</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="notices reveal" style="--d:760ms" aria-live="polite">
|
||||
<div class="notice-head">
|
||||
<span class="eyebrow">Posted notice</span>
|
||||
<span class="notice-index data" id="noticeIndex">1 / 5</span>
|
||||
</div>
|
||||
<div class="notice" id="notice">
|
||||
<h2 class="notice-title" id="noticeTitle">Stay in character</h2>
|
||||
<p class="notice-body" id="noticeBody">Loading the city.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="load reveal" style="--d:940ms">
|
||||
<div class="loadhead">
|
||||
<span class="step data" id="step">Waiting for the game</span>
|
||||
<span class="pct" id="pct">0<i>%</i></span>
|
||||
</div>
|
||||
<div class="track"><i class="bar" id="bar"></i></div>
|
||||
|
||||
<div class="fader">
|
||||
<button class="mute" id="mute" type="button" aria-label="Mute ambience">
|
||||
<svg viewBox="0 0 16 16" width="15" height="15" aria-hidden="true">
|
||||
<path id="spk" d="M3 6h2.5L9 3v10L5.5 10H3z" fill="currentColor"/>
|
||||
<path id="wave" d="M11 5.6a3.4 3.4 0 0 1 0 4.8M12.9 3.7a6 6 0 0 1 0 8.6"
|
||||
fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<label class="sr-only" for="vol">Ambience volume</label>
|
||||
<input id="vol" type="range" min="0" max="100" value="45" step="1">
|
||||
<span class="volval data" id="volval">45</span>
|
||||
</div>
|
||||
<p class="audiohint data" id="audiohint" hidden>Click anywhere to start the ambience</p>
|
||||
</footer>
|
||||
|
||||
</main>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,182 +0,0 @@
|
||||
/* ---------------------------------------------------------------------------
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
fx_version 'cerulean'
|
||||
game 'gta5'
|
||||
|
||||
name 'rp_selftest'
|
||||
description 'Exercises the account/character data path and proves it survives a restart'
|
||||
version '1.0.0'
|
||||
|
||||
shared_scripts {
|
||||
'@rp_core/shared/config.lua',
|
||||
'@rp_core/shared/util.lua',
|
||||
'@rp_core/shared/appearance.lua',
|
||||
}
|
||||
|
||||
server_scripts {
|
||||
'@rp_db/lib/db.lua',
|
||||
'server/test.lua',
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- End-to-end check of the account and character data path.
|
||||
--
|
||||
-- The first run creates a marker account. Every later run finds it already
|
||||
-- there and verifies it came back byte-for-byte, which is what "persists
|
||||
-- between restarts" actually means. Run `selftest_reset` to clear it.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local MARKER = 'zz_selftest'
|
||||
local PASSWORD = 'correct horse 7'
|
||||
|
||||
local passed, failed = 0, 0
|
||||
|
||||
local function check(name, ok, detail)
|
||||
if ok then
|
||||
passed = passed + 1
|
||||
print(('^2 PASS^7 %s'):format(name))
|
||||
else
|
||||
failed = failed + 1
|
||||
print(('^1 FAIL^7 %s ^1%s^7'):format(name, detail or ''))
|
||||
end
|
||||
end
|
||||
|
||||
local function hash(pw)
|
||||
local p = promise.new()
|
||||
exports.rp_core:hashPassword(pw, function(h, err) p:resolve({ h = h, err = err }) end)
|
||||
local r = Citizen.Await(p)
|
||||
return r.h, r.err
|
||||
end
|
||||
|
||||
local function verify(pw, stored)
|
||||
local p = promise.new()
|
||||
exports.rp_core:verifyPassword(pw, stored, function(ok, err) p:resolve({ ok = ok, err = err }) end)
|
||||
return Citizen.Await(p).ok
|
||||
end
|
||||
|
||||
CreateThread(function()
|
||||
if not DB.WaitReady(30000) then
|
||||
print('^1[selftest] database never became ready^7')
|
||||
return
|
||||
end
|
||||
|
||||
print('^5[selftest]^7 ---- account + character data path ----')
|
||||
|
||||
-- 1. password hashing ----------------------------------------------------
|
||||
local h, herr = hash(PASSWORD)
|
||||
check('scrypt hash produced', type(h) == 'string' and h:sub(1, 7) == 'scrypt$', tostring(herr))
|
||||
check('correct password verifies', verify(PASSWORD, h))
|
||||
check('wrong password rejected', not verify(PASSWORD .. 'x', h))
|
||||
check('empty password rejected', not verify('', h))
|
||||
|
||||
local h2 = hash(PASSWORD)
|
||||
check('same password hashes differently (salted)', h ~= h2)
|
||||
check('second hash also verifies', verify(PASSWORD, h2))
|
||||
check('corrupt hash rejected safely', not verify(PASSWORD, 'not-a-hash'))
|
||||
|
||||
-- 2. the account row ------------------------------------------------------
|
||||
local existing = DB.Single('SELECT id, password_hash, created_at FROM accounts WHERE username = ?', { MARKER })
|
||||
local firstRun = existing == nil
|
||||
local accountId
|
||||
|
||||
if firstRun then
|
||||
accountId = DB.Insert(
|
||||
'INSERT INTO accounts (username, password_hash, license) VALUES (?, ?, ?)',
|
||||
{ MARKER, h, 'selftest-license' })
|
||||
check('account created', type(accountId) == 'number' and accountId > 0)
|
||||
print('^3[selftest]^7 marker account created - restart the server to prove persistence')
|
||||
else
|
||||
accountId = existing.id
|
||||
check('account survived restart', true)
|
||||
check('stored hash still verifies after restart', verify(PASSWORD, existing.password_hash))
|
||||
print(('^5[selftest]^7 marker account has existed since %s'):format(tostring(existing.created_at)))
|
||||
end
|
||||
|
||||
-- 3. the character row, including the appearance JSON ---------------------
|
||||
local appearance = Appearance.Sanitise({
|
||||
model = 'f',
|
||||
parents = { father = 12, mother = 33, shapeMix = 0.62, skinMix = 0.25 },
|
||||
hair = { style = 14, colour = 9, highlight = 2 },
|
||||
features = { ['0'] = 0.4, ['13'] = -0.75 },
|
||||
overlays = { eyebrows = { index = 5, opacity = 0.8, colour = 3 } },
|
||||
components = { jacket = { drawable = 42, texture = 3 } },
|
||||
})
|
||||
|
||||
local charId = DB.Scalar('SELECT id FROM characters WHERE account_id = ? AND deleted = 0 LIMIT 1', { accountId })
|
||||
|
||||
if not charId then
|
||||
charId = DB.Insert([[
|
||||
INSERT INTO characters (account_id, slot, first_name, last_name, dob, gender, backstory, appearance)
|
||||
VALUES (?, 0, ?, ?, ?, 'f', ?, ?)
|
||||
]], { accountId, 'Zztest', 'Persistence', '1991-04-18',
|
||||
'Created by the self test.', json.encode(appearance) })
|
||||
check('character created', type(charId) == 'number' and charId > 0)
|
||||
else
|
||||
check('character survived restart', true)
|
||||
end
|
||||
|
||||
local row = DB.Single('SELECT * FROM characters WHERE id = ?', { charId })
|
||||
check('character reads back', row ~= nil)
|
||||
|
||||
if row then
|
||||
local back = json.decode(row.appearance)
|
||||
check('appearance JSON round trip', json.encode(Appearance.Sanitise(back)) == json.encode(appearance),
|
||||
'appearance differs after a round trip')
|
||||
check('float precision kept', math.abs(back.parents.shapeMix - 0.62) < 0.0001,
|
||||
tostring(back.parents.shapeMix))
|
||||
check('negative feature kept', math.abs(back.features['13'] + 0.75) < 0.0001,
|
||||
tostring(back.features['13']))
|
||||
check('starting balances applied', tonumber(row.cash) == Config.Money.startingCash
|
||||
and tonumber(row.bank) == Config.Money.startingBank,
|
||||
('cash=%s bank=%s'):format(tostring(row.cash), tostring(row.bank)))
|
||||
end
|
||||
|
||||
-- 4. the constraints the session code relies on ---------------------------
|
||||
local dupe, dupeErr = DB.Insert(
|
||||
'INSERT INTO accounts (username, password_hash) VALUES (?, ?)', { MARKER, h })
|
||||
check('duplicate username refused by the database', dupe == nil, tostring(dupe))
|
||||
|
||||
local dupeName = DB.Insert([[
|
||||
INSERT INTO characters (account_id, slot, first_name, last_name, dob, gender)
|
||||
VALUES (?, 1, 'Zztest', 'Persistence', '1991-04-18', 'f')
|
||||
]], { accountId })
|
||||
check('duplicate character name refused by the database', dupeName == nil)
|
||||
|
||||
local orphan = DB.Insert([[
|
||||
INSERT INTO characters (account_id, slot, first_name, last_name, dob, gender)
|
||||
VALUES (999999, 0, 'Zzorphan', 'Nobody', '1991-04-18', 'm')
|
||||
]], {})
|
||||
check('character cannot reference a missing account', orphan == nil)
|
||||
|
||||
print(('^5[selftest]^7 ==== %d passed, %d failed ===='):format(passed, failed))
|
||||
if failed == 0 then
|
||||
print(firstRun and '^2[selftest] data path OK (first run)^7' or '^2[selftest] data path OK and persistent^7')
|
||||
else
|
||||
print('^1[selftest] PROBLEMS FOUND^7')
|
||||
end
|
||||
end)
|
||||
|
||||
RegisterCommand('selftest_reset', function()
|
||||
CreateThread(function()
|
||||
DB.Update('DELETE FROM accounts WHERE username = ?', { MARKER }) -- characters cascade
|
||||
print('^3[selftest]^7 marker account removed')
|
||||
end)
|
||||
end, true)
|
||||
@@ -1,19 +0,0 @@
|
||||
fx_version 'cerulean'
|
||||
game 'gta5'
|
||||
|
||||
name 'rp_session'
|
||||
description 'Connection gate, accounts, characters and spawn'
|
||||
author 'Los Santos RP'
|
||||
version '1.0.0'
|
||||
|
||||
shared_scripts {
|
||||
'@rp_core/shared/config.lua',
|
||||
'@rp_core/shared/util.lua',
|
||||
'@rp_core/shared/appearance.lua',
|
||||
}
|
||||
|
||||
server_scripts {
|
||||
'@rp_db/lib/db.lua',
|
||||
'@rp_core/lib/callbacks_server.lua',
|
||||
'server/main.lua',
|
||||
}
|
||||
@@ -1,438 +0,0 @@
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- rp_session - everything between "player opened the server" and "player is
|
||||
-- standing in the world": ban gate, accounts, characters, spawn.
|
||||
--
|
||||
-- The client drives the UI, but every decision is made here. The client may
|
||||
-- only ever ask; it can never assert that it is logged in, that a character
|
||||
-- belongs to it, or where it spawned.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local sessions = {} -- [src] = { license, ip, accountId, username, role, stage }
|
||||
|
||||
-- This build of FXServer ships no `hardcap` resource (it only advertises the
|
||||
-- name in /info.json), and the bundled `chat` and `monitor` resources are not
|
||||
-- started, so capacity has to be enforced here - see the deferral below.
|
||||
-- Kept as a guard in case a future artefact does ship one.
|
||||
CreateThread(function()
|
||||
if GetResourceState('hardcap') == 'started' then
|
||||
StopResource('hardcap')
|
||||
print('^5[rp_session]^7 stopped bundled hardcap; capacity is enforced by this resource')
|
||||
end
|
||||
end)
|
||||
|
||||
local STAGE_AUTH = 'auth'
|
||||
local STAGE_CHARS = 'characters'
|
||||
local STAGE_SPAWN = 'spawn'
|
||||
local STAGE_LIVE = 'live'
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- helpers
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local function licenseOf(src)
|
||||
for i = 0, GetNumPlayerIdentifiers(src) - 1 do
|
||||
local id = GetPlayerIdentifier(src, i)
|
||||
if id and id:sub(1, 8) == 'license:' then return id:sub(9) end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function hashPassword(pw)
|
||||
local p = promise.new()
|
||||
exports.rp_core:hashPassword(pw, function(hash, err) p:resolve({ hash = hash, err = err }) end)
|
||||
local r = Citizen.Await(p)
|
||||
return r.hash, r.err
|
||||
end
|
||||
|
||||
local function verifyPassword(pw, stored)
|
||||
local p = promise.new()
|
||||
exports.rp_core:verifyPassword(pw, stored, function(ok, err) p:resolve({ ok = ok, err = err }) end)
|
||||
local r = Citizen.Await(p)
|
||||
if r.err then print(('^1[rp_session]^7 password verify error: %s'):format(r.err)) end
|
||||
return r.ok == true
|
||||
end
|
||||
|
||||
local function recordAttempt(identity, ip, success)
|
||||
DB.InsertAsync('INSERT INTO auth_attempts (identity, ip, success) VALUES (?, ?, ?)',
|
||||
{ identity, ip or DB.NULL, success and 1 or 0 })
|
||||
end
|
||||
|
||||
--- True when this identity has burned through its allowance recently.
|
||||
local function throttled(identity)
|
||||
local n = DB.Scalar(
|
||||
'SELECT COUNT(*) FROM auth_attempts WHERE identity = ? AND success = 0 AND at > (NOW() - INTERVAL ? SECOND)',
|
||||
{ identity, Config.Session.attemptWindowS })
|
||||
return (tonumber(n) or 0) >= Config.Session.maxAttempts
|
||||
end
|
||||
|
||||
local function characterRows(accountId)
|
||||
return DB.Query([[
|
||||
SELECT id, slot, first_name, last_name, dob, gender, backstory, appearance,
|
||||
cash, bank, job, job_grade, playtime, last_played_at
|
||||
FROM characters
|
||||
WHERE account_id = ? AND deleted = 0
|
||||
ORDER BY slot ASC, id ASC
|
||||
]], { accountId }) or {}
|
||||
end
|
||||
|
||||
--- Trim a DB row down to what the character-select screen is allowed to see.
|
||||
local function characterCard(row)
|
||||
return {
|
||||
id = row.id,
|
||||
firstName = row.first_name,
|
||||
lastName = row.last_name,
|
||||
dob = tostring(row.dob):sub(1, 10),
|
||||
gender = row.gender,
|
||||
backstory = row.backstory,
|
||||
cash = math.floor(tonumber(row.cash) or 0),
|
||||
bank = math.floor(tonumber(row.bank) or 0),
|
||||
job = row.job,
|
||||
playtime = math.floor(tonumber(row.playtime) or 0),
|
||||
lastPlayed= row.last_played_at and tostring(row.last_played_at) or nil,
|
||||
appearance= row.appearance and json.decode(row.appearance) or nil,
|
||||
}
|
||||
end
|
||||
|
||||
local function sessionOf(src, requiredStage)
|
||||
local s = sessions[src]
|
||||
if not s then return nil, 'no session' end
|
||||
if requiredStage and s.stage ~= requiredStage then return nil, 'wrong stage' end
|
||||
return s
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- connection gate
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
AddEventHandler('playerConnecting', function(_, _, deferrals)
|
||||
local src = source
|
||||
deferrals.defer()
|
||||
Wait(0)
|
||||
|
||||
local license = licenseOf(src)
|
||||
if not license then
|
||||
return deferrals.done('Could not read your FiveM licence identifier. Restart FiveM and try again.')
|
||||
end
|
||||
|
||||
-- Capacity. This replaces the stock `hardcap` resource, which is stopped in
|
||||
-- server.cfg so that everything running here is our own code.
|
||||
local maxClients = tonumber(GetConvar('sv_maxclients', '48')) or 48
|
||||
local online = #GetPlayers() -- the joining player is not counted yet
|
||||
if online >= maxClients then
|
||||
return deferrals.done(
|
||||
('The server is full (%d of %d). Try again in a few minutes.'):format(online, maxClients))
|
||||
end
|
||||
|
||||
deferrals.update('Checking your record...')
|
||||
|
||||
if not DB.WaitReady(15000) then
|
||||
return deferrals.done('The server database is not available right now. Try again in a minute.')
|
||||
end
|
||||
|
||||
local ban = DB.Single([[
|
||||
SELECT id, ban_reason, ban_until
|
||||
FROM accounts
|
||||
WHERE license = ? AND banned = 1
|
||||
AND (ban_until IS NULL OR ban_until > NOW())
|
||||
LIMIT 1
|
||||
]], { license })
|
||||
|
||||
if ban then
|
||||
local until_ = ban.ban_until and (' until ' .. tostring(ban.ban_until) .. ' UTC') or ' permanently'
|
||||
return deferrals.done(('You are banned%s.\n\nReason: %s')
|
||||
:format(until_, ban.ban_reason or 'no reason recorded'))
|
||||
end
|
||||
|
||||
-- clear bans that have run out so the account is usable again
|
||||
DB.UpdateAsync([[
|
||||
UPDATE accounts SET banned = 0, ban_reason = NULL, ban_until = NULL
|
||||
WHERE license = ? AND banned = 1 AND ban_until IS NOT NULL AND ban_until <= NOW()
|
||||
]], { license })
|
||||
|
||||
sessions[src] = { license = license, ip = GetPlayerEndpoint(src), stage = STAGE_AUTH }
|
||||
deferrals.done()
|
||||
end)
|
||||
|
||||
AddEventHandler('playerDropped', function()
|
||||
sessions[source] = nil
|
||||
end)
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- callbacks: authentication
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
Core.RegisterCallback('session:state', function(src)
|
||||
local s = sessions[src]
|
||||
if not s then return nil, 'no session' end
|
||||
return { stage = s.stage, serverName = Config.ServerName, maxChars = Config.MaxChars }
|
||||
end)
|
||||
|
||||
Core.RegisterCallback('auth:register', function(src, data)
|
||||
local s, e = sessionOf(src, STAGE_AUTH)
|
||||
if not s then return nil, e end
|
||||
if type(data) ~= 'table' then return nil, 'malformed request' end
|
||||
|
||||
local username = Util.trim(data.username or '')
|
||||
local password = data.password or ''
|
||||
|
||||
local ok, why = Rules.checkUsername(username)
|
||||
if not ok then return nil, why end
|
||||
ok, why = Rules.checkPassword(password)
|
||||
if not ok then return nil, why end
|
||||
|
||||
if throttled(s.license) then
|
||||
return nil, 'Too many attempts. Wait a few minutes and try again.'
|
||||
end
|
||||
|
||||
local taken = DB.Scalar('SELECT id FROM accounts WHERE username = ? LIMIT 1', { username })
|
||||
if taken then
|
||||
recordAttempt(s.license, s.ip, false)
|
||||
return nil, 'That username is already taken'
|
||||
end
|
||||
|
||||
local hash, herr = hashPassword(password)
|
||||
if not hash then
|
||||
print(('^1[rp_session]^7 hashing failed: %s'):format(tostring(herr)))
|
||||
return nil, 'Could not create the account, try again'
|
||||
end
|
||||
|
||||
local id, ierr = DB.Insert(
|
||||
'INSERT INTO accounts (username, password_hash, license, last_login_at, last_ip) VALUES (?, ?, ?, NOW(), ?)',
|
||||
{ username, hash, s.license, s.ip or DB.NULL })
|
||||
|
||||
if not id then
|
||||
-- unique key race: someone registered the same name a moment ago
|
||||
print(('^1[rp_session]^7 register insert failed: %s'):format(tostring(ierr)))
|
||||
return nil, 'That username is already taken'
|
||||
end
|
||||
|
||||
recordAttempt(s.license, s.ip, true)
|
||||
|
||||
s.accountId = id
|
||||
s.username = username
|
||||
s.role = 'player'
|
||||
s.stage = STAGE_CHARS
|
||||
|
||||
exports.rp_core:attachAccount(src, { id = id, username = username, role = 'player', license = s.license })
|
||||
print(('^2[rp_session]^7 new account %q (id %d) from %s'):format(username, id, GetPlayerName(src)))
|
||||
|
||||
return { username = username, characters = {}, maxChars = Config.MaxChars }
|
||||
end)
|
||||
|
||||
Core.RegisterCallback('auth:login', function(src, data)
|
||||
local s, e = sessionOf(src, STAGE_AUTH)
|
||||
if not s then return nil, e end
|
||||
if type(data) ~= 'table' then return nil, 'malformed request' end
|
||||
|
||||
local username = Util.trim(data.username or '')
|
||||
local password = data.password or ''
|
||||
if username == '' or password == '' then return nil, 'Enter your username and password' end
|
||||
|
||||
if throttled(s.license) then
|
||||
return nil, 'Too many failed attempts. Wait a few minutes and try again.'
|
||||
end
|
||||
|
||||
local acc = DB.Single(
|
||||
'SELECT id, username, password_hash, role, banned, ban_reason, ban_until FROM accounts WHERE username = ? LIMIT 1',
|
||||
{ username })
|
||||
|
||||
-- Always run a verification so a missing username and a wrong password take
|
||||
-- the same amount of time and cannot be told apart from outside.
|
||||
local stored = acc and acc.password_hash
|
||||
or 'scrypt$16384$8$1$AAAAAAAAAAAAAAAAAAAAAA==$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'
|
||||
local good = verifyPassword(password, stored)
|
||||
|
||||
if not acc or not good then
|
||||
recordAttempt(s.license, s.ip, false)
|
||||
return nil, 'Incorrect username or password'
|
||||
end
|
||||
|
||||
if acc.banned == 1 then
|
||||
-- re-checked against NOW() so an expired ban does not keep locking them out
|
||||
local active = DB.Scalar(
|
||||
'SELECT 1 FROM accounts WHERE id = ? AND banned = 1 AND (ban_until IS NULL OR ban_until > NOW())',
|
||||
{ acc.id })
|
||||
if active == 1 then
|
||||
return nil, ('This account is banned. Reason: %s'):format(acc.ban_reason or 'no reason recorded')
|
||||
end
|
||||
end
|
||||
|
||||
recordAttempt(s.license, s.ip, true)
|
||||
|
||||
DB.UpdateAsync('UPDATE accounts SET last_login_at = NOW(), last_ip = ?, license = ? WHERE id = ?',
|
||||
{ s.ip or DB.NULL, s.license, acc.id })
|
||||
|
||||
s.accountId = acc.id
|
||||
s.username = acc.username
|
||||
s.role = acc.role
|
||||
s.stage = STAGE_CHARS
|
||||
|
||||
exports.rp_core:attachAccount(src, { id = acc.id, username = acc.username, role = acc.role, license = s.license })
|
||||
|
||||
local cards = {}
|
||||
for _, row in ipairs(characterRows(acc.id)) do cards[#cards + 1] = characterCard(row) end
|
||||
|
||||
print(('^2[rp_session]^7 %s logged in as %q'):format(GetPlayerName(src), acc.username))
|
||||
return { username = acc.username, role = acc.role, characters = cards, maxChars = Config.MaxChars }
|
||||
end)
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- callbacks: characters
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
Core.RegisterCallback('char:list', function(src)
|
||||
local s, e = sessionOf(src, STAGE_CHARS)
|
||||
if not s then return nil, e end
|
||||
local cards = {}
|
||||
for _, row in ipairs(characterRows(s.accountId)) do cards[#cards + 1] = characterCard(row) end
|
||||
return { characters = cards, maxChars = Config.MaxChars }
|
||||
end)
|
||||
|
||||
Core.RegisterCallback('char:create', function(src, data)
|
||||
local s, e = sessionOf(src, STAGE_CHARS)
|
||||
if not s then return nil, e end
|
||||
if type(data) ~= 'table' then return nil, 'malformed request' end
|
||||
|
||||
local first = Util.properName(data.firstName or '')
|
||||
local last = Util.properName(data.lastName or '')
|
||||
|
||||
local ok, why = Rules.checkName(first)
|
||||
if not ok then return nil, 'First name: ' .. why end
|
||||
ok, why = Rules.checkName(last)
|
||||
if not ok then return nil, 'Last name: ' .. why end
|
||||
ok, why = Rules.checkDob(data.dob or '')
|
||||
if not ok then return nil, 'Date of birth: ' .. why end
|
||||
|
||||
local gender = (data.gender == 'f') and 'f' or 'm'
|
||||
local backstory = Util.trim(data.backstory or '')
|
||||
if #backstory > 2000 then return nil, 'Backstory is too long (2000 characters max)' end
|
||||
|
||||
local existing = tonumber(DB.Scalar(
|
||||
'SELECT COUNT(*) FROM characters WHERE account_id = ? AND deleted = 0', { s.accountId })) or 0
|
||||
if existing >= Config.MaxChars then
|
||||
return nil, ('You already have %d characters'):format(Config.MaxChars)
|
||||
end
|
||||
|
||||
local clash = DB.Scalar(
|
||||
'SELECT id FROM characters WHERE first_name = ? AND last_name = ? LIMIT 1', { first, last })
|
||||
if clash then return nil, 'Someone on this server already has that name' end
|
||||
|
||||
local appearance = Appearance.Sanitise(data.appearance)
|
||||
|
||||
local id, ierr = DB.Insert([[
|
||||
INSERT INTO characters (account_id, slot, first_name, last_name, dob, gender, backstory,
|
||||
appearance, cash, bank)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
]], {
|
||||
s.accountId, existing, first, last, data.dob, gender,
|
||||
backstory ~= '' and backstory or DB.NULL,
|
||||
json.encode(appearance), Config.Money.startingCash, Config.Money.startingBank,
|
||||
})
|
||||
|
||||
if not id then
|
||||
print(('^1[rp_session]^7 character insert failed: %s'):format(tostring(ierr)))
|
||||
return nil, 'Could not create that character'
|
||||
end
|
||||
|
||||
print(('^2[rp_session]^7 %s created character %s %s (id %d)'):format(s.username, first, last, id))
|
||||
|
||||
local row = DB.Single([[
|
||||
SELECT id, slot, first_name, last_name, dob, gender, backstory, appearance,
|
||||
cash, bank, job, job_grade, playtime, last_played_at
|
||||
FROM characters WHERE id = ?
|
||||
]], { id })
|
||||
|
||||
return { character = characterCard(row) }
|
||||
end)
|
||||
|
||||
Core.RegisterCallback('char:delete', function(src, data)
|
||||
local s, e = sessionOf(src, STAGE_CHARS)
|
||||
if not s then return nil, e end
|
||||
local id = tonumber(type(data) == 'table' and data.id or nil)
|
||||
if not id then return nil, 'malformed request' end
|
||||
|
||||
-- ownership is checked in the WHERE clause, so a forged id changes nothing
|
||||
local n = DB.Update('UPDATE characters SET deleted = 1 WHERE id = ? AND account_id = ? AND deleted = 0',
|
||||
{ id, s.accountId })
|
||||
if (tonumber(n) or 0) == 0 then return nil, 'That character is not yours' end
|
||||
|
||||
print(('^3[rp_session]^7 %s deleted character %d'):format(s.username, id))
|
||||
return { deleted = id }
|
||||
end)
|
||||
|
||||
Core.RegisterCallback('char:select', function(src, data)
|
||||
local s, e = sessionOf(src, STAGE_CHARS)
|
||||
if not s then return nil, e end
|
||||
local id = tonumber(type(data) == 'table' and data.id or nil)
|
||||
if not id then return nil, 'malformed request' end
|
||||
|
||||
local row = DB.Single([[
|
||||
SELECT * FROM characters WHERE id = ? AND account_id = ? AND deleted = 0
|
||||
]], { id, s.accountId })
|
||||
if not row then return nil, 'That character is not yours' end
|
||||
|
||||
exports.rp_core:attachCharacter(src, row)
|
||||
s.stage = STAGE_SPAWN
|
||||
s.charId = id
|
||||
|
||||
local last = row.position and json.decode(row.position) or nil
|
||||
local spawns = {}
|
||||
for _, sp in ipairs(Config.SpawnPoints) do
|
||||
spawns[#spawns + 1] = {
|
||||
id = sp.id, label = sp.label, area = sp.area, blurb = sp.blurb, map = sp.map,
|
||||
coords = { x = sp.coords.x, y = sp.coords.y, z = sp.coords.z, h = sp.coords.w },
|
||||
cam = { pos = { x = sp.cam.pos.x, y = sp.cam.pos.y, z = sp.cam.pos.z },
|
||||
look = { x = sp.cam.look.x, y = sp.cam.look.y, z = sp.cam.look.z } },
|
||||
}
|
||||
end
|
||||
|
||||
return {
|
||||
character = characterCard(row),
|
||||
appearance = row.appearance and json.decode(row.appearance) or nil,
|
||||
spawns = spawns,
|
||||
lastPosition = last,
|
||||
}
|
||||
end)
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- callbacks: spawn
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
Core.RegisterCallback('spawn:confirm', function(src, data)
|
||||
local s, e = sessionOf(src, STAGE_SPAWN)
|
||||
if not s then return nil, e end
|
||||
|
||||
local choice = type(data) == 'table' and data.id or nil
|
||||
local coords
|
||||
|
||||
if choice == 'last' then
|
||||
local p = exports.rp_core:getCharacter(src)
|
||||
local last = p and p.position
|
||||
if last and last.x then
|
||||
coords = { x = last.x, y = last.y, z = last.z, h = last.h or 0.0 }
|
||||
end
|
||||
end
|
||||
|
||||
if not coords then
|
||||
for _, sp in ipairs(Config.SpawnPoints) do
|
||||
if sp.id == choice then
|
||||
coords = { x = sp.coords.x, y = sp.coords.y, z = sp.coords.z, h = sp.coords.w }
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if not coords then
|
||||
local sp = Config.SpawnPoints[1]
|
||||
coords = { x = sp.coords.x, y = sp.coords.y, z = sp.coords.z, h = sp.coords.w }
|
||||
end
|
||||
|
||||
s.stage = STAGE_LIVE
|
||||
exports.rp_core:setSpawned(src, true)
|
||||
|
||||
local char = exports.rp_core:getCharacter(src)
|
||||
print(('^2[rp_session]^7 %s spawned as %s %s'):format(s.username, char.firstName, char.lastName))
|
||||
|
||||
TriggerClientEvent('rp:session:spawned', src, coords)
|
||||
return { coords = coords, character = char }
|
||||
end)
|
||||
@@ -1,188 +0,0 @@
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Scripted cameras for the pre-spawn screens.
|
||||
--
|
||||
-- One camera object is reused throughout. Everything is driven from a single
|
||||
-- render thread that eases the live values towards target values, so a change
|
||||
-- requested by the UI never snaps.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
Cam = {}
|
||||
|
||||
local cam = nil
|
||||
local mode = 'off' -- 'off' | 'shots' | 'orbit' | 'fly'
|
||||
local renderThread = false
|
||||
|
||||
-- live and target orbit values
|
||||
local orbit = {
|
||||
angle = 180.0, targetAngle = 180.0,
|
||||
radius = 1.6, targetRadius = 1.6,
|
||||
height = 0.65, targetHeight = 0.65,
|
||||
focus = nil, -- entity to orbit
|
||||
pitch = 0.0, targetPitch = 0.0,
|
||||
}
|
||||
|
||||
local shots = { list = {}, index = 0, startedAt = 0, duration = 22000 }
|
||||
local fly = { from = nil, to = nil, lookFrom = nil, lookTo = nil, startedAt = 0, duration = 3000, done = nil }
|
||||
|
||||
local function ensureCam()
|
||||
if cam and DoesCamExist(cam) then return cam end
|
||||
cam = CreateCam('DEFAULT_SCRIPTED_CAMERA', true)
|
||||
SetCamActive(cam, true)
|
||||
RenderScriptCams(true, false, 0, true, true)
|
||||
return cam
|
||||
end
|
||||
|
||||
local function lerp(a, b, t) return a + (b - a) * t end
|
||||
|
||||
local function vlerp(a, b, t)
|
||||
return vector3(lerp(a.x, b.x, t), lerp(a.y, b.y, t), lerp(a.z, b.z, t))
|
||||
end
|
||||
|
||||
-- smoothstep, so fly-throughs start and end at rest
|
||||
local function ease(t)
|
||||
t = math.max(0.0, math.min(1.0, t))
|
||||
return t * t * (3.0 - 2.0 * t)
|
||||
end
|
||||
|
||||
local function startRenderThread()
|
||||
if renderThread then return end
|
||||
renderThread = true
|
||||
|
||||
CreateThread(function()
|
||||
while mode ~= 'off' do
|
||||
local c = ensureCam()
|
||||
|
||||
if mode == 'shots' then
|
||||
local shot = shots.list[shots.index]
|
||||
if shot then
|
||||
local t = (GetGameTimer() - shots.startedAt) / shots.duration
|
||||
if t >= 1.0 then
|
||||
shots.index = (shots.index % #shots.list) + 1
|
||||
shots.startedAt = GetGameTimer()
|
||||
shot = shots.list[shots.index]
|
||||
t = 0.0
|
||||
end
|
||||
-- a slow linear dolly reads as a helicopter, not a spline
|
||||
local pos = vlerp(shot.from, shot.to, t)
|
||||
SetCamCoord(c, pos.x, pos.y, pos.z)
|
||||
PointCamAtCoord(c, shot.look.x, shot.look.y, shot.look.z)
|
||||
end
|
||||
|
||||
elseif mode == 'orbit' then
|
||||
orbit.angle = lerp(orbit.angle, orbit.targetAngle, 0.14)
|
||||
orbit.radius = lerp(orbit.radius, orbit.targetRadius, 0.10)
|
||||
orbit.height = lerp(orbit.height, orbit.targetHeight, 0.10)
|
||||
orbit.pitch = lerp(orbit.pitch, orbit.targetPitch, 0.10)
|
||||
|
||||
local ent = orbit.focus
|
||||
if ent and DoesEntityExist(ent) then
|
||||
local base = GetEntityCoords(ent)
|
||||
local rad = math.rad(orbit.angle)
|
||||
local px = base.x + math.sin(rad) * orbit.radius
|
||||
local py = base.y + math.cos(rad) * orbit.radius
|
||||
local pz = base.z + orbit.height
|
||||
SetCamCoord(c, px, py, pz)
|
||||
PointCamAtCoord(c, base.x, base.y, base.z + orbit.height - orbit.pitch)
|
||||
end
|
||||
|
||||
elseif mode == 'fly' then
|
||||
local t = ease((GetGameTimer() - fly.startedAt) / fly.duration)
|
||||
local pos = vlerp(fly.from, fly.to, t)
|
||||
local look = vlerp(fly.lookFrom, fly.lookTo, t)
|
||||
SetCamCoord(c, pos.x, pos.y, pos.z)
|
||||
PointCamAtCoord(c, look.x, look.y, look.z)
|
||||
if t >= 1.0 then
|
||||
mode = 'idle'
|
||||
if fly.done then
|
||||
local fn = fly.done
|
||||
fly.done = nil
|
||||
fn()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Wait(0)
|
||||
end
|
||||
renderThread = false
|
||||
end)
|
||||
end
|
||||
|
||||
--- Slow establishing shots over the city, cycled forever.
|
||||
function Cam.Shots(list)
|
||||
shots.list = list
|
||||
shots.index = 1
|
||||
shots.startedAt = GetGameTimer()
|
||||
mode = 'shots'
|
||||
ensureCam()
|
||||
SetCamFov(cam, 42.0)
|
||||
startRenderThread()
|
||||
end
|
||||
|
||||
--- Orbit an entity. `snap` places the camera immediately instead of easing in.
|
||||
function Cam.Orbit(entity, radius, height, angle, snap)
|
||||
orbit.focus = entity
|
||||
orbit.targetRadius = radius or orbit.targetRadius
|
||||
orbit.targetHeight = height or orbit.targetHeight
|
||||
if angle then orbit.targetAngle = angle end
|
||||
if snap then
|
||||
orbit.radius = orbit.targetRadius
|
||||
orbit.height = orbit.targetHeight
|
||||
orbit.angle = orbit.targetAngle
|
||||
end
|
||||
mode = 'orbit'
|
||||
ensureCam()
|
||||
SetCamFov(cam, 34.0)
|
||||
startRenderThread()
|
||||
end
|
||||
|
||||
--- Framing presets used by the creator's section tabs.
|
||||
function Cam.OrbitFrame(radius, height, pitch)
|
||||
orbit.targetRadius = radius
|
||||
orbit.targetHeight = height
|
||||
orbit.targetPitch = pitch or 0.0
|
||||
end
|
||||
|
||||
function Cam.Nudge(deltaDegrees)
|
||||
orbit.targetAngle = orbit.targetAngle + deltaDegrees
|
||||
end
|
||||
|
||||
function Cam.SetAngle(deg)
|
||||
orbit.targetAngle = deg
|
||||
end
|
||||
|
||||
function Cam.Angle() return orbit.targetAngle end
|
||||
|
||||
--- Fly from wherever we are to a point looking at a target, then call `done`.
|
||||
function Cam.FlyTo(fromPos, fromLook, toPos, toLook, duration, done)
|
||||
fly.from = fromPos
|
||||
fly.to = toPos
|
||||
fly.lookFrom = fromLook
|
||||
fly.lookTo = toLook
|
||||
fly.duration = duration or 3000
|
||||
fly.startedAt = GetGameTimer()
|
||||
fly.done = done
|
||||
mode = 'fly'
|
||||
ensureCam()
|
||||
startRenderThread()
|
||||
end
|
||||
|
||||
function Cam.SetFov(fov)
|
||||
if cam and DoesCamExist(cam) then SetCamFov(cam, fov + 0.0) end
|
||||
end
|
||||
|
||||
function Cam.Position()
|
||||
if cam and DoesCamExist(cam) then return GetCamCoord(cam) end
|
||||
return GetEntityCoords(PlayerPedId())
|
||||
end
|
||||
|
||||
--- Hand control back to the gameplay camera. With a duration the engine
|
||||
--- interpolates between the two, which hides the cut entirely.
|
||||
function Cam.Release(duration)
|
||||
mode = 'off'
|
||||
duration = duration or 0
|
||||
RenderScriptCams(false, duration > 0, duration, true, true)
|
||||
if cam and DoesCamExist(cam) then
|
||||
DestroyCam(cam, true)
|
||||
end
|
||||
cam = nil
|
||||
end
|
||||
@@ -1,508 +0,0 @@
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- rp_ui - the pre-spawn experience.
|
||||
--
|
||||
-- Owns the stage machine (auth -> characters -> creator -> spawn -> live),
|
||||
-- the NUI bridge, and the transition into the world. The server decides
|
||||
-- everything that matters; this file only asks and then animates the answer.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local stage = 'boot'
|
||||
local account = nil
|
||||
local characters = {}
|
||||
local draft = nil -- appearance currently being edited
|
||||
local previewPed = nil
|
||||
local spawnData = nil -- payload from char:select
|
||||
local chosenChar = nil
|
||||
|
||||
local NEUTRAL = vector3(-1037.0, -2738.0, 20.17) -- flat, always-streamed tarmac
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- helpers
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local function ui(payload)
|
||||
SendNUIMessage(payload)
|
||||
end
|
||||
|
||||
local function focusUI(on)
|
||||
SetNuiFocus(on, on)
|
||||
SetNuiFocusKeepInput(false)
|
||||
end
|
||||
|
||||
local function holdPlayer()
|
||||
local ped = PlayerPedId()
|
||||
SetEntityVisible(ped, false, false)
|
||||
SetEntityCollision(ped, false, false)
|
||||
FreezeEntityPosition(ped, true)
|
||||
SetEntityInvincible(ped, true)
|
||||
SetPlayerControl(PlayerId(), false, 0)
|
||||
SetPlayerInvincible(PlayerId(), true)
|
||||
DisplayHud(false)
|
||||
DisplayRadar(false)
|
||||
ClearPedTasksImmediately(ped)
|
||||
end
|
||||
|
||||
local function releasePlayer()
|
||||
local ped = PlayerPedId()
|
||||
SetEntityVisible(ped, true, false)
|
||||
SetEntityCollision(ped, true, true)
|
||||
FreezeEntityPosition(ped, false)
|
||||
SetEntityInvincible(ped, false)
|
||||
SetPlayerControl(PlayerId(), true, 0)
|
||||
SetPlayerInvincible(PlayerId(), false)
|
||||
DisplayHud(true)
|
||||
DisplayRadar(true)
|
||||
end
|
||||
|
||||
--- Dusk, so the sodium palette of the interface matches the world behind it.
|
||||
local function setSceneMood(on)
|
||||
if on then
|
||||
NetworkOverrideClockTime(20, 40, 0)
|
||||
SetWeatherTypeNowPersist('EXTRASUNNY')
|
||||
else
|
||||
NetworkClearClockTimeOverride()
|
||||
ClearOverrideWeatherType()
|
||||
SetWeatherTypeNow('EXTRASUNNY')
|
||||
end
|
||||
end
|
||||
|
||||
--- Keep the world streamed around a point while no player ped is there.
|
||||
local function focusOn(pos)
|
||||
SetFocusPosAndVel(pos.x, pos.y, pos.z, 0.0, 0.0, 0.0)
|
||||
RequestCollisionAtCoord(pos.x, pos.y, pos.z)
|
||||
end
|
||||
|
||||
local function destroyPreview()
|
||||
if previewPed and DoesEntityExist(previewPed) then
|
||||
DeleteEntity(previewPed)
|
||||
end
|
||||
previewPed = nil
|
||||
end
|
||||
|
||||
local function setStage(next, data)
|
||||
stage = next
|
||||
ui({ action = 'stage', stage = next, data = data })
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- boot
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
CreateThread(function()
|
||||
while not NetworkIsSessionStarted() do Wait(50) end
|
||||
|
||||
holdPlayer()
|
||||
setSceneMood(true)
|
||||
|
||||
local first = Config.CinematicShots[1]
|
||||
focusOn(first.from)
|
||||
SetEntityCoords(PlayerPedId(), NEUTRAL.x, NEUTRAL.y, NEUTRAL.z, false, false, false, false)
|
||||
|
||||
Cam.Shots(Config.CinematicShots)
|
||||
|
||||
-- give the world a moment to stream in behind the loading screen
|
||||
SendLoadingScreenMessage(json.encode({
|
||||
eventName = 'rp:server',
|
||||
name = Config.ServerName,
|
||||
status = 'Connected',
|
||||
}))
|
||||
SendLoadingScreenMessage(json.encode({ eventName = 'rp:rules', rules = Config.Rules }))
|
||||
|
||||
local waited = 0
|
||||
while waited < 6000 do
|
||||
SendLoadingScreenMessage(json.encode({
|
||||
eventName = 'rp:progress',
|
||||
fraction = 0.85 + (waited / 6000) * 0.15,
|
||||
step = 'Streaming the city',
|
||||
}))
|
||||
Wait(250)
|
||||
waited = waited + 250
|
||||
end
|
||||
|
||||
SendLoadingScreenMessage(json.encode({ eventName = 'rp:handover' }))
|
||||
Wait(1500)
|
||||
ShutdownLoadingScreenNui()
|
||||
|
||||
focusUI(true)
|
||||
setStage('auth', {
|
||||
serverName = Config.ServerName,
|
||||
serverTag = Config.ServerTag,
|
||||
maxChars = Config.MaxChars,
|
||||
})
|
||||
end)
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- authentication
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
RegisterNUICallback('auth:submit', function(data, cb)
|
||||
CreateThread(function()
|
||||
local mode = (data and data.mode == 'register') and 'auth:register' or 'auth:login'
|
||||
local res, err = Core.Callback('rp_session', mode, {
|
||||
username = data.username,
|
||||
password = data.password,
|
||||
})
|
||||
|
||||
if not res then return cb({ ok = false, error = err or 'Something went wrong' }) end
|
||||
|
||||
account = { username = res.username, role = res.role }
|
||||
characters = res.characters or {}
|
||||
cb({ ok = true, account = account, characters = characters, maxChars = res.maxChars })
|
||||
end)
|
||||
end)
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- character select
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
--- Stand the highlighted character up so the list is not just text.
|
||||
RegisterNUICallback('char:preview', function(data, cb)
|
||||
CreateThread(function()
|
||||
destroyPreview()
|
||||
|
||||
local appearance
|
||||
for _, c in ipairs(characters) do
|
||||
if c.id == data.id then appearance = c.appearance end
|
||||
end
|
||||
if not appearance then return cb({ ok = false }) end
|
||||
|
||||
appearance = Appearance.Sanitise(appearance)
|
||||
focusOn(Config.CreatorScene.ped)
|
||||
previewPed = PedBuild.CreatePreview(appearance, Config.CreatorScene.ped, Config.CreatorScene.ped.w)
|
||||
if previewPed then
|
||||
PedBuild.Idle(previewPed)
|
||||
Cam.Orbit(previewPed, 2.4, 0.25, Config.CreatorScene.ped.w + 180.0, true)
|
||||
Cam.OrbitFrame(2.4, 0.25, 0.35)
|
||||
end
|
||||
cb({ ok = previewPed ~= nil })
|
||||
end)
|
||||
end)
|
||||
|
||||
RegisterNUICallback('char:delete', function(data, cb)
|
||||
CreateThread(function()
|
||||
local res, err = Core.Callback('rp_session', 'char:delete', { id = data.id })
|
||||
if not res then return cb({ ok = false, error = err }) end
|
||||
|
||||
local list = Core.Callback('rp_session', 'char:list')
|
||||
characters = (list and list.characters) or {}
|
||||
destroyPreview()
|
||||
Cam.Shots(Config.CinematicShots)
|
||||
cb({ ok = true, characters = characters })
|
||||
end)
|
||||
end)
|
||||
|
||||
RegisterNUICallback('char:select', function(data, cb)
|
||||
CreateThread(function()
|
||||
local res, err = Core.Callback('rp_session', 'char:select', { id = data.id })
|
||||
if not res then return cb({ ok = false, error = err }) end
|
||||
|
||||
spawnData = res
|
||||
chosenChar = res.character
|
||||
destroyPreview()
|
||||
|
||||
-- become the character now, off-screen, so the fly-down reveals them
|
||||
if res.appearance then
|
||||
PedBuild.ApplyToPlayer(Appearance.Sanitise(res.appearance))
|
||||
holdPlayer()
|
||||
end
|
||||
|
||||
cb({ ok = true, spawns = res.spawns, lastPosition = res.lastPosition, character = res.character })
|
||||
end)
|
||||
end)
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- character creation
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
--- Read the game's own colour tables so the swatches in the UI are the real
|
||||
--- colours, not an approximation that drifts from what the ped ends up wearing.
|
||||
local paletteCache = nil
|
||||
local function palettes()
|
||||
if paletteCache then return paletteCache end
|
||||
local hair, makeup = {}, {}
|
||||
for i = 0, 63 do
|
||||
local ok, r, g, b = pcall(GetPedHairRgbColor, i)
|
||||
hair[#hair + 1] = ok and { r = r, g = g, b = b } or { r = 40, g = 40, b = 40 }
|
||||
local ok2, r2, g2, b2 = pcall(GetPedMakeupRgbColor, i)
|
||||
makeup[#makeup + 1] = ok2 and { r = r2, g = g2, b = b2 } or { r = 60, g = 40, b = 40 }
|
||||
end
|
||||
paletteCache = { hair = hair, makeup = makeup }
|
||||
return paletteCache
|
||||
end
|
||||
|
||||
--- The creator UI is built from this schema rather than a hardcoded copy, so
|
||||
--- adding a slider is a one-line change in shared/appearance.lua.
|
||||
local function pushDraft(cb)
|
||||
local payload = {
|
||||
ok = true,
|
||||
appearance = draft,
|
||||
limits = PedBuild.Limits(previewPed),
|
||||
palettes = palettes(),
|
||||
schema = {
|
||||
features = Appearance.FEATURES,
|
||||
overlays = Appearance.OVERLAYS,
|
||||
components = Appearance.COMPONENTS,
|
||||
props = Appearance.PROPS,
|
||||
},
|
||||
}
|
||||
if cb then cb(payload) else ui({ action = 'creator:sync', data = payload }) end
|
||||
end
|
||||
|
||||
local function rebuildPreview()
|
||||
destroyPreview()
|
||||
focusOn(Config.CreatorScene.ped)
|
||||
previewPed = PedBuild.CreatePreview(draft, Config.CreatorScene.ped, Config.CreatorScene.ped.w)
|
||||
if previewPed then
|
||||
PedBuild.Idle(previewPed)
|
||||
Cam.Orbit(previewPed, 1.5, 0.62, Config.CreatorScene.ped.w + 180.0, true)
|
||||
end
|
||||
end
|
||||
|
||||
RegisterNUICallback('creator:start', function(data, cb)
|
||||
CreateThread(function()
|
||||
draft = Appearance.Default(data and data.gender or 'm')
|
||||
rebuildPreview()
|
||||
pushDraft(cb)
|
||||
end)
|
||||
end)
|
||||
|
||||
RegisterNUICallback('creator:gender', function(data, cb)
|
||||
CreateThread(function()
|
||||
draft = Appearance.Default(data.gender == 'f' and 'f' or 'm')
|
||||
rebuildPreview()
|
||||
pushDraft(cb)
|
||||
end)
|
||||
end)
|
||||
|
||||
RegisterNUICallback('creator:parent', function(data, cb)
|
||||
if not draft or not previewPed then return cb({ ok = false }) end
|
||||
local key = data.key
|
||||
if key == 'father' or key == 'mother' then
|
||||
draft.parents[key] = math.floor(tonumber(data.value) or 0)
|
||||
elseif key == 'shapeMix' or key == 'skinMix' then
|
||||
draft.parents[key] = tonumber(data.value) or 0.5
|
||||
end
|
||||
SetPedHeadBlendData(previewPed,
|
||||
draft.parents.father, draft.parents.mother, 0,
|
||||
draft.parents.father, draft.parents.mother, 0,
|
||||
draft.parents.shapeMix + 0.0, draft.parents.skinMix + 0.0, 0.0, false)
|
||||
cb({ ok = true })
|
||||
end)
|
||||
|
||||
RegisterNUICallback('creator:feature', function(data, cb)
|
||||
if not draft or not previewPed then return cb({ ok = false }) end
|
||||
local id = math.floor(tonumber(data.id) or 0)
|
||||
local v = math.max(-1.0, math.min(1.0, tonumber(data.value) or 0.0))
|
||||
draft.features[tostring(id)] = v
|
||||
SetPedFaceFeature(previewPed, id, v + 0.0)
|
||||
cb({ ok = true })
|
||||
end)
|
||||
|
||||
RegisterNUICallback('creator:overlay', function(data, cb)
|
||||
if not draft or not previewPed then return cb({ ok = false }) end
|
||||
local slot
|
||||
for _, o in ipairs(Appearance.OVERLAYS) do if o.key == data.key then slot = o end end
|
||||
if not slot then return cb({ ok = false }) end
|
||||
|
||||
local ov = draft.overlays[data.key]
|
||||
if data.index ~= nil then ov.index = math.floor(tonumber(data.index)) end
|
||||
if data.opacity ~= nil then ov.opacity = tonumber(data.opacity) end
|
||||
if data.colour ~= nil then ov.colour = math.floor(tonumber(data.colour)) end
|
||||
|
||||
local index = (ov.index == nil or ov.index < 0) and 255 or ov.index
|
||||
SetPedHeadOverlay(previewPed, slot.id, index, ov.opacity + 0.0)
|
||||
if slot.tint and index ~= 255 then
|
||||
SetPedHeadOverlayColor(previewPed, slot.id, slot.tint == 'hair' and 1 or 2, ov.colour, ov.colour)
|
||||
end
|
||||
cb({ ok = true })
|
||||
end)
|
||||
|
||||
RegisterNUICallback('creator:hair', function(data, cb)
|
||||
if not draft or not previewPed then return cb({ ok = false }) end
|
||||
if data.style ~= nil then draft.hair.style = math.floor(tonumber(data.style)) end
|
||||
if data.colour ~= nil then draft.hair.colour = math.floor(tonumber(data.colour)) end
|
||||
if data.highlight ~= nil then draft.hair.highlight = math.floor(tonumber(data.highlight)) end
|
||||
SetPedComponentVariation(previewPed, 2, draft.hair.style, 0, 0)
|
||||
SetPedHairColor(previewPed, draft.hair.colour, draft.hair.highlight)
|
||||
cb({ ok = true })
|
||||
end)
|
||||
|
||||
RegisterNUICallback('creator:eyes', function(data, cb)
|
||||
if not draft or not previewPed then return cb({ ok = false }) end
|
||||
draft.eyeColour = math.floor(tonumber(data.value) or 0)
|
||||
SetPedEyeColor(previewPed, draft.eyeColour)
|
||||
cb({ ok = true })
|
||||
end)
|
||||
|
||||
RegisterNUICallback('creator:component', function(data, cb)
|
||||
if not draft or not previewPed then return cb({ ok = false }) end
|
||||
local comp
|
||||
for _, c in ipairs(Appearance.COMPONENTS) do if c.key == data.key then comp = c end end
|
||||
if not comp then return cb({ ok = false }) end
|
||||
|
||||
local cc = draft.components[data.key]
|
||||
if data.drawable ~= nil then cc.drawable = math.floor(tonumber(data.drawable)) end
|
||||
if data.texture ~= nil then cc.texture = math.floor(tonumber(data.texture)) end
|
||||
|
||||
-- a new drawable usually has a different number of textures
|
||||
local maxTex = math.max(0, GetNumberOfPedTextureVariations(previewPed, comp.id, cc.drawable) - 1)
|
||||
if cc.texture > maxTex then cc.texture = 0 end
|
||||
|
||||
SetPedComponentVariation(previewPed, comp.id, cc.drawable, cc.texture, 0)
|
||||
cb({ ok = true, texture = cc.texture, maxTexture = maxTex })
|
||||
end)
|
||||
|
||||
RegisterNUICallback('creator:prop', function(data, cb)
|
||||
if not draft or not previewPed then return cb({ ok = false }) end
|
||||
local prop
|
||||
for _, p in ipairs(Appearance.PROPS) do if p.key == data.key then prop = p end end
|
||||
if not prop then return cb({ ok = false }) end
|
||||
|
||||
local pp = draft.props[data.key]
|
||||
if data.drawable ~= nil then pp.drawable = math.floor(tonumber(data.drawable)) end
|
||||
if data.texture ~= nil then pp.texture = math.floor(tonumber(data.texture)) end
|
||||
|
||||
if pp.drawable < 0 then
|
||||
ClearPedProp(previewPed, prop.id)
|
||||
cb({ ok = true, texture = 0, maxTexture = 0 })
|
||||
else
|
||||
local maxTex = math.max(0, GetNumberOfPedPropTextureVariations(previewPed, prop.id, pp.drawable) - 1)
|
||||
if pp.texture > maxTex then pp.texture = 0 end
|
||||
SetPedPropIndex(previewPed, prop.id, pp.drawable, pp.texture, true)
|
||||
cb({ ok = true, texture = pp.texture, maxTexture = maxTex })
|
||||
end
|
||||
end)
|
||||
|
||||
--- Camera framing per creator section.
|
||||
local FRAMES = {
|
||||
heritage = { radius = 1.05, height = 0.66, pitch = 0.02 },
|
||||
face = { radius = 0.85, height = 0.68, pitch = 0.0 },
|
||||
hair = { radius = 1.05, height = 0.70, pitch = 0.0 },
|
||||
body = { radius = 2.60, height = 0.20, pitch = 0.45 },
|
||||
clothing = { radius = 2.30, height = 0.28, pitch = 0.40 },
|
||||
identity = { radius = 1.90, height = 0.45, pitch = 0.25 },
|
||||
}
|
||||
|
||||
RegisterNUICallback('creator:frame', function(data, cb)
|
||||
local f = FRAMES[data.section] or FRAMES.face
|
||||
Cam.OrbitFrame(f.radius, f.height, f.pitch)
|
||||
cb({ ok = true })
|
||||
end)
|
||||
|
||||
RegisterNUICallback('creator:rotate', function(data, cb)
|
||||
Cam.Nudge(tonumber(data.delta) or 0.0)
|
||||
cb({ ok = true })
|
||||
end)
|
||||
|
||||
RegisterNUICallback('creator:submit', function(data, cb)
|
||||
CreateThread(function()
|
||||
local res, err = Core.Callback('rp_session', 'char:create', {
|
||||
firstName = data.firstName,
|
||||
lastName = data.lastName,
|
||||
dob = data.dob,
|
||||
gender = draft and draft.model or 'm',
|
||||
backstory = data.backstory,
|
||||
appearance = draft,
|
||||
})
|
||||
if not res then return cb({ ok = false, error = err }) end
|
||||
|
||||
local list = Core.Callback('rp_session', 'char:list')
|
||||
characters = (list and list.characters) or {}
|
||||
destroyPreview()
|
||||
draft = nil
|
||||
cb({ ok = true, characters = characters })
|
||||
end)
|
||||
end)
|
||||
|
||||
RegisterNUICallback('creator:cancel', function(_, cb)
|
||||
destroyPreview()
|
||||
draft = nil
|
||||
Cam.Shots(Config.CinematicShots)
|
||||
cb({ ok = true })
|
||||
end)
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- spawn
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
RegisterNUICallback('spawn:preview', function(data, cb)
|
||||
CreateThread(function()
|
||||
if not spawnData then return cb({ ok = false }) end
|
||||
for _, sp in ipairs(spawnData.spawns) do
|
||||
if sp.id == data.id then
|
||||
local from = Cam.Position()
|
||||
focusOn(vector3(sp.coords.x, sp.coords.y, sp.coords.z))
|
||||
Cam.FlyTo(
|
||||
from, vector3(sp.cam.look.x, sp.cam.look.y, sp.cam.look.z),
|
||||
vector3(sp.cam.pos.x, sp.cam.pos.y, sp.cam.pos.z),
|
||||
vector3(sp.coords.x, sp.coords.y, sp.coords.z),
|
||||
2200
|
||||
)
|
||||
break
|
||||
end
|
||||
end
|
||||
cb({ ok = true })
|
||||
end)
|
||||
end)
|
||||
|
||||
RegisterNUICallback('spawn:confirm', function(data, cb)
|
||||
CreateThread(function()
|
||||
local res, err = Core.Callback('rp_session', 'spawn:confirm', { id = data.id })
|
||||
if not res then return cb({ ok = false, error = err }) end
|
||||
|
||||
cb({ ok = true })
|
||||
focusUI(false)
|
||||
ui({ action = 'stage', stage = 'flying' })
|
||||
|
||||
local c = res.coords
|
||||
local ped = PlayerPedId()
|
||||
|
||||
-- stream the ground before anyone stands on it
|
||||
focusOn(vector3(c.x, c.y, c.z))
|
||||
RequestCollisionAtCoord(c.x, c.y, c.z)
|
||||
local waited = 0
|
||||
while not HasCollisionLoadedAroundEntity(ped) and waited < 8000 do
|
||||
RequestCollisionAtCoord(c.x, c.y, c.z)
|
||||
Wait(50)
|
||||
waited = waited + 50
|
||||
end
|
||||
|
||||
SetEntityCoordsNoOffset(ped, c.x, c.y, c.z, false, false, false)
|
||||
SetEntityHeading(ped, c.h or 0.0)
|
||||
|
||||
local from = Cam.Position()
|
||||
Cam.FlyTo(
|
||||
from, vector3(c.x, c.y, c.z),
|
||||
vector3(c.x - 2.4, c.y - 2.4, c.z + 1.4), vector3(c.x, c.y, c.z + 0.2),
|
||||
3400,
|
||||
function()
|
||||
SetEntityVisible(ped, true, false)
|
||||
releasePlayer()
|
||||
setSceneMood(false)
|
||||
ClearFocus()
|
||||
Cam.Release(1400)
|
||||
ui({ action = 'stage', stage = 'live' })
|
||||
TriggerEvent('rp:ui:spawned', chosenChar)
|
||||
end
|
||||
)
|
||||
end)
|
||||
end)
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- misc
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
RegisterNUICallback('ui:log', function(data, cb)
|
||||
print(('^3[rp_ui]^7 %s'):format(tostring(data and data.message)))
|
||||
cb({ ok = true })
|
||||
end)
|
||||
|
||||
AddEventHandler('onResourceStop', function(res)
|
||||
if res ~= GetCurrentResourceName() then return end
|
||||
destroyPreview()
|
||||
Cam.Release(0)
|
||||
focusUI(false)
|
||||
releasePlayer()
|
||||
setSceneMood(false)
|
||||
ClearFocus()
|
||||
end)
|
||||
@@ -1,160 +0,0 @@
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Building a freemode ped from an appearance table.
|
||||
--
|
||||
-- The same code paints the creator preview and the live player, so what you
|
||||
-- saw while editing is exactly what walks into the world.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
PedBuild = {}
|
||||
|
||||
local NO_OVERLAY = 255 -- the native's "none" value; our model stores -1
|
||||
|
||||
local function loadModel(name)
|
||||
local hash = joaat(name)
|
||||
if not IsModelInCdimage(hash) or not IsModelValid(hash) then return nil end
|
||||
RequestModel(hash)
|
||||
local waited = 0
|
||||
while not HasModelLoaded(hash) and waited < 10000 do
|
||||
Wait(10)
|
||||
waited = waited + 10
|
||||
end
|
||||
if not HasModelLoaded(hash) then return nil end
|
||||
return hash
|
||||
end
|
||||
|
||||
PedBuild.LoadModel = loadModel
|
||||
|
||||
--- Apply an appearance table to an existing freemode ped.
|
||||
function PedBuild.Apply(ped, a)
|
||||
if not ped or not DoesEntityExist(ped) or type(a) ~= 'table' then return end
|
||||
|
||||
SetPedHeadBlendData(
|
||||
ped,
|
||||
a.parents.father, a.parents.mother, 0,
|
||||
a.parents.father, a.parents.mother, 0,
|
||||
a.parents.shapeMix + 0.0, a.parents.skinMix + 0.0, 0.0,
|
||||
false
|
||||
)
|
||||
|
||||
for _, f in ipairs(Appearance.FEATURES) do
|
||||
local v = a.features[tostring(f.id)]
|
||||
if v then SetPedFaceFeature(ped, f.id, v + 0.0) end
|
||||
end
|
||||
|
||||
for _, o in ipairs(Appearance.OVERLAYS) do
|
||||
local ov = a.overlays[o.key]
|
||||
if ov then
|
||||
local index = (ov.index == nil or ov.index < 0) and NO_OVERLAY or ov.index
|
||||
SetPedHeadOverlay(ped, o.id, index, ov.opacity + 0.0)
|
||||
if o.tint and index ~= NO_OVERLAY then
|
||||
-- tint type 1 = hair palette, 2 = make-up palette
|
||||
SetPedHeadOverlayColor(ped, o.id, o.tint == 'hair' and 1 or 2, ov.colour, ov.colour)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
SetPedComponentVariation(ped, 2, a.hair.style, 0, 0)
|
||||
SetPedHairColor(ped, a.hair.colour, a.hair.highlight)
|
||||
SetPedEyeColor(ped, a.eyeColour)
|
||||
|
||||
for _, c in ipairs(Appearance.COMPONENTS) do
|
||||
local cc = a.components[c.key]
|
||||
if cc then SetPedComponentVariation(ped, c.id, cc.drawable, cc.texture, 0) end
|
||||
end
|
||||
|
||||
for _, p in ipairs(Appearance.PROPS) do
|
||||
local pp = a.props[p.key]
|
||||
if pp then
|
||||
if not pp.drawable or pp.drawable < 0 then
|
||||
ClearPedProp(ped, p.id)
|
||||
else
|
||||
SetPedPropIndex(ped, p.id, pp.drawable, pp.texture, true)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--- How many variations this particular model actually has, so the creator's
|
||||
--- sliders stop at real values instead of guessed ones.
|
||||
function PedBuild.Limits(ped)
|
||||
local limits = { components = {}, props = {}, overlays = {}, hair = 0 }
|
||||
if not ped or not DoesEntityExist(ped) then return limits end
|
||||
|
||||
limits.hair = math.max(0, GetNumberOfPedDrawableVariations(ped, 2) - 1)
|
||||
|
||||
for _, c in ipairs(Appearance.COMPONENTS) do
|
||||
local drawables = math.max(0, GetNumberOfPedDrawableVariations(ped, c.id) - 1)
|
||||
local current = GetPedDrawableVariation(ped, c.id)
|
||||
limits.components[c.key] = {
|
||||
drawable = drawables,
|
||||
texture = math.max(0, GetNumberOfPedTextureVariations(ped, c.id, current) - 1),
|
||||
}
|
||||
end
|
||||
|
||||
for _, p in ipairs(Appearance.PROPS) do
|
||||
local drawables = math.max(-1, GetNumberOfPedPropDrawableVariations(ped, p.id) - 1)
|
||||
local current = GetPedPropIndex(ped, p.id)
|
||||
limits.props[p.key] = {
|
||||
drawable = drawables,
|
||||
texture = math.max(0, GetNumberOfPedPropTextureVariations(ped, p.id, current) - 1),
|
||||
}
|
||||
end
|
||||
|
||||
for _, o in ipairs(Appearance.OVERLAYS) do
|
||||
limits.overlays[o.key] = o.max
|
||||
end
|
||||
|
||||
return limits
|
||||
end
|
||||
|
||||
--- Create a standalone preview ped (never networked).
|
||||
function PedBuild.CreatePreview(appearance, coords, heading)
|
||||
local modelName = Appearance.MODELS[appearance.model] or Appearance.MODELS.m
|
||||
local hash = loadModel(modelName)
|
||||
if not hash then return nil end
|
||||
|
||||
local ped = CreatePed(2, hash, coords.x, coords.y, coords.z, heading + 0.0, false, false)
|
||||
SetModelAsNoLongerNeeded(hash)
|
||||
|
||||
SetEntityInvincible(ped, true)
|
||||
FreezeEntityPosition(ped, true)
|
||||
SetBlockingOfNonTemporaryEvents(ped, true)
|
||||
SetPedDefaultComponentVariation(ped)
|
||||
SetPedCanRagdoll(ped, false)
|
||||
SetEntityCollision(ped, false, false)
|
||||
|
||||
PedBuild.Apply(ped, appearance)
|
||||
return ped
|
||||
end
|
||||
|
||||
--- Turn the local player into this character.
|
||||
function PedBuild.ApplyToPlayer(appearance)
|
||||
local modelName = Appearance.MODELS[appearance.model] or Appearance.MODELS.m
|
||||
local hash = loadModel(modelName)
|
||||
if not hash then return false end
|
||||
|
||||
SetPlayerModel(PlayerId(), hash)
|
||||
SetModelAsNoLongerNeeded(hash)
|
||||
|
||||
local ped = PlayerPedId()
|
||||
SetPedDefaultComponentVariation(ped)
|
||||
PedBuild.Apply(ped, appearance)
|
||||
-- freemode peds start with no head blend applied until this settles a frame
|
||||
Wait(50)
|
||||
PedBuild.Apply(ped, appearance)
|
||||
return true
|
||||
end
|
||||
|
||||
--- A calm idle so the preview does not stand like a mannequin.
|
||||
function PedBuild.Idle(ped)
|
||||
local dict = 'anim@heists@heist_corona@team_idles@female_a'
|
||||
RequestAnimDict(dict)
|
||||
local waited = 0
|
||||
while not HasAnimDictLoaded(dict) and waited < 3000 do
|
||||
Wait(10)
|
||||
waited = waited + 10
|
||||
end
|
||||
if HasAnimDictLoaded(dict) then
|
||||
TaskPlayAnim(ped, dict, 'idle', 2.0, 2.0, -1, 1, 0, false, false, false)
|
||||
end
|
||||
end
|
||||
@@ -1,33 +0,0 @@
|
||||
fx_version 'cerulean'
|
||||
game 'gta5'
|
||||
|
||||
name 'rp_ui'
|
||||
description 'Pre-spawn interface: sign in, character creation and placement'
|
||||
author 'Los Santos RP'
|
||||
version '1.0.0'
|
||||
|
||||
shared_scripts {
|
||||
'@rp_core/shared/config.lua',
|
||||
'@rp_core/shared/util.lua',
|
||||
'@rp_core/shared/appearance.lua',
|
||||
}
|
||||
|
||||
client_scripts {
|
||||
'@rp_core/lib/callbacks_client.lua',
|
||||
'client/camera.lua',
|
||||
'client/ped.lua',
|
||||
'client/main.lua',
|
||||
}
|
||||
|
||||
ui_page 'html/index.html'
|
||||
|
||||
files {
|
||||
'html/index.html',
|
||||
'html/tokens.css',
|
||||
'html/app.css',
|
||||
'html/app.js',
|
||||
'html/fonts/archivo-var.woff2',
|
||||
'html/fonts/plexsans-var.woff2',
|
||||
'html/fonts/plex-400.woff2',
|
||||
'html/fonts/plex-500.woff2',
|
||||
}
|
||||
@@ -1,820 +0,0 @@
|
||||
/* ---------------------------------------------------------------------------
|
||||
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%; }
|
||||
}
|
||||
@@ -1,965 +0,0 @@
|
||||
/* ---------------------------------------------------------------------------
|
||||
Pre-spawn interface behaviour.
|
||||
|
||||
The client Lua owns the truth about stages; this file renders whatever it is
|
||||
told and asks for changes. Validation here is for live feedback only - the
|
||||
server re-checks everything and its answer is the one that counts.
|
||||
--------------------------------------------------------------------------- */
|
||||
|
||||
'use strict';
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const $$ = (sel, root) => Array.from((root || document).querySelectorAll(sel));
|
||||
|
||||
async function post(name, data) {
|
||||
try {
|
||||
const res = await fetch(`https://rp_ui/${name}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json; charset=UTF-8' },
|
||||
body: JSON.stringify(data || {}),
|
||||
});
|
||||
return await res.json();
|
||||
} catch (err) {
|
||||
return { ok: false, error: 'The game stopped responding. Try again.' };
|
||||
}
|
||||
}
|
||||
|
||||
/* Fire-and-forget for high-frequency edits (slider drags). */
|
||||
function poke(name, data) {
|
||||
fetch(`https://rp_ui/${name}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json; charset=UTF-8' },
|
||||
body: JSON.stringify(data || {}),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
screen manager
|
||||
========================================================================= */
|
||||
|
||||
const SCREENS = ['auth', 'characters', 'creator', 'spawn'];
|
||||
const CHIP_FOR = { auth: 'auth', characters: 'characters', creator: 'characters', spawn: 'spawn' };
|
||||
const CHIP_ORDER = ['auth', 'characters', 'spawn'];
|
||||
|
||||
let current = null;
|
||||
|
||||
function showScreen(name) {
|
||||
SCREENS.forEach((s) => {
|
||||
const el = $(`screen-${s}`);
|
||||
if (!el) return;
|
||||
if (s === name) {
|
||||
el.hidden = false;
|
||||
/* restart the entrance animation */
|
||||
el.classList.remove('is-on');
|
||||
void el.offsetWidth;
|
||||
el.classList.add('is-on');
|
||||
} else {
|
||||
el.hidden = true;
|
||||
el.classList.remove('is-on');
|
||||
}
|
||||
});
|
||||
current = name;
|
||||
paintChips(CHIP_FOR[name]);
|
||||
document.body.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function paintChips(activeKey) {
|
||||
const idx = CHIP_ORDER.indexOf(activeKey);
|
||||
$$('.stagechip').forEach((chip) => {
|
||||
const i = CHIP_ORDER.indexOf(chip.dataset.stage);
|
||||
chip.classList.toggle('on', i === idx);
|
||||
chip.classList.toggle('done', i < idx);
|
||||
});
|
||||
}
|
||||
|
||||
/* 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) {
|
||||
btn.classList.toggle('busy', on);
|
||||
btn.disabled = on;
|
||||
}
|
||||
|
||||
function showError(el, message) {
|
||||
if (!message) {
|
||||
el.hidden = true;
|
||||
return;
|
||||
}
|
||||
el.textContent = message;
|
||||
el.hidden = false;
|
||||
/* replay the shake even if the message is unchanged */
|
||||
el.style.animation = 'none';
|
||||
void el.offsetWidth;
|
||||
el.style.animation = '';
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
validation (mirrors shared/util.lua; the server still decides)
|
||||
========================================================================= */
|
||||
|
||||
const check = {
|
||||
username(v) {
|
||||
v = (v || '').trim();
|
||||
if (!v) return [null, 'Pick a username.'];
|
||||
if (v.length < 3) return [false, 'A bit longer - at least 3 characters.'];
|
||||
if (v.length > 20) return [false, 'Too long - 20 characters at most.'];
|
||||
if (!/^[A-Za-z0-9_]+$/.test(v)) return [false, 'Letters, numbers and underscore only.'];
|
||||
return [true, 'That will do.'];
|
||||
},
|
||||
password(v) {
|
||||
v = v || '';
|
||||
if (!v) return [null, 'At least 8 characters, with a letter and a number.'];
|
||||
if (v.length < 8) return [false, `${8 - v.length} more character${8 - v.length === 1 ? '' : 's'} needed.`];
|
||||
if (!/[A-Za-z]/.test(v)) return [false, 'Add at least one letter.'];
|
||||
if (!/[0-9]/.test(v)) return [false, 'Add at least one number.'];
|
||||
return [true, 'Strong enough.'];
|
||||
},
|
||||
name(v) {
|
||||
v = (v || '').trim();
|
||||
if (!v) return [null, 'Required.'];
|
||||
if (v.length < 2) return [false, 'Too short.'];
|
||||
if (v.length > 20) return [false, 'Too long.'];
|
||||
if (!/^[A-Za-z][A-Za-z'-]*$/.test(v)) return [false, "Letters, ' and - only."];
|
||||
return [true, ''];
|
||||
},
|
||||
dob(v) {
|
||||
v = (v || '').trim();
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(v);
|
||||
if (!v) return [null, 'YYYY-MM-DD'];
|
||||
if (!m) return [false, 'Use YYYY-MM-DD.'];
|
||||
const y = +m[1], mo = +m[2], d = +m[3];
|
||||
if (mo < 1 || mo > 12) return [false, 'Month must be 01-12.'];
|
||||
const days = [31, (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0 ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
||||
if (d < 1 || d > days[mo - 1]) return [false, 'That day does not exist.'];
|
||||
if (y < 1930 || y > 2010) return [false, 'Year must be between 1930 and 2010.'];
|
||||
return [true, ''];
|
||||
},
|
||||
};
|
||||
|
||||
/* Paints a field's state. `state` is true / false / null (neutral). */
|
||||
function mark(fieldName, state, message) {
|
||||
const field = document.querySelector(`.field[data-field="${fieldName}"]`);
|
||||
if (!field) return;
|
||||
field.classList.toggle('good', state === true);
|
||||
field.classList.toggle('bad', state === false);
|
||||
const hint = field.querySelector('.hint');
|
||||
if (hint && message !== undefined && message !== null) hint.textContent = message;
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
01 / auth
|
||||
========================================================================= */
|
||||
|
||||
const Auth = {
|
||||
mode: 'register',
|
||||
|
||||
init() {
|
||||
$('switchMode').addEventListener('click', () => this.toggle());
|
||||
$('authForm').addEventListener('submit', (e) => { e.preventDefault(); this.submit(); });
|
||||
|
||||
$('revealPw').addEventListener('click', () => {
|
||||
const input = $('password');
|
||||
const show = input.type === 'password';
|
||||
input.type = show ? 'text' : 'password';
|
||||
$('revealPw').textContent = show ? 'Hide' : 'Show';
|
||||
$('revealPw').setAttribute('aria-label', show ? 'Hide password' : 'Show password');
|
||||
});
|
||||
|
||||
$('username').addEventListener('input', () => {
|
||||
const [ok, msg] = check.username($('username').value);
|
||||
mark('username', ok, msg);
|
||||
});
|
||||
$('password').addEventListener('input', () => {
|
||||
const [ok, msg] = check.password($('password').value);
|
||||
mark('password', ok, msg);
|
||||
if (this.mode === 'register' && $('confirm').value) this.checkConfirm();
|
||||
});
|
||||
$('confirm').addEventListener('input', () => this.checkConfirm());
|
||||
},
|
||||
|
||||
checkConfirm() {
|
||||
const a = $('password').value;
|
||||
const b = $('confirm').value;
|
||||
if (!b) return mark('confirm', null, 'Both entries must match.');
|
||||
mark('confirm', a === b, a === b ? 'They match.' : 'These do not match yet.');
|
||||
},
|
||||
|
||||
toggle() {
|
||||
this.mode = this.mode === 'register' ? 'login' : 'register';
|
||||
const reg = this.mode === 'register';
|
||||
|
||||
$('authTitle').textContent = reg ? 'Open a file' : 'Return to your file';
|
||||
$('authSub').textContent = reg
|
||||
? 'Everything you do in this city is recorded against this file. Choose a name you will remember and a password you will not share.'
|
||||
: 'Sign in and the city picks up where you left it.';
|
||||
$('authSubmit').querySelector('.btn-label').textContent = reg ? 'Open the file' : 'Sign in';
|
||||
$('switchText').textContent = reg ? 'Already have a file?' : 'No file yet?';
|
||||
$('switchMode').textContent = reg ? 'Sign in instead' : 'Open one now';
|
||||
|
||||
$('confirmWrap').classList.toggle('open', reg);
|
||||
$('confirmWrap').setAttribute('aria-hidden', String(!reg));
|
||||
$('password').setAttribute('autocomplete', reg ? 'new-password' : 'current-password');
|
||||
|
||||
showError($('authError'), null);
|
||||
if (!reg) mark('confirm', null, 'Both entries must match.');
|
||||
},
|
||||
|
||||
async submit() {
|
||||
const username = $('username').value.trim();
|
||||
const password = $('password').value;
|
||||
const reg = this.mode === 'register';
|
||||
|
||||
const [uOk, uMsg] = check.username(username);
|
||||
if (uOk !== true) { mark('username', false, uMsg); $('username').focus(); return; }
|
||||
|
||||
if (reg) {
|
||||
const [pOk, pMsg] = check.password(password);
|
||||
if (pOk !== true) { mark('password', false, pMsg); $('password').focus(); return; }
|
||||
if ($('confirm').value !== password) {
|
||||
mark('confirm', false, 'These do not match yet.');
|
||||
$('confirm').focus();
|
||||
return;
|
||||
}
|
||||
} else if (!password) {
|
||||
mark('password', false, 'Enter your password.');
|
||||
$('password').focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = $('authSubmit');
|
||||
busy(btn, true);
|
||||
showError($('authError'), null);
|
||||
|
||||
const res = await post('auth:submit', { mode: this.mode, username, password });
|
||||
busy(btn, false);
|
||||
|
||||
if (!res.ok) return showError($('authError'), res.error || 'That did not work.');
|
||||
|
||||
if (reg) stamp('Filed');
|
||||
Characters.load(res.characters || [], res.maxChars || 3);
|
||||
showScreen('characters');
|
||||
},
|
||||
};
|
||||
|
||||
/* =========================================================================
|
||||
02 / characters
|
||||
========================================================================= */
|
||||
|
||||
const Characters = {
|
||||
list: [],
|
||||
max: 3,
|
||||
selected: null,
|
||||
|
||||
init() {
|
||||
$('charEnter').addEventListener('click', () => this.enter());
|
||||
$('charDelete').addEventListener('click', () => this.remove());
|
||||
},
|
||||
|
||||
load(list, max) {
|
||||
this.list = list || [];
|
||||
this.max = max || 3;
|
||||
this.selected = null;
|
||||
this.render();
|
||||
},
|
||||
|
||||
render() {
|
||||
const ul = $('charList');
|
||||
ul.innerHTML = '';
|
||||
$('charCount').textContent = `${this.list.length} / ${this.max}`;
|
||||
|
||||
this.list.forEach((c, i) => {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'charcard' + (this.selected === c.id ? ' on' : '');
|
||||
li.tabIndex = 0;
|
||||
li.innerHTML = `
|
||||
<span class="charslot">0${i + 1}</span>
|
||||
<span>
|
||||
<span class="charname"></span>
|
||||
<span class="charmeta"></span>
|
||||
</span>
|
||||
<span class="charmoney"></span>`;
|
||||
li.querySelector('.charname').textContent = `${c.firstName} ${c.lastName}`;
|
||||
li.querySelector('.charmeta').textContent = this.meta(c);
|
||||
li.querySelector('.charmoney').textContent = `$${(c.cash + c.bank).toLocaleString('en-US')}`;
|
||||
|
||||
const pick = () => this.select(c.id);
|
||||
li.addEventListener('click', pick);
|
||||
li.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); pick(); } });
|
||||
ul.appendChild(li);
|
||||
});
|
||||
|
||||
if (this.list.length < this.max) {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'charcard empty';
|
||||
li.tabIndex = 0;
|
||||
li.innerHTML = `<span class="charslot">+</span><span class="charname">Create a new identity</span>`;
|
||||
const go = () => Creator.open();
|
||||
li.addEventListener('click', go);
|
||||
li.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(); } });
|
||||
ul.appendChild(li);
|
||||
}
|
||||
|
||||
$('charEnter').disabled = this.selected === null;
|
||||
$('charDelete').disabled = this.selected === null;
|
||||
},
|
||||
|
||||
meta(c) {
|
||||
const hours = Math.floor((c.playtime || 0) / 3600);
|
||||
const played = hours > 0 ? `${hours}h played` : 'never played';
|
||||
const job = c.job && c.job !== 'unemployed' ? c.job : 'no job';
|
||||
return `${c.dob} · ${job} · ${played}`;
|
||||
},
|
||||
|
||||
select(id) {
|
||||
this.selected = id;
|
||||
this.render();
|
||||
showError($('charError'), null);
|
||||
poke('char:preview', { id });
|
||||
},
|
||||
|
||||
async enter() {
|
||||
if (this.selected === null) return;
|
||||
const btn = $('charEnter');
|
||||
busy(btn, true);
|
||||
const res = await post('char:select', { id: this.selected });
|
||||
busy(btn, false);
|
||||
if (!res.ok) return showError($('charError'), res.error || 'Could not load that character.');
|
||||
Spawn.load(res.spawns || [], res.lastPosition, res.character);
|
||||
showScreen('spawn');
|
||||
},
|
||||
|
||||
async remove() {
|
||||
if (this.selected === null) return;
|
||||
const c = this.list.find((x) => x.id === this.selected);
|
||||
if (!c) return;
|
||||
const btn = $('charDelete');
|
||||
|
||||
/* two-step, in place: the button becomes the confirmation */
|
||||
if (btn.dataset.armed !== '1') {
|
||||
btn.dataset.armed = '1';
|
||||
btn.textContent = `Delete ${c.firstName}?`;
|
||||
setTimeout(() => {
|
||||
if (btn.dataset.armed === '1') { btn.dataset.armed = '0'; btn.textContent = 'Delete'; }
|
||||
}, 4000);
|
||||
return;
|
||||
}
|
||||
|
||||
btn.dataset.armed = '0';
|
||||
btn.textContent = 'Delete';
|
||||
busy(btn, true);
|
||||
const res = await post('char:delete', { id: this.selected });
|
||||
busy(btn, false);
|
||||
if (!res.ok) return showError($('charError'), res.error || 'Could not delete that character.');
|
||||
this.load(res.characters || [], this.max);
|
||||
},
|
||||
};
|
||||
|
||||
/* =========================================================================
|
||||
03 / creator
|
||||
========================================================================= */
|
||||
|
||||
const SECTIONS = ['heritage', 'face', 'hair', 'body', 'clothing', 'identity'];
|
||||
|
||||
const Creator = {
|
||||
appearance: null,
|
||||
limits: null,
|
||||
schema: null,
|
||||
palettes: null,
|
||||
section: 'heritage',
|
||||
|
||||
init() {
|
||||
$$('.tab').forEach((t) => t.addEventListener('click', () => this.go(t.dataset.section)));
|
||||
$('creatorCancel').addEventListener('click', () => this.cancel());
|
||||
$('creatorPrev').addEventListener('click', () => this.step(-1));
|
||||
$('creatorNext').addEventListener('click', () => this.step(1));
|
||||
this.initDrag();
|
||||
},
|
||||
|
||||
async open() {
|
||||
const res = await post('creator:start', { gender: 'm' });
|
||||
if (!res.ok) return showError($('charError'), 'Could not start the editor.');
|
||||
this.absorb(res);
|
||||
this.section = 'heritage';
|
||||
this.paintTabs();
|
||||
this.renderAll();
|
||||
showScreen('creator');
|
||||
poke('creator:frame', { section: 'heritage' });
|
||||
},
|
||||
|
||||
absorb(res) {
|
||||
this.appearance = res.appearance;
|
||||
this.limits = res.limits;
|
||||
this.schema = res.schema || this.schema;
|
||||
this.palettes = res.palettes || this.palettes;
|
||||
},
|
||||
|
||||
go(section) {
|
||||
this.section = section;
|
||||
this.paintTabs();
|
||||
poke('creator:frame', { section });
|
||||
},
|
||||
|
||||
paintTabs() {
|
||||
$$('.tab').forEach((t) => t.classList.toggle('is-on', t.dataset.section === this.section));
|
||||
$$('.pane').forEach((p) => p.classList.toggle('is-on', p.dataset.section === this.section));
|
||||
const i = SECTIONS.indexOf(this.section);
|
||||
$('creatorPrev').disabled = i === 0;
|
||||
$('creatorNext').querySelector('.btn-label').textContent =
|
||||
i === SECTIONS.length - 1 ? 'Submit application' : 'Next';
|
||||
},
|
||||
|
||||
step(dir) {
|
||||
const i = SECTIONS.indexOf(this.section);
|
||||
if (dir > 0 && i === SECTIONS.length - 1) return this.submit();
|
||||
const next = SECTIONS[Math.min(SECTIONS.length - 1, Math.max(0, i + dir))];
|
||||
this.go(next);
|
||||
},
|
||||
|
||||
/* --- control builders ------------------------------------------------- */
|
||||
|
||||
slider(label, value, min, max, step, format, onInput) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'ctrl';
|
||||
wrap.innerHTML = `
|
||||
<div class="ctrl-head">
|
||||
<span class="ctrl-label"></span>
|
||||
<span class="ctrl-val"></span>
|
||||
</div>
|
||||
<input class="slider" type="range">`;
|
||||
wrap.querySelector('.ctrl-label').textContent = label;
|
||||
const val = wrap.querySelector('.ctrl-val');
|
||||
const input = wrap.querySelector('input');
|
||||
input.min = min; input.max = max; input.step = step; input.value = value;
|
||||
val.textContent = format(value);
|
||||
input.addEventListener('input', () => {
|
||||
const v = Number(input.value);
|
||||
val.textContent = format(v);
|
||||
onInput(v);
|
||||
});
|
||||
return wrap;
|
||||
},
|
||||
|
||||
stepper(label, value, min, max, onChange) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'ctrl';
|
||||
wrap.innerHTML = `
|
||||
<div class="ctrl-head">
|
||||
<span class="ctrl-label"></span>
|
||||
<span class="ctrl-val"></span>
|
||||
</div>
|
||||
<div class="stepper">
|
||||
<button type="button" aria-label="Previous">−</button>
|
||||
<span class="stepval"></span>
|
||||
<button type="button" aria-label="Next">+</button>
|
||||
</div>`;
|
||||
wrap.querySelector('.ctrl-label').textContent = label;
|
||||
const range = wrap.querySelector('.ctrl-val');
|
||||
const out = wrap.querySelector('.stepval');
|
||||
let v = value;
|
||||
let lo = min;
|
||||
let hi = max;
|
||||
|
||||
const paint = () => {
|
||||
out.textContent = v < 0 ? 'None' : String(v);
|
||||
range.textContent = hi <= lo ? '—' : `${Math.max(0, lo)}–${hi}`;
|
||||
};
|
||||
const set = (next) => {
|
||||
if (next < lo) next = hi; // wraps, so browsing a long list is quick
|
||||
if (next > hi) next = lo;
|
||||
v = next;
|
||||
paint();
|
||||
onChange(v);
|
||||
};
|
||||
/* Changing a garment changes how many variants it has; the variant control
|
||||
is re-bounded in place rather than rebuilt. */
|
||||
const setBounds = (newMax, newValue) => {
|
||||
hi = typeof newMax === 'number' ? newMax : hi;
|
||||
if (typeof newValue === 'number') v = newValue;
|
||||
if (v > hi) v = lo;
|
||||
paint();
|
||||
};
|
||||
wrap.querySelectorAll('button')[0].addEventListener('click', () => set(v - 1));
|
||||
wrap.querySelectorAll('button')[1].addEventListener('click', () => set(v + 1));
|
||||
paint();
|
||||
return { el: wrap, set, setBounds, get value() { return v; } };
|
||||
},
|
||||
|
||||
swatches(label, palette, value, onPick) {
|
||||
const wrap = document.createElement('div');
|
||||
/* 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>`;
|
||||
wrap.querySelector('.ctrl-label').textContent = label;
|
||||
const val = wrap.querySelector('.ctrl-val');
|
||||
const row = wrap.querySelector('.swatches');
|
||||
val.textContent = String(value);
|
||||
|
||||
(palette || []).forEach((c, i) => {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.className = 'swatch' + (i === value ? ' on' : '');
|
||||
b.style.background = `rgb(${c.r}, ${c.g}, ${c.b})`;
|
||||
b.title = `Colour ${i}`;
|
||||
b.addEventListener('click', () => {
|
||||
row.querySelectorAll('.swatch').forEach((s) => s.classList.remove('on'));
|
||||
b.classList.add('on');
|
||||
val.textContent = String(i);
|
||||
onPick(i);
|
||||
});
|
||||
row.appendChild(b);
|
||||
});
|
||||
return wrap;
|
||||
},
|
||||
|
||||
/* --- panes ------------------------------------------------------------ */
|
||||
|
||||
renderAll() {
|
||||
this.renderHeritage();
|
||||
this.renderFace();
|
||||
this.renderHair();
|
||||
this.renderBody();
|
||||
this.renderClothing();
|
||||
this.renderIdentity();
|
||||
},
|
||||
|
||||
pane(section) {
|
||||
const el = document.querySelector(`.pane[data-section="${section}"]`);
|
||||
el.innerHTML = '';
|
||||
return el;
|
||||
},
|
||||
|
||||
renderHeritage() {
|
||||
const p = this.pane('heritage');
|
||||
const a = this.appearance;
|
||||
|
||||
const seg = document.createElement('div');
|
||||
seg.className = 'seg';
|
||||
seg.innerHTML = `
|
||||
<button type="button" data-g="m">Male</button>
|
||||
<button type="button" data-g="f">Female</button>`;
|
||||
seg.querySelectorAll('button').forEach((b) => {
|
||||
b.classList.toggle('on', b.dataset.g === a.model);
|
||||
b.addEventListener('click', async () => {
|
||||
if (b.dataset.g === this.appearance.model) return;
|
||||
const res = await post('creator:gender', { gender: b.dataset.g });
|
||||
if (!res.ok) return;
|
||||
this.absorb(res);
|
||||
this.renderAll();
|
||||
this.paintTabs();
|
||||
});
|
||||
});
|
||||
p.appendChild(seg);
|
||||
|
||||
p.appendChild(this.slider('Father', a.parents.father, 0, 45, 1, (v) => `#${v}`, (v) => {
|
||||
a.parents.father = v;
|
||||
poke('creator:parent', { key: 'father', value: v });
|
||||
}));
|
||||
p.appendChild(this.slider('Mother', a.parents.mother, 0, 45, 1, (v) => `#${v}`, (v) => {
|
||||
a.parents.mother = v;
|
||||
poke('creator:parent', { key: 'mother', value: v });
|
||||
}));
|
||||
p.appendChild(this.slider('Resemblance', a.parents.shapeMix, 0, 1, 0.01,
|
||||
(v) => `${Math.round((1 - v) * 100)}% father / ${Math.round(v * 100)}% mother`,
|
||||
(v) => { a.parents.shapeMix = v; poke('creator:parent', { key: 'shapeMix', value: v }); }));
|
||||
p.appendChild(this.slider('Skin tone', a.parents.skinMix, 0, 1, 0.01,
|
||||
(v) => `${Math.round((1 - v) * 100)}% father / ${Math.round(v * 100)}% mother`,
|
||||
(v) => { a.parents.skinMix = v; poke('creator:parent', { key: 'skinMix', value: v }); }));
|
||||
},
|
||||
|
||||
renderFace() {
|
||||
const p = this.pane('face');
|
||||
const a = this.appearance;
|
||||
|
||||
(this.schema.features || []).forEach((f) => {
|
||||
const key = String(f.id);
|
||||
const v = a.features[key] || 0;
|
||||
p.appendChild(this.slider(f.label, v, -1, 1, 0.01,
|
||||
(x) => (x === 0 ? 'neutral' : `${x > 0 ? '+' : ''}${x.toFixed(2)}`),
|
||||
(x) => { a.features[key] = x; poke('creator:feature', { id: f.id, value: x }); }));
|
||||
});
|
||||
|
||||
p.appendChild(this.slider('Eye colour', a.eyeColour, 0, 31, 1, (v) => `#${v}`, (v) => {
|
||||
a.eyeColour = v;
|
||||
poke('creator:eyes', { value: v });
|
||||
}));
|
||||
|
||||
this.overlayControls(p, (this.schema.overlays || []).filter((o) => o.tint !== 'hair'));
|
||||
},
|
||||
|
||||
renderHair() {
|
||||
const p = this.pane('hair');
|
||||
const a = this.appearance;
|
||||
const maxHair = (this.limits && this.limits.hair) || 0;
|
||||
|
||||
const st = this.stepper('Hair style', a.hair.style, 0, maxHair, (v) => {
|
||||
a.hair.style = v;
|
||||
poke('creator:hair', { style: v });
|
||||
});
|
||||
p.appendChild(st.el);
|
||||
|
||||
p.appendChild(this.swatches('Hair colour', this.palettes && this.palettes.hair, a.hair.colour, (i) => {
|
||||
a.hair.colour = i;
|
||||
poke('creator:hair', { colour: i });
|
||||
}));
|
||||
p.appendChild(this.swatches('Highlights', this.palettes && this.palettes.hair, a.hair.highlight, (i) => {
|
||||
a.hair.highlight = i;
|
||||
poke('creator:hair', { highlight: i });
|
||||
}));
|
||||
|
||||
/* Only the hair-tinted overlays belong here; skin and make-up sit with
|
||||
the face, which keeps either pane from becoming a wall of controls. */
|
||||
this.overlayControls(p, (this.schema.overlays || []).filter((o) => o.tint === 'hair'));
|
||||
},
|
||||
|
||||
overlayControls(p, list) {
|
||||
const a = this.appearance;
|
||||
list.forEach((o) => {
|
||||
const ov = a.overlays[o.key] || { index: -1, opacity: 1, colour: 0 };
|
||||
const s = this.stepper(o.label, ov.index, -1, o.max, (v) => {
|
||||
ov.index = v;
|
||||
poke('creator:overlay', { key: o.key, index: v });
|
||||
});
|
||||
p.appendChild(s.el);
|
||||
|
||||
p.appendChild(this.slider(`${o.label} strength`, ov.opacity, 0, 1, 0.05,
|
||||
(v) => `${Math.round(v * 100)}%`,
|
||||
(v) => { ov.opacity = v; poke('creator:overlay', { key: o.key, opacity: v }); }));
|
||||
|
||||
if (o.tint) {
|
||||
const pal = o.tint === 'hair' ? this.palettes.hair : this.palettes.makeup;
|
||||
p.appendChild(this.swatches(`${o.label} colour`, pal, ov.colour, (i) => {
|
||||
ov.colour = i;
|
||||
poke('creator:overlay', { key: o.key, colour: i });
|
||||
}));
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
renderBody() {
|
||||
const p = this.pane('body');
|
||||
const a = this.appearance;
|
||||
const note = document.createElement('p');
|
||||
note.className = 'sub';
|
||||
note.textContent = 'Build is carried by the heritage blend and these two proportions.';
|
||||
p.appendChild(note);
|
||||
|
||||
['13', '19'].forEach((id) => {
|
||||
const f = (this.schema.features || []).find((x) => String(x.id) === id);
|
||||
if (!f) return;
|
||||
p.appendChild(this.slider(f.label, a.features[id] || 0, -1, 1, 0.01,
|
||||
(x) => (x === 0 ? 'neutral' : `${x > 0 ? '+' : ''}${x.toFixed(2)}`),
|
||||
(x) => { a.features[id] = x; poke('creator:feature', { id: Number(id), value: x }); }));
|
||||
});
|
||||
},
|
||||
|
||||
renderClothing() {
|
||||
const p = this.pane('clothing');
|
||||
const a = this.appearance;
|
||||
|
||||
(this.schema.components || []).forEach((c) => {
|
||||
const cc = a.components[c.key] || { drawable: 0, texture: 0 };
|
||||
const lim = (this.limits.components && this.limits.components[c.key]) || { drawable: 0, texture: 0 };
|
||||
|
||||
const tex = this.stepper(`${c.label} variant`, cc.texture, 0, lim.texture, (v) => {
|
||||
cc.texture = v;
|
||||
poke('creator:component', { key: c.key, texture: v });
|
||||
});
|
||||
|
||||
const draw = this.stepper(c.label, cc.drawable, 0, lim.drawable, async (v) => {
|
||||
cc.drawable = v;
|
||||
const res = await post('creator:component', { key: c.key, drawable: v });
|
||||
if (res && res.ok) {
|
||||
cc.texture = res.texture;
|
||||
tex.setBounds(res.maxTexture, res.texture);
|
||||
}
|
||||
});
|
||||
|
||||
p.appendChild(draw.el);
|
||||
p.appendChild(tex.el);
|
||||
});
|
||||
|
||||
(this.schema.props || []).forEach((pr) => {
|
||||
const pp = a.props[pr.key] || { drawable: -1, texture: 0 };
|
||||
const lim = (this.limits.props && this.limits.props[pr.key]) || { drawable: -1, texture: 0 };
|
||||
const s = this.stepper(pr.label, pp.drawable, -1, Math.max(-1, lim.drawable), (v) => {
|
||||
pp.drawable = v;
|
||||
poke('creator:prop', { key: pr.key, drawable: v });
|
||||
});
|
||||
p.appendChild(s.el);
|
||||
});
|
||||
},
|
||||
|
||||
renderIdentity() {
|
||||
const p = this.pane('identity');
|
||||
p.innerHTML = `
|
||||
<div class="field" data-field="firstName">
|
||||
<label for="firstName">First name</label>
|
||||
<input id="firstName" type="text" maxlength="20" spellcheck="false" placeholder="Danielle">
|
||||
<p class="hint">The name people will call you.</p>
|
||||
</div>
|
||||
<div class="field" data-field="lastName">
|
||||
<label for="lastName">Last name</label>
|
||||
<input id="lastName" type="text" maxlength="20" spellcheck="false" placeholder="Larue">
|
||||
<p class="hint">Family name. Must be unique on this server.</p>
|
||||
</div>
|
||||
<div class="field" data-field="dob">
|
||||
<label for="dob">Date of birth</label>
|
||||
<input id="dob" type="text" maxlength="10" spellcheck="false" placeholder="1994-07-12">
|
||||
<p class="hint">YYYY-MM-DD</p>
|
||||
</div>
|
||||
<div class="field" data-field="backstory">
|
||||
<label for="backstory">Backstory</label>
|
||||
<textarea id="backstory" maxlength="2000"
|
||||
placeholder="Where were they before Los Santos, and why did they leave?"></textarea>
|
||||
<p class="counter"><span id="storyCount">0</span> / 2000</p>
|
||||
</div>`;
|
||||
|
||||
$('firstName').addEventListener('input', () => {
|
||||
const [ok, msg] = check.name($('firstName').value);
|
||||
mark('firstName', ok, msg || 'The name people will call you.');
|
||||
});
|
||||
$('lastName').addEventListener('input', () => {
|
||||
const [ok, msg] = check.name($('lastName').value);
|
||||
mark('lastName', ok, msg || 'Family name. Must be unique on this server.');
|
||||
});
|
||||
$('dob').addEventListener('input', () => {
|
||||
const [ok, msg] = check.dob($('dob').value);
|
||||
mark('dob', ok, msg || 'YYYY-MM-DD');
|
||||
});
|
||||
$('backstory').addEventListener('input', () => {
|
||||
$('storyCount').textContent = String($('backstory').value.length);
|
||||
});
|
||||
},
|
||||
|
||||
/* --- drag to turn ----------------------------------------------------- */
|
||||
|
||||
initDrag() {
|
||||
const tt = $('turntable');
|
||||
let dragging = false;
|
||||
let lastX = 0;
|
||||
let pending = 0;
|
||||
let frame = null;
|
||||
|
||||
const flush = () => {
|
||||
frame = null;
|
||||
if (pending !== 0) {
|
||||
poke('creator:rotate', { delta: pending });
|
||||
pending = 0;
|
||||
}
|
||||
};
|
||||
|
||||
tt.addEventListener('pointerdown', (e) => {
|
||||
dragging = true;
|
||||
lastX = e.clientX;
|
||||
tt.classList.add('dragging');
|
||||
tt.setPointerCapture(e.pointerId);
|
||||
});
|
||||
tt.addEventListener('pointermove', (e) => {
|
||||
if (!dragging) return;
|
||||
pending += (e.clientX - lastX) * 0.42;
|
||||
lastX = e.clientX;
|
||||
if (!frame) frame = requestAnimationFrame(flush);
|
||||
});
|
||||
const stop = (e) => {
|
||||
if (!dragging) return;
|
||||
dragging = false;
|
||||
tt.classList.remove('dragging');
|
||||
try { tt.releasePointerCapture(e.pointerId); } catch (_) {}
|
||||
};
|
||||
tt.addEventListener('pointerup', stop);
|
||||
tt.addEventListener('pointercancel', stop);
|
||||
},
|
||||
|
||||
async cancel() {
|
||||
await post('creator:cancel', {});
|
||||
showScreen('characters');
|
||||
Characters.render();
|
||||
},
|
||||
|
||||
async submit() {
|
||||
const first = $('firstName') ? $('firstName').value.trim() : '';
|
||||
const last = $('lastName') ? $('lastName').value.trim() : '';
|
||||
const dob = $('dob') ? $('dob').value.trim() : '';
|
||||
|
||||
if (this.section !== 'identity') this.go('identity');
|
||||
|
||||
const [fOk, fMsg] = check.name(first);
|
||||
if (fOk !== true) { mark('firstName', false, fMsg || 'Required.'); $('firstName').focus(); return; }
|
||||
const [lOk, lMsg] = check.name(last);
|
||||
if (lOk !== true) { mark('lastName', false, lMsg || 'Required.'); $('lastName').focus(); return; }
|
||||
const [dOk, dMsg] = check.dob(dob);
|
||||
if (dOk !== true) { mark('dob', false, dMsg || 'Required.'); $('dob').focus(); return; }
|
||||
|
||||
const btn = $('creatorNext');
|
||||
busy(btn, true);
|
||||
showError($('creatorError'), null);
|
||||
|
||||
const res = await post('creator:submit', {
|
||||
firstName: first,
|
||||
lastName: last,
|
||||
dob,
|
||||
backstory: $('backstory').value,
|
||||
});
|
||||
busy(btn, false);
|
||||
|
||||
if (!res.ok) return showError($('creatorError'), res.error || 'That application was refused.');
|
||||
|
||||
stamp('Approved');
|
||||
Characters.load(res.characters || [], Characters.max);
|
||||
showScreen('characters');
|
||||
},
|
||||
};
|
||||
|
||||
/* =========================================================================
|
||||
04 / spawn
|
||||
========================================================================= */
|
||||
|
||||
const Spawn = {
|
||||
spawns: [],
|
||||
selected: null,
|
||||
last: null,
|
||||
|
||||
init() {
|
||||
$('spawnConfirm').addEventListener('click', () => this.confirm());
|
||||
},
|
||||
|
||||
load(spawns, lastPosition, character) {
|
||||
this.spawns = spawns || [];
|
||||
this.last = lastPosition || null;
|
||||
this.selected = null;
|
||||
if (character) {
|
||||
$('spawnSub').textContent =
|
||||
`${character.firstName} ${character.lastName}. Pick a district - you can move once you are on the ground.`;
|
||||
}
|
||||
this.render();
|
||||
},
|
||||
|
||||
render() {
|
||||
const ul = $('spawnList');
|
||||
const pins = $('pins');
|
||||
ul.innerHTML = '';
|
||||
pins.innerHTML = '';
|
||||
|
||||
const entries = this.spawns.slice();
|
||||
if (this.last) {
|
||||
entries.unshift({
|
||||
id: 'last',
|
||||
label: 'Where you left off',
|
||||
area: 'Last known position',
|
||||
blurb: 'Pick up exactly where this character stopped.',
|
||||
map: null,
|
||||
});
|
||||
}
|
||||
|
||||
entries.forEach((sp) => {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'spawncard' + (this.selected === sp.id ? ' on' : '');
|
||||
li.tabIndex = 0;
|
||||
li.innerHTML = `
|
||||
<div class="spawnhead">
|
||||
<span class="spawnname"></span>
|
||||
<span class="spawnarea"></span>
|
||||
</div>
|
||||
<p class="spawnblurb"></p>`;
|
||||
li.querySelector('.spawnname').textContent = sp.label;
|
||||
li.querySelector('.spawnarea').textContent = sp.area;
|
||||
li.querySelector('.spawnblurb').textContent = sp.blurb;
|
||||
|
||||
const pick = () => this.select(sp.id);
|
||||
li.addEventListener('click', pick);
|
||||
li.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); pick(); } });
|
||||
ul.appendChild(li);
|
||||
|
||||
if (sp.map) {
|
||||
const pin = document.createElement('div');
|
||||
pin.className = 'pin' + (this.selected === sp.id ? ' on' : '') + (sp.map.x > 0.6 ? ' flip' : '');
|
||||
pin.style.left = `${sp.map.x * 100}%`;
|
||||
pin.style.top = `${sp.map.y * 100}%`;
|
||||
pin.innerHTML = '<i class="pin-mark"></i><span class="pin-label"></span>';
|
||||
pin.querySelector('.pin-label').textContent = sp.label;
|
||||
pins.appendChild(pin);
|
||||
}
|
||||
});
|
||||
|
||||
$('spawnConfirm').disabled = this.selected === null;
|
||||
},
|
||||
|
||||
select(id) {
|
||||
this.selected = id;
|
||||
this.render();
|
||||
showError($('spawnError'), null);
|
||||
if (id !== 'last') poke('spawn:preview', { id });
|
||||
},
|
||||
|
||||
async confirm() {
|
||||
if (this.selected === null) return;
|
||||
const btn = $('spawnConfirm');
|
||||
busy(btn, true);
|
||||
const res = await post('spawn:confirm', { id: this.selected });
|
||||
busy(btn, false);
|
||||
if (!res.ok) return showError($('spawnError'), res.error || 'Could not place you there.');
|
||||
stamp('Issued');
|
||||
},
|
||||
};
|
||||
|
||||
/* =========================================================================
|
||||
messages from the client script
|
||||
========================================================================= */
|
||||
|
||||
window.addEventListener('message', (event) => {
|
||||
const msg = event.data || {};
|
||||
|
||||
if (msg.action === 'stage') {
|
||||
if (msg.stage === 'auth') {
|
||||
const d = msg.data || {};
|
||||
if (d.serverName) $('markName').textContent = d.serverName;
|
||||
if (d.serverTag) $('markTag').textContent = d.serverTag;
|
||||
$('fileSerial').textContent = fileSerial();
|
||||
showScreen('auth');
|
||||
setTimeout(() => $('username').focus(), 520);
|
||||
} else if (msg.stage === 'flying' || msg.stage === 'live') {
|
||||
/* the camera is doing the talking now */
|
||||
SCREENS.forEach((s) => { const el = $(`screen-${s}`); if (el) el.hidden = true; });
|
||||
document.body.classList.add('hidden');
|
||||
} else {
|
||||
showScreen(msg.stage);
|
||||
}
|
||||
}
|
||||
|
||||
if (msg.action === 'creator:sync' && msg.data) {
|
||||
Creator.absorb(msg.data);
|
||||
Creator.renderAll();
|
||||
}
|
||||
});
|
||||
|
||||
function fileSerial() {
|
||||
const y = new Date().getFullYear();
|
||||
const n = String(Math.floor(Math.random() * 900000) + 100000);
|
||||
return `${y}-${n}`;
|
||||
}
|
||||
|
||||
/* Escape backs out of the creator; nothing else steals keys from the game. */
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && current === 'creator') Creator.cancel();
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
Auth.init();
|
||||
Characters.init();
|
||||
Creator.init();
|
||||
Spawn.init();
|
||||
Auth.toggle(); /* start on "sign in"; the copy makes registering obvious */
|
||||
});
|
||||
@@ -1,294 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Los Santos RP</title>
|
||||
<link rel="stylesheet" href="tokens.css">
|
||||
<link rel="stylesheet" href="app.css">
|
||||
</head>
|
||||
<body class="hidden">
|
||||
|
||||
<div class="edge" aria-hidden="true"></div>
|
||||
<div class="grain" aria-hidden="true"></div>
|
||||
|
||||
<!-- ======================= 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>
|
||||
</button>
|
||||
<i class="stagelink" aria-hidden="true"></i>
|
||||
<button class="stagechip" data-stage="characters" type="button" disabled>
|
||||
<em>02</em><span>Identity</span>
|
||||
</button>
|
||||
<i class="stagelink" aria-hidden="true"></i>
|
||||
<button class="stagechip" data-stage="spawn" type="button" disabled>
|
||||
<em>03</em><span>Placement</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="filetag">
|
||||
<span class="filetag-k">File</span>
|
||||
<span id="fileSerial">0000-000000</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ======================= 01 / auth ======================= -->
|
||||
<section class="screen" id="screen-auth" hidden>
|
||||
<div class="rail">
|
||||
<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">Office of Vital Records</span>
|
||||
</div>
|
||||
<div class="lh-form data">VR‑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
|
||||
remember and a password you will not share.
|
||||
</p>
|
||||
|
||||
<form class="form" id="authForm" novalidate>
|
||||
<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="dlarue">
|
||||
<p class="hint" id="hint-username">3–20 characters. Letters, numbers and underscore.</p>
|
||||
</div>
|
||||
|
||||
<div class="field" data-field="password">
|
||||
<label for="password">Password</label>
|
||||
<div class="withbtn">
|
||||
<input id="password" name="password" type="password" autocomplete="current-password"
|
||||
maxlength="72" placeholder="••••••••">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- only present while registering; height animates, nothing reloads -->
|
||||
<div class="collapse" id="confirmWrap" aria-hidden="true">
|
||||
<div class="collapse-inner">
|
||||
<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="••••••••">
|
||||
<p class="hint" id="hint-confirm">Both entries must match.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="formerror" id="authError" role="alert" aria-live="assertive" hidden></p>
|
||||
|
||||
<button class="btn primary" id="authSubmit" type="submit">
|
||||
<span class="btn-label">Open the file</span>
|
||||
<span class="btn-spin" aria-hidden="true"></span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="switch">
|
||||
<span id="switchText">Already have a file?</span>
|
||||
<button class="linkbtn" id="switchMode" type="button">Sign in instead</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ======================= 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‑4</div>
|
||||
</div>
|
||||
|
||||
<div class="rail-inner">
|
||||
<h1 class="head">Who are you today?</h1>
|
||||
|
||||
<div class="kicker">
|
||||
<span class="eyebrow">Registered identities</span>
|
||||
<span class="hair" aria-hidden="true"></span>
|
||||
<span class="data" id="charCount">0 / 3</span>
|
||||
</div>
|
||||
|
||||
<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 danger" id="charDelete" type="button" disabled>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ======================= 03 / creator ======================= -->
|
||||
<section class="screen" id="screen-creator" 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">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">
|
||||
<button class="tab is-on" data-section="heritage" type="button">Heritage</button>
|
||||
<button class="tab" data-section="face" type="button">Face</button>
|
||||
<button class="tab" data-section="hair" type="button">Hair</button>
|
||||
<button class="tab" data-section="body" type="button">Body</button>
|
||||
<button class="tab" data-section="clothing" type="button">Clothing</button>
|
||||
<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>
|
||||
<div class="pane" data-section="hair"></div>
|
||||
<div class="pane" data-section="body"></div>
|
||||
<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>
|
||||
<button class="btn primary" id="creatorNext" type="button">
|
||||
<span class="btn-label">Next</span>
|
||||
<span class="btn-spin" aria-hidden="true"></span>
|
||||
</button>
|
||||
</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">
|
||||
<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>
|
||||
|
||||
<!-- ======================= 04 / spawn ======================= -->
|
||||
<section class="screen" id="screen-spawn" hidden>
|
||||
<div class="rail">
|
||||
<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">Notice of Placement</span>
|
||||
</div>
|
||||
<div class="lh-form data">VR‑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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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(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)"/>
|
||||
|
||||
<rect class="chart-frame" x="30" y="30" width="940" height="940"/>
|
||||
<path class="chart-ticks"
|
||||
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"/>
|
||||
<!-- 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"/>
|
||||
</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 class="stamp-word" id="stampText">Filed</span>
|
||||
<span class="stamp-sub">Office of Vital Records</span>
|
||||
</div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,182 +0,0 @@
|
||||
/* ---------------------------------------------------------------------------
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,40 +1,40 @@
|
||||
# ---------------------------------------------------------------------------
|
||||
# Los Santos RP - server configuration
|
||||
# Copy to /opt/fivem/data/server.cfg (install.sh does this for you).
|
||||
# FiveRP - server configuration
|
||||
# install.sh writes this to server-data/server.cfg with your own values.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
endpoint_add_tcp "0.0.0.0:30120"
|
||||
endpoint_add_udp "0.0.0.0:30120"
|
||||
|
||||
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 steam_webApiKey "none"
|
||||
sv_maxclients 32
|
||||
sv_hostname "FiveRP"
|
||||
sets sv_projectName "FiveRP"
|
||||
sets sv_projectDesc "FiveRP Server"
|
||||
sets tags "roleplay"
|
||||
sv_enforceGameBuild 2699
|
||||
|
||||
set onesync on
|
||||
sv_enforceGameBuild 3095
|
||||
sv_scriptHookAllowed 0
|
||||
# Free key from https://keymaster.fivem.net - keep it out of git
|
||||
sv_licenseKey "cfxk_your_key_here"
|
||||
|
||||
# Licence key and database credentials. Mode 600, never echoed.
|
||||
exec /opt/fivem/data/secrets.cfg
|
||||
|
||||
# --- 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
|
||||
|
||||
# Diagnostics. Safe to leave on; they only touch their own marker rows.
|
||||
ensure rp_selftest
|
||||
|
||||
# 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
|
||||
add_ace group.admin command.quit deny
|
||||
|
||||
# oxmysql connection - the password is generated by install.sh
|
||||
set mysql_connection_string "mysql://fiverp:your_password@localhost/fiverp?charset=utf8mb4"
|
||||
|
||||
# Loading screen first, database before anything that queries it
|
||||
ensure fiverp-loadscreen
|
||||
ensure oxmysql
|
||||
|
||||
ensure mapmanager
|
||||
ensure chat
|
||||
ensure spawnmanager
|
||||
ensure sessionmanager
|
||||
# basic-gamemode force-respawns on map start, which would drop players into the
|
||||
# world behind the auth screen. fiverp-auth owns spawning instead.
|
||||
ensure hardcap
|
||||
ensure rconlog
|
||||
|
||||
ensure fiverp-auth
|
||||
ensure fiverp-characters
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- FiveRP - database schema
|
||||
-- mysql fiverp < sql/schema.sql (install.sh applies this for you)
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE IF NOT EXISTS accounts (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
email VARCHAR(190) NOT NULL,
|
||||
username VARCHAR(32) NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL, -- scrypt, salt and cost live inside the hash
|
||||
license VARCHAR(64) DEFAULT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_login DATETIME DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_email (email),
|
||||
UNIQUE KEY uq_username (username)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS characters (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
account_id INT UNSIGNED NOT NULL,
|
||||
slot TINYINT UNSIGNED NOT NULL DEFAULT 1,
|
||||
first_name VARCHAR(32) NOT NULL,
|
||||
last_name VARCHAR(32) NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
model VARCHAR(32) NOT NULL DEFAULT 'mp_m_freemode_01',
|
||||
appearance LONGTEXT DEFAULT NULL, -- JSON, written by fiverp-characters
|
||||
pos_x FLOAT DEFAULT NULL,
|
||||
pos_y FLOAT DEFAULT NULL,
|
||||
pos_z FLOAT DEFAULT NULL,
|
||||
heading FLOAT DEFAULT NULL,
|
||||
last_played DATETIME DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_slot (account_id, slot),
|
||||
CONSTRAINT fk_char_account FOREIGN KEY (account_id)
|
||||
REFERENCES accounts(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||