Source code for code_battles.utilities

"""Generic useful utilities for creating games with PyScript."""

import asyncio
import math
import sys
from typing import Callable, List, Union
from enum import Enum
from functools import wraps

try:
    import js
except Exception:
    pass


def is_worker():
    try:
        from js import window  # noqa: F401

        return False
    except Exception:
        return is_web_or_worker()


def is_web_or_worker():
    return "MicroPython" in sys.version or "pyodide" in sys.executable


def is_web():
    return is_web_or_worker() and not is_worker()


def web_only(method):
    if is_web():
        return method

    @wraps(method)
    def wrapper(*args, **kwargs):
        print(f"Warning: {method.__name__} should only be called in a web context.")

    return wrapper


[docs] class Alignment(Enum): CENTER = 0 TOP_LEFT = 1
@web_only def download_image(src: str): from js import Image result = asyncio.Future() image = Image.new() image.onload = lambda _: result.set_result(image) image.src = src return result def show_alert( title: str, alert: str, color: str, icon: str, limit_time: int = 5000, is_code=True ): if is_web(): from js import window if hasattr(window, "showAlert"): try: window.showAlert(title, alert, color, icon, limit_time, is_code) except Exception as e: print(e) else: print(f"[ALERT] {title}: {alert}") @web_only def set_results(player_names: List[str], places: List[int], map: str, verbose: bool): from js import window if hasattr(window, "setResults"): try: window.setResults(player_names, places, map, verbose) except Exception as e: print(e) @web_only def show_download(): from js import window if hasattr(window, "showDownload"): try: window.showDownload() except Exception as e: print(e) @web_only def navigate(route: str): from js import window if hasattr(window, "_navigate"): try: window._navigate(route) except Exception as e: print(e) @web_only def download_json(filename: str, contents: str): from js import window if hasattr(window, "downloadJson"): try: window.downloadJson(filename, contents) except Exception as e: print(e) @web_only def console_log(player_index: int, text: str, color: str): from js import window if hasattr(window, "consoleLog"): try: window.consoleLog(player_index, text, color) except Exception as e: print(e) async def with_timeout(fn: Callable[[], None], timeout_seconds: float): async def f(): fn() await asyncio.wait_for(f(), timeout_seconds)
[docs] class GameCanvas: """ A nice wrapper around HTML Canvas for drawing map-based multiplayer games. """ _scale: float def __init__( self, canvas: "js.Element", player_count: int, map_image: "js.Image", max_width: int, max_height: int, extra_width: int, extra_height: int, ): self.canvas = canvas self.player_count = player_count self.map_image = map_image self.extra_height = extra_height self.extra_width = extra_width self._fit_into(max_width, max_height)
[docs] def draw_element( self, image: "js.Image", x: int, y: int, width: int, board_index=0, direction: Union[float, None] = None, alignment=Alignment.CENTER, ): """ Draws the given image on the specified board. Scaled to fit `width` in map pixels, be on position ``(x, y)`` in map pixels and face `direction` where 0 is no rotation and the direction is clockwise positive. """ if direction is None: direction = 0 x, y = self._translate_position(board_index, x, y) width, height = self._translate_width(width, image.width / image.height) if alignment == Alignment.TOP_LEFT: x += width / 2 y += height / 2 self.context.save() self.context.translate(x, y) self.context.rotate(direction) self.context.translate(-width / 2, -height / 2) self.context.drawImage(image, 0, 0, width, height) self.context.restore()
[docs] def draw_text( self, text: str, x: int, y: int, color="black", board_index=0, text_size=15, font="", ): """ Draws the given text in the given coordinates (in map pixels). """ if font != "": font += ", " x, y = self._translate_position(board_index, x, y) self.context.font = f"{text_size * self._scale}pt {font}system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif, 'Noto Emoji'" self.context.fillStyle = color self.context.fillText(text, x, y)
[docs] def draw_line( self, start_x: int, start_y: int, end_x: int, end_y: int, stroke="black", stroke_width=10, board_index=0, ): """ Draws a line between the given ``(start_x, start_y)`` and ``(end_x, end_y)`` coordinates (in map pixels) with the given stroke. """ start_x, start_y = self._translate_position(board_index, start_x, start_y) end_x, end_y = self._translate_position(board_index, end_x, end_y) self.context.strokeStyle = stroke self.context.lineWidth = stroke_width * self._scale self.context.beginPath() self.context.moveTo(start_x, start_y) self.context.lineTo(end_x, end_y) self.context.stroke()
[docs] def draw_rectangle( self, start_x: int, start_y: int, width: int, height: int, fill="black", stroke="transparent", stroke_width=2, board_index=0, ): """ Draws the given rectangle with the top-left corner at `(start_x, start_y)` (in map pixels) and with the specified `width` and `height` (in map pixels) with the given stroke and fill. """ start_x, start_y = self._translate_position(board_index, start_x, start_y) width *= self._scale height *= self._scale self.context.fillStyle = fill self.context.strokeStyle = stroke self.context.lineWidth = stroke_width * self._scale self.context.beginPath() self.context.rect(start_x, start_y, width, height) self.context.stroke() self.context.fill()
[docs] def draw_circle( self, x: int, y: int, radius: float, fill="black", stroke="transparent", stroke_width=2, board_index=0, ): """ Draws the given circle (with the given stroke and fill) in the given coordinates and with the given radius (in map pixels). """ x, y = self._translate_position(board_index, x, y) self.context.fillStyle = fill self.context.strokeStyle = stroke self.context.lineWidth = stroke_width * self._scale self.context.beginPath() self.context.arc(x, y, radius * self._scale, 0, 2 * math.pi) self.context.stroke() self.context.fill()
[docs] def clear(self): """Clears the canvas and re-draws the players' maps.""" self.context.clearRect(0, 0, self.canvas.width, self.canvas.height) self.context.fillStyle = "#fff" self.context.fillRect(0, 0, self.canvas.width, self.canvas.height) for i in range(self.player_count): self.context.drawImage( self.map_image, i * self.canvas.width / self.player_count, 0, self.map_image.width * self._scale, self.map_image.height * self._scale, )
@property def total_width(self) -> float: """The total width of the canvas (in map pixels).""" return self.map_image.width * self.player_count def _fit_into(self, max_width: int, max_height: int): from js import window if self.map_image.width == 0 or self.map_image.height == 0: raise Exception("Map image invalid!") aspect_ratio = (self.map_image.width * self.player_count + self.extra_width) / ( self.map_image.height + self.extra_height ) width = min(max_width, max_height * aspect_ratio) height = width / aspect_ratio self.canvas.style.width = f"{width}px" self.canvas.style.height = f"{height}px" self.canvas.width = width * window.devicePixelRatio self.canvas.height = height * window.devicePixelRatio self._scale = self.canvas.width / ( self.player_count * self.map_image.width + self.extra_width ) self.context = self.canvas.getContext("2d") self.context.textAlign = "center" self.context.textBaseline = "middle" self.canvas_map_width = ( self.canvas.width - self._scale * self.extra_width ) / self.player_count self.canvas_map_height = ( self.canvas_map_width * self.map_image.height / self.map_image.width ) def _translate_position(self, board_index: int, x: float, y: float): x *= self._scale y *= self._scale x += board_index * self.map_image.width * self._scale return x, y def _translate_width(self, width: float, aspect_ratio: float): """Aspect ratio: w/h""" width *= self._scale height = width / aspect_ratio return width, height