Tanks of Freedom

Isometric turn-based strategy, three units, four buildings, AI opponent.

▶ Run in browser

Upstream: 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()