DEV Community

Antonio
Antonio

Posted on

Nove classi per un pulsante: la mia piccola UI per canvas (e perché le voglio bene)

BeeUi
Volevo un pulsante "Play" sul canvas del mio motore di gioco 2D.
Un pulsante. Uno.

Poi ho aperto il file e ho trovato questo:

import {
    BeeUI,
    BeeControl,
    BeePanel,
    BeeStack,
    BeeLabel,
    BeeUIButton,
    BEE_ANCHOR,
    BEE_UI_DEFAULTS,
    drawNineSlice
} from './src/ui/BeeUI.js';
Enter fullscreen mode Exit fullscreen mode

Nove import per un menu di pausa. Ho riso anch'io. Poi ho capito che ognuno di quei nomi ha un motivo per esistere, e alla fine sono diventati la parte del motore che preferisco. Ve li presento uno per uno, con la serietà che si meritano (poca, ma sufficiente).

Il problema: il canvas non sa cos'è un pulsante

Il canvas HTML5 è un foglio bianco con i pixel. Non ha layout, non ha focus, non sa che il tuo menu dovrebbe restare centrato quando la finestra cambia dimensione. Tutto quello che il DOM ti regala gratis, sul canvas te lo devi costruire.

Quindi sì, servono un po' di classi.

BeeControl: il nonno di tutti

BeeControl è il nodo base. Non disegna niente (il suo draw() è letteralmente vuoto), ma sa fare le cose noiose e importanti:

  • tenere una lista di figli e un genitore (con campi privati #parent e #children, così nessuno li tocca a mano);
  • calcolare il proprio rettangolo a partire da ancore e offset;
  • capire se il mouse ci sta sopra con hit(x, y), partendo dall'ultimo figlio, cioè quello disegnato sopra.

Le ancore sono valori da 0 a 1 rispetto al genitore. Il conto è tutto qui:

const x = parentRect.x + parentRect.w * this.anchorLeft + this.offsetLeft;
const w = parentRect.w * (this.anchorRight - this.anchorLeft)
        + (this.offsetRight - this.offsetLeft);
Enter fullscreen mode Exit fullscreen mode

Ancore uguali a sinistra e destra danno una larghezza fissa. Ancore diverse fanno allargare l'elemento insieme al genitore. Chi ha usato Godot riconoscerà il trucco, e non mi vergogno di averlo copiato.

BEE_ANCHOR: perché nessuno vuole scrivere setAnchors(1, 1, 1, 1)

Ricordarsi i numeri delle ancore è un incubo, quindi ci sono dei preset:

BEE_ANCHOR.TOP_LEFT, TOP, TOP_RIGHT,
LEFT, CENTER, RIGHT,
BOTTOM_LEFT, BOTTOM, BOTTOM_RIGHT,
FULL
Enter fullscreen mode Exit fullscreen mode

Vuoi il punteggio in alto a destra con 16 pixel di margine?

new BeeLabel({ text: 'Score: 0', anchor: BEE_ANCHOR.TOP_RIGHT, x: 16, y: 16, width: 160 });
Enter fullscreen mode Exit fullscreen mode

Ridimensioni la finestra e resta lì. Magia? No, sottrazioni.

BEE_UI_DEFAULTS: le decisioni che non voglio prendere ogni volta

{ gap: 8, padding: 12, buttonWidth: 220, buttonHeight: 44 }
Enter fullscreen mode Exit fullscreen mode

È un oggetto congelato con Object.freeze. Quattro numeri, zero discussioni. 44 pixel di altezza perché è una misura comoda da toccare col dito.

BeePanel: un rettangolo con ambizioni

BeePanel è un controllo che finalmente disegna qualcosa: colore di sfondo, bordo, angoli arrotondati (se il browser supporta roundRect) oppure un'immagine. E se gli passi anche slice, usa…

drawNineSlice: l'eroe silenzioso

Hai una cornice da 48×48 pixel e vuoi un pannello da 400×300 senza che gli angoli si deformino. La tecnica nine-slice taglia l'immagine in 9 pezzi: i 4 angoli restano fissi, i bordi si allungano in una sola direzione e il centro si allunga in tutte e due.

new BeePanel({
    anchor: BEE_ANCHOR.CENTER,
    width: 400, height: 300,
    image: frameImage,
    slice: { left: 12, top: 12, right: 12, bottom: 12 }
});
Enter fullscreen mode Exit fullscreen mode

Nove chiamate a drawImage e il tuo menu sembra uscito da un gioco vero.

BeeStack: il flexbox dei poveri (e mi basta)

Mettere i pulsanti uno sotto l'altro calcolando le y a mano è il modo migliore per odiare la gamedev. BeeStack lo fa per te, in verticale ('v') o in orizzontale ('h'):

  • measure() somma le dimensioni dei figli visibili, più spazi e margini interni;
  • layout() li posiziona uno dopo l'altro;
  • se è ancorato al centro, si ricentra da solo in base alla dimensione calcolata.

BeeLabel e BeeUIButton: finalmente quello che volevo

BeeLabel scrive del testo. Fine. BeeUIButton è il pulsante vero: ha stati diversi per mouse sopra, premuto, disabilitato e focus, e si attiva al rilascio del mouse solo se sei ancora sopra al pulsante, esattamente come un pulsante del browser. Se ti penti a metà click, trascini fuori e non succede niente.

BeeUI: il capo

BeeUI è la radice dell'albero. Occupa tutto il canvas e a ogni frame:

  1. ricalcola il layout solo se qualcosa è cambiato (c'è un flag #dirty);
  2. gestisce hover e click del mouse;
  3. gestisce la tastiera: Tab e Shift+Tab, frecce o WASD, Invio e Spazio;
  4. gestisce il gamepad: croce direzionale e tasto A.

La parte che mi piace di più è focusToward(dx, dy). Quando premi "giù", non va semplicemente al pulsante successivo nella lista: cerca il controllo più vicino in quella direzione, penalizzando quelli troppo spostati di lato. Funziona anche con griglie di pulsanti, non solo con le liste.

Inoltre blocca il comportamento normale del Tab sul canvas, così il focus non scappa nella barra degli indirizzi a metà partita.

Un menu di pausa completo

const ui = new BeeUI({ canvas });

const menu = ui.add(new BeeStack({ anchor: BEE_ANCHOR.CENTER, background: '#0f172acc', radius: 12 }));
menu.add(new BeeLabel({ text: 'PAUSA', align: 'center', font: 'bold 24px monospace' }));
menu.add(new BeeUIButton({ text: 'Riprendi', onClick: () => game.resume() }));
menu.add(new BeeUIButton({ text: 'Opzioni',  onClick: () => openOptions() }));
menu.add(new BeeUIButton({ text: 'Esci',     onClick: () => game.quit() }));
ui.focusAt(0);

// nel game loop
ui.update(input);
ui.draw(ctx);
Enter fullscreen mode Exit fullscreen mode

Centrato, navigabile con mouse, tastiera e gamepad, e resta al suo posto quando ridimensioni la finestra. Il tutto in circa 700 righe senza dipendenze.

Quindi, nove classi erano troppe?

Probabilmente sì, per un pulsante.
Per un pulsante che si centra da solo, si allarga, si naviga col joystick e non si rompe quando giri il telefono, no.

E la prossima volta che qualcuno mi chiede "perché non usi semplicemente un <button> sopra il canvas?", gli mando questo articolo e vado a giocare.

Top comments (0)