[实战]C++加Lua加SDL来重写龙神录弹幕游戏(5):添加背景

       敲代码很快,写博客很慢,如果写详细一点,就有点太长了,跟写策划文档没区别了,╮(╯_╰)╭。之前虽然在SDL窗口中显示出了一张人物图片,但也只是为了测试SDL_Image而已。现在就正式来完成这次的工作,显示背景。


       PS:老实说,我真没有仔细看那个弹幕射击游戏的教程,因为代码写的实在是太乱了。因此我就按照教程实现的效果来,不用他的乱七八糟的代码。这次采用的框架也是我刚看的一本书上的,原本是C++写的,我将其改成Lua来写。书我也介绍下,《Game Programming in C++》,作者是Sanjay MADHAV, 前4章是讲SDL的,这就是我会SDL的原因(..•˘_˘•..),后面是讲OpenGL的。我看的是英文版的,在CSDN上搜了下,有资源的,中文版的在某东上竟然搜到,剩下的就看你们自己了。
       删除之前的Renderer类中的2个测试函数,GetTexture和Test,新添加Texture.h,Texture.cpp和TextureWrap.cpp文件。
Texture.h

#pragma once

class Texture
{
public:
	Texture();
	Texture(struct SDL_Renderer* pRenderer, const char* fileName);
	virtual ~Texture();

	/**
	 * 渲染贴图
	 * In ->  struct SDL_Renderer* pRenderer		
	 *		  int x, int y						- 显示位置
	 */
	void Render(struct SDL_Renderer* pRenderer, int x, int y);
	/**
	 * 渲染贴图
	 * In ->  struct SDL_Renderer* pRenderer
	 *		  int x, int y						- 显示位置
	 *		  int width, int height				- 显示的宽高(可以通过这2个值进行缩放)
	 */
	void Render(struct SDL_Renderer* pRenderer, int x, int y, int width, int height);

private:
	/**
	 * 获取贴图宽高数据
	 */
	void QueryTexture();

private:
	const char* m_fileName;
	struct SDL_Texture* m_pSDLTexture = nullptr;
	int m_textureWidth = 0;
	int m_textureHeight = 0;
};

struct lua_State;
namespace LuaWrap
{
	void RegisterTexture(lua_State* L);
}

Texture.cpp

#include "Texture.h"
#include <SDL.h>
#include <SDL_image.h>
#include "Logger.h"

Texture::Texture()
{
	m_fileName = "";
	m_pSDLTexture = nullptr;
	m_textureWidth = 0;
	m_textureHeight = 0;
}

Texture::Texture(SDL_Renderer* pRenderer, const char* fileName)
{
	m_fileName = fileName;
	SDL_Surface* pSurface = IMG_Load(fileName);
	if (!pSurface)
	{
		Logger::LogError("IMG_Load() failed in Renderer::GetTexture(): %s", fileName);
		return;
	}

	m_pSDLTexture = SDL_CreateTextureFromSurface(pRenderer, pSurface);
	SDL_FreeSurface(pSurface);
	if (!m_pSDLTexture)
	{
		Logger::LogError("SDL_CreateTextureFromSurface() failed in GetTexture(): %s", SDL_GetError());
		return;
	}

	QueryTexture();
}

Texture::~Texture()
{
	if (m_pSDLTexture)
	{
		SDL_DestroyTexture(m_pSDLTexture);
		m_pSDLTexture = nullptr;
	}
	Logger::Log("Call Texture' destructor, m_fileName: %s", m_fileName);
}

void Texture::Render(SDL_Renderer* pRenderer, int x, int y)
{
	if (nullptr == pRenderer || nullptr == m_pSDLTexture) return;

	SDL_Rect destination;
	destination.w = m_textureWidth;
	destination.h = m_textureHeight;
	destination.x = x;
	destination.y = y;

	SDL_RenderCopy(pRenderer, m_pSDLTexture, nullptr, &destination);
}

void Texture::Render(SDL_Renderer* pRenderer, int x, int y, int width, int height)
{
	if (nullptr == pRenderer || nullptr == m_pSDLTexture) return;

	if (nullptr == pRenderer || nullptr == m_pSDLTexture) return;

	SDL_Rect destination;
	destination.w = width;
	destination.h = height;
	destination.x = x;
	destination.y = y;

	SDL_RenderCopy(pRenderer, m_pSDLTexture, nullptr, &destination);
}

