2D Raycasting and rect collision in Pygame (Line of Sight, Obstacles)

Viewed 2051

New programmer with three months of Python under my belt (only a few weeks of pygame)...

I'm designing a 2-D, top-down game and have been trying to get obstacles to break sprite LOS. My current attempt is for each sprite to cast a single ray toward every other sprite in visual range, stopping if the ray collides with a LOS blocker's rect, and adding only visible sprites to a list.

So far, the sprites always see one another through the wall. I thought it was an iteration/indentation/too-many-loops issue. I've tried boolean-type breaks and putting the code in a function with return (at several different points in the code), but it didn't help -- the monster always head straight for the player until they hit the wall.

Picture: X-Ray Vision

It's been a few days of Google research and trial and error, but no joy. Any solves or nudges in the right direction much appreciated!

Relevant code follows.

Wall Class

Implementation

    wall = GraphBoxSprite('wall', (WIDTH // 2 - 25, HEIGHT // 2 - 10), 50, 20, BLACK, False, walls, obstacles, losBlockers, allSprites)

Code

class GraphBoxSprite(pygame.sprite.Sprite):
    # Creates a sprite the size and shape of supplied graph size
    def __init__(self, ID, position, squareWidth, squareHeight, colour, transparent=False, *groups):
        super().__init__(*groups)
        self.ID = ID
        self.position = position
        self.image = pygame.Surface((squareWidth, squareHeight)) # Blank surface
        self.rect = self.image.get_rect(topleft=position)
        self.image.fill(colour)
        if transparent is True:
            self.image.set_colorkey(colour) # Transparent background

Creature Class

Implementation

    character = CreatureSprite('character', (WIDTH // 2, HEIGHT // 2 - 50), MEDIUM, QUICK, QUIET, BLUE_LINE, CANDLELIGHT, None, None, characters, allCreatures, allSprites)

Code

class CreatureSprite(pygame.sprite.Sprite):
    # Creates a sprite to order
    def __init__(self, ID, position, size, speed, noiseLevel=None, colour=GREY, lightRadius=None, infravision=None, image=None, *groups):
        super().__init__(*groups)
        self.image = pygame.Surface(size) # Blank surface for image
        self.image.fill(WHITE)
        self.image.set_colorkey(WHITE) # Transparent background
        self.rect = self.image.get_rect(topleft=position)

        self.ID = ID
        self.size = size
        self.colour = colour
        self.lightRadius = lightRadius

        self.losLength = 600 # view distance
        self.losWidth = 150 # degrees

        self.position = Vector2(position)
        self.speed = int(speed / (GRID_SCALE * 10))
        self.velocity = Vector2(0, 0)

        self.destination = Vector2(position)
        self.destinationRadius = 40
        self.heading = self.destination - self.position

        
    def update(self, walls, dungeonDressings, obstacles, losBlockers, monsters, characters, allCreatures, dungeonLights, creatureLights, allLights, allSprites):
        # Draw the creature
        pygame.draw.circle(self.image, self.colour, (self.size[0] // 2, self.size[1] // 2), self.size[0] // 2)
        pygame.draw.line(self.image, WHITE, (self.size[0] // 2, self.size[1] // 2), (self.size[0]  // 2, self.size[1] - 20), 1)


        # Draw light over darkness and colour over light
        if self.lightRadius:
            spritePosition = (int(self.position[0]), int(self.position[1])) # syntactic sugar
            pygame.draw.circle(DARKNESS, (COLOURKEY), spritePosition, self.lightRadius * GRID_SCALE)
            pygame.draw.circle(LIGHTNESS, (GOLDENROD), spritePosition, self.lightRadius * GRID_SCALE)


        # Movement
        self.position += self.velocity  # Update the position vector first
        self.rect.center = self.position  # Update the rect afterwards

        # This vector points to the destination
        self.heading = self.destination - self.position
        distance = self.heading.length()

        # Normalize heading for scale to desired length/speed below
        if self.heading: # Cannot normalize a zero vector
            self.heading.normalize_ip()
            
            # Sprite rotation to heading
            #self.image = pygame.transform.rotate(self.image, self.heading) --- won't accept Vector2, only real number ---

            # Slow down when approaching destination
            if distance > self.destinationRadius:
                self.velocity = self.heading * self.speed
            elif distance <= 15:
                for sprite in allSprites:
                    if sprite in allCreatures or sprite in obstacles: # Creatures maintain personal space
                        self.velocity = Vector2(0, 0)
            else:
                self.velocity = self.heading * (distance / self.destinationRadius * self.speed)


        # Line of Sight
        targets = []
        visible = []
        seen = []

        for target in allSprites:
            if target != self:
                targets.append(target.ID)
                lineOfSight = target.position - self.position
                lineOfSightDist = lineOfSight.length()
                
                if lineOfSightDist < self.losLength: # Target in range
                    #if self.heading < self.losWidth: # Target in field of view --- need to convert to comparable format ---
                    x = self.position[0]
                    y = self.position[1]

                    for i in range(int(lineOfSightDist)): # Try to reach target with a cast ray
                        x += lineOfSight[0]
                        y += lineOfSight[1]
                        
                        for sprite in allSprites:
                            if sprite.rect.collidepoint(int(round(x)), int(round(y))):
                                if sprite in losBlockers: # Stop ray
                                    seen.append(sprite)
                                    visible.append(target.ID)
                                    break
                                else:
                                    seen.append(sprite)
                                    visible.append(target.ID)
                                    break

        print('{} sees {} out of {} in range'.format(self.ID, visible, targets))
        

        # Creature AI
        if self.ID == 'monster':
            if seen:
                for sprite in seen:
                    if sprite.ID == 'character':
                        self.destination = sprite.position


        # When sprites collide
        for sprite in allSprites:
            if self.rect.colliderect(sprite):
                if self in allCreatures and sprite in obstacles:
                    self.velocity = Vector2(0, 0)


    def changeColour(self, colour):
        self.colour = colour

Full Code

import os, sys, ctypes, random, math, copy, time, pickle, shelve, pprint, pygame # Inbuilt functions
import charReference, equipment # Reference material
from pygame.locals import *
from pygame.math import Vector2

pygame.init() 
pygame.display.set_caption('LOOT OR DIE!')

directory = 'C:\\Users\\user\\OneDrive\\LootOrDie\\'

pygame.font.init()


# Main fonts
font             = pygame.font.Font(directory + 'fonts\\Cour.ttf', 18)
fontB            = pygame.font.Font(directory + 'fonts\\Courbd.ttf', 18)
fontI            = pygame.font.Font(directory + 'fonts\\Couri.ttf', 18)
fontBI           = pygame.font.Font(directory + 'fonts\\Courbi.ttf', 18)
smallFont        = pygame.font.Font(directory + 'fonts\\Cour.ttf', 16)
smallFontB       = pygame.font.Font(directory + 'fonts\\Courbd.ttf', 16)
smallFontI       = pygame.font.Font(directory + 'fonts\\Couri.ttf', 16)
smallFontBI      = pygame.font.Font(directory + 'fonts\\Courbi.ttf', 16)
bigFont          = pygame.font.Font(directory + 'fonts\\Cour.ttf', 24)
bigFontB         = pygame.font.Font(directory + 'fonts\\Courbd.ttf', 24)
bigFontI         = pygame.font.Font(directory + 'fonts\\Couri.ttf', 24)
bigFontBI        = pygame.font.Font(directory + 'fonts\\Courbi.ttf', 24)


# Colours
COLOURKEY    = (127,  33,  33) # Transparent colour key

BLACK        = (  0,   0,   0)
WHITE        = (255, 255, 255)
DARKGREY     = ( 64,  64,  64)
GREY         = (128, 128, 128)
LIGHTGREY    = (200, 200, 200)

PINK_LINE    = (238, 106, 167)
RED          = (179,   0,   0)
BLOOD        = (138,   3,   3)
ORANGE       = (255, 185,   0)

GOLD         = (100,  84,   0)
GOLDENROD    = (212, 175,  55)
GREEN        = (  0, 130,   0)
FOREST_GREEN = ( 11, 102,  35)

BLUE_LINE    = (100, 149, 237)
BLUE_INK     = (  0,   0, 128)
DARK_BLUE    = (  0,  60, 120)



""" Define Screen """
# Prevent screen stretching
preventStretch = True
if preventStretch is True:
    user32 = ctypes.windll.user32
    user32.SetProcessDPIAware()


SCREEN_SIZE = WIDTH, HEIGHT = (1440, 900)
SCREEN = pygame.display.set_mode(SCREEN_SIZE, pygame.RESIZABLE) # FULLSCREEN

LEFT_MARGIN = 40
TOP_MARGIN = 26
LINE_HEIGHT = 22

COL1 = LEFT_MARGIN 
COL2 = int(SCREEN.get_width() / 4 + (LEFT_MARGIN * 2))
COL3 = int(((SCREEN.get_width() / 4) + (LEFT_MARGIN * 2)) * 2)
COL4 = int(((SCREEN.get_width() / 4) + (LEFT_MARGIN * 2)) * 3)


# Timing and FPS
clock = pygame.time.Clock()
FPS = 60

# Double mouse-click half second delay
doubleClickClock = pygame.time.Clock()
DOUBLE_MOUSE_CLICK_TIME = 500 



""" Define Buttons """
MENU_BUTTON_WIDTH = 150
MENU_BUTTON_HEIGHT = 30

LINE_BUTTON_WIDTH = int(SCREEN.get_width() / 4 - (LEFT_MARGIN * 2))
LINE_BUTTON_HEIGHT = LINE_HEIGHT - 2

EQUIP_BUTTON_WIDTH = int(SCREEN.get_width() / 2 - (LEFT_MARGIN * 2))
BODY_BUTTON_WIDTH = 255

# TextLine Buttons
col_1_textLines = []
col_2_textLines = []
col_3_textLines = []

lineNo = 0
for line in range(40): # The maximum number of lines per screen
    line = COL1, TOP_MARGIN + LINE_HEIGHT * lineNo
    col_1_textLines.append(line)
    lineNo += 1

col_2_textLines = []
lineNo = 0
for line in range(40):
    line = COL2, TOP_MARGIN + LINE_HEIGHT * lineNo
    col_2_textLines.append(line)
    lineNo += 1

col_2_textLines = []
lineNo = 0
for line in range(40):
    line = COL2, TOP_MARGIN + LINE_HEIGHT * lineNo
    col_2_textLines.append(line)
    lineNo += 1



""" Dungeon Settings """
# Graph paper coordinates
GRID_SCALE    = 5 # feet per square
SQUARE_WIDTH  = 20
SQUARE_HEIGHT = 20
GRID_MARGIN   = 1 # between squares

# Creature Sizes
TINY   = (5, 5)
SMALL  = (10, 10)
MEDIUM = (15, 15)
LARGE  = (20, 20)
HUGE   = (40, 40)

# Creature Speeds and Noise Levels
""" Run is * 10 """
STATIONARY = SILENT      = 0
SHAMBOLIC  = STEALTHY    = 15
SLUGGISH   = QUIET       = 30
SLOW       = NOISY       = 60
PLODDING   = LOUD        = 90
QUICK      = CACOPHONOUS = 120

# Light Source Radii
CANDLELIGHT       = 10
SMALL_ITEMLIGHT   = 10
MEDIUM_ITEMLIGHT  = 15
LONG_ITEMLIGHT    = 20
LANTERNLIGHT      = 30
TORCHLIGHT        = 40

# Cloak screen in darkness
DARKNESS = pygame.Surface(SCREEN_SIZE)
DARKNESS.set_colorkey(COLOURKEY)

# Mask for light colour
LIGHTNESS = pygame.Surface(SCREEN_SIZE)
LIGHTNESS.set_colorkey(COLOURKEY)


def dungeon():
    # Window for testing
    windowSize = windowWidth, windowHeight = WIDTH, HEIGHT
    window = pygame.Surface(windowSize)

    """ Sprite Groups """
    # Dungeon
    walls = pygame.sprite.Group()
    dungeonDressings = pygame.sprite.Group()
    obstacles = pygame.sprite.Group() # Anything that blocks movement
    losBlockers = pygame.sprite.Group() # Anything that blocks line of sight (LOS)

    # Creatures
    characters = pygame.sprite.Group()
    monsters = pygame.sprite.Group()
    allCreatures = pygame.sprite.Group()

    # Lights
    dungeonLights = pygame.sprite.Group()
    creatureLights = pygame.sprite.Group()
    allLights = pygame.sprite.Group()

    # Everything
    allSprites = pygame.sprite.Group()


    """ Sprites """
    # Character sprites
    character = CreatureSprite('character', (WIDTH // 2, HEIGHT // 2 - 50), MEDIUM, QUICK, QUIET, BLUE_LINE, CANDLELIGHT, None, None, characters, allCreatures, allSprites)

    # Enemy sprites
    for monster in range(1):
        orc = CreatureSprite('monster', (WIDTH // 2, HEIGHT // 2 + 50), MEDIUM, PLODDING, NOISY, BLOOD, TORCHLIGHT, None, None, monsters, allCreatures, allSprites)

    # The Wall
    wall = GraphBoxSprite('wall', (WIDTH // 2 - 25, HEIGHT // 2 - 10), 50, 20, BLACK, False, walls, obstacles, losBlockers, allSprites)


    selectedObject = None
    destinationPoint = None


    while True: # Main Loop
        clock.tick(FPS) # Set framerate

        # Capture mouse coordinates on screen and in window
        mousePos = x, y = pygame.mouse.get_pos() 
        mouseWindowPos = (x - int(WIDTH / 2 - windowWidth / 2), y - int(HEIGHT / 2 - windowHeight / 2))

        for event in pygame.event.get():
            if event.type == QUIT:
                pygame.quit()
                sys.exit()

            if event.type == KEYDOWN:
                if event.key == K_ESCAPE:
                    if selectedObject:
                        if selectedObject in characters:
                            selectedObject.changeColour(BLUE_LINE)
                        elif selectedObject in monsters:
                            selectedObject.changeColour(BLOOD)
                        selectedObject = None
                    else:
                        quitGame()

            # Set destination with mouse-click
            if event.type == MOUSEBUTTONDOWN:
                # Check for double click event
                doubleClick = False
                if doubleClickClock.tick() < DOUBLE_MOUSE_CLICK_TIME:
                    doubleClick = True

                if event.button == 1:
                    if selectedObject: # Single-click to de-select sprite
                        if selectedObject in characters:
                            selectedObject.changeColour(BLUE_LINE)
                        elif selectedObject in monsters:
                            selectedObject.changeColour(BLOOD)
                        selectedObject = None

                    elif not selectedObject: # Single-click to select new sprite
                        for sprite in allCreatures:
                            if sprite.rect.collidepoint(mouseWindowPos):
                                selectedObject = sprite
                                selectedObject.changeColour(GOLDENROD)

                elif event.button == 3:
                    if selectedObject: # Send selected sprite to destination
                        selectedObject.destination = mouseWindowPos


        # Arrange dark and light sources
        DARKNESS.fill(0)
        LIGHTNESS.fill(COLOURKEY)

        # Draw squares on coloured background to mimic graph paper
        window.fill(BLUE_LINE)
        for column in range(SQUARE_WIDTH + GRID_MARGIN, windowWidth, SQUARE_WIDTH + GRID_MARGIN):
            for row in range(SQUARE_HEIGHT + GRID_MARGIN, windowHeight, SQUARE_HEIGHT + GRID_MARGIN):
                pygame.draw.rect(window, WHITE, [column, row, SQUARE_WIDTH, SQUARE_HEIGHT])

        # Update Sprites
        allSprites.update(window, walls, dungeonDressings, obstacles, losBlockers, monsters, characters, allCreatures, dungeonLights, creatureLights, allLights, allSprites)

        # Draw items
        walls.draw(window)
        allCreatures.draw(window)

        # Draw screen overlaid with darkness overlaid by lit areas
        SCREEN.blit(window, (WIDTH // 2 - windowWidth // 2, HEIGHT // 2 - windowHeight // 2))

        # Make LIGHTNESS surface partially transparent
        LIGHTNESS.set_alpha(75)
        SCREEN.blit(LIGHTNESS, (0, 0))
        SCREEN.blit(DARKNESS, (0, 0))
        pygame.display.update()



class GraphBoxSprite(pygame.sprite.Sprite):
    # Creates a sprite the size and shape of supplied graph size
    def __init__(self, ID, position, squareWidth, squareHeight, colour, transparent=False, *groups):
        super().__init__(*groups)
        self.ID = ID
        self.position = position
        self.image = pygame.Surface((squareWidth, squareHeight)) # Blank surface
        self.rect = self.image.get_rect(topleft=position)
        self.image.fill(colour)
        if transparent is True:
            self.image.set_colorkey(colour) # Transparent background



class CreatureSprite(pygame.sprite.Sprite):
    # Creates a sprite to order
    def __init__(self, ID, position, size, speed, noiseLevel=None, colour=GREY, lightRadius=None, infravision=None, image=None, *groups):
        super().__init__(*groups)
        self.image = pygame.Surface(size) # Blank surface for image
        self.image.fill(WHITE)
        self.image.set_colorkey(WHITE) # Transparent background
        self.rect = self.image.get_rect(topleft=position)

        self.ID = ID
        self.size = size
        self.colour = colour
        self.lightRadius = lightRadius

        self.losLength = 600 # view distance
        self.losWidth = 150 # degrees

        self.position = Vector2(position)
        self.speed = int(speed / (GRID_SCALE * 10))
        self.velocity = Vector2(0, 0)

        self.destination = Vector2(position)
        self.destinationRadius = 40
        self.heading = self.destination - self.position

        
    def update(self, walls, dungeonDressings, obstacles, losBlockers, monsters, characters, allCreatures, dungeonLights, creatureLights, allLights, allSprites):
        # Draw the creature
        pygame.draw.circle(self.image, self.colour, (self.size[0] // 2, self.size[1] // 2), self.size[0] // 2)
        pygame.draw.line(self.image, WHITE, (self.size[0] // 2, self.size[1] // 2), (self.size[0]  // 2, self.size[1] - 20), 1)


        # Draw light over darkness and colour over light
        if self.lightRadius:
            spritePosition = (int(self.position[0]), int(self.position[1])) # syntactic sugar
            pygame.draw.circle(DARKNESS, (COLOURKEY), spritePosition, self.lightRadius * GRID_SCALE)
            pygame.draw.circle(LIGHTNESS, (GOLDENROD), spritePosition, self.lightRadius * GRID_SCALE)


        # Movement
        self.position += self.velocity  # Update the position vector first
        self.rect.center = self.position  # Update the rect afterwards

        # This vector points to the destination
        self.heading = self.destination - self.position
        distance = self.heading.length()

        # Normalize heading for scale to desired length/speed below
        if self.heading: # Cannot normalize a zero vector
            self.heading.normalize_ip()
            
            # Sprite rotation to heading
            #self.image = pygame.transform.rotate(self.image, self.heading) --- won't accept Vector2, only real number ---

            # Slow down when approaching destination
            if distance > self.destinationRadius:
                self.velocity = self.heading * self.speed
            elif distance <= 15:
                for sprite in allSprites:
                    if sprite in allCreatures or sprite in obstacles: # Creatures maintain personal space
                        self.velocity = Vector2(0, 0)
            else:
                self.velocity = self.heading * (distance / self.destinationRadius * self.speed)


        # Line of Sight
        targets = []
        visible = []
        seen = []

        for target in allSprites:
            if target != self:
                targets.append(target.ID)
                lineOfSight = target.position - self.position
                lineOfSightDist = lineOfSight.length()
                
                if lineOfSightDist < self.losLength: # Target in range
                    #if self.heading < self.losWidth: # Target in field of view --- need to convert to comparable format ---
                    x = self.position[0]
                    y = self.position[1]

                    for i in range(int(lineOfSightDist)): # Try to reach target with a cast ray
                        x += lineOfSight[0]
                        y += lineOfSight[1]
                        
                        for sprite in allSprites:
                            if sprite.rect.collidepoint(int(round(x)), int(round(y))):
                                if sprite in losBlockers: # Stop ray
                                    seen.append(sprite)
                                    visible.append(target.ID)
                                    break
                                else:
                                    seen.append(sprite)
                                    visible.append(target.ID)
                                    break

        print('{} sees {} out of {} in range'.format(self.ID, visible, targets))
        

        # Creature AI
        if self.ID == 'monster':
            if seen:
                for sprite in seen:
                    if sprite.ID == 'character':
                        self.destination = sprite.position


        # When sprites collide
        for sprite in allSprites:
            if self.rect.colliderect(sprite):
                if self in allCreatures and sprite in obstacles:
                    self.velocity = Vector2(0, 0)


    def changeColour(self, colour):
        self.colour = colour



def quitGame():
    pygame.quit()
    sys.exit()



def waitForKeyPress():
    # Pause program until a key is pressed
    while True:
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                pygame.quit()
                sys.exit()

            if event.type == pygame.KEYDOWN or event.type == pygame.MOUSEBUTTONDOWN:
                return



""" Start Game """
if __name__ == '__main__':
    dungeon()

3 Answers

I can't see just from looking at a section of the static code why your program does not work, it seems like it should.

I can however, offer my own solution though:

For each NPC/opponent:

  • Calculate the line between each NPC and the Player. Here we just use the centre of the sprite rectangle because it's easy. Think of this as the "ray" if you like.
  • Determine if any Wall/Blocker object lies on this line/ray.
    • If so, the line of sight is broken, and we can stop checking.

So the line of sight is simply (Player-x, Player-y) to (NPC-x, NPC-y), defining a line.

Each Wall object is four lines (forming a rectangle), so we check for intersection of the Player-NPC line with each of the rectangle-side lines. This simplifies the check down to some linear geometry with no trigonometry or square-roots. There's also no need to "walk" along the line-of-sight checking for occlusions, once anything is found on the line, that NPC can't see the Player.

Note: we do not check for NPCs occluding the vision of other NPCs, by design.

line_of_sight demo

Reference Code:

import pygame
import random

WINDOW_WIDTH = 800
WINDOW_HEIGHT= 600
SURFACE = pygame.HWSURFACE|pygame.DOUBLEBUF|pygame.RESIZABLE

#define colour
WHITE = ( 200, 200, 200)
GREY  = ( 50, 50, 50)
BLACK = (0,0,0)
RED   = ( 200, 0, 0 )
GREEN = (0,255,0)
TAN   = (240,171,15)
YELLOW= (255,255,0)


def lineRectIntersectionPoints( line, rect ):
    """ Get the list of points where the line and rect
        intersect,  The result may be zero, one or two points.

        BUG: This function fails when the line and the side
             of the rectangle overlap """

    def linesAreParallel( x1,y1, x2,y2, x3,y3, x4,y4 ):
        """ Return True if the given lines (x1,y1)-(x2,y2) and
            (x3,y3)-(x4,y4) are parallel """
        return (((x1-x2)*(y3-y4)) - ((y1-y2)*(x3-x4)) == 0)

    def intersectionPoint( x1,y1, x2,y2, x3,y3, x4,y4 ):
        """ Return the point where the lines through (x1,y1)-(x2,y2)
            and (x3,y3)-(x4,y4) cross.  This may not be on-screen  """
        #Use determinant method, as per
        #Ref: https://en.wikipedia.org/wiki/Line%E2%80%93line_intersection
        Px = ((((x1*y2)-(y1*x2))*(x3 - x4)) - ((x1-x2)*((x3*y4)-(y3*x4)))) / (((x1-x2)*(y3-y4)) - ((y1-y2)*(x3-x4)))
        Py = ((((x1*y2)-(y1*x2))*(y3 - y4)) - ((y1-y2)*((x3*y4)-(y3*x4)))) / (((x1-x2)*(y3-y4)) - ((y1-y2)*(x3-x4)))
        return Px,Py

    ### Begin the intersection tests
    result = []
    line_x1, line_y1, line_x2, line_y2 = line   # split into components
    pos_x, pos_y, width, height = rect

    ### Convert the rectangle into 4 lines
    rect_lines = [ ( pos_x, pos_y, pos_x+width, pos_y ), ( pos_x, pos_y+height, pos_x+width, pos_y+height ),  # top & bottom
                   ( pos_x, pos_y, pos_x, pos_y+height ), ( pos_x+width, pos_y, pos_x+width, pos_y+height ) ] # left & right

    ### intersect each rect-side with the line
    for r in rect_lines:
        rx1,ry1,rx2,ry2 = r
        if ( not linesAreParallel( line_x1,line_y1, line_x2,line_y2, rx1,ry1, rx2,ry2 ) ):    # not parallel
            pX, pY = intersectionPoint( line_x1,line_y1, line_x2,line_y2, rx1,ry1, rx2,ry2 )  # so intersecting somewhere
            pX = round( pX )
            pY = round( pY )
            # Lines intersect, but is on the rectangle, and between the line end-points?
            if ( rect.collidepoint( pX, pY )   and
                 pX >= min( line_x1, line_x2 ) and pX <= max( line_x1, line_x2 ) and
                 pY >= min( line_y1, line_y2 ) and pY <= max( line_y1, line_y2 ) ):
                pygame.draw.circle( window, WHITE, ( pX, pY ), 4 )
                result.append( ( pX, pY ) )                                     # keep it
                if ( len( result ) == 2 ):
                    break   # Once we've found 2 intersection points, that's it
    return result



class Wall( pygame.sprite.Sprite):
    """ Rectangular objects that blocks line-of-sight """
    def __init__( self, x, y, width, height ):
        pygame.sprite.Sprite.__init__( self )
        self.image = pygame.Surface( ( width, height ) )
        self.rect  = self.image.get_rect();
        self.rect.topleft = ( x, y )
        self.image.fill( TAN )

    def getRect( self ):
        return self.rect
        

class Being( pygame.sprite.Sprite):
    """ Some kind of creature with miraculous 360 degree vision """
    def __init__( self, x, y, colour=YELLOW, size=48 ):
        pygame.sprite.Sprite.__init__( self )
        self.colour= colour
        self.image = pygame.Surface( ( size, size ), pygame.SRCALPHA )
        self.rect  = self.image.get_rect();
        self.rect.center = ( x, y )
        self.size  = size
        self.seen  = False
        self.update()

    def update( self ):
        """ If we've been seen, go red with embrassment """
        if ( self.seen ):
            colour = RED
        else:
            colour = self.colour
        pygame.draw.circle( self.image, colour, ( self.size//2, self.size//2 ), self.size//2 )

    def setSeen( self, seen=True ):
        self.seen = seen

    def getCentre( self ):
        return self.rect.center

    def getRect( self ):
        return self.rect




### initialisation
pygame.init()
pygame.mixer.init()
window = pygame.display.set_mode( ( WINDOW_WIDTH, WINDOW_HEIGHT ), SURFACE )
pygame.display.set_caption("Line of Sight")

# Occluders
wall_sprites = pygame.sprite.Group()
central_block = Wall( WINDOW_WIDTH//3, WINDOW_HEIGHT//2, WINDOW_WIDTH//3, 20 )  # top middle (random)
wall_sprites.add( central_block )

# NPCs
npc_sprites = pygame.sprite.Group()
for i in range( 3 ):
    npc_sprites.add( Being( random.randint( 50, WINDOW_WIDTH-50 ), 50, GREEN ) )

# Player
player_sprite = pygame.sprite.GroupSingle()
player = Being( WINDOW_WIDTH//2, 3*WINDOW_HEIGHT//4 )  # bottom middle
player_sprite.add ( player )


### Main Loop
clock = pygame.time.Clock()
done = False
while not done:

    # Handle user-input
    for event in pygame.event.get():
        if ( event.type == pygame.QUIT ):
            done = True
        elif ( event.type == pygame.MOUSEBUTTONUP ):
            # On mouse-click
            pass

    # Movement keys
    keys = pygame.key.get_pressed()
    if ( keys[pygame.K_LEFT] ):
        player.rect.x -= 1
    if ( keys[pygame.K_RIGHT] ):
        player.rect.x += 1
    if ( keys[pygame.K_UP] ):
        player.rect.y -= 1
    if ( keys[pygame.K_DOWN] ):
        player.rect.y += 1


    # Update the window, but not more than 60fps
    window.fill( BLACK )

    # Check to see if the Player can see any NPCs
    player_centre = player.getCentre()
    for npc in npc_sprites:
        npc_centre = npc.getCentre()
        # Does the line <player> to <npc> intersect any obstacles?
        line_of_sight = [ player_centre[0], player_centre[1], npc_centre[0], npc_centre[1] ]
        found = True
        for wall in wall_sprites:
            # is anyting blocking the line-of-sight?
            intersection_points = lineRectIntersectionPoints( line_of_sight, wall.getRect() )
            if ( len( intersection_points ) > 0 ):
                found = False
                break # seen already
        # Highlight anyone found
        npc.setSeen( found )
        if ( found ):
            pygame.draw.line( window, WHITE, player_centre, npc_centre )
        else:
            pygame.draw.line( window, GREY, player_centre, npc_centre )
                
    # draw the sprites
    wall_sprites.draw( window )
    npc_sprites.update()
    npc_sprites.draw( window )
    player_sprite.draw( window )

    pygame.display.flip()
    clock.tick_busy_loop(60)


pygame.quit()

Thanks for the input, all. The solution was indeed a bug in my active code, @Kingsley.

While the unseen sprites are still being added to the list of what's seen, I was able to add a single line at the end to remove an unseen sprites from the list.

It's gratifying to know my original code was working, but your visual lines of sight helped me isolate the issue, improve my sprite system, and develop a more robust raycasting system--so thank you!

You'll find visible.remove ( target ) at the end of the Line of Sight for loop:

while True: # Main Loop
    # Capture mouse coordinates on screen and in window
    mousePos = x, y = pygame.mouse.get_pos() 

    for event in pygame.event.get():
        if event.type == QUIT:
            pygame.quit()
            sys.exit()

        if event.type == KEYDOWN:
            if event.key == K_ESCAPE:
                if selectedObject:
                    if selectedObject in characters:
                        selectedObject.selected = False
                    selectedObject = None
                else:
                    quitGame()

        # Set destination with mouse-click
        if event.type == MOUSEBUTTONDOWN:
            # Check for double click event
            doubleClick = False
            if doubleClickClock.tick() < DOUBLE_MOUSE_CLICK_TIME:
                doubleClick = True

            if event.button == 1:
                if selectedObject: # Single-click to de-select sprite
                    if selectedObject in characters:
                        selectedObject.selected = False
                    elif selectedObject in monsters:
                        selectedObject.selected = False
                    selectedObject = None

                if not selectedObject: # Single-click to select new sprite
                    for sprite in allCreatures:
                        if sprite.rect.collidepoint( mousePos ):
                            selectedObject = sprite
                            selectedObject.selected = True

            if event.button == 3:
                if selectedObject: # Send selected sprite to destination
                    selectedObject.destination = mousePos

    # Refresh the screen
    clock.tick( FPS ) # Set framerate
    window.fill( WHITE )

    # Arrange dark and light sources
    DARKNESS.fill( 0 )
    LIGHTNESS.fill( COLOURKEY )

    # Draw Graph Paper
    window.fill( BLUE_LINE )
    for column in range( 0 + GRID_MARGIN, WIDTH, SQUARE_WIDTH + GRID_MARGIN ):
        for row in range( 0 + GRID_MARGIN, HEIGHT, SQUARE_HEIGHT + GRID_MARGIN ):
            pygame.draw.rect( window, WHITE, [column, row, SQUARE_WIDTH, SQUARE_HEIGHT] )

    # Check for creature line of sight
    targets = []
    visible = []
    for viewer in allCreatures:
        viewerCenter = viewer.getCenter()
        for target in allCreatures:
            targetCenter = target.getCenter()
            targets.append(target)

            # Does the line of sight intersect any obstacles?
            lineOfSight = [ viewerCenter[0], viewerCenter[1], targetCenter[0], targetCenter[1] ]
            seen = True

            for blocker in losBlockers:
                # Is anything blocking line of sight?
                intersectionPoints = lineRectIntersectionPoints( lineOfSight, blocker.getRect() )
                if ( len( intersectionPoints ) > 0 ):
                    seen = False
                    break # seen already

            # Highlight anyone found
            target.setSeen( seen )

            if ( seen ):
                pygame.draw.line( window, RED, viewerCenter, targetCenter )
                visible.append( target )
            else:
                pygame.draw.line( window, GREY, viewerCenter, targetCenter )
                if target in visible:
                    visible.remove ( target )
    
        # Creature AI
        if viewer.ID == 'monster':
            if visible:
                for sprite in visible:
                    if sprite.ID == 'character':
                        viewer.destination = sprite.position

    # Update Sprites
    allSprites.update( obstacles, allCreatures, allSprites )

    # Draw items
    allSprites.draw( window )

    # Make LIGHTNESS surface partially transparent
    LIGHTNESS.set_alpha( 75 )
    window.blit( LIGHTNESS, ( 0, 0 ) )
    window.blit( DARKNESS, ( 0, 0 ) )

    pygame.display.update()

I think I have a way of doing it right here. I this what you want?:

import pygame
import random

Randomx = random.randint(1,450)
Randomy = random.randint(1,450)

Wallx = Randomx - 50
Wally = Randomy

screen = pygame.display.set_mode([500,500])

x = 225
y = 225
running = True

while running:
    keys = pygame.key.get_pressed()
    screen.fill([255,255,255])
    if keys[pygame.K_LEFT]:
        #move
        x -= .2
        #make shure it isn't off the screen
        if x <= 0:
            x = 0
    if keys[pygame.K_RIGHT]:
        x += .2
        if x >= 450:
            x = 450
    if keys[pygame.K_UP]:
        y -= .2
        if y <= 0:
            y = 0
    if keys[pygame.K_DOWN]:
        y += .2
        if y >= 450:
            y = 450
    #Red's Vision
    vision1 = pygame.draw.rect(screen, [0,0,0], (0,y,500,50))
    vision2 = pygame.draw.rect(screen, [0,0,0], (x,0,50,500))
    #-----------------------------------------------
    #Red
    red = pygame.draw.rect(screen, [255,0,0], (x,y,50,50))
    #Blue
    blue = pygame.draw.rect(screen, [0,0,255], (Randomx,Randomy,50,50))
    #Wall
    wall = pygame.draw.rect(screen, [0,255,0], (Wallx,Wally,25,50))
    #Collisoin Detection/Vision
    if vision1.colliderect(blue):
        SeeBlock = True
        if x >= Randomx:
            w = x - Randomx
        if x <= Randomx:
            w = Randomx - x
        if x >= Randomx:
            vision3 = pygame.draw.rect(screen, [0,0,0], (x,y,-w,50))
            if vision3.colliderect(wall):
                SeeBlock = False
        if x <= Randomx:
            vision3 = pygame.draw.rect(screen, [0,0,0], (x,y,w,50))
            if vision3.colliderect(wall):
                SeeBlock = False
        red = pygame.draw.rect(screen, [255,0,0], (x,y,50,50))
        blue = pygame.draw.rect(screen, [0,0,255], (Randomx,Randomy,50,50))
        wall = pygame.draw.rect(screen, [0,255,0], (Wallx,Wally,25,50))
        if SeeBlock == True:
            print("I SEE IT!")
        
    if vision2.colliderect(blue):
        SeeBlock = True
        if y >= Randomx:
            w = y - Randomy
        if y <= Randomx:
            w = y + Randomy
        if y >= Randomy:
            vision3 = pygame.draw.rect(screen, [0,0,0], (x,y,-w,50))
            if vision3.colliderect(wall):
                SeeBlock = False
        if y <= Randomy:
            vision3 = pygame.draw.rect(screen, [0,0,0], (x,y,w,50))
            if vision3.colliderect(wall):
                SeeBlock = False
        red = pygame.draw.rect(screen, [255,0,0], (x,y,50,50))
        blue = pygame.draw.rect(screen, [0,0,255], (Randomx,Randomy,50,50))
        wall = pygame.draw.rect(screen, [0,255,0], (Wallx,Wally,25,50))
        if SeeBlock == True:
            print("I SEE IT!")
    pygame.display.flip()
    for event in pygame.event.get():
        if event.type == pygame.KEYDOWN:
            if event.key == pygame.K_ESCAPE:
                running = False
                pygame.quit()
Related