Search code examples
scrollpygamezooming

Pygame sprite hitboxes don't follow as screen scrolls/zooms


First, the CameraGroup class is attributed to the wonderful youtube channel Clear Code.

Second, the hitboxes will not zoom (mousewheel) or scroll (WASD keys) with the planet icons. Try clicking the planets before zooming or scrolling and you will see the hitbox in action.

Third, as the screen is zoomed out the planets get smaller and the camera window shrinks towards the middle of the screen. Ideally, the camera window should stay the full size of the parent window to allow displaying more planets in the zoomed out state.

Finally, I am eager to learn, so please be brutal. Just drop the two planet icons in the folder with the .py file.

enter image description here enter image description here

import os
import pygame
from pygame import *
from sys import exit


class CameraGroup(pygame.sprite.Group):
    def __init__(self):
        super().__init__()
        self.display_surf_foreground = pygame.display.get_surface()

        # camera offset
        self.offset = pygame.math.Vector2()
        self.half_w = self.display_surf_foreground.get_size()[0] // 2
        self.half_h = self.display_surf_foreground.get_size()[1] // 2
        self.scroll_offset = (0, 0)

        # box setup
        self.camera_borders = {'left': 200, 'right': 200, 'top': 100, 'bottom': 100}
        l = self.camera_borders['left']
        t = self.camera_borders['top']
        w = self.display_surf_foreground.get_size()[0] - (self.camera_borders['left'] + self.camera_borders['right'])
        h = self.display_surf_foreground.get_size()[1] - (self.camera_borders['top'] + self.camera_borders['bottom'])

        self.camera_rect = pygame.Rect(l, t, w, h)

        # camera speed
        self.keyboard_speed = 10

        # zoom
        self.zoom_scale = 1
        self.foreground_surf_size = (400, 400)
        self.foreground_surf = pygame.Surface(self.foreground_surf_size, pygame.SRCALPHA)
        self.foreground_rect = self.foreground_surf.get_rect(center=(self.half_w, self.half_h))
        self.foreground_surf_size_vector = pygame.math.Vector2(self.foreground_surf_size)
        self.foreground_offset = pygame.math.Vector2()
        self.foreground_offset.x = self.foreground_surf_size[0] // 2 - self.half_w
        self.foreground_offset.y = self.foreground_surf_size[1] // 2 - self.half_h

        # planets and labels
        self.planet_surf = pygame.Surface
        self.planet_rect = pygame.Rect

    def keyboard_control(self):
        keys = pygame.key.get_pressed()
        if keys[pygame.K_a]:
            self.camera_rect.x -= self.keyboard_speed
        if keys[pygame.K_d]:
            self.camera_rect.x += self.keyboard_speed
        if keys[pygame.K_w]:
            self.camera_rect.y -= self.keyboard_speed
        if keys[pygame.K_s]:
            self.camera_rect.y += self.keyboard_speed

        self.offset.x = self.camera_rect.left - self.camera_borders['left']
        self.offset.y = self.camera_rect.top - self.camera_borders['top']

    def zoom_keyboard_control(self):
        keys = pygame.key.get_pressed()
        if keys[pygame.K_q]:
            self.zoom_scale += 0.1
            if self.zoom_scale > 2:
                self.zoom_scale = 2
        if keys[pygame.K_e]:
            self.zoom_scale -= 0.1
            if self.zoom_scale < .5:
                self.zoom_scale = .5

    def custom_draw(self, planets):
        self.keyboard_control()
        self.zoom_keyboard_control()

        self.foreground_surf.fill((0, 0, 0, 255))

        # active elements
        for planet in planets:
            self.planet_surf = pygame.image.load(planet.icon).convert_alpha()
            offset_coord = planet.coord - self.offset + self.foreground_offset
            self.planet_rect = self.planet_surf.get_rect(center=offset_coord)
            self.foreground_surf.blit(self.planet_surf, self.planet_rect)

        scaled_surf = pygame.transform.scale(self.foreground_surf, self.foreground_surf_size_vector * self.zoom_scale)
        scaled_rect = scaled_surf.get_rect(center=(self.half_w, self.half_h))
        self.display_surf_foreground.blit(scaled_surf, scaled_rect)


