Tower Defence

Waves, placement, currency UI, navigation curves.

▶ Run in browser

Upstream: https://github.com/russs123/tower_defence_tut

Tags: port tier-1

Tower Defence (SimVX port)

SimVX port of russs123/tower_defence_tut (YouTube tutorial source, MIT-style permissive). Wave-based tower defence on a fixed map with a hand-authored enemy path; ports the upstream’s animated basic turret + four upgrade tiers and adds slow and sniper turret variants for the Tier-1 multi-tower-type acceptance bar.

Run

All commands from the SimVX repo root (uv workspace requirement):

# Interactive
uv run python ported_games/tower_defence_tut/simvx_port/main.py

# Headless smoke test (frame_30/60/120 captures)
uv run python ported_games/tower_defence_tut/simvx_port/main.py --test

# Scripted harness (10 stage screenshots covering 3+ waves)
uv run python ported_games/tower_defence_tut/simvx_port/harness.py

# Web export
uv run simvx export web ported_games/tower_defence_tut/simvx_port/main.py \
    -o ported_games/tower_defence_tut/simvx_port/web/index.html

Controls

Action

Keyboard

Mouse

Start (menu)

Enter / Space

Click anywhere

Place BASIC turret

1 / B

“BUY BASIC” panel button

Place SLOW turret

2

Cycle TYPE then “BUY”

Place SNIPER turret

3

Cycle TYPE then “BUY”

Cancel placement

Esc

“CANCEL” panel button

Begin wave

Enter / Space

“BEGIN WAVE” panel button

Fast-forward x2

F

“FAST x2” panel button

Upgrade selected turret

U

“UPGRADE -> Lx (100c)” panel button

Restart

R

“RESTART” panel button

Quit

Q / Esc (menu)

-

Touch / mobile

Tower placement is a single tap on a grass tile – the SimVX web runtime surfaces touchstart/end as MouseButton.LEFT, so the browser export is playable on mobile without code changes.

Tower types

Type

Cost

Range (L1)

DPS (L1)

Notes

Basic

200

120

~5.5

Animated turret from upstream tutorial

Slow

250

70

~2.5

Applies a 0.5x slow debuff for 1 s on hit

Sniper

350

200

~5.8

Long range, slow rate, big single-shot damage

All three types support 4-tier upgrades.

