* 모든 클래스의 부모 클래스 구현

 

- 기본 설계

 

모든 클래스의 부모 클래스로 ClassBase라는 클래스가 존재한다. 내부에 public 함수로 여러 캐스팅 함수와 리플렉션 데이터 접근 함수를 지원하고 있다. 이 클래스는 기본적으로 인스턴스화 할 수 없다.

 

메소드를 제외한 기본 구조는 다음과 같다.

local ClassBase = {
	
	public = {
	},
	
	private = {
		reflectionData = {type = "ClassBase"},		
	},
	
	IsClassBase = true
}

...
...

 

 

- 리플렉션 데이터 구현

 

ClassBase에는 private 멤버로 reflectionData를 가지고 있다. 이 데이터는 상속받은 클래스에서 SetType함수 등을 통해서 적절하게 초기화하여 사용한다. 예를 들어 SuperClass에서는 type = "SuperClass" 로 초기화하고 있다.

 

이를 통해서 실제로 가리키고 있는 객체 타입을 정확하게 판별할 수 있다. 현재는 타입만 다루고 있지만 추후 더 정보를 넣을 예정이다.

function ClassBase.public:SetType(inputString)

	local typeString = type(inputString)
	if typeString ~= "string" then
		return
	end

	local this = CastToRawClassBase(self)

	-- 이 메소드에 들어왔기 때문에 무조건 캐스팅이 보장된다.
	--if not this then return end

	this.private.reflectionData.type = inputString
end

function ClassBase.public:GetType()
	
	local this = CastToRawClassBase(self)
	
	return this.private.reflectionData.type
end

 

 

- 캐스팅 구현

 

이 클래스를 상속 받은 객체는 다른 클래스로 캐스팅을 시도해볼 수 있으며, 내가 해당 클래이인지, 클래스의 일종인지 확인할 수 있는 메소드를 제공한다. 제공 방식은 두 가지로 TypeString을 통하는 방법과 클래스 기본 객체인 CDO를 통하는 방법이 있다. ClassBase이상으로 올라가서 탐색하지 못하게 하기 위해서 ClassBase내부에 있는 IsClassBase 속성을 확인하여 적절하게 브레이크를 걸어준다. (ClassBase로 캐스팅하면 그 위 클래스가 없기 때문에 의미가 없다.)

 

또한 현재 위치하고 있는 클래스 타입을 판별하기 위해서(캐스팅할 때 어느 클래스로 캐스팅할 지 판별할 때 사용된다.) 각 클래스마다 GetClassTypeForInstance를 구현하고 있다. 오버라이딩되어 있는 것은 아니고 해당 클래스에 위치하면 해당 메소드가 호출되는 형태이다.(자식 클레스의 메소드에 의해 부모 클래스의 메소드가 가려지는 효과와 같다.) 이를 통해 현재 클래스를 확인하여 캐스팅 타입과 맞는지 판별할 수 있다.(실제 객체의 타입은 아니며 실제 타입은 리플렉션 데이터를 반환하는 GetType 메소드가 ClassBase에서 제공된다.)

function IsEmpty(target)
	
	local rv = rawget(target, "IsClassBase")
	if rv  then
		return true
	end

	return false
end

function ClassBase.public:CastByType(toClassType)

	local classType = self.GetClassTypeForInstance()

	if toClassType == classType then
		return self
	end

	local fromClassBody = getmetatable(self)
	if not fromClassBody or IsEmpty(fromClassBody) then return nil end

	return fromClassBody:CastByType(toClassType)
end

function ClassBase.public:CastByCDO(toClassBody)

	if not toClassBody or IsEmpty(toClassBody) then
		return nil
	end

	return self:CastByType(toClassBody:GetClassTypeForCDO())
end

function ClassBase.public:IsAByType(toClassType)
 -- CastByType 를 활용한다.
end

function ClassBase.public:IsAByCDO(toClassBody)
 -- CastByCDO 를 활용한다.
end

function ClassBase.public:IsItselfByType(toClassType)
 -- Cast와 로직이 비슷하다. 자기 자신의 클래스인지 확인한다.
end

function ClassBase.public:IsItselfByCDO(toClassBody)
 -- Cast와 로직이 비슷하다. 자기 자신의 클래스인지 확인한다.
end

 

 

* 클래스 정의와 인스턴스

 

- 클래스 기본 객체(CDO)와 인스턴스 구현

 

