# 游戏设计、原型与开发

# 游戏设计和纸质原型

# 像设计师一样思考

我是一名游戏设计师。

# Bartok:游戏练习

# 目标
# 入门指南
# 试玩测试
# 分析:找准问题

每次试玩后,要找到问题所在,尽管它们大多数遵从下面的基本规则,但每个游戏的问题不尽相同:

  • 游戏的难度对于目标受众是否合适?太难,太简单,还是刚好?
  • 游戏的结果靠运气还是策略?随机性是否占比太多,或是玩家一旦占了上风,就会锁定胜局,其他玩家难以翻盘。
  • 当你的回合结束,游戏还依然有趣吗?你是否影响别人回合的行动,或他们的回合对是否有直接影响?
# 更改规则

优秀游戏设计的秘密就是迭代:

  1. 决定进行游戏时你想要的感觉;
  2. 修改规则达到这种感觉;
  3. 玩游戏;
  4. 分析规则是如何影响游戏的感觉的;
  5. 回到步骤 1,重复这个过程,直到你满意为止。

# 游戏的定义

设计者们设定的目标:

  • 体验式理解
  • 创造有趣的选择
  • 鼓励玩乐的态度 你的游戏应该鼓励玩家乐于接受规则的限制。

玩游戏的动机(需求):

  • 人类喜欢设计好的冲突。
  • 人类想要成为别人。
  • 人类想要刺激。

游戏设计是 1%的灵感和 99%的迭代。

# 游戏分析框架

# 游戏学的常用框架

  • MDA:机制(mechanics)、动态(dynamics)和美学(aesthetics)
  • 形式(formal)、戏剧(dramatic)和动态元素(dynamic elements)
  • 四元法(Elemental tetrad):机制、美学、剧情和技术。

# MDA:机制,动态和美学

  • 机制:游戏的数据层面上的组件和算法。
  • 动态:响应玩家输入和其他输出的实时行为。
  • 美学:玩家与游戏系统交互时,应当唤起的情绪反应。

成年人倾向于更具挑战性的游戏,希望自己获胜是因为策略而不是纯靠运气。基于此,设计师想让游戏看起来更有目的和策略性,仅仅通过修改规则(机制的元素之一)完全可以达到这种美学的改变。

# 规则,戏剧和动态元素

  • 形式:规则让游戏与其他媒体和互动区分开来是游戏的骨架。形式包括规则、资源和界限。
  • 戏剧:游戏的剧情和叙事,包括设定。戏剧元素让游戏成型,帮助玩家理解规则、促使玩家与游戏产生情感共鸣。
  • 动态:游戏运行的状态。一旦玩家真正地进行游戏,游戏就进入了动态。动态元素包括决策、行为和游戏实体间的关系。要注意这里的动态与 MDA 中的类似,但是范围更广,因为范围超越了机制的实时运行。
# 形式元素
  • **玩家交互模式:**玩家如何交互?单人、单挑、队伍对抗、乱斗(多个玩家互相对抗)、一对多、合作甚至多人分别对抗一个系统。
  • **目标:**玩家在游戏中的目标是什么?怎样获取胜利。
  • **规则:**股子额限制玩家的行动,告诉他们能做些什么?
  • **过程:**玩家在游戏中的行动。《蛇与梯子》中的规则是根据骰子摇出的数字移动。
  • **资源:**资源是游戏中有价值的各种元素。比如金钱、血量、物品和财产。
  • **边界:**游戏与现实的界限在哪里?
  • **结局:**游戏如何结束?除了终点,过程也不断导向结局。
# 戏剧元素
  • **前提:**游戏世界的背景故事。在《大富翁》中,玩家首先是地产商,努力在亚特兰大和新泽西垄断房地产。
  • **角色:**角色是故事中的人物。电影导演的目标是让观众关心主角,而在游戏中玩家就是主角,设计师要决定让主角作为玩家的代言人(将玩家的意图传达到游戏世界中)或是让玩家扮演一个角色(玩家遵从游戏角色的意志)。
  • **戏剧:**游戏的情节。戏剧包含了整个游戏过程的叙事内容。

戏剧元素的主要目的是帮助玩家理解规则。

# 动态元素
  • **涌现:**简单规则的碰撞可以导致难以预期的结果。
  • **涌现叙事:**游戏与生俱来有能力让玩家置身于不寻常的情景中,因此产生了有趣的故事。
  • **试玩是唯一理解动态的方式:**成熟的游戏设计师更擅长预测游戏的动态行为和涌现,但是没人能在试玩之前准确理解游戏动态的运行。

# 四元法

  • 机制:
  • **美学:**美学解释了游戏如何被五感接受:视觉、听觉、嗅觉、味觉和触觉。
  • **技术:**这个元素涵盖了所有游戏使用的技术。最明显的就是主机硬件、计算机软件、渲染管线等,它还包含了桌面游戏中的技术型元素。
  • 剧情:

# 小结

这些用来刘鹗 ii 游戏和互动体验的框架分别从不同视角结局的:

  • MDA 试图展示玩家与设计师看待游戏的不同方式和目的,设计师通过玩家的视角可以更有效地审视自己的作品。
  • 形式、戏剧和动态元素将游戏设计细分为特定组件分别对待和改进。本意是帮助设计细分游戏的各组成部分,并分别优化。
  • 四元法以游戏开发者视角看待游戏。它将原属于不同团队的游戏基本元素分区:设计师负责机制,艺术家负责美学,编剧负责剧情,还有程序员负责技术。

# 分层四元法

# 内嵌层

内嵌层类似 Schell 的四元法。

四元素的定义类似 Schell 的理论,但它们仅存在于游戏层面。

  • 机制:定义玩家和游戏互动的系统,包括游戏的规则和 Fullerton 书中的形式元素:玩家互动模式、目标、规则、资源和边界。
  • 美学:美学描述了游戏的色香味和感觉,包括从游戏原声到角色建模、包装和封面。
  • 技术:对于电子游戏来说,技术元素主要依靠程序员,但对于设计者来说理解它也很重要,因为程序员为设计师的想法实现提供了基础。
  • 叙事:Schell 在他的四元法中使用的是“剧情”,但我选择了范围更广的叙事(narrative),涵盖了背景、角色和情节。内在叙事包括所有脚本剧情和提前生成的游戏角色。

# 动态层

如你所见,玩家是静态内嵌层到动态层的关键。动态层所有的元素都源自玩家进行游戏,包括玩家控制的各类元素和它们交互产生的结果。动态层是涌现的乐土,复杂的行为从简单的规则里显现出来。游戏的涌现行为常常难以预料,但是游戏设计最重要的技能之一就是预测。动态的四元素是:

  • 机制:不同于内嵌层的机制,动态层的机制包括玩家如何与内在元素互动。动态机制包括过程、策略、游戏的涌现行为和结局。
  • 美学:动态层的美学包括游戏过程中创造的美学元素。
  • 技术:动态的技术指的是游戏过程中技术性组件的行为,包括一对骰子的点数如何从来不符合数学预测的平滑钟形曲线,以及电子游戏的所有代码。游戏中敌人的 AI(人工智能)表现就是个例子,这里的技术包括游戏启动后代码产生的所有行为。
  • 叙事动态叙事指的是游戏过程中产生的剧情,可以是《黑色洛城》和《暴雨》(Heavy Rain)中玩家选择剧情分支,或者玩《模拟人生》时产生的家族故事,甚至是与其他玩家结伴游玩的轶事。火影手游每个角色的独特故事线。

# 文化层

分层四元法最后一层是文化层,超越游戏本身。文化层涵盖了游戏对文化的相互作用。游戏的玩家社群产生了文化层,在这里玩家的力量比游戏设计师更大,设计师的社会责任在这变得清晰。

在文化层中四元素不那么泾渭分明,但仍然值得从四元素的角度来解读。

  • 机制:文化层机制最简单的表现形式就是游戏 mod (玩家直接改变游戏机制,如《蛇与梯子》可以选择蛇也可以选择滑梯模式)。同样也包括游戏即时行为对社会的影响。
  • 美学:与机制类似,文化层美学涵盖了同人作品、游戏音乐重制,或者其他美学上的行为,如 Cosplay(角色扮演,粉丝装扮成游戏角色的样子)。这里的要点是经授权的跨媒体产品(游戏题材转换成其他媒体,比如《古墓丽影》电影版,《口袋妖怪》午餐盒)不属于文化层。因为它们属于游戏知识产权的所有者,而文化层美学是游戏玩家社群创造和控制的。
  • 技术文化层技术涵盖了游戏技术的非游戏应用(比如群集算法可以用于机器人领域)以及影响游戏体验的技术。在 NES 时代(任天堂娱乐系统),拥有 Advantage 或 Max 手柄允许玩家使用连发键(自动快速连按 A 或 B 键)。某些游戏中,这会是巨大优势,影响游戏体验。文化层技术还包括不断探索游戏的可能性和游戏 mod 技术层面改变游戏内在元素。
  • 叙事:文化层叙事涵盖了游戏同人跨媒体产品的叙事部分(比如同人小说,游戏 mod 和玩家自制游戏视频中的叙事部分)。它还包括了社会文化中关于游戏的故事,比如对哦《侠盗猎车手》的恶评和对《风之旅人》和 ICO 的美谈。

# 设计师的责任

游戏设计师都明白要直接为游戏的形式层负责。游戏开发当然要有明确的鬼子额,有趣的美术等鼓励玩家进行游戏。

到了动态层,某些设计师就搞不清楚了。有的人会惊讶于他们游戏所显现的行为,想要将这部分责任推给玩家。比如说,几年前 Valve 决定在《军团要塞 2》中加入帽子。他们选择的机制是随机奖励帽子给登录游戏的玩家。由于帽子奖励只依据玩家登录的时间来判断,导致玩家挂机刷帽子,而不是去玩游戏。Valve 觉察了这种行为,并收回行为可疑玩家的帽子作为惩罚。

看起来是玩家在作弊,但也可以视作他们选择了最有效的获取帽子的方法,符号 Valve 制定的游戏规则。因为系统设计为奖励在线玩家,并没提到要进行游戏,玩家就选择了最容易的方法。玩家也许欺骗了帽子掉落系统的设计意图,但不是系统本身。玩家的动态行为完全符合 Valve 设计导向。如你所见,设计师同样要对系统内含的动态层体验负责。事实上,游戏设计最重要的一点就是预测和打造玩家体验。当然,做起来很难,但这是游戏好玩的关键。

那么设计师在文化层面上的责任呢?由于大部分游戏设计师不曾考虑过,所以社会上普遍认为游戏幼稚粗俗,向年轻人贩卖暴力,歧视女性。你我都知道这本可以避免,并且言过其实,但它已经深植于大众的观念中。游戏可用于教育、激励和治疗。游戏能造福社会,帮助玩家学习技能。嬉戏的态度和简单的规则可以让沉闷的工作变得有趣。作为设计师,你要对游戏给社会和玩家产生的影响负责。我们已经更擅长制作让人沉迷甚至废寝忘食的游戏。还有些人诱骗小孩子在游戏上花费巨资。可悲的是,这类行为损耗了游戏在社会上的形象,让人们蔑视游戏或望而却步。我相信,作为设计师,这是我们的责任,通过游戏造福社会,尊重玩家和他们花费在游戏上的时间精力。

# 小结

如本章所示,分层四元法重点在于理解三个层次表现的游戏从开发者到玩家的所有权转变。内嵌层的所有内容同属于设计师和开发者,并且完全在开发者的掌控中。

动态层是游戏的体验所在,所以游戏设计师需要玩家付诸行动,做出选择体验游戏。通过玩家的决定和对游戏系统的影响,玩家拥有部分体验,但总的来说还在开发者控制之下。如此一来,玩家和开发者共享动态层。

在文化层,游戏脱离了开发者的控制。这也是为什么游戏 mod 适用于文化层:通过游戏 mod,玩家得以控制游戏内容。

另外,文化层也包括社会中的非玩家对游戏的看法,它会受到玩家社群代表的游戏体验影响。不玩游戏的人通过媒体了解游戏,他们阅读的内容(但愿)是玩家所写。虽然说文化层大半由玩家控制,但是开发者和设计师依然有强大的影响力,并要对游戏的社会影响负责。

# 内嵌层

# 机制内嵌

机制内嵌不包括步骤和结果,因为它们都由玩家控制,所以属于动态层。详见下方:

  • 目标:目标包括玩家在游戏中的目标。玩家想要达成什么?
  • 玩家关系:定义玩家之间如何战斗和协作。玩家的目标如何相互影响,互相竞争还是互相帮助?
  • 规则:规则明确和限制了玩家的行为。为了达到目标,玩家什么能做,什么不能做。
  • 边界:边界定义游戏的界限,与魔力圈息息相关。游戏的边界在哪里?魔力圈的范围如何?
  • 资源:资源包括游戏边界内的财产和价值。玩家在游戏中能获取什么奖励?
  • 空间:空间定义游戏区域和其中可能的交互行为。最明显的是桌面游戏,桌面本身就是游戏的空间。
  • 表格:表格定义游戏的数值数据。玩家如何升级变强?既定时间内玩家可做些什么?
# 目标

许多游戏目标很简单明了——为了赢,但实际上,在游戏中玩家们会不停地权衡数个目标。它们可以通过轻重缓急分类,而且对不同玩家,程度不尽相同。

  • 目标的紧要性
    • 短期
    • 中期
    • 长期
  • 目标的重要性
    • 主线目标
    • 支线目标
  • 目标冲突
    • 玩家游戏时间有限
# 玩家关系

玩家心中常有好几个目标,它们决定了玩家与游戏的关系。

  • 单人对抗游戏:玩家的目标就是打通游戏。
  • 多人对抗游戏:数个玩家协作,每个人有不同的目标,但是彼此合作不多。
  • 合作游戏:数个玩家一起通关游戏,目标一致。
  • 玩家对玩家:两个玩家的目标就是击败对方。(决斗场)
  • 多方竞赛:类似玩家对玩家,人数更多且互相对抗。
  • 单方竞赛:一个玩家对一队玩家。
  • 团队对抗:两队玩家互相对抗。

玩家关系和角色由目标定义。

除了上面列出的交互模式,还有各种它们的组合。

任何时候,玩家间的关系由所有玩家目标的组合构成。

  • 主角:主角就是征服游戏的角色。
  • 竞争者:玩家试图征服其他玩家。
  • 合作者:玩家帮助他人。
  • 市民:玩家与其他人在同一世界,但不会合作或竞争。
# 规则

规则限制玩家的行动。规则同样也是设计师概念最爱直观的体现。

与纸笔游戏不同,电子游戏规则不能直接阅读,而是通过游玩解读开发者用代码编写的规则。因为规则是设计师与玩家沟通最直接的方式,规则同时定义了许多其他元素。

明文写出是最直观的,但也能通过规则暗示。比如玩扑克牌,规则暗示牛不能把牌藏在袖子中。

# 边界

边界定义了进行游戏的范围。在这个范围里,游戏规则才适用。

# 资源

资源是游戏中的价值物,这些东西可以是资产或只是数值。游戏中的资产如装备,数值常包括生命值、潜水时的氧气、经验值。

# 空间

设计师经常要负责创造空间。包括设计桌面游戏的桌板和电子游戏中的虚拟关卡。

  • 空间目的:创造合适的空间。
  • 流程:空间是适合玩家通过还是限制行动?背后有何动机?在桌面游戏 Clue 中,玩家每回合摇骰子决定移动距离,穿过版面(版面尺寸 24x25,平均每次移动 3.5 需要 7 回合穿越版面)。意识到这点后,设计师加入了隐藏的传送点直达对角设计,帮助玩家快速移动。
  • 地标:让玩家在虚拟 3D 空间记住地形比现实中更难。鉴于此,更需要在虚拟场景中设置地标让玩家围绕它行动。放置玩家容易识别的地标来节约玩家查看地图的时间。
  • 经验:总的来说,游戏是一种体验,但游戏的地图或空间也需要布置一些玩家可以体验的兴趣点。在《刺客信条 4:黑棋》中,游戏地图是个缩小的真实的加勒比海。尽管真实的加勒比海岛屿之间相隔数天的航程,游戏中的加勒比海散布了许多活动保证玩家每隔几分钟都有事可做,可能是一个岛上的宝箱或者穿越整个敌军舰队。
  • 短、中、长期目标
# 表格

表格是游戏平衡性的关键,尤其是现代的电子游戏。简单地说,表格就是一堆数字,但也能用来设计和描绘各类其他东西。

  • 概率:表格可以用来定义特殊场景下的可能性。在桌游 Tales of the Arabian Nights 中,玩家遇到生物时用一张表格列出一系列遭遇后可能的反应和结果。
  • 进程:在纸面 RPG 游戏如《龙与地下城》中,表格展示了玩家能力和属性的成长。
  • 试玩数据:除了玩家使用表格进行游戏,设计师也用表格记录试玩数据和玩家体验。

当然,表格也是游戏中的一项计数,跨越了机制和技术界限。作为一种技术,它包括存储信息和在表格中进行演算(比如表格中的公式)。作为机制,表格包括设计师印刻在设计中的决定。

# 美学内嵌

美学内嵌是开发者置于游戏中的美学元素,包括所有的五感。作为设计师,你应该意识到玩家在进行游戏时能全部体会到。

# 五种美学感受

设计制作游戏时必须考虑五种感受,这些感受如下:

  • 视觉:在五感中,视觉是游戏中最引人注目的。考虑游戏中的视觉元素时,不要局限于 3D 美术或者桌面游戏中的画板。记得玩家(或潜在玩家)看到的一切都会影响其对游戏的印象和体验。过去一些开发者花费大量精力在游戏美术上,但游戏却隐藏在丑陋的封面包装之后。
  • 听觉:如今游戏中音效的拟真度仅次于视觉。游戏音效包括声效、音乐和对话。另外在中大型团队中,三者交由不同的艺术家处理。
    音效类型 即时性 适用场景
    声效 立即 提醒玩家,传达简单信息
    音乐 营造氛围
    对话 中/长 传达复杂信息

