kivy对pytmx的支持类库


pytmx只有支持pygame和pyglet的,没有针对kivy的支持,封装了一个类库:

import os
import glob
import logging
import xml.etree.ElementTree as ET
from kivy.app import App
from kivy.uix.button import Button
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.label import Label
from kivy.uix.widget import Widget
from kivy.graphics import Color, Rectangle, Line
from kivy.core.window import Window


from kivy.graphics.texture import Texture
from PIL import Image as PILImage
from typing import Optional, Tuple, Callable

import pytmx
from pytmx import TileFlags
from pytmx import TiledImageLayer, TiledObjectGroup, TiledTileLayer #TiledGroupLayer
from pytmx.pytmx import ColorLike, PointLike

from typing import Optional, Union, List
import random  # 引入随机数模块


logger = logging.getLogger(__name__)




def load_tmx(
    filename: str,
    *args,
    **kwargs,
) -> pytmx.TiledMap:
    """Load a TMX file, images, and return a TiledMap class

    PYGAME USERS: Use me.

    this utility has 'smart' tile loading.  by default any tile without
    transparent pixels will be loaded for quick blitting.  if the tile has
    transparent pixels, then it will be loaded with per-pixel alpha.  this is
    a per-tile, per-image check.

    if a color key is specified as an argument, or in the tmx data, the
    per-pixel alpha will not be used at all. if the tileset's image has colorkey
    transparency set in Tiled, the util_pygam will return images that have their
    transparency already set.

    TL;DR:
    Don't attempt to convert() or convert_alpha() the individual tiles.  It is
    already done for you.

    Parameters:
        filename: filename to load

    Returns:
        new pytmx.TiledMap object

    """
    kwargs["image_loader"] = image_loader
    return pytmx.TiledMap(filename, *args, **kwargs)




#def image_loader(filename: str, colorkey: Optional[ColorLike], **kwargs):
def image_loader(filename: str, colorkey: Optional[ColorLike], **kwargs) -> Callable:
    """
    Image loader for Kivy.

    Parameters:
        filename: filename, including path, to load.
        colorkey: colorkey for the image.

    Returns:
        function to load tile images.
    """
    # 加载图像
    image = PILImage.open(filename)

    # 如果指定了颜色键,处理颜色键
    if colorkey:
        colorkey = tuple(int(colorkey[i:i + 2], 16) for i in (1, 3, 5))
        image = image.convert("RGBA")
        data = image.getdata()

        # 替换颜色键为透明
        new_data = []
        for item in data:
            if item[0:3] == colorkey:
                new_data.append((0, 0, 0, 0))  # 变为透明
            else:
                new_data.append(item)
        image.putdata(new_data)

    pixelalpha = kwargs.get("pixelalpha", True)

    # 载入一个小瓦片,一个瓦片会被调用一次
    def load_image(rect=None, flags=None) -> PILImage.Image:
        #logger.info(["rectAndFlags:", rect, bool(flags), flags])
        if rect:
            """将(left, top, width, height) 转换为 (left, top, right, bottom)"""
            left, top, width, height = rect
            right = left + width
            bottom = top + height
            rect = (left, top, right, bottom)

            try:
                tile = image.crop(rect)
            except ValueError:
                logger.error("Tile bounds outside bounds of tileset image")
                raise
        else:
            tile = image.copy()

        #是否需要旋转翻转之类的调整
        if flags:
            tile = handle_transformation(tile, flags)
            
        #logger.info(["rectAndFlags-size:", filename, rect, tile.size])

        # 这个可有可无
        tile = smart_convert(tile, colorkey, pixelalpha)
        return tile

    return load_image