void Texture::QueryTexture()
{
	if (m_pSDLTexture)
		SDL_QueryTexture(m_pSDLTexture, nullptr, nullptr, &m_textureWidth, &m_textureHeight);
}

TextureWrap.cpp

#include "Texture.h"
#include <SDL.h>
#include <LuaClient.h>
#include <new>
#include "Logger.h"

int CreateTexture(lua_State* L)
{
	int count = lua_gettop(L);
	if (0 == count)
	{
		void* pointerToTexture = lua_newuserdata(L, sizeof(Texture));
		new (pointerToTexture)Texture();
	}
	else if(2 == count)
	{
		SDL_Renderer* pRenderer = (SDL_Renderer*)lua_touserdata(L, 1);
		const char* fileName = lua_tostring(L, 2);
		void* pointerToTexture = lua_newuserdata(L, sizeof(Texture));
		new (pointerToTexture)Texture(pRenderer, fileName);
	}
	else
		Logger::LogError("Error: Texture don't have the constructor have %d arguments.", count);
	
	luaL_getmetatable(L, "TextureMetaTable");
	lua_setmetatable(L, -2);

	return 1;
}

int DestroyTexture(lua_State* L)
{
	Texture* pTexture = (Texture*)lua_touserdata(L, 1);
	if (pTexture)
		pTexture->~Texture();

	return 0;
}

int RenderTexture(lua_State* L)
{
	int count = lua_gettop(L);

	Texture* pTexture = (Texture*)lua_touserdata(L, 1);
	SDL_Renderer* pRenderer = (SDL_Renderer*)lua_touserdata(L, 2);
	int x = (int)lua_tonumber(L, 3);
	int y = (int)lua_tonumber(L, 4);
	if (4 == count)
		pTexture->Render(pRenderer, x, y);
	else if(6 == count)
	{
		int width = (int)lua_tonumber(L, 5);
		int height = (int)lua_tonumber(L, 6);
		pTexture->Render(pRenderer, x, y, width, height);
	}

	return 0;
}

int TextureGet(lua_State* L)
{
	luaL_getmetatable(L, "TextureMetaTable");
	lua_pushvalue(L, 2);
	lua_rawget(L, -2);
	
	return 1;
}

void LuaWrap::RegisterTexture(lua_State* L)
{
	if (!L) return;

	lua_newtable(L);
	luaL_newmetatable(L, "TextureMetaTable");
	lua_pushstring(L, "New");
	lua_pushcfunction(L, CreateTexture);
	lua_rawset(L, -3);

	lua_pushstring(L, "__index");
	lua_pushcfunction(L, TextureGet);
	lua_rawset(L, -3);

	lua_pushstring(L, "__gc");
	lua_pushcfunction(L, DestroyTexture);
	lua_rawset(L, -3);

	lua_pushstring(L, "Render");
	lua_pushcfunction(L, RenderTexture);
	lua_rawset(L, -3);

	lua_setmetatable(L, -2);
	lua_setglobal(L, "Texture");
}

       头文件没啥好说的,注释也比较多,先来看Texture的实现。虽然有2个构造函数,但其中一个只是为了介绍lua怎么调用同名且不同参数个数的函数例子而已。因此那个构造函数是没啥用的,虽然可以再添个接口来处理加载SDL_Texture,但核心不是这个,因此就不关注了。
       这边需要关注下析构函数,加了一个输出信息,这个是为了测试Lua中是否有调用GC功能完成C++这边的内存释放功能。
再来看Texture的Render函数,也有2个,只是为了可以控制缩放显示图片大小而已。后面要显示动画的话,估计还要添加个接口,或者写个子类来完成改功能,这些暂且不说。先来看下SDL_RenderCopy的参数,前2个没啥好说的,第3个参数srcrect,这个我猜应该是跟IDXSprite的功能一样,读取贴图中某一个区域,第4个参数dstrect则是显示区域,dstrect的x,y代表的是位置,w,h则是显示的宽高,因此能明白3个参数的Render函数是显示默认贴图大小的,而5个参数的Render函数是可以控制缩放显示图片的大小。
       接着来看TextureWrap.cpp,先来看Wrap的构造函数,通过lua_gettop获取栈中参数数量,再根据参数数量选择合适的构造函数来,使用lua_newuserdata,是因为要让lua来管理内存。在接着看Wrap的析构函数,从栈中取出userdata的地址,强转成Texture对象,再接着释放内存。RenderTexture就不介绍了,跟构造函数类似。RegisterTexture函数中关注下__gc元方法的设置就OK了。
       这次也就只增加了一个C++类,很多代码其实都是在lua上的。为了调试方便,我顺便将LuaClient上的打印栈信息的接口Wrap到lua上去了。
       这次添加的lua代码很多,我就不一一介绍了,太长,先从最简单的开始。
