0%

目录

UGUI Text组件实现图文混排

实现图文混排原理

Unity手册上介绍UI系统和传统的GUI系统都支持富文本,Text、GUIStyle、GUIText和TextMesh类上有rich text的设置选项,开启它后Unity会解析文本中标记标签。

unity中支持的文本标签如下:

其中quad标签可以渲染出行内内嵌的图片,但是只是对TextMesh组件有效。

This is only useful for text meshes and renders an image inline with the text.

TextMesh是用来生成3D图形字体,不能直接用在UI Canvas下。

Text Meshes can be used for rendering road signs, graffiti etc. The Text Mesh places text in the 3D scene. To make generic 2D text for GUIs, use a GUI Text component instead.

2D文本渲染需要用GUI Text组件,它属于传统GUI系统,UI系统(即UGUI)下的Text组件也是一样的用途,正如本节开头说到他们都有rich text的设置。

但是UI的文本组件并不支持内嵌的图片,使用quad标签会出现乱码。好在UGUI开源,可以通过重写Text组件以支持渲染内嵌图片的效果。

quad标签的使用

\

material:渲染组件上引用的材质,值为材质数组的下表,从0开始。

size:决定内嵌图片的像素大小。

x/y/width/height:决定材质贴图上渲染矩形区域的偏移和大小,它们的value代表百分比,类似于uv坐标。

经过测试得知,实际渲染图片大小的关系:图片像素大小 = size *(width/height)。

可以参考这篇文章来推测。

使用在UI Text组件上会出现乱码,其实乱码是字体贴图,放大后可以看见贴图上的文字,只不过缩放太小所以看不到。

打开rich text设置才会解析标签,否则不解析:

渲染内嵌图片

虽然quad在UI上渲染不出图片,但是可以定义渲染区域大小,可以作为占位符。

借助这个特效,渲染内嵌图片可以分三步:

  1. 使用quad标签占位

  2. 去除乱码

  3. 使用MaskableGraphic类渲染贴图,放置于占位符位置上

项目规定,在渲染贴图之前,需要将所有用到的贴图打到一个spriteAsset上,并且将它引用到UIGraphicTextSprite组件上,UIGraphicTextSprite组件继承MaskableGraphic类,具体使用过程参考这篇文章

文字渲染原理

1.左边文本会生成100个顶点数据,100来自假设,但顶点数肯定是4的倍数

2.前8个顶点代表两个中文的位置

3.后面的顶点都是quad标签的顶点,但是unity会做处理,只有前4个代表quad标签的区域,后面顶点位置都位于一个点上,不会渲染内容

流程讲解

UGUI Text组件渲染文字代码流程图:

重写渲染流程:

重写渲染流程详细介绍:

代码实现

正则表达式匹配quad标签

1
2
3
4
5
6
7
private static readonly Regex m_spriteTagRegex =
new Regex(@"<quad name=(.+?) size=(\d*\.?\d+%?) width=(\d*\.?\d+%?) des=(.+?) />", RegexOptions.Singleline);

foreach (Match match in m_spriteTagRegex.Matches(m_OutputText))
{
...
}

清除乱码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//清楚乱码
for (int i = 0; i < listTagInfor.Count; i++)
{
//UGUIText不支持<quad/>标签,表现为乱码,我这里将他的uv全设置为0,清除乱码
for (int m = listTagInfor[i].index * 4; m < listTagInfor[i].index * 4 + 4; m++)
{
//超出可视范围的不会绘制,即leftBottomIndex >= verts.Count。
//所以这里不需要处理也不应该处理。若处理,则数组越界。
if (m >= verts.Count)
{
break;
}
UIVertex tempVertex = verts[m];
tempVertex.uv0 = Vector2.zero;
verts[m] = tempVertex;
}
}

