写一段在MonoGame中解析并使用Bmfont的代码

前言

非常遗憾的是,MonoGame作为一个游戏框架,其默认的spriteFont不支持打印中文(其实这一点也劝退了很多尝试使用MonoGame的开发者)。虽然如此,解决方法也还是存在的,我们可以使用Bmfont(位图字体),自定义我们的字体并使用它输出中文。

Bmfont介绍

官方文档(Bmfont generator,全英文):
https://www.angelcode.com/products/bmfont/documentation.html

Bmfont(位图字体,通常也指它的生成器)是一种字体形式,允许我们从图片来创建自定义字体。只要准备好一张包含所需文字的图片,或者从已有字体中选择文字,就可以生成相应的.fnt字体描述文件和图片文件。然后,我们的程序就可以读取这些文件,并对其中的字体信息加以利用。

生成Bmfont的过程在此不多赘述,本身也很简单,而且网上有很多教程。

接下来,面对生成的.fnt文件和图片文件,我们应当如何利用它们?

注:完整项目代码在此:https://gitee.com/half_tree/full-leaf-framework ,建议结合项目代码(在项目的utils\BmfontController.csutils\BmfontInfo.cs)阅读。

读取Bmfont信息

1
2
3
4
5
6
7
8
9
10
11
12
<?xml version="1.0"?>
<font>
<info face="方正像素14" size="32" bold="0" italic="0" charset="" unicode="1" stretchH="100" smooth="1" aa="1" padding="0,0,0,0" spacing="1,1" outline="1"/>
<common lineHeight="32" base="28" scaleW="256" scaleH="256" pages="1" packed="0" alphaChnl="1" redChnl="0" greenChnl="0" blueChnl="0"/>
<pages>
<page id="0" file="bmfontTest_0.png" />
</pages>
<chars count="3">
<char id="32" x="138" y="31" width="5" height="3" xoffset="-2" yoffset="30" xadvance="16" page="0" chnl="15" />
<char id="33" x="244" y="0" width="6" height="23" xoffset="5" yoffset="4" xadvance="16" page="0" chnl="15" />
<char id="44" x="126" y="35" width="11" height="11" xoffset="-2" yoffset="20" xadvance="16" page="0" chnl="15" />
</font>

如图,这是一个.fnt文件内容,其本质是Xml文件,我们可以使用C#的System.Xml类来读取其信息。

参考这段代码,它提供了一个C#的Bmfont类,可以允许我们读取Xml中的关键信息。