TextureManager.lua

TextureManager = 
{
	m_textures = {},	--所有C++Texture对象
}

--获取Texture,没有就加载到内存中
function TextureManager:GetTexture(pRenderer, textureName)
	--Logger.Log("pRenderer: "..tostring(pRenderer))
	--Logger.Log("textureName: "..tostring(textureName))
	--没有就加载到内存中,并标记引用次数为1
	if nil == self.m_textures[textureName] then
		self.m_textures[textureName] = {}
		self.m_textures[textureName].pTexture = Texture.New(pRenderer, textureName)
		self.m_textures[textureName].count = 1
	else
		--标记引用次数自增1
		self.m_textures[textureName].count = self.m_textures[textureName].count + 1
	end

	return self.m_textures[textureName].pTexture
end

function TextureManager:RemoveTexture(pTexture)
	if nil == pTexture then return end
	--查找,并减少引用次数1次,如果引用次数小于0,就释放内存
	for k, v in pairs(self.m_textures) do
		if v.pTexture == pTexture then
			self.m_textures[k].count = self.m_textures[k].count - 1
			if self.m_textures[k].count <= 0 then
				self.m_textures[k] = nil
				break
			end
		end
	end
end

       TextureManager是全局表,类似於单例,功能就是管理加载贴图,避免重复加载和释放贴图内存的功能。代码有注释,而且代码也不长,只要关注是否将C++的Texture对象有没有在lua中赋值为nil就行,如果没释放,lua在GC中是不会释放内存的,会导致内存泄漏的。因为C++的Texture对象是在表中的,而且这张表也只有在TextureManager表中引用,为了方便,直接将表赋值为nil也完成内存释放的功能了。


       上图就能表示lua释放C++内存功能没有问题。在请按任意键继续前是动态释放的,也就是按下F键,强制释放玩家内存导致贴图也被释放了。而请按任意键继续后则是lua运行结束后调用的。代码会在后面的RyuujinnGame.lua上看到。
       在说Actor之前,先来说下Component和SpriteComponet。
Component.lua

local Component = 
{
	m_pActor = nil,			--拥有者
	m_updateOrder = 100,	--更新顺序
}
Component.__index = Component

--构造函数
function Component:New(pActor, updateOrder, ...)
	local newTable = {}
	setmetatable(newTable, self)
	self.__index = self
	newTable.m_pActor = pActor
	newTable.m_updateOrder = updateOrder
	--将该组件对象传入到Actor中的m_components列表中
	--由Actor来管理该组件对象的更新释放等
	--因此Actor可以根据更新顺序(m_updateOrder)来控制那个组件先更新
	if newTable:GetActor() then
		newTable:GetActor():AddComponent(newTable)
	end
	if newTable.OnInit then
		newTable:OnInit(...)
	end
	return newTable
end

function Component:GetActor()
	return self.m_pActor
end

function Component:GetUpdateOrder()
	return self.m_updateOrder
end

--子类通过实现OnUpdate来完成多态
function Component:Update(deltaTime)
	if self.OnUpdate then
		self:OnUpdate(deltaTime)
	end
end

--相当于析构函数,将该组件对象从Acotr中的m_components列表中移除
--子类通过实现OnRelease来完成多态,
function Component:Release()
	if self:GetActor() then
		self:GetActor():RemoveComponent(newTable)
	end
	if self.OnRelease then
		self:OnRelease()
	end
end

return Component

SpriteComponet.lua

local SpriteComponent = 
{
	m_pTexture = nil,			--C++ Texture类
	m_renderOrder = 100,		--渲染顺序
}
SpriteComponent.__index = SpriteComponent
setmetatable(SpriteComponent, require "Module.Component.Component")

