之前有阵子在业余时间拓展自己的一个游戏框架,结果在实现的过程中发现一个设计问题。这个游戏框架基于MonoGame实现,在MonoGame中,所有的材质渲染(Texture Rendering)都是通过SpriteBatch类来完成的。举个例子,假如希望在屏幕的某个地方显示一个图片材质(imageTexture),就在Game类的子类的Draw方法里,使用下面的代码来绘制图片:

protected override void Draw(GameTime gameTime)
{
// ...
spriteBatch.Draw(imageTexture, new Vector2(x, y), Color.White);
// ...
}

那么如果希望在屏幕的某个地方用某个字体来显示一个字符串,就类似地调用SpriteBatchDrawString方法来完成:

protected override void Draw(GameTime gameTime)
{
// ...
spriteBatch.DrawString(spriteFont, "Hello World", new Vector2(x, y), Color.White);
// ...
}

暂时可以不用管这两个代码中spriteBatch对象是如何初始化的,以及DrawDrawString两个方法的各个参数是什么意思,在本文讨论的范围中,只需要关注spriteFont这个对象即可。MonoGame使用一种叫“内容管道”(Content Pipeline)的技术,将各种资源(声音、音乐、字体、材质等等)编译成xnb文件,之后,通过ContentManager类,将这些资源读入内存,并创建相应的对象。SpriteFont就是其中一种资源(字体)对象,在GameLoad方法中,可以通过指定xnb文件名的方式,从ContentManager获取字体信息:

private SpriteFont? spriteFont;
protected override void LoadContent()
{
// ...
spriteFont = Content.Load<SpriteFont>("fonts\\arial"); // Load from fonts\\arial.xnb
// ...
}

OK,与MonoGame相关的知识就介绍这么多。接下来,就进入具体问题。由于是做游戏开发框架,那么为了能够更加方便地在屏幕上(确切地说是在当前场景里)显示字符串,我封装了一个Label类,这个类大致如下所示:

public class Label : VisibleComponent
{
private readonly SpriteFont _spriteFont; public Label(string text, SpriteFont spriteFont, Vector2 pos, Color color)
{
Text = text;
_spriteFont = spriteFont;
Position = pos;
TextColor = color;
} public string Text { get; set; }
public Vector2 Position { get; set; }
public Color TextColor { get; set; } protected override void ExecuteDraw(GameTime gameTime, SpriteBatch spriteBatch)
=> spriteBatch.DrawString(_spriteFont, Text, Position, TextColor);
}

这样实现本身并没有什么问题,但是仔细思考不难发现,SpriteFont是从Content Pipeline读入的字体信息,而字体信息不仅包含字体名称,而且还包含字体大小(字号),并且在Pipeline编译的时候就已经确定下来了,所以,如果游戏中希望使用同一个字体的不同字号来显示不同的字符串时,就需要加载多个SpriteFont,不仅麻烦而且耗资源,灵活度也不高。

经过一番搜索,发现有一款开源的字体渲染库:FontStashSharp,它有MonoGame的扩展,可以基于字体的不同字号,动态加载字体对象(称之为“动态精灵字体(DynamicSpriteFont)”),然后使用MonoGame原生的SpriteBatch将字符串以指定的动态字体显示在场景中,比如:

private readonly FontSystem _fontSystem = new();
private DynamicSpriteFont? _menuFont; public override void Load(ContentManager contentManager)
{
// Fonts
_fontSystem.AddFont(File.ReadAllBytes("res/main.ttf"));
_menuFont = _fontSystem.GetFont(30);
} public override void Draw(GameTime gameTime, SpriteBatch spriteBatch)
{
spriteBatch.DrawString(_menuFont, "Hello World", new Vector2(100, 100), Color.Red);
}

在上面的Draw方法中,仍然是使用了SpriteBatch.DrawString方法来显示字符串,不同的地方是,这个DrawString方法所接受的第一个参数为DynamicSpriteFont对象,这个DynamicSpriteFont对象是第三方库FontStashSharp提供的,它并不是标准的MonoGame里的类型,所以,这里有两种可能:

  1. DynamicSpriteFont是MonoGame中SpriteFont的子类
  2. FontStashSharp使用了C#扩展方法,对SpriteBatch类型进行了扩展,使得DrawString方法可以使用DynamicSpriteFont来绘制文本

如果是第一种可能,那问题倒也简单,基本上自己开发的这个游戏框架可以不用修改,比如在创建Label实例的时候,构造函数第二个参数直接将DynamicSpriteFont对象传入即可。但不幸的是,这里属于第二种情况,也就是FontStashSharp中的DynamicSpriteFontSpriteFont之间并没有继承关系。