1
2
3
4
5
6
7
/// <param name="root">存放字体文件的根目录位置(不包含最初的Content文件夹)</param>
/// <param name="fontName">.fnt字体文件的名称,不带后缀</param>
private void InitializeFontInfo(string root, string fontName) {
XmlSerializer serializer = new XmlSerializer(typeof(Bmfont));
FileStream stream = new FileStream(@"Content\" + root + @"\" + fontName + ".fnt", FileMode.Open);
fontInfo = (Bmfont)serializer.Deserialize(stream);
}

调用相应的方法,我们就可以获取所有的字体信息。

.fnt文件传递了什么信息

首先,我们来观察.fnt文件内所包含的信息都包括什么。

PS:以下信息翻译自官方文档

info标签

这个标签中的信息大多是对字体本身属性的描述,
在接下来的生成过程中,这里的信息我们会用得比较少。

标签 含义
face 使用的字体的名称
size 字体的大小
bold 字体是粗体
italic 字体是斜体
charset OEM字符集的名称(如果不使用unicode则有用)
unicode 设为1表示使用unicode字符集
stretchH 字符高度延伸比例,100%表示不延伸
smooth 设为1表示开启平滑
aa 使用的超采样的级别,1表示不使用超采样
padding 每个字符的内间距 (up, right, down, left)
spacing 字符的间距 (horizontal, vertical)
outline 每个字符的轮廓宽度

common标签

所有字符都会用到的一些共有属性。

标签 含义
lineHeight 以像素为单位的两行文本的距离
base 从该行的顶部到字符基部的像素数
scaleW 纹理的宽度
scaleH 纹理的高度
pages 字体包含的纹理的页数
packed 黑白的字符是否打包放入每个纹理通道中(1/0),接下来的chnl属性就是在这种情况下描述黑白字符的颜色通道状况

PS:接下来一些关于颜色通道的我就省略了,用不到也懒得翻。

这里有一些属性很重要,例如:

  • lineHeight
    如果你想要换行,那么两行字符隔开的距离就是这个;
  • base
    字符上部到底部的距离,一行本身具有的宽度;
  • scaleW, scaleH
    你的纹理(和.fnt一起的图片文件)的大小。

page标签

纹理文件的id和名称被储存在这里。

标签 描述
id 纹理文件的id
file 对应的纹理文件的名称

这个属性非常重要,你的每个文字的样貌都储存在这些纹理文件里。

char标签

每个字符的信息都在这里,通过这些个属性,我们可以将字符和相应的纹理对应。

标签 描述
id 字符的id
x 纹理中字符图片的左侧位置
y 纹理中字符图片的顶部位置
width 纹理中字符图片的宽度
height 纹理中字符图片的高度
xoffset 当复制纹理中图片到屏幕时,需要从当前位置偏移的距离
yoffset 当复制纹理中图片到屏幕时,需要从当前位置偏移的距离
xadvance 在绘制字体之后,当前位置需要向后推进的距离
page 包含字符图片的纹理页面
chnl 包含有字符图片的纹理通道(1 = blue, 2 = green, 4 = red, 8 = alpha, 15 = all channels)

这些属性告诉了我们文字在纹理图片中的具体位置,以及绘制它们时的操作要求。

需要注意的是,这里的字符id实际上指的是字符的ASCII码。英文和标点与ASCII的对应关系自不必说,其实中文也具有ASCII,中文ASCII码的储存占用了2个字节,上至2^16-1种状态,已经满足了中文汉字的储存需求。

kerning标签

这里储存关于字偶矩的信息——某些时候,一些特殊的字符搭配可能会使得两个字符之间间隔的距离并不只是简单的前一个字符的大小,这时候就要对这种特殊的搭配特殊说明,以此调整字符间距。

标签 描述
first 第一个字符id
second 第二个字符id
amount 当在紧接着第一个字符绘制第二个字符时x位置应当做出的调整量。

接下来,我们将会用这些信息,以字符定位其纹理,再进行输出。

输出对应字符的图像

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
/// <summary>
/// Bmfont控制器
/// </summary>
public class BmfontController {

/// <summary>
/// 存放字体文件的根目录位置(不包含最初的Content文件夹)
/// </summary>
private string root;

/// <summary>
/// Bmfont字体的名称
/// </summary>
private string name;
public string Name { get => name; }

/// <summary>
/// 该字体文件的信息
/// </summary>
private BmfontInfo fontInfo;

/// <summary>
/// id和它所对应的纹理的映射关系
/// </summary>
private Dictionary<int, AnimatedSprite> pagesDic;
/// <summary>
/// id和它所对应字符的信息
/// </summary>
private Dictionary<int, FontChar> charsDic;
/// <summary>
/// 两个字符id和他们对应的字偶矩关系
/// </summary>
private Dictionary<Point, int> kerningsDic;

private ContentManager Content;

/// <summary>
/// 构建一个BmFont管理器
/// </summary>
/// <param name="root">存放字体文件的根目录位置(不包含最初的Content文件夹)</param>
/// <param name="fontName">.fnt字体文件的名称,不带后缀</param>
public BmfontController(string root, string fontName, ContentManager Content) {
// 纹理应当和.fnt文件在同一个位置
this.root = root;
name = fontName;
this.Content = Content;
try { InitializeFontInfo(root, fontName); }
catch { throw new System.Exception(@"没有找到目标字体文件:Content\" + root + @"\" + fontName + ".fnt"); }
InitializeDictionary();
}

private void InitializeFontInfo(string root, string fontName) {
XmlSerializer serializer = new XmlSerializer(typeof(BmfontInfo));
FileStream stream = new FileStream(@"Content\" + root + @"\" + fontName + ".fnt", FileMode.Open);
fontInfo = (BmfontInfo)serializer.Deserialize(stream);
}

private void InitializeDictionary() {
// 先填充pages
pagesDic = new Dictionary<int, AnimatedSprite>();
foreach (FontPage page in fontInfo.Pages) {
// 这里,由于page.File携带.png的后缀名,故仅仅截取第一个'.'前面的字符串,请注意文件名本身不带'.'
Texture2D texture = Content.Load<Texture2D>(root + @"\" + page.File.Split('.')[0]);
var animation = new AnimatedSprite(texture);
pagesDic.Add(page.Id, animation);
}
// 再填充chars
charsDic = new Dictionary<int, FontChar>();
foreach (FontChar charInfo in fontInfo.Chars) {
charsDic.Add(charInfo.Id, charInfo);
}
// 再填充kerningsDic(用Point(x,y)表示映射关系)
kerningsDic = new Dictionary<Point, int>();
if (fontInfo.Kernings != null)
foreach (FontKerning kerning in fontInfo.Kernings) {
var charKerning = new Point(kerning.First, kerning.Second);
kerningsDic.Add(charKerning, kerning.Amount);
}
}
}

如上方代码所示,我们写了一个BmfontController的类,它管理一个Bmfont字体,接下来我们做如下操作:

  1. 初始化赋值,字体名称、目录、MonoGame的资源管理器被传入;
  2. 读取字体信息,BmfontInfo就是我们之前在【读取Bmfont信息】提到的Bmfont类,使用System.Xml的反序列化功能,即可转化出相应的字体信息。
  3. 创建字典,将纹理文件,字符,字偶矩和它们的id对应起来
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
/// <summary>
/// 由Bmfont将即将输出的字符转变的Drawable对象集合
/// </summary>
public class BmfontDrawable {

/// <summary>
/// 要绘制的字符串
/// </summary>
public string drawString;
/// <summary>
/// 可绘制对象集合
/// </summary>
public Drawable[] drawables;

private int length;
/// <summary>
/// 所有可绘制对象长度之和
/// </summary>
public int Length { get => length; }

private int lineSpace;
/// <summary>
/// 行间距
/// </summary>
public int LineSpace { get => lineSpace; }

/// <summary>
/// 对齐方式
/// </summary>
public enum TranslateMethod {
Left = 0,
Middle = 1,
Right = 2
}

internal void SetLength(int length) {
this.length = length;
}

internal void SetLineSpace(int lineSpace) {
this.lineSpace = lineSpace;
}

public BmfontDrawable(string drawString, Drawable[] drawables, int length, int lineSpace) {
this.drawables = drawables;
this.drawString = drawString;
this.length = length;
this.lineSpace = lineSpace;
}

}

然后,我们定义BmfontDrawable类,存放已经转变为图像信息,即将输出的字符集合。

Drawable是我的项目中的一个类,描述一个可绘制对象的信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
/// <summary>
/// 获得由指定字符串转化而成的Drawable集合
/// </summary>
private BmfontDrawable GetDrawableString(string drawString, float sizeScale, bool noOffSet = true,
SpriteEffects effects = SpriteEffects.None, int layer = 10, float transparency = 1) {
List<Drawable> drawChars = new List<Drawable>();
int xSwift = 0;
char[] charArray = drawString.ToCharArray();
for (int i = 0; i < charArray.Length; i++) {
char eachChar = charArray[i];
int charAscii = eachChar; // 将每个字符转化为其对应ASCII
AnimatedSprite sprite = pagesDic[charsDic[charAscii].Page];
FontChar fontChar = charsDic[charAscii];
var drawable = new Drawable(sprite, new Vector2(xSwift, 0),
new Vector2(fontChar.Width / 2, fontChar.Height / 2), sizeScale,
drawArea : new Rectangle(fontChar.X, fontChar.Y, fontChar.Width, fontChar.Height),
effects : effects, layer : layer, transparency : transparency);
// 根据字符信息填充Drawable对象
if (!noOffSet) { drawable.pos += new Vector2(fontChar.XOffset, fontChar.YOffset); }
// 可以强制不使用给定的offset,这样字符不会偏移。
drawChars.Add(drawable);
// 生成Drawable对象,并指定绘制区域
if (i < charArray.Length - 1) {
Point kerning = new Point(charArray[i], charArray[i+1]);
if (kerningsDic.ContainsKey(kerning)) {
xSwift += (int)(kerningsDic[kerning] * sizeScale);
}
else {
xSwift += (int)(fontChar.XAdvance * sizeScale);
}
}
else {
xSwift += (int)(fontChar.XAdvance * sizeScale);
}
// 推进到下一个绘制位点,如果有字偶矩则使用字偶矩
}
var drawables = new Drawable[drawChars.Count];
drawChars.CopyTo(drawables);
// 创建BmfontDrawable对象
int length = xSwift;
int lineSpace = (int)(fontInfo.Common.LineHeight * sizeScale);
var result = new BmfontDrawable(drawString, drawables, length, lineSpace);
return result;
}

我们利用给定的字符串生成Drawable信息,代码如上,读者朋友可以注意观察对给定的xml信息的运用。

细心的读者朋友会发现,第一个Drawable表示的字符的坐标为(0,0),而接下来的字符仅仅表示对于第一个字符的相对坐标,接下来,我们来完善一下绘制过程,让我们可以规定字符的绘制坐标。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
/// <summary>
/// 将多行字符串输出到摄像机上
/// </summary>
/// <param name="camera">摄像机</param>
/// <param name="drawString">字符串(每个元素占据一行)</param>
/// <param name="pos">位置</param>
/// <param name="method">对齐方式</param>
/// <param name="noOffSet">是否使用字符的offset(偏移量)</param>
/// <param name="sizeScale">缩放大小</param>
/// <param name="effects">绘制效果</param>
/// <param name="layer">绘制的优先级,越小越高,越容易绘制在底层</param>
/// <param name="transparency">透明度(0为完全透明,1为完全不透明)</param>
public void InsertDrawObjects(Camera camera, string[] drawString,
Vector2 pos, BmfontDrawable.TranslateMethod method, float sizeScale = 1, bool noOffSet = true,
SpriteEffects effects = SpriteEffects.None, int layer = 10, float transparency = 1) {
foreach (string singleString in drawString) {
try {
var drawableString = GetDrawableString(singleString, sizeScale, noOffSet, effects, layer, transparency);
drawableString.InsertDrawObjects(camera, pos, method);
pos.Y += drawableString.LineSpace;
}
catch (Exception e) { throw new Exception("出现异常字符:" + e); }
}
}

// 此时,我们还规定,在BmfontInfo中,存在InsertDrawObjects()方法,如下。
// 注意!上面那个方法在BmfontController中,下面这个在BmfontInfo里

/// <summary>
/// 将可绘制对象上传到摄像机
/// </summary>
internal void InsertDrawObjects(Camera camera, Vector2 pos, TranslateMethod method) {
switch (method) {
case TranslateMethod.Left:
pos += new Vector2(0, -lineSpace / 2);
break;
case TranslateMethod.Middle:
pos += new Vector2(-length / 2, -lineSpace / 2);
break;
case TranslateMethod.Right:
pos += new Vector2(-length, lineSpace / 2);
break;
}
// 根据对齐方式调整坐标
foreach (Drawable drawable in drawables) {
if (drawable != null) {
drawable.pos += pos;
camera.insertObject(drawable);
}
}
}

接下来,根据drawable中的信息,camera会绘制它们,我们的任务完成了!

总结

以上,就是我对于在MonoGame中使用Bmfont的个人努力。我们学习了Bmfont中.fnt文件的信息内容,并大致了解了如何利用这些信息进行输出。

此外,如果大家觉得上述原理比较复杂,也不想看我的代码(写得比较乱),我还给大家推荐一个使用Bmfont的替代方案:MonoGame.Extended,它的使用会更加简洁且方便。