2026-09-23 손튜터님 강의

정윤재·2026년 9월 23일

Sprint-MaxWalkSpeed 증가, Stamina 지속 소모
Crouch-Crouch(), UnCrouch()
Dash-이동 방향 계산,AddImpulse, Stamina 소모, Cooldown(무적 상태는 우리가 정하기)
IA들 넣기

UCharacterMovementComponent* Movement=GetCharacterMovement();

if(Movement)
{
Movement->GetNavAgentPropertiuesRef().bCanCrouch=true
//캐릭터가 Crouch를 사용할 수 있도록 설정
}

스테미너가 있기 때문에 새로운 스테미너를 적용 시켜야함(HP도 마찬가지고, 새로운 걸 적용시켜야함)
새로 게임 키고 끌 때 적용시켜야줘야함
생성자에서 이런 걸 해줘야함

//세이브 데이터 불러와서 MaxStamina에 세이브한 데이터를 적용하고 그 다음에 초기화
CurrentStamina=MaxStamina;

Movement->MaxWalkSpeed=WalkSpeed;
Movement->MaxWalkSpeedCrouched=CrouchSpeed;

void UPdateMovementAndStamina(DeltaTime);

if(SprintAction)
{
}
if(CrouchAction)
{
}

흐름을 잘 조절하지 못 하면 애니메이션이 확 튈 수 있다
(bool 변수로 상태 조건을 아주 잘 설정해야한다)

if(DashDirection.IsNearlyZero())
{
DashDirection.ISNearlyZero())
Dashdirection=GetActorForwardVector().GetSafeNormal2D());
CurrentStamina=FMath::Clamp(CurrentStamina-DashStaminaCost, 0.0f, MaxStamina);
//순간적으로 Inpulse를 CharacterMovement에 전달합니다.
//bVelocityChange를 true해서 질량에 관계없이 속도 변화량으로 적용
Movement->AddImpulse(DashDirection * DashStrength, true);

bDashOnCooldown=true;

GetWorldTimerManager().SetTimer(DashCooldownTimerHandle, this, &APlayerBase::ResetDashCooldown()
}

void APlayerBase::ResetDashCooldown()
{
bDashOnCooldown=false;
}

void APlayerBase::UpdateMovementAndStamina(float DeltaTime)
{
UCharacterMovementComponent* Movement= GetCharacterMovement();
if(!Movement)
{
return;
}

//실제로 Character가 수평 방향으로 움직이고 있는지 확인
const bool bIsMoving=GetVelocity().SizeSquared2d()>FMath::Square(10.0f);

const bool
bCanSprintNow=bSprintInputHold && CurrentStamina >0.0f && !IsCrouched() && Movement->IsMovingCrouched();

if(bCanSprintNow)
{
Movement->MaxWalkSpeed=SprintSpeed;
CurrentStamina-=SprintStaminaDrainPerSecond DeltaTime;
CurrentStamina=FMath::Clamp(CurrentStamina,0.0f, MaxStamina);
if(CurrentStamina<=0.0f)
{
bSprintInputHold=flase;
Movement->MaxWalkSpeed=WalkSpeed;
}
else
{
Movement->MaxWalkSpeed=WalkSpeed;
CurrentStamina+=StaminaRecoveryPerSecond
DeltaTime;
CurrentStamina=FMath::Clamp(CurrentStamina, 0.0f, MaxStamina);
}
}

float APlayerBase::GetStaminaPercent() const
{
if(MaxStamina<0.0f)
{
return 0.0f;
}
return FMath::Clamp(min,0,max);
}

void APlayerBase::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
GetWorldTimerManager().ClearTimer(DashCooldownTImerHandle);
Super::EndPlay(EndPlayReason);
}

Timer나 Lerp으로 천천히 속도가 늘고 줄게하기

TimerClear에서 오류 줄이기 있는데 그게 플에이어 쪽 코드에 우리가 있음

응용반 5회차 정리 — 걷기 · 달리기 · 앉기 · 대시 + 스태미나

일자 2026-09-23 | 범위 이동 상태 3종 구현 / 변수 설계 원칙 / Tick 기반 스태미나 / 타이머 정리


