开发者博客 – IT技术 尽在开发者博客

开发者博客 – 科技是第一生产力


  • 首页

  • 归档

  • 搜索

【Flutter&Flame 游戏 - 陆】暴击 Dash

发表于 2022-05-31

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第 7 天,点击查看活动详情


前言

这是一套 张风捷特烈 出品的 Flutter&Flame 系列教程,发布于掘金社区。如果你在其他平台看到本文,可以根据对于链接移步到掘金中查看。因为文章可能会更新、修正,一切以掘金文章版本为准。本系列文章一览:

  • 【Flutter&Flame 游戏 - 壹】开启新世界的大门
  • 【Flutter&Flame 游戏 - 贰】操纵杆与角色移动
  • 【Flutter&Flame 游戏 - 叁】键盘事件与手势操作
  • 【Flutter&Flame 游戏 - 肆】精灵图片加载方式
  • 【Flutter&Flame 游戏 - 伍】Canvas 参上 | 角色的血条
  • 【Flutter&Flame 游戏 - 陆】暴击 Dash | 文字构件的使用
  • 【Flutter&Flame 游戏 - 柒】人随指动 | 动画点触与移动
  • 【Flutter&Flame 游戏 - 捌】装弹完毕 | 角色武器发射
  • 【Flutter&Flame 游戏 - 玖】探索构件 | Component 是什么
  • 【Flutter&Flame 游戏 - 拾】探索构件 | Component 生命周期回调
  • 【Flutter&Flame 游戏 - 拾壹】探索构件 | Component 使用细节
  • 【Flutter&Flame 游戏 - 拾贰】探索构件 | 角色管理
  • 【Flutter&Flame 游戏 - 拾叁】碰撞检测 | CollisionCallbacks
  • 【Flutter&Flame 游戏 - 拾肆】碰撞检测 | 之前代码优化
  • 【Flutter&Flame 游戏 - 拾伍】粒子系统 | ParticleSystemComponent
  • 【Flutter&Flame 游戏 - 拾陆】粒子系统 | 粒子的种类
  • 【Flutter&Flame 游戏 - 拾柒】构件特效 | 了解 Effect 体系
  • 【Flutter&Flame 游戏 - 拾捌】构件特效 | ComponentEffect 一族
  • 【Flutter&Flame 游戏 - 拾玖】构件特效 | 了解 EffectController 体系
  • 【Flutter&Flame 游戏 - 贰拾】构件特效 | 其他 EffectControler
  • 【Flutter&Flame 游戏 - 贰壹】视差组件 | ParallaxComponent
  • 【Flutter&Flame 游戏 - 贰贰】菜单、字体和浮层
  • 【Flutter&Flame 游戏 - 贰叁】 资源管理与国际化
  • 【Flutter&Flame 游戏 - 贰肆】pinball 源码分析 - 项目结构介绍
  • 【Flutter&Flame 游戏 - 贰伍】pinball 源码分析 - 资源加载与 Loading
  • 【Flutter&Flame 游戏 - 贰陆】pinball 源码分析 - 游戏主菜单界面
  • 【Flutter&Flame 游戏 - 贰柒】pinball 源码分析 - 角色选择与玩法面板
  • 【Flutter&Flame 游戏 - 贰捌】pinball 源码分析 - 游戏主场景的构成
  • 【Flutter&Flame 游戏 - 贰玖】pinball 源码分析 - 视口与相机

第一季完结,谢谢支持 ~


1. 文字的价值

无论是应用也好、游戏也好,文字 和 图片 是永恒的显示主体。人类文明是依赖于文字进行传承的,文字的最大价值在于传递信息。如下是阴阳师的战斗截图,其中角色的伤害、当前的成绩、鬼火数量、是否暴击、式神存活状态等,都依赖于文字来显示,向玩家反馈交互的信息。


这里先显示在血条上显示一下血量信息,如下所示:


2. 文字构件 - TextComponent

在 Flame 中,使用 TextComponent 构件显示文字。我们知道 Flutter 的绘制中通过 TextPainter 类可以绘制文字,其实 TextComponent 构建本质上就是对 TextPainter 的一层封装而已。提供了一个 TextPaint 类进行使用。


如下是 Liveable 中的处理,只需要创建一个 TextComponent 对象,然后使用 add 方法添加即可。另外任何构件都可以通过 add(RectangleHitbox) 来显示边框信息,方便查看所占区域。代码详见:【06/01】

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
dart复制代码final TextStyle _defaultTextStyle = const TextStyle(fontSize: 10, color: Colors.white);
late final TextComponent _text;

void initPaint({
required double lifePoint,
Color lifeColor = Colors.red,
Color outlineColor = Colors.white,
}) {
// 略...
// 添加生命值文字
_text = TextComponent(textRenderer: TextPaint(style: _defaultTextStyle));
_updateLifeText();
// 添加外框信息
_text.add(RectangleHitbox()..debugMode = true);
add(_text);
}

void _updateLifeText(){
_text.text = 'Hp ${_currentLife.toInt()}';
}

如下可见,默认情况下 TextComponent 会与父区域的左上角对齐。另外 TextComponent 也是 PositionComponent 一族的构件,我们可以对其进行平移、缩放、旋转等操作。


比如下面的 tag1 处通过指定 _text 的 position 进行定位,左侧和血条对齐,并在血条上方:

1
2
3
4
5
6
7
dart复制代码// 添加生命值文字
_text = TextComponent(textRenderer: TextPaint(style: _defaultTextStyle));
_updateLifeText();
double y = -(offsetY+_text.height+2);
double x = (size.x/2)*(1-widthRadio);
_text.position = Vector2(x, y); // tag1
add(_text);

去掉信息框后如下所示,在点击时减少生命,它是通过 _updateLifeText 更新文字显示即可:


3.显示伤害数据

在怪物受到攻击时,一般会显示造成伤害的数据,来让操作者有更直观的体验。现在期望在当怪兽受伤时,左侧显示伤害量,另外伤害量维持 1s 之后自动消失。如下所示:代码见 【06/02】

伤害数据是在 Liveable 中维护的,虽然可以直接在 Liveable 中添加文字。但这样的话会使得 Liveable 的职能过于复杂,也不利于后续的拓展。我们可以单独定义一个 DamageText 构件,来维护伤害数值的显示逻辑。

如下代码所示,在 Liveable 中添加一个 addDamage 的方法,在 tag1 处添加 damageText 文字。然后使用 Future.delayed 方法,延迟 1s 中,调用 damageText.removeFromParent 方法即可移除。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
dart复制代码class DamageText extends PositionComponent{

final TextStyle _damageTextStyle = const TextStyle(
fontSize: 14,
color: Colors.white,
fontFamily: 'Menlo',
shadows: [
Shadow(color: Colors.red, offset: Offset(1, 1), blurRadius: 1),
]);

Future<void> addDamage(int damage) async {
TextComponent damageText =
TextComponent(textRenderer: TextPaint(style: _damageTextStyle));
damageText.text = damage.toString();
damageText.position = Vector2(-30, 0);
add(damageText); // tag1
await Future.delayed(const Duration(seconds: 1));
damageText.removeFromParent();
}

}

这样在 Liveable 中就不必处理具体添加伤害文字的逻辑,只需要通过 DamageText 来管理即可。比如在 loss 方法中,当角色受到伤害,通过 _damageText.addDamage 来添加伤害文字,这样处理就非常方便。想要对伤害文字进行显示进行修改或拓展,直接在 DamageText 处理即可,这就是职责的分离。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
dart复制代码final DamageText _damageText = DamageText();

void initPaint({
required double lifePoint,
Color lifeColor = Colors.red,
Color outlineColor = Colors.white,
}) {
// 略...
add(_damageText);
}

void loss(double point) {
_damageText.addDamage(-point.toInt());
// 略...
}

4. 暴击伤害

这里来模拟一下产生暴击的情况:如下图所示,伤害时有一定概率产生暴击,此时使用另一种文字样式。并给出 暴击 的字样提示:代码见 【06/03】

实现也比较简单,在 addDamage 中,传入 isCrit 的入参,区分是否暴击。如果是暴击,使用 _addCritDamage 进行处理,添加黄色伤害和暴击字样即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
dart复制代码---->[DamageText]----
void addDamage(int damage,{bool isCrit = false}) {
if(!isCrit){
_addWhiteDamage(damage);
}else{
_addCritDamage(damage);
}
}

Future<void> _addCritDamage(int damage) async {
TextComponent damageText =
TextComponent(textRenderer: TextPaint(style: _critDamageTextStyle));
damageText.text = damage.toString();
damageText.position = Vector2(-30, 0);
TextStyle style = _critDamageTextStyle.copyWith(fontSize: 10);
TextComponent infoText = TextComponent(textRenderer: TextPaint(style:style ));
infoText.text = '暴击';
infoText.position = Vector2(-30+damageText.width-infoText.width/2, -infoText.height/2);
add(infoText);
add(damageText);
await Future.delayed(const Duration(seconds: 1));
infoText.removeFromParent();
damageText.removeFromParent();
}

暴击和爆伤,本应是角色的属性,这里暂时不搞这么复杂,在 Liveable 的 loss 方法中,用 75% 暴击和 165% 爆伤进行简单的测试,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
dart复制代码---->[Liveable]----
final Random _random = Random();

void loss(double point) {
double crit = 0.75;
double critDamage = 1.65;
bool isCrit = _random.nextDouble() < crit;
if (isCrit) {
point = point * critDamage;
}
_damageText.addDamage(-point.toInt(),isCrit: isCrit);
// 略...
}

5.多次伤害

伤害是在 1s 后消失,当连续伤害在一秒之内,或者在一次伤害中附加多段伤害,就会产生遮挡。所以需要对多次伤害进行一下偏移处理,效果如下:代码见 【06/04】


这里利用了 Component 的一个特性,每个 Component 都有 children 属性,表示子构件集合。在 addDamage 中,只需要根据集合情况,获取最后一个元素,来确定添加文字的偏移即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
dart复制代码void addDamage(int damage,{bool isCrit = false}) {
Vector2 offset = Vector2(-30, 0);
if(children.isNotEmpty){
final PositionComponent last;
if(children.last is PositionComponent){
last = children.last as PositionComponent;
offset = last.position + Vector2(5, last.height);
}
}
if(!isCrit){
_addWhiteDamage(damage,offset);
}else{
_addCritDamage(damage,offset);
}
}

文字本身比较简单,但与文字相关的数据维护和逻辑处理还是非常复杂的。本文通过显示角色的生命值和伤害值 ,简单说明了一下文字的使用方式。一般游戏中,都是使用图片作为文字,比如阴阳师的伤害数字。记得应该有图片形成字体的工具,比如 6000 的字符串,会对应字体中相关的图片。不过感觉 flame 框架太简单了,应该没有支持。

那本文就介绍到这里,明天见 ~


  • @张风捷特烈 2022.05.31 未允禁转
  • 我的 公众号: 编程之王
  • 我的 掘金主页 : 张风捷特烈
  • 我的 B站主页 : 张风捷特烈
  • 我的 github 主页 : toly1994328

\

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

沉思录 揭秘 Compose 原理:图解 Composa

发表于 2022-05-30

往期文章:

《00. Kotlin Jetpack 实战:开篇》

《09. 图解协程原理》

《10. 沉思录:开篇》

你好,我是朱涛。这是「沉思录」的第二篇文章。

今天我们简单聊聊 Compose 的底层原理。

ThinkKotlin01-2.gif

今年的Google I/O大会上,Android官方针对Jetpack Compose给出了一系列的性能优化建议,文档和视频都已经放出来了。总的来说,官方的内容都非常棒,看完以后我也有些意犹未尽。推荐你去看看。

不过,在聊「性能优化」之前,我们首先要懂「亿点点」Compose的底层原理。

一、Composable 的本质

我们都知道,Jetpack Compose最神奇的地方就是:可以用 Kotlin 写UI界面(无需XML)。而且,借助Kotlin的高阶函数特性,Compose UI界面的写法也非常的直观。

1
2
3
4
5
6
7
8
9
kotlin复制代码// 代码段1

@Composable
fun Greeting() { // 1
Column { // 2
Text(text = "Hello")
Text(text = "Jetpack Compose!")
}
}

上面这段代码,即使你没有任何Compose基础,应该也可以轻松理解。Column相当于Android当中纵向的线性布局LinearLayout,在这个布局当中,我们放了两个Text控件。

最终的UI界面展示,如下图所示。

例子虽然简单,但是上面的代码中,还是有两个细节需要我们注意,我已经用注释标记出来了:

注释1,Greeting()它是一个Kotlin的函数,如果抛开它的@Composable注解不谈的话,那么,它的函数类型应该是() -> Unit。但是,由于@Composable是一个非常特殊的注解,Compose的编译器插件会把它当作影响函数类型的因子之一。所以,Greeting()它的函数类型应该是@Composable () -> Unit。(顺便提一句,另外两个常见的函数类型影响因子是:suspend、函数类型的接收者。)

注释2:Column {},请留意它的{},我们之所以可以这样写代码,这其实是Kotlin提供的高阶函数简写。它完整的写法应该是这样的:

1
2
3
4
5
6
7
8
kotlin复制代码// 代码段2

Column(content = {
log(2)
Text(text = "Hello")
log(3)
Text(text = "Jetpack Compose!")
})

由此可见,Compose的语法,其实就是通过Kotlin的高阶函数实现的。Column()、Text()看起来像是在调用UI控件的构造函数,但它实际上只是一个普通的顶层函数,所以说,这只是一种DSL的“障眼法”而已。

备注:如果你想研究如何用Kotlin编写DSL,可以去看看我公众号的历史文章。

那么,到这里,我们其实可以做出一个阶段性的总结了:Composable的本质,是函数。这个结论看似简单,但它却可以为后面的原理研究打下基础。

接下来,我们来聊聊Composable的特质。

二、Composable 的特质

前面我们已经说过了,Composable本质上就是函数。那么,它的特质,其实跟普通的函数也是非常接近的。这个话看起来像是废话,让我来举个例子吧。

基于前面的代码,我们增加一些log:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
kotlin复制代码// 代码段3

@Composable
fun Greeting() {
log(1)
Column {
log(2)
Text(text = "Hello")
log(3)
Text(text = "Jetpack Compose!")
}
log(4)
}

private fun log(any: Any) {
Log.d("MainActivity", any.toString())
}

请问,上面代码的输出结果是怎样的呢?如果你看过我的协程教程,那么心里肯定会有点“虚”,对吧?不过,上面这段代码的输出结果是非常符合直觉的。

1
2
3
4
5
6
7
8
yaml复制代码// 输出结果
// 注意:当前Compose版本为1.2.0-beta
// 在未来的版本当中,Compose底层是可能做出优化,并且改变这种行为模式的。

com.boycoder.testcompose D/MainActivity: 1
com.boycoder.testcompose D/MainActivity: 2
com.boycoder.testcompose D/MainActivity: 3
com.boycoder.testcompose D/MainActivity: 4

你看,Composable不仅从源码的角度上看是个普通的函数,它在运行时的行为模式,跟普通的函数也是类似的。我们写出来的Composable函数,它们互相嵌套,最终会形成一个树状结构,准确来说是一个N叉树。而Composable函数的执行顺序,其实就是对一个N叉树的DFS遍历。

这样一来,我们写出来的Compose UI就几乎是:“所见即所得”。

也许,你会觉得,上面这个例子,也不算什么,毕竟,XML也可以做到类似的事情。那么,让我们来看另外一个例子吧。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
kotlin复制代码// 代码段4

