BeeEngine JAVASCRIPT 2D ENGINE
Documentazione – BeeEngine
v2.5.0

BeeTime — Documentazione Tecnica

Orologio unico del core loop: un tick per frame, due assi temporali (simulazione e tempo reale)

BeeEngine Mascot

📁 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.

import { BeeEngine, BeeTimer, BEE_TIME_DEFAULTS } from ‘beeengine’; const gioco = new BeeEngine(‘testCanvas’, 800, 600); gioco.start(); gioco.pause(); // mondo fermo, HUD vivo, rAF continua gioco.resume(); gioco.setTimeScale(0.25); // slow-motion gioco.time.togglePause(); // stesso meccanismo di F4

📁 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.

const dt = gioco.time.dt; // simulazione (0 in pausa) const real = gioco.time.unscaledDt; // HUD / audio dello stesso frame gioco.time.delta(true); // identico a unscaledDt

📁 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.

gioco.pause(); // simulazione ferma, canvas vivo gioco.stop(); // niente rAF: schermata congelata sul ultimo frame gioco.start(); // begin() + resume + rAF

📁 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à:

this.time.tick(timestamp); const dt = this.time.dt; if (!this.time.paused) { this.scenes.update(dt, this.input); this.updateEntities(dt, this.input); this.time.consumeFixedSteps((fixedDt) => this.physics.step(fixedDt)); }

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.

const hudTick = new BeeTimer(1, () => {}, true, { useUnscaledTime: true }); hudTick.start(); // nel callback start(update), che gira anche in pausa: gioco.start((dt, input, time) => { hudTick.update(time); });

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

import { BeeEngine, BeeTimer } from ‘beeengine’; const gioco = new BeeEngine(‘testCanvas’, 800, 600); gioco.enableLadybug(); const fadePausa = new BeeTimer(0.25, null, false, { useUnscaledTime: true }); const livello = { onEnter() { /* spawn */ }, update(dt, input, engine) { // qui dt è di simulazione; in freeze questo hook NON parte if (input.wasPressed(‘KeyP’)) engine.setTimeScale(0.25); }, draw(ctx, engine) { ctx.fillStyle = ‘#e2e8f0’; ctx.fillText(‘game ‘ + engine.time.elapsed.toFixed(1) + ‘s’, 16, 24); ctx.fillText(‘real ‘ + engine.time.unscaledElapsed.toFixed(1) + ‘s’, 16, 44); } }; gioco.scenes.add(‘game’, livello).change(‘game’); gioco.start((dt, input, time) => { fadePausa.update(time); if (input.wasPressed(‘Escape’)) { time.togglePause(); fadePausa.start(); } });

📁 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.