0. 이번 회차의 요지

수업에서는 틀만 잡는다. 값과 감각은 각자 기획서에 맞춰 완성해오는 것이 숙제다.

이동 3종의 구현 흐름:

동작핵심 구현
SprintMaxWalkSpeed 증가 + Stamina 지속 소모
CrouchCrouch() / UnCrouch()
Dash이동 방향 계산 → AddImpulse → Stamina 소모 → Cooldown

1. 먼저 — 기획서가 중요하다

지난 회차의 카메라 얘기와 이어진다.

게임마다 카메라 워킹의 고유한 느낌이 있다. 어떤 시점의 게임을 만들든, 그 장르의 게임을 잘 연구해서 자기만의 카메라 워킹을 맞춰야 한다. TargetArmLength 같은 값은 기획에 맞게 조정하는 것이지 정답이 있는 게 아니다.

이번 회차의 속도값들도 마찬가지다. 애니메이션을 생각하고 정해야 한다.


2. 변수 설계 — 무엇을 변수로 뺄 것인가 ★

수업에서 가장 실무적인 부분.

상수로 박아두면 생기는 일
애니메이션 담당이 "350 정도 주니까 좀 더 자유로운 느낌인데요" 라고 하면, 코드 안의 숫자를 전부 찾아다니며 고쳐야 한다.
변수로 빼두면 그 변수 하나만 고치면 그 값을 대입받는 곳들은 건드릴 필요가 없다.

판단 기준

수정할 확률을 생각하고 만든다.
그렇다고 모든 걸 변수로 뺄 필요는 없다. 쓸데없이 메모리에 다 들고 있어야 하니까.

MaxStamina를 변수로 빼는 이유
게이지를 조절할 가능성이 있고, 이 값을 한계선으로 대입해서 쓰기 때문. (Clamp의 상한, 회복 상한 등)

구조체로 묶을까?
묶어도 되고 안 묶어도 된다. 선택이다. 이 정도는 구조체 이름을 정하기가 오히려 애매하다. 데이터테이블까지 갈 필요는 없다.

Enum + Map 형태로 관리하는 건?
굳이. Enum은 게임에서 종류가 정해진 것에 쓴다. 키-밸류로 뭔가를 관리하고 싶을 때 쓰는 것인데, 이런 단순 수치값에 쓰면 불필요하다. Enum이 너무 많으면 오히려 헷갈린다.


3. 헤더 — 전체 구조

public:
    // 현재 스태미나 비율(0~1)을 반환합니다. UI가 게이지를 그릴 때 사용합니다.
    UFUNCTION(BlueprintPure, Category = "Movement|Stamina")
    float GetStaminaPercent() const;

protected:
    virtual void BeginPlay() override;
    virtual void Tick(float DeltaTime) override;

    // 타이머 정리를 위해 반드시 오버라이드합니다. (9장 참고)
    virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override;

    // ---------- 입력 ----------

    UPROPERTY(EditDefaultsOnly, Category = "Input")
    TObjectPtr<UInputAction> SprintAction;

    UPROPERTY(EditDefaultsOnly, Category = "Input")
    TObjectPtr<UInputAction> CrouchAction;

    UPROPERTY(EditDefaultsOnly, Category = "Input")
    TObjectPtr<UInputAction> DashAction;

    // ---------- 속도 ----------

    // 기본 걷기 속도. 애니메이션 블렌드스페이스와 맞춰야 합니다.
    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Movement|Speed")
    float WalkSpeed = 450.0f;

    // 달리기 속도.
    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Movement|Speed")
    float SprintSpeed = 700.0f;

    // 앉은 상태의 이동 속도.
    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Movement|Speed")
    float CrouchSpeed = 250.0f;

    // ---------- 스태미나 ----------

    // 스태미나 최대치. 성장 시스템이 있다면 이 값이 바뀝니다.
    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Movement|Stamina")
    float MaxStamina = 100.0f;

    // 현재 스태미나. 에디터에서 수정하는 값이 아니라 인스턴스 상태이므로 VisibleInstanceOnly.
    UPROPERTY(VisibleInstanceOnly, BlueprintReadOnly, Category = "Movement|Stamina")
    float CurrentStamina = 100.0f;

    // Sprint 중 1초마다 소모되는 Stamina.
    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Movement|Stamina")
    float SprintStaminaDrainPerSecond = 20.0f;

    // Sprint하지 않을 때 1초마다 회복되는 Stamina.
    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Movement|Stamina")
    float StaminaRecoveryPerSecond = 15.0f;

    // Dash 한 번에 사용하는 Stamina.
    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Movement|Stamina")
    float DashStaminaCost = 25.0f;

    // ---------- 대시 ----------

    // Dash 순간의 속도 변화량.
    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Movement|Dash")
    float DashStrength = 900.0f;

    // Dash를 다시 쓸 수 있을 때까지의 시간(초).
    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Movement|Dash")
    float DashCooldown = 0.6f;

