게임 대항해시대3 C# 리메이크 with 엔진

대항해시대3라는 97년도에 출시된 제 최애 게임을 리메이크 하는 개인 프로젝트를 진행 중입니다. 오리지널은 윈도우 앱이지만 8비트이구요 C++에 DirectDraw라는 것으로 구현이 되어 있는데요 리메이크 판은 C#에 DirectX로 구현중입니다.

(한국어판 발견물에 불국사도 있습니다)

WPF를 공부를 해보고자 게임 핼퍼 앱을 만들다가 게임을 후킹 해서 게임쪽에 모딩을 하다가 계속 분석하다보니 게임을 만들 수 있을 것 같아서 다이렉트X로 클로드 시켜서 만들고 있습니다.

.exe를 분석 해서 옵시디안에 기록 한 후 분석들을 보면서 구현 하라고 하니 조금씩 구현을 하더라구요. 하지만 디테일 한것들은 오더를 줘야 합니다. 클로드를 그냥 놔두면 게임인지 찰흙인지 모를것들만 쏟아내서 UI같은 것들을 8비트로 구현된 것을 32비트로 옮기면서 어색한 것 들을 재해석 해주는게 제가 주로 하는 일 입니다.

예를 들면 상단 Bar 그래픽이 옛날에는 이런 이미지를 가지고 0~12픽섹은 왼쪽 13~25 픽셀은 몇번 반복 해서 길이를 맞추고 26~끌 픽셀은 오른쪽 끝에 배치 하는 것을 다시 조합을 해서 넣는 등의 작업입니다.

현재는 해상 이동, 도시, 신규캐릭터, 선박 구입, 동료 등용, 미니게임 등 대부분의 기능들의 뼈대가 구성이 되어가고 있습니다.

유니티보다 DirectX가 좋은 이유는 유니티 처럼 거창하게 SdK같은것 몇GB씩 설치 안해도 바로 게임 구현에 들어갈 수 있다는 점이 아주 큰 매력입니다. 릴리즈해도 유니티는 백몇MB씩 들어가는데 윈도우 기반이니까 수십MB로도 돌아가는 게임을 배포를 해볼 수 있다는 것 자체가 진입장벽이 낮지 않나 싶습니다.

엔진 구성 요소

게임은 런타임 메모리가 화면에 출력되는 것을 사용자가 눈과 귀로 인지 하고 재미를 느끼는 앱입니다. 런타임 메모리에 기본 값들이 있고 거기에 사용자의 입력에 따라 발생한 이벤트에 따라 실행된 함수의 결과가 업데이트 되는 구조입니다.

목표는 오리지널게임의 100%구현인데 구현을 하려면 구성 요소들을 테스트 해야 하거든요 이를테면 배 속도가 항해 속도에 체감할 정도로 영향을 미치는지, 주인공의 무력 스탯이 육상전에서 부대 대미지에 영향을 주는지 등입니다.

테스트를 하려면 구성 요소의 필드 값들(속도, 무력)을 수정 해야 하구요 그럴려면 데이터를 편집하는 툴이 엔진에 추가 되어야 겠다는 생각이 들었습니다.

15개의 좋아요

재미있는 프로젝트네요! 그런데 기술적인 면이 아닌 다른 면에 있어서 한 가지 짚었으면 하는 것이 있어서 의견 드려봅니다.

나중에 규모가 커지고 공개 배포까지 이어진다면 코에이테크모의 게임 IP와 관련된 부분은 미리 한 번 점검해 보시면 좋겠습니다.

특히 원작의 동작을 분석해 독자적으로 재구현하는 것과 원작의 그래픽, 음원, 텍스트, 데이터 파일 등을 그대로 포함하거나 배포하는 것은 저작권 측면에서 성격이 다를 수 있습니다.

가능하다면 엔진과 로직은 독자 구현하고, 원작에서 추출한 자산은 저장소와 릴리스에서 분리하는 구조를 고려해 보셔도 좋겠습니다. 원작 데이터가 필요하다면 사용자가 직접 보유한 설치본에서 읽어 오는 방식도 하나의 대안이 될 수 있습니다.

