게임 개발_HISTORY

내배캠_TIL251015 [팀 프로젝트 TextRPG/콘솔에서 이미지 출력하기] 본문

Development In Unity/내일배움캠프_Unity 12기

내배캠_TIL251015 [팀 프로젝트 TextRPG/콘솔에서 이미지 출력하기]

EVANJ 2025. 10. 15. 09:58

⏰ 일정

  • 종일 팀 프로젝트 제작

📝 학습 요약

  • Intro 연결 및 꾸미기
  • 아스키아트를 추가해서 인트로 꾸미기
  • 콘솔 환경에서 이미지 출력 구현
  • 픽셀 해상도 업그레이드 하기
  • 인트로에서 선택지를 선택하면 반응이 멈추는 이슈

🧩 활동 내용

✔️ Intro 연결 및 꾸미기

▫️콘솔창 배경색과 글자색 바꾸기

Console.ForegroundColor = ConsoleColor.(색상명)

Console.BackgroundColor = ConsoleColor.(색상명)

위처럼 글자 색과 배경에 색깔을 선택해서 바꿀 수 있다.

 

Console.ResetColor();
으로 변경된 색깔을 원래대로 돌려 줄 수도 있다.

 

✔️ 아스키아트를 추가해서 인트로 꾸미기

아스키(ASCII) 아트는 오로지 텍스트와 특수문자만을 조합하여 사진이나 그림을 흉내내는 것을 말한다. 줄여서 AA라고도 부른다.

작업에 앞서 다른 블로그를 살펴보며 어떻게 작업할 지 구상을 해보았다.
해외 작업자 중 TEXT RPG 단어 사이에 칼을 꽂은 멋있는 그림이 있어 따라해 보았다.
C#에서는 다른 언어들과 달리 특수문자의 표현에 제한이 많았다. 그래서 표현 가능한 것으로 고르고 도트를 찍듯이 그림을 찍었다.

텍스트 아스키는 참고로 해당 웹사이트에서 편리하게 구현할 수 있었다.
마치 폰트를 적용하듯이 적을 텍스트를 적으면 바로 아스키 아트로 뽑아 준다.
Text to ASCII Art generator

다음과 같이 아스키 아트 작업을 진행했다.

 

✔️ 콘솔 환경에서 이미지 출력 구현

콘솔창에서도 이미지를 띄울 수 있다는 사실을 구글링하다 알게 되었다. 그 방법은 그렇게 어렵지 않았다.

우선 프로젝트 ⇒ NuGet 패키지 관리  찾아보기 SixLabors.ImageSharp 를 검색하면

해당 패키지를 찾을 수 있다. 설치를 하면 이제 콘솔 환경에서도 픽셀로 이미지를 출력할 수 있게 된다. 

설치 후 GitHub에 커밋을 하면 패키지도 함께 공유되어 이미지 출력이 가능한 구조가 된다.

다음으로 아스크아트로 구현되는 사이즈 및 출력 로직을 세워야 한다.
다음처럼 Main.cs에 로직을 구현했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
using System;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
 
static void Main(string[] args)
{
    EnableAnsiOnWindows();
    Console.OutputEncoding = Encoding.UTF8;
}
 
static void EnableAnsiOnWindows()
{
    if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return;
 
    const int STD_OUTPUT_HANDLE = -11;
    IntPtr handle = GetStdHandle(STD_OUTPUT_HANDLE);
    if (handle == IntPtr.Zero) return;
    if (!GetConsoleMode(handle, out uint mode)) return;
 
    const uint ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004;
    if ((mode & ENABLE_VIRTUAL_TERMINAL_PROCESSING) == 0)
        SetConsoleMode(handle, mode | ENABLE_VIRTUAL_TERMINAL_PROCESSING);
}
 
[DllImport("kernel32.dll", SetLastError = true)] static extern IntPtr GetStdHandle(int nStdHandle);
[DllImport("kernel32.dll", SetLastError = true)] static extern bool GetConsoleMode(IntPtr hConsoleHandle, out uint lpMode);
[DllImport("kernel32.dll", SetLastError = true)] static extern bool SetConsoleMode(IntPtr hConsoleHandle, uint dwMode);
cs

그리고 Intro.cs 에서 실질적으로 사용할 이미지의 경로를 입력하고 폴더에서 불러온다. 이미지를 띄우고 싶은 구간에 입력하면
또 이미지의 사이즈 및 픽셀 크기를 여기서 조정할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
using System.IO;
 