@Composable
fun Greeting() {
log("start")
Column {
repeat(4) {
log("repeat $it")
Text(text = "Hello $it")
}
}
log("end")
}

// 输出结果:
com.boycoder.testcompose D/MainActivity: start
com.boycoder.testcompose D/MainActivity: repeat 0
com.boycoder.testcompose D/MainActivity: repeat 1
com.boycoder.testcompose D/MainActivity: repeat 2
com.boycoder.testcompose D/MainActivity: repeat 3
com.boycoder.testcompose D/MainActivity: end

我们使用repeat{}重复调用了4次Text(),我们就成功在屏幕上创建了4个Text控件,最关键的是,它们还可以在Column{}当中正常纵向排列。这样的代码模式,在从前的XML时代是不可想象的。

话说回来,正是因为Composable的本质就是函数,它才会具备普通函数的一些特质,从而,也让我们可以像写普通代码一样,用逻辑语句来描述UI布局。

好了,现在我们已经知道了Composable的本质是函数,可是,我们手机屏幕上的那些UI控件是怎么出现的呢?接下来,我们需要再学「一点点」Compose编译器插件的知识。PS:这回,我保证真的是「一点点」。

三、Compose 编译器插件

虽然Compose Compiler Plugin看起来像是一个非常高大上的东西,但从宏观概念上来看的话,它所做的事情还是很简单的。

如果你看过我的博客《图解协程原理》的话,你一定会知道,协程的suspend关键字,它可以改变函数的类型,Compose的注解@Composable也是类似的。总的来说,它们之间的对应关系是这样的:

具体来说,我们在Kotlin当中写的Composable函数、挂起函数,在经过编译器转换以后,都会被额外注入参数。对于挂起函数来说,它的参数列表会多出一个Continuation类型的参数;对于Composable函数,它的参数列表会多出一个Composer类型的参数。

为什么普通函数无法调用「挂起函数」和「Composable函数」,底层的原因就是:普通函数根本无法传入Continuation、Composer作为调用的参数。

注意:需要特殊说明的是,在许多场景下,Composable函数经过Compose Compiler Plugin转换后,其实还可能增加其他的参数。更加复杂的情况,我们留到后续的文章里再分析。

另外,由于Compose并不是属于Kotlin的范畴,为了实现Composable函数的转换,Compose团队是通过「Kotlin编译器插件」的形式来实现的。我们写出的Kotlin代码首先会被转换成IR,而Compose Compiler Plugin则是在这个阶段直接改变了它的结构,从而改变了最终输出的Java字节码以及Dex。这个过程,也就是我在文章开头放那张动图所描述的行为。

动图我就不重复贴了,下面是一张静态的流程图。

不过,Compose Compiler 不仅仅只是改变「函数签名」那么简单,如果你将Composable函数反编译成Java代码,你就会发现它的函数体也会发生改变。

让我们来看一个具体的例子,去发掘Compose的「重组」(Recompose)的实现原理。

四、Recompose 的原理

1
2
3
4
5
6
7
8
9
10
kotlin复制代码// 代码段5

class MainActivity : ComponentActivity() {
// 省略

@Composable
fun Greeting(msg: String) {
Text(text = "Hello $msg!")
}
}

上面的代码很简单,Greeting()的逻辑十分简单,不过当它被反编译成Java后,它实际的逻辑会变复杂许多。

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
kotlin复制代码// 代码段6

public static final void Greeting(final String msg, Composer $composer,
final int $changed) { // 多出来的changed我们以后分析吧

// 1,开始
// ↓
$composer = $composer.startRestartGroup(-1948405856);

int $dirty = $changed;
if (($changed & 14) == 0) {
$dirty = $changed | ($composer.changed(msg) ? 4 : 2);
}

if (($dirty & 11) == 2 && $composer.getSkipping()) {
$composer.skipToGroupEnd();
} else {
TextKt.Text-fLXpl1I(msg, $composer, 0, 0, 65534);
}

// 2,结束
// ↓
ScopeUpdateScope var10000 = $composer.endRestartGroup();

if (var10000 != null) {
var10000.updateScope((Function2)(new Function2() {
public final void invoke(@Nullable Composer $composer, int $force) {
// 3,递归调用自己
// ↓
MainActivityKt.Greeting(msg, $composer, $changed | 1);
}
}));
}
}

毫无疑问,Greeting()反编译后,之所以会变得这么复杂,背后的原因全都是因为Compose Compiler Plugin。上面这段代码里值得深挖的细节太多了,为了不偏离主题,我们暂时只关注其中的3个注释,我们一个个看。

  • 注释1,composer.startRestartGroup,这是Compose编译器插件为Composable函数插入的一个辅助代码。它的作用是在内存当中创建一个可重复的Group,它往往代表了一个Composable函数开始执行了;同时,它还会创建一个对应的ScopeUpdateScope,而这个ScopeUpdateScope则会在注释2处用到。
  • 注释2,composer.endRestartGroup(),它往往代表了一个Composable函数执行的结束。而这个Group,从一定程度上,也描述了UI的结构与层级。另外,它也会返回一个ScopeUpdateScope,而它则是触发「Recompose」的关键。具体的逻辑我们看注释3。
  • 注释3,我们往ScopeUpdateScope.updateScope()注册了一个监听,当我们的Greeting()函数需要重组的时候,就会触发这个监听,从而递归调用自身。这时候你会发现,前面提到的RestartGroup也暗含了「重组」的意味。

由此可见,Compose当中看起来特别高大上的「Recomposition」,其实就是:“重新调用一次函数”而已。

ThinkKotlin01-1.gif

那么,Greeting()到底是在什么样的情况下才会触发「重组」呢?我们来看一个更加完整的例子。

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
kotlin复制代码// 代码段7

class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MainScreen()
}
}
}

@Composable
fun MainScreen() {
log("MainScreen start")
val state = remember { mutableStateOf("Init") }
// 1
LaunchedEffect(key1 = Unit) {
delay(1000L)
state.value = "Modified"
}
Greeting(state.value)
log("MainScreen end")
}

private fun log(any: Any) {
Log.d("MainActivity", any.toString())
}

@Composable
fun Greeting(msg: String) {
log("Greeting start $msg")
Text(text = "Hello $msg!")
log("Greeting end $msg")
}

/* 输出结果
MainActivity: MainScreen start
MainActivity: Greeting start Init
MainActivity: Greeting end Init
MainActivity: MainScreen end
等待 1秒
MainActivity: MainScreen start // 重组
MainActivity: Greeting start Modified // 重组
MainActivity: Greeting end Modified // 重组
MainActivity: MainScreen end // 重组
*/

上面的代码逻辑仍然十分的简单,setContent {}调用了MainScreen();MainScreen()调用了Greeting()。唯一需要注意的,就是注释1处的LaunchedEffect{},它的作用是启动一个协程,延迟1秒,并对state进行赋值。

从代码的日志输出,我们可以看到,前面4个日志输出,是Compose初次执行触发的;后面4个日志输出,则是由state改变导致的「重组」。看起来,Compose通过某种机制,捕捉到了state状态的变化,然后通知了MainScreen()进行了重组。

如果你足够细心的话,你会发现,state实际上只在Greeting()用到了,而state的改变,却导致MainScreen()、Greeting()都发生了「重组」,MainScreen()的「重组」看起来是多余。这里其实就藏着Compose性能优化的一个关键点。

注意:类似上面的情况,Compose Compiler 其实做了足够多的优化,MainScreen()的「重组」看似是多余的,但它实际上对性能的影响并不大,我们举这个例子只是为了讲明白「重组」的原理,引出优化的思路。Compose Compiler 具体的优化思路,我们留到以后再来分析。

让我们改动一下上面的代码:

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
kotlin复制代码// 代码段8

class MainActivity : ComponentActivity() {
// 不变
}

@Composable
fun MainScreen() {
log("MainScreen start")
val state = remember { mutableStateOf("Init") }
LaunchedEffect(key1 = Unit) {
delay(1000L)
state.value = "Modified"
}
Greeting { state.value } // 1,变化在这里
log("MainScreen end")
}

private fun log(any: Any) {
Log.d("MainActivity", any.toString())
}

@Composable // 2,变化在这里 ↓
fun Greeting(msgProvider: () -> String) {
log("Greeting start ${msgProvider()}") // 3,变化
Text(text = "Hello ${msgProvider()}!") // 3,变化
log("Greeting end ${msgProvider()}") // 3,变化
}

/*
MainActivity: MainScreen start
MainActivity: Greeting start Init
MainActivity: Greeting end Init
MainActivity: MainScreen end
等待 1秒
MainActivity: Greeting start Modified // 重组
MainActivity: Greeting end Modified // 重组
*/

代码的变化我用注释标记出来了,主要的变化在:注释2,我们把原先String类型的参数改为了函数类型:() -> String。注释1、3处改动,都是跟随注释2的。

请留意代码的日志输出,这次,「重组」的范围发生了变化,MainScreen()没有发生重组!这是为什么呢?这里涉及到两个知识点:一个是Kotlin函数式编程当中的「Laziness」;另一个是Compose重组的「作用域」。我们一个个来看。

4.1 Laziness

Laziness 在函数式编程当中是个相当大的话题,要把这个概念将透的话,得写好几篇文章才行,这里我简单解释下,以后有机会我们再深入讨论。

理解 Laziness 最直观的办法,就是写一段这样对比的代码:

1
2
3
4
5
6
7
8
9
kotlin复制代码// 代码段9

fun main() {
val value = 1 + 2
val lambda: () -> Int = { 1 + 2 }
println(value)
println(lambda)
println(lambda())
}

其实,如果你对Kotlin高阶函数、Lambda理解透彻的话,你马上就能理解代码段8当中的Laziness是什么意思了。如果你对这Kotlin的这些基本概念还不熟悉,可以去看看我公众号的历史文章。

上面这段代码的输出结果如下:

1
2
3
swift复制代码3
Function0<java.lang.Integer>
3

这样的输出结果也很好理解。1 + 2是一个表达式,当我们把它用{}包裹起来以后,它就一定程度上实现了Laziness,我们访问lambda的时候并不会触发实际的计算行为。只有调用lambda()的时候,才会触发实际的计算行为。

Laziness讲清楚了,我们来看看Compose的重组「作用域」。

4.2 重组「作用域」

其实,在前面的代码段6处,我们就已经接触过它了,也就是ScopeUpdateScope。通过前面的分析,我们每个Composable函数,其实都会对应一个ScopeUpdateScope,Compiler底层就是通过注入监听,来实现「重组」的。

实际上,Compose底层还提供一个:状态快照系统(SnapShot)。Compose的快照系统底层的原理还是比较复杂的,以后有机会我们再深入探讨,更多信息你可以看看这个链接。

总的来说,SnapShot 可以监听Compose当中State的读、写行为。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
kotlin复制代码// 代码段10

@Stable
interface MutableState<T> : State<T> {
override var value: T
}

internal open class SnapshotMutableStateImpl<T>(
value: T,
override val policy: SnapshotMutationPolicy<T>
) : StateObject, SnapshotMutableState<T> {

override var value: T
get() = next.readable(this).value
set(value) = next.withCurrent {
if (!policy.equivalent(it.value, value)) {
next.overwritable(this, it) { this.value = value }
}
}
}

本质上,它其实就是通过自定义Getter、Setter来实现的。当我们定义的state变量,它的值从“Init”变为“Modified”的时候,Compose可以通过自定义的Setter捕获到这一行为,从而调用ScopeUpdateScope当中的监听,触发「重组」。

那么,代码段7、代码段8,它们之间的差异到底在哪里呢?关键其实就在于ScopeUpdateScope的不同。

这其中的关联,其实用一句话就可以总结:状态读取发生在哪个Scope,状态更新的时候,哪个Scope就发生重组。

如果你看不懂这句话也没关系,我画了一个图,描述了代码段7、代码段8之间的差异:

对于代码段7,当state的读取发生在MainScreen()的ScopeUpdateScope,那么,当state发生改变的时候,就会触发MainScreen()的Scope进行「重组」。

代码段8也是同理:

ThinkKotlin01.015.jpeg

现在,回过头来看这句话,相信你就能看懂了:状态读取发生在哪个Scope,状态更新的时候,哪个Scope就发生重组。

好,做完前面这些铺垫以后,我们就可以轻松看懂Android官方给出的其中三条性能优化建议了。

  1. Defer reads as long as possible.
  2. Use derivedStateOf to limit recompositions
  3. Avoid backwards writes

以上这3条建议,本质上都是为了尽可能避免「重组」,或者缩小「重组范围」。由于篇幅限制,我们就挑第一条来详细解释吧~

五、尽可能延迟State的读行为

其实,对于我们代码段7、代码段8这样的改变,Compose的性能提升不明显,因为Compiler底层做了足够多的优化,多一个层级的函数调用,并不会有明显差异。Android官方更加建议我们将某些状态的读写延迟到Layout、Draw阶段。

这就跟Compose整个执行、渲染流程相关了。总的来说,对于一个Compose页面来说,它会经历以下4个步骤:

  • 第一步,Composition,这其实就代表了我们的Composable函数执行的过程。
  • 第二步,Layout,这跟我们View体系的Layout类似,但总体的分发流程是存在一些差异的。
  • 第三步,Draw,也就是绘制,Compose的UI元素最终会绘制在Android的Canvas上。由此可见,Jetpack Compose虽然是全新的UI框架,但它的底层并没有脱离Android的范畴。
  • 第四步,Recomposition,重组,并且重复1、2、3步骤。

总体的过程如下图所示:

Android官方推荐我们尽可能推迟状态读取的原因,其实还是希望我们可以在某些场景下直接跳过Recomposition的阶段、甚至Layout的阶段,只影响到Draw。

而实现这一目标的手段,其实就是我们前面提到的「Laziness」思想。让我们以官方提供的代码为例:

首先,我要说明的是,Android官方文档当中的注释其实是存在一个小瑕疵的。它对新手友好,但容易对我们深入底层的人产生困扰。上面代码中描述的Recomposition Scope并不准确,它真正的Recomposition Scope,应该是整个SnackDetail(),而不是Box()。对此,我已经在Twitter与相关的Google工程师反馈了,对方也回复了我,这是“故意为之”的,因为这更容易理解。具体细节,你可以去这条Twitter看看。

好,我们回归正题,具体分析一下这个案例:

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
kotlin复制代码// 代码段11

@Composable
fun SnackDetail() {
// Recomposition Scope
// ...

Box(Modifier.fillMaxSize()) { Start
val scroll = rememberScrollState(0)
// ...
Title(snack, scroll.value) // 1,状态读取
// ...
}
// Recomposition Scope End
}

@Composable
private fun Title(snack: Snack, scroll: Int) {
// ...
val offset = with(LocalDensity.current) { scroll.toDp() }

Column(
modifier = Modifier
.offset(y = offset) // 2,状态使用
) {
// ...
}
}

上面的代码有两个注释,注释1,代表了状态的读取;注释2,代表了状态的使用。这种“状态读取与使用位置不一致”的现象,其实就为Compose提供了性能优化的空间。

那么,具体我们该如何优化呢?其实很简单,借助我们之前Laziness的思想,让:“状态读取与使用位置一致”。

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
kotlin复制代码// 代码段12

@Composable
fun SnackDetail() {
// Recomposition Scope
// ...

Box(Modifier.fillMaxSize()) { Start
val scroll = rememberScrollState(0)
// ...
Title(snack) { scroll.value } // 1,Laziness
// ...
}
// Recomposition Scope End
}

