Animation.py 实现动画刷新逻辑,Sprite.py 绘制精灵图到窗口,直接照搬代码,基本不用改动,然后就可以用来加载精灵图了 https://github.com/mx0c/super-mario-python/blob/master/classes/Sprites.py
不要当过程完美主义者,很容易自我攻击。Sprites.py 的代码看着一大堆,其实就是读取精灵图的 json 配置文件,按配置截取图像并分别转换为 Sprite 对象,Sprite 和 Animation 职责有些混乱。这些数据的主要区别是参数名称不统一,动画的图像数据是多个图像组成的列表,所以映射统一名字了也要分静态图和动画两个部分,还是直接照抄吧。
测试的时候发现,读取 gif 格式图片会报错,每种图片数据格式都不一样,需要不同的处理程序,cairo 中除了 png 并没有相应的处理程序。同时还有一个问题,之前用 cairo 实现对图片缩放,明显变模糊了。按理说素材图片虽然是位图但因为是纯色,手动缩放显示都没问题,不知道为什么会这样,网上解释说是"Cairo 默认使用线性插值算法进行缩放操作,而线性插值会导致图像像素的颜色被平滑地混合,从而造成图像的模糊效果。"。
当然,cairo 的优势是绘制图形,还是先用 wxPython 处理吧。直接用 wx.Image 加载所有格式图片,调用 Scale 方法进行缩放,GetSubImage 方法截取图像,最后再转换成 cairo.ImageSurface(纯粹为了用 cairo)。
wx.Image 默认是 RGBA 字节集,主要用于加载和处理图像。wx.Bitmap 默认为可选 alpha 通道的 RGB 字节集,主要用于绘制和显示图像。详细介绍见 https://docs.wxpython.org/wx.Image.html
wx.Image 加载不透明图片,也会默认创建值为 255 的 alpha 通道。素材中的 characters.gif,用 wx.Image 加载后,调用 GetAlpha 方法发现得到不正确的透明通道值,而且 HasAlpha 得到的结果是 False,因为没有正确解释透明通道数据,RGB 数据也错了,加载后整个图片都变成了粉色。
import wx
app = wx.App()
image = wx.Image("./img/characters.gif")
print("---characters.gif---")
print("RGB data: ", tuple(image.GetData())[:20])
print("Alpha data", tuple(image.GetAlpha())[:20])
print(image.HasAlpha())
image2 = wx.Image("./img/tiles.png")
print("---tiles.png---")
print("RGB data: ", tuple(image2.GetData())[:20])
print("Alpha data", tuple(image2.GetAlpha())[:20])
print(image2.HasAlpha())
"""
---characters.gif---
RGB data: (255, 0, 255, 255, 0, 255, 255, 0, 255, 255, 0, 255, 255, 0, 255, 255, 0, 255, 255, 0)
Alpha data (192, 0, 50, 1, 168, 166, 98, 13, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6)
False
---tiles.png---
RGB data: (156, 74, 0, 255, 206, 198, 255, 206, 198, 255, 206, 198, 255, 206, 198, 255, 206, 198, 255, 206)
Alpha data (255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255)
True
"""
如果有兴趣可以自己尝试写一个能够正确解析数据的处理器 https://docs.wxpython.org/wx.ImageHandler.html ,这里还是直接用工具将 gif 转为 png 图片。
还有一些需要注意的点,比如 python 中 int 是向下取整,会直接舍弃小数,为了准确,在缩放尺寸时用 round 取近似值。
至此,每个精灵图都有了自己的名字,有点像物理魔法使马修给肌肉取名,莫名的喜感,调用的时候更直观。这个单元测试不知道该怎么写,简单测试输出所有处理后的 Sprite 图像,稍微调整一些坐标和尺寸,其中 koopa-hiding-with-legs 坐标完全不对,根据名字也不知道是哪个,后续再改。
from mario.classes.Sprites import Sprites
sprites = Sprites()
for name, sprite in sprites.sprite_collection.items():
if sprite.animation is not None:
# 动画默认显示第一帧
surface = sprite.animation.image
else:
surface = sprite.image
surface.write_to_png(f"./test/{name}.png")
完整代码:
# Animation.py
class Animation:
def __init__(self, images, idle_sprite=None, air_sprite=None, delta_time=7):
self.images = images # 动画逐帧图像 Surface 列表
self.timer = 0 # 主程序刷新次数计次
self.index = 0 # 当前动画帧索引
self.image = self.images[self.index] # 当前动画帧图像,默认显示第一帧
self.idle_sprite = idle_sprite # 空闲状态精灵图,奇怪的设定
self.air_sprite = air_sprite # 在空中状态精灵图
self.delta_time = delta_time # 动画图像更新间隔帧数
def update(self):
# 原程序设置了 FPS(Frames Per Second) 为 60 帧/秒,每 7 帧更新一帧动画图像,即 (1000 毫秒 / 60) * 7 约每 0.12 秒
# 人的大脑视觉处理中心,大约会将看到的画面在视觉里暂留100至400毫秒,因此,当眼前的画面每秒变换十五次以上的时候,人的大脑就会有了“这是连续动作”的错觉。
self.timer += 1
if self.timer % self.delta_time == 0:
# 头尾循环取动画图像
if self.index < len(self.images) - 1:
self.index += 1
else:
self.index = 0
self.image = self.images[self.index]
def idle(self):
self.image = self.idle_sprite
def in_air(self):
self.image = self.air_sprite
# Sprite.py
from cairo import Context
class Sprite:
def __init__(self, image, colliding, animation=None, redraw_background=False):
self.image = image # 调用 SpriteSheet.image_at 返回的 Surface
self.colliding = colliding # 碰撞,并未发现使用
self.animation = animation # Animation 实例,与 Sprite 构成关联关系(依赖像租房,关联像买房)
self.redraw_background = redraw_background
def draw_sprite(self, x, y, screen):
# 背景 tile 尺寸 16*16,sprite json 配置文件中将每个精灵图都放大了 2 倍
dimensions = (x * 32, y * 32)
# screen 是表示窗口或屏幕的 Surface 对象。screen = pygame.display.set_mode(windowSize)
# 因为牵涉到 screen 的定义,wxPython 和 pygame 是不同的,这里假定它为 Surface 对象,后续再修改
ctx = Context(screen)
if self.animation is None:
surface = self.image
else:
self.animation.update()
surface = self.animation.image
# 将源 surface 绘制到窗口上
ctx.set_source_surface(surface, *dimensions)
ctx.paint()
# Sprites.py
import json
from mario.classes.Animation import Animation
from mario.classes.Sprite import Sprite
from mario.classes.SpriteSheet import SpriteSheet
class Sprites:
def __init__(self):
self.sprite_collection = self.load_sprites(
[
"./sprites/Mario.json",
"./sprites/Goomba.json",
"./sprites/Koopa.json",
"./sprites/Animations.json",
"./sprites/BackgroundSprites.json",
"./sprites/ItemAnimations.json",
"./sprites/RedMushroom.json"
]
)
@staticmethod
def load_sprites(file_list):
res_dict = {}
for file in file_list:
with open(file, "r") as json_data:
data = json.load(json_data)
ss = SpriteSheet(data["spriteSheetURL"])
dic = {}
if data["type"] == "background":
for sprite in data["sprites"]:
dic[sprite["name"]] = Sprite(
ss.image_at(
sprite["x"],
sprite["y"],
sprite["scalefactor"],
sprite.get("colorKey", None)
),
sprite["collision"],
None,
sprite["redrawBg"]
)
res_dict.update(dic)
elif data["type"] == "animation":
for sprite in data["sprites"]:
images = []
for image in sprite["images"]:
images.append(
ss.image_at(
image["x"],
image["y"],
image["scale"],
sprite["colorKey"]
)
)
dic[sprite["name"]] = Sprite(
None,
None,
Animation(images, delta_time=sprite["deltaTime"])
)
res_dict.update(dic)
elif data["type"] == "character" or data["type"] == "item":
for sprite in data["sprites"]:
xsize, ysize = sprite.get("xsize") and (sprite["xsize"], sprite["ysize"]) or data.get("size")
dic[sprite["name"]] = Sprite(
ss.image_at(
sprite["x"],
sprite["y"],
sprite["scalefactor"],
sprite.get("colorKey", None),
False,
xsize,
ysize
),
sprite["collision"]
)
res_dict.update(dic)
return res_dict
# SpriteSheet.py
import wx
from wx.lib.wxcairo import ImageSurfaceFromBitmap
from mario.classes.Utils import Utils
class SpriteSheet:
def __init__(self, filename):
try:
# 针对提示 ICCP:known incorrect sRGB profile 图像文件中的sRGB ICC配置文件信息被识别为不正确或已经损坏
# wx.Log.EnableLogging(enable=False)
wx.Image.SetDefaultLoadFlags(0)
# cairo 不支持直接导入其他格式图片,这里通过 wxPython 处理
self.sprite_sheet = wx.Image(filename)
except Exception as e:
print(str(e))
raise SystemExit
def image_at(self, x, y, scale=1, color_key=None, is_column_row=True, tile_width=16, tile_height=16):
if is_column_row:
# x, y 作为行和列,取精灵图范围
rect = wx.Rect(x * tile_width, y * tile_height, tile_width, tile_height)
else:
# x, y 作为坐标,取精灵图范围
rect = wx.Rect(x, y, tile_width, tile_height)
# 从精灵序列图中提取指定区域并创建为新的 wx.Image
image = self.sprite_sheet.GetSubImage(rect)
# 缩放到指定尺寸
if scale != 1:
image = image.Scale(round(image.GetWidth() * scale), round(image.GetHeight() * scale))
# 将处理过的 wx.Image 对象转换为 cairo.ImageSurface 对象
surface = ImageSurfaceFromBitmap(image.ConvertToBitmap())
# 处理透明色
if color_key is not None:
if color_key == -1:
# 取 surface 像素数据
data = surface.get_data()
# 取 surface 第一个点 rgb 像素值
color_key = tuple(data[:3])
else:
# surface data 实际排列为 bgra
color_key = tuple(color_key[::-1])
# 将 color_key 设为透明
surface = Utils.set_colorkey(surface, color_key)
return surface