bouncy fpga game
www.youtube.com/watch?v=IiLWF3GbV7w
1#!/usr/bin/env python3
2"""
3Level Builder for VHDL Bouncy Game
4World: 1500x1500 px | VGA viewport: 640x480
5
6Controls:
7 Left-drag draw new platform (snapped to grid)
8 Right-click delete platform under cursor
9 Click select platform (shown in orange)
10 Del/Bksp delete selected
11 Middle-drag pan view
12 Scroll zoom
13 S toggle grid snap
14 E export JSON + VHDL package
15 L load level.json
16 Q / close quit
17
18Export writes two files:
19 <name>.json – reloadable level data
20 <name>_pkg.vhd – VHDL package (use work.<name>_pkg.all)
21
22Prerequisites: pip install pygame
23"""
24
25import pygame
26import json
27import sys
28from pathlib import Path
29
30# ── Window layout ──────────────────────────────────────────────────────────
31WINDOW_W, WINDOW_H = 1280, 820
32SIDEBAR_W = 270
33VIEW_X = SIDEBAR_W # left edge of the world canvas
34VIEW_W = WINDOW_W - SIDEBAR_W
35VIEW_H = WINDOW_H
36
37# ── World constants (must match VHDL) ──────────────────────────────────────
38WORLD_W, WORLD_H = 1500, 1500
39WALL_L = 8 # physics LEFT_WALL
40WALL_R = 1492 # physics RIGHT_WALL
41CEIL_Y = 16 # physics CEILING (character can't go above)
42GROUND_Y = 1480 # physics GROUND (character center lands here)
43CEIL_VIS = 8 # renderer CEIL_BOT
44GROUND_VIS= 1488 # renderer GROUND_TOP
45CHAR_SIZE = 7 # physics SIZE (half-width/height)
46
47GRID = 10 # snap grid in world pixels
48MIN_DIM = 10 # minimum platform width or height
49
50# ── Colours ────────────────────────────────────────────────────────────────
51C_BG = (18, 18, 18)
52C_SIDEBAR = (28, 28, 28)
53C_SIDEBAR_SEP= (55, 55, 55)
54C_SKY = (20, 20, 40)
55C_GROUND = (20, 80, 20)
56C_CEIL_STRIP = (20, 80, 20)
57C_WALL = (0, 70, 70)
58C_OBS = (210, 210, 0)
59C_OBS_SEL = (255, 130, 0)
60C_OBS_GHOST = (255, 255, 100, 70)
61C_OBS_BORDER = (255, 255, 255)
62C_VIEWPORT = (80, 100, 220)
63C_GRID = (35, 35, 45)
64C_TEXT = (210, 210, 210)
65C_TEXT_DIM = (140, 140, 140)
66C_TITLE = (255, 210, 60)
67C_ACCENT = (100, 200, 255)
68C_OK = (80, 200, 80)
69C_WARN = (220, 80, 80)
70
71
72class LevelBuilder:
73 def __init__(self, level_name: str = "level"):
74 pygame.init()
75 self.screen = pygame.display.set_mode((WINDOW_W, WINDOW_H))
76 pygame.display.set_caption("Level Builder — VHDL Bouncy Game")
77 self.clock = pygame.time.Clock()
78
79 self.font = pygame.font.SysFont("monospace", 13)
80 self.font_sm = pygame.font.SysFont("monospace", 11)
81 self.font_lg = pygame.font.SysFont("monospace", 15, bold=True)
82
83 self.level_name = level_name
84
85 # ── view state ──
86 fit = min(VIEW_W / WORLD_W, VIEW_H / WORLD_H) * 0.90
87 self.zoom = fit
88 self.pan_x = VIEW_X + (VIEW_W - WORLD_W * fit) / 2
89 self.pan_y = (VIEW_H - WORLD_H * fit) / 2
90 self._panning = False
91 self._pan_last = (0, 0)
92
93 # ── edit state ──
94 self.snap = True
95 self._drawing = False
96 self._draw_start= None # world coords (snapped)
97
98 self.obstacles = [] # list of [l, t, r, b]
99 self.selected = None # index or None
100 self.status = "Ready"
101 self.status_ok = True
102
103 # ═══════════════════════════════════════════════════════ coordinate utils
104
105 def w2s(self, wx, wy):
106 """World → screen."""
107 return (self.pan_x + wx * self.zoom,
108 self.pan_y + wy * self.zoom)
109
110 def s2w(self, sx, sy):
111 """Screen → world (float)."""
112 return ((sx - self.pan_x) / self.zoom,
113 (sy - self.pan_y) / self.zoom)
114
115 def _snap(self, v):
116 return round(v / GRID) * GRID if self.snap else int(round(v))
117
118 def _clamp_wx(self, v):
119 return max(WALL_L, min(WALL_R, v))
120
121 def _clamp_wy(self, v):
122 return max(CEIL_Y, min(GROUND_Y, v))
123
124 def _obs_at(self, wx, wy):
125 """Index of topmost obstacle under world point, or None."""
126 for i in range(len(self.obstacles) - 1, -1, -1):
127 l, t, r, b = self.obstacles[i]
128 if l <= wx <= r and t <= wy <= b:
129 return i
130 return None
131
132 # ═══════════════════════════════════════════════════════════ main loop
133
134 def run(self):
135 while True:
136 if not self._handle_events():
137 break
138 self._draw()
139 pygame.display.flip()
140 self.clock.tick(60)
141 pygame.quit()
142
143 # ═══════════════════════════════════════════════════════ event handling
144
145 def _handle_events(self):
146 for ev in pygame.event.get():
147 if ev.type == pygame.QUIT:
148 return False
149
150 elif ev.type == pygame.KEYDOWN:
151 if ev.key == pygame.K_q:
152 return False
153 elif ev.key == pygame.K_ESCAPE:
154 self.selected = None
155 self._drawing = False
156 elif ev.key == pygame.K_s:
157 self.snap = not self.snap
158 self._set_status(f"Snap {'ON' if self.snap else 'OFF'}")
159 elif ev.key == pygame.K_e:
160 self.export()
161 elif ev.key == pygame.K_l:
162 self.load()
163 elif ev.key in (pygame.K_DELETE, pygame.K_BACKSPACE):
164 self._delete_selected()
165
166 elif ev.type == pygame.MOUSEBUTTONDOWN:
167 if not self._in_view(ev.pos):
168 continue
169 if ev.button == 2:
170 self._panning = True
171 self._pan_last = ev.pos
172 elif ev.button == 1:
173 self._on_lmb_down(ev.pos)
174 elif ev.button == 3:
175 self._on_rmb_down(ev.pos)
176
177 elif ev.type == pygame.MOUSEBUTTONUP:
178 if ev.button == 2:
179 self._panning = False
180 elif ev.button == 1 and self._drawing:
181 self._on_lmb_up(ev.pos)
182
183 elif ev.type == pygame.MOUSEMOTION:
184 if self._panning:
185 dx = ev.pos[0] - self._pan_last[0]
186 dy = ev.pos[1] - self._pan_last[1]
187 self.pan_x += dx
188 self.pan_y += dy
189 self._pan_last = ev.pos
190
191 elif ev.type == pygame.MOUSEWHEEL:
192 if self._in_view(pygame.mouse.get_pos()):
193 self._do_zoom(ev.y, pygame.mouse.get_pos())
194
195 return True
196
197 def _in_view(self, pos):
198 return pos[0] >= VIEW_X
199
200 def _on_lmb_down(self, pos):
201 wx, wy = self.s2w(*pos)
202 hit = self._obs_at(wx, wy)
203 if hit is not None:
204 self.selected = hit
205 self._drawing = False
206 else:
207 self.selected = None
208 self._drawing = True
209 self._draw_start = (
210 self._clamp_wx(self._snap(wx)),
211 self._clamp_wy(self._snap(wy)),
212 )
213
214 def _on_lmb_up(self, pos):
215 self._drawing = False
216 if self._draw_start is None:
217 return
218 wx, wy = self.s2w(*pos)
219 ex = self._clamp_wx(self._snap(wx))
220 ey = self._clamp_wy(self._snap(wy))
221 x0, y0 = self._draw_start
222 l, r = sorted([x0, ex])
223 t, b = sorted([y0, ey])
224 if r - l >= MIN_DIM and b - t >= MIN_DIM:
225 self.obstacles.append([l, t, r, b])
226 self.selected = len(self.obstacles) - 1
227 self._set_status(f"Added platform {len(self.obstacles)-1} ({r-l}×{b-t}px)")
228 else:
229 self._set_status("Too small — drag further to create platform", ok=False)
230 self._draw_start = None
231
232 def _on_rmb_down(self, pos):
233 wx, wy = self.s2w(*pos)
234 hit = self._obs_at(wx, wy)
235 if hit is not None:
236 self.obstacles.pop(hit)
237 if self.selected == hit:
238 self.selected = None
239 elif self.selected is not None and self.selected > hit:
240 self.selected -= 1
241 self._set_status(f"Deleted platform {hit}")
242
243 def _delete_selected(self):
244 if self.selected is not None and self.selected < len(self.obstacles):
245 self.obstacles.pop(self.selected)
246 self._set_status(f"Deleted platform {self.selected}")
247 self.selected = None
248
249 def _do_zoom(self, direction, pivot):
250 factor = 1.15 if direction > 0 else (1 / 1.15)
251 new_zoom = max(0.04, min(6.0, self.zoom * factor))
252 mx, my = pivot
253 self.pan_x = mx - (mx - self.pan_x) * (new_zoom / self.zoom)
254 self.pan_y = my - (my - self.pan_y) * (new_zoom / self.zoom)
255 self.zoom = new_zoom
256
257 def _set_status(self, msg, ok=True):
258 self.status = msg
259 self.status_ok = ok
260
261 # ═══════════════════════════════════════════════════════════ rendering
262
263 def _draw(self):
264 self.screen.fill(C_BG)
265
266 clip = pygame.Rect(VIEW_X, 0, VIEW_W, VIEW_H)
267 self.screen.set_clip(clip)
268 self._draw_world()
269 self.screen.set_clip(None)
270
271 self._draw_sidebar()
272 self._draw_cursor_coords()
273
274 def _draw_world(self):
275 z = self.zoom
276
277 def r(wx, wy, ww, wh, color, border=0, border_color=None):
278 sx, sy = self.w2s(wx, wy)
279 sw = max(1, ww * z)
280 sh = max(1, wh * z)
281 rect = pygame.Rect(sx, sy, sw, sh)
282 pygame.draw.rect(self.screen, color, rect)
283 if border:
284 pygame.draw.rect(self.screen, border_color or C_OBS_BORDER, rect, border)
285
286 # Sky
287 r(WALL_L, CEIL_VIS, WALL_R - WALL_L, GROUND_VIS - CEIL_VIS, C_SKY)
288 # Ground
289 r(0, GROUND_VIS, WORLD_W, WORLD_H - GROUND_VIS, C_GROUND)
290 # Ceiling
291 r(WALL_L, 0, WALL_R - WALL_L, CEIL_VIS, C_CEIL_STRIP)
292 # Left wall
293 r(0, 0, WALL_L, WORLD_H, C_WALL)
294 # Right wall
295 r(WALL_R, 0, WORLD_W - WALL_R, WORLD_H, C_WALL)
296
297 # Grid
298 if z >= 0.35:
299 self._draw_grid()
300
301 # Viewport indicator (blue box showing what FPGA screen would show at start pos)
302 cam_x = max(0, min(860, 100 - 320)) # start pos x=100
303 cam_y = max(0, min(1020, 1400 - 240)) # start pos y=1400
304 vp_sl = self.w2s(cam_x, cam_y)
305 vp_br = self.w2s(cam_x + 640, cam_y + 480)
306 pygame.draw.rect(self.screen, C_VIEWPORT,
307 pygame.Rect(vp_sl[0], vp_sl[1],
308 vp_br[0] - vp_sl[0], vp_br[1] - vp_sl[1]), 1)
309
310 # Platforms
311 for i, (l, t, rr, b) in enumerate(self.obstacles):
312 color = C_OBS_SEL if i == self.selected else C_OBS
313 r(l, t, rr - l, b - t, color, 1)
314
315 # Ghost while drawing
316 if self._drawing and self._draw_start:
317 mx, my = pygame.mouse.get_pos()
318 wx, wy = self.s2w(mx, my)
319 ex = self._clamp_wx(self._snap(wx))
320 ey = self._clamp_wy(self._snap(wy))
321 x0, y0 = self._draw_start
322 gl, gr = sorted([x0, ex])
323 gt, gb = sorted([y0, ey])
324 sl = self.w2s(gl, gt)
325 sr = self.w2s(gr, gb)
326 gw, gh = max(1, sr[0] - sl[0]), max(1, sr[1] - sl[1])
327 ghost = pygame.Surface((gw, gh), pygame.SRCALPHA)
328 ghost.fill(C_OBS_GHOST)
329 self.screen.blit(ghost, (sl[0], sl[1]))
330 pygame.draw.rect(self.screen, (255, 255, 100),
331 pygame.Rect(sl[0], sl[1], gw, gh), 1)
332 # Size label
333 lbl = self.font_sm.render(f"{gr-gl}×{gb-gt}", True, (255, 255, 150))
334 self.screen.blit(lbl, (sl[0] + 2, sl[1] + 2))
335
336 def _draw_grid(self):
337 g = GRID * self.zoom
338 if g < 3:
339 return
340 sx0, sy0 = self.w2s(0, 0)
341 sx1, sy1 = self.w2s(WORLD_W, WORLD_H)
342 wx = 0
343 while wx <= WORLD_W:
344 sx = int(self.pan_x + wx * self.zoom)
345 if VIEW_X <= sx <= VIEW_X + VIEW_W:
346 pygame.draw.line(self.screen, C_GRID,
347 (sx, max(0, int(sy0))), (sx, min(VIEW_H, int(sy1))))
348 wx += GRID
349 wy = 0
350 while wy <= WORLD_H:
351 sy = int(self.pan_y + wy * self.zoom)
352 if 0 <= sy <= VIEW_H:
353 pygame.draw.line(self.screen, C_GRID,
354 (max(VIEW_X, int(sx0)), sy), (min(VIEW_X + VIEW_W, int(sx1)), sy))
355 wy += GRID
356
357 def _draw_sidebar(self):
358 pygame.draw.rect(self.screen, C_SIDEBAR,
359 pygame.Rect(0, 0, SIDEBAR_W, WINDOW_H))
360 pygame.draw.line(self.screen, C_SIDEBAR_SEP,
361 (SIDEBAR_W - 1, 0), (SIDEBAR_W - 1, WINDOW_H))
362
363 y = [10]
364
365 def line(text, color=C_TEXT, font=None):
366 f = font or self.font
367 s = f.render(text, True, color)
368 self.screen.blit(s, (8, y[0]))
369 y[0] += s.get_height() + 3
370
371 def gap(n=6):
372 y[0] += n
373
374 line(f"LEVEL: {self.level_name}", C_TITLE, self.font_lg)
375 gap()
376 line(f"Platforms: {len(self.obstacles)}", C_TEXT_DIM)
377 line(f"Snap {GRID}px: {'ON' if self.snap else 'OFF'} [S]", C_TEXT_DIM)
378 gap(10)
379
380 line("CONTROLS", C_ACCENT, self.font_lg)
381 gap(2)
382 for ctrl, desc in [
383 ("L-drag", "draw platform"),
384 ("R-click", "delete"),
385 ("Click", "select"),
386 ("Del", "delete selected"),
387 ("M-drag", "pan"),
388 ("Scroll", "zoom"),
389 ("S", "toggle snap"),
390 ("E", "export"),
391 ("L", "load level.json"),
392 ("Q", "quit"),
393 ]:
394 row = f" {ctrl:<10}{desc}"
395 line(row, C_TEXT_DIM, self.font_sm)
396
397 gap(10)
398 line("PLATFORMS", C_ACCENT, self.font_lg)
399 gap(2)
400
401 # Scrollable obstacle list
402 avail_h = WINDOW_H - y[0] - 50
403 row_h = 14
404 max_rows = avail_h // row_h
405 total = len(self.obstacles)
406 start = max(0, total - max_rows)
407
408 if start > 0:
409 line(f" … {start} more above …", C_TEXT_DIM, self.font_sm)
410
411 for i in range(start, total):
412 l, t, rr, b = self.obstacles[i]
413 w, h = rr - l, b - t
414 txt = f" {i:2d} ({l:4d},{t:4d}) {w:3d}×{h:2d}"
415 color = C_OBS_SEL if i == self.selected else C_TEXT_DIM
416 s = self.font_sm.render(txt, True, color)
417 self.screen.blit(s, (0, y[0]))
418 y[0] += row_h
419 if y[0] > WINDOW_H - 50:
420 break
421
422 # Status bar
423 status_color = C_OK if self.status_ok else C_WARN
424 s = self.font_sm.render(self.status, True, status_color)
425 self.screen.blit(s, (4, WINDOW_H - 34))
426
427 # Export button hint
428 hint = self.font_sm.render("[E] Export JSON + VHDL pkg", True, C_OK)
429 self.screen.blit(hint, (4, WINDOW_H - 18))
430
431 def _draw_cursor_coords(self):
432 mx, my = pygame.mouse.get_pos()
433 if mx < VIEW_X:
434 return
435 wx, wy = self.s2w(mx, my)
436 txt = f"world ({int(wx)}, {int(wy)}) zoom {self.zoom:.2f}×"
437 s = self.font_sm.render(txt, True, (100, 100, 120))
438 self.screen.blit(s, (VIEW_X + 4, WINDOW_H - 16))
439
440 # ═══════════════════════════════════════════════════════════ import/export
441
442 def export(self, stem: str = None):
443 stem = stem or self.level_name
444 json_path = Path(f"{stem}.json")
445 vhdl_path = Path(f"{stem}_pkg.vhd")
446
447 # JSON
448 data = {
449 "level": stem,
450 "world": {"w": WORLD_W, "h": WORLD_H},
451 "obstacles": [
452 {"l": l, "t": t, "r": r, "b": b}
453 for l, t, r, b in self.obstacles
454 ],
455 }
456 json_path.write_text(json.dumps(data, indent=2))
457
458 # VHDL package
459 self._write_vhdl_pkg(vhdl_path, stem)
460
461 self._set_status(
462 f"Exported {len(self.obstacles)} obstacles → {json_path}, {vhdl_path}"
463 )
464 print(f"[export] {json_path} {vhdl_path}")
465
466 def _write_vhdl_pkg(self, path: Path, pkg_name: str):
467 n = len(self.obstacles)
468 pkg = pkg_name.replace("-", "_").replace(" ", "_")
469
470 def arr(field_idx):
471 if n == 0:
472 return "(others => (others => '0'))"
473 vals = ",\n ".join(
474 f"CONV_STD_LOGIC_VECTOR({self.obstacles[i][field_idx]}, 11)"
475 for i in range(n)
476 )
477 return f"(\n {vals}\n )"
478
479 content = f"""\
480-- =======================================================================
481-- Level package: {pkg}
482-- Auto-generated by level_builder.py — do not edit by hand.
483--
484-- Usage in physics_engine / renderer:
485-- library work;
486-- use work.{pkg}.all;
487--
488-- Then iterate:
489-- for i in 0 to OBS_COUNT-1 loop
490-- if c_right >= OBS_L(i) and ... then <collision logic> end if;
491-- end loop;
492-- =======================================================================
493library IEEE;
494use IEEE.STD_LOGIC_1164.all;
495use IEEE.STD_LOGIC_ARITH.all;
496use IEEE.STD_LOGIC_UNSIGNED.all;
497
498package {pkg} is
499
500 constant OBS_COUNT : integer := {n};
501
502 -- Obstacle coordinate arrays (11-bit world-space, left/top/right/bottom)
503 type obs_arr_t is array(0 to OBS_COUNT-1) of std_logic_vector(10 downto 0);
504
505 constant OBS_L : obs_arr_t := {arr(0)};
506 constant OBS_T : obs_arr_t := {arr(1)};
507 constant OBS_R : obs_arr_t := {arr(2)};
508 constant OBS_B : obs_arr_t := {arr(3)};
509
510end package {pkg};
511"""
512 path.write_text(content)
513
514 def load(self, stem: str = None):
515 stem = stem or self.level_name
516 path = Path(f"{stem}.json")
517 if not path.exists():
518 # Try bare "level.json" as fallback
519 path = Path("level.json")
520 if not path.exists():
521 self._set_status(f"File not found: {stem}.json", ok=False)
522 return
523 try:
524 data = json.loads(path.read_text())
525 self.obstacles = [
526 [o["l"], o["t"], o["r"], o["b"]]
527 for o in data.get("obstacles", [])
528 ]
529 self.selected = None
530 self._set_status(
531 f"Loaded {len(self.obstacles)} obstacles from {path}"
532 )
533 if "level" in data:
534 self.level_name = data["level"]
535 pygame.display.set_caption(
536 f"Level Builder — {self.level_name}"
537 )
538 except Exception as e:
539 self._set_status(f"Load error: {e}", ok=False)
540
541
542# ═══════════════════════════════════════════════════════════════════════ main
543
544def main():
545 name = sys.argv[1] if len(sys.argv) > 1 else "level"
546 builder = LevelBuilder(level_name=name)
547
548 # Pre-populate with the current built-in level so you can tweak it
549 builder.obstacles = [
550 # bottom zone
551 [100, 1380, 280, 1396],
552 [450, 1320, 530, 1336],
553 [750, 1390, 950, 1406],
554 [1150,1300,1380,1316],
555 # mid-lower
556 [60, 1100, 250, 1116],
557 [400, 1050, 550, 1066],
558 [720, 1000, 920, 1016],
559 [1100,1120,1350,1136],
560 # mid-upper
561 [150, 780, 380, 796],
562 [550, 700, 650, 716],
563 [850, 650,1050, 666],
564 [1200, 760,1450, 776],
565 # upper
566 [200, 450, 420, 466],
567 [620, 380, 820, 396],
568 [1000, 320,1200, 336],
569 [1320, 420,1460, 436],
570 ]
571
572 # Auto-load JSON if it exists (overrides pre-populated data)
573 if Path(f"{name}.json").exists():
574 builder.load(name)
575
576 builder.run()
577
578
579if __name__ == "__main__":
580 main()