클래스에 존재하는 멤버들과 이를 기본 값으로 가지고 있는 클래스 기본 객체(CDO)를 만들어서 사용하고 있다. 이를 기반으로 인스턴스를 생성한다. 기본적으로 CDO에서 Clone이라는 함수를 통해 인스턴스를 생성하는 방법을 제공한다.

local SuperClassCDO = {

	public = {
		value1 = 1,
		GetClassTypeForInstance = function() return "SuperClass" end
	},
	private = {
		value2 = 2}
}

...
...

function SuperClassCDO.Clone()

	local baseCopy = SuperClassCDO.DeepCopy(SuperClassCDO)
	return setmetatable({}, baseCopy)

end

...
...

위의 코드를 보면 빈 테이블 형식을 생성하고 그 테이블의 메타테이블을 CDO의 깊은 복사본으로 설정하여 반환하는 것을 알 수 있다. 테이블은 기본적으로 참조 기반으로 동작하기 때문에 깊은 복사를 수행해줘야 하고 public, private에 대한 접근 제한을 __index, __newindex를 통해서 컨트롤하기 위해서 빈테이블로 만들고 메타테이블을 연결하는 식으로 인스턴스를 구현하였다.

 

 깊은 복사 코드는 다음과 같다. DeepCopy는 ClassBase의 함수로 DeepCopyDetail을 호출하고 있다.

function DeepCopyDetail(original)

	local originalType = type(original)
	local copy = {}

	if originalType == 'table' then

		for key, value in next, original, nil do
			copy[DeepCopyDetail(key)] = DeepCopyDetail(value)
		end
		setmetatable(copy, DeepCopyDetail(getmetatable(original)))

	else -- 테이블이 아닌 값들
		copy = original
	end

	return copy
	
end

 

 

- 접근 제어 구현

 

접근 제어는 일반적으로 사용하는 public과 private을 구현하였다. 이는 메타테이블의 메타메소드인 __index와 __newindex를 통해서 구현된다. __index와 __newindex는 접근 제어 뿐 아니라 상속을 포함하여 부모 클래스의 함수를 가리는 효과나 오버라이딩 효과 등 여러 곳에서 활용된다.

 

모든 클래스의 __index, __newindex 는 다음과 같이 구현하였다.

function AccessProtection__index(table, key)

	--print("AccessProtection__index => " .. tostring(key))
	--print(table)
	local classBody = getmetatable(table)
	
	-- 있으면 반환한다.
	local value = rawget(classBody.public, key)
	if value then
		return value
	end
	
	-- 부모 클래스를 확인한다.
	return classBody[key]
end

function AccessProtection__newindex(table, key, value)

	--print("AccessProtection__newindex => " .. tostring(key))
	--print(table)
	
	local alreadyExistKey = table[key] -- 부모 클래스에서 찾아보는 과정
	if alreadyExistKey then
		alreadyExistKey = value
	else -- 찾아보고 없으면 그냥 테이블에 할당한다.
		rawset(table, key, value)
	end
	
end

 

하지만 이 방식에서 한 가지 문제점이 있는데 본인 클래스에서 자기 자신 멤버(private)에 접근할 수 없다는 점이다. 보통 모든 언어에서 본인 클래스안에서는 모든 멤버에 접근이 가능하다. 이를 해결하기 위해 인스턴스를 캐스팅하여 반환하는 함수인 GetThisByCDO를 제공한다.

 

인스턴스는 비어있는 테이블이고 메타테이블에 접근하기 때문에 public만 접근할 수 있지만, 이 함수가 반환하는 객체는 메타테이블이 아닌 클래스 그 자체 테이블을 반환한다. 메타테이블이 아니기 때문에 접근 제한이 걸리지 않는다. 다만 private, public 키워드를 붙여서 사용해야한다는 단점이 있다.

function ClassBase.public:GetThisByCDO(toClassBody)
	
	local thisInstance = self:CastByCDO(toClassBody)
	
	if not thisInstance then
		-- 발생하면 원칙을 잘 지키지 않고 코드를 작성한 경우다.
		return nil
	end
	
	-- 이렇게 하면 해당 클래스에서 접근 가능하다. 대신 public, private 과 같은 이름을 앞에 붙여줘야 한다.
	local this = getmetatable(thisInstance)
	
	return this
end

 

사용 예시는 다음과 같다.