Source

  1"""Tower Defence: Waves, placement, currency UI, navigation curves.
  2
  3# /// simvx
  4# tags = ["port", "tier-1"]
  5# upstream = "https://github.com/russs123/tower_defence_tut"
  6# web = { width = 1280, height = 720, responsive = true }
  7# ///
  8
  9Run:
 10    uv run python ported_games/tower_defence_tut/simvx_port/main.py
 11    uv run python ported_games/tower_defence_tut/simvx_port/main.py --test     # headless capture
 12    uv run python ported_games/tower_defence_tut/simvx_port/harness.py         # scripted run
 13    uv run simvx export web ported_games/tower_defence_tut/simvx_port/main.py         -o ported_games/tower_defence_tut/simvx_port/web/index.html
 14"""
 15
 16from __future__ import annotations
 17
 18import sys
 19from pathlib import Path
 20
 21_PORT_DIR = Path(__file__).parent
 22if str(_PORT_DIR) not in sys.path:
 23    sys.path.insert(0, str(_PORT_DIR))
 24
 25from nodes.td_world import TowerDefenceWorld  # noqa: E402
 26
 27from simvx.core import (  # noqa: E402
 28    Input,
 29    InputMap,
 30    Key,
 31    MouseButton,
 32    Node2D,
 33    Sprite2D,
 34    Text2D,
 35    Vec2,
 36)
 37from simvx.graphics import App  # noqa: E402
 38
 39from nodes.td_data import (  # noqa: E402
 40    SCREEN_HEIGHT,
 41    SCREEN_WIDTH,
 42    SIDE_PANEL,
 43    WINDOW_HEIGHT,
 44    WINDOW_WIDTH,
 45)
 46
 47
 48# ---------------------------------------------------------------------------
 49# Menu / Root wrapper
 50# ---------------------------------------------------------------------------
 51
 52
 53class _Menu(Node2D):
 54    """Simple menu screen -- press Enter to begin, Esc to quit."""
 55
 56    def __init__(self, root: TowerDefenceRoot, **kwargs):
 57        super().__init__(**kwargs)
 58        self._root = root
 59        self._blink = 0.0
 60
 61        # Logo background -- the tutorial ships a logo PNG
 62        self.add_child(
 63            Sprite2D(
 64                texture=str(_PORT_DIR / "assets" / "images" / "gui" / "logo.png"),
 65                position=Vec2(WINDOW_WIDTH / 2, WINDOW_HEIGHT / 2 - 40),
 66                width=300, height=320,
 67            )
 68        )
 69        self.add_child(
 70            Text2D(
 71                text="TOWER DEFENCE",
 72                x=WINDOW_WIDTH / 2 - 110, y=80,
 73                font_scale=2.4,
 74                colour=(1.0, 1.0, 1.0, 1.0),
 75            )
 76        )
 77        self.add_child(
 78            Text2D(
 79                text="SimVX port of russs123/tower_defence_tut",
 80                x=WINDOW_WIDTH / 2 - 200, y=130,
 81                font_scale=1.0,
 82                colour=(0.8, 0.85, 0.9, 1.0),
 83            )
 84        )
 85        self._prompt = self.add_child(
 86            Text2D(
 87                text="PRESS ENTER OR CLICK TO START",
 88                x=WINDOW_WIDTH / 2 - 195, y=WINDOW_HEIGHT - 130,
 89                font_scale=1.3,
 90                colour=(1.0, 0.95, 0.7, 1.0),
 91            )
 92        )
 93        self.add_child(
 94            Text2D(
 95                text="Controls: 1 / 2 / 3 = type select  -  U upgrade  -  R restart  -  Q quit",
 96                x=WINDOW_WIDTH / 2 - 295, y=WINDOW_HEIGHT - 70,
 97                font_scale=0.9,
 98                colour=(0.75, 0.78, 0.82, 1.0),
 99            )
100        )
101
102    def on_process(self, dt: float) -> None:
103        self._blink += dt
104        self._prompt.colour = (
105            1.0, 0.95, 0.7, 0.55 + 0.45 * (0.5 + 0.5 * (1.0 if int(self._blink * 2) % 2 == 0 else -1.0))
106        )
107        if (
108            Input.is_action_just_pressed("menu_start")
109            or Input.is_mouse_button_just_pressed(MouseButton.LEFT)
110        ):
111            self._root.start_game()
112        if Input.is_action_just_pressed("menu_quit"):
113            self.app.quit()
114
115
116class TowerDefenceRoot(Node2D):
117    """Top-level scene swap: menu -> game -> menu (on restart key)."""
118
119    def __init__(self, **kwargs):
120        super().__init__(**kwargs)
121        self._menu: _Menu | None = None
122        self._world: TowerDefenceWorld | None = None
123        self._show_menu = True
124
125    def on_ready(self) -> None:
126        # ALL InputMap actions live on the root, registered once. Re-entering
127        # the menu and re-starting the world is therefore safe: no double
128        # registration and no actions lost on world destroy.
129        InputMap.add_action("menu_start", [Key.ENTER, Key.SPACE])
130        InputMap.add_action("menu_quit", [Key.ESCAPE, Key.Q])
131        InputMap.add_action("primary", [MouseButton.LEFT])
132        InputMap.add_action("begin_wave", [Key.ENTER, Key.SPACE])
133        InputMap.add_action("fast_forward", [Key.F])
134        InputMap.add_action("place_basic", [Key.KEY_1, Key.B])
135        InputMap.add_action("place_slow", [Key.KEY_2])
136        InputMap.add_action("place_sniper", [Key.KEY_3])
137        InputMap.add_action("cancel_place", [Key.ESCAPE])
138        InputMap.add_action("upgrade", [Key.U])
139        InputMap.add_action("restart", [Key.R])
140        InputMap.add_action("quit", [Key.Q])
141        self._show_menu_screen()
142
143    def _show_menu_screen(self) -> None:
144        for c in list(self.children):
145            c.destroy()
146        self._world = None
147        self._menu = self.add_child(_Menu(self))
148
149    def start_game(self) -> None:
150        for c in list(self.children):
151            c.destroy()
152        self._menu = None
153        self._world = self.add_child(TowerDefenceWorld())
154        self._world.game_lost.connect(self._on_game_end)
155        self._world.game_won.connect(self._on_game_end)
156
157    def _on_game_end(self) -> None:
158        # Stay on the world scene -- restart button cycles state in-place.
159        pass
160
161
162# ---------------------------------------------------------------------------
163# Entry
164# ---------------------------------------------------------------------------
165
166
167def main() -> None:
168    headless = "--test" in sys.argv
169    if headless:
170        from simvx.graphics import save_png
171
172        capture_at = [30, 60, 120]
173        app = App(
174            width=WINDOW_WIDTH, height=WINDOW_HEIGHT,
175            title="Tower Defence (SimVX)", visible=False,
176        )
177        frames = app.run_headless(
178            TowerDefenceRoot(), frames=130, capture_frames=capture_at,
179        )
180        out_dir = _PORT_DIR / "screenshots"
181        out_dir.mkdir(exist_ok=True)
182        for idx, img in zip(capture_at, frames, strict=False):
183            out_path = out_dir / f"frame_{idx}.png"
184            save_png(out_path, img)
185            print(f"saved {out_path}")
186    else:
187        app = App(
188            width=WINDOW_WIDTH, height=WINDOW_HEIGHT,
189            title="Tower Defence (SimVX)",
190        )
191        app.run(TowerDefenceRoot())
192
193
194if __name__ == "__main__":
195    main()