class Game:
    def __init__(self):
        self.game_over = False
        self.game_year = 0
        self.game_state = 'splash'

    @staticmethod
    def activate_planet(screen, planets):
        active_planet_coord = None
        for planet in planets:
            if planet.rect.collidepoint(pygame.mouse.get_pos()):
                active_planet_coord = planet.rect.center
                return active_planet_coord
        return active_planet_coord

    @staticmethod
    def heartbeat(screen, active_planet_coord):
        # global heartbeat_mod
        global heartbeat
        ticks = pygame.time.get_ticks()
        heartbeat_thump = round(ticks / 1000) % 2

        if heartbeat_thump == 0:
            heartbeat_mod = .1
        else:
            heartbeat_mod = -.1

        heartbeat += heartbeat_mod

        if heartbeat < 1:
            heartbeat = 1
        elif heartbeat > 6:
            heartbeat = 6

        heartbeat_color = (0, 255, 0)
        pygame.draw.circle(screen, heartbeat_color, active_planet_coord, 25 + round(heartbeat), round(heartbeat) + 2)


class Planet(pygame.sprite.Sprite):
    def __init__(self, icon, group):
        super().__init__(group)
        self.icon = icon
        self.coord = (0, 0)
        self.group = group

        self.image = pygame.image.load(self.icon).convert_alpha()
        self.rect = self.image.get_rect(center=self.coord)
        self.planet_icons = []
        self.planet_names = []

    @staticmethod
    def update_coords(planets, star_coords):
        i = 0
        for planet in planets:
            planet.coord = tuple(star_coords[i])
            planet.rect = planet.image.get_rect(center=planet.coord)
            i += 1
            if i == len(star_coords):
                del planets[len(star_coords):]
                return planets


def main():
    global heartbeat
    global heartbeat_mod

    pygame.init()

    width, height = 400, 400
    screen = pygame.display.set_mode((width, height))
    pygame.display.init()
    pygame.display.update()

    active_planet_coord = (-100, -100)

    heartbeat = 0
    heartbeat_mod = .1

    clock = pygame.time.Clock()

    game = Game()
    camera_group = CameraGroup()

    planet_array = os.listdir('./')
    planet_array.pop(0)  # remove 'camera scroll example.py' file
    planet_icons = planet_array
    planets = []
    for new_planet in range(2):
        icon = planet_icons.pop()
        planet = Planet(icon, camera_group)
        planets.append(planet)

    star_coords = [[100, 100], [200, 200]]
    planets = planet.update_coords(planets, star_coords)

    while True:
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                pygame.quit()
                exit()
            elif event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE:
                pygame.quit()
                exit()
            elif event.type == pygame.MOUSEBUTTONDOWN:
                if event.button == 1:
                    active_planet_coord = game.activate_planet(screen, camera_group)
                    if active_planet_coord is None:
                        active_planet_coord = (-100, -100)
                    pygame.display.update()
            elif event.type == pygame.MOUSEWHEEL:
                camera_group.zoom_scale += event.y * 0.1
                if camera_group.zoom_scale > 2:
                    camera_group.zoom_scale = 2
                elif camera_group.zoom_scale < .5:
                    camera_group.zoom_scale = .5

        camera_group.update()
        camera_group.custom_draw(planets)
        game.heartbeat(screen, active_planet_coord)

        pygame.display.update()
        clock.tick(60)


if __name__ == '__main__':
    main()

Solution

  • An alternative way to solve this problem is to "inverse zoom" the mouse position when you use it for click detection:

    zoom:

    p_zoom = (p - zoom_center) * zoom_scale + zoom_center
    

    inverse zoom:

    p = (p_zoom - zoom_center) / zoom_scale + zoom_center
    

    Apply this to your code:

    class Game:
        # [...]
    
         @staticmethod
        def activate_planet(screen, planets):
    
            zoom_scale = planets.zoom_scale
            zoom_center = pygame.math.Vector2(screen.get_rect().center)
            pos = pygame.math.Vector2(pygame.mouse.get_pos())
    
            pos = (pos - zoom_center) / zoom_scale + zoom_center
    
            active_planet_coord = None
            for planet in planets:
                if planet.rect.collidepoint(pos):
                    
                    planet_center = (planet.rect.center - zoom_center) * zoom_scale + zoom_center
                    
                    active_planet_coord = round(planet_center.x), round(planet_center.y)
                    return active_planet_coord
    
            return active_planet_coord