现在总结一下,目前的现状是:

  1. DynamicSpriteFont并不是SpriteFont的子类
  2. 两者提供相似的能力:都能够被SpriteBatch用来绘制文本,都能够基于给定的文本字符串来计算绘制区域的宽度和高度(两者都提供MeasureString方法)
  3. 我希望在我的游戏框架中能够同时使用SpriteFontDynamicSpriteFont,也就是说,我希望Label可以同时兼容SpriteFontDynamicSpriteFont的文本绘制能力

很明显,可以使用GoF95的适配器(Adapter)模式来解决目前的问题,以满足上述3的条件。为此,可以定义一个IFontAdapter接口,然后基于SpriteFontDynamicSpriteFont来提供两种不同的适配器实现,最后,让框架里的类型(比如Label)依赖于IFontAdapter接口即可,UML类图大致如下:

DynamicSpriteFontAdapter被实现在一个独立的包(C#中的Assembly)里,这样做的目的是防止Mfx.Core项目对FontStashSharp有直接依赖,因为Mfx.Core作为整个游戏框架的核心组件,会被不同的游戏主体或者其它组件引用,而这些组件并不需要依赖FontStashSharp。

此外,同样可以使用C#的扩展方法特性,让SpriteBatch可以基于IFontAdapter进行文本绘制:

public static class SpriteBatchExtensions
{
public static void DrawString(
this SpriteBatch spriteBatch,
IFontAdapter fontAdapter,
string text) => fontAdapter.DrawString(spriteBatch, text);
}

其它相关代码类似如下:

public interface IFontAdapter
{
void DrawString(SpriteBatch spriteBatch, string text);
Vector2 MeasureString(string text);
} public sealed class SpriteFontAdapter(SpriteFont spriteFont) : IFontAdapter
{
public Vector2 MeasureString(string text) => spriteFont.MeasureString(text); public void DrawString(SpriteBatch spriteBatch, string text)
=> spriteBatch.DrawString(spriteFont, text);
} public sealed class FontStashSharpAdapter(DynamicSpriteFont spriteFont) : IFontAdapter
{
public void DrawString(SpriteBatch spriteBatch, string text)
=> spriteBatch.DrawString(spriteFont, text); public Vector2 MeasureString(string text) => spriteFont.MeasureString(text);
} public class Label(string text, IFontAdapter fontAdapter) : VisibleComponent
{
// 其它成员忽略
public string Text { get; set; } = text; protected override void ExecuteDraw(GameTime gameTime, SpriteBatch spriteBatch)
=> spriteBatch.DrawString(fontAdapter, Text);
}

总结一下:本文通过对一个实际案例的分析,讨论了GoF95设计模式中的Adapter模式在实际项目中的应用,展示了如何使用面向对象设计模式来解决实际问题的方法。Adapter模式的引入也会产生一些边界效应,比如本案例中FontStashSharp的DynamicSpriteFont其实还能够提供更多更为丰富的功能特性,然而Adapter模式的使用,使得这些功能特性不能被自制的游戏框架充分使用(因为接口统一,而标准的SpriteFont并不提供这些功能),一种有效的解决方案是,扩展IAdapter接口的职责,然后使用空对象模式来补全某个适配器中不被支持的功能特性,但这种做法又会在框架设计中,让某些类型的层次结构设计变得特殊化,也就是为了迎合某个外部框架而去做抽象,使得设计变得不那么纯粹,所以,还是需要根据实际项目的需求来决定设计的方式。

在C#中使用适配器Adapter模式和扩展方法解决面向的对象设计问题的更多相关文章

  1. 【原】模式之-适配器Adapter模式

    适配器Adapter模式 适配器模式(Adapter Pattern)把一个类的接口变换成客户端所期待的的另一种接口,从而使原本因接口不匹配而无法在一起工作的两个类能够在一起工作. 模式所涉及的角色有 ...

  2. 设计模式--适配器(Adapter)模式

    今天学习另一个设计模式,适配器(Adapter)模式,这是一个共同方向,但有特殊要求,就应用到此设计模式.写到这里,想起很久以前,有写过一篇<ASP.NET的适配器设计模式(Adapter)&g ...

  3. java演示适配器(adapter)模式

    为什么要使用模式: 模式是一种做事的一种方法,也即实现某个目标的途径,或者技术. adapter模式的宗旨就是,保留现有类所提供的服务,向客户提供接口,以满足客户的需求. 类适配器:客户端定义了接口并 ...

  4. 设计模式C++描述----06.适配器(Adapter)模式

    一. 定义 适配器模式将一个类的接口转换成客户希望的另外一个接口,使得原本由于接口不兼容而不能一起工作的那些类可以一起工作. Adapter 模式的两种类别:类模式和对象模式. 二. 举例说明 实际中 ...

  5. 适配器(Adapter)模式

    适配器模式把一个类的接口变换成客户端所期待的另一种接口,从而使原本因接口不匹配而无法在一起工作的两个类能够在一起工作. 适配器模式的一些其他名称:变压器模式.转换器模式.包装(Wrapper)模式.适 ...

  6. 2、适配器 adapter 模式 加个"适配器" 以便于复用 结构型设计模式

    1.什么是适配器模式? 适配器如同一个常见的变压器,也如同电脑的变压器和插线板之间的电源连接线,他们虽然都是3相的,但是电脑后面的插孔却不能直接插到插线板上. 如果想让额定工作电压是直流12伏特的笔记 ...

  7. Java 实现适配器(Adapter)模式

    平时我们会常常碰到这种情况,有了两个现成的类,它们之间没有什么联系.可是我们如今既想用当中一个类的方法.同一时候也想用另外一个类的方法.有一个解决方法是.改动它们各自的接口.可是这是我们最不愿意看到的 ...

  8. 漫谈设计模式(一):代理(Proxy)模式与适配器(Adapter)模式对比

    1.前言 为什么要将代理模式与适配器模式放在一起来说呢?因为它们有许多的共同点,当然也有一些不同的地方.首先两者都是属于结构型模式.结构型模型是这样定义的: 结构型模式涉及到如何组合类和类以获得更大的 ...

  9. 关于.NET中迭代器的实现以及集合扩展方法的理解

    在C#中所有的数据结构类型都实现IEnumerable或IEnumerable<T>接口(实现迭代器模式),可以实现对集合遍历(集合元素顺序访问).换句话可以这么说,只要实现上面这两个接口 ...

  10. C#开发学习——.net C#中页面之间传值传参的方法以及内置对象

    1.QueryString是一种非常简单的传值方式,他可以将传送的值显示在浏览器的地址栏中.如果是传递一个或多个安全性要求不高或是结构简单的数值时,可以使用这个方法.但是对于传递数组或对象的话,就不能 ...

随机推荐

  1. 一个基于 SourceGenerator 生成 从 dbReader转换为 class 数据的性能测试实验

    好奇 SourceGenerator 出现开始,好几年了,虽然一直好奇用SourceGenerator 生成代码 与 emit 等动态生成的代码会有多少差距, 但是一直特别懒,不想搞 其实 dappe ...

  2. 【杂谈】JPA乐观锁改悲观锁遇到的一些问题与思考

    背景 接过一个外包的项目,该项目使用JPA作为ORM. 项目中有多个entity带有@version字段 当并发高的时候经常报乐观锁错误OptimisticLocingFailureException ...

  3. Springboot实现HTML表单from简单的接收信息

    HTML< from >元素 from可向Web服务器提交请求 普遍格式: <from action="服务器地址" method="请求方式" ...

  4. 日本联合研究团队发布 Fugaku-LLM——证明大型纯 CPU 超算也可用于大模型训练

    相关: https://mbd.baidu.com/newspage/data/landingsuper?context={"nid"%3A"news_101396655 ...

  5. 【转载】WSL 的基本命令

    参考: https://learn.microsoft.com/zh-cn/windows/wsl/basic-commands https://blog.csdn.net/u010099177/ar ...

  6. Python 计算几月几号是某年的第几天

    闰年需要同时满足以下条件: 1.年份能被4整除: 2.年份若是 100 的整数倍的话需被400整除,否则是平年. #!/usr/bin/python # -*- coding: UTF-8 -*- # ...

  7. Canvas简历编辑器-图形绘制与状态管理(轻量级DOM)

    Canvas简历编辑器-图形绘制与状态管理(轻量级DOM) 在前边我们聊了数据结构的设计和剪贴板的数据操作,那么这些操作都还是比较倾向于数据相关的操作,那么我们现在就来聊聊基本的图形绘制以及图形状态管 ...

  8. SMU Spring 2023 Trial Contest Round 9

    A. Wrong Subtraction  在k次操作里, 将n的最后一位数减1,如果是0就去掉,模拟一下就好了. #include <bits/stdc++.h> //#define i ...

  9. mariadb5.5.56二进制离线安装

    在生产环境中一般使用发布好的二进制版本,简单概括一下安装过程: 1. 下载 地址为:https://downloads.mariadb.org/mariadb/5.5.56/ 这里选择最新版本的5.5 ...

  10. 讲讲Java的序列化反序列化?

    序列化:把对象转换为字节序列的过程称为对象的序列化. 反序列化:把字节序列恢复为对象的过程称为对象的反序列化. 什么时候会用到 当只在本地 JVM 里运行下 Java 实例,这个时候是不需要什么序列化 ...