UE GAS进阶-深入理解GE

本篇文章主要是针对GE的一些细节进行深入的解读,需要具备GAS的相关基础,我也会尽量减少贴代码,有需要的读者可以去看源码,结合着本文可以有更深入的理解,本文很多地方都会相对简述。我认为一个好的学习方式是从问问题开始的,从一个问题开始,回答,然后继续提问,回答,直到终点为止,当绝大多数问题得到解答以后,对相关的知识也就能够掌握了。

本篇文章由四个部分组成:解析Duration Effect,解析GE的预测,Stack Effect的原理,扩展GE功能。本篇文章也将从一个问题开始,首先就是

Duration Effect是如何生效的?

对GAS有一个基础的了解后,我们会知道,Effect本身并不会实例化,当ApplyEffect后,会生成一个实例,即FActiveGameplayEffect,它会储存在ASC的ActiveContainer中。游戏内所有的GE效果都会储存在这里,同时模拟端的GE也是通过它进行网络同步的(在默认模式下,只有主控端的ActiveEffect会同步)

对这个流程感兴趣的可以自己去看函数UAbilitySystemComponent::ApplyGameplayEffectSpecToSelf ,会调用ActiveGameplayEffects.ApplyGameplayEffectSpec(Spec, PredictionKey, bFoundExistingStackableGE) 生成实例化GE,具体的过程我就不赘述了,有一个概念就行。

我们已经有了实例化的GE——ActiveEffect了,它可以被认为是Duration Effect本身,它已经被Add进ActiveEffectContainer中了,但此时它并没有任何实际的效果,很自然地就会需要提问,Duration Effect是在什么地方开始生效的,这关联了以下两个的问题:

GE又是如何激活并生效的,它的激活与关闭是如何实现的的?

这两个问题是相互关联的,当ActiveEffectContainer创建GE时,会调用函数InternalOnActiveGameplayEffectAdded ,在这里激活GE让其生效,这边函数实际意思为,关闭AcitiveGE,输入为true关闭GE,false激活GE,方便理解我就直接把这里的行为称作激活了GE。

需要注意的是,这里的Inhibit指的是让GE激活和关闭效果,并不会将GE移除,和Add和Remove的概念是不一样的。可以理解为GE首先生成ActiveEffect实例,被添加Add到ActiveEffectContainer中,然后通过激活(!Inhibit)GE产生实际的效果,包括添加tag,修改属性等。

有了激活,我们很容易就会想到一个问题,GE里可以通过配置,让ActiveGE在某些tag下生效,没有这些tag的时候无效,是怎么实现的呢?没错,同样是通过调用InhibitGE这个函数。

如果看过我之前解析GAS在5.3改动的那篇文章,就会知道GE在5.3之后,功能是通过EffectComponent去实现的,而TargetTagRequirementGameplayEffectComponent的作用是,在满足特定Tag条件后,会添加移除,或者激活关闭GE。

它的实现方式为,在GE被添加时,监听ASC中tag变化的消息,当tag发生变化时,会触发函数UTargetTagRequirementsGameplayEffectComponent::OnTagChanged ,然后判断是否满足GE激活的条件,如果不满足,则调用Inhibit函数去关闭GE。

有了这个例子作为参考,我们可以很衍生出去,是否可以对GE地功能进行扩展,能不能在特定属性的条件下添加移除GE,或者激活关闭GE呢?

答案是YES,我会在文章的最后,实现这个功能扩展。感谢UE5.3的更新,让类似的功能可以比以前更加方便优雅地实现了。

到了这里,我们了解到GE在被Add到Container中后,会被激活(!Inhibit),GE的效果是在这里生效的,接下来的问题是:

GE激活后,会影响哪些属性呢?

GE生效的函数为AddActiveGameplayEffectGrantedTagsAndModifiers

在这里只考虑非Period的Duration GE,Period GE的实现是通过设置一个Timer,周期性地调用ExecutePeriodicEffect 去生效,可以简单的理解为周期性执行的Instant GE,这里就不详细讨论了。

GE的效果大致有以下几个部分组成:Attribute,Tag,Ability,Cue

Attribute

首先从Attribute开始,Duration GE首先影响的自然就是属性,GE会修改角色的Attribute的Current值,相信读这篇文章的应该都对Attribute的base值和current值有所了解,两者可以大致理解为基础值,和受到各种GE影响后的实际值。base值比较好理解,因为修改后和GE本身就无关了,但是current值是如何实现的呢?这个问题等下会深入的讲解,现在暂时放下不表,我们继续看GE还有什么效果。