function SubClassCDO.public:PrintDerived()

	local this = self:GetThisByCDO(SubClassCDO)

	print("SubClassCDO.public:PrintDerived() => " .. tostring(this.public.value3) .. tostring(this.private.value4))

	this.private.PrintPrivateDerived(self)
	this:PrintBase(self)

end

 

 

* 전체 기능 테스트

 

local SubClassCDO = require(script.SubClass)
local SuperClassCDO = require(script.SuperClass)


local subClassObject = SubClassCDO.Clone()
subClassObject.PrintDerived(subClassObject)

-- SuperClass에 있는 변수
print("value1 is in SuperClass public => " .. tostring(subClassObject.value1))
print("value2 is in SuperClass private => " .. tostring(subClassObject.value2))


-- SubClass에 있는 변수
print("value3 is in SuperClass public => " .. tostring(subClassObject.value3))
print("value4 is in SuperClass private => " .. tostring(subClassObject.value4))


local temp1 = subClassObject:CastByCDO(SuperClassCDO)
print("CastByCDO => SuperClassCDO => " .. temp1.GetClassTypeForInstance())
print("RealType(ref from reflection data) =>" .. temp1:GetType())

local temp2 = subClassObject:CastByType(SuperClassCDO:GetClassTypeForCDO())
print("CastByType => SuperClass Type => " .. temp2.GetClassTypeForInstance())
print("RealType(ref from reflection data) => " .. temp2:GetType())


local temp3 = subClassObject:CastByType("ClassBase")
print("CastByType => ClassBase Type => " .. temp3.GetClassTypeForInstance())
print("RealType(ref from reflection data) => " .. temp3:GetType())

-- SuperClass에 있는 함수
subClassObject.PrintBase(subClassObject)
print("GetPrivateValue2 => ".. tostring(subClassObject:GetPrivateValue2()))
SubClassCDO.public:PrintDerived() => 34
SubClassCDO.private:PrintPrivateDerived() =>
SuperClassCDO.public:PrintBase() => 12
value1 is in SuperClass public => 1
value2 is in SuperClass private => nil
value3 is in SubClass public => 3
value4 is in SubClass private => nil
CastByCDO => SuperClassCDO => SuperClass
RealType(ref from reflection data) =>SubClass
CastByType => SuperClass Type => SuperClass
RealType(ref from reflection data) => SubClass
CastByType => ClassBase Type => ClassBase
RealType(ref from reflection data) => SubClass
SuperClassCDO.public:PrintBase() => 12
GetPrivateValue2 => 2

 

* 정의

 

__index는 테이블의 메타테이블 안에 정의할 수 있는 메타메소드이다. 메타테이블의 __index가 정의되어 있다면 테이블에서 정의되지 않은 키가 들어왔을 때, 바로 nil로 반환하지 않고 __index 메타메소드를 통해 반환하게 된다.

 

 

* 사용 이유

 

다음과 같은 기능을 활용하기 위해 사용된다.

 

  • 테이블에서 정의되지 않은 키가 들어왔을 때 메타테이블에 존재하는 __index를 통해 메타테이블을 조회할 수 있다.
  • 클래스의 상속을 구현할 수 있다.

 

 

* 동작 방식

 

- 테이블에 정의되지 않은 키를 추가로 찾아보는 과정

 

일반적으로 루아에서 테이블에 정의되지 않은 값에 접근 할 때 nil 값을 반환한다. 

 

이렇게 정의되지 않은 값에 접근할 때 루아 인터프리터는 해당 테이블의 메타테이블의 __index 메타메소드를 확인한다.

  • 만약 메타테이블이 존재하지 않거나 __index 메타메소드가 존재하지 않으면 nil을 반환한다.
  • 메타테이블이 존재하고 __index 메타메소드에 정의되어 있으면 __index를 통해 해당 값을 반환하게 된다.

 

 

- __index 함수 버전 예시

 

이렇게 명시적으로 테이블의 메타테이블 안에 __index 메타메소드를 정의하여 기존 테이블에 존재하지 않은 특정 값들에 대해 nil이 아닌 다른 값들을 반환하도록 설정할 수 있다. 다음과 같이 사용할 수 있다.

local myTable = setmetatable({key1 = "value1"}, {
	__index = function(myTable, key)

		if key == "key2" then
			return "metatablevalue"
		end
		
		-- 여기 들어온 이유가 myTable[key]가 nil이라서 들어온 것인데, 또 같은 조건이다. 무한 반복하게 되어 스택 오버플로가 발생한다.
		--return myTable[key]
		
		return nil
	end
})

