BeeTime — Documentazione Tecnica
Orologio unico del core loop: un tick per frame, due assi temporali (simulazione e tempo reale)
📁 Panoramica
BeeTime è l’orologio del motore. Vive in src/core/BeeTime.js, il barrel lo re-esporta, e BeeEngine ne crea uno nel costruttore: this.time = new BeeTime(). Non ce n’è un secondo per le scene. Ogni frame il loop chiama time.tick(timestamp) una sola volta, anche in pausa: da quel tick nascono due delta, non due loop.
Quello che non è: un scheduler di eventi, un tween, un clock di rete. Non conosce le scene, le entity, la fisica. Espone numeri. Chi avanza il mondo (scene, entity, BeePhysicsWorld, BeeTimer) legge quei numeri. Il freeze di Ladybug (F4) non è un secondo orologio: è togglePause() sullo stesso oggetto.
📁 Due assi: scaled e unscaled
Un frame, due orologi. Non mescolarli.
| Asse | Campi | Pausa | timeScale | Uso |
|---|---|---|---|---|
| Simulazione | dt / elapsed / alias scaledDt |
sì: dt = 0 |
sì: unscaledDt * timeScale |
fisica di gameplay, AI, sprite, BeeTimer di default |
| Reale | unscaledDt / unscaledElapsed / alias realDt |
no | no | HUD, UI, mixer audio, fade di pausa, timer di interfaccia |
rawDelta è il delta grezzo in secondi, prima del clamp. unscaledDt = min(rawDelta, maxDelta). Poi dt è zero se paused, altrimenti unscaledDt * timeScale. delta(false) torna dt; delta(true) torna unscaledDt. Gli alias scaledDt e realDt sono getter sullo stesso dato: non esiste un terzo asse.
📁 tick — una volta per frame, sempre
Contratto: il loop chiama tick(timestamp) con il timestamp di requestAnimationFrame (millisecondi). Dentro, la differenza viene divisa per 1000: i campi pubblici sono in secondi. Chiamarlo due volte nello stesso frame avanza elapsed due volte. Non farlo.
Il primo tick (o il primo dopo un reset(0)) è un allineamento: azzera i delta, memorizza il timestamp, non incrementa frameCount / elapsed. Evita lo spike da lastTimestamp = 0. I tick successivi calcolano, clampano, aggiornano FPS e (se non in pausa) l’accumulatore fisso.
frameCount e unscaledElapsed avanzano anche in pausa: stai contando frame disegnati e tempo reale. elapsed no (dt è zero). fps campiona su unscaledDt in una finestra di fpsSampleWindow (default 0,5 s): per mezzo secondo all’avvio resta 0, poi è frames / secondiReali, senza EMA.
Timestamp obbligatorio. Il loop passa sempre l’argomento di rAF. Se chiami tick() senza numero, rawDelta diventa NaN e da lì dt / elapsed restano avvelenati. Non c’è un fallback su performance.now() dentro tick (quel fallback esiste in now() e nel default di begin).
📁 maxDelta — clamp sul tempo reale, non sul dt scalato
Default 0.05 (50 ms). È il tetto di unscaledDt. Serve per un tab in background o un hitch: senza clamp, un raw di 2 secondi teletrasporterebbe tutto. Math.max(0, …) sul raw evita delta negativi se l’orologio salta indietro.
Il clamp sta prima di timeScale. Quindi:
dt = min(raw, 0.05) * timeScale — non min(raw * timeScale, 0.05).
A scala 1, il tetto di simulazione è 50 ms. A scala 2, un hitch già clampato diventa dt = 0.10. A scala 16, dt può arrivare a 0.80 s su un singolo frame variabile. È voluto per il fast-forward; non è un secondo clamp. I campi maxDelta, minTimeScale, maxTimeScale, fixedDelta, maxFixedSteps, fpsSampleWindow sono pubblici e mutabili a caldo: non c’è un setter che ricalcola lo stato interno.
📁 timeScale — slow-mo e fast-forward
Default 1. 0.25 = slow-motion, 2 = fast-forward. Il setter timeScale = x delega a setScale(x): Number(value), se non è finito ripiega su #defaultScale (la scala passata al costruttore), poi clamp tra minTimeScale (0) e maxTimeScale (16). Ritorna this. BeeEngine.setTimeScale è lo stesso metodo.
timeScale = 0 non alza il flag paused. dt è zero, ma il loop del motore non è in pausa: chiama ancora scenes.update(0) e updateEntities(0). Il manager, con dt <= 0, salta il tick delle entity. È un “mondo fermo, scena ancora interrogata”. pause() invece salta l’intero blocco scene/entity/fisica. Non sono equivalenti: per un menu di pausa usa pause() (o F4), non la scala a zero, a meno che tu voglia proprio l’hook della scena con dt zero.
Costruttore: setScale clamp-a, il costruttore no. new BeeTime({ timeScale: 99 }) parte a 99 fino al primo setScale / assegnamento. #defaultScale resta quel valore: un reset() lo ripristina, un setScale(NaN) ci ricade.
📁 pause, freeze, resume, stop — tre livelli, non uno
pause() / resume() / togglePause() toccano solo #paused. Non azzerano elapsed, non chiamano begin, non fermano rAF. In pausa l’accumulatore fisso non cresce (il residuo resta lì). Ladybug F4 è time.togglePause(): freeze e pausa sono la stessa bandiera. L’overlay dice RUN/FREEZE; l’API dice paused.
Il loop di BeeEngine, se time.paused:
1. Chiama comunque tick (HUD reale, FPS, overlay).
2. Non chiama scenes.update, updateEntities, consumeFixedSteps → fisica ferma, entity ferme, hook scene.update non parte.
3. Chiama comunque this.update(dt, input, time) se l’hai passato a start — con dt = 0. Qui vive un menu di pausa che deve leggere l’input mentre il mondo è fermo.
4. Disegna sempre: scene, entity, Ladybug, touch. Puoi ispezionare le hitbox da fermo.
engine.isPaused è getter/setter su time.paused. engine.pause() è time.pause(): il loop resta isRunning.
engine.stop() è un altro livello: isRunning = false, time.pause(), cancelAnimationFrame. Niente tick, niente draw. start() (e resume() se il loop era spento) chiama time.resume() + time.begin(performance.now()) e riaggancia rAF. resume() a loop già acceso non chiama begin: il prossimo frame ha un delta normale, non uno spike. È il percorso pausa/F4.
📁 begin e reset
begin(timestamp = performance.now()) allinea #lastTimestamp, marca #hasTimestamp, azzera rawDelta / unscaledDt / dt / accumulatore / alpha. Non tocca elapsed, scale, pausa, FPS, frameCount. È il “non spike-are dopo start, dopo stop, o dopo un buco”. Il motore lo chiama in start e nel resume che riaccende il loop.
reset(timestamp = 0) è una nuova sessione: paused = false, scala tornata a #defaultScale, contatori a zero, accumulatore a zero. Se passi 0 (default), #hasTimestamp è false: il prossimo tick è di nuovo un allineamento. Se passi un timestamp > 0, il prossimo tick calcola già un delta.
Il setter di lastTimestamp (e l’alias motore engine.lastTime) forza #hasTimestamp = true anche con 0. Usalo solo se stai innestando un clock esterno. Per un riavvio pulito, begin o reset.
📁 Accumulator, passi fissi, alpha
fixedDelta default 1/60. maxFixedSteps default 5. In tick, se non sei in pausa, #fixedAccumulator += dt e poi viene tapato a fixedDelta * maxFixedSteps (~83 ms di simulazione fissa). L’eccesso si butta: è la protezione spiral-of-death, non un debito da saldare dopo.
consumeFixedSteps(callback) esegue callback(fixedDelta) finché l’accumulatore copre un passo, sottrae, conta. Ritorna il numero di step. Poi ricalcola alpha = residuo / fixedDelta (in [0, 1) se hai consumato). Se la callback non è una funzione, o fixedDelta <= 0, ritorna 0 senza toccare nulla.
Il loop V2.4 lo chiama già:
Due timestep nello stesso frame: le entity senza body si muovono con dt variabile; BeePhysicsWorld cammina a passi da 1/60. Se l’entità ha un rigid body, integrate() non si muove da sola — lo fa il solver. alpha è lì per interpolare il draw sul residuo; il renderer di stock non lo usa. Dopo tick e prima di consumeFixedSteps, alpha può essere > 1 (accumulatore pieno di più passi). Leggilo dopo il consume, se ti serve per l’interpolazione.
Disallineamento a scala alta. Il cap dell’accumulatore è ~5 passi (~83 ms). Un dt variabile a timeScale 16 può essere 0,8 s: le entity “veloci” avanzano di 0,8 s, la fisica ne simula al massimo ~0,08 s e butta il resto. Non è un bug del clamp: è il tetto maxFixedSteps che non scala con timeScale. Fast-forward estremo e hitch lunghi desincronizzano body e sprite a dt variabile. A scala 1 e hitch già clampati a 50 ms stai dentro il cap (50 ms < 83 ms): un passo o due, niente drop.
📁 Integrazione: BeeEngine, scene, timer, Ladybug
BeeEngine è l’unico owner del tick. Non chiamare time.tick dalla scena. Non costruire un secondo BeeTime “per il menu”: usa unscaledDt sullo stesso orologio, o un BeeTimer con useUnscaledTime: true.
BeeSceneManager.update(dt) parte solo se il motore non è in pausa. Un HUD animato messo in scene.update si ferma con F4. Mettilo in scene.draw (il draw gira sempre) oppure nel callback engine.update, e avanza i tween con time.unscaledDt.
BeeTimer di default somma time.dt (pausa e slow-mo lo fermano / lo rallentano). Con { useUnscaledTime: true } somma unscaledDt. Devi comunque chiamare timer.update(gioco.time) tu: il loop non aggiorna i timer da solo. Se il timer vive in scene.update, in pausa non viene chiamato — l’opzione unscaled non basta se nessuno invoca update.
Non esiste engine.togglePause: chiama gioco.time.togglePause() oppure gioco.pause() / gioco.resume(). Ladybug: F3 alterna slowScale (default 0,25) e 1 (se eri in freeze, prima fa resume); F4 freeze; overlay FPS legge time.fps, durata ciclo unscaledDt in ms, timeScale, stato pausa.
📁 Esempio: scena, slow-mo, HUD immune
📁 Note per chi integra — cosa è chiuso e cosa no
Un tick, un owner. Il timestamp entra da rAF. Non sommare un tuo dt calcolato a mano a elapsed: useresti due orologi.
Pausa ≠ stop ≠ scala 0. Pausa: simulazione ferma, canvas vivo, scene.update saltato. Stop: niente frame. Scala 0: dt zero ma il blocco scene/entity del loop parte lo stesso.
Menu di pausa. Non metterlo in scene.update: F4 lo spegne. Callback engine.update + unscaledDt, oppure input letto nel draw (peggio: mescoli logica e canvas).
Nessun interpolatore nel renderer. alpha è il residuo del solver, non un lerp applicato agli sprite. Se interpoli, fallo tu dopo consumeFixedSteps.
Nessun clamp sul dt scalato. maxDelta taglia il tempo reale. La scala può moltiplicare oltre 50 ms. Il cap fisso (5 passi) può allora droppare fisica rispetto alle entity a dt variabile.
Costruttore e NaN. Options timeScale non clampate. tick senza timestamp avvelena i float. Sono limiti, non “si sistema da solo”.
Non è un clock di rete / audio thread. now() è performance.now() sul main thread. Per l’audio usa l’asse unscaled, non un secondo BeeTime.
📁 Riepilogo API
Fluent: setScale, pause, resume, togglePause, begin, reset, tick ritornano this. I default sono in BEE_TIME_DEFAULTS (oggetto frozen); l’istanza ne tiene copie mutabili.
| Membro | Ritorna | Scopo |
|---|---|---|
BEE_TIME_DEFAULTS |
oggetto frozen | maxDelta 0,05 · timeScale 1 · minTimeScale 0 · maxTimeScale 16 · fixedDelta 1/60 · maxFixedSteps 5 · fpsSampleWindow 0,5. |
new BeeTime(options) |
BeeTime |
Copia i default + options. Non clamp-a timeScale al construct. |
dt / scaledDt |
number |
Delta di simulazione (s). 0 se paused. |
unscaledDt / realDt |
number |
Delta reale clampato a maxDelta. Vive in pausa. |
rawDelta |
number |
Delta grezzo in secondi, prima del clamp. Mai negativo. |
elapsed / unscaledElapsed |
number |
Tempo accumulato di simulazione vs reale. |
frameCount / fps |
number |
Frame da reset. FPS su finestra di tempo reale (0 finché la finestra non è piena). |
alpha |
number |
Residuo / fixedDelta. Utile dopo consumeFixedSteps. |
timeScale |
number |
Getter/setter. Il setter è setScale (clamp + fallback sul default). |
paused |
boolean (readonly) |
Solo via pause / resume / togglePause. |
lastTimestamp |
number |
Ultimo timestamp rAF (ms). Il setter marca l’orologio come già allineato. |
delta(unscaled = false) |
number |
true → unscaled, false → dt. |
setScale(value) |
this |
Clamp [minTimeScale, maxTimeScale]. Non finito → #defaultScale. |
pause() / resume() / togglePause() |
this |
Flag simulazione. Non fermano rAF. F4 = toggle. |
now() |
number |
performance.now(). |
begin(timestamp?) |
this |
Allinea senza azzerare elapsed/scala. Default: performance.now(). |
reset(timestamp = 0) |
this |
Nuova sessione: contatori a zero, scala di default, unpause. |
tick(timestamp) |
this |
Un frame. Timestamp in ms. Chiamare sempre, anche in pausa. |
consumeFixedSteps(fn) |
number |
Esegue fn(fixedDelta) N volte. Il loop lo usa su physics.step. |
maxDelta / fixedDelta / maxFixedSteps |
number |
Pubblici, mutabili. Il cap fisso è fixedDelta * maxFixedSteps. |
