renpy/launcher/game/web.rpy
2023-09-13 00:16:10 +02:00

596 lines
22 KiB
Plaintext

# Copyright 2004-2023 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
import re
import hashlib
WEB_PATH = None
class ProgressiveFilter(object):
def __init__(self, project):
self.project = project
self.destination = get_web_destination(project)
self.remote_files = {}
self.images = []
self.path_filters = []
load_filters(project, self.path_filters)
def filter(self, file, variant, format):
"""
Detect and copy the files to be downloaded progressively,
and prevent them to be put in the ZIP archive.
Returns True if the file must be included in the ZIP archive.
"""
if variant != 'web' or format != 'zip': # useless?
return True
base, ext = os.path.splitext(file.name)
copy_file = False
# Images
if (ext.lower() in ('.jpg', '.jpeg', '.png', '.webp', '.avif', '.svg')
and filters_match(self.path_filters, file.name, 'image')):
# Add image to list and generate placeholder later
self.images.append((file.path, file.name))
copy_file = True
# Musics (but not SFX - no placeholders for short, non-looping sounds)
elif (ext.lower() in ('.wav', '.mp2', '.mp3', '.ogg', '.opus')
and filters_match(self.path_filters, file.name, 'music')):
self.remote_files[file.name[len('game/'):]] = 'music -'
copy_file = True
# Voices
elif (ext.lower() in ('.wav', '.mp2', '.mp3', '.ogg', '.opus')
and filters_match(self.path_filters, file.name, 'voice')):
self.remote_files[file.name[len('game/'):]] = 'voice -'
copy_file = True
# Videos are never included.
elif (ext.lower() in ('.ogv', '.webm', '.mp4', '.mkv', '.avi')):
self.remote_files[file.name[len('game/'):]] = 'video -'
copy_file = True
if not copy_file:
return True
# Copy the file to the destination folder, keeping metadata
dst_path = os.path.join(self.destination, file.name)
dst_dir = os.path.dirname(dst_path)
if not os.path.isdir(dst_dir):
os.makedirs(dst_dir, 0o755)
shutil.copy2(file.path, dst_path)
return False
def finalize(self):
"""
Append some generated files to the ZIP archive.
"""
zout = zipfile.ZipFile(os.path.join(self.destination, 'game.zip'), 'a')
tmpdir = tempfile.mkdtemp()
# Generate and append placeholder image files to archive
for (src, dst) in self.images:
surface = pygame_sdl2.image.load(src)
(w, h) = (surface.get_width(), surface.get_height())
self.remote_files[dst[len('game/'):]] = 'image {},{}'.format(w,h)
tmpfile = generate_image_placeholder(surface, tmpdir)
placeholder_relpath = os.path.join('_placeholders', dst[len('game/'):])
zout.write(tmpfile, placeholder_relpath)
# Prepare a list of remote files for renpy.loader
remote_files_str = ''
for f in sorted(self.remote_files):
remote_files_str += f + "\n"
remote_files_str += self.remote_files[f] + "\n"
zout.writestr('game/renpyweb_remote_files.txt',
remote_files_str,
zipfile.ZIP_DEFLATED)
# Clean-up
shutil.rmtree(tmpdir)
zout.close()
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 generate_pwa_icons(p, destination):
"""
Checks if the pwa_icon.png file exists in the game folder and generates
required icons for PWA in subdirectory icons/ in the destination folder.
If no pwa_icon.png is found, the default Ren'Py icon is used instead in
the web folder, if exists.
"""
# Check if there's a custom icon in the game directory
icon_path = os.path.join(p.path, 'web-icon.png')
# Generate a default icon if there isn't
if not os.path.exists(icon_path):
icon_path = os.path.join(WEB_PATH, 'web-icon.png')
# Check if path is a valid image
if not os.path.exists(icon_path):
# Skip pwa support if no icon is found
return
# Create icons directory
icons_dir = os.path.join(destination, 'icons')
if not os.path.isdir(icons_dir):
os.makedirs(icons_dir, 0o777)
# Check the height and width of the icon
icon = pygame_sdl2.image.load(icon_path)
icon_width = icon.get_width()
icon_height = icon.get_height()
if icon_width != icon_height:
raise RuntimeError("The icon must be square")
if icon_width < 512:
raise RuntimeError("The icon must be at least 512x512 pixels")
scale = renpy.display.scale.smoothscale
best_compression = 9
# Generate 512x512 icon, if needed
if icon_width != 512:
icon512 = scale(icon, (512, 512))
pygame_sdl2.image.save(icon512, os.path.join(icons_dir, 'icon-512x512.png'), best_compression)
else:
pygame_sdl2.image.save(icon, os.path.join(icons_dir, 'icon-512x512.png'), best_compression)
# Generate 384x384 icon
icon384 = scale(icon, (384, 384))
pygame_sdl2.image.save(icon384, os.path.join(icons_dir, 'icon-384x384.png'), best_compression)
# Generate 192x192 icon
icon192 = scale(icon, (192, 192))
pygame_sdl2.image.save(icon192, os.path.join(icons_dir, 'icon-192x192.png'), best_compression)
# Generate 152x152 icon
icon152 = scale(icon, (152, 152))
pygame_sdl2.image.save(icon152, os.path.join(icons_dir, 'icon-152x152.png'), best_compression)
# Generate 144x144 icon
icon144 = scale(icon, (144, 144))
pygame_sdl2.image.save(icon144, os.path.join(icons_dir, 'icon-144x144.png'), best_compression)
# Generate 128x128 icon
icon128 = scale(icon, (128, 128))
pygame_sdl2.image.save(icon128, os.path.join(icons_dir, 'icon-128x128.png'), best_compression)
# Generate 96x96 icon
icon96 = scale(icon, (96, 96))
pygame_sdl2.image.save(icon96, os.path.join(icons_dir, 'icon-96x96.png'), best_compression)
# Generate 72x72 icon
icon72 = scale(icon, (72, 72))
pygame_sdl2.image.save(icon72, os.path.join(icons_dir, 'icon-72x72.png'), best_compression)
# Add 128 pixels to the 384x384 icon to generate 512x512 icon maskable
icon512_maskable = pygame_sdl2.Surface((512, 512), pygame_sdl2.SRCALPHA)
icon512_maskable.blit(icon384, (64, 64))
pygame_sdl2.image.save(icon512_maskable, os.path.join(icons_dir, 'icon-512x512-maskable.png'), best_compression)
# Resize icon512_maskable to 384x384 to generate 384x384 icon maskable
icon384_maskable = scale(icon512_maskable, (384, 384))
pygame_sdl2.image.save(icon384_maskable, os.path.join(icons_dir, 'icon-384x384-maskable.png'), best_compression)
# Resize icon512_maskable to 192x192 to generate 192x192 icon maskable
icon192_maskable = scale(icon512_maskable, (192, 192))
pygame_sdl2.image.save(icon192_maskable, os.path.join(icons_dir, 'icon-192x192-maskable.png'), best_compression)
# Resize icon512_maskable to 152x152 to generate 152x152 icon maskable
icon152_maskable = scale(icon512_maskable, (152, 152))
pygame_sdl2.image.save(icon152_maskable, os.path.join(icons_dir, 'icon-152x152-maskable.png'), best_compression)
# Resize icon512_maskable to 144x144 to generate 144x144 icon maskable
icon144_maskable = scale(icon512_maskable, (144, 144))
pygame_sdl2.image.save(icon144_maskable, os.path.join(icons_dir, 'icon-144x144-maskable.png'), best_compression)
# Resize icon512_maskable to 128x128 to generate 128x128 icon maskable
icon128_maskable = scale(icon512_maskable, (128, 128))
pygame_sdl2.image.save(icon128_maskable, os.path.join(icons_dir, 'icon-128x128-maskable.png'), best_compression)
# Resize icon512_maskable to 96x96 to generate 96x96 icon maskable
icon96_maskable = scale(icon512_maskable, (96, 96))
pygame_sdl2.image.save(icon96_maskable, os.path.join(icons_dir, 'icon-96x96-maskable.png'), best_compression)
# Resize icon512_maskable to 72x72 to generate 72x72 icon maskable
icon72_maskable = scale(icon512_maskable, (72, 72))
pygame_sdl2.image.save(icon72_maskable, os.path.join(icons_dir, 'icon-72x72-maskable.png'), best_compression)
def generate_files_catalog(destination):
"""
Generates a JSON file with information about the game files.
This file is used by the service worker to cache the game files.
:param destination: string, The destination path where the files will be copied to and where
game folder is located.
:param version: string, the version of the game. Should be the same as the version in the
manifest.json file.
:return: None
"""
catalog = {
"files": [ ],
"version": int(time.time())
}
# Walk through the game folder
for root, dirs, files in os.walk(destination):
for file in files:
# Get the absolute path of the file
file_path = os.path.join(root, file)
# Convert it to relative path of the file
file_name = os.path.relpath(file_path, destination)
# Replace backslashes with forward slashes
file_name = file_name.replace("\\", "/")
# Add the file to the catalog
catalog["files"].append(file_name)
with io.open(os.path.join(destination, "pwa_catalog.json"), 'w', encoding='utf-8') as f:
f.write(json.dumps(catalog))
def prepare_pwa_files(p, destination):
"""
Replaces in service-worker.js the cache name with the game name and current timestamp.
Replace in manifest.json the project name with the ones in the game.
"""
# Open the service-worker.js file
with io.open(os.path.join(destination, "service-worker.js"), encoding='utf-8') as f:
service_worker = f.read()
# Use re to slugify the game name, avoiding use of 3rd party libraries
slugified_name = re.sub(r'\W+', '-', p.dump['build']['display_name']).lower()
service_worker = service_worker.replace('renpy-web-game', slugified_name)
# Write the file
with io.open(os.path.join(destination, "service-worker.js"), 'w', encoding='utf-8') as f:
f.write(service_worker)
# Open the manifest.json file
with io.open(os.path.join(destination, "manifest.json"), encoding='utf-8') as f:
manifest = json.load(f)
# Replace the project name with the ones in the game
manifest["name"] = p.dump['build']['display_name']
screen_size = p.dump.get("size")
# If width are smaller than height, set the orientation to portrait. If not, leave it as is.
if screen_size[0] < screen_size[1]:
manifest["orientation"] = "portrait-primary"
# Write the file
with io.open(os.path.join(destination, "manifest.json"), 'w', encoding='utf-8') as f:
f.write(json.dumps(manifest))
generate_files_catalog(destination)
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)
files_filter = ProgressiveFilter(p)
# Use the distributor to make game.zip.
distribute.Distributor(p, packages=[ "web" ], packagedest=os.path.join(destination, "game"),
reporter=reporter, noarchive=True, scan=False, files_filter=files_filter)
reporter.info(_("Preparing progressive download"))
files_filter.finalize()
# Copy the files from WEB_PATH to destination.
for fn in os.listdir(WEB_PATH):
if fn in { "game.zip", "hash.txt", "index.html", "pwa_icon.png" }:
continue
shutil.copy(os.path.join(WEB_PATH, fn), os.path.join(destination, fn))
# Find the presplash and copy it over.
presplash = None
if not PY2:
for fn in [ "web-presplash.png", "web-presplash.jpg", "web-presplash.webp" ]:
fullfn = os.path.join(project.current.path, fn)
if os.path.exists(fullfn):
presplash = fn
break
if presplash:
os.unlink(os.path.join(destination, "web-presplash.jpg"))
shutil.copy(os.path.join(project.current.path, presplash), os.path.join(destination, presplash))
# Copy over index.html.
with io.open(os.path.join(WEB_PATH, "index.html"), encoding='utf-8') as f:
html = f.read()
if PY2:
html = html.replace("%%TITLE%%", display_name)
else:
html = html.replace("Ren'Py Web Game", display_name)
if presplash:
html = html.replace("web-presplash.jpg", presplash)
with io.open(os.path.join(destination, "index.html"), "w", encoding='utf-8') as f:
f.write(html)
if not PY2:
generate_pwa_icons(p, destination)
prepare_pwa_files(p, destination)
# Zip up the game.
zip_targets = [ ]
for dn, dirs, files in os.walk(destination):
for directory in dirs:
zip_targets.append(os.path.join(dn, directory))
for file in files:
zip_targets.append(os.path.join(dn, file))
with zipfile.ZipFile(destination + ".zip", 'w') as zf:
for i, target in enumerate(zip_targets):
zf.write(target, os.path.relpath(target, destination))
reporter.progress(_("Creating package..."), i + 1, len(zip_targets))
# Start the web server.
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.")
textbutton _("Return") action Jump("front_page") style "l_left_button"
label web:
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