재미있는 시도인 만큼 게임 IP 문제 때문에 프로젝트가 아쉽게 중단되지 않도록 초기에 경계를 정해 두시면 이후 확장에도 도움이 될 것 같습니다. :+1:

8개의 좋아요

네 좋은 의견 감사드립니다. 써주신 책도 감사히 잘 봤습니다.

안 그래도 저작권 부분에 대해 고민하는 시간을 보냈고 AI에도 당연히 물어봤습니다.

삼국지 게임도 코에이 게임과 아주 유사하게 만든 작품들이 스팀에 꽤 많이 올라와서 판매되고 있습니다. 왜냐하면 역사적 인물을 참고해서 캐릭터 등을 그렸다고 하면 걸기가 아주 애매하다고 합니다.

이 게임도 마찬가지로 역사적 인물들이 등장하고 초상화 등이 오히려 삼국지 보다 많이 남아 있어서 조금만 신경을 쓰면 많이 피해 갈 수 있다고 합니다. 지도도 세계지도이고 배도 15~16세기 배가 다 자료들이 남아있죠.

한가지 드리고 싶은 말씀은 우리 커뮤니티에 글을 올릴 때 심사숙고 하다가 지우는 경우가 많은데요 이번 글도 괜히 올리는게 아닌가 싶은 생각이 들기도 했습니다. 게시판 이동이 결국 됐는데요 저도 고민을 안하고 올린 것은 아닙니다. 하지만 옮겨지니 아쉽네요.

WPF, Maui등을 하다가 DirectX로 넘어와 봤는데 저는 이 부분에 대해서 기술적으로 고민을 했고 그 앱의 형태가 게임이 된 것입니다. Slog에 적합한 주제가 아니라고 생각 하신이유가 궁금합니다. 시간이 되신다면 답변을 들으면 좋겠습니다.

현재 완성품을 올린 것이 아니고 완성하는 과정을 공유하고 싶어서 올린것이었습니다. 글 서두에도 만드는 중이라고 밝혔는데 다시 좀 옮겨주시면 감사하겠습니다. 아니면 완성도 안됐는데 내가 만들었어요 올리는것도 편하진 않네요.

신문도 정치, 사회면이 있고 엔터테인먼트 면도 있는데 이 Topic이 이 커뮤니티의 Slog쪽에 그렇게 안어울리는 토픽입니까? 다양성을 조금 늘리고 포스팅이 많이 되어서 다양한 영감을 이 닷넷 진영에서 받아보고 싶습니다.

UI글이 많이 올라오는데 저도 UI쪽으로 고민이 아주 많고 엔진쪽으로도 고민이 아주 많습니다. 그래서 굳이 개인블로그에 올릴걸 하는 생각이 다시 한번 듭니다.

6개의 좋아요

피드백 감사드립니다. 카테고리 변경은 다시 진행했습니다. :+1:

그리고 게시판 이동은 그렇게 무겁거나 절대적인 변경이 아닙니다. 지금보다 더 가벼운 피드백을 주셔도 얼마든지 재조정이 가능하니 부담없이 의견 부탁드립니다. :folded_hands:

5개의 좋아요

일종의 godot 엔진 처럼 2D용 자체 엔진을 만드는것도 좋은 시도일것 같습니다.

그래서 전 개인적으로 대항해시대2를 다시 만들어보고 싶은 생각도 있었습니다.

거기에 항구를 기존보다 더 촘촘하게 해서 더 많은 항구와 마을을 찾아가는 탐험 경험도 좋지 않을까 했었습니다.

어쨋든 응원드리고 진행하시는 부분 관심있게 보겠습니다.

5개의 좋아요

매우 흥미롭네요.
최근 cursor로 작업하다가 바이너리 리버싱 엔지니어링은 정책 차단을 당했는데 클로드는 허용하는 걸지도

5개의 좋아요

아 추억이네요 ㅎ 지중해 브금이 참 좋았는데 ㅎㅎ

4개의 좋아요

저도 출발은 세이브데이터 뷰어 & 에디터였습니다. 그러다가 모드로 넘어갔는데 모드쪽은 게임 커뮤니티에 분석자료들이 많이 올라와 있어서 그걸 참고해서 주소만 찾아달라고 하니 이 부분은 리버스 엔지니어링과 경계가 희미해서 클로드가 진행을 해주었습니다.

