Retour aux projets

2025 · actif

NoctisUI

Une librairie d'interface utilisateur pour Minecraft, qui permet de créer des interfaces graphiques personnalisées pour les mods Minecraft.

JavaGLSLMinecraft APIForge API
Voir le projet en ligne ↗

Overview

NoctisUI est une librairie d'interface utilisateur pour Minecraft, qui permet de créer des interfaces graphiques personnalisées pour les mods Minecraft.

Ce projet est né de la volonté de créer une interface utilisateur plus moderne et plus flexible pour mon projet de serveur Minecraft, en utilisant les dernières versions du jeu.

Le commencement

Le projet a commencé en 2025, avec l'idée de fournir aux développeurs de mods Minecraft un outil puissant pour créer des interfaces graphiques personnalisées et attrayantes. Au départ je n'avais aucune idée de comment faire, mais j'ai commencé à chercher d'autres librairies d'interface existantes, mais rien. Puis un jour, je suis tombé sur une personne qui développait un client modifié pour Minecraft, et qui avait des interfaces graphiques dans son mod, ce que je recherchais. Il m'a expliqué comment faire. J'ai ensuite découvert qu'il utilisait des shaders pour faire ses interfaces.

Le système de shader

Contrairement aux rendus classiques de Minecraft qui utilisent de simples textures ou des triangles plats, NoctisUI déporte le dessin géométrique sur des shaders GLSL personnalisés. Cela permet d'obtenir un rendu net (anti-aliasé) pour les arrondis, les cercles, les dégradés et les effets de flou (glassmorphism), peu importe la résolution ou le niveau de zoom de l'interface.

1. Enregistrement des Shaders

Les shaders sont enregistrés dans le pipeline graphique de Minecraft via l'événement Forge RegisterShadersEvent :

// me/axeno/noctisui/client/render/Shaders.java
@SubscribeEvent
public static void onRegisterShaders(RegisterShadersEvent event) throws IOException {
    // Enregistrement des shaders principaux (formes géométriques, flou, etc.)
    register(event, "rounded_rect", DefaultVertexFormat.POSITION_COLOR, shader -> ROUNDED_RECT = shader);
    register(event, "rounded_outline", DefaultVertexFormat.POSITION_COLOR, shader -> ROUNDED_OUTLINE = shader);
    register(event, "blur", DefaultVertexFormat.POSITION, shader -> BLUR = shader);
    
    // Le MSDF (Multi-channel Signed Distance Field) permet d'afficher des textes lissés à l'infini
    register(event, "msdf", DefaultVertexFormat.POSITION_TEX_COLOR, shader -> {
        MSDF = shader;
        msdfPxrange = shader.getUniform("pxRange"); // Ajustement de la netteté de la police
    });
}

