mirror of
https://github.com/blender/blender-addons.git
synced 2025-08-16 15:35:05 +00:00
577 lines
20 KiB
Python
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()
|