private:
    // Sprint 입력을 누르고 있는 상태.
    bool bSprintInputHold = false;

    // Dash Cooldown 상태.
    bool bDashOnCooldown = false;

    FTimerHandle DashCooldownTimerHandle;

    void StartSprint();
    void StopSprint();
    void ToggleCrouch();
    void Dash();
    void ResetDashCooldown();
    void UpdateMovementAndStamina(float DeltaTime);

cpp에서 TimerManager.h를 include할 것.


4. 생성자 — Crouch 활성화

// 스태미나를 매 프레임 갱신해야 하므로 Tick을 켭니다.
PrimaryActorTick.bCanEverTick = true;

UCharacterMovementComponent* Movement = GetCharacterMovement();
if (Movement)
{
    // 캐릭터가 Crouch를 사용할 수 있도록 설정합니다. (아래 11장 Q1 참고)
    Movement->GetNavAgentPropertiesRef().bCanCrouch = true;
}

Tick을 당장 안 쓰더라도 bCanEverTick = true 자체를 지우지 않는 편이 낫다.


5. BeginPlay — 초기화 시점이 중요하다

UCharacterMovementComponent* Movement = GetCharacterMovement();
if (!Movement)
{
    return;
}

Movement->MaxWalkSpeed        = WalkSpeed;
Movement->MaxWalkSpeedCrouched = CrouchSpeed;

// 세이브 데이터를 불러와 MaxStamina에 적용한 뒤, 그 값으로 현재치를 초기화합니다.
// 순서가 반대면 이전 세션의 성장치가 날아갑니다.
CurrentStamina = MaxStamina;

왜 이 순서가 중요한가 — 젤다 예시
링크가 괴물을 많이 잡아 최대 HP가 150이 됐다. 저장하고 게임을 껐다가 켰다.
다시 켰을 때 그 150을 불러와서 적용한 다음에 현재 HP를 채워야 한다. 초기화 지점에서 이걸 놓치면 성장이 사라진다.


6. Tick과 입력 바인딩

void APlayerBase::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);

    UpdateMovementAndStamina(DeltaTime);
}

SetupPlayerInputComponent에서 EnhancedInputComponent->BindAction으로 Sprint / Crouch / Dash를 연결한다.


7. 동작 함수 — 조건을 먼저 막는다

void APlayerBase::StartSprint()
{
    // 앉은 상태에서는 달릴 수 없습니다.
    if (bIsCrouched)
    {
        return;
    }

    bSprintInputHold = true;
}

상황에 따라 "앉은 상태에서 대시 불가", "스태미나 부족 시 불가" 같은 조건을 if로 막아둔다. 머릿속에서 플레이를 상상하면서 걸러낼 것.

Dash — 유저는 우리 생각대로 조작하지 않는다 ★

W만 누르고 대시하지 않는다. W+A를 같이 누른다. 여기에 대한 예외 처리가 필요하다.

