BeeTransform — Documentazione Tecnica
Trasformata 2D affine (TRS + pivot): locali vs mondo, cache dirty, grafo parent/child
📁 Panoramica
BeeTransform è la geometria. Vive in src/core/BeeTransform.js, il barrel la re-esporta con BEE_TRANSFORM_DEFAULTS. BeeEntity ne possiede una (entity.transform) e inoltra x/y/worldX/rotation/scale. Non è un offset anni ’90 (world = parent.x + x): è una matrice affine
T(pos) · R · S · T(-pivot), composta col parent: world = parentWorld · local.
Quello che non è: un nodo di scena, un body, un renderer. Non ha update, non conosce il canvas oltre applyWorldTo(ctx), non integra velocità. Tiene posa, cache e albero di trasformate. La world matrix è lazy: si ricalcola solo se dirty, senza allocare oggetti nuovi nel tick.
📁 Locali vs mondo — il contratto
x, y, rotation (radianti), scaleX/scaleY, pivotX/pivotY sono sempre locali al parent. x/y sono la posizione del pivot nello spazio del parent, non necessariamente lo spigolo in alto a sinistra.
worldX / worldY sono tx/ty della world matrix: il punto mondo in cui cade l’origine locale (0, 0). Se il pivot non è zero, worldX non è la posizione mondo del pivot. Quella si ottiene con transformPoint(pivotX, pivotY).
| Spazio | Campi | Significato |
|---|---|---|
| Locale | x y rotation scaleX/Y pivotX/Y |
Scrittura: marca dirty. x/y = posa del pivot nel parent |
| Mondo | worldX worldY worldRotation worldScaleX/Y |
Lettura lazy. worldX/Y = origine locale (0, 0) in mondo |
Scrivere worldX / worldY (o setWorldOrigin) non fa x = world - parent.x. Inverte la catena del parent e poi ricava i locali tenendo conto di rotazione, scala e pivot.
Un solo pezzo del contratto: la scrittura non ricalcola il mondo. Lo fa la prima lettura dopo che la catena è sporca.
- 1. set x / y Assegna i locali. Se il valore è uguale, esce.
-
2. markDirty Flag mondo + inversa. Propaga ai figli. Chiama
onDirty. - 3. nessun sync La matrix mondo resta vecchia finché nessuno la chiede.
-
4. get worldX Prima lettura:
#syncWorlddal parent in giù, poi cache.
📁 BEE_TRANSFORM_DEFAULTS
Oggetto frozen. Il costruttore fa { ...defaults, ...options }. zIndex è l’unico campo pubblico senza cache: non entra nella matrice.
| Parametro | Default | Significato |
|---|---|---|
x / y |
0 |
Posizione del pivot nello spazio del parent |
rotation |
0 |
Radianti. Alias pratico: rotationDegrees |
scaleX / scaleY |
1 |
Scala locale. 0 e negativi (flip) sono ammessi |
pivotX / pivotY |
0 |
Punto locale attorno a cui ruoti e scali |
zIndex |
0 |
Ordinamento; la classe non lo usa in #syncWorld |
📁 Dirty cache — tre flag, zero alloc
Tre sporcizie, non una. #worldDirty per la world matrix, #inverseDirty per l’inversa (pick, setWorldOrigin), #trigDirty per cos/sin della sola rotazione. #local, #world, #inverse, #point, #aabb sono oggetti riutilizzati: nel tick non nasce nulla.
markDirty() esce subito se era già sporco — non ripercorre lo stesso ramo, e non richiama onDirty. Poi marca sé, l’inversa, i figli della transform, e (se è una funzione) onDirty. BeeEntity ci aggancia #cascadeDirty: marca i transform dei figli entity. Se i due alberi sono allineati, i figli ricevono due markDirty; il secondo è no-op.
La local matrix non ha un flag proprio: #writeLocal la riscrive ogni volta che serve (sync mondo o getter localMatrix). Costa quattro float e due trig cache-ati. Non mutare l’oggetto restituito da localMatrix / worldMatrix: è lo stesso buffer interno, e il prossimo sync lo sovrascrive.
📁 Parent / child
setParent(transform) è difensivo: ignora sé stesso, è idempotente se il parent è già quello, rifiuta i cicli (#hasAncestor). Stacca dal parent precedente, aggancia, markDirty, ritorna this. setParent(null) stacca. Non esiste detach() sulla transform: quello sta su BeeEntity.
parent e children sono getter. children è l’array interno vivo, non una copia. Push/splice a mano aggirano stacco, anti-ciclo e dirty. Per cambiare il grafo usa sempre setParent.
Due alberi. BeeEntity.addChild aggiorna entrambi (entity.#parent + transform.setParent). Se chiami solo entity.transform.setParent, il mondo segue la transform, ma update cammina i figli entity: i grafi divergono.
📁 setWorldOrigin e i setter mondo
Contratto dichiarato nel sorgente: posiziona l’origine locale (0, 0) in un punto mondo. Se c’è un parent, parent.inverseTransformPoint porta il target in spazio parent; poi i locali assorbono il pivot (x = origin + local · pivot).
worldX = v è setWorldOrigin(v, this.worldY). Legge worldY prima (sync), poi scrive. Due assegnamenti separati vanno bene; non è un set atomico (x, y) se in mezzo muovi il parent.
Se la world matrix è singolare (scala 0, o parent schiacciato), invert fallisce in silenzio e lascia l’identità. setWorldOrigin / inverseTransformPoint trattano allora il mondo come se fosse già locale: i numeri tornano, la posa no. È un limite, non un throw.
📁 Punti, AABB, canvas, lookAt
transformPoint(localX, localY, out?) applica la world. inverseTransformPoint l’inversa, ricalcolata solo se #inverseDirty. Default out è #point condiviso: tieni le coordinate, non il riferimento.
getWorldAABB(width, height, out?) è l’AABB mondo del rettangolo locale (0, 0)–(width, height) dopo rotazione/scala (i quattro spigoli). Default out è #aabb condiviso. Ladybug usa questi bounds per l’OBB; il culling anche.
applyWorldTo(ctx) chiama ctx.transform(...) — moltiplica la CTM, non la sostituisce. Serve save/restore. Non è setTransform.
lookAt(worldX, worldY) punta l’asse locale X dal pivot (non da (0, 0)) verso il target mondo, e scrive rotation in radianti.
📁 Integrazione BeeEntity
Nel costruttore: this.transform = new BeeTransform({ x, y }) e onDirty = () => this.#cascadeDirty(). I getter/setter di posa dell’entity sono proxy. getWorldAABB() dell’entity, se non c’è body, delega a transform.getWorldAABB(width, height). applyWorldTransform(ctx) è applyWorldTo. integrate scrive transform.x/y/rotation (setter → dirty). destroy azzera onDirty e setParent(null) sui figli.
Il solver piattaforme scrive worldX/worldY usando width/height grezzi, non l’AABB ruotata e non il pivot. Con pivot ≠ 0 o rotazione non nulla l’atterraggio “worldY = py - height” non coincide più col bordo visivo. Limite del solver, non della matrice.
Sovrascrivere transform.onDirty dopo la construct spegne la cascata entity. Se ti serve un hook, incatena: chiama prima il callback esistente.
📁 Dove è facile sbagliare
Locali vs mondo. x è il pivot nel parent. worldX è l’origine (0, 0) in mondo. Con pivot al centro dello sprite i due numeri non coincidono. Per “dove sta il centro” usa transformPoint(pivotX, pivotY).
Non è più parent.x + x. Appena il parent ruota o scala, worldX non è una somma. I figli orbitano. Se ti aspetti l’offset anni ’90, stai leggendo la classe sbagliata (o una docs di BeeEntity precedente alla 2.5).
Dirty ≠ sync. Dopo x = … la world matrix è invalida, non aggiornata. Va bene: il costo sta sulla lettura. Non cache-are tu un worldX letto all’inizio del frame se poi muovi il parent.
Due grafi. Parentare solo la transform (o solo l’entity) le desincronizza. Per i nodi di gioco: addChild / detach.
children vivo, matrici vive, #point vivo. Non mutare quegli array/oggetti. Copia i numeri. Passa un out tuo a getWorldAABB se tieni il box oltre la prossima chiamata.
applyWorldTo moltiplica. Senza save/restore la CTM si accumula ogni sprite.
Decomposizione mondo. worldScaleX/Y sono lunghezze di colonna (sempre ≥ 0): il flip si perde. worldRotation è atan2(b, a): con scala negativa o shear (parent non uniforme + figlio ruotato) non ricostruisce TRS in modo unico.
Inversa singolare. Scala 0 → inversa = identità, senza errore. Number(value) || 0 su x/y/rotation/pivot normalizza i falsy a 0; la scala invece accetta 0 perché usa Number.isFinite.
📁 Esempio: hub, satellite, draw locale
📁 Riepilogo API
Fluent: setScale, setPivot, setPivotNormalized, setParent, setWorldOrigin, applyWorldTo, lookAt ritornano this. I default sono in BEE_TRANSFORM_DEFAULTS (frozen).
| Membro | Ritorna | Scopo |
|---|---|---|
BEE_TRANSFORM_DEFAULTS |
oggetto frozen | x/y 0 · rotation 0 · scaleX/Y 1 · pivotX/Y 0 · zIndex 0. |
new BeeTransform(options) |
BeeTransform |
Copia default + options. Parte già dirty. onDirty è null. |
x / y |
number |
Locali (pivot nel parent). Setter: Number(value) || 0, poi markDirty. |
rotation / rotationDegrees |
number |
Radianti / gradi. Il setter gradi scrive rotation. |
scaleX / scaleY / setScale(x, y = x) |
number / this |
Scala locale. Non finito → 1. Accetta 0 e negativi. |
pivotX / pivotY / setPivot(x, y) |
number / this |
Punto locale di rotazione/scala. |
setPivotNormalized(nx, ny, w, h) |
this |
pivot = (nx * w, ny * h). Centro sprite: (0.5, 0.5, width, height). |
parent / children |
BeeTransform|null / array |
Sola lettura. children è l’array vivo. |
setParent(transform) |
this |
Reparent + anti-ciclo. null stacca. |
markDirty() |
void |
Invalida mondo/inversa, cascata figli, onDirty. No-op se già dirty. |
onDirty |
function|null |
Hook pubblico. BeeEntity lo sovrascrive con la cascata entity. |
localMatrix / worldMatrix |
oggetto {a,b,c,d,tx,ty} |
Buffer interni. La world sync-a prima. Non mutarli. |
worldX / worldY |
number |
Origine locale in mondo. I setter chiamano setWorldOrigin. |
worldRotation / worldScaleX / worldScaleY |
number |
Solo lettura. Scala = hypot delle colonne (senza segno di flip). |
setWorldOrigin(worldX, worldY) |
this |
Mette (0, 0) locale su un punto mondo. Inverte il parent, non sottrae. |
transformPoint(lx, ly, out?) |
{x, y} |
Locale → mondo. Default out = #point condiviso. |
inverseTransformPoint(wx, wy, out?) |
{x, y} |
Mondo → locale. Inversa lazy. Matrice singolare → identità. |
getWorldAABB(w, h, out?) |
{x, y, width, height} |
AABB mondo del rettangolo locale. Default out = #aabb. |
applyWorldTo(ctx) |
this |
ctx.transform (multiply). Abbinalo a save/restore. |
lookAt(worldX, worldY) |
this |
Orienta l’asse X locale dal pivot verso il target mondo. |
zIndex |
number |
Pubblico, senza dirty. La classe non ordina il draw. |
