其实最关键的就是游戏的逻辑变化怎么样反映到组件上,毕竟我们现在要靠组件来渲染游戏对象。
比如渲染一个精灵,那实际上就是用一个img元素来实现,然后通过变换坐标来移动。
我们要解决的就是,怎么样可以让组件只负责渲染,而坐标之类的数据独立运作。
所有与Blazor组件无关的代码,我都放在AntDesignGameFramework项目里,其中,游戏对象是参考了Unity3D,大致的结构如下:
Object
-> GameObject
-> Component
-> Transform
-> 各种游戏对象的组件
结构很简单。
Object:是基础类,里面有一个UID,用来做唯一标记。
GameObject:就是我们的游戏对象,游戏对象包含各种组件(Component)
Component:组件,实现游戏对象的各种属性或逻辑,像血量条这些UI也通过组件附加到游戏对象上。
Transform:记录游戏对象的坐标、大小等属性,同时还记录游戏对象的所有子对象,是最重要的一个组件。
当然,这些不是重点,这部分的封装还比较初级,仅供演示。
重点是,这些GameObject怎么渲染出来?
我们有一个GameWorld.razor组件,这个组件只做一件事情,渲染世界里的所有GameObject:
foreach(var gameObject in GameContext.GameObjects)
{
<RenderGameWordObject @key=@gameObject.Uid @ref="_renderGameWorldObject" GameObject="@gameObject" />
}
GameContext可以理解为是一个游戏世界,里面就是存放了所有的GameObject对象。
RenderGameWordObject也是一个Blazor组件,它是真正负责渲染GameObject的组件,它会渲染GameObject本身以及下级GameObject:
<DynamicComponent
Type="@GameObject.WebComponentType"
Parameters=@(GameObject.WebParameters)
/>
@for(int i = 0; i< GameObject.Transform.GetChildCount(); i++)
{
var childGameObject = GameObject.Transform.GetChild(i);
if(childGameObject.Transform.GetChildCount() > 0)
{
<RenderGameWordObject GameObject="@childGameObject" />
}
else
{
<DynamicComponent
Type="@childGameObject.WebComponentType"
Parameters=@(childGameObject.WebParameters)
/>
}
}
@code
{
[Parameter]
public GameObject GameObject { get; set; }
}
GameObject的上下级关系是通过Transform组件来实现的,即,Transform记录了GameObject的所有子对象(熟悉Unity3D的应该就很清楚了)。
因此,在渲染GameObject的同时,还要递归渲染它的子对象。
这些逻辑,有游戏开发经验的各位,肯定都清楚。这里主要解释一下DynamicComponent。
DynamicComponent是Blazor的一个很强大的功能——动态渲染组件。
通过传递Type属性,它就能把Type对应类型的组件给渲染出来。
比如,我们有个Sprite.razor组件,那么,通过下面的代码就能把它渲染出来:
<DynamicComponent Type="typeof(Sprite)" />
因为它支持动态设置Type,所以,我们就可以随时切换要渲染的组件类型,达到动态渲染游戏对象的目的。
渲染是没问题了,那组件的参数怎么传递?
DynamicComponent还支持Parameters属性,通过传递一个Dictionary对象来给组件传递各种参数。
于是,最终,我们的GameObject对象是通过类似下面的逻辑来渲染的:
游戏对象,SpriteGameObject.cs:
public class SpriteGameObject : GameObject
{
public string AssetName { get; set; }
}
游戏对象渲染组件,Sprite.razor:
<img
src="@GameObject.AssetName"
width="@GameObject.Transform.Size.Width"
height="@GameObject.Transform.Size.Height"
/>
@code{
[Parameter]
public GameObject GameObject { get; set; }
}
世界对象渲染组件,GameWorld.razor:
<DynamicComponent Type="typeof(Sprite)" Parameters="new Dictionary<string, object>() { { "GameObject", new SpriteGameObject()} }"/>
也就是说,我们每一个类型的游戏对象(如SpriteGameObject.cs),都需要对应一个渲染组件(如Sprite.razor),游戏对象负责处理游戏逻辑,渲染组件负责渲染对象。
而GameWorld组件负责渲染所有的游戏对象。
比如我这个Demo有一个页面,用来展示简单的战斗逻辑,这个页面的组件是LetFight.razor。
那么,GameWorld组件就作为LetFight的核心组件,然后在游戏循环的每一帧里调用GameWorld里的所有GameObject的Update函数,以此来更新游戏逻辑。
具体看一下源码就清楚了,以下是简化后的代码。
LetFight.razor:
<GameWorld @ref="@_gameWorld" />
LetFight.razor.cs:
public partial class LetFight : ComponentBase
{
private GameLoop _gameLoop;
private GameWorld _gameWorld;
protected override void OnAfterRender(bool firstRender)
{
if (_gameLoop != null)
{
return;
}
var gameContext = new DemoGameContext();
gameContext.Display.Size = new Size(1200, 600);
// 创建英雄
ActorGameObject heroGameObject = new ActorGameObject(typeof(Actor));
gameContext.AddGameObject(heroGameObject);
_gameWorld.SetGameContext(gameContext);
_gameWorld.Refresh();
_gameLoop = new();
_gameLoop.Logic += Logic;
_gameLoop.Start();
}
private async Task Logic(object sender, GameLoopLogicEventArgs e)
{
// 游戏每一帧调用GameContext的Step函数,在Step函数里调用每一个GameObject的Update函数,更新游戏对象逻辑
await _gameWorld.GameContext.Step(e.ElapsedTime);
_gameWorld.Refresh();
}
}
可以说,这是我这个Demo最重要的一个部分,我要解决的问题其实也就是这些,其它的就是更加细节的实现了。
通过把Blazor组件和游戏对象的剥离,我们就可以尽情地写游戏逻辑,不需要管渲染、不需要考虑组件的问题。
甚至,在一定程度上,我们可以复用Unity3D里的一些逻辑代码。