Arty的奇妙空间

用Easyx复刻经典2D游戏

EasyX 复刻《SilkSong》 金牌收录

项目简介

这是一个用 EasyX 和 Windows API(GDI、DirectShow)复刻的一款官方还未发售的 2D 横板类银河恶魔城游戏——《丝之歌》,开发语言是 C++。人物素材对标《丝之歌》,而由于缺乏专业美术,部分场景素材用的还是《空洞骑士》。

项目分为两个部分:Engine 和 Project。

Engine(仅适用于开发简易 2D 游戏)部分有渲染系统、粒子系统、碰撞系统、物理模拟系统、媒体系统、动画系统、相机系统、键鼠交互系统、UI 系统、计时系统等等。整体设计理念有借鉴一些主流工业级引擎(如 UE、Unity)。Engine 部分与游戏具体内容无关。Project 部分则是开发者需要具体编写的有关游戏内容的部分。

基本概念

游戏程序有一个全局对象——游戏世界(World),游戏世界中有诸多容器来存储并组织一切各具代表意义的对象类(如所有游戏对象,所有可渲染物体,所有碰撞体,所有计时器等等等)。开发者可以定义多个场景关卡(Level),但一个世界同时只能运行一个 Level,开发者需要在 Level 中组织管理游戏对象(Actor),在 Actor 游戏对象身上管理各具功能的组件(ActorComponent)。

每个 Level 有且仅有一个特殊的 Actor——Controller。它是玩家控制器,你可以派生此类并自定义键鼠交互映射规则(在重载的虚函数 SetupInputComponent 当中)。

void Player::SetupInputComponent(InputComponent* inputComponent)
{
	inputComponent->SetMapping("WalkLeft", EKeyCode::VK_A);
	// ......
​
	inputComponent->BindAction("WalkLeft", EInputType::Holding, [this]()
	{
		// ......
	});
	// ......
}

在每个 Level 中,你需要在构造函数中通过 SetDefaultController 函数指定该场景的默认 Controller,否则,程序将会给你自动指定一个毫无任何功能的原始 Controller。

MossGrottoLevel::MossGrottoLevel()
{
	SetDefaultController<Player>();
	// ......
}

Character 则是更为特殊的 Controller,它是专门的横板 2D 跳跃游戏的玩家角色,此类中提供了一些已经实现好的角色交互和运动逻辑,开发者如若要制作 2D 横板跳跃游戏,派生它再合适不过了。

开发者大多数时间都要自定义具体的 Actor 和 ActorComponent 来实现游戏业务逻辑,或是搭建 Level 场景,或是实现 UI 界面(UserInterface),而它们都属于 Object 类。每个 Object 类都有三种特殊函数——BeginPlay、Update、EndPlay。BeginPlay 用来定义一些游戏对象的开始逻辑(注意不是创建组件的初始化逻辑,创建组件最好在构造函数就进行),该函数会在构造函数执行后紧接着执行(但不是立刻,程序逻辑上并未紧挨,这两个执行节点之间还会处理一些其它逻辑,只是他们的执行间隔小于一个游戏循环帧而已)。Update 则会每一个游戏逻辑循环帧执行,此外它会被传入一个参数 deltaTime 来得知此次循环和上一个循环之间的时间间隔供开发者使用,以便于消除实时帧率的波动对部分游戏逻辑的影响(如运动学层面等等),总之开发者在该类函数中编写游戏业务逻辑再合适不过了。EndPlay 函数则适合编写一些对象销毁逻辑,比如游戏对象的亡语。注意,开发者禁止自定义析构函数,而是用 EndPlay 来代替,否则会出现一些冲突性的 bug。以上三个函数均需采用装饰者模式来重载,需要先执行父类虚函数再定义自己的逻辑,否则会丧失一些核心功能。

void Player::Update(float deltaTime)
{
	Character::Update(deltaTime);
 	// 开发者自定义逻辑......
}

Actor 是开发者大多数时间要去定义的类。开发者需要在构造函数中创建并绑定组件:

XXX::XXX()
{
	render = ConstructComponent<SpriteRenderer>();
	render->AttachTo(root);
	rigid = ConstructComponent<RigidBody>();
	// ......
}

每个 Actor 有一个默认的 root 场景根组件(SceneComponent),它使得 Actor 具有了场景属性——即 Transform 类,该类包含了位置坐标(location)、旋转(rotation)以及缩放(scale)。我们通过 ConstructComponent 函数来创建组件,该函数会创建一个 ActorComponent 组件类并将其注册到 Actor 容器当中。如果组件是场景组件的派生类,则需通过 AttachTo 函数来绑定到指定组件上,该函数可以设置场景组件之间的父子关系,方便通过相对场景属性来计算最终的绝对场景属性(在世界的场景属性),一般最好绑定到 root 上。

void AttachTo(Actor* par, FAttachmentTransformRules rule = FAttachmentTransformRules::KeepRelativeTransform);
​
template<typename T>
T* ConstructComponent();

如果想要销毁某个 Actor 对象,可以直接调用 Destroy 函数:

void Destroy();

该函数会将该 Actor 加入待销毁容器当中,在本次逻辑循环帧的某个时刻统一销毁。当然,Actor 的默认析构函数已经包含了对其身上注册的所有组件的销毁逻辑。