항해 화면 같은 경우는 모드의 지도 출력 기능 기반으로 DirectX로 옮겨달라고 하니 잘 옮겨주었습니다.

중간 단계 없이 바로 “분석해줘” 이런식으로 하면 토큰을 많이 먹을 것 같아서 그런건지 결과를 대체로 유저들이 만족을 못해서인지 리버스 엔지니어링을 거부하더라구요.

라이선스 서버에서 인증 여부를 전달 받아서 권한을 열어주는 앱에서 tcp요청 하는 패킷을 로깅해달라고 하니 이 부분은 클로드도 안해주더라구요.

5개의 좋아요

엔진은 플레이어에게 정보를 주고, 입력을 받고, 데이터를 변경 시키는 기능을 합니다.

엔진의 런타임에 영향을 미치는 플레이어의 행동입니다.

각 기능들이 어디에 배치 될 것인지 지정 합니다. 분류가 안된 것들도 있을 수 있습니다.

위 오브젝트 들을 엔진 안쪽에 Class로 만들고 분류 합니다.

6개의 좋아요

데미지 애니메이션의 구현

육상전 구현중입니다. 대항해시대인데 3편은 특이하게 육상전이 있습니다. 게임에서 가장 역동적인 컨텐츠라고 할 수 있습니다.

하지만 밋밋하게 가만히 있고 숫자만 뜨면 재미가 없겠죠. 그래서 타격감을 부여 해주어야 합니다.

공격 당하고 흔들림

공격당하고 흔들림

데미지 표시

앞에서 맞았으면 흔들려 준 다음에 얼마의 피해가 있었는지도 시각적으로 보여줘야겠죠? 이미지가 떳다가 사라지면 밋밋하기 때문에 아래에서 떠오르는 효과로 4프레임에 걸쳐 구현 되어있습니다.

데미지 표시

다음은 애니메이션을 C#으로 다시 구현한 부분입니다. WPF로 구현을 했구요 Redraw()를 호출 합니다.

// 숫자가 뜬 채로 넉 장 — 숫자는 장마다 <b>왼위로 떠오르고</b> 맞은 부대는 좌우로
// 흔들린다. 마지막 자리에 선 채로 다섯 눈금을 더 쉬고 나서 걷힌다.
for (int f = 0; f < Shakes.Length; f++)
{
    var (dx, dy) = Rises[f];
    foreach (var (glyph, left, top) in shown) At(glyph, left + dx, top + dy);
    foreach (int slot in hurt) _shake[slot] = Shakes[f];
    Redraw();
    Rest(SwingMs);
}
foreach (int slot in hurt) _shake[slot] = 0;
Redraw();
Rest(RestMs);

foreach (var (glyph, _, _) in shown) _board.Children.Remove(glyph);
/// <summary>부대와 병사수를 다시 그린다.</summary>
private void Redraw()
{
    for (int i = _board.Children.Count - 1; i >= 0; i--)
        if (_board.Children[i] is FrameworkElement { Tag: UnitLayer })
            _board.Children.RemoveAt(i);
    Stand();
}

그러나 어색합니다. 맞고 흔들리고나서 데미지가 올라가야 하는데 같이 올라오니까 어색합니다.

맞으면서 흔들림

분석을 해보니 제자리 왼쪽 오른쪽 오른쪽 제자리 이렇게 가네요

0 → −8 → 0 → +8 → 0 으로 움직이구요. 코드는 다음과 같이 수정되었습니다.

