UGUI 实现圆形环绕文字

美术希望在 Unity3d 中实现圆形环绕的文字。但是找了一圈没有发现比较适合的插件。于是读了一下 unity3d-ui 的源码 关于文字渲染的部分,最终找到了一种简单可行的方案。

本文使用的示例基于 Unity3d 4.6.x

0x00 Solution

UGUI 的 Text 渲染的过程是由 TextGenerator 产生顶点数据,配合字体产生的贴图最终显示在屏幕上。如果我们能修改 Text 的顶点位置,就能按照需求将文字摆放到圆周上。

从 Text 类的继承树(Text -|> MaskableGraphic -|> Graphic)我们可以追溯到 Graphic 基类。在 Graphic 类中有一个 void UpdateGeometry() 方法,它会调用由子类覆盖的 void OnFillVBO(List<UIVertex> vbo) 方法产生顶点,然后从组件中获取实现 IVertexModifier 接口的脚本,按从上到下的顺序执行 ModifyVertices() 操作,以便在顶点数据更新到显存之前修改它。

protected virtual void UpdateGeometry()
{
    var vbo = s_VboPool.Get();

    if (rectTransform != null && rectTransform.rect.width >= 0 && rectTransform.rect.height >= 0)
        OnFillVBO(vbo);

    var components = ComponentListPool.Get();
    GetComponents(typeof(IVertexModifier), components);

    for (var i = 0; i < components.Count; i++)
        (components[i] as IVertexModifier).ModifyVertices(vbo);
    ComponentListPool.Release(components);

    canvasRenderer.SetVertices(vbo);
    s_VboPool.Release(vbo);
}

在 UGUI 中,使用到 IVertexModifier 这个接口的类有 BaseVertexEffect,以及它的子类:Shadow(阴影),和 Outline(描边)。恍然大悟!原来这两个特效便是通过修改顶点实现的:阴影的本质就是复制原有的文字顶点到 VBO(vertex buffer object)尾部,再修改原顶点的颜色并作一点偏移,阴影就出来了。

0x01 Demo

可见我们只要继承 BaseVertexEffect 类,实现修改文字顶点的坐标即可把文字移动到圆周上。以下是成品的效果,比较简陋地实现了将文字变换成圆周上部居中,可调整半径和字距:

circletext-0.png

circletext-1.png

circletext-2.png

圆心位于中轴点。文本框设置成 Overflow ,不然原有的框外的字就不显示了。此外由于 IVertexModifier 接口的组件是被顺序调用的,所以这个脚本要放在其它文字效果的上面(如投影、描边):

circletext-3.png

顶点变换是通过三角函数计算每个文字移动后在圆周的位置,然后使用矩阵变换将每个字符的四个顶点变换到相应的位置上。每个字符顶点的顺序是:左上,右上,右下,左下。

> 代码传送门 <

0x02 More

将矩阵变换的中心放到字符中下部,可以实现底部对齐,这样对中文字体的字距把握好一些。

circletext-4.png

以上的思路还可以扩展到将文字沿路径或其它图形。该方法只有在文字有变化的时候才会调用,所以性能很高。

新手引导系统设计

经历了两个 cocos2d-x 游戏项目,在新手引导系统的设计上也有了一点经验,但经历更多的是坑。我想在这里记录一个更加成熟一点方案,并在现有的 unity3d 项目上实现出来。

0x00 框架

新手引导系统应该是独立的,与其它系统之间是低耦合的,理想状态下不需要在其它业务逻辑中放置额外的代码——例如在按钮逻辑中去调用新手引导系统的接口来驱动引导进入下一步骤。

如果将新手引导系统拟人化,试想它看着一个新玩家进入游戏,然后根据游戏状态的决定是否开始引导,然后一步一步引导玩家操作界面上的元素,直到教会玩家一些基本操作。

没错,这就是观察者模式。我们不需要在游戏业务中到处插入新手引导系统的代码,而是让新手引导系统主动订阅游戏状态的变化,根据状态来决定引导流程。在我的 PureMVC 实现中,我通过劫持 Facade.sendNotification() 方法来获得游戏状态的变化。由于整个游戏的所有消息都经过这里,于是引导系统就可以在这里获知它感兴趣的信息,从而推动引导进程。