@Composable
private fun Title(snack: Snack, scrollProvider: () -> Int) {
// ...
val offset = with(LocalDensity.current) { scrollProvider().toDp() }
Column(
modifier = Modifier
.offset(y = offset) // 2,状态读取+使用
) {
// ...
}
}

请留意注释1这里的变化,由于我们将scroll.value变成了Lambda,所以,它并不会在composition期间产生状态读取行为,这样,当scroll.value发生变化的时候,就不会触发「重组」,这就是「Laziness」的意义。

代码段11、代码段12之间的差异是巨大的:

前者会在页面滑动的期间频繁触发:「重组」+「Layout」+「Draw」,后者则完全绕过了「重组」,只有「Layout」+「Draw」,由此可见,它的性能提升也是非常显著的。

六、结尾

OK,到这里,我们这篇文章就该结束了。我们来简单总结一下:

  • 第一,Composable函数的本质,其实就是函数。多个Composable函数互相嵌套以后,就自然形成了一个UI树。Composable函数执行的过程,其实就是一个DFS遍历过程。
  • 第二,@Composable修饰的函数,最终会被Compose编译器插件修改,不仅它的函数签名会发生变化,它函数体的逻辑也会有天翻地覆的改变。函数签名的变化,导致普通函数无法直接调用Composable函数;函数体的变化,是为了更好的描述Compose的UI结构,以及实现「重组」。
  • 第三,重组,本质上就是当Compose状态改变的时候,Runtime对Composable函数的重复调用。这涉及到Compose的快照系统,还有ScopeUpdateScope。
  • 第四,由于ScopeUpdateScope取决于我们对State的读取位置,因此,这就决定了我们可以使用Kotlin函数式编程当中的Laziness思想,对Compose进行「性能优化」。也就是让:状态读取与使用位置一致,尽可能缩小「重组作用域」,尽可能避免「重组」发生。
  • 第五,今年的Google I/O大会上,Android官方团队提出了:5条性能优化的最佳实践,其中3条建议的本质,都是在践行:状态读取与使用位置一致的原则。
  • 第六,我们详细分析了其中的一条建议「尽可能延迟State的读行为」。由于Compose的执行流程分为:「Composition」、「Layout」、「Draw」,通过Laziness,我们可以让Compose跳过「重组」的阶段,大大提升Compose的性能。

七、结束语

其实,Compose的原理还是相当复杂的。它除了UI层跟Android有较强的关联以外,其他的部分Compiler、Runtime、Snapshot都是可以独立于Android以外而存在的。这也是为什么JetBrains可以基于Jetpack Compose构建出Compose-jb的原因。

这篇文章是「沉思录」系列的第二篇文章,后续除了Compose以后,还会有《Kotlin Jetpack 实战》系列,敬请期待。

感谢你的阅读,别忘了点赞+评论+关注,我们下期再见!

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

【Flutter&Flame 游戏 - 伍】 Canvas

发表于 2022-05-30

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第 6 天,点击查看活动详情


前言

这是一套 张风捷特烈 出品的 Flutter&Flame 系列教程,发布于掘金社区。如果你在其他平台看到本文,可以根据对于链接移步到掘金中查看。因为文章可能会更新、修正,一切以掘金文章版本为准。本系列文章一览:

  • 【Flutter&Flame 游戏 - 壹】开启新世界的大门
  • 【Flutter&Flame 游戏 - 贰】操纵杆与角色移动
  • 【Flutter&Flame 游戏 - 叁】键盘事件与手势操作
  • 【Flutter&Flame 游戏 - 肆】精灵图片加载方式
  • 【Flutter&Flame 游戏 - 伍】Canvas 参上 | 角色的血条
  • 【Flutter&Flame 游戏 - 陆】暴击 Dash | 文字构件的使用
  • 【Flutter&Flame 游戏 - 柒】人随指动 | 动画点触与移动
  • 【Flutter&Flame游戏 - 捌】装弹完毕 | 角色武器发射
  • 【Flutter&Flame游戏 - 玖】探索构件 | Component 是什么
  • 【Flutter&Flame游戏 - 拾】探索构件 | Component 生命周期回调
  • 【Flutter&Flame游戏 - 拾壹】探索构件 | Component 使用细节
  • 【Flutter&Flame 游戏 - 拾贰】探索构件 | 角色管理
  • 【Flutter&Flame 游戏 - 拾叁】碰撞检测 | CollisionCallbacks
  • 【Flutter&Flame 游戏 - 拾肆】碰撞检测 | 之前代码优化
  • 【Flutter&Flame 游戏 - 拾伍】粒子系统 | ParticleSystemComponent
  • 【Flutter&Flame 游戏 - 拾陆】粒子系统 | 粒子的种类
  • 【Flutter&Flame 游戏 - 拾柒】构件特效 | 了解 Effect 体系
  • 【Flutter&Flame 游戏 - 拾捌】构件特效 | ComponentEffect 一族
  • 【Flutter&Flame 游戏 - 拾玖】构件特效 | 了解 EffectController 体系
  • 【Flutter&Flame 游戏 - 贰拾】构件特效 | 其他 EffectControler
  • 【Flutter&Flame 游戏 - 贰壹】视差组件 | ParallaxComponent
  • 【Flutter&Flame 游戏 - 贰贰】菜单、字体和浮层
  • 未完待续 ~

1. 他乡遇故知 - Canvas

小册 《Flutter 绘制指南 - 妙笔生花》可以说是专门为 Canvas 绘制而生的。其实游戏的本质就是不断刷新中的绘制,在 Flame 引擎中,也暴露了渲染方法,给使用者自定义绘制的机会。这就说明我们在之前累积的绘制技巧,也可以在 Flame 中得以应用。

如上图说示,render 方法定义在 Component 类中,所以说任何构件都可以覆写它,来获取 Canvas 构建。另外可以看到 Component 本身有一些生命周期方法,我们之前已经见识过了 onLoad 、update 、onGameResize 方法。关于 Component 的生命周期,现在先不着急,后面会专门写一篇来说。


2.简单画一笔

如下在 Monster 类中覆写 render 方法,通过 Canvas 对象绘制一个白圈 。可以看出这里画布的原点在构件的左上角:代码见 【05/01】

1
2
3
4
5
6
dart复制代码---->[component/Monster]----
@override
void render(Canvas canvas){
super.render(canvas);
canvas.drawCircle(Offset.zero, 10, Paint()..color=Color(0xffffffff));
}

其中注意一点:super.render 代表父类执行渲染方法。未覆写render 方法时, Monster 的绘制会默认触发父级的。如果你覆写了 render 方法,则会走 Monster 中的,如果不调用 super.render ,那么父级的绘制将不会生效,也就是说怪兽是没有被渲染的,这是面向对象的最基本知识。

另外绘制也是 后者居上 ,也就是说出现重叠时,后绘制的图案会盖住先绘制的图案,如下所示:


3. 绘制血条

既然怪兽已经出现了,血条自然不能少。如下,在 Monster 类中简单画个白框红血的条:代码见 【05/02】

下面是绘制的简单逻辑,其中主要逻辑的是计算外框和血条的两个 Rect 矩形对象。外框的白条矩形通过中心点加宽高来确定的,因为这里希望血条居中,且可以可以通过比率 widthRadio 控制长度。在白条矩形确定之后,左下角的点就能确定,此时通过两点确定矩形会比较方便。如何选用最简单的方式来确定图形信息,是绘制的一个小细节。

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
dart复制代码Paint _outlinePaint = Paint();
Paint _fillPaint = Paint();

@override
void render(Canvas canvas) {
super.render(canvas);

Color lifeColor = Colors.red; // 血条颜色
Color outlineColor = Colors.white; // 外框颜色
final double offsetY = 10; // 血条距构件顶部偏移量
final double widthRadio = 0.8; // 血条/构件宽度
final double lifeBarHeight = 4; // 血条高度
final double lifeProgress = 0.8; // 当前血条百分百

Rect rect = Rect.fromCenter(
center: Offset(size.x / 2, lifeBarHeight / 2 - offsetY),
width: size.x * widthRadio,
height: lifeBarHeight);
Rect lifeRect = Rect.fromPoints(
rect.topLeft + Offset(rect.width * (1 - lifeProgress), 0),
rect.bottomRight);

_outlinePaint
..color = outlineColor
..style = PaintingStyle.stroke
..strokeWidth = 1;
_fillPaint.color = lifeColor;
canvas.drawRect(lifeRect, _fillPaint);
canvas.drawRect(rect, _outlinePaint);
}

这里为了方便演示,只画了最简洁的血条,大家也可以发挥自己的绘画天赋,在网上找一些好看的血条画画看。


4. 代码复用的好帮手 -mixin

我们刚才只在 Monster 类中覆写的 render ,绘制血条。那主角 Adventurer 也需要要血条,笨方法是把 Monster 中的绘制拷一份到 Adventurer 中。如果一个游戏中有非常多需要需要血条的构件,这样做显然是不可行的。相同的绘制逻辑分散在各个类中,不利于维护和拓展。

反过来我们可以想一下,为什么每个构件都可以很简单地使用手势事件,答案是 mixin 。通过混入的方式可以拓展一个类的功能,所以混入一个 Liveable 来让构件显示血条,自然也不是什么难事。如下所示,是【05/03】 的案例效果 :


如下,Liveable 依赖于 PositionComponent 类,因为绘制时需要构件的尺寸。其中 initPaint 方法中,用于初始化一些配置参数用于自定义,比如血条颜色、外框颜色、生命上限等。这里只是简单演示,满足最基本的需求,你也可以提供一些其他的配置参数,或者定义一个配置信息类,简化传参流程。

在这里只要覆写 render 方法,执行刚才写的绘制逻辑即可。

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
dart复制代码---->[05/03/mixins]----
mixin Liveable on PositionComponent {
final Paint _outlinePaint = Paint();
final Paint _fillPaint = Paint();
late double lifePoint; // 生命上限
late double _currentLife; // 当前生命值

void initPaint({
required double lifePoint,
Color lifeColor = Colors.red,
Color outlineColor = Colors.white,
}) {
_outlinePaint
..color = outlineColor
..style = PaintingStyle.stroke
..strokeWidth = 1;
_fillPaint.color = lifeColor;

this.lifePoint = lifePoint;
_currentLife = lifePoint;
}

// 当前血条百分百
double get _progress => _currentLife / lifePoint;

@override
void render(Canvas canvas) {
super.render(canvas);
final double offsetY = 10; // 血条距构件顶部偏移量
final double widthRadio = 1.5; // 血条/构件宽度
final double lifeBarHeight = 4; // 血条高度

Rect rect = Rect.fromCenter(
center: Offset(size.x / 2, lifeBarHeight / 2 - offsetY),
width: size.x / 2 * widthRadio,
height: lifeBarHeight);

Rect lifeRect = Rect.fromPoints(
rect.topLeft + Offset(rect.width * (1 - _progress), 0),
rect.bottomRight);

canvas.drawRect(lifeRect, _fillPaint);
canvas.drawRect(rect, _outlinePaint);
}
}

使用起来就非常简单,只要是 PositionComponent 一族的构件,都可以混入刚才自定义的 Liveable,然后只要在 onLoad 方法中通过 initPaint 方法初始化数据即可。

这样就可以在 Adventurer 的头上加一个血条,生命值上限是 1000。

通过这里可以看出 mixin 对于独立逻辑的封装,还是非常有优势的。混入类中可以拓展 属性 和 方法 ,只要 A 混入了 B ,就说明 A 可以视为 B 的子类,即可访问 B 中的所有非私有 属性 和 方法 。

对于 mixin 的理解,是 Dart 中非常重要,也是很多新手所忽略的。在 Flutter 框架的源码中 mixin 有着非常多的使用场景。在 《Flutter 渲染机制 - 聚沙成塔》的第十二章,结合源码中的实际使用对 mixin 有详细的介绍。网上很多文章简单写两个 demo ,是很难真正理解 mixin 的价值的。


5. 血条的减少

有了血条不让它减少有点可惜了,如下案例中,通过点击事件让怪物的血量减少:代码见 【05/04】

血量是在 Liveable 类中定义的,所以也在此维护血量值。如下在 Liveable 中定义 loss 方法,对生命值进行减少。并在生命值小于 0 时,触发 onDied 方法。混入类可以覆写这个方法来监听角色的死亡。

1
2
3
4
5
6
7
8
9
10
dart复制代码---->[05/04/Liveable]
void loss(double point) {
_currentLife -= point;
if (_currentLife <= 0) {
_currentLife = 0;
onDied();
}
}
void onDied() {
}

然后在 TolyGame 中使用 TapDetector 监听点击事件,每次点击让 monster 减少 50 点生命值。游戏中让射手发出弓箭,检测命中后,让 monster 生命值减少,或通过体力药水或辅助角色恢复生命值。

