之前有陣子在業餘時間擴充自己的一個遊戲框架,結果在實現的過程中發現一個設計問題。這個遊戲框架基於MonoGame實現,在MonoGame中,所有的材質渲染(Texture Rendering)都是透過SpriteBatch
類來完成的。舉個例子,假如希望在螢幕的某個地方顯示一個圖片材質(imageTexture),就在Game
類的子類的Draw
方法裡,使用下面的程式碼來繪製圖片:
protected override void Draw(GameTime gameTime)
{
// ...
spriteBatch.Draw(imageTexture, new Vector2(x, y), Color.White);
// ...
}
那麼如果希望在螢幕的某個地方用某個字型來顯示一個字串,就類似地呼叫SpriteBatch
的DrawString
方法來完成:
protected override void Draw(GameTime gameTime)
{
// ...
spriteBatch.DrawString(spriteFont, "Hello World", new Vector2(x, y), Color.White);
// ...
}
暫時可以不用管這兩個程式碼中spriteBatch
物件是如何初始化的,以及Draw
和DrawString
兩個方法的各個引數是什麼意思,在本文討論的範圍中,只需要關注spriteFont
這個物件即可。MonoGame使用一種叫“內容管道”(Content Pipeline)的技術,將各種資源(聲音、音樂、字型、材質等等)編譯成xnb
檔案,之後,透過ContentManager
類,將這些資源讀入記憶體,並建立相應的物件。SpriteFont
就是其中一種資源(字型)物件,在Game
的Load
方法中,可以透過指定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裡的型別,所以,這裡有兩種可能:
DynamicSpriteFont
是MonoGame中SpriteFont
的子類- FontStashSharp使用了C#擴充套件方法,對
SpriteBatch
型別進行了擴充套件,使得DrawString
方法可以使用DynamicSpriteFont
來繪製文字
如果是第一種可能,那問題倒也簡單,基本上自己開發的這個遊戲框架可以不用修改,比如在建立Label
例項的時候,建構函式第二個引數直接將DynamicSpriteFont
物件傳入即可。但不幸的是,這裡屬於第二種情況,也就是FontStashSharp中的DynamicSpriteFont
與SpriteFont
之間並沒有繼承關係。
現在總結一下,目前的現狀是:
DynamicSpriteFont
並不是SpriteFont
的子類- 兩者提供相似的能力:都能夠被
SpriteBatch
用來繪製文字,都能夠基於給定的文字字串來計算繪製區域的寬度和高度(兩者都提供MeasureString
方法) - 我希望在我的遊戲框架中能夠同時使用
SpriteFont
和DynamicSpriteFont
,也就是說,我希望Label可以同時相容SpriteFont
和DynamicSpriteFont
的文字繪製能力
很明顯,可以使用GoF95的介面卡(Adapter)模式來解決目前的問題,以滿足上述3的條件。為此,可以定義一個IFontAdapter
介面,然後基於SpriteFont
和DynamicSpriteFont
來提供兩種不同的介面卡實現,最後,讓框架裡的型別(比如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
介面的職責,然後使用空物件模式來補全某個介面卡中不被支援的功能特性,但這種做法又會在框架設計中,讓某些型別的層次結構設計變得特殊化,也就是為了迎合某個外部框架而去做抽象,使得設計變得不那麼純粹,所以,還是需要根據實際專案的需求來決定設計的方式。