local originFacadeSendNotification = pm.Facade.sendNotification
function pm.Facade:sendNotification(notificationName, body, type)
    originFacadeSendNotification(self, notificationName, body, type)
    GuideManager:Dispatch({
        catalog = "notification",
        name = notificationName,
        body = body,
        type = type,
    })
end

GuideManager:Dispatch() 是一个用于被动更新游戏状态的接口,当状态变化后,新手引导系统检测引导配置,判断是否需要激活引导。

当新手引导系统关闭的时候,Dispatch 不做额外的操作,不太需要担心性能问题。Dispatch 方法中会捕获一些引导需要用到的状态,例如当前所处界面、玩家等级经验、背包中的道具等等。如果劫持 sendNotification 还不足以满足需要,还可以主动调用 Dispatch 方法向新手引导系统推送具体信息。

0x01 遮罩层实现

当引导开始后,根据配置文件中的步骤,在屏幕上显示引导文字及指针。通常除了需要操作的按钮周围是亮着的,其它部分会被遮挡,并且屏幕的其它部分是无法操作的。这样简单的一个需求,实现起来却并不容易。

考虑到新手引导的独立性和与其它系统的低耦合度,当引导过程中需要锁定特定按钮,应该是主动去检索按钮的位置信息,而不是在界面逻辑中提供按钮位置。于是在配置文件中需要指定按钮在场景中的绝对路径,然后引导系统使用 GameObject.Find("/path/to/game/object/with/button") 以一定频率来轮询按钮,直到获取到指定对象或者超时。除了切换或加载场景之外一般都能一次命中。

引导系统定位到按钮后,需要在该位置绘制遮罩层,用于遮挡屏幕上其它部分。该遮罩层还有一个作用,就是阻止触摸事件向下传递,从而禁用所有界面操作。那么如何让我们指定的按钮可以操作呢?可以通过一个透明的代理对象(Event Trigger)将点击事件转发到目录按钮上(Target Button),这个代理对象被叠加到目标按钮上方,并实时跟踪目标按钮的位置和大小。

这样的好处是,定位到目标按钮后,即使按钮在位置变化、或者被部分遮档。该代理对象的触摸区域一样能将事件准确无误地传达给目标按钮。此外,引导层的上方还可以放置一些诸如对话框,引导手势之类的其它组件。

至于如何在遮照层上绘制与按钮大小相近的缕空区域,这里有一个的方案:

  =======          <- Dialog Panel
            ^^^    <- Proxy Button (Event Trigger)
------------///--- <- Guide Mask (Raw Image, camera[culling mask = guide])
            ~~~    <- Shape (tag = guide, shader = cutout)
                
============***=== <- UI Panel, * = Target Button

Canvas 下分为两个层,一个是 UIMain 用来放界面,在其之上是 UIOverlay 用来放一些叠加层,例如引导层。

引导层的主体是一个 Raw Image, 其 texture 是由 Camera 组件绘制的。Camera 首先用一个半透明黑色清空屏幕,然后在目标按钮的区域绘制一个形状,该形状是一个简单的 quad 并指定了一个混合模式为 blend Zero OneMinusSrcAplha 的 shader —— 用于在半透明黑色屏幕上缕空(cutout)出目标按钮区域。形状作为代理对象的子节点,跟随代理对象移动和缩放,于是缕空区域就随着变化。

Event Trigger 除了可以传递点击事件,还可以实现拖拽事件。甚至可以将事件代理给 ScrollRect 等对象。

0x02 引导配置

通过配置文件来建立引导所需要的步骤,可以方便程序和策划制作和修改新手引导,并且可以降低引导系统的耦合度。

一个引导是由一组起动条件、一组目标状态和一系列步骤组成。起动条件决定引导是否激活,目标状态决定引导是否已经完成;而这些步骤则是达成目标所需要的操作。

例如引导新手进入游戏进行一场战斗的起动条件是:总经验 = 0;目标状态是:总经验 > 0。这些状态可以直接从游戏数据中获得,不需要服务端做额外的记录。若玩家的总经验 > 0,表明他至少完成过一场战斗,这步的引导就可以标记为完成状态了。此外还可以读取关卡的进度、甚至是用指定引导的状态来作为起动条件。