1
2
3
4
5
dart复制代码class TolyGame extends FlameGame with KeyboardEvents,TapDetector {
@override
void onTap() {
monster.loss(50);
}

下面来看一下,如何在 monster 生命值为 0 时进行移除。上面在 Liveable 定义了 onDied 回调,只要在被混入类中覆写 onDied 方法即可监听到生命值为小于等于零的事件。然后执行 removeFromParent 方法即可:

1
2
3
4
5
6
dart复制代码class Monster extends SpriteAnimationComponent with Liveable {

@override
void onDied() {
removeFromParent();
}

本文通过了自定义绘制角色的血条,认识了 Component#render 回调方法,在其中可以获取 Canvas 对象,进行自定义绘制操作。另外也借此认识了一下 mixin 对于独立逻辑封装的妙处。那本文就到这里,明天见 ~

  • @张风捷特烈 2022.05.30 未允禁转
  • 我的 公众号: 编程之王
  • 我的 掘金主页 : 张风捷特烈
  • 我的 B站主页 : 张风捷特烈
  • 我的 github 主页 : toly1994328

\

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

【Flutter&Flame 游戏 - 肆】精灵图片加载方式

发表于 2022-05-29

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第 5 天,点击查看活动详情


前言

这是一套 张风捷特烈 出品的 Flutter&Flame 系列教程,发布于掘金社区。如果你在其他平台看到本文,可以根据对于链接移步到掘金中查看。因为文章可能会更新、修正,一切以掘金文章版本为准。本系列文章一览:

  • 【Flutter&Flame 游戏 - 壹】开启新世界的大门
  • 【Flutter&Flame 游戏 - 贰】操纵杆与角色移动
  • 【Flutter&Flame 游戏 - 叁】键盘事件与手势操作
  • 【Flutter&Flame 游戏 - 肆】精灵图片加载方式
  • 【Flutter&Flame 游戏 - 伍】Canvas 参上 | 角色的血条
  • 【Flutter&Flame 游戏 - 陆】暴击 Dash | 文字构件的使用
  • 【Flutter&Flame 游戏 - 柒】人随指动 | 动画点触与移动
  • 【Flutter&Flame 游戏 - 捌】装弹完毕 | 角色武器发射
  • 【Flutter&Flame 游戏 - 玖】探索构件 | Component 是什么
  • 【Flutter&Flame 游戏 - 拾】探索构件 | Component 生命周期回调
  • 【Flutter&Flame 游戏 - 拾壹】探索构件 | Component 使用细节
  • 【Flutter&Flame 游戏 - 拾贰】探索构件 | 角色管理
  • 【Flutter&Flame 游戏 - 拾叁】碰撞检测 | CollisionCallbacks
  • 【Flutter&Flame 游戏 - 拾肆】碰撞检测 | 之前代码优化
  • 【Flutter&Flame 游戏 - 拾伍】粒子系统 | ParticleSystemComponent
  • 【Flutter&Flame 游戏 - 拾陆】粒子系统 | 粒子的种类
  • 【Flutter&Flame 游戏 - 拾柒】构件特效 | 了解 Effect 体系
  • 【Flutter&Flame 游戏 - 拾捌】构件特效 | ComponentEffect 一族
  • 【Flutter&Flame 游戏 - 拾玖】构件特效 | 了解 EffectController 体系
  • 【Flutter&Flame 游戏 - 贰拾】构件特效 | 其他 EffectControler
  • 【Flutter&Flame 游戏 - 贰壹】视差组件 | ParallaxComponent
  • 【Flutter&Flame 游戏 - 贰贰】菜单、字体和浮层
  • 【Flutter&Flame 游戏 - 贰叁】 资源管理与国际化
  • 【Flutter&Flame 游戏 - 贰肆】pinball 源码分析 - 项目结构介绍
  • 【Flutter&Flame 游戏 - 贰伍】pinball 源码分析 - 资源加载与 Loading
  • 【Flutter&Flame 游戏 - 贰陆】pinball 源码分析 - 游戏主菜单界面
  • 【Flutter&Flame 游戏 - 贰柒】pinball 源码分析 - 角色选择与玩法面板
  • 【Flutter&Flame 游戏 - 贰捌】pinball 源码分析 - 游戏主场景的构成
  • 【Flutter&Flame 游戏 - 贰玖】pinball 源码分析 - 视口与相机

第一季完结,谢谢支持 ~


1. 什么是精灵图

我们前面用的角色动画帧有九张,就表示需要加载九次图片资源。对于动画帧来说,每帧的尺寸一般都是一样的,可以将它们拼接在一张图片中,如下图所示:图片取自于 【pinball】开源项目。

这在前端开发中比较常见,因为每个小图片都需要发一次请求,将小图片拼在一起,可以减少请求的次数。在游戏开发者也是如此,将小图片拼合在一起可以有效减少加载的次数。


2. 如何从精灵图中获取图片

Flame 中通过 SpriteSheet 类对精灵图进行处理,如下通过 fromColumnsAndRows 构造可以指定行列。这也就说明该类只能加载的图片要求:精灵图中的单体必须尺寸一致。

1
2
3
4
5
6
7
8
9
dart复制代码---->[源码: SpriteSheet]----
SpriteSheet.fromColumnsAndRows({
required this.image,
required int columns,
required int rows,
}) : srcSize = Vector2(
image.width / columns,
image.height / rows,
);

然后通过 getSpriteById 传入索引来获取第几个图片对应的 Sprite 对象。另外还提供了 getSprite 方法,通过指定行列获取图片对应的 Sprite 对象。注意,索引和行列都是从 0 开始数的。

1
2
3
4
5
6
7
8
dart复制代码---->[源码: SpriteSheet]----
Sprite getSprite(int row, int column) {
return getSpriteById(row * columns + column);
}

Sprite getSpriteById(int spriteId) {
return _spriteCache[spriteId] ??= _computeSprite(spriteId);
}

3. 使用测试案例

如下案例中,加载第一帧作为另一个角色 Monster ,且该角色会随机出现在屏幕的最右侧。代码见 【04/01】


Monster 继承自 SpriteComponent ,在构造方法里传入 精灵、尺寸、位置 信息,方便初始化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
dart复制代码class Monster extends SpriteComponent {
Monster({
required Sprite sprite,
required Vector2 size,
required Vector2 position,
}) : super(
sprite: sprite,
size: size,
position: position,
anchor: Anchor.center,
);

@override
Future<void> onLoad() async {
add(RectangleHitbox()..debugMode = true);
}
}

下面是 TolyGame 中的逻辑处理,在 onLoad 方法中加载精灵图,并取第一帧作为 Monster 的显示图片。另外随机出现在屏幕的最右侧,言外之意是横坐标固定,纵坐标随机,代码处理如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
dart复制代码---->[04/01/TolyGame]----
final Random _random = Random();

@override
Future<void> onLoad() async {
player = Adventurer();
add(player);
// 加载 SpriteSheet
const String src = 'adventurer/animatronic.png';
await images.load(src);
var image = images.fromCache(src);
SpriteSheet sheet = SpriteSheet.fromColumnsAndRows(image: image, columns: 13, rows: 6);
Sprite sprite = sheet.getSpriteById(0);
// 初始化 Monster
Vector2 monsterSize = Vector2(64,64);
final double pY = _random.nextDouble()*size.y;
final double pX = size.x - monsterSize.x/2; // tag1
monster = Monster(sprite: sprite,size: monsterSize,position: Vector2(pX, pY));
add(monster);
}

*注* :tag1 处减去 monsterSize.x/2,是因为 Monster 的锚点在中心,不减的话就会如下图所示。也就是说 Component 的 position 指的是锚点处的坐标,想让Monster 的右侧显示出来,向左偏移一半宽度即可。


4. 精灵图动画的加载

在第一篇 我们就介绍过使用 SpriteAnimationComponent 构件显示多帧动画,其实本质上就是多个 Sprite 对象,循环切换而已。前面知道如何通过 SpriteSheet 获取对应索引的 Sprite ,那接下来的事情就好办了。这里完成如下图所示的效果:代码见 【04/02】


实现,将 Monster 改为继承自 SpriteAnimationComponent ,支持帧动画。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
dart复制代码class Monster extends SpriteAnimationComponent {
Monster({
required SpriteAnimation animation,
required Vector2 size,
required Vector2 position,
}) : super(
animation: animation,
size: size,
position: position,
anchor: Anchor.center,
);

@override
Future<void> onLoad() async {
add(RectangleHitbox()..debugMode = true);
}
}

如下遍历 frameCount 此,获取 Sprite 列表,来生成 SpriteAnimation 即可,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
dart复制代码---->[04/02/TolyGame$onLoad]----
const String src = 'adventurer/animatronic.png';
await images.load(src);
var image = images.fromCache(src);
SpriteSheet sheet = SpriteSheet.fromColumnsAndRows(
image: image,
columns: 13,
rows: 6,
);

int frameCount = sheet.rows * sheet.columns;
List<Sprite> sprites = List.generate(frameCount, sheet.getSpriteById);
SpriteAnimation animation = SpriteAnimation.spriteList(
sprites,
stepTime: 1 / 24,
loop: true,
);

对于这种一张图记录的全是一个动画的精灵图,也可以不使用 SpriteSheet 。通过 fromFrameData 构造可以更简单直接地创建动画精灵对象,也能完成同样的效果。也就是写法上简洁一点而已,本质上没有什么区别。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
dart复制代码const int rowCount = 6;
const int columnCount = 13;
final Vector2 textureSize = Vector2(
image.width / columnCount,
image.height / rowCount,
);

SpriteAnimation animation = SpriteAnimation.fromFrameData(
image,
SpriteAnimationData.sequenced(
amount: rowCount * columnCount,
amountPerRow: columnCount,
stepTime: 1 / 24,
textureSize: textureSize,
),
);

5. 分包管理 - 简单拓展 SpriteSheet

通过 SpriteSheet 可以更灵活地操作需要哪些帧,比如像这种多个角色出现在一张精灵图里,SpriteAnimation.fromFrameData 就没法用了。


SpriteSheet 可以通过行列来获取指定的图片,比如下面红框所示的是 第四行,第五列图片,由于索引从 0 计数,也就是用 (3,4) 表示。

SpriteSheet 中的方法非常少,并没有获取索引区间段 Sprite 列表的方法,像这种图要自己来数,就比较麻烦。可以写个 extension 来拓展一下,可能一般人顺手就在 lib 中创建的文件夹开写了。看 flutter 官方的 pinball 项目中,会对模块进行分包,而不是所有代码都塞在一块。

image-20220528103427445.png

这里的 extension 和项目本身关系不大,是对 flame 的拓展,相对独立。以后可能还会写其他的拓展方法以便使用,这里也在项目中创建一个 packages 来进行分包管理。这样的另一个好处是:我可以将 flame_ext 分享到 pub 中,让所有人都可以使用。


下面说下创建包的方式,在 New Flutter Projrct 中 Projrct type 选择 Package 即可,如下把包创建在项目根目录的 packages 下:


然后在 pubspec.yaml 中通过 path 来引入本地库:

1
2
3
4
yaml复制代码dependencies:
#...
flame_ext:
path: packages/flame_ext

当前的包结构如下,在 flame_ext.dart 中导出 sprite_sheet_ext.dart ,这样引入 flame_ext.dart 即可使用 sprite_sheet_ext 中定义的拓展方法。

下面是 sprite_sheet_ext 中的处理逻辑,拓展一个 getRowSprites 的方法,返回 Sprite 列表。该方法的作用是:取第几行,从第几个开始的多少个 Sprite 形成列表。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
dart复制代码---->[sprite_sheet_ext.dart]----
extension SpriteSheetExt on SpriteSheet {
/// 获取指定索引区间的 [Sprite] 列表
/// [row] : 第几行
/// [start] : 第几个开始
/// [count] : 有几个
List<Sprite> getRowSprites({
required int row,
int start = 0,
required count,
}) {
return List.generate(count, (i) => getSprite(row, start + i)).toList();
}
}

比如下面指定是第三行,从 0 开始取五帧,语义上就非常明确,而不需要每次使用都计算一下:

1
dart复制代码sheet.getRowSprites(row: 3,count: 5)


如下是通过这种方法显示的效果,代码见: 【04/03】

1
2
3
4
5
6
7
8
dart复制代码import 'package:flame_ext/flame_ext.dart';

List<Sprite> sprites = sheet.getRowSprites(row: 3,count: 5);
SpriteAnimation animation = SpriteAnimation.spriteList(
sprites,
stepTime: 1 / 10,
loop: true,
);

再比如,第一行,从第六个开始,取 4 个,是石像怪的序列帧:

1
dart复制代码List<Sprite> sprites = sheet.getRowSprites(row: 0,start: 5,count: 4);


到这里,对精灵图的使用就介绍完毕了,大家可以结合上一章的内容,思考一下,如何让 Monster 主动向左运动。如下代码实现在 【04/04】 ,那本文就到这里,明天见 ~


  • @张风捷特烈 2022.05.29 未允禁转
  • 我的 公众号: 编程之王
  • 我的 掘金主页 : 张风捷特烈
  • 我的 B站主页 : 张风捷特烈
  • 我的 github 主页 : toly1994328

\

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

【Flutter&Flame 游戏 - 叁】手势操作与键盘事

发表于 2022-05-28

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第 4 天,点击查看活动详情


前言

这是一套 张风捷特烈 出品的 Flutter&Flame 系列教程,发布于掘金社区。如果你在其他平台看到本文,可以根据对于链接移步到掘金中查看。因为文章可能会更新、修正,一切以掘金文章版本为准。本系列文章一览:

  • 【Flutter&Flame 游戏 - 壹】开启新世界的大门
  • 【Flutter&Flame 游戏 - 贰】操纵杆与角色移动
  • 【Flutter&Flame 游戏 - 叁】键盘事件与手势操作
  • 【Flutter&Flame 游戏 - 肆】精灵图片加载方式
  • 【Flutter&Flame 游戏 - 伍】Canvas 参上 | 角色的血条
  • 【Flutter&Flame 游戏 - 陆】暴击 Dash | 文字构件的使用
  • 【Flutter&Flame 游戏 - 柒】人随指动 | 动画点触与移动
  • 【Flutter&Flame 游戏 - 捌】装弹完毕 | 角色武器发射
  • 【Flutter&Flame 游戏 - 玖】探索构件 | Component 是什么
  • 【Flutter&Flame 游戏 - 拾】探索构件 | Component 生命周期回调
  • 【Flutter&Flame 游戏 - 拾壹】探索构件 | Component 使用细节
  • 【Flutter&Flame 游戏 - 拾贰】探索构件 | 角色管理
  • 【Flutter&Flame 游戏 - 拾叁】碰撞检测 | CollisionCallbacks
  • 【Flutter&Flame 游戏 - 拾肆】碰撞检测 | 之前代码优化
  • 【Flutter&Flame 游戏 - 拾伍】粒子系统 | ParticleSystemComponent
  • 【Flutter&Flame 游戏 - 拾陆】粒子系统 | 粒子的种类
  • 【Flutter&Flame 游戏 - 拾柒】构件特效 | 了解 Effect 体系
  • 【Flutter&Flame 游戏 - 拾捌】构件特效 | ComponentEffect 一族
  • 【Flutter&Flame 游戏 - 拾玖】构件特效 | 了解 EffectController 体系
  • 【Flutter&Flame 游戏 - 贰拾】构件特效 | 其他 EffectControler
  • 【Flutter&Flame 游戏 - 贰壹】视差组件 | ParallaxComponent
  • 【Flutter&Flame 游戏 - 贰贰】菜单、字体和浮层
  • 【Flutter&Flame 游戏 - 贰叁】 资源管理与国际化
  • 【Flutter&Flame 游戏 - 贰肆】pinball 源码分析 - 项目结构介绍
  • 【Flutter&Flame 游戏 - 贰伍】pinball 源码分析 - 资源加载与 Loading
  • 【Flutter&Flame 游戏 - 贰陆】pinball 源码分析 - 游戏主菜单界面
  • 【Flutter&Flame 游戏 - 贰柒】pinball 源码分析 - 角色选择与玩法面板
  • 【Flutter&Flame 游戏 - 贰捌】pinball 源码分析 - 游戏主场景的构成
  • 【Flutter&Flame 游戏 - 贰玖】pinball 源码分析 - 视口与相机

第一季完结,谢谢支持 ~


1. 键盘事件

Flutter 作为跨平台的开发框架,本身有键盘的监听行为。Flame 中的键盘事件也只是对 Flutter 原生的一层封装而已,还是非常好理解的。这里我们先来实现如下的效果:按 Y 键时,让角色以自身中心沿 y 轴 反转; 按 X 键时,让角色以自身中心沿 x 轴 反转:代码在 【03/01】


首先介绍一下 Flame 对键盘事件的封装,可以看出是以 mixin 的方式提供回调实现的。注意一点,因为只是 on Game ,示意只有 Game 一族的类才可以混入。


前面知道 FlameGame 中混入了 Game ,所以是 Game 一族。这里 TolyGame 就可以混入 KeyboardEvents 。然后通过覆写 onKeyEvent 方法,来监听键盘事件。其中 RawKeyEvent 有两种类型:RawKeyDownEvent 表示按键按下;RawKeyUpEvent 表示按键抬起,代码如下:

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
dart复制代码---->[03/01]----
class TolyGame extends FlameGame with KeyboardEvents{
late final HeroComponent player;

@override
Future<void> onLoad() async {
player = HeroComponent();
add(player);
}

@override
KeyEventResult onKeyEvent(
RawKeyEvent event,
Set<LogicalKeyboardKey> keysPressed,
) {
final isKeyDown = event is RawKeyDownEvent;
if (event.logicalKey == LogicalKeyboardKey.keyY&&isKeyDown) {
//TODO 反转Y
}
if (event.logicalKey == LogicalKeyboardKey.keyX&&isKeyDown) {
//TODO 反转X
}
return super.onKeyEvent(event, keysPressed);
}
}

2. 角色的镜像反转

上一篇介绍过角色的 移动 和 旋转 ,这里来看一下通过 缩放 来实现沿轴的 镜像反转 。其实思路很简单,对于点来说,沿 Y 轴镜像是保持 y 坐标不变,x 坐标取相反数。scale 的本质就是对坐标在横纵分量上的乘积,所以 scale(-1,1) 表示的是将 x 坐标。

如下,在 HeroComponent 构建中添加 flip 方法,默认沿 y 轴镜像反转:

1
2
3
4
5
6
7
dart复制代码---->[03/01/HeroComponent]----
void flip({
bool x = false,
bool y = true,
}) {
scale = Vector2(scale.x * (y ? -1 : 1), scale.y * (x ? -1 : 1));
}

接下来,只要在按键监听中,触发 flip 方法即可。这样就实现了通过按键,控制对角色进行镜像反转的效果,代码如下:

1
2
3
4
5
6
7
dart复制代码---->[03/01/TolyGame$onKeyEvent]----
if (event.logicalKey == LogicalKeyboardKey.keyY && isKeyDown) {
player.flip(y: true);
}
if (event.logicalKey == LogicalKeyboardKey.keyX && isKeyDown) {
player.flip(x: true);
}

同理,结合上一章的内容,我们也可以通过键盘按键来控制角色的移动,如下所示,通过 上下左右 或 WSAD 键进行移动:代码在 【03/02】

代码如下,其中 step 表示按一下的偏移量:

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
dart复制代码final double step = 10;

if (event.logicalKey == LogicalKeyboardKey.keyY && isKeyDown) {
player.flip(y: true);
}
if (event.logicalKey == LogicalKeyboardKey.keyX && isKeyDown) {
player.flip(x: true);
}
if ((event.logicalKey == LogicalKeyboardKey.arrowUp
|| event.logicalKey == LogicalKeyboardKey.keyW)
&& isKeyDown) {
player.move(Vector2(0, -step));
}
if ((event.logicalKey == LogicalKeyboardKey.arrowDown
|| event.logicalKey == LogicalKeyboardKey.keyS)
&& isKeyDown) {
player.move(Vector2(0, step));
}
if ((event.logicalKey == LogicalKeyboardKey.arrowLeft
|| event.logicalKey == LogicalKeyboardKey.keyA)
&& isKeyDown) {
player.move(Vector2(-step, 0));
}
if ((event.logicalKey == LogicalKeyboardKey.arrowRight
|| event.logicalKey == LogicalKeyboardKey.keyD)
&& isKeyDown) {
player.move(Vector2(step, 0));
}

通过这两个小例子就简单认识了在 Flame 中如何监听键盘事件,下面来看一下手势事件,比如点击、拖拽、长按等。


3. 手势检测 - 点击事件

同样,Flame 中的手势检测也是基于 Flutter 的一层封装,通过 mixin 实现监听功能。另外,注意一点,这也也都是 on Game ,也就是说只有 Game 一族的类才能使用这些手势检测器。

这些手势检测器和 Flutter 中的含义基本一致,就不一一赘述了。使用方式都是混入后,通过覆写方法进行监听,这里主要对 点击 TapDetector 和 拖拽 PanDetector 进行介绍。


如下的小例子中,每次点击屏幕时,角色会顺时针旋转 90° ,而且按下后会显示角色的边界信息,抬手后会消失。代码在 【03/03】

实现也非常简单,将 TolyGame 混入 TapDetector ,然后覆写相关方法进行即可。其中旋转通过 _counter 进行计数,每次点击时加一,然后旋转到 _counter * pi / 2 角度即可。角色的边框信息,通过添加 RectangleHitbox 即可,当其 debugMode 为 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
dart复制代码class TolyGame extends FlameGame with TapDetector {
late final HeroComponent player;
late final RectangleHitbox box;

@override
Future<void> onLoad() async {
player = HeroComponent();
box = RectangleHitbox()..debugMode = false;
player.add(box);
add(player);
}

final double step = 10;

double _counter = 0;

@override
void onTap() {
_counter++;
player.rotateTo(_counter * pi / 2);
}

@override
void onTapCancel() {
box.debugMode = false;
}

@override
void onTapDown(TapDownInfo info) {
box.debugMode = true;
}

@override
void onTapUp(TapUpInfo info) {
box.debugMode = false;
}
}

4.手势检测 - 拖拽事件

其实上一章中介绍的操作杆,本质上就是基于拖拽事件实现的,只不过限定拖拽区域而言。如下是通过 PanDetector 实现的移动,在 onPanUpdate 回调中可以监听到鼠标的移位量: 【03/04】

代码如下,通过 onPanUpdate 回调的 DragUpdateInfo 对象中的 info.delta.global 可以得到相对于全局的鼠标偏移量:

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
dart复制代码class TolyGame extends FlameGame with PanDetector {
// 略同...

@override
void onPanDown(DragDownInfo info) {
box.debugMode = true;
}

@override
void onPanStart(DragStartInfo info) {}

@override
void onPanUpdate(DragUpdateInfo info) {
player.move(info.delta.global);
}

@override
void onPanEnd(DragEndInfo info) {
box.debugMode = false;
}

@override
void onPanCancel() {
box.debugMode = false;
}
}

5. Component 的手势与键盘监听

前面说过,上面的监听都是只能被混入到 Game 一族中,也就是说 Component 构件不能混入,更像是一个全局的手势、事件检测。那么如果只想对某个 Component 进行监听,又该怎么办呢?Flame 源码中在 components/mixin 中提供了 Component 专属的键盘、手势检测混入类。


如下是一个小案例,当鼠标移入角色区域时,边框信息呈绿色,按下时边框变红,且角色顺时针旋转 90° ;鼠标移出区域或抬起时,边框信息取消。这里使用了 Tappable 和 Hoverable 两个 mixin ,代码详见: 【03/05】

处理方式和前面基本一致,这里就不赘述了。另外说明一点,如果某个 Component 混入了 Tappable ,那么最外层的 TolyGame 就要混入 HasTappables ;同理:

事件类型 Component 混入 XXXGame 需混入
单击 Tappable HasTappables
悬浮 Hoverable HasHoverables
键盘 KeyboardHandler HasKeyboardHandlerComponents
拖拽 Draggable HasDraggables
1
2
3
4
5
6
7
dart复制代码class TolyGame extends FlameGame  with HasTappables,HasHoverables {
// 略...
}

class HeroComponent extends SpriteAnimationComponent with HasGameRef, Tappable,Hoverable {
// 略...
}

到这里,基本的键盘事件和手势操作就已经介绍完了,这些和 Flutter 原生的事件基本一致。这里来简单瞄一眼单击事件 onTap 的触发,可以看出本质上还是 GestureDetector 在 onTap 中触发 game.onTap 方法的。所以这里的手势和键盘事件也不是什么新知识。

主要需要注意的是:Flame 中对事件检测封装了两套 mix :一套是基于 Game 的,用于全局的事件检测。另一套是基于 Component 的,用于某个构件角色的事件检测。这里只是简单介绍一下事件的使用,在后面还会经常使用。那本文就到这里,明天见 ~


  • @张风捷特烈 2022.05.28 未允禁转
  • 我的 公众号: 编程之王
  • 我的 掘金主页 : 张风捷特烈
  • 我的 B站主页 : 张风捷特烈
  • 我的 github 主页 : toly1994328

\

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Threejs 打造缤纷夏日3D梦中情岛 🌊

发表于 2022-05-27

我正在参加「初夏创意投稿大赛」详情请看:初夏创意投稿大赛

声明:本文涉及图文和模型素材仅用于个人学习、研究和欣赏,请勿二次修改、非法传播、转载、出版、商用、及进行其他获利行为。

背景

深居内陆的人们,大概每个人都有过大海之梦吧。夏日傍晚在沙滩漫步奔跑;或是在海上冲浪游泳;或是在海岛游玩探险;亦或静待日出日落……本文使用 React + Three.js 技术栈,实现 3D 海洋和岛屿,主要包含知识点包括:Tone Mapping、Water 类、Sky 类、Shader 着色、ShaderMaterial 着色器材质、Raycaster 检测遮挡以及 Three.js 的其他基础知识,让我们在这个夏天通过此页面共赴大海之约。

效果

  • 💻 本页面适配 PC 端及移动端,大屏访问效果更佳。
  • 👁‍🗨 在线预览地址1:3d-eosin.vercel.app/#/ocean
  • 👁‍🗨 在线预览地址2:dragonir.github.io/3d/#/ocean

码上掘金

地址:https://code.juejin.cn/pen/7102188496340647950

实现

👨‍🎨 素材准备

开发之前,需要准备页面所需的素材,本文用到的海岛素材是在 sketchfab.com 找的免费模型。下载好素材之后,在 Blender 中打开,按自己的想法调整模型的颜色、材质、大小比例、角度、位置等信息,删减不需要的模块、缩减面数以压缩模型体积,最后删除相机、光照、UV、动画等多余信息,只导出模型网格备用。

📦 资源引入

首先,引入开发所需的必备资源,OrbitControls 用于镜头轨道控制;GLTFLoader 用于加载 gltf 格式模型;Water 是 Three.js 内置的一个类,可以生成类似水的效果;Sky 可以生成天空效果;TWEEN 用来生成补间动画;Animations 是对 TWEEN 控制镜头补间动画方法的封装;waterTexture 、flamingoModel、islandModel 三者分别是水的法向贴图、飞鸟模型、海岛模型;vertexShader 和 fragmentShader 是用于生成彩虹的 Shader 着色器。

1
2
3
4
5
6
7
8
9
10
11
12
js复制代码import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader";
import { Water } from 'three/examples/jsm/objects/Water';
import { Sky } from 'three/examples/jsm/objects/Sky';
import { TWEEN } from "three/examples/jsm/libs/tween.module.min";
import Animations from '@/assets/utils/animations';
import waterTexture from '@/containers/Ocean/images/waternormals.jpg';
import islandModel from '@/containers/Ocean/models/island.glb';
import flamingoModel from '@/containers/Ocean/models/flamingo.glb';
import vertexShader from '@/containers/Ocean/shaders/rainbow/vertex.glsl';
import fragmentShader from '@/containers/Ocean/shaders/rainbow/fragment.glsl';

📃 页面结构

页面主要由3部分构成:canvas.webgl 用于渲染 WEBGL 场景;div.loading 用于模型加载完成前显示加载进度;div.point 用于添加交互点,省略部分是其他几个交互点信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
js复制代码render () {
return (
<div className='ocean'>
<canvas className='webgl'></canvas>
{this.state.loadingProcess === 100 ? '' : (
<div className='loading'>
<span className='progress'>{this.state.loadingProcess} %</span>
</div>
)}
<div className="point point-0">
<div className="label label-0">1</div>
<div className="text">灯塔:矗立在海岸的岩石之上,白色的塔身以及红色的塔屋,在湛蓝色的天空和深蓝色大海的映衬下,显得如此醒目和美丽。</div>
</div>
// ...
</div>
)
}

🌏 场景初始化

在这部分,先定义好需要的状态值,loadingProcess 用于显示页面加载进度。

1
2
3
js复制代码state = {
loadingProcess: 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
js复制代码const clock = new THREE.Clock();
const raycaster = new THREE.Raycaster()
const sizes = {
width: window.innerWidth,
height: window.innerHeight
}
const renderer = new THREE.WebGLRenderer({
canvas: document.querySelector('canvas.webgl'),
antialias: true
});
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
renderer.setSize(sizes.width, sizes.height);
// 设置渲染效果
renderer.toneMapping = THREE.ACESFilmicToneMapping;
// 创建场景
const scene = new THREE.Scene();
// 创建相机
const camera = new THREE.PerspectiveCamera(55, sizes.width / sizes.height, 1, 20000);
camera.position.set(0, 600, 1600);
// 添加镜头轨道控制器
const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 0, 0);
controls.enableDamping = true;
controls.enablePan = false;
controls.maxPolarAngle = 1.5;
controls.minDistance = 50;
controls.maxDistance = 1200;
// 添加环境光
const ambientLight = new THREE.AmbientLight(0xffffff, .8);
scene.add(ambientLight);
// 添加平行光
const dirLight = new THREE.DirectionalLight(0xffffff, 1);
dirLight.color.setHSL(.1, 1, .95);
dirLight.position.set(-1, 1.75, 1);
dirLight.position.multiplyScalar(30);
scene.add(dirLight);
// 页面缩放监听并重新更新场景和相机
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}, false);

