diff options
author | Mitchell Stokes <mogurijin@gmail.com> | 2014-06-26 03:49:50 +0400 |
---|---|---|
committer | Mitchell Stokes <mogurijin@gmail.com> | 2014-06-26 03:53:19 +0400 |
commit | c5fddd6f67ecde0aaca60337f0a3a5e635d3b671 (patch) | |
tree | b194403eb98bd8740de9815ca9c0ac297edde938 /game_engine_publishing.py | |
parent | d5426e412430181d91ebd103d685fce601ec59fd (diff) |
BGE Publishing: Adding a new addon to help make publishing games easier.
Some highlights:
* New panel in the render options to control publishing
* Easier cross-platform publishing
* Ability to archive published binaries
More information can be found on the addons wiki page:
http://wiki.blender.org/index.php/Extensions:2.6/Py/Scripts/Game_Engine/Publishing
Note: This addon is intended to replace the Save As Runtime addon.
Diffstat (limited to 'game_engine_publishing.py')
-rw-r--r-- | game_engine_publishing.py | 541 |
1 files changed, 541 insertions, 0 deletions
diff --git a/game_engine_publishing.py b/game_engine_publishing.py new file mode 100644 index 00000000..991a9acf --- /dev/null +++ b/game_engine_publishing.py @@ -0,0 +1,541 @@ +# ##### BEGIN GPL LICENSE BLOCK ##### +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# ##### END GPL LICENSE BLOCK ##### + +import bpy +import os +import tempfile +import shutil +import tarfile +import time +import stat + + +bl_info = { + "name": "Game Engine Publishing", + "author": "Mitchell Stokes (Moguri)", + "version": (0, 1, 0), + "blender": (2, 72, 0), + "location": "Render Properties > Publishing Info", + "description": "", + "warning": "beta", + "wiki_url": "http://wiki.blender.org/index.php/Extensions:2.6/Py/Scripts/Game_Engine/Publishing", + "category": "Game Engine", +} + + +def WriteRuntime(player_path, output_path, asset_paths, copy_python, overwrite_lib, copy_dlls, make_archive, report=print): + import struct + + player_path = bpy.path.abspath(player_path) + ext = os.path.splitext(player_path)[-1].lower() + output_path = bpy.path.abspath(output_path) + output_dir = os.path.dirname(output_path) + if not os.path.exists(output_dir): + os.makedirs(output_dir) + + python_dir = os.path.join(os.path.dirname(player_path), + bpy.app.version_string.split()[0], + "python", + "lib") + + # Check the paths + if not os.path.isfile(player_path) and not(os.path.exists(player_path) and player_path.endswith('.app')): + report({'ERROR'}, "The player could not be found! Runtime not saved") + return + + # Check if we're bundling a .app + if player_path.lower().endswith('.app'): + # Python doesn't need to be copied for OS X since it's already inside blenderplayer.app + copy_python = False + + output_path = bpy.path.ensure_ext(output_path, '.app') + + if os.path.exists(output_path): + shutil.rmtree(output_path) + + shutil.copytree(player_path, output_path) + bpy.ops.wm.save_as_mainfile(filepath=os.path.join(output_path, 'Contents', 'Resources', 'game.blend'), + relative_remap=False, + compress=False, + copy=True, + ) + else: + # Enforce "exe" extension on Windows + if player_path.lower().endswith('.exe'): + output_path = bpy.path.ensure_ext(output_path, '.exe') + + # Get the player's binary and the offset for the blend + file = open(player_path, 'rb') + player_d = file.read() + offset = file.tell() + file.close() + + # Create a tmp blend file (Blenderplayer doesn't like compressed blends) + tempdir = tempfile.mkdtemp() + blend_path = os.path.join(tempdir, bpy.path.clean_name(output_path)) + bpy.ops.wm.save_as_mainfile(filepath=blend_path, + relative_remap=False, + compress=False, + copy=True, + ) + + # Get the blend data + blend_file = open(blend_path, 'rb') + blend_d = blend_file.read() + blend_file.close() + + # Get rid of the tmp blend, we're done with it + os.remove(blend_path) + os.rmdir(tempdir) + + # Create a new file for the bundled runtime + output = open(output_path, 'wb') + + # Write the player and blend data to the new runtime + print("Writing runtime...", end=" ", flush=True) + output.write(player_d) + output.write(blend_d) + + # Store the offset (an int is 4 bytes, so we split it up into 4 bytes and save it) + output.write(struct.pack('BBBB', (offset >> 24) & 0xFF, + (offset >> 16) & 0xFF, + (offset >> 8) & 0xFF, + (offset >> 0) & 0xFF)) + + # Stuff for the runtime + output.write(b'BRUNTIME') + output.close() + + print("done", flush=True) + + # Make sure the runtime is executable + os.chmod(output_path, 0o755) + + # Copy bundled Python + blender_dir = os.path.dirname(player_path) + + if copy_python: + print("Copying Python files...", end=" ", flush=True) + py_folder = os.path.join(bpy.app.version_string.split()[0], "python", "lib") + dst = os.path.join(output_dir, py_folder) + src = python_dir + + if os.path.exists(dst) and overwrite_lib: + shutil.rmtree(dst) + + if not os.path.exists(dst): + shutil.copytree(src, dst, ignore=lambda dir, contents: [i for i in contents if i == '__pycache__']) + print("done", flush=True) + else: + print("used existing Python folder", flush=True) + + # And DLLs if we're doing a Windows runtime) + if copy_dlls and ext == ".exe": + print("Copying DLLs...", end=" ", flush=True) + for file in [i for i in os.listdir(blender_dir) if i.lower().endswith('.dll')]: + src = os.path.join(blender_dir, file) + dst = os.path.join(output_dir, file) + shutil.copy2(src, dst) + + print("done", flush=True) + + # Copy assets + for ap in asset_paths: + src = bpy.path.abspath(ap.name) + dst = os.path.join(output_dir, ap.name[2:] if ap.name.startswith('//') else ap.name) + + if os.path.exists(src): + if os.path.isdir(src): + if ap.overwrite and os.path.exists(dst): + shutil.rmtree(dst) + elif not os.path.exists(dst): + shutil.copytree(src, dst) + else: + if ap.overwrite or not os.path.exists(dst): + shutil.copy2(src, dst) + else: + report({'ERROR'}, "Could not find asset path: '%s'" % src) + + # Make archive + if make_archive: + print("Making archive...", end=" ", flush=True) + + arctype = '' + if player_path.lower().endswith('.exe'): + arctype = 'zip' + elif player_path.lower().endswith('.app'): + arctype = 'zip' + else: # Linux + arctype = 'gztar' + + basedir = os.path.normpath(os.path.join(os.path.dirname(output_path), '..')) + afilename = os.path.join(basedir, os.path.basename(output_dir)) + + if arctype == 'gztar': + # Create the tarball ourselves instead of using shutil.make_archive + # so we can handle permission bits. + + # The runtimename needs to use forward slashes as a path separator + # since this is what tarinfo.name is using. + runtimename = os.path.relpath(output_path, basedir).replace('\\', '/') + + def _set_ex_perm(tarinfo): + if tarinfo.name == runtimename: + tarinfo.mode = 0o755 + return tarinfo + + with tarfile.open(afilename + '.tar.gz', 'w:gz') as tf: + tf.add(output_dir, os.path.relpath(output_dir, basedir), filter=_set_ex_perm) + elif arctype == 'zip': + shutil.make_archive(afilename, 'zip', output_dir) + else: + report({'ERROR'}, "Unknown archive type %s for runtime %s\n" % (arctype, player_path)) + + print("done", flush=True) + + +class PublishAllPlatforms(bpy.types.Operator): + bl_idname = "wm.publish_platforms" + bl_label = "Exports a runtime for each listed platform" + + def execute(self, context): + ps = context.scene.ge_publish_settings + + if ps.publish_default_platform: + print("Publishing default platform") + blender_bin_path = bpy.app.binary_path + blender_bin_dir = os.path.dirname(blender_bin_path) + ext = os.path.splitext(blender_bin_path)[-1].lower() + WriteRuntime(os.path.join(blender_bin_dir, 'blenderplayer' + ext), + os.path.join(ps.output_path, 'default', ps.runtime_name), + ps.asset_paths, + True, + True, + True, + ps.make_archive, + self.report + ) + else: + print("Skipping default platform") + + for platform in ps.platforms: + if platform.publish: + print("Publishing", platform.name) + WriteRuntime(platform.player_path, + os.path.join(ps.output_path, platform.name, ps.runtime_name), + ps.asset_paths, + True, + True, + True, + ps.make_archive, + self.report + ) + else: + print("Skipping", platform.name) + + return {'FINISHED'} + + +class RENDER_PT_publish(bpy.types.Panel): + bl_label = "Publishing Info" + bl_space_type = "PROPERTIES" + bl_region_type = "WINDOW" + bl_context = "render" + + @classmethod + def poll(cls, context): + scene = context.scene + return scene and (scene.render.engine == "BLENDER_GAME") + + def draw(self, context): + ps = context.scene.ge_publish_settings + layout = self.layout + + layout.prop(ps, 'output_path') + layout.prop(ps, 'runtime_name') + layout.prop(ps, 'lib_path') + layout.prop(ps, 'make_archive') + + layout.label("Asset Paths") + row = layout.row() + row.template_list("UI_UL_list", "assets_list", ps, 'asset_paths', ps, 'asset_paths_active') + col = row.column(align=True) + col.operator(PublishAddAssetPath.bl_idname, icon='ZOOMIN', text="") + col.operator(PublishRemoveAssetPath.bl_idname, icon='ZOOMOUT', text="") + + if len(ps.asset_paths) > ps.asset_paths_active >= 0: + ap = ps.asset_paths[ps.asset_paths_active] + row = layout.row() + row.prop(ap, 'name') + row.prop(ap, 'overwrite') + + layout.label("Platforms") + row = layout.row() + row.template_list("UI_UL_list", "platforms_list", ps, 'platforms', ps, 'platforms_active') + + col = row.column(align=True) + col.operator(PublishAddPlatform.bl_idname, icon='ZOOMIN', text="") + col.operator(PublishRemovePlatform.bl_idname, icon='ZOOMOUT', text="") + col.menu("PUBLISH_MT_platform_specials", icon='DOWNARROW_HLT', text="") + + if len(ps.platforms) > ps.platforms_active >= 0: + platform = ps.platforms[ps.platforms_active] + layout.prop(platform, 'name') + layout.prop(platform, 'player_path') + layout.prop(platform, 'publish') + + layout.prop(ps, 'publish_default_platform') + layout.operator(PublishAllPlatforms.bl_idname, 'Publish') + + +class PublishAutoPlatforms(bpy.types.Operator): + bl_idname = "scene.publish_auto_platforms" + bl_label = "Auto Add Platforms" + + def execute(self, context): + ps = context.scene.ge_publish_settings + + lib_path = bpy.path.abspath(ps.lib_path) + + for lib in [i for i in os.listdir(lib_path) if os.path.isdir(os.path.join(lib_path, i))]: + print("Found folder:", lib) + player_found = False + for root, dirs, files in os.walk(os.path.join(lib_path, lib)): + if "__MACOSX" in root: + continue + + for f in dirs + files: + if f.startswith("blenderplayer.app") or f.startswith("blenderplayer"): + a = ps.platforms.add() + if lib.startswith('blender-'): + # Clean up names for packages from blender.org + # example: blender-2.71-RC2-OSX_10.6-x86_64.zip => OSX_10.6-x86_64.zip + # We're pretty consistent on naming, so this should hold up. + a.name = '-'.join(lib.split('-')[3 if 'rc' in lib.lower() else 2:]) + else: + a.name = lib + a.player_path = bpy.path.relpath(os.path.join(root, f)) + player_found = True + break + + if player_found: + break + + return {'FINISHED'} + +# TODO This operator takes a long time to run, which is bad for UX. Could this instead be done as some sort of +# modal dialog? This could also allow users to select which platforms to download and give a better progress +# indicator. +class PublishDownloadPlatforms(bpy.types.Operator): + bl_idname = "scene.publish_download_platforms" + bl_label = "Download Platforms" + + def execute(self, context): + import html.parser + import urllib.request + + remote_platforms = [] + + ps = context.scene.ge_publish_settings + lib_path = bpy.path.abspath(ps.lib_path) + + print("Retrieving list of platforms from blender.org...", end=" ", flush=True) + + class AnchorParser(html.parser.HTMLParser): + def handle_starttag(self, tag, attrs): + if tag == 'a': + for key, value in attrs: + if key == 'href' and value.startswith('blender'): + remote_platforms.append(value) + + url = 'http://download.blender.org/release/Blender' + bpy.app.version_string.split()[0] + parser = AnchorParser() + data = urllib.request.urlopen(url).read() + parser.feed(str(data)) + + print("done", flush=True) + + print("Downloading files (this will take a while depending on your internet connection speed).", flush=True) + for i in remote_platforms: + src = '/'.join((url, i)) + dst = os.path.join(lib_path, i) + + dst_dir = '.'.join([i for i in dst.split('.') if i not in {'zip', 'tar', 'bz2'}]) + if not os.path.exists(dst) and not os.path.exists(dst.split('.')[0]): + print("Downloading " + src + "...", end=" ", flush=True) + urllib.request.urlretrieve(src, dst) + print("done", flush=True) + else: + print("Reusing existing file: " + dst, flush=True) + + print("Unpacking " + dst + "...", end=" ", flush=True) + if os.path.exists(dst_dir): + shutil.rmtree(dst_dir) + shutil.unpack_archive(dst, dst_dir) + print("done", flush=True) + + print("Creating platform from libs...", flush=True) + bpy.ops.scene.publish_auto_platforms() + return {'FINISHED'} + + +class PublishAddPlatform(bpy.types.Operator): + bl_idname = "scene.publish_add_platform" + bl_label = "Add Publish Platform" + + def execute(self, context): + a = context.scene.ge_publish_settings.platforms.add() + a.name = a.name + return {'FINISHED'} + + +class PublishRemovePlatform(bpy.types.Operator): + bl_idname = "scene.publish_remove_platform" + bl_label = "Remove Publish Platform" + + def execute(self, context): + ps = context.scene.ge_publish_settings + if ps.platforms_active < len(ps.platforms): + ps.platforms.remove(ps.platforms_active) + return {'FINISHED'} + return {'CANCELLED'} + + +# TODO maybe this should display a file browser? +class PublishAddAssetPath(bpy.types.Operator): + bl_idname = "scene.publish_add_assetpath" + bl_label = "Add Asset Path" + + def execute(self, context): + a = context.scene.ge_publish_settings.asset_paths.add() + a.name = a.name + return {'FINISHED'} + + +class PublishRemoveAssetPath(bpy.types.Operator): + bl_idname = "scene.publish_remove_assetpath" + bl_label = "Remove Asset Path" + + def execute(self, context): + ps = context.scene.ge_publish_settings + if ps.asset_paths_active < len(ps.asset_paths): + ps.asset_paths.remove(ps.asset_paths_active) + return {'FINISHED'} + return {'CANCELLED'} + + +class PUBLISH_MT_platform_specials(bpy.types.Menu): + bl_label = "Platform Specials" + + def draw(self, context): + layout = self.layout + layout.operator(PublishAutoPlatforms.bl_idname) + layout.operator(PublishDownloadPlatforms.bl_idname) + + +class PlatformSettings(bpy.types.PropertyGroup): + name = bpy.props.StringProperty( + name = "Platform Name", + description = "The name of the platform", + default = "Platform", + ) + + player_path = bpy.props.StringProperty( + name = "Player Path", + description = "The path to the Blenderplayer to use for this platform", + default = "//lib/platform/blenderplayer", + subtype = 'FILE_PATH', + ) + + publish = bpy.props.BoolProperty( + name = "Publish", + description = "Whether or not to publish to this platform", + default = True, + ) + + +class AssetPath(bpy.types.PropertyGroup): + # TODO This needs a way to be a FILE_PATH or a DIR_PATH + name = bpy.props.StringProperty( + name = "Asset Path", + description = "Path to the asset to be copied", + default = "//src", + subtype = 'FILE_PATH', + ) + + overwrite = bpy.props.BoolProperty( + name = "Overwrite Asset", + description = "Overwrite the asset if it already exists in the destination folder", + default = True, + ) + + +class PublishSettings(bpy.types.PropertyGroup): + output_path = bpy.props.StringProperty( + name = "Publish Output", + description = "Where to publish the game", + default = "//bin/", + subtype = 'DIR_PATH', + ) + + runtime_name = bpy.props.StringProperty( + name = "Runtime name", + description = "The filename for the created runtime", + default = "game", + ) + + lib_path = bpy.props.StringProperty( + name = "Library Path", + description = "Directory to search for platforms", + default = "//lib/", + subtype = 'DIR_PATH', + ) + + publish_default_platform = bpy.props.BoolProperty( + name = "Publish Default Platform", + description = "Whether or not to publish the default platform (the Blender install running this addon) when publishing platforms", + default = True, + ) + + + platforms = bpy.props.CollectionProperty(type=PlatformSettings, name="Platforms") + platforms_active = bpy.props.IntProperty() + + asset_paths = bpy.props.CollectionProperty(type=AssetPath, name="Asset Paths") + asset_paths_active = bpy.props.IntProperty() + + make_archive = bpy.props.BoolProperty( + name = "Make Archive", + description = "Create a zip archive of the published game", + default = True, + ) + + +def register(): + bpy.utils.register_module(__name__) + + bpy.types.Scene.ge_publish_settings = bpy.props.PointerProperty(type=PublishSettings) + + +def unregister(): + bpy.utils.unregister_module(__name__) + del bpy.types.Scene.ge_publish_settings + + +if __name__ == "__main__": + register() |