class SelectableObject(LayoutObject): """A LayoutObject that can be selected and moved around""" def __init__(self, dictionary: dict): super().__init__(dictionary) self.selected = False self._hitbox: Optional[Mask] = None self._center_offset = Vector(0, 0) self._last_zoom: int = 1 self._last_camera = Vector(0, 0) def render(self, display: Surface, camera: Vector, zoom: float, args=None): self._last_zoom = zoom self._last_camera = camera def collidepoint(self, point: Sequence[Number]) -> bool: mask_size = Vector(self._hitbox.get_size()) point = Vector(point[:2]) / self._last_zoom - self._last_camera.flip_y() - self.pos[:2].flip_y() point = ((point + self._center_offset) * HITBOX_RESOLUTION + mask_size / 2).round() if 0 <= point.x < mask_size.x and 0 <= point.y < mask_size.y: return bool(self._hitbox.get_at(point)) return False def colliderect(self, rect: Sequence[Number], mask: Mask = None) -> bool: mask_size = Vector(self._hitbox.get_size()) point = Vector(rect[:2]) / self._last_zoom - self._last_camera.flip_y() - self.pos[:2].flip_y() point = ((point + self._center_offset) * HITBOX_RESOLUTION + mask_size / 2).round() if mask is None: mask = rect_hitbox_mask(rect, self._last_zoom) return bool(self._hitbox.overlap(mask, point))
def editor(layout: dict, layoutfile: str, jsonfile: str, backupfile: str, events: ev.EventCommunicator): zoom = 20 size = Vector(BASE_SIZE) camera = Vector(0, 0) clock = pygame.time.Clock() paused = False pause_force_render = False draw_points = False draw_hitboxes = False panning = False selecting = False moving = False point_moving = False mouse_pos = Vector(0, 0) old_mouse_pos = Vector(0, 0) old_true_mouse_pos = Vector(0, 0) selecting_pos = Vector(0, 0) dragndrop_pos = Vector(0, 0) object_being_edited: Optional[lay.SelectableObject] = None selected_shape: Optional[lay.CustomShape] = None bg_color = BACKGROUND_BLUE bg_color_2 = BACKGROUND_BLUE_GRID fg_color = WHITE object_lists = [ terrain_stretches := lay.LayoutList(lay.TerrainStretch, layout), water_blocks := lay.LayoutList(lay.WaterBlock, layout), platforms := lay.LayoutList(lay.Platform, layout), ramps := lay.LayoutList(lay.Ramp, layout), custom_shapes := lay.LayoutList(lay.CustomShape, layout), pillars := lay.LayoutList(lay.Pillar, layout), anchors := lay.LayoutList(lay.Anchor, layout) ] objects: Dict[Type[lay.LayoutObject], lay.LayoutList] = {li.cls: li for li in object_lists} bridge = lay.Bridge(layout) selectable_objects = lambda: tuple(chain(custom_shapes, pillars)) holding_shift = lambda: pygame.key.get_mods() & pygame.KMOD_SHIFT true_mouse_pos = lambda: mouse_pos.flip_y() / zoom - camera # Start pygame display = pygame.display.set_mode(size, pygame.RESIZABLE) pygame.display.set_caption("PolyEditor") if ICON: pygame.display.set_icon(pygame.image.load(ICON)) pygame.init() if USER32: # Maximize USER32.ShowWindow(USER32.GetForegroundWindow(), 3) for pyevent in pygame.event.get(): if pyevent.type == pygame.VIDEORESIZE: size = Vector(pyevent.size) display = pygame.display.set_mode(size, pygame.RESIZABLE) lay.DUMMY_SURFACE = pygame.Surface(size, pygame.SRCALPHA, 32) camera = (size / zoom / 2 + (0, 10)).flip_y() menu_button_font = pygame.font.SysFont("Courier", 20, True) menu_button = pygame.Surface( Vector(menu_button_font.size("Menu")) + (10, 6)) menu_button.fill(BACKGROUND_BLUE_DARK) pygame.draw.rect(menu_button, BLACK, menu_button.get_rect(), 1) menu_button.blit(menu_button_font.render("Menu", True, WHITE), (5, 4)) menu_button_rect = None # Editor loop while True: # Process editor events if (event := events.read()) is not None: if event == ev.CLOSE_EDITOR: pygame.quit() events.send(ev.DONE) return elif event == ev.DONE: paused = False elif object_being_edited: if event == ev.EXIT: object_being_edited = None events.send(ev.CLOSE_OBJ_EDIT) elif hasattr(event, "values"): values = event.values hl_objs = [o for o in selectable_objects() if o.selected] if len(hl_objs) == 1: obj = hl_objs[0] obj.pos = Vector(values[popup.POS_X], values[popup.POS_Y], values[popup.POS_Z]) if isinstance(obj, lay.CustomShape): obj.scale = Vector(values[popup.SCALE_X], values[popup.SCALE_Y], values[popup.SCALE_Z]) obj.rotations = Vector(values[popup.ROT_X], values[popup.ROT_Y], values[popup.ROT_Z]) obj.color = Vector(values[popup.RGB_R], values[popup.RGB_G], values[popup.RGB_B]) obj.flipped = values[popup.FLIP] obj.calculate_hitbox() elif isinstance(obj, lay.Pillar): obj.height = values[popup.HEIGHT] else: # Multiple objects for obj in hl_objs: if isinstance(obj, lay.CustomShape): obj.color = (values[popup.RGB_R], values[popup.RGB_G], values[popup.RGB_B]) elif paused: if event in (ev.MENU_RETURN, ev.ESCAPE, ev.FOCUS_OUT): events.send(ev.DONE) paused = False elif event == ev.MENU_SAVE: pygame.event.post(pygame.event.Event( SAVE_LAYOUT_EVENT, {})) events.send(ev.DONE) paused = False elif event == ev.MENU_HITBOXES: draw_hitboxes = not draw_hitboxes pause_force_render = True elif event == ev.MENU_COLORS: if bg_color == BACKGROUND_GRAY: bg_color = BACKGROUND_BLUE bg_color_2 = BACKGROUND_BLUE_GRID fg_color = WHITE else: bg_color = BACKGROUND_GRAY bg_color_2 = BACKGROUND_GRAY_GRID fg_color = BLACK pause_force_render = True elif event == ev.MENU_CHANGE_LEVEL: events.send(ev.RESTART_PROGRAM) elif event == ev.MENU_QUIT: events.send(ev.CLOSE_PROGRAM, force=False) # Proccess pygame events for pyevent in pygame.event.get(): if pyevent.type == pygame.QUIT: events.send(ev.CLOSE_PROGRAM, force=True) elif pyevent.type == pygame.ACTIVEEVENT: if pyevent.state == 6 and not pyevent.gain: # Minimized object_being_edited = None events.send(ev.CLOSE_OBJ_EDIT) events.send(ev.DONE) elif pyevent.type == pygame.VIDEORESIZE: size = Vector(pyevent.size) display = pygame.display.set_mode(size, pygame.RESIZABLE) lay.DUMMY_SURFACE = pygame.Surface(size, pygame.SRCALPHA, 32) pause_force_render = True elif (paused and pyevent.type == pygame.KEYDOWN and pyevent.key in (pygame.K_ESCAPE, pygame.K_RETURN, pygame.K_SPACE)): events.send(ev.DONE) paused = False continue if paused: continue elif pyevent.type == SAVE_LAYOUT_EVENT: jsonstr = json.dumps(layout, indent=2) jsonstr = re.sub(r"(\r\n|\r|\n)( ){6,}", r" ", jsonstr) # limit depth to 3 levels jsonstr = re.sub(r"(\r\n|\r|\n)( ){4,}([}\]])", r" \3", jsonstr) with open(jsonfile, "w") as openfile: openfile.write(jsonstr) program = run(f"{POLYCONVERTER} {jsonfile}", capture_output=True) if program.returncode == SUCCESS_CODE: output = program.stdout.decode().strip() if len(output) == 0: events.send(popup.notif, "Saved! No new changes to apply.") paused = True else: if "backup" in program.stdout.decode(): events.send(popup.notif, f"Saved level as {layoutfile}!", f"(Copied original to {backupfile})") else: events.send( popup.notif, f"Saved level as {layoutfile}!", ) elif program.returncode == FILE_ERROR_CODE: # failed to write file? events.send(popup.notif, "Couldn't save:", program.stdout.decode().strip()) else: outputs = [ program.stdout.decode().strip(), program.stderr.decode().strip() ] events.send(popup.notif, f"Unexpected error while trying to save:", "\n".join([o for o in outputs if len(o) > 0])) elif pyevent.type == pygame.MOUSEBUTTONDOWN: if pyevent.button == 1: # left click if menu_button_rect.collidepoint(pyevent.pos): events.send(popup.open_menu, clicked=True) paused = True continue if draw_points: # Point editing for obj in reversed(custom_shapes): if draw_points and obj.bounding_box.collidepoint( *pyevent.pos): clicked_point = [ p.collidepoint(pyevent.pos) for p in obj.point_hitboxes ] if holding_shift() and obj.add_point_hitbox: if obj.add_point_hitbox.collidepoint( pyevent.pos): obj.add_point(obj.add_point_closest[2], obj.add_point_closest[0]) obj.selected_point_index = obj.add_point_closest[ 2] point_moving = True selected_shape = obj break elif True in clicked_point: point_moving = True obj.selected_point_index = clicked_point.index( True) selected_shape = obj for o in selectable_objects(): o.selected = False object_being_edited = None events.send(ev.CLOSE_OBJ_EDIT) break if not point_moving: # Dragging and multiselect for obj in reversed(selectable_objects()): if obj.collidepoint(pyevent.pos): if not holding_shift(): moving = True dragndrop_pos = true_mouse_pos( ) if not obj.selected else Vector() if not obj.selected: if not holding_shift( ): # clear other selections for o in selectable_objects(): o.selected = False obj.selected = True object_being_edited = None events.send(ev.CLOSE_OBJ_EDIT) elif holding_shift(): obj.selected = False object_being_edited = None events.send(ev.CLOSE_OBJ_EDIT) break if not (moving or point_moving): panning = True dragndrop_pos = true_mouse_pos() old_mouse_pos = Vector(pyevent.pos) if pyevent.button == 3: # right click object_being_edited = None events.send(ev.CLOSE_OBJ_EDIT) # Delete point deleted_point = False if draw_points: for obj in reversed(custom_shapes): if obj.bounding_box.collidepoint(*pyevent.pos): for i, point in enumerate(obj.point_hitboxes): if point.collidepoint(pyevent.pos): if len(obj.points) > 3: obj.del_point(i) deleted_point = True break if deleted_point: break if not deleted_point: if not point_moving or moving or holding_shift(): selecting_pos = Vector(pyevent.pos) selecting = True if pyevent.button == 4: # mousewheel up zoom_old_pos = true_mouse_pos() if not holding_shift() and round(zoom * (ZOOM_MULT - 1)) >= 1: zoom = round(zoom * ZOOM_MULT) else: zoom += 1 zoom = min(zoom, ZOOM_MAX) zoom_new_pos = true_mouse_pos() camera += zoom_new_pos - zoom_old_pos if pyevent.button == 5: # mousewheel down zoom_old_pos = true_mouse_pos() if not holding_shift() and round(zoom / (ZOOM_MULT - 1)) >= 1: zoom = round(zoom / ZOOM_MULT) else: zoom -= 1 zoom = max(zoom, ZOOM_MIN) zoom_new_pos = true_mouse_pos() camera += zoom_new_pos - zoom_old_pos elif pyevent.type == pygame.MOUSEBUTTONUP: if pyevent.button == 1: # left click if point_moving: selected_shape.selected_point_index = None selected_shape = None point_moving = False if (not holding_shift() and dragndrop_pos and ((not panning and dragndrop_pos != true_mouse_pos()) or (panning and dragndrop_pos == true_mouse_pos()))): hl_objs = [ o for o in selectable_objects() if o.selected ] for obj in hl_objs: obj.selected = False object_being_edited = None events.send(ev.CLOSE_OBJ_EDIT) panning = False moving = False if pyevent.button == 3: # right click selecting = False elif pyevent.type == pygame.MOUSEMOTION: mouse_pos = Vector(pyevent.pos) if panning: camera += (mouse_pos - old_mouse_pos).flip_y() / zoom old_mouse_pos = mouse_pos elif pyevent.type == pygame.KEYDOWN: move_x, move_y = 0, 0 move = False if pyevent.key == pygame.K_ESCAPE: events.send(popup.open_menu, clicked=False) object_being_edited = None paused = True continue elif pyevent.key == pygame.K_LEFT: move_x = -1 move = True elif pyevent.key == pygame.K_RIGHT: move_x = 1 move = True elif pyevent.key == pygame.K_UP: move_y = 1 move = True elif pyevent.key == pygame.K_DOWN: move_y = -1 move = True elif pyevent.key == pygame.K_s: pygame.event.post(pygame.event.Event( SAVE_LAYOUT_EVENT, {})) elif pyevent.key == pygame.K_p: draw_points = not draw_points elif pyevent.key == pygame.K_h: draw_hitboxes = not draw_hitboxes elif pyevent.key == pygame.K_d: # Delete selected for obj in [o for o in selectable_objects() if o.selected]: if isinstance(obj, lay.CustomShape): for dyn_anc_id in obj.dynamic_anchor_ids: for anchor in [a for a in anchors]: if anchor.id == dyn_anc_id: anchors.remove(anchor) objects[type(obj)].remove(obj) elif pyevent.key == pygame.K_c: # Copy Selected for old_obj in [ o for o in selectable_objects() if o.selected ]: new_obj = type(old_obj)(deepcopy(old_obj.dictionary)) old_obj.selected = False new_obj.selected = True if isinstance(old_obj, lay.CustomShape): new_anchors = [] for i in range(len(old_obj.dynamic_anchor_ids)): for anchor in [ a for a in anchors if a.id == old_obj.dynamic_anchor_ids[i] ]: new_anchor = deepcopy(anchor) new_anchor.id = str(uuid4()) new_anchors.append(new_anchor) anchors.extend(new_anchors) new_obj.dynamic_anchor_ids = [ a.id for a in new_anchors ] new_obj.anchors = new_anchors new_obj.pos += (1, -1) objects[type(new_obj)].append(new_obj) elif pyevent.key == pygame.K_e: # Popup window to edit object properties hl_objs = [o for o in selectable_objects() if o.selected] if len(hl_objs) == 0: # under cursor for obj in reversed(selectable_objects()): if obj.collidepoint(mouse_pos): obj.selected = True hl_objs.append(obj) break if object_being_edited: # clear previous for obj in hl_objs: obj.selected = False hl_objs.clear() object_being_edited = None events.send(ev.CLOSE_OBJ_EDIT) elif len(hl_objs) == 1: obj = hl_objs[0] values = { popup.POS_X: obj.pos.x, popup.POS_Y: obj.pos.y, popup.POS_Z: obj.pos.z } if isinstance(obj, lay.CustomShape): rot = obj.rotations values[popup.SCALE_X] = obj.scale.x values[popup.SCALE_Y] = obj.scale.y values[popup.SCALE_Z] = obj.scale.z values[popup.ROT_Z] = rot.z # Z first values[popup.ROT_X] = rot.x values[popup.ROT_Y] = rot.y values[popup.RGB_R] = obj.color[0] values[popup.RGB_G] = obj.color[1] values[popup.RGB_B] = obj.color[2] values[popup.FLIP] = obj.flipped elif isinstance(obj, lay.Pillar): values[popup.HEIGHT] = obj.height object_being_edited = obj events.send(ev.OPEN_OBJ_EDIT, values=values) elif len(hl_objs) > 1: values = {} for i in range(len(hl_objs)): if isinstance(hl_objs[i], lay.CustomShape): values[popup.RGB_R] = hl_objs[i].color[0] values[popup.RGB_G] = hl_objs[i].color[1] values[popup.RGB_B] = hl_objs[i].color[2] object_being_edited = hl_objs[i] events.send(ev.OPEN_OBJ_EDIT, values=values) break # Move selection with keys if move: hl_objs = [o for o in selectable_objects() if o.selected] for obj in hl_objs: obj.pos += (move_x, move_y) if len(hl_objs) == 0: camera -= (move_x, move_y) elif object_being_edited and len( hl_objs ) == 1 and object_being_edited == hl_objs[0]: events.send(ev.UPDATE_OBJ_EDIT, values={ popup.POS_X: hl_objs[0].pos.x, popup.POS_Y: hl_objs[0].pos.y }) # Don't render while paused if paused and not pause_force_render: clock.tick(FPS) continue # Render background display.fill(bg_color) block_size = zoom line_width = lay.scale(1, zoom) shift = (camera * zoom % block_size).round() for x in range(shift.x, size.x, block_size): pygame.draw.line(display, bg_color_2, (x, 0), (x, size.y), line_width) for y in range(-shift.y, size.y, block_size): pygame.draw.line(display, bg_color_2, (0, y), (size.x, y), line_width) # Move selection with mouse if moving: hl_objs = [o for o in selectable_objects() if o.selected] for obj in hl_objs: obj.pos += true_mouse_pos() - old_true_mouse_pos if object_being_edited and len( hl_objs) == 1 and object_being_edited == hl_objs[0]: events.send(ev.UPDATE_OBJ_EDIT, values={ popup.POS_X: hl_objs[0].pos.x, popup.POS_Y: hl_objs[0].pos.y }) true_mouse_change = true_mouse_pos() - old_true_mouse_pos old_true_mouse_pos = true_mouse_pos() # Render Objects for terrain in terrain_stretches: terrain.render(display, camera, zoom, fg_color) for water in water_blocks: water.render(display, camera, zoom, fg_color) for platform in platforms: platform.render(display, camera, zoom) for ramp in ramps: ramp.render(display, camera, zoom) shape_args = lay.ShapeRenderArgs(draw_points, draw_hitboxes, holding_shift(), mouse_pos, true_mouse_change) for shape in custom_shapes: shape.render(display, camera, zoom, shape_args) for shape in custom_shapes: shape.render_points(display, camera, zoom, shape_args) if shape_args.top_point is not None: color = lay.HIGHLIGHT_COLOR if shape_args.selected_point is not None else lay.POINT_COLOR shape_args.top_point.render( display, color, round(zoom * lay.POINT_SELECTED_RADIUS)) for pillar in pillars: pillar.render(display, camera, zoom, draw_hitboxes) bridge.render(display, camera, zoom) dyn_anc_ids = list( chain(*[shape.dynamic_anchor_ids for shape in custom_shapes])) for anchor in anchors: anchor.render(display, camera, zoom, dyn_anc_ids) # Selecting shapes if selecting: rect = (min(selecting_pos.x, mouse_pos.x), min(selecting_pos.y, mouse_pos.y), abs(mouse_pos.x - selecting_pos.x), abs(mouse_pos.y - selecting_pos.y)) pygame.draw.rect(display, lay.SELECT_COLOR, rect, 1) mask = lay.rect_hitbox_mask(rect, zoom) for obj in selectable_objects(): if not holding_shift(): obj.selected = obj.colliderect(rect, mask) elif obj.colliderect(rect, mask): # multiselect obj.selected = True # Display mouse position, zoom and fps font = pygame.font.SysFont("Courier", 20) pos_msg = f"[{round(true_mouse_pos().x, 2):>6},{round(true_mouse_pos().y, 2):>6}]" pos_text = font.render(pos_msg, True, fg_color) display.blit(pos_text, (2, 5)) font = pygame.font.SysFont("Courier", 16) zoom_msg = f"({zoom})" zoom_size = font.size(zoom_msg) zoom_text = font.render(zoom_msg, True, fg_color) display.blit(zoom_text, (round(size[0] / 2 - zoom_size[0] / 2), 5)) fps_msg = str(round(clock.get_fps())).rjust(2) fps_size = font.size(fps_msg) fps_text = font.render(fps_msg, True, fg_color) display.blit(fps_text, (size[0] - fps_size[0] - 5, 5)) # Display buttons menu_button_rect = display.blit( menu_button, (10, size.y - menu_button.get_size()[1] - 10)) pause_force_render = False pygame.display.flip() clock.tick(FPS)