在有关设置或者获取 Actor 的场景属性方面,我提供了一系列函数接口,相信大家看名字也能知道它的功能:

	/** 获取场景属性(相对父对象坐标系)**/
	const FVector2D& GetLocalPosition() const;
	float GetLocalRotation() const;
	const FVector2D& GetLocalScale() const;
	const FTransform& GetLocalTransform() const;
​
	/** 获取场景属性(世界绝对坐标系)**/
	FVector2D GetWorldPosition()const;
	float GetWorldRotation()const;
	FVector2D GetWorldScale()const;
​
	/** 设置场景属性(相对父对象坐标系,如若没有父对象则为世界绝对坐标系)**/
	void SetLocalPosition(const FVector2D& pos);
	void SetLocalRotation(float angle);
	void SetLocalScale(const FVector2D& scale);
	void SetPositionAndRotation(const FVector2D& pos, float angle);
	void SetLocalTransform(const FTransform& transform);
​
	/** 增加场景属性偏移量 **/
	void AddPosition(FVector2D pos);
	void AddRotation(float rot);

其中 GetWorldPosition、GetWorldRotation、GetWorldScale 这三个函数是通过递归来计算某个 Actor 的世界绝对场景属性的,例如:

float Actor::GetWorldRotation() const
{
	if (parent && transformRule.RotationRule == EAttachmentRule::KeepRelative)
	{
		return parent->GetWorldRotation() + GetLocalRotation();
	}
	else return GetLocalRotation();
}
​
FVector2D Actor::GetWorldScale() const
{
	if (parent && transformRule.ScaleRule == EAttachmentRule::KeepRelative)
	{
		return parent->GetWorldScale() * GetLocalScale();
	}
	else return GetLocalScale();
}
FVector2D Actor::GetWorldPosition() const
{
	if (parent && transformRule.LocationRule == EAttachmentRule::KeepRelative)
	{
		return parent->GetWorldPosition() + FVector2D::RotateVector(parent->GetWorldRotation(), GetLocalPosition() * parent->GetWorldScale());
	}
	else return GetLocalPosition();
}

GameplayStatics 是一个静态类,其中定义了一系列最常用的静态函数供开发者使用,如创建游戏对象 CreateObject,查找某类游戏对象 FindObjectOfClass,查找带有某个标签名的对象 FindObjectOfName 创建 UI 界面 CreateUI,跳转到新的关卡场景 OpenLevel 等等等。其中 CreateObject 大概是使用频率最频繁的函数之一了:

template<typename T>
static T* CreateObject(FVector2D pos = FVector2D::ZeroVector, float angle = 0, FVector2D scale = FVector2D::UnitVector);
GameplayStatics::CreateObject<MossFloor>({ 1390.f, 1245.f });

 

Timer 是计时器,可以绑定目标函数回调,指定其执行间隔。同时,开发者也可以决定该函数回调是一次性还是永久,如果是永久,同样可以指定从绑定函数开始到其初次执行的时间间隔。绑定需要通过 Timer 类的 Bind 函数,该计时器将会被注册到世界计时器容器统一管理。

template<typename T>
void Bind(double delay, T* obj, void(T::* function)(), bool repeat = false, double firstDelay = -1.0)
​
void Bind(double delay, std::function<void()>function, bool repeat = false, double firstDelay = -1.0);


使用示例如下:

MyTimer.Bind(3.f, [this]() {// ......}, true, 0.5f);


Engine 部分一共 10031 行代码,几乎涵盖了大多数轻量级 2D 游戏开发所需实现的功能(图像渲染、相机处理、图像处理、帧动画、音视频播放、物理模拟与碰撞检测、UI 界面、粒子特效、键鼠控制、计时器、事件系统、文件读写、场景切换管理等等)。由于篇幅问题,以上仅介绍了游戏开发者可能最常用的几种函数或是接触的类。其余各具功能的组件、UI 部件等等的功能和使用方法请参照具体代码注释。

开发环境

VS2022 + EasyX_20240601 

素材来源

大部分美术、音效皆来自spriters或原版解包,小部分来自网上各种民间版《丝之歌》以及《空洞骑士》wiki 百科网站,其余皆为作者手绘,或是用 ps 精修。

所有素材皆用作学习交流,严禁商用。

游戏简介

目前只还原了两种可相互切换的菜单场景、两个二代“苔藓洞穴”的场景、三个一代“泪水之城”的场景、一个一代“格林剧团”的场景、两种小怪以及两场经典 boss 战。但主角的几乎所有行为、技能均已还原。

菜单“CHANGE THEME”可以在怀旧和最新场景间来回切换。怀旧场景会直接进入一代的“泪水之城”,而新版场景会进入二代“苔藓洞穴”。在“苔藓洞穴”死后会复活在“泪水之城”。

游戏中 WASD 控制上下左右,按住 Space 空格疾跑,J 攻击,K 跳跃(按住时间越久跳得越高,跳跃到平台边沿会自动攀爬),F 冲刺(地面小冲刺,空中大冲刺),L 闪避,Q 飞镖(飞镖可互动,向下攻击用作垫脚石),E 治疗,I 近战技能,地面使用 O 为远程技

能,空中使用 O 发射钩锁斜向上移动,U 快速突刺技能。特别的,长按 W/S 向上/下看,W+J 向上攻击,S+J 斜向下攻击,座位旁按 W 坐下,坐下以后按 S 起立,按下 X 消耗灵魂可触发一次短时间格挡,格挡成功会有回报和反击。

游戏执行效果

项目地址

github

gitee