def smart_convert(original: PILImage.Image, colorkey: Optional[ColorLike], pixelalpha: bool) -> PILImage.Image:
    """
    Return a new Image with optimal pixel/data format.

    Parameters:
        original: tile image to inspect
        colorkey: optional colorkey for the tileset image
        pixelalpha: if true, prefer per-pixel alpha surfaces

    Returns:
        new tile image
    """
    #logger.info(["处理聪明转变"])
    if colorkey:
        # Convert to RGBA and apply color key
        original = original.convert("RGBA")
        data = original.getdata()

        # Create a new data list for the image
        new_data = []
        for item in data:
            if item[:3] == colorkey:  # Compare RGB
                new_data.append((0, 0, 0, 0))  # Transparent
            else:
                new_data.append(item)

        # Update the image data
        original.putdata(new_data)

    else:
        # Check for transparent pixels
        original = original.convert("RGBA")
        data = original.getdata()

        has_transparency = any(item[3] < 255 for item in data)

        if not has_transparency:
            # No transparent pixels, convert to RGB
            original = original.convert("RGB")
        elif pixelalpha:
            # Prefer per-pixel alpha
            pass  # No change needed, already RGBA
        else:
            # If we don't handle transparency, convert to RGB
            original = original.convert("RGB")

    return original







def handle_transformation(tile: PILImage.Image, flags: TileFlags) -> PILImage.Image:
    """
    Transform tile according to the flags and return a new one.

    Parameters:
        tile: tile surface to transform (Pillow Image).
        flags: TileFlags object.

    Returns:
        new tile surface.
    """
    #logger.info(["处理转化",tile, flags])
    # 旋转 270 度
    if flags.flipped_diagonally:
        logger.info(["开始旋转角度",tile])
        tile = tile.rotate(270, expand=True)

    # 水平翻转或垂直翻转
    if flags.flipped_horizontally:
        logger.info(["开始水平翻转",tile])
        tile = tile.transpose(PILImage.FLIP_LEFT_RIGHT)
    if flags.flipped_vertically:
        logger.info(["开始垂直翻转", tile])
        tile = tile.transpose(PILImage.FLIP_TOP_BOTTOM)

    return tile


"""强势转化PIL为KIVY纹理"""
def pil_image_to_texture(pil_image:PILImage) -> Texture:
    """
    Convert a PIL Image to a Kivy Texture
    """
    # Ensure the image is in RGBA mode
    pil_image = pil_image.convert("RGBA")
    
    # Get image dimensions
    width, height = pil_image.size
    
    # Get the image data
    img_data = pil_image.tobytes()

    # Create a new texture object
    texture = Texture.create(size=(width, height))
    
    # Flip the image data vertically (PIL and Kivy texture coordinates are inverted)
    texture.flip_vertical()
    
    # Load the image data into the texture
    texture.blit_buffer(img_data, colorfmt='rgba', bufferfmt='ubyte')

    return texture