还有一方面要注意的是背景噪声。对手机游戏来说,玩家几乎都是在嘈杂的环境下玩。

  • 触感:数字和桌面游戏的触感完全不同,但对玩家来说这是最直观的。在桌面游戏中,触感在于游戏道具、卡牌和桌面等。电子游戏也有触感。设计师要考虑手柄的手感和玩家操作时的疲劳感。随着平板和智能手机上的优秀越来越多,触摸手势也需要设计师用心考虑。
  • 嗅觉:味道虽然不常见,但也不是没有。比如有些书用特别的印刷工艺制作气味,桌游印刷时也可以采用。
# 美学目标

情绪:美学帮助游戏营造情绪氛围的效果出众。虽然可以通过机制传达情绪,但视听比机制的影响力有效得多。 信息:颜色信息内置于我们哺乳动物的心智中。警示颜色红、黄、黑色在哺乳动物界随处可见。反之,蓝色和绿色通常代表平和。另外,可以训练玩家对特定美学的理解。

# 叙事内嵌

与其他形式的体验一样,剧情和叙事是许多交互体验的重要一环。

# 叙事内嵌的组件
  • 前提:前提是叙事的基础,故事在此产生。
  • 设定:设定是前提的骨架上扩展开来,详细描绘故事发生的世界。
  • 角色:故事为角色服务,最棒的故事往往有着让我们在意的角色。
  • 情节:情节是叙事时发生的一系列事件。
# 传统戏剧

尽管互动叙事提供给编剧和开发者许多新机会,但整体还是要自己遵循传统戏剧结构。

# 五幕结构
  • 第一幕 铺垫:介绍前情、设置和重要角色。
  • 第二幕 情节上行:有事发生 导致了重要角色间和戏剧的张力上升。
  • 第三幕 高潮:所有的事情会首一处,结局定型。
  • 第四幕 情节下行: 剧情朝着结尾发展。
  • 第五幕 结局:故事结尾。
# 三幕结构
  • 第一幕 铺垫:向观众介绍世界、背景设定和主要角色。
  • 钩子:迅速勾起观众的注意。
  • 引发事件:事件进入主角的生活,让他启程冒险。
  • 第一戏剧点:第一戏剧点随着第一幕结束,推进玩家向第二个触发。
  • 第二幕 对抗:主角踏上征途,但一路坎坷。
  • 第二戏剧点:第二戏剧点结束后推进主角作出决定,进入第三幕。
  • 第三幕 结局:故事结束,主角成功或失败。
  • 高潮:所有冲突的汇集,悬念落下。
# 互动叙事和线性叙事的区别

玩家参与式不断影响媒体,成为互动叙事的动因。

# 情节 vs 自由意识
  • 限制可能性:
  • 允许玩家选择多个线性支线任务
  • 多个伏笔
  • 做一些佩剑 NPC 支持主角
# 感情投入:角色 vs 化身
  • 角色扮演
  • 沉默的主角
  • 玩家的选择要有意义
# 叙事内嵌的目标
  • 唤起情感
  • 动机和理由:叙事可以操纵情绪,同样可能促使玩家采取行动。
  • 进程和奖励:许多游戏用过场讲故事和奖励玩家。
  • 加强机制

# 技术内嵌

# 动态层

# 玩家的角色

只有通过玩家行动,游戏才能从一系列内嵌要素转变成了一种体验。

因为玩家非常重要,我们作为开发者要尊重他们,确保规则清晰易懂,以便顺利传达我们的设计意图。

# 涌现

本章最重要的概念就是涌现,它的核心是即使简单规则也能产生复杂的动态行为。

想要预测涌现很难,所以你试玩才显得尤其重要。作为游戏开发者,及早测试,经常测试,留意异常情况。

# 动态机制

动态层中的动态机制让互动媒体与其他媒体区别分开来,成为了游戏。动态机制包括了步骤、有意义的玩法、策略、规则、玩家意图和结果。

# 有意义的玩法
  • 可识别
  • 可协调
# 策略

当游戏允许有意义的行为,玩家通常会利用策略取胜。

# 最优策略

当游戏非常简单时,玩家可能会找出游戏的最优策略。

通常意义上的最优策略指的是帮助玩家扩大赢面的笼统概念。

# 策略性设计

作为设计师,有好多种方式确保游戏更倾向策略性。首先,要记住提供给玩家多种获胜选择,每个都需要做出艰难的选择。另外,如果这些目标之间互相纠缠(比如两个目标的条件一样),在游戏时会让玩家朝特定角色发展。

# 自订规则

玩家自定义规则。

# 玩家意图
  • 成就型(方块 ♦️):追求游戏中的最高分。想要称霸游戏。
  • 搜索型(黑桃 ♠️):致力于探索游戏每个角落。想要了解优秀。
  • 社交型(红心 ♥️):想和朋友一起玩游戏。希望了解其他玩家。
  • 杀手型(梅花 ♣️):喜欢挑衅其他玩家。想要主宰其他玩家。

还有两类:

  • 作弊者:在意输赢但不在乎游戏公平。作弊者会为了取胜扭曲规则。
  • 扫兴者:不在乎输赢也不在意游戏。扫兴者常常会破坏其他玩家的体验。
# 结果
  • 直接结果:每个独立行为都有结果。
  • 任务结果:许多游戏中都有任务,完成后会给予奖励。
  • 积累结果:当玩家花费时间朝一个目标努力最终达成,这就叫积累结果。
  • 最终结果:大多数游戏结束是会有个结果。

# 动态美学

  • 过程美学:美学是利用电子游戏中的代码生成(或者桌面游戏中利用机制生成)。这包括了游戏过程直接由代码生成的音乐和美术。
  • 环境美学:这是游戏进行中的环境,不太受到开发者控制。
# 过程音乐
# 过程美术
  • 粒子系统
  • 过程动画
  • 过程环境
# 环境美学
# 游戏环境视觉
  • 环境亮度
  • 玩家屏幕分辨率
# 游戏声音环境
  • 环境噪音
  • 玩家控制音量
# 体贴玩家
  • 色盲
  • 癫痫和偏头痛

# 动态叙事

互动小说、通过共同体验构建关系

# 涌现叙事

# 动态技术

# 小结

# 文化层

# 文化机制

  • 游戏 mod:玩家改动游戏机制,制作成 mod。
  • 定制关卡

# 美学文化

  • 同人图
  • Cosplay
  • 游戏性的艺术

# 叙事文化

  • 同人小说
  • 剧情 mod

# 技术文化

  • 游戏外的游戏技术
  • 玩家制作的外部工具

# 授权的跨媒体不属于文化层

# 游戏的文化影响

# 像一名设计师一样工作

# 迭代设计

  • 分析:分析阶段主要是弄清楚自己所处的位置和自己想要达成的目标。
  • 设计:现在你已经清楚自己的位置以及期望的目标,用你现有的资源创造一个设计,这个设计要能够解决你的难题或是提供可利用的机会。通过头脑风暴的方式开始设计,最后决定一个切实可行的计划。
  • 实现:你已经有设计方案了,现在开始贯彻实行它。有一句古谚语是这样说的:“直到有人开始玩它,它才是一个游戏。”该阶段的任务是把游戏设计的想法尽可能快地转换成可玩的原型。
  • 测试:请一些人来玩你的作品,并观察他们的反应。随着你在设计上的经验不断积累,你就会更加清晰自己设计的游戏机制会带来什么样的玩家反应。
# 分析
  1. 我的游戏面向哪些玩家?

玩家想要什么内容和玩家喜欢什么内容和玩家喜欢什么内容是完全不同的。

  1. 我有什么资源?

正面看待自己现有的资源、优势和劣势,这样能帮助你更好地策划游戏。作为一名独立游戏开发者,你最主要的资源就是才能和时间。

  1. 现有技术有什么?

你必须要彻底搜索一遍同类型下有什么其他作品,这样你才能知道别人在处理同样一个问题的时候是如何应对的。即便有人和你有同样的创意,但是他肯定是用不同的方式实现的,从他们的成功和失败之处学习,你能够在自己的作品上设计得更出色。

  1. 我想快点做出一个能投入测试的可玩性高的游戏,有没有什么捷径?

想想你的作品里的核心机制是什么(比如在《超级玛丽》中,核心机制就是跳跃),再来设计和测试。这样你就知道值不值得接下来继续开发了。美工、音乐以及其他外观要素对于游戏开发的最后阶段尤为重要,但是在现在这个时间点上,你关注的重点还是应该在游戏的机制和游戏性上。先把这些弄明白,这是你作为一名游戏设计师的核心目标。

# 设计
  • 倾听玩家的声音:你想要哪类玩家来玩你的游戏?你想要哪类玩家群体买你的作品?
  • 倾听团队的声音:多数游戏项目里,你都要和其他才华横溢的团队成员一起工作。你作为设计师的职责就是搜集所有团队成员的想法,并合作挖掘出对于所瞄准的目标玩家最好的游戏创意。
  • 倾听客户的声音:作为一名职业游戏设计师,在很长的一段时间里,你都在为客户工作(老板、委员会等),你也需要他们的投资。你的工作就是在各个阶段听取它们的想法:他们告诉你他们想要的是什么;他们心里想要的是什么但没有说出来的;甚至是他们自己都没承认但却是内心深处真正想要东西。
  • 倾听作品的声音:作为一名设计师,你是最接近作品的游戏设计的,你也可以从整体的角度俯瞰作品的全貌。即使游戏的某个方面的设计非常巧妙,它也有可能和剩下的部分不融洽和谐。
  • 倾听自己的声音
    • 听从你的直觉:在策划设计时如果你有了什么灵光一现,不如尝试一下。说不定是你的直觉比理性先一步找到了答案。
    • 注意你的健康:不要试图通过疯狂工作连夜的方式来解决任何问题。
  • 自己的声音别人听是什么样的:我听起来有礼貌吗?我听起来真的关系对方吗?我是不是应该听起来更在乎这个项目?成功人士总是表现得恭敬和关爱他人。
# 实现

在迭代设计过程中为了有效实现自己的想法,测试游戏是最有用的方法。假如你要给《超级玛丽》《洛克人》这样的平台游戏做测试,你就需要做个数字化原型。然而,如果你是给图形用户界面(GUI)菜单系统做测试的话,你不用特地构建一个完整的数字版本,只需要打印出来菜单的不同页面,然后在你的电脑上操作画面的同时,给测试人员浏览这些页面就可以了。

# 测试

如你所见,纸上原型是项目早期阶段进行测试的一个便捷途径。提高设计水平的最好办法就是,尽可能多让别人来测试你的作品并获得他们的反馈。另外,测试人员在告诉你反馈内容时你最好能记录下来。

地点 反馈 潜在问题 严重程度 提出的方法
Boss1 “第一个 Boos 打完了我不知道应该去干什么。“我现在应该往哪走?”“好了,现在该干什么” 玩家在打完第一个 Boss 后不知道下一步该做什么。在这之前玩的过程中指向性还是很强,但是现在玩家不知道应该去做什么 设计成在第一个 Boss 被击败后,让导师角色返回,给玩家第二个任务

最重要的是你要尽快完成修改,进行下一次测试,这样才会知道你的解决方法有没有凑效。

# 创新

世界上一共有两种创新:渐进型创新和交汇点创新。

  • 渐进型创新
    • 在可预知的情况下进行改善。
    • 可以预料,值得信赖。如果你在找人投资自己的项目的话,这样的创新很容易说服投资人投入资金。
    • 这种类型的创新永远不会瞬间出现飞跃性的成果。
  • 交汇点创新
    • 这种创新出现在两种截然不同的观念碰撞的时候,这同时也是很多伟大理念出现的时刻。
    • 然而,正是因为交汇点创新的成果过于新颖且难以预料,要让别人认同这种成果是非常困难的。

头脑风暴同时利用了两种创新模式,可以让你创造出更棒的作品。

# 头脑风暴与构思

# 步骤 1:拓展阶段

在这个阶段,不要担心自己写的内容,不要删掉任何内容,想到什么就写什么。

# 步骤 2:收集阶段

收集之前所有集思广益得来的想法,将它们每个依次写到卡片上。这些就叫作思想卡片。

# 步骤 3:碰撞阶段

把所有的思想卡片整理好,给每名团队成员发两张。每个人把自己的两张卡片放到白板上给所有人展示,然后大家根据这两站卡片的内容一起想出三个不同的游戏点子。(如果两张卡的想法都过于相似或是完全不能融合到一起的话,可以跳过这两张卡片)。

# 步骤 4: 评分阶段

所有人写完了以后,在最受欢迎的前三个想法旁边打对号。最后你就会发现,有的点子上的对号很多,有些则很少。

# 改变你的想法

重复设计过程中重要的一环就是改变自身的想法。随着你在作品里完成了各种各样不同的重复设计后,你就会不可避免地对自己的设计作出相应的改变。

# 随着开发的进行,你会越来越投入

上面描述的方法对于小型企划或是项目的产前阶段都很适用。但是如果项目的参与人数众多,成员又投入了大量的时间和精力的话,改变想法则是一件既困难又昂贵的事情。一个标准的专业游戏开发分为几个不同阶段:

  • 制作前(Preproduction):在制作前的阶段,你要试验各种不同的原型,并找出最有趣且最吸引人的那一个。这样一个 demo 要给主管领导过目,由他来决定是否可以继续制作。
  • 制作(Production):在游戏行业里,作品进入制作阶段后,团队成员的规模将会显著增大。该阶段主要以 demo 为核心做出相应的高完成度内容。
  • 内部测试(Alpha):进入这个阶段时,所有的功能设计和游戏机制都已经 100% 确定了。在这个阶段,我们不能再对系统的设计作出更改,只能针对测试中出现的问题做出相应的更改。
  • Beta 测试:进入这个阶段时,作品已经基本完成了。在本阶段,你应该修复所有可能会崩溃游戏的 Bug,即便有的 Bug 仍然没被发现,这些 Bug 也只能是一些轻微的错误。Beta 阶段的主要目的是找到并修复余下的 Bug。
  • “黄金”阶段(Gold):当你的项目进入“黄金”阶段,就离发售不远了。
  • 发售后(Post-release):发售后的这个时间段可以用来开发 DLC。因为 DLC 通常包含多个新任务和新关卡,所以每个 DLC 的开发要经历的过程和大型游戏开发是一样的(虽然规格相对较小):制作前、制作、内部测试、Beta 测试以及”黄金阶段。

# 规划作品的范围大小

作为一名游戏设计师,你要明白的一个重要概念是如何规划游戏内容的范围。根据你现有的时间和资源来合理压缩设计内容的过程就是规划范围,而过多的设计内容则是游戏项目的第一杀手。

你所见到的和玩到的游戏都是由几十个人在几个月的时间里全职工作完成的。

为了你自己,不要尝试做那些你能想到的知名游戏,像是《泰坦陨落》《魔兽世界》或是什么其他大作。相反,你应该找到一个相对较小、非常棒的核心机制,在一个小尺寸范围里深度挖掘它。

在你的事业蒸蒸日上的时候,你可能就有机会去做一些像《星际争霸》《侠盗猎车》的大型游戏,但是要记住,所有人都是从一个小作品开始。

所以现在,你要将作品的设计范围规划得小一些。设法想出一些能在短时间内完整制作的点子,然后完成它。只要你能做得出色,之后你想要再添加什么内容都可以。

# 小结

重复迭代设计、快速制作原型及合理地规划内容范围是改善游戏设计的重点。

# 设计目标

# 设计目标:一个不完整的清单

# 以设计师为中心的目标
  • 财富
  • 名气
  • 团体
  • 个人表达
  • 更高的善
  • 成为一名出色的游戏策划
# 以玩家为中心的目标
  • 趣味:你想要玩家喜欢玩你的作品。
  • 游戏性态度:你想要玩家投入游戏的幻想世界中来。
  • 心流:提供给玩家最优的挑战。
  • 结构化的冲突:你想提供呢一个玩家间竞争对抗的途径,这种途径对游戏系统也是极大的挑战。
  • 力量感:你想让玩家在游戏里感觉强大
  • 兴趣/关注/投入:你想要作品吸引玩家。
  • 有意义的决定:你想要玩家做出的选择对他们自身和游戏都有意义。
  • 体验式理解:你想要玩家通过玩游戏学到东西

# 以设计师为中心的目标

# 以玩家为中心的目标

只要作品能吸引住玩家,玩家是愿意玩趣味性低的游戏的。

# 趣味性

提供游戏趣味性的三个方面:

  • 乐趣性
  • 吸引力
  • 满足感
# 游戏性态度

指玩家愿意全身心投入到游戏中的态度。

在社交游戏里,体力是一种资源,无论玩家是否登录,体力都在缓慢地恢复增长。

# 魔法圈理论
# 心流
# 冲突对抗

单纯的玩乐和游戏之间最本质的区别就是游戏总是包含对抗或竞争,这种竞争可能是玩家之间的竞争,也可以是玩家和游戏系统之间的对抗。

# 力量感
  • 内在动力
  • 表演

# 小结

# 纸面原型

纸面原型是游戏设计师迅速测试优秀和改变想法的重要工具之一,这个工具简便易用。虽然你的想法和概念最终都要数字化,但是它能告诉你作品还缺少了什么内容。

# 纸面原型的优势

# 纸面原型工具

# 一个纸面原型的例子

# 纸面原型的优点

# 纸面原型的缺点

# 小结

# 游戏测试

# 数学和游戏平衡

# 概率

骰子越多,得到的数值越接近平均值。

# 桌游中的乱数产生技术

# 加权分布