void APlayerBase::Dash()
{
    if (bDashOnCooldown || CurrentStamina < DashStaminaCost)
    {
        return;
    }

    UCharacterMovementComponent* Movement = GetCharacterMovement();
    if (!Movement)
    {
        return;
    }

    // 마지막 이동 입력 방향을 가져옵니다.
    // 입력 벡터를 그대로 쓰기 때문에 W+A 동시 입력이면 대각선으로 나갑니다.
    FVector DashDirection = GetLastMovementInputVector().GetSafeNormal2D();

    // 이동 입력이 없다면 Character가 바라보는 방향으로 Dash 합니다.
    if (DashDirection.IsNearlyZero())
    {
        DashDirection = GetActorForwardVector().GetSafeNormal2D();
    }

    CurrentStamina = FMath::Clamp(CurrentStamina - DashStaminaCost, 0.0f, MaxStamina);

    // 순간적으로 Impulse를 CharacterMovement에 전달합니다.
    // bVelocityChange를 true로 두면 질량과 무관하게 속도 변화량으로 적용됩니다.
    Movement->AddImpulse(DashDirection * DashStrength, true);

    bDashOnCooldown = true;
    GetWorldTimerManager().SetTimer(
        DashCooldownTimerHandle,
        this,
        &APlayerBase::ResetDashCooldown,
        DashCooldown,
        false
    );
}

// 쿨다운이 끝났을 때 상태를 되돌립니다.
// 나중에 초기화할 것이 늘어날 수 있으므로 함수로 분리해 변경 범위를 좁혀둡니다.
void APlayerBase::ResetDashCooldown()
{
    bDashOnCooldown = false;
}

유저는 스페이스를 누르지 말라면 누른다. 예외 처리는 선택이 아니다.


8. UpdateMovementAndStamina — 이번 회차에서 가장 중요한 코드 ★

void APlayerBase::UpdateMovementAndStamina(float DeltaTime)
{
    UCharacterMovementComponent* Movement = GetCharacterMovement();
    if (!Movement)
    {
        return;
    }

    // 실제로 캐릭터가 수평 방향으로 움직이고 있는지 확인합니다.
    // SizeSquared를 쓰는 이유는 제곱근 연산을 피하기 위함입니다.
    const bool bIsMoving = GetVelocity().SizeSquared2D() > FMath::Square(10.0f);

    // Sprint가 성립하는 모든 조건을 한 줄로 모읍니다.
    const bool bCanSprintNow =
        bSprintInputHold
        && CurrentStamina > 0.0f
        && !bIsCrouched
        && Movement->IsMovingOnGround()
        && bIsMoving;

    if (bCanSprintNow)
    {
        Movement->MaxWalkSpeed = SprintSpeed;

        CurrentStamina -= SprintStaminaDrainPerSecond * DeltaTime;
        CurrentStamina = FMath::Clamp(CurrentStamina, 0.0f, MaxStamina);

        // 스태미나가 바닥나면 입력을 유지하고 있어도 Sprint를 해제합니다.
        if (CurrentStamina <= 0.0f)
        {
            bSprintInputHold = false;
            Movement->MaxWalkSpeed = WalkSpeed;
        }
    }
    else
    {
        Movement->MaxWalkSpeed = WalkSpeed;

        CurrentStamina += StaminaRecoveryPerSecond * DeltaTime;
        CurrentStamina = FMath::Clamp(CurrentStamina, 0.0f, MaxStamina);
    }
}

여기서 가져갈 것 두 가지

① Clamp를 습관화할 것
총 장전, HP 감소, 스태미나 — 범위가 있는 값은 전부 Clamp로 막는다. 한 치의 오차가 없어야 버그가 안 생긴다. 유저는 우리 상상을 초월한다.

② 플레이어의 상태를 관리해야 한다bCanSprintNow처럼 여러 조건을 모아 "지금 플레이어가 무엇을 하고 있는가"를 판정하는 구조가 필요하다.

  • 상태 종류가 늘어나면 이때 Enum을 쓴다. (2장의 "종류가 정해진 것"이 여기다)
  • AI의 상태를 하나하나 체크하듯, 플레이어도 체크해야 한다.
  • 레벨 전환 / 세이브 로드 / 사망 / 재시작 시점에 이 상태를 저장하거나 초기화해야 할 때가 온다.
  • 달리던 중에 게임이 꺼졌다면? 언제, 어느 부분을 초기화할지를 미리 생각해둘 것.

9. EndPlay — 타이머 정리 ★