💡 Tone Mapping

可以注意到,本文使用了 renderer.toneMapping = THREE.ACESFilmicToneMapping 来设置页面渲染效果。目前 Three.js 中有以下几种 Tone Mapping 值,它们定义了 WebGLRenderer 的 toneMapping 属性,用于在近似标准计算机显示器或移动设备的低动态范围 LDR 屏幕上展示高动态范围 HDR 外观。大家可以修改不同的值看看渲染效果有何不同。

  • THREE.NoToneMapping
  • THREE.LinearToneMapping
  • THREE.ReinhardToneMapping
  • THREE.CineonToneMapping
  • THREE.ACESFilmicToneMapping

🌊 海

使用 Three.js 自带的 Water 类创建海洋,首先创建一个平面网格 waterGeometry,让后将它传递给 Water,并配置相关属性,最后将海洋添加到场景中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
js复制代码const waterGeometry = new THREE.PlaneGeometry(10000, 10000);
const water = new Water(waterGeometry, {
textureWidth: 512,
textureHeight: 512,
waterNormals: new THREE.TextureLoader().load(waterTexture, texture => {
texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
}),
sunDirection: new THREE.Vector3(),
sunColor: 0xffffff,
waterColor: 0x0072ff,
distortionScale: 4,
fog: scene.fog !== undefined
});
water.rotation.x = - Math.PI / 2;
scene.add(water);

💡 Water 类

参数说明:

  • textureWidth:画布宽度
  • textureHeight:画布高度
  • waterNormals:法向量贴图
  • sunDirection:阳光方向
  • sunColor:阳光颜色
  • waterColor:水颜色
  • distortionScale:物体倒影分散度
  • fog:雾
  • alpha:透明度

🌞 空

接着,使用 Three.js 自带的天空类 Sky 创建天空,通过修改着色器参数设置天空样式,然后创建太阳并添加到场景中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
js复制代码const sky = new Sky();
sky.scale.setScalar(10000);
scene.add(sky);
const skyUniforms = sky.material.uniforms;
skyUniforms['turbidity'].value = 20;
skyUniforms['rayleigh'].value = 2;
skyUniforms['mieCoefficient'].value = 0.005;
skyUniforms['mieDirectionalG'].value = 0.8;
// 太阳
const sun = new THREE.Vector3();
const pmremGenerator = new THREE.PMREMGenerator(renderer);
const phi = THREE.MathUtils.degToRad(88);
const theta = THREE.MathUtils.degToRad(180);
sun.setFromSphericalCoords(1, phi, theta);
sky.material.uniforms['sunPosition'].value.copy(sun);
water.material.uniforms['sunDirection'].value.copy(sun).normalize();
scene.environment = pmremGenerator.fromScene(sky).texture;

💡 Sky 类

天空材质着色器参数说明:

  • turbidity 浑浊度
  • rayleigh 视觉效果就是傍晚晚霞的红光的深度
  • luminance 视觉效果整体提亮或变暗
  • mieCoefficient 散射系数
  • mieDirectionalG 定向散射值

🌈 虹

首先,创建具有彩虹渐变效果的着色器 Shader, 然后使用着色器材质 ShaderMaterial, 创建圆环 THREE.TorusGeometry 并添加到场景中。

顶点着色器 vertex.glsl:

1
2
3
4
5
6
7
glsl复制代码varying vec2 vUV;
varying vec3 vNormal;
void main () {
vUV = uv;
vNormal = vec3(normal);
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}