生成网格数据

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
public struct InlineSpriteInfor
{
// 文字的最后的位置
public Vector3 textpos;
// 4 顶点
public Vector3[] vertices;
//4 uv
public Vector2[] uv;
//6 三角顶点顺序
public int[] triangles;
public bool isEmptySprite;
public Texture texture;
public string name;
}
void CalcQuadTag(IList<UIVertex> verts)
{
m_AnimSpriteInfo = new Dictionary<int, InlineSpriteInfor[]>();

//通过标签信息来设置需要绘制的图片的信息
listSprite = new List<InlineSpriteInfor>();
for (int i = 0; i < listTagInfor.Count; i++)
{ 3 2
var leftBottomIndex = ((listTagInfor[i].index + 1) * 4) - 1;
if (leftBottomIndex >= verts.Count)
{
break;
}
InlineSpriteInfor tempSprite = new InlineSpriteInfor();
tempSprite.name = listTagInfor[i].name;
tempSprite.isEmptySprite = listTagInfor[i].isEmptySprite;

tempSprite.textpos = verts[leftBottomIndex].position;
//设置图片的位置
tempSprite.vertices = new Vector3[4];
tempSprite.vertices[0] = new Vector3(0, 0, 0) + tempSprite.textpos;
tempSprite.vertices[1] = new Vector3(listTagInfor[i].size.x, listTagInfor[i].size.y, 0) + tempSprite.textpos;
tempSprite.vertices[2] = new Vector3(listTagInfor[i].size.x, 0, 0) + tempSprite.textpos;
tempSprite.vertices[3] = new Vector3(0, listTagInfor[i].size.y, 0) + tempSprite.textpos;

//计算其uv
Sprite sprite;
m_nameToSpriteDict.TryGetValue(listTagInfor[i].name, out sprite);
Rect spriteRect = sprite.textureRect;
Texture texSource = sprite.texture;
tempSprite.texture = texSource;
Vector2 texSize = new Vector2(texSource.width, texSource.height);
tempSprite.uv = new Vector2[4];
tempSprite.uv[0] = new Vector2(spriteRect.x / texSize.x, spriteRect.y / texSize.y);
tempSprite.uv[1] = new Vector2((spriteRect.x + spriteRect.width) / texSize.x, (spriteRect.y + spriteRect.height) / texSize.y);
tempSprite.uv[2] = new Vector2((spriteRect.x + spriteRect.width) / texSize.x, spriteRect.y / texSize.y);
tempSprite.uv[3] = new Vector2(spriteRect.x / texSize.x, (spriteRect.y + spriteRect.height) / texSize.y);

//声明三角顶点所需要的数组
tempSprite.triangles = new int[6];
listSprite.Add(tempSprite);
}

渲染贴图

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
void DrawSprite(UIGraphicTextSprites spriteGraphic, List<InlineSpriteInfor> listInlineSprite)
{
var spriteCanvasRenderer = spriteGraphic.GetComponentInChildren<CanvasRenderer>();
if (m_spriteMesh == null)
{
m_spriteMesh = new Mesh();
}

if (drawSpriteVertices == null) drawSpriteVertices = new List<Vector3>();
else drawSpriteVertices.Clear();
if (drawSpriteUv == null) drawSpriteUv = new List<Vector2>();
else drawSpriteUv.Clear();
if (drawSpriteTriangles == null) drawSpriteTriangles = new List<int>();
else drawSpriteTriangles.Clear();

for (int i = 0; i < listInlineSprite.Count; i++)
{
var inlineSprite = listInlineSprite[i];
if (inlineSprite.isEmptySprite)
{
continue;
}
for (int j = 0; j < inlineSprite.vertices.Length; j++)
{
drawSpriteVertices.Add(inlineSprite.vertices[j]);
}
for (int j = 0; j < inlineSprite.uv.Length; j++)
{
drawSpriteUv.Add(inlineSprite.uv[j]);
}
for (int j = 0; j < inlineSprite.triangles.Length; j++)
{
drawSpriteTriangles.Add(inlineSprite.triangles[j]);
}
}
//计算顶点绘制顺序
for (int i = 0; i < drawSpriteTriangles.Count; i++)
{
if (i % 6 == 0)
{
int num = i / 6;
drawSpriteTriangles[i] = 0 + 4 * num;
drawSpriteTriangles[i + 1] = 1 + 4 * num;
drawSpriteTriangles[i + 2] = 2 + 4 * num;

drawSpriteTriangles[i + 3] = 1 + 4 * num;
drawSpriteTriangles[i + 4] = 0 + 4 * num;
drawSpriteTriangles[i + 5] = 3 + 4 * num;
}
}
m_spriteMesh.vertices = drawSpriteVertices.ToArray ();
m_spriteMesh.uv = drawSpriteUv.ToArray ();
m_spriteMesh.triangles = drawSpriteTriangles.ToArray ();

spriteCanvasRenderer.SetMesh(m_spriteMesh);
spriteGraphic.UpdateMaterial();
}

动态图片是按一定频率切换主贴图和网格数据实现的

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
float fTime = 0.0f;
//int iIndex = 0;
void Update()
{
if(listSprite == null || listSprite.Count==0 || m_hasAnimTag==false) return;

fTime += Time.deltaTime;
if (fTime >= DynamicTagSwitchInterval)
{
UpdateAnimSprite();
fTime = 0.0f;
}

}

void UpdateAnimSprite() {
for (int i = 0; i < m_AnimIndex.Count; i++)
{
var animIndex = m_AnimIndex[i];
if (!m_AnimSpriteInfo.ContainsKey(animIndex)) continue;

m_AnimSpriteStep[animIndex]++;
if (m_AnimSpriteStep[animIndex] >= m_AnimSpriteInfo[animIndex].Length)
{
m_AnimSpriteStep[animIndex] = 0;
}
var step = m_AnimSpriteStep[animIndex];
var inlineSprite = m_AnimSpriteInfo[animIndex][step];
Sprite sprite;
m_nameToSpriteDict.TryGetValue(inlineSprite.name, out sprite);
var spriteGraphic = m_spriteGraphicArray[animIndex];
spriteGraphic.SetMainTexture(sprite.texture);
DrawSprite(spriteGraphic, new List<InlineSpriteInfor>(){inlineSprite});
}

}

超链接实现

超链接处理流程

响应点击事件

  1. UIGraphicText组件继承IPointerClickHandler接口,实现OnPointerClick方法

  2. RectTransformUtility.ScreenPointToLocalPointInRectangle方法将屏幕坐标转成local坐标

  3. 判断是否点击在包围盒上,从而响应预先绑定的点击事件

源代码

UIGraphicText.cs

UIGraphicTextSpritesMgr.cs

EmojiSpriteAsset.cs

UIGraphicTextSprites.cs

图文混排-支持多个不同emoji来自不同图集和散图

之前针对表情不支持多图集的问题优化过一板,可以回顾下这篇文章:UIGraphicText组件表情渲染优化-支持表情来之不同图集

但后来发现图文混排场景不支持多个不同emoji来自不同图集和散图,下面是问题描述。

PC下不打图集下发现,多个不同的emoji标签显示同一个emoji sprite。

C:\\88534cc8db9f6423fece8da47e598d32C:\\53fba21b5a4b310d26e01fbc92b36a2e

原因分析

每个text组件下只分配一个maskableGraphic组件,如其名可遮罩图形,该组件可以根据uv坐标来截取自身mainTexture上的区域,以渲染emoji。

当不打图集,uv坐标就代表整个mainTexture,也就是整张单图,而mainTexture同一时刻只能代表一张单图。

所以在这个例子中,所有的emoji都显示同一张图。

按照这个原理,即使在打图集的情况下,假如一行消息上的emoji并不是都来自同一个图集,那么显示上也会出问题。

解决方案

这个问题的瓶颈在于maskableGraphic组件的数量,可以根据不同的texture来分配maskableGraphic组件来渲染emoji,这样就可以支持同时显示来自不同texture的emoji。

问题分拆:

  1. 分组,按sprite的texture的不同来决定分配UIGraphiTextSprites组件(继承maskableGraphic),每个UIGraphiTextSprites组件负责渲染这个texture的emoji。
  2. 重构drawSprites方法逻辑以支持分组渲染emoji
  3. 处理Update逻辑以支持渲染动图

但实践结果发现,按texture不同来分配maskableGraphic组件的方式并不适用于多个动图(动图即多个sprite切换)的情况,比如不打图集情况下有两个一样的动图emoji,当动图切换sprite texture时候,因为按texture分配组件所以两个一样的动图emoji只分配一个组件,前一个emoji会被后一个emoji抢占组件而不渲染。

C:\\7a9f240bac4cb3c6c539f8bad0891c12C:\\ad1a89e4b9b73f312118b21ca07bab28

所以最终的方案为:

  1. 区分静图和动图的emoji,静图的emoji按texture分配UIGraphiTextSprites组件
  2. 动图的emoji分配单独一个UIGraphiTextSprites组件负责渲染
  3. UIGraphicTextSpritesMgr池化UIGraphiTextSprites组件,避免在消息列表界面频繁创建和销毁组件

分配策略代码如下:

展开源码

UIGraphicTextSprites[] m_spriteGraphicArray;

private void DistributeTextSprite(List\<SpriteTagInfor> listTag)

{

var staticEmojiDict = new Dictionary\<Texture, List\<int>>();

for (var i=0;i\<listTag.Count;i++)

{

var tagInfor = listTag[i];

var animTagArray = m_AnimSpriteTag[i];

if (animTagArray.Length>1)

{

var textSprite = CreateTextSprite();

m_spriteGraphicArray[i] = textSprite;

}

else

{

var sprite = m_nameToSpriteDict[tagInfor.name];

if (!staticEmojiDict.ContainsKey(sprite.texture)) {

staticEmojiDict[sprite.texture] = new List\<int>(){i};

}

staticEmojiDict[sprite.texture].Add(i);

}

}

// staticEmojiDict:{texture1=>{1,3}}

foreach (var item in staticEmojiDict)

{

var textSprite = CreateTextSprite();

foreach(var index in item.Value)

{

m_spriteGraphicArray[index] = textSprite;

}

}

}

具体实现

下面以渲染emoji的流程图来讲解讲解改造的过程。

遇到的问题

1.OnPopulateMesh中控制gameObject的slefActive会导致报错,与onLogCallBack有冲突。

“Trying to add…… while we are already inside a graphic rebuild loop.”这句是UGUI的报错,查看了UGUI的源码发现:

canvas刷新时,会将标记为dirty的UIElement重新构建,graphic类会生成网格时调用OnPopulateMesh。

这个时候Graphic类不能更改,更改会让canvas标记setDirty加入到待构建队列,而且在canvas刷新时不能禁止入队,所以报了这个错。

下面是UGUI的源码:

C:\\8671ab9a5ec1a5f297a33d24684e119bC:\\3b0528cdf5e8756c32cae65ffba1311aC:\\11e53b162bb563663b032f919afcab4a

即在OnPopulateMesh方法里不能执行setActive方法,可以放在base.SetCVerticesDirty方法里做。

2.重用cell的带sprite的text没有更新文本,只是调用了disable后调了enable,log:

因为是base.SetVertivesDirty方法前提前返回了,没有加入到canvas的待更新队列不会更新图形,所以不能这样做。

下面是引起错误的代码:

测试与规范

兼容性排查,排查修改类被引用到的地方是否正常?

修改了UIGraphicText类和UIGraphicTextSprites类,两者在项目中引用我排查过,变更代码不会引起错误,但UIGraphicTextSprites类属于submodule的框架层,不知其他项目的情况。

测试案例如下:

C:\\b7c4a0986ebdf75c5ef2bda9f54b15cfC:\\b40295cf6f4f0fca2550c102727d03d9

观察在各个平台上打不打图集的表现情况:

测试平台 是否打图集 结果(emoji、多个emoji、动图emoji、动图)
Unity 不打 正常
Unity 正常
安卓 正常
IOS 正常

游戏热更新

version数据解析

在本地打包时会有跳过热更新或者测试热更新的需求,但没有文档解析打包界面version相关选项的作用,也不知道登录界面版本字符串的含义,所以这篇文章对打包过程的version相关参数和选项作详细解析,以及简单讲解游戏包更新流程。

参数解析

  • Build Package的Version:仅用作展示,作为游戏登录界面的版本字符串的一部分。
  • Build Package的VersionCode:作为GameConst.txt里的NativeVersionCode,用来更新包外的GameConst.txt。当包内与包外的GameConst里的NativeVersionCode不同时,会覆盖外部GameConst.txt文件。另一个作用是作为请求remoteVersionManifest文件获取最新版本号的参数。
  • 游戏常量配置的游戏是否启用热更新:对应GameConst.txt的gameOpenHotUpdate字段,可以忽略Version而跳过热更。
  • 登录界面的版本字符串分为MSDK和非MSDK格式,区别就是MSDK格式多了腾讯热更版本号,对游戏更新逻辑没影响。
    • MSDK格式:V{msdkVersion}-{LocalVersion}-{VersionName}-{VersionCode}
    • 非MSDK格式:V{LocalVersion}-{VersionName}-{VersionCode}

LocalVersion用于对比remoteVersion决定更新,VersionName对应Build Package的Version,VersionCode对应Build Package的VersionCode。

LocalVersion的值记录于Assets\Resources\version.manifest文件中

更新流程

版本号a.b.c各级的作用:

热更线程代码

DownloadThread.cs

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
// downloadFile用子线程实现,new Thread(new ThreadStart(RunDownloading))
class DownloadThread
{
public void Start()
{
if (!_isStop || _thread != null)
{
CLogger.Log("DownloadThread::Start() - Download Thread Already Started:" + !_isStop + "#thread:" + ((_thread == null) ? "null" : _thread.ManagedThreadId.ToString()));
return;
}
_isStop = false;
_thread = new Thread(new ThreadStart(RunDownloading));
CLogger.Log("DownloadThread::Start() - Create Download Thread:" + _thread.ManagedThreadId);
_thread.IsBackground = true; // 设置为后台线程,确保当主线程退出时该线程也会结束
_thread.Start();
}

private void RunDownloading()
{
for (; ; )
{
if (_isStop)
{
CLogger.Log("DownloadThread::RunDownloading() - Download Thread Meet Stop Flag,ThreadId:" + Thread.CurrentThread.ManagedThreadId);
break;
}
if (_currentTask == null)
{
lock (_pendingTasks)
{
int num = _pendingTasks.Count;
if (num > 0)
{
_currentTask = _pendingTasks.Dequeue();
}
else
{
_isWaitting = true;
this.Wait();
_isWaitting = false;
}
}
}

if (!_isStop && _currentTask != null)
{
//Debug.Log ("StartDownload");
// 开始断点续传下载文件
DownloadFromBreakPoint(_currentTask);
//Debug.Log ("EndDownload");
}
}
}

lock关键字

IEquatasble https://www.cnblogs.com/lian–ying/p/9502879.html

C#多线程编程

http://images.china-pub.com/ebook4610001-4615000/4613657/ch01.pdf

http://apps.mxinfos.com/电子书籍/多线程编程.pdf

断点续传

下载热更文件使用了断点续传,原因是当下载大文件中途断网或退出时,可以保证下次更新在上次下载进度基础上继续正常下载。

断点续传需要服务端支持,需支持允许分段方式请求的文件数据。

使用http协议头字段range告知服务器下载文件字节数据范围值,例如:range:bytes=500-1000。配合If-Range:Etag/if-modified判断文件是否发生变化。详细看考这篇文章

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
81
82
83
// 关键代码
if (File.Exists(task.storagePath))
{
// 1.从已下载部分数据的文件统计已下载字节数、剩余下载字节数
using (FileStream fileStream = new FileStream(task.storagePath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.ReadWrite))
{
receivedLength = fileStream.Length;
toDownloadLength = totalLength - receivedLength;
fileStream.Close();
}

if (receivedLength != dfi.receivedSize)
{
CLogger.Log(string.Format("DownloadThread::DownloadFromBreakPoint() - break point save receive size is wrong for file[{0}], saveSize={1}, fileSize={2}", _currentTaskFileName, dfi.receivedSize, receivedLength));
}
}
task.fileLength = totalLength;
task.receivedLength = receivedLength;
_currentTaskTotalBytes = totalLength;
_currentTaskReceivedBytes = receivedLength;

bool transferOkay = true;
if (toDownloadLength > 0L)
{
CLogger.Log("DownloadThread::DownloadFromBreakPoint() - start http download, The request url is [" + uri + "] with range [" + receivedLength + "," + totalLength + "]");

HttpWebRequest request2 = (HttpWebRequest)WebRequest.Create(uri);
request2.Timeout = kTimeOut;
request2.KeepAlive = true;
request2.ReadWriteTimeout = kTimeOut;
request2.AddRange((int)receivedLength, (int)totalLength);

HttpWebResponse response2 = (HttpWebResponse)request2.GetResponse();
transferOkay = this.ReadBytesFromResponse(task, response2);
response2.Close();
request2.Abort();
}
if (transferOkay)
{
this.OnDownloadFinished(task, null);
}

// 2.读取数据存储进文件
private bool ReadBytesFromResponse(DownloadTask task, WebResponse response)
{
bool okay = false;
DownloadFileTransferInfo fileInfo = _transferMgr.GetDownloadFileInfo(task.file);
FileUtils.Instance.CheckDirExistsForFile(task.storagePath);

using (FileStream fileStream = new FileStream(task.storagePath, task.receivedLength == 0 ? FileMode.Create : FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.ReadWrite))
{
// 3.设置文件流的起始指针为已收到的字节大小
fileStream.Position = task.receivedLength;
byte[] array = new byte[1024];
using (Stream responseStream = response.GetResponseStream())
{
int bytesRead = 0;
while (task.receivedLength < task.fileLength)
{
// 4.读写流把数据从respone体中读出并写入到目标文件中
bytesRead = responseStream.Read(array, 0, array.Length);
fileStream.Write(array, 0, bytesRead);
task.receivedLength += bytesRead;
_currentTaskReceivedBytes = task.receivedLength;

_transferMgr.UpdateFileTransferProgress(fileInfo, task.receivedLength);
}

okay = true;
}

if (task.receivedLength != task.fileLength)
{
string s = string.Format("DownloadThread::ReadBytesFromResponse() - Download length not fit Error:{0}/{1}", task.receivedLength, task.fileLength);
CLogger.LogError(s);
okay = false;
this.OnDownloadFinished(task, new Exception(s));
}
}

return okay;
}

上面断点续传的是ab文件,下面续传zip文件:

BreakpointTransferZip.cs

热更包与基线

热更文件检出

热更包的资源文件通过对比两个版本的资源变更情况得出

用git对比?选出具有相同目录结构文件?

网上的一般方式是用MD5检出变更的文件,服务器会下发最新版文件的MD5信息,用之和本地文件MD5对比。

应不用选出具有相同目录结构的文件包,服务端会下发热更文件的相对存储路径。

基线怎么来呢?

很简单,记录上一次打包的所有ab到cache文件即可。百田热更基线记录abCache的时机为每次发热更包后,腾讯的则是发整包版本后。

射箭小游戏

碰撞检测

属于2D游戏,没有深度,碰撞检测采用判断两线段是否相交的方法,即箭头两帧之间位置线段与箭靶中心为原点的xy轴中心线两线段。

之所以选择这种判断方法,是因为中靶的位置是算分数的一个因素,不能像射气球那种带碰撞盒的方式,箭进入碰撞盒后就会立刻停止。这样的得分效果不好,只能拿到击中边缘的3分。

假如两帧的线段同时与两中心线相交,优先取与y轴平行的中心线交点,因为箭停留在靶上的效果较好。

实际效果发现与x轴平行的右半段,会很大概率挡住箭射中Y轴中心线,这样效果不好。右半段是为了托住箭避免没中Y轴线而穿靶,所以可以加个检测前提。

关键代码

判断二维平面两线段是否相交代码:

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
-- 求线段交点,以解线性方程组的方式
-- @param p0 Vector2
-- @return 线段是否相交,交点
function GameUtils.calcIntersectionOfLinear2D(p0, p1, p2, p3)
-- 直线的一般方程为F(x) = ax + by + c = 0
-- 已知两点可得:a = y0 – y1, b = x1 – x0, c = x0y1 – x1y0
-- 可推出两直线交点:
-- x = (b0*c1 – b1*c0)/D
-- y = (a1*c0 – a0*c1)/D
-- D = a0*b1 – a1*b0, (D为0时,表示两直线重合)
local a0=p0.y-p1.y
local b0=p1.x-p0.x
local c0=p0.x*p1.y-p0.y*p1.x
local a1=p2.y-p3.y
local b1=p3.x-p2.x
local c1=p2.x*p3.y-p2.y*p3.x
local D=a0*b1-a1*b0
if D == 0 then
return false
end
local x=(b0*c1-b1*c0)/D
local y=(c0*a1-c1*a0)/D
-- 判断交点是否在两条线段上
local EPSINON = 0.000001 --浮点数相减的结果的不精确问题
if (x - p0.x) * (x - p1.x) <= EPSINON and
(y - p0.y) * (y - p1.y) <= EPSINON and
(x - p2.x) * (x - p3.x) <= EPSINON and
(y - p2.y) * (y - p3.y) <= EPSINON
then
return true, Vector2.New(x, y)
end
return false, Vector2.New(x, y)
end

在实现线段的碰撞检测中踩了些坑,总结两点注意点:

  1. 两帧之间的位置坐标点不要采取预测下一帧位置的方式,而是保留上一帧位置。因为用默认一帧deltaTime(约0.034,30帧)预测下一帧位置,可能由于卡帧导致跟实际下一帧的位置出现带有隐患的相差,假如这个相差的距离刚好包含了中心位置,就会出现穿透现象。写这么长,不如画个图:
  1. 浮点数运算精度缺失问题。坐标点的值时浮点数,判断相等时候要注意,不能使用全等,即使两个相等浮点数相减不一定每次都等于0,使用允许误差范围判断math.abs(a -b) < c,c为使用场景下允许的最大误差.
1
2
3
4
5
6
7
8
9
10
11
12
if (x - p0.x) * (x - p1.x) <= 0 and 
(y - p0.y) * (y - p1.y) <= 0 and
(x - p2.x) * (x - p3.x) <= 0 and
(y - p2.y) * (y - p3.y) <= 0
then
改为:
local EPSINON = 0.000001
if (x - p0.x) * (x - p1.x) <= EPSINON and
(y - p0.y) * (y - p1.y) <= EPSINON and
(x - p2.x) * (x - p3.x) <= EPSINON and
(y - p2.y) * (y - p3.y) <= EPSINON
then

3.检测移动靶的碰撞检测问题。当靶与箭相向运动,靶移动速度过快,同时箭的移动速度过慢(两帧之间线段太短),就会很大概率出现穿透问题。有个很简单的解决方法,检测移动靶时候箭两帧之间的线段不能过小 (小于阈值时适当延长),而且策划的配置不会配太快的靶,这样就基本能避免检测移动物体的穿透问题。有时候限制bug发生的条件也可以简单地解决问题。

但是还是发现偶然小概率有穿靶情况,假如两帧之间最短距离定得太大,击中时箭会有种被靶吸附的效果,不太好。认真观察这个bug,箭刚好错过了移动靶的中位线时正好都未于靶右半部分。网上有个方法是增大碰撞区域,这里可以将靶右半部分作为碰撞盒,引入碰撞盒检测。

综上所述,得出碰撞检测的最佳实践是:

1.优先检查Y轴中心线

2.将x轴中心线以圆心分成两段,检测是否与半段相交。

3.否则判断箭是否下落状态并且下落角度大于45度,检测是否与右半段相交。

4.否则检测箭是否位于靶右半边区域,并且箭在上升,则碰撞;或者箭下降且到了中心点y轴一下区域,则碰撞。

摇杆拉弓效果

摇杆拉弓弓弦表现伸缩性

通过拉伸弓弦图片width来实现,但有个问题是弓弦的粗细程度会跟着伸缩而改变,在大小比较小的情况下表现不太明显,可以接受。

轨迹预览

这个轨迹实现有三步:

  1. 初始化克隆出一系列轨迹点

  2. 抽离出箭移动公式,输入时刻输出位置

  3. 在拖拽监听中设置一系列轨迹点的位置