PirateMaker

An in-game level editor and the level it builds, in one process.

▶ Run in browser

Upstream: https://github.com/clear-code-projects/PirateMaker

Licence: this port's own code is offered under the terms it inherits from the original, not the SimVX Examples Licence the rest of the gallery carries; its attribution does not reduce them to a single identifier. See ATTRIBUTION.md for the upstream it re-implements, the terms of anything it bundles, and the credit each one requires.

Ports live in the repository only, not in the simvx-examples distribution, because each is a derivative work licensed individually against the game it re-implements. Read it with git clone https://git.simvx.com/simvx/simvx.

Tags: port tier-2

PirateMaker: SimVX port

A side-scrolling pirate platformer with its level editor built into the game. Port of clear-code-projects/PirateMaker (CC0 code, Pixelfrog CC0 art). Paint a level onto an infinite grid, press Play, and run it in the same process: no reload, no export, no separate editor build.

What this shows

  • Node2D composition for both modes, swapped in and out under one root.

  • One pannable canvas container: every tile and object is a child of a single Node2D, so panning the view is one transform, not per-sprite bookkeeping.

  • Sprite2D at native texture size, with draw_size for hit-testing, so nothing in the port has to measure a PNG.

  • CanvasLayer for screen-pinned UI (title menu, tile palette, controls strip) that ignores the world transform.

  • Camera2D following the player in play mode.

  • InputMap actions registered once on the root, so they survive mode swaps.

  • AudioPlayer for looping music per mode plus one-shot coin, hit and jump effects.

  • Signal to keep the editor and the play mode from knowing about the root.

  • JSON save/load of the whole canvas.

Run

All commands run from the repository root:

# Interactive
uv run python examples/ports/piratemaker/main.py

# Headless capture (8 frames -> screenshots/)
uv run python examples/ports/piratemaker/main.py --test

# Web export
uv run simvx export web examples/ports/piratemaker/main.py -o /tmp/piratemaker.html

Controls

Everything below except the [ / ] shortcut also has an on-screen button, and every tile is reachable by tapping a palette quadrant, so the port plays with a mouse or by touch alone.

Editor

Input

Paint the selected tile or object

Left button (drag to keep painting)

Erase a tile or object

Right button, or Erase then the left button

Pick a palette entry

Left button on a palette quadrant; tap it again to step to the next entry

Cycle within a group

Right button on that quadrant

Swap foreground / background palms

Middle button on the palm quadrant, or Palm fg/bg

Step through every tile

[ and ]

Move the pirate or the horizon

Drag them

Pan the view

Middle-button drag, the wheel, or Pan then the left button

Play the level

Enter, or Play level

Save / load

S / L, or Save / Load

Quit

Q, or Quit

Erase and Pan latch what the left button does and stay lit while active; press the same button again to go back to painting. They exist so the editor works on a touch screen, which has no right button, no middle button and no wheel.

Play

Input

Move

Left / Right, A / D, or the on-screen pad

Jump

Space, W, Up, or Jump

Back to the editor

Esc, or Back to editor

A level needs at least one terrain or water tile before it will play.

Levels

levels/level.json is the example level that ships with the port and is what Load reads until you save your own. Save writes levels/user_level.json, which is local scratch and is not part of the checkout, so building a level never dirties version control.

Layout

  • main.py: PirateMakerRoot runs the title menu and swaps editor and play modes.

  • settings.py: tile size, window size, colours, the EDITOR_DATA tile table.

  • support.py: folder-of-PNGs animation loaders.

  • harness.py: --test deterministic capture mode.

  • nodes/editor.py: EditorMode, EditorObject, Cloud, CanvasTile, palette.

  • nodes/level.py: PlayMode builds a runnable level from the editor’s grid dict.

  • nodes/player.py: player movement, gravity, AABB collision, animation.

  • nodes/enemies.py: spikes, tooth, shell, pearl.

  • nodes/hud.py: title menu, bottom controls strip, on-screen movement pad.

  • nodes/folder_sprite.py: Sprite2D that cycles a folder of frames.

  • nodes/save_load.py: JSON save/load.

Asset credits and per-asset licences are in ATTRIBUTION.md.

Source files

File

Summary

Lines

main.py

PirateMaker: An in-game level editor and the level it builds, in one process.

159

harness.py

Headless --test mode: capture screenshots covering the title menu, an empty

98

nodes/__init__.py

PirateMaker port: node modules.

1

nodes/editor.py

PirateMaker editor mode.

965

nodes/enemies.py

PirateMaker enemies: Spikes (static), Tooth (patrol), Shell+Pearl.

150

nodes/folder_sprite.py

Multi-PNG animated sprite: cycles Sprite2D.texture across a folder of frames.

71

nodes/hud.py

Screen-space UI: title menu, bottom controls strip, on-screen movement pad.

348

nodes/level.py

PirateMaker play mode.

305

nodes/player.py

PirateMaker Player node: controls, gravity, AABB collision, animation.

178

nodes/save_load.py

Level JSON save/load: persists editor canvas state.

145

settings.py

PirateMaker port: global constants and editor data table.

99

sfx.py

Synthesized sound effects for the PirateMaker port.

68

support.py

Asset / animation helpers for the PirateMaker port.

49

