python 3.x – Tile based lighting system 2d

For 2D games like you’re making, how we could apply lighting – more like, shadowing – could go into 2 options:

  1. Change screen color to shadow color & set transparency to objects, as OP suggested
  2. Sandwich entire thing between screen and light layer

Let’s start with problem of 1st option:


Problem of setting transparency

Here’s demo code based on your idea:

"""
Demonstration of color overlapping
"""

import pygame as pg


class Player(pg.sprite.Sprite):
    def __init__(self):
        super(Player, self).__init__()
        self.image = pg.Surface((50, 50))
        self.image.fill((255, 255, 255))
        self.rect = self.image.get_rect()

        # setting alpha on player
        self.image.set_alpha(125)

    def update(self, *args, **kwargs):
        x, y = pg.mouse.get_pos()
        c_x, c_y = self.rect.center
        self.rect.move_ip(x - c_x, y - c_y)


def mainloop():
    player = Player()
    screen = pg.display.set_mode((500, 500))

    circle_colors = (255, 0, 0), (0, 255, 0), (0, 0, 255)
    circle_coords = (150, 250), (250, 250), (350, 250)

    # make surface, set alpha then draw circle
    bg_surfaces = []
    for (color, center) in zip(circle_colors, circle_coords):
        surface = pg.Surface((500, 500), pg.SRCALPHA, 32)
        surface.convert_alpha()
        surface.set_alpha(125)
        pg.draw.circle(surface, color, center, 75)
        bg_surfaces.append(surface)

    running = True

    while running:
        screen.fill((0, 0, 0))

        # draw background
        for surface in bg_surfaces:
            screen.blit(surface, surface.get_rect())

        for event in pg.event.get():
            if event.type == pg.QUIT:
                running = False

        player.update()
        screen.blit(player.image, player.rect)
        pg.display.flip()


if __name__ == '__main__':
    pg.init()
    mainloop()
    pg.quit()

As you see, now the player (White square)’s color is Mixed with background circles.

It’s basically just like what the drawing program does with layers. Set layer transparency 50% and stack – everything mixes, producing an undesirable effect which is far from the lighting effect you wanted.

CSP demo

Unless you want Creeper or Steve to blend with the background and become a ghosty figure, it’s better to go for sandwiched layout.


Sandwiched Layout

Following is demo code which uses mouse position as light source position.

Rendering order is Ground > Player > light overlay(shadow)

Demo code:

"""
Code demonstration for https://stackoverflow.com/q/72610504/10909029
Written on Python 3.10 (Using Match on input / event dispatching)
"""

import math
import random
import itertools
from typing import Dict, Tuple, Sequence

import pygame as pg


class Position:
    """Namespace for size and positions"""
    tile_x = 20
    tile_size = tile_x, tile_x


class SpriteGroup:
    """Namespace for sprite groups, with chain iterator keeping the order"""
    ground = pg.sprite.Group()
    entities = pg.sprite.Group()
    light_overlay = pg.sprite.Group()

    @classmethod
    def all_sprites(cls):
        return itertools.chain(cls.ground, cls.entities, cls.light_overlay)


class Player(pg.sprite.Sprite):
    """Player class, which is merely a rect following pointer in this example."""
    def __init__(self):
        super(Player, self).__init__()
        self.image = pg.Surface((50, 50))
        self.image.fill((255, 255, 255))
        self.rect = self.image.get_rect()

        SpriteGroup.entities.add(self)

        self.rect.move_ip(225, 225)

    def update(self, *args, **kwargs):
        pass
        # Intentionally disabling mouse following code
        # x, y = pg.mouse.get_pos()
        # c_x, c_y = self.rect.center
        # self.rect.move_ip(x - c_x, y - c_y)


