Source code for vs2

"""The sealed, scene-owned Ventilastation display API (revision 2).

V1 lives in :mod:`ventilastation.sprites` and deliberately remains separate.
This module is the complete V2 surface: a scene builds a fixed display graph
once, then mutates it without allocating renderer records while it runs.
"""

import struct
import utime

try:
    import ujson as _json
except ImportError:
    import json as _json

from ventilastation import api_guard
from ventilastation import romformat as _romformat
from ventilastation.director import director, stripes
from ventilastation.display_geometry import DISPLAY_HEIGHT, DISPLAY_WIDTH
from ventilastation.scene import Scene as _Scene
from ventilastation.runtime import get_platform


def _claim():
    api_guard.claim("vs2", "vs2")


_claim()


# Projection values are the native renderer's wire values.
FULLSCREEN = 0
TUNNEL = 1
HUD = 2

TRANSPARENT = 255
EMPTY_TILE = 255
NO_LAYER = 255
RECYCLE = object()

FLAG_VISIBLE = 0x01
FLAG_FLIP_X = 0x02
FLAG_FLIP_Y = 0x04

PAYLOAD_MAGIC = b"VS2\0"
PAYLOAD_VERSION = 3
PAYLOAD_HEADER_SIZE = 16
PAYLOAD_LAYER_SIZE = 8
PAYLOAD_SPRITE_SIZE = 24
PAYLOAD_TILEMAP_SIZE = 32
PAYLOAD_DRAW_REF_SIZE = 2
DRAW_SPRITE = 0
DRAW_TILEMAP = 1


