클라이언트 UI 개발에 있어 웹 페이지를 작성하는 방식과 유사한 접근 방식을 적용하는 것은 많은 개발자들의 이상향일 것입니다. 동적인 웹 페이지는 HTML을 사용하여 레이아웃을 구성하고, VBScript나 JavaScript와 같은 스크립트 언어를 활용하여 사용자 상호작용 로직을 구현합니다. 이러한 방식은 요구사항 변경 시 서버 측의 코드만 수정하면 사용자에게 새로운 콘텐츠를 제공할 수 있다는 장점이 있습니다.
기존의 클라이언트 프로그램 개발 방식은 웹 개발과는 상당히 다를 수 있습니다. 전통적인 방식에서는 프로그래머가 UI 디자이너가 미리 배치한 다양한 컨트롤들을 코드 상에서 직접 관리해야 했습니다. 각 컨트롤의 표시 상태와 위치는 코드 로직을 통해 관리되었으며, UI 요소가 많아질수록 레이아웃 관련 코드는 복잡해졌습니다. UI 구성이 완료된 후에는 각 UI 요소에 대한 로직을 추가해야 했습니다. 로직이 비교적 고정되어 있어 변경이 필요 없다면 효율적일 수 있지만, 클라이언트 개발자들은 UI 수정에 대한 어려움을 경험하곤 합니다. 단순한 컨트롤 위치 조정은 비교적 간단하지만, UI 스타일이나 프로그램 전체의 실행 로직 변경이 필요할 경우, 전통적인 방식으로 개발된 클라이언트 프로그램은 업데이트가 매우 어렵거나 불가능해지기도 합니다.
최근에는 다양한 UI 개발 라이브러리에서 XML을 사용하여 UI 레이아웃을 정의하는 방식이 널리 채택되고 있습니다. XML 기반 레이아웃의 핵심은 HTML과 유사하게 UI 요소의 대량 생성, 자동 배치, 그리고 UI 크기 변경 시의 요소 적응성을 해결하는 데 있습니다. 하지만 UI 라이브러리에서 스크립트를 사용하여 로직을 제어하는 경우는 아직 많지 않습니다. 제가 아는 바로는 QT와 Bolt 정도가 스크립트 지원을 제공하지만, 두 라이브러리 모두 규모가 크며, QT의 스크립트 사용 방식(Bolt는 잘 모름) 또한 웹 개발과는 거리가 있습니다.
DuiEngine 시절부터 SOUI 라이브러리에는 Lua 스크립트 모듈이 포함되어 있었습니다. 하지만 당시에는 Lua 스크립트와 C++ 컨트롤 코드 간의 단순한 상호 호출 기능에 국한되었고, 명확한 애플리케이션 패턴을 형성하지는 못했습니다. 2015년 설 연휴 기간 동안 스크립트 모듈을 리팩토링한 결과, SOUI에서 웹 개발과 유사한 UI 개발 프로세스를 구현할 수 있게 되었습니다.
아래는 SOUI 데모에서 100% Lua 스크립트로 구현된 간단한 게임 효과입니다.
이러한 효과를 구현하기 위해 사용된 코드를 살펴보겠습니다.
SOUI에서 스크립트 모듈은 렌더링 모듈 등과 마찬가지로 플러그인 형태로 구현됩니다. 프로그램 시작 시 Lua 스크립트 모듈을 로드해야 합니다:
int WINAPI _tWinMain(HINSTANCE hInstance, HINSTANCE /*hPrevInstance*/, LPTSTR /*lpstrCmdLine*/, int /*nCmdShow*/)
{
// Lua 환경 초기화를 위해 OleInitialize를 반드시 호출해야 합니다.
HRESULT hRes = OleInitialize(NULL);
SASSERT(SUCCEEDED(hRes));
int nRet = 0;
SComMgr *pComMgr = new SComMgr;
{
// SApplication 객체는 전체 애플리케이션의 리소스를 관리합니다.
SApplication *theApp = new SApplication(pRenderFactory, hInstance);
#ifdef DLL_CORE
// Lua 스크립트 모듈 로드 (SOUI 코어가 DLL로 컴파일된 경우에만 사용 가능)
BOOL bLoaded = pComMgr->CreateScrpit_Lua((IObjRef**)&pScriptLua);
SASSERT_FMT(bLoaded, _T("load interface [%s] failed!"), _T("scirpt_lua"));
theApp->SetScriptFactory(pScriptLua);
#endif
// 전역 리소스 기술 XML 로드
theApp->Init(_T("xml_init"));
{
// SOUI 레이아웃을 사용하는 애플리케이션 창 생성 및 표시
CMainDlg dlgMain;
dlgMain.Create(GetActiveWindow(), 0, 0, 800, 600);
dlgMain.GetNative()->SendMessage(WM_INITDIALOG);
dlgMain.CenterWindow();
dlgMain.ShowWindow(SW_SHOWNORMAL);
nRet = theApp->Run(dlgMain.m_hWnd);
}
// 애플리케이션 종료
delete theApp;
}
exit:
delete pComMgr;
OleUninitialize();
return nRet;
}
다음으로, XML 레이아웃의 <SOUI> 태그 아래에 <script> 자식 태그를 추가해야 합니다:
<SOUI trCtx="dlg_main" title="SOUI-DEMO version:%ver%" bigIcon="LOGO:32" smallIcon="LOGO:16" width="600" height="400" appWnd="1" margin="5,5,5,5" resizable="1" translucent="1" alpha="255">
<skin>
<gif name="gif_penguin" src="gif:gif_penguin"/>
<apng name="apng_haha" src="apng:apng_haha"/>
</skin>
<style>
<class name="cls_edit" ncSkin="_skin.sys.border" margin-x="2" margin-y="2" />
</style>
<script src="lua:lua_test">
<!-- src 속성이 지정되지 않으면 cdata 섹션에서 스크립트 로드 -->
<![CDATA[
function on_init(args)
SMessageBox(0,T "execute script function: on_init", T "msgbox", 1);
end
function on_exit(args)
SMessageBox(0,T "execute script function: on_exit", T "msgbox", 1);
end
function onEvtTest2(args)
SMessageBox(0,T "onEvtTest2", T "msgbox", 1);
return 1;
end
function onEvtTstClick(args)
local txt3 = SStringW(L"append", -1);
local sender = toSWindow(args.sender);
sender:GetParent():CreateChildrenFromString(L"<button pos=\"0,0,150,30\" on_command=\"onEvtTest2\">lua btn 中文</button>");
sender:SetVisible(0, 1);
return 1;
end
]]>
</script>
<root class="cls_dlg_frame" cache="1" on_init="on_init" on_exit="on_exit">
<caption pos="0,0,-0,30" show="1" font="adding:8">
<icon pos="10,8" src="LOGO:16"/>
<text class="cls_txt_red">SOUI-DEMO version:%ver%</text>
<imgbtn id="1" skin="_skin.sys.btn.close" pos="-45,0" tip="close" animate="1"/>
<imgbtn id="2" skin="_skin.sys.btn.maximize" pos="-83,0" animate="1" />
<imgbtn id="3" skin="_skin.sys.btn.restore" pos="-83,0" show="0" animate="1" />
<imgbtn id="5" skin="_skin.sys.btn.minimize" pos="-121,0" animate="1" />
<imgbtn name="btn_menu" skin="skin_btn_menu" pos="-151,2" animate="1" />
</caption>
<other/>
</root>
</SOUI>
<script> 태그 내에서 src 속성을 사용하여 스크립트 파일 경로를 지정하거나, 스크립트를 CDATA 섹션에 직접 작성할 수 있습니다. src 속성이 우선적으로 적용됩니다.
위 XML 예시에서 <root> 태그에 on_init과 on_exit이라는 새로운 속성이 추가된 것을 확인할 수 있습니다. 이 속성들은 UI 초기화 완료 시점과 UI 소멸 직전에 실행될 스크립트 함수를 지정합니다.
이 두 단계를 완료하면 SOUI에서 스크립트를 사용하는 준비가 완료됩니다.
위 이미지에서 보이는 경마 게임 효과를 구현하기 위해, 먼저 XML을 사용하여 기본적인 UI 요소 레이아웃을 구성합니다.
<?xml version="1.0" encoding="utf-8"?>
<include>
<window size="full,full" name="game_wnd" on_size="on_canvas_size" id="300">
<text pos="0,0,-0,@30" colorBkgnd="#cccccc" colorText="#ff0000" align="center">SOUI + LUA 跑马机</text>
<window pos="0,[0,-0,-50">
<window pos="0,0,-64,-0" name="game_canvas" clipClient="1" colorBkgnd="#ffffff">
<!-- 경기장 -->
<gifplayer name="player_1" float="1" skin="gif_horse">
<text pos="0,%1" colorText="rgb(255,0,0)" font="size:20">1</text>
</gifplayer>
<gifplayer name="player_2" float="1" skin="gif_horse">
<text pos="0,0" colorText="rgb(255,0,0)" font="size:20">2</text>
</gifplayer>
<gifplayer name="player_3" float="1" skin="gif_horse">
<text pos="0,0" colorText="rgb(255,0,0)" font="size:20">3</text>
</gifplayer>
<gifplayer name="player_4" float="1" skin="gif_horse">
<text pos="0,0" colorText="rgb(255,0,0)" font="size:20">4</text>
</gifplayer>
<gifplayer name="flag_win" float="1" skin="gif_win" show="0" id="400"/>
<text pos="|0,0">赔率:</text>
<text pos="[0,0,@20,[0" colorText="#ff0000" name="txt_rate">4</text>
<hr pos="-1,0,-0,-0" mode="vertical" colorLine="#ff0000"/>
</window>
<window pos="[0,0,-0,-0">
<window pos="0,%12.5,@64,@64" offset="0,-0.5" skin="img_coin" id="1" tip="下注1号马" on_command="on_bet">0</window>
<window pos="0,%37.5,@64,@64" offset="0,-0.5" skin="img_coin" id="2" tip="下注2号马" on_command="on_bet">0</window>
<window pos="0,%62.5,@64,@64" offset="0,-0.5" skin="img_coin" id="3" tip="下注3号马" on_command="on_bet">0</window>
<window pos="0,%87.5,@64,@64" offset="0,-0.5" skin="img_coin" id="4" tip="下注4号马" on_command="on_bet">0</window>
</window>
</window>
<window pos="10,[5,-0,-0" name="game_toolbar">
<button name="btn_run" pos="0,|0,@100,@30" offset="0,-0.5" tip="run the game" on_command="on_run">run</button>
<text pos="]-5,0,@-1,-0" offset="-1,0">现有金币:</text>
<text pos="-64,0,@64,-0" name="txt_coins" align="center">100</text>
</window>
</window>
</include>
위 XML에서 <gifplayer> 컨트롤은 4마리의 말을 나타내지만, 위치는 pos 속성으로 명시적으로 지정하지 않았습니다. 이는 말의 위치가 동적으로 변하기 때문이며, 스크립트에서 제어해야 합니다. 또한, 여러 버튼 컨트롤에 지정된 on_command 속성에 주목하십시오. on_command는 컨트롤의 클릭 이벤트 속성으로, 지정 시 스크립트 내의 해당 함수를 실행합니다.
SOUI에서는 모든 이벤트에 대해 XML에서 스크립트 함수로 매핑 가능한 속성을 정의합니다. 이 속성의 구현 방식은 다음과 같습니다:
class SOUI_EXP EventCmd : public TplEventArgs<EventCmd>
{
SOUI_CLASS_NAME(EventCmd, L"on_command")
public:
EventCmd(SObject *pSender) : TplEventArgs<EventCmd>(pSender) {}
enum { EventID = EVT_CMD };
};
위 코드를 보면, 이 속성은 해당 이벤트 클래스가 SOUI 내에서 가지는 문자열 이름임을 알 수 있습니다.
이제 경마 게임을 구현하는 실제 Lua 스크립트 코드를 살펴보겠습니다.
local win = nil;
local tid = 0;
local gamewnd = nil;
local gamecanvas = nil;
local players = {};
local flag_win = nil;
local coins_all = 100; -- 현재 보유 금액
local coins_bet = {0, 0, 0, 0}; -- 베팅 금액
local bet_rate = 4; -- 배당률
local prog_max = 200; -- 최대 진행 단계
local prog_all = {0, 0, 0, 0}; -- 말별 진행도
function on_init(args)
-- 전역 객체 초기화
win = toHostWnd(args.sender);
gamewnd = win:GetRoot():FindChildByNameA("game_wnd", -1);
gamecanvas = gamewnd:FindChildByNameA("game_canvas", -1);
flag_win = gamewnd:FindChildByNameA("flag_win", -1);
players = {
gamecanvas:FindChildByNameA("player_1", -1),
gamecanvas:FindChildByNameA("player_2", -1),
gamecanvas:FindChildByNameA("player_3", -1),
gamecanvas:FindChildByNameA("player_4", -1)
};
-- 레이아웃 조정
on_canvas_size(nil);
math.randomseed(os.time());
end
function on_exit(args)
-- 종료 시 필요한 로직
end
function on_timer(args)
if gamewnd ~= nil then
local rcCanvas = gamecanvas:GetWindowRect2();
local heiCanvas = rcCanvas:Height();
local widCanvas = rcCanvas:Width();
local rcPlayer = players[1]:GetWindowRect2();
local wid = rcPlayer:Width();
local hei = rcPlayer:Height();
local win_id = 0;
for i = 1, 4 do
local prog = prog_all[i];
if prog < prog_max then
prog = prog + math.random(0, 5);
prog_all[i] = prog;
local rc = players[i]:GetWindowRect2();
rc.left = rcCanvas.left + (widCanvas - wid) * prog / prog_max;
players[i]:Move2(rc.left, rc.top, -1, -1);
else
win_id = i; -- 우승마 ID
local rc = players[i]:GetWindowRect2();
rc.left = rcCanvas.left + (widCanvas - wid);
players[i]:Move2(rc.left, rc.top, -1, -1);
end
end
if win_id ~= 0 then
-- 게임 종료 처리
gamewnd:FindChildByNameA("btn_run", -1):FireCommand(); -- 버튼 클릭 이벤트 발생
coins_all = coins_all + coins_bet[win_id] * bet_rate;
gamewnd:FindChildByNameA("txt_coins", -1):SetWindowText(T(coins_all));
coins_bet = {0, 0, 0, 0}; -- 베팅 초기화
local rcPlayer = players[win_id]:GetWindowRect2();
local szFlag = flag_win:GetDesiredSize(rcPlayer);
rcPlayer.right = rcPlayer.left + szFlag.cx;
rcPlayer.bottom = rcPlayer.top + szFlag.cy;
rcPlayer:OffsetRect(-szFlag.cx, -szFlag.cy / 3);
flag_win:Move(rcPlayer);
flag_win:SetVisible(1, 1);
flag_win:SetUserData(win_id); -- 우승마 ID 저장
-- 베팅 금액 표시 초기화
for i = 1, 4 do
gamewnd:FindChildByID(i, -1):SetWindowText(T("0"));
end
end
end
end
function on_bet(args)
-- 게임 중에는 베팅 불가
if tid ~= 0 then
return 1;
end
local btn = toSWindow(args.sender);
if coins_all >= 10 then
local id = btn:GetID();
coins_bet[id] = coins_bet[id] + 10;
coins_all = coins_all - 10;
btn:SetWindowText(T(coins_bet[id]));
gamewnd:FindChildByNameA("txt_coins", -1):SetWindowText(T(coins_all));
end
return 1;
end
function on_canvas_size(args)
if win == nil then
return 0;
end
local rcCanvas = gamecanvas:GetWindowRect2();
local heiCanvas = rcCanvas:Height();
local widCanvas = rcCanvas:Width();
local szPlayer = players[1]:GetDesiredSize(rcCanvas);
local wid = szPlayer.cx;
local hei = szPlayer.cy;
local rcPlayer = CRect(0, 0, wid, hei);
local interval = (heiCanvas - hei * 4) / 5;
rcPlayer:OffsetRect(rcCanvas.left, rcCanvas.top + interval);
for i = 1, 4 do
local rc = rcPlayer;
-- 말의 초기 위치 계산
rc.left = rcCanvas.left + (widCanvas - wid) * prog_all[i] / prog_max;
rc.right = rc.left + wid;
players[i]:Move(rc);
rcPlayer:OffsetRect(0, interval + hei);
end
-- 우승 깃발 위치 조정
local win_id = flag_win:GetUserData();
if win_id ~= 0 then
local rcPlayer = players[win_id]:GetWindowRect2();
local szFlag = flag_win:GetDesiredSize(rcPlayer);
flag_win:Move2(rcPlayer.left - szFlag.cx, rcPlayer.top - szFlag.cy / 3, -1, -1);
end
return 1;
end
function on_run(args)
local btn = toSWindow(args.sender);
if tid == 0 then
-- 게임 시작
prog_all = {0, 0, 0, 0}; -- 진행도 초기화
on_canvas_size(nil);
tid = win:setInterval("on_timer", 200); -- 타이머 시작
btn:SetWindowText(T"stop");
flag_win:SetVisible(0, 1); -- 우승 깃발 숨김
else
-- 게임 중지
win:clearTimer(tid); -- 타이머 중지
tid = 0;
btn:SetWindowText(T"run");
end
return 1;
end
function on_btn_select_cbx(args)
-- 콤보박스 선택 관련 로직 (예시)
local btn = toSWindow(args.sender);
local cbxwnd = btn:GetWindow(2); -- 이전 형제 창 가져오기
local cbx = toComboboxBase(cbxwnd);
cbx:SetCurSel(-1); -- 선택 해제
end
on_init 함수에서는 필요한 UI 컨트롤들을 가져와 Lua 전역 변수에 저장합니다.
on_canvas_size 함수에서는 UI 크기에 따라 4마리의 말 위치를 자동으로 조정합니다.
사용자 조작에 대한 이벤트 처리 함수들은 여러 버튼의 이벤트에 응답합니다.
이로써 간단한 경마 게임 효과 구현이 완료되었습니다. 이 예제가 너무 간단하다고 생각할 수도 있지만, 상상해보십시오. 동일한 방식을 사용하면 UI와 로직을 서버에서 관리하고, 클라이언트는 웹 페이지처럼 매일 서버로부터 UI를 업데이트받을 수 있습니다. 이는 클라이언트 프로그램의 미래를 보여주는 가능성입니다.
추신: SOUI에 웹 개발과 유사한 스크립트 기능을 구현하는 것은 SOUI 2015년의 중요한 업데이트 중 하나였습니다. 원래는 이러한 내용을 소개하는 블로그 게시물을 작성할 계획이었으나, 기존에 사용하던 lua_tinker 0.5c 버전에서 C++ 클래스를 Lua로 내보내는 데 문제가 있어 적절한 해결책을 찾지 못하고 있었습니다. luabind나 tolua++와 같은 성숙한 방법들도 시도했지만, SOUI와 같이 경량화된 UI 라이브러리에는 다소 부담스럽게 느껴졌습니다. luawrapper, fflua, 그리고 인터넷에서 찾은 lua_tinker 수정 버전 등 다양한 방법을 시도했지만 만족스럽지 못했습니다. 특히, C++ 객체의 다중 상속 문제로 인해 내보낸 하위 클래스에서 기본 클래스의 메서드를 정상적으로 호출하지 못하는 점이 가장 큰 어려움이었습니다. 이 문제를 해결할 방법을 이해하고 나서야 이 Lua 모듈을 소개할 수 있게 되었고, SOUI의 Lua 코어 버전을 5.1에서 5.2.3으로 업그레이드했습니다.