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¶
# from the repo root
uv run python examples/ports/tanks_of_freedom/main.py # interactive
uv run python examples/ports/tanks_of_freedom/main.py --test # headless screenshots
uv run python examples/ports/tanks_of_freedom/harness.py # logic checks
uv run simvx export web examples/ports/tanks_of_freedom/main.py \
-o /tmp/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, so the web export (which never callsmain()) 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. |
281 |
|
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. |
164 |
|
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. |
420 |
Source¶
1"""Tanks of Freedom: Isometric turn-based strategy, three units, four buildings, AI opponent.
2
3# /// simvx
4# tags = ["port", "tier-2"]
5# upstream = "https://github.com/w84death/Tanks-of-Freedom"
6# web = { width = 1280, height = 720, responsive = true }
7# ///
8
9Port of P1X's open-source Godot game Tanks of Freedom: a 12x12 skirmish
10against a heuristic AI. Click a blue unit to select it, move inside its
11yellow AP range, attack an adjacent enemy on a red tile, and capture the
12red HQ with infantry (or destroy every red unit) to win.
13
14Exercises TileMap(mode="isometric") with Y-sorted unit and building
15children, procedural numpy sprite textures handed straight to Sprite2D,
16Signal-driven turn flow, BFS movement / attack-range overlays drawn from
17pooled sprites, and AudioPlayer for the upstream CC-BY-SA sound effects.
18
19Run:
20 uv run python examples/ports/tanks_of_freedom/main.py # interactive
21 uv run python examples/ports/tanks_of_freedom/main.py --test # headless screenshots
22 uv run python examples/ports/tanks_of_freedom/harness.py # logic checks
23 uv run simvx export web examples/ports/tanks_of_freedom/main.py -o /tmp/tanks_of_freedom.html
24"""
25
26from __future__ import annotations
27
28import sys
29from pathlib import Path
30
31_PORT_DIR = Path(__file__).parent
32if str(_PORT_DIR) not in sys.path:
33 sys.path.insert(0, str(_PORT_DIR))
34
35from nodes.data import WINDOW_HEIGHT, WINDOW_WIDTH # noqa: E402
36from nodes.world import TanksWorld # noqa: E402
37
38from simvx.core import Input, Node2D # noqa: E402
39from simvx.core.input.enums import Key, MouseButton # noqa: E402
40from simvx.core.input.map import InputMap # noqa: E402
41from simvx.core.ui.widgets import Label # noqa: E402
42from simvx.graphics import App # noqa: E402
43
44ASSET_DIR = _PORT_DIR / "assets"
45
46
47# ---------------------------------------------------------------------- menu
48class _Menu(Node2D):
49 """Splash screen: press Enter / click to begin."""
50
51 def __init__(self, root, **kwargs):
52 super().__init__(**kwargs)
53 self._root = root
54 self._blink = 0.0
55
56 title = Label("TANKS OF FREEDOM")
57 title.position = (WINDOW_WIDTH / 2 - 220, 130)
58 title.size = (440, 60)
59 title.font_size = 48.0
60 title.text_colour = (1.0, 0.95, 0.6, 1.0)
61 title.alignment = "center"
62 self.add_child(title)
63
64 sub = Label("SimVX port: turn-based isometric strategy")
65 sub.position = (WINDOW_WIDTH / 2 - 260, 200)
66 sub.size = (520, 24)
67 sub.font_size = 18.0
68 sub.text_colour = (0.85, 0.88, 0.92, 1.0)
69 sub.alignment = "center"
70 self.add_child(sub)
71
72 # How-to lines
73 lines = [
74 "Click a unit to select.",
75 "Yellow = move range. Red = attack range.",
76 "Click a highlighted tile to commit.",
77 "Capture the enemy HQ or destroy all enemy units.",
78 "End-Turn button or Enter to pass the turn.",
79 ]
80 for i, line in enumerate(lines):
81 lbl = Label(line)
82 lbl.position = (WINDOW_WIDTH / 2 - 260, 280 + i * 28)
83 lbl.size = (520, 24)
84 lbl.font_size = 16.0
85 lbl.text_colour = (0.80, 0.85, 0.92, 1.0)
86 lbl.alignment = "center"
87 self.add_child(lbl)
88
89 prompt = Label("PRESS ENTER OR CLICK TO START")
90 prompt.position = (WINDOW_WIDTH / 2 - 220, WINDOW_HEIGHT - 140)
91 prompt.size = (440, 32)
92 prompt.font_size = 20.0
93 prompt.text_colour = (1.0, 0.95, 0.7, 1.0)
94 prompt.alignment = "center"
95 self.add_child(prompt)
96 self._prompt = prompt
97
98 credit = Label("Upstream MIT/CC-BY-SA, w84death/Tanks-of-Freedom")
99 credit.position = (WINDOW_WIDTH / 2 - 230, WINDOW_HEIGHT - 60)
100 credit.size = (460, 22)
101 credit.font_size = 14.0
102 credit.text_colour = (0.70, 0.74, 0.80, 1.0)
103 credit.alignment = "center"
104 self.add_child(credit)
105
106 def on_update(self, dt: float) -> None:
107 self._blink += dt
108 on = int(self._blink * 2) % 2 == 0
109 self._prompt.text_colour = (1.0, 0.95, 0.7, 1.0 if on else 0.4)
110 if Input.is_action_just_pressed("menu_start") or Input.is_action_just_pressed("primary"):
111 self._root.start_game()
112 if Input.is_action_just_pressed("menu_quit"):
113 self.app.quit()
114
115
116# ---------------------------------------------------------------------- root
117class TanksRoot(Node2D):
118 """Top-level swap: menu -> world -> menu (on restart / game over)."""
119
120 def __init__(self, *, autostart: bool = False, **kwargs):
121 super().__init__(**kwargs)
122 self._world: TanksWorld | None = None
123 self._menu: _Menu | None = None
124 self._autostart = autostart
125
126 def on_ready(self) -> None:
127 # All InputMap actions live in root.on_ready (web exporter skips main).
128 InputMap.add_action("menu_start", [Key.ENTER, Key.SPACE])
129 InputMap.add_action("menu_quit", [Key.ESCAPE, Key.Q])
130 InputMap.add_action("primary", [MouseButton.LEFT])
131 InputMap.add_action("end_turn", [Key.ENTER, Key.SPACE])
132 InputMap.add_action("cancel", [Key.ESCAPE])
133
134 if self._autostart:
135 self.start_game()
136 else:
137 self._show_menu()
138
139 def _show_menu(self) -> None:
140 for c in list(self.children):
141 c.destroy()
142 self._world = None
143 self._menu = self.add_child(_Menu(self))
144
145 def start_game(self) -> None:
146 for c in list(self.children):
147 c.destroy()
148 self._menu = None
149 self._world = self.add_child(TanksWorld(asset_dir=ASSET_DIR))
150 self._world.game_over.connect(self._on_game_over)
151
152 def _on_game_over(self, winner: int) -> None:
153 # Stay on the world; restart button (in HUD) takes us back to a fresh world.
154 if winner == -1:
155 # Explicit restart request.
156 self.start_game()
157
158 def on_draw(self, renderer) -> None:
159 # Dark slate background drawn once per frame; child draws on top.
160 renderer.draw_rect((0, 0), (WINDOW_WIDTH, WINDOW_HEIGHT), colour=(0.10, 0.13, 0.18, 1.0), filled=True)
161
162
163# -------------------------------------------------------------------- entry
164def _drive_test_input(root, frame_idx: int) -> None:
165 """Scripted input for headless --test capture.
166
167 Frame events:
168 F30 : hover over the blue infantry at (2, 10), neutral hover state
169 F55 : click it to select (move-range overlay shows on F60)
170 F90 : hover at (3, 8) so planned path renders
171 F130 : click to commit move (mid-step on F145)
172 F200 : Enter, end blue's turn, AI starts thinking
173 F260 : hover mid-map to keep the cursor visible during the AI turn
174 F360 : Enter again, end red's turn
175 F420 : select the blue helicopter at (4, 9)
176 F470 : hover the neutral factory at (6, 6)
177 F510 : click it to send the helicopter forward
178 F560 : Enter, end blue's second turn
179 """
180 from simvx.core.testing import InputSimulator
181
182 sim = InputSimulator()
183 world = getattr(root, "_world", None)
184 if world is None:
185 return
186 tm = world.tile_map
187
188 def world_xy(cell):
189 wx, wy = tm.map_to_world(cell)
190 return (tm.position.x + wx, tm.position.y + wy)
191
192 # Step events
193 if frame_idx == 30:
194 sim.move_mouse(*world_xy((2, 10)))
195 elif frame_idx == 55:
196 sim.move_mouse(*world_xy((2, 10)))
197 sim.click(world_xy((2, 10)), button=0)
198 elif frame_idx == 90:
199 sim.move_mouse(*world_xy((3, 8)))
200 elif frame_idx == 130:
201 sim.move_mouse(*world_xy((3, 8)))
202 sim.click(world_xy((3, 8)), button=0)
203 elif frame_idx == 200:
204 sim.tap_key(Key.ENTER)
205 elif frame_idx == 260:
206 sim.move_mouse(*world_xy((6, 6)))
207 elif frame_idx == 360:
208 sim.tap_key(Key.ENTER)
209 elif frame_idx == 420:
210 # Pick another blue unit (helicopter at (4,9)), probably moved
211 sim.click(world_xy((4, 9)), button=0)
212 elif frame_idx == 470:
213 sim.move_mouse(*world_xy((6, 6)))
214 elif frame_idx == 510:
215 # Move heli far forward toward neutral building
216 sim.click(world_xy((6, 6)), button=0)
217 elif frame_idx == 560:
218 sim.tap_key(Key.ENTER)
219
220
221def _save_frames(out_dir: Path, capture_at, frames, prefix: str = "frame") -> None:
222 from simvx.graphics import save_png
223
224 out_dir.mkdir(exist_ok=True)
225 for idx, img in zip(capture_at, frames, strict=False):
226 # Force alpha to 255: the framebuffer leaves alpha=0 in regions
227 # that were never explicitly written, which PNG viewers
228 # composite onto white and display as washed-out.
229 img = img.copy()
230 img[:, :, 3] = 255
231 out_path = out_dir / f"{prefix}_{idx}.png"
232 save_png(img, out_path)
233 print(f"saved {out_path}")
234
235
236def main() -> None:
237 headless = "--test" in sys.argv
238 if headless:
239 out_dir = _PORT_DIR / "screenshots"
240
241 # Pass 1: menu screen (no auto-start).
242 menu_capture = [20]
243 app = App(
244 width=WINDOW_WIDTH,
245 height=WINDOW_HEIGHT,
246 title="Tanks of Freedom (SimVX)",
247 visible=False,
248 bg_colour=(0.10, 0.13, 0.18, 1.0),
249 )
250 menu_frames = app.run_headless(TanksRoot(autostart=False), frames=30, capture_frames=menu_capture)
251 _save_frames(out_dir, menu_capture, menu_frames, prefix="menu")
252
253 # Pass 2: gameplay scripted: select, move, end turn, AI move.
254 capture_at = [10, 30, 60, 100, 145, 220, 320, 425, 480, 540, 620]
255 app = App(
256 width=WINDOW_WIDTH,
257 height=WINDOW_HEIGHT,
258 title="Tanks of Freedom (SimVX)",
259 visible=False,
260 bg_colour=(0.10, 0.13, 0.18, 1.0),
261 )
262 root = TanksRoot(autostart=True)
263 frames = app.run_headless(
264 root,
265 frames=650,
266 capture_frames=capture_at,
267 on_frame=lambda idx, t: _drive_test_input(root, idx),
268 )
269 _save_frames(out_dir, capture_at, frames, prefix="frame")
270 else:
271 app = App(
272 width=WINDOW_WIDTH,
273 height=WINDOW_HEIGHT,
274 title="Tanks of Freedom (SimVX)",
275 bg_colour=(0.10, 0.13, 0.18, 1.0),
276 )
277 app.run(TanksRoot())
278
279
280if __name__ == "__main__":
281 main()