'''
瓦片地图渲染
'''
class TiledRenderer(Widget):
    def __init__(self, **kwargs):
        super(TiledRenderer, self).__init__(**kwargs)
        self.maps = glob.glob('data/*.tmx')
        self.current_map_index = 0
        self.map_label = Label(text='loadMap', size_hint=(1, 0.1))
        self.add_widget(self.map_label)
        self.load_map()
        self.tmx = None
        self.pixel_size = None

        layout = BoxLayout(size_hint=(1, None), height=50)
        next_button = Button(text='show next MAP →')
        next_button.bind(on_press=self.next_map)
        layout.add_widget(next_button)

        self.add_widget(layout)


    def hex_to_rgb(self, hex_color):
        """Convert hex color string to an (r, g, b) tuple."""
        hex_color = hex_color.lstrip('#')
        return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))


    def load_map(self):
        """加载当前地图"""
        if not self.maps:
            raise FileNotFoundError("未找到任何 TMX 文件。")
        
        try:
            current_map = self.maps[self.current_map_index]
            self.tmx = load_tmx(current_map)
            self.pixel_size = self.tmx.width * self.tmx.tilewidth, self.tmx.height * self.tmx.tileheight

            logger.info(f"载入地图成功啦啦啦啦啦: {os.path.basename(current_map)}")
            
            #logger.debug("layers:self.tmx::::",self.tmx)
            if self.tmx:
                self.map_label.text = f"currentMap: {os.path.basename(current_map)}"
                #logger.debug(self.map_label.text)

                self.draw_map()
                logger.debug(f"渲染地图成功啦啦啦啦啦: {os.path.basename(current_map)}")

                
                return None

        except Exception as e:
            #raise e
            logger.error(f"加载地图时出现异常: {e}")

        logger.error(f"发生错误!!!开始打印空地图啊啊啊啊啊啊啊啊啊啊啊啊啊啊啊啊啊啊")
        self.draw_empty_map()




    '''开始画图囖'''
    def draw_map(self) -> None:
        """Render our map to a Kivy canvas"""
        """绘制地图"""
        self.canvas.clear()

        # fill the background color of our render surface
        if self.tmx.background_color:
            r, g, b = self.hex_to_rgb(self.tmx.background_color)
            with self.canvas:
                Color(r / 255.0, g / 255.0, b / 255.0)
                #Color(0.5, 0.5, 0.5, 1) 
                Rectangle(pos=(0, 0), size=(self.tmx.width * self.tmx.tilewidth, self.tmx.height * self.tmx.tileheight))
        # else:
        #     with self.canvas:
        #         # 使用随机颜色块
        #         Color( random.random(), random.random(), random.random(), 1)  # 随机颜色RGB
        #         Rectangle(pos=(0, 0), size=(500,500))

        '''
        # 确保绘制瓦片,即使解码失败
        for y in range(10):  # 假设地图高度为10
            for x in range(10):  # 假设地图宽度为10
                tile_id = tile_ids[y * 10 + x] if y * 10 + x < len(tile_ids) else 0
                color = (0.5, 0.5, 0.5, 1) if tile_id != 0 else (1, 1, 1, 1)  # 空瓦片为白色
                with self.canvas:
                    Color(*color)
                    Rectangle(pos=(x * tile_size, y * tile_size), size=(tile_size, tile_size))
        '''

        # iterate over all the visible layers, then draw them
        for layer in self.tmx.visible_layers:
            # each layer can be handled differently by checking their type
            if isinstance(layer, TiledTileLayer):
                self.render_tile_layer(layer)

            elif isinstance(layer, TiledObjectGroup):
                self.render_object_layer(layer)

            elif isinstance(layer, TiledImageLayer):
                self.render_image_layer(layer)



    '''渲染瓦片'''
    def render_tile_layer(self, layer:TiledTileLayer) -> None:
        """Render all TiledTiles in this layer"""
        logger.debug(["渲染工作中:::render_tile_layer 渲染瓦片",TiledTileLayer])

        # deref these heavily used references for speed
        tw = self.tmx.tilewidth
        th = self.tmx.tileheight
        #logger.critical((self.tmx.tilewidth,self.tmx.tileheight))


        # iterate over the tiles in the layer, and blit them
        if self.tmx.orientation == "orthogonal":
            logger.debug("orientation=orthogonal Orthogonal正交俯视地图")
            index = 0
            for x, y, pil_image in layer.tiles():
                index+=1

                # 计算瓦片位置
                pos_x = x * tw
                pos_y = Window.height - (y + 1) * th  # 反转Y轴(原来的是y * th)


                # 检查坐标的正确性
                if x < 0 or y < 0:
                    raise ValueError(f"Invalid coordinates: x={x}, y={y}")
    
                #print("ssssssssss1=",x, y, pil_image)
                # Assuming `image` is already a Kivy texture
                texture = pil_image_to_texture(pil_image)
                #print("ssssssssss2=",type(texture))

                #logger.debug([f'{index}.画一个瓦片A: pos=({x * tw}, {y * th}), size=({tw}, {th})',pil_image.size,])
                #logger.debug([f'{index}.画一个瓦片AAA: pos=({pos_x}, {pos_y}), size=({tw}, {th})',pil_image.size,])
                with self.canvas:
                    #Rectangle(texture=texture, pos=(pos_x, pos_y), size=(32, 32))
                    Rectangle(texture=texture, pos=(pos_x, pos_y), size=(tw, th))


        elif self.tmx.orientation == "isometric":
            logger.debug("orientation=isometric Isometric等距立体感地图")

            # this value is used later to render the entire map to a pygame surface
            ox = self.pixel_size[0] // 2
            tw2 = tw // 2
            th2 = th // 2
            for x, y, image in layer.tiles():
                sx = x * tw2 - y * tw2
                sy = x * th2 + y * th2
                with self.canvas:
                    Rectangle(texture=image, pos=(sx + ox, sy), size=(tw, th))



    '''渲染对象'''
    def render_object_layer(self, layer:TiledObjectGroup) -> None:
        """Render all TiledObjects contained in this layer"""
        logger.debug(["渲染工作中:::render_object_layer 渲染对象",TiledObjectGroup,layer])

        # these colors are used to draw vector shapes, like polygon and box shapes
        rect_color = (255, 0, 0) # Kivy's Color uses 0-1 scale
        poly_color = (0, 255, 0)

        # iterate over all the objects in the layer
        #logger.debug(["forShowLayerObj-layer=", layer])
        for obj in layer:
            logger.info(["forShowLayerObj", obj, obj.image,obj.x,obj.y])

            tw, th = obj.image.size
            # 计算瓦片位置
            pos_x = obj.x + 0
            pos_y = Window.height - (obj.y + 0)  # 反转Y轴(跟上面的计算瓦片位置又不同)
        
            if obj.image:
                logger.debug([f'画一个瓦片BBB: pos=({pos_x}, {pos_y}), size=', obj.image.size])
                texture = pil_image_to_texture(obj.image)
                # Render the object with its image
                with self.canvas:
                    Rectangle(texture=texture, pos=(pos_x, pos_y), size=obj.image.size)

            elif False:
                #这个是备用的
                with self.canvas:
                    Rectangle(texture=texture, pos=(obj.x, obj.y), size=(obj.width, obj.height))


            elif hasattr(obj, "points"):
                # Draw the object's polygon or line
                #draw_lines(poly_color, obj.closed, obj.points, 3)
                logger.warning("污点点,啥也不干")
                pass

            else:
                logger.warning("污图片,开始画单纯的线条")
                points = obj.apply_transformations()
                with self.canvas:
                    Color(*rect_color)
                    Line(points=points, width=2)


    ''' 渲染图像'''
    def render_image_layer(self, layer:TiledImageLayer) -> None:
        logger.debug(["渲染工作中:::render_image_layer 渲染图片",TiledImageLayer])
        if layer.image:
            # Assuming `layer.image` is a Kivy texture
            with self.canvas:
                Rectangle(texture=layer.image, pos=(0, 0), size=(self.tmx.width * self.tmx.tilewidth, self.tmx.height * self.tmx.tileheight))



    '''画个空图'''
    def draw_empty_map(self):
        """绘制一个空白的地图,显示为灰色色块"""
        self.canvas.clear()
        tile_size = 32
        for y in range(10):
            for x in range(10):
                with self.canvas:
                    # 使用随机颜色块
                    Color( random.random(), random.random(), random.random(), 1)  # 随机颜色RGB
                    Rectangle(pos=(x * tile_size, y * tile_size), size=(tile_size, tile_size))


    '''下一个地图'''
    def next_map(self, instance):
        self.current_map_index = (self.current_map_index + 1) % len(self.maps)
        logger.debug([self.maps, self.maps[self.current_map_index]])
        self.load_map()