引导可以有多个入口,其中一个由主场景开始,可以防止玩家中途退出游戏,重登后失去引导。一般情况下引导过程可能在游戏中的某个场景开始。

目标状态是在引导步聚中完成的,但是有时候完成目标后,引导还需要做一些后继操作,例如几句温馨提示,或者引导玩家回到主场景。

引导配置的结构很大程度决定了引导系统的灵活性,这部分的内容需要在项目中慢慢摸索。待今后总结出更多经验再来详述。

参考资料

  1. Unity3D新手引导开发手记

Lua 深拷贝的实现

使用 PureMVC 构建游戏项目 Part III 的 Model 章节,我提到了使用对象拷贝(copy 或克隆/Clone)来实现值对象(Value Object)的保护。那么要如何在 lua 中实现对象拷贝呢?

0x00 定义

首先思考一下拷贝的本质是什么?简单说就是创建出一个和本体一样的副本,并且对副本的任何操作都不影响本体。以最基本的数值类型为例:

foo = 1
bar = foo -- assign to foo
bar = 2 -- make a change
print(foo, bar) -- 1, 2

可见通过直接赋值,将 foo “拷贝”到 bar 后,修改 bar 并不会影响到 foo 。这完成符合上述对拷贝的定义。然而在 lua 中并不是所有类型的变量都可以通过直接赋值来实现这样的拷贝——比如 table 类型就不行:

foo = {key = 1}

bar = foo
bar = {key = 2} -- assign to another table
print(foo.key, bar.key) -- 1, 2
assert(foo.key ~= bar.key) -- ok

baz = foo
baz.key = 2 -- make a change by key
print(foo.key, baz.key) -- 2, 2
assert(foo.key ~= baz.key) -- error

可以看到,将 foo 赋值给 baz 后,修改了 baz.key 之后,foo.key 也被改动了。通常我们叫这种类型的赋值为:浅拷贝,它不符合我们对拷贝的定义:对副本的任何操作都不影响本体;以之相对的就是深拷贝。

0x01 Lua 类型

在 lua 中有 8 种基本类型,分别是:nil、boolean、number、string、userdata、function、thread、table。

赋值拷贝

其中通过直接赋值能满足我们对拷贝的定义的类型有:nil、boolean、number、string、function。对于前三者没有什么好说的,它们就是简单值类型,不会有什么操作能间接影响它们的副本。

对于 string,虽然它本身不是简单值类型,但在 lua 中有特殊的内存管理方式,不能直接去修改它的值,而且任何影响它的操作都会创建新的副本——不会影响本体,所以它符合我们对拷贝的定义。

还有 function,它也不是简单值类型,但是即使多个不同的变量引用它也没有关系,因为没有什么操作能修改或影响它。同一段代码,在程序中只需要有一个实例即可。所以我认为它也符合我们对拷贝的定义。

非赋值拷贝

另外三种 lua 基本类型 userdata、thread、table 都是非简单值类型。其中 table 可以说是 lua 数据结构的根基,要实现其它的数据结构都要依赖它,在 lua 程序中模拟面向对象类也离不开它,然而它不能简单通过赋值进行拷贝,而是需要创建一个新的 table 并将原 table 的 key-value 递归拷贝,此外还要考虑元表等相关处理,具体代码在后面讨论。

thread 和 userdata 比较复杂。在我们的游戏项目中通常只对值对象(Value Object)进行深拷贝,且通常值对象只存放一些简单值类型和 table,并不会包括这两种值类型。所以如果遇到这样的类型的话,只对其使用浅拷贝不作其它处理。

thread 可以通过 coroutine.create 创建并通过 coroutine.resume/coroutine.yield 影响其状态,但不能直接通过源 thread 创建其副本——即使知道它的源 function ,也不能保证新创建的 thread 的内部状态与源 thread 一致。
对于 userdata 情况就更复杂了,userdata 是由 luavm 的宿主程序管理的数据,其行为不在本文讨论范围。

0x02 实现

通过对 lua 基本类型的分析,可知只需要对 table 类型进行递归拷贝即可。其它所有类型直接用赋值拷贝或浅拷贝。

