# Copyright 2004-2022 Tom Rothamel # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation files # (the "Software"), to deal in the Software without restriction, # including without limitation the rights to use, copy, modify, merge, # publish, distribute, sublicense, and/or sell copies of the Software, # and to permit persons to whom the Software is furnished to do so, # subject to the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. # This file contains the launcher support for creating RenPyWeb projects, # other than the logic in the standard distribute code. init python: import shutil import webserver import io import tempfile import time import pygame_sdl2 import zipfile WEB_PATH = None def find_web(): global WEB_PATH candidates = [ ] WEB_PATH = os.path.join(config.renpy_base, "web") if os.path.isdir(WEB_PATH) and check_hash_txt("web"): pass else: WEB_PATH = None find_web() def get_web_destination(p): """ Returns the path to the desint """ build = p.dump['build'] base_name = build['directory_name'] destination = build["destination"] parent = os.path.dirname(p.path) destination = os.path.join(parent, destination, base_name + "-web") return destination def generate_image_placeholder(surface, tmpdir): """ Creates size-efficient 1/32 thumbnail for use as download preview. Pixellate first for better graphic results. Will be stretched back when playing. """ if surface.get_width() > 32 and surface.get_height() > 32: renpy.display.module.pixellate(surface,surface,32,32,32,32) thumbnail = renpy.display.pgrender.transform_scale(surface, (surface.get_width()/32, surface.get_height()/32)) else: # avoid unsupported 0-width or 0-height picture thumbnail = surface save_as_png = os.path.join(tmpdir, 'use_png_format.png') best_compression = 9 pygame_sdl2.image.save(thumbnail, save_as_png, best_compression) return save_as_png def load_filters(p, path_filters): """ Type- and path-based filtering, following progressive_download.txt rules (created with default rules if not there already). Assumes prior filtering based on hard-coded list of file extensions. """ rules_path = os.path.join(p.path,'progressive_download.txt') if not os.path.exists(rules_path): open(rules_path, 'w').write( "# RenPyWeb progressive download rules - first match applies\n" + "# '+' = progressive download, '-' = keep in game.zip (default)\n" + "# See https://www.renpy.org/doc/html/build.html#classifying-and-ignoring-files for matching\n" + "#\n" + "# +/- type path\n" + '- image game/gui/**\n' + '+ image game/**\n' + '+ music game/audio/**\n' + '+ voice game/voice/**\n' ) # Parse rules line_no = 0 for line in open(rules_path, 'r').readlines(): line_no += 1 if line.startswith('#') or line.strip() == '': continue try: (f_rule, f_type, f_pattern) = line.rstrip("\r\n").split(' ', 3-1) except ValueError: raise RuntimeError("Missing element at progressive_download.txt:%d" % line_no) try: f_rule = {'+': True, '-': False}[f_rule] except KeyError: raise RuntimeError("Invalid rule '%s' at progressive_download.txt:%d" % (f_rule, line_no)) if f_type not in ('image', 'music', 'voice'): raise RuntimeError("Invalid type '%s' at progressive_download.txt:%d" % (f_type, line_no)) path_filters.append((f_rule, f_type, f_pattern)) def filters_match(path_filters, path, path_type): """ Returns whether path matches a progressive download rule """ for (f_rule, f_type, f_pattern) in path_filters: if path_type == f_type and distribute.match(path, f_pattern): return f_rule return False def repack_for_progressive_download(p): """ Filter out downloadable resources and generate placeholders """ destination = get_web_destination(p) path_filters = [] load_filters(p, path_filters) shutil.move( os.path.join(destination, 'game.zip'), os.path.join(destination, 'game-old.zip')) zin = zipfile.ZipFile(os.path.join(destination, 'game-old.zip')) zout = zipfile.ZipFile(os.path.join(destination, 'game.zip'), 'w') remote_files = {} tmpdir = tempfile.mkdtemp() for m in zin.infolist(): base, ext = os.path.splitext(m.filename) # Images if (ext.lower() in ('.jpg', '.jpeg', '.png', '.webp') and filters_match(path_filters, m.filename, 'image')): zin.extract(m, path=destination) surface = pygame_sdl2.image.load(os.path.join(destination,m.filename)) (w,h) = (surface.get_width(),surface.get_height()) remote_files[m.filename[len('game/'):]] = 'image {},{}'.format(w,h) tmpfile = generate_image_placeholder(surface, tmpdir) placeholder_relpath = os.path.join('_placeholders', m.filename[len('game/'):]) zout.write(tmpfile, placeholder_relpath) # Musics (but not SFX - no placeholders for short, non-looping sounds) elif (ext.lower() in ('.wav', '.mp2', '.mp3', '.ogg', '.opus') and filters_match(path_filters, m.filename, 'music')): zin.extract(m, path=destination) remote_files[m.filename[len('game/'):]] = 'music -' # Voices elif (ext.lower() in ('.wav', '.mp2', '.mp3', '.ogg', '.opus') and filters_match(path_filters, m.filename, 'voice')): zin.extract(m, path=destination) remote_files[m.filename[len('game/'):]] = 'voice -' # Videos are currently not supported, strip them if not already elif (ext.lower() in ('.ogv', '.webm', '.mp4', '.mkv', '.avi')): pass # Default: keep (extract & recompress to new .zip) else: # Not using zout.writestr(m, zin.read(m)) to avoid MemoryError tmpfile = zin.extract(m, tmpdir) date_time = time.mktime(m.date_time+(0,0,0)) os.utime(tmpfile, (date_time,date_time)) zout.write(tmpfile, m.filename, m.compress_type) # Prepare a list of remote files for renpy.loader remote_files_str = '' for f in sorted(remote_files): remote_files_str += f + "\n" remote_files_str += remote_files[f] + "\n" zout.writestr('game/renpyweb_remote_files.txt', remote_files_str, zipfile.ZIP_DEFLATED) # Clean-up shutil.rmtree(tmpdir) zout.close() zin.close() os.unlink(os.path.join(destination, 'game-old.zip')) def build_web(p, gui=True): # Figure out the reporter to use. if gui: reporter = distribute.GuiReporter() else: reporter = distribute.TextReporter() # Determine details. p.update_dump(True, gui=gui) destination = get_web_destination(p) display_name = p.dump['build']['display_name'] # Clean the folder, then remake it. if os.path.exists(destination): shutil.rmtree(destination) os.makedirs(destination, 0o777) # Use the distributor to make game.zip. distribute.Distributor(p, packages=[ "web" ], packagedest=os.path.join(destination, "game"), reporter=reporter, noarchive=True, scan=False) reporter.info(_("Preparing progressive download")) repack_for_progressive_download(p) # Copy the files from WEB_PATH to destination. for fn in os.listdir(WEB_PATH): if fn in { "game.zip", "hash.txt", "index.html" }: continue shutil.copy(os.path.join(WEB_PATH, fn), os.path.join(destination, fn)) # Copy over index.html. with io.open(os.path.join(WEB_PATH, "index.html"), encoding='utf-8') as f: html = f.read() html = html.replace("%%TITLE%%", display_name) with io.open(os.path.join(destination, "index.html"), "w", encoding='utf-8') as f: f.write(html) webserver.start(destination) def launch_web(): renpy.run(OpenURL("http://127.0.0.1:8042/index.html")) screen web(): frame: style_group "l" style "l_root" window: has vbox label _("Web: [project.current.display_name!q]") add HALF_SPACER hbox: # Left side. frame: style "l_indent" xmaximum ONEHALF xfill True has vbox add SEPARATOR2 add HALF_SPACER frame: style "l_indent" has vbox text _("Build:") add HALF_SPACER frame style "l_indent": has vbox textbutton _("Build Web Application") action Jump("web_build") textbutton _("Build and Open in Browser") action Jump("web_launch") textbutton _("Open in Browser") action Jump("web_start") textbutton _("Open build directory") action Jump("open_build_directory") add SPACER textbutton _("Force Recompile") action DataToggle("force_recompile") style "l_checkbox" # Right side. frame: style "l_indent" xmaximum ONEHALF xfill True has vbox add SEPARATOR2 add HALF_SPACER frame: style "l_indent" has vbox text _("Images and music can be downloaded while playing. A 'progressive_download.txt' file will be created so you can configure this behavior.") add SPACER text _("Current limitations in the web platform mean that loading large images may cause audio or framerate glitches, and lower performance in general. Movies aren't supported.") textbutton _("Return") action Jump("front_page") style "l_left_button" label web: if not PY2: $ interface.info(_("This feature is not supported in Ren'Py 8."), _("We will restore support in a future release of Ren'Py 8. Until then, please use Ren'Py 7 for web support.")) return if WEB_PATH is None: $ interface.yesno(_("Before packaging web apps, you'll need to download RenPyWeb, Ren'Py's web support. Would you like to download RenPyWeb now?"), no=Jump("front_page")) $ add_dlc("web", restart=True) call screen web jump front_page label web_build: $ build_web(project.current, gui=True) jump web label web_launch: $ build_web(project.current, gui=True) $ launch_web() jump web label open_build_directory(): $ project.current.update_dump(True, gui=True) $ renpy.run(OpenDirectory(get_web_destination(project.current), absolute=True)) jump web label web_start: $ project.current.update_dump(True, gui=True) $ webserver.start(get_web_destination(project.current)) $ launch_web() jump web