之前有阵子在业余时间拓展自己的一个游戏框架,结果在实现的过程中发现一个设计问题。这个游戏框架基于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. docker 概念,安装,启动,运行

    docker概念,安装,启动,运行模式 docker的概念 镜像(image) 模板,可以通过模板来创建容器服务,tomcat镜像===>run==>tomacat01容器(提供服务),通 ...

  2. 自动化测试框架开发python selenium excel POM

    自动化测试框架开发python selenium excel POM 本文主要是采用python selenium excel POM 做UI自动化测试 测试框架选用的是unittest 测试框架用的 ...

  3. 使用Nginx Proxy Manager配置Halo的反向代理和申请 SSL 证书

    本文介绍Nginx Proxy Manager配置Halo的反向代理和申请 SSL 证书,如需要了解Halo 2的安装,参考如何在Linux云服务器上通过Docker Compose部署安装Halo, ...

  4. CentOS安装RustDesk自建服务器

    一.安装node.js 1.下载并解压 [root@VM-8-2-centos src]# cd /usr/local # 下载node文件 [root@VM-8-2-centos local]# w ...

  5. 使用AI技术(单张图片或文字)生产3D模型 —— Ai生成3D模型的时代来了

    地址: https://www.bilibili.com/video/BV1A2421P7pH/ 视频用到的工具voxcraft体验地址:https://voxcraft.ai/

  6. 人类社会学:“重男轻女"思潮的比重,从多孩男女性别比角度思考

    参考资料: https://baijiahao.baidu.com/s?id=1780697594797038227 https://mbd.baidu.com/newspage/data/video ...

  7. ubuntu系统升级软件sudo apt upgrade后GPU崩溃报错,显示驱动版本不匹配——ubuntu系统版本过低导致的问题

    ubuntu系统升级软件(sudo apt upgrade)后,GPU崩溃报错,查看系统日志: Aug 2 06:25:02 lcwt rsyslogd: [origin software=" ...

  8. 2.3.6版本发布!Apache SeaTunnel Zeta引擎迎来新架构!

    Apache SeaTunnel 2.3.6 版本于近日正式发布,社区期待的 SeaTunnel Zeta Master/Worker 新架构.事件通知机制.支持动态编译的transform等新功能和 ...

  9. 【牛客刷题】HJ6 质数因子

    题目链接 这道题本身更多的是考察如何计算一个数的质数因子,更像是一道数学题,用到了循环的方法: package main import ( "fmt" "math&quo ...

  10. programmers model

    Handler模式一直使用MSP,所以在handler模式下处理器会忽略SPSEL位:异常进入及返回机制会更新CONTROL寄存器. 在操作系统的环境中,推荐线程在Thread模式下运行使用PSP,内 ...