片段着色器 fragment.glsl:

1
2
3
4
5
6
glsl复制代码varying vec2 vUV;
varying vec3 vNormal;
void main () {
vec4 c = vec4(abs(vNormal) + vec3(vUV, 0.0), 0.1); // 设置透明度为0.1
gl_FragColor = c;
}

彩虹渐变着色器效果:

1
2
3
4
5
6
7
8
9
10
11
12
js复制代码const material = new THREE.ShaderMaterial({
side: THREE.DoubleSide,
transparent: true,
uniforms: {},
vertexShader: vertexShader,
fragmentShader: fragmentShader
});
const geometry = new THREE.TorusGeometry(200, 10, 50, 100);
const torus = new THREE.Mesh(geometry, material);
torus.opacity = .1;
torus.position.set(0, -50, -400);
scene.add(torus);

💡 Shader 着色器

WebGL 中记述了坐标变换的机制就叫做着色器 Shader,着色器又有处理几何图形顶点的 顶点着色器 和处理像素的 片段着色器 两种类型

准备顶点着色器和片元着色器

着色器的添加有多种方法,最简单的方法就是把着色器记录在 HTML 中。该方法利用HTML 的 script 标签来实现,如:

顶点着色器:

1
html复制代码<script id="vshader" type="x-shader/x-vertex"></script>

片段着色器:

1
html复制代码<script id="fshader" type="x-shader/x-fragment"></script>

🎏 也可以像本文中一样,直接使用单独创建 glsl 格式文件引入。

着色器的三个变量与运行方式
  • Uniforms:是所有顶点都具有相同的值的变量。 比如灯光,雾,和阴影贴图就是被储存在 uniforms 中的数据。uniforms 可以通过顶点着色器和片元着色器来访问。
  • Attributes:是与每个顶点关联的变量。例如,顶点位置,法线和顶点颜色都是存储在 attributes 中的数据。attributes 只可以在顶点着色器中访问。
  • Varyings:是从顶点着色器传递到片元着色器的变量。对于每一个片元,每一个varying 的值将是相邻顶点值的平滑插值。

顶点着色器 首先运行,它接收 attributes, 计算每个单独顶点的位置,并将其他数据varyings 传递给片段着色器。片段着色器 后运行,它设置渲染到屏幕的每个单独的片段的颜色。

💡 ShaderMaterial 着色器材质

Three.js 所谓的材质对象 Material 本质上就是着色器代码和需要传递的 uniform 数据光源、颜色、矩阵。Three.js 提供可直接渲染着色器语法的材质 ShaderMaterial 和 RawShaderMaterial。

  • RawShaderMaterial: 和原生 WebGL 中一样,顶点着色器、片元着色器代码基本没有任何区别,不过顶点数据和 uniform 数据可以通过 Three.js 的 API 快速传递,要比使用 WebGL 原生的 API 与着色器变量绑定要方便得多。
  • ShaderMaterial:ShaderMaterial 比 RawShaderMaterial 更方便些,着色器中的很多变量不用声明,Three.js 系统会自动设置,比如顶点坐标变量、投影矩阵、视图矩阵等。

构造函数:

1
js复制代码ShaderMaterial(parameters : Object)

parameters:可选,用于定义材质外观的对象,具有一个或多个属性。

常用属性:

  • attributes[Object]:接受如下形式的对象,{ attribute1: { value: []} } 指定要传递给顶点着色器代码的 attributes;键为 attribute 修饰变量的名称,值也是对象格式,如 { value: [] }, value 是固定名称,因为 attribute 相对于所有顶点,所以应该回传一个数组格式。只有 bufferGeometry 类型的能使用该属性。
  • .uniforms[Object]:如下形式的对象:{ uniform1: { value: 1.0 }, uniform2: { value: 2.0 }} 指定要传递给shader 代码的 uniforms;键为 uniform 的名称,值是如下形式:{ value: 1.0 } 这里 value 是 uniform 的值。名称必须匹配着色器代码中 uniform 的 name,和 GLSL 代码中的定义一样。 注意,uniforms 逐帧被刷新,所以更新 uniform 值将立即更新 GLSL 代码中的相应值。
  • .fragmentShader[String]:片元着色器的 GLSL 代码,它也可以作为一个字符串直接传递或者通过 AJAX 加载。
  • .vertexShader[String]:顶点着色器的 GLSL 代码,它也可以作为一个字符串直接传递或者通过 AJAX 加载。

🌴 岛

接着,使用 GLTFLoader 加载岛屿模型并添加到场景中。加载之前可以使用 LoadingManager 来管理加载进度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
js复制代码const manager = new THREE.LoadingManager();
manager.onProgress = async(url, loaded, total) => {
if (Math.floor(loaded / total * 100) === 100) {
this.setState({ loadingProcess: Math.floor(loaded / total * 100) });
Animations.animateCamera(camera, controls, { x: 0, y: 40, z: 140 }, { x: 0, y: 0, z: 0 }, 4000, () => {
this.setState({ sceneReady: true });
});
} else {
this.setState({ loadingProcess: Math.floor(loaded / total * 100) });
}
};
const loader = new GLTFLoader(manager);
loader.load(islandModel, mesh => {
mesh.scene.traverse(child => {
if (child.isMesh) {
child.material.metalness = .4;
child.material.roughness = .6;
}
})
mesh.scene.position.set(0, -2, 0);
mesh.scene.scale.set(33, 33, 33);
scene.add(mesh.scene);
});

🦅 鸟

使用 GLTFLoader 加载岛屿模型添加到场景中,获取模型自带的动画帧并进行播放,记得要在 requestAnimationFrame 中更新动画。可以使用 clone 方法在场景中添加多只飞鸟。鸟模型来源于 Three.js 官网。

1
2
3
4
5
6
7
8
9
10
11
js复制代码loader.load(flamingoModel, gltf => {
const mesh = gltf.scene.children[0];
mesh.scale.set(.35, .35, .35);
mesh.position.set(-100, 80, -300);
mesh.rotation.y = - 1;
mesh.castShadow = true;
scene.add(mesh);
const mixer = new THREE.AnimationMixer(mesh);
mixer.clipAction(gltf.animations[0]).setDuration(1.2).play();
this.mixers.push(mixer);
});

🖐 交互点

添加交互点,鼠标 hover 悬浮时显示提示语,点击交互点可以切换镜头角度,视角聚焦到交互点对应的位置 📍 上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
js复制代码const points = [
{
position: new THREE.Vector3(10, 46, 0),
element: document.querySelector('.point-0')
},
// ...
];
document.querySelectorAll('.point').forEach(item => {
item.addEventListener('click', event => {
let className = event.target.classList[event.target.classList.length - 1];
switch(className) {
case 'label-0':
Animations.animateCamera(camera, controls, { x: -15, y: 80, z: 60 }, { x: 0, y: 0, z: 0 }, 1600, () => {});
break;
// ...
}
}, false);
});

🎥 动画

在 requestAnimationFrame 中更新水、镜头轨道控制器、相机、TWEEN、交互点等动画。

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
js复制代码const animate = () => {
requestAnimationFrame(animate);
water.material.uniforms['time'].value += 1.0 / 60.0;
controls && controls.update();
const delta = clock.getDelta();
this.mixers && this.mixers.forEach(item => {
item.update(delta);
});
const timer = Date.now() * 0.0005;
TWEEN && TWEEN.update();
camera && (camera.position.y += Math.sin(timer) * .05);
if (this.state.sceneReady) {
// 遍历每个点
for (const point of points) {
// 获取2D屏幕位置
const screenPosition = point.position.clone();
screenPosition.project(camera);
raycaster.setFromCamera(screenPosition, camera);
const intersects = raycaster.intersectObjects(scene.children, true);
if (intersects.length === 0) {
// 未找到相交点,显示
point.element.classList.add('visible');
} else {
// 找到相交点
// 获取相交点的距离和点的距离
const intersectionDistance = intersects[0].distance;
const pointDistance = point.position.distanceTo(camera.position);
// 相交点距离比点距离近,隐藏;相交点距离比点距离远,显示
intersectionDistance < pointDistance ? point.element.classList.remove('visible') : point.element.classList.add('visible');
}
const translateX = screenPosition.x * sizes.width * 0.5;
const translateY = - screenPosition.y * sizes.height * 0.5;
point.element.style.transform = `translateX(${translateX}px) translateY(${translateY}px)`;
}
}
renderer.render(scene, camera);
}
animate();
}

💡 Raycaster 检测遮挡

仔细观察,在上述 👆 更新交互点动画的方法中,通过 raycaster 射线来检查交互点是否被物体遮挡,如果被遮挡就隐藏交互点,否则显示交互点,大家可以通过旋转场景观察到这一效果。

💥 镜头光晕

给点光源增加镜头光晕 Lensflare 效果,看起来更加真实,营造满满氛围感!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
js复制代码import { Lensflare, LensflareElement } from 'three/examples/jsm/objects/Lensflare.js';
import lensflareTexture0 from '@/containers/Ocean/images/lensflare0.png';
import lensflareTexture1 from '@/containers/Ocean/images/lensflare1.png';
// 太阳点光源
const pointLight = new THREE.PointLight(0xffffff, 1.2, 2000);
pointLight.color.setHSL(.995, .5, .9);
pointLight.position.set(0, 45, -2000);
const textureLoader = new THREE.TextureLoader();
const textureFlare0 = textureLoader.load(lensflareTexture0);
const textureFlare1 = textureLoader.load(lensflareTexture1);
// 镜头光晕
const lensflare = new Lensflare();
lensflare.addElement(new LensflareElement(textureFlare0, 600, 0, pointLight.color));
lensflare.addElement(new LensflareElement(textureFlare1, 60, .6));
lensflare.addElement(new LensflareElement(textureFlare1, 70, .7));
lensflare.addElement(new LensflareElement(textureFlare1, 120, .9));
lensflare.addElement(new LensflareElement(textureFlare1, 70, 1));
pointLight.add(lensflare);
scene.add(pointLight);

总结

本文包含的新知识点主要包括:

  • Tone Mapping
  • Water 类
  • Sky 类
  • Shader 着色器
  • ShaderMaterial 着色器材质
  • Raycaster 检测遮挡
  • Lensflare 镜头光晕

想了解其他前端知识或其他未在本文中详细描述的 Web 3D 开发技术相关知识,可阅读我往期的文章。转载请注明原文地址和作者。如果觉得文章对你有帮助,不要忘了一键三连哦 👍。

参考

  • [1]. threejs.org

附录

  • 朕的3D专栏
  • [1]. 🦊 Three.js 实现3D开放世界小游戏:阿狸的多元宇宙
  • [2]. 🔥 Three.js 火焰效果实现艾尔登法环动态logo
  • [3]. 🐼 Three.js 实现2022冬奥主题3D趣味页面,含冰墩墩
  • ...
  • [1]. 📷 前端实现很哇塞的浏览器端扫码功能
  • [2]. 🌏 前端瓦片地图加载之塞尔达传说旷野之息
  • [3]. 😱 仅用CSS几步实现赛博朋克2077风格视觉效果
  • ...

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

【Flutter&Flame 游戏 - 贰】操纵杆与角色移动

发表于 2022-05-27

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第 3 天,点击查看活动详情


前言

这是一套 张风捷特烈 出品的 Flutter&Flame 系列教程,发布于掘金社区。如果你在其他平台看到本文,可以根据对于链接移步到掘金中查看。因为文章可能会更新、修正,一切以掘金文章版本为准。本系列文章一览:

  • 【Flutter&Flame 游戏 - 壹】开启新世界的大门
  • 【Flutter&Flame 游戏 - 贰】操纵杆与角色移动
  • 【Flutter&Flame 游戏 - 叁】键盘事件与手势操作
  • 【Flutter&Flame 游戏 - 肆】精灵图片加载方式
  • 【Flutter&Flame 游戏 - 伍】Canvas 参上 | 角色的血条
  • 【Flutter&Flame 游戏 - 陆】暴击 Dash | 文字构件的使用
  • 【Flutter&Flame 游戏 - 柒】人随指动 | 动画点触与移动
  • 【Flutter&Flame 游戏 - 捌】装弹完毕 | 角色武器发射
  • 【Flutter&Flame 游戏 - 玖】探索构件 | Component 是什么
  • 【Flutter&Flame 游戏 - 拾】探索构件 | Component 生命周期回调
  • 【Flutter&Flame 游戏 - 拾壹】探索构件 | Component 使用细节
  • 【Flutter&Flame 游戏 - 拾贰】探索构件 | 角色管理
  • 【Flutter&Flame 游戏 - 拾叁】碰撞检测 | CollisionCallbacks
  • 【Flutter&Flame 游戏 - 拾肆】碰撞检测 | 之前代码优化
  • 【Flutter&Flame 游戏 - 拾伍】粒子系统 | ParticleSystemComponent
  • 【Flutter&Flame 游戏 - 拾陆】粒子系统 | 粒子的种类
  • 【Flutter&Flame 游戏 - 拾柒】构件特效 | 了解 Effect 体系
  • 【Flutter&Flame 游戏 - 拾捌】构件特效 | ComponentEffect 一族
  • 【Flutter&Flame 游戏 - 拾玖】构件特效 | 了解 EffectController 体系
  • 【Flutter&Flame 游戏 - 贰拾】构件特效 | 其他 EffectControler
  • 【Flutter&Flame 游戏 - 贰壹】视差组件 | ParallaxComponent
  • 【Flutter&Flame 游戏 - 贰贰】菜单、字体和浮层
  • 【Flutter&Flame 游戏 - 贰叁】 资源管理与国际化
  • 【Flutter&Flame 游戏 - 贰肆】pinball 源码分析 - 项目结构介绍
  • 【Flutter&Flame 游戏 - 贰伍】pinball 源码分析 - 资源加载与 Loading
  • 【Flutter&Flame 游戏 - 贰陆】pinball 源码分析 - 游戏主菜单界面
  • 【Flutter&Flame 游戏 - 贰柒】pinball 源码分析 - 角色选择与玩法面板
  • 【Flutter&Flame 游戏 - 贰捌】pinball 源码分析 - 游戏主场景的构成
  • 【Flutter&Flame 游戏 - 贰玖】pinball 源码分析 - 视口与相机

第一季完结,谢谢支持 ~


1. Flame 官方案例

在 github 仓库中 flame/examples 中是官方的案例,对于入门而言是很有参考意义的。 但它也不是非常惊艳,作为一个游戏引擎的官方案例来说,还是太过简陋。

其中介绍了很多基本模块,每个模块中的案例都能很方便地找到对应的源码,这一点还是很值得肯定的。


本文我们将基于如下的 Joystick 案例,介绍一下操纵杆的使用,以及角色的移动。移动是最基础的游戏交互,还是先介绍为好。


本文的效果如下,通过左下角的操纵杆,来移动角色:本文源码于 【lib/02】


2. 操纵杆的使用

操纵杆的原理非常简单,如下以大圆中心为原点建立坐标系,正方向分别向 右 和 下 。通过小圆心的坐标就可以确定偏移量以及旋转角度。这里主要使用偏移量来修改角色的 position 位置。


