Ein Computer im Spiel. Man tritt an ein Terminal, drueckt E, und bekommt eine
Oberflaeche mit Mail, Browser, Adressbuch, Kalender und weiteren Anwendungen.
Andere Resources haengen sich als App ein.
Auf ESX umgestellt:
- Fuenf harte Abhaengigkeiten zeigten auf Resources, die es hier nicht gibt.
Eine fehlende Abhaengigkeit verhindert den Start vollstaendig - die
Resource waere nie hochgekommen.
- Die Bruecke zum Framework neu geschrieben; Charakterdaten, Aktenverwaltung
und Immobilien ausgebaut, weil die zugehoerigen Systeme fehlen.
- pc_live_devices fehlte im Schema, wird vom Code aber gelesen. Ohne die
Tabelle ist kein fester PC ansprechbar. Ein PC braucht ausserdem zwei
Eintraege mit derselben Kennung: Standort in der Datenbank, Kamerafahrt in
der config.lua.
Mail ueberarbeitet:
- Postfaecher haengen an einer Anmeldung statt an der Person. Mehrere Leute
koennen dasselbe Firmenpostfach gleichzeitig offen haben.
- Ordner gehoeren zum Postfach, nicht zur Person. Eine Mail an ein geteiltes
Postfach wird je Empfaenger einmal gespeichert; damit das Einsortieren
trotzdem fuer alle gilt, tragen alle Kopien einer Zustellung dieselbe
Kennung. Gelesen und geloescht bleibt persoenlich - das ist keine
Eigenschaft der Nachricht, sondern der Person.
- Signaturen gehoeren ebenfalls zum Postfach.
- Kalender koennen privat, geteilt oder oeffentlich sein. Ein oeffentlicher
Termin haengt bewusst auch an einem Postfach, sonst koennte ihn spaeter
niemand mehr aendern oder absagen.
Neue Apps: Webhosting, Bleeter und dessen Verwaltung.
Behoben:
- prompt(), confirm() und alert() froren das NUI ein. FiveMs CEF hat keinen
Handler fuer die eingebauten Browserdialoge - der Aufruf oeffnet nichts und
kehrt nie zurueck. Ersetzt durch eigene Dialoge.
- Die Fenster tragen data-wid, keine id. Wer sie mit getElementById sucht,
schreibt ins Leere und das Fenster bleibt leer.
Enthaelt README.md mit Einrichtung und PLUGINS.md: eine Anleitung, wie man eine
eigene App baut und einhaengt, mit vollstaendigem Beispiel von der Tabelle bis
zum Schreibtischsymbol.
321 lines
11 KiB
Markdown
321 lines
11 KiB
Markdown
# Eigene Apps für pc-live
|
||
|
||
Eine App ist kein eigener Ordner und kein Plugin-Format — sie besteht aus vier
|
||
Teilen, die an vier Stellen eingetragen werden. Diese Anleitung baut eine
|
||
vollständige App von Null auf: **Notizen**, mit Datenbank, Server, Oberfläche
|
||
und Schreibtischsymbol.
|
||
|
||
Am Ende steht ein Gerüst zum Kopieren.
|
||
|
||
---
|
||
|
||
## Wie eine App aufgebaut ist
|
||
|
||
```
|
||
NUI (Browser) Client (Lua) Server (Lua)
|
||
┌──────────────────┐ ┌────────────────────┐ ┌────────────────────┐
|
||
│ nui/js/apps/ │ │ client/notizen.lua │ │ server/notizen.lua │
|
||
│ notizen.js │ │ │ │ │
|
||
│ │ │ │ │ │
|
||
│ fetchNui('op') ──┼─────►│ RegisterNUICallback├───►│ RegisterNetEvent │
|
||
│ │ │ │ │ │ │
|
||
│ window.message ◄─┼──────┤ SendNUIMessage │◄───┤ TriggerClientEvent │
|
||
└──────────────────┘ └────────────────────┘ └────────────────────┘
|
||
```
|
||
|
||
Das NUI kann **nur** Callbacks der eigenen Resource aufrufen — deshalb der
|
||
Umweg über den Client. Antworten laufen umgekehrt über `SendNUIMessage` und
|
||
landen im Nachrichtenverteiler in `nui/js/desktop.js`.
|
||
|
||
---
|
||
|
||
## Schritt 1 — Tabelle
|
||
|
||
`sql/notizen.sql`:
|
||
|
||
```sql
|
||
CREATE TABLE IF NOT EXISTS `pc_live_notizen` (
|
||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||
`identifier` VARCHAR(120) NOT NULL,
|
||
`titel` VARCHAR(120) NOT NULL DEFAULT '',
|
||
`inhalt` TEXT NOT NULL,
|
||
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||
ON UPDATE CURRENT_TIMESTAMP,
|
||
PRIMARY KEY (`id`),
|
||
KEY `idx_identifier` (`identifier`)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||
```
|
||
|
||
---
|
||
|
||
## Schritt 2 — Server
|
||
|
||
`server/notizen.lua`:
|
||
|
||
```lua
|
||
-- Notizen. Jede Notiz gehoert genau einer Person.
|
||
|
||
RegisterNetEvent('pc-live:server:getNotizen', function()
|
||
local src = source
|
||
local user = ActiveSessions[src] -- wer sitzt am PC?
|
||
if not user then return end
|
||
|
||
local rows = MySQL.query.await(
|
||
'SELECT id, titel, inhalt FROM pc_live_notizen WHERE identifier = ? ORDER BY id DESC',
|
||
{ user.identifier }) or {}
|
||
|
||
TriggerClientEvent('pc-live:client:notizen', src, rows)
|
||
end)
|
||
|
||
RegisterNetEvent('pc-live:server:saveNotiz', function(data)
|
||
local src = source
|
||
local user = ActiveSessions[src]
|
||
if not user then return end
|
||
if type(data) ~= 'table' then return end
|
||
|
||
-- Immer beschneiden: was aus dem NUI kommt, hat der Spieler in der Hand.
|
||
local titel = Utils.Sanitize(tostring(data.titel or '')):sub(1, 120)
|
||
local inhalt = Utils.Sanitize(tostring(data.inhalt or '')):sub(1, 5000)
|
||
if titel == '' then return end
|
||
|
||
local id = tonumber(data.id)
|
||
if id then
|
||
-- Die Bedingung auf identifier ist die Besitzpruefung: ohne sie
|
||
-- koennte jemand mit einer fremden Id die Notiz eines anderen aendern.
|
||
MySQL.update.await(
|
||
'UPDATE pc_live_notizen SET titel = ?, inhalt = ? WHERE id = ? AND identifier = ?',
|
||
{ titel, inhalt, id, user.identifier })
|
||
else
|
||
MySQL.insert.await(
|
||
'INSERT INTO pc_live_notizen (identifier, titel, inhalt) VALUES (?,?,?)',
|
||
{ user.identifier, titel, inhalt })
|
||
end
|
||
|
||
TriggerEvent('pc-live:server:getNotizen') -- geht nicht! siehe unten
|
||
end)
|
||
```
|
||
|
||
> **Falle:** `TriggerEvent` auf ein *Netzwerk*ereignis funktioniert nicht wie
|
||
> gedacht — `source` ist dort nicht der Spieler, der Handler bricht ab. Baue
|
||
> stattdessen eine Funktion, die beide Wege benutzen:
|
||
>
|
||
> ```lua
|
||
> local function sendeNotizen(src, identifier) … end
|
||
> ```
|
||
|
||
---
|
||
|
||
## Schritt 3 — Client als Brücke
|
||
|
||
`client/notizen.lua`:
|
||
|
||
```lua
|
||
-- Die Liste erlaubter Aktionen steht fest. Ohne sie koennte das NUI jedes
|
||
-- beliebige Serverereignis ausloesen.
|
||
local OPS = {
|
||
getNotizen = 'pc-live:server:getNotizen',
|
||
saveNotiz = 'pc-live:server:saveNotiz',
|
||
}
|
||
|
||
RegisterNUICallback('notizen', function(data, cb)
|
||
cb({}) -- sofort bestaetigen
|
||
|
||
local event = OPS[tostring(data.op or '')]
|
||
if not event then return end
|
||
|
||
TriggerServerEvent(event, data.payload or {})
|
||
end)
|
||
|
||
RegisterNetEvent('pc-live:client:notizen', function(rows)
|
||
SendNUIMessage({ action = 'notizen_data', data = rows or {} })
|
||
end)
|
||
```
|
||
|
||
---
|
||
|
||
## Schritt 4 — Oberfläche
|
||
|
||
`nui/js/apps/notizen.js`:
|
||
|
||
```js
|
||
const NotizenApp = (() => {
|
||
const WIN_ID = 'app-notizen';
|
||
const el = ICWebRender.el; // kleiner Helfer aus webhosting.js
|
||
|
||
let notizen = [];
|
||
let winEl = null;
|
||
|
||
function open() {
|
||
const win = WindowManager.create({
|
||
id: WIN_ID, title: 'Notizen', icon: '📝', width: 720, height: 520,
|
||
content: '<div class="app-content" id="notizen-main"></div>',
|
||
});
|
||
if (!win) return;
|
||
|
||
winEl = win; // merken! siehe Falle unten
|
||
fetchNui('notizen', { op: 'getNotizen' });
|
||
render();
|
||
}
|
||
|
||
function root(id) {
|
||
if (!winEl || !winEl.isConnected) {
|
||
winEl = document.querySelector('[data-wid="' + WIN_ID + '"]');
|
||
}
|
||
return winEl ? winEl.querySelector('#' + id) : null;
|
||
}
|
||
|
||
function render() {
|
||
const main = root('notizen-main');
|
||
if (!main) return;
|
||
main.replaceChildren();
|
||
|
||
notizen.forEach(n => {
|
||
const karte = el('div', 'wh-row');
|
||
// Spielerinhalte immer ueber textContent – nie ueber innerHTML.
|
||
karte.appendChild(el('div', 'wh-row-title', n.titel));
|
||
karte.appendChild(el('div', 'wh-row-sub', n.inhalt));
|
||
main.appendChild(karte);
|
||
});
|
||
|
||
const neu = el('button', 'icweb-btn-primary', 'Neue Notiz');
|
||
neu.addEventListener('click', () => {
|
||
// Kein prompt() – siehe Fallen.
|
||
ICWebRender.promptText('Neue Notiz', { placeholder: 'Titel' }, (titel) => {
|
||
fetchNui('notizen', { op: 'saveNotiz', payload: { titel, inhalt: '' } });
|
||
});
|
||
});
|
||
main.appendChild(neu);
|
||
}
|
||
|
||
/** Vom Nachrichtenverteiler in desktop.js. */
|
||
function onData(rows) {
|
||
notizen = rows || [];
|
||
render();
|
||
}
|
||
|
||
return { open, onData };
|
||
})();
|
||
```
|
||
|
||
---
|
||
|
||
## Schritt 5 — Einhängen
|
||
|
||
**`fxmanifest.lua`**
|
||
|
||
```lua
|
||
client_scripts { …, 'client/notizen.lua' }
|
||
server_scripts { …, 'server/notizen.lua' }
|
||
files { …, 'nui/js/apps/notizen.js' }
|
||
```
|
||
|
||
**`nui/index.html`** — vor `desktop.js`, und nach `webhosting.js`, falls du
|
||
`ICWebRender` benutzt:
|
||
|
||
```html
|
||
<script src="js/apps/notizen.js"></script>
|
||
```
|
||
|
||
**`nui/js/desktop.js`** — Symbol und Antwortkanal:
|
||
|
||
```js
|
||
const APP_META = {
|
||
…
|
||
'pc.notizen': { name: 'Notizen', icon: '📝', open: () => NotizenApp.open() },
|
||
};
|
||
|
||
// im Nachrichtenverteiler:
|
||
case 'notizen_data': NotizenApp.onData(data); break;
|
||
```
|
||
|
||
**`server/apps.lua`** — damit die App auf dem Schreibtisch landet:
|
||
|
||
```lua
|
||
AppRegistry.Register({
|
||
app_id = "pc.notizen",
|
||
name = "Notizen",
|
||
icon = "📝",
|
||
version = "1.0.0",
|
||
category = "utility",
|
||
permissions = {},
|
||
dependencies = {},
|
||
default = true, -- true = bei jedem vorinstalliert
|
||
description = "Notizen schreiben und wiederfinden.",
|
||
price = 0, -- > 0: muss im Store gekauft werden
|
||
})
|
||
```
|
||
|
||
Fertig. Neu starten, PC öffnen — das Symbol ist da.
|
||
|
||
---
|
||
|
||
## Aus einer anderen Resource
|
||
|
||
Der Serverteil kann auch woanders liegen. Die Registrierung geht dann über
|
||
einen Export:
|
||
|
||
```lua
|
||
exports['pc-live']:RegisterApp({ app_id = 'meine.app', name = 'Meine App', … })
|
||
```
|
||
|
||
Die **Oberfläche muss trotzdem in `pc-live/nui/`** liegen: Das NUI lädt nur
|
||
Dateien der eigenen Resource. Deine Resource liefert also die Serverlogik, und
|
||
in pc-live liegen Brücke und Oberfläche. So machen es `ic-web` und `bleeter`.
|
||
|
||
Der Client deiner Resource kann direkt mit deinem Server sprechen — pc-live
|
||
muss nichts weiterreichen. Nur der Weg **ins** NUI führt über pc-live, weil
|
||
`SendNUIMessage` immer im eigenen Fenster landet.
|
||
|
||
---
|
||
|
||
## Fallen, die Zeit kosten
|
||
|
||
**Das Fenster hat keine `id`.** `WindowManager.create` setzt `data-wid`.
|
||
`document.getElementById(WIN_ID)` findet nichts, deine App schreibt ins Leere
|
||
und das Fenster bleibt leer. Merke dir das zurückgegebene Element oder suche
|
||
mit `document.querySelector('[data-wid="…"]')`.
|
||
|
||
**`prompt()`, `confirm()` und `alert()` frieren das NUI ein.** FiveMs CEF hat
|
||
keinen Handler für die eingebauten Browserdialoge — der Aufruf öffnet nichts
|
||
und kehrt nie zurück. Nimm `ICWebRender.promptText(titel, optionen, rückruf)`
|
||
und `ICWebRender.confirmBox(titel, text, rückruf, optionen)`.
|
||
|
||
**Kein `innerHTML` für Spielerinhalte.** Das NUI ist derselbe Kontext wie der
|
||
PC: Wer dort Markup einschleusen kann, kann auch dessen Callbacks aufrufen.
|
||
`textContent` und `setAttribute`, immer.
|
||
|
||
**`TINYINT(1)` ist mal `1`, mal `true`.** `oxmysql` wandelt es bei
|
||
`query`/`single` in einen Boolean, bei `prepare` nicht. `== 1` geht deshalb
|
||
irgendwann schief — und zwar still. Schreib dir einen Helfer wie `ICWeb.Bool`.
|
||
|
||
**Keine Datenbankarbeit in `onResourceStop`.** Ein `await` wartet dort auf eine
|
||
Antwort, die niemand mehr entgegennimmt: „Execution of function reference in
|
||
script host failed". Aufräumen gehört in den Start — das erwischt auch den
|
||
Absturz, den ein Stop-Handler nie sieht.
|
||
|
||
**`TriggerEvent` auf ein Netzwerkereignis** hat kein `source`. Zieh die Logik
|
||
in eine Funktion und ruf die von beiden Seiten.
|
||
|
||
**Eigene Stile kapseln.** Bindest du eine fremde CSS-Datei ein, färbt sie
|
||
womöglich den ganzen PC. Setz deine Regeln unter eine eigene Wurzelklasse —
|
||
siehe `nui/css/erzeuge-bleeter-css.py`, das genau das mechanisch erledigt.
|
||
|
||
---
|
||
|
||
## Was schon da ist
|
||
|
||
Bevor du etwas nachbaust:
|
||
|
||
| | aus | |
|
||
|---|---|---|
|
||
| `ICWebRender.el(tag, klasse, text)` | `webhosting.js` | Element bauen |
|
||
| `ICWebRender.modal(titel, aufbau, breit)` | `webhosting.js` | Dialograhmen |
|
||
| `ICWebRender.promptText` / `confirmBox` | `webhosting.js` | Eingabe und Rückfrage |
|
||
| `Desktop.showNotification(text)` | `desktop.js` | kurze Meldung |
|
||
| `WindowManager.create/close/focus` | `windowManager.js` | Fenster |
|
||
| `fetchNui(name, daten)` | `desktop.js` | Aufruf an den Client |
|
||
|
||
Für das Aussehen kannst du die vorhandenen Klassen benutzen: `wh-page`,
|
||
`wh-row`, `wh-field`, `wh-label`, `icweb-input`, `icweb-btn-primary`,
|
||
`icweb-btn-ghost`, `icweb-btn-danger`, `wh-tag`, `wh-hint`. Dann fügt sich
|
||
deine App ohne eigenes CSS ein.
|