加权分布指的是一些可能性更容易发生。设计想要一半玩家的攻击带有随机加成。

# Calc 中的概率加权

概率加权在电子游戏中随处可见。比如你想让敌人遇到玩家时,40%的概率攻击、40%的概率防御,20%的概率逃跑。你可以常见一个数组[攻击,攻击,防御,防御,逃跑],并且让敌人的 AI 第一次遭遇玩家是取一个随机数。

# 排列

# 正负反馈

理解游戏平衡最重要的一点就是搞清楚正反馈和负反馈。在一个拥有正反馈机制的游戏中,一名在游戏初期就取得了有利条件的玩家,将更容易取得优势并最终赢得游戏。在一个拥有负反馈机制的游戏中,一名正处于下风的玩家将被赋予更多的优势。负反馈机制让游戏中落后的玩家感到更加“公平”,而且总体上讲,这种机制还能让对抗的过程变得更长,而且让哪怕是落后了很多的玩家也感激自己还能翻盘的机会。

# 使用 Calc 调整武器平衡

电子表格在游戏设计中的另一大用处就是平衡不同武器或能力的属性。

  • 射速。
  • 每发伤害量。
  • 一定距离内的命中率。
# 计算单发命中率
# 计算平均伤害值
# 绘制平均伤害值的图表

# 小结

# 游戏平衡的意义

# 按照 Apache OpenOffice Calc

# 用 Calc 检查骰子

# 概率

# 桌游中的乱数产生技术

# 加权分布

# 排列

# 正负反馈

# 使用 Calc 调整武器平衡

# 小结

# 谜题设计

# 谜题无处不在

# Scott Kim 与谜题设计

# 什么是谜题

妙趣横生的谜题都有一个正确的答案。

谜题让人开心

  • 新奇
  • 合适的难度
  • 棘手
# 谜题的种类
  • 动作
  • 故事
  • 建造
    • (建造类解谜游戏融合了建造、工程以及空间推理谜题几个要素)
  • 策略
    • 策略类游戏一般都是将多人的游戏转变成单人的版本。(象棋)
# 玩解谜游戏的四大理由
  • 挑战
  • 打发时间
  • 角色和氛围
  • 心灵之旅
# 解谜需要的思考模式
  • 填字
  • 画面
    • 包括拼图、寻物解谜、2D/3D 空间解谜。
  • 逻辑
    • 许多游戏都是基于演绎推论:排除掉所有错误的可能性,只留下正确的一个。
    • 用归纳推理的游戏要比想象中的少:从明确的一个事实推论整体的可能性。
  • 文字/画面
  • 图像/逻辑
  • 逻辑/文字
# 数字谜题设计的八个步骤
  1. 灵感(生活中的灵感源头)
  2. 简单化:需要把原本的想法简单化才能变成可玩的谜题游戏。
    1. 找出谜题的核心机制,玩家需要的解题技巧。
    2. 去除不相关的内容,聚焦重点内容。
    3. 一体化。
    4. 简化操作。
  3. 建造组件:制作一个可以简单快速制作谜题的工具。
  4. 规则
  5. 谜题
  6. 测试
  7. 排序
  8. 外观
# 谜题设计的七个目标
  • 用户友好
  • 入门简单
  • 即时反馈
  • 永动:游戏机制要刺激玩家机修游玩,并且在游戏过程中,不应该有明显的停止点。(Play Again 》 Game Over)
  • 清晰的目标:你要玩家清晰地知道谜题的主要目的。
  • 难度级别
  • 一些特别的内容

# 动作解谜游戏的几种类型

  • 滑块/位置解谜
    • 这种游戏通常都是第三人次动作游戏,要求玩家移动地面上的方块或箱子一类的东秀。
  • 物理解谜
    • 这个类型的游戏涉及物理环境模拟。玩家需要移动物品来击中目标。
  • 横越解谜
    • 这种类型的谜题的任务是让玩家到达一个目标地点,但是中间的过程很复杂。《刺客信条》
  • 潜行谜题
    • 这种类型的游戏中,要求玩家在不被敌人发现的前提下达到目标点,敌人巡逻的路径都是事先安排好的。
  • 连锁反应
    • 这种游戏里都有物理模拟系统,各种物品之间可以相互影响,可以制造爆炸等。
  • Boss 战
    • 多数游戏的 Boss 战,玩家都要找到 Boss 攻击的模式和节奏才能打败 Boss。

# 小结

# 指引玩家

# 直接指引

  • 及时性:指引信息必须在需要时立即传达给玩家。关于操作的指引应该在玩家需要使用到这项操作的时候才立刻出现在屏幕之上。
  • 稀缺性
  • 简洁性
  • 明确性
# 直接指引的具体手段
  • 介绍
  • 尝试行动
  • 地图或导航系统
  • 弹出

# 间接指引

  • 约束
  • 目标
  • 物理界面
  • 视觉设计
    • 光线
    • 相似性
    • 路径
    • 路标
    • 箭头
    • 镜头
    • 对比
      • 亮度
      • 材质
      • 颜色
      • 方向性
  • 音频设计
  • 玩家化身
  • NPC
    • 构建行为
      • 消极的行为
      • 积极的行为
      • 安全
      • 情感联系

# 介绍新技能和新概念

  • 排序
    • 单独介绍
    • 扩展
    • 增加危险
    • 提升难度
  • 融合

# 小结

# 数字游戏产业

# 关于游戏产业

# 游戏教育

# 走进行业中去

# 等不及开始做游戏了

# 小结

# 数字原型

# 数字化系统中的思维

通过前面第一部分的学习,你可以认识到游戏是由互相关联的系统构建的。

# 棋类游戏中的系统思维

棋类游戏中有很多规则其实并未写入规则手册中,而是由玩家基于公平比赛的共识默契遵守。这一观念存在于虚拟现实的设想当中,也可以很大程度上解释为什么一群儿童可以自创游戏并且他们全都能凭直觉明白玩法。对大多数人类玩家来说,游戏玩法中隐藏着大量的默认规则。

但是,计算机游戏做每件事时都要依赖明确的指令。尽管计算机通过近几十年的发展,已经达到堪称强大的程度,但其本质仍然是无意识的机器,只能每秒上百万次地依次执行各条明确指令。只有把你自己的想法编译成非常简单的指令让它执行,计算机才能产生貌似智能的行为表现。

# 简单命令练习

这里有个经典的练习,可以帮助学习计算机科学的学生理解如何从简单指令的角度思考问题,方法就是用简单命令指挥另外一个从卧姿转为站姿。

首先,让同伴仰卧在地板上,然后告诉他严格按你所发出的命令的字面含义做相应动作。你的目标是向同伴发出一系列命令,使他站立起来。但你不能使用“站起来”之类的复杂命令,只能像指挥机器人一样使用简单命令。

# 计算机语言
# 代码库

从上面的练习中你可以看到,比起花费力气发出很多低级命令,如果你能告诉同伴“站起来”的话,事情就会简单许多。在这里,“站起来”就相当于一个多功能高级命令,你可以把这个命令把你的要求告诉同伴,而不用考虑同伴最开始是什么姿势。在 C# 中,常用行为的高级命令集称为代码库(code library)。如果你使用 C# 和 Unity 进行开发,有上百个这样的代码库供你使用。

最常用的代码库是把 C# 语言集成到 Unity 开发环境的代码库。这个代码库功能非常强大,以 UnityEngine 的名称导入。UnityEngine 代码库包含用于以下功能的代码:

  • 卓越的光影效果,例如烟雾和反射。
  • 物理模拟,包括重力、碰撞,甚至是布料模拟。
  • 来自鼠标、键盘、游戏手柄、触摸平板的输入。
  • 上千种其他功能。
# 开发环境

Unity 程序可以当作一个开发环境,我们先创建各个游戏组件,然后在这个开发环境中把所有组件组合在一起。在 Unity 中,三维模型、音乐和音频片段、二维图像和纹理以及你编写的 C# 脚本,这些资源都不是直接在 Unity 中创建的,但通过 Unity ,你可以把它们整合成一个完整的计算机游戏。Unity 还可以用来在三维空间中布置游戏中的对象,处理用户输入,设置屏幕中的虚拟摄像机,并最终把这些资源编译成一个可以运行的游戏。

# 把复杂问题分解为简单问题

通过前面的练习,你一定会注意到,如果不允许给出“站起来”这样的复杂指令,你就需要把复杂命令分解为更细、更琐碎的命令。尽管这在练习时很困难,但你会在编程过程中发现,把复杂命令分解为简单命令的技巧是你处理所面临的挑战时最重要的能留,让你把所创建的游戏一点一点建立起来。

# 游戏分析:《拾苹果》(Apple Picker)

# 《拾苹果》游戏的基本玩法

玩家控制着屏幕下方的三个篮筐,可以用鼠标左右移动。苹果树在屏幕上方跨素左右移动,并隔一段时间掉下一个苹果,玩家必须在苹果落地之前用篮筐接住它们。玩家每接住一个苹果就会获得一定的分数,但如果苹果落地一个,所有的苹果会立即消失,而玩家会损失一个篮筐。玩家损失全部三个篮筐后,游戏结束。

# 《拾苹果》游戏中的游戏对象

在 Unity 的术语中,游戏中的任何物体(通常指屏幕上可以看到的任何物体)都称为游戏对象(GameObject)。

**A. 篮筐:**篮筐由玩家控制,随鼠标左右移动。篮筐在碰到苹果时即可接住苹果,同时玩家得分。

**B. 苹果:**苹果从苹果树上落下,并垂直向下坠落。如果苹果碰到任何一个篮筐,即被篮筐接住,同时从屏幕上消失(让玩家得分)。在碰到游戏窗口的底边时,苹果也会消失,并且会使其他苹果同时消失。这会使篮筐数目减少一个(按从下到上的顺序减少),然后苹果树上又重新开始掉苹果。

**C. 苹果树:**苹果树会随机向左或向右移动,并不时掉下苹果。苹果掉落的时间间隔是固定的,因此,只有左右移动是随机行为。

# 《拾苹果》游戏中的游戏对象动作列表

关注每个游戏对象各个时刻的动作。

篮筐的动作:

篮筐的动作包括:

  • 随玩家的鼠标左右移动
  • 如果篮筐碰到苹果,则接住苹果。

苹果的动作

苹果的动作包括:

  • 下落
  • 如果苹果碰到地面,它就会消失,并且使其他苹果一起消失

苹果树的动作

苹果树的动作包括:

  • 左右随机移动
  • 每隔 0.5 秒落下一个苹果
# 《拾苹果》游戏中的游戏对象流程图

要考虑游戏中动作和决策流程,使用流程图通常是一个不错的方式。一开始时,我们只需要考虑单个回合中发生的动作。

篮筐的流程图

苹果的流程图

苹果树的流程图

  • 是否变化方向
  • 是否落下苹果

计算机游戏中的帧

“帧”的概念起源于电影行业。从前,电影影片是由上千张单独的胶片(称为帧)构成的,这些胶片在快速依次播放时(速度为 16 或 24 帧/秒),就会产生动态的效果。在电视领域,动态效果是由投射到屏幕上的一系列电子影像产生的,这些影像也称为帧(速度约为 30 帧/秒)。

随着计算机图形快到足以显示动画和其他运动影像,在计算机屏幕上显示的各个单幅画面也被称为帧。另外,使电脑屏幕上产生该画面的所有运算都是该帧的组成部分。当 Unity 以 60 帧/秒的速度运行游戏时,它每秒在屏幕上显示 60 幅画面,同时,它还在进行大量必要的数学运算,使物体按要求从一帧运到到下一帧。

# 小结

如上,数字化游戏可以分解为一系列非常简单的选择和命令。这项工作暗含在本书创建模式的过程中,读者在设计和开发自己的游戏项目时也需要进行这项工作。

# Unity 开发环境简介

Unity 本身可以被视作一个组装程序,虽然 Unity 可以把优秀原型中的元素全部组装到一起,但大部分资源是在其他程序中创建的;在 MAYA、Autodest、3DS MAX 或 Blender 等三维建模程序中创建模型和材质;在 Photoshop 或 GIMP 等图像编辑软件中制作图像;在 Pro Tools 或 Audacity 等音频编辑软件中编辑声音。

# 下载 Unity 软件

# 开发环境简介

# 首次运行 Unity 软件

# 设置 Unity 的窗口布局

# 熟悉 Unity 界面

  • 场景(Scene)面板:场景面板为你提供三维场景内容的导航,允许你选择、移动、旋转或缩放场景中的对象。
  • 游戏(Game)面板:你可以在游戏中查看游戏运行时的实际画面。
  • 层级(Hierarchy)面板:层级面板展示当前场景中包含的每个游戏对象(GameObject)。在目前这个阶段,你可以把场景当作游戏的关卡。从摄像机到游戏角色,场景中存在的所有东西都是游戏对象。
  • 项目(Project)面板:项目面板包含了项目中所有的资源(Assets)。每一项资源都是构成项目的一个任何类型的文件,包括图像、三维模型、C#代码、文本文件、音频、字体等文件。项目面板是对 Assets 文件夹的一个映射,该文件夹位于电脑硬盘上 Unity 项目文件夹下。这些资源不一定出现在当前场景中。
  • 检视(Inspector)面板:当在项目面板中选中一项资源,或在场景面板或层级面板中选中一个游戏对象时,你可以在检视面板中查看或编辑它的相关信息。
  • 控制台(Console)面板:你可以在控制台面板中查看 Unity 软件给出的关于错误或代码 Bug 的信息,也可以通过它帮助自己理解代码的内部运行情况。

# 小结

# C#编程语言简介

# 理解 C# 的特性

  • 是编译型语言
  • 是托管代码
  • 是强类型语言
  • 基于函数
  • 面向对象
# C#是一种编译型语言

编程语言还分为编译型

# C#是托管代码

幸运的是,C# 属于托管代码,也就是说,内存的分配和释放是自动进行的。在托管代码中仍然可能发生内存泄漏,但意外导致内存泄漏的情况会更难发生。

# 阅读和理解 C# 语法

# 小结

# Hello World:你的首个程序

# 创建新项目

当在 Unity 中创建项目时,你实际上是创建了一个包含所有项目文件的文件夹。

警告:在 Unity 程序运行时,千万不要修改项目文件夹的名称。 如果你在 Unity 程序运行时修改了项目文件夹的名称,Unity 程序会崩溃得很难看。Unity 程序在运行时,会在后台做很多文件管理工作,如果此时修改文件夹名称,几乎肯定会造成程序崩溃。如果你希望改变项目文件夹的名称,需要先退出 Unity,再修改文件夹名称,然后重新启动过 Unity。

# 新建 C# 脚本

将 HelloWorld 脚本拖动到主摄像机上,会将脚本绑定到主摄像机上,成为它的一个组件。出现在场景层级面板上的所有对象(例如主摄像机)都是游戏对象,游戏对象是由组件构成的。

# Start() 和 Update() 的区别

Start() 函数和 Update() 都是 Unity 版 C# 语言中的特殊函数。Start() 函数会在每个项目的第一帧中被调用一次,而 Update() 函数会在每一帧中被调用一次。

提示: 如果你希望相同的消息在重复出现时只显示一次,你可以单击控制台面板上的折叠(Collapse)按钮,这会保证各种不同消息内容各只显示一次。

# 让事情更有趣

检视面板的首要目的是使用户可以查看和编辑游戏对象的各个组件。Cube 游戏对象具有 Transform (变换)、Mesh Filter(网格过滤器)、Box Collider(盒碰撞器)和 Mesh Renderer (网格渲染器)组件。

  • 变换(Transform):变换组件设置游戏对象的位置、旋转和缩放。这是唯一一个所有游戏对象都必有的组件。
  • Cube(Mesh Filter)Mesh Filter 组件为游戏对象提供三维外形,以三角形构成的网络建立模型。游戏中的三维模型通常是中空的,仅具有表面。例如,鸡蛋的三维模型将只有模拟蛋壳形状的三角形网格,而不像真正的鸡蛋那样还包含蛋清和蛋黄。网格过滤器在游戏对象上绑定一个三维模型。在立方体中,网格过滤器使用 Unity 中内置的简单三维立方体模型。但你也可以在项目面板中导入复杂的三维模型,在你的游戏中添加更加复杂的网格。
  • 盒碰撞器(Box Collider):碰撞器组件允许游戏对象在 Unity 的物理模拟系统中与其他对象发生交互。软件中有几种不同类型的碰撞器,最常用的有:球状、胶囊状、盒状和网格状(按运算复杂度从低到高排序)。具有碰撞器组件的游戏对象(以及非刚体组件)会被当作空间中不可移动的物体,可与其他物体发生碰撞。
  • 网格渲染器(Mesh Render):虽然网格过滤器可以提供游戏对象的实际几何形状,但要游戏对象显示在屏幕上,要通过网格渲染器。没有渲染器,Unity 中任何物体都无法显示在屏幕上。渲染器与主摄像机一起把网格过滤器的三维几何形状转化为屏幕上显示的像素。
  • 刚体(Rigidbody):刚体组件可以通知 Unity 对这个游戏对象进行物理模拟。其中包括重力、碰撞和拉拽等机械力。刚体可允许具有碰撞器的游戏对象在空间中移动。如果没有刚体组件,即使游戏对象通过组件移动了位置,它的碰撞器组件仍然会停在原地。你如果希望一个游戏对象可以移动并且可以和其他碰撞器发生碰撞,就必须为它添加刚体组件。

Unity 中的所有物理模拟都基于公制单位。也就是说:

  • 1 个距离单位 = 1 米(例如,变换中的位置单位)。
  • 1 个质量单位 = 1 千克(例如,刚体的质量单位)
  • 默认重力为 -9.8 = 9.8m/s2,方向向下(y 轴负方向)。
  • 普通人类角色的身高约为 2 个长度单位(2 米)。