[docs] class SceneSealedError(RuntimeError): """A structural call was made outside :meth:`Scene.build`. Creating layers and drawables is only legal while ``build()`` runs. Once it returns the scene is sealed, and calling :meth:`Layer.sprite` or any other factory from ``update()``, a timer, or ``teardown()`` raises this instead of quietly growing the display graph mid-game. It is also raised when a drawable that outlived its scene is written to, which is how a stale handle kept across scene entries surfaces. """
[docs] class ResourceLimitError(RuntimeError): """A scene asked for more sprites, tilemaps or layers than the target has. The message carries a per-layer census so the offending budget is obvious:: sprite 101/100 in Vixeous (world: 62, hud: 39); reduce the sprite budget Because every structural call happens during :meth:`Scene.build`, this is always raised the first time the scene is entered rather than mid-game. """
[docs] class AssetLimitError(ResourceLimitError): """The loaded asset pack holds more image strips than the target supports."""
[docs] class AssetNotFoundError(LookupError): """An image name is not present in the scene's asset pack. Usually a typo, or a PNG that is not in the game's ``images/`` folder. """
[docs] class FrameError(ValueError): """A frame index falls outside its image's frame count. Raised at the assignment, so an out-of-range frame is reported at the line that set it instead of rendering garbage pixels. """
class _Limits: """Per-target resource budgets, exposed as ``vs2.limits``.""" layers = 8 sprites = 100 tilemaps = 16 image_strips = 100 limits = _Limits() class _Display: """Display geometry and palette animation, exposed as ``vs2.display``.""" #: Circumference of the display in columns, and the period of every X #: coordinate. Use it instead of writing ``% 256`` in game code. width = DISPLAY_WIDTH #: Number of LEDs on the bar, from the outer rim inward. This is the Y #: range of a :data:`~vs2.HUD` layer; :data:`~vs2.TUNNEL` and #: :data:`~vs2.FULLSCREEN` use depth from 0 through 255. height = DISPLAY_HEIGHT @property def palettes(self): """The loaded palette block, as a mutable buffer, or ``None``. This is the same buffer the asset bank loaded, so recolouring in place allocates nothing. Call :meth:`vs2.display.apply_palettes` to publish the change. The buffer is replaced whenever a new asset pack loads, so resolve it in :meth:`Scene.build` like any other asset handle. """ return getattr(director, "palette_data", None) def apply_palettes(self): """Publish the current contents of :attr:`vs2.display.palettes` to the renderer. Cheap enough to call every tick, which is what a colour-cycling effect wants:: def build(self): self.palettes = vs2.display.palettes def update(self): cycle(self.palettes) vs2.display.apply_palettes() """ palettes = self.palettes if palettes is not None: get_platform().display.set_palettes(palettes) display = _Display() class _BaseLeds: """The console base's RGB strip, reached as ``vs2.base.leds``.""" def __init__(self, owner): self.owner = owner def set_all(self, red, green, blue): """Set every base LED to one colour. Each channel is ``0..255``.""" self.owner._set_leds(red, green, blue) def off(self): """Turn the base LEDs off.""" self.set_all(0, 0, 0) class _BaseServo: """The console base's servo, reached as ``vs2.base.servo``.""" def __init__(self, owner): self.owner = owner def set(self, position): """Move the servo. ``position`` is ``0..255``.""" self.owner._set_servo(position) class _BaseButtons: """The console base's lit buttons, reached as ``vs2.base.buttons``.""" def __init__(self, owner): self.owner = owner def set(self, mask, blink_ms=0): """Light the buttons named by ``mask``, optionally blinking. ``mask`` combines ``vs2.base.BUTTON_LED_1`` and ``vs2.base.BUTTON_LED_2``; ``blink_ms`` is ``0..10000``, where 0 means steady. """ self.owner._set_buttons(mask, blink_ms) def off(self): """Turn the button lights off.""" self.set(0) class _BaseControl: """Console base hardware, exposed as ``vs2.base``. Values are range-checked and de-duplicated, so writing the same colour every tick costs one comparison and sends nothing. All of it is safe to call on a console with no physical base attached — the commands simply go nowhere. Base output follows the app, not the scene: LED, servo and button-light state persists across scene transitions within a game and resets to safe defaults when the game returns to the launcher. """ #: Left button light. BUTTON_LED_1 = 0x01 #: Right button light. BUTTON_LED_2 = 0x02 #: Both button lights. BUTTON_LED_ALL = 0x03 def __init__(self): self.leds = _BaseLeds(self) self.servo = _BaseServo(self) self.buttons = _BaseButtons(self) self._led_state = None self._servo_state = None self._button_state = None @staticmethod def _integer(value, minimum, maximum, name): if not isinstance(value, int) or value < minimum or value > maximum: raise ValueError("%s must be in %d..%d" % (name, minimum, maximum)) return value @staticmethod def _send(line): get_platform().comms.send(line.encode("ascii")) def _set_leds(self, red, green, blue): state = (self._integer(red, 0, 255, "red"), self._integer(green, 0, 255, "green"), self._integer(blue, 0, 255, "blue")) if state != self._led_state: self._send("base leds %d %d %d" % state) self._led_state = state def _set_servo(self, position): state = self._integer(position, 0, 255, "position") if state != self._servo_state: self._send("base servo %d" % state) self._servo_state = state def _set_buttons(self, mask, blink_ms): state = (self._integer(mask, 0, self.BUTTON_LED_ALL, "mask"), self._integer(blink_ms, 0, 10000, "blink_ms")) if state != self._button_state: self._send("base buttons %d %d" % state) self._button_state = state base = _BaseControl() from . import controls def _app_slug(): return api_guard.current_app() or "the current app" def _qualified_asset(name): if "/" in name: return name slug = api_guard.current_app() return (slug + "/" + name) if slug else name class _Audio: """Sound and music playback, exposed as ``vs2.audio``. Names resolve against the current app's ``sounds/`` folder. A name containing ``/`` is already qualified and is used as-is, which is how a game borrows another app's audio:: vs2.audio.sound("shoot") # this game's shoot.mp3 vs2.audio.sound("alecu.vyruss/shoot1") # another game's Nothing is decoded on the board: these send compact commands to the host. """ def sound(self, name): """Play a one-shot sound effect.""" director.sound_play(_qualified_asset(name)) def music(self, name, loop=False): """Start a music track, optionally looping. Music follows the app, not the scene: a track started here keeps playing across :meth:`Scene.push`, :meth:`Scene.pop` and :meth:`Scene.switch` within the same game, and stops on its own when the game returns to the launcher. Call :meth:`vs2.audio.stop_music` to stop it sooner. """ director.music_play(_qualified_asset(name), loop=bool(loop)) def stop_music(self): """Stop the current music track.""" director.music_off() def notes(self, notes): """Play a sequence of synthesised notes.""" director.notes_play(api_guard.current_app() or "", notes) audio = _Audio() class _Saves: """A game's saved data, exposed as ``vs2.saves``. Each game has one small file of named values, kept on the console's flash (and in a ``saves`` folder beside the emulator's runtime), so a high score or a setting survives a restart. Values can be anything JSON can hold: numbers, strings, booleans, lists and dicts of those. Reading allocates and writing is slow, so load in ``build()`` and save only when the value has changed, never every tick. """ def _filename(self): slug = api_guard.current_app() if not slug: raise RuntimeError("vs2.saves can only be used by a running game") return "saves/" + slug + ".json" def _read(self, filename): try: data = get_platform().storage.read_json(filename) except Exception: return {} # no file yet, or one that cannot be read return data if isinstance(data, dict) else {} def load(self, name, default=None): """Return the value saved under ``name``, or ``default`` if there is none (nothing saved yet, or the file cannot be read).""" return self._read(self._filename()).get(name, default) def save(self, name, value): """Save ``value`` under ``name``. Returns: bool: ``True`` if a file was written. ``False`` if the value was already saved (nothing is written then, which spares the flash) or the file could not be written, in which case the reason is printed and the game carries on. Raises: TypeError: If ``name`` is not a string or ``value`` cannot be saved as JSON. """ if not isinstance(name, str): raise TypeError("a save name must be a string") _json.dumps(value) # raises TypeError for what JSON cannot hold filename = self._filename() data = self._read(filename) if name in data and data[name] == value: return False data[name] = value storage = get_platform().storage try: storage.makedirs("saves") storage.write_json(filename, data) except OSError as error: print("vs2.saves: could not write %s: %s" % (filename, error)) return False return True saves = _Saves() def _vs2_backend(): return getattr(get_platform(), "vs2", None) def _fixed_8_8(value): try: fixed = int(value * 256) except TypeError: fixed = 0 if fixed < -0x80000000: return -0x80000000 if fixed > 0x7fffffff: return 0x7fffffff return fixed def _floor_coord(value): try: value_int = int(value) except TypeError: return 0 return value_int - 1 if value < value_int else value_int def _render_coord(value, minimum=0, maximum=255): try: value = int(value) except TypeError: value = 0 if value < minimum: return minimum if value > maximum: return maximum return value def _sprite_flags(sprite): flags = FLAG_VISIBLE if sprite.visible else 0 if sprite.flip_x: flags |= FLAG_FLIP_X if sprite.flip_y: flags |= FLAG_FLIP_Y return flags def _tilemap_flags(tilemap): flags = FLAG_VISIBLE if tilemap.visible else 0 if tilemap.flip_x: flags |= FLAG_FLIP_X if tilemap.flip_y: flags |= FLAG_FLIP_Y return flags def _strip_metadata(number): metadata = getattr(director, "image_metadata", {}).get(number) if metadata is not None: return metadata # Tests and tiny apps can install a strip directly into the backend. The # fallback preserves that useful workflow while ROM-loaded games get the # authoritative metadata parsed by the director. table = getattr(get_platform().sprites, "stripes", {}) strip = table.get(number) if isinstance(strip, dict): return strip if strip is not None and len(strip) >= 4: width, height, frames, palette = _romformat.decode_header(strip) return {"width": width, "height": height, "frames": frames, "palette": palette} return None
[docs] class Image: """A resolved image from the scene's asset pack. Returned by :meth:`Scene.image` and carried by every drawable as its ``image`` attribute. Resolving a name once and reusing the handle avoids repeating the lookup, and lets one image back several drawables:: def build(self): enemy = self.image("enemy.png") self.small = self.world.sprite_pool(enemy, count=12) self.boss = self.world.sprite(enemy, frame=4) Attributes are read-only and come from the ROM's metadata, so reading :attr:`width` or :attr:`frames` costs nothing at runtime. """ def __init__(self, name, strip, metadata): #: The image's name in the asset pack, e.g. ``"ship.png"``. self.name = name self._strip = strip #: Width of a single frame, in display columns. self.width = int(metadata["width"]) #: Height of a single frame, in LEDs. self.height = int(metadata["height"]) #: Number of animation frames. Valid frame indices are ``0..frames-1``. self.frames = int(metadata["frames"]) #: Glyph table declared in ``__images__.yaml``, or ``None``. Used by #: :meth:`Layer.label` to map characters to frames. # ROM V2 records carry a zero-length glyph trailer when the manifest # does not declare a custom map. Treat that decoded empty string as # absent so labels retain their documented CP437/ASCII fallback. self.glyphs = metadata.get("glyphs") or None
def _resolve_image(value): if isinstance(value, Image): return value if not isinstance(value, str): raise TypeError("image must be an image name or vs2.Image") try: strip = stripes[value] except KeyError: raise AssetNotFoundError("image '%s' is not in %s" % (value, _app_slug())) metadata = _strip_metadata(strip) if metadata is None: raise AssetNotFoundError("image '%s' has no loaded metadata in %s" % (value, _app_slug())) return Image(value, strip, metadata) def _intersects_circular(x1, width1, x2, width2): delta = min(x1, x2) x1 = (x1 - delta + display.width // 2) % display.width x2 = (x2 - delta + display.width // 2) % display.width return x1 < x2 + width2 and x1 + width1 > x2
[docs] class Scene(_Scene): """One screen of a game: a display graph plus the logic that drives it. Subclass it, build the graph in :meth:`build`, and move it in :meth:`update`:: class MyGame(vs2.Scene): def build(self): self.world = self.layer("world", projection=vs2.TUNNEL) self.ship = self.world.sprite("ship.png", x=128, y=0) def update(self): self.ship.x += 0.5 The scene has three phases. During ``build()`` it is *building* and layers and drawables may be created. When ``build()`` returns it is *sealed*: ``update()`` and timers may move, re-frame and hide what exists, but any structural call raises :class:`SceneSealedError`. When the scene stops showing it is *closed*, its drawables are released, and pending timers are discarded. ``build()`` runs on every entry, so a scene shown a second time starts from a fresh graph. Persistent state — scores, progress, unlocked levels — can live in ``__init__``, which runs once; drawable handles must be rebuilt. """ #: Seconds without input from any controller before :meth:`on_idle` fires. #: ``None`` disables the timeout. Defaults to 30 so an unattended console #: always drifts back to the launcher's attract loop. idle_timeout = 30 #: When true, ``Y`` or ``BACK`` pops the scene. Set it to ``False`` to claim #: those buttons for gameplay; the scene stays exitable through the idle #: timeout and the console's home command. back_button = True #: When true, the drifting starfield is drawn behind this scene. Applied on #: entry; a scene that leaves it false has no stars, whatever came before. #: (Scenes written for the older sprite API always have them.) starfield = False #: Name of the asset pack to load, defaulting to the current app's own. #: Shared and system scenes set this to borrow another pack. asset_pack = None def __init__(self): _claim() _Scene.__init__(self) self.layers = [] self._phase = "new" self._vs2_payload = None self._pending_transition = None self._sprite_count = 0 self._tilemap_count = 0 self._image_cache = {} self._payload_sprites = () self._payload_tilemaps = () self._payload_drawables = () self._payload_frames_size = 0
[docs] def build(self): """Create this scene's layers and drawables. Override this. Runs on every entry to the scene. When it returns the scene is sealed, so this is the only place :meth:`layer` and the :class:`Layer` factories may be called. """
[docs] def update(self): """Advance the game by one tick. Override this. Called once per rotation of the display. It may move, re-frame, show and hide the drawables built in :meth:`build`, but may not create more. """
[docs] def teardown(self): """Clean up when the scene stops showing. Optional. Saving a score or cancelling external work belongs here. Timers and drawables are released for you, so most scenes never need it. """
def _require_build(self, method): if self._phase != "building": raise SceneSealedError("%s() is only allowed while %s.build() runs" % (method, self.__class__.__name__)) def _reserve(self, kind, count, layer): if kind == "sprite": current = self._sprite_count limit = limits.sprites elif kind == "tilemap": current = self._tilemap_count limit = limits.tilemaps else: current = len(self.layers) limit = limits.layers requested = current + count layer_count = getattr(layer, "_" + kind + "_count", 0) + count if requested > limit: census = [] if kind in ("sprite", "tilemap"): for candidate in self.layers: candidate_count = getattr(candidate, "_" + kind + "_count", 0) if candidate is layer: candidate_count += count if candidate_count: census.append("%s: %d" % (candidate.name or "unnamed", candidate_count)) raise ResourceLimitError( "%s %d/%d in %s%s; reduce the %s budget" % (kind, requested, limit, self.__class__.__name__, " (" + ", ".join(census) + ")" if census else "", kind) ) if kind == "sprite": self._sprite_count = requested elif kind == "tilemap": self._tilemap_count = requested if kind in ("sprite", "tilemap"): setattr(layer, "_" + kind + "_count", layer_count)
[docs] def image(self, value): """Resolve an image name to an :class:`Image` handle. Handles are cached for the life of the scene, so resolving the same name twice returns the same object. Passing an :class:`Image` returns it unchanged, which lets factories accept either form. Args: value: An image name such as ``"ship.png"``, or an :class:`Image`. Returns: Image: The resolved handle. Raises: AssetNotFoundError: If the name is not in the scene's asset pack. """ if isinstance(value, Image): return value cached = self._image_cache.get(value) if cached is None: cached = _resolve_image(value) self._image_cache[value] = cached return cached
[docs] def layer(self, name=None, projection=TUNNEL, visible=True): """Create a layer. Only callable from :meth:`build`. Layers paint bottom to top in the order they are created, so create the background first and the HUD last. Args: name: A label used in resource-limit messages. Worth setting. projection: :data:`~vs2.TUNNEL`, :data:`~vs2.HUD` or :data:`~vs2.FULLSCREEN`. Decides how the layer maps Y to LEDs. visible: Whether the layer draws at all. Returns: Layer: The new layer, ready to create drawables on. Raises: SceneSealedError: If called outside ``build()``. ResourceLimitError: If the scene already has ``vs2.limits.layers``. """ self._require_build("layer") self._reserve("layer", 1, self) layer = Layer(self, name, projection, visible) self.layers.append(layer) return layer
[docs] def on_enter(self): backend = _vs2_backend() self._clear_drawables() self._phase = "building" self._pending_transition = None self._sprite_count = 0 self._tilemap_count = 0 self._image_cache.clear() self._payload_sprites = () self._payload_tilemaps = () self._payload_drawables = () self._payload_frames_size = 0 if backend is not None: backend.reset_scene() backend.set_active(True) try: # V2 owns its asset pack. Existing unit tests and embedded tools # may construct an anonymous scene with preinstalled strips, in # which case there is intentionally nothing to load. pack = self.asset_pack or getattr(self, "_vs_api_slug", None) if pack: rom = "roms/" + pack + ".rom" # A scene the app created itself shares its creator's strips, # so reuse them rather than inflating the same ROM again on # every Title -> Game -> GameOver transition. if not (getattr(self, "_vs_adopted", False) and getattr(director, "loaded_rom", None) == rom): director.load_rom(rom) image_count = len(stripes) if image_count > limits.image_strips: raise AssetLimitError( "%s defines %d images; this target supports %d" % (_app_slug(), image_count, limits.image_strips) ) self.build() self._phase = "sealed" self._seal_drawables() except Exception: self._phase = "closed" if backend is not None: backend.set_active(False) backend.reset_scene() raise setter = getattr(get_platform().display, "set_starfield", None) if setter is not None: setter(bool(self.starfield))
[docs] def on_exit(self): backend = _vs2_backend() self._phase = "closing" try: self.teardown() finally: self.pending_calls.clear() self._clear_drawables() self._vs2_payload = None self._image_cache.clear() self._payload_sprites = () self._payload_tilemaps = () self._payload_drawables = () self._payload_frames_size = 0 self._phase = "closed" if backend is not None: backend.set_active(False) backend.reset_scene() _Scene.on_exit(self)
def _clear_drawables(self): for layer in self.layers: layer._close() del self.layers[:] def _seal_drawables(self): """Freeze render topology once; export and native rendering reuse it.""" sprites = [] tilemaps = [] drawables = [] native_order = [] frames_size = 0 for layer_index, layer in enumerate(self.layers): for drawable in layer._drawables: if isinstance(drawable, Sprite): sprites.append((drawable, layer_index)) drawables.append((DRAW_SPRITE, len(sprites) - 1)) native_order.append(drawable._sprite) else: tilemaps.append((drawable, layer_index)) drawables.append((DRAW_TILEMAP, len(tilemaps) - 1)) native_order.append(drawable._tilemap) frames_size += len(drawable.cells) self._payload_sprites = tuple(sprites) self._payload_tilemaps = tuple(tilemaps) self._payload_drawables = tuple(drawables) self._payload_frames_size = frames_size backend = _vs2_backend() if backend is not None: setter = getattr(backend, "set_draw_order", None) if setter is None: raise RuntimeError("this firmware is too old for VS2 revision 2") setter(tuple(native_order))
[docs] def call_later(self, delay, callback, *args, **kwargs): """Schedule a one-shot callback ``delay`` milliseconds from now. Extra positional and keyword arguments are stored with the timer and passed to the callback when it fires:: self.call_later(1500, self.respawn) self.call_later(500, self.spawn_wave, wave, boss=True) Timers live only as long as the scene is showing. Pending callbacks are discarded the moment it stops showing — popped, or suspended under a :meth:`push` — and a scene shown again starts from a fresh :meth:`build` with no timers. Scheduling is meant to be infrequent: menus, respawn delays, wave timers. Do not call this every tick. Raises: SceneSealedError: If the scene is not building or running. """ if self._phase not in ("building", "sealed"): raise SceneSealedError("call_later() requires a building or running V2 scene") when = utime.ticks_add(utime.ticks_ms(), int(delay)) entry = (when, callback, args, kwargs) for index, queued in enumerate(self.pending_calls): if utime.ticks_diff(when, queued[0]) < 0: self.pending_calls.insert(index, entry) break else: self.pending_calls.append(entry)
def _queue_transition(self, kind, target=None): if self._pending_transition is not None: raise RuntimeError("%s already queued a scene transition" % self.__class__.__name__) self._pending_transition = (kind, target)
[docs] def push(self, scene): """Suspend this scene and run ``scene`` on top of it. This scene's timers are discarded and its drawables released; when the pushed scene pops, this one is entered again from a fresh :meth:`build`. """ self._queue_transition("push", scene)
[docs] def pop(self): """Leave this scene, resuming the one below, or exit to the launcher. Returns ``None``, so ``return self.pop()`` reads as "handle this input, then stop". No V2 scene raises ``StopIteration``. """ self._queue_transition("pop")
[docs] def switch(self, scene): """Replace this scene with ``scene``, without growing the stack. Music and base-hardware state carry over, since both follow the app rather than the scene. """ self._queue_transition("switch", scene)
[docs] def on_idle(self): """Called after :attr:`idle_timeout` seconds without input. The default pops the scene, so an unattended console walks back out to the launcher one screen at a time. Override it for an attract loop:: def on_idle(self): self.push(AttractSlideshow()) """ self.pop()
def _run_defaults(self): if self._pending_transition is not None: return if self.back_button and (controls.joy1.just_pressed(controls.Y) or controls.joy1.just_pressed(controls.BACK)): self.pop() return if (self.idle_timeout is not None and int(controls.idle_ms) >= int(self.idle_timeout) * 1000): self.on_idle() def _drain_timers(self): now = utime.ticks_ms() while self.pending_calls: when, callback, args, kwargs = self.pending_calls[0] if utime.ticks_diff(when, now) > 0: break self.pending_calls.pop(0) callback(*args, **kwargs) if self._pending_transition is not None: break def _adopt(self, scene): """Give a scene built by this app's code the app's identity. app_loader stamps only the scene returned by ``main()``. A scene that game code creates and passes to :meth:`push` or :meth:`switch` belongs to the same app, so it takes the same markers (which tell the platform to export its VS2 payload and the API guard which app is running) and, unless it names its own, the same asset pack. Scenes that already carry markers, and scenes that are not V2 scenes, are left alone. """ if not isinstance(scene, Scene): return slug = getattr(self, "_vs_api_slug", None) declared = getattr(self, "_vs_declared_api", None) if slug is None and declared is None: return if (getattr(scene, "_vs_api_slug", None) is not None or getattr(scene, "_vs_declared_api", None) is not None): return scene._vs_api_slug = slug scene._vs_declared_api = declared if scene.asset_pack is None: scene.asset_pack = self.asset_pack scene._vs_adopted = True def _commit_transition(self): transition = self._pending_transition self._pending_transition = None if transition is None: return kind, target = transition if kind == "push": self._adopt(target) director.push(target) elif kind == "switch": self._adopt(target) director.switch(target) else: director.pop()
[docs] def scene_step(self): self.update() if self._pending_transition is None: self._run_defaults() if self._pending_transition is None: self._drain_timers() # Director re-reads the stack after scene_step(), so callbacks that # queued a transition cannot run against a scene that has just left. self._commit_transition()
[docs] class Layer: """An ordered group of drawables sharing one projection. Created by :meth:`Scene.layer`, never directly. A layer owns its drawables: they are created through :meth:`sprite`, :meth:`sprite_pool`, :meth:`tilemap` and :meth:`label`, which allocate and attach in one step, so a drawable that nothing draws cannot exist. Within a layer, drawables paint in creation order — each one over those created before it — and layers themselves paint in the order the scene created them. Sprites, tilemaps and labels interleave freely:: ground = world.tilemap("ground.png", columns=8, rows=17) player = world.sprite("ship.png") clouds = world.tilemap("clouds.png", columns=8, rows=4) That paints ground, then the player, then clouds over both. """ def __init__(self, scene, name, projection, visible): self.scene = scene self.name = name self._projection = _projection(projection) self._visible = bool(visible) self._drawables = [] self._closed = False self._sprite_count = 0 self._tilemap_count = 0 backend = _vs2_backend() self._layer = backend.Layer(mode=self._projection, visible=self._visible) if backend else None def _require_build(self, method): if self._closed: raise SceneSealedError("%s belongs to a closed V2 scene" % (method,)) self.scene._require_build(method) def _close(self): for drawable in self._drawables: drawable._closed = True drawable._layer = None del self._drawables[:] self.scene = None self._layer = None self._closed = True @property def projection(self): """How this layer maps Y to LEDs -- :data:`~vs2.TUNNEL`, :data:`~vs2.HUD` or :data:`~vs2.FULLSCREEN`. Writable at runtime, e.g. a radar layer that flips between tunnel and HUD.""" return self._projection @projection.setter def projection(self, value): self._require_open("projection") self._projection = _projection(value) if self._layer is not None: self._layer.set_mode(self._projection) @property def visible(self): """Whether the whole layer draws. Toggling it touches no drawable.""" return self._visible @visible.setter def visible(self, value): self._require_open("visible") self._visible = bool(value) if self._layer is not None: self._layer.set_visible(self._visible) @property def sprites(self): """The layer's :class:`Sprite` drawables, in creation order.""" return [drawable for drawable in self._drawables if isinstance(drawable, Sprite)] @property def tilemaps(self): """The layer's :class:`Tilemap` drawables, in creation order.""" return [drawable for drawable in self._drawables if isinstance(drawable, Tilemap)] def _require_open(self, field): if self._closed: raise SceneSealedError("cannot change %s on a closed V2 layer" % field)
[docs] def sprite(self, image, x=0, y=0, frame=0, visible=True, flip_x=False, flip_y=False): """Create one sprite on this layer. Only callable from ``build()``. Args: image: An image name or an :class:`Image` handle. x: Angle. 0 is the bottom of the disc, 64 left, 128 top, 192 right; wraps at :data:`vs2.display.width`. y: Distance inward from the rim, where 0 is the outermost LED. Its range follows the layer's projection: ``0..53`` on HUD, ``0..255`` (depth) on TUNNEL and FULLSCREEN. frame: Initial frame, validated against the image's frame count. visible: Whether it draws. Independent of ``frame``. flip_x: Mirror horizontally. flip_y: Mirror vertically. Returns: Sprite: The new sprite. Raises: SceneSealedError: If called outside ``build()``. ResourceLimitError: If the scene is out of sprite slots. FrameError: If ``frame`` is out of range for the image. """ self._require_build("sprite") self.scene._reserve("sprite", 1, self) sprite = Sprite(self, self.scene.image(image), x, y, frame, visible, flip_x, flip_y) self._drawables.append(sprite) return sprite
[docs] def sprite_pool(self, image, count, frame=0, on_empty=None): """Create a fixed group of interchangeable sprites. All ``count`` sprites are allocated here, hidden, so the budget is spent visibly and up front in one reviewable number. Nothing is allocated when they later spawn and despawn. Args: image: An image name or an :class:`Image` handle. count: How many sprites to reserve. Must be at least 1. frame: Initial frame for every sprite in the pool. on_empty: What :meth:`SpritePool.spawn` does when the pool is exhausted. ``None`` returns ``None`` — "no free bullet this frame" is a game rule, not an error. :data:`vs2.RECYCLE` reuses the oldest live sprite instead, which is what explosions and particles usually want. Returns: SpritePool: The new pool, with every sprite hidden. Raises: SceneSealedError: If called outside ``build()``. ResourceLimitError: If the scene cannot fit ``count`` more sprites. """ self._require_build("sprite_pool") count = int(count) if count < 1: raise ValueError("sprite_pool count must be positive") self.scene._reserve("sprite", count, self) image = self.scene.image(image) sprites = [] for _ in range(count): sprite = Sprite(self, image, 0, 0, frame, False, False, False) self._drawables.append(sprite) sprites.append(sprite) return SpritePool(sprites, on_empty)
[docs] def tilemap(self, image, columns, rows, cells=None, x=0, y=0, view_width=None, view_height=None, view_x=0, view_y=0, visible=True, flip_x=False, flip_y=False): """Create a grid of tiles drawn from one tileset image. Tile size comes from the image, so it can never disagree with it. Args: image: The tileset. One frame per distinct tile. columns: Grid width in tiles. rows: Grid height in tiles. cells: An existing ``bytearray`` of ``columns * rows`` tile indices to draw from. Omit it and one is allocated, filled with :data:`vs2.EMPTY_TILE`. Pass your own to keep object identity with a buffer the game already owns. x: Angle of the map's origin; 0 is the bottom of the disc. y: Distance of the origin inward from the rim, 0 being the outermost LED. view_width: Width of the on-screen window onto the map. Defaults to the whole map. view_height: Height of that window. Defaults to the whole map. view_x: Horizontal scroll offset into the map. view_y: Vertical scroll offset into the map. visible: Whether it draws. flip_x: Mirror the complete visible viewport horizontally. flip_y: Mirror the complete visible viewport vertically. Returns: Tilemap: The new tilemap. Raises: SceneSealedError: If called outside ``build()``. ResourceLimitError: If the scene is out of tilemap slots. ValueError: On a :data:`~vs2.FULLSCREEN` layer, which cannot draw tilemaps, or if ``cells`` is the wrong length. """ self._require_build("tilemap") if self.projection == FULLSCREEN: raise ValueError("tilemap() is not supported on a FULLSCREEN layer") self.scene._reserve("tilemap", 1, self) tilemap = Tilemap(self, self.scene.image(image), columns, rows, cells, x, y, view_width, view_height, view_x, view_y, visible, flip_x, flip_y) self._drawables.append(tilemap) return tilemap
[docs] def label(self, image, columns, rows=1, x=0, y=0, text=None, glyphs=None, visible=True, flip_x=False, flip_y=False): """Create a text label: a tilemap you write strings into. A label costs one tilemap record and ``columns * rows`` bytes no matter how often the text changes, where the same text as individual sprites would cost one sprite per character. Characters map to frames through a glyph table, resolved once here in this order: an explicit ``glyphs`` argument, then a ``glyphs:`` entry declared beside the strip in ``__images__.yaml``, then CP437 (``frame = ord(ch)``). Unmappable characters and spaces become :data:`vs2.EMPTY_TILE`, which the renderer skips. Args: image: The font strip. One frame per glyph. columns: Width in characters. rows: Height in lines. One-line labels get the :attr:`Label.text` property. x: Angle; 0 is the bottom of the disc. y: Distance inward from the rim, 0 being the outermost LED. Text is most legible at low Y, near the rim. text: Initial text for a one-line label. glyphs: A charmap such as ``"0123456789/-"``, where ``frame = glyphs.index(ch)``. Include ``" "`` to get a real space glyph on fonts with opaque backgrounds. visible: Whether it draws. flip_x: Mirror the complete label horizontally. flip_y: Mirror the complete label vertically. Set both flips to rotate a label 180 degrees without changing its font strip. Returns: Label: The new label. Raises: SceneSealedError: If called outside ``build()``. ResourceLimitError: If the scene is out of tilemap slots. Labels count against the tilemap budget. ValueError: On a :data:`~vs2.FULLSCREEN` layer. """ self._require_build("label") if self.projection == FULLSCREEN: raise ValueError("label() is not supported on a FULLSCREEN layer") self.scene._reserve("tilemap", 1, self) label = Label( self, self.scene.image(image), columns, rows, x, y, glyphs, visible, flip_x, flip_y, ) self._drawables.append(label) if text is not None: label.text = text return label
def _projection(value): value = int(value) if value not in (FULLSCREEN, TUNNEL, HUD): raise ValueError("projection must be vs2.FULLSCREEN, TUNNEL, or HUD") return value
[docs] class Sprite: """One movable image on a layer. Created by :meth:`Layer.sprite` or handed out by a :class:`SpritePool`, never directly. Writing to ``x``, ``y``, ``frame``, ``visible``, ``flip_x``, ``flip_y`` or ``image`` goes straight into the renderer's record and allocates nothing, so moving sprites every tick is free. ``frame`` and ``visible`` are independent, which is what makes priming a pooled sprite two plain statements:: shot.frame = BULLET_FRAME # still hidden shot.show() # now visible, same frame """ def __init__(self, layer, image, x, y, frame, visible, flip_x, flip_y): self._layer = layer self._closed = False backend = _vs2_backend() if backend is None: backend = get_platform().sprites self._sprite = backend.Sprite() self._uses_fixed_coords = hasattr(self._sprite, "set_x_fixed") self._has_flags = hasattr(self._sprite, "set_flags") self._has_layer = hasattr(self._sprite, "set_layer") self._image = image self._x = x self._y = y self._frame = 0 self._visible = bool(visible) self._flip_x = bool(flip_x) self._flip_y = bool(flip_y) self._pool = None self._pool_live_index = -1 self._set_frame(frame) self._sync_all() def _require_open(self, field): if self._closed: raise SceneSealedError("cannot change %s on a sprite from a closed V2 scene" % field) def _sync_all(self): self._sprite.set_strip(self._image._strip) if self._uses_fixed_coords: self._sprite.set_x_fixed(_fixed_8_8(self._x)) self._sprite.set_y_fixed(_fixed_8_8(self._y)) else: self._sprite.set_x(_floor_coord(self._x) % display.width) self._sprite.set_y(_render_coord(_floor_coord(self._y), 0, display.height - 1)) self._sprite.set_perspective(self._layer.projection) if self._has_layer: self._sprite.set_layer(self._layer._layer) self._sync_flags() self._sync_frame() def _sync_flags(self): if self._has_flags: self._sprite.set_flags(_sprite_flags(self)) def _sync_frame(self): if self._has_flags or self._visible: self._sprite.set_frame(self._frame) else: self._sprite.set_frame(EMPTY_TILE) def _set_frame(self, value): value = int(value) if value < 0 or value >= self._image.frames: raise FrameError("%s has %d frames; frame must be 0..%d" % (self._image.name, self._image.frames, self._image.frames - 1)) self._frame = value @property def layer(self): """The :class:`Layer` that draws this sprite. Read-only for its whole life, so draw order is never ambiguous.""" return self._layer @property def image(self): """The sprite's :class:`Image`. Assigning a name or handle swaps the artwork, keeping :attr:`frame` if it is still in range and resetting it to 0 otherwise.""" return self._image @image.setter def image(self, value): self._require_open("image") image = self._layer.scene.image(value) self._image = image if self._frame >= image.frames: self._frame = 0 self._sprite.set_strip(image._strip) self._sync_frame() @property def x(self): """Angle around the disc -- 0 at the bottom, 64 left, 128 top, 192 right -- wrapping at :data:`vs2.display.width`. Fractional values are kept: the renderer stores signed 8.8 fixed point.""" return self._x @x.setter def x(self, value): self._require_open("x") self._x = value if self._uses_fixed_coords: self._sprite.set_x_fixed(_fixed_8_8(value)) else: self._sprite.set_x(_floor_coord(value) % display.width) @property def y(self): """Distance inward from the rim, where 0 is the outermost LED. What it means follows the layer's projection. On a :data:`~vs2.HUD` layer it is a direct LED index, ``0..53``. On a :data:`~vs2.TUNNEL` layer it is depth, ``0..255``: 0 is the outer rim and 255 is the centre. :data:`~vs2.FULLSCREEN` uses that same curve as radial extent: 0 fills the disc and increasing values contract toward the centre. Out-of-range values clip. Fractional values are kept.""" return self._y @y.setter def y(self, value): self._require_open("y") self._y = value if self._uses_fixed_coords: self._sprite.set_y_fixed(_fixed_8_8(value)) else: self._sprite.set_y(_render_coord(_floor_coord(value), 0, display.height - 1)) @property def frame(self): """Which animation frame to draw. Never changes :attr:`visible`. Raises: FrameError: If the frame is out of range for the image.""" return self._frame @frame.setter def frame(self, value): self._require_open("frame") self._set_frame(value) self._sync_frame() @property def visible(self): """Whether the sprite draws. Never changes :attr:`frame`.""" return self._visible @visible.setter def visible(self, value): self._require_open("visible") self._visible = bool(value) self._sync_flags() self._sync_frame() @property def flip_x(self): """Mirror the sprite horizontally.""" return self._flip_x @flip_x.setter def flip_x(self, value): self._require_open("flip_x") self._flip_x = bool(value) self._sync_flags() @property def flip_y(self): """Mirror the sprite vertically.""" return self._flip_y @flip_y.setter def flip_y(self, value): self._require_open("flip_y") self._flip_y = bool(value) self._sync_flags() @property def width(self): """Frame width from the image metadata. Read-only, no renderer call.""" return self._image.width @property def height(self): """Frame height from the image metadata. Read-only, no renderer call.""" return self._image.height
[docs] def show(self): """Make the sprite visible without touching its frame.""" self.visible = True
[docs] def hide(self): """Hide the sprite without touching its frame.""" self.visible = False
[docs] def overlaps(self, other): """Whether this sprite's box overlaps ``other``'s. An axis-aligned test that allocates nothing. X wraps around the display, so a sprite straddling column 0 still collides correctly; Y does not wrap, because the disc has an inside and an outside. """ return (_intersects_circular(self.x, self.width, other.x, other.width) and self.y < other.y + other.height and self.y + self.height > other.y)
[docs] def first_overlap(self, sprites): """Return the first sprite in ``sprites`` that overlaps this one. Accepts any iterable, including a :class:`SpritePool`, which is the common case:: baddie = shot.first_overlap(self.baddies) if baddie: self.baddies.despawn(baddie) Returns: Sprite or None: The first overlapping sprite, or ``None``. """ for other in sprites: if self.overlaps(other): return other return None
class _PoolIterator: def __init__(self, pool): self.pool = pool self.index = 0 self.previous = None def __iter__(self): return self def __next__(self): if self.previous is not None and self.previous._pool_live_index == -1: self.previous = None else: self.index += 1 if self.previous is not None else 0 self.previous = None if self.index >= len(self.pool._live): raise StopIteration self.previous = self.pool._live[self.index] return self.previous
[docs] class SpritePool: """A fixed group of interchangeable sprites, cycled without allocating. Created by :meth:`Layer.sprite_pool`. Every operation is O(1) index bookkeeping — nothing allocates a Python object or a renderer record. Iterating yields only the live sprites, and despawning the current sprite mid-loop is supported, which is the pattern most games need. Y is depth on a ``TUNNEL`` layer, so a shot fired away from the player counts up toward the centre and is retired at a depth the game picks:: for shot in self.shots: shot.y += SHOT_SPEED if shot.y > SHOT_RANGE: self.shots.despawn(shot) continue baddie = shot.first_overlap(self.baddies) if baddie: self.baddies.despawn(baddie) self.booms.spawn(x=baddie.x, y=baddie.y) self.shots.despawn(shot) ``len(pool)`` is the live count and :attr:`free` is the remainder. """ def __init__(self, sprites, on_empty): self._free = sprites self._live = [] self._on_empty = on_empty for sprite in sprites: sprite._pool = self @property def free(self): """How many sprites are still available to :meth:`spawn`.""" return len(self._free)
[docs] def __len__(self): """The number of live sprites.""" return len(self._live)
[docs] def __iter__(self): """Iterate the live sprites, tolerating despawns during the loop.""" return _PoolIterator(self)
[docs] def spawn(self, x, y, frame=0, flip_x=False, flip_y=False): """Take a free sprite, position it, show it, and return it. Args: x: Angular position. y: Radial position. frame: Frame to show. flip_x: Mirror horizontally. flip_y: Mirror vertically. Returns: Sprite or None: The spawned sprite. ``None`` when the pool is exhausted and it was not created with ``on_empty=vs2.RECYCLE``. """ if not self._free: if self._on_empty is not RECYCLE: return None self.despawn(self._live[0]) sprite = self._free.pop() sprite.x = x sprite.y = y sprite.frame = frame sprite.flip_x = flip_x sprite.flip_y = flip_y sprite.visible = True sprite._pool_live_index = len(self._live) self._live.append(sprite) return sprite
[docs] def despawn(self, sprite): """Hide ``sprite`` and return it to the pool. Raises: ValueError: If the sprite belongs to another pool, or is already despawned. Both mean a bookkeeping bug in the game. """ if getattr(sprite, "_pool", None) is not self or sprite._pool_live_index < 0: raise ValueError("sprite is not live in this pool") index = sprite._pool_live_index tail = self._live.pop() if tail is not sprite: self._live[index] = tail tail._pool_live_index = index sprite._pool_live_index = -1 sprite.hide() self._free.append(sprite)
[docs] def despawn_all(self): """Despawn every live sprite. The usual way to reset a level.""" while self._live: self.despawn(self._live[-1])
[docs] class Tilemap: """A grid of tiles drawn from one tileset image. Created by :meth:`Layer.tilemap`. The grid itself is a flat ``bytearray`` of tile indices in :attr:`cells`, one byte per cell, addressed in ``(column, row)`` order — the same axis order as ``(x, y)`` everywhere else:: self.ground[col, row] = ROCK tile = self.ground[col, row] self.ground.fill(GRASS) self.ground.cells[row * self.ground.columns + col] = ROCK # fastest :data:`vs2.EMPTY_TILE` leaves a cell blank and the renderer skips it. Scrolling moves the view over the grid rather than rewriting it, so it costs two scalar writes and allocates nothing:: self.ground.view_y = self.depth % self.ground.tile_height """ def __init__(self, layer, image, columns, rows, cells, x, y, view_width, view_height, view_x, view_y, visible, flip_x=False, flip_y=False): columns = int(columns) rows = int(rows) if columns < 1 or rows < 1: raise ValueError("tilemap columns and rows must be positive") if cells is None: cells = bytearray([EMPTY_TILE] * (columns * rows)) if len(cells) != columns * rows: raise ValueError("cells length must equal columns * rows") self._layer = layer self._closed = False self._image = image self._columns = columns self._rows = rows self._tile_width = image.width self._tile_height = image.height self._cells = cells if isinstance(cells, bytearray) else bytearray(cells) self._cells_view = memoryview(self._cells) self._x = x self._y = y self._view_x = int(view_x) self._view_y = int(view_y) self._view_width = int(view_width if view_width is not None else columns * image.width) self._view_height = int(view_height if view_height is not None else rows * image.height) if self._view_x < 0 or self._view_y < 0 or self._view_width < 1 or self._view_height < 1: raise ValueError("invalid tilemap view") self._visible = bool(visible) self._flip_x = bool(flip_x) self._flip_y = bool(flip_y) backend = _vs2_backend() self._tilemap = None if backend is not None and hasattr(backend, "Tilemap"): self._tilemap = backend.Tilemap( strip=image._strip, frames=self._cells_view, columns=columns, rows=rows, tile_width=image.width, tile_height=image.height) self._sync_all() def _require_open(self, field): if self._closed: raise SceneSealedError("cannot change %s on a tilemap from a closed V2 scene" % field) @property def layer(self): """The :class:`Layer` that draws this tilemap.""" return self._layer @property def image(self): """The tileset :class:`Image` this map draws from.""" return self._image @property def columns(self): """Grid width in tiles. Read-only.""" return self._columns @property def rows(self): """Grid height in tiles. Read-only.""" return self._rows @property def tile_width(self): """Width of one tile, taken from the image. Read-only.""" return self._tile_width @property def tile_height(self): """Height of one tile, taken from the image. Read-only.""" return self._tile_height @property def cells(self): """The flat ``bytearray`` of tile indices, row-major. Writable in place; the buffer itself cannot be replaced or resized while the map is active, because the renderer reads these bytes directly.""" # Keeping an exported memoryview alive makes CPython and MicroPython # reject bytearray resizing, while callers that supplied a bytearray # retain its exact identity for zero-copy bulk updates. return self._cells @property def x(self): """Angle of the map's origin, 0 at the bottom of the disc, wrapping at :data:`vs2.display.width`.""" return self._x @x.setter def x(self, value): self._require_open("x") self._x = value if self._tilemap is not None: self._tilemap.set_x_fixed(_fixed_8_8(value)) @property def y(self): """Distance of the map's origin inward from the rim, 0 being the outermost LED. Interpreted per the layer's projection, like :attr:`vs2.Sprite.y`.""" return self._y @y.setter def y(self, value): self._require_open("y") self._y = value if self._tilemap is not None: self._tilemap.set_y_fixed(_fixed_8_8(value)) @property def view_x(self): """Horizontal scroll offset into the grid. Writing it pans the view without touching :attr:`cells`.""" return self._view_x @view_x.setter def view_x(self, value): self._require_open("view_x") self._view_x = int(value) self._sync_view() @property def view_y(self): """Vertical scroll offset into the grid. Writing it pans the view without touching :attr:`cells`.""" return self._view_y @view_y.setter def view_y(self, value): self._require_open("view_y") self._view_y = int(value) self._sync_view() @property def view_width(self): """Width of the on-screen window onto the map. Read-only.""" return self._view_width @property def view_height(self): """Height of the on-screen window onto the map. Read-only.""" return self._view_height @property def visible(self): """Whether the tilemap draws.""" return self._visible @visible.setter def visible(self, value): self._require_open("visible") self._visible = bool(value) self._sync_flags() @property def flip_x(self): """Whether the complete visible viewport is mirrored horizontally.""" return self._flip_x @flip_x.setter def flip_x(self, value): self._require_open("flip_x") self._flip_x = bool(value) self._sync_flags() @property def flip_y(self): """Whether the complete visible viewport is mirrored vertically.""" return self._flip_y @flip_y.setter def flip_y(self, value): self._require_open("flip_y") self._flip_y = bool(value) self._sync_flags() def _sync_all(self): if self._tilemap is None: return self._tilemap.set_x_fixed(_fixed_8_8(self._x)) self._tilemap.set_y_fixed(_fixed_8_8(self._y)) self._tilemap.set_perspective(self._layer.projection) self._tilemap.set_layer(self._layer._layer) self._sync_flags() self._sync_view() def _sync_flags(self): if self._tilemap is not None: self._tilemap.set_flags(_tilemap_flags(self)) def _sync_view(self): if self._tilemap is not None: self._tilemap.set_viewport(self._view_x, self._view_y, self._view_width, self._view_height)
[docs] def __getitem__(self, position): """Read one cell: ``tile = tilemap[column, row]``. Raises: IndexError: If the cell is outside the grid. """ column, row = position return self.cells[self._cell_index(column, row)]
[docs] def __setitem__(self, position, value): """Write one cell: ``tilemap[column, row] = tile``. Raises: IndexError: If the cell is outside the grid. """ self._require_open("cells") self.cells[self._cell_index(*position)] = int(value)
def _cell_index(self, column, row): column = int(column) row = int(row) if column < 0 or column >= self.columns or row < 0 or row >= self.rows: raise IndexError("tilemap cell out of range") return row * self.columns + column
[docs] def fill(self, value): """Set every cell to ``value``, in place.""" self._require_open("cells") value = int(value) for index in range(len(self.cells)): self.cells[index] = value
[docs] def show(self): """Make the tilemap visible.""" self.visible = True
[docs] def hide(self): """Hide the tilemap.""" self.visible = False
[docs] class Label(Tilemap): """A tilemap you write text into. Created by :meth:`Layer.label`. Because it is a tilemap, a label counts against the tilemap budget rather than the sprite budget, and costs the same whether it shows two characters or twenty. The display stores tiles counter-clockwise, but a label handles that inversion internally: you write ordinary left-to-right strings and never see the reversed indices. Only fixed-cell ASCII and CP437 are supported. There is no variable-width text, wrapping, or Unicode shaping. """ def __init__(self, layer, image, columns, rows, x, y, glyphs, visible, flip_x=False, flip_y=False): self._glyphs = glyphs if glyphs is not None else image.glyphs Tilemap.__init__(self, layer, image, columns, rows, None, x, y, image.width * int(columns), image.height * int(rows), 0, 0, visible, flip_x, flip_y) def _glyph(self, char, frame_offset): if char == " " and self._glyphs is None: return EMPTY_TILE if self._glyphs is None: value = ord(char) else: try: value = self._glyphs.index(char) except ValueError: return EMPTY_TILE value += frame_offset return value if 0 <= value < self.image.frames else EMPTY_TILE
[docs] def write(self, column, row, text, frame_offset=0, pad=True): """Write ``text`` starting at ``(column, row)``. Touches only the bytes it changes and allocates nothing, so it is safe to call every tick. Text longer than the remaining columns is clipped. ``frame_offset`` shifts every glyph by a constant, which is how font strips that pack a second colour at a fixed offset are reached:: self.status.write(3, 1, "ABXY", frame_offset=0x80) Args: column: Starting column. row: Row to write into. text: The string. Non-strings are converted. frame_offset: Added to each glyph's frame index. pad: When true, clear the rest of the row after the text. Set it false to overwrite in place without disturbing neighbours. Raises: IndexError: If ``column`` or ``row`` is outside the grid. """ self._require_open("cells") column = int(column) row = int(row) if row < 0 or row >= self.rows: raise IndexError("label row out of range") if column < 0 or column >= self.columns: raise IndexError("label column out of range") text = str(text) remaining = self.columns - column if pad else min(len(text), self.columns - column) for index in range(remaining): char = text[index] if index < len(text) else None value = EMPTY_TILE if char is None else self._glyph(char, int(frame_offset)) # Tile storage runs counter-clockwise; this is the one place the # direction inversion belongs, never in game code. self.cells[row * self.columns + self.columns - 1 - (column + index)] = value
@property def text(self): """The label's contents, for one-line labels only. Reading maps the cells back through the glyph table and strips trailing blanks; writing truncates at :attr:`~Tilemap.columns` and pads the rest with :data:`vs2.EMPTY_TILE`:: title = hud.label("rainbow437.png", columns=18, text="VS2 SPRITES") title.text = "GAME OVER" Raises: AttributeError: On a label with more than one row. Use :meth:`write` there. """ if self.rows != 1: raise AttributeError("multi-line labels have no text property") chars = [] for column in range(self.columns): frame = self.cells[self.columns - 1 - column] if frame == EMPTY_TILE: chars.append(" ") elif self._glyphs is None: chars.append(chr(frame)) elif frame < len(self._glyphs): chars.append(self._glyphs[frame]) else: chars.append(" ") return "".join(chars).rstrip() @text.setter def text(self, value): if self.rows != 1: raise AttributeError("multi-line labels have no text property") self.write(0, 0, value)
[docs] def set_number(self, value, width=1, pad="0"): """Write a right-aligned integer without building a string. A score updated every tick this way costs no allocation at all, where ``"%05d" % value`` would allocate a string per frame:: self.score.set_number(value, width=5, pad="0") Negative values clamp to zero, and values too large for ``width`` fill with nines rather than overflowing the label. Args: value: The number to display. width: How many digit cells to use, starting at column 0. pad: Character for leading blanks. ``"0"`` zero-pads; ``" "`` leaves them blank. Raises: ValueError: If ``width`` does not fit the label. """ width = int(width) if width < 1 or width > self.columns: raise ValueError("number width must fit the label") value = int(value) if value < 0: value = 0 divisor = 1 for _ in range(width - 1): divisor *= 10 for index in range(width): digit = value // divisor if divisor else value if digit > 9: digit = 9 if value < divisor and pad != "0" and index < width - 1: char = pad[0] if pad else " " else: char = chr(ord("0") + digit) self.cells[self.columns - 1 - index] = self._glyph(char, 0) value %= divisor if divisor else 1 divisor //= 10 if divisor > 1 else 1
def _payload_buffer(scene, size): payload = scene._vs2_payload if payload is None or len(payload) != size: payload = bytearray(size) scene._vs2_payload = payload return payload def export_scene_payload(scene=None): """Export the sealed display graph for desktop and browser renderers.""" _claim() if scene is None: return bytearray() if scene._phase != "sealed": raise SceneSealedError("cannot export a scene before build() seals it") layers = scene.layers sprites = scene._payload_sprites tilemaps = scene._payload_tilemaps drawables = scene._payload_drawables size = (PAYLOAD_HEADER_SIZE + len(layers) * PAYLOAD_LAYER_SIZE + len(sprites) * PAYLOAD_SPRITE_SIZE + len(tilemaps) * PAYLOAD_TILEMAP_SIZE + scene._payload_frames_size + len(drawables) * PAYLOAD_DRAW_REF_SIZE) payload = _payload_buffer(scene, size) struct.pack_into("<4sBBBBHHHH", payload, 0, PAYLOAD_MAGIC, PAYLOAD_VERSION, len(layers), len(sprites), len(tilemaps), PAYLOAD_HEADER_SIZE, PAYLOAD_LAYER_SIZE, PAYLOAD_SPRITE_SIZE, PAYLOAD_TILEMAP_SIZE) offset = PAYLOAD_HEADER_SIZE for index, layer in enumerate(layers): struct.pack_into("<BBBBBBBB", payload, offset, index, layer.projection, FLAG_VISIBLE if layer.visible else 0, 0, 0, 0, 0, 0) offset += PAYLOAD_LAYER_SIZE for sprite, layer_index in sprites: struct.pack_into("<BBBBBBhhii", payload, offset, layer_index, sprite.image._strip, sprite.frame, sprite.layer.projection, _sprite_flags(sprite), 0, 0, 0, _fixed_8_8(sprite.x), _fixed_8_8(sprite.y)) offset += PAYLOAD_SPRITE_SIZE cells_offset = offset + len(tilemaps) * PAYLOAD_TILEMAP_SIZE for tilemap, layer_index in tilemaps: struct.pack_into("<BBBBHHHHHHHHiiI", payload, offset, layer_index, tilemap.image._strip, _tilemap_flags(tilemap), tilemap.layer.projection, tilemap.columns, tilemap.rows, tilemap.tile_width, tilemap.tile_height, tilemap.view_x, tilemap.view_y, tilemap.view_width, tilemap.view_height, _fixed_8_8(tilemap.x), _fixed_8_8(tilemap.y), cells_offset) offset += PAYLOAD_TILEMAP_SIZE payload[cells_offset:cells_offset + len(tilemap.cells)] = tilemap.cells cells_offset += len(tilemap.cells) for kind, index in drawables: payload[cells_offset] = kind payload[cells_offset + 1] = index cells_offset += PAYLOAD_DRAW_REF_SIZE return payload def reset_runtime_state(): base._led_state = None base._servo_state = None base._button_state = None