elation-engine
Version:
WebGL/WebVR engine written in Javascript
752 lines (724 loc) • 25.5 kB
JavaScript
var deps = [
"engine.parts",
"engine.materials",
"engine.assets",
"engine.geometries",
//"engine.sharing",
"engine.things.generic",
"engine.things.menu",
"engine.systems.ai",
"engine.systems.controls",
"engine.systems.physics",
"engine.systems.sound",
"engine.systems.world",
"utils.math",
"ui.base"
];
if (true || elation.env.isBrowser) {
deps = deps.concat([
"engine.external.three.three",
//"engine.sharing",
"engine.systems.render",
//"engine.systems.admin",
]);
} else if (elation.env.isNode) {
deps.push("engine.external.three.three");
}
elation.require(deps, function() {
elation.requireCSS('engine.engine');
elation.requireCSS('ui.themes.dark');
elation.extend("engine.instances", {});
elation.extend("engine.create", function(name, systems, callback) {
var engine = new elation.engine.main(name);
elation.events.add(engine.systems, 'engine_systems_added', function() { callback(engine); })
engine.systems.add(systems);
this.instances[name] = engine;
return engine;
});
elation.extend("engine.main", function(name, client) {
this.started = false;
this.running = false;
this.name = name;
this.useAnimationFrame = true;
this.targetFramerate = 60;
this.scratchobjects = {};
this.init = function() {
this.client = client;
this.systems = new elation.engine.systemmanager(this);
// shutdown cleanly if the user leaves the page
var target = null;
if (elation.env.isBrowser) target = window
elation.events.add(target, "unload", elation.bind(this, this.stop));
elation.engine.assets.init();
elation.events.fire({element: this, type: "engine_init"});
}
this.start = function() {
this.started = this.running = true;
elation.events.fire({element: this, type: "engine_start"});
this.lastupdate = performance.now();
// Start run loop, passing in current time
this.run(0);
}
this.stop = function() {
if (this.running) {
this.running = false;
elation.events.fire({element: this, type: "engine_stop"});
}
}
this.run = function(ts, xrpose) {
// recursively request another frame until we're no longer running
//if (!ts) ts = new Date().getTime();
//let xrspace = this.systems.render.views.main.xrspace; // FIXME - hacky
let nextframetime = this.advance();
if (this.running) {
if (!this.boundfunc) this.boundfunc = elation.bind(this, this.run);
this.frame(this.boundfunc, nextframetime);
}
}
this.advance = function() {
let ts = performance.now();
var evdata = {
ts: ts,
delta: (ts - this.lastupdate) / 1000,
//pose: xrpose,
//xrspace: xrspace
};
// fire engine_frame event, which kicks off processing in any active systems
//console.log("==========");
elation.events.fire({type: "engine_frame", element: this, data: evdata});
let dt = ts - this.lastupdate;
this.lastupdate = ts;
return (1000 / this.targetFramerate) - dt;
}
this.frame = function(fn, nextframetime) {
// Advance the engine by one frame
// Note that the render loop runs separately using requestAnimationFrame()
//setTimeout(fn, nextframetime);
//requestAnimationFrame(fn);
}
// Convenience functions for querying objects from world
this.getThingsByTag = function(tag) {
return this.systems.world.getThingsByTag(tag);
}
this.getThingsByType = function(type) {
return this.systems.world.getThingsByType(type);
}
this.getThingByObject = function(obj) {
return this.systems.world.getThingsByObject(object);
}
this.getThingsByProperty = function(key, value) {
return this.systems.world.getThingsByProperty(key, value);
}
this.getThingById = function(id) {
return this.systems.world.getThingById(id);
}
this.getScratchObject = function(name, type) {
if (!this.scratchobjects[name]) {
this.scratchobjects[name] = (type ? new type() : {});
}
return this.scratchobjects[name];
}
this.init();
});
elation.extend("engine.systemmanager", function(args) {
this._engine = args;
this.active = {};
elation.events.add(this._engine, "engine_stop", elation.bind(this, this.shutdown));
this.add = function(names, args) {
// register and initialize new systems, which will respond to events emitted by the engine
if (!elation.utils.isArray(names)) {
names = [names];
}
var systems = {};
var requires = names.map(function(a) { return "engine.systems." + a; });
elation.require(requires, elation.bind(this, function() {
for (var i = 0; i < names.length; i++) {
var name = names[i];
systems[name] = this[name] = new elation.engine.systems[name](args);
this[name].attach(this._engine);
}
setTimeout(elation.bind(this, function() {
elation.events.fire({element: this, type: 'engine_systems_added'});
}), 0);
}));
return systems;
}
this.get = function(name) {
return this[name];
}
this.shutdown = function() {
console.log("shut down all the systems!");
}
});
elation.extend("engine.systems.system", function(args) {
this.engineevents = "engine_start,engine_frame,engine_stop";
this.attach = function(engine) {
this.engine = engine;
elation.events.add(this, "system_attach,system_detach", this);
elation.events.add(this.engine, this.engineevents, this);
elation.events.fire({element: this, type: "system_attach"});
}
this.detach = function() {
this.engine = false;
elation.events.remove(this.engine, this.engineevents, this);
elation.events.fire({element: this, type: "system_detach"});
}
this.handleEvent = function(ev) {
if (typeof this[ev.type] == 'function') {
this[ev.type](ev);
}
}
});
if (0) {
elation.component.add("engine.configuration", function() {
this.init = function() {
this.client = this.args.client;
this.engine = this.client.engine;
this.view = this.client.view;
this.create();
this.addclass('engine_configuration');
}
this.create = function() {
var panels = this.initPanels();
var configtabs = elation.ui.tabbedcontent({
append: this,
items: panels
});
this.tabs = configtabs;
}
this.initPanels = function(panels) {
if (!panels) panels = {};
/* Control Settings */
panels['controls'] = {
label: 'Controls',
content: elation.engine.systems.controls.config({
controlsystem: this.engine.systems.controls
})
};
/* Video Settings */
panels['video'] = {
label: 'Video',
content: elation.engine.systems.render.config({
client: this.client,
rendersystem: this.engine.systems.render,
})
};
/* Sound Settings */
panels['sound'] = {
label: 'Sound',
content: elation.engine.systems.sound.config({
client: this.client
})
};
/* Share Settings */
/*
panels['sharing'] = {
label: 'Sharing',
content: elation.engine.sharing.config({
client: this.client
});
};
*/
return panels;
}
this.addPanel = function(name, component) {
this.tabs.add(name, {label: name, content: component});
}
this.toggleFullscreen = function() {
var view = this.view;
if (view) {
view.toggleFullscreen();
}
}
this.toggleVR = function() {
var view = this.view;
if (view) {
var mode = (view.rendermode == 'default' ? 'oculus' : 'default');
view.setRenderMode(mode);
}
}
this.calibrateVR = function() {
if (this.engine.systems.controls) {
this.engine.systems.controls.calibrateHMDs();
}
}
}, elation.ui.panel);
}
elation.elements.define('engine.client', class extends elation.elements.base {
init() {
super.init()
this.defineAttributes({
name: { type: 'string', default: 'default' },
resolution: { type: 'string' },
fullsize: { type: 'boolean', default: false },
crosshair: { type: 'boolean', default: false },
picking: { type: 'boolean', default: false },
stats: { type: 'boolean', default: false },
useWebVRPolyfill: { type: 'boolean', default: false },
engine: { type: 'object' },
});
if (this.fullsize == 'false') this.fullsize = false; // FIXME - the type coersion should be doing this for us
//this.name = this.args.name || 'default';
//this.setEngineConfig(this.args);
}
create() {
super.create();
this.enginecfg = {
systems: [
"physics",
"world",
"ai",
//"admin",
"render",
"sound",
"controls"
],
crosshair: true,
stats: false,
picking: true,
fullsize: this.fullsize && this.fullsize != 'false',
resolution: (this.resolution ? this.resolution.split('x') : []),
useWebVRPolyfill: true,
enablePostprocessing: true
};
this.initEngine();
this.loadEngine();
}
// Set up engine parameters before creating. To be overridden by extending class
initEngine() {
var hashargs = elation.url();
this.enginecfg.systems = [];
this.enginecfg.systems.push("controls");
this.enginecfg.systems.push("physics");
this.enginecfg.systems.push("world");
this.enginecfg.systems.push("ai");
if (hashargs.admin == 1) {
//this.enginecfg.systems.push("admin");
}
this.enginecfg.systems.push("render");
this.enginecfg.systems.push("sound");
// Register sessiongranted event handler, to automatically enter XR for in-XR navigation
if ('xr' in navigator) {
navigator.xr.addEventListener('sessiongranted', (ev) => {
this.startXR();
});
}
}
setEngineConfig(args) {
var cfg = this.enginecfg;
if (args.resolution) {
cfg.resolution = args.resolution.split('x');;
cfg.fullsize = false;
}
if (args.fullsize !== undefined) cfg.fullsize = args.fullsize && args.fullsize != 'false'; // FIXME - type coersion should be doing this
if (args.crosshair !== undefined) cfg.crosshair = args.crosshair;
if (args.picking !== undefined) cfg.picking = args.picking;
if (args.stats !== undefined) cfg.stats = args.stats;
if (args.useWebVRPolyfill !== undefined) cfg.useWebVRPolyfill = args.useWebVRPolyfill;
}
// Instantiate the engine
loadEngine() {
let engine = new elation.engine.main(this.name, this);
this.engine = engine;
elation.events.add(engine.systems, 'engine_systems_added', ev => this.startEngine(engine));
engine.systems.add(this.enginecfg.systems);
elation.engine.instances[this.name] = engine;
}
initWorld() {
// Virtual stub - inherit from elation.engine.client, then override this for your app
var worldurl = elation.utils.arrayget(this.args, 'world.url');
var worlddata = elation.utils.arrayget(this.args, 'world.data');
var parsedurl = elation.utils.parseURL(document.location.hash);
if (worldurl && !(parsedurl.hashargs && parsedurl.hashargs['world.url'])) {
this.engine.systems.world.loadSceneFromURL(worldurl);
} else if (worlddata) {
this.engine.systems.world.load(worlddata);
}
}
startEngine(engine) {
this.world = engine.systems.world; // shortcut
this.style.overflow = 'hidden';
try {
var cfg = this.enginecfg;
this.view = elation.elements.create('engine.systems.render.view', {
name: 'main',
append: this,
fullsize: cfg.fullsize,
resolution: cfg.resolution,
picking: cfg.picking,
engine: this.name,
showstats: cfg.stats,
crosshair: cfg.crosshair,
useWebVRPolyfill: cfg.useWebVRPolyfill,
enablepostprocessing: cfg.enablePostprocessing
} );
this.initWorld();
this.initControls();
engine.start();
} catch (e) {
console.error(e);
elation.events.fire({element: this, type: 'engine_error', data: e});
}
}
initControls() {
this.controlstate = this.engine.systems.controls.addContext(this.name, {
//'menu': ['keyboard_esc,gamepad_0_button_9', elation.bind(this, this.toggleMenu)],
'share_screenshot': ['keyboard_ctrl_period', elation.bind(this, this.shareScreenshot)],
//'share_gif': ['keyboard_period', elation.bind(this, this.shareMP4)],
'pointerlock': ['pointerlock', elation.bind(this, this.togglePointerLock)],
'vr_toggle': ['keyboard_ctrl_rightsquarebracket', elation.bind(this, this.toggleVR)],
'vr_calibrate': ['keyboard_ctrl_leftsquarebracket', elation.bind(this, this.calibrateVR)],
});
this.engine.systems.controls.activateContext(this.name);
}
setActiveThing(thing) {
this.engine.systems.render.views.main.setactivething(thing);
if (thing.ears) {
this.engine.systems.sound.setActiveListener(thing.ears);
}
}
getPlayer() {
if (!this.player) {
var players = this.engine.systems.world.getThingsByTag('player');
if (players && players.length > 0) {
this.player = players[0];
}
}
return this.player;
}
showMenu() {
var player = this.getPlayer();
if (player){
if (!this.menu) {
this.menu = player.camera.spawn('menu', null, {
position: [0,0,-0.2],
items: [
/*
{
text: 'Intro',
callback: elation.bind(this, this.startIntro),
disabled: true
},
*/
{
text: 'Play',
callback: elation.bind(this, this.startGame)
},
{
text: 'Options',
callback: elation.bind(this, this.configureOptions),
},
{
text: 'About',
callback: elation.bind(this, this.showAbout),
},
/*
{
text: 'Quit',
disabled: true
}
*/
],
labelcfg: {
size: .01,
lineheight: 1.5,
color: 0x999999,
hovercolor: 0x003300,
disabledcolor: 0x000000,
disabledhovercolor: 0x330000,
}
});
} else {
player.camera.add(this.menu);
}
player.disable();
this.menu.enable();
this.menu.refresh();
player.refresh();
this.menuShowing = true;
}
}
hideMenu() {
var player = this.getPlayer();
if (player && this.menu) {
player.camera.remove(this.menu);
if (this.configmenu) this.configmenu.hide();
//if (this.loaded) {
//player.enable();
//}
this.menuShowing = false;
this.menu.disable();
}
}
toggleMenu(ev) {
if (ev.value == 1) {
if (this.menuShowing) {
this.hideMenu();
} else {
this.showMenu();
}
}
}
togglePointerLock(ev) {
if (ev.value == 0) {
this.showMenu();
}
}
toggleFullscreen(ev) {
var view = this.view;
if (view && (typeof ev == 'undefined' || ev.value == 1 || typeof ev.value == 'undefined')) {
view.toggleFullscreen();
}
}
configureOptions() {
if (!this.configmenu) {
var configpanel = elation.engine.configuration({client: this});
this.configmenu = elation.ui.window({
append: document.body,
classname: this.name + '_config',
center: true,
resizable: false,
title: 'Configuration',
controls: true,
maximize: false,
minimize: false,
content: configpanel
});
}
this.configmenu.show();
}
startGame() {
this.hideMenu();
}
showAbout() {
}
createSharePicker() {
}
initSharing() {
this.sharedialog = elation.engine.sharing({append: document.body, client: this});
}
shareScreenshot(ev) {
if (typeof ev == 'undefined' || ev.value == 1) {
if (!this.sharedialog) {
this.sharedialog = elation.engine.sharing({append: document.body, client: this});
} else {
this.sharedialog.show();
}
this.sharedialog.share();
return;
/*
if (!this.sharepicker) {
this.createSharePicker();
}
var recorder = this.view.recorder;
recorder.captureJPG().then(elation.bind(this, function(data) {
var img = data.image.data;
this.sharepicker.share({
name: this.getScreenshotFilename('jpg'),
type: 'image/jpeg',
image: img,
});
var now = new Date().getTime();
console.log('finished jpg in ' + data.time.toFixed(2) + 'ms');
}));
*/
//recorder.capturePNG().then(elation.bind(this, function(data) {
this.screenshot({format: 'png'}).then(elation.bind(this, function(data) {
var imgdata = data.split(',')[1]; //data.image.data;
var bytestr = atob(imgdata);
var img = new Uint8Array(bytestr.length);
for (var i = 0; i < bytestr.length; i++) {
img[i] = bytestr.charCodeAt(i);
}
this.player.disable();
this.sharepicker.share({
name: this.getScreenshotFilename('png'),
type: 'image/png',
image: img,
}).then(elation.bind(this, function(upload) {
//this.player.enable();
}));
var now = new Date().getTime();
//console.log('finished png in ' + data.time.toFixed(2) + 'ms');
console.log('finished png');
}));
}
}
shareGif(ev) {
if (typeof ev == 'undefined' || ev.value == 1) {
if (!this.sharepicker) {
this.createSharePicker();
}
var recorder = this.view.recorder;
recorder.captureGIF(1920, 1080, 1, 200).then(elation.bind(this, function(data) {
var img = data.file;
this.sharepicker.share({
name: this.getScreenshotFilename('gif'),
type: 'image/gif',
image: img,
});
var now = new Date().getTime();
console.log('finished gif in ' + data.time.toFixed(2) + 'ms');
}));
}
}
shareMP4(ev) {
if (typeof ev == 'undefined' || ev.value == 1) {
if (!this.sharepicker) {
this.createSharePicker();
}
var recorder = this.view.recorder;
recorder.captureMP4(640, 360, 25, 30).then(elation.bind(this, function(data) {
var img = data.file;
this.sharepicker.share({
name: this.getScreenshotFilename('mp4'),
type: 'video/mp4',
image: img,
});
var now = new Date().getTime();
console.log('finished mp4 in ' + data.time.toFixed(2) + 'ms');
}));
}
}
getScreenshotFilename(extension) {
if (!extension) extension = 'png';
var now = new Date();
function pad(n) {
if (n < 10) return '0' + n;
return n;
}
var prefix = elation.config.get('engine.screenshot.prefix', 'screenshot');
var date = now.getFullYear() + '-' + pad(now.getMonth() + 1) + '-' + pad(now.getDate());
var time = pad(now.getHours()) + ':' + pad(now.getMinutes()) + ':' + pad(now.getSeconds());
var filename = prefix + '-' + date + ' ' + time + '.' + extension
return filename;
}
screenshot(args) {
return this.view.screenshot(args);
}
startXR(mode="immersive-vr", xroptions) {
if (!this.xrsession) {
if (!xroptions) {
xroptions = {
optionalFeatures: [ 'local-floor', 'bounded-floor', 'hand-tracking', 'layers' ]
};
}
if (!this.xrwindow) {
this.xrwindow = elation.elements.create('ui-window', {
bottom: true,
center: true,
controls: 0,
className: 'webxr_notification',
resizable: 0,
//content: 'WebXR active. Please put on your headset',
append: this.engine.systems.render.views.main,
});
} else {
this.xrwindow.show();
}
this.xrwindow.setcontent('Initializing WebXR session...');
navigator.xr.requestSession(mode, xroptions).then(async (session) => {
let xr = this.engine.systems.render.renderer.xr;
this.xrsession = session;
await xr.setSession(session);
xr.enabled = true;
/*
// TODO - Oculus' OpenVR support currently (Oct 2020) ends the session when the HMD is removed, instead of sending a visibilitychange event. We should revisit this once they've fixed it.
session.addEventListener('visibilitychange', (ev) => {
console.log('visibility changed', ev);
if (session.visibilityState == 'visible') {
xr.enabled = true;
xr.setSession(session);
} else {
xr.enabled = false;
xr.setSession(null);
}
});
*/
//session.requestAnimationFrame(() => {
// Create our new render view on the first XR frame, so the session has time to initialize properly
if (!this.engine.systems.render.views.xr) {
let view = elation.elements.create('engine.systems.render.view', {
name: 'xr',
append: this,
fullsize: false,
picking: true,
engine: this.engine.name,
showstats: false,
crosshair: false,
useWebVRPolyfill: false,
enablepostprocessing: false,
xrsession: session,
} );
view.enablepostprocessing = false; // FIXME - not sure why I have to explicitly set this here after passing it in via constructor
this.engine.systems.render.view_add('xr', view);
this.xrview = view;
this.engine.systems.render.views.main.enabled = false;
console.log('new xr guy', view, this.engine.systems.render.views);
} else {
this.engine.systems.render.views.main.enabled = false;
this.engine.systems.render.views.xr.enabled = true;
this.engine.systems.render.views.xr.setXRSession(session);
console.log('set new immersive session', session);
if (this.xrplayer) {
this.xrplayer.setSession(session, this.engine.systems.render.views.xr);
}
}
session.addEventListener('end', (ev) => this.handleXRend());
elation.html.addclass(this.engine.systems.render.views.main, 'webxr_session_active');
this.xrwindow.setcontent('WebXR session is active. Please put on your headset.');
elation.events.fire({element: this, type: 'xrsessionstart', data: session});
//});
});
}
if (this.engine.systems.sound) {
this.engine.systems.sound.enableSound();
}
}
handleXRend(ev) {
this.engine.systems.render.views.main.enabled = true;
this.engine.systems.render.views.xr.enabled = false;
this.engine.systems.render.views.xr.handleXRend(ev);
elation.html.removeclass(this.engine.systems.render.views.main, 'webxr_session_active');
console.log('xr session stopped', this.xrsession);
this.xrsession = false;
if (this.xrplayer) {
this.xrplayer.setSession(false);
}
if (this.xrwindow) {
this.xrwindow.setcontent('WebXR session ended');
this.xrwindow.hide();
}
elation.events.fire({element: this, type: 'xrsessionend'});
}
stopXR() {
//this.handleXRend();
if (this.xrsession) {
try {
this.xrsession.end();
} catch (e) {
console.log('Tried to end XR session that was already ended');
this.xrsession = false;
};
}
//this.engine.systems.render.views.main.enabled = true;
//this.engine.systems.render.views.xr.enabled = false;
//this.xrsession = false;
}
});
elation.component.add('engine.server', function() {
this.init = function() {
this.name = this.args.name || 'default';
this.engine = elation.engine.create(this.name, ['physics', 'world', 'server'], elation.bind(this, this.startEngine));
}
this.initWorld = function() {
// Virtual stub - inherit from elation.engine.server, then override this for your app
}
this.startEngine = function(engine) {
this.engine = engine;
this.world = this.engine.systems.world; // shortcut
this.initWorld();
engine.start();
}
});
});