phoboslab / Impact

HTML5 Game Engine
MIT License
1.99k stars 204 forks source link

Feature request: bake.js, a NodeJS alternative to bake.php #87

Open Joncom opened 3 years ago

Joncom commented 3 years ago

Would be nice to be able to bake games without having PHP installed.

Just have a bake.js file that does everything that bake.php does.

phoboslab commented 3 years ago

Related to this, I wrote a minimal implementation of the API to support Weltmeister with NodeJS for my Impact2 branch a few years ago. In my typical style it doesn't need any external packages (no npm). Some paths may need to be adjusted for Impact1. It's supposed to sit in the main directory as server.js and you'd start the server for Weltmeister with node server.js.

https://gist.github.com/phoboslab/0bf09acb2ea4912d621ae80c439a4bba

So, just an idea for an (imho) nice and clean solution: how about we rename this to impact.js, put it in the main directory and add the baking functionality. You could then call it with:

Edit: looking at this again, it would need some more adjustments. The current Weltmeister API distinguished between a browse and glob command, which could both be served through the new glob command of the server.js alone. This requires some changes in the weltmeister.js and modal-dialogs.js, though. Not sure if it's worth the effort.

Joncom commented 3 years ago

I kind of like the simplicity and separation of concerns of having:

Edit: I like your server.js. It looks like a simpler version of impact-dev-server, which I've been using.

Joncom commented 3 years ago

Edit: looking at this again, it would need some more adjustments. The current Weltmeister API distinguished between a browse and glob command, which could both be served through the new glob command of the server.js alone. This requires some changes in the weltmeister.js and modal-dialogs.js, though. Not sure if it's worth the effort.

I got it working.

Here's a patch to get Weltmeister running off node server.js. It looks bigger than it really is, due to indentation.

diff --git a/lib/weltmeister/config.js b/lib/weltmeister/config.js
index 8d99632..cf29d1b 100644
--- a/lib/weltmeister/config.js
+++ b/lib/weltmeister/config.js
@@ -119,7 +119,6 @@ wm.config = {
    // to change these.
    'api': {
        'save': 'lib/weltmeister/api/save.php',
-       'browse': 'lib/weltmeister/api/browse.php',
        'glob': 'lib/weltmeister/api/glob.php'
    }
 };