function SpriteComponent:OnInit(renderOrder)
	self.m_renderOrder = renderOrder
	--将该组件对象传入到RyuujinnGame中的m_pSpriteComponents列表中
	--由RyuujinnGame来管理该组件的渲染等
	--因此RyuujinnGame可以根据渲染顺序(m_renderOrder)来控制那个先渲染
	if self:GetActor() and self:GetActor():GetGame() then
		self:GetActor():GetGame():AddSpriteComponent(self)
	end
end

function SpriteComponent:GetRenderOrder()
	return self.m_renderOrder
end

function SpriteComponent:Render(pSDLRenderer)
	if nil == pSDLRenderer or nil == self.m_pTexture then return end

	local pActor = self:GetActor()
	if nil == pActor then return end

	--获取Actor的位置和缩放来渲染图片
	local x, y = pActor:GetPosition()
	local width, height = pActor:GetScale()
	if (1 == width and 1 == height) or (nil == width and nil == height) then
		self.m_pTexture:Render(pSDLRenderer, x, y)
	else
		self.m_pTexture:Render(pSDLRenderer, x, y, width, height)
	end
end

function SpriteComponent:SetTexture(pTexture)
	self.m_pTexture = pTexture
end

function SpriteComponent:OnRelease()
	if self:GetActor() and self:GetActor():GetGame() then
		self:GetActor():GetGame():RemoveSpriteComponent(self)
	end
	TextureManager:RemoveTexture(self.m_pTexture)
	self.m_pTexture = nil
end

return SpriteComponent

       为了实现子类构造函数参数和父类不同的问题,使用了lua的可变参数功能,可以在Component:New中看到会先检查子类中是否有OnInit函数,如果有就将可变参数传入OnInit函数中。再接着看下Component的子类SpriteComponent的OnInit函数,只有一个参数renderOrder参数。因为后面写的Player和BG都是采用默认值,所以只传入一个参数,有兴趣的可以传入3个参数来测试。SpriteComponent稍微有点特殊,因为它不仅仅受到Actor管理,也受到了RyuujinnGame的管理,这个会在后面讲到。
       接着来看下Actor,后面继承Actor的Player和BG类就不会细讲了,感觉太简单了,这边在说明下,Player还是测试代码。
Actor.lua

local Actor = 
{
	m_game = nil,								--游戏类
	m_actorState = ActorState.Active,			--状态
	--m_position = { x = 0.0, y = 0.0 },		--位置
	--m_scale = { width = 1.0, height = 1.0 },	--缩放
	--m_components = {},						--所有组件
}
Actor.__index = Actor

--构造函数
function Actor:New(game, ...)
	local newTable = 
	{
		m_position = { x = 0.0, y = 0.0 },			
		m_scale = { width = 1.0, height = 1.0 },	
		m_components = {}
	}
	setmetatable(newTable, self)
	self.__index = self
	newTable.m_game = game
	--将该Actor对象传入到RyuujinnGame中的m_pActors或者m_pPendingActors列表中
	--由RyuujinnGame来管理该Actor对象的更新释放等
	if newTable:GetGame() then
		newTable:GetGame():AddActor(newTable)
	end
	if newTable.OnInit then
		newTable:OnInit(...)
	end
	return newTable
end

function Actor:IsDead()
	return ActorState.Dead == self.m_actorState
end

function Actor:GetGame()
	return self.m_game
end

function Actor:GetActorState()
	return self.m_actorState
end

function Actor:GetPosition()
	return self.m_position.x, self.m_position.y
end

function Actor:GetScale()
	return self.m_scale.width, self.m_scale.height
end

function Actor:SetActorState(actorState)
	self.m_actorState = actorState
end

function Actor:SetPosition(x, y)
	self.m_position.x, self.m_position.y = x, y
end

function Actor:SetScale(width, height)
	self.m_scale.width, self.m_scale.height = width, height
end

--对组件进行排序,根据更新顺序
local function SortComponent(a, b)
	if a:GetUpdateOrder() ~= b:GetUpdateOrder() then
		return a:GetUpdateOrder() < b:GetUpdateOrder()
	end
	return false
end

function Actor:AddComponent(pComponent)
	if nil == pComponent then return end

	table.insert(self.m_components, pComponent)
	table.sort(self.m_components, SortComponent)
end

