at main 580 lines 21 kB view raw
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()