# 创建预设(Prefab)

现在,我们把立方体添加到预设中。预设是指项目中的可重用元素,可以任意实例化。你可以把预设当成是游戏对象的模子,每个从预设中创建的游戏对象都称为预设的一个实例。(所以这个过程被称为实例化。)要创建一个预设,需要在层级面板上单击 Cube,把它拖动到项目面板上之后松开鼠标。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class CubeSpawner : MonoBehaviour
{
  public GameObject cubePrefabVar;
  // Start is called before the first frame update
  void Start()
  {
    //Instantiate(cubePrefabVar);
  }

  // Update is called once per frame
  void Update()
  {
    Instantiate(cubePrefabVar);
  }
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

和上一个脚本一样,它也需要绑定到其他对象上才能运行。

# 小结

# 变量和组件

Unity 常见中的任何对象都是游戏对象,构成游戏对象的组件可以使游戏对象完成定位、物理模拟、特效、三维屏幕显示、角色动画等各种功能。

# 变量

# C# 中的强类型变量

C# 中的变量是强类型变量,也就是说,变量只能接受指定类型的值,而不能被随意赋值。这种做法很有必要,因为计算机需要知道应该为各个变量分别分配多少内存空间。

# 重要的 C# 变量类型

所有这些 C# 基础变量都以小写字母开头,而 Unity 的数据类型则以大写字母开头。

# 变量的作用域

# 命名惯例

  1. 在所有名称中都使用驼峰命名法。
  2. 变量名称应使用小写字母开头(例如 someVariableName)。
  3. 函数名称应使用大写字母开头(例如 Start()、Update())。
  4. 类名称应使用大写字母开头(例如 GameObject、ScopeExample)。
  5. 私有变量名称应以下画线开头(例如 _hiddenVariable)。
  6. 静态变量名称应全部使用大写字母,并且使用蛇底式命名法。(例如 NUM_INSTANCES)。蛇底式命名法在多个单词之间使用下划线连接。

# Unity 中的重要变量类型

# 三维向量

三维向量是三维软件中常见的数据类型,常用于存储对象的三维空间位置。

Vector3 position = new Vector3(0.0f, 3.0f, 4.0f); // 设置 x,y,z 的值
1

三维向量的实例变量和函数

三维向量的静态类变量和函数

# 颜色(Color):带有透明度信息的颜色

Color 变量类型可以存储关于颜色及透明度(alpha 值)的信息。C# 中的红、绿、蓝成分分别存储为一个 0.0f 到 1.0f 之间的浮点数,其中 0.0f 代表该颜色通道亮度为 0,而 1.0f 代表该颜色通道亮度为最高。

颜色的实例变量和函数

颜色的静态类变量和函数

# 四元数(Quaternion):旋转信息

多数情况下,你会将欧拉旋转作为参数传入,使 Unity 可以将其转换成四元数,从而定义一个四元数:

Quaternion lookUp45Deg = Quaternion.Euler(-45f, 0f, 0f);
1

在这种情况下,传入 Quaternion.Euler() 函数的三个浮点数是沿 x、y 和 z 轴(在 Unity 中分别以红、绿、蓝色显示)旋转的角度。

# 数学运算(Mathf):一个数学函数库

Mathf 不算一个真正的数据类型,而是一个非常实用的数学函数库。Mathf 附带的所有变量和函数都是静态的,你不能创建 Mathf 的实例。

# 屏幕(Screen):关于屏幕显示的信息

屏幕是另一个类似 Mathf 的库,可提供关于 Unity 游戏所使用的特定计算机屏幕的信息。它与设备无关,因此不论你使用的 Window、OS X、iOS 设备还是安卓面板,它都可以提供精确的信息。

print( Screen.width); // 以像素为单位输出屏幕宽度
print( Screen.height); // 以像素为单位输出屏幕高度
Screen.showCursor = false;  // 隐藏光标
1
2
3
# 系统信息(SystemInfo):关于设备的信息

系统信息可以提供关于游戏运行设备的特定信息。它包括关于操作系统、处理器数量、显示硬件等设备的信息。

print(SystemInfo.operatingSystem);
1
# 游戏对象(GameObject):场景中任意对象的类型

GameObject 是 Unity 场景中所有实体的基类。你在 Unity 游戏屏幕上看到的所有东西都是游戏对象类的子类。

GameObject gObj = new GameObject("MyGO"); // 创建一个名为 MyGO 的游戏对象
print(gObj.name); // 输出 MyGO,游戏对象 gObj 的名称
Transform trans = gObj.GetComponent<Transform>();// 定义变量 trans 为 gObj 的变换组件
1
2
3

这里的 gObj.GetComponent<Transform>() 方法特别重要,因为它可以用来访问游戏对象所绑定的组件。你有时会看到像 GetComponent<>() 这样带有尖括号(<>)的方法,我们称之为(generic methods),因为它们可用于多种不同的数据类型。在GetComponent<Transform>() 中,数据类型为变换,它通知 GetComponent<T>() 方法去查找游戏对象的变换组件并返回它。这种方法也可用来获取游戏对象的任何其他组件,只要在尖括号中输入该组件的名称即可。以下是其中几例:

Render rend = gObj.GetComponent<Renderer>(); // 获取渲染器组件
Collider rend = gObj.GetComponent<Collider>(); // 获取碰撞器组件
HelloWorld rend = gObj.GetComponent<HelloWorld>(); // 获取绑定在游戏对象上的任何 C#类的实例
1
2
3

# Unity 游戏对象和组件

# 变换:定位、旋转和缩放

变换是所有游戏对象中都必然存在的组件。变换组件控制着游戏对象的定位(游戏对象的位置)、旋转(游戏对象的方向)和缩放(游戏对象的尺寸)。尽管在检视面板上不体现,但实际上变换组件还负责着层级面板中的父/子关系。若一个对象谁另一对象的子对象,它将像附着在父对象一样,随父对象同步移动。

# 网格过滤器(MeshFilter):你所看到的模型

网格过滤器组件将项目面板中的 MeshFilter 绑定到游戏对象上。要使模型显示在屏幕上,游戏对象必须必须有一个网格过滤器(用于处理实际的三维网格数据)和一个网格渲染器(用于将网格与着色器或材质相关联,在屏幕上显示图形)。网格过滤器为游戏对象创建一个皮肤或表面,网格渲染器决定该表面的形状、颜色和纹理。

# 渲染器:使你能够查看游戏对象

渲染器组件(多数为网格渲染器)允许你从屏幕上查看场景和游戏面板中的游戏。网格渲染器要求网格过滤器提供三维网格数据,如果你不希望看到一团丑陋的洋红色,还应至少为网格渲染器提供一种材质(材质决定对象的纹理)。渲染器将 网格过滤器、材质和光照组合在一起,将游戏对象呈现在屏幕上。

# 碰撞器:游戏对象的物理存在

碰撞器组件使游戏对象在游戏世界中产生物理存在,可与其他对象发生碰撞。Unity 中有四种类型的碰撞器组件:

  • 球状碰撞器:运算速度最快的碰撞器形状。为球体。
  • 胶囊碰撞器:两端为球体,中间部分为圆柱体的碰撞器。运算速度次之。
  • 盒状碰撞器:一种长方体。适用于箱子、汽车、人体躯干等。
  • 网格碰撞器:由三维网格构成的碰撞器。尽管它实用并且精确,但运算速度比另外三种碰撞器要慢很多。并且,只有凸多面体(Convex)属性设置为 true 的网格碰撞器才可以与其他网格碰撞器发生碰撞。

Unity 中的物理过程和碰撞是通过 NVIDIA PhysX 引擎处理的。尽管它通常不能提供非常快速和精确的碰撞,但要知道所有的物理引擎都有其局限性,即使 PhysX 在处理高速对象或薄墙壁时偶尔也会出现问题。

# 刚体:物理模拟

刚体组件控制着游戏对象的物理模拟。刚体组件在每次f FixedUpdate(通常每隔 1/50 秒执行一次)函数中模拟加速度和速度,更新变换组件中的定位和旋转。刚体组件还可以为重力、拉力、风力、爆炸力等各种力建模。如果你希望直接设置游戏对象的位置,而不使用刚体所提供的物理过程,请将运动学模式(isKinematic)设置为 true。

要使碰撞器随游戏对象移动,游戏对象必须有刚体组件。否则,在 Unity 的 PhysX 物理模拟过程中,碰撞器将原地不动。也就是说,如果未添加刚体组件,游戏对象将在屏幕中移动,但在 PhysX 引擎中,游戏对象的碰撞器组件将保持原样,因此保留原来位置。

# 脚本:你编写的 C# 脚本

所有 C# 脚本也是游戏对象组件。把脚本当作组件处理的好处之一是你可以在每个游戏对象添加多个脚本。

# 小结

# 布尔运算符和比较运算符

# 布尔值

# 比较运算符

# 条件语句

# 小结

# 循环语句

# 循环语句的种类

# 创建项目

# while 循环

# 死循环的危害

# 更实用的 while 循环

# do......while 循环

# for 循环

# foreach 循环

# 循环中的跳转语句

# 小结

# List 和数组

# C# 的集合

集合是可通过一个变量引用的一组对象。在日常生活中,集合类似于一个人群、狮群,或鸟群。在 C# 中,有两种必须理解的重要集合类型:

  • **数组:**数组是最低等但速度最快的集合类型。数组只能存储一种类型的数据,在定义数组时,必须同时确定它的长度。另外还可以创建多维数组或交错数组(由数组构成的数组)。
  • **List:**List 是更为灵活的数组,但仍然是强类型(即它们只能存储一种类型的对象)。List 的长度是可以变化的,在不知道其中集合对象的具体数目时,List 会很实用。
  • List 和数组的下标从零开始

# List

using System.Collections; // 让脚本可以使用 ArrayLists(第三种集合类型,为非强类型)
using System.Collections.Generic; // List 和其他泛型集合属于该库
using UnityEngine; // 让脚本可以使用标准呢的 Unity 对象

public class ListEx : MonoBehaviour
{
  public List<string> sList;

  // Start is called before the first frame update
  void Start()
  {
    //Start is called before the first frame update
    sList = new List<string>();
    sList.Add("Experience");
    sList.Add("is");
    sList.Add("what");
    sList.Add("you");
    sList.Add("get");
    sList.Add("when");
    sList.Add("you");
    sList.Add("didn't");
    sList.Add("get");
    sList.Add("what");
    sList.Add("you");
    sList.Add("wanted.");
    print("sList Count = " + sList.Count);
    print("第 0 个元素为:" + sList[0]);
    print("第 1 个元素为:" + sList[1]);
    print("第 2 个元素为:" + sList[2]);
    print("第 8 个元素为:" + sList[8]);

    string str = "";
    foreach (string sTemp in sList)
    {
      str += sTemp + " ";
    }
    print(str);
  }

  // Update is called once per frame
  void Update()
  {

  }
}

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
# List 的重要属性和方法

属性

  • sL[2](下标访问):返回由参数(2)所指定的下标的 List 元素。
  • sL.Count:返回 List 中当前的元素个数。

方法

  • sL.Add("Hello"):在 sl 的末尾添加元素“Hello”。
  • sL.Clear():清除 sL 中现有的全部元素,使其变为空 List。
  • sL.IndexOf("A"):查找 sL 中第一个为 “A”的元素,并且返回该元素的下标。如果 List 中不存在括号中的变量,该表达式将返回 -1.要确定 List 中是否包含某个元素,这是一种既快速又安全的方法。
  • sL.Insert(2, "B.5"):将元素 “B.5” 插入到 sL 第 2 个元素之前,其后的元素将逐个向后移动。
  • sL.Remove("C"):从 List 中移除指定的元素。如果 List 中有两个元素的值都是“C”,则只有第一个被移除。
  • sL.RemoveAt(0):移除参数所指定的下标处的元素。

如果将 List 转换为数组

  • sL.ToArray():生成一个包含 sL 所有元素的数组。新数组中的元素类型与原来的 List 相同。

# 数组

数组是最为简单的集合类型,同时也是最快的。使用数组不要求导入任何库(即使用 using 命令),因为它们是 C# 中核心的内置对象。另外,数组中包括多维数组和交错数组,二者也非常实用。

# 基本数组的创建

数组长度是固定的,在定义数组时必须确定下来。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class ArrayEx : MonoBehaviour
{
  public string[] sArray;
  // Start is called before the first frame update
  void Start()
  {
    sArray = new string[10];
    sArray[0] = "这";
    sArray[1] = "是";
    sArray[2] = "几个";
    sArray[3] = "词";

    print("数组的长度为:" + sArray.Length);

    string str = "";
    foreach (string sTemp in sArray)
    {
      str += "|" + sTemp;
    }
    print(str);

  }

  // Update is called once per frame
  void Update()
  {

  }
}

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
  1. 数组被定义之后,将使用该数组所含数据类型的默认值垃填充相应的长度。整数或浮点数的默认值为 0。对于字符串和游戏对象等复杂对象,所有元素都被填充为 null(表示未赋予任何值)。
# 数组中的空元素

数组中间允许存在空元素,这是 List 无法做到的。如果你的游戏中有一个类似计分板的东西,每名玩家在计分上有一种得分标记,但在标记之间可能有空位的话,数组的这种特性就非常实用了。

sArray[0] = "这";
sArray[1] = "是";
sArray[3] = "几";
sArray[6] = "词";
1
2
3
4
# 数组的重要属性和方法

属性

  • sA[2](下标访问):返回由参数(2)指定的下标位置的数组元素。
  • 如果下标参数超出了数组下标的有效范围,则会产生一个运行时错误。
  • sA[1] = "Bravo" (用浓郁赋值的下标访问)
  • sA.Length:返回数组的总长度。所有元素都被计算在内,不论其是已赋值。

静态方法

数组的静态方法属于 System.Array 类,可作用于数组,使其具有 List 的部分功能。

  • System.Array.IndexOf(sA, "C"):从数组 sA 中查找第一个值为“C” 的元素并返回该元素的下标。如果数组中不存在要查找的变量,则返回 -1。
  • System.Array.Resize(ref Sa, 6):这个 C# 方法可以调整数组的长度。第一个参数是对数组的引用(所有需要在签名加上 ref 关键词),第二个参数是为数组指定的新长度。如果第二个参数所指定的长度小于数组原来的长度,多余的元素将被剔除出数组。System.Array.Resize() 方法对多维数组不起作用。

如何将数组转化为 List

  • List<string> sL = new List<string> (sA):这行代码将创建一个名为 sL 的 List,并复制数组 sA 中的元素。

# 多维数组

另外,还可以创建具有两个或更多下标的多维数组,这种数组很实用。在多维数组中,方括号中的下标数目不止一个,而是两个或更多。在创建可以容纳其他物体的二维网格时,这种多维数组非常实用。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Array2dEx : MonoBehaviour
{
  public string[,] sArray2d;
  // Start is called before the first frame update
  void Start()
  {
    sArray2d = new string[4, 4];
    sArray2d[0, 0] = "A";
    sArray2d[0, 3] = "B";
    sArray2d[1, 2] = "C";
    sArray2d[3, 1] = "D";
    print("数组 sArray2d 的长度为:" + sArray2d.Length);

    string str = "";
    for (int i = 0; i < 4; i++)
    {
      for (int j = 0; j < 4; j++)
      {
        if (sArray2d[i, j] != null)
        {
          str += "|" + sArray2d[i, j];
        }
        else
        {
          str += "|_";
        }
      }
      str += "|" + "\n";
    }
    print(str);
  }


  // Update is called once per frame
  void Update()
  {

  }
}

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

还应该注意,在 Unity 的检视面板中不显示多维你数组。事实上,如果检视面板不知道如何正确显示一个变量,它会彻底忽略这个变量,所以在检视面板中连多维数组的变量名也根本不显示。

# 交错数组

交错数组是由数组构成的数组,它与多维数组有些类似,但它允许其中的子数组具有不同的长度。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class JaggedArrayEx : MonoBehaviour
{
  public string[][] jArray;
  // Start is called before the first frame update
  void Start()
  {
    jArray = new string[4][];
    jArray[0] = new string[4];
    jArray[0][0] = "A";
    jArray[0][1] = "B";
    jArray[0][2] = "C";
    jArray[0][3] = "D";

    // 以下用单行方式完成数组的初始化
    jArray[1] = new string[] { "E", "F", "G" };
    jArray[2] = new string[] { "H", "I" };
    jArray[3] = new string[4];
    jArray[3][0] = "J";
    jArray[3][3] = "K";

    print(" jArray 的长度是:" + jArray.Length); // =》4

    print(" jArray[1] 的长度是:" + jArray[1].Length); // 3

    string str = "";
    foreach (string[] sArray in jArray)
    {
      foreach (string sTemp in sArray)
      {
        if (sTemp != null)
        {
          str += " | " + sTemp;
        }
        else
        {
          str += " | ";
        }
        str += " | \n";
      }
    }
    print(str);
  }

  // Update is called once per frame
  void Update()
  {

  }
}

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
# 交错 List

最后还有一类交错集合,即交错 List。可以用 List<List<string>> 语句声明一个交错的二维字符串 List。和交错数组一样,每个子 List 一开始均为 null,你必须初始化这些子 List,如下代码所示。与其他 List 一样,交错 List 也不允许空元素。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class JaggedListTest : MonoBehaviour
{
  public List<List<string>> jaggedList;
  // Start is called before the first frame update
  void Start()
  {
    jaggedList = new List<List<string>>();

    // 向 jaggedList 中添加两个 List<string>
    jaggedList.Add(new List<string>());
    jaggedList.Add(new List<string>());

    // 向 jaggedList[0] 中添加两个字符串
    jaggedList[0].Add("Hello");
    jaggedList[1].Add("World");

    // 向 jaggedList 添加第三个 List<string>,其中包含数据
    jaggedList.Add(new List<string>(new string[] { "complex", "initialization" }));

    string str = "";
    foreach (List<string> sL in jaggedList)
    {
      foreach (string sTemp in sL)
      {
        if (sTemp != null)
        {
          str += " | " + sTemp;
        }
        else
        {
          str += " | ";
        }
      }
      str += " | \n";
    }
    print(str);
  }

  // Update is called once per frame
  void Update()
  {

  }
}

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

# 应该使用数组还是 List

数组和 List 集合类型的区别主要在于以下几个方面:

  • List 具有可变的长度,而数组的长度不太容易改变。
  • 数组速度稍快,但多数情况下感觉不出来。
  • 数组允许有多维下标。
  • 数组允许在集合中存在空元素。

因为 List 更容易使用,不需要事先筹划太多(因为它们的长度可以改变),我个人常倾向于使用 List,而不是数组。在制作游戏原型时,这种倾向更为明显,因为原型需要很大的灵活性。

# 小结

学会了 List 和数组的用户,你就可以在编写游戏时操作大量的对象了。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class CubeSpawner3 : MonoBehaviour
{
  public GameObject cubePrefabVar;
  public List<GameObject> gameObjectList; // 用于存储所有的立方体
  public float scalingFactor = 0.95f;
  public int numCubes = 0; // 已初始化的立方体数目
  // Start is called before the first frame update
  void Start()
  {
    gameObjectList = new List<GameObject>();
  }

  // Update is called once per frame
  void Update()
  {
    numCubes++; // 使立方体数目增加1

    GameObject gObj = Instantiate(cubePrefabVar) as GameObject; /* as GameObject
    通知 C# 这个对象应当做游戏对象(GameObject)来处理 */

    // 以下几行代码将设置新建立方体的一些属性值
    gObj.name = "Cube" + numCubes;
    Color c = new Color(Random.value, Random.value, Random.value);
    // 为立方体随机指定一个颜色
    gObj.GetComponent<Renderer>().material.color = c;
    gObj.GetComponent<Transform>().position = Random.insideUnitSphere; /* 返回一个
    半径为 1 的球体(球心位于坐标 [0, 0, 0] 内的随机一个位置。这个代码使立方体随机分布在
    [0, 0, 0] 附近,而不是出现在同一点上。*/

    gameObjectList.Add(gObj);

    // 需要从 gameObjectList 中删除的立方体的信息
    // 将存储在这个 removeList 中
    // c# 中不允许正在遍历该 List 的 foreach 循环中删除 List 中的元素
    List<GameObject> removeList = new List<GameObject>();

    // 遍历 gameObjectList 中的每个立方体
    foreach (GameObject goTemp in gameObjectList)
    {
      // 获取立方体的大小
      float scale = goTemp.transform.localScale.x;
      scale *= scalingFactor;
      goTemp.transform.localScale = Vector3.one * scale;

      if (scale <= 0.1f) // 如果尺寸小于 0.1f
      {
        removeList.Add(goTemp); // 则添加到 removeList 中
      }
    }

    foreach (GameObject goTemp in removeList)
    {
      gameObjectList.Remove(goTemp);
      Destroy(goTemp); // 销毁立方体游戏对象
    }

  }
}

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

# 函数与参数

# 创建函数示例的项目

# 函数的定语

# 函数的形式参数和实际参数

# 函数的返回值

# 使用合适的函数名称

# 什么情况下应该使用函数

# 函数重载

函数重载用于参数类型不多的情况,如果较多的话则可以采用抽象思想,策略模式来处理。

  // 下面两个函数在 this.parts 中按名称或游戏对象查找某个组件
  Part FindPart(string n)
  {
    foreach (Part prt in parts)
    {
      if (prt.name == n)
      {
        return prt;
      }
    }
    return null;
  }

  Part FindPart(GameObject go)
  {
    foreach (Part prt in parts)
    {
      if (prt.go == go)
      {
        return prt;
      }
    }
    return null;
  }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

# 可选参数

# param 关键字

# 递归函数

# 小结

# 代码调试

# 如何开始调试

# 绑定或移除脚本时出现的错误

# 使用调试器逐语句运行代码

# 小结

#

# 理解类

# 创建 Enemy 类示例的项目

Enemy

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Enemy : MonoBehaviour
{
  public float speed = 10f; // 移动速度,单位为米/秒
  public float fireRate = 0.3f; // 射击次数/秒
  // Start is called before the first frame update
  void Start()
  {

  }
  // Update is called once per frame
  void Update()
  {
    Move();
  }

  public virtual void Move()
  {
    Vector3 tempPos = pos;
    tempPos.y -= speed * Time.deltaTime;
    pos = tempPos;
  }

  private void OnCollisionEnter(Collision coll)
  {
    GameObject other = coll.gameObject;
    switch (other.tag)
    {
      case "Hero":
        // 暂未实现,但这用于消灭游戏主角
        break;
      case "HeroLaser":
        // 敌人被消灭
        Destroy(this.gameObject);
        break;
    }
  }

// 属性
  public Vector3 pos
  {
    get
    {
      return (this.transform.position);
    }
    set
    {
      this.transform.position = value;
    }
  }




}

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

EnemyZig 子类

using UnityEngine;
using System.Collections;

public class EnemyZig : Enemy
{
  public override void Move()
  {
    Vector3 temPos = pos;
    temPos.x = Mathf.Sin(Time.time * Mathf.PI * 2) * 4;
    pos = temPos;
    base.Move(); // 调用父类中的 Move() 方法
  }
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14

警告:要特别小心竞态条件(Race Condition)竞态条件是指两件事事务之间存在依赖关系,但你不确定哪件事务发生在前。LateUpdate() 是 Unity 中另一个每帧都会调用的内置函数。在每帧中,Unity 首先调用绑定到游戏对象上的所有类的 Update() 函数,在所有 Update() 都执行完毕后,Unity 会调用所有对象上的 LateUpdate() 函数。所有,我们可以在 LateUpdate() 函数放置一些需要在 Update() 后执行的逻辑。

# 类的继承

# 小结

# 面向对象思维

# 面向对象的比喻

# 面向对象的 Boids 实现方法

# 小结

# 敏捷思维

# 敏捷软件开发宣言

# Scrum 方法论

# 小结

# 游戏原型实例和教程

# 游戏原型 1:《拾苹果》

# 数字化原型的目的

# 准备工作

# 摄像机设置

摄像机位置是游戏中最不能出错的内容之一。对于《拾苹果》游戏来说,我们希望摄像机显示一个适当大小的游戏区域。因为这个游戏的玩法完全是二维的,所以我们需要一个**正投影(Orthographic)**摄像机,而不是透视投影(Perspective)摄像机。关于这两种摄像机的投影方式,详见下面专栏。

# 正投影摄像机和透视投影摄像机的对比

透视投影摄像机类似于人的眼睛;因为光线经过透镜成像,所以靠近摄像机的物体显得较大,而远离摄像机的物体显得较小。这给透视摄影摄像机一个平截头四棱锥体(像一个削去尖顶的四棱金字塔)的视野(也称投影)。要查看这种效果,请单击层级中的主摄像机,在场景面板中拉远镜头,从摄像机延伸出的金字塔状网格线的就是平截头体视野,表示摄像机的可视范围。

对于正投影摄像机,物体与摄像机的距离不会影响它的大小。正投影摄像机的投影是一个长方体,而非平截头体。要查看这种效果,请在层级面板中选中摄像机,在检视面板中你找到 Camera 组件,将 Projection 属性从 Perspective 改为 Orthogonal。现在,灰色的视野范围将是一个三维矩形,而非金字塔形。

有时候,需要将场景面板设置为正投影而非透视投影。做法是:单击场景面板右上角坐标轴手柄下方的 <Persp 字样>。单击这个字样会在透视和等轴(缩写为 Iso)场景视图间切换,等轴是正投影的同义词。

# 开始工作:绘图资源

# 编写《拾苹果》游戏原型的代码

# 让游戏中的运动基于时间

**让游戏中的运动基于时间,是指不管游戏的帧速率是多少,运动都保持恒定的速度。**通过 Time.deltaTime 可以实现这一点,因为它能告诉我们从上一帧到现在经历了多少时间。Time.deltaTime 通常非常小。对于 25fps (帧/秒)的游戏来说,Time.deltaTime 为 0.04f,即每帧的时间为 4/100 秒。如果这行代码以 25 fps 的速度运行,结果将解析为:

pos.x += speed * Time.deltaTime;
pos.x += 1.0f * 0.04f;
pos.x += 0.04f;
1
2
3

因此,在 1/25 秒的时间内,pos.x 将递增 0.04 米。在 1 秒内,pos.x 将增加为 0.04 米/帧 * 25 帧,即 1 米/秒。这相当于将运动速度设置为 1 米/秒

如果游戏以 100 fps 的帧速率运行,该行代码将解析为:

pos.x += speed * Time.deltaTime;
pos.x += 1.0f * 0.01f;
pos.x += 0.01f;
1
2
3

所以,在 1/100 秒时间里,pos.x 每帧将递增 0.01f 米。在 1 秒钟的时间里,pos.x 的增量为 0.01 米/秒 * 100 帧,合计 1 米/秒

不管游戏的帧速率如何,基于时间的运动都可以保证游戏元素以恒定速度运动,这样可以保证游戏在最新配置和老配置的计算机上都可以玩。基于时间的编程在编写移动设备上运行的游戏时非常重要,因为移动设备的配置变化得非常快。

补充:

  1. 帧率为 25fps,即一秒变化 25 次,而帧率为 100 fps,即一秒变化 100 次,虽然基于时间的运动速度是一样,但是帧率低的由于变化次数少,变化的速率太慢骗不过眼睛,会让玩家明显感觉画面卡顿不流畅。
  2. 对于根据帧率计算随机率时,如果放在 Update() 时在每帧进行一次计算会不准确,因此低帧率的概率明显低于高帧率的概率。所以在 Unity 需要把这些逻辑放在 FixUpdate() 函数里,它根据时间来执行。
# 设置游戏对象图层

图层是指对象的分组,我们可以规定各组对象之间是否发生碰撞。如果苹果树和苹果放在两个不同的图层中,并在图层管理器中规定两个图层不发生碰撞,这样苹果和苹果树也不会再撞到一起了。

# 图形用户界面(GUI)和游戏管理

游戏的最后一件工作是 GUI 和游戏管理,使其更像是一个真正的游戏。

# 脚本间的调用

在未接住苹果时结束本回合并消除篮筐,可以让《拾苹果》感觉更像一个真正的游戏。这时,由 Apple 对象负责销毁自身,这没有问题,但是 Apple 需要以某种方式将该事件通知 ApplePicker 脚本,以便让 Apple Picker 可以结束本回合并销毁其余的苹果。这涉及脚本间的相互调用。

Apple.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Apple : MonoBehaviour
{
  public static float bottomY = -20f;

  // Start is called before the first frame update
  void Start()
  {

  }

  // Update is called once per frame
  void Update()
  {
    if (transform.position.y < bottomY)
    {
      Destroy(this.gameObject);

      // 获取对哦主摄像机的 ApplePicker 组件的引用
      ApplePicker apScript = Camera.main.GetComponent<ApplePicker>();

      // 调用 apScript 的 AppleDestroyed 方法
      apScript.AppleDestroyed();
    }
  }
}

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

ApplePicker.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.SceneManagement;

public class ApplePicker : MonoBehaviour
{
  public GameObject basketPrefab;
  public int numBaskets = 3;
  public float basketBottomY = -14f;
  public float basketSpacingY = 2f;
  public List<GameObject> basketList;

  // Start is called before the first frame update
  void Start()
  {
    basketList = new List<GameObject>();
    for (int i = 0; i < numBaskets; i++)
    {
      GameObject tBasketGO = Instantiate(basketPrefab) as GameObject;
      Vector3 pos = Vector3.zero;
      pos.y = basketBottomY + (basketSpacingY * i);
      tBasketGO.transform.position = pos;
      basketList.Add(tBasketGO);
    }

  }

  // Update is called once per frame
  void Update()
  {

  }

  public void AppleDestroyed()
  {
    // 消除所有下落中的苹果
    GameObject[] tAppleArray = GameObject.FindGameObjectsWithTag("Apple");
    foreach (GameObject tGo in tAppleArray)
    {
      Destroy(tGo);
    }

    // 消除一个篮筐
    // 获取 basketList 中最后一个篮筐的序号
    int basketIndex = basketList.Count - 1;
    // 取得该篮筐的引用
    GameObject tBasketGO = basketList[basketIndex];
    // 从列表中清除该篮筐并销毁该游戏对象
    basketList.RemoveAt(basketIndex);
    Destroy(tBasketGO);

    // 重新开始游戏,HighScore.score 不会受影响
    if (basketList.Count == 0)
    {
      //Application.LoadLevel("_Scene_0");
      SceneManager.LoadScene("_Scene_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
53
54
55
56
57
58
59
60
61
# 添加最高得分记录
  1. Awake() 是 Unity 的内置方法(类似 Start()Update()),在首次创建 HighScore 实例时运行(因此 Awake() 总在 Start() 之前发生)。
  2. PlayerPrefs 是一个关键字和数值的字典,可以通过关键字(即独一无二的字符串)引用值。

# 小结

图层使用步骤:

1.设置图层,可以设置各种不同类型的游戏对象的交互,控制对象之间是否发生碰撞。

  • 图层是指对象的分组,我们可以规定各组对象之间是否发生碰撞。如果苹果树和苹果放在两个不同的图层中,并在图层管理器中规定两个图层不发生碰撞,这样苹果和苹果树也不会再撞到一起了。
  1. 然后可以通过物理器 Physics 进行编辑图层的碰撞交互规则了。
  2. 定义好图层之后,就可以为游戏对象指定合适的图层了。
  3. 添加碰撞事件处理逻辑。

通过图层可以设置哪些图层的游戏对象可以发生碰撞,然后在碰撞时通过标签找到相关的游戏对象,进行逻辑的处理。

private void OnTriggerEnter(Collider other)
  {
    // 当其他物体撞到触发器时
    // 检查是否是弹丸
    if (other.gameObject.tag == "Projectile")
    {
      // 如果是弹丸
      Goal.goalMet = true;
      // 同时将颜色的不透明度设置得更高
      Renderer renderer = this.gameObject.GetComponent<Renderer>();
      Color c = renderer.material.color;
      c.a = 1;
      c.r = 255;
      renderer.material.color = c;
    }
  }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

# 游戏原型 2:《爆破任务》

# 游戏原型概念

在本游戏中,玩家将使用弹弓把弹丸发射到一座城堡中,目标是炸掉城堡。每座城堡会有一个目标区域,弹丸需要碰到这些区域才能进入下一关。

以下是我们希望看到的事件顺序:

  1. 当玩家的鼠标光标处于弹弓附近的特定区域内时,弹弓会发光。
  2. 玩家在弹弓发光时按下鼠标左键(Unity 中的 button 0),会在鼠标光标位置出现一发弹丸。
  3. 玩家按下鼠标并拖动时,弹丸会随鼠标移动,但会保持在弹弓的球状碰撞器内。
  4. 在弹弓架的两个分叉到弹丸之间会出现两条白线,增加真实感。
  5. 玩家松开鼠标左键时,弹弓会哦把弹丸发射出去。
  6. 城堡位于几米之外,玩家的目标是让城堡倒下并砸到其中的特定区域。
  7. 玩家要达到目标,可发射任意数量的弹丸。每发弹丸会哦留下一条轨迹,玩家可以在下一次发射时作为参考。

# 绘图资源

如果一个游戏对象(例如 Base)是其他游戏对象的子对象,当修改它的变换组件时,使用的是局部坐标,即设置 Base 相对于其父对象 Slingshot 的相对位置,而不是 Base 在游戏全局世界坐标的位置。

当碰撞器的 Is Trigger 为 true 时,它被称作触发器。在 Unity 中,触发器是物理模拟的构建之一,当其他碰撞器或触发器穿过时,触发器可以发出通知信息。但是,其他对象不会被触发器弹开,这一点有别于普通的碰撞器。我们将使用这个触发器处理弹弓的鼠标交互。

# 编写游戏原型的代码

在你创建自己的代码时,这是一个很好的方式:实现一个容易编写的小功能并测试,然后再实现另一个小功能。

# Slingshot 类

鼠标移入移出

  //  OnMouseEnter 和 OnMouseExit 需要添加 collider 组件,并且 isTrigger = false
  private void OnMouseEnter()
  {
    //print("Slingshot: OnMouseEnter()");
    launchPoint.SetActive(true);
  }

  private void OnMouseExit()
  {
    //print("Slingshot: OnMouseExit()");
    launchPoint.SetActive(false);
  }
1
2
3
4
5
6
7
8
9
10
11
12

游戏对象的 SetActive() 方法可以让游戏渲染或忽视该游戏对象。如果游戏对象的 active 属性设置为 false,它就不会显示在屏幕上,也不会接受 Update()OnCollisionEnter() 等任何函数调用。这时,游戏对象并没有销毁,它只是未激活。在游戏对象的检视面板中,顶部游戏对象名称左侧的复选框代表了游戏的激活状态。

游戏对象的组件也有类似的复选框,它表示该组件是否已启用。对于大多数组件(例如渲染器 Renderer 和【碰撞器 Collider),可以通过代码设置其是否启用(例如 Renderer.Enabled = false),但出于某种原因,Halo 组件在 Unity 中不可访问,也就是说,我们不能通过 C# 脚本操作 Halo 组件。在 Unity 中,你会时不时地遇到这类问题,你需要换一种方法解决。在这里,我们不能禁用 Halo,所以我们转而停用包含该组件的游戏对象。

# 实例化一个弹丸
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Slingshot : MonoBehaviour
{
  static public Slingshot S; // FollowCam 的单例对象
  // 在 Unity 检视面板中设置的字段
  public GameObject prefabProjectile;
  public float velocityMult = 4f;
  public bool ______________________;

  // 动态设置的字段
  public GameObject launchPoint;
  public Vector3 launchPos;
  public GameObject projectile;
  public bool aimingMode;


  private void Awake()
  {
    S = this;
    launchPoint = GameObject.Find("LaunchPoint");
    if (launchPoint != null)
    {
      launchPoint.SetActive(false);
      launchPos = launchPoint.GetComponent<Transform>().position;
    }
  }

  //// Start is called before the first frame update
  void Start()
  {

  }

  // Update is called once per frame
  void Update()
  {
    // 如果弹弓未处于瞄准模(aimingMode),则跳过以下代码
    if (!aimingMode) return;
    // 获取鼠标光标在二维窗口中的坐标
    Vector3 mousePos2D = Input.mousePosition;
    // 将鼠标光标位置转换为三维世界坐标
    mousePos2D.z = -Camera.main.transform.position.z;
    Vector3 mousePos3D = Camera.main.ScreenToWorldPoint(mousePos2D);

    // 计算 launchPos 到 mousePos3D 两点之间的坐ba差
    Vector3 mouseDelta = mousePos3D - launchPos;

    // 将坐标差限制在弹弓的球状碰撞器半径范围内
    float maxMagnitude = this.gameObject.GetComponent<SphereCollider>().radius;
    if (mouseDelta.magnitude > maxMagnitude)
    {
      mouseDelta.Normalize(); // 保持 MouseDelta 方向不变将它长度变为1
      mouseDelta *= maxMagnitude;
    }
    // 将 projectitle 移动到新位置
    Vector3 projPos = launchPos + mouseDelta;
    projectile.transform.position = projPos;

    if (Input.GetMouseButtonUp(0))
    {
      // 如果已经公开鼠标
      aimingMode = false;
      projectile.GetComponent<Rigidbody>().isKinematic = false;
      projectile.GetComponent<Rigidbody>().velocity = -mouseDelta * velocityMult;
      FollowCam.S.poi = projectile; // 同步摄像机兴趣点
      projectile = null;
      MissionDemolition.ShotFired(); // 增加发射次数
    }
  }

  //  OnMouseEnter 和 OnMouseExit 需要添加 collider 组件,并且 isTrigger = false
  private void OnMouseEnter()
  {
    //print("Slingshot: OnMouseEnter()");
    launchPoint.SetActive(true);
  }

  private void OnMouseExit()
  {
    //print("Slingshot: OnMouseExit()");
    launchPoint.SetActive(false);
  }

  `
  private void OnMouseDown()
  {
    // 玩家在鼠标光标悬停在弹弓上方时按下了鼠标左键
    aimingMode = true;
    // 实例化一个弹丸
    projectile = Instantiate(prefabProjectile) as GameObject;
    // 该实例的初始位置位于 launchPoint 处
    projectile.transform.position = launchPos;
    // 设置当前的 Kinematic 属性
    projectile.GetComponent<Rigidbody>().isKinematic = true;
  }`

}
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
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
  1. 这里首先要注意的是 Slingshot 类代码最上方的附加字段(即变量)。有个全局布尔类型变量看起来特别奇怪:******__******。这个变量用于一种非常特殊的目的:在检视面板中,它是 Slingshot 脚本组件的分隔线,它的上方是需要在检视面板中设置的变量,下方是在游戏运行时通过代码动态设置的变量。因为在 Unity 检视面板中,序列化的全局变量是按其声明顺序排列的,所以在检视面板中,由下划线构成的布尔型变量可以充当预先设置变量和动态设置变量的分隔线。
  2. 当 Rigidbody 为运动学刚体(即 isKinematic == true)时,对象的运动不会自动遵循物流源流,但仍然属于物理模拟的构成部分(即刚体的运动不会收到碰撞和重力的影响,但仍然会影响其他非运动学刚体的运动)。
  3. 向量加减运算是将各向量分别相加减。图中以二维向量为例,但三维向量也适用同样的方法。向量 A 和 B 的 x、y 分别相减,得到一个新的二维向量(2-5,8-3),即(-3,5)。图中演示的 A-B 得到的是 A 和 B 之间的向量距离,同时也是从点 B 移动到点 A 所移动的方向和距离。为方便记忆,可写为 AMBLAA(A Minus B Looks At A,即向量 A-B 的方向为指向 A 点)。

这在 Update() 方法中非常重要,因为弹丸需要位于从 launchPos 出发指向当前鼠标光标位置的向量之上,这一向量称作 mouseDelta。

 // Update is called once per frame
  void Update()
  {
    // 如果弹弓未处于瞄准模(aimingMode),则跳过以下代码
    if (!aimingMode) return;
    // 获取鼠标光标在二维窗口中的坐标
    Vector3 mousePos2D = Input.mousePosition;
    // 将鼠标光标位置转换为三维世界坐标
    mousePos2D.z = -Camera.main.transform.position.z;
    Vector3 mousePos3D = Camera.main.ScreenToWorldPoint(mousePos2D);

    // 计算 launchPos 到 mousePos3D 两点之间的坐ba差
    Vector3 mouseDelta = mousePos3D - launchPos;

    // 将坐标差限制在弹弓的球状碰撞器半径范围内
    float maxMagnitude = this.gameObject.GetComponent<SphereCollider>().radius;
    if (mouseDelta.magnitude > maxMagnitude)
    {
      mouseDelta.Normalize(); // 保持 MouseDelta 方向不变将它长度变为1
      mouseDelta *= maxMagnitude;
    }
    // 将 projectitle 移动到新位置
    Vector3 projPos = launchPos + mouseDelta;
    projectile.transform.position = projPos;
  }
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
# 自动跟踪摄像机

在弹丸发射以后,我们需要让主摄像机(_Main Camera)跟踪它,但是摄像机的行为还要更为复杂一些。对摄像机行为的完整描述如下:

  1. 弹弓处于瞄准状态(aimingMode == true),摄像机固定于初始位置。
  2. 弹丸发射之后,摄像机跟踪它(加一些平滑效果使画面更流畅)。
  3. 摄像机随弹丸移到空中之后,要增加 Camera.orthographiceSize,使地面(Ground)始终保持在画面底部。
  4. 当弹丸停止运动之后,摄像机停止跟踪并返回到初始位置。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class FollowCam : MonoBehaviour
{
  static public FollowCam S; // FollowCam 的单例对象
  // 在 unity 检视面板中设置的字段
  public float easing = 0.05f;
  public Vector2 minXY;
  public bool _______________________________;

  // 动态设置的字段
  public GameObject poi; // 兴趣点
  public float camZ; // 摄像机的 Z 坐标

  private void Awake()
  {
    S = this;
    camZ = this.transform.position.z; // 摄像机初始 z 坐标
  }

  // Start is called before the first frame update
  void Start()
  {

  }

  private void FixedUpdate()
  {
    Vector3 destination;
    // 如果兴趣点(poi)不存在,返回 P: [0, 0, 0]
    if (poi == null)
    {
      destination = Vector3.zero;
    }
    else
    {

      // 获取兴趣点的位置
      destination = poi.transform.position;

      // 如果兴趣点是一个 Projectile 实例,检查它是否已经静止
      if (poi.tag == "Projectile")
      {
        //print(poi.GetComponent<Rigidbody>().IsSleeping());
        // 如果它处于 sleeping 状态(即未移动)
        if (poi.GetComponent<Rigidbody>().IsSleeping())
        {
          // 返回默认视图
          poi = null;
          // 在下一次更新
          return;
        }
      }
    }

    // 限定 x 和 y 的最小值
    destination.x = Mathf.Max(minXY.x, destination.x);
    destination.y = Mathf.Max(minXY.y, destination.y);

    // 在摄像机当前位置和目标位置之间增添插值
    destination = Vector3.Lerp(transform.position, destination, easing);
    // 保持 destination.z 的值为 camZ
    destination.z = camZ;
    // 将摄像机设置到 destination
    transform.position = destination;
    // 设置摄像机的 orthographicSize,使地面始终处于画面之中
    this.gameObject.GetComponent<Camera>().orthographicSize = destination.y + 10;

  }


  // Update is called once per frame
  void Update()
  {
    //if (poi == null) return;

    //// 获取兴趣点的位置
    //Vector3 destination = poi.transform.position;
    //// 在摄像机当前位置和目标位置之间增添插值
    //destination = Vector3.Lerp(transform.position, destination, easing);
    //// 保持 destination.z 的值为 camZ
    //destination.z = camZ;
    //// 将摄像机设置到 destination
    //transform.position = destination;
  }
}

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
84
85
86
87
88
89

首先,你会注意到在 FollowCam 代码的最上方是单例对象 S。“单例模式是一种设计模式,用于游戏中只存在唯一实例的类。”因为 Mission Demolition 游戏中只有一个摄像机,所以很适合使用单例模式。作为一个全局静态变量,单例对象 S 可以在代码任何位置通过 FollowCam.S 访问,这样我们可以随时通过 FollowCam.S.poi 设置全局字段 poi。

你可能会注意到下面一些问题:

A. 如果把场景面板拉得足够远,你会看到弹丸实际上已经飞出了地面的尽头。

B. 如果朝向地面发射,你会看到弹丸在撞到地面以后既不反弹也不停下来。如果你在发射后按下暂停键,在层级面板中选中 Projectile,然后结束暂停,你会看到它在撞到地面上会无休止地向前滚动。

C. 当弹丸刚发射时,摄像机会跳到 Projectile 的位置,看起来有些突兀。

D. 弹丸到达一定高度之后,画面上只能看到天空,很难看出它的高度。

  1. 首先,要解决问题 A,可以把 Ground 的变换组件修改为 P:[100, -10, 0] R:[0, 0, 0] S:[400, 1, 1]。
  2. 要解决问题 B,需要为弹丸添加刚体约束和物理材质(Physic Material)。请在项目面板中选择 Projectile 预设,单击 Rigidbody 组件左侧的三角形展开按钮,勾选 Freeze Position(冻结位置)z 和 Freeze Rotation(冻结旋转轴)x,y,z。Freeze Position z 可以冻结弹丸的 z 坐标,使它不会朝向摄像机移动或远离摄像机(使它与地面依旧将来要添加的城堡处于相同的 z 深度)。

这里,你还需要从Collision Detection(碰撞检测)下拉菜单中选择 Continuous(连续)。若想深入了解碰撞检测的类型,你可以单击 Rigidbody 组件右上角的帮助图标查看帮助文件。简而言之,连续碰撞检测比 Discrete (非连续)更加耗费 CPU 资源,但能够更精确地处理快速移动的物体,例如这里的 Projectile。

  1. 这些刚体组件设置可以防止弹丸无休止地滚动下去,但是感觉仍然不真实。你生活中一直在体验物理运动,你可以从中直观地感受到哪些行为更像自然、真实世界的物理运动。对于玩家来说,同样如此。也就是说,尽管物理是一个需要大量数学建模的复杂系统,但如果你能让游戏中你的物理符合玩家的习惯,你就不必向他们解释太多数学原理。

为你的物理模拟对象添加一种物理材质,可以让它感觉更为真实。请在菜单栏中执行资源(Assets)-> 创建(Create)-> 物理材质(Physis Material)命令。将其应用到 Projectile.ShereCollider。选中 Projectile 预设,就能在检视面板中看到 PMat_projectile 已经赋给了球状碰撞器的材质。现在再单击播放按钮,你会看到弹丸在你触地之后会反弹起来,而不再是向前滑动。

  1. 问题 C 可以通过两种方法共同解决:通过插值使画面更平滑,并对摄像机位置加以限制.

Vector3.Lerp() 方法返回两点之间的一个线性插值位置,取两点位置的加权平均值。如果第三个参数 easing 的值为 0,Lerp() 会返回第一个参数(transform.position)的位置;如果 easing 的值为 1,Lerp() 将返回第二个参数(destination)的位置;如果 easing 值在 0 到 1 之间,则 Lerp() 返回值将位于两点之间(当 easing 为 0.5 时,返回两点的中点)。这里让 easing = 0.05,告诉 Unity 让摄像机从当前位置将向兴趣点位置移动,每帧移动 5% 的距离。因为兴趣点的位置在持续移动,所以我哦们会得到一个平滑的摄像机跟踪运动。请尝试使用不同的 easing 值,看看该值如何影响摄像机运动。这是一种非常简单的线性插值方法,并非是基于时间的。

  1. 即使有了上述平滑措施,你可能仍然感觉摄像机的运动有些卡顿和不稳定。这是因为物理模拟的更新频率在 50 fps,而 Update() 则是以计算机能够达到的最高帧率调用的。在运行速度较快的计算机上,摄像机的更新频率远大于物理模拟,这样一来,每次在弹丸的位置改变之前,摄像机的位置已经更新了数次。要解决这一问题,将 Update() 方法的名称改为 FixUpdate()Update() 中的代码每帧都会运行一次,而 FixedUpdate() 中的代码则是每个物理模拟帧运行一次(50fps),不论计算机速度如何。修改完毕之后,跟踪摄像机的卡顿现象将会得到解决。

  2. 现在,我们为跟踪摄像机的位置添加一些限制。

 // 限定 x 和 y 的最小值
 destination.x = Mathf.Max(minXY.x, destination.x);
 destination.y = Mathf.Max(minXY.y, destination.y);
1
2
3
  1. 问题 D 可以通过动态调整摄像机的 orthographicSize 来解决。
# 相对运动错觉和速度感

跟踪摄像机运动现在已经可以完美工作了,但仍然很难感觉出弹丸的运动快慢,当它在空中飞行的时候更是如此。要解决这一问题,我们需要利用到相对运动错觉的概念。相对运动错觉是由于周围物体快速经过而造成运动感,在二维游戏中的视差滚动就是基于这一原理。在二维游戏中,视差滚动为使前景物体快速经过,而让背景物体以更慢的速度相对于主摄像机移动。

绘制云朵

  1. 移除球状碰撞器
  2. 新建一个材质,添加 Shader(着色器)组件旁边的下拉菜单中执行 Self-Illiumin(自发光) > Diffuse (漫射光命令)。这个着色器是自发光的(它自身会发光),同时也会响应场景中的平行光。
  3. 添加一个空对象,把云朵子对象放置进去。

新建一个名为 CloudCrafter 的脚本,将它拖放到 __Scripts 文件夹中并拖放到 _Main Camera 上。这会为 _Main Camera 添加第二个脚本组件,在 Unity 中,只要两个脚本不互相冲突(例如,不会在每一帧设置同一游戏对象的位置),这样做没有任何问题。因为 FllowCam 脚本负责移动摄像机,而 CloudCrafter 脚本负责在空中摆放云朵,二者不会发生任何冲突。

CloudCrafter.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class CloudCrafter : MonoBehaviour
{

  // 在 unity 检视面板设置的字段
  public int numClouds = 40; // 要创建云朵的数量
  public GameObject[] cloudPrefabs; // 云朵预设的数组
  public Vector3 cloudPosMin; // 云朵位置的下限
  public Vector3 cloudPosMax; // 云朵位置的上限
  public float cloudScaleMin = 1; //云朵的最小缩放比例
  public float cloudScaleMax = 5; // 云朵的最大缩放比例
  public float cloudSpeedMult = 0.5f; // 调整云朵速度

  public bool _____________________;
  // 在代码中动态设置的字段
  public GameObject[] cloudInstances;

  private void Awake()
  {
    // 创建一个 cloudInstances 数组,用于存储所有云朵的实例
    cloudInstances = new GameObject[numClouds];
    // 查找 CloudAnchor 父对象
    GameObject anchor = GameObject.Find("CloudAnchor");

    // 遍历 Cloud[数字]并创建实例
    GameObject cloud;
    for (int i = 0; i < numClouds; i++)
    {
      // 在 0 到 cloudPrefabs.Length - 1 之间选择一个整数
      // Random.Range 返回值中不包含范围上限
      int prefabNum = Random.Range(0, cloudPrefabs.Length);
      // 创建一个实例
      cloud = Instantiate(cloudPrefabs[prefabNum]) as GameObject;

      // 设置云朵位置
      Vector3 cPos = Vector3.zero;
      cPos.x = Random.Range(cloudPosMin.x, cloudPosMax.x);
      cPos.y = Random.Range(cloudPosMin.y, cloudPosMax.y);

      // 设置云朵缩放比例
      float scaleU = Random.value;
      float scaleVal = Mathf.Lerp(cloudScaleMin, cloudScaleMax, scaleU);

      // 较小的云朵(即 scaleU 值较小)离地面较近
      cPos.y = Mathf.Lerp(cloudPosMin.y, cPos.y, scaleU);
      // 较小的云朵距离较远
      cPos.z = 100 - 90 * scaleU;
      // 将上述变换数值应用到云朵
      cloud.transform.position = cPos;
      cloud.transform.localScale = Vector3.one * scaleVal;

      // 使云朵成为 CloudAnchor 的子对象
      cloud.transform.parent = anchor.transform;
      // 将云朵添加到 CloudInstances 数组中
      cloudInstances[i] = cloud;
    }
  }


  // Start is called before the first frame update
  void Start()
  {

  }

  // Update is called once per frame
  void Update()
  {
    // 遍历所有已创建的云朵
    foreach (GameObject cloud in cloudInstances)
    {
      // 获取云朵的缩放比例和位置
      float scaleVal = cloud.transform.localScale.x;
      Vector3 cPos = cloud.transform.position;
      // 云朵越大,移动速度越快
      cPos.x -= scaleVal * Time.deltaTime * cloudSpeedMult;
      // 如果云朵已经位于面板左侧较远位置
      if (cPos.x <= cloudPosMin.x)
      {
        // 则将它放置到最右侧
        cPos.x = cloudPosMax.x;
      }
      // 将新位置应用到云朵上
      cloud.transform.position = cPos;
    }
  }
}

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
84
85
86
87
88
89
90
91
# 创建城堡
  1. 单击坐标轴小手柄 z 轴反方向的箭头,使场景面板切换到正投影视图的后视图。
# 返回弹弓画面进行另一次发射

有了要击倒的城堡,现在需要添加更多游戏逻辑。当弹丸静止之后,摄像机应返回到弹弓的位置:

  1. 首先,应该为 Projectile 预设添加一个 Projectile 标签。在项目面板中选中 Projectile 预设,在检视面板中,点开 Tag 旁边的下拉菜单并选择 Add Tag(添加标签)。单击 Tags 旁边的三角形展开按钮,在 Element0 中输入 Projectile。再次在项目面板中点击 Projectile 预设,从检视面板中更新后的 Tag 列表中选中你 Projectile,为它添加标签。
  2. 打开 Follow Cam 脚本,修改以下代码行:
  private void FixedUpdate()
  {
    Vector3 destination;
    // 如果兴趣点(poi)不存在,返回 P: [0, 0, 0]
    if (poi == null)
    {
      destination = Vector3.zero;
    }
    else
    {

      // 获取兴趣点的位置
      destination = poi.transform.position;

      // 如果兴趣点是一个 Projectile 实例,检查它是否已经静止
      if (poi.tag == "Projectile")
      {
        //print(poi.GetComponent<Rigidbody>().IsSleeping());
        // 如果它处于 sleeping 状态(即未移动)
        if (poi.GetComponent<Rigidbody>().IsSleeping())
        {
          // 返回默认视图
          poi = null;
          // 在下一次更新
          return;
        }
      }
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
# 为弹丸添加轨迹

尽管 Unity 中确实有自带的轨迹渲染器(Trail Renderer)效果,但它不能达到我们所要实现的目标,因为我们需要对轨迹进行更多控制。这里,我们将在 Line Renderer(线渲染器)组件的基础之上建立轨迹渲染器:

  1. 首先建立一个空白游戏对象,将其命名为 ProjectileLine,为其添加一个轨迹渲染器组件(执行 Components > Effects > Line Renderer 命令)。

  2. ProjectileLine.cs

using UnityEngine;
using System.Collections;
using System.Collections.Generic;

public class ProjectileLine : MonoBehaviour
{
  static public ProjectileLine S; // 单例对象
  // 在 Unity 检视面板中设置的字段
  public float minDist = 0.1f;
  public bool _____________________________________;

  // 在代码中动态设置的字段
  public LineRenderer line;
  private GameObject _poi;
  public List<Vector3> points;

  private void Awake()
  {
    S = this; // 设置单例对象
    // 获取对线渲染器(LineRennderer)的引用
    line = GetComponent<LineRenderer>();
    // 在需要使用 LineRenderer 之前,将其禁用
    line.enabled = false;
    // 初始化三维向量点的 List
    points = new List<Vector3>();
  }

  // 这是一个属性,即伪装成字段的方法
  public GameObject poi
  {
    get
    {
      return (_poi);
    }
    set
    {
      _poi = value;
      if (_poi != null)
      {
        // 当把 _poi 设置为新对象时,将复位其所有内容
        line.enabled = false;
        points = new List<Vector3>();
        AddPoint();
      }
    }
  }

  // 这个函数用于直接清除线条
  public void Clear()
  {
    _poi = null;
    line.enabled = false;
    points = new List<Vector3>();
  }
  public void AddPoint()
  {
     if (_poi == null)
    {
      return;
    }
    // 用于在线条上添加一个点,记录发射点的位置
    Vector3 pt = _poi.transform.position;
    if (points.Count > 0 && (pt - lastPoint).magnitude < minDist)
    {
      // 如果该点与上一个点的位置不够远,则返回
      return;
    }

    if (points.Count == 0)
    {
      // 如果当前是发射点
      Vector3 launchPos = Slingshot.S.launchPoint.transform.position;
      Vector3 launchPosDiff = pt - launchPos;
      // ... 则添加一根线条,帮助之后瞄准
      points.Add(pt + launchPosDiff);
      points.Add(pt);
      line.positionCount = 2;
      // 设置两个点,两点形成一条直线
      line.SetPosition(0, points[0]);
      line.SetPosition(1, points[1]);
      // 启动线渲染器
      line.enabled = true;
    }
    else
    {
      // 正常添加点的操作
      points.Add(pt);
      // 设置线段的端点数
      line.positionCount = points.Count;
      // 两点确定一条直线,所以我们没刷新一次,依次绘制一点就可以形成线段了
      line.SetPosition(points.Count - 1, lastPoint);
      line.enabled = true;
    }
  }

  // 返回最近添加的点的位置
  public Vector3 lastPoint
  {
    get
    {
      if (points == null)
      {
        // 如果当前还没有点,返回 Vector3.zero
        return (Vector3.zero);
      }
      return (points[points.Count - 1]);
    }

  }

  // Use this for initialization
  void Start()
  {

  }

  // Update is called once per frame
  void Update()
  {

  }

  private void FixedUpdate()
  {
    if (poi == null)
    {
      // 如果兴趣点不存在,则找出一个
      if (FollowCam.S.poi != null)
      {
        if (FollowCam.S.poi.tag == "Projectile")
        {
          poi = FollowCam.S.poi;
        }
        else
        {
          return; // 如果未找到兴趣点,则返回
        }
      }

    }
    // 如果存在兴趣点,则在 FixedUpdate 中在其位置上增加一个点
    AddPoint();
    if (poi.GetComponent<Rigidbody>().IsSleeping())
    {
      // 当兴趣点静止时,将其清空(设置为 null)
      poi = null;
    }
  }
}


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
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151

参考资料

# 击中目标

被弹丸击中后,城堡的目标需要做出响应:

  1. 创建一个名为 Goal 的脚本,将其绑定到 Goal 预设上。在 Goal 脚本中输入以下代码:
using UnityEngine;
using System.Collections;

public class Goal : MonoBehaviour
{
  // 可在代码任意位置访问的静态字段
  static public bool goalMet = false;

  private void OnTriggerEnter(Collider other)
  {
    // 当其他物体撞到触发器时
    // 检查是否是弹丸
    if (other.gameObject.tag == "Projectile")
    {
      // 如果是弹丸
      Goal.goalMet = true;
      // 同时将颜色的不透明度设置得更高
      Renderer renderer = this.gameObject.GetComponent<Renderer>();
      Color c = renderer.material.color;
      c.a = 1;
      c.r = 255;
      renderer.material.color = c;
    }
  }
}

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

# 添加更多难度级别和游戏逻辑

游戏状态管理

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
// 游戏状态管理
public enum GameMode
{
  idle,
  playing,
  levelEnd
}
public class MissionDemolition : MonoBehaviour
{

  static public MissionDemolition S; // 单例对象
  // 在 Unity 检视面板中设置的字段
  public GameObject[] castles; // 存储所有城堡对象的数组
  public Text gtLevel; // GT_Level 界面文字
  public Text gtScore; // GT_Score 界面文字
  public Vector3 castlePos; // 放置城堡的位置

  public bool _____________________________;

  // 在代码中动态设置的变量
  public int level; // 当前级别
  public int levelMax; // 级别的数量
  public int shotsTaken;
  public GameObject castle; // 当前城堡
  public GameMode mode = GameMode.idle;
  public string showing = "Slingshot"; // 摄像机的模式

  // Start is called before the first frame update
  void Start()
  {
    S = this; // 定义单例对象
    level = 0;
    levelMax = castles.Length;
    StartLevel();
  }

  // Update is called once per frame
  void Update()
  {
    ShowGT();
    // 检查是否已完成该级别
    if (mode == GameMode.playing && Goal.goalMet)
    {
      // 当完成级别时,改变 mode,停止检查
      mode = GameMode.levelEnd;
      // 缩写画面比例
      SwitchView("Both");
      // 在2秒后开始下一级别
      Invoke("NextLevel", 2f);
    }
  }

  void StartLevel()
  {
    // 如果已经有城堡存在,则清除原有的城堡
    if (castle != null)
    {
      Destroy(castle);
    }
    // 清除原有的弹丸
    GameObject[] gos = GameObject.FindGameObjectsWithTag("Projectile");
    foreach (GameObject pTemp in gos)
    {
      Destroy(pTemp);
    }
    // 实例化新城堡
    castle = Instantiate(castles[level]) as GameObject;
    castle.transform.position = castlePos;
    shotsTaken = 0;

    // 重置摄像机位置
    SwitchView("Both");
    ProjectileLine.S.Clear();
    // 重置目标状态
    Goal.goalMet = false;
    ShowGT();
    mode = GameMode.playing;
  }

  void ShowGT()
  {
    // 设置界面文字
    gtLevel.text = "Level: " + (level + 1) + "of " + levelMax;
    gtScore.text = "Shots Taken: " + shotsTaken;
  }

  private void OnGUI()
  {
    // 在屏幕顶端绘制用户界面按钮,用于切换视图
    Rect buttonRect = new Rect((Screen.width / 2) - 50, 10, 100, 24);
    switch (showing)
    {
      case "Slingshot":
        if (GUI.Button(buttonRect, "查看城堡"))
        {
          SwitchView("Castle");
        }
        break;
      case "Castle":
        if (GUI.Button(buttonRect, "查看全部"))
        {
          SwitchView("Both");
        }
        break;
      case "Both":
        if (GUI.Button(buttonRect, "查看弹弓"))
        {
          SwitchView("Slingshot");
        }
        break;
    }

  }

  // 允许在代码任意位置切换视图的静态方法,相当于算法中的订阅者
  static public void SwitchView(string eView)
  {
    S.showing = eView;
    switch (S.showing)
    {
      case "Slingshot":
        FollowCam.S.poi = null;
        break;
      case "Castle":
        FollowCam.S.poi = S.castle;
        break;
      case "Both":
        FollowCam.S.poi = GameObject.Find("ViewBoth");
        break;
    }
  }

  void NextLevel()
  {
    level++;
    if (level == levelMax)
    {
      level = 0;
    }
    StartLevel();
  }

  // 允许在代码任意位置增加发射次数的代码
  public static void ShotFired()
  {
    S.shotsTaken++;
  }
}

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
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153

# 小结

# Unity 相关知识点:
  • 力学
  • 碰撞器与触发器
  • 不同脚本下的
  • 摄像机跟随目标
  • 点击按钮切换摄像机视图
  • 目标与触发器相碰
  • 向量
  • 游戏的 setActive
  • 单例对象
  • Rigibody constrains 可以冻结物体的 x、y、z 轴
  • 添加标签
    • 可以通过标签,判断一系列的游戏对象。(类似文章标签的用法)
# 编程技术
  • 单例对象(游戏状态管理、摄像机)

  • 枚举类型(游戏状态)

    // 游戏状态管理
    public enum GameMode
    {
      idle,
      playing,
      levelEnd
    }
    
    1
    2
    3
    4
    5
    6
    7

# 问题

# 1. 对于 line render 还不是很熟悉,后续可以实现一个鼠标点击画线的效果。两点确定一条直线。(GIS 应该用到)

https://gameinstitute.qq.com/community/detail/126849

# 2. 摄像机 Projection Orthographic 的时候,size 为什么可以影响被摄物体的在视窗的显示大小?

对于正投影摄像机,物体与摄像机的距离不会影响它的大小,但是改变 size 就可以?为什么呢?

Size 当摄像机设成正交投影时,摄像机对应的那个长方体的大小,也就是被渲染的方块区域。渲染区域变大了,那么原来的物体在总体渲染区域的比例下变小了。

参考资料:

其中,O 点称为摄像机光心,x 轴和 y 轴与图像的 X 轴、Y 轴平行,z 轴为摄像机光轴,他与图像平面垂直。光轴与图像平面的焦点即为图像坐标系的原点,由点 O 与 x、y、z 轴构成的直角坐标系称为摄像机坐标系。OO1 为摄像机焦距。

# 3. Rigidbody 中的 isKinematic 作用是什么?

功能区别:

Is Kinematic 是否为 Kinematic 刚体,如果启用该参数,则对象不会被物理所控制,只能通过直接设置位置、旋转和缩放来操作它,一般用来实现移动平台,或者带有 HingeJoint 的动画刚体

当 Rigidbody 为运动学刚体(即 isKinematic == true)时,对象的运动不会自动遵循物理原理,但仍然属于物理模拟的构成部分(即刚体的运动不会收到碰撞和重力的影响,但仍然会影响其他非运动学刚体的运动)。

举例说明:如图 10-19 所示,A 和 B 为两个刚体物体,A 在 B 的正上方,开始时 A 和 B 的重力感应都被关闭,都处于静止状态,且接受动力学模拟即 isKinematic 为 false。现在开启 A 的重力感应,则 A 从 1 处开始加速下落,当下落到 2 处时,关闭 A 的重力感应,但 isKinematic 依然为 false(即接受动力学模拟),则 A 将以当前速度匀速下落。但是此时若关闭物理感应,即 isKinematic=true,则 A 将立即停止移动。当 A 与 B 发生碰撞时,若 B 的重力感应依然关闭,但接受动力学模拟,即 isKinematic=false,则根据动量守恒 B 将产生一个向下的速度。但是若关闭 B 物体的动力学模拟,即 isKinematic=true,则 B 保持静止,不会因受到 A 的碰撞而下落。

在 Unity 中在刚体不与其他物体接触的情况下 velocity 的值只与 Gravity、drag 及 Kinematic 有关,与质量 mass 及物体的 Scale 值无关。 isKinematic 为 true 时,velocity 将不起作用。

 if (Input.GetMouseButtonUp(0))
    {
      // 如果已经公开鼠标
      aimingMode = false;
      projectile.GetComponent<Rigidbody>().isKinematic = false;
      projectile.GetComponent<Rigidbody>().velocity = -mouseDelta * velocityMult;
      FollowCam.S.poi = projectile; // 同步摄像机兴趣点
      projectile = null;
      MissionDemolition.ShotFired(); // 增加发射次数
    }
1
2
3
4
5
6
7
8
9
10
# 4. Unity 中标签与图层的区别?

通过图层可以设置哪些图层的游戏对象可以发生碰撞,然后在碰撞时通过标签找到相关的游戏对象,进行逻辑的处理。

private void OnTriggerEnter(Collider other)
  {
    // 当其他物体撞到触发器时
    // 检查是否是弹丸
    if (other.gameObject.tag == "Projectile")
    {
      // 如果是弹丸
      Goal.goalMet = true;
      // 同时将颜色的不透明度设置得更高
      Renderer renderer = this.gameObject.GetComponent<Renderer>();
      Color c = renderer.material.color;
      c.a = 1;
      c.r = 255;
      renderer.material.color = c;
    }
  }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

# 5. collider 的作用以及 isTrigger 的属性用法是什么?

OnMouseEnter 和 OnMouseExit 需要添加 collider 组件,并且 isTrigger = false。为什么呢,这里涉及到触发器和碰撞器。

1.系统默认会给每个对象 (GameObject) 添加一个碰撞组件 (ColliderComponent),一些背景对象则可以取消该组件。

2.在 unity3d 中,能检测碰撞发生的方式有两种,一种是利用碰撞器,另一种则是利用触发器。这两种方式的应用非常广泛。为了完整的了解这两种方式,我们必须理解以下概念:

(一)碰撞器是一群组件,它包含了很多种类,比如:Box ColliderCapsule Collider 等,这些碰撞器应用的场合不同,但都必须加到 GameObjecet 身上。 (二)所谓触发器,只需要在检视面板中的碰撞器组件中勾选 IsTrigger 属性选择框。 (三)在Unity3d中,主要有以下接口函数来处理这两种碰撞检测:

  • 触发信息检测:
  1. MonoBehaviour.OnTriggerEnter( Collider other )当进入触发器
  2. MonoBehaviour.OnTriggerExit( Collider other )当退出触发器
  3. MonoBehaviour.OnTriggerStay( Collider other )当逗留触发器
  • -碰撞信息检测:
  1. MonoBehaviour.OnCollisionEnter( Collision collisionInfo ) 当进入碰撞器
  2. MonoBehaviour.OnCollisionExit( Collision collisionInfo ) 当退出碰撞器
  3. MonoBehaviour.OnCollisionStay( Collision collisionInfo )  当逗留碰撞器

在目前掌握的情况分析,在Unity中参与碰撞的物体分2大块:1.发起碰撞的物体。2.接收碰撞的物体。

1. 发起碰撞物体有Rigodbody , CharacterController .

2. 接收碰撞物体由:所有的 Collider .

工作的原理为:发生碰撞的物体中必须要有“发起碰撞”的物体。否则,碰撞不响应。

比如:墙用 BoxCollider ,所以墙与墙之间无反应。

比如:一个带有 Rigidbody 属性的箱子,能落到带有 MeshCollider 属性的地面上。

比如:一个带有 Rigidbody属性的箱子,可以被一个带有 CharacterController 属性的人推着跑。

就是此原因。

在所有 Collider上有一个 Is Triggerboolean 型参数。

当发生碰撞反应的时候,会先检查此属性。

当激活此选项时,会调用碰撞双方的脚本 OnTrigger***, 反之,脚本方面没有任何反应。

当激活此选项时,不会发生后续物理的反应。反之,发生后续的物理反应。

总结:Is Trigger 好比是一个物理功能的开关, 是要“物理功能”还是要“OnTrigger脚本”。

OnTriggerEnter 触发条件:

  • 碰撞双方都必须是碰撞体
  • 碰撞双方其中一个碰撞体必须勾选 IsTigger 选项
  • 碰撞双方其中一个必须是刚体
  • 刚体的 IsKinematic 选项可以勾选也可以不勾选

只要满足上面两个条件,不管谁主动都会触发

备注:

  • OnTriggerEnter 方法的形参对象指的是碰撞双方中没有携带 OnTriggerEnter 方法的一方
  • OnTriggerEnter 方法前可以带上 publicprivate,或者干脆两个都不带

参考资料:

# 游戏原型 3:《太空射击》

在本章中,你将使用几种编程技术创建自己的射击游戏,这些技术包括类继承枚举类型(enum)静态字段和方法以及单例模式,在你的编程和原型制作生涯中,这些技术会派上用场。

# 准备工作

# 导入 Unity 资源包

当你制作游戏原型时,它的玩法和体验要比外观重要,但我们要理解游戏如何运作,仍然需要用到绘图资源,比如一些材质(可以通过 Photoshop 制作)和着色器。

着色器是让计算机知道如何在游戏对象上渲染材质的程序,可以让场景看起来更有真实感或卡通感,或者产生其他感觉,着色器是现代游戏图形的一个重要部分。Unity 使用自己独有的着色器语言 ShaderLab。

# 设置场景

因为游戏是垂直方向从下向上射击,所以我们需要为游戏面板设置一个纵向的宽高比。单击游戏面板上位于选项卡下方的宽高比弹出菜单,单击菜单项列表最下方的加号(+)图标。将游戏面板宽高比设置为新增加的 Portrait(3:4)。

# 创建主角飞船

在本章中,我们会一边创建图形一边写代码,而不是提前创建出所有的图形。这个思路也算是敏捷开发,按模块一步步创建。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Hero : MonoBehaviour
{
  static public Hero S; // 单例对象
  public float gameRestartDelay = 2f;

  // 以下字段用来控制飞船的运动
  public float speed = 30;
  public float rollMult = -45;
  public float pitchMult = 30;

  public bool ______________________;
 
  private void Awake()
  {
    S = this; // 设置单例对象
  }

  // Update is called once per frame
  void Update()
  {
    // 从 Input(用户输入)类中获取信息,读取水平和竖直轴
    float xAxis = Input.GetAxis("Horizontal");
    float yAxis = Input.GetAxis("Vertical");
    // 基于获取的水平轴和竖直轴信息修改 transform.position
    Vector3 pos = transform.position;
    pos.x += xAxis * speed * Time.deltaTime;
    pos.y += yAxis * speed * Time.deltaTime;
    transform.position = pos;

    // 让飞船旋转一个角度,使它更具动感
    transform.rotation = Quaternion.Euler(yAxis * pitchMult, xAxis * rollMult, 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

查看 InputManager 信息

# 主角飞船的护盾

_Hero 的护盾由透明度、带贴图的正方形(产生图像)和球状碰撞器(用于处理碰撞)组合而成。

新建一个矩形(执行 GameObject > 3D Object > Quad 命令),将其命名为 Shield 并设置为 _Hero 的子对象。

在层级面板中选中 Shield,你会在检视面板中看到 Mat_Shield 组件。将 Mat_Shield 的 Shader 组件设置为 Custom > UnlitAlpha。在 Mat_Shield 下方有一块区域可以选择材质的主色纹理。单击选择右下角的纹理区域,并选中名为 Shields 的纹理。单击颜色选取块,选择一种浅绿色,然后将 Tiling.x 的设置为 0.2,Offset.x 设置为 0.4,前者使 Mat_Shield 在水平方向上只使用 Shield 纹理的 1/5,后者则指定是哪 1/5.

Tiling.y 应保持 1.0 不变,Offset.y 应保持 0 不变。这是因为纹理在水平方向上分为五部分,但竖直方向上只有一部分。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Shield : MonoBehaviour
{
  public float rotationsPerSecond = 0.1f;
  public bool __________________________;
  public int levelShown = 0;

  // Start is called before the first frame update
  void Start()
  {

  }

  // Update is called once per frame
  void Update()
  {
    // 读取 Hero 单例对象的当前护盾等级,向下取整确保 shield 纹理的水平偏移量为单幅纹理宽度的倍数,而不会偏移到两幅纹理图像之间
    int currLevel = Mathf.FloorToInt(Hero.S.shieldLevel); 
    // 通过
    // 如果当前护盾等级与显示的等级不符......
    if (levelShown != currLevel)
    {
      levelShown = currLevel;
      Renderer renderer = this.gameObject.GetComponent<Renderer>();
      Material mat = renderer.material;
      // 则调整纹理偏移量,呈现正确的护盾画面
      mat.mainTextureOffset = new Vector2(0.2f * levelShown, 0);
    }

    // 每秒钟将护盾旋转到一定角度
    float rZ = (rotationsPerSecond * Time.time * 360) % 360f; 
    transform.rotation = Quaternion.Euler(0, 0, rZ);
  }
}
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
# 将 _Hero 限制在屏幕内

边界框查看:(Bounds)边界框

# 添加敌机

# 随机生成敌机

# 设置标签、图层和物理规则

# 射击

# 可序列化的 Weapon Definition 类
# 用于 WeaponDefinition 的字典
# 使用函数委托进行射击
  • 统一的武器初始化定义:Main.cs
  • Hero.cs 声明武器 weapons 和函数委托 fireDelegate
    • 调用 Weapon.SetType 激活某一个 weapon 类型
  • Weapon.cs 根据激活的 weapon 类型,从 Main.cs 中获取该武器类型的定义,从而在 fire() 函数中进行 makeProjectile() 的对应处理。

最后在玩家按发射键时,调用 fireDelegate(),依次调用 fire() 函数,发出子弹。

可以通过调试,查看调用堆栈来理解这个过程。

# 解决代码中的竞态条件

各个脚本的生命周期函数的执行顺序会影响调用,这里跟 Web 开发应用框架 Vue 的组件生命周期函数很类似。

# 让敌机可以掉落道具

# 为其他敌机编程

# 添加粒子效果和背景

# 小结

# 各个游戏对象的动作组合以及状态调用
  • Main Camera()
  • _Hero(Hero.cs)
    • Shield(Shield.cs)
    • Cube
    • Cockpit
    • Weapon_0(Weapon.cs)
    • Weapon_1
    • Weapon_2
    • Weapon_3
    • Weapon_4
  • Enemy(Enemy)

分析游戏对象,可以更明白对象之间的调用

  • 动作组合
  • 状态
  • 生命周期

英雄的不同装备,不同的脚本逻辑。不同类型的敌人,绑定了不同的脚本。

# 问题1:使用泛型与直接传参给函数,两者的使用区别?

首先就是不需要固定参数的类型,也不需要在函数内部进行判断。例如栈,栈的数据类型可以是任何类型,也就是泛型。

# 问题2:枚举类型作为 Type 使用,有什么不同?
# 问题3:单一职责的应用:weapon 类型、projectile 类型、main 类里面的 weaponDefinition 数据共用?
# 问题4:如何知道同一场景下,不同游戏对象的 Awake() 和 Start() 等执行的顺序?

举例子,A 对象的 Awake() 方法是否一定比 B 对象的 Start() 方法要早执行?还有就是派生类与父类的生命周期执行顺序,这个跟前端组件很类似。

# 游戏原型 4:《矿工接龙》

# 游戏原型 5:《Bartok》

# 游戏原型 6:《Word Game》

# 游戏原型 7:QuickSnap

# 游戏原型 8:Omega Mage 原型

# 附录

# 附录 B 实用概念

# 函数授权

函数授权简单理解为

第一步:定义授权类型

public delegate float FloatOperationDelegate(float f0, float f1)
1

上一行创建了一个 FloatOperationDelegate 授权定义,需要两个 float 作为输入。

public class DelegateExample: MonoBehaviour {
  // 1. 
  public delegate float FloatOperationDelegate(float f0, float f1);

  public float FloatAdd(float f0, float f1) { ... }
  public float FloatMultiply(float f0, float f1) { ... }

  // 类型声明一个 "fod" 变量
  public FloatOperationDelegate fod; // 授权变量

  void Awake() {
    // 为 fod 分配 FloatAdd() 方法
    fod = FloatAdd;

    // 当 fod 作为方法时调用它;fod 接着调用 FloatAdd(
      fod(2, 3);
    
    // 授权也可以多播,意味着多个目标方法可以分配给授权
    fod += FloatMultiply;
    )
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

# 插值

插值是指两个值之间的任何数学结合。图形代码中元素的移动看起来平滑且饱满。通过使用各种形式的插值和贝塞尔曲线可实现。

# 线性插值

线性插值是一种数学方法,通过规定存在于两个现有值之间来定义一个新的值或位置。所有的线性插值遵循相同的公式

p01 = (1-u) * p0 + u * p1;
1

代码看起来如下:

Vector3 p0 = new Vector3(0, 0, 0);
Vector3 p1 = new Vector(1, 1, 0);
float u = 0.5f;
Vector3 p01 = (1 - u) * p0 + u * p1;
print(p01); // 打印:p0和 p1 之间的半点 (0.5, 0.5, 0);
1
2
3
4
5

在上面的代码中,通过在 P0 和 P1 之间插值创建一个新的点 p01。u 取值范围在 0 和 1 之间。其可以生成任何数量的维度,尽管我们在 Unity 中一般使用 Vector3s 插值。

# 基于时间的线性插值

在基于时间的线性插值中,可以保证插值将在一个指定的时间内完成,因为 u 的值是基于时间数量除以所需的总时间插值的结果。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Interpolator : MonoBehaviour
{

  public Vector3 p0 = new Vector3(0, 0, 0);
  public Vector3 p1 = new Vector3(3, 4, 5);
  public float timeDuration = 1;
  // 设置 checkToCalculate 为 true 开始移动
  public bool checkToCalculate = false;
  public bool ________________________;

  public Vector3 p01;
  public bool moving = false;
  public float timeStart;

  // Start is called before the first frame update
  void Start()
  {

  }

  // Update is called once per frame
  void Update()
  {
    if (checkToCalculate)
    {
      checkToCalculate = false;
      moving = true;
      timeStart = Time.time;
    }

    if (moving)
    {
      float u = (Time.time - timeStart) / timeDuration;
      if (u >= 1)
      {
        u = 1;
        moving = false;
      }
      // 标准线性插值函数
      p01 = ((1 - u) * p0) + u * p1;
      transform.position = p01;
    }

  }
}

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

这里因为 timeStart 固定后,Time.time 是不断增大的,所以最终 u 的值会达到 1,在这个过程中不断刷新帧,最终的效果是 p01 会逐渐移动到 p1 的位置上。当然如果 timeDuration 越大的话,那就 u 达到 1 需要的时间就越长。

# 利用 Zeno 悖论的线性插值

使用这个方法创建摄像机使它可以随意跟拍兴趣点

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class ZenosFollower : MonoBehaviour
{
  public GameObject poi; // 兴趣点
  public float u = 0.1f;

  public Vector3 p0, p1, p01;
  // Start is called before the first frame update
  void Start()
  {
  }

  // Update is called once per frame
  void Update()
  {
    // 获取 this 和 poi 的位置
    p0 = this.transform.position;
    p1 = poi.transform.position;

    // 二插值
    p01 = (1 - u) * p0 + u * p1;

    // 将 this 移动到新位置
    this.transform.position = p01;
  }
}

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
# 其他插值

几乎可以插值任何类型的数值,在 Unity 中意味着我们可以很容易实现插值,如尺度、旋转以及颜色等。插值可以实现随时间而渐变的效果。

using UnityEngine;
using System.Collections;

public class Interpolator2 : MonoBehaviour
{
  public Transform c0, c1;
  public float timeDuration = 1; // 秒为单位
  // 设置 checkToCalculate 为 true 开始移动
  public bool checkToCalculate = false;
  public bool _________________;

  public Vector3 p01;
  public Color c01;
  public Quaternion r01;
  public Vector3 s01;
  public bool moving = false;
  public float timeStart;

  // Use this for initialization
  void Start()
  {

  }

  // Update is called once per frame
  void Update()
  {
    if (checkToCalculate)
    {
      checkToCalculate = false;
      moving = true;
      timeStart = Time.time;
    }

    if (moving)
    {
      float u = (Time.time - timeStart) / timeDuration;
      if (u >= 1)
      {
        u = 1;
        moving = false;
      }

      // 标准线性插值函数
      p01 = (1 - u) * c0.position + u * c1.position;
      c01 = (1 - u) * c0.GetComponent<Renderer>().material.color +
        u * c1.GetComponent<Renderer>().material.color;
      s01 = (1 - u) * c0.localScale + u * c1.localScale;
      // 旋转的处理方法稍有不同,因为四元数有点麻烦
      r01 = Quaternion.Slerp(c0.rotation, c1.rotation, u);

      // 将上面的值赋给当前 Cube01
      transform.position = p01;
      this.gameObject.GetComponent<Renderer>().material.color = c01;
      transform.localScale = s01;
      transform.rotation = r01;
    }
  }
}

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

这段代码实现了 p01 从 c0 到 c1 的颜色、大小等渐变效果。

# 线性外插法

我们迄今为止所有插值的 u 值范围都是 0 到 1.如果让 u 超出这个范围,可以实现外插(如此命名因为不同于在两值之间内插值。)

using UnityEngine;
using System.Collections;

public class Interpolator2 : MonoBehaviour
{
  public Transform c0, c1;
  public float uMin = 0;
  public float uMax = 1;
  public float timeDuration = 1; // 秒为单位
  // 设置 checkToCalculate 为 true 开始移动
  public bool checkToCalculate = false;
  public bool _________________;

  public Vector3 p01;
  public Color c01;
  public Quaternion r01;
  public Vector3 s01;
  public bool moving = false;
  public float timeStart;

  // Use this for initialization
  void Start()
  {

  }

  // Update is called once per frame
  void Update()
  {
    if (checkToCalculate)
    {
      checkToCalculate = false;
      moving = true;
      timeStart = Time.time;
    }

    if (moving)
    {
      float u = (Time.time - timeStart) / timeDuration;
      if (u >= 1) // u >= 1证明已经完成了动画,timeDuration
      {
        u = 1;
        moving = false;
      }

      // 调整 u 的范围为 uMin 到 uMax
      u = (1 - u) * uMin + u * uMax; // 线性内插也是这样做的。

      // 标准线性插值函数
      p01 = (1 - u) * c0.position + u * c1.position;
      c01 = (1 - u) * c0.GetComponent<Renderer>().material.color +
        u * c1.GetComponent<Renderer>().material.color;
      s01 = (1 - u) * c0.localScale + u * c1.localScale;
      // 旋转的处理方法稍有不同,因为四元数有点麻烦
      r01 = Quaternion.Slerp(c0.rotation, c1.rotation, u);

      // 将上面的值赋给当前 Cube01
      transform.position = p01;
      this.gameObject.GetComponent<Renderer>().material.color = c01;
      transform.localScale = s01;
      transform.rotation = r01;
    }
  }
}

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
# 缓动线性插值

利用插值,配合时间,前端也可以实现一个迷型的动画库(也就是一些缓动函数)。在 Unity 中可以自动刷新帧(Update()),只需要根据时间刷新 u 的值,需要改变的东西要放到这个 Update() 里面去了。

float u = (Time.time - timeStart) / timeDuration;

// u >= 1证明已经完成了动画,timeDuration
1
2
3

而对于 web 浏览器的话,则需要使用 setTimeOut 或者 requestAnimationFrame 来强制刷新帧。 需要推算 60 帧/秒(比较好的动画效果),从而得出一帧 16.7ms,最后根据 during 计算出完成这个动画需要的帧数,然后每刷新一次都执行 callback(value) 回调函数,去处理关联的东西的值。

下面这个算法不是根据插值来计算的,需要重新理清楚。或者重写。

const animate = {
  // t: current time, b: begInnIng value, c: change In value, d: duration
  animateType: {
    // 匀速 // TODO 重构
    linear(t, b, c, d) {
      return (c * t) / d + b;
    },
    // 先慢后快
    easeInQuad(t, b, c, d) {
      return c * (t /= d) * t + b;
    },
    // 先快后慢
    easeOutQuad(t, b, c, d) {
      return -c * (t /= d) * (t - 2) + b;
    }
  },
  defaultOpts: {
    from: 0,
    to: 1000,
    during: 300,
    type: "easeInQuad",
    callback() {}
  },
  // 增加动画算法
  extend(type) {
    this.animateType = Object.assign({}, this.animateType, type);
  },
  /*
    * options 配置
      {
        from: 开始值,
        to: 目标值,
        during: 持续时间,
        type: 动画函数
        callback: 回调
      }
      return <Promise>
  */
  play(options) {
    return new Promise(resolve => {
      const opts = Object.assign({}, this.defaultOpts, options);
      const { to, from, type, during, callback } = opts;
      // 计算总共的帧数
      // 1秒 = 60帧
      // 1帧 = 16.7ms
      // 根据毫秒数得出总共的帧数
      const durFps = Math.ceil(during / 16.7);
      // requestAnimationFrame的兼容处理
      if (!window.requestAnimationFrame) {
        window.requestAnimationFrame = fn => {
          setTimeout(fn, 16.7);
        };
      }
      // 动画运动实际上就是 0 ~ 动画总帧数 的过程
      let start = 0;
      // 运动
      const step = () => {
        // 当前的运动位置
        const value = this.animateType[type](start, from, to - from, durFps);
        callback(value);
        // 时间递增
        start++;
        // 如果还没有运动到位,继续
        if (start <= durFps) {
          window.requestAnimationFrame(step);
        } else {
          // 动画结束,在promise.then中执行相关操作
          resolve();
        }
      };
      // 开始执行动画
      step();
    });
  }
};

export default animate;

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
# 贝塞尔曲线
# 递归贝塞尔曲线函数

# 术语

  • MMORPG:大型多人在线角色扮演游戏(Massively Multiplayer Online Role Playing Game);

# 参考资料

Last Updated: 6/15/2020, 8:57:57 AM