print(myTable.key1, myTable.key2, myTable.key3)
 value1 metatablevalue nil

코드에서 보면 테이블에 key1밖에 존재하지 않지만 key2를 찾을 때 메타테이블을 선언할 때 정의한 내부 __index 메타메소드를 통해 nil이 아닌 "metatablevalue"을 반환하는 것을 볼 수 있다. 만약 __index 메타메소드가 정의되지 있지 않다면 nil을 반환했을 것이다. key3은 테이블과 __index를 통해서 찾을 수 없기 때문에 nil을 반환하게 된다.

 

 

- __index 단순화 버전 예시

 

__index를 함수가 아닌 테이블로 초기화할 수도 있다. 그러면 __index 테이블에서 키로 값을  찾게 된다.

메타테이블을 연결하고 __index 자체를 메타테이블로 지정하면 메타테이블을 그대로 상속받는 효과를 얻을 수 있다. 

local table1 = {
	value1 = 1,
	value2 = 2,
	value3 = 3,
	print = function() print("print function in table1") end,
	printValue = function(value) print("printValue function in table1 => ".. tostring(value)) end
}

-- 메타테이블로 사용할 때 해당 테이블에 특정 키가 없으면 이 테이블에서 찾아보겠다는 뜻이다.
table1.__index = table1

local table2 = setmetatable({value4 = 4, value5 = 5}, table1)

print(table2.value1)
table2.print()
table2.printValue(3)
1
print function in table1
printValue function in table1 => 3

 

위의 코드를 함수 버전으로 구현하면 다음과 같다.

local table1 = {
	value1 = 1,
	value2 = 2,
	value3 = 3,
	print = function() print("print function in table1") end,
	printValue = function(value) print("printValue function in table1 => ".. tostring(value)) end
}

table1.__index = function(table, key) 
	local metatable = getmetatable(table)
	
	--if not metatable then
	--	return nil
	--end
	
	return metatable[key]
end

local table2 = setmetatable({value4 = 4, value5 = 5}, table1)

print(table2.value1)
table2.print()
table2.printValue(3)
1
print function in table1
printValue function in table1 => 3

 

 

- __index 함수 버전과 단순화 버전 호출 원리(추측)

 

두 버전을 적절하게 지원하는 이유는 myTable.key / myTable[key]로 접근하였을 때 특정 함수를 호출하여 적절하게 처리한다고 생각한다. 함수는 다음과 같이 구현되어 있을 가능성이 높다.(자료 서칭과 디버깅을 통해 작성된 코드입니다. 정확하지 않을 수 있습니다.)

local function GetValueByKey(table, key)

	-- table[key]을 사용하면 다시 메타테이블의 __index를 통해서 찾기 때문에 실제로 원하는대로 동작하지는 않는다. 
	-- if table[key] then return table[key] end
	
	
	-- 따라서 메타메소드를 우회할 수 있는 rawget함수를 사용한다.
	local value = rawget(table, key)
	if value then return table[key] end

	-- 메타테이블에서 추가로 찾아본다.	
	local metatable = getmetatable(table)
	if not metatable or not metatable.__index then return nil end

	-- 타입에 따라서 적절하게 반환해준다.
	local typeString = type(metatable.__index)

	if typeString == "table" then

		return metatable.__index[key]

	end

	-- __index값으로 table 이외에는 무조건 function을 넣어야 크래시가 발생하지 않는다.
	return metatable.__index(table, key)
end

여기서 주의할 점은 table[key] 연산 자체가 내부적으로 메타메소드를 호출하고 있고, table[key] 무한 루프에 빠질 수 있기 때문에 메타메소드를 호출하지 않고 우회하는 함수인 rawget을 사용하여 해당 테이블에 범위에서만 키가 존재하는 지 검사 해야한다.

 

 

- __index 정의 시 주의 사항

 

  1. 여기서 주의해야할 점은 __index 내부로 들어오는 매개변수는 해당 테이블과 키값으로 들어온다는 점이다.
  2. __index 메타 메소드 내부에서는 다시 해당 테이블을 키값으로 접근하게 되면 재귀가 무한 반복되니 주의하여야 한다.
  3. 2번과 같은 논리가 테이블에도 적용된다. metatable.__index = table 으로 설정하면 문제가 생긴다.