class TileLightOverlay(pg.sprite.Sprite):
    """
    Light overlay tile. Using separate sprites, so we don't have to blit on
    every object above ground that requires lighting.
    """

    # light lowest boundary
    lighting_lo = 255

    # light effect radius
    light_radius = Position.tile_x * 8

    def __init__(self, x, y):
        super(TileLightOverlay, self).__init__()

        self.image = pg.Surface(Position.tile_size)
        self.image.fill((0, 0, 0))

        self.rect = self.image.get_rect()
        self.rect.move_ip(x * Position.tile_x, y * Position.tile_x)

        SpriteGroup.light_overlay.add(self)

    def update(self, *args, **kwargs):
        self.image.set_alpha(self.brightness)

    @property
    def brightness(self):
        """Calculate distance between mouse & apply light falloff accordingly"""
        distance = math.dist(self.rect.center, pg.mouse.get_pos())

        if distance > self.light_radius:
            return self.lighting_lo

        return (distance / self.light_radius) * self.lighting_lo


class TileGround(pg.sprite.Sprite):
    """Ground tile representation. Not much is going on here."""

    def __init__(self, x, y, tile_color: Sequence[float]):
        super(TileGround, self).__init__()

        self.image = pg.Surface(Position.tile_size)
        self.image.fill(tile_color)

        self.rect = self.image.get_rect()
        self.rect.move_ip(x * Position.tile_x, y * Position.tile_x)

        SpriteGroup.ground.add(self)

        # create and keep its pair light overlay tile.
        self.light_tile = TileLightOverlay(x, y)


class World:
    """World storing ground tile data."""
    # tile type storing color etc. for this example only have color.
    tile_type: Dict[int, Tuple[float, float, float]] = {
        0: (56, 135, 93),
        1: (36, 135, 38),
        2: (135, 128, 56)
    }

    def __init__(self):
        # coord system : +x → / +y ↓
        # generating random tile data
        self.tile_data = [
            [random.randint(0, 2) for _ in range(25)]
            for _ in range(25)
        ]
        # generated tiles
        self.tiles = []

    def generate(self):
        """Generate world tiles"""
        for x, row in enumerate(self.tile_data):
            tiles_row = [TileGround(x, y, self.tile_type[col]) for y, col in enumerate(row)]
            self.tiles.append(tiles_row)


def process_input(event: pg.event.Event):
    """Process input, in case you need it"""
    match event.key:
        case pg.K_ESCAPE:
            pg.event.post(pg.event.Event(pg.QUIT))
        case pg.K_UP:
            pass
        # etc..


def display_fps_closure(screen: pg.Surface, clock: pg.time.Clock):
    """FPS display"""
    font_name = pg.font.get_default_font()
    font = pg.font.Font(font_name, 10)
    color = (0, 255, 0)

    def inner():
        text = font.render(f"{int(clock.get_fps())} fps", True, color)
        screen.blit(text, text.get_rect())

    return inner


def mainloop():
    # keeping reference of method/functions to reduce access overhead
    fetch_events = pg.event.get
    display = pg.display

    # local variable setup
    screen = display.set_mode((500, 500))
    player = Player()
    world = World()
    world.generate()

    clock = pg.time.Clock()
    display_fps = display_fps_closure(screen, clock)

    running = True

    # main loop
    while running:
        screen.fill((0, 0, 0))

        # process event
        for event in fetch_events():
            # event dispatch
            match event.type:
                case pg.QUIT:
                    running = False
                case pg.KEYDOWN:
                    process_input(event)

        # draw in ground > entities > light overlay order
        for sprite in SpriteGroup.all_sprites():
            sprite.update()
            screen.blit(sprite.image, sprite.rect)

        # draw fps - not related to question, was lazy to remove & looks fancy
        clock.tick()
        display_fps()

        display.flip()


if __name__ == '__main__':
    pg.init()
    pg.font.init()
    mainloop()
    pg.quit()

Demo image

You’ll see it’s blending properly with shadow without mixing color with ground tiles.


There could be much better approach or ways to implement this – as I never used pygame before, there would be a bunch of good/better stuffs I didn’t read on the document.

But one thing for sure – always approach your goal with mindset that everything is related to your problem until you reach the goal! Comment you thought it wasn’t going to be helpful gave me idea for this design.

Leave a Comment