2. Le Fragment Shader (Calcul d'arrondi dynamique)

Pour dessiner les arrondis de manière mathématique et lisse, nous utilisons une SDF (Signed Distance Field) dans le shader. Par exemple, le shader rounded_rect.fsh calcule la distance de chaque pixel par rapport aux bordures et applique un adoucissement (smoothstep) :

// assets/noctisui/shaders/core/rounded_rect.fsh
 
// Fonction SDF pour un rectangle avec des rayons de coin individuels
float variableRoundedBoxSDF(vec2 p, vec2 size, vec4 cornerRadii) {
    vec2 d = abs(p) - size;
    // Sélection du bon rayon selon le quadrant (haut-gauche, haut-droite, etc.)
    float radius = mix(
        mix(RadiusBottomLeft, RadiusBottomRight, step(0.0, p.x)),
        mix(RadiusTopLeft, RadiusTopRight, step(0.0, p.x)),
        step(0.0, p.y)
    );
    // Retourne la distance signée ajustée avec le rayon
    return length(max(d + vec2(radius), 0.0)) - radius;
}
 
void main() {
    // ... normalisation des coordonnées ...
    float distance = variableRoundedBoxSDF(centerCoords, size - 1.0, vec4(RadiusTopLeft, RadiusTopRight, RadiusBottomRight, RadiusBottomLeft));
    
    // Application de l'anti-aliasing pour des bords lisses et sans crénelage
    float smoothedAlpha = 1.0f - smoothstep(0.0f, Smoothness, distance);
    fragColor = vec4(color.rgb, smoothedAlpha * color.a);
}

Le système de composants

Chaque élément graphique de NoctisUI dérive d'une architecture orientée objet. La base repose sur la classe abstraite UIBaseComponent qui implémente l'interface UIComponent et gère le positionnement, les dimensions, l'activation, et l'écoute des événements utilisateur.

1. Le conteneur principal : DivComponent

Le DivComponent fonctionne de la même manière qu'une balise <div> en HTML. Il sert de conteneur pour d'autres sous-composants et réinitialise l'espace de coordonnées relative (les enfants se positionnent à partir de (0, 0) par rapport au conteneur parent).

Il gère également les arrière-plans, les bordures, et le rendu de flou dynamique (blur shader) pour un effet flouté en arrière-plan :

// me/axeno/noctisui/client/component/DivComponent.java
@Override
public void render(GuiGraphics context, double mouseX, double mouseY, float delta) {
    if (!visible) return;
 
    PoseStack matrices = context.pose();
    matrices.pushPose();
    matrices.translate(x, y, 0); // Décale le repère pour les enfants
 
    // Rendu du flou d'arrière-plan (effet acrylique)
    if (blurEnabled) {
        Render2DEngine.drawBlur(matrices, 0, 0, width, height, cornerRadius, blurQuality, blurBrightness);
    }
 
    // Rendu du fond coloré et de la bordure via nos shaders de rectangles arrondis
    if (backgroundColor != null) {
        Render2DEngine.drawRoundedRect(matrices, 0, 0, width, height, cornerRadius, backgroundColor);
        if (outlineWidth > 0) {
            Render2DEngine.drawRoundedOutline(matrices, 0, 0, width, height, cornerRadius, outlineWidth, outlineColor);
        }
    }
 
    // Rendu récursif des composants enfants (les coordonnées de la souris sont ajustées)
    for (UIBaseComponent child : children) {
        if (!child.isVisible() || !child.isEnabled()) continue;
        child.render(context, mouseX - x, mouseY - y, delta);
    }
 
    matrices.popPose(); // Restaure le repère initial
}

2. Le bouton interactif : ButtonComponent

Le ButtonComponent enrichit l'expérience utilisateur en ajoutant des transitions animées lors du survol (hover) grâce à une interpolation temporelle des couleurs de fond et de texte :

// me/axeno/noctisui/client/component/ButtonComponent.java
@Override
public void render(GuiGraphics context, double mouseX, double mouseY, float delta) {
    Color currentBackgroundColor = backgroundColor;
    Color currentLabelColor = labelColor;
 
    // Calcul de l'animation de hover (fondu fluide des couleurs)
    if (hasHover) {
        long elapsed = System.currentTimeMillis() - hoverStartTime;
        float progress = Math.min(1f, (float) elapsed / hoverAnimationDuration);
        
        if (isHovered) {
            currentBackgroundColor = Color.interpolateColor(backgroundColor, hoverBackgroundColor, progress);
            currentLabelColor = Color.interpolateColor(labelColor, hoverLabelColor, progress);
        } else {
            currentBackgroundColor = Color.interpolateColor(hoverBackgroundColor, backgroundColor, progress);
            currentLabelColor = Color.interpolateColor(hoverLabelColor, labelColor, progress);
        }
    }
 
    // Rendu du bouton avec les couleurs animées
    Render2DEngine.drawRoundedRect(matrices, x, y, width, height, radius, currentBackgroundColor);
    
    // Rendu du texte centré
    float textX = x + (width - font.getWidth(label, fontSize)) / 2;
    float textY = y + (height - font.getLineHeight(fontSize)) / 2;
    font.render(matrices, label, textX, textY, fontSize, currentLabelColor.getValue());
}

3. Entrée de texte : TextInput

Le TextInput est un composant de saisie de texte configurable prenant en charge plusieurs types de données (mot de passe avec bouton œil, e-mail, recherche, nombre, etc.). Il intègre des animations de focus, de survol et de validation :

// me/axeno/noctisui/client/component/input/TextInput.java
// Exemple d'écoute du clavier et filtrage de saisie
@Override
public boolean charTyped(char chr, int modifiers) {
    if (!focused || !enabled || !visible) return false;
    
    // Validation du type de caractère saisi (ex: uniquement des chiffres pour InputType.NUMBER)
    if (inputType == InputType.NUMBER && !Character.isDigit(chr) && chr != '-') {
        return false;
    }
    
    // Insertion du caractère à la position du curseur
    insertText(Character.toString(chr));
    return true;
}

4. Cases à cocher et interrupteurs : CheckboxComponent et SwitchComponent

Ces deux composants fournissent des états booléens avec des animations physiques distinctes lors des clics :

  • Le CheckboxComponent dessine une case et y anime une coche à l'intérieur.
  • Le SwitchComponent dessine un bouton glissant horizontal (knob) dont la coordonnée X s'interpole en fonction de l'état actif ou inactif.
// me/axeno/noctisui/client/component/SwitchComponent.java
// Interpolation de la position de la bulle d'interrupteur
float knobX = x + PADDING + (width - PADDING * 2 - knobSize) * animationProgress;
Render2DEngine.drawRoundedRect(matrices, knobX, y + (height - knobSize) / 2f, knobSize, knobSize, knobRadius, knobColor);

5. Sélection numérique : SliderComponent

Le SliderComponent permet de modifier une valeur numérique entre des bornes définies. Il prend en charge l'arrondi par étapes (step) et met à jour dynamiquement son libellé (ex : Volume : 50) :

// me/axeno/noctisui/client/component/SliderComponent.java
// Calcul de la valeur selon la position X de la souris
private void updateValueFromMouse(double mouseX) {
    float percentage = (float) ((mouseX - x) / width);
    float rawValue = min + percentage * (max - min);
    
    // Arrondi à l'étape la plus proche (step)
    this.value = Math.max(min, Math.min(max, MathUtils.roundToStep(rawValue, step)));
}

Le système de notification

NoctisUI intègre un gestionnaire complet de notifications (NotificationManager) de type "toasts" qui s'affichent sur l'écran du joueur avec des animations de glissement (slide-in) et de disparition en fondu (fade-out).

1. Gestion de l'empilement (Stacking)

Pour éviter de surcharger l'écran en cas de notifications répétitives, le gestionnaire fusionne les notifications similaires en incrémentant un compteur visuel au lieu de créer une nouvelle carte de notification :

// me/axeno/noctisui/client/component/notification/NotificationManager.java
public void addNotification(String id, String title, String message, NotificationType type, long duration) {
    Notification incoming = new Notification(id, title, message, type, duration);
    
    // Si une notification avec le même ID existe déjà, on augmente le compteur (stack count)
    for (Notification existing : notifications) {
        if (existing.isSimilarTo(incoming)) {
            existing.incrementStack(); // Augmente le compteur et réinitialise la durée d'affichage
            return;
        }
    }
    notifications.add(incoming);
}

2. Courbes d'animations (Easing)

La classe Notification utilise des équations d'assouplissement cubiques (easeOutCubic et easeInCubic) pour donner une sensation d'inertie physique lors de l'apparition et de la disparition de la notification :

// me/axeno/noctisui/client/component/notification/Notification.java
public void update() {
    long elapsedSinceCreation = System.currentTimeMillis() - creationTime;
    long elapsedSinceLastStack = System.currentTimeMillis() - lastStackTime;
 
    // Entrée : animation de glissement amorti (easeOutCubic)
    if (elapsedSinceCreation < ENTER_MS) {
        animationProgress = easeOutCubic(elapsedSinceCreation / (float) ENTER_MS);
    }
    // Sortie : fondu et glissement accéléré (easeInCubic)
    else if (elapsedSinceLastStack > duration - EXIT_MS) {
        float fadeT = (elapsedSinceLastStack - (duration - EXIT_MS)) / (float) EXIT_MS;
        animationProgress = 1f - easeInCubic(fadeT);
    } else {
        animationProgress = 1f;
    }
}

3. Rendu de la carte de notification

Chaque notification est dessinée sous forme d'une carte avec une barre d'accentuation latérale colorée (selon le type : SUCCESS, ERROR, WARNING, INFO), un badge avec une icône vectorielle Lucide, et le badge multiplicateur (×N) s'il y a un empilement :

// me/axeno/noctisui/client/component/notification/NotificationManager.java
private void renderCard(PoseStack matrices, Notification notification, int x, int y, int height, float alpha) {
    NotificationType type = notification.getType();
    Color background = NotificationTheme.withAlpha(NotificationTheme.CARD_BACKGROUND, alpha);
    
    // Arrière-plan de la carte arrondi
    Render2DEngine.drawRoundedRect(matrices, x, y, CARD_WIDTH, height, CORNER_RADIUS, background);
    
    // Barre d'accentuation à gauche
    Render2DEngine.drawRoundedRect(matrices, x + 4, y + 9, ACCENT_WIDTH, height - 18, 1.5f, type.getAccentBright());
    
    // Rendu de l'icône dans son badge arrondi
    renderIconBadge(matrices, type, badgeX, badgeY, alpha);
    
    // Rendu des textes titre et message
    renderWrappedText(matrices, notification.getTitle(), textX, textY, maxTextWidth, titleColor, true, TITLE_SIZE);
    
    // Si la notification est empilée, on affiche la bulle "xN"
    if (notification.hasStack()) {
        renderStackBadge(matrices, notification, x, y, alpha, type.getAccent());
    }
}

Création d'un écran (Screen)

Pour utiliser NoctisUI, il faut créer une classe héritant de la classe Minecraft standard net.minecraft.client.gui.screens.Screen. Les étapes consistent à initialiser les composants et à rediriger les événements du jeu (rendu et entrées utilisateur).

1. Héritage et Initialisation

Dans la méthode init(), nous instancions le conteneur racine DivComponent (généralement centré à l'écran) et y ajoutons les différents composants enfants :

// me/axeno/noctisui/client/screen/TestScreen.java
public class TestScreen extends Screen {
    private DivComponent root;
 
    public TestScreen() {
        super(Component.literal("NoctisUI Screen"));
    }
 
    @Override
    protected void init() {
        float panelX = (this.width - PANEL_WIDTH) / 2f;
        float panelY = (this.height - PANEL_HEIGHT) / 2f;
 
        // Configuration du conteneur racine
        root = new DivComponent(panelX, panelY, PANEL_WIDTH, PANEL_HEIGHT);
        root.enableBlur(26f, 0.95f); // Flou d'arrière-plan
        root.setBackgroundColor(new Color(0, 0, 0, 90));
        root.setCornerRadius(14);
 
        // Ajout d'un bouton de fermeture
        ButtonComponent closeButton = new ButtonComponent(24, 400, 190, 34, "Fermer", new Color(55, 55, 65), Color.WHITE);
        closeButton.setOnClick(btn -> onClose());
        root.addChild(closeButton);
    }
}

2. Rendu et Transparence

Pour que le shader de flou fonctionne correctement, il a besoin d'échantillonner le monde du jeu situé derrière l'écran. Il faut donc surcharger renderBackground pour qu'elle ne dessine pas le fond grisé opaque classique de Minecraft :

// Rendu principal de l'interface
@Override
public void render(GuiGraphics guiGraphics, int mouseX, int mouseY, float partialTick) {
    if (root != null) {
        root.render(guiGraphics, mouseX, mouseY, partialTick);
    }
}
 
// Désactivation de l'arrière-plan noir par défaut pour laisser le monde visible
@Override
public void renderBackground(GuiGraphics guiGraphics) {
    // Laissé vide volontairement pour que le shader de flou puisse échantillonner le monde du jeu
}

3. Délégation des entrées souris et clavier

Pour que les composants puissent réagir aux clics, glissements de souris et saisies clavier, les événements système de Minecraft sont transmis directement au composant racine root ou aux éléments ciblés :

// Transmission des clics de souris
@Override
public boolean mouseClicked(double mouseX, double mouseY, int button) {
    if (root != null) root.mouseClicked(mouseX, mouseY, button);
    return true;
}
 
// Transmission de la saisie de caractères
@Override
public boolean charTyped(char chr, int modifiers) {
    for (TextInput input : textInputs) {
        if (input.charTyped(chr, modifiers)) return true;
    }
    return super.charTyped(chr, modifiers);
}