__index 메타메소드가 호출된 이유는 myTable[key]가 nil이라서 들어온 것인데, __index 메타 메소드에서 myTable[key]를 접근하면 또 nil이 나오고 다시 __index 메타메소드를 호출하게 된다. 결국 무한 반복하게 되어 스택 오버플로가 발생한다. 

 

* 정의

 

__newindex는 테이블의 메타테이블 안에 정의할 수 있는 메타메소드이다. 테이블에서 테이블에 정의되어 있지 않은 새로운 키를 할당하려고 할 때 해당 테이블의 메타테이블에서 __newindex가 정의되어 있다면 새로운 키에 대한 처리 방식을 정의할 수 있다. 

 

 

* 사용 이유

 

새로운 키가 할당되려고 할 때 동작방식을 정의함으로 써 여러 가지 기능을 만들어낼 수 있다. 예를 들어보면 읽기 전용 테이블을 구현할 수도 있다.

 

 

* 동작 방식

 

- 테이블에 새로운 키를 처리하는 과정

 

만약 테이블에 존재하지 않는 키에 대해 값을 할당하려고 하면  루아 인터프리터는 __newindex 메타메소드를 찾아보고 만약 __newindex가 정의되어 있다면 값을 할당하는 대신에 __newindex 메타메소드를 호출한다. 

 

__newindex는 __index와 함께 사용하면 활용도가 더 높다. 읽기 전용 테이블, 기본 값을 가지고 있는 테이블, 상속 관련 기능들도 구현할 수 있다. 

 

(메타메소드를 호출하지 않고 우회할 수 있는 rawset이라는 원시 함수를 제공한다. 이는 내부 로직을 구현할 때 무한 루프 방지 등 유용하게 사용될 수 있다.)

 

 

- __newindex 함수 버전 예시

 

__index와 다르게 새로운 값들을 할당하는 과정이기 때문에 value 매개변수도 같이 들어온다. 예시에서는 메타테이블에 정보를 저장하도록 만들었다.

local table1 = {}
local table2 = setmetatable({value1 = 1}, table1)
table1.__newindex = function(table, key, value)
	
	local metatable = getmetatable(table)
	
	--if not metatable  then
	--	-- 원래 테이블에 추가하도록 할수도 있다.
	--	-- rawset(table, key, value) -- 무한 재귀를 막기 위해 메타메소드 우회
	--	return
	--end
	
	-- metatable도 __newindex가 정의되어 있다면 이에 따라 수행된다.
	-- 다 찾아보고 부모 클래스도 없으면 그냥 본인 테이블에 넣는 것으로 정했다.
    -- 제일 아래에 있는 부모 클래스에 넣을 수도 있다.
	local rv = metatable[key]
	if not rv then
		rawset(table, key, value)
	else
		metatable[key] = value
	end
end

table2.newValue = 5
print(table2.newValue, table1.newValue)

table2.value1 = 10
print(table2.value1, table1.value1)
nil 5
10 nil

 

 

- __newindex 단순화 버전 예시

 

간단히 함수가 아닌 테이블로 지정하면 해당 테이블로 할당하게 된다.

local table1 = {}
local table2 = setmetatable({value1 = 1}, table1)
table1.__newindex = table1

table2.newValue = 5
print(table2.newValue, table1.newValue)

table2.value1 = 10
print(table2.value1, table1.value1)
nil 5
10 nil

 

 

- __newindex 함수 버전과 단순화 버전 호출 원리(추측)

 

myTable[key] = value / myTable.key = value로 할당할 때 다음과 같은 함수가 호출되어 처리될 것이라고 추측하고 있다. 메타메소드를 우회하기 위해 rawget, rawset을 사용하였다.

local function SetKeyValue(table, key, value)
	
	-- 원래 있는 값이면 그냥 갱신해준다. 메타메소드를 우회하기 위해 rawget을 사용한다.
	local targetValue = rawget(table, key)
	if targetValue then
		rawset(table, key, value)
		return
	end
	
	-- 메타테이블에서 __newindex가 정의되어 있지 않다면 테이블에 그냥 넣어준다. 기본 동작이다.
	local metatable = getmetatable(table)
	if not metatable or not metatable.__newindex then 
		-- table[key] = value를 사용하면 다시 메타테이블의 __newindex 통하게 되기 때문에 무한 재귀가 발생한다.
		-- table[key] = value

		-- 따라서 메타메소드를 우회할 수 있는 rawset함수를 사용한다.
		rawset(table, key, value)
		return
	end

	-- 타입에 따라서 적절하게 할당해 준다.
	local typeString = type(metatable.__newindex)

	if typeString == "table" then

		metatable.__newindex[key] = value
		return
	end

	metatable.__newindex(table, key, value)