Tag

Tag的修改比较简单,获取GE Def和Spec中的Grant Tags,然后调用ASC的UpdateTagMap对CountTagContainer进行修改,比较简单,不赘述了。

然后通过ASC对Tag进行网络的属性同步

这里需要注意的是,在模拟端,默认情况下ActiveEffect并不会同步,可以理解为在模拟端,GE是不存在的,GE效果是通过服务器上Tag, Attribute,Cue等效果的同步,在模拟端产生效果的。

Ability

给予Ability,此功能在此函数里已经被废弃,5.3以后是通过GE Component去给予Ability的。

读者可以通过在GE中配置UAbilitiesGameplayEffectComponent 让GE具有Give Ability的功能。

GE对Ability的影响还有一个,可以block ability,依据Ability中配置的BlockTags条件,停止当前正在激活的技能。

Cue

最后一个GE的效果是触发Gameplay Cue,逻辑大致为,遍历配置需要触发的GameplayCues,然后修改角色的Tags,让其添加对应的Cue Tag,然后调用InvokeGameplayCueEvent 让Cue在本地生效,最后让Cue通过同步在模拟端生效。

从这里可以看出,Cue Tag虽然添加到ASC上,但它实际并没有被网络同步,是一个Local Tag。

扩展

除了以上这些基础的功能,我们还可以通过GE Component,很简单的让GE产生更多的效果,与其它的功能模块进行互动。假设,我是说假设我们要做一个高达游戏(笑 ),有的高达可以形态变化,在人形和飞机形态进行切换,其它的程序实现并提供了变身的功能模块,它会切换高达的Mesh,状态机等,并向我们提供了接口。我们如何接入呢?

我们可以将变身视为一种Buff状态,用GE进行管理。然后派生一个UGameplayEffectComponentUTransformGameplayEffectComponent ,用于管理变身状态。这方面可以参考UAbilitiesGameplayEffectComponent ,我这里大致说一下实现的思路。

首先重写OnActiveGameplayEffectAdded 函数,然后绑定OnInhibitionChanged的广播,它会在GE激活和关闭时触发

然后在 OnInhibitionChanged 中,调用其它程序小伙伴给我们提供的接口,去开启和关闭变身。参考AbilityEffectComponent,则是添加和移除Ability。

OK,我们知道了GE在激活后,会影响哪些属性,并且我们可以扩展让他有更多的效果了。那么我们继续提出新的问题,在知道GE在激活时可以影响属性后,这个影响是如何管理的?比如多个GE同时影响同一个属性,这个属性的最终的实际值是怎么确定的?比如GE被移除后,被它影响的属性又是如何还原为原有状态的?这些问题的第一层回答是,Duration GE影响的是属性的current值,不会影响base值,那么我们继续提问:

Attribute的current value是如何实现的?

这个问题最简单的回答是,属性的结构体FGameplayAttributeData中有两个属性,BaseValue和CurrentValue,一个是基础值,一个是受外界影响的值。这也是绝大多数教程解答的,但它却解释不了GE对属性的影响是如何被管理的,接下来我会讲述我个人的理解。

首先从头开始缕,Attribute都具有Base值,它可以被理解为一种固有的属性,一旦修改便会永久改变。然后Current值可以理解为Base值被各种”暂时“效果修饰后的值。这种修饰就是一种Mod,GAS中的Mod有Add, Multiply, Divide, Override四种。最终的current值就是由多个Mod共同影响后决定的。

这里需要注意的是,Multiply和Divide并不是多次相乘或者相除,比如有两个 Multiply1.2的效果,实际的乘数不是1.44,而是1+0.2+0.2=1.4,divide也是类似的。

我把这样一次计算Current值的过程称为实际属性计算,计算顺序如下:

  1. 如果有Override的Mod,则直接使用override value作为输出
  2. 累计相同符号的mod,比如多个add mod:10,20会合成为30,多个multiply mod:1.2,1.5会合成为1.7
  3. 对属性的base值,先加,后乘,再除最后输出作为结果

接下来看看代码,一个基础的Mod如下,包括三个参数,第一个是Channel,这个我们先暂时不管,第二个是Mod符号,第三个为Mod数据,这个数据中最核心的是EvaluatedMagnitude,即Mod修饰的大小。