void APlayerBase::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
    // 이걸 지우지 않으면 크래시가 납니다.
    // 메뉴로 나갈 때, 컷신으로 넘어갈 때 타이머가 죽은 객체를 호출하면서 터집니다.
    GetWorldTimerManager().ClearTimer(DashCooldownTimerHandle);

    Super::EndPlay(EndPlayReason);
}

잊으면 안 되는 부분. 레벨 전환에서 가장 많이 터지는 지점 중 하나다.


10. 미래에 마주할 문제 — 세이브 / 로드

최종 프로젝트에서 가장 문제가 되는 영역이다.

  • 세이브/로드에는 SaveGame 클래스가 따로 있다. 거기에 저장한다.
  • 문제는 아무나 거기 접근할 수 없다는 것. 그래서 게임이 끝날 때까지 살아남고 누구나 쉽게 접근 가능한 GameInstance를 경유하는 경우가 많다.
  • 세이브/로드에 데이터테이블을 쓰는 게 아니다. 데이터테이블은 미리 정해둔 값을 다룰 때 쓴다. 저장 데이터는 성격이 다르다.
  • 핵심은 저장된 데이터를 언제 가져와서 언제 초기화할지다. 꼭 기억할 것.

더 깊이 가면
체크포인트, 문이 열렸는지 닫혔는지, 오브젝트가 부서졌는지 — 레벨 전체의 상태를 어딘가에서 관리해야 한다. 초기화할 때 어떤 값을 쓸지 선택해야 하고, 그 작업 때문에 로딩 화면이 존재하는 것이다.


11. 보충 설명 (정리자 추가)

아래는 수업 중 발언 그대로가 아니라, 문서 정리 과정에서 덧붙인 것.

Q1. GetNavAgentPropertiesRef().bCanCrouch = true가 왜 필요한가?

기본값이 false라서 그렇다.

ACharacter::Crouch()는 내부적으로 CanCrouch()를 먼저 확인하고, CanCrouch()가 이 bCanCrouch 플래그를 본다. 꺼져 있으면 Crouch()를 불러도 아무 일도 일어나지 않는다. 에러도 안 난다.

  • 블루프린트에서는 CharacterMovement 디테일 패널의 Can Crouch 체크박스가 같은 값이다. 템플릿에서 시작하면 이미 켜져 있는 경우가 있어서 "없어도 되던데?"가 나온다.
  • 관련 값: CrouchedHalfHeight — 앉았을 때 캡슐 높이. 기본 40. 이걸 안 맞추면 머리가 천장을 뚫거나 메쉬가 뜬다.

흔한 함정 — Crouch() 호출했는데 반응이 없다 → 십중팔구 이 플래그.

Q2. "MaxWalkSpeed를 갑자기 바꾸면 안 된다"는 게 무슨 뜻이었나?

애님 블루프린트는 보통 Velocity의 크기(Speed)를 받아서 Idle / Walk / Run 블렌드스페이스를 구동한다.

MaxWalkSpeed를 450에서 700으로 한 프레임에 갈아끼우면 실제 속도도 급격히 튀고, 블렌드스페이스 입력값이 중간 구간을 건너뛴다. 그 결과:

  • 걷기 애니메이션에서 달리기 애니메이션으로 툭 끊어져 전환된다
  • 발이 미끄러지는 느낌(풋 슬라이딩)이 생긴다
  • 앉기도 마찬가지 — 캡슐 높이가 순간 변하면 카메라가 확 꺼진다

해결 방향 — 보간한다

// 목표 속도를 정하고, 실제 MaxWalkSpeed는 그쪽으로 서서히 이동시킵니다.
const float TargetSpeed = bCanSprintNow ? SprintSpeed : WalkSpeed;

Movement->MaxWalkSpeed = FMath::FInterpTo(
    Movement->MaxWalkSpeed,   // 현재값
    TargetSpeed,              // 목표값
    DeltaTime,
    8.0f                      // 보간 속도 — 이 값이 감각을 결정합니다
);

수업에서 언급된 대안들: 보간(Lerp / FInterpTo), TimerHandle, 람다 중 무엇을 쓰든 된다. 요점은 값이 확확 튀지 않게 하는 것.