end

 

 

 

* 정의

 

메타테이블(metatable)은 키 세트(key set)와 관련 메타 메소드의 도움으로 메타테이블과 연결된 테이블 동작에 대한 수정을 도와주는 테이블이다.

 

 

* 사용 이유

 

다음과 같은 기능을 활용하기 위해 사용된다.

 

  • 테이블 연산자(operator)에 기능을 변경 및 추가할 수 있다.
  • 테이블에서 정의되지 않은 키가 들어왔을 때 메타테이블에 존재하는 __index를 통해 메타테이블을 조회할 수 있다.
  • 클래스의 상속을 구현할 수 있다.

 

* 동작 방식

 

- setmatatable, getmatatable

 

메타테이블을 사용하기 위한 두 가지 중요한 메소드를 제공한다.

 

  • setmetatable(table, metatable) : 해당 테이블의 메타테이블을 설정하기 위해 사용된다.
  • getmetatable(table) - 해당 테이블의 메타테이블을 가져오기 위해 사용된다.

 

setmetatable은 다음과 같이 사용할 수 있다.

-- mytable = setmetatable({},{})
mytable = {}
mymetatable = {}
setmetatable(mytable,mymetatable)

 

 

- __index

 

https://create-new-worlds.tistory.com/363

 

[Lua] __index

* 정의 __index는 테이블의 메타테이블 안에 정의할 수 있는 메타메소드이다. 만약 테이블에서 정의되지 않은 값을 바로 nil로 반환하지 않고 추가로 __index 메타메소드를 확인하여 적절한 값을 반

create-new-worlds.tistory.com

 

 

- __newindex

 

https://create-new-worlds.tistory.com/369

 

[Lua] __newindex

* 정의 __newindex는 테이블의 메타테이블 안에 정의할 수 있는 메타메소드이다. 테이블에서 테이블에 정의되어 있지 않은 새로운 키를 할당하려고 할 때 해당 테이블의 메타테이블에서 __newindex가

create-new-worlds.tistory.com

 

 

- operator 메타메소드, 기타 메타메소드

 

+, -, *, /, % 등 수 많은 연산자에 대해서도 행동을 정의할 수 있다. 연산자 오버로딩과 비슷하게 생각하면 된다.

 

그 외에도 메소드의 동작을 정의할 수 있는 __call 메타메소드, print 에서의 동작 방식을 변경할 수 있는 __tostring등이 존재한다.

 

자세한 내용은 여기서 볼 수 있다.

https://www.tutorialspoint.com/lua/lua_metatables.htm#

 

Lua - Metatables

Lua - Metatables A metatable is a table that helps in modifying the behavior of a table it is attached to with the help of a key set and related meta methods. These meta methods are powerful Lua functionality that enables features like − Changing/adding

www.tutorialspoint.com

 

* 구조 정리

 

간단한 그림으로 나타내면 다음과 같이 구조로 사용하고 있다. (변경될 수 있음)

 

 

* 개선된 점

 

위와 같은 구조 변경으로 다음과 같은 점들을 개선할 수 있었다.

 

- RemoteFunction -> RemoteEvent 

 

현재 게임에서는 네트워크 통신에서 반환 값이 따로 필요 없어서 단방향 통신 기법인 RemoteEvent를 사용하였다. 

 

RemoteFunction은 사용하면 응답을 기다리는 스트립트를 내부적으로 생성하고(스크립트는 상황에 따라 새로운 스레드에서 돌아갈 수 있다.), RemoteEvent에 비해서 네트워크 지연시간과 트래픽이 크다. RemoteEvent를 사용함으로써 이에 대한 성능 향상효과를 기대할 수 있을 것 같다.

 

 

- 코드 분리

 

@ 공용

 

공용 데이터, ModuleScript는 ReplicatedStorage에 저장하여 서버와 클라이언트에서 같이 사용하도록 하였다. 공용되는 데이터 덕분에 기존에 매직 넘버로 해두었던 변수 값들과 중복으로 사용하던 스크립트를 적절하게 초기화할 수 있게 되었다.

 