但是 table 有一些特性需要注意:

  1. 拷贝后的 table 应与原 table 具有相同的元表;
  2. 元表不需要递归拷贝;

《Lua 简单面向对象模型》一文中设计的“类”,即是一种作为元表的 table,拷贝这个类的实例,只要深拷贝实例的 table 最后将元表指向同一个类表就行了。

此外,还有注意到一类用例,在参数文献[1]中就犯了这个错误:

foo = {}
bar = {foo, foo}
assert(bar[1] == bar[1])

baz = deepCopy(bar)
assert(bar[1] ~= baz[1]) -- it is a clone
assert(baz[1] == baz[2]) -- important

注意到 bar 里的两个元素,其实是同一个 table,所以进行深拷贝的时候要保持这种联系。而不是在深拷贝后里面变成两个独立的 table。在最终的实现代码里,使用了 lookup_table 来保存已经拷贝过的对象。

另外还要注意一点就是 table 的 key 可以是任意基本类型,所以不仅需要对 value 进行递归拷贝,也要对 key 进行递归拷贝。

以下是最终实现的代码:

function deepCopy(object)
    local lookup_table = {}
    local function _copy(object)
        if type(object) ~= "table" then
            return object
        elseif lookup_table[object] then
            return lookup_table[object]
        end

        local new_table = {}
        lookup_table[object] = new_table
        for key, value in pairs(object) do
            new_table[_copy(key)] = _copy(value)
        end
        return setmetatable(new_table, getmetatable(object))
    end

    return _copy(object)
end

参考文献

  1. Lua实现浅拷贝和深拷贝: (P.S.该实现没有处理表中存在相同元素的问题)

微信红包分配算法

最近在微信群里抢红包,经过观察,其规则如下:

发红包时

  • 总额不得少于份数 * 0.01 元

抢红包时

  • 每份至少能够分到 0.01 元
  • 每份累加起来总是等于总额
  • 每份的数额是随机的
  • 先抢或后抢并没有明显优势

要如何实现分配算法,能够满足这一需求?

我的第一反应是可以参考遗传算法中的「轮盘赌」权重分配:假定总额为 amount,份数为 n,算法首先随机产生 n 个权重 weight(s),然后计算权重总和 total,每个领取红包的人得到一个权重 weight_i,然后从总额中取走 weight_i / total * amount 即可。

但由于规则中的货币不可无限细分,而是以 0.01 元为最小单位;此外还要保证每个人至少能得到 0.01 元。所以在取钱的时候要做一些限定:

  • 上限不超过 (剩余总额 - (剩余份数 - 1) * 0.01 元):给后来者留下足够的余额
  • 下限不低于 0.01 元:保底安慰奖

以下是用 javascript 写的 demo,为了避免浮点数精度问题,这里的单位为 1 分,即 0.01 元:

var amount = 100; // cent
var n = 10;

var weights = []
var total = 0
for(var i = 0; i < n; i++) {
    var weight = Math.random();
    weights.push(weight);
    total += weight;
}

var balance = amount;
var count = n;

var takes = [];
while(count > 0) {
    var weight = weights[n - count];
    var take = Math.ceil(weight / total * amount);
    take = Math.max(1, Math.min(take, balance - (count - 1)));

    takes.push(take);
    balance -= take;
    count -= 1;
}

// weights
// [0.7933445337694138, 0.0034502430353313684, 0.39447791734710336, 0.29893025243654847, 0.6303203923162073, 0.5726003672461957, 0.9000553076621145, 0.1479424126446247, 0.4969204587396234, 0.4672785080038011]

// takes
// [17, 1, 9, 7, 14, 13, 20, 4, 11, 4]

这一算法有以下优点:

  • 随机权重预分配的方法很容易实现;
  • 以分为单位,很简单就避开了小数浮点精度问题;
  • 保证所有的钱都被取完,总是使用向上取整;
  • 上下限的控制保证了规则中最小额度的问题;

用向下取整,最后可能会剩出一些钱,可以留给最后一个人;
用向上取整,最后一个人可能取不到原本应得的额度,但是不需要处理余额;