同样,操纵杆本身也是 Component 构建。如下,在 TolyGame 的 onLoad中构造 JoystickComponent 对象,通过 add 方法加入到游戏中。主要这里的 TolyGame 需要混入 HasDraggables ,才能支持操纵杆拖拽。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
dart复制代码---->[02/game.dart]----
class TolyGame extends FlameGame with HasDraggables {

late final JoystickComponent joystick;

@override
Future<void> onLoad() async {
final knobPaint = BasicPalette.blue.withAlpha(200).paint();
final backgroundPaint = BasicPalette.blue.withAlpha(100).paint();
joystick = JoystickComponent(
knob: CircleComponent(radius: 25, paint: knobPaint),
background: CircleComponent(radius: 60, paint: backgroundPaint),
margin: const EdgeInsets.only(left: 40, bottom: 40),
);
add(joystick);
}

现在操纵杆已经加入到了 游戏场景 之中,接下来把角色加入进来。方式也很简单,创建 HeroComponent 对象,再添加到场景中即可。代码如下:

1
2
3
4
5
6
dart复制代码---->[02/game.dart]----
late final HeroComponent player;

---->[onLoad 方法]----
player = HeroComponent();
add(player);

这就说明,游戏中的各种角色,都是 Component 构件,添加到游戏场景之中,后添加的在上层。游戏的核心是维护各个对象数据间的关系。


3. 角色的移动

在上一篇中,我们介绍了 PositionComponent 一族的构件中有 position 属性,来定位角色位置。也就是说,只要根据操纵杆的偏移量,对 position 属性进行修改即可。另外说一下,在一个 Component 中添加 RectangleHitbox ,就可以有如上的紫色信息框,便于查看角色所占区域即位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
dart复制代码class HeroComponent extends SpriteAnimationComponent with HasGameRef {

HeroComponent() : super(size: Vector2(50,37), anchor: Anchor.center);
@override
Future<void> onLoad() async {
List<Sprite> sprites = [];
for(int i=0;i<=8;i++){
sprites.add(await gameRef.loadSprite('adventurer/adventurer-bow-0$i.png'));
}
animation = SpriteAnimation.spriteList(sprites, stepTime: 0.15);
position = gameRef.size / 2;
add(RectangleHitbox()..debugMode = true);
}

double speed = 200.0; // Pixels/ 秒

void move(Vector2 ds){
position.add(ds);
}
}

这里定义一个 move 方法,接受 Vector2 位移量,类中定义了一个 speed ,用于控制移动速度,值越大就表示每秒运动的位移越长。


4. 世界的刷新

我们日常生活中有钟表计时,可以明确时间的概念,现实中时间是不断进行的,永不停息。在游戏开发中也是类似,默认情况下世界处于不断刷新渲染之中,每次的刷新渲染成为一帧。如果每秒渲染 60 次,那就说明游戏每秒可达 60 帧,也就是常说的 60fps 。不过游戏中的时间是可以暂停的。

另外,在 Component 类中定义了 update 方法,可以覆写它来监听每次刷新的事件。前面我们知道 FlameGame 本身也是 Component ,所以在子类 TolyGame 中可以覆写 update 来监听帧的更新。通过打印日志可以看出来,会不断触发,其中 dt 回调表示两帧之间的时间差。而且每帧之间约等于 0.01666 秒 ,也就是 16.6 ms ,即每秒可刷新 60 次。

1
2
3
4
5
6
dart复制代码---->[02/game.dart/TolyGame]----
@override
void update(double dt) {
super.update(dt);
print(dt);
}


使用,只要在 update 回调中,执行 player 的 move 方法即可修改角色位置。其中 joystick.relativeDelta 是偏移量和外圆半径的比值,也就是指移动的百分比。根据物理学公式,可以计算出偏移位移

1
ini复制代码ds = v * t

其中速度是一个二维的向量,是速度值和 joystick.relativeDelta 向量结合获得的。从而达到操纵杆百分比越大,速度越快的效果。

1
2
3
4
5
6
7
8
dart复制代码@override
void update(double dt) {
super.update(dt);
if (!joystick.delta.isZero()) {
Vector2 ds = joystick.relativeDelta * player.speed * dt;
player.move(ds);
}
}

另外可以通过 joystick.delta.screenAngle() 获取操纵杆的旋转角度,也就是可以对角色进行旋转操作,如下所示:

在 PositionComponent 中除了 Vector2 类型的 position 进行定位;还有double 类型的 angle 用于控制旋转角度;以及 Vector2 类型的 scale 控制缩放。

1
2
3
4
dart复制代码---->[HeroComponent#rotateTo]----
void rotateTo(double deg){
angle = deg;
}

在 joystick 偏移了非零时,获取角度为 player 设置旋转角度即可。另外,如果操纵杆偏移量为 0 ,恢复原位。

1
2
3
4
5
6
7
dart复制代码---->[TolyGame#update]----
// 角色旋转
if (!joystick.delta.isZero()) {
player.rotateTo(joystick.delta.screenAngle());
}else{
player.rotateTo(0);
}

5. 小结

本文主要简单认识了一下 JoystickComponent 操纵杆构件,并基于此实现了对角色的移动和旋转操作。也简单认识了一下世界的刷新的触发,这里简单瞄一下源码,其实刷新的触发和 Flutter 原生的 Animation 动画刷新是类似的,都是基于 Ticker 来触发。

Flame 引擎中的 GameLoop 就相当于一个没有停止时间,不断运行的动画。看过《动画小册》的应该对这些比较清楚,这里不过多引申,后面有机会再掰扯掰扯源码。动画和游戏有种类似的本质,都是连续变化的帧。只不过游戏有大量的交互和对象间关系的处理,逻辑非常复杂而已。那本文就到这里,明天见 ~


  • @张风捷特烈 2022.05.27 未允禁转
  • 我的 公众号: 编程之王
  • 我的 掘金主页 : 张风捷特烈
  • 我的 B站主页 : 张风捷特烈
  • 我的 github 主页 : toly1994328

\

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

【Flutter&Flame 游戏 - 壹】开启新世界的大门

发表于 2022-05-26

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第 2 天,点击查看活动详情


一、 新的可能性

Google I/O 2022 对于 Flutter 而言,将 休闲游戏 带入了大众的视野。让 Flutter 除了应用开发之外,有了新的可能性。其中作为游戏开发引擎的 Flame ,也为更多人所知晓。说实话,之前我并不怎么看得上 Flame ,无论是官网还是文档,内容都很少,感觉非常小众。我期待着官方可以出一个游戏引擎,但现在看来,官方也倾向于使用 Flame 引擎来开发休闲的 2D 游戏,那我无需再等,开摆 。

  • 【Flutter&Flame 游戏 - 壹】开启新世界的大门
  • 【Flutter&Flame 游戏 - 贰】操纵杆与角色移动
  • 【Flutter&Flame 游戏 - 叁】键盘事件与手势操作
  • 【Flutter&Flame 游戏 - 肆】精灵图片加载方式
  • 【Flutter&Flame 游戏 - 伍】Canvas 参上 | 角色的血条
  • 【Flutter&Flame 游戏 - 陆】暴击 Dash | 文字构件的使用
  • 【Flutter&Flame 游戏 - 柒】人随指动 | 动画点触与移动
  • 【Flutter&Flame 游戏 - 捌】装弹完毕 | 角色武器发射
  • 【Flutter&Flame 游戏 - 玖】探索构件 | Component 是什么
  • 【Flutter&Flame 游戏 - 拾】探索构件 | Component 生命周期回调
  • 【Flutter&Flame 游戏 - 拾壹】探索构件 | Component 使用细节
  • 【Flutter&Flame 游戏 - 拾贰】探索构件 | 角色管理
  • 【Flutter&Flame 游戏 - 拾叁】碰撞检测 | CollisionCallbacks
  • 【Flutter&Flame 游戏 - 拾肆】碰撞检测 | 之前代码优化
  • 【Flutter&Flame 游戏 - 拾伍】粒子系统 | ParticleSystemComponent
  • 【Flutter&Flame 游戏 - 拾陆】粒子系统 | 粒子的种类
  • 【Flutter&Flame 游戏 - 拾柒】构件特效 | 了解 Effect 体系
  • 【Flutter&Flame 游戏 - 拾捌】构件特效 | ComponentEffect 一族
  • 【Flutter&Flame 游戏 - 拾玖】构件特效 | 了解 EffectController 体系
  • 【Flutter&Flame 游戏 - 贰拾】构件特效 | 其他 EffectControler
  • 【Flutter&Flame 游戏 - 贰壹】视差组件 | ParallaxComponent
  • 【Flutter&Flame 游戏 - 贰贰】菜单、字体和浮层
  • 【Flutter&Flame 游戏 - 贰叁】 资源管理与国际化
  • 【Flutter&Flame 游戏 - 贰肆】pinball 源码分析 - 项目结构介绍
  • 【Flutter&Flame 游戏 - 贰伍】pinball 源码分析 - 资源加载与 Loading
  • 【Flutter&Flame 游戏 - 贰陆】pinball 源码分析 - 游戏主菜单界面
  • 【Flutter&Flame 游戏 - 贰柒】pinball 源码分析 - 角色选择与玩法面板
  • 【Flutter&Flame 游戏 - 贰捌】pinball 源码分析 - 游戏主场景的构成
  • 【Flutter&Flame 游戏 - 贰玖】pinball 源码分析 - 视口与相机

第一季完结,谢谢支持 ~


1. 弹球开源弹球游戏 pinball

在 I/O 2022 中, 官方用 Flutter 写的弹球小游戏,确实让我眼前一亮,开源地址在:【pinball】。这说明基本的碰撞、音乐、动画没有什么问题,用来做休闲小游戏是足够的,我也就没什么好担心的了。

所以,接下来将开启一个系列,研究 Flutter&Flame 的游戏 2D 休闲游戏开发。另外,为了录屏、截图方便,这里主要在 macOS 平台上运行,实现桌面版的 Flutter 游戏。


2. 本文目标

本文作为 Flame 最简使用,相当于一个 Hello World 级别的案例。

  • 项目搭建与资源配置
  • 播放背景音乐
  • 显示如下的人物动作


二、项目搭建

1. 依赖与资源配置

首先在 pubspec.yaml 中引入 flame 和 flame_audio 包。

1
2
3
4
5
yaml复制代码---->[]----
dependencies:
 #...
flame: ^1.1.1
flame_audio: ^1.0.2

然后在根目录下创建 assets 目录,其中图片放在 images 文件夹下,音乐放在 audio 下,并在 pubspec.yaml 中配置对应的文件夹。这里的背景音乐,取自【pinball】 中,图片资源在网上找的。


2. 最简代码

这里先实现一下静态图片的展示 + 背景音乐播放:代码 【tag1-1】

目前 lib 代码结构如下:

1
2
3
css复制代码├── lib
│   ├── component.dart
│   └── main.dart

在 main.dart 里,runApp 方法传入 GameWidget 组件,其中 game 入参对象是自定义的 TolyGame 。继承自 FlameGame ,并重写 onLoad 方法,添加一个自定义的 HeroComponent 。另外通过 FlameAudio.play 方法播放音乐。

1
2
3
4
5
6
7
8
9
10
11
12
13
scala复制代码---->[main.dart]----
main() {
 runApp(GameWidget(game: TolyGame()));
 FlameAudio.play('background.mp3');
}


class TolyGame extends FlameGame {
 @override
 Future<void> onLoad() async {
   await add(HeroComponent());
}
}

在 component.dart 中,HeroComponent 继承自 SpriteComponent 。在 super 构造中指定 size 和 anchor 参数。覆写 onLoad 方法,为 sprite 成员实例化。在 onGameResize 中将位置居中,这样运行项目,就可以得到如下的效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
scala复制代码---->[component.dart]----
class HeroComponent extends SpriteComponent {

 HeroComponent() : super(size: Vector2(50,37), anchor: Anchor.center);

 @override
 Future<void> onLoad() async {
   sprite = await Sprite.load('adventurer/adventurer-bow-00.png');
}

 @override
 void onGameResize(Vector2 gameSize) {
   super.onGameResize(gameSize);
   position = gameSize / 2;
}
}

3. 初步分析

在自定义的 HeroComponent 中,我们操作了两个没有声明的对象,这说明肯定是在父类中声明过了。其中sprite 成员定义在 SpriteComponent 中,position 成员定义在 PositionComponent 中。

1
2
3
4
5
6
7
scala复制代码---->[源码-sprite_component.dart]----
class SpriteComponent extends PositionComponent with HasPaint {
 Sprite? sprite;
 
---->[源码-position_component.dart]----
@override
set position(Vector2 position) => transform.position = position;

简单瞄一下源码可以看到继承关系,所以在子类中可直接使用这两个属性。position 是 Vector2 对象,可以确定位置,sprite 是 Sprite 对象,可以确定资源。

1
2
3
lua复制代码PositionComponent
|--- SpriteComponent
|--- HeroComponent (自定义)

从这里可以简单感知到,在 Flame 中 Component 是一个比较重要的概念,它可以决定显示。为了避免和 Flutter 中的 Widget 组件 语义冲突, 这里称 Component 为 构件 。


三、多图人物的帧动画

上面简单地实现了展示一张图片,下面来看一下多帧的图片如何显示:代码 【tag1-2】


1. 代码实现

之所以看到射手在动,是因为在不断播放,如下文件夹是不同帧对应的图片,adventurer-bow 有 9 帧。


实现起来也非常简单,单图有 SpriteComponent 构件,多图也有对应的 SpriteAnimationComponent 构件。该类中内置声明了SpriteAnimation 类型的 animation 对象,所以在 onLoad 中初始化即可。其中 stepTime 用于控制运动的速度,这里 0.15 比较正常,数值越小,运动越快。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
scss复制代码class HeroComponent extends SpriteAnimationComponent {
 HeroComponent() : super(size: Vector2(50,37), anchor: Anchor.center);
 
 @override
 Future<void> onLoad() async {
   List<Sprite> sprites = [];
   for(int i=0;i<=8;i++){
     sprites.add(await Sprite.load('adventurer/adventurer-bow-0$i.png'));
  }
   animation = SpriteAnimation.spriteList(sprites, stepTime: 0.15);
}
 
 @override
 void onGameResize(Vector2 gameSize) {
   super.onGameResize(gameSize);
   position = gameSize / 2;
}
}

2. 本文小结

通过这个小案例,我们见到了几个类,这里来梳理一下。其中 GameWidget 是继承自 GameWidget 的组件,构造时必须传入 Game 类型的 game 入参。

1
2
3
4
5
6
scala复制代码class GameWidget<T extends Game> extends StatefulWidget {
   final T game;
 
   const GameWidget({
   Key? key,
   required this.game,

另外, FlameGame 类继承自 Component 并且混入 Game ,这也是为什么 FlameGame 子类可以作为 game 参数的原因。

1
scala复制代码class FlameGame extends Component with Game {

最后,最重要的莫过于 Component 构件,可以看出我们当前的代码都是围绕 Component 一族展开的。我们自定义类中覆写的 onLoad 、onGameResize 方法,都是定义在 Component 中的。另外 add 方法,可以添加一个 Component 对象,这是很明显的组合设计模式。

1
2
3
4
5
6
7
dart复制代码---->[源码-component.dart]----
Future<void>? onLoad() => null;

@mustCallSuper
void onGameResize(Vector2 size) => handleResize(size);

Future<void>? add(Component component) => component.addToParent(this);

在后面我们应该还会遇到功能各异的 Component ,或也可能自定义一个 Component 来实现某种特殊的功能。本文作为一个简单的引子,想介绍的就这么多,那就到这里,明天见 ~


  • @张风捷特烈 2022.05.26 未允禁转
  • 我的 公众号: 编程之王
  • 我的 掘金主页 : 张风捷特烈
  • 我的 B站主页 : 张风捷特烈
  • 我的 github 主页 : toly1994328

\

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

【若川视野 x 源码共读】第33期 arrify 转数组

发表于 2022-05-21

源码共读前言

为了能帮助到更多对源码感兴趣、想学会看源码、提升自己写作和前端技术能力的同学。 帮助读者夯实基础,查漏补缺,开阔眼界,拓宽视野,知其然知其所以然。

我倾力组织了每周一起学200行左右的源码共读活动。我写有《学习源码整体架构系列》20余篇。

欢迎点此扫码加我微信 ruochuan02 交流,参与 源码共读 活动,每周大家一起学习200行左右的源码,共同进步。可以持续关注我@若川。

从易到难推荐学习顺序

活动介绍和顺序具体看这里从易到难推荐学习顺序

语雀有树形菜单,更方便查看,所以也放下语雀的这一期链接

提交笔记

提交笔记方式
简言之:看任务,看辅助文章、看源码,交流讨论,在掘金写笔记,写好后提交到本文评论区。

为了给大家谋福利,另外给大家的文章带来更多阅读量,便于搜索,从2022年3月27日起,笔记可以直接发布在掘金,以《标题自取》标题不限,可以取个好标题,容易被掘金推荐。

笔记文章开头加两句话:

  • 本文参加了由公众号@若川视野 发起的每周源码共读活动, 点击了解详情一起参与。
  • 这是源码共读的第xx期,链接:xxx。

笔记文章最后,建议写上总结、收获、感受等。

  • 开头第一句作用是:方便每月统计评优,掘金赞助送小礼物。顺便帮忙宣传推广,让更多人参与进来,一起学习。
  • 开头第二句作用是:加上是多少期,当前任务说明的链接,方便读者知道这是什么活动。

笔记写完后,到当前期活动的文章评论区留言自己的文章和笔记特点。方便大家查阅学习交流讨论。

往期所有笔记存放在语雀讨论区。

任务发布时间

5月2日 - 5月29日。可以按照自己节奏学习,提交笔记即可(不一定要严格按照我规定的时间)。往期共读也可以及时复习,笔记未完成可以继续完成。

学习任务

  • github仓库地址 arrify
  • github1s: github1s.com/sindresorhu…
  • 这期源码行数不多, 就十几行。
  • 学习 Symbol.iterator 的使用场景
  • 这期比较简单,主要学会通过测试用例调试源码。 可以多关注怎么发布npm包的、esm、测试用例 、ts 等(也可以不关注)。
  • 建议克隆代码下来,关注测试用例,自己多通过测试用例调试,自己调试过才能够学会,感受更深一些。
  • 关于如何调试看这篇:新手向:前端程序员必学基本技能——调试JS代码
  • 根据大家问卷反馈情况,多设置一些相对简单的,先让大家参与进来,让大家觉得源码也不难。
  • 最后大家没填问卷的,有空抽几分钟来填下源码共读活动问卷~你们的反馈至关重要wj.qq.com/s2/9304505/…

参考文章

  • 深入浅出package.json
  • ES6标准入门 Iterator 和 for…of 循环
  • mdn Symbol.iterator
  • mdn 迭代器和生成器
  • 看文章,看源码,交流讨论,写笔记发布在掘金/语雀。再在这篇文章下评论放上提交笔记的链接。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

深入浅出packagejson

发表于 2022-05-18

 npm是前端开发人员广泛使用的包管理工具,项目中通过package.json来管理项目中所依赖的npm包的配置。package.json就是一个json文件,除了能够描述项目的包依赖外,允许我们使用“语义化版本规则”指明你项目依赖包的版本,让你的构建更好地与其他开发者分享,便于重复使用。
本文主要从最近的实践出发,结合最新的npm和node的版本,介绍一下package.json中一些常见的配置以及如何写一个规范的package.json
  • package.json
  • package.json常用属性
  • package.json环境相关属性
  • package.json依赖相关属性
  • package.json三方属性

一、package.json

1. package.json简介

在nodejs项目中,package.json是管理其依赖的配置文件,通常我们在初始化一个nodejs项目的时候会通过:
1
js复制代码npm init

然后在你的目录下会生成3个目录/文件, node_modules, package.json和 package.lock.json。其中package.json的内容为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
js复制代码{
"name": "Your project name",
"version": "1.0.0",
"description": "Your project description",
"main": "app.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
},
"author": "Author name",
"license": "ISC",
"dependencies": {
"dependency1": "^1.4.0",
"dependency2": "^1.5.2"
}
}

上述可以看出,package.json中包含了项目本身的元数据,以及项目的子依赖信息(比如dependicies等)。

2. package-lock.json

我们发现在npm init的时候,不仅生成了package.json文件,还生成了package-lock.json文件。那么为什么存在package.json的清空下,还需要生成package-lock.json文件呢。本质上package-lock.json文件是为了锁版本,在package.json中指定的子npm包比如:react: "^16.0.0",在实际安装中,只要高于react的版本都满足package.json的要求。这样就使得根据同一个package.json文件,两次安装的子依赖版本不能保证一致。


而package-lock文件如下所示,子依赖dependency1就详细的指定了其版本。起到lock版本的作用。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
js复制代码{
"name": "Your project name",
"version": "1.0.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
"dependency1": {
"version": "1.4.0",
"resolved":
"https://registry.npmjs.org/dependency1/-/dependency1-1.4.0.tgz",
"integrity":
"sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA=="
},
"dependency2": {
"version": "1.5.2",
"resolved":
"https://registry.npmjs.org/dependency2/-/dependency2-1.5.2.tgz",
"integrity":
"sha512-WOn21V8AhyE1QqVfPIVxe3tupJacq1xGkPTB4iagT6o+P2cAgEOOwIxMftr4+ZCTI6d551ij9j61DFr0nsP2uQ=="
}
}
}

二、package.json常用属性

本章来聊聊package.json中常用的配置属性,形如name,version等属性太过简单,不一一介绍。本章主要介绍一下script、bin和workspaces属性。

2.1 script

在npm中使用script标签来定义脚本,每当制定npm run的时候,就会自动创建一个shell脚本,这里需要注意的是,npm run新建的这个 Shell,会将本地目录的node\_modules/.bin子目录加入PATH变量。


这意味着,当前目录的node\_modules/.bin子目录里面的所有脚本,都可以直接用脚本名调用,而不必加上路径。比如,当前项目的依赖里面有 esbuild,只要直接写esbuild xxx 就可以了。
1
2
3
4
5
6
js复制代码{
// ...
"scripts": {
"build": "esbuild index.js",
}
}
1
2
3
4
5
6
js复制代码{
// ...
"scripts": {
"build": "./node_modules/.bin/esbuild index.js"
}
}

上面两种写法是等价的。

2.2 bin

bin属性用来将可执行文件加载到全局环境中,指定了bin字段的npm包,一旦在全局安装,就会被加载到全局环境中,可以通过别名来执行该文件。

比如@bytepack/cli的npm包:

1
2
3
js复制代码"bin": {
"bytepack": "./bin/index.js"
},

一旦在全局安装了@bytepack/cli,就可以直接通过bytepack来执行相应的命令,比如

1
2
arduino复制代码bytepack -v
//显示1.11.0

如果非全局安装,那么会自动连接到项目的node_module/.bin目录中。与前面介绍的script标签中所说的一致,可以直接用别名来使用。

2.3 workspaces

在项目过大的时候,最近越来越流行monorepo。提到monorepo就绕不看workspaces,早期我们会用yarn workspaces,现在npm官方也支持了workspaces.
workspaces解决了本地文件系统中如何在一个顶层root package下管理多个子packages的问题,在workspaces声明目录下的package会软链到最上层root package的node\_modules中。


直接以官网的例子来说明:
1
2
3
4
5
6
js复制代码{
"name": "my-project",
"workspaces": [
"packages/a"
]
}

在一个npm包名为my-project的npm包中,存在workspaces配置的目录。

1
2
3
4
5
6
js复制代码.
+-- package.json
+-- index.js
`-- packages
+-- a
| `-- package.json

并且该最上层的名为my-project的root包,有packages/a子包。此时,我们如果npm install,那么在root package中node_modules中安装的npm包a,指向的是本地的package/a.

1
2
3
4
5
6
7
8
js复制代码.
+-- node_modules
| `-- packages/a -> ../packages/a
+-- package-lock.json
+-- package.json
`-- packages
+-- a
| `-- package.json

上述的

1
js复制代码-- packages/a -> ../packages/a

指的就是从node_modules中a链接到本地npm包的软链

三、package.json环境相关属性

常见的环境,基本上分为浏览器browser和node环境两大类,接下来我们来看看package.json中,跟环境相关的配置属性。环境的定义可以简单理解如下:
  • browser环境:比如存在一些只有在浏览器中才会存在的全局变量等,比如window,Document等
  • node环境: npm包的源文件中存在只有在node环境中才会有的一些变量和内置包,内置函数等。

3.1 type

js的模块化规范包含了commonjs、CMD、UMD、AMD和ES module等,最早先在node中支持的仅仅是commonjs字段,但是从node13.2.0开始后,node正式支持了ES module规范,在package.json中可以通过type字段来声明npm包遵循的模块化规范。
1
2
3
4
5
js复制代码//package.json
{
name: "some package",
type: "module"||"commonjs"
}

需要注意的是:

  • 不指定type的时候,type的默认值是commonjs,不过建议npm包都指定一下type
  • 当type字段指定值为module则采用ESModule规范
  • 当type字段指定时,目录下的所有.js后缀结尾的文件,都遵循type所指定的模块化规范
  • 除了type可以指定模块化规范外,通过文件的后缀来指定文件所遵循的模块化规范,以.mjs结尾的文件就是使用的ESModule规范,以.cjs结尾的遵循的是commonjs规范

3.2 main & module & browser

除了type外,package.json中还有main,module和browser 3个字段来定义npm包的入口文件。
  • main : 定义了 npm 包的入口文件,browser 环境和 node 环境均可使用
  • module : 定义 npm 包的 ESM 规范的入口文件,browser 环境和 node - 环境均可使用
  • browser : 定义 npm 包在 browser 环境下的入口文件

我们来看一下这3个字段的使用场景,以及同时存在这3个字段时的优先级。我们假设有一个npm包为demo1,

1
2
3
4
5
js复制代码----- dist
|-- index.browser.js
|-- index.browser.mjs
|-- index.js
|-- index.mjs

其package.json中同时指定了main,module和browser这3个字段,

1
2
3
4
5
6
7
8
9
10
js复制代码  "main": "dist/index.js",  // main 
"module": "dist/index.mjs", // module

// browser 可定义成和 main/module 字段一一对应的映射对象,也可以直接定义为字符串
"browser": {
"./dist/index.js": "./dist/index.browser.js", // browser+cjs
"./dist/index.mjs": "./dist/index.browser.mjs" // browser+mjs
},

// "browser": "./dist/index.browser.js" // browser

默认构建和使用,比如我们在项目中引用这个npm包:

1
js复制代码import demo from 'demo'

通过构建工具构建上述代码后,模块的加载循序为:

browser+mjs > module > browser+cjs > main

这个加载顺序是大部分构建工具默认的加载顺序,比如webapck、esbuild等等。可以通过相应的配置修改这个加载顺序,不过大部分场景,我们还是会遵循默认的加载顺序。

3.3 exports

如果在package.json中定义了exports字段,那么这个字段所定义的内容就是该npm包的真实和全部的导出,优先级会高于main和file等字段。

举例来说:

1
2
3
4
5
6
7
js复制代码{
"name": "pkg",
"exports": {
".": "./main.mjs",
"./foo": "./foo.js"
}
}
1
js复制代码import { something } from "pkg"; // from "pkg/main.mjs"
1
js复制代码const { something } = require("pkg/foo"); // require("pkg/foo.js")

从上述的例子来看,exports可以定义不同path的导出。如果存在exports后,以前正常生效的file目录到处会失效,比如require(‘pkg/package.json’),因为在exports中没有指定,就会报错。

exports还有一个最大的特点,就是条件引用,比如我们可以根据不同的引用方式或者模块化类型,来指定npm包引用不同的入口文件。
1
2
3
4
5
6
7
8
9
10
js复制代码// package.json
{
"name":"pkg",
"main": "./main-require.cjs",
"exports": {
"import": "./main-module.js",
"require": "./main-require.cjs"
},
"type": "module"
}

上述的例子中,如果我们通过

1
js复制代码const p = require('pkg')

引用的就是”./main-require.cjs”。

如果通过:

1
js复制代码import p from 'pkg'

引用的就是”./main-module.js”

最后需要注意的是 :
如果存在exports属性,exports属性不仅优先级高于main,同时也高于module和browser字段。

三、package.json依赖相关属性

package.json中跟依赖相关的配置属性包含了dependencies、devDependencies、peerDependencies和peerDependenciesMeta等。

dependencies是项目的依赖,而devDependencies是开发所需要的模块,所以我们可以在开发过程中需要的安装上去,来提高我们的开发效率。这里需要注意的时,在自己的项目中尽量的规范使用,形如webpack、babel等是开发依赖,而不是项目本身的依赖,不要放在dependencies中。

dependencies除了dependencies和devDependencies,本文重点介绍的是peerDependencies和peerDependenciesMeta。

3.1 peerDependencies

peerDependencies是package.json中的依赖项,可以解决核心库被下载多次,以及统一核心库版本的问题。

1
2
3
4
5
js复制代码//package/pkg
----- node_modules
|-- npm-a -> 依赖了react,react-dom
|-- npm-b -> 依赖了react,react-dom
|-- index.js

比如上述的例子中如果子npm包a,b都以来了react和react-dom,此时如果我们在子npm包a,b的package.json中声明了PeerDependicies后,相应的依赖就不会重新安装。

需要注意的有两点:

  • 对于子npm包a,在npm7中,如果单独安装子npm a,其peerDependicies中的包,会被安装下来。但是npm7之前是不会的。
  • 请规范和详细的指定PeerDependicies的配置,笔者在看到有些react组件库,不在PeerDependicies中指定react和react-dom,或者将react和react-dom放到了dependicies中,这两种不规范的指定都会存在一些问题。
  • 其二,正确的指定PeerDependicies中npm包的版本,react-focus-lock@2.8.1,peerDependicies指定的是:”react”: “^16.8.0 || ^17.0.0 || ^18.0.0”,但实际上,这个react-focus-lock并不支持18.x的react

3.2 peerDependenciesMeta

看到“Meta”就有元数据的意思,这里的peerDependenciesMeta就是详细修饰了peerDependicies,比如在react-redux这个npm包中的package.json中有这么一段:
1
2
3
4
5
6
7
8
9
10
11
js复制代码 "peerDependencies": {
"react": "^16.8.3 || ^17 || ^18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
}
}

这里指定了”react-dom”,”react-native”在peerDependenciesMeta中,且为可选项,因此如果项目中检测没有安装”react-dom”和”react-native”都不会报错。

值得注意的是,通过peerDependenciesMeta我们确实是取消了限制,但是这里经常存在非A即B的场景,比如上述例子中,我们需要的是“react-dom”和"react-native"需要安装一个,但是实际上通过上述的声明,我们实现不了这种提示。

四、package.json三方属性

package.json中也存在很多三方属性,比如tsc中使用的types、构建工具中使用的sideEffects,git中使用的husky,eslint使用的eslintIgnore,这些扩展的配置,针对特定的开发工具是有意义的这里不一一举例。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

1…949596…956

开发者博客

9558 日志
1953 标签
RSS
© 2025 开发者博客
本站总访问量次
由 Hexo 强力驱动
|
主题 — NexT.Muse v5.1.4
0%