Skip to content
Snippets Groups Projects
game_engine_publishing.py 20.3 KiB
Newer Older
  • Learn to ignore specific revisions
  • # ##### 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), Oren Titane (Genome36)",
    
        "blender": (2, 75, 0),
    
        "location": "Render Properties > Publishing Info",
    
        "description": "Publish .blend file as game engine runtime, manage versions and platforms",
    
        "warning": "",
    
        "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
    
            with open(player_path, "rb") as file:
                player_d = file.read()
                offset = file.tell()
    
    
            # 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
    
            with open(blend_path, "rb") as blend_file:
                blend_d = blend_file.read()
    
    
            # 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
    
            with open(output_path, "wb") as output:
                # 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')
    
    
            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_UL_assets(bpy.types.UIList):
        bl_label = "Asset Paths Listing"
    
        def draw_item(self, context, layout, data, item, icon, active_data, active_propname):
            layout.prop(item, "name", text="", emboss=False)
    
    
    class RENDER_UL_platforms(bpy.types.UIList):
        bl_label = "Platforms Listing"
    
        def draw_item(self, context, layout, data, item, icon, active_data, active_propname):
            row = layout.row()
            row.label(item.name)
            row.prop(item, "publish", text="")
    
    
    
    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')
    
    
            row.template_list("RENDER_UL_assets", "assets_list", ps, 'asset_paths', ps, 'asset_paths_active')
    
            # operators
    
            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, 'overwrite')
    
    
            layout.separator()
    
            # publishing list
            row = layout.row(align=True)
            row.label("Platforms")
            row.prop(ps, 'publish_default_platform')
    
            # UI_UL_list
    
            row.template_list("RENDER_UL_platforms", "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.operator(PublishAllPlatforms.bl_idname, 'Publish Platforms')
    
    
    
    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)
    
            if not os.path.exists(lib_path):
                self.report({'ERROR'}, "Could not add platforms, lib folder (%s) does not exist" % lib_path)
                return {'CANCELLED'}
    
    
            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
    
    
            # create lib folder if not already available
    
            lib_path = bpy.path.abspath(ps.lib_path)
    
            if not os.path.exists(lib_path):
                os.makedirs(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()