renpy/launcher/game/web.rpy
2023-01-18 23:13:55 +01:00

376 lines
12 KiB
Plaintext

# Copyright 2004-2022 Tom Rothamel <pytom@bishoujo.us>
#
# 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