Documentation · Développeurs

Interface (NUI)

L'interface unique, ajouter une vue, couleurs du serveur, menus.

SommaireInterface (NUI)

Une seule interface pour tout le serveur

Toute l'interface du Socle (HUD, menus, notifications, garage, tablettes, écrans d'ox_lib…) est une seule page web : resources/socle/ui/dist/index.html, déclarée par le ui_page du manifeste de socle. Chaque écran est une vue de cette page, affichée ou masquée à la demande.

Pourquoi une seule :

  • chaque ui_page est une page web complète que chaque joueur charge à chaque connexion : une page par ressource, c'est autant de mémoire et de temps de chargement en plus ;
  • le focus (souris, clavier) est géré à un seul endroit (Core.Focus) : deux pages qui se disputent SetNuiFocus donnent des curseurs bloqués ;
  • le thème (couleur du serveur, apparence réglée au panel) s'applique partout d'un coup.

Règle pour ton code : n'ajoute jamais de `ui_page` à une ressource. Ajoute une vue à l'interface du Socle, ou utilise une vue existante.

Avant d'écrire une vue : celles qui existent déjà

Beaucoup de besoins sont couverts sans écrire de React ni recompiler :

BesoinSans nouvelle vue
Formulaire de saisielib.inputDialog(...) d'ox_lib (rendu par le Socle) ou exports.socle:requestTextInput({ title, default })
Confirmation avec curseurlib.alertDialog(...) ou exports.socle:createPrompt(titre, texte, texte2, fn)
Oui / Non sans curseurexports.socle:Decide({ ... })
Menu de choixlib.registerContext + lib.showContext, lib.showMenu, lib.registerRadial
Mini-jeu d'adresselib.skillCheck(...)
Barre de progressionlib.progressBar(...) ou exports.socle:StartProgress({ label, duration })
Notificationexports.socle:Notify(...), lib.notify(...), ESX.ShowNotification(...)
Texte d'aide, barre de touchesexports.socle:HelpText(...), exports.socle:KeyHints(...), lib.showTextUI(...)
Panneau d'informationsexports.socle:drawInfo(titre, éléments)
Étiquette flottanteexports.socle:setWorldTag(id, { coords, title, ... })

Les menus ox_lib relayés

ox_lib contient des retouches marquées [Socle] : quand la ressource socle est démarrée, ses écrans ne s'affichent plus dans la page d'ox_lib mais dans l'interface Socle, avec le style du serveur. Ton code appelle l'API ox_lib normale :

-- client : un menu contextuel ox_lib, affiché par l'interface Socle
lib.registerContext({
    id = "prime_menu",
    title = Core.t("prime.menu.titre"),
    options = {
        { title = Core.t("prime.menu.reclamer"), icon = "coins", onSelect = function() reclamer() end },
        { title = Core.t("prime.menu.infos"), icon = "circle-info", onSelect = function() infos() end },
    },
})
lib.showContext("prime_menu")

-- saisie : la logique reste dans ox_lib, l'affichage passe par le Socle
local valeurs = lib.inputDialog(Core.t("prime.saisie.titre"), {
    { type = "number", label = Core.t("prime.saisie.montant"), min = 1, max = 1000 },
})

Les vues correspondantes du Socle s'appellent oxinput, oxalert, oxskill, oxtextui, oxcontext, oxmenu et oxradial. Les menus natifs du Socle (Core.Menu.create, utilisés par les menus F5 et F10) passent eux aussi par cette interface, vue menu.

Comment le Lua parle à l'interface

Afficher, masquer, envoyer

-- client : affiche la vue « prime » et lui passe des données
Core.nui("interface:show", { id = "prime", data = { montant = 500, jour = "2026-10-11" } })

-- masque la vue
Core.nui("interface:hide", { id = "prime" })

-- message libre vers l'interface (reçu par useNuiEvent("prime:maj", ...))
Core.nui("prime:maj", { data = { montant = 750 } })

Core.nui(action, données) attend que l'interface soit chargée avant d'envoyer : tu peux l'appeler dès le démarrage, dans un thread.

Recevoir une réponse de l'interface

-- client : Core.rnui attend le chargement de l'interface, d'où le thread
CreateThread(function()
    Core.rnui("prime:fermer", function(data, respond)
        Core.Focus.release("prime")
        Core.nui("interface:hide", { id = "prime" })
        respond({ ok = true })   -- facultatif : sans respond, l'interface reçoit { status = "received" }
    end)
end)