此外,我也围观了一些别人的思路,除了与我想法相同的以外,还有一些其它的思路:

  1. 正态分布:从总额为单位 1 的角度思考,进行随机划分。算法比较复杂,另外还要考虑标准差来影响贫富差距;
  2. 先到先得:不作预处理,先抢的人,先从余额里取走随机额度。这种方法可控性太差,有可能出现先抢的人拿走决大部分,而越是后来者只能得越少;

参考资料


2016-02-29 更新:官方的方案还真的是不作预处理,先到先得: 微信红包的架构设计简介

自动修改 Unity3d 导出的 Xcode 项目

在 Unity3d 导出 iOS 项目后,常常需要定制一些选项,例如指定额外的 framework,修改 Info.plist 等。

Unity3d 在导出工程的时候提供两个选项:替换整个项目「replace」和追加变动「append」。一旦使用了 replace 选项,之前所有手工设置的变更都会丢失,需要重新设置。

那么有没有一种方法可以在导出项目的时候对 Xcode 项目进行自动化配置呢?正好 Unity3d 官方提供了一个叫 xcodeapi 的项目,提供了一套简单易用的接口满足一些常见的定制需求。

bitbucket 上下载 xcodeapi 的源码,放到 Unity 项目的 Assets 目录下任意位置,例如 /path/to/project/Assets/Editor/xcodeapi 然后创建一个 MonoBehaviour 的子类,并在其中实现带有 [PostProcessBuild] 属性的 public static void OnPostprocessBuild (BuildTarget BuildTarget, string path); 方法:

如果有多个 PostProcessBuild 属性,可以使用 [PostProcessBuildAttribute(1)] 来指明运行的顺序,详情见此文档

using UnityEngine;
using UnityEditor;
using UnityEditor.Callbacks;
using UnityEditor.iOS.Xcode;
using System.Collections;
using System.IO;

public class XCodeProjectMod : MonoBehaviour {

    [PostProcessBuild]
    public  static  void OnPostprocessBuild (BuildTarget BuildTarget, string path) {

        if (BuildTarget == BuildTarget.iPhone) {

            // 添加额外的 AssetsLibrary.framework
            // 用于检测相册权限等功能

            string projPath = PBXProject.GetPBXProjectPath (path);
            PBXProject proj = new PBXProject ();
        
            proj.ReadFromString (File.ReadAllText (projPath));
            string target = proj.TargetGuidByName ("Unity-iPhone");
            
            // add extra framework(s)
            proj.AddFrameworkToProject (target, "AssetsLibrary.framework", false);

            // set code sign identity & provisioning profile
            proj.SetBuildProperty (target, "CODE_SIGN_IDENTITY", "iPhone Distribution: _______________");
            proj.SetBuildProperty (target, "PROVISIONING_PROFILE", "********-****-****-****-************"); 

            // rewrite to file
            File.WriteAllText (projPath, proj.WriteToString ());

            // 由于我的开发机是英文系统,但游戏需要设置为中文;
            // 需要在修改 Info.plist 中的 CFBundleDevelopmentRegion 字段为 zh_CN

            // Get plist
            string plistPath = path + "/Info.plist";
            PlistDocument plist = new PlistDocument();
            plist.ReadFromString(File.ReadAllText(plistPath));
            
            // Get root
            PlistElementDict rootDict = plist.root;
            
            // Change value of CFBundleDevelopmentRegion in Xcode plist
            rootDict.SetString("CFBundleDevelopmentRegion", "zh_CN");

            PlistElementArray urlTypes = rootDict.CreateArray("CFBundleURLTypes");

            // add weixin url scheme
            PlistElementDict wxUrl = urlTypes.AddDict();
            wxUrl.SetString("CFBundleTypeRole", "Editor");
            wxUrl.SetString("CFBundleURLName", "weixin");
            PlistElementArray wxUrlScheme = wxUrl.CreateArray("CFBundleURLSchemes");
            wxUrlScheme.AddString("____________");            

            // Write to file
            File.WriteAllText(plistPath, plist.WriteToString());
        }
    }
}

上面的例子展示了添加 Framework 和 修改 Info.plist 的功能,更多的接口可以参考官方的文档,但是里面的说明并不够详细,可能需要慢慢摸索。