diff --git a/lib/weltmeister/entities.js b/lib/weltmeister/entities.js
index 68fa638..5f7fd8e 100644
--- a/lib/weltmeister/entities.js
+++ b/lib/weltmeister/entities.js
@@ -7,52 +7,49 @@ ig.module(
 .defines(function(){ "use strict";

 // Load the list of entity files via AJAX PHP glob
-var path = wm.config.api.glob + '?',
+var moduleNames = [],
+    modules = {},
     globs = typeof wm.config.project.entityFiles == 'string' ? 
         [wm.config.project.entityFiles] : 
         wm.config.project.entityFiles;
-    
-for (var i = 0; i < globs.length; i++) {
-    path += 'glob[]=' + encodeURIComponent(globs[i]) + '&';
-}
-
-path += 'nocache=' + Math.random();
-   
-var req = $.ajax({
-   url: path, 
-   method: 'get',
-   dataType: 'json',

-   // MUST load synchronous, as the engine would otherwise determine that it
-   // can't resolve dependencies to weltmeister.entities when there are
-   // no more files to load and weltmeister.entities is still not defined
-   // because the ajax request hasn't finished yet.
-   // FIXME FFS!
-   async: false, 
-   success: function(files) {
+for (let i = 0; i < globs.length; i++) {
+   var dir = globs[i].substr(0, globs[i].indexOf('*'));
+   var req = $.ajax({
+       url: wm.config.api.glob + '?dir=' + encodeURIComponent(dir) + '&type=js',
+       method: 'get',
+       dataType: 'json',

-       // File names to Module names
-       var moduleNames = [];
-       var modules = {};
-       for( var i = 0; i < files.length; i++ ) {
-           var name = files[i]
-               .replace(new RegExp("^"+ig.lib+"|\\.js$", "g"), '')
-               .replace(/\//g, '.');
-           moduleNames.push( name );
-           modules[name] = files[i];
+       // MUST load synchronous, as the engine would otherwise determine that it
+       // can't resolve dependencies to weltmeister.entities when there are
+       // no more files to load and weltmeister.entities is still not defined
+       // because the ajax request hasn't finished yet.
+       // FIXME FFS!
+       async: false, 
+       success: function(data) {
+           
+           // File names to Module names
+           var files = data.files;
+           for( var i = 0; i < files.length; i++ ) {
+               var name = files[i]
+                   .replace(new RegExp("^"+ig.lib+"|\\.js$", "g"), '')
+                   .replace(/\//g, '.');
+               moduleNames.push( name );
+               modules[name] = files[i];
+           }
+       },
+       error: function( xhr, status, error ){
+           throw( 
+               "Failed to load entity list via glob.php: " + error + "\n" +
+               xhr.responseText
+           );
        }
-       
-       // Define a Module that requires all entity Modules
-       ig.module('weltmeister.entities')
-           .requires.apply(ig, moduleNames)
-           .defines(function(){ wm.entityModules = modules; });
-   },
-   error: function( xhr, status, error ){
-       throw( 
-           "Failed to load entity list via glob.php: " + error + "\n" +
-           xhr.responseText
-       );
-   }
-});
+   });
+}
+
+// Define a Module that requires all entity Modules
+ig.module('weltmeister.entities')
+   .requires.apply(ig, moduleNames)
+   .defines(function(){ wm.entityModules = modules; });

 });
\ No newline at end of file
diff --git a/lib/weltmeister/modal-dialogs.js b/lib/weltmeister/modal-dialogs.js
index 53ff3cd..cc920bd 100644
--- a/lib/weltmeister/modal-dialogs.js
+++ b/lib/weltmeister/modal-dialogs.js
@@ -93,7 +93,7 @@ wm.ModalDialogPathSelect = wm.ModalDialog.extend({
        this.parent();
        this.pathInput = $('<input/>', {'type': 'text', 'class': 'modalDialogPath'} );
        this.buttonDiv.before( this.pathInput );
-       this.pathDropdown = new wm.SelectFileDropdown( this.pathInput, wm.config.api.browse, this.fileType );
+       this.pathDropdown = new wm.SelectFileDropdown( this.pathInput, wm.config.api.glob, this.fileType );
    },

diff --git a/lib/weltmeister/select-file-dropdown.js b/lib/weltmeister/select-file-dropdown.js
index 6975a1d..4dfbb83 100644
--- a/lib/weltmeister/select-file-dropdown.js
+++ b/lib/weltmeister/select-file-dropdown.js
@@ -59,14 +59,12 @@ wm.SelectFileDropdown = ig.Class.extend({
            this.div.append( parentDir );
        }
        for( var i = 0; i < data.dirs.length; i++ ) {
-           var name = data.dirs[i].match(/[^\/]*$/)[0] + '/';
-           var dir = $('<a/>', {'class':'dir', href:data.dirs[i], html: name, title: name});
+           var dir = $('<a/>', {'class':'dir', href: data.dirs[i].path, html: data.dirs[i].name, title: data.dirs[i].name});
            dir.bind( 'click', this.selectDir.bind(this) );
            this.div.append( dir );
        }
        for( var i = 0; i < data.files.length; i++ ) {
-           var name = data.files[i].match(/[^\/]*$/)[0];
-           var file = $('<a/>', {'class':'file', href:data.files[i], html: name, title: name});
+           var file = $('<a/>', {'class':'file', href: data.files[i].path, html: data.files[i].name, title: data.files[i].name});
            file.bind( 'click', this.selectFile.bind(this) );
            this.div.append( file );
        }
diff --git a/lib/weltmeister/weltmeister.js b/lib/weltmeister/weltmeister.js
index 772e531..ceb70a3 100644
--- a/lib/weltmeister/weltmeister.js
+++ b/lib/weltmeister/weltmeister.js
@@ -64,13 +64,13 @@ wm.Weltmeister = ig.Class.extend({

        // Dialogs
-       this.loadDialog = new wm.ModalDialogPathSelect( 'Load Level', 'Load', 'scripts' );
+       this.loadDialog = new wm.ModalDialogPathSelect( 'Load Level', 'Load', wm.Weltmeister.levelType );
        this.loadDialog.onOk = this.load.bind(this);
        this.loadDialog.setPath( wm.config.project.levelPath );
        $('#levelLoad').bind( 'click', this.showLoadDialog.bind(this) );
        $('#levelNew').bind( 'click', this.showNewDialog.bind(this) );

-       this.saveDialog = new wm.ModalDialogPathSelect( 'Save Level', 'Save', 'scripts' );
+       this.saveDialog = new wm.ModalDialogPathSelect( 'Save Level', 'Save', wm.Weltmeister.levelType );
        this.saveDialog.onOk = this.save.bind(this);
        this.saveDialog.setPath( wm.config.project.levelPath );
        $('#levelSaveAs').bind( 'click', this.saveDialog.open.bind(this.saveDialog) );
@@ -84,7 +84,7 @@ wm.Weltmeister = ig.Class.extend({
        this.mode = this.MODE.DEFAULT;

-       this.tilesetSelectDialog = new wm.SelectFileDropdown( '#layerTileset', wm.config.api.browse, 'images' );
+       this.tilesetSelectDialog = new wm.SelectFileDropdown( '#layerTileset', wm.config.api.glob, wm.Weltmeister.imageType );
        this.entities = new wm.EditEntities( $('#layerEntities') );

        $('#layers').sortable({
@@ -455,16 +455,12 @@ wm.Weltmeister = ig.Class.extend({
                "});";
        }

-       var postString = 
-           'path=' + encodeURIComponent( path ) +
-           '&data=' + encodeURIComponent(dataString);
-       
        var req = $.ajax({
-           url: wm.config.api.save,
+           url: wm.config.api.save + '?path=' + encodeURIComponent( path ),
            type: 'POST',
            dataType: 'json',
            async: false,
-           data: postString,
+           data: dataString,
            success:this.saveResponse.bind(this)
        });
    },
@@ -937,6 +933,9 @@ wm.Weltmeister.getMaxHeight = function() {
    return $(window).height() - $('#headerMenu').height();
 };

+wm.Weltmeister.levelType = 'js,json';
+wm.Weltmeister.imageType = 'png,jpg,jpeg,gif';
+

 // Custom ig.Image class for use in Weltmeister. To make the zoom function 
 // work, we need some additional scaling behavior:
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..cfdc077
--- /dev/null
+++ b/server.js
@@ -0,0 +1,155 @@
+'use strict';
+
+let http = require('http'),
+   url = require('url'),
+   querystring = require('querystring'),
+   fs = require('fs'),
+   port = parseInt(process.argv[2] || 8080, 10);
+
+
+let API = {
+   glob: function(params, body, callback) {
+       let dir = params.dir ? params.dir.replace(/\/$/, '') : '';
+       fs.readdir('./' + dir, function(err, files){
+           if (err) {
+               return callback('dir listing failed for ./' + dir);
+           }
+
+           let parent = dir.replace(/\/?[^\/]*$/,'');
+           let ret = {current: dir, parent: parent, dirs: [], files: []},
+               typeMatch = (params.type ? params.type.split(',') : [])
+                       .map(type => new RegExp('\\.' + type + '$'));
+
+           for (let i = 0; i < files.length; i++) {
+               let fileName = files[i],
+                   filePath = (dir && dir+'/') + fileName;
+
+               if (fileName.match(/^\./)) {
+                   continue;
+               }
+
+               if (fs.statSync('./' + filePath).isDirectory()) {
+                   ret.dirs.push({name: fileName, path: filePath});
+               }
+               else if (
+                   typeMatch.length === 0 ||
+                   typeMatch.find(pattern => fileName.match(pattern))
+               ) {
+                   ret.files.push({name: fileName, path: filePath});
+               }
+           }
+           callback(null, ret);
+       });
+   },
+
+   save: function(params, body, callback) {
+       if (!params.path) {
+           return callback('no path specified');
+       }
+
+       fs.writeFile(params.path, body, {flag: 'w'}, function(err) {
+           if (err) {
+               return callback('error writing file ' + params.path);
+           }
+           callback(null, {saved: true});
+       });
+   }
+};
+
+
+
+let utf8 = '; charset=utf-8';
+let mime = {
+   html: 'text/html' + utf8,
+   htm: 'text/html' + utf8,
+   json: 'application/json' + utf8,
+   js: 'application/javascript' + utf8,
+   jpeg: 'image/jpeg',
+   jpg: 'image/jpeg',
+   gif: 'image/gif',
+   png: 'image/png',
+   ogg: 'audio/ogg',
+   mp3: 'audio/mp3',
+   aac: 'audio/aac',
+   css: 'text/css' + utf8,
+   bin: 'application/octet-stream'
+};
+
+let serveAPI = function(request, response, name) {
+   let uri = url.parse(request.url, true),
+       apiFunc = API[name];
+
+   if (!apiFunc) {
+       return writeError(request, response, 404, 'no such API function: ' + name);
+   }
+
+   console.log('API:', name, uri.query);
+
+   let bodyChunks = [];
+   request.on('data', function(chunk){
+       bodyChunks.push(chunk);
+   });
+
+   request.on('end', function(){
+       let body = Buffer.concat(bodyChunks);
+       apiFunc(uri.query, body, function(err, data){
+           if (err) {
+               writeError(request, response, 500, err);
+           }
+           else {
+               response.writeHead(200, {'Content-Type': mime.json});
+               response.write(JSON.stringify(data));
+               response.end();
+           }
+       });
+   });
+};
+
+let serveStaticFile = function(request, response) {
+   let uri = querystring.unescape(url.parse(request.url).pathname),
+       fileName = './' + uri;
+
+   fs.stat(fileName, function(err, stat) {
+       if (err) {
+           return writeError(request, response, 404, 'not found');
+       }
+
+       if (stat.isDirectory()) {
+           fileName += '/index.html';
+       }
+
+       fs.readFile(fileName, 'binary', function(err, file) {
+           if (err) {
+               return writeError(request, response, 500, 'internal error');
+           }
+
+           let ext = fileName.match(/\.(\w+)$/);
+           console.log('200: ' + uri);
+           response.writeHead(200, {'content-type': (ext && mime[ext[1]]) || mime.bin});
+           response.write(file, 'binary');
+           response.end();
+       });
+   });
+};
+
+let writeError = function(request, response, code, message) {
+   console.log(code+': ' + request.url);
+   response.writeHead(code, {'Content-Type': mime.json});
+   response.write(JSON.stringify({error: {code: code, message: message}}));
+   response.end();
+};
+
+http.createServer(function(request, response) {
+   let apiMatch = null;
+   if (apiMatch = request.url.match(/\/api\/(\w+)/)) {
+       serveAPI(request, response, apiMatch[1]);
+   }
+   else {
+       serveStaticFile(request, response);
+   }
+}).listen(port);
+
+console.log(
+   'Listening on http://localhost:' + port + '/\n' +
+   'CTRL + C to shutdown'
+);

The browse API call doesn't exist anymore (it uses glob).

Since your version of glob is slightly different, and also because the level filename in your version is set via the URL (instead of as part of the POST data), this is no longer backwards compatible with the old PHP back-end. Think it is important to keep the PHP back-end working? Would it possibly make sense to remove it?

Also tweaked your version to filter multiple file types at once, because the original Weltmeister shows only "js/json" files in the level drop-down, and only "png/jpg/jpeg/gif" files in tile-sheet drop-down.

Any thoughts to make this cleaner?