function Actor:RemoveComponent(pComponent)
	if nil == pComponent then return end

	for i = #self.m_components, 1, -1 do
		if self.m_components[i] == pComponent then
			table.remove(self.m_components, i)
			return
		end
	end
end

function Actor:UpdateComponent(deltaTime)
	for i = 1, #self.m_components do
		if self.m_components[i] ~= nil then
			self.m_components[i]:Update(deltaTime)
		end
	end
end

--子类通过实现OnUpdate来完成多态
function Actor:Update(deltaTime)
	if ActorState.Active == self.m_actorState then
		self:UpdateComponent()
		if self.OnUpdate then
			self:OnUpdate(deltaTime)
		end
	end
end

function Actor:Release()
	if self:GetGame() then
		self:GetGame():RemoveActor(self)
	end
	for i = #self.m_components, 1, -1 do
		self.m_components[i]:Release()
		self.m_components[i] = nil
	end
	if self.OnRelease then
		self:OnRelease()
	end
end

return Actor

       Actor的构造函数类似Component,因此跳过,接着的是一堆Getter和Setter函数,也跳过。接着看到SortComponent和AddComponent,AddComponent这个函数之前就在Component中有看到,之前说Component受到Actor管理,就因为这个功能,这里解释到底做了什么:Actor将Component组件添加m_components表中,再调用SortComponent进行排序(根据Component中的更新顺序,也就是m_updateOrder)。因此在后面的Actor:UpdateComponent函数中就可以根据更新顺序来对所有组件进行一次有序的更新,举个例子:这一帧本来是收到了玩家的输入,受到控制的角色的MoveComponent应该使角色向前移动一步,在接着SpriteComponent按更新后的位置渲染,但因为没有更新顺序的话,会导致先渲染再更新角色,举得例子有可能不恰当,但差不多是这个意思。
       最后的脚本就是RyuujinnGame.lua了,改动还是稍微有一点的,︿( ̄︶ ̄)︿。但是在说RyuujinnGame.lua之前,我们需要先修改Window.ini的配置,将窗口大小改成640x480就好了,我在调试的时候发现背景图片大小就这么大,也懒得去测试适应。而且这个640x480的大小还是我猜的,因为日本那个教程用的是DxLib,就是我之前说不喜欢它的封装,就没有用DxLib来开发。这次调试的时候,就没有找到设置窗口大小的地方,这有毒吧,太不友好了。
RyuujinnGame.lua

require "Module.Enum.ActorState"
require "Manager.TextureManager"

RyuujinnGame = 
{
	m_bUpdatingActors = true,		--正在更新Actors
	m_pActors = {},					--正在活动中的Actors
	m_pPendingActors = {},			--新创建的Actors
	m_pSpriteComponents = {},		--渲染Sprite
	m_pPlayer = nil,				--主角
}
setmetatable(RyuujinnGame, 
{ 
	__index = require "GameBase",
	__newindex = function(t, key, newValue)--禁止重载GameBase函数
		local oldValue = t[key]
		if oldValue == nil or type(oldValue) ~= "function" then
			rawset(t, key, newValue)
		else
			Logger.LogError("This action overrides GameBase's function is not allowed\n"..debug.traceback())
		end
	end
})

function RyuujinnGame:OnInit()
	--添加背景
	require("Entity.BG"):New(self, "Resource/img/board/10.png", 0, 0)
	require("Entity.BG"):New(self, "Resource/img/board/11.png", 0, 16)
	require("Entity.BG"):New(self, "Resource/img/board/12.png", 0, 464)
	require("Entity.BG"):New(self, "Resource/img/board/20.png", 416, 0)
	--添加玩家
	self.m_pPlayer = require("Entity.Player"):New(self)
	return true
end

function RyuujinnGame:OnRelease()
	for i = #self.m_pActors, 1, -1 do
		self.m_pActors[i]:Release()
	end
	for i = #self.m_pPendingActors, 1, -1 do
		self.m_pPendingActors[i]:Release()
	end
end

function RyuujinnGame:OnHandleInput()
	if self.m_pPlayer and self.m_pPlayer.HandleInput then
		self.m_pPlayer:HandleInput()
	end

	--测试是否有调用GC,释放C++ Texture资源
	if Renderer.GetKeyboardState(SDL_KEYCODE.SDL_SCANCODE_F) then
		if self.m_pPlayer then
			self.m_pPlayer:Release()
			self.m_pPlayer = nil
		end
	end