private void Flash(IReadOnlyList<LandFight.Line> bout)
{
    var hurt = new List<int>();
    var hits = new List<(int Slot, string Men)>();
    foreach (var line in bout)
    {
        if (line.Damage <= 0) continue;
        if (line.Target < 0 || line.Target >= StandAt.Length) continue;
        if (!hurt.Contains(line.Target)) hurt.Add(line.Target);
        hits.Add((line.Target, line.Damage.ToString()));
    }

    if (hits.Count == 0) return;

    // ① 치는 몸짓이 끝나면 <b>한 장 멎었다가</b> 맞은 부대가 좌우로 흔들린다.
    //    이 동안 숫자는 아직 안 뜬다.
    Rest(SwingMs);

    int drift = 0;
    foreach (int step in Shakes)
    {
        // 표는 <b>자리가 아니라 걸음</b>이다 — 더해 가야 0·−8·0·+8·0 이 된다.
        drift += step;
        foreach (int slot in hurt) _shake[slot] = drift;
        Redraw();
        Rest(SwingMs);
    }
    foreach (int slot in hurt) _shake[slot] = 0;
    Redraw();

    // ② 흔들림이 멎고 나서 숫자가 뜬다.
    var shown = new List<(FrameworkElement Glyph, int Left, int Top)>();
    foreach (var (slot, men) in hits)
    {
        var (x, y) = StandAt[slot];
        int left = x + (LandArt.DeployWidth - men.Length * Digit) / 2;
        int top = y + (LandArt.DeployWidth - Digit) / 2;

        foreach (char c in men)
        {
            if (Number(c - '0') is { } glyph)
            {
                Panel.SetZIndex(glyph, FlashDepth);
                _board.Children.Add(At(glyph, left - Outline, top - Outline));
                shown.Add((glyph, left - Outline, top - Outline));
            }
            left += Digit;
        }
    }

    if (shown.Count == 0) { Rest(RestMs); return; }

    // ③ 넉 장에 걸쳐 왼위로 떠오른다. 마지막 자리에 선 채로 다섯 눈금을 더 쉰다.
    foreach (var (dx, dy) in Rises)
    {
        foreach (var (glyph, left, top) in shown) At(glyph, left + dx, top + dy);
        Rest(SwingMs);
    }
    Rest(RestMs);

    foreach (var (glyph, _, _) in shown) _board.Children.Remove(glyph);
}

결과

흔들리고 데미지

하지만 너무 계단이 지네요 비트맵 스케일 모드를 써서 이미지가 축소/확대되어도 깨지지 않고 깔끔하게 나오도록 설정 BitmapScalingMode.Fant을 적용 해줍니다.

private static Image Picture(uint[] bgra, int w, int h, int drawW = 0, int drawH = 0)
{
    var bmp = BitmapSource.Create(w, h, 96, 96, PixelFormats.Pbgra32, null, bgra, w * 4);
    bmp.Freeze();

    var image = new Image
    {
        Source = bmp,
        Width = drawW > 0 ? drawW : w,
        Height = drawH > 0 ? drawH : h,
        Stretch = Stretch.Fill,
    };
    RenderOptions.SetBitmapScalingMode(image, BitmapScalingMode.Fant);
    return image;
}

조금 나아졌지만 아직 원본인 오리지널에는 미치지 못합니다.

일단은 여기까지입니다.

3개의 좋아요

베타 런칭 했습니다.

그래픽 향상시키는 모드를 추가 했습니다.

6개의 좋아요

바이브코딩으로 했을 때 창 이슈 계속 반복

창(Window)이 여러개 나오고 사라지고 부모가 맵이었다가 도시 이미지 였다가 메뉴였다가 계속 바뀌는 상황에서 창들이 겹치고 닫혀야 할게 안닫히는 문제가 있었습니다.

다음과 같이 뒤에 메뉴가 접히는게 자연스러운데 안접히니까 어색하죠. 기능상 문제는 없습니다만은 어색한 문제는 있습니다.

이 문제는 창(Window)의 Owner문제 였습니다. 예를들면 도시 '메뉴’에서 '인물정보’를 호출 하는 경우 '메뉴’는 '인물정보’창의 Owner가 됩니다.

하지만 도시 밖을 나가면 도시는 없지만 '인물정보’는 호출 할 수 있어서 도시에서 고치면 도시 밖에서 문제가 생기고 도시밖에서 고치면 도시 안에서 문제가 생겼습니다.

찾아보니 창의 Owner를 지정 해도 되지만 바꿀 수 있다고 합니다. 그래서 호출하는 창을 부모로 하게끔 바꾸는 방식으로 회피 하였습니다.

3개의 좋아요

대단하시네요. 이런 방식의 공부는 흥미를 잃지 않고 꾸준히 하기에 좋을 것 같습니다! 잘 구경하고 갑니다!!