ModInfo则被存储在FAggregatorModChannel中。先不要管Channel的定义,可以把它理解为用于存储某一个属性所有的Mod。

可以看到所有的Mod都存储在ModChannel中的Mods处

计算Current值的方法就是上图说明的方式,假设Health属性base=100,additive mod: 20, 30,multiply mod: 1.2, 1.5,divide mod: 1.2,1.2

  1. 如果有Override Mod,直接返回Mod值
  2. 将不同运算符的Mod累加,additive mod sum=20+30=50,multiply mod sum=1+(1.2-1)+(1.5-1)=1.7,divide mod sum=1+(1.2-1)+(1.2-1)=1.4
  3. 实际结果 result=(100+50)*1.7/1.4=182

代码贴在下面了,有兴趣可以自己去看看。

上面ModInfo和ModChannel已经可以实现base和current值区分的,对于GE来说,只需要在激活的时候创建ModInfo并添加到属性对应的ModChannel中,然后根据base value计算出current value。但这个时候我们再提出一个新的问题,如果策划希望支持乘区的概念,我们怎么支持呢?比如希望对攻击力的增加,一部分受到乘法的影响,另一部分则不会,如何实现?

当前的这个结构毫无疑问,并不能通过一个单一的攻击属性实现这个功能,当然可以通过增加AttackPercent,AttackExtra等额外的属性来解决这个问题,甚至我认为这可能是更好的解决方案,但这里我要隆重的提到GAS里Attribute Mod Channel的概念,目前我似乎没有看到国内的文章提到过。

没错,GAS里已经给实际属性计算划分了Channel,我们可以根据需求将GE添加的Mod添加到希望对应的Channel中,然后按照不同的Channel去分别计算,实际current的计算就不是上面提到的只进行了一次计算,而是下方的样子。

在多个Channel的情况下,会将上一个Channel的实际属性计算的最终结果result,作为下一个Channel的实际属性计算的输入,直到所有的Channel都计算完毕,最终输出的result为属性当前的current value。

接下来看实际的代码,可以看到结构体ModChannelContainer中,储存了Channels的Map,当前枚举ModEvaluationChannel最多支持10个Channel,默认情况下只有Channel 0生效,我们如果去修改GAS代码,也可以支持更多,但感觉就没有必要了。

查看代码,计算就是按照上图所示,遍历所有的Channel,将上一个Channel的输出作为下一个Channel的base输入,直到计算出最终的结果作为current值。

有了多条Channel,上述那个”讨厌“的策划的需求就可以得到解决了,需要受到乘法影响的攻击力加值,放到Channel 0中,不需要受到乘法影响的攻击力放到Channel 1中,那么实际的攻击力为:

Attack = ((Attack Base + Attack_Add_0)*Attack_Multiply_0) + Attack_Add_1

Perfect!但是需要注意,默认情况下,GAS只支持Channel0,那么如何打开多Channel的功能呢? 我也放在文章的末尾进行解答。

有了ModChannelContainer后,我们介绍最后一个结构体——Aggregator,ModChannelContainer存储在Aggregator中

读者可能会有一个疑问,明明ModChannelContainer已经支持计算实际属性值了,只需要在GE激活和关闭时,添加和移除Container内的Mod即可,为什么还要增加一个aggregator呢?

关键在于GE修改某个属性,是可以基于另外的属性的,比如说,狂战士的有一个狂暴Buff,他的攻击力会根据血量百分比的减少而增加,那么当狂战士的生命值减少时,狂暴buff增加的攻击力也应该发生变化。在GE中选择MagnitudeCalculationType为AttributeBased后,如果不选择SnapShot,就可以实现上述的功能,GAS中又是如何实现属性依赖的功能呢?
在这里插入图片描述

首先,在填写GE时,如果设置了Backing Attribute后,那么程序就可以知道这个GE需要依赖哪些Attribute,比如上面那个例子里,狂暴buff需要依赖的属性是生命值。那么我们只需要把狂暴buff的Effect Handle存储在生命属性的Aggregator中,在生命值发生变化时,先找到对应的Effect Handle,找到狂暴buff,然后知道这个buff会依据生命值去修改攻击力,接下来就可以依据新的生命值重新更新攻击力加成即可。

我们来看看Aggregator中,是如何实现的。可以看到FAggregator中,ModChannels即上面提到的ChannelContainer,用于计算实际属性值,这里的Depedents就代表了依赖于此属性的Active Effects,以上面的例子来说,生命值属性的的Aggrator中,依赖于生命值的GE就包括了狂暴buff。

