gravity/simulator.js
2025-12-29 16:06:08 -06:00

189 lines
5.3 KiB
JavaScript

import {
DISPLAY_CURRENT_MODE,
DISPLAY_CURRENT_SCALE,
DISPLAY_PANNING_INFO,
EVENT_ZOOM,
SCALE_POWER_MAX,
SCALE_POWER_MIN,
} from './config.js';
import { Display } from './display.js';
import { Objects } from './objects.js';
import { Overlay } from './overlay.js';
import { Pointer } from './pointer.js';
import { Options } from './options.js';
import { ModeSwitch } from './tool/modes.js';
import { OptionsTool } from './tool/options.js';
import { PlayPause } from './tool/play-pause.js';
import { Zoom } from './tool/zoom.js';
import { Toolbar } from './toolbar.js';
import { ToolbarGroup } from './toolbar-group.js';
export class Sim {
info = {};
rawTime = undefined;
time = undefined;
nextZoom = undefined;
playing = true;
objects = undefined;
display = undefined;
overlay = undefined;
pointer = undefined;
panning = undefined;
toolbarGroups = {};
toolbars = {};
isCurrentMode = () => undefined;
getCurrentMode = () => undefined;
setCurrentMode = () => undefined;
getOption = () => undefined;
onModeEnter = () => undefined;
onModeLeave = () => undefined;
constructor(divId) {
this.divId = divId;
const div = document.getElementById(this.divId);
this.div = div;
this.options = new Options(this);
this.display = new Display(this);
this.objects = new Objects(this);
this.toolbarGroups = {
left: new ToolbarGroup(this),
right: new ToolbarGroup(this).topRight(),
};
this.toolbars = {
tools: new Toolbar(this, 'Tools', this.toolbarGroups.left),
modes: new Toolbar(this, 'Modes', this.toolbarGroups.left),
options: new Toolbar(this, 'Options', this.toolbarGroups.right),
params: new Toolbar(this, 'Parameters', this.toolbarGroups.right),
}
this.overlay = new Overlay(this);
this.pointer = new Pointer(this);
{
// Configure toolbars
const { tools, modes, options, params } = this.toolbars;
// Primary Toolbar
tools.addTool(new Zoom(tools));
tools.addTool(new PlayPause(tools));
// Secondary Toolbar: Mode Switches
modes.addTool(new ModeSwitch(modes));
// Options Toolbar
options.addTool(new OptionsTool(options, [
'pauseDuring', 'display', 'collision'
]));
// Parameters Toolbar
params.addTool(new OptionsTool(params, [
'param'
]));
}
// Initiate main loop
this.rawTime = document.timeline.currentTime / 1000;
this.time = 0;
requestAnimationFrame(t => this.loop(t));
}
// It's better not to change the scale in the middle of possible frame calculations,
// so use this to schedule it and let the event loop pick it up.
// velocity should be in Sim coordinate scale
scheduleZoom({x, y}, factor, velocity) {
this.nextZoom = {x, y, factor, velocity};
}
// x, y should be in Sim coordinates
// velocity should be in Sim coordinate scale
zoom({x, y, factor, velocity}) {
// x, y are the mouse coordinates, which should be the center of the new view frame
// the new view origin should be x, y minus half the new view width and height
// compute new scale
this.display.scalePower += factor;
// TODO: Lossy rescaling to expand zoom range
if (this.display.scalePower > SCALE_POWER_MAX) this.display.scalePower = SCALE_POWER_MAX;
if (this.display.scalePower < SCALE_POWER_MIN) this.display.scalePower = SCALE_POWER_MIN;
// compute coordinates of new view frame
this.display.viewOrigin.x = x - this.display.width / 2;
this.display.viewOrigin.y = y - this.display.height / 2;
this.pointer.clearPointerHistory();
if (this.playing && velocity) {
this.panning = {
velocity: {
x: -velocity.x,
y: -velocity.y,
}
};
}
const e = new CustomEvent(EVENT_ZOOM);
this.div.dispatchEvent(e);
}
// Transform display coordinates to simulator coordinates using scale and viewOrigin
screenToSim(x, y) {
return this.display.screenToSim(x, y);
}
play() {
this.playing = true;
}
pause() {
this.playing = false;
}
getScaleDisplay() {
const scale = 2 ** Math.abs(this.display.scalePower);
return this.display.scalePower >= 0 ? `${scale}` : `1/${scale}`;
}
// cb: () => undefined
onZoom(cb) {
this.div.addEventListener(EVENT_ZOOM, () => {
cb();
});
}
// Main loop
loop(currentTime) {
currentTime /= 1000;
const timeScale = this.getOption('param.timeScale');
const elapsedTime = (currentTime - this.rawTime) * timeScale;
this.rawTime = currentTime;
if (this.playing) {
this.time += elapsedTime;
}
if (DISPLAY_CURRENT_MODE) {
this.info['Mode'] = this.getCurrentMode();
}
if (this.nextZoom) {
this.zoom(this.nextZoom);
this.nextZoom = undefined;
}
if (DISPLAY_CURRENT_SCALE) {
this.info['Scale'] = this.getScaleDisplay();
}
if (DISPLAY_PANNING_INFO) {
const {x, y} = this.panning?.velocity ?? {};
this.info['Panning'] = [`${x?.toPrecision(6)}, `, y?.toPrecision(6)];
}
this.display.computePanning(elapsedTime);
this.objects.computeFrame(elapsedTime);
this.overlay.renderInfo();
this.display.fillCanvas();
this.display.drawObjects();
for (const group in this.toolbarGroups) {
this.toolbarGroups[group].frame();
}
requestAnimationFrame(t => this.loop(t));
}
}