I developed two custom UI components with Pygame: a Button widget and a Frame widget. After placing the Button inside the Frame, I encountered an issue with the Button’s collision detection.
Requirements pygame-ce==2.5.6 python >=3.11
Problem
When the Button was nested inside the Frame, its collision area was still being calculated relative to the main screen, not relative to the Frame’s coordinate space. This caused inaccurate interaction behavior when the cursor hovered or clicked on the Button.
My Solution
To resolve the issue, I attempted to convert the cursor position by adjusting event.pos inside the BTNBase class. I defined a method named convert_pos to translate the global cursor coordinates into the Frame’s local coordinate system.
Request for Feedback I would like to know whether my current implementation of the Frame widget is structurally correct. Will the current design cause issues in future updates?
import pygame # pygame-ce==2.5.6
from abc import ABC, abstractmethod
CLR_SPACE_GREY = (17, 0, 34) # main #110022
CLR_VOID = (5, 13, 37)
CLR_NEON_GREEN = (80, 255, 120) # Active #50ff78
CLR_NEON_GREEN_LIGHT = (150, 255, 180) # Hover #96FFB4
CLR_NEON_GREEN_DARK = (20, 120, 50) # Press #147832
BTN_WIDTH = 200
BTN_HEIGHT = 50
BTN_SIZE = (BTN_WIDTH,BTN_HEIGHT)
BTN_CLR_NORMAL = CLR_NEON_GREEN
BTN_CLR_HOVER = CLR_NEON_GREEN_LIGHT
BTN_CLR_PRESS = CLR_NEON_GREEN_DARK
class Color:
def __init__(self, r:int, g:int, b:int):
self.r = r
self.g = g
self.b = b
class Point:
def __init__(self,x:int,y:int):
self.x = x
self.y = y
class BTNBase(pygame.sprite.Sprite, ABC):
def __init__(self,command:function=None):
super().__init__()
self.on_click = command
self.state = "NORMAL"
self.pressed = False
@staticmethod
def convert_pos(pos:tuple[int,int], offsets:tuple[int,int]):
"""
to fix frame positioning
actually buttons collision is counted relative to main screen surface
but to fix it i convert cursor position for when widget is inside frame
:param pos: cursor position by `pygame.event`
:param offsets: the difference between main pos and frame pos
:return: exact position where cursor can find widget
"""
return (pos[0]-offsets[0] ,pos[1]-offsets[1])
def events(self, event:pygame.event.Event, frame_offsets:tuple[int,int] = (0,0)):
if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
cursor_pos = self.convert_pos((event.pos[0],event.pos[1]), frame_offsets)
if self.rect.collidepoint(cursor_pos):
self.pressed = True
self.state = "PRESS"
elif event.type == pygame.MOUSEBUTTONUP and event.button == 1:
cursor_pos = self.convert_pos((event.pos[0],event.pos[1]), frame_offsets)
if self.rect.collidepoint(cursor_pos):
self.curr_time = pygame.time.get_ticks()
if self.on_click and self.pressed:
self.on_click()
self.pressed = False
if self.rect.collidepoint(cursor_pos):
self.state = "HOVER"
else:
self.state = "NORMAL"
if event.type == pygame.MOUSEMOTION:
cursor_pos = self.convert_pos((event.pos[0],event.pos[1]), frame_offsets)
if self.rect.collidepoint(cursor_pos):
if not self.pressed:
self.state = "HOVER"
else:
self.state = "NORMAL"
@abstractmethod
def blits(self):
pass
class Button(BTNBase):
def __init__(self,surface:pygame.Surface, width:int=10, height:int=10, x:int=10, y:int=10, text:str="" ,font_size:int=18, font_bold:bool=False, command:function=None):
super().__init__(command)
self.surface = surface
self.background = pygame.Surface((width,height), pygame.SRCALPHA)
self.image = self.background
self.rect = self.image.get_rect(topleft = (x, y))
self.font = pygame.font.Font(None, font_size)
if font_bold:
self.font.set_bold(True)
self.text = self.font.render(text,True,CLR_SPACE_GREY)
self.text_rect = self.text.get_rect() # pos relates to self.image pos
self.text_rect.topleft = ((width//2)-(self.text_rect.w//2),(height//2)-(self.text_rect.h//2))
def blits(self):
self.image = self.background
if self.state == "NORMAL":
self.image.fill(BTN_CLR_NORMAL)
elif self.state == "HOVER":
self.image.fill(BTN_CLR_HOVER)
elif self.state == "PRESS":
self.image.fill(BTN_CLR_PRESS)
self.image.blit(self.text, self.text_rect)
self.surface.blit(self.image, self.rect)
class Frame(pygame.Surface):
def __init__(self, surface:pygame.Surface, size:Point=(10,10), x:int = 10, y:int = 10, bg:Color=(pygame.Color("Black"))):
super().__init__(size,pygame.SRCALPHA)
self.bg = bg
self.fill(self.bg)
self.surface = surface
self.rect = pygame.rect.Rect((x,y),size)
self.widgets = dict()
self.widgets_count = 0
def add_widget(self,widget:pygame.sprite.Sprite): # TODO add feature to add multiple widgets at once
self.widgets_count += 1
self.widgets[self.widgets_count] = widget
def events(self,event):
self.fill(self.bg)
for key,widget in self.widgets.items():
widget.events(event, frame_offsets=(self.rect.x, self.rect.y))
widget.blits()
def blits(self):
self.surface.blit(self, self.rect)
if __name__ == "__main__":
pygame.init()
demo_screen = pygame.display.set_mode((400,400))
frame = Frame(demo_screen,(200, 300), x=40, y=40)
# Button(surface, width, height, x-pos, y-pos, text, ...)
btn1 = Button(frame, 100 , 50 , 40 , 40, "BTN1",font_size=22, command=lambda : print("BTN1"))
btn2 = Button(frame, 100 , 50 , 40 , 100, "BTN2",font_size=22, command=lambda : print("BTN2"))
btn3 = Button(frame, 100 , 50 , 40 , 160, "BTN3",font_size=22, command=lambda : print("BTN3"))
frame.add_widget(btn1)
frame.add_widget(btn2)
frame.add_widget(btn3)
while True:
for event in pygame.event.get():
if event.type == pygame.QUIT:
break;
demo_screen.fill(CLR_SPACE_GREY)
frame.events(event)
frame.blits()
pygame.display.update()
```