这个依赖会在ActiveEffectContainer的函数ApplyGameplayEffectSpec 中注册,感兴趣的可以自己去看看,这边就不仔细说了。

有了依赖关系后,假设属性发生变化后,会调用BroadcastOnDirty 去通知依赖于此属性的属性更新。

打开这个函数可以看到,里面最多的代码就是解决无限循环的问题,比如攻击力依赖于生命,生命又依赖于攻击力,那么攻击力发生改变后,这个链就会一直持续下去,官方的做法是添加了一个计数器,BroadcastingDirtyCount ,当它大于10的时候会停止更新依赖属性。我个人认为还是从设计层面上避免循环依赖为好,在可能产生循环依赖的地方,使用SnapShot快照进行替代。

核心代码就比较简单了,循环所有依赖的于此属性的ActiveEffect,然后通过函数OnMagnitudeDependencyChange 更新属性。大致的方式就是遍历改Effect全部的Modifiers,然后根据依赖属性新的值重新计算出新的ModInfos,先移除老的ModInfos,再把更新后的Add到所修改属性的Aggregator中去。

这里又会调用BroadcastOnDirty 去更新依赖于更新的属性的属性,无限循环的问题就是在这里产生的。

至此,Duration Effect是如何生效的,应该就可以得到一个相对完成的解答了,它首先会生成一个实例Active Effect,并被添加到Active Effect Container中,然后被激活,在这里会生成GE属性效果的一系列Mod,存储到GE所要修改的属性的Aggregator中,然后Aggregator会根据存储的Channel里的所有Mod,重新计算出属性的current value,更新后的属性值则会通过属性同步,从服务端发送到客户端。

当GE被移除时,也是类似的流程,首先Active Effect会被关闭,它所修改的属性的Aggregator会根据Active Effect Handle找到此GE生成的所有Mod,将其从Aggregator中移除,然后Aggregator会重新计算current value并同步到客户端。接着GE会被从Active Effect Container中移除,然后此消息也会通过FastArrayItem的属性同步,在客户端将对应的Active Effect给移除掉。

至此,Duration Effect产生效果的链条就很清晰了。这里只以Attribute效果作为研究的对象,其它包括Cue其实也有很多的技巧和可研究的地方,但碍于篇幅,这期我就不赘述了。

勇者面对BOSS


GE是如何实现预测的?

这一部分我会分析一下GE的预测机制,对于详细解释GAS中预测的实现机制的文章,市面上已经有很多了,都写得很好,所以我这边就大致讲解一下,并不会仔细的分析源码,想要深入学习的可以去搜一下别人的文章。

GE的预测实际上很简单,在玩家的主控端可以生成PredictionKey,然后主控端需要释放GE时,可以在调用ApplyGameplayEffect时将有效的PredictionKey作为参数输入

FActiveGameplayEffectsContainer::ApplyGameplayEffectSpec 函数中,在本地生成了一个预测的ActiveEffect,将代码拉到函数的最下面,可以看到在主控端,会将函数RemoveActiveGameplayEffect_NoReturn 绑定到PredictionKey的Reject和CaughtUp回调上。回调函数做的事情很简单,就是将预测GE给移除掉。

这意味着无论服务器判定这次ApplyGameplayEffect的结果是有效的还是无效的,当PredictionKey触发RPC回调后,客户端的本地预测GE都会被移除,本地预测GE只是作为一个临时的数据存在,无论成功或是失败,最后都会被销毁

接下来讲讲服务器上的行为,首先要明确的是,ApplyGameplayEffect的函数并没有RPC,也就是客户点调用并不会让服务器也触发ApplyGameplayEffect,无论在客户端和服务器上,释放GE都是独立的行为,他们之间的关联只是通过PredictionKey,服务器会将本地的预测GE给移除掉。

那么我们在技能启动的时候,如果调用ApplyGameplayEffect,服务端和客户端都会触发呀,这是如何做到的呢?很简单,启动技能这个行为是在玩家客户端发起的,ActivateAbility有RPC调用会触发Server_ActivateAbility,将客户端生成的PredictionKey传给服务器。因此服务器和客户端的ApplyEffect行为是在ActivateAbility函数中彼此独立的调用的,他们之间的关联是通过客户端上传的PredictionKey来建立的。