Source

  1#!/usr/bin/env python3
  2"""PirateMaker: An in-game level editor and the level it builds, in one process.
  3
  4# /// simvx
  5# tags = ["port", "tier-2"]
  6# upstream = "https://github.com/clear-code-projects/PirateMaker"
  7# web = { width = 1280, height = 720, responsive = true }
  8# ///
  9
 10A port of Clear Code's PirateMaker. Paint terrain, water, gold, enemies and palm
 11trees onto an infinite grid, drag the pirate and the horizon into place, then hit
 12Play and run the level you just made: same process, no reload, no export step.
 13
 14Shows off Node2D scene composition, one pannable canvas container standing in for
 15a camera, Sprite2D and CanvasLayer, Camera2D follow, InputMap actions, AudioPlayer
 16music and sound effects, and JSON level save/load.
 17
 18Editor: left button paints, right button erases (and cycles the palette quadrant
 19under the cursor), middle button or the wheel pans, `[` and `]` step through
 20tiles, S saves, L loads, Enter plays, Q quits.
 21Play: Left/Right or A/D to move, Space to jump, Esc back to the editor.
 22All of those except the bracket keys have an on-screen button too (tapping a
 23palette quadrant steps through its tiles), and the strip's Erase and Pan buttons
 24latch what the left button does, so the whole thing works by touch.
 25
 26Run:
 27    uv run python examples/ports/piratemaker/main.py
 28    uv run python examples/ports/piratemaker/main.py --test
 29"""
 30
 31from __future__ import annotations
 32
 33import argparse
 34import sys
 35from pathlib import Path
 36
 37_PORT_DIR = Path(__file__).resolve().parent
 38if str(_PORT_DIR) not in sys.path:
 39    sys.path.insert(0, str(_PORT_DIR))
 40
 41from nodes.editor import EditorMode
 42from nodes.hud import MenuScreen
 43from nodes.level import PlayMode
 44from settings import WINDOW_HEIGHT, WINDOW_WIDTH
 45
 46from simvx.core import Input, InputMap, Key, MouseButton, Node2D
 47from simvx.graphics import App
 48
 49
 50class PirateMakerRoot(Node2D):
 51    """Root scene: title menu, then the editor and (when playing) the level.
 52
 53    Mode swap is local: the editor stays as a child, hidden while a level plays.
 54    InputMap actions are registered once here so they survive every swap.
 55    """
 56
 57    def on_ready(self) -> None:
 58        # Movement (play mode)
 59        InputMap.add_action("move_left", [Key.LEFT, Key.A])
 60        InputMap.add_action("move_right", [Key.RIGHT, Key.D])
 61        InputMap.add_action("jump", [Key.SPACE, Key.UP, Key.W])
 62
 63        # Editor selection
 64        InputMap.add_action("select_prev", [Key.LEFT_BRACKET])
 65        InputMap.add_action("select_next", [Key.RIGHT_BRACKET])
 66
 67        # Mode swap
 68        InputMap.add_action("play", [Key.ENTER])
 69        InputMap.add_action("editor", [Key.ESCAPE])
 70
 71        # Painting / panning
 72        InputMap.add_action("paint", [MouseButton.LEFT])
 73        InputMap.add_action("erase", [MouseButton.RIGHT])
 74        InputMap.add_action("pan", [MouseButton.MIDDLE])
 75
 76        # Save / load slot
 77        InputMap.add_action("save_level", [Key.S])
 78        InputMap.add_action("load_level", [Key.L])
 79
 80        # Quit
 81        InputMap.add_action("quit_app", [Key.Q])
 82
 83        self.editor: EditorMode | None = None
 84        self.level: PlayMode | None = None
 85
 86        self.menu: MenuScreen | None = self.add_child(MenuScreen())
 87        self.menu.button_pressed.connect(self._on_menu_button)
 88
 89    def _on_menu_button(self, action: str) -> None:
 90        if action == "start":
 91            self.open_editor()
 92        elif action == "quit":
 93            self.quit()
 94
 95    def quit(self) -> None:
 96        self.app.quit()
 97
 98    def open_editor(self) -> None:
 99        """Dismiss the title menu and build the editor."""
100        if self.menu is not None:
101            self.menu.destroy()
102            self.menu = None
103        if self.editor is None:
104            self.editor = self.add_child(EditorMode())
105            self.editor.play_requested.connect(self.start_play)
106            self.editor.quit_requested.connect(self.quit)
107
108    def on_update(self, dt: float) -> None:
109        editing = self.editor is not None and self.editor.visible
110
111        # Editor -> play (Enter)
112        if editing and Input.is_action_just_pressed("play"):
113            self.start_play()
114
115        # Play -> editor (Esc)
116        if self.level is not None and Input.is_action_just_pressed("editor"):
117            self.return_to_editor()
118
119        # Hard quit (Q from the editor only)
120        if editing and Input.is_action_just_pressed("quit_app"):
121            self.quit()
122
123    def start_play(self) -> None:
124        """Build a level dict from the editor canvas and spawn a PlayMode child."""
125        if self.editor is None or self.level is not None:
126            return
127        grid = self.editor.create_grid()
128        if not grid["terrain"] and not grid["water"]:
129            # Nothing to stand on: refuse to play
130            return
131        self.editor.set_active(False)
132        self.level = self.add_child(PlayMode(grid))
133        self.level.editor_requested.connect(self.return_to_editor)
134
135    def return_to_editor(self) -> None:
136        if self.level is not None:
137            self.level.stop_music()
138            self.level.destroy()
139            self.level = None
140        if self.editor is not None:
141            self.editor.set_active(True)
142
143
144def main() -> None:
145    parser = argparse.ArgumentParser()
146    parser.add_argument("--test", action="store_true", help="Headless capture mode for screenshots.")
147    args = parser.parse_args()
148
149    if args.test:
150        from harness import run_test
151
152        run_test()
153        return
154
155    App(width=WINDOW_WIDTH, height=WINDOW_HEIGHT, title="PirateMaker").run(PirateMakerRoot())
156
157
158if __name__ == "__main__":
159    main()