또한 메시지를 포함한 모든 통신을 리플리케이션 변수를 통해서만 수행했던 부분을 필요한 정보만 RemoteEvent를 통해 넘겨줌으로써 네트워크 성능을 개선할 수 있었다.

 

추가로 참고할 정보 : ReplicatedStorage는 기본적으로 클라이언트에서 변경하더라도 서버로 리플리케이션되지 않기 때문에 서버는 염려 없이 해당 데이터를 사용할 수 있고 반대로 클라이언트는 리플리케이션된 값들을 읽어올 수 있다.

 

 

@ 서버

 

클라이언트로 노출되지 않아야할 정보들을 서버 전용 공간인 ServerStorage, ServerScriptService에 저장하고 있다. ServerScriptService에서는 메인 루프를 포함한 게임의 전반적인 로직과 플레이어의 초기화 로직을 담고 있고 ServerStorage에서는 서버에서만 사용되는 데이터들을 담고 있다.

 

 

@ 클라이언트

 

현재 클라이언트에서 돌아가는 스크립트(LocalScript)는 Gui을 컨트롤하는 스크립트로 클라이언트 공간인 StarterGui에 존재하고 있다. 이 스크립트는 ReplicatedStorage에 있는 여러 데이터들을 읽거나 콜백을 등록하여(RemoteEvent) 그 정보들을 기반으로 Gui를 갱신한다.

 

 

 

* 맵 스폰 위치 결정 알고리즘

 

최대한 거리를 적절히 두기 위해서 다음과 같은 방식으로 플레이어들을 배치하도록 한다.

 

 

- 배치 형식

 

그림에서 빨간색 위치에 플레이어를 배치한다.

 

<0 ~ 4명>

 

 

<5 ~ 8명>

 

이런 식의 패턴으로 배치되도록 알고리즘을 작성하였다. 

 

시작할 때 중앙을 바라보도록 하였으며 장애물이 있을 수 있기 때문에 임시로 일정 위치의 높이에서 RayCast를 사용하여 해당 위치에 텔레포트 하도록 하였다.

 

local function CalcSpawnPoints(playerCount, mapBase)
	local splitCount = 1

	local splitFactor = DivideWithoutRemainder(playerCount, 4);
	if playerCount % 4 ~= 0 then
		splitFactor += 1
	end

	splitCount += 2 * splitFactor
	local sidePerPlayerCount = DivideWithoutRemainder(splitCount, 2);

	local centerPosition = mapBase.Position
	local xLengthPerSector = mapBase.Size.X / splitCount
	local zLengthPerSector  = mapBase.Size.Z / splitCount
	
	local rv = {}
	
	local currentPosition = centerPosition - Vector3.new(xLengthPerSector * sidePerPlayerCount, 0, zLengthPerSector * sidePerPlayerCount)
	
	currentPosition = currentPosition + Vector3.new(0, 0, zLengthPerSector)
	for  i = 1, sidePerPlayerCount, 1 do
		table.insert(rv, currentPosition)
		table.insert(rv, Vector3.new(currentPosition.X + (splitCount -1) * xLengthPerSector, currentPosition.Y, currentPosition.Z))

		currentPosition = currentPosition + Vector3.new(0, 0, zLengthPerSector * 2)
	end
	
	
	currentPosition = centerPosition - Vector3.new(xLengthPerSector * sidePerPlayerCount, 0, zLengthPerSector * sidePerPlayerCount)

	currentPosition = currentPosition + Vector3.new(xLengthPerSector, 0, 0)
	for  i = 1, sidePerPlayerCount, 1 do
		table.insert(rv, currentPosition)
		table.insert(rv, Vector3.new(currentPosition.X, currentPosition.Y, currentPosition.Z + ((splitCount -1) * xLengthPerSector)))

		currentPosition = currentPosition + Vector3.new(xLengthPerSector * 2, 0, 0)
	end
	
	return rv
end

local function TeleportPlayerToSpawnPoint(character, spawnPoint, mapCenterPosition)
	local rayLength = 100
	local rayDirection = Vector3.new(0, -rayLength, 0)
	
	local humanoidRootPart = character:FindFirstChild("HumanoidRootPart")

	local rayOrigin = Vector3.new(spawnPoint.X, spawnPoint.Y + rayLength, spawnPoint.Z)

	local raycastParams = RaycastParams.new()
	local raycastResult = workspace:Raycast(rayOrigin, rayDirection, raycastParams)

	local finalPosition = rayOrigin
	if raycastResult then
		finalPosition = raycastResult.Position
	end

	humanoidRootPart.CFrame = CFrame.lookAt(finalPosition, mapCenterPosition)
