coder
coder

Reputation: 137

Tile based lighting system 2d

I am looking for a tile based lighting system for my tile based game. I have not tried anything because I can't think of an effective way to do this. I have searched stack overflow and I found this but its not what I want. I am making a 2d version of Minecraft with pygame.

here is my tile class

class tile():
    def __init__(self, block_category, block_type, x, y, world, win):
        self.x, self.y, self.width, self.height = (x*64), (y*64), 64, 64
        self.block_type = block_type
        self.light_level = 1 # i want light level to range from 0-1
        self._image = None
        self.world = world
        self.win = win
        self.posx, self.posy = x, y
        try:
            self._image = self.world.block_textures[block_category][block_type]
        except:
            self._image = self.world.block_textures["missing"]["missing_texture"]
        self.image = self._image

    def draw(self):
        #draw code here self.posx, self.win, self.world and self.posy are used here if you are wondering
    
    def change_block(self, block_category, block_type):
        try:
            self._image = self.world.block_textures[block_category][block_type]
        except:
            self._image = self.world.block_textures["missing"]["missing_texture"]
        self.image = self._image

and my world data looks like this

def generate_world(self):
    for x in range(0, self.width):
        self.tiles[x] = {}
        for y in range(0, self.height):
            self.tiles[x][y] = tile("terrain", "air", x, y, self, self.win)
        
    for x in range(0, self.width):
        for y in range(0, self.height):
            if y == 0:
                self.tiles[x][y].change_block("terrain", "bedrock")
            elif y == 38:
                self.tiles[x][y].change_block("terrain", "grass_block")
            elif y < 38 and y > 34:
                self.tiles[x][y].change_block("terrain", "dirt")
            elif y < 35 and y > 0:
                self.tiles[x][y].change_block("terrain", "stone")
            if x == 0 or x == self.height - 1:
                self.tiles[x][y].change_block("terrain", "bedrock")
        
    return self.tiles

my game looks like this my game looks like this

Upvotes: 1

Views: 545

Answers (2)

coder
coder

Reputation: 137

One option is a black background, then I use set_alpha() to set how light or dark the tile is (how much the black background is seen through the tile) and no overlay is needed. Thanks to @jupiterbjy's original answer for inspiration.

Upvotes: 0

jupiterbjy
jupiterbjy

Reputation: 3503

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()

player color mixed with background

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 undesirable effect which is far from 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 bunch of good/better stuffs I didn't read on 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.

Upvotes: 4

Related Questions