
콘솔 창을 기준으로 만들어본 SpaceInvader입니다. 플레이어는 좌우로만 움직일 수 있고 적 인베이더들을 총알로 전부 격추 시켜 클리어하는 게임입니다.
클래스들의 역할
1. 전체 게임의 흐름을 제어하는 Game, PlayScene
2. 게임 내에 객체들을 관리하는 World, InvaderManager
3. 게임 내 물체들의 개별 행동들을 관리하는 Entity, Player, Invader, Bullet
4. 키보드 입력과, 충돌 판정, 화면 출력 등 독립적으로 수행하는 기능들인 InputSystem, CollisionSystem, RendererSystem

// game.cs
using System.Diagnostics; // 시간을 재는 '스톱워치' 기능을 쓰려고 가져옴
using System.Threading; // 잠깐 대기하는 'Sleep' 기능을 쓰려고 가져옴
namespace SpaceInvader
{
public sealed class Game
{
// 실제 게임 로직(플레이어, 적, 화면 등)
private PlayScene _scene = new PlayScene();
// 프레임
private const int TargetFps = 30;
// 1프레임당 걸려야 하는 시간 계산. 1000ms(1초) 나누기 30 = 약 33ms
private const int FrameMs = 1000 / TargetFps;
public void Run()
{
// 시간을 잴 스톱워치 생성
var stopWatch = new Stopwatch();
_scene.Enter();
// _scene에서 나가기까지 계속 반복
// 게임이 돌아가는 메인 루프
while (!_scene.IsExitRequest)
{
// 시간 측정 시작!
stopWatch.Restart();
// 1. 키 입력 받고, 캐릭터 위치 옮기고, 죽었나 살았나 체크하고
_scene.Update();
// 2. 화면 출력
_scene.Render();
// 3. 속도 조절
// 이번 프레임을 처리하는 데 걸린 시간을 잰 다음, 33ms보다 빨리 끝났으면 남은 시간만큼 쉼.
// 이걸 안 하면 컴퓨터 성능에 따라 게임 속도가 빨라짐.
int remainMs = FrameMs - (int)stopWatch.ElapsedMilliseconds;
if (remainMs > 0)
{
Thread.Sleep(remainMs);
}
}
// 게임 종료
_scene.Exit();
}
}
}
// playscene.cs
using SpaceInvader.System;
using SpaceInvader.Util;
using SpaceInvader.World;
using System;
namespace SpaceInvader
{
public sealed class PlayScene
{
private World.World _world = new World.World();
private InputSystem _input = new InputSystem(); // 키보드 입력
private CollisionSystem _collision = new CollisionSystem(); // 충돌 판정
private ConsoleRenderer _renderer = new ConsoleRenderer(); // 화면 출력
// 게임을 끄라는 신호인지 확인하는 변수
public bool IsExitRequest { get; private set; } = false;
// 게임 시작할 때 딱 한 번 실행되는 함수
public void Enter()
{
_world.Initialize(); // 초기화 (플레이어 만들고, 적 배치하고)
_renderer.Initialize(); // 초기화 (화면 깨끗이 지우기)
}
// 매 프레임마다 반복 실행되는 함수
public void Update()
{
// 1. 키보드 입력을 받아옴
InputFrame input = _input.ReadInput();
// 만약 ESC키를 눌러서 종료 요청이 들어왔다면
if (input.Exit)
{
IsExitRequest = true; // 종료
return;
}
// 2. 입력을 전달 (플레이어 움직임 처리)
_world.ApplyInput(input);
// 3. 진행 (적들 움직이고, 총알 날아가고)
_world.Update();
// 4. 충돌 검사 (총알이 적을 맞췄는지 확인)
_collision.CheckCollision(_world);
// 5. 시체 치우기 (죽은 적, 화면 밖으로 나간 총알 삭제)
_world.Cleanup();
// 6. 게임 오버 됐는지 확인
CheckGameOver();
}
// 게임 오버 조건을 검사하는 함수
private void CheckGameOver()
{
// 조건 1: 인베이더가 바닥까지 내려왔거나 (_world.IsGameOver가 true)
// 조건 2: 플레이어가 죽어서 삭제됐을 경우 (Player 변수가 null이 됨)
if (_world.IsGameOver || _world.Player == null)
{
IsExitRequest = true; // 게임 루프를 끝내라고 신호 보냄
// 화면을 지우고 GAME OVER 글자를 띄움
Console.Clear();
Console.SetCursorPosition(0, 0);
Console.WriteLine("GAME OVER");
}
}
// 화면 출력 함수
public void Render()
{
// 게임 오버 상태가 아닐 때만
if (!IsExitRequest)
{
_renderer.Draw(_world); // 현재 상태를 버퍼에 그림
_renderer.Present(); // 실제 모니터에 보여줌
}
}
// 게임 끝날 때 뒷정리하는 함수
public void Exit()
{
_renderer.Shutdown();
}
}
}
//world.cs
using System.Collections.Generic;
using SpaceInvader.Util;
namespace SpaceInvader.World
{
public sealed class World
{
// 게임에 존재하는 모든 물체를 담는 리스트
private readonly List<Entity> _entities = new List<Entity>();
// 추가/삭제할 물체를 잠깐 담아두는 임시 리스트
// for문 돌면서 리스트를 직접 건드리면 에러가 나기 때문에, 나중에 한꺼번에 처리하려고
private List<Entity> _toAdd = new List<Entity>();
private List<Entity> _toRemove = new List<Entity>();
private Player _player;
private InvaderManager _invaderManager;
// 게임 화면 크기\
public int Width { get; private set; } = 80;
public int Height { get; private set; } = 30;
// 게임 오버 상태인지 확인하는 변수\
public bool IsGameOver { get; set; } = false;
public World()
{
// 월드가 만들어질 때 적 관리자도 같이 고용함
_invaderManager = new InvaderManager(this);
}
// 물체를 추가 예약하는 함수
public void Add(Entity entity)
{
_toAdd.Add(entity); // 대기열에 넣음
// 만약 추가된 게 플레이어라면 따로 변수에 저장해둠 (나중에 찾기 쉽게)
if (entity is Player) _player = (Player)entity;
}
// 물체를 삭제 예약하는 함수
public void Remove(Entity entity)
{
_toRemove.Add(entity); // 대기열에 넣음
// 플레이어가 삭제된다면 변수도 비워둠
if (entity is Player) _player = null;
}
// 외부에서 _entities 리스트를 읽을 수 있게 해주는
public List<Entity> Entities => _entities;
public Player Player => _player;
// 게임 시작 시 초기화
public void Initialize()
{
// 기존에 있던 거 싹 비움
_entities.Clear();
_toAdd.Clear();
_toRemove.Clear();
_player = null;
IsGameOver = false; // 게임오버 상태도 초기화
// 플레이어를 화면 중앙 하단(Width / 2, Height - 2)에 생성해서 추가함
Add(new Player(new Vec2Int(Width / 2, Height - 2)));
// 적을 배치함
_invaderManager.Initialize();
}
// 예약된 추가/삭제 작업을 실제로 수행하는 함수
private void CommitList()
{
if (_toAdd.Count > 0)
{
_entities.AddRange(_toAdd); // 추가 대기열에 있던 걸 진짜 리스트에 합침
_toAdd.Clear(); // 대기열 비움
}
if (_toRemove.Count > 0)
{
foreach (Entity e in _toRemove)
{
_entities.Remove(e); // 삭제 대기열에 있던 걸 진짜 리스트에서 지움
}
_toRemove.Clear(); // 대기열 비움
}
}
// 매 프레임 호출되는 업데이트 함수
public void Update()
{
// 적 관리자에게 전달 (적 이동, 공격 시키기)
_invaderManager.Update();
// 물체 이동
foreach (var e in _entities)
{
e.Update(this);
}
// 위에서 추가/삭제 예약된 게 있으면 실제 리스트에 반영
CommitList();
}
// 죽은 물체들을 골라내서 삭제 예약하는 함수
public void Cleanup()
{
foreach (var e in _entities)
{
if (e.IsAlive == false)
{
Remove(e); // 죽었으면 삭제 리스트로 보냄
}
}
CommitList(); // 실제 삭제 수행
}
// 키 입력을 받아서 처리하는 함수
public void ApplyInput(InputFrame input)
{
if (Player == null) return; // 플레이어가 죽어서 없으면 아무것도 안 함
// 왼쪽/오른쪽 키 눌렀으면 플레이어 이동
if (input.Left) Player.RequestMove(-1);
if (input.Right) Player.RequestMove(+1);
// 발사 키 눌렀으면 플레이어가 총알을 쏨
if (input.Fire)
{
var bullet = Player.Attack(); // 플레이어가 총알을 쏨
if (bullet != null)
{
Add(bullet); // 그 총알을 추가
}
}
}
}
}
// invadermanager.cs
using SpaceInvader.Util;
using System;
using System.Collections.Generic;
using System.Linq;
namespace SpaceInvader.World
{
public sealed class InvaderManager
{
private Random _random = new Random(); // 랜덤 뽑기
private World _world;
// 적 배치 설정값들
private int _row = 20; // 가로로 몇 마리
private int _col = 3; // 세로로 몇 줄
private int _rowSpacing = 3; // 가로 간격
private int _colSpacing = 2; // 세로 간격
private int _rowStart = 10; // 시작 위치 X
private int _colStart = 3; // 시작 위치 Y
// 현재 살아있는 적들 목록
private List<Invader> _aliveInvaders = new List<Invader>();
// 공격 쿨타임
private int _attackInterval = 30; // 30프레임마다 한 번 공격 명령 약 1초인듯
private int _attackCooldown = 0;
public InvaderManager(World world)
{
_world = world;
}
public void Initialize()
{
SpawnFormation(); // 적들을 대형에 맞춰 생성
}
// 적 생성 함수
public void SpawnFormation()
{
for (int j = 0; j < _col; j++) // 세로 줄 반복
{
for (int i = 0; i < _row; i++) // 가로 줄 반복
{
// 위치 계산해서 인베이더 생성 후 월드에 추가
_world.Add(new Invader(new Vec2Int(_rowStart + i * _rowSpacing, _colStart + j * _colSpacing)));
}
}
}
// 월드에 있는 애들 중 살아있는 인베이더만 추려냄
public void GetAliveInvaders()
{
_aliveInvaders.Clear();
foreach (var entity in _world.Entities)
{
if (entity.IsAlive && entity is Invader inv)
{
_aliveInvaders.Add(inv);
}
}
}
// 매 턴 행동
public void Update()
{
GetAliveInvaders(); // 살아있는 애들 파악
// 1. 단체 이동 관리 (벽 닿으면 내려가기 등)
ProcessMovement();
// 2. 공격 명령 (랜덤하게 총 쏘기)
ProcessAttack();
}
private void ProcessMovement()
{
bool isHitWall = false;
// 누구 하나라도 벽에 닿았는지 검사
foreach (var inv in _aliveInvaders)
{
if (inv.IsHitWall(_world))
{
isHitWall = true;
break;
}
}
// 벽에 닿았다면? 전원 방향 반대로 바꾸고 한 칸 전진
if (isHitWall)
{
foreach (var inv in _aliveInvaders)
{
inv.DirX *= -1; // 방향 반전
inv.MoveToDown(); // 아래로 한 칸
// 게임 오버 체크
// 만약 내려왔는데 거기가 바닥이면
if (inv.Position.Y >= _world.Height - 2)
{
_world.IsGameOver = true; // 게임 오버
}
}
}
}
private void ProcessAttack()
{
if (_aliveInvaders.Count == 0) return; // 적이 다 죽었으면 공격 못함
_attackCooldown--;
if (_attackCooldown <= 0) // 공격할 타이밍이 되면
{
_attackCooldown = _attackInterval; // 쿨타임 리셋
// 가장 아래쪽에 있는 애들 중 하나를 뽑음
Invader shooter = GetRandomBottomInvader();
if (shooter != null)
{
// 걔한테 총알 받아냄
Bullet bullet = shooter.Attack();
// 그 총알을 월드에 등록 (그래야 날아감)
_world.Add(bullet);
}
}
}
// 맨 밑에 있는 녀석들 중에서 하나 랜덤으로 고르는 함수
public Invader GetRandomBottomInvader()
{
// 1. X축끼리 그룹을 지음
// 2. 각 그룹에서 Y값이 제일 큰 것만 뽑음
var bottoms =
_aliveInvaders.GroupBy(i => i.Position.X).Select(g => g.OrderByDescending(i => i.Position.Y).First()).ToList();
if (bottoms.Count == 0)
return null;
// 추려낸 맨 앞줄 중 랜덤으로 한 명 선택
return bottoms[_random.Next(bottoms.Count)];
}
}
}
// entitiy.cs
using SpaceInvader.Util;
using System;
namespace SpaceInvader.World
{
// abstract class(추상 클래스):
public abstract class Entity
{
// 위치 정보 (X, Y 좌표)
public Vec2Int Position { get; protected set; }
// 살아있는지 여부 (true면 생존, false면 사망)
public bool IsAlive { get; protected set; } = true;
// abstract: 자식들이 무조건 자기만의 Shape을 정해야 함.
public abstract char Shape { get; }
// 생성자: 태어날 때 위치를 정해줌
protected Entity(Vec2Int position)
{
Position = position;
}
// virtual: 자식들이 원하면 이 기능을 자기 입맛대로 바꿔서(override) 써도 됨
// 기본적으로는 아무것도 안 함
public virtual void Update(World world)
{
}
public virtual void Move(World world)
{
}
public virtual void OnHit(Entity other)
{
}
}
}
// player.cs
using SpaceInvader.Util;
namespace SpaceInvader.World
{
public sealed class Player : Entity
{
public override char Shape => 'A'; // 플레이어 모양은 'A'
public int Hp { get; private set; }
public int Damage { get; private set; }
private int _pendingMove; // 이동하려는 방향 (-1, 0, 1) 임시 저장
private int _fireCooldownTicks; // 쿨타임 체크 변수
private int _fireInterval = 6; // 총 쏘고 나서 6프레임동안은 못 쏨
// 생성자
public Player(Vec2Int position) : base(position) { }
// 입력을 받아서 저장해둠
public void RequestMove(int dir)
{
_pendingMove = dir;
}
// 진짜 움직이는 함수
public override void Move(World world)
{
if (_pendingMove != 0) // 움직일 계획이 있다면
{
int newX = Position.X + _pendingMove;
// 화면 밖으로 못 나가게 가둠
newX = Clamp(newX, 0, world.Width - 1);
// 위치 확정
Position = new Vec2Int(newX, Position.Y);
_pendingMove = 0; // 이동했으니 계획 초기화
}
}
public override void Update(World world)
{
// 총알 쿨타임 줄이기
_fireCooldownTicks--;
if (_fireCooldownTicks < 0)
{
_fireCooldownTicks = 0;
}
// 움직임 처리
Move(world);
}
// 총 쏘는 함수
public Bullet Attack()
{
// 쿨타임이 다 됐을 때만 발사 가능
if (_fireCooldownTicks <= 0)
{
_fireCooldownTicks = _fireInterval; // 쿨타임 다시 채움
// 플레이어 바로 위(Y-1)에서 생성
var spawnPos = new Vec2Int(Position.X, Position.Y - 1);
// 주인은 Player라고 하고 총알 생성
return new Bullet(spawnPos, -1, BulletOwner.Player);
}
return null; // 쿨타임 중이면 총알 안 나감
}
// 숫자가 최소값과 최대값 사이를 벗어나지 않게 잡아주는 함수
private int Clamp(int value, int min, int max)
{
if (value < min) return min;
if (value > max) return max;
return value;
}
// 누가 나를 때렸을 때 실행됨
public override void OnHit(Entity other)
{
// 부딪힌 게 총알이고, 그 총알 주인이 인베이더라면
if (other is Bullet b && b.BulletOwner == BulletOwner.Invader)
{
IsAlive = false; // 죽음
}
}
}
}
// invader.cs
using SpaceInvader.Util;
namespace SpaceInvader.World
{
public sealed class Invader : Entity
{
public override char Shape => 'W'; // 적 모양은 'W'
public int DirX { get; set; } = +1; // 현재 이동 방향 (+1이면 오른쪽, -1이면 왼쪽)
// 이동 속도 조절용 변수
private int _moveInterval = 5; // 5프레임마다 한 번 움직임
private int _moveCooldownTicks;
public Invader(Vec2Int position) : base(position) { }
// 총알을 만들어서 뱉어냄
public Bullet Attack()
{
// 내 바로 아래(Y+1)에서 생성, 아래쪽(+1)으로 날아감, 주인은 Invader
return new Bullet(new Vec2Int(Position.X, Position.Y + 1), 1, BulletOwner.Invader);
}
public override void Update(World world)
{
_moveCooldownTicks--;
if (_moveCooldownTicks <= 0) // 움직일 시간이 되면
{
_moveCooldownTicks = _moveInterval; // 쿨타임 리셋하고
Move(world); // 움직임
}
}
public override void Move(World world)
{
MoveHorizontal(); // 기본적으론 옆으로만 감
}
// 벽에 부딪혔는지 검사하는 함수
public bool IsHitWall(World world)
{
int nextX = Position.X + DirX;
// 화면 왼쪽 끝보다 작거나, 오른쪽 끝보다 크면 벽에 닿은 거임
return nextX < 0 || nextX >= world.Width;
}
// 옆으로 한 칸 이동
private void MoveHorizontal()
{
Position = new Vec2Int(Position.X + DirX, Position.Y);
}
// 아래로 한 칸 이동 (벽에 닿았을 때 매니저가 시킴)
public void MoveToDown()
{
Position = new Vec2Int(Position.X, Position.Y + 1);
}
// 맞았을 때
public override void OnHit(Entity other)
{
// 플레이어의 총알에 맞았으면 사망
if (other is Bullet b && b.BulletOwner == BulletOwner.Player)
{
IsAlive = false;
}
}
}
}
// bullet.cs
using SpaceInvader.Util;
namespace SpaceInvader.World
{
// 총알 주인이 누군지 표시하기 위한 이름표
public enum BulletOwner
{
Player, Invader
}
public sealed class Bullet : Entity
{
public override char Shape => 'I'; // 총알 모양
private readonly int _dirY = 1; // 위로 갈지 아래로 갈지 방향
public BulletOwner BulletOwner { get; private set; } // 누가 쐈는지
// _moveTick: 이동하기 위해 기다리는 카운트
// _moveInterval: 몇 번 기다렸다가 움직일지 정하는 값 (2면 2번 쉴 때 1번 움직임)
private int _moveTick = 0;
private int _moveInterval = 2; // 클수록 총알이 느려짐
public Bullet(Vec2Int position, int dirY, BulletOwner bulletOwner) : base(position)
{
_dirY = dirY;
BulletOwner = bulletOwner;
// 다 똑같이 느리게 만듦
}
public override void Update(World world)
{
// 총알 속도 늦추기
_moveTick--; // 카운트를 하나 깜
if (_moveTick > 0) return; // 아직 움직일 때가 아니면 여기서 함수 끝냄
_moveTick = _moveInterval; // 카운트 다시 충전
// 현재 위치에서 Y방향으로 한 칸 이동
Position = new Vec2Int(Position.X, Position.Y + _dirY);
// 화면 위나 아래로 벗어나면
if (Position.Y < 0 || Position.Y > world.Height)
{
IsAlive = false; // 죽은 걸로 처리 (Cleanup때 사라짐)
}
}
public override void OnHit(Entity other)
{
IsAlive = false; // 누구랑 부딪히면 총알은 사라짐
}
}
}
// input.cs
using System;
using SpaceInvader.Util;
namespace SpaceInvader.System
{
public sealed class InputSystem
{
// 키보드 입력을 읽어서 'InputFrame'이라는 박스에 담아 리턴함
public Util.InputFrame ReadInput()
{
bool left = false, right = false, fire = false, exit = false;
// 키보드 버퍼에 입력이 남아있는 동안 계속 읽음
// (빠르게 여러 키를 눌렀을 때 씹히지 않게 하기 위함)
while (Console.KeyAvailable)
{
var key = Console.ReadKey(true).Key; // 키를 읽음 (화면엔 안 보이게 true 옵션)
switch (key)
{
case ConsoleKey.A:
case ConsoleKey.LeftArrow:
left = true; // 왼쪽 키
break;
case ConsoleKey.D:
case ConsoleKey.RightArrow:
right = true; // 오른쪽 키
break;
case ConsoleKey.Spacebar:
fire = true; // 스페이스바
break;
case ConsoleKey.Escape:
exit = true; // ESC
break;
}
}
// 확인된 키 상태를 포장해서 보냄
return new InputFrame(left, right, fire, exit);
}
}
}
// collision.cs
namespace SpaceInvader.System
{
public sealed class CollisionSystem
{
public void CheckCollision(World.World world)
{
var entities = world.Entities;
// A랑 B랑 비교하고, A랑 C랑 비교
for (int i = 0; i < entities.Count; i++)
{
for (int j = i + 1; j < entities.Count; j++)
{
var a = entities[i];
var b = entities[j];
// 두 물체의 위치가 똑같다면? 충돌
if (a.Position.Equals(b.Position))
{
// 서로에게 맞았다고 알려줌
a.OnHit(b);
b.OnHit(a);
}
}
}
}
}
}
// renderer.cs
using SpaceInvader.World;
using System;
using System.Text;
namespace SpaceInvader.System
{
public sealed class ConsoleRenderer
{
private int _width;
private int _height;
private char[,] _buffer; // 2차원 배열
// 화면을 한 번에 출력하기 위해 글자를 모아두는 긴 문자열 빌더
private readonly StringBuilder _sb = new StringBuilder();
public void Initialize()
{
Console.Clear(); // 콘솔창 깨끗하게 지움
}
public void Shutdown()
{
}
public void Draw(World.World world)
{
_width = world.Width;
_height = world.Height;
// _buffer가 없거나 크기가 바뀌었으면 새로 만듦
if (_buffer == null ||
_buffer.GetLength(0) != _height ||
_buffer.GetLength(1) != _width)
{
_buffer = new char[_height, _width];
}
// 1. 도화지 전체를 공백으로 칠해서 지움
for (int y = 0; y < _height; y++)
{
for (int x = 0; x < _width; x++)
{
_buffer[y, x] = ' ';
}
}
// 2. 월드에 있는 모든 물체를 순서대로 그림
foreach (var e in world.Entities)
{
if (e.IsAlive == false) continue; // 죽은 놈은 안 그림
int x = e.Position.X;
int y = e.Position.Y;
// 화면 밖으로 나간 놈은 그리지 않음
if (x < 0 || x >= _width ||
y < 0 || y >= _height) continue;
// 해당 위치에 그 물체의 Shape을 찍음
_buffer[y, x] = e.Shape;
}
}
// 다 그린걸 모니터에 출력하는 함수
public void Present()
{
if (_buffer == null) return;
Console.SetCursorPosition(0, 0); // 커서를 맨 위로 보냄
_sb.Clear(); // 문자열 빌더 비움
// 도화지 내용을 문자열 빌더에 한 줄씩 옮겨 담음
for (int y = 0; y < _height; y++)
{
for (int x = 0; x < _width; x++)
{
_sb.Append(_buffer[y, x]);
}
_sb.AppendLine(); // 줄바꿈
}
// 한 방에 출력
Console.Write(_sb.ToString());
}
}
}