Tanks of Freedom¶
Isometric turn-based strategy, three units, four buildings, AI opponent.
▶ Run in browserUpstream: https://github.com/w84death/Tanks-of-Freedom
Licence: this port's own code is offered under MIT, not the SimVX Examples Licence the rest of the gallery carries. 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
Tanks of Freedom: SimVX port¶
Turn-based isometric strategy: a single 12x12 skirmish against a heuristic AI, re-implemented in SimVX from P1X’s open-source Godot game.
Run¶
# the export runs in a copy made by `simvx examples copy ports_tanks_of_freedom`
simvx examples run ports_tanks_of_freedom # interactive
simvx examples run ports_tanks_of_freedom --test # headless screenshots
simvx export web main.py \
-o tanks_of_freedom.html
Controls¶
Click a blue unit to select it. Yellow tiles are its move range, red tiles are enemies it can attack this turn, yellow dots preview the planned path.
Click a yellow tile to move there, a red tile to attack.
Click a building to read its panel; click empty ground or press Escape to deselect.
End Turn (top right) or Enter passes the turn to red.
Escape / Q quits from the menu.
Win condition¶
Capture the red HQ or destroy every red unit. Only infantry capture: end an infantry move on a building’s tile and it changes hands. The mid-map neutral barracks and factory can be flipped on the way, but this port has no unit production, so only the HQ decides the game.
What this port demonstrates¶
Isometric tile maps.
TankTileMapsubclasses the engine’sTileMap, setsmode="isometric"and a 64x32 cell, and lets the engine Y-sort its children so units and buildings occlude each other correctly as they walk up the diamond (nodes/tile_map.py).Procedural art, no image files. Terrain diamonds, unit silhouettes, buildings, flags, health bars, range overlays and explosion frames are all numpy RGBA arrays handed to
Sprite2D(texture=...)with nearest-neighbour filtering (nodes/textures.py).Signals as the turn spine.
TurnManageremitsturn_started, units emitmove_finishedwhen their walk animation lands, and the HUD buttons emit their own signals: the world only ever reacts. Move handlers connect withonce=Trueso a completed move cannot fire again later (nodes/world.py).Sprite pooling for overlays. Highlighting 30+ reachable cells every frame reuses a pool of hidden
Sprite2Dchildren instead of creating and destroying nodes (nodes/cursor.py).Input actions, not raw keys.
primary,end_turn,cancel,menu_startandmenu_quitare registered in the root node’s ready path, where the tree exists, so the web export gets the same bindings as the desktop run.Audio. The upstream CC-BY-SA effects are loaded into
AudioPlayernodes and triggered on select, move, capture, hit, explosion and end of turn.
Upstream¶
w84death/Tanks-of-Freedom: Godot 2.1, MIT code and graphics, CC-BY-SA 4.0 audio.
This port re-implements the game in Python and reuses only the sound effects.
See ATTRIBUTION.md and UPSTREAM_LICENSE.md.
Layout¶
main.py: App entry, menu/world swap, headless capture script.nodes/data.py: constants, unit stats, map layout, starting line-up.nodes/textures.py: procedural numpy sprites.nodes/tile_map.py: iso TileMap wrapper + terrain grid.nodes/unit.py: Unit (Sprite2D) with move animation.nodes/building.py: Building (HQ / Barracks / Factory / Airport).nodes/cursor.py: hover cursor + range/path overlays.nodes/pathfinder.py: BFS path + AP flood fill.nodes/combat.py: pure-function attack resolution.nodes/turn.py: TurnManager state machine.nodes/ai.py: red-side heuristic AI.nodes/hud.py: top bar + side panel + buttons.nodes/world.py: TanksWorld; ties everything together.
Source files¶
File |
Summary |
Lines |
|---|---|---|
Tanks of Freedom: Isometric turn-based strategy, three units, four buildings, AI opponent. |
278 |
|
Scripted runtime harness: drives the world headless and verifies state. |
106 |
|
Tanks of Freedom port nodes. |
1 |
|
Simple turn-based AI for the red side. |
140 |
|
Building node: owns a procedural sprite + flag overlay. |
62 |
|
Pure-function combat resolution. |
48 |
|
Hover cursor + selection / range overlays. |
95 |
|
Game constants and data tables for Tanks of Freedom. |
207 |
|
Short-lived visual effects: capture flash, attack flash, explosions. |
108 |
|
HUD: top status bar + right-side info panel + end-turn button. |
182 |
|
Pathfinding + flood-fill helpers. |
116 |
|
Procedural numpy textures for Tanks of Freedom. |
356 |
|
TankTileMap: wraps simvx TileMap(mode=”isometric”) with terrain layout. |
122 |
|
TurnManager: alternates blue / red turns, tracks game-over state. |
47 |
|
Unit nodes: Sprite2D wrapper with stats, AP and per-turn flags. |
167 |
|
TanksWorld: top-level game scene. |
421 |
Source¶
1# /// simvx
2# tags = ["port", "tier-2"]
3# upstream = "https://github.com/w84death/Tanks-of-Freedom"
4# web = { width = 1280, height = 720, responsive = true }
5# ///
6"""Tanks of Freedom: Isometric turn-based strategy, three units, four buildings, AI opponent.
7
8Port of P1X's open-source Godot game Tanks of Freedom: a 12x12 skirmish
9against a heuristic AI. Click a blue unit to select it, move inside its
10yellow AP range, attack an adjacent enemy on a red tile, and capture the
11red HQ with infantry (or destroy every red unit) to win.
12
13Exercises TileMap(mode="isometric") with Y-sorted unit and building
14children, procedural numpy sprite textures handed straight to Sprite2D,
15Signal-driven turn flow, BFS movement / attack-range overlays drawn from
16pooled sprites, and AudioPlayer for the upstream CC-BY-SA sound effects.
17
18Run:
19 simvx examples run ports_tanks_of_freedom # interactive
20 simvx examples run ports_tanks_of_freedom --test # headless screenshots
21 simvx export web main.py -o tanks_of_freedom.html
22"""
23
24from __future__ import annotations
25
26import sys
27from pathlib import Path
28
29_PORT_DIR = Path(__file__).parent
30
31from nodes.data import WINDOW_HEIGHT, WINDOW_WIDTH # noqa: E402
32from nodes.world import TanksWorld # noqa: E402
33
34from simvx.core import Input, Node2D # noqa: E402
35from simvx.core.input.controls import Key, Mouse
36from simvx.core.input.map import InputMap # noqa: E402
37from simvx.core.ui.widgets import Label # noqa: E402
38from simvx.graphics import App # noqa: E402
39
40ASSET_DIR = _PORT_DIR / "assets"
41
42
43# ---------------------------------------------------------------------- menu
44class _Menu(Node2D):
45 """Splash screen: press Enter / click to begin."""
46
47 def __init__(self, root, **kwargs):
48 super().__init__(**kwargs)
49 self._root = root
50 self._blink = 0.0
51
52 title = Label("TANKS OF FREEDOM")
53 title.position = (WINDOW_WIDTH / 2 - 220, 130)
54 title.size = (440, 60)
55 title.font_size = 48.0
56 title.text_colour = (1.0, 0.95, 0.6, 1.0)
57 title.alignment = "centre"
58 self.add_child(title)
59
60 sub = Label("SimVX port: turn-based isometric strategy")
61 sub.position = (WINDOW_WIDTH / 2 - 260, 200)
62 sub.size = (520, 24)
63 sub.font_size = 18.0
64 sub.text_colour = (0.85, 0.88, 0.92, 1.0)
65 sub.alignment = "centre"
66 self.add_child(sub)
67
68 # How-to lines
69 lines = [
70 "Click a unit to select.",
71 "Yellow = move range. Red = attack range.",
72 "Click a highlighted tile to commit.",
73 "Capture the enemy HQ or destroy all enemy units.",
74 "End-Turn button or Enter to pass the turn.",
75 ]
76 for i, line in enumerate(lines):
77 lbl = Label(line)
78 lbl.position = (WINDOW_WIDTH / 2 - 260, 280 + i * 28)
79 lbl.size = (520, 24)
80 lbl.font_size = 16.0
81 lbl.text_colour = (0.80, 0.85, 0.92, 1.0)
82 lbl.alignment = "centre"
83 self.add_child(lbl)
84
85 prompt = Label("PRESS ENTER OR CLICK TO START")
86 prompt.position = (WINDOW_WIDTH / 2 - 220, WINDOW_HEIGHT - 140)
87 prompt.size = (440, 32)
88 prompt.font_size = 20.0
89 prompt.text_colour = (1.0, 0.95, 0.7, 1.0)
90 prompt.alignment = "centre"
91 self.add_child(prompt)
92 self._prompt = prompt
93
94 credit = Label("Upstream MIT/CC-BY-SA, w84death/Tanks-of-Freedom")
95 credit.position = (WINDOW_WIDTH / 2 - 230, WINDOW_HEIGHT - 60)
96 credit.size = (460, 22)
97 credit.font_size = 14.0
98 credit.text_colour = (0.70, 0.74, 0.80, 1.0)
99 credit.alignment = "centre"
100 self.add_child(credit)
101
102 def on_update(self, dt: float) -> None:
103 self._blink += dt
104 on = int(self._blink * 2) % 2 == 0
105 self._prompt.text_colour = (1.0, 0.95, 0.7, 1.0 if on else 0.4)
106 if Input.just_pressed("menu_start") or Input.just_pressed("primary"):
107 self._root.start_game()
108 if Input.just_pressed("menu_quit"):
109 self.app.quit()
110
111
112# ---------------------------------------------------------------------- root
113class TanksRoot(Node2D):
114 """Top-level swap: menu -> world -> menu (on restart / game over)."""
115
116 def __init__(self, *, autostart: bool = False, **kwargs):
117 super().__init__(**kwargs)
118 self._world: TanksWorld | None = None
119 self._menu: _Menu | None = None
120 self._autostart = autostart
121
122 def on_ready(self) -> None:
123 # All InputMap actions live in root.on_ready: a headless --test run resets
124 # input state, so anything registered before it is gone.
125 InputMap.add_action("menu_start", [Key.ENTER, Key.SPACE])
126 InputMap.add_action("menu_quit", [Key.ESCAPE, Key.Q])
127 InputMap.add_action("primary", [Mouse.LEFT])
128 InputMap.add_action("end_turn", [Key.ENTER, Key.SPACE])
129 InputMap.add_action("cancel", [Key.ESCAPE])
130
131 if self._autostart:
132 self.start_game()
133 else:
134 self._show_menu()
135
136 def _show_menu(self) -> None:
137 for c in list(self.children):
138 c.destroy()
139 self._world = None
140 self._menu = self.add_child(_Menu(self))
141
142 def start_game(self) -> None:
143 for c in list(self.children):
144 c.destroy()
145 self._menu = None
146 self._world = self.add_child(TanksWorld(asset_dir=ASSET_DIR))
147 self._world.game_over.connect(self._on_game_over)
148
149 def _on_game_over(self, winner: int) -> None:
150 # Stay on the world; restart button (in HUD) takes us back to a fresh world.
151 if winner == -1:
152 # Explicit restart request.
153 self.start_game()
154
155 def on_draw(self, renderer) -> None:
156 # Dark slate background drawn once per frame; child draws on top.
157 renderer.rect((0, 0), (WINDOW_WIDTH, WINDOW_HEIGHT), fill=(0.10, 0.13, 0.18, 1.0))
158
159
160# -------------------------------------------------------------------- entry
161def _drive_test_input(root, frame_idx: int) -> None:
162 """Scripted input for headless --test capture.
163
164 Frame events:
165 F30 : hover over the blue infantry at (2, 10), neutral hover state
166 F55 : click it to select (move-range overlay shows on F60)
167 F90 : hover at (3, 8) so planned path renders
168 F130 : click to commit move (mid-step on F145)
169 F200 : Enter, end blue's turn, AI starts thinking
170 F260 : hover mid-map to keep the cursor visible during the AI turn
171 F360 : Enter again, end red's turn
172 F420 : select the blue helicopter at (4, 9)
173 F470 : hover the neutral factory at (6, 6)
174 F510 : click it to send the helicopter forward
175 F560 : Enter, end blue's second turn
176 """
177 from simvx.core.testing import InputSimulator
178
179 sim = InputSimulator(root.tree)
180 world = getattr(root, "_world", None)
181 if world is None:
182 return
183 tm = world.tile_map
184
185 def world_xy(cell):
186 wx, wy = tm.map_to_world(cell)
187 return (tm.position.x + wx, tm.position.y + wy)
188
189 # Step events
190 if frame_idx == 30:
191 sim.move_mouse(*world_xy((2, 10)))
192 elif frame_idx == 55:
193 sim.move_mouse(*world_xy((2, 10)))
194 sim.click(world_xy((2, 10)), button=0)
195 elif frame_idx == 90:
196 sim.move_mouse(*world_xy((3, 8)))
197 elif frame_idx == 130:
198 sim.move_mouse(*world_xy((3, 8)))
199 sim.click(world_xy((3, 8)), button=0)
200 elif frame_idx == 200:
201 sim.tap_key(Key.ENTER)
202 elif frame_idx == 260:
203 sim.move_mouse(*world_xy((6, 6)))
204 elif frame_idx == 360:
205 sim.tap_key(Key.ENTER)
206 elif frame_idx == 420:
207 # Pick another blue unit (helicopter at (4,9)), probably moved
208 sim.click(world_xy((4, 9)), button=0)
209 elif frame_idx == 470:
210 sim.move_mouse(*world_xy((6, 6)))
211 elif frame_idx == 510:
212 # Move heli far forward toward neutral building
213 sim.click(world_xy((6, 6)), button=0)
214 elif frame_idx == 560:
215 sim.tap_key(Key.ENTER)
216
217
218def _save_frames(out_dir: Path, capture_at, frames, prefix: str = "frame") -> None:
219 from simvx.graphics import save_png
220
221 out_dir.mkdir(exist_ok=True)
222 for idx, img in zip(capture_at, frames, strict=False):
223 # Force alpha to 255: the framebuffer leaves alpha=0 in regions
224 # that were never explicitly written, which PNG viewers
225 # composite onto white and display as washed-out.
226 img = img.copy()
227 img[:, :, 3] = 255
228 out_path = out_dir / f"{prefix}_{idx}.png"
229 save_png(img, out_path)
230 print(f"saved {out_path}")
231
232
233def main() -> None:
234 headless = "--test" in sys.argv
235 if headless:
236 out_dir = _PORT_DIR / "screenshots"
237
238 # Pass 1: menu screen (no auto-start).
239 menu_capture = [20]
240 app = App(
241 width=WINDOW_WIDTH,
242 height=WINDOW_HEIGHT,
243 title="Tanks of Freedom (SimVX)",
244 visible=False,
245 bg_colour=(0.10, 0.13, 0.18, 1.0),
246 )
247 menu_frames = app.run_headless(TanksRoot(autostart=False), frames=30, capture_frames=menu_capture)
248 _save_frames(out_dir, menu_capture, menu_frames, prefix="menu")
249
250 # Pass 2: gameplay scripted: select, move, end turn, AI move.
251 capture_at = [10, 30, 60, 100, 145, 220, 320, 425, 480, 540, 620]
252 app = App(
253 width=WINDOW_WIDTH,
254 height=WINDOW_HEIGHT,
255 title="Tanks of Freedom (SimVX)",
256 visible=False,
257 bg_colour=(0.10, 0.13, 0.18, 1.0),
258 )
259 root = TanksRoot(autostart=True)
260 frames = app.run_headless(
261 root,
262 frames=650,
263 capture_frames=capture_at,
264 on_frame=lambda idx, t: _drive_test_input(root, idx),
265 )
266 _save_frames(out_dir, capture_at, frames, prefix="frame")
267 else:
268 app = App(
269 width=WINDOW_WIDTH,
270 height=WINDOW_HEIGHT,
271 title="Tanks of Freedom (SimVX)",
272 bg_colour=(0.10, 0.13, 0.18, 1.0),
273 )
274 app.run(TanksRoot())
275
276
277if __name__ == "__main__":
278 main()