Quiet RNA warning
[blender-addons.git] / game_engine_publishing.py
blob495b01237ab63db2528887afd425261d9966fd4b
1 # ##### BEGIN GPL LICENSE BLOCK #####
3 # This program is free software; you can redistribute it and/or
4 # modify it under the terms of the GNU General Public License
5 # as published by the Free Software Foundation; either version 2
6 # of the License, or (at your option) any later version.
8 # This program is distributed in the hope that it will be useful,
9 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # GNU General Public License for more details.
13 # You should have received a copy of the GNU General Public License
14 # along with this program; if not, write to the Free Software Foundation,
15 # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 # ##### END GPL LICENSE BLOCK #####
19 import bpy
20 import os
21 import tempfile
22 import shutil
23 import tarfile
24 import time
25 import stat
28 bl_info = {
29 "name": "Game Engine Publishing",
30 "author": "Mitchell Stokes (Moguri), Oren Titane (Genome36)",
31 "version": (0, 1, 0),
32 "blender": (2, 75, 0),
33 "location": "Render Properties > Publishing Info",
34 "description": "Publish .blend file as game engine runtime, manage versions and platforms",
35 "warning": "",
36 "wiki_url": "http://wiki.blender.org/index.php/Extensions:2.6/Py/Scripts/Game_Engine/Publishing",
37 "category": "Game Engine",
41 def WriteRuntime(player_path, output_path, asset_paths, copy_python, overwrite_lib, copy_dlls, make_archive, report=print):
42 import struct
44 player_path = bpy.path.abspath(player_path)
45 ext = os.path.splitext(player_path)[-1].lower()
46 output_path = bpy.path.abspath(output_path)
47 output_dir = os.path.dirname(output_path)
48 if not os.path.exists(output_dir):
49 os.makedirs(output_dir)
51 python_dir = os.path.join(os.path.dirname(player_path),
52 bpy.app.version_string.split()[0],
53 "python",
54 "lib")
56 # Check the paths
57 if not os.path.isfile(player_path) and not(os.path.exists(player_path) and player_path.endswith('.app')):
58 report({'ERROR'}, "The player could not be found! Runtime not saved")
59 return
61 # Check if we're bundling a .app
62 if player_path.lower().endswith('.app'):
63 # Python doesn't need to be copied for OS X since it's already inside blenderplayer.app
64 copy_python = False
66 output_path = bpy.path.ensure_ext(output_path, '.app')
68 if os.path.exists(output_path):
69 shutil.rmtree(output_path)
71 shutil.copytree(player_path, output_path)
72 bpy.ops.wm.save_as_mainfile(filepath=os.path.join(output_path, 'Contents', 'Resources', 'game.blend'),
73 relative_remap=False,
74 compress=False,
75 copy=True,
77 else:
78 # Enforce "exe" extension on Windows
79 if player_path.lower().endswith('.exe'):
80 output_path = bpy.path.ensure_ext(output_path, '.exe')
82 # Get the player's binary and the offset for the blend
83 with open(player_path, "rb") as file:
84 player_d = file.read()
85 offset = file.tell()
87 # Create a tmp blend file (Blenderplayer doesn't like compressed blends)
88 tempdir = tempfile.mkdtemp()
89 blend_path = os.path.join(tempdir, bpy.path.clean_name(output_path))
90 bpy.ops.wm.save_as_mainfile(filepath=blend_path,
91 relative_remap=False,
92 compress=False,
93 copy=True,
96 # Get the blend data
97 with open(blend_path, "rb") as blend_file:
98 blend_d = blend_file.read()
100 # Get rid of the tmp blend, we're done with it
101 os.remove(blend_path)
102 os.rmdir(tempdir)
104 # Create a new file for the bundled runtime
105 with open(output_path, "wb") as output:
106 # Write the player and blend data to the new runtime
107 print("Writing runtime...", end=" ", flush=True)
108 output.write(player_d)
109 output.write(blend_d)
111 # Store the offset (an int is 4 bytes, so we split it up into 4 bytes and save it)
112 output.write(struct.pack('BBBB', (offset >> 24) & 0xFF,
113 (offset >> 16) & 0xFF,
114 (offset >> 8) & 0xFF,
115 (offset >> 0) & 0xFF))
117 # Stuff for the runtime
118 output.write(b'BRUNTIME')
120 print("done", flush=True)
122 # Make sure the runtime is executable
123 os.chmod(output_path, 0o755)
125 # Copy bundled Python
126 blender_dir = os.path.dirname(player_path)
128 if copy_python:
129 print("Copying Python files...", end=" ", flush=True)
130 py_folder = os.path.join(bpy.app.version_string.split()[0], "python", "lib")
131 dst = os.path.join(output_dir, py_folder)
132 src = python_dir
134 if os.path.exists(dst) and overwrite_lib:
135 shutil.rmtree(dst)
137 if not os.path.exists(dst):
138 shutil.copytree(src, dst, ignore=lambda dir, contents: [i for i in contents if i == '__pycache__'])
139 print("done", flush=True)
140 else:
141 print("used existing Python folder", flush=True)
143 # And DLLs if we're doing a Windows runtime)
144 if copy_dlls and ext == ".exe":
145 print("Copying DLLs...", end=" ", flush=True)
146 for file in [i for i in os.listdir(blender_dir) if i.lower().endswith('.dll')]:
147 src = os.path.join(blender_dir, file)
148 dst = os.path.join(output_dir, file)
149 shutil.copy2(src, dst)
151 print("done", flush=True)
153 # Copy assets
154 for ap in asset_paths:
155 src = bpy.path.abspath(ap.name)
156 dst = os.path.join(output_dir, ap.name[2:] if ap.name.startswith('//') else ap.name)
158 if os.path.exists(src):
159 if os.path.isdir(src):
160 if ap.overwrite and os.path.exists(dst):
161 shutil.rmtree(dst)
162 elif not os.path.exists(dst):
163 shutil.copytree(src, dst)
164 else:
165 if ap.overwrite or not os.path.exists(dst):
166 shutil.copy2(src, dst)
167 else:
168 report({'ERROR'}, "Could not find asset path: '%s'" % src)
170 # Make archive
171 if make_archive:
172 print("Making archive...", end=" ", flush=True)
174 arctype = ''
175 if player_path.lower().endswith('.exe'):
176 arctype = 'zip'
177 elif player_path.lower().endswith('.app'):
178 arctype = 'zip'
179 else: # Linux
180 arctype = 'gztar'
182 basedir = os.path.normpath(os.path.join(os.path.dirname(output_path), '..'))
183 afilename = os.path.join(basedir, os.path.basename(output_dir))
185 if arctype == 'gztar':
186 # Create the tarball ourselves instead of using shutil.make_archive
187 # so we can handle permission bits.
189 # The runtimename needs to use forward slashes as a path separator
190 # since this is what tarinfo.name is using.
191 runtimename = os.path.relpath(output_path, basedir).replace('\\', '/')
193 def _set_ex_perm(tarinfo):
194 if tarinfo.name == runtimename:
195 tarinfo.mode = 0o755
196 return tarinfo
198 with tarfile.open(afilename + '.tar.gz', 'w:gz') as tf:
199 tf.add(output_dir, os.path.relpath(output_dir, basedir), filter=_set_ex_perm)
200 elif arctype == 'zip':
201 shutil.make_archive(afilename, 'zip', output_dir)
202 else:
203 report({'ERROR'}, "Unknown archive type %s for runtime %s\n" % (arctype, player_path))
205 print("done", flush=True)
208 class PublishAllPlatforms(bpy.types.Operator):
209 bl_idname = "wm.publish_platforms"
210 bl_label = "Exports a runtime for each listed platform"
212 def execute(self, context):
213 ps = context.scene.ge_publish_settings
215 if ps.publish_default_platform:
216 print("Publishing default platform")
217 blender_bin_path = bpy.app.binary_path
218 blender_bin_dir = os.path.dirname(blender_bin_path)
219 ext = os.path.splitext(blender_bin_path)[-1].lower()
220 WriteRuntime(os.path.join(blender_bin_dir, 'blenderplayer' + ext),
221 os.path.join(ps.output_path, 'default', ps.runtime_name),
222 ps.asset_paths,
223 True,
224 True,
225 True,
226 ps.make_archive,
227 self.report
229 else:
230 print("Skipping default platform")
232 for platform in ps.platforms:
233 if platform.publish:
234 print("Publishing", platform.name)
235 WriteRuntime(platform.player_path,
236 os.path.join(ps.output_path, platform.name, ps.runtime_name),
237 ps.asset_paths,
238 True,
239 True,
240 True,
241 ps.make_archive,
242 self.report
244 else:
245 print("Skipping", platform.name)
247 return {'FINISHED'}
250 class RENDER_UL_assets(bpy.types.UIList):
251 bl_label = "Asset Paths Listing"
253 def draw_item(self, context, layout, data, item, icon, active_data, active_propname):
254 layout.prop(item, "name", text="", emboss=False)
257 class RENDER_UL_platforms(bpy.types.UIList):
258 bl_label = "Platforms Listing"
260 def draw_item(self, context, layout, data, item, icon, active_data, active_propname):
261 row = layout.row()
262 row.label(item.name)
263 row.prop(item, "publish", text="")
266 class RENDER_PT_publish(bpy.types.Panel):
267 bl_label = "Publishing Info"
268 bl_space_type = "PROPERTIES"
269 bl_region_type = "WINDOW"
270 bl_context = "render"
272 @classmethod
273 def poll(cls, context):
274 scene = context.scene
275 return scene and (scene.render.engine == "BLENDER_GAME")
277 def draw(self, context):
278 ps = context.scene.ge_publish_settings
279 layout = self.layout
281 # config
282 layout.prop(ps, 'output_path')
283 layout.prop(ps, 'runtime_name')
284 layout.prop(ps, 'lib_path')
285 layout.prop(ps, 'make_archive')
287 layout.separator()
289 # assets list
290 layout.label("Asset Paths")
292 # UI_UL_list
293 row = layout.row()
294 row.template_list("RENDER_UL_assets", "assets_list", ps, 'asset_paths', ps, 'asset_paths_active')
296 # operators
297 col = row.column(align=True)
298 col.operator(PublishAddAssetPath.bl_idname, icon='ZOOMIN', text="")
299 col.operator(PublishRemoveAssetPath.bl_idname, icon='ZOOMOUT', text="")
301 # indexing
302 if len(ps.asset_paths) > ps.asset_paths_active >= 0:
303 ap = ps.asset_paths[ps.asset_paths_active]
304 row = layout.row()
305 row.prop(ap, 'overwrite')
307 layout.separator()
309 # publishing list
310 row = layout.row(align=True)
311 row.label("Platforms")
312 row.prop(ps, 'publish_default_platform')
314 # UI_UL_list
315 row = layout.row()
316 row.template_list("RENDER_UL_platforms", "platforms_list", ps, 'platforms', ps, 'platforms_active')
318 # operators
319 col = row.column(align=True)
320 col.operator(PublishAddPlatform.bl_idname, icon='ZOOMIN', text="")
321 col.operator(PublishRemovePlatform.bl_idname, icon='ZOOMOUT', text="")
322 col.menu("PUBLISH_MT_platform_specials", icon='DOWNARROW_HLT', text="")
324 # indexing
325 if len(ps.platforms) > ps.platforms_active >= 0:
326 platform = ps.platforms[ps.platforms_active]
327 layout.prop(platform, 'name')
328 layout.prop(platform, 'player_path')
330 layout.operator(PublishAllPlatforms.bl_idname, 'Publish Platforms')
333 class PublishAutoPlatforms(bpy.types.Operator):
334 bl_idname = "scene.publish_auto_platforms"
335 bl_label = "Auto Add Platforms"
337 def execute(self, context):
338 ps = context.scene.ge_publish_settings
340 # verify lib folder
341 lib_path = bpy.path.abspath(ps.lib_path)
342 if not os.path.exists(lib_path):
343 self.report({'ERROR'}, "Could not add platforms, lib folder (%s) does not exist" % lib_path)
344 return {'CANCELLED'}
346 for lib in [i for i in os.listdir(lib_path) if os.path.isdir(os.path.join(lib_path, i))]:
347 print("Found folder:", lib)
348 player_found = False
349 for root, dirs, files in os.walk(os.path.join(lib_path, lib)):
350 if "__MACOSX" in root:
351 continue
353 for f in dirs + files:
354 if f.startswith("blenderplayer.app") or f.startswith("blenderplayer"):
355 a = ps.platforms.add()
356 if lib.startswith('blender-'):
357 # Clean up names for packages from blender.org
358 # example: blender-2.71-RC2-OSX_10.6-x86_64.zip => OSX_10.6-x86_64.zip
359 # We're pretty consistent on naming, so this should hold up.
360 a.name = '-'.join(lib.split('-')[3 if 'rc' in lib.lower() else 2:])
361 else:
362 a.name = lib
363 a.player_path = bpy.path.relpath(os.path.join(root, f))
364 player_found = True
365 break
367 if player_found:
368 break
370 return {'FINISHED'}
372 # TODO This operator takes a long time to run, which is bad for UX. Could this instead be done as some sort of
373 # modal dialog? This could also allow users to select which platforms to download and give a better progress
374 # indicator.
375 class PublishDownloadPlatforms(bpy.types.Operator):
376 bl_idname = "scene.publish_download_platforms"
377 bl_label = "Download Platforms"
379 def execute(self, context):
380 import html.parser
381 import urllib.request
383 remote_platforms = []
385 ps = context.scene.ge_publish_settings
387 # create lib folder if not already available
388 lib_path = bpy.path.abspath(ps.lib_path)
389 if not os.path.exists(lib_path):
390 os.makedirs(lib_path)
392 print("Retrieving list of platforms from blender.org...", end=" ", flush=True)
394 class AnchorParser(html.parser.HTMLParser):
395 def handle_starttag(self, tag, attrs):
396 if tag == 'a':
397 for key, value in attrs:
398 if key == 'href' and value.startswith('blender'):
399 remote_platforms.append(value)
401 url = 'http://download.blender.org/release/Blender' + bpy.app.version_string.split()[0]
402 parser = AnchorParser()
403 data = urllib.request.urlopen(url).read()
404 parser.feed(str(data))
406 print("done", flush=True)
408 print("Downloading files (this will take a while depending on your internet connection speed).", flush=True)
409 for i in remote_platforms:
410 src = '/'.join((url, i))
411 dst = os.path.join(lib_path, i)
413 dst_dir = '.'.join([i for i in dst.split('.') if i not in {'zip', 'tar', 'bz2'}])
414 if not os.path.exists(dst) and not os.path.exists(dst.split('.')[0]):
415 print("Downloading " + src + "...", end=" ", flush=True)
416 urllib.request.urlretrieve(src, dst)
417 print("done", flush=True)
418 else:
419 print("Reusing existing file: " + dst, flush=True)
421 print("Unpacking " + dst + "...", end=" ", flush=True)
422 if os.path.exists(dst_dir):
423 shutil.rmtree(dst_dir)
424 shutil.unpack_archive(dst, dst_dir)
425 print("done", flush=True)
427 print("Creating platform from libs...", flush=True)
428 bpy.ops.scene.publish_auto_platforms()
429 return {'FINISHED'}
432 class PublishAddPlatform(bpy.types.Operator):
433 bl_idname = "scene.publish_add_platform"
434 bl_label = "Add Publish Platform"
436 def execute(self, context):
437 a = context.scene.ge_publish_settings.platforms.add()
438 a.name = a.name
439 return {'FINISHED'}
442 class PublishRemovePlatform(bpy.types.Operator):
443 bl_idname = "scene.publish_remove_platform"
444 bl_label = "Remove Publish Platform"
446 def execute(self, context):
447 ps = context.scene.ge_publish_settings
448 if ps.platforms_active < len(ps.platforms):
449 ps.platforms.remove(ps.platforms_active)
450 return {'FINISHED'}
451 return {'CANCELLED'}
454 # TODO maybe this should display a file browser?
455 class PublishAddAssetPath(bpy.types.Operator):
456 bl_idname = "scene.publish_add_assetpath"
457 bl_label = "Add Asset Path"
459 def execute(self, context):
460 a = context.scene.ge_publish_settings.asset_paths.add()
461 a.name = a.name
462 return {'FINISHED'}
465 class PublishRemoveAssetPath(bpy.types.Operator):
466 bl_idname = "scene.publish_remove_assetpath"
467 bl_label = "Remove Asset Path"
469 def execute(self, context):
470 ps = context.scene.ge_publish_settings
471 if ps.asset_paths_active < len(ps.asset_paths):
472 ps.asset_paths.remove(ps.asset_paths_active)
473 return {'FINISHED'}
474 return {'CANCELLED'}
477 class PUBLISH_MT_platform_specials(bpy.types.Menu):
478 bl_label = "Platform Specials"
480 def draw(self, context):
481 layout = self.layout
482 layout.operator(PublishAutoPlatforms.bl_idname)
483 layout.operator(PublishDownloadPlatforms.bl_idname)
486 class PlatformSettings(bpy.types.PropertyGroup):
487 name = bpy.props.StringProperty(
488 name = "Platform Name",
489 description = "The name of the platform",
490 default = "Platform",
493 player_path = bpy.props.StringProperty(
494 name = "Player Path",
495 description = "The path to the Blenderplayer to use for this platform",
496 default = "//lib/platform/blenderplayer",
497 subtype = 'FILE_PATH',
500 publish = bpy.props.BoolProperty(
501 name = "Publish",
502 description = "Whether or not to publish to this platform",
503 default = True,
507 class AssetPath(bpy.types.PropertyGroup):
508 # TODO This needs a way to be a FILE_PATH or a DIR_PATH
509 name = bpy.props.StringProperty(
510 name = "Asset Path",
511 description = "Path to the asset to be copied",
512 default = "//src",
513 subtype = 'FILE_PATH',
516 overwrite = bpy.props.BoolProperty(
517 name = "Overwrite Asset",
518 description = "Overwrite the asset if it already exists in the destination folder",
519 default = True,
523 class PublishSettings(bpy.types.PropertyGroup):
524 output_path = bpy.props.StringProperty(
525 name = "Publish Output",
526 description = "Where to publish the game",
527 default = "//bin/",
528 subtype = 'DIR_PATH',
531 runtime_name = bpy.props.StringProperty(
532 name = "Runtime name",
533 description = "The filename for the created runtime",
534 default = "game",
537 lib_path = bpy.props.StringProperty(
538 name = "Library Path",
539 description = "Directory to search for platforms",
540 default = "//lib/",
541 subtype = 'DIR_PATH',
544 publish_default_platform = bpy.props.BoolProperty(
545 name = "Publish Default Platform",
546 description = "Whether or not to publish the default platform (the Blender install running this addon) when publishing platforms",
547 default = True,
551 platforms = bpy.props.CollectionProperty(type=PlatformSettings, name="Platforms")
552 platforms_active = bpy.props.IntProperty()
554 asset_paths = bpy.props.CollectionProperty(type=AssetPath, name="Asset Paths")
555 asset_paths_active = bpy.props.IntProperty()
557 make_archive = bpy.props.BoolProperty(
558 name = "Make Archive",
559 description = "Create a zip archive of the published game",
560 default = True,
564 def register():
565 bpy.utils.register_module(__name__)
567 bpy.types.Scene.ge_publish_settings = bpy.props.PointerProperty(type=PublishSettings)
570 def unregister():
571 bpy.utils.unregister_module(__name__)
572 del bpy.types.Scene.ge_publish_settings
575 if __name__ == "__main__":
576 register()