Une erreur dans le gestionnaire est attrapée : l'interface reçoit { status = "error", error = … }. Ce que l'interface envoie est une donnée du client : si l'action touche à la valeur, elle repart vers le serveur par un callback validé (voir Référence API).

Le focus

-- Core.Focus.acquire(id, curseur, garderLesTouchesDuJeu)
Core.Focus.acquire("prime", true, false)   -- curseur visible, le jeu ne reçoit plus le clavier
Core.Focus.release("prime")

Les demandes de focus s'empilent : quand ta vue relâche le sien, le focus revient à l'écran ouvert avant (un menu, par exemple). N'appelle jamais SetNuiFocus directement.

Ajouter une vue React

L'interface est une application React 18 + Vite, dans resources/socle/ui/.

ui/
  index.html, loadscreen.html
  src/
    main.jsx          ← registre des vues
    nui.js            ← onNui, useNuiEvent, fetchNui, postNui
    interface.js      ← useInterfaceShow, showInterface, hideInterface
    i18n.js           ← useLocale (textes du jeu)
    style.css         ← variables CSS du thème
    components/
      kit/            ← Sheet.jsx, KeyBar.jsx : composants partagés
      <dossiers des vues>
  dist/               ← le build chargé par le jeu

1. Le composant

ui/src/components/prime/Prime.jsx :

import { useEffect, useState } from "react";
import { useInterfaceShow } from "../../interface";
import { postNui, useNuiEvent } from "../../nui";
import { useLocale } from "../../i18n";
import styles from "./Prime.module.css";

