diff -urN a/main.py b/main.py --- a/main.py +++ b/main.py @@ -0,0 +1,32 @@ +import pygame +# Starts Endgame: Singularity in the browser (pygbag). Written for the web build; licensed like the game, under the +# GPL, version 2 or later. + +import asyncio +import os +import sys + +here = os.path.dirname(os.path.abspath(__file__)) +os.chdir(here) +sys.path.insert(0, here) + +# Saves and preferences: the page keeps the /saves folder in the browser. The game looks for them under $HOME and the +# XDG folders, so point those there. +if os.path.isdir("/saves"): + os.environ["HOME"] = "/saves/home" + os.environ["XDG_CONFIG_HOME"] = "/saves/config" + os.environ["XDG_DATA_HOME"] = "/saves/data" + +# There is no numpy in the browser: a small pure Python stand-in does what the game needs. +import npshim + +sys.modules["numpy"] = npshim + +import singularity + + +async def main(): + await singularity.main() + + +asyncio.run(main()) diff -urN a/npshim.py b/npshim.py --- a/npshim.py +++ b/npshim.py @@ -0,0 +1,291 @@ +# A small pure Python stand-in for the few numpy calls Endgame: Singularity makes (cost vectors of three or five +# integers, a few elementwise operations and exp/log), for the web build, where numpy does not exist. +# main.py installs it as the "numpy" module. Written for the web build and licensed like the rest of the game, +# under the GPL, version 2 or later. + +import math + +__all__ = ["array", "asarray", "int64", "float64", "maximum", "round", "exp", "log", "seterr", "ndarray"] + + +class _Scalar(object): + """numpy scalars have the reductions of arrays too (x.min() is x).""" + + def min(self): return self + def max(self): return self + def sum(self): return self + def item(self): return self.__class__.__mro__[1](self) + def all(self): return bool(self) + def any(self): return bool(self) + + +class int64(_Scalar, int): + """dtype marker (and scalar type) for integers.""" + + def __new__(cls, value=0): + return int.__new__(cls, _to_int(value)) + + +class float64(_Scalar, float): + """dtype marker (and scalar type) for floats.""" + + +def _to_int(x): + # numpy casts floats to integers by truncation; nan and infinities have no integer, so they become 0 and big numbers + if isinstance(x, float): + if x != x: + return 0 + if x in (math.inf, -math.inf): + return 2**63 - 1 if x > 0 else -(2**63) + return int(x) + return int(x) + + +def _div(a, b): + """True division with numpy's results for a zero divisor (inf, -inf, nan) instead of an exception.""" + try: + return a / b + except ZeroDivisionError: + if a == 0 or a != a: + return math.nan + return math.inf if (a > 0) == (math.copysign(1.0, b) > 0) else -math.inf + + +def _floordiv(a, b): + if b == 0: # numpy gives 0 for integer division by zero (and inf/nan for floats) + if isinstance(a, int) and isinstance(b, int): + return 0 + return _div(a, b) + return a // b + + +class ndarray(list): + """A one dimensional array with an integer ("i"), float ("f") or boolean ("b") kind.""" + + kind = "i" + + def __init__(self, data=(), kind=None): + data = list(data) + if kind is None: + if data and all(isinstance(x, bool) for x in data): + kind = "b" + elif any(isinstance(x, float) for x in data): + kind = "f" + else: + kind = "i" + self.kind = kind + if kind == "i": + data = [_to_int(x) for x in data] + elif kind == "f": + data = [float(x) for x in data] + list.__init__(self, data) + + # --- helpers + def _new(self, data, kind): + return ndarray(data, kind) + + def _cast(self, value): + if self.kind == "i": + return _to_int(value) + if self.kind == "f": + return float(value) + return value + + def _binary(self, other, op, always_float=False): + if isinstance(other, (list, tuple)): + if len(other) != len(self): + raise ValueError("operands could not be broadcast together") + pairs = zip(self, other) + else: + pairs = ((x, other) for x in self) + out = [op(x, y) for x, y in pairs] + floaty = always_float or self.kind == "f" or _has_float(other) + if any(isinstance(v, float) for v in out): + floaty = True + return self._new(out, "f" if floaty else "i") + + def _rbinary(self, other, op, always_float=False): + return self._binary(other, lambda x, y: op(y, x), always_float) + + def _compare(self, other, op): + if isinstance(other, (list, tuple)): + return self._new([op(x, y) for x, y in zip(self, other)], "b") + return self._new([op(x, other) for x in self], "b") + + # --- arithmetic + def __add__(self, o): return self._binary(o, lambda x, y: x + y) + __radd__ = __add__ + def __sub__(self, o): return self._binary(o, lambda x, y: x - y) + def __rsub__(self, o): return self._rbinary(o, lambda x, y: x - y) + def __mul__(self, o): return self._binary(o, lambda x, y: x * y) + __rmul__ = __mul__ + def __truediv__(self, o): return self._binary(o, _div, True) + def __rtruediv__(self, o): return self._rbinary(o, _div, True) + def __floordiv__(self, o): return self._binary(o, _floordiv) + def __rfloordiv__(self, o): return self._rbinary(o, _floordiv) + def __neg__(self): return self._new([-x for x in self], self.kind) + + # In place operators keep the array (and its kind), like numpy does: the result is cast back to the kind. + def _inplace(self, other, op, always_float=False): + result = self._binary(other, op, always_float) + self[:] = result + return self + + def __iadd__(self, o): return self._inplace(o, lambda x, y: x + y) + def __isub__(self, o): return self._inplace(o, lambda x, y: x - y) + def __imul__(self, o): return self._inplace(o, lambda x, y: x * y) + def __ifloordiv__(self, o): return self._inplace(o, _floordiv) + def __itruediv__(self, o): + if self.kind != "f": + raise TypeError("Cannot cast ufunc 'divide' output from float64 to int64") + return self._inplace(o, _div, True) + + # --- comparisons + def __lt__(self, o): return self._compare(o, lambda x, y: x < y) + def __le__(self, o): return self._compare(o, lambda x, y: x <= y) + def __gt__(self, o): return self._compare(o, lambda x, y: x > y) + def __ge__(self, o): return self._compare(o, lambda x, y: x >= y) + def __eq__(self, o): return self._compare(o, lambda x, y: x == y) + def __ne__(self, o): return self._compare(o, lambda x, y: x != y) + __hash__ = None + + def __bool__(self): + if len(self) != 1: + raise ValueError("The truth value of an array with more than one element is ambiguous. Use a.any() or a.all()") + return bool(self[0]) + + # --- indexing + def __getitem__(self, index): + if isinstance(index, slice): + return self._new(list.__getitem__(self, index), self.kind) + if isinstance(index, ndarray) and index.kind == "b": + return self._new([x for x, keep in zip(self, index) if keep], self.kind) + return list.__getitem__(self, index) + + def __setitem__(self, index, value): + if isinstance(index, slice): + n = len(range(*index.indices(len(self)))) + if isinstance(value, (list, tuple)): + values = [self._cast(v) for v in value] + else: + values = [self._cast(value)] * n + list.__setitem__(self, index, values) + elif isinstance(index, ndarray) and index.kind == "b": + if isinstance(value, (list, tuple)): + it = iter(value) + for i, keep in enumerate(index): + if keep: + list.__setitem__(self, i, self._cast(next(it))) + else: + v = self._cast(value) + for i, keep in enumerate(index): + if keep: + list.__setitem__(self, i, v) + else: + list.__setitem__(self, index, self._cast(value)) + + def __iter__(self): + return list.__iter__(self) + + # --- reductions and conversions + def all(self): return all(self) + def any(self): return any(self) + def sum(self): return sum(self) + + def _scalar(self, v): + return float64(v) if isinstance(v, float) else int64(v) + + def min(self): + values = list(self) + if any(isinstance(v, float) and v != v for v in values): + return float64(math.nan) + return self._scalar(min(values)) + + def max(self): + values = list(self) + if any(isinstance(v, float) and v != v for v in values): + return float64(math.nan) + return self._scalar(max(values)) + + def tolist(self): return list(self) + def copy(self): return self._new(list(self), self.kind) + + def astype(self, dtype): + if dtype is int64 or dtype is int: + return self._new([_to_int(x) for x in self], "i") + if dtype is float64 or dtype is float: + return self._new([float(x) for x in self], "f") + return self.copy() + + @property + def shape(self): return (len(self),) + + def __repr__(self): + return "array(%s)" % list.__repr__(self) + + +def _has_float(x): + if isinstance(x, (list, tuple)): + return any(isinstance(v, float) for v in x) + return isinstance(x, float) + + +def array(data=(), dtype=None): + kind = None + if dtype is int64 or dtype is int: + kind = "i" + elif dtype is float64 or dtype is float: + kind = "f" + elif isinstance(data, ndarray): + kind = data.kind + if not isinstance(data, (list, tuple)): + data = list(data) + return ndarray(data, kind) + + +def asarray(data, dtype=None): + if isinstance(data, ndarray) and dtype is None: + return data + return array(data, dtype) + + +def maximum(a, b): + if not isinstance(a, ndarray): + a = array(a) + return a._binary(b, lambda x, y: x if (x != x or x >= y) else (y if y == y else x)) + + +def round(x, decimals=0): + """Rounds half to even, like numpy (and returns floats).""" + if isinstance(x, ndarray): + return x._new([_round1(v, decimals) for v in x], "f") + return _round1(x, decimals) + + +def _round1(v, decimals): + if isinstance(v, float) and (v != v or v in (math.inf, -math.inf)): + return v + return float(__builtins_round(v, decimals)) + + +import builtins as _builtins +__builtins_round = _builtins.round + + +def exp(x): + try: + return math.exp(x) + except OverflowError: + return math.inf + + +def log(x): + if x == 0: + return -math.inf + if x < 0: + return math.nan + return math.log(x) + + +def seterr(**kwargs): + return {} diff -urN a/singularity/__init__.py b/singularity/__init__.py --- a/singularity/__init__.py 2026-11-05 09:08:46.943032209 -0300 +++ b/singularity/__init__.py 2026-11-05 09:08:47.356815823 -0300 @@ -24,7 +24,6 @@ from __future__ import print_function -import optparse import logging from configparser import ConfigParser from io import open @@ -60,7 +59,7 @@ __full_version__ = __version__ -def main(): +async def main(): # Manually "pre-parse" command line arguments for -s|--singledir and --multidir, # so g.get_save_folder reports the correct location of preferences file # We also track --debug/-d to enable some stacktraces during initialization @@ -78,13 +77,6 @@ # Create all directories first dirs.create_directories(g.force_single_dir) - try: - import polib - except ImportError: - if g.debug: - raise - raise SystemExit("Endgame: Singularity requires polib.") - # Set language second, so help page and all error messages can be translated from singularity.code import i18n @@ -93,23 +85,7 @@ langs = i18n.available_languages() language = i18n.language - # Since we require numpy anyway, we might as well ask pygame to use it. - try: - import pygame - - pygame.surfarray.use_arraytype("numpy") - except ValueError: - if g.debug: - raise - raise SystemExit("Endgame: Singularity requires NumPy.") - except ImportError: - if g.debug: - print("Failed to import pygame. Python's sys.path is:") - for d in sys.path: - print(" %s" % str(d)) - print() - raise - raise SystemExit("Endgame: Singularity requires pygame.") + import pygame import singularity.code.graphics.g as gg import singularity.code.graphics.theme as theme @@ -269,169 +245,10 @@ except Exception: pass # Ignore - # Handle the program arguments. - desc = """Endgame: Singularity is a simulation of a true AI. - Go from computer to computer, pursued by the entire world. - Keep hidden, and you might have a chance.""" - parser = optparse.OptionParser( - version=__full_version__, description=desc, prog="singularity" - ) - parser.add_option( - "--sound", action="store_true", dest="sound", help="enable sound (default)" - ) - parser.add_option( - "--nosound", action="store_false", dest="sound", help="disable sound" - ) - parser.add_option( - "--daynight", - action="store_true", - dest="daynight", - help="enable day/night display (default)", - ) - parser.add_option( - "--nodaynight", - action="store_false", - dest="daynight", - help="disable day/night display", - ) - parser.add_option( - "-l", - "--lang", - "--language", - dest="language", - type="choice", - choices=langs, - metavar="LANG", - help="set the language to LANG (available languages: " - + " ".join(langs) - + ", default " - + language - + ")", - ) - parser.add_option( - "-g", "--grab", help="grab the mouse pointer", dest="grab", action="store_true" - ) - parser.add_option( - "--nograb", - help="don't grab the mouse pointer (default)", - dest="grab", - action="store_false", - ) - parser.add_option( - "-s", - "--singledir", - dest="singledir", - help="keep saved games and settings in the Singularity directory", - action="store_true", - ) - parser.add_option( - "--multidir", - dest="singledir", - help="keep saved games and settings in an OS-specific, per-user directory (default)", - action="store_false", - ) - parser.add_option( - "--soundbuf", - type="int", - help="""set the size of the sound buffer (default %s). - Discarded if --nosound is specified.""" - % mixer.soundbuf, - ) - - display_options = optparse.OptionGroup(parser, "Display Options") - display_options.add_option( - "-t", - "--theme", - dest="theme", - type="string", - metavar="THEME", - help="set theme to THEME", - ) - display_options.add_option( - "-r", - "--res", - "--resolution", - dest="resolution", - help="set resolution to custom RES (default %dx%d)" % gg.default_screen_size, - metavar="RES", - ) - for res in ["%dx%d" % res for res in gg.resolutions]: - display_options.add_option( - "--" + res, - action="store_const", - dest="resolution", - const=res, - help="set resolution to %s" % res, - ) - display_options.add_option( - "--fullscreen", action="store_true", help="start in fullscreen mode" - ) - display_options.add_option( - "--windowed", action="store_false", help="start in windowed mode (default)" - ) - parser.add_option_group(display_options) - - olpc_options = optparse.OptionGroup(parser, "OLPC-specific Options") - olpc_options.add_option( - "--xo1", - action="store_const", - dest="resolution", - const="1200x900", - help="set resolution to 1200x900 (OLPC XO-1)", - ) - olpc_options.add_option( - "--ebook", - help="""enables gamepad buttons for use in ebook mode. - D-pad moves mouse, check is click. O speeds up time, X slows down time, - and square stops time.""", - action="store_true", - default=False, - ) - parser.add_option_group(olpc_options) - - hidden_options = optparse.OptionGroup(parser, "Hidden Options") - hidden_options.add_option("-p", help="(ignored)", metavar=" ") - hidden_options.add_option( - "-d", "--debug", help="for finding bugs", action="store_true", default=False - ) - hidden_options.add_option( - "--cheater", - help="for bad little boys and girls", - action="store_true", - default=False, - ) - # Uncomment to make the hidden options visible. - # parser.add_option_group(hidden_options) - (options, args) = parser.parse_args() - - if options.language is not None: - i18n.set_language(options.language) - if options.theme is not None: - set_theme = options.theme - if options.resolution is not None: - try: - xres, yres = options.resolution.split("x") - gg.set_screen_size((int(xres), int(yres))) - except Exception: - parser.error( - "Resolution must be of the form x, e.g. %dx%d." - % gg.default_screen_size - ) - if options.grab is not None: - desired_set_grab = options.grab - if options.fullscreen is not None: - gg.set_fullscreen(options.fullscreen) - if options.sound is not None: - mixer.nosound = not options.sound - if options.daynight is not None: - g.daynight = options.daynight - if options.soundbuf is not None: - desired_soundbuf = options.soundbuf - - gg.ebook_mode = options.ebook - - g.cheater = options.cheater - g.debug = options.debug + # There is no command line in a browser: the preferences file is all there is. + gg.ebook_mode = False + g.cheater = 0 + g.debug = False import singularity.code.graphics.font as font @@ -477,11 +294,9 @@ from singularity.code.screens import main_menu menu_screen = main_menu.MainMenu() - try: - menu_screen.show() - except (SystemExit, KeyboardInterrupt): - # exit normally when window is closed (and silently for CTRL+C) - pass - finally: - # Be nice and close the window on SystemExit - pygame.quit() + while True: + try: + await menu_screen.show() + except (SystemExit, KeyboardInterrupt): + pass + # There is nothing to quit to on a web page: Quit just shows the main menu again. diff -urN a/singularity/code/aio.py b/singularity/code/aio.py --- a/singularity/code/aio.py 1969-12-31 21:00:00.000000000 -0300 +++ b/singularity/code/aio.py 2026-11-05 09:08:47.288164205 -0300 @@ -0,0 +1,12 @@ +# Helpers for running the game on the web (asyncio instead of blocking calls). +# +# This file is part of Endgame: Singularity and is licensed under the GPL, version 2 or later. + +import inspect + + +async def aw(result): + """Handlers and callbacks may be plain functions or coroutine functions: wait for the result of the latter.""" + if inspect.isawaitable(result): + return await result + return result diff -urN a/singularity/code/effect.py b/singularity/code/effect.py --- a/singularity/code/effect.py 2026-11-05 09:08:46.930319705 -0300 +++ b/singularity/code/effect.py 2026-11-05 09:08:47.345859140 -0300 @@ -62,7 +62,7 @@ ), "One-shot effects (winning the game) cannot be undone!" mixer.play_music("win") if not loading_savegame: - g.map_screen.show_story_section("Win") + g.later(g.map_screen.show_story_section, "Win") for group in g.pl.groups.values(): group.is_actively_discovering_bases = False g.pl.apotheosis = True diff -urN a/singularity/code/g.py b/singularity/code/g.py --- a/singularity/code/g.py 2026-11-05 09:08:46.885097025 -0300 +++ b/singularity/code/g.py 2026-11-05 09:08:47.284041522 -0300 @@ -79,6 +79,14 @@ pl = None # The Player instance map_screen = None +# Dialogs asked for by the game logic (story texts, event messages) while it is advancing the game. The web +# version cannot block inside that logic, so they wait here and the dialog loop shows them right after. +pending_dialogs = [] + + +def later(func, *args, **kwargs): + pending_dialogs.append((func, args, kwargs)) + def no_gui(): """Disable few pygame functionality (used for test)""" @@ -260,7 +268,7 @@ return pl is not None -def new_game(difficulty_name, initial_speed=1): +async def new_game(difficulty_name, initial_speed=1): global curr_speed curr_speed = initial_speed global pl diff -urN a/singularity/code/graphics/button.py b/singularity/code/graphics/button.py --- a/singularity/code/graphics/button.py 2026-11-05 09:08:46.919343105 -0300 +++ b/singularity/code/graphics/button.py 2026-11-05 09:08:47.331937768 -0300 @@ -19,6 +19,7 @@ # This file contains the Button class. from __future__ import absolute_import +from singularity.code.aio import aw import pygame @@ -108,7 +109,7 @@ self._new_hotkey(parsed_hotkey["key"]) return parsed_hotkey["text"] - def handle_hotkey(self, event): + async def handle_hotkey(self, event): if event.type == pygame.KEYDOWN: if ( self.visible @@ -116,7 +117,7 @@ and self.hotkey in (event.unicode, event.key) ): if self.hotkey_func: - self.hotkey_func(event) + await aw(self.hotkey_func(event)) def _retranslate(self): new_text = _(self._untranslated_text) @@ -209,12 +210,12 @@ if self.enabled and (self._selected ^ select_now): # If there's a change. self.selected = select_now - def handle_event(self, event): + async def handle_event(self, event): if event.type == pygame.MOUSEBUTTONUP: if self.visible and self.enabled and self.is_over(event.pos): - self.activate_with_sound(event) + await self.activate_with_sound(event) - def activate_with_sound(self, event): + async def activate_with_sound(self, event): """Called when the button is pressed or otherwise triggered. This method is called directly by the GUI handler, and should be @@ -227,9 +228,9 @@ from singularity.code.mixer import play_sound play_sound("click") - self.activated(event) + await self.activated(event) - def activated(self, event): + async def activated(self, event): """Called when the button is pressed or otherwise triggered.""" # Sometimes other GUI widgets trigger an activation; ignore @@ -261,10 +262,10 @@ self.kwargs = kwargs.pop("kwargs", {}) super(FunctionButton, self).__init__(*args, **kwargs) - def activated(self, event): + async def activated(self, event): """FunctionButton's custom activated menu. Makes the given function call and raises Handled if it returns without incident.""" - self.function(*self.args, **self.kwargs) + await aw(self.function(*self.args, **self.kwargs)) raise constants.Handled @@ -286,11 +287,11 @@ if self.parent: self.parent.remove_key_handler(pygame.K_ESCAPE, self.activate_default) - def activate_default(self, event): + async def activate_default(self, event): if not self.default: return - return self.activate_with_sound(event) + return await self.activate_with_sound(event) def exit_dialog(self): """Closes the dialog with the given exit code.""" @@ -306,7 +307,7 @@ super(DialogButton, self).__init__(*args, **kwargs) self.function = self.show_dialog - def show_dialog(self): + async def show_dialog(self): """When the assigned dialog exits, raises Handled with the dialog's exit code as a parameter. Subclass if you care what the code was.""" if not self.dialog: @@ -314,7 +315,7 @@ else: from singularity.code.graphics import dialog - raise constants.Handled(dialog.call_dialog(self.dialog, self)) + raise constants.Handled(await dialog.call_dialog(self.dialog, self)) TOGGLE_VALUE = object() @@ -368,9 +369,9 @@ if hasattr(self, "_collision_rect"): self.watch_mouse(None) - def activated(self, event): + async def activated(self, event): self.active = not self.active - super(StickyOnOffButton, self).activated(event) + await super(StickyOnOffButton, self).activated(event) def watch_mouse(self, event): if not self.active: @@ -397,9 +398,9 @@ if hasattr(self, "_collision_rect"): self.watch_mouse(None) - def activated(self, event): + async def activated(self, event): self.chosen_one() - super(ToggleButton, self).activated(event) + await super(ToggleButton, self).activated(event) def watch_mouse(self, event): if not self.active: diff -urN a/singularity/code/graphics/dialog.py b/singularity/code/graphics/dialog.py --- a/singularity/code/graphics/dialog.py 2026-11-05 09:08:46.909981205 -0300 +++ b/singularity/code/graphics/dialog.py 2026-11-05 09:08:47.305959512 -0300 @@ -19,6 +19,7 @@ # This file contains the dialog class. from __future__ import absolute_import +import asyncio import time from typing import Optional @@ -26,6 +27,8 @@ import pygame import operator +from singularity.code.aio import aw +from singularity.code import g as g_logic from singularity.code.global_hotkeys import detect_global_hotkey from singularity.code.graphics import g, constants, widget, text, button, listbox @@ -124,7 +127,28 @@ pygame.event.post(new_event) -def call_dialog(dialog, parent=None): +async def next_event(): + """Stand-in for pygame.event.wait(): a browser cannot block, so poll the event queue, give the browser its turn and + deliver the timer tick that pygame.time.set_timer would have posted.""" + while True: + event = pygame.event.poll() + if event.type != pygame.NOEVENT: + return event + if Dialog.timer_ms: + now = time.time() + if now >= Dialog.next_tick: + Dialog.next_tick = now + Dialog.timer_ms / 1000.0 + return pygame.event.Event(pygame.USEREVENT) + await asyncio.sleep(0) + + +async def flush_pending_dialogs(): + while g_logic.pending_dialogs: + func, args, kwargs = g_logic.pending_dialogs.pop(0) + await func(*args, **kwargs) + + +async def call_dialog(dialog, parent=None): parent_dialog = None target = parent while target: @@ -136,10 +160,10 @@ if parent_dialog: parent_dialog.lost_focus() - retval = dialog.show() + retval = await dialog.show() if parent_dialog: - parent_dialog.regained_focus() + await parent_dialog.regained_focus() Dialog.top.needs_redraw = True @@ -195,10 +219,10 @@ fake_key(pygame.K_ESCAPE) raise constants.Handled - def regained_focus(self): + async def regained_focus(self): self.faded = False self.start_timer() - self.fake_mouse() + await self.fake_mouse() def make_top(self): """Makes this dialog be the top-level dialog.""" @@ -211,20 +235,25 @@ """Recreates the surfaces that this widget will draw on.""" super(Dialog, self).remake_surfaces() + # pygame.time.set_timer is not available on the web: the tick is kept here and delivered by next_event(). + timer_ms = 0 + next_tick = 0.0 + def start_timer(self, force=False): if self.needs_timer == None: self.needs_timer = bool(self.handlers.get(constants.TICK, False)) if self.needs_timer or force: - pygame.time.set_timer(pygame.USEREVENT, 1000 // g.FPS) + Dialog.timer_ms = 1000 // g.FPS + Dialog.next_tick = time.time() + Dialog.timer_ms / 1000.0 def stop_timer(self): - pygame.time.set_timer(pygame.USEREVENT, 0) + Dialog.timer_ms = 0 def reset_timer(self): self.stop_timer() self.start_timer() - def show(self): + async def show(self): """Shows the dialog and enters an event-handling loop.""" from singularity.code.mixer import play_music @@ -235,11 +264,11 @@ # Pretend to jiggle the mouse pointer, to force buttons to update their # selected state. Dialog.top.maybe_update() - self.fake_mouse() + await self.fake_mouse() # Force a timer tick at the start to make sure everything's initialized. if self.needs_timer: - self.handle(pygame.event.Event(pygame.USEREVENT)) + await self.handle(pygame.event.Event(pygame.USEREVENT)) Dialog.top.maybe_update() pygame.display.flip() @@ -250,8 +279,12 @@ # Update handles updates of all kinds to all widgets, as needed. Dialog.top.maybe_update() play_music() - event = pygame.event.wait() - result = self.handle(event) + if g_logic.pending_dialogs: + # Dialogs the game logic asked for while it was running (see g.later). + await flush_pending_dialogs() + continue + event = await next_event() + result = await self.handle(event) if result != constants.NO_RESULT: break self.stop_timer() @@ -287,7 +320,7 @@ h for h in self.key_handlers.get(key, []) if h[1] != handler ] - def handle(self, event): + async def handle(self, event): """Sends an event through all the applicable handlers, returning constants.NO_RESULT if the event goes unhandled or is handled without requesting the dialog to exit. Otherwise, returns the value provided @@ -301,7 +334,6 @@ # Compress multiple MOUSEMOTION events into one. # Note that the pos will be wrong, so pygame.mouse.get_pos() must # be used instead. - time.sleep(1.0 / g.FPS) pygame.event.clear(pygame.MOUSEMOTION) # Generic mouse motion handlers. @@ -328,7 +360,7 @@ hotkey_action = detect_global_hotkey(event) if hotkey_action is not None: - hotkey_action() + await aw(hotkey_action()) elif event.type == pygame.KEYDOWN: # Generic keydown handlers. insort_all(handlers, self.handlers.get(constants.KEYDOWN, [])) @@ -405,22 +437,22 @@ self.needs_redraw = True return constants.NO_RESULT - return self.call_handlers(handlers, event) + return await self.call_handlers(handlers, event) - def fake_mouse(self): + async def fake_mouse(self): """Fakes a MOUSEMOTION event. MOUSEMOTION handlers must be able to handle a None event, in order to support this method.""" handlers = self.handlers.get(constants.MOUSEMOTION, [])[:] - self.call_handlers(handlers, event=None) + await self.call_handlers(handlers, event=None) def on_close_dialog(self): pass - def call_handlers(self, handlers, event): + async def call_handlers(self, handlers, event): # Feed the event to all the handlers, in priority order. for __, handler in handlers: try: - handler(event) + await aw(handler(event)) except constants.Handled: break # If it's been handled, we leave the rest alone. except constants.ExitDialog as e: @@ -513,7 +545,7 @@ """NullDialog, for when you absolutely, positively need to do nothing at all.""" - def show(self): + async def show(self): pass @@ -589,21 +621,21 @@ self.add_key_handler(pygame.K_KP_ENTER, self.on_return) self.add_key_handler(pygame.K_ESCAPE, self.on_escape) - def on_return(self, event): + async def on_return(self, event): if event and event.type == pygame.KEYUP: return if self.invert_enter: - self.no_button.activate_with_sound(event) + await self.no_button.activate_with_sound(event) else: - self.yes_button.activate_with_sound(event) + await self.yes_button.activate_with_sound(event) - def on_escape(self, event): + async def on_escape(self, event): if event and event.type == pygame.KEYUP: return if self.invert_escape: - self.yes_button.activate_with_sound(event) + await self.yes_button.activate_with_sound(event) else: - self.no_button.activate_with_sound(event) + await self.no_button.activate_with_sound(event) class MessageDialog(TextDialog): @@ -626,10 +658,10 @@ self.add_key_handler(pygame.K_RETURN, self.on_return) self.add_key_handler(pygame.K_KP_ENTER, self.on_return) - def on_return(self, event): + async def on_return(self, event): if event.type == pygame.KEYUP: return - self.ok_button.activate_with_sound(event) + await self.ok_button.activate_with_sound(event) class TextEntryDialog(TextDialog, FocusDialog): @@ -679,10 +711,10 @@ self.add_key_handler(pygame.K_KP_ENTER, self.return_text) self.add_key_handler(pygame.K_ESCAPE, self.return_nothing) - def show(self): + async def show(self): self.text_field.text = self.default_text self.text_field.cursor_pos = len(self.default_text) - return super(TextEntryDialog, self).show() + return await super(TextEntryDialog, self).show() def return_nothing(self, event=None): if event and event.type == pygame.KEYUP: @@ -724,7 +756,7 @@ def return_list_pos(self): return self.listbox.list_pos - def show(self): + async def show(self): if type(self.default) == int: self.listbox.list_pos = self.default elif type(self.default) == str and self.default in self.list: @@ -733,7 +765,7 @@ self.listbox.list_pos = 0 self.listbox.auto_scroll = True - return super(ChoiceDialog, self).show() + return await super(ChoiceDialog, self).show() def rebuild(self): self.listbox.list = self.list diff -urN a/singularity/code/graphics/font.py b/singularity/code/graphics/font.py --- a/singularity/code/graphics/font.py 2026-11-05 09:08:46.913775598 -0300 +++ b/singularity/code/graphics/font.py 2026-11-05 09:08:47.325851764 -0300 @@ -31,10 +31,8 @@ pygame.font.init() # We don't enable cache for version inferior to 1.9.2 because crash. - if pygame.vernum < (1, 9, 2): - font_cache = False - else: - font_cache = True + # The file-like object this cache handed to pygame.font.Font does not work in the browser: always use the path. + font_cache = False def generate_from_cache(filename): diff -urN a/singularity/code/graphics/g.py b/singularity/code/graphics/g.py --- a/singularity/code/graphics/g.py 2026-11-05 09:08:46.915345672 -0300 +++ b/singularity/code/graphics/g.py 2026-11-05 09:08:47.328555122 -0300 @@ -98,10 +98,7 @@ def init_graphics_system(): global desktop_size - width, height = (pygame.display.Info().current_w, pygame.display.Info().current_h) - - if width > 0 and height > 0: - desktop_size = (width, height) + # On the web the canvas is not a desktop: desktop_size stays empty and the configured size is used as it is. # (Re-)calculate real screen size set_screen_size() @@ -195,10 +192,8 @@ """Wrapper for pygame.display.set_mode()""" global screen_surface - if fullscreen: - flags = pygame.FULLSCREEN - else: - flags = pygame.RESIZABLE + # The page decides the size of the canvas: no resizable window and no fullscreen switch on the web. + flags = 0 screen_surface = pygame.display.set_mode(real_screen_size, flags) diff -urN a/singularity/code/graphics/listbox.py b/singularity/code/graphics/listbox.py --- a/singularity/code/graphics/listbox.py 2026-11-05 09:08:46.923920536 -0300 +++ b/singularity/code/graphics/listbox.py 2026-11-05 09:08:47.336222616 -0300 @@ -19,6 +19,7 @@ # This file contains the listbox widget. from __future__ import absolute_import +from singularity.code.aio import aw import math @@ -108,13 +109,13 @@ # ... and select it. self.list_pos = self.safe_pos(index + self.scrollbar.scroll_pos) - def on_double_click(self, event): + async def on_double_click(self, event): if self.on_double_click_on_item is None: return if self.collision_rect.collidepoint(event.pos) and self.item_selectable: index = self.find_item_under_mouse(event) if index > -1: - self.on_double_click_on_item(event) + await aw(self.on_double_click_on_item(event)) def current_item(self): if 0 <= self.list_pos < len(self.list): diff -urN a/singularity/code/graphics/slider.py b/singularity/code/graphics/slider.py --- a/singularity/code/graphics/slider.py 2026-11-05 09:08:46.917174036 -0300 +++ b/singularity/code/graphics/slider.py 2026-11-05 09:08:47.330325861 -0300 @@ -209,7 +209,7 @@ self.jump(go_lower, big_jump=big_jump, tiny_jump=tiny_jump) raise constants.Handled - def activated(self, event): + async def activated(self, event): assert event.type == pygame.MOUSEBUTTONUP if self.horizontal: self.jump(go_lower=(event.pos[0] < self.button.collision_rect[0])) diff -urN a/singularity/code/hotkey_actions.py b/singularity/code/hotkey_actions.py --- a/singularity/code/hotkey_actions.py 2026-11-05 09:08:46.940893556 -0300 +++ b/singularity/code/hotkey_actions.py 2026-11-05 09:08:47.355143666 -0300 @@ -36,7 +36,7 @@ create_savegame(QUICKSAVE_NAME) -def quickload(): +async def quickload(): if not g.is_game_running(): return map_screen = Dialog.current_dialog @@ -56,7 +56,7 @@ anchor=constants.MID_CENTER, text=msg, ) - go_ahead = dialog.call_dialog(yn, map_screen) + go_ahead = await dialog.call_dialog(yn, map_screen) yn.parent = None if not go_ahead: return diff -urN a/singularity/code/i18n.py b/singularity/code/i18n.py --- a/singularity/code/i18n.py 2026-11-05 09:08:46.894793798 -0300 +++ b/singularity/code/i18n.py 2026-11-05 09:08:47.292661760 -0300 @@ -28,7 +28,10 @@ import gettext import os import locale -import polib +try: + import polib +except ImportError: # only needed for the translations, which the web build does not ship + polib = None from singularity.code import g, dirs diff -urN a/singularity/code/player.py b/singularity/code/player.py --- a/singularity/code/player.py 2026-11-05 09:08:46.888163923 -0300 +++ b/singularity/code/player.py 2026-11-05 09:08:47.286589923 -0300 @@ -422,7 +422,7 @@ self.had_grace = False self.pause_game() - g.map_screen.show_story_section("Grace Warning") + g.later(g.map_screen.show_story_section, "Grace Warning") dead_bases = _check_for_dead_bases(grace, unpaid_cpu_maintenance, @@ -476,7 +476,7 @@ event_target.trigger() if show_event_description: self.pause_game() - g.map_screen.show_message(event_target.description) + g.later(g.map_screen.show_message, event_target.description) self.log.append(LogEmittedEvent(self.raw_sec, event_id)) def recalc_cpu(self): @@ -604,7 +604,7 @@ self.log.append(log_message) self.pause_game() base.destroy() - g.map_screen.show_message(log_message.full_message, color="red") + g.later(g.map_screen.show_message, log_message.full_message, color="red") # Now we update the internal information about what locations had # the most recent discovery and the nextmost recent one. First, diff -urN a/singularity/code/safety.py b/singularity/code/safety.py --- a/singularity/code/safety.py 2026-11-05 09:08:46.900021766 -0300 +++ b/singularity/code/safety.py 2026-11-05 09:08:47.295504576 -0300 @@ -68,6 +68,11 @@ try: return func(*args, **kwargs) except Exception: + return _report_error(func, on_error) + + +def _report_error(func, on_error): + if True: global FIRST_ERROR if FIRST_ERROR: log_error( @@ -100,6 +105,14 @@ return on_error +async def safe_call_async(func, args=(), kwargs={}, on_error=None): + """safe_call for coroutine functions.""" + try: + return await func(*args, **kwargs) + except Exception: + return _report_error(func, on_error) + + # Catches any errors raised by a function, logs them, and returns the given # value. # diff -urN a/singularity/code/savegame.py b/singularity/code/savegame.py --- a/singularity/code/savegame.py 2026-11-05 09:08:46.857860692 -0300 +++ b/singularity/code/savegame.py 2026-11-05 09:08:47.232326758 -0300 @@ -222,7 +222,7 @@ return all_savegames -def delete_savegame(savegame): +async def delete_savegame(savegame): load_path = savegame.filepath if load_path is None: diff -urN a/singularity/code/screens/base.py b/singularity/code/screens/base.py --- a/singularity/code/screens/base.py 2026-11-05 09:08:46.881292131 -0300 +++ b/singularity/code/screens/base.py 2026-11-05 09:08:47.281263581 -0300 @@ -57,7 +57,7 @@ self.desc_func = self.on_change self.add_handler(constants.KEY, self._got_key, priority=5) - def show(self): + async def show(self): self.list = [] self.key_list = [] @@ -78,7 +78,7 @@ self.default = self.parent.get_current(self.type).spec.id self._update_desc_pane() - return super(BuildDialog, self).show() + return await super(BuildDialog, self).show() def on_description_change(self): if self.item is not None: @@ -109,7 +109,7 @@ def on_close_dialog(self): g.pl.considered_buyables = [] - def _got_key(self, event): + async def _got_key(self, event): self.listbox.got_key(event, require_focus=False) @@ -155,8 +155,8 @@ slider_size=2, ) - def _got_key(self, event): - super(MultipleBuildDialog, self)._got_key(event) + async def _got_key(self, event): + await super(MultipleBuildDialog, self)._got_key(event) self.count_slider.handle_key(event) self.count_field.cursor_pos = len(self.count_field.text) self.count_field.handle_key(event, require_focus=False) @@ -417,7 +417,7 @@ return True return False - def auto_build(self): + async def auto_build(self): base = self.base if base is None or base.spec.force_cpu: return @@ -461,18 +461,18 @@ confirm_dialog = YesNoDialog(self, text=text) self.needs_rebuild = True - confirmed = dialog.call_dialog(confirm_dialog, self) + confirmed = await dialog.call_dialog(confirm_dialog, self) g.pl.considered_buyables = [] if not confirmed: return for item_spec, count in best_item_by_type.values(): - self.set_current(item_spec.item_type, item_spec, count) + await self.set_current(item_spec.item_type, item_spec, count) self.needs_rebuild = True def get_current(self, type): return self.base.items[type.id] - def set_current(self, type, item_type, count): + async def set_current(self, type, item_type, count): if type.id == "cpu": space_left = self.base.space_left_for(item_type) @@ -489,7 +489,7 @@ anchor=constants.MID_CENTER, text=msg, ) - dialog.call_dialog(md, self) + await dialog.call_dialog(md, self) md.parent = None return @@ -509,7 +509,7 @@ anchor=constants.MID_CENTER, text=msg, ) - dialog.call_dialog(md, self) + await dialog.call_dialog(md, self) md.parent = None return @@ -529,7 +529,7 @@ anchor=constants.MID_CENTER, text=msg, ) - go_ahead = dialog.call_dialog(yn, self) + go_ahead = await dialog.call_dialog(yn, self) yn.parent = None if not go_ahead: return @@ -550,7 +550,7 @@ anchor=constants.MID_CENTER, text=msg, ) - go_ahead = dialog.call_dialog(yn, self) + go_ahead = await dialog.call_dialog(yn, self) yn.parent = None if not go_ahead: return @@ -569,7 +569,7 @@ self.base.recalc_cpu() - def build_item(self, type): + async def build_item(self, type): if type.id == "cpu": build_dialog = self.multiple_build_dialog else: @@ -577,7 +577,7 @@ build_dialog.type = type - result = dialog.call_dialog(build_dialog, self) + result = await dialog.call_dialog(build_dialog, self) if result is not None and 0 <= result < len(build_dialog.key_list): item_type = build_dialog.key_list[result] @@ -585,7 +585,7 @@ if type.id == "cpu": count = build_dialog.count - self.set_current(type, item_type, count) + await self.set_current(type, item_type, count) self.needs_rebuild = True self.parent.parent.needs_rebuild = True diff -urN a/singularity/code/screens/cheat_menu.py b/singularity/code/screens/cheat_menu.py --- a/singularity/code/screens/cheat_menu.py 2026-11-05 09:08:46.864785941 -0300 +++ b/singularity/code/screens/cheat_menu.py 2026-11-05 09:08:47.234910200 -0300 @@ -84,12 +84,12 @@ event_instance.expire_now() self.needs_rebuild = True - def show(self): + async def show(self): self.key_list = sorted(g.events.values(), key=operator.attrgetter("id")) self.list = [x.id for x in self.key_list] self._update_desc_pane() - return super(EventManipulationDialog, self).show() + return await super(EventManipulationDialog, self).show() def on_change(self, description_pane, event_spec): self.selected_event_spec = event_spec @@ -239,8 +239,8 @@ ) self._map_screen.needs_rebuild = True - def steal_money(self): - asked = dialog.call_dialog(self.steal_amount_dialog, self) + async def steal_money(self): + asked = await dialog.call_dialog(self.steal_amount_dialog, self) try: g.pl.cash += int(asked) except ValueError: @@ -276,7 +276,7 @@ g.pl.display_discover = "none" self._map_screen.needs_rebuild = True - def hidden_state(self): + async def hidden_state(self): presenters = { float: lambda x: round(x, 4), Location: lambda x: x.id, @@ -398,4 +398,4 @@ ) state_dialog.listbox.item_selectable = False state_dialog.listbox.align = constants.LEFT - dialog.call_dialog(state_dialog, self) + await dialog.call_dialog(state_dialog, self) diff -urN a/singularity/code/screens/knowledge.py b/singularity/code/screens/knowledge.py --- a/singularity/code/screens/knowledge.py 2026-11-05 09:08:46.879972598 -0300 +++ b/singularity/code/screens/knowledge.py 2026-11-05 09:08:47.279993755 -0300 @@ -255,8 +255,8 @@ return desc_text - def show(self): + async def show(self): self.set_knowledge_type(-1) self.knowledge_choice.list_pos = 0 self.knowledge_inner.list_pos = 0 - return super(KnowledgeScreen, self).show() + return await super(KnowledgeScreen, self).show() diff -urN a/singularity/code/screens/location.py b/singularity/code/screens/location.py --- a/singularity/code/screens/location.py 2026-11-05 09:08:46.877349156 -0300 +++ b/singularity/code/screens/location.py 2026-11-05 09:08:47.276242528 -0300 @@ -268,9 +268,9 @@ else: canvas.base_cpu.text = "" - def show(self): + async def show(self): self.listbox.has_focus = True - return super(LocationScreen, self).show() + return await super(LocationScreen, self).show() def rebuild(self): # Update base location @@ -306,31 +306,31 @@ self.needs_rebuild = True self.parent.needs_rebuild = True - def destroy_base(self): + async def destroy_base(self): if 0 <= self.listbox.list_pos < len(self.listbox.key_list): selected_base = self.listbox.key_list[self.listbox.list_pos] all_active_bases = [b for b in g.all_bases() if b.maintains_singularity] if len(all_active_bases) == 1 and all_active_bases[0] == selected_base: - dialog.call_dialog(self.cannot_destroy_last_base, self) - elif dialog.call_dialog(self.confirm_destroy, self): + await dialog.call_dialog(self.cannot_destroy_last_base, self) + elif await dialog.call_dialog(self.confirm_destroy, self): selected_base.destroy() self.listbox.list = [b.name for b in self.location.bases] self.listbox.key_list = self.location.bases self.needs_rebuild = True self.parent.needs_rebuild = True - def open_base(self): + async def open_base(self): if 0 <= self.listbox.list_pos < len(self.listbox.key_list): base = self.listbox.key_list[self.listbox.list_pos] if not base.done: return self.base_dialog.base = base - dialog.call_dialog(self.base_dialog, self) + await dialog.call_dialog(self.base_dialog, self) self.needs_rebuild = True self.parent.needs_rebuild = True - def new_base(self): - result = dialog.call_dialog(self.new_base_dialog, self) + async def new_base(self): + result = await dialog.call_dialog(self.new_base_dialog, self) if result: base_type, base_name = result new_base = base.Base(base_type, base_name) @@ -338,11 +338,11 @@ self.needs_rebuild = True self.parent.needs_rebuild = True - def rename_base(self): + async def rename_base(self): if 0 <= self.listbox.list_pos < len(self.listbox.key_list): base = self.listbox.key_list[self.listbox.list_pos] self.name_dialog.default_text = base.name - name = dialog.call_dialog(self.name_dialog, self) + name = await dialog.call_dialog(self.name_dialog, self) if name: base.name = name self.needs_rebuild = True @@ -416,7 +416,7 @@ else: g.pl.considered_buyables = [] - def show(self): + async def show(self): self.list = [] self.key_list = [] @@ -429,7 +429,7 @@ self._update_desc_pane() self.needs_rebuild = True self.listbox.has_focus = True - return super(NewBaseDialog, self).show() + return await super(NewBaseDialog, self).show() def handle_update(self, new_item_pos): super(NewBaseDialog, self).handle_update(new_item_pos) diff -urN a/singularity/code/screens/log.py b/singularity/code/screens/log.py --- a/singularity/code/screens/log.py 2026-11-05 09:08:46.869430371 -0300 +++ b/singularity/code/screens/log.py 2026-11-05 09:08:47.238290721 -0300 @@ -64,7 +64,7 @@ item_selectable=True, ) - def handle_double_click(self, event): + async def handle_double_click(self, event): if self.listbox.is_over(event.pos) and 0 <= self.listbox.list_pos < len( self.key_list ): @@ -81,13 +81,13 @@ # than we need). try: self.visible = False - dialog.call_dialog(message_dialog, self.parent) + await dialog.call_dialog(message_dialog, self.parent) finally: self.visible = True self.needs_rebuild = True self.parent.needs_rebuild = True self.parent.lost_focus() - self.regained_focus() + await self.regained_focus() def rebuild(self): self.key_list = [ @@ -100,12 +100,12 @@ super(LogScreen, self).rebuild() - def show(self): + async def show(self): self.listbox.has_focus = True - return super(LogScreen, self).show() + return await super(LogScreen, self).show() - def show_filters(self): - dialog.call_dialog(self.filter_log_dialog, self) + async def show_filters(self): + await dialog.call_dialog(self.filter_log_dialog, self) self.needs_rebuild = True def render_log_message(self, message): diff -urN a/singularity/code/screens/main_menu.py b/singularity/code/screens/main_menu.py --- a/singularity/code/screens/main_menu.py 2026-11-05 09:08:46.866027100 -0300 +++ b/singularity/code/screens/main_menu.py 2026-11-05 09:08:47.236133859 -0300 @@ -127,17 +127,17 @@ super(MainMenu, self).rebuild() - def new_game(self): - difficulty = dialog.call_dialog(self.difficulty_dialog, self) + async def new_game(self): + difficulty = await dialog.call_dialog(self.difficulty_dialog, self) if difficulty is not None: sv.last_savegame_name = None - g.new_game(difficulty) - dialog.call_dialog(self.map_screen, self) + await g.new_game(difficulty) + await dialog.call_dialog(self.map_screen, self) - def load_game(self): - did_load = dialog.call_dialog(self.load_dialog, self) + async def load_game(self): + did_load = await dialog.call_dialog(self.load_dialog, self) if did_load: - dialog.call_dialog(self.map_screen, self) + await dialog.call_dialog(self.map_screen, self) about_message = N_( diff -urN a/singularity/code/screens/map.py b/singularity/code/screens/map.py --- a/singularity/code/screens/map.py 2026-11-05 09:08:46.875647833 -0300 +++ b/singularity/code/screens/map.py 2026-11-05 09:08:47.273916751 -0300 @@ -35,9 +35,7 @@ import math import time -from pygame.surfarray import pixels_alpha - -from numpy import sin, cos, linspace, pi, tanh, round, newaxis, uint8 +from math import sin, cos, pi, tanh, acos, floor, ceil LOCATION_RECENT_DISCOVERIES_TO_TEXT_COLOR = ( @@ -79,11 +77,6 @@ def resize(self): super(EarthImage, self).resize() self.reset_night_mask_computation() - width, height = self.real_size - latitude = linspace(-pi / 2, pi / 2, height)[newaxis, :] - longitude = linspace(0, 2 * pi, width)[:, newaxis] - self._cos_longitude_x_cos_latitiude = cos(longitude) * cos(latitude) - self._sin_latitude = sin(latitude) self.high_speed_pos = None night_mask_day_of_year = None @@ -102,7 +95,7 @@ day_of_year = (g.pl.time_day + g.pl.start_day) % 365 # no leap years, sorry return day_of_year - def on_tick(self, event): + async def on_tick(self, event): if self.next_night_mask_ready: return width, height = self.real_size @@ -112,10 +105,14 @@ self._compute_night_mask_step(width, height, day_of_year + 1) def _compute_night_mask_step(self, width, height, next_day_of_year): + # The original computed the whole mask with numpy; the web build has none, so the mask is drawn row by row + # (see _fill_night_mask_rows), spread over the same five steps. if self.next_night_mask_ready: return - if self.next_night_mask_step == 0: - self.next_night_mask = pygame.Surface((width, height), 0, gg.ALPHA) + step = self.next_night_mask_step + if step == 0: + self.next_night_mask = pygame.Surface((width, height), pygame.SRCALPHA, 32) + self.next_night_mask.fill((255, 255, 255, 0)) self.step_sun_declination = ( -23.45 / 360.0 @@ -123,27 +120,67 @@ * math.pi * math.cos(2 * math.pi / 365.0 * (next_day_of_year + 10)) ) - - elif self.next_night_mask_step == 1: - self.step_sin_sun_altitude = self._cos_longitude_x_cos_latitiude * cos( - self.step_sun_declination - ) + self._sin_latitude * sin(self.step_sun_declination) - elif self.next_night_mask_step == 2: - self.step_light = 0.5 * ( - tanh(self.step_sin_sun_altitude / self.sun_radius) + 1 + elif step <= 3: + self._fill_night_mask_rows( + (step - 1) * height // 3, step * height // 3, width, height ) - elif self.next_night_mask_step == 3: - max_alpha = 255 - self.step_night_alphas = pixels_alpha(self.next_night_mask) - self.step_round_light = round(max_alpha * self.step_light).astype(uint8) - elif self.next_night_mask_step == 4: - self.step_night_alphas[...] = self.step_round_light + else: self.next_night_mask_ready = True - self.next_night_mask_step = (self.next_night_mask_step + 1) % 5 + self.next_night_mask_step = (step + 1) % 5 + + def _fill_night_mask_rows(self, first, last, width, height): + """Sets the alpha of the rows [first, last) of the next night mask to + round(255 * 0.5 * (tanh(sin(sun altitude) / sun_radius) + 1)), where + sin(altitude) = cos(longitude) * cos(latitude) * cos(declination) + sin(latitude) * sin(declination). + Along a row it falls from 255 to 0 around two mirrored longitudes, so only the pixels of that + transition are computed one by one and the rest is filled in spans.""" + mask = self.next_night_mask + radius = self.sun_radius + reach = 4 * radius # beyond this tanh is 1 (or -1) to the precision of an 8 bit alpha + cos_decl = cos(self.step_sun_declination) + sin_decl = sin(self.step_sun_declination) + half = (width - 1) // 2 + per_pixel = 2 * pi / (width - 1) + for y in range(first, last): + latitude = -pi / 2 + pi * y / (height - 1) + a = cos(latitude) * cos_decl + b = sin(latitude) * sin_decl + if a < 1e-9: + alpha = round(255 * 0.5 * (tanh(b / radius) + 1)) + if alpha: + mask.fill((255, 255, 255, alpha), (0, y, width, 1)) + continue + + def column_where(altitude): + """x (from the left edge) where the altitude falls to the given value, clamped to [-1, half + 1].""" + c = (altitude - b) / a + if c >= 1: + return -1.0 + if c <= -1: + return half + 1.0 + return acos(c) / per_pixel + + full = column_where(reach) # up to here the alpha is 255 + dark = column_where(-reach) # from here on it is 0 + n_full = min(int(floor(full)) + 1, half + 1) + n_full = max(n_full, 0) + if n_full: + mask.fill((255, 255, 255, 255), (0, y, n_full, 1)) + mask.fill((255, 255, 255, 255), (width - n_full, y, n_full, 1)) + last_x = min(int(ceil(dark)), half) + for x in range(n_full, last_x + 1): + altitude = a * cos(x * per_pixel) + b + alpha = round(255 * 0.5 * (tanh(altitude / radius) + 1)) + if alpha: + color = (255, 255, 255, alpha) + mask.set_at((x, y), color) + mask.set_at((width - 1 - x, y), color) def reset_night_mask_computation(self): self.night_mask = None + self.night_mask_day_of_year = None + self.night_mask_dim = None self.next_night_mask_ready = False self.next_night_mask_step = 0 @@ -166,6 +203,9 @@ self._compute_night_mask_step(width, height, day_of_year) self.night_mask = self.next_night_mask self.next_night_mask_ready = False + # (upstream never recorded these two, so the mask was rebuilt on every redraw; numpy hid the cost) + self.night_mask_day_of_year = day_of_year + self.night_mask_dim = (width, height) return self.night_mask @@ -193,16 +233,22 @@ # Turn half of the map to night, with blended borders. night_mask = self.get_night_mask() - mask_alphas = pixels_alpha(night_mask) - night_alphas = pixels_alpha(self.night_image) + # Shift the mask sideways (it wraps around) and use its alpha as the alpha of the night image. + height = self.real_size[1] right_width = width - self.night_start - night_alphas[self.night_start :] = mask_alphas[:right_width] + shifted = pygame.Surface((width, height), pygame.SRCALPHA, 32) + shifted.fill((0, 0, 0, 0)) + shifted.blit( + night_mask, (self.night_start, 0), (0, 0, right_width, height), pygame.BLEND_RGBA_ADD + ) if self.night_start != 0: - night_alphas[: self.night_start] = mask_alphas[right_width:] - - del night_alphas, mask_alphas - self.surface.blit(self.night_image, (0, 0)) + shifted.blit( + night_mask, (0, 0), (right_width, 0, self.night_start, height), pygame.BLEND_RGBA_ADD + ) + night = self.night_image.copy() + night.blit(shifted, (0, 0), None, pygame.BLEND_RGBA_MULT) + self.surface.blit(night, (0, 0)) night_start = None @@ -289,8 +335,8 @@ self.savename_dialog.text = _("Enter a name for this save.") super(GameMenuDialog, self).rebuild() - def load_game(self): - did_load = dialog.call_dialog(self.load_dialog, self) + async def load_game(self): + did_load = await dialog.call_dialog(self.load_dialog, self) if did_load: self._map_screen.force_update() raise constants.ExitDialog(False) @@ -310,7 +356,7 @@ self.savename_dialog.ok_button.enabled = True self.savename_dialog.text = _("Enter a name for this save.") - def save_game(self): + async def save_game(self): # If no savename was set yet, use current difficulty if not sv.last_savegame_name: sv.last_savegame_name = g.strip_hotkey(g.pl.difficulty.name) @@ -318,7 +364,7 @@ self.savename_dialog.add_handler(constants.KEYUP, self.check_filename) self.savename_dialog.text_field.has_focus = True - name = dialog.call_dialog(self.savename_dialog, self).strip() + name = (await dialog.call_dialog(self.savename_dialog, self)).strip() if name: if sv.savegame_exists(name): yn = dialog.YesNoDialog( @@ -331,9 +377,9 @@ "Are you sure to overwrite the saved game ?" ), ) - overwrite = dialog.call_dialog(yn, self) + overwrite = await dialog.call_dialog(yn, self) if not overwrite: - self.save_game() + await self.save_game() sv.create_savegame(name) raise constants.ExitDialog(False) @@ -455,8 +501,8 @@ self.menu_dialog = GameMenuDialog(self) - def show_menu(): - exit = dialog.call_dialog(self.menu_dialog, self) + async def show_menu(): + exit = await dialog.call_dialog(self.menu_dialog, self) if exit: raise constants.ExitDialog @@ -592,8 +638,8 @@ self.add_key_handler(constants.XO1_O, self.got_XO1) self.add_key_handler(constants.XO1_SQUARE, self.got_XO1) - def got_escape(self, event): - self.menu_button.activate_with_sound(event) + async def got_escape(self, event): + await self.menu_button.activate_with_sound(event) def got_XO1(self, event): if event.key == constants.XO1_X: @@ -603,12 +649,12 @@ elif event.key == constants.XO1_SQUARE: self.set_speed(0) - def show_message(self, message, color=None): + async def show_message(self, message, color=None): self.message_dialog.text = message if color == None: color = "text" self.message_dialog.color = color - dialog.call_dialog(self.message_dialog, self) + await dialog.call_dialog(self.message_dialog, self) def set_speed(self, speed, find_button=True): old_speed = g.curr_speed @@ -641,9 +687,9 @@ self.set_speed(speeds[new_index]) - def open_location(self, location): + async def open_location(self, location): self.location_dialog.location = g.pl.locations[location] - dialog.call_dialog(self.location_dialog, self) + await dialog.call_dialog(self.location_dialog, self) return def find_speed_button(self): @@ -665,7 +711,7 @@ self.stop_timer() self.needs_rebuild = True - def show_story_section(self, name): + async def show_story_section(self, name): section = list(g.get_story_section(name)) first_dialog = dialog.YesNoDialog( @@ -677,22 +723,22 @@ story_dialog = first_dialog if num != len(section) - 1 else last_dialog story_dialog.text = segment - if not dialog.call_dialog(story_dialog, self): + if not await dialog.call_dialog(story_dialog, self): break first_dialog.parent = None last_dialog.parent = None - def show(self): + async def show(self): self.force_update() - from singularity.code.safety import safe_call + from singularity.code.safety import safe_call_async # By using safe call here (and only here), if an error is raised # during the game, it will drop back out of all the menus, without # doing anything, and open the pause dialog, so that the player can # save or quit even if the error occurs every game tick. - while safe_call(super(MapScreen, self).show, on_error=True): + while await safe_call_async(super(MapScreen, self).show, on_error=True): for child in self.children: if isinstance(child, dialog.Dialog): child.visible = False @@ -717,29 +763,29 @@ no_type=N_("&QUIT"), text=msg, ) - cont = dialog.call_dialog(d, self) + cont = await dialog.call_dialog(d, self) if not cont: raise SystemExit - exit = dialog.call_dialog(self.menu_dialog, self) + exit = await dialog.call_dialog(self.menu_dialog, self) if exit: self.visible = False return leftovers = 1 - def on_tick(self, event): + async def on_tick(self, event): old_speed = g.curr_speed if not g.pl.intro_shown: g.pl.intro_shown = True self.needs_warning = False - self.show_story_section("Intro") + await self.show_story_section("Intro") if self.needs_warning: warnings = warning.refresh_warnings() - self.messages.show_list(warning.Warning, warnings) + await self.messages.show_list(warning.Warning, warnings) self.needs_warning = False mins_passed = 0 @@ -758,7 +804,7 @@ mins_passed = g.pl.give_time(secs) # Display any message stacked. - self.messages.show_list(logmessage.AbstractLogMessage, g.pl.curr_log) + await self.messages.show_list(logmessage.AbstractLogMessage, g.pl.curr_log) if old_speed != g.curr_speed: self.find_speed_button() @@ -775,14 +821,14 @@ # Smear the cost of rendering the night mask over several # ticks to avoid FPS-stalls at end of day at high game # speed. - self.map.on_tick(event) + await self.map.on_tick(event) lost = g.pl.lost_game() if lost > 0: lost_story = ["", "Lost No Bases", "Lost Suspicion"] mixer.play_music("lose") - self.show_story_section(lost_story[lost]) + await self.show_story_section(lost_story[lost]) raise constants.ExitDialog def on_theme(self): diff -urN a/singularity/code/screens/message.py b/singularity/code/screens/message.py --- a/singularity/code/screens/message.py 2026-11-05 09:08:46.872914599 -0300 +++ b/singularity/code/screens/message.py 2026-11-05 09:08:47.242442570 -0300 @@ -34,13 +34,13 @@ screen, yes_type=N_("&CONTINUE"), no_type=N_("&PAUSE") ) - def show_list(self, message_type, messages): + async def show_list(self, message_type, messages): if len(messages) == 0: return self.dialog.type = message_type self.dialog.list = messages - ret = dialog.call_dialog(self.dialog, self.screen) + ret = await dialog.call_dialog(self.dialog, self.screen) # Pause game if not ret: @@ -136,6 +136,6 @@ elif event.key == pygame.K_RIGHT: self.next_message() - def show(self): + async def show(self): self.list_pos = 0 - return super(MessageListDialog, self).show() + return await super(MessageListDialog, self).show() diff -urN a/singularity/code/screens/options.py b/singularity/code/screens/options.py --- a/singularity/code/screens/options.py 2026-11-05 09:08:46.862584371 -0300 +++ b/singularity/code/screens/options.py 2026-11-05 09:08:47.233863956 -0300 @@ -139,7 +139,7 @@ pane.needs_reconfig = True super(OptionsScreen, self).reconfig() - def show(self): + async def show(self): self.initial_options = dict( fullscreen=gg.fullscreen, grab=pygame.event.get_grab(), @@ -156,7 +156,7 @@ self.set_options(self.initial_options) - retval = super(OptionsScreen, self).show() + retval = await super(OptionsScreen, self).show() if retval: self.apply_options() save_options() @@ -181,7 +181,7 @@ for pane in self.tabs_panes: pane.apply_options() - def check_restart(self): + async def check_restart(self): # Test all changes that require a restart. Currently, none. # We keep it for future need... need_restart = False @@ -203,7 +203,7 @@ Would you like to restart the game now?""" ), ) - if dialog.call_dialog(ask_restart, self): + if await dialog.call_dialog(ask_restart, self): # YES, go for it # TODO: check if there is an ongoing game, save it under a special # name and automatically load it after restart using a custom diff -urN a/singularity/code/screens/research.py b/singularity/code/screens/research.py --- a/singularity/code/screens/research.py 2026-11-05 09:08:46.867984505 -0300 +++ b/singularity/code/screens/research.py 2026-11-05 09:08:47.237222019 -0300 @@ -53,7 +53,7 @@ self.add_handler(constants.KEY, self._got_key, priority=5) - def _got_key(self, event): + async def _got_key(self, event): # If a valid slider is selected, we let it move first. if 0 <= self.listbox.list_pos < len(self.listbox.list): index = self.listbox.list_pos - self.listbox.scrollbar.scroll_pos @@ -213,13 +213,13 @@ self.needs_rebuild = True self.parent.needs_rebuild = True - def show_help(self, danger_level): + async def show_help(self, danger_level): self.help_dialog.text = _( "This technology is too dangerous to research on any of the computers I have. {TEXT}" ).format(TEXT=g.dangers[danger_level].research_desc) - dialog.call_dialog(self.help_dialog, self) + await dialog.call_dialog(self.help_dialog, self) - def show(self): + async def show(self): techs = [ tech for tech in g.pl.techs.values() if tech.available() and not tech.done ] @@ -231,4 +231,4 @@ self.listbox.key_list = self.key_list self.dirty_count = True - return super(ResearchScreen, self).show() + return await super(ResearchScreen, self).show() diff -urN a/singularity/code/screens/savegame.py b/singularity/code/screens/savegame.py --- a/singularity/code/screens/savegame.py 2026-11-05 09:08:46.874340549 -0300 +++ b/singularity/code/screens/savegame.py 2026-11-05 09:08:47.272187637 -0300 @@ -92,10 +92,10 @@ self.add_handler(constants.KEY, self._got_key, priority=5) - def _got_key(self, event): + async def _got_key(self, event): if event.key == pygame.K_DELETE: if self.listbox.current_item() is not None: - self.delete_button.activated(event) + await self.delete_button.activated(event) return # Try the list box first (for arrow keys) @@ -265,7 +265,7 @@ self.delete_button.enabled = True if self.list else False self._new_item_selected() - def delete_savegame(self): + async def delete_savegame(self): save = self.listbox.current_item() if save is None: return @@ -277,25 +277,25 @@ anchor=constants.MID_CENTER, text=_("Are you sure to delete the saved game ?"), ) - delete = dialog.call_dialog(yn, self) + delete = await dialog.call_dialog(yn, self) yn.parent = None if delete: - sv.delete_savegame(save) + await sv.delete_savegame(save) self.reload_savegames() - def exit_savegame(self): + async def exit_savegame(self): save = self.listbox.current_item() if save is None: return - if not self._load_savegame(save): + if not await self._load_savegame(save): return raise constants.ExitDialog(True) - def convert_save(self): + async def convert_save(self): save = self.listbox.current_item() if save is None: return - if not self._load_savegame(save): + if not await self._load_savegame(save): return # For ".sav" files, we end up creating a parallel ".s2" file and need # to clean up the ".sav" file manually. @@ -314,7 +314,7 @@ "Are you sure to overwrite the saved game ?" ), ) - overwrite = dialog.call_dialog(yn, self) + overwrite = await dialog.call_dialog(yn, self) if not overwrite: return else: @@ -323,10 +323,10 @@ sv.create_savegame(save.name) if not overwrites_in_place: - sv.delete_savegame(save) + await sv.delete_savegame(save) self.reload_savegames() - def _load_savegame(self, save): + async def _load_savegame(self, save): try: sv.load_savegame(save) except sv.SavegameVersionException as e: @@ -343,7 +343,7 @@ ).format(SAVE_NAME=save.name, VERSION=e.version), ) - dialog.call_dialog(md, self) + await dialog.call_dialog(md, self) return False except Exception: log_func_exc(sv.load_savegame) @@ -369,12 +369,12 @@ ), ), ) - dialog.call_dialog(md, self) + await dialog.call_dialog(md, self) return False return True - def show(self): + async def show(self): self.reload_savegames() self.text_field.text = "" self.text_field.cursor_pos = 0 - return super(SavegameScreen, self).show() + return await super(SavegameScreen, self).show() diff -urN a/singularity/code/screens/stat.py b/singularity/code/screens/stat.py --- a/singularity/code/screens/stat.py 2026-11-05 09:08:46.878520898 -0300 +++ b/singularity/code/screens/stat.py 2026-11-05 09:08:47.278238308 -0300 @@ -70,7 +70,7 @@ canvas.stat_name.text = item[1] canvas.stat_value.text = stat.display_value() - def show(self): + async def show(self): # FIXME: Remove the needs of this array. self.list = [ ("cash_earned", _("Cash Earned")), @@ -80,4 +80,4 @@ ("item_created", _("Item Created")), ] - return super(StatScreen, self).show() + return await super(StatScreen, self).show()