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 #####
29 "name": "Game Engine Publishing",
30 "author": "Mitchell Stokes (Moguri), Oren Titane (Genome36)",
32 "blender": (2, 75, 0),
33 "location": "Render Properties > Publishing Info",
34 "description": "Publish .blend file as game engine runtime, manage versions and platforms",
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):
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],
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")
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
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'),
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()
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
,
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
)
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
)
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
)
134 if os
.path
.exists(dst
) and overwrite_lib
:
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)
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)
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
):
162 elif not os
.path
.exists(dst
):
163 shutil
.copytree(src
, dst
)
165 if ap
.overwrite
or not os
.path
.exists(dst
):
166 shutil
.copy2(src
, dst
)
168 report({'ERROR'}, "Could not find asset path: '%s'" % src
)
172 print("Making archive...", end
=" ", flush
=True)
175 if player_path
.lower().endswith('.exe'):
177 elif player_path
.lower().endswith('.app'):
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
:
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
)
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
),
230 print("Skipping default platform")
232 for platform
in ps
.platforms
:
234 print("Publishing", platform
.name
)
235 WriteRuntime(platform
.player_path
,
236 os
.path
.join(ps
.output_path
, platform
.name
, ps
.runtime_name
),
245 print("Skipping", platform
.name
)
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
):
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"
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
282 layout
.prop(ps
, 'output_path')
283 layout
.prop(ps
, 'runtime_name')
284 layout
.prop(ps
, 'lib_path')
285 layout
.prop(ps
, 'make_archive')
290 layout
.label("Asset Paths")
294 row
.template_list("RENDER_UL_assets", "assets_list", ps
, 'asset_paths', ps
, 'asset_paths_active')
297 col
= row
.column(align
=True)
298 col
.operator(PublishAddAssetPath
.bl_idname
, icon
='ZOOMIN', text
="")
299 col
.operator(PublishRemoveAssetPath
.bl_idname
, icon
='ZOOMOUT', text
="")
302 if len(ps
.asset_paths
) > ps
.asset_paths_active
>= 0:
303 ap
= ps
.asset_paths
[ps
.asset_paths_active
]
305 row
.prop(ap
, 'overwrite')
310 row
= layout
.row(align
=True)
311 row
.label("Platforms")
312 row
.prop(ps
, 'publish_default_platform')
316 row
.template_list("RENDER_UL_platforms", "platforms_list", ps
, 'platforms', ps
, 'platforms_active')
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
="")
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
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
)
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
)
349 for root
, dirs
, files
in os
.walk(os
.path
.join(lib_path
, lib
)):
350 if "__MACOSX" in root
:
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:])
363 a
.player_path
= bpy
.path
.relpath(os
.path
.join(root
, f
))
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
375 class PublishDownloadPlatforms(bpy
.types
.Operator
):
376 bl_idname
= "scene.publish_download_platforms"
377 bl_label
= "Download Platforms"
379 def execute(self
, context
):
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
):
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)
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()
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()
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
)
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()
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
)
477 class PUBLISH_MT_platform_specials(bpy
.types
.Menu
):
478 bl_label
= "Platform Specials"
480 def draw(self
, context
):
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(
502 description
= "Whether or not to publish to this platform",
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(
511 description
= "Path to the asset to be copied",
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",
523 class PublishSettings(bpy
.types
.PropertyGroup
):
524 output_path
= bpy
.props
.StringProperty(
525 name
= "Publish Output",
526 description
= "Where to publish the game",
528 subtype
= 'DIR_PATH',
531 runtime_name
= bpy
.props
.StringProperty(
532 name
= "Runtime name",
533 description
= "The filename for the created runtime",
537 lib_path
= bpy
.props
.StringProperty(
538 name
= "Library Path",
539 description
= "Directory to search for platforms",
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",
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",
565 bpy
.utils
.register_module(__name__
)
567 bpy
.types
.Scene
.ge_publish_settings
= bpy
.props
.PointerProperty(type=PublishSettings
)
571 bpy
.utils
.unregister_module(__name__
)
572 del bpy
.types
.Scene
.ge_publish_settings
575 if __name__
== "__main__":