class MyApp(App):
    def build(self):
        return TiledRenderer()

if __name__ == '__main__':
    #logging.basicConfig(level=logging.INFO)

    #解压pytmx的base64数据

    '''
    pybase64 = 'eJyNkMEKADAIQh32/9+8HQqk2djhEWWYRABxYBJSla5Pfc1W0n1U+8H5l6a9y07MOZx/3+1ZaHyJO6+79fqVzjfbTAEP'
    logger.critical(pytmx.pytmx.unpack_gids(pybase64,'base64','zlib'))
    # [CRITICAL] [3, 4, 3, 3, 4, 3, 4, 4, 4, 4, 3, 3, 4, 3, 3, 3, 4, 3, 3, 3, 3, 4, 1, 1, 4, 4, 4, 4, 4, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 4, 3, 3, 3, 3, 1, 1, 3, 3, 1, 3, 4, 3, 4, 4, 3, 1, 1, 4, 4, 4, 4, 1, 1, 4, 3, 3, 1, 1, 4, 4, 1, 1, 1, 1, 3, 4, 3, 1, 1, 3, 1, 1, 1, 4, 4, 4, 4, 4, 1, 3, 4, 3, 3, 3, 3, 4, 4, 3, 3]
    '''

    Window.size = (600,600)

    
    MyApp().run()

原文链接:https://blog.yongit.com/note/1573129.html