// Ticket de la prime de minuit (exemple de documentation).
export default function Prime() {
  const t = useLocale();
  const [ticket, setTicket] = useState(null);

  // données passées par Core.nui("interface:show", { id = "prime", data = ... })
  useInterfaceShow("prime", (data) => setTicket(data));
  // message libre Core.nui("prime:maj", { data = ... })
  useNuiEvent("prime:maj", (data) => setTicket((old) => ({ ...old, ...data })));

  // Échap ferme la vue ; le Lua relâche le focus et masque la vue
  useEffect(() => {
    const onKey = (event) => event.key === "Escape" && postNui("prime:fermer");
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, []);

  if (!ticket) return null;

  return (
    <div className={styles.ticket}>
      <span className={styles.titre}>{t("prime.ui.titre")}</span>
      <strong className={styles.montant}>{t("prime.ui.montant", [ticket.montant])}</strong>
      <button className={styles.bouton} onClick={() => postNui("prime:fermer")}>
        {t("prime.ui.fermer")}
      </button>
    </div>
  );
}

Les outils disponibles :

FonctionFichierRôle
useInterfaceShow(id, fn)interface.jsreçoit les data de interface:show (mises en attente si la vue n'est pas encore montée)
useNuiEvent(action, fn)nui.jsreçoit les messages Core.nui(action, { data })
fetchNui(nom, data)nui.jsappelle Core.rnui(nom, …) et renvoie la réponse (promesse)
postNui(nom, data)nui.jsmême chose sans attendre la réponse
useLocale()i18n.jsrenvoie t(clé, [valeurs]) ; les textes viennent des fichiers i18n/langs du jeu

Les données arrivent telles que le Lua les envoie ; une table Lua vide arrive sous forme de tableau vide [].

2. Les couleurs : uniquement des variables CSS

ui/src/components/prime/Prime.module.css :

/* Jamais de couleur en dur : tout suit le thème du serveur */
.ticket {
  position: absolute;
  right: 2vh;
  top: 40%;
  padding: 1.6vh 2vh;
  background: rgb(var(--color-surface));
  border-left: 0.4vh solid rgb(var(--color-primary));
  color: #fff;
}
.titre { color: rgb(var(--color-muted)); font-size: 1.3vh; text-transform: uppercase; }
.montant { display: block; font-size: 3vh; color: rgb(var(--color-primary)); }
.bouton {
  margin-top: 1vh;
  background: rgb(var(--color-primary));
  color: rgb(var(--color-on-primary)); /* noir ou blanc selon la clarté de la couleur */
}

Les variables du thème contiennent trois nombres R G B, à utiliser avec rgb(var(--…)) (et une opacité si besoin : rgb(var(--color-primary) / 0.5)). Les principales, définies dans style.css :

VariableRôle
--color-primarycouleur du serveur (panel Informations › Identité › Couleur, colors.accent) ; toujours unie
--fill-primary-imagedégradé de la couleur du serveur (linear-gradient(…)), ou none quand elle est unie (colors.accentGradient)
--color-on-primarytexte lisible sur la couleur du serveur, calculé automatiquement (sur la moyenne des deux couleurs en mode dégradé)
--color-successréussite
--color-dark, --color-surface, --color-surface-light, --color-panel, --color-card, --color-control, --color-buttonfonds
--color-mutedtexte secondaire

Les classes Tailwind du projet utilisent les mêmes variables (bg-primary, text-on-primary, bg-surface…). La classe bg-primary pose déjà le dégradé éventuel par-dessus la couleur unie. Dans un module CSS, pour une surface pleine qui doit suivre le dégradé :

.bouton {
  background-color: rgb(var(--color-primary));
  background-image: var(--fill-primary-image, none);
  color: rgb(var(--color-on-primary));
}

Garde --color-primary seul pour les textes, bordures et filets : c'est la règle suivie par toutes les vues livrées.

Comment le thème arrive :

  1. au chargement, le Lua envoie ui:load avec la couleur du serveur ; l'interface pose --color-primary et --color-on-primary ;
  2. ui:accent envoie la couleur et son dégradé éventuel dès que les réglages sont chargés, puis à chaque changement au panel : --color-primary, --fill-primary-image et --color-on-primary sont mis à jour à chaud ;
  3. la page Apparence du panel envoie d'autres variables CSS et du CSS libre (ui/dist/panel-theme.js) : ils s'appliquent par-dessus, sans rebuild.

Écris donc ta vue uniquement avec ces variables : elle suivra la couleur et l'apparence de chaque serveur.

3. Le registre

Dans ui/src/main.jsx, importe ta vue et ajoute-la au tableau interfaces :

import Prime from "./components/prime/Prime";

const interfaces = [
  // … vues existantes …
  {
    id: "prime",                 // l'id utilisé par Core.nui("interface:show", { id = "prime" })
    name: "Prime de minuit",     // nom affiché dans l'outil de développement
    component: Prime,
    defaultVisible: false,       // masquée au départ
    devData: { montant: 500 },   // facultatif : données d'essai pour l'outil de développement
  },
];

4. Les textes

Ajoute les clés utilisées par la vue (prime.ui.titre, prime.ui.montant avec un %s, prime.ui.fermer) dans les quatre fichiers i18n/langs/*.json, comme expliqué dans Créer ton module. L'interface reçoit le dictionnaire de la langue active au chargement, et à nouveau quand un texte est modifié au panel.

5. Le build

cd resources/socle/ui
npm install
npm run build

npm run build régénère ui/dist/ (page principale et écran de chargement). Le jeu ne charge que dist : sans build, ta vue n'existe pas en jeu. Redémarre ensuite la ressource socle sur ton serveur de développement, ou le serveur entier.

6. Le Lua qui l'ouvre

-- client : afficher le ticket après une prime versée
local function afficherTicket(montant)
    Core.Focus.acquire("prime", true, false)
    Core.nui("interface:show", { id = "prime", data = { montant = montant } })
end

CreateThread(function()
    Core.rnui("prime:fermer", function()
        Core.Focus.release("prime")
        Core.nui("interface:hide", { id = "prime" })
    end)
end)

Travailler hors du jeu

cd resources/socle/ui
npm run dev

Vite sert l'interface dans ton navigateur. En développement uniquement :

  • F2 ouvre l'outil de développement : la liste des vues, à afficher ou masquer, avec leurs devData ;
  • ?accent=255,0,0 dans l'adresse rejoue la couleur du serveur, pour vérifier que toute ta vue la suit ; ajoute &accent2=236,72,153&angle=90 pour la rejouer en dégradé ;
  • les textes du jeu sont servis depuis i18n/langs, et les fichiers de ui/dev-fixtures/ (états de démonstration) ne sont jamais copiés dans dist.

Bundle léger

L'interface se charge à chaque connexion de chaque joueur. Garde-la légère :

  • aucun CDN ni ressource externe : polices, images et sons sont locaux (ui/public/, assets/ du Socle ou médiathèque du panel) ;
  • pas de nouvelle dépendance npm sans raison forte : React 18 suffit pour une vue ;
  • des images optimisées (WebP), pas de vidéo dans le bundle.

Les mises à jour et ta vue

Conséquences pratiques :

  • préfère les vues existantes (tableau en haut de page) chaque fois que c'est possible ;
  • garde tes vues dans leur propre dossier ui/src/components/<ton_module>/ et note les lignes ajoutées à main.jsx : c'est tout ce qu'il faut remettre ;
  • avant de recompiler après une mise à jour, assure-toi que ton ui/src correspond à la version installée.