몽타주 쪽 주의
몽타주는 노드 흐름을 따라가기 때문에, 애니메이션 전환을 제대로 조정하지 않으면 뭉개진다. 생각지도 못한 곳에서 캐릭터가 튀거나, 죽었는데 일어나는 상황이 나온다.

Q3. 플레이어 클래스가 너무 길어지면?

스태미나까지만 짰는데도 1000줄이 넘어간다. 그래서 플레이어와 PlayerController를 나눈다.

더 길어지면 각 동작을 컴포넌트로 빼는 방법도 있다. 다만 필수는 아니고 스타일 차이다. (4회차의 "불편을 겪은 뒤에 나눈다"와 같은 얘기)

플레이어는 정말 중요하다. 회사에서는 여러 명이 붙어서 만들 정도다.


12. 숙제 ★

  1. 복습
  2. 오늘 한 것 기반으로 완성해오기 — 걷기 / 달리기 / 앉기 / 대시가 자연스럽게 이어지도록
  3. 기획서 완성하기

완성의 기준

  • 애니메이션이 자연스럽게 넘어가는가 (속도가 확 튀지 않는가, Walk 애님과 속도가 맞는가)
  • 대시가 입력 방향을 제대로 읽는가 (임펄스로 밀어내는 것)
  • Tick 기반 스태미나 소모·회복이 도는가

값과 감각은 각자의 몫이다. 코드를 그대로 쓰는 게 아니라 읽고 본인이 생각해서 적용해보는 것, 그게 응용반을 하는 이유다.


13. 사설 — 감을 잃으면 안 된다

수업 중 나온 이야기. 기술 내용은 아니지만 남겨둘 만해서 따로 정리한다.

게임 만드는 것도 예술이다
영감을 받고 자극을 받지 않으면 게임을 만들 수 없다. 게임이 일이 되어버리면 새로운 걸 창작해내기 어려운 시기가 온다.

그래서 일부러 매일 새로운 게임을 해본다고. 스팀 인디 데모는 거의 다 해보는 편이고, 대부분은 재미가 없다. 그러다 평가에서 원하는 요소가 보여 사일런트 힐 타운폴을 해봤더니 오랜만에 두근거림을 느꼈다는 얘기.

개발자의 색깔

색깔이 없으면 이도 저도 아닌 게임이 나온다.
이도 저도 아닌 게임은 AI가 다 만들어낸다.

언급된 레퍼런스:

  • 컨트롤(Control) — 예술적으로 훌륭하다. 게임성은 몰라도, 연출을 보고 쇼킹했던 손에 꼽는 경험. 이 회사는 변화를 두려워하지 않는다
  • 앨런 웨이크 2 — 추천작
  • 사일런트 힐 타운폴 — 최근에 재미를 느낀 작품

한국 게임에 대해
모바일에는 강하지만 소위 AAA급이 많지 않았다. 지금은 스텔라 블레이드, P의 거짓, 붉은사막 같은 것들이 나오고 있고 잘되어서 좋다. 스텔라 블레이드가 원래 국내 투자를 못 받을 뻔했다는 것도 지금 보면 웃긴 일이다. 인식이라는 게 참 무섭다. 중국과 일본은 치고 나가고 있다.

일반 회사에 들어가는 것도 좋지만, 각자의 창작을 발휘해서 "대한민국 게임도 이런 걸 만들 수 있다"를 알리는 사람이 많이 나왔으면 한다는 것.

결론은 잘하자.


14. 한 줄 정리

  • 변수로 뺄지는 수정할 확률로 판단한다. 다 빼는 것도 답이 아니다
  • Crouch()가 안 먹으면 bCanCrouch부터 확인
  • 범위 있는 값은 전부 Clamp
  • 속도·높이는 한 프레임에 갈아끼우지 말고 보간한다
  • Dash 방향은 GetLastMovementInputVector() → 없으면 GetActorForwardVector()
  • EndPlay에서 ClearTimer. 안 하면 레벨 전환에서 터진다
  • 플레이어 상태는 모아서 관리해야 하고, 종류가 늘면 그때가 Enum을 쓸 자리다

허익님 꺼 파쿠리 해옴 정리할 예정

profile
게임 개발자 꿈나무

0개의 댓글