322 lines
11 KiB
Markdown
322 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.
|