end

 

 

- 게임 화면

 

실제 플레이 화면은 다음과 같다. 서버에서 탑뷰로 확인한 모습이다.

 

<4명>

 

<6명>

 

* 메인 루프 만들기

 

- ServerStorage

 

서버에서 사용되는 리소스들을 보관하기 위해서 사용하고 있다. 게임을 진행할 맵 정보와 무기 정보들을 가지고 있다.

 

 

- ServerScriptService

 

서버에서만 동작하는 스크립트를 작성하기 위해서 ServerScriptService에 스크립트를 추가하였다. 게임 루프도 여기서 정의하고 있다.

 

메인 루프가 돌아가는 Main 스크립트와 입장한 플레이어 정보를 초기화하는 Initializer 스크립트를 만들었다.

 

Initializer에서는 현재는 간단히 리더보드의 요소를 넣어주는 역할을 하고 있다.

local function Initialize(player)
	
	local leaderstatsFolder = Instance.new("Folder")
	leaderstatsFolder.Name = "leaderstats"
	leaderstatsFolder.Parent = player
	
	local coins = Instance.new("IntValue")
	coins.Name = "Coins"
	coins.Value = 50
	coins.Parent = leaderstatsFolder
	
end

game.Players.PlayerAdded:Connect(Initialize)

 

 

Main 스크립트에서는 게임 로직을 담고 있으며 간단히 다음과 같이 동작한다.

while true do
	
	1. 적당한 플레이어가 들어올 때까지 기다린다.
	
	
	2. 들어온 플레이어들을 테이블에 저장해둔다. 
	
	   중간에 들어온 플레이어는 이 게임이 끝날 때까지 기다려야 한다.
	
	
	3. 맵을 선택한다. ServerStorage/Maps 에서 랜덤하게 선택하고 클론을 만들어 사용한다.
	
	
	4. 배틀 로얄을 시작하기 위해 플레이어 정보를 초기화한다.
	
	   사용할 도구들, 생존을 확인할 수단 등을 플레이어에게 추가한다.
	
	   생성된 맵에 적절하게 배치해야 하기 때문에 공간을 적절하게 분할하여 
	   최대한 거리를 두고 배치하는 알고리즘을 작성했다.
	
	while true do
	
		5. 게임을 시작한다.
		   
		   본격적으로 게임루프가 시작되고 남은 시간과 생존자들의 수 등의 정보들을 
		   ReplicationStorage를 통해 각 클라이언트에서 확인할 수 있도록 하고
		   각 클라이언트는 이를 기반으로 Gui를 갱신한다.
		   
		   남아있는 플레이어 수를 지속적으로 확인하여 최종 한명이 남는다면 해당 플레이어에게 보상을 지급한다.
	   
	end

	6. 정보 초기화

		생성한 맵을 삭제하고 플레이어에게 주었던 도구들, 다른 정보 등을 모두 삭제한다.

end

 

 

- 3인 플레이 테스트 영상

 

 

* 정의

 

ServerScriptService는 Script와 ModuleScript, 스크립팅과 관련된 서버에서 사용되는 에셋을 담는 컨테이너 서비스이다.

 

컨텐츠는 플레이어의 클라이언트에게 절대 복제되지 않기 때문에 중요한 게임 로직을 담고 있는 보안 저장소로 사용된다. 이 서비스가 비활성화되어 있지 않으면 스크립트는 실행된다.

 

 

* 동작 방식

 

- 특징

 

  • LoadStringEnabled를 통해 루아에서 loadString 함수가 비활성화 / 활성화를 설정할 수 있다. 보안을 위해서 꺼두는게 좋다.
  • 스크립팅과 관련 없는 다른 에셋에 접근하려면 이러한 에셋이 ServerStorage에 존재해야 한다. ServerStorage는 ServerScriptService와 비슷하게 동작하지만 Script 오브젝트가 Disabled가 아니라도 실행되지 않는다는 점이 다르다.
  • 서버와 클라이언트에서 모두 사용되는 에셋과 모듈스크립트는 ReplicatedStorage를 사용하는 것이 좋다.

 

+ Recent posts