gravity/simulator.js
2026-01-29 11:28:39 -06:00

212 lines
5.8 KiB
JavaScript

import {
EVENT_PLAY_PAUSE,
EVENT_ZOOM,
FRAMERATE_SAMPLE_DURATION,
} from './config.js';
import {Display} from './display.js';
import {Options} from './options.js';
import {Overlay} from './overlay.js';
import {Panning} from './panning.js';
import {Pointer} from './pointer.js';
import {Select} from './select.js';
import {simOptions} from './sim-options.js';
import {initializeTools} from './sim-tools.js';
import {System} from './system.js';
import {Zoom} from './zoom.js';
export class Sim {
info = {};
rawTime = undefined;
time = undefined;
timeScale = undefined;
nextZoom = undefined;
playing = true;
recentFrames = [];
frameRate = 0;
system = undefined;
display = undefined;
overlay = undefined;
pointer = undefined;
zoom = undefined;
panning = undefined;
select = undefined;
toolbarGroups = {};
toolbars = {};
isCurrentMode = () => undefined;
getCurrentMode = () => undefined;
setCurrentMode = () => undefined;
getOption = () => undefined;
setOption = () => undefined;
onModeEnter = () => undefined;
onModeLeave = () => undefined;
constructor(divId) {
this.divId = divId;
const div = document.getElementById(this.divId);
this.div = div;
const url = new URL(window.location.href);
if (url.host === 'gravity.dev.laddhoffman.com') {
document.title += ' [Dev]';
} else if (url.host === 'gravity.local') {
document.title += ' [Local]';
}
this.options = new Options(this, simOptions);
this.display = new Display(this);
this.system = new System(this);
this.overlay = new Overlay(this);
this.pointer = new Pointer(this);
this.zoom = new Zoom(this);
this.panning = new Panning(this);
this.select = new Select(this);
initializeTools(this);
// Initiate main loop
this.rawTime = document.timeline.currentTime;
this.time = 0;
requestAnimationFrame(t => this.frame(t));
}
// Main loop
frame(currentTime) {
const early = this.markFrame(currentTime);
if (early) {
// Slow down :)
requestAnimationFrame(t => this.frame(t));
return;
}
this.timeScale = this.getOption('param.timeScale');
const elapsedTime = (currentTime - this.rawTime) * this.timeScale;
this.rawTime = currentTime;
if (this.playing) {
this.time += elapsedTime;
}
if (this.getOption('debug.currentMode')) {
this.info['Mode'] = this.getCurrentMode();
}
if (this.getOption('debug.frameRate')) {
this.info['Frame Rate'] = this.frameRate.toPrecision(3);
}
this.zoom.frame(elapsedTime);
this.panning.frame(elapsedTime);
this.pointer.frame(elapsedTime);
// The display.frame() wipes out the canvas, so all
// main canvas drawing routines must come after the next line.
this.display.frame(elapsedTime);
this.select.frame(elapsedTime);
// The system.frame() renders objects
this.system.frame(elapsedTime);
this.overlay.frame(elapsedTime);
for (const group in this.toolbarGroups) {
this.toolbarGroups[group].frame(elapsedTime);
}
// Schedule our next iteration
// TODO: Consider waiting until the next frame is likely to execute,
// in order to aim closer to the target frame rate
requestAnimationFrame(t => this.frame(t));
}
// Record frame timestamp information
markFrame(t) {
const {recentFrames: rfs} = this;
if (!rfs.length) {
rfs.push(t);
return;
}
let oldest = rfs[0];
let duration = t - oldest;
const count = rfs.length + 1;
const frameRate = 1000 * count / duration;
const targetFrameRate = this.getOption('display.targetFrameRate');
if (frameRate > targetFrameRate + 1) {
return true;
}
this.frameRate = frameRate;
rfs.push(t);
while (duration >= FRAMERATE_SAMPLE_DURATION) {
rfs.shift();
oldest = rfs[0];
duration = t - oldest;
}
return false;
}
// toJSON is used to export the state of the simulation
toJSON() {
return {
dateSaved: new Date().toISOString(),
system: this.system.toJSON(),
panning: this.panning.toJSON(),
display: this.display.toJSON(),
time: this.time,
timeScale: this.timeScale,
currentMode: this.getCurrentMode(),
};
}
// fromJSON is used to import the state of the simulation
fromJSON(state) {
this.pause();
this.system.fromJSON(state.system);
this.panning.fromJSON(state.panning);
this.display.fromJSON(state.display);
this.time = state.time;
this.timeScale = state.timeScale;
this.setCurrentMode(state.currentMode);
}
// velocity should be in Sim coordinate scale
scheduleZoom({x, y}, factor, velocity) {
this.zoom.scheduleZoom({x, y}, factor, velocity);
}
// Transform display coordinates to simulator coordinates using scale and viewOrigin
screenToSim(x, y) {
return this.display.screenToSim(x, y);
}
// Start the simulation if it's not already running
play() {
this.playing = true;
if (this.panning.paused) {
this.panning.paused = false;
}
const e = new CustomEvent(EVENT_PLAY_PAUSE, {detail: {playing: this.playing}});
this.div.dispatchEvent(e);
}
// Pause the simulation if it's currently running
pause() {
this.playing = false;
if (this.panning?.velocity) {
this.panning.paused = true;
}
const e = new CustomEvent(EVENT_PLAY_PAUSE, {detail: {playing: this.playing}});
this.div.dispatchEvent(e);
}
// Generate the display content representing the current scale of the simulation
getScaleDisplay() {
const scale = 2 ** Math.abs(this.display.scalePower);
const scaleText = this.display.scalePower >= 0 ? `${scale}` : `1/${scale}`;
return `${scaleText} (${this.display.scalePower})`;
}
// cb: () => undefined
onZoom(cb) {
this.div.addEventListener(EVENT_ZOOM, () => {
cb();
});
}
}