PirateMaker¶
An in-game level editor and the level it builds, in one process.
▶ Run in browserUpstream: 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.Sprite2Dat native texture size, withdraw_sizefor hit-testing, so nothing in the port has to measure a PNG.CanvasLayerfor screen-pinned UI (title menu, tile palette, controls strip) that ignores the world transform.Camera2Dfollowing the player in play mode.InputMapactions registered once on the root, so they survive mode swaps.AudioPlayerfor looping music per mode plus one-shot coin, hit and jump effects.Signalto 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 |
|
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 |
|
Save / load |
|
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, |
Jump |
|
Back to the 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:PirateMakerRootruns the title menu and swaps editor and play modes.settings.py: tile size, window size, colours, theEDITOR_DATAtile table.support.py: folder-of-PNGs animation loaders.harness.py:--testdeterministic capture mode.nodes/editor.py:EditorMode,EditorObject,Cloud,CanvasTile, palette.nodes/level.py:PlayModebuilds 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:Sprite2Dthat 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 |
|---|---|---|
PirateMaker: An in-game level editor and the level it builds, in one process. |
159 |
|
Headless |
98 |
|
PirateMaker port: node modules. |
1 |
|
PirateMaker editor mode. |
965 |
|
PirateMaker enemies: Spikes (static), Tooth (patrol), Shell+Pearl. |
150 |
|
Multi-PNG animated sprite: cycles Sprite2D.texture across a folder of frames. |
71 |
|
Screen-space UI: title menu, bottom controls strip, on-screen movement pad. |
348 |
|
PirateMaker play mode. |
305 |
|
PirateMaker Player node: controls, gravity, AABB collision, animation. |
178 |
|
Level JSON save/load: persists editor canvas state. |
145 |
|
PirateMaker port: global constants and editor data table. |
99 |
|
Synthesized sound effects for the PirateMaker port. |
68 |
|
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()