Files
blender-addons/game_engine_publishing.py
2015-09-21 20:07:06 +10:00

577 lines
20 KiB
Python

# ##### 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)",
"version": (0, 1, 0),
"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
# config
layout.prop(ps, 'output_path')
layout.prop(ps, 'runtime_name')
layout.prop(ps, 'lib_path')
layout.prop(ps, 'make_archive')
layout.separator()
# assets list
layout.label("Asset Paths")
# UI_UL_list
row = layout.row()
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="")
# indexing
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 = layout.row()
row.template_list("RENDER_UL_platforms", "platforms_list", ps, 'platforms', ps, 'platforms_active')
# operators
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="")
# indexing
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
# verify lib folder
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()