end

function RyuujinnGame:OnUpdate(deltaTime)
	collectgarbage("collect")
	self:UpdateActor(deltaTime)
end

function RyuujinnGame:OnRender()
	self:RenderSpriteComponent()
end

function RyuujinnGame:AddActor(pActor)
	if self.m_bUpdatingActors then
		table.insert(self.m_pPendingActors, pActor)
	else
		table.insert(self.m_pActors, pActor)
	end
end

function RyuujinnGame:RemoveActor(pActor)
	for i = #self.m_pPendingActors, 1, -1 do
		if self.m_pPendingActors[i] == pActor then
			table.remove(self.m_pPendingActors, i)
		end
	end

	for i = #self.m_pActors, 1, -1 do
		if self.m_pActors[i] == pActor then
			table.remove(self.m_pActors, i)
		end
	end
end

function RyuujinnGame:UpdateActor(deltaTime)
	self.m_bUpdatingActors = true
	for k, v in pairs(self.m_pActors) do
		v:Update(deltaTime)
	end
	self.m_bUpdatingActors = false

	for i = #self.m_pPendingActors, 1, -1 do
		table.insert(self.m_pActors, self.m_pPendingActors[i])
		table.remove(self.m_pPendingActors, i)
	end

	local deadActors = {}
	for i = #self.m_pActors, 1, -1 do
		if self.m_pActors[i]:IsDead() then
			table.insert(deadActors, self.m_pActors[i])
		end
	end

	for i = #deadActors, 1, -1 do
		deadActors[i]:Release()
	end
end

--对组件进行排序,根据渲染顺序
local function SortSpriteComponent(a, b)
	if a:GetRenderOrder() ~= b:GetRenderOrder() then
		return a:GetRenderOrder() < b:GetRenderOrder()
	end
	return false
end

function RyuujinnGame:AddSpriteComponent(pSpriteComponent)
	table.insert(self.m_pSpriteComponents, pSpriteComponent)
	table.sort(self.m_pSpriteComponents, SortSpriteComponent)
end

function RyuujinnGame:RemoveSpriteComponent(pSpriteComponent)
	for i = #self.m_pSpriteComponents, 1, -1 do
		if self.m_pSpriteComponents[i] == pSpriteComponent then
			table.remove(self.m_pSpriteComponents, i)
		end
	end
end

function RyuujinnGame:RenderSpriteComponent()
	for i = 1, #self.m_pSpriteComponents do
		self.m_pSpriteComponents[i]:Render(self:GetRenderer())
	end
end

       之前说SpriteComponent稍微有点特殊,就是因为该组件受到了RyuujinnGame的管理,因为只有在RyuujinnGame中才能获取所有SpriteComponent,然后根据渲染顺序来进行渲染。RyuujinnGame中操作SpriteComponent的函数和Actor中操作AddComponent的函数非常类似,就不再重复了。
       RyuujinnGame:OnInit初始化代码就是添加4个BG对象,和一个Player对象,这里的Player对象主要是为了测试lua的CG用的,而这次的主要功能就是背景而已,不要忘记。因此,OnInit中这么添加BG,就能被渲染出来了,而且也不要我们后面在特殊处理,因为RyuujinnGame中的m_pActors表会自己管理BG的更新释放,m_pSpriteComponents表会对BG进行渲染。
       这里在总结下这个框架:RyuujinnGame中有2个Actor表,一个是正在活动中的,一个是新生成的,而Actor中有1个Component表,因此每次在RyuujinnGame更新时,正在活动中的Actors都会调用Update方法来进行对Actor中的组件进行更新,这样就做到了一个所有物体的更新,并且在其中有可能有新生成的Actor,而这些就不会填加到正在活动中的Actor表中,而是加入新生成的Actor表中,等正在活动中的Actors全部更新完毕后,就将新生成的Actor表中的对象全部放入到正在活动中的Actor表中,等待下一次的更新。
       这次代码真的比较多,就没有一一介绍了,代码我也会上传,但发现现在CSDN的积分有时候不能控制了,有时候能控制,因此我就把该库设置成public了,不再设置为私有库了。

PS:我已经将龙神录的所有资源都拷过来了
源码下载地址
github地址

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章