对于服务端,在创建ActiveEffect后,会走到上面代码的if分支,调用ActiveEffectContainer的MarkItemDirty函数,通过FastArray的属性同步,将ActiveEffect的数据从服务器同步到客户端。

对于这里具体的案例来说,在MarkItemDirty后,会触发函数FActiveGameplayEffect::PostReplicatedAdd ,这里涉及到虚幻TArray快速网络同步的概念。在这里,会将同步到客户端的ActiveEffect添加进ActiveEffectContainer中。

由此可以看出,本地GE预测的回滚和服务端的GE同步是两个独立的行为。

测试案例

虽然我的解释已经是极度简化说明的GE预测了,但是相信还是会有很多读者觉得不够直观,因此我在这里会通过几个例子来说明GE释放的不同情况。为了测试,我会将网络延迟调整到500ms以上。

案例 1

在本地预测启动技能,在客户端技能ActivateAbility时,直接给自己ApplyEffect,效果为本地立刻添加GE_Test,然后延迟过后,服务器也调用了ActivateAbility→ApplyEffect,添加了GE_Test,接着客户端本地的GE_Test会被移除,服务器的GE_Test被同步下来。

案例 2

在原有的基础上进行如下修改,服务器上此技能不能启动,客户端则可以。那么GE_Test会怎么样呢?

实际的情况会是,玩家的客户端成功启动技能添加上了预测的GE_Test,然后延迟过后,服务端的技能启动失败,没有添加上GE_Test,然后PredictionKey回调后,客户端的预测GE被移除。两端都不存在GE_Test了。

案例 3

再进行一个小修改,技能延迟之后,我们延迟一帧再ApplyEffect,此时GE_Test是否会被添加到玩家客户端呢?

答案是不会,此时玩家的本地客户端将不会生成预测的GE_Test,实际的情况是,玩家本地预测启动了技能,但是GE_Test则不会被预测,服务端判断技能无法启动,然后调用Client Fail的函数,将玩家端的技能给End掉。

为什么这种情况GE_Test不会被预测呢?因为本地的预测都依赖于有效的PredictionKey,它被包裹在ScopeWindow中,而ScopeWindow的有效生命周期是在启动技能的函数闭包内,当函数执行完将会被析构,此后的PredictionKey都将会无效。简单理解来说,新生成的PredictionKey只在当前帧生效。因为没有有效的PredictionKey,本地无法预测GE。

直面恐惧,创造未来


Stack是如何实现的?

接下来需要解决的问题是,GE的堆叠是如何实现的。首先还是回到一切的起点,查看函数FActiveGameplayEffectsContainer::ApplyGameplayEffectSpec ,在一开始根据Spec找到堆叠GE已经存在的ExistingStackableGE,找的方法大致是遍历Container中所有的ActiveGE,条件为:Def和输入的Effect相同,且StackingTypr不等于None,Duration不等于instant等,找到后将ActiveEffect返回

Overflow

如果当前ActiveEffect的stack count等于def的StackLimitCount,表示新的GE已经无法继续堆叠了,处理overflow。

第一种情况是当OverFlow发生后,会Apply Overflow Effects,这个作用的例子为:当地方身上的燃烧buff达到五层后,会触发爆炸。

第二种情况是overflow后移除effect,即buff叠满后,移除当前的buff。

更新StackCount

处理完overflow的可能后,开始计算新的堆叠数量

更新Duration

如果GE的DurationRefreshPolicy为NeverFresh,设置bSetDuration为false,如果bSetDuration为true,此参数会在后面重新刷新GE的持续时间

RestartActiveGameplayEffectDuration 函数中,会更新Effect的StartServerWorldTime,StartWorldTime
在这里插入图片描述

注册Timer,这里会在堆叠GE首次创建,或者刷新时调用,时长为GE的持续时间。当然这里设置的是GE的持续时间,是针对所有的Duration Effect,而不仅仅是Stack Effect

在最后通知StackCountChange,更新AggregatorModMagnitudes,更新tag,广播EventSet里的OnStackChanged

DurationExpired

当Stack Effect持续时间到后触发,FActiveGameplayEffectsContainer::CheckDuration

依据不同的情况处理StackEffect, 处理的方式分别为,全部移除,移除一个StackCount然后刷新持续时间,不移除StackCount然后刷新持续时间

终幕


GE功能拓展

这个部分介绍一些扩展GE功能的方式

基于属性添加移除GE

在这里实现一个扩展GE的功能,通过GE Component实现:当指定属性条件满足后,移除当前GE。举个例子,还用之前提到的狂战士Buff,当角色生命值大于80%时,自动移除狂暴buff。

