Tanks of Freedom¶
Isometric turn-based strategy, three units, four buildings, AI opponent.
▶ Run in browserUpstream: https://github.com/w84death/Tanks-of-Freedom
Tags: port tier-2
Tanks of Freedom: SimVX port¶
Turn-based isometric strategy. Single 12x12 skirmish vs a simple AI.
First Tier 2 consumer of the engine’s TileMap(mode="isometric") mode.
Run¶
# Interactive (default: opens menu)
uv run python ported_games/tanks_of_freedom/simvx_port/main.py
# Headless screenshots
uv run python ported_games/tanks_of_freedom/simvx_port/main.py --test
# Logic-level harness
uv run python ported_games/tanks_of_freedom/simvx_port/harness.py
# Web export
uv run simvx export web ported_games/tanks_of_freedom/simvx_port/main.py \
-o ported_games/tanks_of_freedom/simvx_port/web/index.html
Controls¶
Click a blue unit to select.
Yellow tiles = move range. Red tiles = attack range. Yellow dots = planned path.
Click a highlighted tile to commit.
End Turn button (top right) or Enter to pass turn.
Esc / Q from the menu quits.
Win condition¶
Capture the enemy HQ (with infantry) or destroy all enemy units.
Upstream¶
w84death/Tanks-of-Freedom: Godot 2.1, MIT code/gfx + CC-BY-SA 4.0 audio.
See UPSTREAM_LICENSE.md for full license.
Layout¶
main.py: App entry, menu/world swap.nodes/data.py: constants, stats, map layout.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 / flood fill.nodes/combat.py: pure-function attack resolution.nodes/turn.py: TurnManager state machine.nodes/ai.py: simple AI heuristic.nodes/hud.py: top bar + side panel + buttons.nodes/world.py: TanksWorld; ties everything together.
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
9Run:
10 uv run python ported_games/tanks_of_freedom/simvx_port/main.py
11 uv run python ported_games/tanks_of_freedom/simvx_port/main.py --test
12 uv run python ported_games/tanks_of_freedom/simvx_port/harness.py
13 uv run simvx export web ported_games/tanks_of_freedom/simvx_port/main.py -o ported_games/tanks_of_freedom/simvx_port/web/index.html
14"""
15from __future__ import annotations
16
17import sys
18from pathlib import Path
19
20_PORT_DIR = Path(__file__).parent
21if str(_PORT_DIR) not in sys.path:
22 sys.path.insert(0, str(_PORT_DIR))
23
24from nodes.data import ( # noqa: E402
25 PLAYER_NAME,
26 WINDOW_HEIGHT,
27 WINDOW_WIDTH,
28)
29from nodes.world import TanksWorld # noqa: E402
30
31from simvx.core import Input, Node2D, Signal # noqa: E402
32from simvx.core.input.enums import Key, MouseButton # noqa: E402
33from simvx.core.input.map import InputMap # noqa: E402
34from simvx.core.ui.widgets import Label # noqa: E402
35from simvx.graphics import App # noqa: E402
36
37ASSET_DIR = _PORT_DIR / "assets"
38
39
40# ---------------------------------------------------------------------- menu
41class _Menu(Node2D):
42 """Splash screen: press Enter / click to begin."""
43
44 def __init__(self, root, **kwargs):
45 super().__init__(**kwargs)
46 self._root = root
47 self._blink = 0.0
48
49 title = Label("TANKS OF FREEDOM")
50 title.position = (WINDOW_WIDTH / 2 - 220, 130)
51 title.size = (440, 60)
52 title.font_size = 48.0
53 title.text_colour = (1.0, 0.95, 0.6, 1.0)
54 title.alignment = "center"
55 self.add_child(title)
56
57 sub = Label("SimVX port: turn-based isometric strategy")
58 sub.position = (WINDOW_WIDTH / 2 - 260, 200)
59 sub.size = (520, 24)
60 sub.font_size = 18.0
61 sub.text_colour = (0.85, 0.88, 0.92, 1.0)
62 sub.alignment = "center"
63 self.add_child(sub)
64
65 # How-to lines
66 lines = [
67 "Click a unit to select.",
68 "Yellow = move range. Red = attack range.",
69 "Click a highlighted tile to commit.",
70 "Capture the enemy HQ or destroy all enemy units.",
71 "End-Turn button or Enter to pass the turn.",
72 ]
73 for i, line in enumerate(lines):
74 lbl = Label(line)
75 lbl.position = (WINDOW_WIDTH / 2 - 260, 280 + i * 28)
76 lbl.size = (520, 24)
77 lbl.font_size = 16.0
78 lbl.text_colour = (0.80, 0.85, 0.92, 1.0)
79 lbl.alignment = "center"
80 self.add_child(lbl)
81
82 prompt = Label("PRESS ENTER OR CLICK TO START")
83 prompt.position = (WINDOW_WIDTH / 2 - 220, WINDOW_HEIGHT - 140)
84 prompt.size = (440, 32)
85 prompt.font_size = 20.0
86 prompt.text_colour = (1.0, 0.95, 0.7, 1.0)
87 prompt.alignment = "center"
88 self.add_child(prompt)
89 self._prompt = prompt
90
91 credit = Label("Upstream MIT/CC-BY-SA, w84death/Tanks-of-Freedom")
92 credit.position = (WINDOW_WIDTH / 2 - 230, WINDOW_HEIGHT - 60)
93 credit.size = (460, 22)
94 credit.font_size = 14.0
95 credit.text_colour = (0.70, 0.74, 0.80, 1.0)
96 credit.alignment = "center"
97 self.add_child(credit)
98
99 def on_process(self, dt: float) -> None:
100 self._blink += dt
101 on = (int(self._blink * 2) % 2 == 0)
102 self._prompt.text_colour = (1.0, 0.95, 0.7, 1.0 if on else 0.4)
103 if (
104 Input.is_action_just_pressed("menu_start")
105 or Input.is_mouse_button_just_pressed(MouseButton.LEFT)
106 ):
107 self._root.start_game()
108 if Input.is_action_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 (web exporter skips main).
124 InputMap.add_action("menu_start", [Key.ENTER, Key.SPACE])
125 InputMap.add_action("menu_quit", [Key.ESCAPE, Key.Q])
126 InputMap.add_action("primary", [MouseButton.LEFT])
127 InputMap.add_action("end_turn", [Key.ENTER, Key.SPACE])
128 InputMap.add_action("cancel", [Key.ESCAPE])
129
130 if self._autostart:
131 self.start_game()
132 else:
133 self._show_menu()
134
135 def _show_menu(self) -> None:
136 for c in list(self.children):
137 c.destroy()
138 self._world = None
139 self._menu = self.add_child(_Menu(self))
140
141 def start_game(self) -> None:
142 for c in list(self.children):
143 c.destroy()
144 self._menu = None
145 self._world = self.add_child(TanksWorld(asset_dir=ASSET_DIR))
146 self._world.game_over.connect(self._on_game_over)
147
148 def _on_game_over(self, winner: int) -> None:
149 # Stay on the world; restart button (in HUD) takes us back to a fresh world.
150 if winner == -1:
151 # Explicit restart request.
152 self.start_game()
153
154 def on_draw(self, renderer) -> None:
155 # Dark slate background drawn once per frame; child draws on top.
156 renderer.draw_rect((0, 0), (WINDOW_WIDTH, WINDOW_HEIGHT),
157 colour=(0.10, 0.13, 0.18, 1.0), filled=True)
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 near a unit to keep cursor visible
171 F360 : Enter again, end red's turn
172 F420 : late-state for final capture
173 """
174 from simvx.core.input.enums import Key
175 from simvx.core.testing import InputSimulator
176 sim = InputSimulator()
177 world = getattr(root, "_world", None)
178 if world is None:
179 return
180 tm = world.tile_map
181
182 def world_xy(cell):
183 wx, wy = tm.map_to_world(cell)
184 return (tm.position.x + wx, tm.position.y + wy)
185
186 # Step events
187 if frame_idx == 30:
188 sim.move_mouse(*world_xy((2, 10)))
189 elif frame_idx == 55:
190 sim.move_mouse(*world_xy((2, 10)))
191 sim.click(world_xy((2, 10)), button=0)
192 elif frame_idx == 90:
193 sim.move_mouse(*world_xy((3, 8)))
194 elif frame_idx == 130:
195 sim.move_mouse(*world_xy((3, 8)))
196 sim.click(world_xy((3, 8)), button=0)
197 elif frame_idx == 200:
198 sim.tap_key(Key.ENTER)
199 elif frame_idx == 260:
200 sim.move_mouse(*world_xy((6, 6)))
201 elif frame_idx == 360:
202 sim.tap_key(Key.ENTER)
203 elif frame_idx == 420:
204 # Pick another blue unit (helicopter at (4,9)), probably moved
205 sim.click(world_xy((4, 9)), button=0)
206 elif frame_idx == 470:
207 sim.move_mouse(*world_xy((6, 6)))
208 elif frame_idx == 510:
209 # Move heli far forward toward neutral building
210 sim.click(world_xy((6, 6)), button=0)
211 elif frame_idx == 560:
212 sim.tap_key(Key.ENTER)
213 elif frame_idx == 260:
214 sim.move_mouse(*world_xy((6, 6)))
215
216
217def _save_frames(out_dir: Path, capture_at, frames, prefix: str = "frame") -> None:
218 from simvx.graphics import save_png
219 out_dir.mkdir(exist_ok=True)
220 for idx, img in zip(capture_at, frames, strict=False):
221 # Force alpha to 255: the framebuffer leaves alpha=0 in regions
222 # that were never explicitly written, which PNG viewers
223 # composite onto white and display as washed-out.
224 img = img.copy()
225 img[:, :, 3] = 255
226 out_path = out_dir / f"{prefix}_{idx}.png"
227 save_png(out_path, img)
228 print(f"saved {out_path}")
229
230
231def main() -> None:
232 headless = "--test" in sys.argv
233 if headless:
234 out_dir = _PORT_DIR / "screenshots"
235
236 # Pass 1: menu screen (no auto-start).
237 menu_capture = [20]
238 app = App(width=WINDOW_WIDTH, height=WINDOW_HEIGHT,
239 title="Tanks of Freedom (SimVX)", visible=False,
240 bg_colour=(0.10, 0.13, 0.18, 1.0))
241 menu_frames = app.run_headless(TanksRoot(autostart=False), frames=30,
242 capture_frames=menu_capture)
243 _save_frames(out_dir, menu_capture, menu_frames, prefix="menu")
244
245 # Pass 2: gameplay scripted: select, move, end turn, AI move.
246 capture_at = [10, 30, 60, 100, 145, 220, 320, 425, 480, 540, 620]
247 app = App(width=WINDOW_WIDTH, height=WINDOW_HEIGHT,
248 title="Tanks of Freedom (SimVX)", visible=False,
249 bg_colour=(0.10, 0.13, 0.18, 1.0))
250 root = TanksRoot(autostart=True)
251 frames = app.run_headless(
252 root,
253 frames=650,
254 capture_frames=capture_at,
255 on_frame=lambda idx, t: _drive_test_input(root, idx),
256 )
257 _save_frames(out_dir, capture_at, frames, prefix="frame")
258 else:
259 app = App(width=WINDOW_WIDTH, height=WINDOW_HEIGHT,
260 title="Tanks of Freedom (SimVX)",
261 bg_colour=(0.10, 0.13, 0.18, 1.0))
262 app.run(TanksRoot())
263
264
265if __name__ == "__main__":
266 main()