try
{
    string pixelImage = Path.Combine(AppContext.BaseDirectory, "PixelArt""InventoryIcon.png"); // 파일명은 원하는 걸로
    if (File.Exists(pixelImage))
    {
        // 작게: 28~36 사이 추천. 콘솔 폭 보면서 조절하세요.
        AsciiArt.DrawQuad(pixelImage, cellWidth: 20, aspectFix: 0.45); //AsciiArt.Draw(small, asciiWidth: 72, pixelMode: true, pixelCell: " "); __큰 이미지일 때 사용
        Console.WriteLine(); // 간격
    }
    else
    {
        Console.WriteLine("[이미지 없음: PixelArt/InventoryIcon.png]");
    }
}
catch (Exception ex)
{
    Console.WriteLine($"[이미지 출력 에러] {ex.Message}");
}
cs

아스크 아트에서 가장 코어가 될 클래스 구현은 AsciiArt.cs 에 입력한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
using System;
using System.Text;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
 
public static class AsciiArt
{
    public static void Draw(string imagePath, int asciiWidth = 80bool pixelMode = true)
    {
        using var img = Image.Load<Rgba32>(imagePath);
 
        double aspectFix = 0.55// 콘솔 세로비 보정
        int h = (int)(img.Height / (double)img.Width * asciiWidth * aspectFix);
        img.Mutate(x => x.Resize(asciiWidth, h));
 
        var sb = new StringBuilder(h * asciiWidth * 3);
        for (int y = 0; y < img.Height; y++)
        {
            for (int x = 0; x < img.Width; x++)
            {
                var p = img[x, y];
                if (pixelMode)
                    sb.Append($"\x1b[48;2;{p.R};{p.G};{p.B}m  ");     // 배경 두 칸
                else
                    sb.Append($"\x1b[38;2;{p.R};{p.G};{p.B}m@");      // 문자 한 칸
            }
            sb.Append("\x1b[0m\n"); // 줄바꿈마다 리셋
        }
        Console.Write(sb.ToString());
        Console.Write("\x1b[0m");   // 최종 리셋
    }
cs

여기까지 코드를 안전하게 입력하면 이미지 구현이 가능해진다.

 

이미지 출력에 기뻐하며 작업내역을 커밋 후 팀원들에게 확인을 요청했다. 하지만 팀원들 모두 이미지를 출력이 되지 않았다.
튜터님에게 질문을 하고 해답을 얻은 것은 경로가 유니티 경로로 설정되어 있어 콘솔에서 읽지 못한다는 것이였다.
문제의 경로는 
bin>Debug>net8.0>Asset>PixelArt 

으로 설정되어 있었다. 해당 경로는 유니티 전용 경로이므로 아래와 같이 변경했다.
Class10TRPG> PixelArt
cs 데이터들과 같은 경로로 설정하니 커밋할 때 변경내역으로 읽히고 공유가 가능해졌다.

✔️ 픽셀 해상도 업그레이드 하기
이제 이미지는 정상적으로 출력이 가능하나 사이즈가 눌리거나 너무 크고 해상도가 낮아 형체를 알아 보기 힘든 모습이였다.

우선 사이즈 조절은 Intro.cs 에서

try
                {
                    string small = Path.Combine(AppContext.BaseDirectory, "Assets", "InventoryIcon.png"); // 파일명은 원하는 걸로
                    if (File.Exists(small))
                    {
                        // 작게: 28~36 사이 추천. 콘솔 폭 보면서 조절하세요.
                        AsciiArt.Draw(small, asciiWidth: 12, pixelMode: true, asciiHeight: 12);
                        Console.WriteLine(); // 간격
                    }
                    else
                    {
                        Console.WriteLine("[이미지 없음: Assets/emblem.png]");
                    }
                }
                catch (Exception ex)
                {
                    Console.WriteLine($"[이미지 출력 에러] {ex.Message}");
                }

if 문 안에 첫 코드가 사이즈 조절이 가능한 변수이다.
다음 해상도가 낮은 문제는 업그레이드가 가능한 코드를 추가 및 변경해서 해결할 수 있었다.
AsciiArt.cs 에 아래의 코드를 추가하면 고화질의 픽셀 이미지를 추출할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
    public static void DrawHiRes(string imagePath, int asciiWidth = 80double aspectFix = 1.0)
    {
        using var img = Image.Load<Rgba32>(imagePath);
 
        // 2줄을 1문자로 합치므로 일반 Draw보다 보정이 적게 필요(1.0 근처)
        int h = (int)(img.Height / (double)img.Width * asciiWidth * aspectFix);
        if ((h & 1== 1) h++// 짝수 높이 맞춤(위/아래 한 쌍)
 
        img.Mutate(x => x.Resize(asciiWidth, h));
 
        var sb = new StringBuilder(h * asciiWidth * 8);
        for (int y = 0; y < h; y += 2)
        {
            for (int x = 0; x < asciiWidth; x++)
            {
                var up = img[x, y];
                var dn = img[x, y + 1];
 
                // 전경색=윗픽셀, 배경색=아랫픽셀, '▀' 문자로 출력
                sb.Append($"\x1b[38;2;{up.R};{up.G};{up.B}m\x1b[48;2;{dn.R};{dn.G};{dn.B}m▀");
            }
            sb.Append("\x1b[0m\n");
        }
        sb.Append("\x1b[0m");
        Console.Write(sb.ToString());
    }
 
    // 2×2 사분면(Quadrant) 렌더러
    public static void DrawQuad(string imagePath, int cellWidth = 80double aspectFix = 1.0)
    {
        using var img = Image.Load<Rgba32>(imagePath);
 
        // 문자 1칸이 2×2 픽셀 → 실제 리사이즈 해상도는 (cellWidth*2)×(cellHeight*2)
        int cellHeight = (int)(img.Height / (double)img.Width * cellWidth * aspectFix);
        if (cellHeight < 1) cellHeight = 1;
 
        int w = cellWidth * 2;
        int h = cellHeight * 2;
 
        img.Mutate(x => x.Resize(w, h));
 
        var sb = new StringBuilder(h * cellWidth * 8);
 
        // 2×2 블록을 하나의 문자로
        for (int y = 0; y < h; y += 2)
        {
            for (int x = 0; x < w; x += 2)
            {
                var pUL = img[x, y];       // Upper-Left
                var pUR = img[x + 1, y];   // Upper-Right
                var pLL = img[x, y + 1];   // Lower-Left
                var pLR = img[x + 1, y + 1]; // Lower-Right
 
                // 두 대표색(fg/bg) 선택: 밝기 기준으로 두 그룹으로 나눠 간단히 추정
                double lumUL = (0.2126 * pUL.R + 0.7152 * pUL.G + 0.0722 * pUL.B);
                double lumUR = (0.2126 * pUR.R + 0.7152 * pUR.G + 0.0722 * pUR.B);
                double lumLL = (0.2126 * pLL.R + 0.7152 * pLL.G + 0.0722 * pLL.B);
                double lumLR = (0.2126 * pLR.R + 0.7152 * pLR.G + 0.0722 * pLR.B);
 
                // 상하/좌우 대비가 약한 경우 색상 평균으로도 충분
                var avg = new Rgba32(
                    (byte)((pUL.R + pUR.R + pLL.R + pLR.R) / 4),
                    (byte)((pUL.G + pUR.G + pLL.G + pLR.G) / 4),
                    (byte)((pUL.B + pUR.B + pLL.B + pLR.B) / 4),
                    255);
 
                // 단순화: 밝은쪽을 FG, 어두운쪽을 BG로 가정
                // (원하면 K-Means 등으로 더 정교하게 해도 됨)
                double threshold = (lumUL + lumUR + lumLL + lumLR) / 4.0;
 
                // 어떤 사분면이 FG(전경색)일지 마스크 생성
                int mask = 0;
                if (lumUL >= threshold) mask |= 1;   // UL
                if (lumUR >= threshold) mask |= 2;   // UR
                if (lumLL >= threshold) mask |= 4;   // LL
                if (lumLR >= threshold) mask |= 8;   // LR
 
                // FG/BG 색상: 각 그룹 평균
                (Rgba32 fg, Rgba32 bg) = ComputeFgBgByMask(mask, pUL, pUR, pLL, pLR, avg);
 
                // 문자 선택
                char ch = QuadCharFromMask(mask);
 
                // ANSI 출력 (FG=전경, BG=배경)
                sb.Append($"\x1b[38;2;{fg.R};{fg.G};{fg.B}m\x1b[48;2;{bg.R};{bg.G};{bg.B}m{ch}");
            }
            sb.Append("\x1b[0m\n"); // 줄 끝 리셋
        }
 
        sb.Append("\x1b[0m");
        Console.Write(sb.ToString());
    }
 
    static (Rgba32 fg, Rgba32 bg) ComputeFgBgByMask(int mask, Rgba32 ul, Rgba32 ur, Rgba32 ll, Rgba32 lr, Rgba32 fallback)
    {
        // FG 그룹 평균, BG 그룹 평균 계산
        int fgCount = 0, bgCount = 0;
        int fr = 0, fgSum = 0, fb = 0// naming: fR,fG,fB
        int br = 0, bgSum = 0, bb = 0// bR,bG,bB
 
        void Acc(ref int cr, ref int cg, ref int cb, Rgba32 p)
        {
            cr += p.R; cg += p.G; cb += p.B;
        }
        void AddFg(Rgba32 p) { fgCount++; Acc(ref fr, ref fgSum, ref fb, p); }
        void AddBg(Rgba32 p) { bgCount++; Acc(ref br, ref bgSum, ref bb, p); }
 
        ((mask & 1!= 0 ? (Action<Rgba32>)AddFg : AddBg)(ul);
        ((mask & 2!= 0 ? (Action<Rgba32>)AddFg : AddBg)(ur);
        ((mask & 4!= 0 ? (Action<Rgba32>)AddFg : AddBg)(ll);
        ((mask & 8!= 0 ? (Action<Rgba32>)AddFg : AddBg)(lr);
 
        Rgba32 FG = fgCount > 0 ? new Rgba32((byte)(fr / fgCount), (byte)(fgSum / fgCount), (byte)(fb / fgCount), 255) : fallback;
        Rgba32 BG = bgCount > 0 ? new Rgba32((byte)(br / bgCount), (byte)(bgSum / bgCount), (byte)(bb / bgCount), 255) : fallback;
 
        return (FG, BG);
    }
 
    static char QuadCharFromMask(int m)
    {
        // 비트: 1=UL, 2=UR, 4=LL, 8=LR
        // 매핑 표(대표 조합)
        return m switch
        {
            0 => ' ',         // none
            1 => '▘',         // UL
            2 => '▝',         // UR
            3 => '▀',         // UL+UR
            4 => '▖',         // LL
            5 => '▌',         // UL+LL
            6 => '▞',         // UR+LL
            7 => '▛',         // UL+UR+LL
            8 => '▗',         // LR
            9 => '▚',         // UL+LR
            10 => '▐',         // UR+LR
            11 => '▜',         // UL+UR+LR
            12 => '▄',         // LL+LR
            13 => '▙',         // UL+LL+LR
            14 => '▟',         // UR+LL+LR
            15 => '█',         // all
            _ => ' '
        };
    }
cs

코드를 추가함에 따라 Inventory.cs(이미지를 삽입한 cs)에도 높은 해상도 코드를 적용해야 했다.
다음과 같이 변경했다.

                        // 작게: 28~36 사이 추천. 콘솔 폭 보면서 조절하세요.
                        AsciiArt.DrawQuad(pixelImage, cellWidth: 20, aspectFix: 0.45); //AsciiArt.Draw(small, asciiWidth: 72, pixelMode: true, pixelCell: " "); __큰 이미지일 때 사용
                        Console.WriteLine(); // 간격

cellWidth와 aspectFix 변수의 값을 출력되는 이미지를 확인하며 미세 조정을 해야했다.

 

이제는 픽셀 아트다우면서 형체를 알아볼 수 있게 되었다.

 

✔️ 인트로에서 선택지를 선택하면 반응이 멈추는 이슈

예를 들어 인트로로 진입하고 퀘스트 창 진입의 선택지를 선택하면 해당 선택지에 대해 입력했음을 피드백으로 "퀘스트를 선택하셨습니다."와 같이 출력된다. 이후 키를 한번 더 눌러야 다음단계로 넘어가지는 구조인데 이러한 부분이 흐름이 끊기는 것 같다는 팀원들의 피드백을 받아 1초 후 진입되도록 수정을 해보았다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
case "1":
    Console.WriteLine("\n상태 보기를 선택했습니다.");
    System.Threading.Thread.Sleep(1000);
    Console.Clear();
    Status.Show(player);              // 또는 new Status().Show(player);
    break;
 
case "2":
    Console.WriteLine("\n전투를 시작합니다.");
    System.Threading.Thread.Sleep(1000);
    Console.Clear();
    battleItem.Start(player, inventoryItem);
    break;
cs

 

✔️ GitHub와 Slack 연동하기
Slack 앱에서 Github와 연동하면 여러 활동들을 slack에서 알림을 받아볼 수 있다. github에서 커밋을 하고 공유하는 것이 작업자들 간에서 중요한데 연동을 하면 수훨하게 공유할 수 있다.

1. 우선 Slack에서 GitHub App을 설치한다.
더보기 → 자동화 → Github검색 → 추가
순서로 진행하고 액세스 권한을 추가하면 된다.

2. GitHub 채팅창으로 이동해 

/github subscribe https://github.com/smu06030/movie-app-team12

 

을 입력하고 Repository를 연결한다.
그리고 인증절차 및 설치를 진행한다.


3. GitHub에서 Slack App 추가하기

 

4. 채팅창에 명령어를 입력해서 커밋 내역 알림이 오도록 설정을 한다.
여기서 알림이 구동 안되는 문제가 있었는데
명령어 뒤에 세미콜론과 별을 입력하지 않았기 때문이었다.

/github subscribe owner/repository commit:*


해당 명령어를 입력하면 다음과 같이 연결이 되어 작동된다.

커밋 알림 설정 연동 메세지
커밋 후 자동 알림 메세지

🚀 내일 할 일 (To-do)

  • 팀프로젝트 도전기능 구현
  • PPT 내용을 팀원들과 의논하며 작성하기