实现这个功能后,读者可以很轻松的举一反三,实现根据属性条件激活关闭GE;或者只有满足指定属性,GE才能被释放;又或者满足特定属性条件后,释放额外的GE等功能。

这里讲解一下思路:

首先创建一个结构体AttributeCondition,里面的数据是:属性,属性条件,还有一个函数,当特定的属性满足条件时,返回true。这个代码我不就不贴了,应该比较简单。

然后创建GameplayEffectComponent的派生类。其中有成员变量RemovalAttributeCondition,作用是说明当特定的属性满足条件后,应将此Component的GE给移除。
在这里插入图片描述
创建函数RegisterAttributeListener,作用是绑定指定属性的AttributeChange回调。注意还需要一个配套的UnregisterAttributeListener去解除回调绑定,这里就不贴代码了。

核心代码其实就最后这一句话,监听指定属性的变化,然后触发OnAttributeChange函数,在这里判断属性条件,决定是否应该将GE移除。这里需要将DelegateHandle的引用返回

接下来重写函数OnActiveGameplayEffectAdded,此函数会在GE实例化并被添加到ActiveEffectContainer中时调用

这里要做的是两件事,第一件是调用前面实现的RegisterAttributeListener,监听指定属性的变化,然后将对应的属性和委托存在AllBoundEvents中,因为Gameplay Effect Component并没有被实例化,所以里面并不能存储数据,我们需要将数据通过绑定OnEffectRemove的回调,触发OnActiveGameplayEffectRemoved函数,并将AllBoundEvents数据传进去。

然后实现函数OnAttributeChange,它的作用是判断监听的属性变化后,是否满足了移除条件,如果满足则移除此ActiveEffect

最后实现函数OnActiveGameplayEffectRemoved,它会在GE从ActiveEffectContainer中移除时触发,这里要做的事情就是,将添加的属性回调给移除掉。

大功完成,最终效果如下图所示,当生命值大于等于80%时,GE会被移除。

如何应用CustomChannel

这里介绍如何在项目中启用多个Channels

首先打开DefaultGame.ini,在AbilitySystemGlobals下填写红框里的代码,最多可以支持10个Channels

然后打开编辑器,创建一个GE,在Modifier处就可以看到支持选择Channel了

然后测试一下,在默认状态下HealthMax=500,实现一个GE的效果为Add 100,Multiply 1.2,释放GE后,HealthMax = (500+100)*1.2=720

如果我们额外释放一个GE,再加上100的生命值,那最后实际的生命值为

HealthMax = (500+100+100)*1.2=840

当我们使用新添加的Channel1 TestChannel去添加这100的生命值,Channel0和Channel1的数值是分别计算的,现在生命值为820而不是840

其中计算的顺序是这样的:

  1. 首先计算Channel0 = (500+100)*1.2=720
  2. 然后Channel1会基于Channel0计算,HealthMax=(720+100)=820

如此就实现了一个属性的乘区

不过我个人认为这种做法并不好,至少不应该滥用,会让对Attribute的使用变得更加的复杂,也会让准确的获取数值加成麻烦一点,我认为更好的方式是创建多个乘区的属性,而不是直接使用channel,至少是不应该滥用,我想这也是官方没有提及这里的原因。

飞吧,用破碎的翅膀


OK,本篇文章到这里就结束了,希望能够帮助读到这里的读者。

写下这篇文章后,有些事情大概就已经定好了,即将离开广州了,我会想念这里的猪脚饭,生蚝还有早茶,还有曾经一起工作过的人。毕业投入游戏行业后,虽然只有短短的三年,但也算是经历了很多。从一开始的充满热情,有些天真,到现在意识到很多事情并不能以个人的主观意愿为转移,努力并不能会有一个好的结果,虽然它是最重要的,但有时候赛道和环境,乃至运气才是通向成功的加速器。

但做游戏毕竟是快乐的,每次功能实现后所能得到的正反馈是极其强烈的,我觉得做游戏本身甚至比玩游戏还有意思。我个人还是始终相信,游戏行业在当下仍然是一条值得去走的路,作为一个内容行业,崎岖艰险但永远充满机会。行业寒冬,所能做的就是努力提高自己,让自己有更强的实力去面对冰雪消融的时刻,相信会有万物复苏的时刻,与大家共勉。

  • 15
    点赞
  • 24
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值