Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
146c6592aa | ||
|
|
6464bff749 | ||
|
|
7367926afe | ||
|
|
60d03b91f0 | ||
|
|
8971a629cd | ||
|
|
4eb256cec2 | ||
|
|
0d2306ffb6 | ||
|
|
83355741c3 | ||
|
|
67e42f7649 | ||
|
|
952e5c95a0 | ||
|
|
514f988819 | ||
|
|
272510f8c0 | ||
|
|
37648feafd | ||
|
|
b1bccda4a8 | ||
|
|
63e571196f | ||
|
|
bc610a7f93 | ||
|
|
e4918bf688 | ||
|
|
35773c1216 | ||
|
|
fa7625befd | ||
|
|
a92879bd41 | ||
|
|
d23e931ad7 | ||
|
|
3bfb6808f4 | ||
|
|
0434c0979e | ||
|
|
65abfae9ae | ||
|
|
074e73b07a | ||
|
|
5b247ab35e | ||
|
|
2d19815e5b | ||
|
|
ea5c8d1924 | ||
|
|
8b65a47790 | ||
|
|
f353327ffd | ||
|
|
c3e5012043 |
@@ -1,8 +1,10 @@
|
||||
# FiveRP — Server Design System
|
||||
|
||||
Единый стиль для **всего** сервера: loading screen, авторизация, будущие меню
|
||||
(инвентарь, телефон, банк, HUD). Основан на белой «стеклянной» теме Filmorum
|
||||
(`fiveM_design.md`), адаптирован под CEF/NUI FiveM.
|
||||
Единый стиль для **всего** сервера: экран загрузки, авторизация, выбор
|
||||
персонажа, HUD и все будущие меню (инвентарь, телефон, банк).
|
||||
|
||||
Стиль: **Clean UI / Dark Minimalism**. Плоско, контрастно, строго.
|
||||
Никакого объёма, скевоморфизма, клейморфизма и матового стекла.
|
||||
|
||||
> Правило номер один: **ни один компонент не хардкодит цвет, радиус или шрифт.**
|
||||
> Только `var(--…)` из блока токенов ниже. Смена темы = одна строка.
|
||||
@@ -11,112 +13,85 @@
|
||||
|
||||
## 0. Шрифт — Unbounded
|
||||
|
||||
**Unbounded — основной шрифт всего сервера.** Геометрический дисплейный гротеск,
|
||||
переменная ось веса 300–800. Никаких других сансов в интерфейсе.
|
||||
Один шрифт на весь проект. Начертания: `400` Regular, `500` Medium,
|
||||
`700` Bold, `900` Black.
|
||||
|
||||
Файлы лежат в каждом ресурсе локально (`html/fonts/`), а НЕ грузятся с
|
||||
`fonts.googleapis.com`: у части игроков CEF стартует раньше сети, и веб-шрифт
|
||||
успевает не приехать — интерфейс мигает системным шрифтом. Локальный woff2
|
||||
рисуется с первого кадра.
|
||||
Шрифт лежит **локально** в каждом ресурсе (`html/fonts/*.woff2`) — CEF не
|
||||
всегда имеет доступ в интернет, а Google Fonts по CDN молча не загрузится
|
||||
и всё поедет на системный шрифт.
|
||||
|
||||
```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 теми же блоками */
|
||||
```
|
||||
Три файла и три `@font-face` с `unicode-range`: latin, latin-ext, cyrillic.
|
||||
Первый блок покрывает только `U+0000-00FF` — если ограничиться им одним,
|
||||
кириллица отвалится. Копировать блок целиком из любого готового ресурса.
|
||||
|
||||
```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` |
|
||||
| Заголовок экрана | 900 | 26–46px | UPPERCASE, `letter-spacing: 1px` |
|
||||
| Заголовок панели/карточки | 700–900 | 13–24px | UPPERCASE, `letter-spacing: 1px` |
|
||||
| Надзаголовок (eyebrow) | 500 | 10–11px | UPPERCASE, `letter-spacing: 1px` |
|
||||
| Подпись поля / label | 500 | 10px | UPPERCASE, `letter-spacing: 1px` |
|
||||
| Основной текст | 400 | 11–13px | обычный регистр |
|
||||
| Значение, цифра | 700 | 11–20px | `font-variant-numeric: tabular-nums` |
|
||||
| Кнопка, таб, чип | 700 | 10–12px | UPPERCASE, `letter-spacing: 1px` |
|
||||
|
||||
Правила:
|
||||
- **Крупный кегль = минусовой трекинг.** У Unbounded широкие апроши; без
|
||||
`letter-spacing: -0.03em` заголовок расползается.
|
||||
- **Мелкий кегль = плюсовой трекинг + uppercase.** Ниже 12px строчные буквы
|
||||
Unbounded слипаются — все подписи только капслоком с разрядкой.
|
||||
- **Веса 300 и 800 — крайности**, в интерфейсе живут 400/500/600/700.
|
||||
- **Не набирать Unbounded длинный текст.** Абзац больше двух строк — читать
|
||||
тяжело. Для многострочных описаний кегль 13px/1.65 и вес 400, не больше.
|
||||
- **Цифры документов, ID, MRZ — только `--mono`.** Unbounded для них слишком
|
||||
«дизайнерский», а нужен вид машинного считывания.
|
||||
**Минимальный размер — 10px.** Ничего мельче, даже в HUD. В HUD размеры
|
||||
заданы в `em` от `font-size` контейнера (`16px × --scale`), поэтому
|
||||
`0.63em` — нижняя допустимая граница.
|
||||
|
||||
---
|
||||
|
||||
## 1. Философия
|
||||
|
||||
- **Air, light, frosted** — полупрозрачные стеклянные панели поверх живой игры.
|
||||
- **Кислород** — между карточками 12px, между секциями 40–48px.
|
||||
- **Один акцент** — синий `#0071e3`, дозированно: кнопка, ссылка, фокус.
|
||||
- **Сдержанное движение** — fade и подъём на 4px. Анимации ради анимации нет.
|
||||
- **Один герой на экран** — крупный объект (паспорт, лого), остальное тихое.
|
||||
* Плоскость. Глубина создаётся **границей и отступом**, а не тенью.
|
||||
* Воздух. Много пустого места, `padding` карточек 20–25px.
|
||||
* Один акцент. Оранжевый — только для активного и главного. Если на экране
|
||||
три оранжевых элемента, два из них лишние.
|
||||
* Резкость. `transition: all 0.2s ease`. Никакой «желейности» и пружин.
|
||||
|
||||
---
|
||||
|
||||
## 2. Токены
|
||||
|
||||
Копировать в `:root` каждого нового интерфейса без изменений.
|
||||
|
||||
```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;
|
||||
--bg: rgba(24, 24, 24, 1); /* #181818 — фон приложения */
|
||||
--card-bg: rgba(30, 30, 30, 1); /* #1E1E1E — карточки, панели */
|
||||
--btn-bg: rgba(51, 51, 51, 1); /* #333333 — кнопки, треки, чипы */
|
||||
--btn-hover: rgba(70, 70, 70, 1); /* #464646 — hover */
|
||||
--accent: rgba(255, 119, 22, 1); /* #FF7716 — акцент */
|
||||
--text-primary: rgba(241, 241, 241, 1); /* #F1F1F1 — текст, иконки */
|
||||
--text-secondary: rgba(153, 153, 153, 1); /* #999999 — подписи, цифры */
|
||||
--border: rgba(51, 51, 51, 0.5); /* тонкая рамка вместо тени */
|
||||
|
||||
--fg: #1d1d1f;
|
||||
--fg-secondary: #6e6e73;
|
||||
--fg-tertiary: #a1a1a6;
|
||||
--sans: 'Unbounded', sans-serif;
|
||||
}
|
||||
|
||||
--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;
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
font-family: var(--sans);
|
||||
user-select: none;
|
||||
}
|
||||
```
|
||||
|
||||
Тёмная и бежевая темы — через `data-theme` на `html`, переопределением тех же
|
||||
токенов (`--bg`, `--surface`, `--fg`, `--accent`, `--success`, `--danger`).
|
||||
`user-select: none` на всём — кроме `input`, там нужен `user-select: text`,
|
||||
иначе игрок не выделит то, что печатает.
|
||||
|
||||
### Геометрия
|
||||
|
||||
| Элемент | Радиус |
|
||||
|---|---|
|
||||
| Карточка, панель, модалка | `16px` |
|
||||
| Кнопка, поле ввода, notice | `12px` |
|
||||
| Таб, чип, бейдж, переключатель (pill) | `20px` |
|
||||
| Контейнер сегментированного контрола | `20–30px` |
|
||||
|
||||
Рамка везде одна: `1px solid var(--border)`.
|
||||
|
||||
---
|
||||
|
||||
@@ -125,155 +100,175 @@
|
||||
Это не «хорошо бы», а условия, при нарушении которых интерфейс ломается
|
||||
в игре, оставаясь идеальным в браузере.
|
||||
|
||||
1. **`backdrop-filter` и `box-shadow` не живут на одном элементе.**
|
||||
Композитор CEF рисует тень такого элемента непрозрачным прямоугольником —
|
||||
в игре под панелью появляется чёрный квадрат. Если нужны и блюр, и тень —
|
||||
тень вешать на элемент-обёртку, а блюр на внутренний. По умолчанию
|
||||
**у стеклянных панелей тени нет вообще**: их отделяет от игры блюр и
|
||||
светлая граница, этого достаточно.
|
||||
1. **Ни `backdrop-filter`, ни `box-shadow`.** Композитор CEF рисует и то,
|
||||
и другое непрозрачным прямоугольником — в игре под панелью появляется
|
||||
чёрный квадрат. В текущей теме они и не нужны: панель отделяет от игры
|
||||
сплошная заливка `--card-bg` и рамка `--border`. Запрет абсолютный, в том
|
||||
числе для hover-состояний.
|
||||
|
||||
2. **`html, body { background: transparent }`** — иначе NUI закрашивает игру.
|
||||
2. **`html, body { background: transparent }`** для всего, что рисуется
|
||||
поверх игры (HUD, выбор персонажа). Иначе NUI закрасит кадр. Экран
|
||||
загрузки и авторизация владеют экраном целиком — им положен `--bg`.
|
||||
|
||||
3. **Никаких затемняющих подложек.** Экран за панелью — это игра, её видно
|
||||
и она красивая. Контраст добирается блюром и белой заливкой стекла, а не
|
||||
`rgba(0,0,0,…)` поверх кадра. Допустим только **осветляющий** вуаль:
|
||||
`radial-gradient(…, rgba(255,255,255,0.28), transparent)`.
|
||||
3. **Шрифты — локальные woff2.** См. раздел 0.
|
||||
|
||||
4. **Шрифты — локальные woff2.** См. раздел 0.
|
||||
4. **Иконки — только SVG-штрихом**, `stroke: currentColor`, `fill: none`.
|
||||
Эмодзи в CEF отрисуются пустыми прямоугольниками: цветного эмодзи-шрифта
|
||||
там нет.
|
||||
|
||||
5. **`prefers-reduced-motion`** — обязательный блок с самого начала.
|
||||
|
||||
6. **Курсор и фокус** — `SetNuiFocus(true, true)` только когда панель открыта,
|
||||
5. **Курсор и фокус** — `SetNuiFocus(true, true)` только пока панель открыта,
|
||||
и обязательно `SetNuiFocus(false, false)` при закрытии, иначе игрок
|
||||
остаётся без управления.
|
||||
останется без управления.
|
||||
|
||||
---
|
||||
|
||||
## 4. Стеклянная панель
|
||||
## 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 */
|
||||
.card {
|
||||
background-color: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 25px;
|
||||
}
|
||||
```
|
||||
|
||||
Тень разрешена только элементам **без** `backdrop-filter` (карточки на
|
||||
непрозрачном фоне, экран загрузки):
|
||||
Разделитель внутри карточки — `border-top: 1px solid var(--border)`
|
||||
вместе с симметричным `padding`, а не отдельный `<hr>` и не смена фона.
|
||||
|
||||
Hover у кликабельной карточки:
|
||||
|
||||
```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); }
|
||||
.card:hover {
|
||||
background-color: var(--btn-bg);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
```
|
||||
|
||||
Ховер кликабельной карточки: `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);
|
||||
```css
|
||||
/* главное действие */
|
||||
.btn-primary {
|
||||
background-color: var(--accent);
|
||||
color: var(--bg); /* тёмный текст на акценте — максимум контраста */
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
padding: 14px 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.btn-ghost:hover { background: rgba(0,113,227,.14); }
|
||||
.btn-primary:hover { transform: translateY(-2px); }
|
||||
|
||||
/* всё остальное */
|
||||
.btn-ghost {
|
||||
background-color: var(--btn-bg);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
/* типографика та же */
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.btn-ghost:hover { background-color: var(--btn-hover); }
|
||||
```
|
||||
|
||||
Ровно два стиля кнопок на весь сервер. Третий не заводить.
|
||||
**На одном экране ровно одна `.btn-primary`.**
|
||||
|
||||
Табы, чипы и сегментированные контролы — та же логика в pill-форме:
|
||||
неактивный `--btn-bg` + `--text-secondary`, активный `--accent` + `--bg`.
|
||||
|
||||
Опасное действие (удаление) — это `.btn-ghost` с `--text-secondary`,
|
||||
который становится акцентным на hover. Отдельного красного в палитре нет,
|
||||
и заводить его не нужно: подтверждение делается вторым нажатием, а не цветом.
|
||||
|
||||
---
|
||||
|
||||
## 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;
|
||||
input {
|
||||
background-color: var(--btn-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
outline: none;
|
||||
transition: background-color .2s, border-color .2s, box-shadow .2s;
|
||||
user-select: text;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.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); }
|
||||
input:focus { border-color: var(--accent); }
|
||||
input.is-bad { border-color: var(--accent); }
|
||||
input::placeholder { color: var(--text-secondary); }
|
||||
```
|
||||
|
||||
Лейбл над полем — 11px/600/`+0.09em`/uppercase/`--fg-secondary`.
|
||||
Ошибка показывается рамкой и текстом в `.notice`, а не тряской поля.
|
||||
|
||||
---
|
||||
|
||||
## 7. Компоновка
|
||||
|
||||
- Панель по центру экрана, ширина `min(940px, 92vw)`, радиус `--radius-lg`.
|
||||
- Внутренние отступы панели 32–40px.
|
||||
- Между полями формы 16px, между группами 24px.
|
||||
- Двухколоночная схема «форма + герой»: `grid-template-columns: minmax(0,1fr) 420px`.
|
||||
- Сворачивание в одну колонку — анимацией `grid-template-columns` и `width`,
|
||||
620ms `--ease`.
|
||||
Grid и Flexbox. Никаких плавающих и абсолютных раскладок там, где хватает
|
||||
сетки. Между карточками — `gap: 20px`, внутри карточки между блоками —
|
||||
`14–22px`.
|
||||
|
||||
Ширины, принятые в проекте: панель формы `440px`, карточка персонажа `300px`,
|
||||
боковая панель редактора `420px`, модалка настроек `min(560px, 84vw)`.
|
||||
|
||||
---
|
||||
|
||||
## 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 }
|
||||
transition: all 0.2s ease;
|
||||
```
|
||||
|
||||
@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;
|
||||
}
|
||||
```css
|
||||
.fade-up { animation: fade-up 0.4s ease both; }
|
||||
@keyframes fade-up {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
```
|
||||
|
||||
Задержки `.delay-1…4` шагом 60ms, не больше четырёх ступеней.
|
||||
|
||||
В каждом интерфейсе есть класс-выключатель для headless-скриншотов:
|
||||
|
||||
```css
|
||||
body.no-anim *, body.no-anim *::before, body.no-anim *::after {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
```
|
||||
|
||||
Без него `--virtual-time-budget` снимает первый, ещё прозрачный кадр:
|
||||
он не прокручивает CSS-анимации.
|
||||
|
||||
---
|
||||
|
||||
## 9. Фокус и доступность
|
||||
## 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` только для
|
||||
плейсхолдеров и декоративных подписей, никогда для смысловых.
|
||||
1. **Теней.** Ни `box-shadow`, ни `filter: drop-shadow`, ни `inset`-теней.
|
||||
(`inset: 0` как сокращение для `top/right/bottom/left` — это другое
|
||||
свойство, оно разрешено и используется повсеместно.)
|
||||
2. **Градиентов.** Только сплошные заливки.
|
||||
3. **Блюра.** Ни `backdrop-filter`, ни `filter: blur`.
|
||||
4. **Скевоморфизма.** Ничего, что притворяется бумагой, металлом, стеклом
|
||||
или физической кнопкой.
|
||||
5. **Цветов вне палитры.** Восемь токенов из раздела 2 — это весь набор.
|
||||
|
||||
---
|
||||
|
||||
@@ -282,77 +277,42 @@
|
||||
| Ресурс | Что это | Файлы |
|
||||
|---|---|---|
|
||||
| `fiverp-loadscreen` | экран загрузки сервера | `html/index.html`, `style.css`, `app.js` |
|
||||
| `fiverp-auth` | регистрация и вход, паспорт США | `html/index.html`, `style.css`, `app.js` |
|
||||
| `fiverp-auth` | регистрация и вход | `html/index.html`, `style.css`, `app.js` |
|
||||
| `fiverp-characters` | выбор персонажа и редактор внешности | `html/index.html`, `style.css`, `app.js` |
|
||||
| `fiverp-hud` | игровой HUD и его настройки | `html/index.html`, `style.css`, `app.js` |
|
||||
|
||||
`fiverp-auth` использует один «герой» — разворот паспорта США (страница с
|
||||
данными: фото, поля, MRZ). Паспорт — единственный насыщенный цветом объект
|
||||
на экране; форма рядом с ним остаётся полностью нейтральной.
|
||||
`fiverp-auth` показывает карточку паспорта, которая заполняется по мере
|
||||
ввода имени. Это **плоская карточка с данными**, а не изображение документа:
|
||||
никакой бумаги, гильошей и бликов ламината.
|
||||
|
||||
`fiverp-hud` живёт поверх игры и потому не имеет собственного фона; его
|
||||
масштаб и прозрачность задаются переменными `--scale` и `--hud-opacity`
|
||||
на `#hud`, которые проставляет `applyPrefs()`.
|
||||
|
||||
Раскладка HUD (решения приняты и менять их без причины не нужно):
|
||||
|
||||
* Шкалы состояния и индикатор рации — колонкой **справа от миникарты**.
|
||||
Габариты радара на 1080p измерены по кадру: правый край `15.6vw`, низ
|
||||
`1.7vh`. Колонка начинается с `16.4vw` и растёт вверх от той же линии.
|
||||
* Цвета шкал — **строго палитра раздела 2**. Никаких «зелёное здоровье,
|
||||
синяя броня»: акцент только на низком значении и на активной рации.
|
||||
* Карточка сервера — сверху справа, кошелёк и панель авто — снизу справа.
|
||||
* Пока игрок не в мире (авторизация, выбор персонажа), игровой HUD скрыт
|
||||
целиком: `DisplayRadar(false)` плюс `HideHudComponentThisFrame` на
|
||||
деньги, звёзды розыска и названия улиц. `DisplayRadar` — залипающий
|
||||
флаг, его возвращают в `true` в момент спавна.
|
||||
|
||||
---
|
||||
|
||||
## 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 и **посмотреть глазами** до выката.
|
||||
|
||||
---
|
||||
|
||||
## 12. Тёмный слой: HUD и всё, что живёт поверх игры
|
||||
|
||||
Разделы 1–11 описывают **меню** — экраны, которые забирают себе внимание
|
||||
целиком. HUD — другой случай, и правила у него другие.
|
||||
|
||||
Меню открыто минуту, HUD висит перед глазами часами. Светлая панель в углу —
|
||||
первое, что игрок выключает: она спорит с кадром, светит в тёмных сценах и
|
||||
перекрывает игру, ради которой он здесь. Поэтому **оверлей поверх игры —
|
||||
тёмный**, и это не «вторая тема», а другой класс интерфейса.
|
||||
|
||||
```css
|
||||
:root {
|
||||
--panel: rgba(12, 14, 18, 0.68); /* основная подложка */
|
||||
--panel-deep: rgba(12, 14, 18, 0.82); /* когда нужно больше контраста */
|
||||
--inset: rgba(255, 255, 255, 0.06);/* вложенная поверхность: тег, чип */
|
||||
|
||||
--line: rgba(255, 255, 255, 0.09);
|
||||
--line-soft: rgba(255, 255, 255, 0.05);
|
||||
|
||||
--fg: #f4f5f7;
|
||||
--fg-2: rgba(244, 245, 247, 0.60);
|
||||
--fg-3: rgba(244, 245, 247, 0.34);
|
||||
|
||||
--accent: #4b8dff; /* синий из раздела 2, поднятый под тёмный фон */
|
||||
--radius: 14px; /* HUD компактнее меню: 14/9, а не 24/16 */
|
||||
}
|
||||
```
|
||||
|
||||
Правила поверх общих:
|
||||
|
||||
1. **Контраст даёт подложка и волосяная граница, не тень и не блюр.**
|
||||
Запрет `box-shadow` и `backdrop-filter` из раздела 3 действует и здесь.
|
||||
2. **Затемняющий слой на весь экран запрещён** (раздел 3.3) — но сама панель
|
||||
HUD тёмная. Это не противоречие: затемняется область панели, а не кадр.
|
||||
Единственное исключение — скрим под открытой панелью настроек: пока она
|
||||
открыта, игрок не играет.
|
||||
3. **Ничего не рисовать дважды.** Здоровье и броню игра уже показывает на
|
||||
миникарте — в HUD их нет. Прежде чем добавить показатель, проверь, не
|
||||
рисует ли его уже сам GTA.
|
||||
4. **Размеры в `vh`/`vw`, не в `px`.** Пиксельный HUD, собранный на 1080p,
|
||||
на 1440p становится нечитаемо мелким. Базовый кегль:
|
||||
`font-size: calc(1.75vh * var(--scale, 1))`, всё внутри — в `em`.
|
||||
5. **Углы экрана заняты игрой.** Левый нижний — радар; HUD туда не лезет.
|
||||
6. **Цифры — `font-variant-numeric: tabular-nums`.** Иначе счётчик денег и
|
||||
спидометр дёргают соседние элементы на каждом изменении.
|
||||
7. **`pointer-events: none` на всём слое.** Игрок ведёт машину, а не работает
|
||||
с интерфейсом. Клики принимает только открытая панель настроек.
|
||||
|
||||
| Ресурс | Класс | Тема |
|
||||
|---|---|---|
|
||||
| `fiverp-loadscreen`, `fiverp-auth` | меню | светлая, разделы 1–11 |
|
||||
| `fiverp-hud` | оверлей | тёмная, раздел 12 |
|
||||
1. Скопировать `html/fonts/` и блок `:root` из любого готового ресурса.
|
||||
2. Поверх игры — `html, body { background: transparent }`; на весь экран —
|
||||
`background-color: var(--bg)`.
|
||||
3. Панель — карточка из раздела 4, **без** `box-shadow`.
|
||||
4. Ровно одна `.btn-primary` на экран.
|
||||
5. Заголовки UPPERCASE, вес 700–900, `letter-spacing: 1px`.
|
||||
6. Ничего мельче 10px.
|
||||
7. Иконки — SVG-штрихом, не эмодзи.
|
||||
8. Есть `body.no-anim` для скриншотов.
|
||||
9. Прогнать глазами: нет градиентов, теней, блюра, цветов вне палитры.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# FiveRP — 0.1
|
||||
# FiveRP — 0.2
|
||||
|
||||
A roleplay base for FiveM, written from scratch rather than forked from a
|
||||
framework. Eight resources: an account login, a character creator, permanent
|
||||
@@ -29,12 +29,46 @@ Running against FXServer **25770** (recommended channel), GTA V game build
|
||||
|
||||

|
||||
|
||||
**HUD** — a dark overlay that never repeats what the game already draws. There
|
||||
is no health ring: health is on the minimap already. The bottom-left corner is
|
||||
left to the radar.
|
||||
**Character select** — three slots per account, each one deleted only on a
|
||||
second, deliberate press.
|
||||
|
||||

|
||||
|
||||
**Appearance editor** — heritage, face, hair, skin and clothes, with the ped
|
||||
rendered live by the game beside the panel.
|
||||
|
||||

|
||||
|
||||
**HUD** — a flat overlay that never repeats what the game already draws. There
|
||||
is no health ring: health is on the minimap already. The condition rings and the
|
||||
voice indicator stand in a column beside the radar, measured to its right edge
|
||||
so the two line up at 1080p.
|
||||
|
||||

|
||||
|
||||
**Inventory** (**I**) — sixteen bag slots and five worn ones. Drag to move, drop
|
||||
onto the strip to put something on the ground, right-click to use or to wear.
|
||||
|
||||

|
||||
|
||||
**Chat and notifications** — the chat is for things people said; everything the
|
||||
server tells you is a toast on the right.
|
||||
|
||||

|
||||
|
||||
**Dealership** — the car stands in front of you, in the colour you picked,
|
||||
before any money moves.
|
||||
|
||||

|
||||
|
||||
**Garage** — any attendant fetches one of yours or puts away the one you are in.
|
||||
|
||||

|
||||
|
||||
**Berry buyer** — sells the shears, buys what you bring back.
|
||||
|
||||

|
||||
|
||||
**HUD settings** (`/hud`) — every module can be turned off, and the choice is
|
||||
saved on that computer, not on the character.
|
||||
|
||||
@@ -53,6 +87,15 @@ saved on that computer, not on the character.
|
||||
| `fiverp-economy` | Cash and bank, every movement written to a ledger |
|
||||
| `fiverp-status` | Hunger, thirst and mood, persisted per character |
|
||||
| `fiverp-time` | One clock for the whole server, running at real speed |
|
||||
| `fiverp-vehicles` | Dealership, ownership and garages |
|
||||
| `fiverp-robbery` | Holding up a passer-by at gunpoint |
|
||||
| `fiverp-notify` | Toasts, so the chat stays a place people talk |
|
||||
| `fiverp-admin` | Staff commands, gated on the `fiverp.admin` ace |
|
||||
| `fiverp-berries` | Berry picking: shears, four patches, and a buyer in Paleto |
|
||||
| `fiverp-inventory` | 16 bag slots, five worn slots, and what ends up on the pavement |
|
||||
| `fiverp-interact` | The hold-to-act marker every other resource uses |
|
||||
| `fiverp-chat` | Chat, and the command list behind the slash key |
|
||||
| `fiverp-bank` | Branches, tellers, 121 ATMs and every move between accounts |
|
||||
| `fiverp-hud` | The in-game overlay and its settings panel |
|
||||
|
||||
Server logic is JavaScript (Node 22, shipped inside FXServer), client logic is
|
||||
@@ -194,11 +237,147 @@ add_ace group.admin fiverp.economy allow
|
||||
| `/setmoney <id> <cash\|bank> <amount>` | admin | Set a balance outright |
|
||||
| `/setneed <id> <hunger\|thirst\|mood> <0-100>` | admin | Set a need |
|
||||
|
||||
Keys: **B** seatbelt, **L** lock or unlock the car you are in or looking at.
|
||||
Vehicles: the forecourt outside Premium Deluxe — outdoors, because FiveM does not
|
||||
stream that building's interior. Hold **E** at the salesman, pick a car
|
||||
and a colour, and it appears on the turntable in front of you before you pay. It
|
||||
is bought from the bank, gets a plate of its own, and belongs to the character
|
||||
who bought it. Any garage attendant will fetch one of yours or put the one you
|
||||
are sitting in away. Anything left in the world goes back to a garage when you
|
||||
disconnect or the server restarts.
|
||||
|
||||
Robbery: draw a gun and walk up to someone. A marker appears on them, hold **E**,
|
||||
their hands go up and you go through their pockets — $500 to $1,400. A minute and
|
||||
a half before you can do it again, and half an hour before that particular person
|
||||
has anything left.
|
||||
|
||||
Berry picking: the buyer in Paleto Bay sells shears for $150 — without them no
|
||||
bush will let you work, because cutting is what leaves it alive to fruit again.
|
||||
Four patches are marked on the map; hold **E** at a bush to cut, and it goes
|
||||
quiet for three minutes afterwards for everyone. Four kinds grow, from common
|
||||
blackberries at $12 to cloudberries at $130. Eat them for hunger, a little
|
||||
thirst and a lift, or sell the lot back at the counter.
|
||||
|
||||
Inventory: **I** opens it. Sixteen bag slots and five worn ones — headwear,
|
||||
outerwear, undershirt, trousers, shoes. Drag to move, drop onto the strip at the
|
||||
bottom to put something on the ground, double-click or right-click to use it.
|
||||
A dropped item becomes a real object you can walk back to and pick up.
|
||||
|
||||
The clothes the creator put on your character are adopted into the worn slots a
|
||||
moment after you spawn, so you can take them off and stand there in your
|
||||
underwear. They go into the bag like anything else and can be put back on.
|
||||
|
||||
`shared/items.lua` is the item registry and is empty apart from five garment
|
||||
carriers, one per worn slot — those are the mechanism, not content; the comment
|
||||
at the top is the shape an entry takes — label, description, weight, action and
|
||||
the world model it becomes when dropped. `action` is a name, not code: the
|
||||
inventory announces it and whichever resource owns that effect handles it.
|
||||
|
||||
Interacting: anything you can act on shows a dial where it is. Walk up, hold **E**,
|
||||
and a ring fills around the key — the action only fires when it is full. Letting go
|
||||
early cancels it. A tap does nothing, deliberately: nothing that moves money should
|
||||
be one twitch away.
|
||||
|
||||
Banking: hold **E** at a teller or an ATM, or type `/bank` while standing at one. A teller
|
||||
deposits, withdraws and transfers to another player by ID, all free. An ATM does
|
||||
the first two only and keeps 3% of a withdrawal, minimum $5 — walking into a
|
||||
branch is what saves you the fee.
|
||||
|
||||
Chat: **T** opens it, `/` lists every command registered on the server and keeps
|
||||
narrowing as you type. Tab or Enter completes the highlighted one. A row of
|
||||
buttons fills in the roleplay channels for you.
|
||||
|
||||
| Channel | Reaches | Reads as |
|
||||
| --- | --- | --- |
|
||||
| plain text | 20 m | `Vinni Admin (14): text` — the name, and the id everyone can see |
|
||||
| `/me` | 20 m | *Marcus Reyes counts the notes* |
|
||||
| `/do` | 20 m | *the machine is already dead ((Marcus Reyes))* |
|
||||
| `/try` | 20 m | the attempt, then **succeeds** or **fails** — the server flips it |
|
||||
| `/s` | 45 m | shouted |
|
||||
| `/w` | 4 m | whispered |
|
||||
| `/b` | 20 m | `(( out of character ))` |
|
||||
|
||||
Range is measured server-side from both players' positions, so it cannot be
|
||||
argued with from a client.
|
||||
|
||||
Anything the server tells you — money arriving, a bush picked bare, a command
|
||||
refused — is a toast on the right rather than a line of chat:
|
||||
|
||||
```lua
|
||||
exports['fiverp-notify']:show('Berries', '**3** × Blackberries', 'good')
|
||||
```
|
||||
```js
|
||||
exports['fiverp-notify'].notify(src, 'Bank', '**$2,500** arrived', 'good')
|
||||
```
|
||||
|
||||
`kind` is `good`, `bad` or `info`; only `good` spends the accent. Wrap a number
|
||||
in `**stars**` to set it apart. New resources should use this — a toast is read
|
||||
once and forgotten, and the chat is for things people said.
|
||||
|
||||
Staff commands need `add_ace group.admin fiverp.admin allow`. `/revive [id]`
|
||||
brings someone back up, `/noclip` flies (shift faster, alt finer, space and
|
||||
ctrl for height), `/tpm` teleports to your map marker, `/coords` prints where you are standing in the
|
||||
form the config files want, to the screen and to the server console. To anyone without the
|
||||
ace they answer "Unknown command" — a refusal that names what it refused is a
|
||||
list of staff commands for anyone who guesses.
|
||||
|
||||
Keys: **B** seatbelt, **L** lock or unlock the car you are in or looking at,
|
||||
**Z** cycle voice range (whisper 3 m / normal 8 m / shout 20 m — applied to
|
||||
Mumble, so the HUD shows how far you actually carry).
|
||||
|
||||
---
|
||||
|
||||
## 0.1 — what this version is
|
||||
## 0.2 — what this version adds
|
||||
|
||||
Everything in 0.1 still holds. On top of it:
|
||||
|
||||
- **A new look.** Every interface was rewritten to one style — Clean UI / Dark
|
||||
Minimalism, eight colour tokens, flat surfaces, a single orange accent, and
|
||||
no gradients, shadows or blur. The design system in `CLAUDE.md` was rewritten
|
||||
with it, so the next screen matches without anyone having to remember.
|
||||
- **Chat**, replacing the stock one. Typing `/` lists every command that exists
|
||||
on the server and narrows as you type. Roleplay channels — `/me`, `/do`,
|
||||
`/try`, `/s`, `/w`, `/b` — each with a range the server measures.
|
||||
- **Notifications**, so the chat can stay a place people talk.
|
||||
- **Hold-to-act markers.** Anything you can interact with shows a dial where it
|
||||
is; hold **E** and a ring fills. Nothing important is one tap away.
|
||||
- **Banking.** Eight branches with tellers, 121 ATMs. Deposits, withdrawals,
|
||||
and transfers by player ID at a teller; an ATM does the first two and keeps
|
||||
3%.
|
||||
- **Inventory.** Sixteen bag slots and five worn ones, drag and drop, and
|
||||
dropped items become real objects you can walk back to. The clothes your
|
||||
character is already wearing are adopted into the worn slots, so you can take
|
||||
them off.
|
||||
- **Weapons** as inventory items — the bag is the truth, and the ped is made to
|
||||
match it.
|
||||
- **Berry picking**, a full job: buy shears, work four patches, eat what you
|
||||
pick or sell it back.
|
||||
- **Vehicles.** A dealership where the car stands in front of you in the colour
|
||||
you chose before any money moves, ownership tied to the character, and
|
||||
garages that fetch and store.
|
||||
- **Robbery.** Draw a gun, hold up a passer-by, go through their pockets.
|
||||
- **Staff commands**: `/revive`, `/noclip`, `/tpm`, `/coords`.
|
||||
|
||||
Known limits, honestly:
|
||||
|
||||
- Most of this has been verified by rendering the interfaces and by reading the
|
||||
logs, not by playing it with several people. Placement in the world — where a
|
||||
ped stands, where a car appears — is the part most likely to be off, which is
|
||||
why `/coords` exists.
|
||||
- The item registry ships with berries, clothing carriers and three weapons.
|
||||
There is no economy of goods yet, and nothing to buy with what a job pays
|
||||
except a car.
|
||||
- Worn clothing is one icon per slot, not per garment. A jacket and a shirt are
|
||||
both `worn_top` and draw the same picture.
|
||||
- Vehicles have no fuel, no damage persistence and no keys beyond ownership.
|
||||
Locking is the HUD's `L`, which is the game's own lock and nothing more.
|
||||
- Robbery has no consequence attached: no police response, no witnesses, no
|
||||
wanted level.
|
||||
- Still no migration runner. `sql/schema.sql` is idempotent — every table is
|
||||
`CREATE TABLE IF NOT EXISTS` — so re-running it is how you upgrade.
|
||||
|
||||
---
|
||||
|
||||
## 0.1 — what the first version was
|
||||
|
||||
First tagged version. What works:
|
||||
|
||||
@@ -212,17 +391,7 @@ First tagged version. What works:
|
||||
player's, and the date is the real date
|
||||
- The HUD, with per-computer settings
|
||||
|
||||
Known limits, honestly:
|
||||
|
||||
- Nothing here has been load-tested with a full server. It has been run and
|
||||
verified with a handful of players.
|
||||
- No jobs, no vehicles you can own, no inventory. Money exists, but nothing
|
||||
worth spending it on ships in this version.
|
||||
- The vehicle panel's fuel is client-side. Fine for a HUD, not something to
|
||||
build a fuel economy on later without moving it server-side first.
|
||||
- `sql/schema.sql` is the only migration path. There is no migration runner yet,
|
||||
so upgrades between versions will be hand-applied SQL until there is.
|
||||
|
||||
`CLAUDE.md` in the root is the design system and the working rules for this
|
||||
codebase — read it before adding an interface, especially section 12, which is
|
||||
why the HUD is dark and the menus are light.
|
||||
codebase — read it before adding an interface. Every screen follows one style,
|
||||
Clean UI / Dark Minimalism: eight colour tokens, flat surfaces, a single orange
|
||||
accent, and no gradients, shadows or blur anywhere.
|
||||
|
||||
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 823 KiB After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 646 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 669 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 790 KiB After Width: | Height: | Size: 61 KiB |
@@ -0,0 +1,174 @@
|
||||
-- Staff commands — the client half.
|
||||
-- Nothing here is a permission. Each of these only runs because the server sent
|
||||
-- the event, and the server is where the ace was checked.
|
||||
|
||||
local noclip = false
|
||||
local noclipSpeed = 1.0
|
||||
|
||||
-- ---------------------------------------------------------------- revive
|
||||
RegisterNetEvent('fiverp-admin:revive', function()
|
||||
local ped = PlayerPedId()
|
||||
local coords = GetEntityCoords(ped)
|
||||
|
||||
if IsEntityDead(ped) then
|
||||
-- Resurrect first, then put everything back: the natives below are ignored
|
||||
-- on a corpse.
|
||||
NetworkResurrectLocalPlayer(coords.x, coords.y, coords.z,
|
||||
GetEntityHeading(ped), true, false)
|
||||
end
|
||||
|
||||
SetEntityHealth(ped, GetEntityMaxHealth(ped))
|
||||
ClearPedBloodDamage(ped)
|
||||
ClearPedTasksImmediately(ped)
|
||||
SetPlayerInvincible(PlayerId(), false)
|
||||
SetEntityInvincible(ped, false)
|
||||
FreezeEntityPosition(ped, false)
|
||||
end)
|
||||
|
||||
-- ------------------------------------------------------------------- tpm
|
||||
RegisterNetEvent('fiverp-admin:tpm', function()
|
||||
local blip = GetFirstBlipInfoId(8) -- the waypoint
|
||||
if not DoesBlipExist(blip) then
|
||||
TriggerServerEvent('fiverp-admin:tpmDone', false)
|
||||
return
|
||||
end
|
||||
|
||||
local at = GetBlipInfoIdCoord(blip)
|
||||
local ped = PlayerPedId()
|
||||
local entity = IsPedInAnyVehicle(ped, false) and GetVehiclePedIsIn(ped, false) or ped
|
||||
|
||||
-- A waypoint carries no height, so the ground has to be found. Sweeping down
|
||||
-- from a series of heights is the only reliable way: a single probe answers
|
||||
-- from whatever happens to be under that exact altitude, and answers nothing
|
||||
-- while the world there is still streaming in.
|
||||
FreezeEntityPosition(entity, true)
|
||||
DoScreenFadeOut(250)
|
||||
Wait(300)
|
||||
|
||||
local found, z = false, 0.0
|
||||
for _, from in ipairs({ 1000.0, 700.0, 400.0, 200.0, 100.0, 50.0, 20.0 }) do
|
||||
SetEntityCoordsNoOffset(entity, at.x, at.y, from, false, false, false)
|
||||
RequestCollisionAtCoord(at.x, at.y, from)
|
||||
|
||||
local deadline = GetGameTimer() + 900
|
||||
while not HasCollisionLoadedAroundEntity(entity) and GetGameTimer() < deadline do
|
||||
Wait(0)
|
||||
end
|
||||
|
||||
found, z = GetGroundZFor_3dCoord(at.x, at.y, from, false)
|
||||
if found then break end
|
||||
end
|
||||
|
||||
SetEntityCoordsNoOffset(entity, at.x, at.y, (found and z or 100.0) + 1.0,
|
||||
false, false, false)
|
||||
|
||||
FreezeEntityPosition(entity, false)
|
||||
DoScreenFadeIn(350)
|
||||
TriggerServerEvent('fiverp-admin:tpmDone', true)
|
||||
end)
|
||||
|
||||
-- ---------------------------------------------------------------- coords
|
||||
RegisterNetEvent('fiverp-admin:coords', function()
|
||||
local ped = PlayerPedId()
|
||||
local c = GetEntityCoords(ped)
|
||||
local h = GetEntityHeading(ped)
|
||||
|
||||
local text = ('vector4(%.2f, %.2f, %.2f, %.1f)'):format(c.x, c.y, c.z, h)
|
||||
TriggerServerEvent('fiverp-admin:coordsAt', text)
|
||||
|
||||
-- Also on screen, because the point of the command is to read it.
|
||||
exports['fiverp-notify']:show('Coords', text, 'info', 12000)
|
||||
print('[coords] ' .. text)
|
||||
end)
|
||||
|
||||
-- ---------------------------------------------------------------- noclip
|
||||
local function stopNoclip(ped)
|
||||
noclip = false
|
||||
SetEntityCollision(ped, true, true)
|
||||
SetEntityVisible(ped, true, false)
|
||||
SetEntityInvincible(ped, false)
|
||||
FreezeEntityPosition(ped, false)
|
||||
SetEveryoneIgnorePlayer(PlayerId(), false)
|
||||
end
|
||||
|
||||
RegisterNetEvent('fiverp-admin:noclip', function()
|
||||
local ped = PlayerPedId()
|
||||
|
||||
if noclip then
|
||||
stopNoclip(ped)
|
||||
return
|
||||
end
|
||||
|
||||
noclip = true
|
||||
noclipSpeed = 1.0
|
||||
SetEntityCollision(ped, false, false)
|
||||
SetEntityVisible(ped, false, false)
|
||||
SetEntityInvincible(ped, true)
|
||||
SetEveryoneIgnorePlayer(PlayerId(), true)
|
||||
|
||||
CreateThread(function()
|
||||
while noclip do
|
||||
local me = PlayerPedId()
|
||||
FreezeEntityPosition(me, true)
|
||||
|
||||
-- Movement follows the camera, not the ped: while flying, where you are
|
||||
-- looking is the only direction that means anything.
|
||||
local cam = GetGameplayCamRot(2)
|
||||
local pitch, yaw = math.rad(cam.x), math.rad(cam.z)
|
||||
local cosPitch = math.cos(pitch)
|
||||
|
||||
local forward = vector3(-math.sin(yaw) * cosPitch,
|
||||
math.cos(yaw) * cosPitch,
|
||||
math.sin(pitch))
|
||||
local right = vector3(math.cos(yaw), math.sin(yaw), 0.0)
|
||||
|
||||
local pos = GetEntityCoords(me)
|
||||
local move = vector3(0.0, 0.0, 0.0)
|
||||
|
||||
if IsControlPressed(0, 32) then move = move + forward end -- W
|
||||
if IsControlPressed(0, 33) then move = move - forward end -- S
|
||||
if IsControlPressed(0, 34) then move = move - right end -- A
|
||||
if IsControlPressed(0, 35) then move = move + right end -- D
|
||||
if IsControlPressed(0, 22) then move = move + vector3(0.0, 0.0, 1.0) end -- space
|
||||
if IsControlPressed(0, 36) then move = move - vector3(0.0, 0.0, 1.0) end -- ctrl
|
||||
|
||||
-- Shift is faster, alt is finer. Held rather than toggled, because the
|
||||
-- speed you want changes several times crossing one room.
|
||||
local speed = noclipSpeed
|
||||
if IsControlPressed(0, 21) then speed = speed * 4.0 end
|
||||
if IsControlPressed(0, 19) then speed = speed * 0.25 end
|
||||
|
||||
if #move > 0.0 then
|
||||
SetEntityCoordsNoOffset(me,
|
||||
pos.x + move.x * speed, pos.y + move.y * speed, pos.z + move.z * speed,
|
||||
false, false, false)
|
||||
end
|
||||
|
||||
SetEntityHeading(me, math.deg(-yaw) % 360.0)
|
||||
|
||||
-- The world does not stream around an entity that is not colliding with
|
||||
-- it, so ask for it directly or flying ends up over a hole.
|
||||
RequestCollisionAtCoord(pos.x, pos.y, pos.z)
|
||||
|
||||
DisableControlAction(0, 21, true)
|
||||
DisableControlAction(0, 22, true)
|
||||
|
||||
Wait(0)
|
||||
end
|
||||
|
||||
local me = PlayerPedId()
|
||||
FreezeEntityPosition(me, false)
|
||||
-- Land on whatever is under the exit point rather than in it.
|
||||
local pos = GetEntityCoords(me)
|
||||
local found, z = GetGroundZFor_3dCoord(pos.x, pos.y, pos.z, false)
|
||||
if found then
|
||||
SetEntityCoordsNoOffset(me, pos.x, pos.y, z + 1.0, false, false, false)
|
||||
end
|
||||
end)
|
||||
end)
|
||||
|
||||
AddEventHandler('onResourceStop', function(name)
|
||||
if name == GetCurrentResourceName() and noclip then
|
||||
stopNoclip(PlayerPedId())
|
||||
end
|
||||
end)
|
||||
@@ -0,0 +1,12 @@
|
||||
fx_version 'cerulean'
|
||||
game 'gta5'
|
||||
|
||||
name 'fiverp-admin'
|
||||
author 'FiveRP'
|
||||
description 'Staff commands'
|
||||
version '0.1.0'
|
||||
|
||||
dependency 'fiverp-notify'
|
||||
|
||||
client_scripts { 'client/cl_admin.lua' }
|
||||
server_scripts { 'server/sv_admin.js' }
|
||||
@@ -0,0 +1,118 @@
|
||||
// Staff commands.
|
||||
//
|
||||
// Every one of them is gated on an ACE, checked here. A client asking to be
|
||||
// revived or to teleport is asking the server, and the server is what decides
|
||||
// whether they may — nothing in the client half is a permission.
|
||||
//
|
||||
// add_ace group.admin fiverp.admin allow
|
||||
// add_principal identifier.fivem:1 group.admin
|
||||
|
||||
const ACE = 'fiverp.admin';
|
||||
|
||||
function ask(resource, method, fallback, ...args) {
|
||||
try {
|
||||
const target = exports[resource];
|
||||
const result = target[method](...args);
|
||||
return result === undefined ? fallback : result;
|
||||
} catch (err) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function tell(src, text, kind) {
|
||||
try {
|
||||
exports['fiverp-notify'].notify(Number(src), 'Staff', text, kind || 'info');
|
||||
} catch (err) {
|
||||
// Notify is not running: say it in the chat rather than not at all.
|
||||
emitNet('chat:addMessage', Number(src), {
|
||||
kind: 'system', system: true, args: ['Staff', text]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// The console is source 0 and is always allowed; anyone else needs the ace.
|
||||
function allowed(src) {
|
||||
return Number(src) === 0 || IsPlayerAceAllowed(String(src), ACE);
|
||||
}
|
||||
|
||||
function staffCommand(name, help, handler) {
|
||||
RegisterCommand(name, (source, args) => {
|
||||
const src = Number(source);
|
||||
if (!allowed(src)) {
|
||||
// Says nothing about what the command does: a refusal that describes the
|
||||
// thing refused is a list of staff commands for anyone who guesses.
|
||||
if (src) tell(src, 'Unknown command');
|
||||
return;
|
||||
}
|
||||
handler(src, args);
|
||||
}, false);
|
||||
|
||||
// Registered with a false restriction on purpose — the check above is the
|
||||
// real one, and this only puts the command in the slash list for staff.
|
||||
emit('chat:addSuggestion', '/' + name, help);
|
||||
}
|
||||
|
||||
// Resolve a player from a dynamic id, falling back to the caller.
|
||||
function targetOf(src, arg) {
|
||||
if (arg === undefined || arg === '') return src;
|
||||
|
||||
const id = Number(arg);
|
||||
if (!Number.isFinite(id)) return null;
|
||||
|
||||
const found = ask('fiverp-ids', 'sourceFromDynamic', null, id);
|
||||
return found ? Number(found) : null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- revive
|
||||
staffCommand('revive', 'Bring a player back up', (src, args) => {
|
||||
const target = targetOf(src, args[0]);
|
||||
if (!target) return tell(src, 'Nobody online with that ID');
|
||||
|
||||
emitNet('fiverp-admin:revive', target);
|
||||
|
||||
const who = ask('fiverp-ids', 'getDynamicId', '?', target);
|
||||
if (target !== src) {
|
||||
tell(src, `Revived ID ${who}`);
|
||||
tell(target, 'A member of staff brought you back up.');
|
||||
} else {
|
||||
tell(src, 'Back up');
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- noclip
|
||||
// Toggled entirely on the client, but only ever started from here: otherwise
|
||||
// the command exists on every machine whether or not it is allowed to work.
|
||||
staffCommand('noclip', 'Fly through the world', (src) => {
|
||||
if (!src) return;
|
||||
emitNet('fiverp-admin:noclip', src);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------- tpm
|
||||
staffCommand('tpm', 'Teleport to your map marker', (src) => {
|
||||
if (!src) return;
|
||||
emitNet('fiverp-admin:tpm', src);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------ coords
|
||||
// Where you are standing, formatted the way the config files want it. Placing
|
||||
// anything in this world means picking coordinates, and picking them by
|
||||
// guessing from a screenshot is how a car dealership ends up inside a wall.
|
||||
staffCommand('coords', 'Print where you are standing', (src) => {
|
||||
if (!src) return;
|
||||
emitNet('fiverp-admin:coords', src);
|
||||
});
|
||||
|
||||
onNet('fiverp-admin:coordsAt', (text) => {
|
||||
const src = Number(global.source);
|
||||
if (!allowed(src)) return;
|
||||
// To the console as well: a toast cannot be copied out of.
|
||||
console.log(`[coords] ${GetPlayerName(String(src))}: ${text}`);
|
||||
tell(src, text);
|
||||
});
|
||||
|
||||
// The client asks for the ground height at the marker, which only it can read.
|
||||
onNet('fiverp-admin:tpmDone', (ok) => {
|
||||
const src = Number(global.source);
|
||||
if (!allowed(src)) return;
|
||||
tell(src, ok ? 'Teleported' : 'Set a waypoint first');
|
||||
});
|
||||
@@ -61,6 +61,22 @@ CreateThread(function()
|
||||
FreezeEntityPosition(ped, true)
|
||||
SetPlayerInvincible(PlayerId(), true)
|
||||
DisableAllControlActions(0)
|
||||
|
||||
-- Nothing the game draws belongs on a login screen: there is no character
|
||||
-- yet, so the radar, the health bar and the cash counter are all showing
|
||||
-- state that has no owner.
|
||||
DisplayRadar(false)
|
||||
HideHudComponentThisFrame(1) -- WANTED_STARS
|
||||
HideHudComponentThisFrame(2) -- WEAPON_ICON
|
||||
HideHudComponentThisFrame(3) -- CASH
|
||||
HideHudComponentThisFrame(4) -- MP_CASH
|
||||
HideHudComponentThisFrame(6) -- VEHICLE_NAME
|
||||
HideHudComponentThisFrame(7) -- AREA_NAME
|
||||
HideHudComponentThisFrame(9) -- STREET_NAME
|
||||
HideHudComponentThisFrame(13) -- CASH_CHANGE
|
||||
HideHudComponentThisFrame(19) -- WEAPON_WHEEL
|
||||
HideHudComponentThisFrame(20) -- WEAPON_WHEEL_STATS
|
||||
|
||||
Wait(0)
|
||||
end
|
||||
end)
|
||||
@@ -82,6 +98,15 @@ CreateThread(function()
|
||||
Wait(500)
|
||||
|
||||
startCamera()
|
||||
|
||||
-- FiveM tears the loading screen down when the player spawns, and nobody
|
||||
-- spawns here: auth holds the gate shut until there is an identity. Left
|
||||
-- alone it stays mounted on top of the world for the whole login, so the
|
||||
-- camera below renders into a screen nobody can see. Shut it down by hand
|
||||
-- now that there is a camera to show.
|
||||
ShutdownLoadingScreenNui()
|
||||
ShutdownLoadingScreen()
|
||||
|
||||
openAuth()
|
||||
end)
|
||||
|
||||
|
||||
@@ -98,113 +98,50 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- ---------------------------------------------------------- passport -->
|
||||
<!-- ------------------------------------------------------- permit card -->
|
||||
<div class="pane pane-card" id="pane-card">
|
||||
<div class="passport" id="permit">
|
||||
<div class="pp-guilloche" aria-hidden="true"></div>
|
||||
<!-- Kept because app.js writes to it; it draws nothing. A laminate
|
||||
glare would be a gradient, and gradients are out. -->
|
||||
<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-seal" aria-hidden="true">FR</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>
|
||||
<span class="pp-stamp" id="seal">Issued <b id="stampDate">— — —</b></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 class="pp-photo" aria-hidden="true">
|
||||
<svg viewBox="0 0 48 60">
|
||||
<circle cx="24" cy="19" r="10"/>
|
||||
<path d="M5 58c0-10.5 8.5-19 19-19s19 8.5 19 19"/>
|
||||
</svg>
|
||||
</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>Permit no.</dt><dd class="mono" id="cardNo">—</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>
|
||||
<div class="pp-f"><dt>Issued</dt><dd class="mono" id="cardIssue">—</dd></div>
|
||||
<div class="pp-f"><dt>Expires</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>
|
||||
<div class="pp-f pp-f--wide"><dt>Signature</dt>
|
||||
<dd class="pp-sign-ink" id="cardSign"></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>
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
-- FiveRP bank — the client half.
|
||||
-- Draws the blips, spawns the tellers, and decides when the prompt is on screen.
|
||||
-- It holds no money and validates no transaction: every button in the NUI ends
|
||||
-- up as a server event that re-checks distance, balance and fee from scratch.
|
||||
|
||||
local locations = json.decode(LoadResourceFile(GetCurrentResourceName(), 'shared/locations.json'))
|
||||
local BANKS = locations.banks
|
||||
local ATMS = locations.atms
|
||||
|
||||
local isOpen = false
|
||||
local peds = {}
|
||||
|
||||
-- ------------------------------------------------------------------- blips
|
||||
CreateThread(function()
|
||||
for _, bank in ipairs(BANKS) do
|
||||
local blip = AddBlipForCoord(bank.blip[1], bank.blip[2], bank.blip[3])
|
||||
SetBlipSprite(blip, 108) -- bank
|
||||
SetBlipDisplay(blip, 4)
|
||||
SetBlipScale(blip, 0.85)
|
||||
SetBlipColour(blip, 2)
|
||||
SetBlipAsShortRange(blip, true)
|
||||
-- One legend entry for all of them. The map groups blips by sprite *and*
|
||||
-- name, so eight distinct names meant eight lines to scroll past; the
|
||||
-- branch itself is still named on the marker you walk up to.
|
||||
BeginTextCommandSetBlipName('STRING')
|
||||
AddTextComponentSubstringPlayerName('Bank')
|
||||
EndTextCommandSetBlipName(blip)
|
||||
end
|
||||
|
||||
-- 121 of these. Short-range so they only appear on the minimap when you are
|
||||
-- near one, otherwise the map turns into a wall of icons.
|
||||
for _, atm in ipairs(ATMS) do
|
||||
local blip = AddBlipForCoord(atm[1], atm[2], atm[3])
|
||||
SetBlipSprite(blip, 277) -- atm
|
||||
SetBlipDisplay(blip, 4)
|
||||
SetBlipScale(blip, 0.55)
|
||||
SetBlipColour(blip, 2)
|
||||
SetBlipAsShortRange(blip, true)
|
||||
BeginTextCommandSetBlipName('STRING')
|
||||
AddTextComponentSubstringPlayerName('ATM')
|
||||
EndTextCommandSetBlipName(blip)
|
||||
end
|
||||
end)
|
||||
|
||||
-- ------------------------------------------------------------------- peds
|
||||
local function loadModel(name)
|
||||
local hash = GetHashKey(name)
|
||||
RequestModel(hash)
|
||||
local timeout = GetGameTimer() + 8000
|
||||
while not HasModelLoaded(hash) and GetGameTimer() < timeout do Wait(0) end
|
||||
return HasModelLoaded(hash) and hash or nil
|
||||
end
|
||||
|
||||
-- Where the teller actually stands: behind the counter, which is the customer's
|
||||
-- spot pushed back along the direction the teller faces.
|
||||
local function tellerSpot(bank)
|
||||
local h = math.rad(bank.heading)
|
||||
-- GTA heading: 0 is +Y and turns anticlockwise, so forward is (-sin, cos).
|
||||
local fx, fy = -math.sin(h), math.cos(h)
|
||||
return vector3(
|
||||
bank.counter[1] - fx * Config.tellerSetback,
|
||||
bank.counter[2] - fy * Config.tellerSetback,
|
||||
bank.counter[3]
|
||||
)
|
||||
end
|
||||
|
||||
local function spawnTeller(index)
|
||||
if peds[index] and DoesEntityExist(peds[index]) then return end
|
||||
|
||||
local bank = BANKS[index]
|
||||
local hash = loadModel(Config.pedModel)
|
||||
if not hash then return end
|
||||
|
||||
local at = tellerSpot(bank)
|
||||
|
||||
-- The floor is measured, not assumed. Guessing an offset is what put the
|
||||
-- first version of this ped waist-deep in the carpet.
|
||||
local found, groundZ = GetGroundZFor_3dCoord(at.x, at.y, at.z + 1.0, false)
|
||||
local z = found and groundZ or (at.z - 1.0)
|
||||
|
||||
local ped = CreatePed(4, hash, at.x, at.y, z, bank.heading + 0.0, false, true)
|
||||
SetEntityInvincible(ped, true)
|
||||
FreezeEntityPosition(ped, true)
|
||||
SetBlockingOfNonTemporaryEvents(ped, true) -- do not flee, do not wander
|
||||
SetPedCanRagdoll(ped, false)
|
||||
SetPedDiesWhenInjured(ped, false)
|
||||
SetPedCanBeTargetted(ped, false)
|
||||
TaskStartScenarioInPlace(ped, 'WORLD_HUMAN_CLIPBOARD', 0, true)
|
||||
SetModelAsNoLongerNeeded(hash)
|
||||
|
||||
peds[index] = ped
|
||||
end
|
||||
|
||||
local function despawnTeller(index)
|
||||
local ped = peds[index]
|
||||
if ped and DoesEntityExist(ped) then DeleteEntity(ped) end
|
||||
peds[index] = nil
|
||||
end
|
||||
|
||||
local function isOurs(ped)
|
||||
for _, mine in pairs(peds) do
|
||||
if mine == ped then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- The stock crowd queues at the same counter and plays the same idle animation,
|
||||
-- so from the doorway there appear to be several tellers and only one of them
|
||||
-- answers. Clear them; the game keeps spawning more, so this has to repeat.
|
||||
local function clearAmbient(centre)
|
||||
local handle, ped = FindFirstPed()
|
||||
local ok = true
|
||||
|
||||
repeat
|
||||
if DoesEntityExist(ped)
|
||||
and not IsPedAPlayer(ped)
|
||||
and not isOurs(ped)
|
||||
and #(GetEntityCoords(ped) - centre) <= Config.clearAmbientRadius
|
||||
then
|
||||
SetEntityAsMissionEntity(ped, true, true)
|
||||
DeleteEntity(ped)
|
||||
end
|
||||
ok, ped = FindNextPed(handle)
|
||||
until not ok
|
||||
|
||||
EndFindPed(handle)
|
||||
end
|
||||
|
||||
-- Tellers exist only while somebody could see them: 121 ATMs cost nothing, but
|
||||
-- eight peds standing in empty interiors do.
|
||||
CreateThread(function()
|
||||
while true do
|
||||
local me = GetEntityCoords(PlayerPedId())
|
||||
|
||||
for i, bank in ipairs(BANKS) do
|
||||
local at = tellerSpot(bank)
|
||||
local d = #(me - at)
|
||||
|
||||
if d < 60.0 then
|
||||
spawnTeller(i)
|
||||
if d < 25.0 then clearAmbient(at) end
|
||||
elseif d > 90.0 then
|
||||
despawnTeller(i)
|
||||
end
|
||||
end
|
||||
|
||||
Wait(2000)
|
||||
end
|
||||
end)
|
||||
|
||||
-- --------------------------------------------------------------- proximity
|
||||
-- Points are registered with fiverp-interact rather than drawn here, so a
|
||||
-- teller and an ATM feel the same as every other thing on the server you can
|
||||
-- act on: a dial, a held key, a ring that fills.
|
||||
|
||||
local registered = {}
|
||||
|
||||
local function pointId(kind, index) return kind .. ':' .. index end
|
||||
|
||||
local function addPoint(kind, index, coords, label)
|
||||
local id = pointId(kind, index)
|
||||
if registered[id] then return end
|
||||
registered[id] = true
|
||||
|
||||
exports['fiverp-interact']:add({
|
||||
id = id,
|
||||
coords = coords,
|
||||
label = label,
|
||||
distance = kind == 'teller' and Config.reachTeller or Config.reachAtm,
|
||||
hold = kind == 'teller' and 550 or 700,
|
||||
onSelect = function()
|
||||
TriggerServerEvent('fiverp-bank:open', kind, index)
|
||||
end
|
||||
})
|
||||
end
|
||||
|
||||
local function dropPoint(kind, index)
|
||||
local id = pointId(kind, index)
|
||||
if not registered[id] then return end
|
||||
registered[id] = nil
|
||||
exports['fiverp-interact']:remove(id)
|
||||
end
|
||||
|
||||
-- 121 ATMs cannot all be live points at once, so only what is nearby is
|
||||
-- registered. The window is generous — the marker itself decides what is close
|
||||
-- enough to use.
|
||||
CreateThread(function()
|
||||
while true do
|
||||
local me = GetEntityCoords(PlayerPedId())
|
||||
|
||||
for i, bank in ipairs(BANKS) do
|
||||
local at = vector3(bank.counter[1], bank.counter[2], bank.counter[3])
|
||||
if #(me - at) < 40.0 then
|
||||
addPoint('teller', i, at, 'Teller')
|
||||
else
|
||||
dropPoint('teller', i)
|
||||
end
|
||||
end
|
||||
|
||||
for i, atm in ipairs(ATMS) do
|
||||
-- Marker on the keypad, not on the pavement the machine is bolted to.
|
||||
local at = vector3(atm[1], atm[2], atm[3] + Config.atmMarkerHeight)
|
||||
if #(me - at) < 40.0 then
|
||||
addPoint('atm', i, at, 'ATM')
|
||||
else
|
||||
dropPoint('atm', i)
|
||||
end
|
||||
end
|
||||
|
||||
Wait(1500)
|
||||
end
|
||||
end)
|
||||
|
||||
-- Still needed by /bank, which does not go through the marker.
|
||||
local function findNearest()
|
||||
local me = GetEntityCoords(PlayerPedId())
|
||||
|
||||
for i, bank in ipairs(BANKS) do
|
||||
if #(me - vector3(bank.counter[1], bank.counter[2], bank.counter[3])) <= Config.reachTeller then
|
||||
return { kind = 'teller', index = i }
|
||||
end
|
||||
end
|
||||
|
||||
for i, atm in ipairs(ATMS) do
|
||||
if #(me - vector3(atm[1], atm[2], atm[3])) <= Config.reachAtm then
|
||||
return { kind = 'atm', index = i }
|
||||
end
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------- menu
|
||||
RegisterNetEvent('fiverp-bank:show', function(payload)
|
||||
isOpen = true
|
||||
SetNuiFocus(true, true)
|
||||
SendNUIMessage({ action = 'open', data = payload })
|
||||
end)
|
||||
|
||||
RegisterNetEvent('fiverp-bank:update', function(payload)
|
||||
SendNUIMessage({ action = 'update', data = payload })
|
||||
end)
|
||||
|
||||
RegisterNetEvent('fiverp-bank:result', function(payload)
|
||||
SendNUIMessage({ action = 'result', data = payload })
|
||||
end)
|
||||
|
||||
local function closeMenu()
|
||||
isOpen = false
|
||||
SetNuiFocus(false, false)
|
||||
SendNUIMessage({ action = 'close' })
|
||||
end
|
||||
|
||||
RegisterNUICallback('close', function(_, cb) closeMenu(); cb('ok') end)
|
||||
|
||||
RegisterNUICallback('action', function(data, cb)
|
||||
TriggerServerEvent('fiverp-bank:action', {
|
||||
kind = data.kind,
|
||||
index = data.index,
|
||||
op = data.op,
|
||||
amount = data.amount,
|
||||
target = data.target
|
||||
})
|
||||
cb('ok')
|
||||
end)
|
||||
|
||||
RegisterCommand('bank', function()
|
||||
local here = findNearest()
|
||||
if not here then
|
||||
exports['fiverp-notify']:show('Bank', 'Stand at a teller or an ATM first.', 'bad')
|
||||
return
|
||||
end
|
||||
TriggerServerEvent('fiverp-bank:open', here.kind, here.index)
|
||||
end, false)
|
||||
|
||||
TriggerEvent('chat:addSuggestion', '/bank', 'Open banking at a teller or an ATM')
|
||||
|
||||
AddEventHandler('onResourceStop', function(name)
|
||||
if name ~= GetCurrentResourceName() then return end
|
||||
SetNuiFocus(false, false)
|
||||
for i in pairs(peds) do despawnTeller(i) end
|
||||
end)
|
||||
@@ -0,0 +1,26 @@
|
||||
fx_version 'cerulean'
|
||||
game 'gta5'
|
||||
|
||||
name 'fiverp-bank'
|
||||
author 'FiveRP'
|
||||
description 'Bank branches, tellers and ATMs'
|
||||
version '0.1.0'
|
||||
|
||||
dependency 'fiverp-interact'
|
||||
|
||||
ui_page 'html/index.html'
|
||||
|
||||
shared_script 'shared/config.lua'
|
||||
client_scripts { 'client/cl_bank.lua' }
|
||||
server_scripts { 'server/sv_bank.js' }
|
||||
|
||||
files {
|
||||
'shared/locations.json',
|
||||
'html/index.html',
|
||||
'html/style.css',
|
||||
'html/app.js',
|
||||
'html/scrollbar.js',
|
||||
'html/fonts/unbounded-latin.woff2',
|
||||
'html/fonts/unbounded-latin-ext.woff2',
|
||||
'html/fonts/unbounded-cyrillic.woff2',
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
/* FiveRP bank — the menu.
|
||||
Shows balances and collects an amount. It computes the ATM fee only to print
|
||||
it before you commit; the number that actually gets charged is worked out
|
||||
again on the server, from its own config. */
|
||||
|
||||
const RES = 'fiverp-bank';
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
let state = { kind: 'teller', index: 0, cash: 0, bank: 0, feePercent: 0, feeMinimum: 0 };
|
||||
let op = 'deposit';
|
||||
|
||||
const COPY = {
|
||||
deposit: {
|
||||
button: 'Deposit',
|
||||
note: 'Cash in your pocket goes into the account. No charge, here or at an ATM.'
|
||||
},
|
||||
withdraw: {
|
||||
teller: {
|
||||
button: 'Withdraw',
|
||||
note: 'The teller hands it over at face value. Walking in is what makes it free.'
|
||||
},
|
||||
atm: {
|
||||
button: 'Withdraw',
|
||||
note: 'The machine keeps a cut of anything it hands out. A teller does not.'
|
||||
}
|
||||
},
|
||||
transfer: {
|
||||
button: 'Send',
|
||||
note: 'Straight from your account to another player\'s, by their ID. No charge. They have to be online.'
|
||||
}
|
||||
};
|
||||
|
||||
function money(value) {
|
||||
return '$' + Math.floor(Number(value) || 0).toLocaleString('en-US');
|
||||
}
|
||||
|
||||
function amountEntered() {
|
||||
const raw = String($('amount').value).replace(/[^0-9]/g, '');
|
||||
return raw ? Math.floor(Number(raw)) : 0;
|
||||
}
|
||||
|
||||
// Mirrors the server's rule. Kept deliberately in one small function so there is
|
||||
// one place to change if the rate ever moves.
|
||||
function feeFor(amount) {
|
||||
if (state.kind !== 'atm' || op !== 'withdraw' || amount <= 0) return 0;
|
||||
return Math.max(state.feeMinimum, Math.ceil(amount * state.feePercent / 100));
|
||||
}
|
||||
|
||||
function ceiling() {
|
||||
if (op === 'deposit') return state.cash;
|
||||
return state.bank;
|
||||
}
|
||||
|
||||
function paint() {
|
||||
$('cash').textContent = money(state.cash);
|
||||
$('bank').textContent = money(state.bank);
|
||||
$('place').textContent = state.name || 'Bank';
|
||||
$('kind').textContent = state.kind === 'atm' ? 'ATM' : 'Teller';
|
||||
|
||||
// An ATM has no transfer, so the tab is removed rather than shown as refused.
|
||||
const transferTab = document.querySelector('[data-op="transfer"]');
|
||||
transferTab.classList.toggle('is-gone', state.kind === 'atm');
|
||||
if (state.kind === 'atm' && op === 'transfer') setOp('deposit');
|
||||
|
||||
const copy = op === 'withdraw' ? COPY.withdraw[state.kind] : COPY[op];
|
||||
$('note').textContent = copy.note;
|
||||
$('submit').textContent = copy.button;
|
||||
|
||||
$('targetField').classList.toggle('is-gone', op !== 'transfer');
|
||||
|
||||
paintQuick();
|
||||
paintFee();
|
||||
}
|
||||
|
||||
function paintQuick() {
|
||||
const max = ceiling();
|
||||
const steps = [100, 500, 1000, 5000].filter((v) => v <= max);
|
||||
const host = $('quick');
|
||||
|
||||
host.innerHTML = steps.map((v) =>
|
||||
`<button class="chip" type="button" data-v="${v}">${money(v)}</button>`).join('')
|
||||
+ (max > 0 ? `<button class="chip" type="button" data-v="${max}">All ${money(max)}</button>` : '');
|
||||
|
||||
host.querySelectorAll('.chip').forEach((el) => {
|
||||
el.addEventListener('click', () => {
|
||||
$('amount').value = el.dataset.v;
|
||||
paintFee();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function paintFee() {
|
||||
const amount = amountEntered();
|
||||
const fee = feeFor(amount);
|
||||
const el = $('fee');
|
||||
|
||||
if (op === 'transfer') {
|
||||
el.innerHTML = amount > 0 ? `They receive <b>${money(amount)}</b>.` : '';
|
||||
} else if (fee > 0) {
|
||||
el.innerHTML = `Fee <b>${money(fee)}</b> — leaves your account as <b>${money(amount + fee)}</b>.`;
|
||||
} else if (state.kind === 'atm' && op === 'withdraw') {
|
||||
el.innerHTML = `Fee is ${state.feePercent}%, minimum ${money(state.feeMinimum)}.`;
|
||||
} else {
|
||||
el.textContent = '';
|
||||
}
|
||||
|
||||
const over = amount > 0 && (op === 'deposit'
|
||||
? amount > state.cash
|
||||
: amount + fee > state.bank);
|
||||
|
||||
$('submit').disabled = amount <= 0 || over;
|
||||
if (over) notice(op === 'deposit' ? 'More than you are carrying' : 'More than the account holds');
|
||||
}
|
||||
|
||||
function notice(text, good) {
|
||||
const el = $('notice');
|
||||
el.textContent = text || '';
|
||||
el.classList.toggle('is-shown', !!text);
|
||||
el.classList.toggle('is-good', !!good);
|
||||
}
|
||||
|
||||
function setOp(next) {
|
||||
op = next;
|
||||
document.querySelectorAll('.tab').forEach((t) =>
|
||||
t.classList.toggle('is-on', t.dataset.op === next));
|
||||
notice('');
|
||||
paint();
|
||||
}
|
||||
|
||||
function post(name, data) {
|
||||
fetch(`https://${RES}/${name}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json; charset=UTF-8' },
|
||||
body: JSON.stringify(data || {})
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function submit() {
|
||||
const amount = amountEntered();
|
||||
if (amount <= 0) return notice('Enter an amount');
|
||||
|
||||
if (op === 'transfer') {
|
||||
const target = String($('target').value).replace(/[^0-9]/g, '');
|
||||
if (!target) return notice('Enter the player ID to send to');
|
||||
post('action', { kind: state.kind, index: state.index, op, amount, target: Number(target) });
|
||||
} else {
|
||||
post('action', { kind: state.kind, index: state.index, op, amount });
|
||||
}
|
||||
|
||||
$('submit').disabled = true;
|
||||
}
|
||||
|
||||
function close() {
|
||||
$('root').classList.remove('is-open');
|
||||
post('close', {});
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------- wiring */
|
||||
|
||||
document.querySelectorAll('.tab').forEach((t) =>
|
||||
t.addEventListener('click', () => setOp(t.dataset.op)));
|
||||
|
||||
$('close').addEventListener('click', close);
|
||||
$('submit').addEventListener('click', submit);
|
||||
$('amount').addEventListener('input', () => { notice(''); paintFee(); });
|
||||
|
||||
$('amount').addEventListener('keydown', (e) => { if (e.key === 'Enter') submit(); });
|
||||
$('target').addEventListener('keydown', (e) => { if (e.key === 'Enter') submit(); });
|
||||
|
||||
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') close(); });
|
||||
|
||||
/* ------------------------------------------------------------- from Lua */
|
||||
|
||||
window.addEventListener('message', (event) => {
|
||||
const msg = event.data || {};
|
||||
|
||||
if (msg.action === 'open') {
|
||||
state = Object.assign(state, msg.data || {});
|
||||
op = 'deposit';
|
||||
$('amount').value = '';
|
||||
$('target').value = '';
|
||||
notice('');
|
||||
setOp('deposit');
|
||||
$('root').classList.add('is-open');
|
||||
setTimeout(() => $('amount').focus(), 30);
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.action === 'update') {
|
||||
state = Object.assign(state, msg.data || {});
|
||||
$('amount').value = '';
|
||||
paint();
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.action === 'result') {
|
||||
const p = msg.data || {};
|
||||
notice(p.message, p.ok);
|
||||
$('submit').disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.action === 'close') {
|
||||
$('root').classList.remove('is-open');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>FiveRP — Bank</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="root" class="root">
|
||||
<div class="panel">
|
||||
|
||||
<header class="head">
|
||||
<div>
|
||||
<p class="eyebrow" id="place">Fleeca</p>
|
||||
<h1 class="title" id="kind">Teller</h1>
|
||||
</div>
|
||||
<button class="close" id="close" type="button">Close</button>
|
||||
</header>
|
||||
|
||||
<div class="cards">
|
||||
<div class="card">
|
||||
<span class="card__label">Cash on hand</span>
|
||||
<span class="card__value" id="cash">$0</span>
|
||||
</div>
|
||||
<div class="card card--bank">
|
||||
<span class="card__label">Bank</span>
|
||||
<span class="card__value" id="bank">$0</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="tabs" id="tabs">
|
||||
<button class="tab is-on" data-op="deposit" type="button">Deposit</button>
|
||||
<button class="tab" data-op="withdraw" type="button">Withdraw</button>
|
||||
<button class="tab" data-op="transfer" type="button">Transfer</button>
|
||||
</nav>
|
||||
|
||||
<div class="body">
|
||||
<p class="note" id="note"></p>
|
||||
|
||||
<label class="field" id="targetField">
|
||||
<span class="label">Player ID</span>
|
||||
<input id="target" type="text" inputmode="numeric" autocomplete="off"
|
||||
spellcheck="false" placeholder="14">
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span class="label">Amount</span>
|
||||
<div class="amount">
|
||||
<span class="amount__sign">$</span>
|
||||
<input id="amount" type="text" inputmode="numeric" autocomplete="off"
|
||||
spellcheck="false" placeholder="0">
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div class="quick" id="quick"></div>
|
||||
|
||||
<p class="fee" id="fee"></p>
|
||||
<p class="notice" id="notice"></p>
|
||||
</div>
|
||||
|
||||
<footer class="foot">
|
||||
<button class="btn-primary" id="submit" type="button">Deposit</button>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,123 @@
|
||||
/* A scrollbar we actually own.
|
||||
CEF will not let the native one go quietly: ::-webkit-scrollbar-button keeps
|
||||
drawing a stepper stub at each end whatever display, size or background it is
|
||||
given. So the native bar is hidden outright and this draws the replacement.
|
||||
|
||||
Attach with data-scroll on any scrolling element. The bar is position:fixed
|
||||
and measured from the element's own rect, so it needs no particular ancestor
|
||||
and works inside a modal that moves. */
|
||||
(() => {
|
||||
const MIN_THUMB = 28;
|
||||
|
||||
function attach(host) {
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'sbar';
|
||||
const thumb = document.createElement('i');
|
||||
thumb.className = 'sbar__thumb';
|
||||
bar.appendChild(thumb);
|
||||
document.body.appendChild(bar);
|
||||
|
||||
let dragging = false;
|
||||
let grabOffset = 0;
|
||||
|
||||
// The bar is appended to the body, not to the host, so nothing about the
|
||||
// host being faded out reaches it. Walk the ancestors and hide with them —
|
||||
// otherwise a chat that has quietly gone away leaves its scrollbar behind.
|
||||
function hostVisible() {
|
||||
let el = host;
|
||||
while (el && el !== document.body) {
|
||||
const style = getComputedStyle(el);
|
||||
if (style.display === 'none' || style.visibility === 'hidden') return false;
|
||||
if (Number(style.opacity) < 0.05) return false;
|
||||
el = el.parentElement;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function layout() {
|
||||
const box = host.getBoundingClientRect();
|
||||
const scrollable = host.scrollHeight - host.clientHeight;
|
||||
|
||||
// Nothing to scroll, or nothing to scroll it on: either way the bar is a
|
||||
// control that does nothing.
|
||||
if (scrollable <= 1 || box.height <= 0 || !hostVisible()) {
|
||||
bar.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
bar.style.display = 'block';
|
||||
bar.style.top = box.top + 'px';
|
||||
bar.style.height = box.height + 'px';
|
||||
bar.style.left = (box.right - 12) + 'px';
|
||||
|
||||
const ratio = host.clientHeight / host.scrollHeight;
|
||||
const h = Math.max(MIN_THUMB, box.height * ratio);
|
||||
const travel = box.height - h;
|
||||
const progress = host.scrollTop / scrollable;
|
||||
|
||||
thumb.style.height = h + 'px';
|
||||
thumb.style.transform = `translateY(${travel * progress}px)`;
|
||||
}
|
||||
|
||||
function onDrag(event) {
|
||||
if (!dragging) return;
|
||||
const box = host.getBoundingClientRect();
|
||||
const h = thumb.getBoundingClientRect().height;
|
||||
const travel = box.height - h;
|
||||
if (travel <= 0) return;
|
||||
|
||||
const wanted = event.clientY - box.top - grabOffset;
|
||||
const progress = Math.max(0, Math.min(1, wanted / travel));
|
||||
host.scrollTop = progress * (host.scrollHeight - host.clientHeight);
|
||||
}
|
||||
|
||||
thumb.addEventListener('pointerdown', (event) => {
|
||||
dragging = true;
|
||||
grabOffset = event.clientY - thumb.getBoundingClientRect().top;
|
||||
bar.classList.add('is-held');
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
// Listen on the window, not the thumb: the pointer regularly leaves a 4px
|
||||
// wide target mid-drag and the drag has to survive that.
|
||||
window.addEventListener('pointermove', onDrag);
|
||||
window.addEventListener('pointerup', () => {
|
||||
dragging = false;
|
||||
bar.classList.remove('is-held');
|
||||
});
|
||||
|
||||
// Clicking the empty track jumps a page, the way a real scrollbar does.
|
||||
bar.addEventListener('pointerdown', (event) => {
|
||||
if (event.target === thumb) return;
|
||||
const box = thumb.getBoundingClientRect();
|
||||
host.scrollTop += (event.clientY < box.top ? -1 : 1) * host.clientHeight * 0.9;
|
||||
});
|
||||
|
||||
host.addEventListener('scroll', layout);
|
||||
window.addEventListener('resize', layout);
|
||||
|
||||
// The panel is rebuilt (settings reset, tab switch) without the element ever
|
||||
// being replaced, so the size has to be re-read when its contents change.
|
||||
new MutationObserver(layout).observe(host, { childList: true, subtree: true });
|
||||
|
||||
layout();
|
||||
return layout;
|
||||
}
|
||||
|
||||
const layouts = [];
|
||||
function scan() {
|
||||
document.querySelectorAll('[data-scroll]').forEach((el) => {
|
||||
if (el.dataset.scrollBound) return;
|
||||
el.dataset.scrollBound = '1';
|
||||
layouts.push(attach(el));
|
||||
});
|
||||
}
|
||||
|
||||
// Re-measure when a panel is opened: a hidden element has no useful rect.
|
||||
window.addEventListener('fiverp:relayout', () => layouts.forEach((fn) => fn()));
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', scan);
|
||||
} else {
|
||||
scan();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,365 @@
|
||||
/* FiveRP — chat.
|
||||
Design system: CLAUDE.md — Clean UI / Dark Minimalism.
|
||||
Sits over the game, so the page itself stays transparent and only the
|
||||
panels take a fill. No blur, no shadow, no gradient. */
|
||||
|
||||
@font-face {
|
||||
font-family: 'Unbounded';
|
||||
font-style: normal;
|
||||
font-weight: 300 900;
|
||||
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 900;
|
||||
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 900;
|
||||
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: rgba(24, 24, 24, 1);
|
||||
--card-bg: rgba(30, 30, 30, 1);
|
||||
--btn-bg: rgba(51, 51, 51, 1);
|
||||
--btn-hover: rgba(70, 70, 70, 1);
|
||||
--accent: rgba(255, 119, 22, 1);
|
||||
--text-primary: rgba(241, 241, 241, 1);
|
||||
--text-secondary: rgba(153, 153, 153, 1);
|
||||
--border: rgba(51, 51, 51, 0.5);
|
||||
|
||||
--sans: 'Unbounded', sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
font-family: var(--sans);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
html, body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
body { color: var(--text-primary); }
|
||||
|
||||
.root {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: rgba(24, 24, 24, 0.86);
|
||||
}
|
||||
|
||||
.root.is-open { display: flex; }
|
||||
|
||||
.panel {
|
||||
width: min(520px, 86vw);
|
||||
background-color: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────── head */
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
padding: 25px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 20px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.close {
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-primary);
|
||||
background-color: var(--btn-bg);
|
||||
border-radius: 20px;
|
||||
padding: 10px 18px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.close:hover { background-color: var(--btn-hover); }
|
||||
|
||||
/* ───────────────────────────────────────────────────────────── balances */
|
||||
|
||||
.cards {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
padding: 20px 25px 0;
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: var(--btn-bg);
|
||||
border-radius: 12px;
|
||||
padding: 16px 18px;
|
||||
}
|
||||
|
||||
.card__label {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.card__value {
|
||||
display: block;
|
||||
font-size: 20px;
|
||||
font-weight: 900;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.card--bank .card__value { color: var(--accent); }
|
||||
|
||||
/* ───────────────────────────────────────────────────────────────── tabs */
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin: 20px 25px 0;
|
||||
background-color: var(--btn-bg);
|
||||
border-radius: 20px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
background: transparent;
|
||||
border-radius: 20px;
|
||||
padding: 11px 14px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.tab:hover { color: var(--text-primary); }
|
||||
|
||||
.tab.is-on {
|
||||
background-color: var(--accent);
|
||||
color: var(--bg);
|
||||
}
|
||||
|
||||
/* An ATM cannot transfer, so the tab is not offered rather than offered and
|
||||
refused. */
|
||||
.tab.is-gone { display: none; }
|
||||
|
||||
/* ───────────────────────────────────────────────────────────────── body */
|
||||
|
||||
.body { padding: 22px 25px 0; }
|
||||
|
||||
.note {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: block;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.field.is-gone { display: none; }
|
||||
|
||||
.label {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.field input {
|
||||
width: 100%;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background-color: var(--btn-bg);
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
padding: 14px 16px;
|
||||
outline: none;
|
||||
transition: all 0.2s ease;
|
||||
user-select: text;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.field input::placeholder { color: var(--text-secondary); font-weight: 500; }
|
||||
|
||||
.field input:focus { border-color: var(--accent); }
|
||||
|
||||
.amount {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background-color: var(--btn-bg);
|
||||
padding: 0 16px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.amount:focus-within { border-color: var(--accent); }
|
||||
|
||||
.amount__sign {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.amount input {
|
||||
border: 0;
|
||||
padding: 14px 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.amount input:focus { border: 0; }
|
||||
|
||||
/* quick amounts */
|
||||
|
||||
.quick {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
border: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
background-color: var(--btn-bg);
|
||||
border-radius: 20px;
|
||||
padding: 9px 15px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.chip:hover {
|
||||
background-color: var(--btn-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.fee {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
min-height: 15px;
|
||||
}
|
||||
|
||||
.fee b {
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.notice {
|
||||
display: none;
|
||||
margin-top: 14px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 1.45;
|
||||
border-radius: 12px;
|
||||
padding: 12px 15px;
|
||||
background-color: var(--btn-bg);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.notice.is-shown { display: block; }
|
||||
|
||||
.notice.is-good { color: var(--text-primary); }
|
||||
|
||||
/* ───────────────────────────────────────────────────────────────── foot */
|
||||
|
||||
.foot {
|
||||
padding: 22px 25px 25px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
color: var(--bg);
|
||||
background-color: var(--accent);
|
||||
border-radius: 12px;
|
||||
padding: 15px 20px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-primary:hover { transform: translateY(-2px); }
|
||||
|
||||
.btn-primary:disabled {
|
||||
cursor: default;
|
||||
background-color: var(--btn-bg);
|
||||
color: var(--text-secondary);
|
||||
transform: none;
|
||||
}
|
||||
|
||||
body.no-anim *,
|
||||
body.no-anim *::before,
|
||||
body.no-anim *::after {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
// FiveRP bank — the server half.
|
||||
// Every number here is re-derived server-side. The client sends what the player
|
||||
// pressed; it never sends a balance, a fee, or a claim about where it is
|
||||
// standing that is taken at face value.
|
||||
|
||||
const CONFIG = {
|
||||
atmFeePercent: 3.0,
|
||||
atmFeeMinimum: 5,
|
||||
reachTeller: 2.2,
|
||||
reachAtm: 1.6,
|
||||
maxPerAction: 5000000
|
||||
};
|
||||
|
||||
const LOC = JSON.parse(
|
||||
LoadResourceFile(GetCurrentResourceName(), 'shared/locations.json'));
|
||||
|
||||
// A little slack on top of the client's reach: the position the server has for
|
||||
// a player lags their own by a frame or two, and refusing an honest player
|
||||
// standing still is worse than allowing one who stepped back half a metre.
|
||||
const SLACK = 2.5;
|
||||
|
||||
function ask(resource, method, fallback, ...args) {
|
||||
try {
|
||||
const target = exports[resource];
|
||||
const result = target[method](...args);
|
||||
return result === undefined ? fallback : result;
|
||||
} catch (err) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function distance(a, b) {
|
||||
const dx = a[0] - b[0], dy = a[1] - b[1], dz = a[2] - b[2];
|
||||
return Math.sqrt(dx * dx + dy * dy + dz * dz);
|
||||
}
|
||||
|
||||
// Where the player actually is, according to the server.
|
||||
function whereIs(src) {
|
||||
const ped = GetPlayerPed(String(src));
|
||||
if (!ped) return null;
|
||||
const c = GetEntityCoords(ped);
|
||||
return [c[0], c[1], c[2]];
|
||||
}
|
||||
|
||||
function spotOf(kind, index) {
|
||||
const i = Number(index);
|
||||
if (kind === 'teller') {
|
||||
const bank = LOC.banks[i - 1];
|
||||
// The counter is where the customer stands, which is what the server has to
|
||||
// measure against — not where the teller is standing behind it.
|
||||
return bank ? { coords: bank.counter, name: bank.name } : null;
|
||||
}
|
||||
if (kind === 'atm') {
|
||||
const atm = LOC.atms[i - 1];
|
||||
return atm ? { coords: atm, name: 'ATM' } : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// The single gate every request goes through.
|
||||
function verify(src, kind, index) {
|
||||
const spot = spotOf(kind, index);
|
||||
if (!spot) return { ok: false, error: 'No such place' };
|
||||
|
||||
const me = whereIs(src);
|
||||
if (!me) return { ok: false, error: 'Where are you?' };
|
||||
|
||||
const reach = (kind === 'teller' ? CONFIG.reachTeller : CONFIG.reachAtm) + SLACK;
|
||||
if (distance(me, spot.coords) > reach) {
|
||||
return { ok: false, error: 'Too far from the terminal' };
|
||||
}
|
||||
return { ok: true, spot };
|
||||
}
|
||||
|
||||
// Round, positive, whole, and inside one sane move.
|
||||
function amountOf(value) {
|
||||
const n = Math.floor(Number(value));
|
||||
if (!Number.isFinite(n) || n <= 0 || n > CONFIG.maxPerAction) return null;
|
||||
return n;
|
||||
}
|
||||
|
||||
// The ATM's cut, in whole dollars, on withdrawals only.
|
||||
function feeFor(kind, amount) {
|
||||
if (kind !== 'atm') return 0;
|
||||
return Math.max(CONFIG.atmFeeMinimum,
|
||||
Math.ceil(amount * CONFIG.atmFeePercent / 100));
|
||||
}
|
||||
|
||||
async function balancesOf(src) {
|
||||
const b = await ask('fiverp-economy', 'getBalances', null, src);
|
||||
return b && typeof b.cash === 'number' ? b : { cash: 0, bank: 0 };
|
||||
}
|
||||
|
||||
async function pushState(src, kind, index, name) {
|
||||
const b = await balancesOf(src);
|
||||
emitNet('fiverp-bank:update', src, {
|
||||
kind, index, name,
|
||||
cash: b.cash,
|
||||
bank: b.bank,
|
||||
feePercent: kind === 'atm' ? CONFIG.atmFeePercent : 0,
|
||||
feeMinimum: kind === 'atm' ? CONFIG.atmFeeMinimum : 0
|
||||
});
|
||||
}
|
||||
|
||||
function done(src, ok, message) {
|
||||
emitNet('fiverp-bank:result', src, { ok, message });
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ open
|
||||
onNet('fiverp-bank:open', async (kind, index) => {
|
||||
const src = Number(global.source);
|
||||
const check = verify(src, kind, index);
|
||||
if (!check.ok) return done(src, false, check.error);
|
||||
|
||||
const b = await balancesOf(src);
|
||||
emitNet('fiverp-bank:show', src, {
|
||||
kind,
|
||||
index: Number(index),
|
||||
name: check.spot.name,
|
||||
cash: b.cash,
|
||||
bank: b.bank,
|
||||
feePercent: kind === 'atm' ? CONFIG.atmFeePercent : 0,
|
||||
feeMinimum: kind === 'atm' ? CONFIG.atmFeeMinimum : 0
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- actions
|
||||
onNet('fiverp-bank:action', async (req) => {
|
||||
const src = Number(global.source);
|
||||
const kind = req && req.kind;
|
||||
const index = req && req.index;
|
||||
|
||||
const check = verify(src, kind, index);
|
||||
if (!check.ok) return done(src, false, check.error);
|
||||
|
||||
const amount = amountOf(req.amount);
|
||||
if (amount === null) return done(src, false, 'Enter a whole amount above zero');
|
||||
|
||||
const balances = await balancesOf(src);
|
||||
|
||||
// ---- deposit
|
||||
if (req.op === 'deposit') {
|
||||
if (balances.cash < amount) return done(src, false, 'Not enough cash on you');
|
||||
|
||||
const res = await ask('fiverp-economy', 'deposit', { ok: false }, src, amount,
|
||||
check.spot.name);
|
||||
if (!res || !res.ok) return done(src, false, (res && res.error) || 'Deposit refused');
|
||||
|
||||
await pushState(src, kind, index, check.spot.name);
|
||||
return done(src, true, `Deposited $${amount.toLocaleString('en-US')}`);
|
||||
}
|
||||
|
||||
// ---- withdraw
|
||||
if (req.op === 'withdraw') {
|
||||
const fee = feeFor(kind, amount);
|
||||
const total = amount + fee;
|
||||
|
||||
if (balances.bank < total) {
|
||||
return done(src, false, fee
|
||||
? `Not enough in the bank — ${amount} plus a $${fee} fee`
|
||||
: 'Not enough in the bank');
|
||||
}
|
||||
|
||||
const res = await ask('fiverp-economy', 'withdraw', { ok: false }, src, amount,
|
||||
check.spot.name);
|
||||
if (!res || !res.ok) return done(src, false, (res && res.error) || 'Withdrawal refused');
|
||||
|
||||
// The fee is taken as a separate move so it shows up in the ledger as a fee
|
||||
// and not as part of what the player asked for.
|
||||
if (fee > 0) {
|
||||
await ask('fiverp-economy', 'removeBank', { ok: false }, src, fee,
|
||||
'atm_fee', check.spot.name);
|
||||
}
|
||||
|
||||
await pushState(src, kind, index, check.spot.name);
|
||||
return done(src, true, fee
|
||||
? `Withdrew $${amount.toLocaleString('en-US')} — fee $${fee}`
|
||||
: `Withdrew $${amount.toLocaleString('en-US')}`);
|
||||
}
|
||||
|
||||
// ---- transfer (tellers only)
|
||||
if (req.op === 'transfer') {
|
||||
if (kind !== 'teller') {
|
||||
return done(src, false, 'Transfers need a teller — an ATM cannot do them');
|
||||
}
|
||||
|
||||
const targetSrc = ask('fiverp-ids', 'sourceFromDynamic', null, Number(req.target));
|
||||
if (!targetSrc) return done(src, false, 'Nobody online with that ID');
|
||||
if (Number(targetSrc) === src) return done(src, false, 'That is your own ID');
|
||||
|
||||
if (balances.bank < amount) return done(src, false, 'Not enough in the bank');
|
||||
|
||||
const res = await ask('fiverp-economy', 'transfer', { ok: false },
|
||||
src, Number(targetSrc), amount, 'bank', 'bank_transfer');
|
||||
if (!res || !res.ok) return done(src, false, (res && res.error) || 'Transfer refused');
|
||||
|
||||
await pushState(src, kind, index, check.spot.name);
|
||||
|
||||
ask('fiverp-notify', 'notify', false, Number(targetSrc), 'Bank',
|
||||
`**$${amount.toLocaleString('en-US')}** arrived from ID ${
|
||||
ask('fiverp-ids', 'getDynamicId', '?', src)}`, 'good');
|
||||
|
||||
return done(src, true, `Sent $${amount.toLocaleString('en-US')} to ID ${req.target}`);
|
||||
}
|
||||
|
||||
return done(src, false, 'Unknown action');
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
-- Numbers both halves have to agree on. The server re-derives every one of them
|
||||
-- for itself; nothing here is trusted because it arrived from a client.
|
||||
Config = {
|
||||
-- What the ATM keeps for handing you your own money. Tellers charge nothing:
|
||||
-- that difference is the whole reason to walk into a branch.
|
||||
atmFeePercent = 3.0,
|
||||
atmFeeMinimum = 5,
|
||||
|
||||
-- How close you must be to use either. Checked on the client for the prompt
|
||||
-- and again on the server for the transaction.
|
||||
reachTeller = 2.4,
|
||||
reachAtm = 1.6,
|
||||
|
||||
-- A single move. Not a balance cap — a cap on one press of a button.
|
||||
maxPerAction = 5000000,
|
||||
|
||||
-- The teller stands this far behind the point the customer stands on, along
|
||||
-- the direction they face. Nudge it if a branch puts them on the wrong side
|
||||
-- of the counter; every Fleeca shares one interior, so one number covers them.
|
||||
tellerSetback = 1.9,
|
||||
|
||||
-- The ATM coordinates are at the machine's foot. The marker belongs on the
|
||||
-- keypad, which is about this far up.
|
||||
atmMarkerHeight = 0.95,
|
||||
|
||||
-- Ambient peds inside this radius of a teller are removed. The stock crowd
|
||||
-- queues at the same counter and plays the same idle, which reads as a second
|
||||
-- set of tellers nobody can talk to.
|
||||
clearAmbientRadius = 6.0,
|
||||
|
||||
pedModel = 'a_m_y_business_02'
|
||||
}
|
||||
@@ -0,0 +1,723 @@
|
||||
{
|
||||
"banks": [
|
||||
{
|
||||
"name": "Fleeca \u2014 Legion Square",
|
||||
"counter": [
|
||||
149.93,
|
||||
-1040.54,
|
||||
29.37
|
||||
],
|
||||
"heading": 340.0,
|
||||
"blip": [
|
||||
149.93,
|
||||
-1040.54,
|
||||
29.37
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Fleeca \u2014 Hawick",
|
||||
"counter": [
|
||||
313.17,
|
||||
-278.9,
|
||||
54.17
|
||||
],
|
||||
"heading": 340.0,
|
||||
"blip": [
|
||||
313.17,
|
||||
-278.9,
|
||||
54.17
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Fleeca \u2014 Alta",
|
||||
"counter": [
|
||||
-351.34,
|
||||
-49.53,
|
||||
49.04
|
||||
],
|
||||
"heading": 340.0,
|
||||
"blip": [
|
||||
-351.34,
|
||||
-49.53,
|
||||
49.04
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Fleeca \u2014 Burton",
|
||||
"counter": [
|
||||
-1212.15,
|
||||
-331.19,
|
||||
37.79
|
||||
],
|
||||
"heading": 25.0,
|
||||
"blip": [
|
||||
-1212.15,
|
||||
-331.19,
|
||||
37.79
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Fleeca \u2014 Rockford",
|
||||
"counter": [
|
||||
-2962.51,
|
||||
482.53,
|
||||
15.7
|
||||
],
|
||||
"heading": 88.0,
|
||||
"blip": [
|
||||
-2962.51,
|
||||
482.53,
|
||||
15.7
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Fleeca \u2014 Great Ocean",
|
||||
"counter": [
|
||||
1175.05,
|
||||
2706.28,
|
||||
38.09
|
||||
],
|
||||
"heading": 180.0,
|
||||
"blip": [
|
||||
1175.05,
|
||||
2706.28,
|
||||
38.09
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Fleeca \u2014 Paleto Bay",
|
||||
"counter": [
|
||||
-111.24,
|
||||
6470.11,
|
||||
31.63
|
||||
],
|
||||
"heading": 135.0,
|
||||
"blip": [
|
||||
-111.24,
|
||||
6470.11,
|
||||
31.63
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Pacific Standard",
|
||||
"counter": [
|
||||
235.04,
|
||||
216.36,
|
||||
106.29
|
||||
],
|
||||
"heading": 340.0,
|
||||
"blip": [
|
||||
235.04,
|
||||
216.36,
|
||||
106.29
|
||||
]
|
||||
}
|
||||
],
|
||||
"atms": [
|
||||
[
|
||||
-301.657,
|
||||
-829.589,
|
||||
31.42
|
||||
],
|
||||
[
|
||||
-303.226,
|
||||
-829.312,
|
||||
31.42
|
||||
],
|
||||
[
|
||||
-204.019,
|
||||
-861.009,
|
||||
29.271
|
||||
],
|
||||
[
|
||||
118.642,
|
||||
-883.569,
|
||||
30.139
|
||||
],
|
||||
[
|
||||
24.593,
|
||||
-945.543,
|
||||
28.333
|
||||
],
|
||||
[
|
||||
5.686,
|
||||
-919.955,
|
||||
28.481
|
||||
],
|
||||
[
|
||||
296.176,
|
||||
-896.232,
|
||||
28.29
|
||||
],
|
||||
[
|
||||
296.878,
|
||||
-894.32,
|
||||
28.261
|
||||
],
|
||||
[
|
||||
147.473,
|
||||
-1036.218,
|
||||
28.368
|
||||
],
|
||||
[
|
||||
145.839,
|
||||
-1035.625,
|
||||
28.368
|
||||
],
|
||||
[
|
||||
112.476,
|
||||
-819.808,
|
||||
30.34
|
||||
],
|
||||
[
|
||||
111.389,
|
||||
-774.84,
|
||||
30.438
|
||||
],
|
||||
[
|
||||
114.547,
|
||||
-775.972,
|
||||
30.417
|
||||
],
|
||||
[
|
||||
-256.639,
|
||||
-715.89,
|
||||
32.788
|
||||
],
|
||||
[
|
||||
-259.277,
|
||||
-723.265,
|
||||
32.702
|
||||
],
|
||||
[
|
||||
-254.522,
|
||||
-692.887,
|
||||
32.578
|
||||
],
|
||||
[
|
||||
-27.89,
|
||||
-724.109,
|
||||
43.223
|
||||
],
|
||||
[
|
||||
-30.1,
|
||||
-723.286,
|
||||
43.223
|
||||
],
|
||||
[
|
||||
228.032,
|
||||
337.85,
|
||||
104.501
|
||||
],
|
||||
[
|
||||
158.797,
|
||||
234.745,
|
||||
105.643
|
||||
],
|
||||
[
|
||||
527.778,
|
||||
-160.661,
|
||||
56.137
|
||||
],
|
||||
[
|
||||
-57.17,
|
||||
-92.379,
|
||||
56.751
|
||||
],
|
||||
[
|
||||
89.813,
|
||||
2.88,
|
||||
67.352
|
||||
],
|
||||
[
|
||||
285.348,
|
||||
142.975,
|
||||
103.162
|
||||
],
|
||||
[
|
||||
357.128,
|
||||
174.084,
|
||||
102.06
|
||||
],
|
||||
[
|
||||
1137.811,
|
||||
-468.863,
|
||||
65.699
|
||||
],
|
||||
[
|
||||
1167.06,
|
||||
-455.654,
|
||||
65.819
|
||||
],
|
||||
[
|
||||
1077.779,
|
||||
-776.966,
|
||||
57.257
|
||||
],
|
||||
[
|
||||
289.53,
|
||||
-1256.788,
|
||||
28.441
|
||||
],
|
||||
[
|
||||
289.268,
|
||||
-1282.32,
|
||||
28.655
|
||||
],
|
||||
[
|
||||
-165.584,
|
||||
234.766,
|
||||
93.929
|
||||
],
|
||||
[
|
||||
-165.584,
|
||||
232.695,
|
||||
93.929
|
||||
],
|
||||
[
|
||||
-1044.466,
|
||||
-2739.641,
|
||||
8.124
|
||||
],
|
||||
[
|
||||
-1205.378,
|
||||
-326.529,
|
||||
36.851
|
||||
],
|
||||
[
|
||||
-1206.142,
|
||||
-325.032,
|
||||
36.851
|
||||
],
|
||||
[
|
||||
-846.654,
|
||||
-341.509,
|
||||
37.669
|
||||
],
|
||||
[
|
||||
-847.204,
|
||||
-340.429,
|
||||
37.679
|
||||
],
|
||||
[
|
||||
-720.629,
|
||||
-415.524,
|
||||
33.98
|
||||
],
|
||||
[
|
||||
-867.013,
|
||||
-187.993,
|
||||
36.882
|
||||
],
|
||||
[
|
||||
-867.975,
|
||||
-186.342,
|
||||
36.882
|
||||
],
|
||||
[
|
||||
-1415.48,
|
||||
-212.332,
|
||||
45.495
|
||||
],
|
||||
[
|
||||
-1430.663,
|
||||
-211.359,
|
||||
45.472
|
||||
],
|
||||
[
|
||||
-1410.736,
|
||||
-98.928,
|
||||
51.397
|
||||
],
|
||||
[
|
||||
-1410.183,
|
||||
-100.645,
|
||||
51.397
|
||||
],
|
||||
[
|
||||
-1282.098,
|
||||
-210.56,
|
||||
41.43
|
||||
],
|
||||
[
|
||||
-1286.704,
|
||||
-213.783,
|
||||
41.43
|
||||
],
|
||||
[
|
||||
-1289.742,
|
||||
-227.165,
|
||||
41.43
|
||||
],
|
||||
[
|
||||
-1285.136,
|
||||
-223.942,
|
||||
41.43
|
||||
],
|
||||
[
|
||||
-712.936,
|
||||
-818.483,
|
||||
22.741
|
||||
],
|
||||
[
|
||||
-710.083,
|
||||
-818.476,
|
||||
22.736
|
||||
],
|
||||
[
|
||||
-617.803,
|
||||
-708.859,
|
||||
29.043
|
||||
],
|
||||
[
|
||||
-617.803,
|
||||
-706.852,
|
||||
29.043
|
||||
],
|
||||
[
|
||||
-614.519,
|
||||
-705.598,
|
||||
30.224
|
||||
],
|
||||
[
|
||||
-611.858,
|
||||
-705.598,
|
||||
30.224
|
||||
],
|
||||
[
|
||||
-660.676,
|
||||
-854.488,
|
||||
23.457
|
||||
],
|
||||
[
|
||||
-537.805,
|
||||
-854.936,
|
||||
28.275
|
||||
],
|
||||
[
|
||||
-594.614,
|
||||
-1160.852,
|
||||
21.334
|
||||
],
|
||||
[
|
||||
-596.125,
|
||||
-1160.85,
|
||||
21.334
|
||||
],
|
||||
[
|
||||
-526.779,
|
||||
-1223.374,
|
||||
17.453
|
||||
],
|
||||
[
|
||||
-1569.84,
|
||||
-547.031,
|
||||
33.932
|
||||
],
|
||||
[
|
||||
-1570.765,
|
||||
-547.704,
|
||||
33.932
|
||||
],
|
||||
[
|
||||
-1305.708,
|
||||
-706.688,
|
||||
24.314
|
||||
],
|
||||
[
|
||||
-1315.416,
|
||||
-834.431,
|
||||
15.952
|
||||
],
|
||||
[
|
||||
-1314.466,
|
||||
-835.691,
|
||||
15.952
|
||||
],
|
||||
[
|
||||
-2071.928,
|
||||
-317.286,
|
||||
12.318
|
||||
],
|
||||
[
|
||||
-821.894,
|
||||
-1081.555,
|
||||
10.137
|
||||
],
|
||||
[
|
||||
-1110.228,
|
||||
-1691.154,
|
||||
3.378
|
||||
],
|
||||
[
|
||||
-2956.848,
|
||||
487.216,
|
||||
14.478
|
||||
],
|
||||
[
|
||||
-2958.977,
|
||||
487.307,
|
||||
14.478
|
||||
],
|
||||
[
|
||||
-2974.586,
|
||||
380.127,
|
||||
14.0
|
||||
],
|
||||
[
|
||||
-1091.888,
|
||||
2709.054,
|
||||
17.919
|
||||
],
|
||||
[
|
||||
-2295.852,
|
||||
357.935,
|
||||
173.601
|
||||
],
|
||||
[
|
||||
-2295.069,
|
||||
356.256,
|
||||
173.601
|
||||
],
|
||||
[
|
||||
-2294.3,
|
||||
354.606,
|
||||
173.601
|
||||
],
|
||||
[
|
||||
-3144.888,
|
||||
1127.811,
|
||||
19.838
|
||||
],
|
||||
[
|
||||
-3043.835,
|
||||
594.164,
|
||||
6.733
|
||||
],
|
||||
[
|
||||
-3241.455,
|
||||
997.908,
|
||||
11.548
|
||||
],
|
||||
[
|
||||
2564.0,
|
||||
2584.553,
|
||||
37.068
|
||||
],
|
||||
[
|
||||
2558.324,
|
||||
350.988,
|
||||
107.597
|
||||
],
|
||||
[
|
||||
156.189,
|
||||
6643.2,
|
||||
30.594
|
||||
],
|
||||
[
|
||||
173.825,
|
||||
6638.217,
|
||||
30.594
|
||||
],
|
||||
[
|
||||
-282.714,
|
||||
6226.43,
|
||||
30.496
|
||||
],
|
||||
[
|
||||
-95.87,
|
||||
6457.462,
|
||||
30.474
|
||||
],
|
||||
[
|
||||
-97.637,
|
||||
6455.732,
|
||||
30.468
|
||||
],
|
||||
[
|
||||
-132.666,
|
||||
6366.876,
|
||||
30.473
|
||||
],
|
||||
[
|
||||
-386.46,
|
||||
6046.411,
|
||||
30.474
|
||||
],
|
||||
[
|
||||
1687.395,
|
||||
4815.9,
|
||||
41.006
|
||||
],
|
||||
[
|
||||
1700.694,
|
||||
6426.762,
|
||||
31.633
|
||||
],
|
||||
[
|
||||
1822.971,
|
||||
3682.577,
|
||||
33.267
|
||||
],
|
||||
[
|
||||
1171.523,
|
||||
2703.139,
|
||||
37.148
|
||||
],
|
||||
[
|
||||
1172.457,
|
||||
2703.139,
|
||||
37.148
|
||||
],
|
||||
[
|
||||
238.268,
|
||||
217.109,
|
||||
105.406
|
||||
],
|
||||
[
|
||||
238.698,
|
||||
216.187,
|
||||
105.406
|
||||
],
|
||||
[
|
||||
237.838,
|
||||
218.031,
|
||||
105.406
|
||||
],
|
||||
[
|
||||
237.408,
|
||||
218.954,
|
||||
105.406
|
||||
],
|
||||
[
|
||||
236.978,
|
||||
219.876,
|
||||
105.406
|
||||
],
|
||||
[
|
||||
264.869,
|
||||
209.949,
|
||||
105.406
|
||||
],
|
||||
[
|
||||
265.217,
|
||||
210.905,
|
||||
105.406
|
||||
],
|
||||
[
|
||||
265.565,
|
||||
211.861,
|
||||
105.406
|
||||
],
|
||||
[
|
||||
265.913,
|
||||
212.817,
|
||||
105.406
|
||||
],
|
||||
[
|
||||
266.261,
|
||||
213.773,
|
||||
105.406
|
||||
],
|
||||
[
|
||||
380.656,
|
||||
322.842,
|
||||
102.566
|
||||
],
|
||||
[
|
||||
1153.111,
|
||||
-326.902,
|
||||
68.205
|
||||
],
|
||||
[
|
||||
33.194,
|
||||
-1348.806,
|
||||
28.497
|
||||
],
|
||||
[
|
||||
130.579,
|
||||
-1292.369,
|
||||
28.271
|
||||
],
|
||||
[
|
||||
130.15,
|
||||
-1291.626,
|
||||
28.271
|
||||
],
|
||||
[
|
||||
129.698,
|
||||
-1290.842,
|
||||
28.271
|
||||
],
|
||||
[
|
||||
-57.402,
|
||||
-1751.747,
|
||||
28.421
|
||||
],
|
||||
[
|
||||
-718.261,
|
||||
-915.713,
|
||||
18.216
|
||||
],
|
||||
[
|
||||
-273.367,
|
||||
-2024.208,
|
||||
29.17
|
||||
],
|
||||
[
|
||||
-262.361,
|
||||
-2012.054,
|
||||
29.17
|
||||
],
|
||||
[
|
||||
-1391.344,
|
||||
-589.863,
|
||||
29.316
|
||||
],
|
||||
[
|
||||
-1827.689,
|
||||
784.465,
|
||||
137.315
|
||||
],
|
||||
[
|
||||
-3040.205,
|
||||
593.297,
|
||||
6.909
|
||||
],
|
||||
[
|
||||
-3240.028,
|
||||
1008.545,
|
||||
11.831
|
||||
],
|
||||
[
|
||||
2559.052,
|
||||
389.474,
|
||||
107.623
|
||||
],
|
||||
[
|
||||
1703.315,
|
||||
4934.053,
|
||||
41.064
|
||||
],
|
||||
[
|
||||
1735.01,
|
||||
6410.01,
|
||||
34.037
|
||||
],
|
||||
[
|
||||
2683.592,
|
||||
3286.3,
|
||||
54.241
|
||||
],
|
||||
[
|
||||
1968.392,
|
||||
3743.078,
|
||||
31.344
|
||||
],
|
||||
[
|
||||
540.221,
|
||||
2671.683,
|
||||
41.156
|
||||
]
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
-- Berry picking — the client half.
|
||||
-- Places the buyer and the bushes, plays the cut, and shows the counter. It
|
||||
-- decides nothing: what came off a bush and what it is worth are answered by
|
||||
-- the server, which is why neither number appears in this file.
|
||||
|
||||
local bushes = {} -- id -> { prop, coords }
|
||||
-- Rejected spots are remembered so the sweep does not probe them again every
|
||||
-- two seconds for the rest of the session.
|
||||
local rejected = {}
|
||||
local buyerPed = nil
|
||||
local cutting = false
|
||||
|
||||
-- ------------------------------------------------------------------ blips
|
||||
CreateThread(function()
|
||||
-- Sprite 52 (a shop) rather than something basket-shaped: the ids beyond the
|
||||
-- vanilla range render as nothing at all, which is why the buyer had no blip
|
||||
-- to find in the first place.
|
||||
local blip = AddBlipForCoord(BerryConfig.buyer.coords)
|
||||
SetBlipSprite(blip, 52)
|
||||
SetBlipDisplay(blip, 4)
|
||||
SetBlipScale(blip, 0.9)
|
||||
SetBlipColour(blip, 2)
|
||||
SetBlipAsShortRange(blip, false)
|
||||
BeginTextCommandSetBlipName('STRING')
|
||||
AddTextComponentSubstringPlayerName(BerryConfig.buyer.label)
|
||||
EndTextCommandSetBlipName(blip)
|
||||
|
||||
-- One blip per patch, not per bush, and every one of them named the same:
|
||||
-- the map groups by sprite and name, so four names meant four lines in the
|
||||
-- legend instead of one entry you can page through.
|
||||
for _, patch in ipairs(BerryConfig.patches) do
|
||||
local ring = AddBlipForRadius(patch.centre, patch.radius)
|
||||
SetBlipColour(ring, 2)
|
||||
SetBlipAlpha(ring, 80)
|
||||
|
||||
local m = AddBlipForCoord(patch.centre)
|
||||
SetBlipSprite(m, 1)
|
||||
SetBlipDisplay(m, 4)
|
||||
SetBlipScale(m, 0.75)
|
||||
SetBlipColour(m, 2)
|
||||
SetBlipAsShortRange(m, false)
|
||||
BeginTextCommandSetBlipName('STRING')
|
||||
AddTextComponentSubstringPlayerName('Berry patch')
|
||||
EndTextCommandSetBlipName(m)
|
||||
end
|
||||
end)
|
||||
|
||||
-- ------------------------------------------------------------------- peds
|
||||
local function loadModel(name)
|
||||
local hash = GetHashKey(name)
|
||||
RequestModel(hash)
|
||||
local timeout = GetGameTimer() + 8000
|
||||
while not HasModelLoaded(hash) and GetGameTimer() < timeout do Wait(0) end
|
||||
return HasModelLoaded(hash) and hash or nil
|
||||
end
|
||||
|
||||
local function spawnBuyer()
|
||||
if buyerPed and DoesEntityExist(buyerPed) then return end
|
||||
|
||||
local hash = loadModel(BerryConfig.buyer.model)
|
||||
if not hash then return end
|
||||
|
||||
local at = BerryConfig.buyer.coords
|
||||
local found, groundZ = GetGroundZFor_3dCoord(at.x, at.y, at.z + 1.0, false)
|
||||
local z = found and groundZ or (at.z - 1.0)
|
||||
|
||||
buyerPed = CreatePed(4, hash, at.x, at.y, z, BerryConfig.buyer.heading, false, true)
|
||||
SetEntityInvincible(buyerPed, true)
|
||||
FreezeEntityPosition(buyerPed, true)
|
||||
SetBlockingOfNonTemporaryEvents(buyerPed, true)
|
||||
SetPedCanRagdoll(buyerPed, false)
|
||||
TaskStartScenarioInPlace(buyerPed, 'WORLD_HUMAN_STAND_IMPATIENT', 0, true)
|
||||
SetModelAsNoLongerNeeded(hash)
|
||||
|
||||
exports['fiverp-interact']:add({
|
||||
id = 'berry:buyer',
|
||||
coords = vector3(at.x, at.y, at.z),
|
||||
label = BerryConfig.buyer.label,
|
||||
distance = 2.4,
|
||||
hold = 550,
|
||||
onSelect = function() TriggerServerEvent('fiverp-berries:open') end
|
||||
})
|
||||
end
|
||||
|
||||
local function despawnBuyer()
|
||||
if buyerPed and DoesEntityExist(buyerPed) then DeleteEntity(buyerPed) end
|
||||
buyerPed = nil
|
||||
exports['fiverp-interact']:remove('berry:buyer')
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------------- bushes
|
||||
-- Finding the actual ground, which is harder than it sounds in a forest.
|
||||
--
|
||||
-- GetGroundZFor_3dCoord answers with the first solid thing below the height you
|
||||
-- give it, and a branch is solid. Probing from high up therefore lands on the
|
||||
-- canopy, and probing from low down answers nothing while the world is still
|
||||
-- streaming. So: find something, then keep looking underneath it until there is
|
||||
-- nothing further down. What is left is terrain.
|
||||
local function surfaceBelow(x, y, from)
|
||||
local found, z = GetGroundZFor_3dCoord(x, y, from + 0.0, false)
|
||||
if found and z > -200.0 then return z end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function groundAt(x, y, hint)
|
||||
RequestCollisionAtCoord(x, y, hint)
|
||||
|
||||
local z = nil
|
||||
for _, from in ipairs({ hint + 4.0, hint + 20.0, hint + 60.0, 200.0, 600.0 }) do
|
||||
z = surfaceBelow(x, y, from)
|
||||
if z then break end
|
||||
Wait(0)
|
||||
end
|
||||
if not z then return nil end
|
||||
|
||||
-- Descend. Each step asks what is below the surface we just found; if there
|
||||
-- is another one a clear step further down, the first was something we were
|
||||
-- standing on rather than the floor.
|
||||
for _ = 1, 8 do
|
||||
local below = surfaceBelow(x, y, z - 0.8)
|
||||
if not below or (z - below) < 1.0 then break end
|
||||
z = below
|
||||
end
|
||||
|
||||
return z
|
||||
end
|
||||
|
||||
local function spawnBush(entry)
|
||||
if rejected[entry.id] then return end
|
||||
if bushes[entry.id] and bushes[entry.id].prop then return end
|
||||
|
||||
local x, y = entry.coords[1], entry.coords[2]
|
||||
|
||||
-- No ground yet means the world around it has not loaded. Leave it for the
|
||||
-- next pass rather than planting it at a height that is merely plausible.
|
||||
local z = groundAt(x, y, entry.coords[3])
|
||||
if not z then return end
|
||||
|
||||
-- Too high above the patch means the probe found a branch, not soil. This is
|
||||
-- what put bushes up in the trees.
|
||||
if math.abs(z - entry.coords[3]) > BerryConfig.place.maxLift then
|
||||
rejected[entry.id] = true
|
||||
return
|
||||
end
|
||||
|
||||
-- Slope, from the surface normal where the game will give one. Guarded,
|
||||
-- because this native's return shape differs between builds and reading .z
|
||||
-- off the wrong thing threw — which killed the spawn loop and took every
|
||||
-- bush on the server with it. A check that can silently remove the feature
|
||||
-- it guards is worse than no check.
|
||||
local okNormal, normalZ = pcall(function()
|
||||
local found, _, normal = GetGroundZAndNormalFor_3dCoord(x, y, z + 1.0)
|
||||
if found and normal and normal.z then return normal.z end
|
||||
return nil
|
||||
end)
|
||||
|
||||
if okNormal and normalZ and normalZ < BerryConfig.place.minNormalZ then
|
||||
rejected[entry.id] = true
|
||||
return
|
||||
end
|
||||
|
||||
-- And a second opinion from the terrain either side, because the normal is
|
||||
-- one point and a bush can sit on a flat rock at the top of a drop.
|
||||
local lowest, highest = z, z
|
||||
for _, off in ipairs({ { 1.2, 0.0 }, { -1.2, 0.0 }, { 0.0, 1.2 }, { 0.0, -1.2 } }) do
|
||||
local nz = groundAt(x + off[1], y + off[2], z)
|
||||
if not nz then rejected[entry.id] = true return end
|
||||
if nz < lowest then lowest = nz end
|
||||
if nz > highest then highest = nz end
|
||||
end
|
||||
|
||||
if highest - lowest > BerryConfig.place.maxSlope then
|
||||
rejected[entry.id] = true
|
||||
return
|
||||
end
|
||||
|
||||
local name = BerryConfig.bushModels[(entry.model % #BerryConfig.bushModels) + 1]
|
||||
local hash = loadModel(name)
|
||||
|
||||
local prop = nil
|
||||
if hash then
|
||||
prop = CreateObject(hash, x, y, z, false, false, false)
|
||||
PlaceObjectOnGroundProperly(prop)
|
||||
FreezeEntityPosition(prop, true)
|
||||
SetModelAsNoLongerNeeded(hash)
|
||||
z = GetEntityCoords(prop).z
|
||||
end
|
||||
|
||||
bushes[entry.id] = { prop = prop, coords = vector3(x, y, z) }
|
||||
|
||||
exports['fiverp-interact']:add({
|
||||
id = 'berry:bush:' .. entry.id,
|
||||
coords = vector3(x, y, z + 0.85),
|
||||
label = 'Berries',
|
||||
distance = BerryConfig.reach,
|
||||
hold = BerryConfig.holdMs,
|
||||
onSelect = function() cut(entry.id) end
|
||||
})
|
||||
end
|
||||
|
||||
local function despawnBush(id)
|
||||
local held = bushes[id]
|
||||
if not held then return end
|
||||
if held.prop and DoesEntityExist(held.prop) then DeleteEntity(held.prop) end
|
||||
exports['fiverp-interact']:remove('berry:bush:' .. id)
|
||||
bushes[id] = nil
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------- cut
|
||||
local ANIM_DICT = 'amb@prop_human_bum_bin@idle_b'
|
||||
local ANIM_NAME = 'idle_d'
|
||||
|
||||
function cut(id)
|
||||
if cutting then return end
|
||||
cutting = true
|
||||
|
||||
local ped = PlayerPedId()
|
||||
|
||||
RequestAnimDict(ANIM_DICT)
|
||||
local timeout = GetGameTimer() + 3000
|
||||
while not HasAnimDictLoaded(ANIM_DICT) and GetGameTimer() < timeout do Wait(0) end
|
||||
|
||||
if HasAnimDictLoaded(ANIM_DICT) then
|
||||
TaskPlayAnim(ped, ANIM_DICT, ANIM_NAME, 4.0, -4.0, -1, 1, 0, false, false, false)
|
||||
end
|
||||
|
||||
-- The animation runs first and the ask goes out after it: a cut that is
|
||||
-- refused should still have looked like reaching into a bush.
|
||||
CreateThread(function()
|
||||
Wait(BerryConfig.animMs)
|
||||
ClearPedTasks(PlayerPedId())
|
||||
TriggerServerEvent('fiverp-berries:cut', id)
|
||||
cutting = false
|
||||
end)
|
||||
end
|
||||
|
||||
-- A bush somebody just stripped goes quiet until it regrows, for everyone.
|
||||
RegisterNetEvent('fiverp-berries:cutAccepted', function(id, seconds)
|
||||
local held = bushes[id]
|
||||
if not held then return end
|
||||
|
||||
exports['fiverp-interact']:remove('berry:bush:' .. id)
|
||||
|
||||
CreateThread(function()
|
||||
Wait((seconds or 180) * 1000)
|
||||
if bushes[id] then
|
||||
exports['fiverp-interact']:add({
|
||||
id = 'berry:bush:' .. id,
|
||||
coords = vector3(held.coords.x, held.coords.y, held.coords.z + 0.85),
|
||||
label = 'Berries',
|
||||
distance = BerryConfig.reach,
|
||||
hold = BerryConfig.holdMs,
|
||||
onSelect = function() cut(id) end
|
||||
})
|
||||
end
|
||||
end)
|
||||
end)
|
||||
|
||||
-- ---------------------------------------------------------------- streaming
|
||||
local bushList = {}
|
||||
|
||||
RegisterNetEvent('fiverp-berries:bushList', function(list)
|
||||
bushList = list or {}
|
||||
end)
|
||||
|
||||
CreateThread(function()
|
||||
Wait(3000)
|
||||
TriggerServerEvent('fiverp-berries:bushes')
|
||||
|
||||
while true do
|
||||
local me = GetEntityCoords(PlayerPedId())
|
||||
|
||||
local d = #(me - BerryConfig.buyer.coords)
|
||||
if d < 60.0 then spawnBuyer() elseif d > 90.0 then despawnBuyer() end
|
||||
|
||||
for _, entry in ipairs(bushList) do
|
||||
local at = vector3(entry.coords[1], entry.coords[2], entry.coords[3])
|
||||
local dist = #(me - at)
|
||||
if dist < 70.0 then
|
||||
-- One bush that cannot be placed must not stop the other forty-nine.
|
||||
local ok, err = pcall(spawnBush, entry)
|
||||
if not ok then
|
||||
print('[fiverp-berries] bush ' .. tostring(entry.id) .. ': ' .. tostring(err))
|
||||
rejected[entry.id] = true
|
||||
end
|
||||
elseif dist > 110.0 then
|
||||
despawnBush(entry.id)
|
||||
end
|
||||
end
|
||||
|
||||
Wait(2000)
|
||||
end
|
||||
end)
|
||||
|
||||
-- ------------------------------------------------------------------- menu
|
||||
local menuOpen = false
|
||||
|
||||
RegisterNetEvent('fiverp-berries:counter', function(data)
|
||||
SendNUIMessage({ action = 'counter', data = data })
|
||||
end)
|
||||
|
||||
RegisterNetEvent('fiverp-berries:show', function()
|
||||
menuOpen = true
|
||||
SetNuiFocus(true, true)
|
||||
SendNUIMessage({ action = 'open' })
|
||||
end)
|
||||
|
||||
RegisterNetEvent('fiverp-berries:told', function(text, kind)
|
||||
-- The counter shows it too while it is open; out in the field the toast is
|
||||
-- the only place it appears. The chat is for people talking.
|
||||
SendNUIMessage({ action = 'told', text = text })
|
||||
exports['fiverp-notify']:show('Berries', text, kind or 'info')
|
||||
end)
|
||||
|
||||
RegisterNetEvent('fiverp-berries:ate', function(ms)
|
||||
local ped = PlayerPedId()
|
||||
local dict, clip = 'mp_player_inteat@burger', 'mp_player_int_eat_burger'
|
||||
|
||||
RequestAnimDict(dict)
|
||||
local timeout = GetGameTimer() + 2000
|
||||
while not HasAnimDictLoaded(dict) and GetGameTimer() < timeout do Wait(0) end
|
||||
if not HasAnimDictLoaded(dict) then return end
|
||||
|
||||
-- Flag 49 keeps it upper-body only, so eating does not stop you walking.
|
||||
TaskPlayAnim(ped, dict, clip, 4.0, -4.0, ms or 2600, 49, 0, false, false, false)
|
||||
end)
|
||||
|
||||
local function closeMenu()
|
||||
menuOpen = false
|
||||
SetNuiFocus(false, false)
|
||||
SendNUIMessage({ action = 'close' })
|
||||
end
|
||||
|
||||
RegisterNUICallback('close', function(_, cb) closeMenu(); cb('ok') end)
|
||||
RegisterNUICallback('buyShears', function(_, cb) TriggerServerEvent('fiverp-berries:buyShears'); cb('ok') end)
|
||||
RegisterNUICallback('sell', function(data, cb) TriggerServerEvent('fiverp-berries:sell', data.item); cb('ok') end)
|
||||
|
||||
AddEventHandler('onResourceStop', function(name)
|
||||
if name ~= GetCurrentResourceName() then return end
|
||||
SetNuiFocus(false, false)
|
||||
despawnBuyer()
|
||||
for id in pairs(bushes) do despawnBush(id) end
|
||||
end)
|
||||
@@ -0,0 +1,25 @@
|
||||
fx_version 'cerulean'
|
||||
game 'gta5'
|
||||
|
||||
name 'fiverp-berries'
|
||||
author 'FiveRP'
|
||||
description 'Berry picking: shears, patches, and a buyer in Paleto'
|
||||
version '0.1.0'
|
||||
|
||||
dependency 'fiverp-interact'
|
||||
dependency 'fiverp-inventory'
|
||||
|
||||
ui_page 'html/index.html'
|
||||
|
||||
shared_script 'shared/config.lua'
|
||||
client_scripts { 'client/cl_berries.lua' }
|
||||
server_scripts { 'server/sv_berries.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',
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/* Berry buyer — the counter.
|
||||
Every number on screen arrived from the server, including how many berries
|
||||
the player is carrying: asking the inventory here would only produce a second
|
||||
opinion, and the one that pays out is the server's. */
|
||||
|
||||
const RES = 'fiverp-berries';
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
let counter = { stock: [], shears: { price: 0, owned: false }, cash: 0 };
|
||||
|
||||
function money(value) {
|
||||
return '$' + Math.floor(Number(value) || 0).toLocaleString('en-US');
|
||||
}
|
||||
|
||||
function post(name, data) {
|
||||
fetch(`https://${RES}/${name}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json; charset=UTF-8' },
|
||||
body: JSON.stringify(data || {})
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function escape(text) {
|
||||
return String(text).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function draw() {
|
||||
$('cash').textContent = money(counter.cash);
|
||||
|
||||
const shears = counter.shears || {};
|
||||
$('tool').innerHTML = `
|
||||
<div class="tool__text">
|
||||
<div class="tool__name">Pruning shears</div>
|
||||
<div class="tool__note">
|
||||
Cut the cluster and the bush fruits again. Tear it off by hand and the
|
||||
patch stops giving. You need a pair before any bush will let you work.
|
||||
</div>
|
||||
</div>
|
||||
${shears.owned
|
||||
? '<span class="tool__owned">In your bag</span>'
|
||||
: `<button class="tool__buy" id="buy" type="button">Buy ${money(shears.price)}</button>`}`;
|
||||
|
||||
if (!shears.owned) {
|
||||
$('buy').addEventListener('click', () => post('buyShears', {}));
|
||||
}
|
||||
|
||||
const stock = counter.stock || [];
|
||||
$('rows').innerHTML = stock.map((s) => `
|
||||
<div class="row${s.have > 0 ? '' : ' is-empty'}">
|
||||
<span class="row__name">${escape(s.label)}</span>
|
||||
<span class="row__have">${s.have}</span>
|
||||
<span class="row__price"><b>${money(s.price * s.have)}</b> at ${money(s.price)}</span>
|
||||
<button class="row__sell" data-item="${escape(s.item)}" type="button">Sell</button>
|
||||
</div>`).join('');
|
||||
|
||||
$('rows').querySelectorAll('.row__sell').forEach((el) => {
|
||||
el.addEventListener('click', () => post('sell', { item: el.dataset.item }));
|
||||
});
|
||||
|
||||
const total = stock.reduce((sum, s) => sum + s.price * s.have, 0);
|
||||
const btn = $('sellAll');
|
||||
btn.disabled = total <= 0;
|
||||
btn.textContent = total > 0 ? `Sell everything — ${money(total)}` : 'Nothing to sell';
|
||||
}
|
||||
|
||||
let noticeTimer = null;
|
||||
function notice(text) {
|
||||
$('notice').textContent = text || '';
|
||||
clearTimeout(noticeTimer);
|
||||
if (text) noticeTimer = setTimeout(() => { $('notice').textContent = ''; }, 4000);
|
||||
}
|
||||
|
||||
function close() {
|
||||
$('root').classList.remove('is-open');
|
||||
post('close', {});
|
||||
}
|
||||
|
||||
$('close').addEventListener('click', close);
|
||||
$('sellAll').addEventListener('click', () => post('sell', { item: 'all' }));
|
||||
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') close(); });
|
||||
|
||||
window.addEventListener('message', (event) => {
|
||||
const msg = event.data || {};
|
||||
|
||||
if (msg.action === 'counter') {
|
||||
counter = Object.assign(counter, msg.data || {});
|
||||
draw();
|
||||
return;
|
||||
}
|
||||
if (msg.action === 'open') { $('root').classList.add('is-open'); notice(''); return; }
|
||||
if (msg.action === 'close') { $('root').classList.remove('is-open'); return; }
|
||||
if (msg.action === 'told') { notice(msg.text); }
|
||||
});
|
||||
|
||||
draw();
|
||||
@@ -0,0 +1,42 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>FiveRP — Berry buyer</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="root" class="root">
|
||||
<div class="panel">
|
||||
|
||||
<header class="head">
|
||||
<div>
|
||||
<p class="eyebrow">Paleto Bay</p>
|
||||
<h1 class="title">Berry buyer</h1>
|
||||
</div>
|
||||
<div class="cash">
|
||||
<span class="cash__label">Cash</span>
|
||||
<span class="cash__value" id="cash">$0</span>
|
||||
</div>
|
||||
<button class="close" id="close" type="button">Close</button>
|
||||
</header>
|
||||
|
||||
<section class="tool" id="tool"></section>
|
||||
|
||||
<section class="stock">
|
||||
<p class="group">What you brought</p>
|
||||
<div class="rows" id="rows"></div>
|
||||
</section>
|
||||
|
||||
<footer class="foot">
|
||||
<p class="notice" id="notice"></p>
|
||||
<button class="btn-primary" id="sellAll" type="button">Sell everything</button>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,215 @@
|
||||
/* FiveRP — chat.
|
||||
Design system: CLAUDE.md — Clean UI / Dark Minimalism.
|
||||
Sits over the game, so the page itself stays transparent and only the
|
||||
panels take a fill. No blur, no shadow, no gradient. */
|
||||
|
||||
@font-face {
|
||||
font-family: 'Unbounded';
|
||||
font-style: normal;
|
||||
font-weight: 300 900;
|
||||
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 900;
|
||||
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 900;
|
||||
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: rgba(24, 24, 24, 1);
|
||||
--card-bg: rgba(30, 30, 30, 1);
|
||||
--btn-bg: rgba(51, 51, 51, 1);
|
||||
--btn-hover: rgba(70, 70, 70, 1);
|
||||
--accent: rgba(255, 119, 22, 1);
|
||||
--text-primary: rgba(241, 241, 241, 1);
|
||||
--text-secondary: rgba(153, 153, 153, 1);
|
||||
--border: rgba(51, 51, 51, 0.5);
|
||||
|
||||
--sans: 'Unbounded', sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0; padding: 0; box-sizing: border-box;
|
||||
font-family: var(--sans); user-select: none;
|
||||
}
|
||||
|
||||
html, body { width: 100%; height: 100%; overflow: hidden; background: transparent; }
|
||||
body { color: var(--text-primary); }
|
||||
|
||||
.root {
|
||||
position: fixed; inset: 0; display: none;
|
||||
align-items: center; justify-content: center;
|
||||
background-color: rgba(24, 24, 24, 0.86);
|
||||
}
|
||||
|
||||
.root.is-open { display: flex; }
|
||||
|
||||
.panel {
|
||||
width: min(520px, 88vw);
|
||||
background-color: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex; align-items: center; gap: 20px;
|
||||
padding: 25px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-size: 10px; font-weight: 500; letter-spacing: 1px;
|
||||
text-transform: uppercase; color: var(--text-secondary); margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 20px; font-weight: 900; letter-spacing: 1px; text-transform: uppercase;
|
||||
}
|
||||
|
||||
.cash { flex: 1; text-align: right; }
|
||||
|
||||
.cash__label {
|
||||
display: block; font-size: 10px; font-weight: 500; letter-spacing: 1px;
|
||||
text-transform: uppercase; color: var(--text-secondary); margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.cash__value {
|
||||
font-size: 16px; font-weight: 900; font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.close {
|
||||
flex-shrink: 0; border: 1px solid var(--border); cursor: pointer;
|
||||
font-size: 10px; font-weight: 700; letter-spacing: 1px; text-transform: uppercase;
|
||||
color: var(--text-primary); background-color: var(--btn-bg);
|
||||
border-radius: 20px; padding: 10px 18px; transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.close:hover { background-color: var(--btn-hover); }
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────── shears */
|
||||
|
||||
.tool {
|
||||
display: flex; align-items: center; gap: 16px;
|
||||
margin: 25px; padding: 18px;
|
||||
background-color: var(--btn-bg); border-radius: 12px;
|
||||
}
|
||||
|
||||
.tool__text { flex: 1; min-width: 0; }
|
||||
|
||||
.tool__name {
|
||||
font-size: 12px; font-weight: 700; letter-spacing: 1px;
|
||||
text-transform: uppercase; margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.tool__note {
|
||||
font-size: 10px; font-weight: 400; line-height: 1.5; color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.tool__buy {
|
||||
flex-shrink: 0; border: 0; cursor: pointer;
|
||||
font-size: 10px; font-weight: 700; letter-spacing: 1px; text-transform: uppercase;
|
||||
color: var(--bg); background-color: var(--accent);
|
||||
border-radius: 20px; padding: 11px 18px; transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.tool__buy:hover { transform: translateY(-2px); }
|
||||
|
||||
.tool__owned {
|
||||
flex-shrink: 0; font-size: 10px; font-weight: 700; letter-spacing: 1px;
|
||||
text-transform: uppercase; color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────── stock */
|
||||
|
||||
.stock { padding: 0 25px; }
|
||||
|
||||
.group {
|
||||
font-size: 10px; font-weight: 700; letter-spacing: 1px;
|
||||
text-transform: uppercase; color: var(--text-secondary); margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.rows { display: flex; flex-direction: column; gap: 8px; }
|
||||
|
||||
.row {
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
background-color: var(--btn-bg); border-radius: 12px; padding: 13px 16px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
/* Nothing of that kind in the bag: shown, but plainly not actionable. */
|
||||
.row.is-empty { opacity: 0.45; }
|
||||
|
||||
.row__name {
|
||||
flex: 1; min-width: 0;
|
||||
font-size: 12px; font-weight: 700; letter-spacing: 1px; text-transform: uppercase;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
|
||||
.row__have {
|
||||
font-size: 11px; font-weight: 700; color: var(--text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.row__price {
|
||||
font-size: 11px; font-weight: 500; color: var(--text-secondary);
|
||||
font-variant-numeric: tabular-nums; min-width: 74px; text-align: right;
|
||||
}
|
||||
|
||||
.row__price b { font-weight: 700; color: var(--text-primary); }
|
||||
|
||||
.row__sell {
|
||||
flex-shrink: 0; border: 1px solid var(--border); cursor: pointer;
|
||||
font-size: 10px; font-weight: 700; letter-spacing: 1px; text-transform: uppercase;
|
||||
color: var(--text-primary); background-color: var(--card-bg);
|
||||
border-radius: 20px; padding: 9px 16px; transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.row__sell:hover { background-color: var(--btn-hover); }
|
||||
|
||||
.row.is-empty .row__sell { visibility: hidden; }
|
||||
|
||||
/* ───────────────────────────────────────────────────────────────── foot */
|
||||
|
||||
.foot {
|
||||
display: flex; align-items: center; gap: 20px; padding: 25px;
|
||||
}
|
||||
|
||||
.notice {
|
||||
flex: 1; min-width: 0;
|
||||
font-size: 11px; font-weight: 500; line-height: 1.45; color: var(--accent);
|
||||
min-height: 16px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
flex-shrink: 0; border: 0; cursor: pointer;
|
||||
font-size: 12px; font-weight: 700; letter-spacing: 1px; text-transform: uppercase;
|
||||
color: var(--bg); background-color: var(--accent);
|
||||
border-radius: 12px; padding: 15px 24px; transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-primary:hover { transform: translateY(-2px); }
|
||||
|
||||
.btn-primary:disabled {
|
||||
cursor: default; background-color: var(--btn-bg);
|
||||
color: var(--text-secondary); transform: none;
|
||||
}
|
||||
|
||||
body.no-anim *, body.no-anim *::before, body.no-anim *::after {
|
||||
animation: none !important; transition: none !important;
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
// Berry picking — the server half.
|
||||
//
|
||||
// It owns the bushes, the cooldowns and the money. The client says "I cut bush
|
||||
// 41"; the server decides whether that bush exists, whether the player is
|
||||
// standing near it, whether it has regrown, whether they own shears, and only
|
||||
// then what came off it. Nothing about the yield or the price ever travels
|
||||
// upward.
|
||||
|
||||
const CONFIG = {
|
||||
shearsItem: 'berry_shears',
|
||||
shearsPrice: 150,
|
||||
regrowSeconds: 180,
|
||||
reach: 1.8,
|
||||
// Slack on top of the client's reach: the server's copy of a position lags a
|
||||
// frame or two, and refusing an honest player standing still is worse than
|
||||
// allowing one who stepped back half a metre.
|
||||
slack: 2.5,
|
||||
buyer: { coords: [2224.4, 5155.6, 57.0] },
|
||||
// Flat farmland. Kept identical to shared/config.lua by hand, because the two
|
||||
// runtimes cannot read one file and the server must not learn a bush's
|
||||
// position from a client.
|
||||
patches: [
|
||||
{ name: 'Grapeseed fields', centre: [2140.0, 4880.0, 41.0], radius: 55.0, bushes: 14 },
|
||||
{ name: 'Alamo flats', centre: [1720.0, 4680.0, 42.0], radius: 50.0, bushes: 12 },
|
||||
{ name: 'East pastures', centre: [2420.0, 4720.0, 34.0], radius: 50.0, bushes: 12 },
|
||||
{ name: 'Braddock plain', centre: [1930.0, 5040.0, 45.0], radius: 48.0, bushes: 12 }
|
||||
],
|
||||
// Candidates per wanted bush. The client drops the ones that land on a slope
|
||||
// or in a canopy, so there have to be spares or a patch ends up sparse.
|
||||
spareFactor: 1.6,
|
||||
berries: [
|
||||
{ item: 'berry_black', label: 'Blackberries', draw: 55, price: 12, yield: [2, 4] },
|
||||
{ item: 'berry_rasp', label: 'Raspberries', draw: 25, price: 24, yield: [2, 3] },
|
||||
{ item: 'berry_blue', label: 'Blueberries', draw: 15, price: 45, yield: [1, 3] },
|
||||
{ item: 'berry_cloud', label: 'Cloudberries', draw: 5, price: 130, yield: [1, 2] }
|
||||
]
|
||||
};
|
||||
|
||||
const PRICE = {};
|
||||
CONFIG.berries.forEach((b) => { PRICE[b.item] = b.price; });
|
||||
|
||||
// -------------------------------------------------------------- the items
|
||||
// Registered with the inventory rather than written into its registry file:
|
||||
// berries belong to this job, and the job is what should carry them.
|
||||
const ITEMS = {
|
||||
berry_shears: {
|
||||
label: 'Pruning shears',
|
||||
description: 'Cuts a berry cluster off cleanly, so the bush lives to fruit again.',
|
||||
weight: 0.6, stack: 1, model: 'prop_tool_shears', image: 'berry_shears.png'
|
||||
},
|
||||
berry_black: {
|
||||
label: 'Blackberries',
|
||||
description: 'Common, and they stain everything they touch.',
|
||||
weight: 0.15, stack: 24, action: 'eat_berry', model: 'prop_cs_bowl_01', image: 'berry_black.png'
|
||||
},
|
||||
berry_rasp: {
|
||||
label: 'Raspberries',
|
||||
description: 'Sweeter, and they bruise if you are careless.',
|
||||
weight: 0.12, stack: 24, action: 'eat_berry', model: 'prop_cs_bowl_01', image: 'berry_rasp.png'
|
||||
},
|
||||
berry_blue: {
|
||||
label: 'Blueberries',
|
||||
description: 'Grow high up the slope where few people bother to walk.',
|
||||
weight: 0.10, stack: 24, action: 'eat_berry', model: 'prop_cs_bowl_01', image: 'berry_blue.png'
|
||||
},
|
||||
berry_cloud: {
|
||||
label: 'Cloudberries',
|
||||
description: 'Rare enough that the buyer will ask where you found them.',
|
||||
weight: 0.08, stack: 12, action: 'eat_berry', model: 'prop_cs_bowl_01', image: 'berry_cloud.png'
|
||||
}
|
||||
};
|
||||
|
||||
// What eating one does. Berries are food first, water second, and a small lift
|
||||
// either way — foraging should be worth doing when you are out in the hills
|
||||
// with nothing else.
|
||||
const EAT = { hunger: 6, thirst: 2, mood: 3 };
|
||||
|
||||
function ask(resource, method, fallback, ...args) {
|
||||
try {
|
||||
const target = exports[resource];
|
||||
const result = target[method](...args);
|
||||
return result === undefined ? fallback : result;
|
||||
} catch (err) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- the bushes
|
||||
// Placed once, from a fixed seed. Random placement would move every bush on
|
||||
// every restart, and a patch nobody can learn is not a place, it is noise.
|
||||
function lcg(seed) {
|
||||
let s = seed >>> 0;
|
||||
return () => {
|
||||
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
|
||||
return s / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
const bushes = []; // { id, patch, coords: [x,y,z], model }
|
||||
const cutAt = new Map(); // bush id -> game timer of the last cut
|
||||
|
||||
function buildBushes() {
|
||||
const rand = lcg(20260901);
|
||||
let id = 1;
|
||||
|
||||
CONFIG.patches.forEach((patch, pi) => {
|
||||
const candidates = Math.ceil(patch.bushes * CONFIG.spareFactor);
|
||||
for (let i = 0; i < candidates; i++) {
|
||||
// Square root on the radius, or every bush clusters in the middle.
|
||||
const angle = rand() * Math.PI * 2;
|
||||
const dist = Math.sqrt(rand()) * patch.radius;
|
||||
bushes.push({
|
||||
id: id++,
|
||||
patch: pi,
|
||||
patchName: patch.name,
|
||||
coords: [
|
||||
patch.centre[0] + Math.cos(angle) * dist,
|
||||
patch.centre[1] + Math.sin(angle) * dist,
|
||||
patch.centre[2]
|
||||
],
|
||||
model: Math.floor(rand() * 4)
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
buildBushes();
|
||||
|
||||
function bushById(id) {
|
||||
return bushes.find((b) => b.id === Number(id)) || null;
|
||||
}
|
||||
|
||||
function whereIs(src) {
|
||||
const ped = GetPlayerPed(String(src));
|
||||
if (!ped) return null;
|
||||
const c = GetEntityCoords(ped);
|
||||
return [c[0], c[1], c[2]];
|
||||
}
|
||||
|
||||
// Flat distance. The client snaps a bush to the ground it finds, so its z and
|
||||
// the z this file stores are not the same number, and comparing them would
|
||||
// refuse people standing right in front of the thing.
|
||||
function flatDistance(a, b) {
|
||||
const dx = a[0] - b[0], dy = a[1] - b[1];
|
||||
return Math.sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
|
||||
function pickBerry(roll) {
|
||||
const total = CONFIG.berries.reduce((sum, b) => sum + b.draw, 0);
|
||||
let at = roll * total;
|
||||
for (const berry of CONFIG.berries) {
|
||||
at -= berry.draw;
|
||||
if (at <= 0) return berry;
|
||||
}
|
||||
return CONFIG.berries[0];
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ start
|
||||
setTimeout(() => {
|
||||
ask('fiverp-inventory', 'registerItems', false, ITEMS);
|
||||
}, 2000);
|
||||
|
||||
onNet('fiverp-berries:bushes', () => {
|
||||
emitNet('fiverp-berries:bushList', Number(global.source), bushes.map((b) => ({
|
||||
id: b.id, coords: b.coords, model: b.model
|
||||
})));
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------- cut
|
||||
onNet('fiverp-berries:cut', async (bushId) => {
|
||||
const src = Number(global.source);
|
||||
const bush = bushById(bushId);
|
||||
if (!bush) return;
|
||||
|
||||
const me = whereIs(src);
|
||||
if (!me) return;
|
||||
if (flatDistance(me, bush.coords) > CONFIG.reach + CONFIG.slack) {
|
||||
return emitNet('fiverp-berries:told', src, 'Too far from the bush', 'bad');
|
||||
}
|
||||
|
||||
if (!ask('fiverp-inventory', 'hasItem', false, src, CONFIG.shearsItem, 1)) {
|
||||
return emitNet('fiverp-berries:told', src,
|
||||
'You need shears. The buyer sells them.', 'bad');
|
||||
}
|
||||
|
||||
const last = cutAt.get(bush.id) || 0;
|
||||
const waited = (GetGameTimer() - last) / 1000;
|
||||
if (last && waited < CONFIG.regrowSeconds) {
|
||||
return emitNet('fiverp-berries:told', src,
|
||||
`Picked bare — **${Math.ceil(CONFIG.regrowSeconds - waited)}s** to regrow`, 'info');
|
||||
}
|
||||
|
||||
// Claim the bush before anything can fail, so two players cutting the same
|
||||
// one in the same second cannot both be paid for it.
|
||||
cutAt.set(bush.id, GetGameTimer());
|
||||
emitNet('fiverp-berries:cutAccepted', -1, bush.id, CONFIG.regrowSeconds);
|
||||
|
||||
const berry = pickBerry(Math.random());
|
||||
const [low, high] = berry.yield;
|
||||
const amount = low + Math.floor(Math.random() * (high - low + 1));
|
||||
|
||||
const given = await ask('fiverp-inventory', 'giveItem', { ok: false },
|
||||
src, berry.item, amount);
|
||||
|
||||
if (!given || !given.placed) {
|
||||
return emitNet('fiverp-berries:told', src, 'Your bag is full', 'bad');
|
||||
}
|
||||
|
||||
emitNet('fiverp-berries:told', src,
|
||||
`**${given.placed}** × ${berry.label}` + (given.left ? ' — the rest would not fit' : ''),
|
||||
'good');
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------- the buyer
|
||||
function nearBuyer(src) {
|
||||
const me = whereIs(src);
|
||||
return !!me && flatDistance(me, CONFIG.buyer.coords) <= 3.5 + CONFIG.slack;
|
||||
}
|
||||
|
||||
async function sendCounter(src) {
|
||||
const stock = CONFIG.berries.map((b) => ({
|
||||
item: b.item,
|
||||
label: b.label,
|
||||
price: b.price,
|
||||
have: ask('fiverp-inventory', 'countItem', 0, src, b.item)
|
||||
}));
|
||||
|
||||
emitNet('fiverp-berries:counter', src, {
|
||||
stock,
|
||||
shears: {
|
||||
item: CONFIG.shearsItem,
|
||||
price: CONFIG.shearsPrice,
|
||||
owned: ask('fiverp-inventory', 'hasItem', false, src, CONFIG.shearsItem, 1)
|
||||
},
|
||||
cash: ask('fiverp-economy', 'getCash', 0, src)
|
||||
});
|
||||
}
|
||||
|
||||
onNet('fiverp-berries:open', async () => {
|
||||
const src = Number(global.source);
|
||||
if (!nearBuyer(src)) return;
|
||||
await sendCounter(src);
|
||||
emitNet('fiverp-berries:show', src);
|
||||
});
|
||||
|
||||
onNet('fiverp-berries:buyShears', async () => {
|
||||
const src = Number(global.source);
|
||||
if (!nearBuyer(src)) return;
|
||||
|
||||
if (ask('fiverp-inventory', 'hasItem', false, src, CONFIG.shearsItem, 1)) {
|
||||
return emitNet('fiverp-berries:told', src, 'You already have a pair', 'info');
|
||||
}
|
||||
|
||||
const paid = await ask('fiverp-economy', 'removeCash', { ok: false },
|
||||
src, CONFIG.shearsPrice, 'berry_shears', 'Berry buyer');
|
||||
if (!paid || !paid.ok) {
|
||||
return emitNet('fiverp-berries:told', src, 'Not enough cash on you', 'bad');
|
||||
}
|
||||
|
||||
const given = await ask('fiverp-inventory', 'giveItem', { ok: false },
|
||||
src, CONFIG.shearsItem, 1);
|
||||
|
||||
if (!given || !given.ok) {
|
||||
// Refund rather than take the money and hand over nothing.
|
||||
await ask('fiverp-economy', 'addCash', null, src, CONFIG.shearsPrice,
|
||||
'berry_shears_refund', 'Berry buyer');
|
||||
return emitNet('fiverp-berries:told', src, 'No room in your bag', 'bad');
|
||||
}
|
||||
|
||||
await sendCounter(src);
|
||||
emitNet('fiverp-berries:told', src, 'Shears bought — cut, do not tear', 'good');
|
||||
});
|
||||
|
||||
// Sell one kind, or everything. The count is never taken from the client: it is
|
||||
// whatever the inventory says is actually there.
|
||||
onNet('fiverp-berries:sell', async (which) => {
|
||||
const src = Number(global.source);
|
||||
if (!nearBuyer(src)) return;
|
||||
|
||||
const kinds = which === 'all'
|
||||
? CONFIG.berries.map((b) => b.item)
|
||||
: [String(which)];
|
||||
|
||||
let earned = 0;
|
||||
const sold = [];
|
||||
|
||||
for (const item of kinds) {
|
||||
if (!PRICE[item]) continue;
|
||||
|
||||
const have = ask('fiverp-inventory', 'countItem', 0, src, item);
|
||||
if (have <= 0) continue;
|
||||
|
||||
const removed = await ask('fiverp-inventory', 'removeItem', { ok: false },
|
||||
src, item, have);
|
||||
const n = (removed && removed.removed) || 0;
|
||||
if (n <= 0) continue;
|
||||
|
||||
earned += n * PRICE[item];
|
||||
sold.push(`${n} × ${ITEMS[item].label}`);
|
||||
}
|
||||
|
||||
if (!earned) {
|
||||
return emitNet('fiverp-berries:told', src, 'Nothing to sell', 'info');
|
||||
}
|
||||
|
||||
await ask('fiverp-economy', 'addCash', null, src, earned, 'berry_sale', 'Berry buyer');
|
||||
await sendCounter(src);
|
||||
emitNet('fiverp-berries:told', src,
|
||||
`Sold ${sold.join(', ')} for **$${earned.toLocaleString('en-US')}**`, 'good');
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------ eating
|
||||
// One at a time. The check lives here rather than on the client because the
|
||||
// client is where a hundred clicks a second come from: without it, holding a
|
||||
// stack of twenty and pressing quickly eats all twenty in one animation and the
|
||||
// hunger bar jumps as if the food were free.
|
||||
const EAT_MS = 2600;
|
||||
const eating = new Map(); // source -> when the current mouthful ends
|
||||
|
||||
on('fiverp-inventory:used', async (src, item, action) => {
|
||||
if (action !== 'eat_berry') return;
|
||||
src = Number(src);
|
||||
|
||||
const now = GetGameTimer();
|
||||
const until = eating.get(src) || 0;
|
||||
if (now < until) {
|
||||
return ask('fiverp-notify', 'notify', false, src, 'Berries',
|
||||
'Finish the mouthful first', 'info');
|
||||
}
|
||||
eating.set(src, now + EAT_MS);
|
||||
|
||||
const removed = await ask('fiverp-inventory', 'removeItem', { ok: false }, src, item, 1);
|
||||
if (!removed || !removed.removed) {
|
||||
eating.delete(src);
|
||||
return;
|
||||
}
|
||||
|
||||
ask('fiverp-status', 'give', false, src, 'hunger', EAT.hunger);
|
||||
ask('fiverp-status', 'give', false, src, 'thirst', EAT.thirst);
|
||||
ask('fiverp-status', 'give', false, src, 'mood', EAT.mood);
|
||||
|
||||
emitNet('fiverp-berries:ate', src, EAT_MS);
|
||||
});
|
||||
|
||||
on('playerDropped', () => { eating.delete(Number(global.source)); });
|
||||
@@ -0,0 +1,78 @@
|
||||
-- Berry picking: the shared numbers.
|
||||
-- The server re-derives every one of them; nothing here is trusted because it
|
||||
-- arrived from a client.
|
||||
|
||||
BerryConfig = {
|
||||
-- The buyer. Sells the shears, buys what you bring back.
|
||||
-- A few metres up the street from the Paleto Fleeca, which is a coordinate
|
||||
-- this server already knows is on solid ground.
|
||||
-- Out in the open on the Grapeseed farm road. The first attempt put him a few
|
||||
-- metres from the Paleto bank and he landed inside its back office: a
|
||||
-- coordinate being on solid ground says nothing about whether there is a wall
|
||||
-- around it.
|
||||
buyer = {
|
||||
coords = vector3(2224.4, 5155.6, 57.0),
|
||||
heading = 60.0,
|
||||
model = 'a_m_m_farmer_01',
|
||||
label = 'Berry buyer'
|
||||
},
|
||||
|
||||
-- Cutting with shears leaves the bush alive, which is why they are required
|
||||
-- rather than merely useful: tearing berries off by hand is how a patch stops
|
||||
-- producing.
|
||||
shears = {
|
||||
item = 'berry_shears',
|
||||
price = 150
|
||||
},
|
||||
|
||||
-- How long a bush takes to be worth cutting again.
|
||||
regrowSeconds = 180,
|
||||
|
||||
-- Reach, and how long the cut takes.
|
||||
reach = 1.8,
|
||||
holdMs = 900,
|
||||
animMs = 2600,
|
||||
|
||||
-- Where the patches are. Bushes are placed by the server in a fixed scatter
|
||||
-- around each centre, so everyone sees the same bush in the same place and
|
||||
-- they do not move between restarts.
|
||||
-- Flat country only. The first set sat on the north face of Chiliad and in
|
||||
-- Raton Canyon, where half the bushes ended up on ground too steep to stand
|
||||
-- on and the rest in tree canopy. These are the farm flats north-east of the
|
||||
-- Alamo Sea, which are open fields.
|
||||
--
|
||||
-- `bushes` is how many are wanted; the client generates half again as many
|
||||
-- candidates and keeps the ones that pass the ground checks, so a patch on
|
||||
-- awkward terrain thins out instead of hovering.
|
||||
patches = {
|
||||
{ name = 'Grapeseed fields', centre = vector3(2140.0, 4880.0, 41.0), radius = 55.0, bushes = 14 },
|
||||
{ name = 'Alamo flats', centre = vector3(1720.0, 4680.0, 42.0), radius = 50.0, bushes = 12 },
|
||||
{ name = 'East pastures', centre = vector3(2420.0, 4720.0, 34.0), radius = 50.0, bushes = 12 },
|
||||
{ name = 'Braddock plain', centre = vector3(1930.0, 5040.0, 45.0), radius = 48.0, bushes = 12 }
|
||||
},
|
||||
|
||||
-- A bush is only planted where a person could stand to cut it.
|
||||
place = {
|
||||
maxSlope = 1.2, -- metres of drop across the 2.4 m span sampled around it
|
||||
maxLift = 12.0, -- how far above the patch height the ground may be
|
||||
minNormalZ = 0.94 -- how level the surface has to be; 1.0 is dead flat,
|
||||
-- 0.94 is about a 20 degree slope
|
||||
},
|
||||
|
||||
-- Bush props. Several so a patch does not look stamped out.
|
||||
bushModels = {
|
||||
'prop_bush_lrg_04b',
|
||||
'prop_bush_lrg_04c',
|
||||
'prop_bush_med_05',
|
||||
'prop_bush_med_07'
|
||||
},
|
||||
|
||||
-- What grows, how likely it is, and what the buyer pays for one.
|
||||
-- Weight is the draw weight, not the kilograms.
|
||||
berries = {
|
||||
{ item = 'berry_black', label = 'Blackberries', weight = 55, price = 12, yield = { 2, 4 } },
|
||||
{ item = 'berry_rasp', label = 'Raspberries', weight = 25, price = 24, yield = { 2, 3 } },
|
||||
{ item = 'berry_blue', label = 'Blueberries', weight = 15, price = 45, yield = { 1, 3 } },
|
||||
{ item = 'berry_cloud', label = 'Cloudberries', weight = 5, price = 130, yield = { 1, 2 } }
|
||||
}
|
||||
}
|
||||
@@ -177,6 +177,19 @@ CreateThread(function()
|
||||
DisableControlAction(0, 1, true)
|
||||
DisableControlAction(0, 2, true)
|
||||
SetPlayerInvincible(PlayerId(), true)
|
||||
|
||||
-- Same reason as auth: picking a character is not being in the world, so
|
||||
-- the radar and the cash counter have nothing truthful to show yet.
|
||||
DisplayRadar(false)
|
||||
HideHudComponentThisFrame(1) -- WANTED_STARS
|
||||
HideHudComponentThisFrame(2) -- WEAPON_ICON
|
||||
HideHudComponentThisFrame(3) -- CASH
|
||||
HideHudComponentThisFrame(4) -- MP_CASH
|
||||
HideHudComponentThisFrame(6) -- VEHICLE_NAME
|
||||
HideHudComponentThisFrame(7) -- AREA_NAME
|
||||
HideHudComponentThisFrame(9) -- STREET_NAME
|
||||
HideHudComponentThisFrame(13) -- CASH_CHANGE
|
||||
|
||||
Wait(0)
|
||||
else
|
||||
Wait(300)
|
||||
@@ -188,6 +201,11 @@ end)
|
||||
local function beginSelection()
|
||||
exports.spawnmanager:setAutoSpawn(false)
|
||||
|
||||
-- Safety net. fiverp-loadscreen is manual-shutdown, so if auth ever fails to
|
||||
-- take it down the player would sit behind it forever. Calling it twice is
|
||||
-- harmless; never calling it is not.
|
||||
ShutdownLoadingScreenNui()
|
||||
|
||||
-- A clear midday sky so nobody sculpts a face in the dark.
|
||||
NetworkOverrideClockTime(13, 0, 0)
|
||||
SetWeatherTypeNowPersist('EXTRASUNNY')
|
||||
@@ -254,6 +272,11 @@ local function enterCity(data)
|
||||
SetEntityCollision(ped, true, true)
|
||||
EnableAllControlActions(0)
|
||||
SetPlayerInvincible(PlayerId(), false)
|
||||
|
||||
-- DisplayRadar is a sticky flag, not a per-frame one: auth and the slot
|
||||
-- screen switched it off, and this is the moment it becomes true again.
|
||||
DisplayRadar(true)
|
||||
|
||||
DoScreenFadeIn(800)
|
||||
|
||||
TriggerEvent('chat:addMessage', {
|
||||
|
||||
@@ -16,6 +16,7 @@ files {
|
||||
'html/index.html',
|
||||
'html/style.css',
|
||||
'html/app.js',
|
||||
'html/scrollbar.js',
|
||||
'html/fonts/unbounded-latin.woff2',
|
||||
'html/fonts/unbounded-latin-ext.woff2',
|
||||
'html/fonts/unbounded-cyrillic.woff2'
|
||||
|
||||
@@ -400,6 +400,8 @@ function renderTab() {
|
||||
const def = TABS.find((t) => t.id === tab) || TABS[0];
|
||||
def.build(host);
|
||||
renderTabs();
|
||||
// Each tab is a different length, so the custom scrollbar has to re-measure.
|
||||
requestAnimationFrame(() => window.dispatchEvent(new Event('fiverp:relayout')));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- editor
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
|
||||
<nav class="tabs" id="tabs"></nav>
|
||||
|
||||
<div class="controls" id="controls"></div>
|
||||
<div class="controls scroll-y" id="controls" data-scroll></div>
|
||||
|
||||
<p class="notice" id="notice"></p>
|
||||
|
||||
@@ -56,5 +56,6 @@
|
||||
|
||||
</main>
|
||||
<script src="app.js"></script>
|
||||
<script src="scrollbar.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/* A scrollbar we actually own.
|
||||
CEF will not let the native one go quietly: ::-webkit-scrollbar-button keeps
|
||||
drawing a stepper stub at each end whatever display, size or background it is
|
||||
given. So the native bar is hidden outright and this draws the replacement.
|
||||
|
||||
Attach with data-scroll on any scrolling element. The bar is position:fixed
|
||||
and measured from the element's own rect, so it needs no particular ancestor
|
||||
and works inside a modal that moves. */
|
||||
(() => {
|
||||
const MIN_THUMB = 28;
|
||||
|
||||
function attach(host) {
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'sbar';
|
||||
const thumb = document.createElement('i');
|
||||
thumb.className = 'sbar__thumb';
|
||||
bar.appendChild(thumb);
|
||||
document.body.appendChild(bar);
|
||||
|
||||
let dragging = false;
|
||||
let grabOffset = 0;
|
||||
|
||||
// The bar is appended to the body, not to the host, so nothing about the
|
||||
// host being faded out reaches it. Walk the ancestors and hide with them —
|
||||
// otherwise a chat that has quietly gone away leaves its scrollbar behind.
|
||||
function hostVisible() {
|
||||
let el = host;
|
||||
while (el && el !== document.body) {
|
||||
const style = getComputedStyle(el);
|
||||
if (style.display === 'none' || style.visibility === 'hidden') return false;
|
||||
if (Number(style.opacity) < 0.05) return false;
|
||||
el = el.parentElement;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function layout() {
|
||||
const box = host.getBoundingClientRect();
|
||||
const scrollable = host.scrollHeight - host.clientHeight;
|
||||
|
||||
// Nothing to scroll, or nothing to scroll it on: either way the bar is a
|
||||
// control that does nothing.
|
||||
if (scrollable <= 1 || box.height <= 0 || !hostVisible()) {
|
||||
bar.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
bar.style.display = 'block';
|
||||
bar.style.top = box.top + 'px';
|
||||
bar.style.height = box.height + 'px';
|
||||
bar.style.left = (box.right - 12) + 'px';
|
||||
|
||||
const ratio = host.clientHeight / host.scrollHeight;
|
||||
const h = Math.max(MIN_THUMB, box.height * ratio);
|
||||
const travel = box.height - h;
|
||||
const progress = host.scrollTop / scrollable;
|
||||
|
||||
thumb.style.height = h + 'px';
|
||||
thumb.style.transform = `translateY(${travel * progress}px)`;
|
||||
}
|
||||
|
||||
function onDrag(event) {
|
||||
if (!dragging) return;
|
||||
const box = host.getBoundingClientRect();
|
||||
const h = thumb.getBoundingClientRect().height;
|
||||
const travel = box.height - h;
|
||||
if (travel <= 0) return;
|
||||
|
||||
const wanted = event.clientY - box.top - grabOffset;
|
||||
const progress = Math.max(0, Math.min(1, wanted / travel));
|
||||
host.scrollTop = progress * (host.scrollHeight - host.clientHeight);
|
||||
}
|
||||
|
||||
thumb.addEventListener('pointerdown', (event) => {
|
||||
dragging = true;
|
||||
grabOffset = event.clientY - thumb.getBoundingClientRect().top;
|
||||
bar.classList.add('is-held');
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
// Listen on the window, not the thumb: the pointer regularly leaves a 4px
|
||||
// wide target mid-drag and the drag has to survive that.
|
||||
window.addEventListener('pointermove', onDrag);
|
||||
window.addEventListener('pointerup', () => {
|
||||
dragging = false;
|
||||
bar.classList.remove('is-held');
|
||||
});
|
||||
|
||||
// Clicking the empty track jumps a page, the way a real scrollbar does.
|
||||
bar.addEventListener('pointerdown', (event) => {
|
||||
if (event.target === thumb) return;
|
||||
const box = thumb.getBoundingClientRect();
|
||||
host.scrollTop += (event.clientY < box.top ? -1 : 1) * host.clientHeight * 0.9;
|
||||
});
|
||||
|
||||
host.addEventListener('scroll', layout);
|
||||
window.addEventListener('resize', layout);
|
||||
|
||||
// The panel is rebuilt (settings reset, tab switch) without the element ever
|
||||
// being replaced, so the size has to be re-read when its contents change.
|
||||
new MutationObserver(layout).observe(host, { childList: true, subtree: true });
|
||||
|
||||
layout();
|
||||
return layout;
|
||||
}
|
||||
|
||||
const layouts = [];
|
||||
function scan() {
|
||||
document.querySelectorAll('[data-scroll]').forEach((el) => {
|
||||
if (el.dataset.scrollBound) return;
|
||||
el.dataset.scrollBound = '1';
|
||||
layouts.push(attach(el));
|
||||
});
|
||||
}
|
||||
|
||||
// Re-measure when a panel is opened: a hidden element has no useful rect.
|
||||
window.addEventListener('fiverp:relayout', () => layouts.forEach((fn) => fn()));
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', scan);
|
||||
} else {
|
||||
scan();
|
||||
}
|
||||
})();
|
||||
@@ -1,297 +1,626 @@
|
||||
/* 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. */
|
||||
/* FiveRP — character select and the appearance editor.
|
||||
Design system: /opt/fivem/server-data/CLAUDE.md — Clean UI / Dark Minimalism.
|
||||
The game renders the ped behind this page, so the editor keeps the right half
|
||||
of the screen clear. No backdrop-filter and no box-shadow: CEF composites both
|
||||
as opaque black plates. */
|
||||
|
||||
@font-face {
|
||||
font-family: 'Unbounded';
|
||||
font-style: normal;
|
||||
font-weight: 300 900;
|
||||
font-display: block;
|
||||
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;
|
||||
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 900;
|
||||
font-display: block;
|
||||
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;
|
||||
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 900;
|
||||
font-display: block;
|
||||
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;
|
||||
--bg: rgba(24, 24, 24, 1);
|
||||
--card-bg: rgba(30, 30, 30, 1);
|
||||
--btn-bg: rgba(51, 51, 51, 1);
|
||||
--btn-hover: rgba(70, 70, 70, 1);
|
||||
--accent: rgba(255, 119, 22, 1);
|
||||
--text-primary: rgba(241, 241, 241, 1);
|
||||
--text-secondary: rgba(153, 153, 153, 1);
|
||||
--border: rgba(51, 51, 51, 0.5);
|
||||
|
||||
--sans: 'Unbounded', sans-serif;
|
||||
}
|
||||
|
||||
* { 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;
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
font-family: var(--sans);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.root { position: fixed; inset: 0; display: none; }
|
||||
html, body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
body { color: var(--text-primary); }
|
||||
|
||||
.root {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.root.is-open { display: block; }
|
||||
|
||||
.screen { position: absolute; inset: 0; display: none; }
|
||||
.screen {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.screen.is-active { display: flex; }
|
||||
|
||||
/* --------------------------------------------------------- shared bits */
|
||||
/* ─────────────────────────────────────────────────────── slot select screen */
|
||||
|
||||
/* No fill: the game renders behind this screen too, and the cards carry their
|
||||
own background. */
|
||||
.screen--select {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.head {
|
||||
text-align: center;
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-size: 10px; font-weight: 600; letter-spacing: 0.20em;
|
||||
text-transform: uppercase; color: var(--fg-tertiary);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 30px; font-weight: 700; letter-spacing: -0.035em;
|
||||
line-height: 1.1; margin-top: 10px;
|
||||
font-size: 34px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
line-height: 1.1;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.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);
|
||||
.sub {
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.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);
|
||||
.slots {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 300px);
|
||||
gap: 20px;
|
||||
}
|
||||
.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);
|
||||
min-height: 320px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 25px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.slot--empty {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.slot--empty:hover {
|
||||
background-color: var(--btn-bg);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.plus {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 16px;
|
||||
background-color: var(--btn-bg);
|
||||
color: var(--accent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28px;
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.slot--empty:hover .plus {
|
||||
background-color: var(--accent);
|
||||
color: var(--bg);
|
||||
}
|
||||
|
||||
.slot--empty .label {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.slot--empty .hint {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.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);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.slot-name {
|
||||
margin-top: 14px;
|
||||
font-size: 21px; font-weight: 700; letter-spacing: -0.03em; line-height: 1.2;
|
||||
font-size: 24px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
line-height: 1.15;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.slot-meta {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.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);
|
||||
align-self: flex-start;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
color: var(--bg);
|
||||
background-color: var(--accent);
|
||||
border-radius: 20px;
|
||||
padding: 6px 14px;
|
||||
}
|
||||
.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-badge.is-draft {
|
||||
background-color: var(--btn-bg);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.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-actions {
|
||||
margin-top: auto;
|
||||
padding-top: 22px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.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); }
|
||||
.foot-note {
|
||||
margin-top: 28px;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------- editor screen */
|
||||
.screen--editor { pointer-events: none; } /* the ped stays clickable-through */
|
||||
.screen--editor .panel,
|
||||
.screen--editor .stagebar { pointer-events: auto; }
|
||||
/* ──────────────────────────────────────────────────────────────── buttons */
|
||||
|
||||
.btn-primary,
|
||||
.btn-ghost {
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
border-radius: 12px;
|
||||
padding: 13px 18px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--accent);
|
||||
color: var(--bg);
|
||||
}
|
||||
|
||||
.btn-primary:hover { transform: translateY(-2px); }
|
||||
|
||||
.btn-ghost {
|
||||
background-color: var(--btn-bg);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.btn-ghost:hover { background-color: var(--btn-hover); }
|
||||
|
||||
.btn-danger { color: var(--text-secondary); }
|
||||
|
||||
.btn-danger:hover {
|
||||
background-color: var(--btn-hover);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────── the editor screen */
|
||||
|
||||
.screen--editor {
|
||||
align-items: stretch;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.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);
|
||||
width: 420px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--card-bg);
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
padding: 25px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 20px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.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);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 18px 25px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.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; }
|
||||
.tab {
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
background-color: var(--btn-bg);
|
||||
border-radius: 20px;
|
||||
padding: 9px 16px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
background-color: var(--btn-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tab.is-on {
|
||||
background-color: var(--accent);
|
||||
color: var(--bg);
|
||||
}
|
||||
|
||||
.controls {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 22px 25px;
|
||||
}
|
||||
|
||||
.group-label {
|
||||
margin: 16px 0 10px;
|
||||
font-size: 10px; font-weight: 600; letter-spacing: 0.14em;
|
||||
text-transform: uppercase; color: var(--fg-tertiary);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
margin: 22px 0 12px;
|
||||
}
|
||||
|
||||
.group-label:first-child { margin-top: 0; }
|
||||
|
||||
.ctl { margin-bottom: 13px; }
|
||||
/* a labelled slider */
|
||||
|
||||
.ctl { margin-bottom: 16px; }
|
||||
|
||||
.ctl-top {
|
||||
display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 6px;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.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;
|
||||
.ctl-name {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ctl-val {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.ctl input[type='range'],
|
||||
.controls input[type='range'] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background-color: var(--btn-bg);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.controls input[type='range']::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* male / female */
|
||||
|
||||
.toggle {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
background-color: var(--btn-bg);
|
||||
border-radius: 20px;
|
||||
padding: 4px;
|
||||
}
|
||||
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);
|
||||
flex: 1;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
background: transparent;
|
||||
border-radius: 20px;
|
||||
padding: 10px 14px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.toggle button:hover { color: var(--text-primary); }
|
||||
|
||||
.toggle button.is-on {
|
||||
background-color: var(--accent);
|
||||
color: var(--bg);
|
||||
}
|
||||
|
||||
/* the legal-name fields */
|
||||
|
||||
.field-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
.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);
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
.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;
|
||||
|
||||
input.field {
|
||||
width: 100%;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background-color: var(--btn-bg);
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
padding: 12px 14px;
|
||||
outline: none;
|
||||
transition: all 0.2s ease;
|
||||
user-select: text;
|
||||
}
|
||||
.field::placeholder { color: var(--fg-tertiary); font-weight: 400; }
|
||||
.field:focus { background: #fff; border-color: var(--accent); }
|
||||
|
||||
input.field:focus { border-color: var(--accent); }
|
||||
|
||||
input.field:disabled { color: var(--text-secondary); }
|
||||
|
||||
input.field::placeholder { color: var(--text-secondary); }
|
||||
|
||||
/* notice + footer */
|
||||
|
||||
.notice {
|
||||
margin: 0 24px; min-height: 0; font-size: 11px; font-weight: 500; color: var(--danger);
|
||||
opacity: 0; transition: opacity .2s var(--ease);
|
||||
display: none;
|
||||
margin: 0 25px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--accent);
|
||||
background-color: var(--btn-bg);
|
||||
border-radius: 12px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
.notice.is-shown { opacity: 1; margin-bottom: 10px; }
|
||||
|
||||
.notice.is-shown { display: block; }
|
||||
|
||||
.panel-foot {
|
||||
display: flex; gap: 8px; padding: 16px 24px 0;
|
||||
border-top: 1px solid var(--line);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 20px 25px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.panel-foot .btn-primary { flex: 1; }
|
||||
.btn-dice { padding: 11px 14px; }
|
||||
|
||||
/* Camera controls, centred under the ped. */
|
||||
/* The primary sits on its own row: "Save & enter city" does not fit beside two
|
||||
ghosts in a 420px panel, and letting it wrap made it taller than its row. */
|
||||
.panel-foot .btn-primary {
|
||||
order: -1;
|
||||
flex: 1 1 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.panel-foot .btn-ghost {
|
||||
flex: 1;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────── the bar under the live 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);
|
||||
position: absolute;
|
||||
left: 420px;
|
||||
right: 0;
|
||||
bottom: 4vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.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;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
color: var(--text-primary);
|
||||
background-color: var(--card-bg);
|
||||
border-radius: 50%;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.round:hover { color: #fff; background: var(--accent); }
|
||||
.chips { display: flex; gap: 4px; }
|
||||
|
||||
.round:hover { background-color: var(--btn-hover); }
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
background-color: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 30px;
|
||||
padding: 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); }
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
background: transparent;
|
||||
border-radius: 20px;
|
||||
padding: 10px 20px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.chip:hover { color: var(--text-primary); }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: .001ms !important;
|
||||
transition-duration: .001ms !important;
|
||||
}
|
||||
.chip.is-on {
|
||||
background-color: var(--accent);
|
||||
color: var(--bg);
|
||||
}
|
||||
|
||||
/* ───────────────────────────────────────────────────────────── entrances */
|
||||
|
||||
.fade-up { animation: fade-up 0.4s ease both; }
|
||||
|
||||
.delay-1 { animation-delay: 0.06s; }
|
||||
.delay-2 { animation-delay: 0.12s; }
|
||||
.delay-3 { animation-delay: 0.18s; }
|
||||
|
||||
@keyframes fade-up {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
body.no-anim *,
|
||||
body.no-anim *::before,
|
||||
body.no-anim *::after {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
opacity: 1 !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────── custom scrollbar */
|
||||
/* The native bar is hidden rather than styled: CEF keeps drawing a stepper stub
|
||||
at each end no matter what ::-webkit-scrollbar-button is told. scrollbar.js
|
||||
draws the replacement, which is a plain element and therefore ours. */
|
||||
.scroll-y {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.scroll-y::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sbar {
|
||||
position: fixed;
|
||||
width: 4px;
|
||||
border-radius: 20px;
|
||||
background-color: var(--btn-bg);
|
||||
z-index: 50;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sbar__thumb {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border-radius: 20px;
|
||||
background-color: var(--text-secondary);
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.sbar:hover .sbar__thumb,
|
||||
.sbar.is-held .sbar__thumb {
|
||||
background-color: var(--accent);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
-- FiveRP chat — the client half.
|
||||
-- Speaks the same events the stock chat does (chat:addMessage, chat:addSuggestion
|
||||
-- and friends), so every resource on the server keeps working untouched. What it
|
||||
-- adds is the command list: typing a slash shows what actually exists here.
|
||||
|
||||
local isOpen = false
|
||||
local suggestions = {} -- name (lower, no slash) -> { name, params, help }
|
||||
|
||||
local function push()
|
||||
local list = {}
|
||||
for _, entry in pairs(suggestions) do list[#list + 1] = entry end
|
||||
SendNUIMessage({ action = 'commands', data = list })
|
||||
end
|
||||
|
||||
local function remember(name, help, params)
|
||||
local clean = (name or ''):gsub('^/', ''):lower()
|
||||
if clean == '' then return end
|
||||
|
||||
-- Params arrive as a list of { name, help }; flatten to "<a> <b>" for display.
|
||||
local text = ''
|
||||
if type(params) == 'table' then
|
||||
for _, p in ipairs(params) do
|
||||
text = text .. (text == '' and '' or ' ') .. '<' .. tostring(p.name or '?') .. '>'
|
||||
end
|
||||
end
|
||||
|
||||
suggestions[clean] = { name = clean, help = help or '', params = text }
|
||||
end
|
||||
|
||||
-- The engine knows every command registered on this client and, once the player
|
||||
-- has spawned, the server's too. That is the floor: anything registered without
|
||||
-- a chat suggestion still shows up, just without help text.
|
||||
local function harvestRegistered()
|
||||
local ok, list = pcall(GetRegisteredCommands)
|
||||
if not ok or type(list) ~= 'table' then return end
|
||||
|
||||
for _, cmd in ipairs(list) do
|
||||
local name = (cmd.name or ''):lower()
|
||||
-- Internal plumbing the player can never usefully type.
|
||||
if name ~= '' and not name:match('^[+-]') and not name:match('^__') then
|
||||
if not suggestions[name] then
|
||||
suggestions[name] = { name = name, help = '', params = '' }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------ events
|
||||
-- The stock chat's API, reimplemented so nothing else has to change.
|
||||
AddEventHandler('chat:addSuggestion', function(name, help, params)
|
||||
remember(name, help, params)
|
||||
push()
|
||||
end)
|
||||
|
||||
AddEventHandler('chat:addSuggestions', function(list)
|
||||
for _, s in ipairs(list or {}) do remember(s.name, s.help, s.params) end
|
||||
push()
|
||||
end)
|
||||
|
||||
AddEventHandler('chat:removeSuggestion', function(name)
|
||||
suggestions[(name or ''):gsub('^/', ''):lower()] = nil
|
||||
push()
|
||||
end)
|
||||
|
||||
-- Same rule for the suggestion events: declared net-safe above, handled once by
|
||||
-- the AddEventHandler pair already registered.
|
||||
RegisterNetEvent('chat:addSuggestion')
|
||||
RegisterNetEvent('chat:addSuggestions')
|
||||
RegisterNetEvent('chat:removeSuggestion')
|
||||
|
||||
-- args is the stock shape: { colour, { author, body } } or a plain string.
|
||||
local function render(...)
|
||||
local args = { ... }
|
||||
local payload = args[1]
|
||||
|
||||
if type(payload) == 'table' and payload.args then
|
||||
local a = payload.args
|
||||
SendNUIMessage({ action = 'message', data = {
|
||||
author = #a > 1 and a[1] or nil,
|
||||
body = #a > 1 and a[2] or a[1],
|
||||
-- Roleplay channels carry a third value (/try's outcome); the renderer
|
||||
-- decides what to do with it based on kind.
|
||||
extra = a[3],
|
||||
kind = payload.kind,
|
||||
system = payload.system or (#a < 2)
|
||||
}})
|
||||
return
|
||||
end
|
||||
|
||||
-- Legacy call: addMessage(colour, { author, body })
|
||||
local a = args[2]
|
||||
if type(a) == 'table' then
|
||||
SendNUIMessage({ action = 'message', data = {
|
||||
author = #a > 1 and a[1] or nil,
|
||||
body = #a > 1 and a[2] or a[1],
|
||||
system = #a < 2
|
||||
}})
|
||||
return
|
||||
end
|
||||
|
||||
SendNUIMessage({ action = 'message', data = { body = tostring(payload), system = true } })
|
||||
end
|
||||
|
||||
-- RegisterNetEvent with a function attaches a handler of its own, so pairing it
|
||||
-- with AddEventHandler ran render twice and every line arrived doubled. Declare
|
||||
-- the event as net-safe, then attach exactly once.
|
||||
RegisterNetEvent('chat:addMessage')
|
||||
AddEventHandler('chat:addMessage', render)
|
||||
|
||||
RegisterNetEvent('chat:clear')
|
||||
AddEventHandler('chat:clear', function() SendNUIMessage({ action = 'clear' }) end)
|
||||
|
||||
RegisterNetEvent('chat:commands', function(list)
|
||||
for _, s in ipairs(list or {}) do remember(s.name, s.help, s.params) end
|
||||
push()
|
||||
end)
|
||||
|
||||
-- ------------------------------------------------------------------- input
|
||||
local function openChat()
|
||||
if isOpen then return end
|
||||
isOpen = true
|
||||
harvestRegistered() -- pick up anything registered since the last time
|
||||
push()
|
||||
SetNuiFocus(true, true)
|
||||
SendNUIMessage({ action = 'open' })
|
||||
end
|
||||
|
||||
RegisterCommand('fiverp:chat', function() openChat() end, false)
|
||||
RegisterKeyMapping('fiverp:chat', 'Chat: open', 'keyboard', 't')
|
||||
|
||||
RegisterNUICallback('close', function(_, cb)
|
||||
isOpen = false
|
||||
SetNuiFocus(false, false)
|
||||
cb('ok')
|
||||
end)
|
||||
|
||||
RegisterNUICallback('send', function(data, cb)
|
||||
local text = (data and data.text or ''):gsub('^%s+', ''):gsub('%s+$', '')
|
||||
if text ~= '' then
|
||||
if text:sub(1, 1) == '/' then
|
||||
ExecuteCommand(text:sub(2))
|
||||
else
|
||||
TriggerServerEvent('_chat:messageEntered', text)
|
||||
end
|
||||
end
|
||||
cb('ok')
|
||||
end)
|
||||
|
||||
-- Nothing to type into while a menu owns the cursor, and T would otherwise be
|
||||
-- swallowed by it.
|
||||
CreateThread(function()
|
||||
while true do
|
||||
if isOpen then
|
||||
DisableControlAction(0, 245, true) -- INPUT_MP_TEXT_CHAT_ALL
|
||||
Wait(0)
|
||||
else
|
||||
Wait(250)
|
||||
end
|
||||
end
|
||||
end)
|
||||
|
||||
CreateThread(function()
|
||||
Wait(2000)
|
||||
harvestRegistered()
|
||||
push()
|
||||
end)
|
||||
|
||||
AddEventHandler('onResourceStop', function(name)
|
||||
if name == GetCurrentResourceName() then SetNuiFocus(false, false) end
|
||||
end)
|
||||
@@ -0,0 +1,21 @@
|
||||
fx_version 'cerulean'
|
||||
game 'gta5'
|
||||
|
||||
name 'fiverp-chat'
|
||||
author 'FiveRP'
|
||||
description 'Chat with live command suggestions'
|
||||
version '0.1.0'
|
||||
|
||||
ui_page 'html/index.html'
|
||||
|
||||
client_scripts { 'client/cl_chat.lua' }
|
||||
server_scripts { 'server/sv_chat.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',
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
/* FiveRP chat — the renderer.
|
||||
Owns the log, the input and the command list. It never decides what a command
|
||||
does; it only knows what exists, so that typing a slash answers "what can I
|
||||
type here" without the player leaving the game to find out. */
|
||||
|
||||
const RES = 'fiverp-chat';
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
const chat = $('chat');
|
||||
const log = $('log');
|
||||
const bar = $('bar');
|
||||
const input = $('input');
|
||||
const hintBox = $('hints');
|
||||
|
||||
const MAX_LINES = 60;
|
||||
const LINE_LIFE = 25000; // how long a line stays on screen with the chat closed
|
||||
const MAX_HINTS = 8;
|
||||
|
||||
let commands = []; // [{ name, params, help }]
|
||||
let matches = [];
|
||||
let cursor = 0;
|
||||
let idleTimer = null;
|
||||
|
||||
const history = [];
|
||||
let historyAt = -1;
|
||||
|
||||
/* ------------------------------------------------------------------- log */
|
||||
|
||||
function escape(text) {
|
||||
return String(text)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
// Colour codes (^1..^9) are a convention half the ecosystem still emits. We do
|
||||
// not paint with them — the palette has one accent — but they must not be left
|
||||
// on screen as literal text.
|
||||
function stripCodes(text) {
|
||||
return String(text).replace(/\^[0-9]/g, '');
|
||||
}
|
||||
|
||||
function addLine(msg) {
|
||||
const kind = msg.kind || (msg.system ? 'system' : 'say');
|
||||
const line = document.createElement('div');
|
||||
line.className = 'line line--' + kind + (msg.system ? ' line--system' : '');
|
||||
|
||||
const who = msg.author ? `<span class="line__who">${escape(stripCodes(msg.author))}</span>` : '';
|
||||
const body = escape(stripCodes(msg.body));
|
||||
const name = escape(stripCodes(msg.author || ''));
|
||||
|
||||
let inner;
|
||||
if (kind === 'me') {
|
||||
// Third person, because this is what everyone else watches happen.
|
||||
inner = `<span class="line__body">${name} ${body}</span>`;
|
||||
} else if (kind === 'do') {
|
||||
// The scene, with the author in brackets so it is clear who asserted it.
|
||||
inner = `<span class="line__body">${body} ((${name}))</span>`;
|
||||
} else if (kind === 'try') {
|
||||
const failed = msg.extra === 'fails';
|
||||
inner = `<span class="line__body">${name} ${body}</span>` +
|
||||
`<span class="line__outcome${failed ? ' is-fail' : ''}">${escape(msg.extra || '')}</span>`;
|
||||
} else if (kind === 'ooc') {
|
||||
// Brackets are the convention, and they matter: without them an aside about
|
||||
// dinner reads as something a character actually said.
|
||||
inner = who + `<span class="line__body">(( ${body} ))</span>`;
|
||||
} else {
|
||||
inner = who + `<span class="line__body">${body}</span>`;
|
||||
}
|
||||
|
||||
line.innerHTML = inner;
|
||||
|
||||
log.appendChild(line);
|
||||
while (log.childElementCount > MAX_LINES) log.removeChild(log.firstElementChild);
|
||||
|
||||
// Each line retires on its own clock. Fading the whole log at once meant a
|
||||
// busy minute left a wall of text sitting there, and the scrollbar with it.
|
||||
setTimeout(() => {
|
||||
line.classList.add('is-stale');
|
||||
window.dispatchEvent(new Event('fiverp:relayout'));
|
||||
}, LINE_LIFE);
|
||||
|
||||
log.scrollTop = log.scrollHeight;
|
||||
window.dispatchEvent(new Event('fiverp:relayout'));
|
||||
wake();
|
||||
}
|
||||
|
||||
// The log fades out when nothing has happened for a while, so an idle screen is
|
||||
// the game rather than a box of old text.
|
||||
function wake() {
|
||||
chat.classList.add('is-shown');
|
||||
clearTimeout(idleTimer);
|
||||
if (chat.classList.contains('is-open')) return;
|
||||
idleTimer = setTimeout(() => {
|
||||
chat.classList.remove('is-shown');
|
||||
window.dispatchEvent(new Event('fiverp:relayout'));
|
||||
}, 12000);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ quick keys */
|
||||
// The channels worth one press. Each one only fills the input: what you write
|
||||
// still has to be written, and nothing is sent by a single click.
|
||||
const QUICK = [
|
||||
{ label: 'Me', insert: '/me ', title: 'Something your character does' },
|
||||
{ label: 'Do', insert: '/do ', title: 'Something true about the scene' },
|
||||
{ label: 'Try', insert: '/try ', title: 'Attempt something that can fail' },
|
||||
{ label: 'Shout', insert: '/s ', title: 'Twice as far as normal' },
|
||||
{ label: 'Whisper', insert: '/w ', title: 'Only right beside you' },
|
||||
{ label: 'OOC', insert: '/b ', title: 'Out of character' }
|
||||
];
|
||||
|
||||
function buildQuick() {
|
||||
const host = $('quick');
|
||||
host.innerHTML = QUICK.map((q, i) =>
|
||||
`<button type="button" data-i="${i}" title="${q.title}">${q.label}</button>`).join('');
|
||||
|
||||
host.querySelectorAll('button').forEach((el) => {
|
||||
// mousedown, not click: click fires after blur, and the input losing focus
|
||||
// mid-press is what makes a chat box feel broken.
|
||||
el.addEventListener('mousedown', (event) => {
|
||||
event.preventDefault();
|
||||
const q = QUICK[Number(el.dataset.i)];
|
||||
input.value = q.insert;
|
||||
cursor = 0;
|
||||
renderHints();
|
||||
markQuick();
|
||||
input.focus();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Highlight whichever channel the line currently belongs to.
|
||||
function markQuick() {
|
||||
const value = input.value;
|
||||
$('quick').querySelectorAll('button').forEach((el) => {
|
||||
const q = QUICK[Number(el.dataset.i)];
|
||||
el.classList.toggle('is-on', value.startsWith(q.insert) ||
|
||||
value.trim() === q.insert.trim());
|
||||
});
|
||||
}
|
||||
|
||||
buildQuick();
|
||||
|
||||
/* -------------------------------------------------------------- commands */
|
||||
|
||||
function setCommands(list) {
|
||||
const seen = new Map();
|
||||
(list || []).forEach((c) => {
|
||||
const name = String(c.name || '').replace(/^\//, '').toLowerCase();
|
||||
if (!name) return;
|
||||
// Later registrations win: a resource that adds help text should beat the
|
||||
// bare name we picked up from the engine's own list.
|
||||
const prev = seen.get(name);
|
||||
if (prev && !c.help && prev.help) return;
|
||||
seen.set(name, { name, params: c.params || '', help: c.help || '' });
|
||||
});
|
||||
commands = [...seen.values()].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
function typedCommand() {
|
||||
const value = input.value;
|
||||
if (!value.startsWith('/')) return null;
|
||||
// Only while still typing the name itself. Once there is a space the player
|
||||
// is filling in arguments and a list of other commands is noise.
|
||||
const head = value.slice(1);
|
||||
return head.includes(' ') ? null : head.toLowerCase();
|
||||
}
|
||||
|
||||
function renderHints() {
|
||||
const typed = typedCommand();
|
||||
bar.classList.toggle('is-command', input.value.startsWith('/'));
|
||||
|
||||
if (typed === null) {
|
||||
hintBox.classList.remove('is-shown');
|
||||
matches = [];
|
||||
return;
|
||||
}
|
||||
|
||||
// Empty (just "/") lists everything; otherwise prefix first, then anything
|
||||
// containing it, so a half-remembered name still finds its command.
|
||||
const starts = commands.filter((c) => c.name.startsWith(typed));
|
||||
const contains = typed
|
||||
? commands.filter((c) => !c.name.startsWith(typed) && c.name.includes(typed))
|
||||
: [];
|
||||
matches = starts.concat(contains);
|
||||
|
||||
cursor = Math.min(cursor, Math.max(0, matches.length - 1));
|
||||
hintBox.classList.add('is-shown');
|
||||
|
||||
if (!matches.length) {
|
||||
hintBox.innerHTML =
|
||||
`<div class="hints__empty">No command starts with “${escape(typed)}”. ` +
|
||||
`Press / on its own to see all ${commands.length}.</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const shown = matches.slice(0, MAX_HINTS);
|
||||
hintBox.innerHTML = shown.map((c, i) => {
|
||||
const hit = c.name.startsWith(typed) ? typed.length : 0;
|
||||
const head = escape(c.name.slice(0, hit));
|
||||
const rest = escape(c.name.slice(hit));
|
||||
return `<div class="hint${i === cursor ? ' is-active' : ''}" data-i="${i}">
|
||||
<span class="hint__name">/${head}<span class="hint__rest">${rest}</span></span>
|
||||
${c.params ? `<span class="hint__args">${escape(c.params)}</span>` : ''}
|
||||
${c.help ? `<span class="hint__help">${escape(c.help)}</span>` : ''}
|
||||
</div>`;
|
||||
}).join('') + (matches.length > MAX_HINTS
|
||||
? `<div class="hints__count">${matches.length - MAX_HINTS} more — keep typing</div>`
|
||||
: '');
|
||||
|
||||
hintBox.querySelectorAll('.hint').forEach((el) => {
|
||||
el.addEventListener('mousedown', (event) => {
|
||||
event.preventDefault(); // keep focus in the input
|
||||
cursor = Number(el.dataset.i);
|
||||
complete();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function complete() {
|
||||
const pick = matches[cursor];
|
||||
if (!pick) return;
|
||||
input.value = '/' + pick.name + (pick.params ? ' ' : '');
|
||||
renderHints();
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------- open / close */
|
||||
|
||||
function open() {
|
||||
chat.classList.add('is-open', 'is-shown');
|
||||
clearTimeout(idleTimer);
|
||||
input.value = '';
|
||||
historyAt = -1;
|
||||
cursor = 0;
|
||||
renderHints();
|
||||
markQuick();
|
||||
setTimeout(() => input.focus(), 0);
|
||||
window.dispatchEvent(new Event('fiverp:relayout'));
|
||||
}
|
||||
|
||||
function close(sent) {
|
||||
chat.classList.remove('is-open');
|
||||
hintBox.classList.remove('is-shown');
|
||||
input.value = '';
|
||||
input.blur();
|
||||
wake();
|
||||
post('close', { sent: !!sent });
|
||||
}
|
||||
|
||||
function post(name, data) {
|
||||
fetch(`https://${RES}/${name}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json; charset=UTF-8' },
|
||||
body: JSON.stringify(data || {})
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function send() {
|
||||
const text = input.value.trim();
|
||||
if (!text) { close(false); return; }
|
||||
|
||||
history.unshift(text);
|
||||
if (history.length > 40) history.pop();
|
||||
|
||||
post('send', { text });
|
||||
close(true);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ keys */
|
||||
|
||||
input.addEventListener('input', () => { cursor = 0; renderHints(); markQuick(); });
|
||||
|
||||
input.addEventListener('keydown', (event) => {
|
||||
const open = hintBox.classList.contains('is-shown') && matches.length;
|
||||
|
||||
if (event.key === 'Escape') { close(false); return; }
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
// Enter on a highlighted suggestion completes it rather than sending a
|
||||
// half-typed command: two keystrokes, no wrong command run by accident.
|
||||
if (open && typedCommand() && matches[cursor] && matches[cursor].name !== typedCommand()) {
|
||||
event.preventDefault();
|
||||
complete();
|
||||
return;
|
||||
}
|
||||
send();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Tab') {
|
||||
event.preventDefault();
|
||||
if (open) complete();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
const step = event.key === 'ArrowDown' ? 1 : -1;
|
||||
|
||||
if (open) {
|
||||
event.preventDefault();
|
||||
const count = Math.min(matches.length, MAX_HINTS);
|
||||
cursor = (cursor + step + count) % count;
|
||||
renderHints();
|
||||
return;
|
||||
}
|
||||
|
||||
// No suggestions on screen, so the arrows walk what you said before.
|
||||
if (!history.length) return;
|
||||
event.preventDefault();
|
||||
historyAt = Math.max(-1, Math.min(history.length - 1, historyAt + step));
|
||||
input.value = historyAt < 0 ? '' : history[historyAt];
|
||||
renderHints();
|
||||
}
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------- from Lua */
|
||||
|
||||
window.addEventListener('message', (event) => {
|
||||
const msg = event.data || {};
|
||||
|
||||
if (msg.action === 'message') { addLine(msg.data || {}); return; }
|
||||
if (msg.action === 'open') { open(); return; }
|
||||
if (msg.action === 'close') { close(false); return; }
|
||||
if (msg.action === 'clear') { log.innerHTML = ''; return; }
|
||||
if (msg.action === 'commands') { setCommands(msg.data); renderHints(); return; }
|
||||
|
||||
if (msg.action === 'visible') {
|
||||
chat.style.display = msg.value ? '' : 'none';
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>FiveRP Chat</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="chat" class="chat">
|
||||
<!-- No data-scroll: the log scrolls itself to the bottom and a bar over
|
||||
the world is one more thing to look at. -->
|
||||
<div class="log" id="log"></div>
|
||||
|
||||
<!-- One press instead of remembering the slash. -->
|
||||
<div class="quick" id="quick"></div>
|
||||
|
||||
<div class="bar" id="bar">
|
||||
<input id="input" type="text" autocomplete="off" spellcheck="false" maxlength="240"
|
||||
placeholder="Say something, or start with / for a command">
|
||||
</div>
|
||||
|
||||
<!-- Filled while the input starts with a slash. -->
|
||||
<div class="hints" id="hints"></div>
|
||||
</div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,391 @@
|
||||
/* FiveRP — chat.
|
||||
Design system: CLAUDE.md — Clean UI / Dark Minimalism.
|
||||
Sits over the game, so the page itself stays transparent and only the
|
||||
panels take a fill. No blur, no shadow, no gradient. */
|
||||
|
||||
@font-face {
|
||||
font-family: 'Unbounded';
|
||||
font-style: normal;
|
||||
font-weight: 300 900;
|
||||
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 900;
|
||||
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 900;
|
||||
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: rgba(24, 24, 24, 1);
|
||||
--card-bg: rgba(30, 30, 30, 1);
|
||||
--btn-bg: rgba(51, 51, 51, 1);
|
||||
--btn-hover: rgba(70, 70, 70, 1);
|
||||
--accent: rgba(255, 119, 22, 1);
|
||||
--text-primary: rgba(241, 241, 241, 1);
|
||||
--text-secondary: rgba(153, 153, 153, 1);
|
||||
--border: rgba(51, 51, 51, 0.5);
|
||||
|
||||
--sans: 'Unbounded', sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
font-family: var(--sans);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
html, body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
body { color: var(--text-primary); }
|
||||
|
||||
.chat {
|
||||
position: fixed;
|
||||
left: 1.6vw;
|
||||
top: 2.2vh;
|
||||
width: 25vw;
|
||||
min-width: 330px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
/* Reading the log and typing in it are different states. Idle, the log is
|
||||
visible but faded and the input is gone; open, everything is solid. */
|
||||
.chat.is-shown { opacity: 0.75; }
|
||||
|
||||
.chat.is-open { opacity: 1; }
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────── log */
|
||||
|
||||
.log {
|
||||
max-height: 26vh;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
/* No gutter: the scrollbar is gone, so nothing needs reserving for it. */
|
||||
}
|
||||
|
||||
/* Retired while the chat is closed; opening it brings the history back. */
|
||||
.chat:not(.is-open) .line.is-stale { display: none; }
|
||||
|
||||
.line {
|
||||
/* Translucent rather than the solid card fill. A line of chat sits over the
|
||||
world for a quarter of a minute at a time, and at full opacity it reads as
|
||||
a panel someone opened rather than as something being said. */
|
||||
background-color: rgba(30, 30, 30, 0.62);
|
||||
border: 1px solid rgba(51, 51, 51, 0.35);
|
||||
border-radius: 12px;
|
||||
padding: 8px 12px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 400;
|
||||
line-height: 1.45;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
user-select: text;
|
||||
animation: line-in 0.2s ease both;
|
||||
}
|
||||
|
||||
@keyframes line-in {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
/* The author is a chip rather than coloured text: one accent, and a name is a
|
||||
label, not a value. */
|
||||
.line__who {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
color: var(--bg);
|
||||
background-color: var(--accent);
|
||||
border-radius: 20px;
|
||||
padding: 3px 9px;
|
||||
margin-right: 8px;
|
||||
vertical-align: 1px;
|
||||
}
|
||||
|
||||
/* A message the server sent about the world, not a person saying something. */
|
||||
.line--system .line__who {
|
||||
color: var(--text-primary);
|
||||
background-color: var(--btn-bg);
|
||||
}
|
||||
|
||||
.line--system { color: var(--text-secondary); }
|
||||
|
||||
.line__body b { font-weight: 700; }
|
||||
|
||||
/* ────────────────────────────────────────────────────── quick actions */
|
||||
/* The roleplay channels, one press away. They fill the input rather than
|
||||
sending anything: what you write still has to be written. */
|
||||
.quick {
|
||||
display: none;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.chat.is-open .quick { display: flex; }
|
||||
|
||||
.quick button {
|
||||
border: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
font-size: 9.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
background-color: rgba(30, 30, 30, 0.88);
|
||||
border-radius: 20px;
|
||||
padding: 7px 12px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.quick button:hover {
|
||||
background-color: var(--btn-bg);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.quick button.is-on {
|
||||
background-color: var(--accent);
|
||||
color: var(--bg);
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────── input bar */
|
||||
|
||||
.bar {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
background-color: rgba(30, 30, 30, 0.88);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 0 14px;
|
||||
}
|
||||
|
||||
.chat.is-open .bar { display: flex; }
|
||||
|
||||
/* The slash the player types is the only slash on screen. A decorative one
|
||||
beside it read as a second character they had not entered. Typing a command
|
||||
is signalled by the field's edge instead. */
|
||||
.bar.is-command { border-color: var(--accent); }
|
||||
|
||||
.bar input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
padding: 12px 0;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.bar input::placeholder { color: var(--text-secondary); }
|
||||
|
||||
/* ─────────────────────────────────────────────────── command suggestions */
|
||||
|
||||
.hints {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
background-color: rgba(30, 30, 30, 0.94);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 6px;
|
||||
max-height: 32vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.hints.is-shown { display: flex; }
|
||||
|
||||
.hint {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
border-radius: 12px;
|
||||
padding: 9px 11px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.hint:hover { background-color: var(--btn-bg); }
|
||||
|
||||
.hint.is-active { background-color: var(--btn-bg); }
|
||||
|
||||
.hint__name {
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.hint.is-active .hint__name { color: var(--accent); }
|
||||
|
||||
/* The part of the name still to be typed, so a partial match reads as a
|
||||
completion rather than as a different command. */
|
||||
.hint__rest { color: var(--text-secondary); }
|
||||
|
||||
.hint__args {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.hint__help {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
text-align: right;
|
||||
font-size: 10px;
|
||||
font-weight: 400;
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hints__empty {
|
||||
padding: 11px;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.hints__count {
|
||||
padding: 6px 11px 8px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────── custom scrollbar */
|
||||
.scroll-y {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.scroll-y::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sbar {
|
||||
position: fixed;
|
||||
width: 4px;
|
||||
border-radius: 20px;
|
||||
background-color: var(--btn-bg);
|
||||
z-index: 50;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sbar__thumb {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border-radius: 20px;
|
||||
background-color: var(--text-secondary);
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.sbar:hover .sbar__thumb,
|
||||
.sbar.is-held .sbar__thumb { background-color: var(--accent); }
|
||||
|
||||
body.no-anim *,
|
||||
body.no-anim *::before,
|
||||
body.no-anim *::after {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────── roleplay channels */
|
||||
/* Each channel has to be identifiable at a glance, but the palette has one
|
||||
accent — so they are told apart by shape and weight, not by colour. */
|
||||
|
||||
/* /me and /do are narration, not speech: no chip, italic, and a leading rule
|
||||
so a wall of them does not read as people talking. */
|
||||
.line--me,
|
||||
.line--do,
|
||||
.line--try {
|
||||
border-left: 2px solid var(--accent);
|
||||
font-style: italic;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.line--me .line__who,
|
||||
.line--do .line__who,
|
||||
.line--try .line__who {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.line--do { border-left-color: var(--text-secondary); }
|
||||
|
||||
/* The outcome of a /try, which the server decided. */
|
||||
.line__outcome {
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
font-size: 10px;
|
||||
color: var(--accent);
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.line__outcome.is-fail { color: var(--text-secondary); }
|
||||
|
||||
/* Out of character is deliberately the quietest thing on screen. */
|
||||
.line--ooc {
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.line--ooc .line__who {
|
||||
color: var(--text-primary);
|
||||
background-color: var(--btn-bg);
|
||||
}
|
||||
|
||||
/* Distance, shown as weight: a shout is loud, a whisper is barely there. */
|
||||
.line--shout .line__body {
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.line--whisper {
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.line--whisper .line__who {
|
||||
color: var(--text-primary);
|
||||
background-color: var(--btn-bg);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
// FiveRP chat — the server half.
|
||||
// Turns what a player typed into what everyone in earshot sees. Every channel
|
||||
// here is proximity-based: the server reads both players' positions itself, so
|
||||
// range cannot be argued with from a client.
|
||||
|
||||
const MAX_LENGTH = 240;
|
||||
|
||||
// How far each channel carries, in metres. A number here is the whole
|
||||
// difference between a private aside and shouting across the street.
|
||||
const RANGE = {
|
||||
say: 20.0,
|
||||
me: 20.0,
|
||||
do: 20.0,
|
||||
try: 20.0,
|
||||
ooc: 20.0,
|
||||
shout: 45.0,
|
||||
whisper: 4.0
|
||||
};
|
||||
|
||||
const KNOWN = [
|
||||
{ name: 'help', help: 'Every command you can use here' },
|
||||
{ name: 'me', help: 'Something your character does', params: [{ name: 'action' }] },
|
||||
{ name: 'do', help: 'Something that is true about the scene', params: [{ name: 'detail' }] },
|
||||
{ name: 'try', help: 'Attempt something — it may fail', params: [{ name: 'action' }] },
|
||||
{ name: 's', help: 'Shout, twice as far as normal', params: [{ name: 'text' }] },
|
||||
{ name: 'w', help: 'Whisper, only right beside you', params: [{ name: 'text' }] },
|
||||
{ name: 'b', help: 'Out of character, in brackets', params: [{ name: 'text' }] },
|
||||
{ name: 'id', help: "Your own ids, or another player's", params: [{ name: 'id' }] },
|
||||
{ name: 'players', help: 'Who is online right now' },
|
||||
{ name: 'balance', help: 'Your cash and your bank' },
|
||||
{ name: 'pay', help: 'Hand cash to someone within 8 m',
|
||||
params: [{ name: 'id' }, { name: 'amount' }] },
|
||||
{ name: 'time', help: 'The server clock and its timezone' },
|
||||
{ name: 'hud', help: 'Open the HUD settings' },
|
||||
{ name: 'bank', help: 'Open banking at a teller or an ATM' }
|
||||
];
|
||||
|
||||
function clean(text) {
|
||||
return String(text || '')
|
||||
.replace(/\^[0-9]/g, '') // colour codes: not our palette
|
||||
.replace(/[\x00-\x1F\x7F]/g, '') // control characters
|
||||
.trim()
|
||||
.slice(0, MAX_LENGTH);
|
||||
}
|
||||
|
||||
function ask(resource, method, fallback, ...args) {
|
||||
try {
|
||||
const target = exports[resource];
|
||||
const result = target[method](...args);
|
||||
return result === undefined ? fallback : result;
|
||||
} catch (err) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
// Who is speaking, in one lookup.
|
||||
//
|
||||
// Not fiverp-characters: its getActiveCharacter export returns the character's
|
||||
// row id — a number — so reading .firstName off it is undefined, and every
|
||||
// roleplay line came out under the player's platform name instead. fiverp-ids
|
||||
// already keeps the character name beside the ids for exactly this.
|
||||
function identity(src) {
|
||||
const ids = ask('fiverp-ids', 'getIds', null, Number(src));
|
||||
return {
|
||||
name: (ids && ids.name) || null,
|
||||
id: (ids && ids.dynamicId) || null
|
||||
};
|
||||
}
|
||||
|
||||
// The name the city knows, for the roleplay channels.
|
||||
function characterName(src) {
|
||||
const { name } = identity(src);
|
||||
return name || GetPlayerName(String(src)) || 'Unknown';
|
||||
}
|
||||
|
||||
// Both halves of who is speaking: the name the city knows them by, and the id
|
||||
// everyone can see regardless. Before a character is picked there is no name to
|
||||
// use, so the label falls back to Citizen and keeps the id.
|
||||
function speakerLabel(src) {
|
||||
const { name, id } = identity(src);
|
||||
const who = name || 'Citizen';
|
||||
return id ? `${who} (${id})` : who;
|
||||
}
|
||||
|
||||
function positionOf(src) {
|
||||
const ped = GetPlayerPed(String(src));
|
||||
if (!ped) return null;
|
||||
const c = GetEntityCoords(ped);
|
||||
return [c[0], c[1], c[2]];
|
||||
}
|
||||
|
||||
// Everyone within range of the speaker, the speaker included.
|
||||
// Enumerated by index rather than through getPlayers(), which is a Lua-side
|
||||
// convenience and not guaranteed to exist in this runtime.
|
||||
function everyone() {
|
||||
const out = [];
|
||||
for (let i = 0; i < GetNumPlayerIndices(); i++) {
|
||||
out.push(Number(GetPlayerFromIndex(i)));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function audience(src, range) {
|
||||
const from = positionOf(src);
|
||||
if (!from) return [Number(src)];
|
||||
|
||||
return everyone().filter((id) => {
|
||||
if (id === Number(src)) return true;
|
||||
const at = positionOf(id);
|
||||
if (!at) return false;
|
||||
const dx = at[0] - from[0], dy = at[1] - from[1], dz = at[2] - from[2];
|
||||
return Math.sqrt(dx * dx + dy * dy + dz * dz) <= range;
|
||||
});
|
||||
}
|
||||
|
||||
// kind reaches the stylesheet, which is what decides how a line reads.
|
||||
function broadcast(src, range, payload) {
|
||||
audience(src, range).forEach((id) => emitNet('chat:addMessage', id, payload));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ plain speech
|
||||
// onNet, not on: this arrives from a client, and the engine refuses to route a
|
||||
// net event into a plain handler — "was not safe for net", and the message is
|
||||
// dropped in silence.
|
||||
onNet('_chat:messageEntered', function (text) {
|
||||
const src = Number(global.source);
|
||||
const body = clean(text);
|
||||
if (!body) return;
|
||||
|
||||
broadcast(src, RANGE.say, {
|
||||
kind: 'say',
|
||||
args: [speakerLabel(src), body]
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------- roleplay
|
||||
function rpCommand(name, range, build, emptyHint) {
|
||||
RegisterCommand(name, (source, args) => {
|
||||
const src = Number(source);
|
||||
if (!src) return;
|
||||
|
||||
const body = clean(args.join(' '));
|
||||
if (!body) {
|
||||
emitNet('chat:addMessage', src, {
|
||||
kind: 'system', args: ['Chat', emptyHint], system: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
broadcast(src, range, build(src, body));
|
||||
}, false);
|
||||
}
|
||||
|
||||
// What your character does. Written in the third person because that is how
|
||||
// everyone else sees it happen.
|
||||
rpCommand('me', RANGE.me,
|
||||
(src, body) => ({ kind: 'me', args: [characterName(src), body] }),
|
||||
'Usage: /me opens the briefcase');
|
||||
|
||||
// Something true about the scene that your character cannot say out loud —
|
||||
// a state of the world rather than an action.
|
||||
rpCommand('do', RANGE.do,
|
||||
(src, body) => ({ kind: 'do', args: [characterName(src), body] }),
|
||||
'Usage: /do the lock is already broken');
|
||||
|
||||
// An attempt that can fail. The coin is flipped here, on the server: a client
|
||||
// that could decide its own outcome would never lose one.
|
||||
rpCommand('try', RANGE.try, (src, body) => ({
|
||||
kind: 'try',
|
||||
args: [characterName(src), body, Math.random() < 0.5 ? 'succeeds' : 'fails']
|
||||
}), 'Usage: /try to pick the lock');
|
||||
|
||||
rpCommand('s', RANGE.shout,
|
||||
(src, body) => ({ kind: 'shout', args: [speakerLabel(src), body] }),
|
||||
'Usage: /s over here!');
|
||||
|
||||
rpCommand('w', RANGE.whisper,
|
||||
(src, body) => ({ kind: 'whisper', args: [speakerLabel(src), body] }),
|
||||
'Usage: /w keep this between us');
|
||||
|
||||
// Out of character. Kept visually distinct on purpose: the whole point is that
|
||||
// it is not something anyone in the world said.
|
||||
rpCommand('b', RANGE.ooc,
|
||||
(src, body) => ({ kind: 'ooc', args: [speakerLabel(src), body] }),
|
||||
'Usage: /b back in a minute');
|
||||
|
||||
// ------------------------------------------------------------------ misc
|
||||
on('playerJoining', () => {
|
||||
emitNet('chat:commands', Number(global.source), KNOWN);
|
||||
});
|
||||
|
||||
RegisterCommand('help', (source) => {
|
||||
const src = Number(source);
|
||||
if (!src) return;
|
||||
emitNet('chat:addMessage', src, {
|
||||
kind: 'system', system: true,
|
||||
args: ['Help', 'Press T then / to see every command, and keep typing to narrow it down.']
|
||||
});
|
||||
}, false);
|
||||
@@ -131,6 +131,58 @@ CreateThread(function()
|
||||
end
|
||||
end)
|
||||
|
||||
-- -------------------------------------------------------------------- voice
|
||||
-- Three proximities, cycled with Z. The range is applied to Mumble, so the
|
||||
-- indicator is not decoration: what it says is what other players can hear.
|
||||
local VOICE_RANGES = {
|
||||
{ label = 'Whisper', metres = 3.0 },
|
||||
{ label = 'Normal', metres = 8.0 },
|
||||
{ label = 'Shout', metres = 20.0 }
|
||||
}
|
||||
local voiceStep = 2 -- start on Normal
|
||||
|
||||
local function applyVoiceRange()
|
||||
local r = VOICE_RANGES[voiceStep]
|
||||
-- Guarded: a server running without Mumble voice should not lose its HUD.
|
||||
pcall(function() MumbleSetTalkerProximity(r.metres + 0.0) end)
|
||||
end
|
||||
|
||||
RegisterCommand('fiverp:voiceCycle', function()
|
||||
if not visible then return end
|
||||
voiceStep = voiceStep % #VOICE_RANGES + 1
|
||||
applyVoiceRange()
|
||||
end, false)
|
||||
|
||||
RegisterKeyMapping('fiverp:voiceCycle', 'Voice: change range', 'keyboard', 'z')
|
||||
|
||||
CreateThread(function()
|
||||
applyVoiceRange()
|
||||
local wasTalking, lastStep = nil, nil
|
||||
|
||||
while true do
|
||||
if visible then
|
||||
local talking = NetworkIsPlayerTalking(PlayerId())
|
||||
|
||||
-- Only post on change: this loop runs fast enough to matter.
|
||||
if talking ~= wasTalking or voiceStep ~= lastStep then
|
||||
wasTalking, lastStep = talking, voiceStep
|
||||
SendNUIMessage({
|
||||
action = 'voice',
|
||||
data = {
|
||||
talking = talking,
|
||||
range = VOICE_RANGES[voiceStep].label,
|
||||
step = voiceStep,
|
||||
steps = #VOICE_RANGES
|
||||
}
|
||||
})
|
||||
end
|
||||
Wait(100)
|
||||
else
|
||||
Wait(400)
|
||||
end
|
||||
end
|
||||
end)
|
||||
|
||||
-- ---------------------------------------------------------------- settings
|
||||
RegisterCommand('hud', function()
|
||||
if not visible then return end
|
||||
@@ -155,6 +207,20 @@ CreateThread(function()
|
||||
end
|
||||
end)
|
||||
|
||||
-- The pause menu is not the world. NUI keeps drawing over it because it is a
|
||||
-- separate layer the game does not own, so the HUD has to stand down itself.
|
||||
CreateThread(function()
|
||||
local hidden = false
|
||||
while true do
|
||||
local paused = IsPauseMenuActive()
|
||||
if paused ~= hidden then
|
||||
hidden = paused
|
||||
SendNUIMessage({ action = 'visible', value = visible and not paused })
|
||||
end
|
||||
Wait(150)
|
||||
end
|
||||
end)
|
||||
|
||||
AddEventHandler('onResourceStop', function(name)
|
||||
if name == GetCurrentResourceName() then SetNuiFocus(false, false) end
|
||||
end)
|
||||
|
||||
@@ -19,6 +19,7 @@ files {
|
||||
'html/index.html',
|
||||
'html/style.css',
|
||||
'html/app.js',
|
||||
'html/scrollbar.js',
|
||||
'html/fonts/unbounded-latin.woff2',
|
||||
'html/fonts/unbounded-latin-ext.woff2',
|
||||
'html/fonts/unbounded-cyrillic.woff2'
|
||||
|
||||
@@ -13,11 +13,12 @@ const DEFAULTS = {
|
||||
status: true,
|
||||
money: true,
|
||||
vehicle: true,
|
||||
voice: true,
|
||||
seconds: true, // let the clock tick
|
||||
numbers: false, // print the value under each status ring
|
||||
dimFull: true, // fade a need that is comfortably full
|
||||
units: 'kmh',
|
||||
place: 'radar', // where the condition rings live
|
||||
place: 'radar', // 'radar' is the column beside the minimap
|
||||
scale: 100,
|
||||
opacity: 100
|
||||
};
|
||||
@@ -54,6 +55,9 @@ function applyPrefs() {
|
||||
const status = $('status');
|
||||
status.classList.toggle('at-bottom', prefs.place === 'bottom');
|
||||
status.classList.toggle('at-top', prefs.place === 'top');
|
||||
// With the numbers hidden the glyph should sit in the middle of the ring, not
|
||||
// above a gap where a number would have been.
|
||||
status.classList.toggle('show-numbers', !!prefs.numbers);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ status rings */
|
||||
@@ -111,6 +115,23 @@ function setRing(key, value) {
|
||||
ring.classList.toggle('is-calm', prefs.dimFull && pct >= 95);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------- the voice */
|
||||
// The range is not decoration: cl_hud applies it to Mumble, so what the pips
|
||||
// show is genuinely how far the player carries.
|
||||
function setVoice(v) {
|
||||
if (!v) return;
|
||||
$('voiceRange').textContent = v.range || 'Normal';
|
||||
$('voice').classList.toggle('is-talking', !!v.talking);
|
||||
|
||||
const steps = Number(v.steps) || 3;
|
||||
const step = Number(v.step) || 1;
|
||||
const pips = $('voiceSteps');
|
||||
if (pips.childElementCount !== steps) {
|
||||
pips.innerHTML = Array.from({ length: steps }, () => '<u></u>').join('');
|
||||
}
|
||||
[...pips.children].forEach((el, i) => el.classList.toggle('is-on', i < step));
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------- the money */
|
||||
const money = { cash: null, bank: null };
|
||||
|
||||
@@ -206,7 +227,8 @@ const CONTROLS = [
|
||||
{ key: 'server', type: 'switch', label: 'Server card', note: 'Name, clock, players online and your ids' },
|
||||
{ key: 'status', type: 'switch', label: 'Condition', note: 'Armour, hunger, thirst and mood' },
|
||||
{ key: 'money', type: 'switch', label: 'Wallet' },
|
||||
{ key: 'vehicle', type: 'switch', label: 'Vehicle panel', note: 'Only ever shown while you are in a car' }
|
||||
{ key: 'vehicle', type: 'switch', label: 'Vehicle panel', note: 'Only ever shown while you are in a car' },
|
||||
{ key: 'voice', type: 'switch', label: 'Voice range', note: 'How far you carry. Z changes it' }
|
||||
]},
|
||||
{ group: 'Server card', items: [
|
||||
{ key: 'seconds', type: 'switch', label: 'Show seconds' }
|
||||
@@ -215,7 +237,7 @@ const CONTROLS = [
|
||||
{ key: 'numbers', type: 'switch', label: 'Show numbers', note: 'Print the percentage under each ring' },
|
||||
{ key: 'dimFull', type: 'switch', label: 'Fade what is full', note: 'Quietens needs you do not have to think about' },
|
||||
{ key: 'place', type: 'segmented', label: 'Position',
|
||||
options: [['radar', 'Above radar'], ['bottom', 'Bottom'], ['top', 'Top left']] }
|
||||
options: [['radar', 'Beside radar'], ['bottom', 'Bottom'], ['top', 'Top left']] }
|
||||
]},
|
||||
{ group: 'Vehicle', items: [
|
||||
{ key: 'units', type: 'segmented', label: 'Speed', options: [['kmh', 'km/h'], ['mph', 'mph']] }
|
||||
@@ -347,8 +369,18 @@ window.addEventListener('message', (event) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.action === 'voice') {
|
||||
setVoice(msg.data);
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.action === 'settings') {
|
||||
$('settings').classList.toggle('is-open', !!msg.value);
|
||||
// A hidden panel has no rect, so the custom scrollbar can only size itself
|
||||
// once the panel is actually on screen.
|
||||
if (msg.value) {
|
||||
requestAnimationFrame(() => window.dispatchEvent(new Event('fiverp:relayout')));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -31,8 +31,24 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- the body: only what the game does not already draw -->
|
||||
<section class="status" data-module="status" id="status"></section>
|
||||
<!-- bottom left, beside the radar: condition and voice -->
|
||||
<div class="left-stack">
|
||||
<section class="status" data-module="status" id="status"></section>
|
||||
|
||||
<section class="voice" data-module="voice" id="voice">
|
||||
<span class="voice__icon">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<rect x="9" y="3" width="6" height="11" rx="3"/>
|
||||
<path d="M5.5 11.5a6.5 6.5 0 0 0 13 0"/>
|
||||
<path d="M12 18v3"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="voice__text">
|
||||
<b class="voice__range" id="voiceRange">Normal</b>
|
||||
<i class="voice__steps" id="voiceSteps"></i>
|
||||
</span>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- bottom right: the wallet, and the car when there is one -->
|
||||
<div class="right-stack">
|
||||
@@ -102,7 +118,7 @@
|
||||
<button class="close" id="settingsClose" type="button">Done</button>
|
||||
</header>
|
||||
|
||||
<div class="settings__body" id="settingsBody"></div>
|
||||
<div class="settings__body scroll-y" id="settingsBody" data-scroll></div>
|
||||
|
||||
<footer class="settings__foot">
|
||||
<button class="ghost" id="settingsReset" type="button">Reset to defaults</button>
|
||||
@@ -112,5 +128,6 @@
|
||||
</div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
<script src="scrollbar.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/* A scrollbar we actually own.
|
||||
CEF will not let the native one go quietly: ::-webkit-scrollbar-button keeps
|
||||
drawing a stepper stub at each end whatever display, size or background it is
|
||||
given. So the native bar is hidden outright and this draws the replacement.
|
||||
|
||||
Attach with data-scroll on any scrolling element. The bar is position:fixed
|
||||
and measured from the element's own rect, so it needs no particular ancestor
|
||||
and works inside a modal that moves. */
|
||||
(() => {
|
||||
const MIN_THUMB = 28;
|
||||
|
||||
function attach(host) {
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'sbar';
|
||||
const thumb = document.createElement('i');
|
||||
thumb.className = 'sbar__thumb';
|
||||
bar.appendChild(thumb);
|
||||
document.body.appendChild(bar);
|
||||
|
||||
let dragging = false;
|
||||
let grabOffset = 0;
|
||||
|
||||
// The bar is appended to the body, not to the host, so nothing about the
|
||||
// host being faded out reaches it. Walk the ancestors and hide with them —
|
||||
// otherwise a chat that has quietly gone away leaves its scrollbar behind.
|
||||
function hostVisible() {
|
||||
let el = host;
|
||||
while (el && el !== document.body) {
|
||||
const style = getComputedStyle(el);
|
||||
if (style.display === 'none' || style.visibility === 'hidden') return false;
|
||||
if (Number(style.opacity) < 0.05) return false;
|
||||
el = el.parentElement;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function layout() {
|
||||
const box = host.getBoundingClientRect();
|
||||
const scrollable = host.scrollHeight - host.clientHeight;
|
||||
|
||||
// Nothing to scroll, or nothing to scroll it on: either way the bar is a
|
||||
// control that does nothing.
|
||||
if (scrollable <= 1 || box.height <= 0 || !hostVisible()) {
|
||||
bar.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
bar.style.display = 'block';
|
||||
bar.style.top = box.top + 'px';
|
||||
bar.style.height = box.height + 'px';
|
||||
bar.style.left = (box.right - 12) + 'px';
|
||||
|
||||
const ratio = host.clientHeight / host.scrollHeight;
|
||||
const h = Math.max(MIN_THUMB, box.height * ratio);
|
||||
const travel = box.height - h;
|
||||
const progress = host.scrollTop / scrollable;
|
||||
|
||||
thumb.style.height = h + 'px';
|
||||
thumb.style.transform = `translateY(${travel * progress}px)`;
|
||||
}
|
||||
|
||||
function onDrag(event) {
|
||||
if (!dragging) return;
|
||||
const box = host.getBoundingClientRect();
|
||||
const h = thumb.getBoundingClientRect().height;
|
||||
const travel = box.height - h;
|
||||
if (travel <= 0) return;
|
||||
|
||||
const wanted = event.clientY - box.top - grabOffset;
|
||||
const progress = Math.max(0, Math.min(1, wanted / travel));
|
||||
host.scrollTop = progress * (host.scrollHeight - host.clientHeight);
|
||||
}
|
||||
|
||||
thumb.addEventListener('pointerdown', (event) => {
|
||||
dragging = true;
|
||||
grabOffset = event.clientY - thumb.getBoundingClientRect().top;
|
||||
bar.classList.add('is-held');
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
// Listen on the window, not the thumb: the pointer regularly leaves a 4px
|
||||
// wide target mid-drag and the drag has to survive that.
|
||||
window.addEventListener('pointermove', onDrag);
|
||||
window.addEventListener('pointerup', () => {
|
||||
dragging = false;
|
||||
bar.classList.remove('is-held');
|
||||
});
|
||||
|
||||
// Clicking the empty track jumps a page, the way a real scrollbar does.
|
||||
bar.addEventListener('pointerdown', (event) => {
|
||||
if (event.target === thumb) return;
|
||||
const box = thumb.getBoundingClientRect();
|
||||
host.scrollTop += (event.clientY < box.top ? -1 : 1) * host.clientHeight * 0.9;
|
||||
});
|
||||
|
||||
host.addEventListener('scroll', layout);
|
||||
window.addEventListener('resize', layout);
|
||||
|
||||
// The panel is rebuilt (settings reset, tab switch) without the element ever
|
||||
// being replaced, so the size has to be re-read when its contents change.
|
||||
new MutationObserver(layout).observe(host, { childList: true, subtree: true });
|
||||
|
||||
layout();
|
||||
return layout;
|
||||
}
|
||||
|
||||
const layouts = [];
|
||||
function scan() {
|
||||
document.querySelectorAll('[data-scroll]').forEach((el) => {
|
||||
if (el.dataset.scrollBound) return;
|
||||
el.dataset.scrollBound = '1';
|
||||
layouts.push(attach(el));
|
||||
});
|
||||
}
|
||||
|
||||
// Re-measure when a panel is opened: a hidden element has no useful rect.
|
||||
window.addEventListener('fiverp:relayout', () => layouts.forEach((fn) => fn()));
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', scan);
|
||||
} else {
|
||||
scan();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,194 @@
|
||||
-- FiveRP interact — hold-to-act markers.
|
||||
-- One shared system so every interaction on the server behaves the same way:
|
||||
-- a dial appears on the thing, you hold E, a ring fills, and only when it is
|
||||
-- full does anything happen. A tap does nothing on purpose — nothing important
|
||||
-- should be one twitch away.
|
||||
--
|
||||
-- exports['fiverp-interact']:add({
|
||||
-- id = 'atm:14',
|
||||
-- coords = vector3(...), -- or entity = pedHandle
|
||||
-- label = 'ATM',
|
||||
-- distance = 1.6, -- how close before it can be used
|
||||
-- hold = 600, -- ms
|
||||
-- onSelect = function() ... end
|
||||
-- })
|
||||
-- exports['fiverp-interact']:remove('atm:14')
|
||||
|
||||
local points = {} -- id -> point
|
||||
local order = {} -- stable iteration, so ties resolve the same way
|
||||
|
||||
local active = nil -- id under the cursor right now
|
||||
local holding = false
|
||||
local holdStart = 0
|
||||
local lastX, lastY = -1, -1
|
||||
|
||||
local SHOW_RANGE = 12.0 -- how far away the dial appears at all
|
||||
local KEY_E = 38 -- INPUT_CONTEXT
|
||||
|
||||
local function coordsOf(point)
|
||||
if point.entity and DoesEntityExist(point.entity) then
|
||||
return GetEntityCoords(point.entity)
|
||||
end
|
||||
return point.coords
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------ exports
|
||||
function AddPoint(spec)
|
||||
if not spec or not spec.id then return end
|
||||
if not points[spec.id] then order[#order + 1] = spec.id end
|
||||
|
||||
points[spec.id] = {
|
||||
id = spec.id,
|
||||
coords = spec.coords,
|
||||
entity = spec.entity,
|
||||
label = spec.label or '',
|
||||
key = spec.key or 'E',
|
||||
distance = spec.distance or 2.0,
|
||||
hold = spec.hold or 600,
|
||||
onSelect = spec.onSelect,
|
||||
canUse = spec.canUse
|
||||
}
|
||||
end
|
||||
|
||||
function RemovePoint(id)
|
||||
if not points[id] then return end
|
||||
points[id] = nil
|
||||
for i, key in ipairs(order) do
|
||||
if key == id then table.remove(order, i) break end
|
||||
end
|
||||
|
||||
-- If the point under the cursor is the one going away, the marker has to be
|
||||
-- told. Clearing `active` on its own left the dial drawn at its last screen
|
||||
-- position with nothing left to update it — which is why cutting a bush
|
||||
-- pinned an E to the middle of the screen and it stayed there.
|
||||
if active == id then
|
||||
active = nil
|
||||
holding = false
|
||||
lastX, lastY = -1, -1
|
||||
SendNUIMessage({ action = 'release', done = false })
|
||||
SendNUIMessage({ action = 'hide' })
|
||||
end
|
||||
end
|
||||
|
||||
exports('add', function(spec) AddPoint(spec) end)
|
||||
exports('remove', function(id) RemovePoint(id) end)
|
||||
exports('isBusy', function() return holding end)
|
||||
|
||||
-- ------------------------------------------------------------------- search
|
||||
-- The closest point that is in range, in front of the camera, and willing.
|
||||
local function pick(me)
|
||||
local best, bestDist = nil, SHOW_RANGE
|
||||
|
||||
for _, id in ipairs(order) do
|
||||
local point = points[id]
|
||||
if point then
|
||||
local at = coordsOf(point)
|
||||
if at then
|
||||
local d = #(me - at)
|
||||
if d < bestDist then
|
||||
if not point.canUse or point.canUse() then
|
||||
best, bestDist = point, d
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return best, bestDist
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------- render
|
||||
local function send(msg) SendNUIMessage(msg) end
|
||||
|
||||
local function stopHold(done)
|
||||
if not holding then return end
|
||||
holding = false
|
||||
send({ action = 'release', done = done })
|
||||
end
|
||||
|
||||
local function clear()
|
||||
if active then
|
||||
stopHold(false)
|
||||
active = nil
|
||||
lastX, lastY = -1, -1
|
||||
send({ action = 'hide' })
|
||||
end
|
||||
end
|
||||
|
||||
CreateThread(function()
|
||||
while true do
|
||||
local wait = 250
|
||||
local ped = PlayerPedId()
|
||||
|
||||
-- Nothing to point at while dead, in a car, or with a menu holding focus.
|
||||
if IsPedDeadOrDying(ped, true) or IsPedInAnyVehicle(ped, false) or IsNuiFocused() then
|
||||
clear()
|
||||
else
|
||||
local me = GetEntityCoords(ped)
|
||||
local point, dist = pick(me)
|
||||
|
||||
if not point then
|
||||
clear()
|
||||
else
|
||||
wait = 0
|
||||
local at = coordsOf(point)
|
||||
local onScreen, sx, sy = GetScreenCoordFromWorldCoord(at.x, at.y, at.z)
|
||||
|
||||
if not onScreen then
|
||||
clear()
|
||||
else
|
||||
if active ~= point.id then
|
||||
stopHold(false)
|
||||
active = point.id
|
||||
end
|
||||
|
||||
local w, h = GetActiveScreenResolution()
|
||||
local px = math.floor(sx * w)
|
||||
local py = math.floor(sy * h)
|
||||
|
||||
-- Only post when it actually moved: this runs every frame and the
|
||||
-- NUI does not need a message that changes nothing.
|
||||
if px ~= lastX or py ~= lastY then
|
||||
lastX, lastY = px, py
|
||||
send({
|
||||
action = 'show',
|
||||
x = px, y = py,
|
||||
label = point.label,
|
||||
key = point.key,
|
||||
near = dist <= point.distance
|
||||
})
|
||||
end
|
||||
|
||||
-- Close enough to act, so the key starts meaning something.
|
||||
if dist <= point.distance then
|
||||
if IsControlPressed(0, KEY_E) then
|
||||
if not holding then
|
||||
holding = true
|
||||
holdStart = GetGameTimer()
|
||||
send({ action = 'hold', ms = point.hold })
|
||||
elseif GetGameTimer() - holdStart >= point.hold then
|
||||
stopHold(true)
|
||||
-- Cleared before the callback: opening a menu takes focus, and
|
||||
-- the marker should already be gone by then.
|
||||
local fn = point.onSelect
|
||||
clear()
|
||||
if fn then fn(point.id) end
|
||||
Wait(250)
|
||||
end
|
||||
else
|
||||
stopHold(false)
|
||||
end
|
||||
else
|
||||
stopHold(false)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Wait(wait)
|
||||
end
|
||||
end)
|
||||
|
||||
AddEventHandler('onResourceStop', function(name)
|
||||
if name == GetCurrentResourceName() then send({ action = 'hide' }) end
|
||||
end)
|
||||
@@ -0,0 +1,20 @@
|
||||
fx_version 'cerulean'
|
||||
game 'gta5'
|
||||
|
||||
name 'fiverp-interact'
|
||||
author 'FiveRP'
|
||||
description 'Hold-to-interact markers'
|
||||
version '0.1.0'
|
||||
|
||||
ui_page 'html/index.html'
|
||||
|
||||
client_scripts { 'client/cl_interact.lua' }
|
||||
|
||||
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,76 @@
|
||||
/* FiveRP interact — the marker.
|
||||
Draws one dial at a screen position Lua projected for it. It measures nothing
|
||||
and decides nothing: the ring's fill is a CSS transition whose duration Lua
|
||||
sets, and Lua times the real hold itself, so what the ring shows and what the
|
||||
game accepts cannot drift apart. */
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
const marker = $('marker');
|
||||
const arc = $('arc');
|
||||
const label = $('label');
|
||||
const key = $('key');
|
||||
|
||||
let visible = false;
|
||||
|
||||
function place(msg) {
|
||||
marker.style.left = msg.x + 'px';
|
||||
marker.style.top = msg.y + 'px';
|
||||
|
||||
if (msg.label !== undefined && label.textContent !== msg.label) {
|
||||
label.textContent = msg.label;
|
||||
}
|
||||
if (msg.key !== undefined && key.textContent !== msg.key) {
|
||||
key.textContent = msg.key;
|
||||
}
|
||||
|
||||
marker.classList.toggle('is-near', !!msg.near);
|
||||
|
||||
if (!visible) {
|
||||
visible = true;
|
||||
marker.classList.add('is-shown');
|
||||
}
|
||||
}
|
||||
|
||||
function hide() {
|
||||
if (!visible) return;
|
||||
visible = false;
|
||||
marker.classList.remove('is-shown', 'is-holding', 'is-done');
|
||||
reset();
|
||||
}
|
||||
|
||||
function reset() {
|
||||
arc.classList.remove('is-filling');
|
||||
arc.classList.add('is-reset');
|
||||
// Force the browser to apply the reset before any new transition starts,
|
||||
// otherwise the next fill animates from wherever the last one stopped.
|
||||
void arc.offsetWidth;
|
||||
arc.classList.remove('is-reset');
|
||||
}
|
||||
|
||||
function startHold(ms) {
|
||||
reset();
|
||||
marker.classList.add('is-holding');
|
||||
marker.classList.remove('is-done');
|
||||
arc.style.setProperty('--hold', ms + 'ms');
|
||||
// Same reason as above: the class has to land on a fresh layout.
|
||||
requestAnimationFrame(() => arc.classList.add('is-filling'));
|
||||
}
|
||||
|
||||
function endHold(done) {
|
||||
marker.classList.remove('is-holding');
|
||||
if (done) {
|
||||
marker.classList.add('is-done');
|
||||
setTimeout(() => marker.classList.remove('is-done'), 220);
|
||||
}
|
||||
reset();
|
||||
}
|
||||
|
||||
window.addEventListener('message', (event) => {
|
||||
const msg = event.data || {};
|
||||
|
||||
if (msg.action === 'show') { place(msg); return; }
|
||||
if (msg.action === 'hide') { hide(); return; }
|
||||
if (msg.action === 'hold') { startHold(msg.ms || 600); return; }
|
||||
if (msg.action === 'release') { endHold(!!msg.done); }
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>FiveRP Interact</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="marker" class="marker">
|
||||
<div class="dial">
|
||||
<svg viewBox="0 0 44 44">
|
||||
<circle class="dial__track" cx="22" cy="22" r="19"></circle>
|
||||
<circle class="dial__fill" id="arc" cx="22" cy="22" r="19"></circle>
|
||||
</svg>
|
||||
<span class="dial__key" id="key">E</span>
|
||||
</div>
|
||||
<span class="marker__label" id="label"></span>
|
||||
</div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,186 @@
|
||||
/* FiveRP — chat.
|
||||
Design system: CLAUDE.md — Clean UI / Dark Minimalism.
|
||||
Sits over the game, so the page itself stays transparent and only the
|
||||
panels take a fill. No blur, no shadow, no gradient. */
|
||||
|
||||
@font-face {
|
||||
font-family: 'Unbounded';
|
||||
font-style: normal;
|
||||
font-weight: 300 900;
|
||||
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 900;
|
||||
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 900;
|
||||
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: rgba(24, 24, 24, 1);
|
||||
--card-bg: rgba(30, 30, 30, 1);
|
||||
--btn-bg: rgba(51, 51, 51, 1);
|
||||
--btn-hover: rgba(70, 70, 70, 1);
|
||||
--accent: rgba(255, 119, 22, 1);
|
||||
--text-primary: rgba(241, 241, 241, 1);
|
||||
--text-secondary: rgba(153, 153, 153, 1);
|
||||
--border: rgba(51, 51, 51, 0.5);
|
||||
|
||||
--sans: 'Unbounded', sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
font-family: var(--sans);
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
html, body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
body { color: var(--text-primary); }
|
||||
|
||||
/* The marker lives at a projected world point, so Lua owns left/top and this
|
||||
file owns everything about how it looks. translate(-50%,-50%) keeps the dial
|
||||
centred on the point rather than hanging off it. */
|
||||
.marker {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.marker.is-shown { display: flex; }
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────── dial */
|
||||
|
||||
.dial {
|
||||
position: relative;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
/* Scale is the only motion: a marker that slides would fight the world point
|
||||
it is supposed to be pinned to. */
|
||||
transform: scale(0.86);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.marker.is-near .dial { transform: scale(1); }
|
||||
|
||||
.dial svg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.dial svg circle {
|
||||
fill: none;
|
||||
stroke-width: 3;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.dial__track { stroke: var(--btn-bg); }
|
||||
|
||||
/* r=19 in a 44 box: circumference 119.38. The arc is drawn by moving the dash
|
||||
offset from full to zero, and the duration is set from Lua so the ring and
|
||||
the hold it represents always take the same time. */
|
||||
.dial__fill {
|
||||
stroke: var(--accent);
|
||||
stroke-dasharray: 119.38;
|
||||
stroke-dashoffset: 119.38;
|
||||
}
|
||||
|
||||
.dial__fill.is-filling {
|
||||
stroke-dashoffset: 0;
|
||||
transition: stroke-dashoffset linear;
|
||||
transition-duration: var(--hold, 600ms);
|
||||
}
|
||||
|
||||
/* Released early: snap back rather than unwind, so a cancelled hold reads as
|
||||
cancelled and not as a second, slower interaction. */
|
||||
.dial__fill.is-reset {
|
||||
stroke-dashoffset: 119.38;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
/* The disc behind the letter. Solid, because the world behind it is arbitrary
|
||||
and a letter on grass has to stay readable. */
|
||||
.dial::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 5px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.dial__key {
|
||||
position: relative;
|
||||
font-size: 15px;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
color: var(--text-primary);
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.marker.is-holding .dial__key { color: var(--accent); }
|
||||
|
||||
/* Held long enough: the whole dial goes accent for a beat so the press is
|
||||
acknowledged even if the menu takes a moment to open. */
|
||||
.marker.is-done .dial::before {
|
||||
background-color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.marker.is-done .dial__key { color: var(--bg); }
|
||||
|
||||
/* ───────────────────────────────────────────────────────────────── label */
|
||||
|
||||
.marker__label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-primary);
|
||||
background-color: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
padding: 6px 12px;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
/* The label only earns its space once you are close enough to act. */
|
||||
.marker.is-near .marker__label { opacity: 1; }
|
||||
@@ -0,0 +1,318 @@
|
||||
-- FiveRP inventory — the client half.
|
||||
-- Opens the panel, forwards what the player did, and draws the piles lying on
|
||||
-- the ground. It stores nothing: the state it renders arrives from the server
|
||||
-- whole, every time, so there is no local copy that can drift out of step.
|
||||
|
||||
-- The registry as the server knows it, which is the merged one: shared/items.lua
|
||||
-- plus whatever other resources registered. ItemDef() alone would only ever see
|
||||
-- this resource's own half.
|
||||
local registry = {}
|
||||
|
||||
local function defOf(id)
|
||||
return registry[id] or ItemDef(id)
|
||||
end
|
||||
|
||||
local isOpen = false
|
||||
local inWorld = false -- spawned, as opposed to still on the slot screen
|
||||
local groundProps = {} -- dropId -> entity
|
||||
|
||||
-- ------------------------------------------------------------------ panel
|
||||
local function close()
|
||||
if not isOpen then return end
|
||||
isOpen = false
|
||||
SetNuiFocus(false, false)
|
||||
SendNUIMessage({ action = 'close' })
|
||||
end
|
||||
|
||||
local function open()
|
||||
if isOpen then return end
|
||||
if IsPedDeadOrDying(PlayerPedId(), true) then return end
|
||||
isOpen = true
|
||||
SetNuiFocus(true, true)
|
||||
SendNUIMessage({ action = 'open' })
|
||||
TriggerServerEvent('fiverp-inventory:request')
|
||||
end
|
||||
|
||||
RegisterCommand('fiverp:inventory', function()
|
||||
if isOpen then close() else open() end
|
||||
end, false)
|
||||
|
||||
RegisterKeyMapping('fiverp:inventory', 'Inventory: open', 'keyboard', 'i')
|
||||
|
||||
RegisterCommand('inventory', function() open() end, false)
|
||||
TriggerEvent('chat:addSuggestion', '/inventory', 'Open your bag')
|
||||
|
||||
-- ------------------------------------------------------------------ worn
|
||||
-- The five worn slots drive real ped components. Reading them is how the
|
||||
-- inventory learns what the creator put on the character; writing them is what
|
||||
-- makes taking a shirt off visible on the body.
|
||||
|
||||
local function isFemale(ped)
|
||||
return GetEntityModel(ped) == GetHashKey('mp_f_freemode_01')
|
||||
end
|
||||
|
||||
local function bareFor(ped, component)
|
||||
local set = BARE[isFemale(ped) and 'female' or 'male']
|
||||
return set and set[component] or 0
|
||||
end
|
||||
|
||||
-- What is on the body right now, in the shape the server stores.
|
||||
local function readWorn()
|
||||
local ped = PlayerPedId()
|
||||
local out = {}
|
||||
|
||||
for slot, map in pairs(WEAR_COMPONENTS) do
|
||||
if map.prop then
|
||||
local drawable = GetPedPropIndex(ped, map.index)
|
||||
-- -1 is "no prop", which is an empty slot rather than a hat of index -1.
|
||||
if drawable >= 0 then
|
||||
out[slot] = { drawable = drawable, texture = GetPedPropTextureIndex(ped, map.index) }
|
||||
end
|
||||
else
|
||||
local drawable = GetPedDrawableVariation(ped, map.index)
|
||||
local bare = bareFor(ped, map.index)
|
||||
-- Wearing the bare drawable means wearing nothing, so there is no
|
||||
-- garment to adopt: otherwise every character would start with an
|
||||
-- "Undershirt" that is actually their skin.
|
||||
if drawable ~= bare then
|
||||
local piece = {
|
||||
drawable = drawable,
|
||||
texture = GetPedTextureVariation(ped, map.index)
|
||||
}
|
||||
if map.also then
|
||||
piece.arms = GetPedDrawableVariation(ped, map.also)
|
||||
piece.armsTexture = GetPedTextureVariation(ped, map.also)
|
||||
end
|
||||
out[slot] = piece
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return out
|
||||
end
|
||||
|
||||
-- Put the body into the state the rows describe. Every slot is written, not
|
||||
-- only the filled ones: an empty slot has to actively become bare skin, or
|
||||
-- removing a shirt would change a row and nothing else.
|
||||
local function applyWorn(wear)
|
||||
-- Not while the creator owns the ped: it is mid-dress there, and writing
|
||||
-- components underneath it would fight the preview.
|
||||
if not inWorld then return end
|
||||
|
||||
local ped = PlayerPedId()
|
||||
if not DoesEntityExist(ped) then return end
|
||||
|
||||
for slot, map in pairs(WEAR_COMPONENTS) do
|
||||
local row = wear and wear[slot]
|
||||
local meta = row and row.meta
|
||||
|
||||
if map.prop then
|
||||
if meta and meta.drawable and meta.drawable >= 0 then
|
||||
SetPedPropIndex(ped, map.index, meta.drawable, meta.texture or 0, true)
|
||||
else
|
||||
ClearPedProp(ped, map.index)
|
||||
end
|
||||
elseif meta and meta.drawable then
|
||||
SetPedComponentVariation(ped, map.index, meta.drawable, meta.texture or 0, 0)
|
||||
if map.also and meta.arms then
|
||||
SetPedComponentVariation(ped, map.also, meta.arms, meta.armsTexture or 0, 0)
|
||||
end
|
||||
elseif row then
|
||||
-- Something is in the slot but it does not say what it looks like. Leave
|
||||
-- the body alone: stripping it would be acting on a row we cannot read,
|
||||
-- and the player would watch their clothes vanish for no visible reason.
|
||||
else
|
||||
SetPedComponentVariation(ped, map.index, bareFor(ped, map.index), 0, 0)
|
||||
if map.also then
|
||||
SetPedComponentVariation(ped, map.also, bareFor(ped, map.also), 0, 0)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
RegisterNetEvent('fiverp-inventory:registry', function(items)
|
||||
registry = items or {}
|
||||
end)
|
||||
|
||||
RegisterNetEvent('fiverp-inventory:state', function(state)
|
||||
if state and state.items then registry = state.items end
|
||||
SendNUIMessage({ action = 'state', data = state })
|
||||
-- The rows are the truth about what is worn, so the body follows them rather
|
||||
-- than the other way round.
|
||||
if state and state.wear then applyWorn(state.wear) end
|
||||
end)
|
||||
|
||||
RegisterNetEvent('fiverp-inventory:refused', function(payload)
|
||||
SendNUIMessage({ action = 'refused', data = payload })
|
||||
end)
|
||||
|
||||
RegisterNUICallback('close', function(_, cb) close(); cb('ok') end)
|
||||
|
||||
RegisterNUICallback('move', function(data, cb)
|
||||
TriggerServerEvent('fiverp-inventory:move', {
|
||||
fromContainer = data.fromContainer, fromSlot = data.fromSlot,
|
||||
toContainer = data.toContainer, toSlot = data.toSlot
|
||||
})
|
||||
cb('ok')
|
||||
end)
|
||||
|
||||
-- Wearing something is not doing something: it happens on the spot and the
|
||||
-- panel stays open, because the next thing you do is usually to the slot beside
|
||||
-- it.
|
||||
RegisterNUICallback('toggleWear', function(data, cb)
|
||||
TriggerServerEvent('fiverp-inventory:toggleWear', data.container, data.slot)
|
||||
cb('ok')
|
||||
end)
|
||||
|
||||
RegisterNUICallback('use', function(data, cb)
|
||||
-- Using something is doing something, and doing it happens in the world.
|
||||
-- Closing also removes the way to press the same item twenty times while the
|
||||
-- first press is still playing out.
|
||||
close()
|
||||
TriggerServerEvent('fiverp-inventory:use', data.container, data.slot)
|
||||
cb('ok')
|
||||
end)
|
||||
|
||||
RegisterNUICallback('drop', function(data, cb)
|
||||
TriggerServerEvent('fiverp-inventory:drop', data.container, data.slot, data.count)
|
||||
cb('ok')
|
||||
end)
|
||||
|
||||
-- ----------------------------------------------------------------- ground
|
||||
-- A dropped item is a real object in the world, which is the whole point of the
|
||||
-- model field in the registry. No model, no prop — the interact marker still
|
||||
-- appears, so nothing becomes unrecoverable because an entry is incomplete.
|
||||
-- Anything dropped has to be visible. An item with no model of its own, or one
|
||||
-- whose model turns out not to exist, becomes a small box.
|
||||
--
|
||||
-- Small is checked rather than assumed: the first pick here was a shipping
|
||||
-- crate taller than the player standing next to it. GetModelDimensions gives
|
||||
-- the bounding box, so a model that is nothing like the size of a dropped
|
||||
-- object can be rejected instead of discovered in a screenshot.
|
||||
local FALLBACK_MODELS = {
|
||||
'prop_cs_cardbox_01',
|
||||
'prop_paper_bag_01',
|
||||
'prop_food_bs_bag_01',
|
||||
'prop_cs_box_clothes',
|
||||
'prop_box_ammo01a'
|
||||
}
|
||||
|
||||
local MAX_DROP_SIZE = 0.75 -- metres on the longest side
|
||||
|
||||
local function modelFits(hash)
|
||||
local min, max = GetModelDimensions(hash)
|
||||
if not min or not max then return true end
|
||||
local size = max - min
|
||||
return math.max(size.x, size.y, size.z) <= MAX_DROP_SIZE
|
||||
end
|
||||
|
||||
local function tryModel(name, checkSize)
|
||||
if not name then return nil end
|
||||
local hash = GetHashKey(name)
|
||||
if not IsModelInCdimage(hash) or not IsModelValid(hash) then return nil end
|
||||
|
||||
RequestModel(hash)
|
||||
local timeout = GetGameTimer() + 4000
|
||||
while not HasModelLoaded(hash) and GetGameTimer() < timeout do Wait(0) end
|
||||
if not HasModelLoaded(hash) then return nil end
|
||||
|
||||
if checkSize and not modelFits(hash) then
|
||||
SetModelAsNoLongerNeeded(hash)
|
||||
return nil
|
||||
end
|
||||
|
||||
return hash
|
||||
end
|
||||
|
||||
local function spawnProp(drop)
|
||||
local def = defOf(drop.item)
|
||||
|
||||
-- The item's own model is taken as authored, whatever its size; the fallbacks
|
||||
-- are size-checked because those are ours to get wrong.
|
||||
local hash = tryModel(def and def.model, false)
|
||||
if not hash then
|
||||
for _, name in ipairs(FALLBACK_MODELS) do
|
||||
hash = tryModel(name, true)
|
||||
if hash then break end
|
||||
end
|
||||
end
|
||||
if not hash then return end
|
||||
|
||||
local prop = CreateObject(hash, drop.coords[1], drop.coords[2], drop.coords[3], false, false, false)
|
||||
PlaceObjectOnGroundProperly(prop)
|
||||
FreezeEntityPosition(prop, true)
|
||||
SetEntityCollision(prop, false, false)
|
||||
SetModelAsNoLongerNeeded(hash)
|
||||
|
||||
groundProps[drop.id] = prop
|
||||
end
|
||||
|
||||
local function addGround(drop)
|
||||
if not drop or not drop.id then return end
|
||||
|
||||
spawnProp(drop)
|
||||
|
||||
local def = defOf(drop.item)
|
||||
local label = def and def.label or drop.item
|
||||
if drop.count > 1 then label = label .. ' x' .. drop.count end
|
||||
|
||||
exports['fiverp-interact']:add({
|
||||
id = 'drop:' .. drop.id,
|
||||
coords = vector3(drop.coords[1], drop.coords[2], drop.coords[3] + 0.35),
|
||||
label = label,
|
||||
distance = 1.8,
|
||||
hold = 400,
|
||||
onSelect = function()
|
||||
TriggerServerEvent('fiverp-inventory:pickup', drop.id)
|
||||
end
|
||||
})
|
||||
end
|
||||
|
||||
local function removeGround(id)
|
||||
exports['fiverp-interact']:remove('drop:' .. id)
|
||||
local prop = groundProps[id]
|
||||
if prop and DoesEntityExist(prop) then DeleteEntity(prop) end
|
||||
groundProps[id] = nil
|
||||
end
|
||||
|
||||
RegisterNetEvent('fiverp-inventory:groundAdd', function(drop)
|
||||
removeGround(drop.id) -- a partial pickup re-sends the same id
|
||||
addGround(drop)
|
||||
end)
|
||||
|
||||
RegisterNetEvent('fiverp-inventory:groundGone', function(id) removeGround(id) end)
|
||||
|
||||
RegisterNetEvent('fiverp-inventory:groundAll', function(list)
|
||||
for _, drop in ipairs(list or {}) do addGround(drop) end
|
||||
end)
|
||||
|
||||
-- ----------------------------------------------------------------- wiring
|
||||
local function onSpawn()
|
||||
inWorld = true
|
||||
-- A beat after the spawn: applyAppearance runs in the same frame and reading
|
||||
-- the ped before it has finished would adopt whatever it was wearing first.
|
||||
CreateThread(function()
|
||||
Wait(2500)
|
||||
TriggerServerEvent('fiverp-inventory:adoptWorn', readWorn())
|
||||
TriggerServerEvent('fiverp-inventory:request')
|
||||
TriggerServerEvent('fiverp-inventory:groundRequest')
|
||||
end)
|
||||
end
|
||||
|
||||
AddEventHandler('fiverp-chars:spawn', onSpawn)
|
||||
RegisterNetEvent('fiverp-chars:spawn', onSpawn)
|
||||
|
||||
-- Back to the menus: the bag must not sit on top of them.
|
||||
local function onBegin()
|
||||
inWorld = false
|
||||
close()
|
||||
end
|
||||
|
||||
AddEventHandler('fiverp-chars:begin', onBegin)
|
||||
RegisterNetEvent('fiverp-chars:begin', onBegin)
|
||||
|
||||
AddEventHandler('onResourceStop', function(name)
|
||||
if name ~= GetCurrentResourceName() then return end
|
||||
SetNuiFocus(false, false)
|
||||
for id in pairs(groundProps) do removeGround(id) end
|
||||
end)
|
||||
@@ -0,0 +1,33 @@
|
||||
fx_version 'cerulean'
|
||||
game 'gta5'
|
||||
|
||||
name 'fiverp-inventory'
|
||||
author 'FiveRP'
|
||||
description 'Backpack, worn clothing and what ends up on the pavement'
|
||||
version '0.1.0'
|
||||
|
||||
dependency 'fiverp-interact'
|
||||
|
||||
ui_page 'html/index.html'
|
||||
|
||||
shared_script 'shared/items.lua'
|
||||
|
||||
-- items.js first: the runtime has no require(), so order is how these two share
|
||||
-- anything at all.
|
||||
server_scripts {
|
||||
'shared/items.js',
|
||||
'server/sv_inventory.js'
|
||||
}
|
||||
|
||||
client_scripts { 'client/cl_inventory.lua' }
|
||||
|
||||
files {
|
||||
'html/index.html',
|
||||
'html/style.css',
|
||||
'html/app.js',
|
||||
'html/scrollbar.js',
|
||||
'html/images/*.png',
|
||||
'html/fonts/unbounded-latin.woff2',
|
||||
'html/fonts/unbounded-latin-ext.woff2',
|
||||
'html/fonts/unbounded-cyrillic.woff2',
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
/* FiveRP inventory — the panel.
|
||||
It draws whatever state the server last sent and asks for changes. It never
|
||||
applies one locally: a slot that moved on screen before the server agreed
|
||||
would be a slot the player believes in and the world does not. */
|
||||
|
||||
const RES = 'fiverp-inventory';
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
let state = {
|
||||
bag: {}, wear: {}, items: {},
|
||||
weight: 0, maxWeight: 30, slots: 16,
|
||||
wearSlots: ['hat', 'top', 'undershirt', 'pants', 'shoes']
|
||||
};
|
||||
|
||||
const WEAR_LABELS = {
|
||||
hat: 'Headwear',
|
||||
top: 'Outerwear',
|
||||
undershirt: 'Undershirt',
|
||||
pants: 'Trousers',
|
||||
shoes: 'Shoes'
|
||||
};
|
||||
|
||||
let drag = null; // { container, slot, item }
|
||||
|
||||
function post(name, data) {
|
||||
fetch(`https://${RES}/${name}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json; charset=UTF-8' },
|
||||
body: JSON.stringify(data || {})
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function escape(text) {
|
||||
return String(text).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function def(id) {
|
||||
return state.items[id] || null;
|
||||
}
|
||||
|
||||
// Two letters of the label, which is what stands in until an item has artwork.
|
||||
function initials(id) {
|
||||
const d = def(id);
|
||||
const label = (d && d.label) || id || '?';
|
||||
const words = label.split(/\s+/).filter(Boolean);
|
||||
return (words.length > 1 ? words[0][0] + words[1][0] : label.slice(0, 2)).toUpperCase();
|
||||
}
|
||||
|
||||
function rowAt(container, slot) {
|
||||
const box = state[container];
|
||||
return (box && box[String(slot)]) || null;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------- drawing */
|
||||
|
||||
function slotMarkup(container, slot, row) {
|
||||
const d = row ? def(row.item) : null;
|
||||
const name = d ? d.label : (row ? row.item : '');
|
||||
|
||||
const art = row
|
||||
? (d && d.image
|
||||
? `<img src="images/${escape(d.image)}" alt="">`
|
||||
: `<span class="slot__initials">${escape(initials(row.item))}</span>`)
|
||||
: '';
|
||||
|
||||
const count = row && row.count > 1
|
||||
? `<span class="slot__count">${row.count}</span>` : '';
|
||||
|
||||
const weight = row && d
|
||||
? `<span class="slot__weight">${(d.weight * row.count).toFixed(1)}</span>` : '';
|
||||
|
||||
const label = row
|
||||
? `<span class="slot__name">${escape(name)}</span>`
|
||||
: (container === 'wear'
|
||||
? `<span class="slot__placeholder">${escape(WEAR_LABELS[slot] || slot)}</span>`
|
||||
: '');
|
||||
|
||||
const title = row && d && d.description
|
||||
? ` title="${escape(d.label)} — ${escape(d.description)}"` : '';
|
||||
|
||||
return `<div class="slot${row ? ' has-item' : ''}"
|
||||
data-container="${container}" data-slot="${slot}"${title}>
|
||||
<div class="slot__art">${art}</div>
|
||||
${label}${count}${weight}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function draw() {
|
||||
$('bag').innerHTML = Array.from({ length: state.slots }, (_, i) =>
|
||||
slotMarkup('bag', i, rowAt('bag', i))).join('');
|
||||
|
||||
$('wear').innerHTML = state.wearSlots.map((slot) =>
|
||||
slotMarkup('wear', slot, rowAt('wear', slot))).join('');
|
||||
|
||||
const pct = Math.min(100, (state.weight / state.maxWeight) * 100);
|
||||
$('weight').textContent = Number(state.weight).toFixed(1);
|
||||
$('maxWeight').textContent = state.maxWeight;
|
||||
$('loadFill').style.width = pct + '%';
|
||||
$('loadFill').classList.toggle('is-heavy', pct >= 85);
|
||||
|
||||
bindSlots();
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- dragging */
|
||||
|
||||
function markTargets(on) {
|
||||
if (!on || !drag) {
|
||||
document.querySelectorAll('.slot').forEach((el) =>
|
||||
el.classList.remove('is-blocked', 'is-target'));
|
||||
return;
|
||||
}
|
||||
|
||||
const d = def(drag.item);
|
||||
document.querySelectorAll('.slot').forEach((el) => {
|
||||
if (el.dataset.container !== 'wear') return;
|
||||
// A clothing slot that cannot take this item is dimmed up front, rather
|
||||
// than accepting the drop and bouncing back when the server refuses.
|
||||
const fits = d && d.wear && d.wear === el.dataset.slot;
|
||||
el.classList.toggle('is-blocked', !fits);
|
||||
});
|
||||
}
|
||||
|
||||
function startDrag(el, event) {
|
||||
const container = el.dataset.container;
|
||||
const slot = el.dataset.slot;
|
||||
const row = rowAt(container, slot);
|
||||
if (!row) return;
|
||||
|
||||
drag = { container, slot, item: row.item };
|
||||
el.classList.add('is-dragging');
|
||||
|
||||
// The ghost carries the item's picture, not its initials. Dragging a thing
|
||||
// you cannot see is dragging a rectangle, and the slot it came from is
|
||||
// already dimmed — there is nothing else on screen saying what is in hand.
|
||||
const ghost = $('ghost');
|
||||
const d = def(row.item);
|
||||
ghost.innerHTML = d && d.image
|
||||
? `<img src="images/${escape(d.image)}" alt="">`
|
||||
: escape(initials(row.item));
|
||||
ghost.classList.add('is-shown');
|
||||
moveGhost(event);
|
||||
markTargets(true);
|
||||
}
|
||||
|
||||
function moveGhost(event) {
|
||||
const ghost = $('ghost');
|
||||
ghost.style.left = event.clientX + 'px';
|
||||
ghost.style.top = event.clientY + 'px';
|
||||
}
|
||||
|
||||
function endDrag(event) {
|
||||
if (!drag) return;
|
||||
|
||||
const ghost = $('ghost');
|
||||
ghost.classList.remove('is-shown');
|
||||
document.querySelectorAll('.slot').forEach((el) => el.classList.remove('is-dragging'));
|
||||
$('dropzone').classList.remove('is-target');
|
||||
|
||||
const under = document.elementFromPoint(event.clientX, event.clientY);
|
||||
const target = under && under.closest('.slot');
|
||||
const zone = under && under.closest('#dropzone');
|
||||
|
||||
if (zone) {
|
||||
post('drop', { container: drag.container, slot: drag.slot });
|
||||
} else if (target && !target.classList.contains('is-blocked')) {
|
||||
const toContainer = target.dataset.container;
|
||||
const toSlot = target.dataset.slot;
|
||||
if (toContainer !== drag.container || toSlot !== drag.slot) {
|
||||
post('move', {
|
||||
fromContainer: drag.container, fromSlot: drag.slot,
|
||||
toContainer, toSlot
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
drag = null;
|
||||
markTargets(false);
|
||||
}
|
||||
|
||||
function bindSlots() {
|
||||
document.querySelectorAll('.slot').forEach((el) => {
|
||||
el.addEventListener('mousedown', (event) => {
|
||||
if (event.button !== 0) return;
|
||||
event.preventDefault();
|
||||
startDrag(el, event);
|
||||
});
|
||||
|
||||
// Right-click and double-click both mean "use", because both are what
|
||||
// people already try.
|
||||
el.addEventListener('contextmenu', (event) => {
|
||||
event.preventDefault();
|
||||
use(el);
|
||||
});
|
||||
|
||||
el.addEventListener('dblclick', () => use(el));
|
||||
});
|
||||
}
|
||||
|
||||
function use(el) {
|
||||
const container = el.dataset.container;
|
||||
const slot = el.dataset.slot;
|
||||
const row = rowAt(container, slot);
|
||||
if (!row) return;
|
||||
|
||||
const d = def(row.item);
|
||||
|
||||
// Clothing is put on where it lies and the panel stays; anything else is
|
||||
// used, which happens out in the world, so the panel gets out of the way.
|
||||
if (d && d.wear) {
|
||||
post('toggleWear', { container, slot });
|
||||
return;
|
||||
}
|
||||
|
||||
post('use', { container, slot });
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', (event) => {
|
||||
if (!drag) return;
|
||||
moveGhost(event);
|
||||
|
||||
const under = document.elementFromPoint(event.clientX, event.clientY);
|
||||
const target = under && under.closest('.slot');
|
||||
const zone = under && under.closest('#dropzone');
|
||||
|
||||
document.querySelectorAll('.slot').forEach((el) =>
|
||||
el.classList.toggle('is-target', el === target && !el.classList.contains('is-blocked')));
|
||||
$('dropzone').classList.toggle('is-target', !!zone);
|
||||
});
|
||||
|
||||
document.addEventListener('mouseup', endDrag);
|
||||
|
||||
/* ---------------------------------------------------------------- notice */
|
||||
|
||||
let noticeTimer = null;
|
||||
function notice(text) {
|
||||
const el = $('notice');
|
||||
el.textContent = text || '';
|
||||
el.classList.toggle('is-shown', !!text);
|
||||
clearTimeout(noticeTimer);
|
||||
if (text) noticeTimer = setTimeout(() => el.classList.remove('is-shown'), 2600);
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------- open, close */
|
||||
|
||||
function close() {
|
||||
$('root').classList.remove('is-open');
|
||||
post('close', {});
|
||||
}
|
||||
|
||||
$('close').addEventListener('click', close);
|
||||
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape' || event.key.toLowerCase() === 'i') close();
|
||||
});
|
||||
|
||||
window.addEventListener('message', (event) => {
|
||||
const msg = event.data || {};
|
||||
|
||||
if (msg.action === 'open') {
|
||||
$('root').classList.add('is-open');
|
||||
notice('');
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.action === 'close') {
|
||||
$('root').classList.remove('is-open');
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.action === 'state') {
|
||||
state = Object.assign(state, msg.data || {});
|
||||
draw();
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.action === 'refused') {
|
||||
notice((msg.data && msg.data.message) || 'That did not work');
|
||||
}
|
||||
});
|
||||
|
||||
draw();
|
||||
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 39 KiB |
@@ -0,0 +1,63 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>FiveRP — Inventory</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="root" class="root">
|
||||
<div class="panel">
|
||||
|
||||
<header class="head">
|
||||
<div>
|
||||
<p class="eyebrow">Carrying</p>
|
||||
<h1 class="title">Inventory</h1>
|
||||
</div>
|
||||
|
||||
<div class="load">
|
||||
<div class="load__top">
|
||||
<span class="load__value" id="weight">0.0</span>
|
||||
<span class="load__max">/ <b id="maxWeight">30</b> kg</span>
|
||||
</div>
|
||||
<div class="load__track"><i class="load__fill" id="loadFill"></i></div>
|
||||
</div>
|
||||
|
||||
<button class="close" id="close" type="button">Close</button>
|
||||
</header>
|
||||
|
||||
<div class="body">
|
||||
<!-- worn -->
|
||||
<section class="wear">
|
||||
<p class="group">Worn</p>
|
||||
<div class="wear__slots" id="wear"></div>
|
||||
</section>
|
||||
|
||||
<!-- backpack -->
|
||||
<section class="bag">
|
||||
<p class="group">Bag</p>
|
||||
<div class="bag__grid" id="bag"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<footer class="foot">
|
||||
<p class="hint">
|
||||
Drag to move. Double-click or right-click to use.
|
||||
Drag onto the ground strip to drop.
|
||||
</p>
|
||||
<div class="dropzone" id="dropzone">
|
||||
<span>Drop here</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<p class="notice" id="notice"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Follows the cursor while dragging, so the slot itself never moves. -->
|
||||
<div class="ghost" id="ghost"></div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,123 @@
|
||||
/* A scrollbar we actually own.
|
||||
CEF will not let the native one go quietly: ::-webkit-scrollbar-button keeps
|
||||
drawing a stepper stub at each end whatever display, size or background it is
|
||||
given. So the native bar is hidden outright and this draws the replacement.
|
||||
|
||||
Attach with data-scroll on any scrolling element. The bar is position:fixed
|
||||
and measured from the element's own rect, so it needs no particular ancestor
|
||||
and works inside a modal that moves. */
|
||||
(() => {
|
||||
const MIN_THUMB = 28;
|
||||
|
||||
function attach(host) {
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'sbar';
|
||||
const thumb = document.createElement('i');
|
||||
thumb.className = 'sbar__thumb';
|
||||
bar.appendChild(thumb);
|
||||
document.body.appendChild(bar);
|
||||
|
||||
let dragging = false;
|
||||
let grabOffset = 0;
|
||||
|
||||
// The bar is appended to the body, not to the host, so nothing about the
|
||||
// host being faded out reaches it. Walk the ancestors and hide with them —
|
||||
// otherwise a chat that has quietly gone away leaves its scrollbar behind.
|
||||
function hostVisible() {
|
||||
let el = host;
|
||||
while (el && el !== document.body) {
|
||||
const style = getComputedStyle(el);
|
||||
if (style.display === 'none' || style.visibility === 'hidden') return false;
|
||||
if (Number(style.opacity) < 0.05) return false;
|
||||
el = el.parentElement;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function layout() {
|
||||
const box = host.getBoundingClientRect();
|
||||
const scrollable = host.scrollHeight - host.clientHeight;
|
||||
|
||||
// Nothing to scroll, or nothing to scroll it on: either way the bar is a
|
||||
// control that does nothing.
|
||||
if (scrollable <= 1 || box.height <= 0 || !hostVisible()) {
|
||||
bar.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
bar.style.display = 'block';
|
||||
bar.style.top = box.top + 'px';
|
||||
bar.style.height = box.height + 'px';
|
||||
bar.style.left = (box.right - 12) + 'px';
|
||||
|
||||
const ratio = host.clientHeight / host.scrollHeight;
|
||||
const h = Math.max(MIN_THUMB, box.height * ratio);
|
||||
const travel = box.height - h;
|
||||
const progress = host.scrollTop / scrollable;
|
||||
|
||||
thumb.style.height = h + 'px';
|
||||
thumb.style.transform = `translateY(${travel * progress}px)`;
|
||||
}
|
||||
|
||||
function onDrag(event) {
|
||||
if (!dragging) return;
|
||||
const box = host.getBoundingClientRect();
|
||||
const h = thumb.getBoundingClientRect().height;
|
||||
const travel = box.height - h;
|
||||
if (travel <= 0) return;
|
||||
|
||||
const wanted = event.clientY - box.top - grabOffset;
|
||||
const progress = Math.max(0, Math.min(1, wanted / travel));
|
||||
host.scrollTop = progress * (host.scrollHeight - host.clientHeight);
|
||||
}
|
||||
|
||||
thumb.addEventListener('pointerdown', (event) => {
|
||||
dragging = true;
|
||||
grabOffset = event.clientY - thumb.getBoundingClientRect().top;
|
||||
bar.classList.add('is-held');
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
// Listen on the window, not the thumb: the pointer regularly leaves a 4px
|
||||
// wide target mid-drag and the drag has to survive that.
|
||||
window.addEventListener('pointermove', onDrag);
|
||||
window.addEventListener('pointerup', () => {
|
||||
dragging = false;
|
||||
bar.classList.remove('is-held');
|
||||
});
|
||||
|
||||
// Clicking the empty track jumps a page, the way a real scrollbar does.
|
||||
bar.addEventListener('pointerdown', (event) => {
|
||||
if (event.target === thumb) return;
|
||||
const box = thumb.getBoundingClientRect();
|
||||
host.scrollTop += (event.clientY < box.top ? -1 : 1) * host.clientHeight * 0.9;
|
||||
});
|
||||
|
||||
host.addEventListener('scroll', layout);
|
||||
window.addEventListener('resize', layout);
|
||||
|
||||
// The panel is rebuilt (settings reset, tab switch) without the element ever
|
||||
// being replaced, so the size has to be re-read when its contents change.
|
||||
new MutationObserver(layout).observe(host, { childList: true, subtree: true });
|
||||
|
||||
layout();
|
||||
return layout;
|
||||
}
|
||||
|
||||
const layouts = [];
|
||||
function scan() {
|
||||
document.querySelectorAll('[data-scroll]').forEach((el) => {
|
||||
if (el.dataset.scrollBound) return;
|
||||
el.dataset.scrollBound = '1';
|
||||
layouts.push(attach(el));
|
||||
});
|
||||
}
|
||||
|
||||
// Re-measure when a panel is opened: a hidden element has no useful rect.
|
||||
window.addEventListener('fiverp:relayout', () => layouts.forEach((fn) => fn()));
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', scan);
|
||||
} else {
|
||||
scan();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,441 @@
|
||||
/* FiveRP — chat.
|
||||
Design system: CLAUDE.md — Clean UI / Dark Minimalism.
|
||||
Sits over the game, so the page itself stays transparent and only the
|
||||
panels take a fill. No blur, no shadow, no gradient. */
|
||||
|
||||
@font-face {
|
||||
font-family: 'Unbounded';
|
||||
font-style: normal;
|
||||
font-weight: 300 900;
|
||||
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 900;
|
||||
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 900;
|
||||
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: rgba(24, 24, 24, 1);
|
||||
--card-bg: rgba(30, 30, 30, 1);
|
||||
--btn-bg: rgba(51, 51, 51, 1);
|
||||
--btn-hover: rgba(70, 70, 70, 1);
|
||||
--accent: rgba(255, 119, 22, 1);
|
||||
--text-primary: rgba(241, 241, 241, 1);
|
||||
--text-secondary: rgba(153, 153, 153, 1);
|
||||
--border: rgba(51, 51, 51, 0.5);
|
||||
|
||||
--sans: 'Unbounded', sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
font-family: var(--sans);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
html, body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
body { color: var(--text-primary); }
|
||||
|
||||
.root {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: rgba(24, 24, 24, 0.86);
|
||||
}
|
||||
|
||||
.root.is-open { display: flex; }
|
||||
|
||||
.panel {
|
||||
position: relative;
|
||||
width: min(880px, 92vw);
|
||||
background-color: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────── head */
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 25px;
|
||||
padding: 25px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 20px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Weight is the only real constraint on a bag, so it gets the space a number
|
||||
that changes deserves rather than a line of small print. */
|
||||
.load {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.load__top {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.load__value {
|
||||
font-size: 18px;
|
||||
font-weight: 900;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.load__max {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.load__max b { font-weight: 700; }
|
||||
|
||||
.load__track {
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background-color: var(--btn-bg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.load__fill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 0;
|
||||
border-radius: 2px;
|
||||
background-color: var(--text-primary);
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
/* Near the limit the bar becomes the one accent thing on screen, because at
|
||||
that point it is the only number that will stop you doing something. */
|
||||
.load__fill.is-heavy { background-color: var(--accent); }
|
||||
|
||||
.close {
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-primary);
|
||||
background-color: var(--btn-bg);
|
||||
border-radius: 20px;
|
||||
padding: 10px 18px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.close:hover { background-color: var(--btn-hover); }
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────── body */
|
||||
|
||||
.body {
|
||||
display: grid;
|
||||
/* minmax(0, 1fr), not 1fr: a grid track's automatic minimum is its content's
|
||||
min-content width, and four square slots with labels are wider than the
|
||||
panel — the bag pushed the whole thing past its own edge. */
|
||||
grid-template-columns: 220px minmax(0, 1fr);
|
||||
gap: 25px;
|
||||
padding: 25px;
|
||||
}
|
||||
|
||||
.body > section { min-width: 0; }
|
||||
|
||||
.group {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.wear__slots {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bag__grid {
|
||||
display: grid;
|
||||
/* Same trap one level down: each 1fr track still has an automatic minimum of
|
||||
its content, and a nowrap label like "Handheld radio" is wider than a
|
||||
quarter of the panel — so four of them pushed the grid past its column.
|
||||
minmax(0, …) lets the track shrink and the label ellipsis do its job. */
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* ───────────────────────────────────────────────────────────────── slots */
|
||||
|
||||
.slot {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
background-color: var(--btn-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 9px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
cursor: default;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.slot.has-item { cursor: grab; }
|
||||
|
||||
.slot.has-item:hover {
|
||||
background-color: var(--btn-hover);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* The slot being dragged stays put and dims; the ghost is what moves. */
|
||||
.slot.is-dragging { opacity: 0.35; }
|
||||
|
||||
.slot.is-target {
|
||||
border-color: var(--accent);
|
||||
background-color: var(--btn-hover);
|
||||
}
|
||||
|
||||
/* A clothing slot that cannot take what is being dragged says so before the
|
||||
move is attempted, rather than after the server refuses it. */
|
||||
.slot.is-blocked { opacity: 0.35; }
|
||||
|
||||
/* Worn slots are wide rather than square: they are a list of five named places,
|
||||
not a grid of interchangeable ones. */
|
||||
.wear .slot {
|
||||
aspect-ratio: auto;
|
||||
height: 58px;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
padding: 9px 12px;
|
||||
}
|
||||
|
||||
.slot__art {
|
||||
position: absolute;
|
||||
inset: 9px;
|
||||
bottom: 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.wear .slot__art {
|
||||
position: static;
|
||||
inset: auto;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 12px;
|
||||
background-color: var(--card-bg);
|
||||
}
|
||||
|
||||
.slot__art img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* No artwork yet for anything, so the initials stand in. It reads as a
|
||||
placeholder rather than as a broken image. */
|
||||
.slot__initials {
|
||||
font-size: 15px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 1px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.wear .slot__initials { font-size: 12px; }
|
||||
|
||||
.slot__name {
|
||||
position: relative;
|
||||
/* Room for the weight, which is pinned to the same bottom corner. Without it
|
||||
a long label runs straight through the number. */
|
||||
padding-right: 26px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wear .slot__name { padding-right: 0; }
|
||||
|
||||
.wear .slot__name { flex: 1; min-width: 0; }
|
||||
|
||||
/* The empty worn slot still says what belongs in it. */
|
||||
.slot__placeholder {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.slot__count {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--bg);
|
||||
background-color: var(--accent);
|
||||
border-radius: 20px;
|
||||
padding: 3px 7px;
|
||||
}
|
||||
|
||||
.wear .slot__count { top: 50%; right: 12px; transform: translateY(-50%); }
|
||||
|
||||
.slot__weight {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 9px;
|
||||
font-size: 9px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.wear .slot__weight { display: none; }
|
||||
|
||||
/* ───────────────────────────────────────────────────────────────── foot */
|
||||
|
||||
.foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 25px;
|
||||
padding: 0 25px 25px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
flex: 1;
|
||||
font-size: 10px;
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.dropzone {
|
||||
flex-shrink: 0;
|
||||
width: 220px;
|
||||
border: 1px dashed var(--btn-bg);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.dropzone.is-target {
|
||||
border-style: solid;
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────── notice */
|
||||
|
||||
.notice {
|
||||
position: absolute;
|
||||
left: 25px;
|
||||
bottom: 25px;
|
||||
display: none;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--accent);
|
||||
background-color: var(--btn-bg);
|
||||
border-radius: 12px;
|
||||
padding: 11px 15px;
|
||||
}
|
||||
|
||||
.notice.is-shown { display: block; }
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────── ghost */
|
||||
|
||||
.ghost {
|
||||
position: fixed;
|
||||
z-index: 90;
|
||||
display: none;
|
||||
pointer-events: none;
|
||||
width: 74px;
|
||||
height: 74px;
|
||||
border-radius: 12px;
|
||||
background-color: var(--btn-hover);
|
||||
border: 1px solid var(--accent);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 15px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 1px;
|
||||
color: var(--text-primary);
|
||||
transform: translate(-50%, -50%);
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.ghost img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.ghost.is-shown { display: flex; }
|
||||
|
||||
body.no-anim *,
|
||||
body.no-anim *::before,
|
||||
body.no-anim *::after {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
@@ -0,0 +1,582 @@
|
||||
// FiveRP inventory — the server half.
|
||||
//
|
||||
// It owns every slot. A client may ask to move, use, drop or pick something up;
|
||||
// it never states what it is holding. Each request is checked against the row
|
||||
// the database has, not against anything that arrived over the wire, and the
|
||||
// answer sent back is the whole inventory rather than a delta — a client that
|
||||
// missed one update would otherwise diverge quietly and stay wrong.
|
||||
|
||||
// shared/items.js is loaded before this file by the manifest and leaves its
|
||||
// definitions as globals; the runtime has no require().
|
||||
|
||||
const sql = (method, query, params) =>
|
||||
exports.oxmysql[`${method}_async`](query, params);
|
||||
|
||||
// character_id -> { bag: {slot: {item,count,meta}}, wear: {...} }
|
||||
const held = new Map();
|
||||
// source -> character_id, so a request can be traced to rows without trusting it
|
||||
const owner = new Map();
|
||||
|
||||
// Dropped items waiting on the ground. Kept in memory on purpose: a bag left in
|
||||
// an alley should not survive a restart, and persisting it would make the world
|
||||
// fill up with everything anyone ever discarded.
|
||||
const ground = new Map();
|
||||
let nextDrop = 1;
|
||||
|
||||
function ask(resource, method, fallback, ...args) {
|
||||
try {
|
||||
const target = exports[resource];
|
||||
const result = target[method](...args);
|
||||
return result === undefined ? fallback : result;
|
||||
} catch (err) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function charOf(src) {
|
||||
return owner.get(Number(src)) || null;
|
||||
}
|
||||
|
||||
function blank() {
|
||||
return { bag: {}, wear: {} };
|
||||
}
|
||||
|
||||
function weightOf(store) {
|
||||
let total = 0;
|
||||
for (const container of ['bag', 'wear']) {
|
||||
for (const slot of Object.keys(store[container])) {
|
||||
const row = store[container][slot];
|
||||
total += itemWeight(row.item, row.count);
|
||||
}
|
||||
}
|
||||
return Math.round(total * 100) / 100;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- load
|
||||
async function load(charId) {
|
||||
const rows = await sql('query',
|
||||
'SELECT id, container, slot, item, count, meta FROM character_items WHERE character_id = ?',
|
||||
[charId]) || [];
|
||||
|
||||
const store = blank();
|
||||
const junk = [];
|
||||
|
||||
for (const row of rows) {
|
||||
// An item that has been removed from the registry since it was stored is
|
||||
// dropped on load rather than shown as a slot nobody can explain.
|
||||
if (!itemDef(row.item)) { junk.push(row); continue; }
|
||||
|
||||
const meta = row.meta ? safeParse(row.meta) : null;
|
||||
|
||||
// A garment is its drawable. A worn_* row without one describes no actual
|
||||
// clothing, so it would sit in the slot looking equipped while the body
|
||||
// stayed bare — which is exactly what rows written before meta survived a
|
||||
// drop now look like. Discard them rather than keep an item that cannot be
|
||||
// put on.
|
||||
if (String(row.item).startsWith('worn_') &&
|
||||
(!meta || typeof meta.drawable !== 'number')) {
|
||||
junk.push(row);
|
||||
continue;
|
||||
}
|
||||
|
||||
store[row.container][String(row.slot)] = {
|
||||
item: row.item,
|
||||
count: Number(row.count),
|
||||
meta
|
||||
};
|
||||
}
|
||||
|
||||
await Promise.all(junk.map((row) =>
|
||||
sql('update', 'DELETE FROM character_items WHERE id = ?', [row.id])));
|
||||
|
||||
held.set(charId, store);
|
||||
return store;
|
||||
}
|
||||
|
||||
function safeParse(text) {
|
||||
try { return JSON.parse(text); } catch (err) { return null; }
|
||||
}
|
||||
|
||||
function storeOf(charId) {
|
||||
return held.get(charId) || blank();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ write
|
||||
// The row is the truth, so every change goes to the database before the client
|
||||
// hears about it. Slots are written individually: a wholesale delete-and-insert
|
||||
// would lose everything if the insert half failed.
|
||||
async function persistSlot(charId, container, slot, row) {
|
||||
if (!row) {
|
||||
await sql('update',
|
||||
'DELETE FROM character_items WHERE character_id = ? AND container = ? AND slot = ?',
|
||||
[charId, container, String(slot)]);
|
||||
return;
|
||||
}
|
||||
|
||||
await sql('update',
|
||||
`INSERT INTO character_items (character_id, container, slot, item, count, meta)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE item = VALUES(item), count = VALUES(count), meta = VALUES(meta)`,
|
||||
[charId, container, String(slot), row.item, row.count,
|
||||
row.meta ? JSON.stringify(row.meta) : null]);
|
||||
}
|
||||
|
||||
function push(src) {
|
||||
const charId = charOf(src);
|
||||
if (!charId) return;
|
||||
const store = storeOf(charId);
|
||||
|
||||
emitNet('fiverp-inventory:state', src, {
|
||||
bag: store.bag,
|
||||
wear: store.wear,
|
||||
weight: weightOf(store),
|
||||
maxWeight: MAX_WEIGHT,
|
||||
slots: BAG_SLOTS,
|
||||
wearSlots: WEAR_SLOTS,
|
||||
// The registry travels with the state so the panel can label and weigh what
|
||||
// it draws without a second round trip.
|
||||
items: ITEMS
|
||||
});
|
||||
}
|
||||
|
||||
function refuse(src, why) {
|
||||
emitNet('fiverp-inventory:refused', src, { message: why });
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ moves
|
||||
function firstFreeBagSlot(store) {
|
||||
for (let i = 0; i < BAG_SLOTS; i++) {
|
||||
if (!store.bag[String(i)]) return String(i);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Add to an existing stack first, then to a free slot. Returns what could not
|
||||
// be placed, so a caller can decide whether a partial add is acceptable.
|
||||
//
|
||||
// An item carrying meta is never merged into a stack. Meta is what makes one
|
||||
// instance different from another — the drawable a jacket remembers — and
|
||||
// stacking two of them would keep one and silently destroy the other's.
|
||||
function addTo(store, item, count, meta) {
|
||||
const def = itemDef(item);
|
||||
if (!def) return count;
|
||||
|
||||
let left = count;
|
||||
const stack = itemStack(item);
|
||||
|
||||
if (stack > 1 && !meta) {
|
||||
for (const slot of Object.keys(store.bag)) {
|
||||
const row = store.bag[slot];
|
||||
if (row.item !== item || row.count >= stack) continue;
|
||||
const room = stack - row.count;
|
||||
const take = Math.min(room, left);
|
||||
row.count += take;
|
||||
left -= take;
|
||||
if (left <= 0) return 0;
|
||||
}
|
||||
}
|
||||
|
||||
while (left > 0) {
|
||||
const slot = firstFreeBagSlot(store);
|
||||
if (!slot) break;
|
||||
// With meta there is one instance per slot, by definition.
|
||||
const take = meta ? 1 : Math.min(stack, left);
|
||||
store.bag[slot] = { item, count: take, meta: meta || null };
|
||||
left -= take;
|
||||
}
|
||||
|
||||
return left;
|
||||
}
|
||||
|
||||
async function saveAll(charId, store) {
|
||||
const jobs = [];
|
||||
for (const container of ['bag', 'wear']) {
|
||||
for (const slot of Object.keys(store[container])) {
|
||||
jobs.push(persistSlot(charId, container, slot, store[container][slot]));
|
||||
}
|
||||
}
|
||||
await Promise.all(jobs);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- lifecycle
|
||||
on('fiverp:characterSelected', async (src, charId) => {
|
||||
src = Number(src);
|
||||
charId = Number(charId);
|
||||
if (!src || !charId) return;
|
||||
|
||||
owner.set(src, charId);
|
||||
await load(charId);
|
||||
push(src);
|
||||
});
|
||||
|
||||
on('playerDropped', () => {
|
||||
const src = Number(global.source);
|
||||
const charId = owner.get(src);
|
||||
owner.delete(src);
|
||||
// The cache is dropped with the player; the rows stay where they are.
|
||||
if (charId) held.delete(charId);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------ events
|
||||
onNet('fiverp-inventory:request', () => {
|
||||
push(Number(global.source));
|
||||
});
|
||||
|
||||
// Move one slot onto another: swap, merge or split-free move, decided here.
|
||||
onNet('fiverp-inventory:move', async (req) => {
|
||||
const src = Number(global.source);
|
||||
const charId = charOf(src);
|
||||
if (!charId || !req) return;
|
||||
|
||||
const store = storeOf(charId);
|
||||
const from = { c: req.fromContainer, s: String(req.fromSlot) };
|
||||
const to = { c: req.toContainer, s: String(req.toSlot) };
|
||||
|
||||
if (!['bag', 'wear'].includes(from.c) || !['bag', 'wear'].includes(to.c)) return;
|
||||
|
||||
const moving = store[from.c][from.s];
|
||||
if (!moving) return refuse(src, 'Nothing there');
|
||||
|
||||
if (from.c === to.c && from.s === to.s) return;
|
||||
|
||||
if (!fitsSlot(moving.item, to.c, to.s)) {
|
||||
return refuse(src, to.c === 'wear'
|
||||
? 'That does not go there'
|
||||
: 'No such slot');
|
||||
}
|
||||
|
||||
const target = store[to.c][to.s];
|
||||
|
||||
if (target) {
|
||||
// Same item and room in the stack: merge rather than swap.
|
||||
const stack = itemStack(moving.item);
|
||||
if (target.item === moving.item && stack > 1 && target.count < stack) {
|
||||
const take = Math.min(stack - target.count, moving.count);
|
||||
target.count += take;
|
||||
moving.count -= take;
|
||||
if (moving.count <= 0) delete store[from.c][from.s];
|
||||
} else {
|
||||
// Swap, but only if the displaced item is allowed where it would land.
|
||||
if (!fitsSlot(target.item, from.c, from.s)) {
|
||||
return refuse(src, 'Nothing to swap it with');
|
||||
}
|
||||
store[to.c][to.s] = moving;
|
||||
store[from.c][from.s] = target;
|
||||
}
|
||||
} else {
|
||||
store[to.c][to.s] = moving;
|
||||
delete store[from.c][from.s];
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
persistSlot(charId, from.c, from.s, store[from.c][from.s] || null),
|
||||
persistSlot(charId, to.c, to.s, store[to.c][to.s] || null)
|
||||
]);
|
||||
push(src);
|
||||
});
|
||||
|
||||
// Use whatever is in a slot. This resource does not know what "drink" means; it
|
||||
// announces the action and lets whoever owns that effect handle it.
|
||||
onNet('fiverp-inventory:use', async (container, slot) => {
|
||||
const src = Number(global.source);
|
||||
const charId = charOf(src);
|
||||
if (!charId) return;
|
||||
|
||||
const store = storeOf(charId);
|
||||
const row = store[String(container)] && store[String(container)][String(slot)];
|
||||
if (!row) return;
|
||||
|
||||
const def = itemDef(row.item);
|
||||
if (!def || !def.action) return refuse(src, 'Nothing happens');
|
||||
|
||||
emit('fiverp-inventory:used', src, row.item, def.action, row.meta);
|
||||
});
|
||||
|
||||
// Put a garment on, or take it off, without the player having to drag it. The
|
||||
// destination is derived from the item's own wear slot, so there is nothing for
|
||||
// a client to choose and nothing for it to get wrong.
|
||||
onNet('fiverp-inventory:toggleWear', async (container, slot) => {
|
||||
const src = Number(global.source);
|
||||
const charId = charOf(src);
|
||||
if (!charId) return;
|
||||
|
||||
const store = storeOf(charId);
|
||||
container = String(container);
|
||||
slot = String(slot);
|
||||
|
||||
const row = store[container] && store[container][slot];
|
||||
if (!row) return;
|
||||
|
||||
const def = itemDef(row.item);
|
||||
if (!def || !def.wear) return refuse(src, 'That is not something you wear');
|
||||
|
||||
if (container === 'wear') {
|
||||
// Taking off: into the bag, wherever there is room.
|
||||
const free = firstFreeBagSlot(store);
|
||||
if (!free) return refuse(src, 'No room in your bag');
|
||||
|
||||
store.bag[free] = row;
|
||||
delete store.wear[slot];
|
||||
|
||||
await Promise.all([
|
||||
persistSlot(charId, 'wear', slot, null),
|
||||
persistSlot(charId, 'bag', free, store.bag[free])
|
||||
]);
|
||||
} else {
|
||||
// Putting on: whatever is already in that slot swaps into the vacated one,
|
||||
// so wearing a second jacket does not silently destroy the first.
|
||||
const target = def.wear;
|
||||
const worn = store.wear[target] || null;
|
||||
|
||||
store.wear[target] = row;
|
||||
if (worn) {
|
||||
store.bag[slot] = worn;
|
||||
} else {
|
||||
delete store.bag[slot];
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
persistSlot(charId, 'wear', target, store.wear[target]),
|
||||
persistSlot(charId, 'bag', slot, store.bag[slot] || null)
|
||||
]);
|
||||
}
|
||||
|
||||
push(src);
|
||||
});
|
||||
|
||||
// Drop to the ground. The count is clamped to what is actually in the slot, so
|
||||
// a client asking to drop more than it has drops what it has.
|
||||
onNet('fiverp-inventory:drop', async (container, slot, count) => {
|
||||
const src = Number(global.source);
|
||||
const charId = charOf(src);
|
||||
if (!charId) return;
|
||||
|
||||
const store = storeOf(charId);
|
||||
container = String(container);
|
||||
slot = String(slot);
|
||||
|
||||
const row = store[container] && store[container][slot];
|
||||
if (!row) return;
|
||||
|
||||
const take = Math.max(1, Math.min(Number(count) || row.count, row.count));
|
||||
|
||||
const ped = GetPlayerPed(String(src));
|
||||
if (!ped) return;
|
||||
const c = GetEntityCoords(ped);
|
||||
|
||||
row.count -= take;
|
||||
if (row.count <= 0) delete store[container][slot];
|
||||
|
||||
const id = nextDrop++;
|
||||
ground.set(id, {
|
||||
id,
|
||||
item: row.item,
|
||||
count: take,
|
||||
// Carried through the drop. Without it a jacket picked back up is a jacket
|
||||
// that has forgotten which jacket it was, and putting it on does nothing.
|
||||
meta: row.meta || null,
|
||||
coords: [c[0], c[1], c[2] - 0.9],
|
||||
at: GetGameTimer()
|
||||
});
|
||||
|
||||
await persistSlot(charId, container, slot, store[container][slot] || null);
|
||||
push(src);
|
||||
|
||||
emitNet('fiverp-inventory:groundAdd', -1, ground.get(id));
|
||||
});
|
||||
|
||||
onNet('fiverp-inventory:pickup', async (dropId) => {
|
||||
const src = Number(global.source);
|
||||
const charId = charOf(src);
|
||||
if (!charId) return;
|
||||
|
||||
const drop = ground.get(Number(dropId));
|
||||
if (!drop) return;
|
||||
|
||||
// Distance is measured here, from the server's own copy of both positions.
|
||||
const ped = GetPlayerPed(String(src));
|
||||
if (!ped) return;
|
||||
const c = GetEntityCoords(ped);
|
||||
const dx = c[0] - drop.coords[0], dy = c[1] - drop.coords[1], dz = c[2] - drop.coords[2];
|
||||
if (Math.sqrt(dx * dx + dy * dy + dz * dz) > 3.0) return refuse(src, 'Too far away');
|
||||
|
||||
const store = storeOf(charId);
|
||||
if (weightOf(store) + itemWeight(drop.item, drop.count) > MAX_WEIGHT) {
|
||||
return refuse(src, 'Too heavy to carry');
|
||||
}
|
||||
|
||||
const left = addTo(store, drop.item, drop.count, drop.meta);
|
||||
if (left === drop.count) return refuse(src, 'No room');
|
||||
|
||||
// Whatever would not fit stays on the ground rather than vanishing.
|
||||
if (left > 0) {
|
||||
drop.count = left;
|
||||
emitNet('fiverp-inventory:groundAdd', -1, drop);
|
||||
} else {
|
||||
ground.delete(drop.id);
|
||||
emitNet('fiverp-inventory:groundGone', -1, drop.id);
|
||||
}
|
||||
|
||||
await saveAll(charId, store);
|
||||
push(src);
|
||||
});
|
||||
|
||||
// A joining client needs the piles that are already lying around.
|
||||
onNet('fiverp-inventory:groundRequest', () => {
|
||||
emitNet('fiverp-inventory:groundAll', Number(global.source), [...ground.values()]);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------- registry
|
||||
// Other resources add their own items here rather than editing shared/items.
|
||||
// A job's berries belong to the job, not to the inventory: the registry file
|
||||
// stays the contract, and content stays with whatever owns it.
|
||||
global.exports('registerItems', (defs) => {
|
||||
if (!defs || typeof defs !== 'object') return false;
|
||||
|
||||
for (const id of Object.keys(defs)) {
|
||||
const def = defs[id];
|
||||
if (!def || typeof def !== 'object' || !def.label) continue;
|
||||
ITEMS[id] = {
|
||||
label: def.label,
|
||||
description: def.description || '',
|
||||
weight: Number(def.weight) || 0,
|
||||
stack: Math.max(1, Math.floor(Number(def.stack) || 1)),
|
||||
action: def.action || null,
|
||||
model: def.model || null,
|
||||
image: def.image || null,
|
||||
wear: def.wear || null
|
||||
};
|
||||
}
|
||||
|
||||
// Clients need the definitions to draw and to spawn a dropped prop, and a
|
||||
// player already in the world will not get them any other way.
|
||||
emitNet('fiverp-inventory:registry', -1, ITEMS);
|
||||
return true;
|
||||
});
|
||||
|
||||
on('playerJoining', () => {
|
||||
emitNet('fiverp-inventory:registry', Number(global.source), ITEMS);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------ worn
|
||||
// A character arrives dressed by the creator, and those clothes are ped
|
||||
// components rather than items. This adopts them once, so the worn slots show
|
||||
// what is actually on the body instead of five empty boxes.
|
||||
//
|
||||
// The client reads the drawables because only the client has the ped; the
|
||||
// server decides whether to accept them, and after that the rows are the truth.
|
||||
// A slot that is already filled is left alone: re-adopting would resurrect a
|
||||
// shirt the player had taken off.
|
||||
onNet('fiverp-inventory:adoptWorn', async (worn) => {
|
||||
const src = Number(global.source);
|
||||
const charId = charOf(src);
|
||||
if (!charId || !worn || typeof worn !== 'object') return;
|
||||
|
||||
const store = storeOf(charId);
|
||||
const jobs = [];
|
||||
|
||||
for (const slot of WEAR_SLOTS) {
|
||||
if (store.wear[slot]) continue;
|
||||
|
||||
const piece = worn[slot];
|
||||
if (!piece || typeof piece !== 'object') continue;
|
||||
|
||||
const drawable = Math.floor(Number(piece.drawable));
|
||||
const texture = Math.floor(Number(piece.texture));
|
||||
if (!Number.isFinite(drawable) || drawable < 0) continue;
|
||||
|
||||
store.wear[slot] = {
|
||||
item: 'worn_' + slot,
|
||||
count: 1,
|
||||
// Everything needed to put it back on, and nothing the client could
|
||||
// profit from lying about: a drawable index is not a value.
|
||||
meta: {
|
||||
drawable,
|
||||
texture: Number.isFinite(texture) && texture >= 0 ? texture : 0,
|
||||
arms: piece.arms === undefined ? null : Math.floor(Number(piece.arms)),
|
||||
armsTexture: piece.armsTexture === undefined ? null : Math.floor(Number(piece.armsTexture))
|
||||
}
|
||||
};
|
||||
jobs.push(persistSlot(charId, 'wear', slot, store.wear[slot]));
|
||||
}
|
||||
|
||||
if (!jobs.length) return;
|
||||
await Promise.all(jobs);
|
||||
push(src);
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------- exports
|
||||
// What other resources use. All of them take a source and go through the same
|
||||
// checks a player's own request would.
|
||||
global.exports('giveItem', async (src, item, count, meta) => {
|
||||
const charId = charOf(src);
|
||||
if (!charId) return { ok: false, error: 'no character' };
|
||||
if (!itemDef(item)) return { ok: false, error: 'unknown item' };
|
||||
|
||||
const n = Math.max(1, Math.floor(Number(count) || 1));
|
||||
const store = storeOf(charId);
|
||||
|
||||
if (weightOf(store) + itemWeight(item, n) > MAX_WEIGHT) {
|
||||
return { ok: false, error: 'too heavy' };
|
||||
}
|
||||
|
||||
const left = addTo(store, item, n, meta || null);
|
||||
|
||||
await saveAll(charId, store);
|
||||
push(src);
|
||||
return { ok: left === 0, placed: n - left, left };
|
||||
});
|
||||
|
||||
global.exports('removeItem', async (src, item, count) => {
|
||||
const charId = charOf(src);
|
||||
if (!charId) return { ok: false, error: 'no character' };
|
||||
|
||||
const store = storeOf(charId);
|
||||
let left = Math.max(1, Math.floor(Number(count) || 1));
|
||||
|
||||
for (const container of ['bag', 'wear']) {
|
||||
for (const slot of Object.keys(store[container])) {
|
||||
if (left <= 0) break;
|
||||
const row = store[container][slot];
|
||||
if (row.item !== item) continue;
|
||||
|
||||
const take = Math.min(row.count, left);
|
||||
row.count -= take;
|
||||
left -= take;
|
||||
if (row.count <= 0) {
|
||||
delete store[container][slot];
|
||||
await persistSlot(charId, container, slot, null);
|
||||
} else {
|
||||
await persistSlot(charId, container, slot, row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
push(src);
|
||||
return { ok: left === 0, removed: (Number(count) || 1) - left };
|
||||
});
|
||||
|
||||
// A plain function as well as an export: an export cannot call itself, and
|
||||
// hasItem is just a question about this count.
|
||||
function countItem(src, item) {
|
||||
const charId = charOf(src);
|
||||
if (!charId) return 0;
|
||||
const store = storeOf(charId);
|
||||
|
||||
let total = 0;
|
||||
for (const container of ['bag', 'wear']) {
|
||||
for (const slot of Object.keys(store[container])) {
|
||||
if (store[container][slot].item === item) total += store[container][slot].count;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
global.exports('countItem', countItem);
|
||||
global.exports('hasItem', (src, item, count) =>
|
||||
countItem(src, item) >= (Number(count) || 1));
|
||||
|
||||
global.exports('getInventory', (src) => {
|
||||
const charId = charOf(src);
|
||||
return charId ? storeOf(charId) : null;
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
// The item registry, server side.
|
||||
//
|
||||
// Same content as shared/items.lua and for the same reason: the server has to
|
||||
// answer "what does this weigh" and "may this go in that slot" without asking a
|
||||
// client, and the client needs the labels to draw anything at all. Two runtimes
|
||||
// means two copies; they are kept side by side so a change to one is an obvious
|
||||
// omission if the other is not touched.
|
||||
//
|
||||
// Empty on purpose. The shape an entry takes:
|
||||
//
|
||||
// water: {
|
||||
// label: 'Water bottle',
|
||||
// description: 'Half a litre, still.',
|
||||
// weight: 0.5, // kilograms, per unit
|
||||
// stack: 12, // how many fit in one slot; 1 = no stacking
|
||||
// action: 'drink', // null means it cannot be used
|
||||
// model: 'prop_ld_flow_bottle', // the real object, spawned when dropped
|
||||
// image: 'water.png',
|
||||
// wear: null // 'hat'|'top'|'undershirt'|'pants'|'shoes'
|
||||
// }
|
||||
|
||||
// Globals rather than module exports: the FiveM JS runtime has no require(),
|
||||
// so files share state by being listed in order in the manifest.
|
||||
const ITEMS = {};
|
||||
|
||||
// The five garment carriers — see items.lua for why they exist. Kept identical
|
||||
// on purpose: the server decides what may sit in a worn slot, and it cannot ask
|
||||
// a client.
|
||||
[
|
||||
['hat', 'Headwear', 0.3],
|
||||
['top', 'Outerwear', 0.8],
|
||||
['undershirt', 'Undershirt', 0.3],
|
||||
['pants', 'Trousers', 0.6],
|
||||
['shoes', 'Shoes', 0.9]
|
||||
].forEach(([slot, label, weight]) => {
|
||||
ITEMS['worn_' + slot] = {
|
||||
label,
|
||||
description: 'Taken off, it goes in your bag like anything else.',
|
||||
weight,
|
||||
stack: 1,
|
||||
action: null,
|
||||
model: null,
|
||||
image: 'worn_' + slot + '.png',
|
||||
wear: slot
|
||||
};
|
||||
});
|
||||
|
||||
const WEAR_SLOTS = ['hat', 'top', 'undershirt', 'pants', 'shoes'];
|
||||
|
||||
const BAG_SLOTS = 16;
|
||||
const MAX_WEIGHT = 30.0;
|
||||
|
||||
function itemDef(id) {
|
||||
return Object.prototype.hasOwnProperty.call(ITEMS, id) ? ITEMS[id] : null;
|
||||
}
|
||||
|
||||
function itemWeight(id, count) {
|
||||
const def = itemDef(id);
|
||||
if (!def) return 0;
|
||||
return (def.weight || 0) * (count || 1);
|
||||
}
|
||||
|
||||
function itemStack(id) {
|
||||
const def = itemDef(id);
|
||||
return (def && def.stack) || 1;
|
||||
}
|
||||
|
||||
// A clothing item may only sit in the slot it was made for; everything else may
|
||||
// only sit in the bag.
|
||||
function fitsSlot(id, container, slot) {
|
||||
const def = itemDef(id);
|
||||
if (!def) return false;
|
||||
|
||||
if (container === 'wear') {
|
||||
return !!def.wear && def.wear === slot;
|
||||
}
|
||||
const index = Number(slot);
|
||||
return Number.isInteger(index) && index >= 0 && index < BAG_SLOTS;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
-- The item registry.
|
||||
--
|
||||
-- Deliberately empty. Everything below describes the shape an entry has to
|
||||
-- take, because an inventory that accepts loosely-shaped items becomes
|
||||
-- impossible to reason about once there are two hundred of them.
|
||||
--
|
||||
-- The server treats this file as the only truth about what an item is. A client
|
||||
-- can ask to move or use something; it can never tell the server what that
|
||||
-- something weighs or does.
|
||||
--
|
||||
-- ITEMS['water'] = {
|
||||
-- label = 'Water bottle', -- what the player reads
|
||||
-- description = 'Half a litre, still.',-- one or two lines, shown on hover
|
||||
-- weight = 0.5, -- kilograms, per unit
|
||||
-- stack = 12, -- how many fit in one slot (1 = no stacking)
|
||||
-- action = 'drink', -- what using it does; nil means it cannot be used
|
||||
-- model = 'prop_ld_flow_bottle', -- the real object, spawned when dropped
|
||||
-- image = 'water.png', -- html/images/, falls back to initials
|
||||
-- wear = nil -- 'hat'|'top'|'undershirt'|'pants'|'shoes'
|
||||
-- -- set only for clothing; restricts which slot accepts it
|
||||
-- }
|
||||
--
|
||||
-- `action` is a name, not code. What it does lives in the resource that owns
|
||||
-- the effect: fiverp-inventory emits fiverp-inventory:use with the action and
|
||||
-- the item, and whoever cares listens. That keeps food, weapons and paperwork
|
||||
-- out of this file.
|
||||
|
||||
ITEMS = {}
|
||||
|
||||
-- The five garment carriers.
|
||||
--
|
||||
-- These are not content, they are the mechanism: a character arrives already
|
||||
-- dressed by the creator, and those clothes are ped components, not items. One
|
||||
-- carrier per slot holds whichever drawable is on the character, in its meta.
|
||||
-- Without them the worn slots could only ever be empty, because the registry
|
||||
-- has nothing that may sit in them.
|
||||
local function garment(slot, label, weight)
|
||||
ITEMS['worn_' .. slot] = {
|
||||
label = label,
|
||||
description = 'Taken off, it goes in your bag like anything else.',
|
||||
weight = weight,
|
||||
stack = 1,
|
||||
action = nil,
|
||||
model = nil,
|
||||
-- One picture per slot rather than per garment: the registry stores which
|
||||
-- slot a thing belongs in, not what it looks like. A jacket and a shirt
|
||||
-- share worn_top and therefore share the icon.
|
||||
image = 'worn_' .. slot .. '.png',
|
||||
wear = slot
|
||||
}
|
||||
end
|
||||
|
||||
garment('hat', 'Headwear', 0.3)
|
||||
garment('top', 'Outerwear', 0.8)
|
||||
garment('undershirt', 'Undershirt', 0.3)
|
||||
garment('pants', 'Trousers', 0.6)
|
||||
garment('shoes', 'Shoes', 0.9)
|
||||
|
||||
-- Which ped component each slot drives.
|
||||
--
|
||||
-- `prop` means it is a prop index rather than a component; hats are the only
|
||||
-- one of the five. `also` is dragged along with the parent: the arms drawable
|
||||
-- belongs to the torso, and leaving it behind puts sleeves on a bare chest.
|
||||
WEAR_COMPONENTS = {
|
||||
hat = { prop = true, index = 0 },
|
||||
top = { index = 11, also = 3 },
|
||||
undershirt = { index = 8 },
|
||||
pants = { index = 4 },
|
||||
shoes = { index = 6 }
|
||||
}
|
||||
|
||||
-- What the body looks like with that piece removed. Underwear, not nothing:
|
||||
-- these are the drawables the game itself uses for an undressed freemode ped.
|
||||
BARE = {
|
||||
male = {
|
||||
[11] = 15, [3] = 15, [8] = 15, [4] = 21, [6] = 34
|
||||
},
|
||||
female = {
|
||||
[11] = 15, [3] = 15, [8] = 15, [4] = 17, [6] = 35
|
||||
}
|
||||
}
|
||||
|
||||
-- The five places clothing can go, in the order the panel draws them.
|
||||
WEAR_SLOTS = { 'hat', 'top', 'undershirt', 'pants', 'shoes' }
|
||||
|
||||
WEAR_LABELS = {
|
||||
hat = 'Headwear',
|
||||
top = 'Outerwear',
|
||||
undershirt = 'Undershirt',
|
||||
pants = 'Trousers',
|
||||
shoes = 'Shoes'
|
||||
}
|
||||
|
||||
BAG_SLOTS = 16 -- the backpack grid
|
||||
MAX_WEIGHT = 30.0 -- kilograms a character can carry
|
||||
|
||||
-- Everything the rest of the resource asks about an item goes through here, so
|
||||
-- an unknown id fails the same way everywhere instead of at whichever line
|
||||
-- happened to touch it first.
|
||||
function ItemDef(id)
|
||||
return ITEMS[id]
|
||||
end
|
||||
|
||||
function ItemWeight(id, count)
|
||||
local def = ITEMS[id]
|
||||
if not def then return 0.0 end
|
||||
return (def.weight or 0.0) * (count or 1)
|
||||
end
|
||||
|
||||
function ItemStack(id)
|
||||
local def = ITEMS[id]
|
||||
return (def and def.stack) or 1
|
||||
end
|
||||
@@ -8,6 +8,13 @@ version '1.0.0'
|
||||
|
||||
loadscreen 'html/index.html'
|
||||
|
||||
-- Without this the engine owns the loading screen's lifetime and tears it down
|
||||
-- on spawn — which never happens here, because auth holds spawning shut until
|
||||
-- the player has an identity. The screen then outlives its purpose and covers
|
||||
-- the world, and a manual ShutdownLoadingScreenNui() is ignored. Declaring
|
||||
-- manual shutdown makes that call authoritative; fiverp-auth makes it.
|
||||
loadscreen_manual_shutdown 'yes'
|
||||
|
||||
files {
|
||||
'html/index.html',
|
||||
'html/style.css',
|
||||
|
||||
@@ -7,15 +7,6 @@
|
||||
</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>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
/* 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. */
|
||||
Design system: /opt/fivem/server-data/CLAUDE.md — Clean UI / Dark Minimalism.
|
||||
Flat surfaces, solid fills, one accent. No gradients, no blur, no shadows. */
|
||||
|
||||
@font-face {
|
||||
font-family: 'Unbounded';
|
||||
font-style: normal;
|
||||
font-weight: 300 800;
|
||||
font-weight: 300 900;
|
||||
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,
|
||||
@@ -16,7 +15,7 @@
|
||||
@font-face {
|
||||
font-family: 'Unbounded';
|
||||
font-style: normal;
|
||||
font-weight: 300 800;
|
||||
font-weight: 300 900;
|
||||
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,
|
||||
@@ -26,247 +25,177 @@
|
||||
@font-face {
|
||||
font-family: 'Unbounded';
|
||||
font-style: normal;
|
||||
font-weight: 300 800;
|
||||
font-weight: 300 900;
|
||||
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;
|
||||
--bg: rgba(24, 24, 24, 1);
|
||||
--card-bg: rgba(30, 30, 30, 1);
|
||||
--btn-bg: rgba(51, 51, 51, 1);
|
||||
--btn-hover: rgba(70, 70, 70, 1);
|
||||
--accent: rgba(255, 119, 22, 1);
|
||||
--text-primary: rgba(241, 241, 241, 1);
|
||||
--text-secondary: rgba(153, 153, 153, 1);
|
||||
--border: rgba(51, 51, 51, 0.5);
|
||||
|
||||
--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;
|
||||
--sans: 'Unbounded', sans-serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
font-family: var(--sans);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
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%;
|
||||
body {
|
||||
background-color: var(--bg);
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 32px 96px;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────── centre stage */
|
||||
|
||||
.stage {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: min(560px, 78vw);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* App-icon style badge: the one saturated object on an otherwise white page. */
|
||||
/* The mark is a plain square with the accent as its only colour: at this size
|
||||
an outline would read as a hairline artefact on a 1080p projector. */
|
||||
.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;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 16px;
|
||||
background-color: var(--accent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.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);
|
||||
font-size: 24px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 1px;
|
||||
color: var(--bg);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.20em;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-tertiary);
|
||||
margin-bottom: 14px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.wordmark {
|
||||
font-size: clamp(52px, 6.4vw, 84px);
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.045em;
|
||||
font-size: 46px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
line-height: 1;
|
||||
color: var(--fg);
|
||||
margin-bottom: 16px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.tagline {
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: var(--fg-secondary);
|
||||
margin-bottom: 46px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 44px;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------- 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; }
|
||||
/* ───────────────────────────────────────────────────────── progress meter */
|
||||
|
||||
.meter {
|
||||
width: 100%;
|
||||
background-color: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 22px 25px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.meter-top {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.phase {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.09em;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-secondary);
|
||||
}
|
||||
|
||||
.pct {
|
||||
font-size: 34px;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.045em;
|
||||
color: var(--fg);
|
||||
color: var(--accent);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.pct i {
|
||||
font-style: normal;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--fg-tertiary);
|
||||
margin-left: 3px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.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;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background-color: var(--btn-bg);
|
||||
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);
|
||||
height: 100%;
|
||||
width: 0;
|
||||
border-radius: 3px;
|
||||
background-color: var(--accent);
|
||||
transition: width 0.2s 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;
|
||||
font-weight: 400;
|
||||
color: var(--text-secondary);
|
||||
min-height: 14px;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------- dock */
|
||||
/* ──────────────────────────────────────────────────────────── bottom dock */
|
||||
|
||||
.dock {
|
||||
position: fixed;
|
||||
z-index: 2;
|
||||
left: 0; right: 0; bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
padding: 26px 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 26px 32px;
|
||||
}
|
||||
|
||||
.tip {
|
||||
@@ -274,73 +203,117 @@ transition: width 520ms var(--ease);
|
||||
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);
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.tip.is-swapping { opacity: 0; }
|
||||
|
||||
.tip-label {
|
||||
flex: none;
|
||||
padding: 5px 12px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.14em;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
color: var(--bg);
|
||||
background-color: var(--accent);
|
||||
border-radius: 20px;
|
||||
padding: 5px 12px;
|
||||
}
|
||||
|
||||
.tip-body {
|
||||
font-size: 12.5px;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: var(--text-secondary);
|
||||
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;
|
||||
flex-shrink: 0;
|
||||
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;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
background-color: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
padding: 9px 16px;
|
||||
}
|
||||
.badge i { font-style: normal; color: var(--fg-tertiary); }
|
||||
.badge .dot {
|
||||
width: 7px; height: 7px;
|
||||
|
||||
.badge i {
|
||||
font-style: normal;
|
||||
color: var(--text-secondary);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
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); }
|
||||
background-color: var(--accent);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: .001ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: .001ms !important;
|
||||
}
|
||||
/* ───────────────────────────────────────────────────────────── entrances */
|
||||
|
||||
.fade-up {
|
||||
animation: fade-up 0.4s ease both;
|
||||
}
|
||||
|
||||
.delay-1 { animation-delay: 0.06s; }
|
||||
.delay-2 { animation-delay: 0.12s; }
|
||||
.delay-3 { animation-delay: 0.18s; }
|
||||
.delay-4 { animation-delay: 0.24s; }
|
||||
|
||||
@keyframes fade-up {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
/* A still frame for screenshots: kills entrance animation so a headless
|
||||
capture does not photograph the first, invisible frame. */
|
||||
body.no-anim *,
|
||||
body.no-anim *::before,
|
||||
body.no-anim *::after {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
opacity: 1 !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────── custom scrollbar */
|
||||
/* The native bar is hidden rather than styled: CEF keeps drawing a stepper stub
|
||||
at each end no matter what ::-webkit-scrollbar-button is told. scrollbar.js
|
||||
draws the replacement, which is a plain element and therefore ours. */
|
||||
.scroll-y {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.scroll-y::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sbar {
|
||||
position: fixed;
|
||||
width: 4px;
|
||||
border-radius: 20px;
|
||||
background-color: var(--btn-bg);
|
||||
z-index: 50;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sbar__thumb {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border-radius: 20px;
|
||||
background-color: var(--text-secondary);
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.sbar:hover .sbar__thumb,
|
||||
.sbar.is-held .sbar__thumb {
|
||||
background-color: var(--accent);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
-- Toasts.
|
||||
--
|
||||
-- exports['fiverp-notify']:show('Berries', '3 × Blackberries', 'good')
|
||||
-- exports['fiverp-notify']:show(nil, 'Too far away', 'bad')
|
||||
--
|
||||
-- kind is 'good' | 'bad' | 'info'. Wrap a number in **stars** to set it apart
|
||||
-- from the sentence around it.
|
||||
|
||||
local function show(title, text, kind, duration)
|
||||
if not text or text == '' then return end
|
||||
SendNUIMessage({
|
||||
action = 'show',
|
||||
data = { title = title, text = text, kind = kind or 'info', duration = duration }
|
||||
})
|
||||
end
|
||||
|
||||
exports('show', show)
|
||||
exports('clear', function() SendNUIMessage({ action = 'clear' }) end)
|
||||
|
||||
RegisterNetEvent('fiverp-notify:show', function(payload)
|
||||
if type(payload) ~= 'table' then return end
|
||||
show(payload.title, payload.text, payload.kind, payload.duration)
|
||||
end)
|
||||
@@ -0,0 +1,21 @@
|
||||
fx_version 'cerulean'
|
||||
game 'gta5'
|
||||
|
||||
name 'fiverp-notify'
|
||||
author 'FiveRP'
|
||||
description 'Toasts, so the chat can stay a place people talk'
|
||||
version '0.1.0'
|
||||
|
||||
ui_page 'html/index.html'
|
||||
|
||||
client_scripts { 'client/cl_notify.lua' }
|
||||
server_scripts { 'server/sv_notify.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',
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/* FiveRP notify — the toasts.
|
||||
Deliberately dumb: it renders what it is handed and forgets it on a timer.
|
||||
Anything that has to be remembered belongs in the chat, which is why the two
|
||||
are separate resources rather than one with a flag. */
|
||||
|
||||
const stack = document.getElementById('stack');
|
||||
const MAX = 5;
|
||||
|
||||
function escape(text) {
|
||||
return String(text).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
// **bold** for the one number in a line that is worth reading on its own.
|
||||
function markup(text) {
|
||||
return escape(text).replace(/\*\*(.+?)\*\*/g, '<b>$1</b>');
|
||||
}
|
||||
|
||||
function drop(el) {
|
||||
if (!el || el.classList.contains('is-going')) return;
|
||||
el.classList.add('is-going');
|
||||
setTimeout(() => el.remove(), 220);
|
||||
}
|
||||
|
||||
function show(data) {
|
||||
const kind = ['good', 'bad', 'info'].includes(data.kind) ? data.kind : 'info';
|
||||
|
||||
const el = document.createElement('div');
|
||||
el.className = 'toast toast--' + kind;
|
||||
el.innerHTML =
|
||||
(data.title ? `<div class="toast__title">${escape(data.title)}</div>` : '') +
|
||||
`<div class="toast__text">${markup(data.text || '')}</div>`;
|
||||
|
||||
stack.appendChild(el);
|
||||
|
||||
// Oldest first: a burst should push the stale ones out rather than grow past
|
||||
// the edge of the screen.
|
||||
while (stack.childElementCount > MAX) drop(stack.firstElementChild);
|
||||
|
||||
const life = Math.max(1200, Math.min(15000, Number(data.duration) || 4200));
|
||||
setTimeout(() => drop(el), life);
|
||||
}
|
||||
|
||||
window.addEventListener('message', (event) => {
|
||||
const msg = event.data || {};
|
||||
if (msg.action === 'show') show(msg.data || {});
|
||||
if (msg.action === 'clear') stack.innerHTML = '';
|
||||
});
|
||||