모딩/마인크래프트 모드 개발 일지

마크 모딩) 26.02.03

kimchangmin02 2026. 2. 3. 12:25
if (roll < 0.04f) return BrutalBossRank.TIER_1; // 4%
if (roll < 0.07f) return BrutalBossRank.TIER_2; // 4% + 3% = 7%
if (roll < 0.09f) return BrutalBossRank.TIER_3; // 7% + 2% = 9%
if (roll < 0.10f) return BrutalBossRank.TIER_4; // 9% + 1% = 10%

"공의 개수"로 이해하기 (눈금자 원리)

RAND.nextFloat() 0.0부터 1.0 사이의 숫자를 무작위로 뽑습니다. 이걸 0부터 100까지 있는 1미터짜리 자라고 생각해보세요.

  • 0cm ~ 4cm 구간 (길이 4): 여기에 화살이 꽂히면 TIER 1입니다. (확률 4%)
  • 4cm ~ 7cm 구간 (길이 3): 여기에 화살이 꽂히면 TIER 2입니다. (확률 3%)
  • 7cm ~ 9cm 구간 (길이 2): 여기에 화살이 꽂히면 TIER 3입니다. (확률 2%)
  • 9cm ~ 10cm 구간 (길이 1): 여기에 화살이 꽂히면 TIER 4입니다. (확률 1%)
  • 10cm ~ 100cm 구간 (길이 90): 나머지입니다. (확률 90%)

결국 각 티어가 차지하는 **"영역의 넓이"**가 곧 확률이 되는 것입니다.

 

 

"순서만 바꾸면 된다"고 할 때 조심할 점 (확률의 간격)

질문하신 내용 중에 x < 0.01, x < 0.02, x < 0.03 순서로 하면 된다고 하셨는데, 그렇게 하면 **모든 티어의 확률이 똑같이 1%**가 되어버립니다.

왜 그런지 숫자를 하나씩 뜯어볼까요?

  • if (roll < 0.01f) return TIER_4;  0.00 ~ 0.01 사이 (길이 0.01 = 1% 확률)
  • if (roll < 0.02f) return TIER_3; → 0.01까지는 위에서 이미 걸러졌으니, 실제 범위는 0.01 ~ 0.02 (길이 0.01 = 1% 확률)
  • if (roll < 0.03f) return TIER_2; → 실제 범위는 0.02 ~ 0.03 (길이 0.01 = 1% 확률)
  • if (roll < 0.04f) return TIER_1; → 실제 범위는 0.03 ~ 0.04 (길이 0.01 = 1% 확률)

즉, 확률을 4%, 3%, 2%, 1%로 만들고 싶다면, 경계선 숫자를 그만큼 더 크게 벌려줘야 합니다.



 

 

 

 

 

public static BrutalBossRank calculateTier() {
    float roll = RAND.nextFloat(); // 0.0 ~ 1.0

    if (roll < 0.04f) return BrutalBossRank.TIER_1;
    roll -= 0.04f; // 사용한 확률만큼 깎기

    if (roll < 0.03f) return BrutalBossRank.TIER_2;
    roll -= 0.03f;

    if (roll < 0.02f) return BrutalBossRank.TIER_3;
    roll -= 0.02f;

    if (roll < 0.01f) return BrutalBossRank.TIER_4;

    return null; 
}

 

비유: 100cm짜리 막대기 자르기

우리가 가진 막대기는 roll이고, 총 길이는 100cm라고 칩시다.
내가 뽑은 숫자가 5cm 지점이라고 가정해 볼게요. (roll = 0.05)

  1. TIER 1 (확률 4%) 검사:
    • "너 4cm 안에 있니?" -> 아니오 (5cm니까요)
    • 자르기: "그럼 앞의 4cm는 필요 없으니 잘라버릴게!"
    • 이제 내 숫자는 5cm에서 4cm를 뺀 1cm가 됩니다. (roll -= 0.04)
  2. TIER 2 (확률 3%) 검사:
    • "너 (남은 막대기에서) 3cm 안에 있니?" -> 네! (지금 1cm니까요)
    • 결과: TIER 2 당첨!

 

 

 

            // 이미 보스인지 확인 (중복 적용 방지)
            if (!living.getPersistentData().contains("BrutalBossTier")) {
                BrutalBossRank rank = BrutalBossBuilder.calculateTier();
                //null하게 하려면 여기서 null처리도 해줘야하나 
                BrutalBossBuilder.applyBossStats(living, rank);
            }
        }
    }

 

rank null이라는 것은 **"보스 당첨이 안 되었다"**는 뜻이죠. 즉, 일반 몹으로 남겨둬야 한다는 의미입니다.

이럴 때는 간단하게 **if (rank != null)**로 감싸주기만 하면 됩니다. null일 때는 applyBossStats를 호출하지 않고 그냥 넘어가게 되므로, 자연스럽게 아무런 강화도 적용되지 않은 일반 몹 상태가 유지됩니다.

 

 

 

 

 

네, 맞습니다! 아주 날카로운 지적입니다. 그 부분이 바로 "무한 주사위 굴리기" 버그가 발생할 수 있는 지점이에요.

구체적으로 설명해 드릴게요.

1. contains("BrutalBossTier")의 의미

이 코드는 **"이 몹이 이미 우리 시스템(보스 시스템)에 의해 한 번이라도 검사받은 적이 있는가?"**를 묻는 것입니다.

  • 태그가 있다 = 이미 보스인지 아닌지 결정됨. (다시 계산할 필요 없음)
  • 태그가 없다 = 아직 주사위를 굴리지 않은 갓 태어난 몹. (계산 필요)

2. 왜 null일 때 처리를 안 하면 계속 주사위를 굴리게 될까요?

만약 코드를 이렇게 짰다고 가정해봅시다:

if (!living.getPersistentData().contains("BrutalBossTier")) { // 1. 태그 없네? 들어와!
    BrutalBossRank rank = calculateTier(); 
    if (rank != null) { // 2. 90% 확률로 null이 나옴
        applyBossStats(living, rank); // 3. 보스일 때만 태그를 저장함
    }
}
  • 첫 번째 검사: 주사위를 굴렸는데 null(일반 몹)이 나왔습니다. 아무것도 안 하고 함수가 끝납니다. 태그가 여전히 없습니다.
  • 두 번째 검사 (1초 뒤): contains("BrutalBossTier")를 확인하니 태그가 여전히 없네요? 또 들어와서 주사위를 굴립니다.
  • 결과: 일반 몹은 보스가 될 때까지 1초마다 계속 주사위를 굴리는 꼴이 됩니다. 결국 모든 몹이 보스가 되어버리겠죠!

3. 해결 방법: "꽝"도 기록해야 합니다.

OR 조건을 쓸 필요 없이, 보스든 아니든 일단 주사위를 굴렸으면 "검사 완료" 도장을 찍어줘야 합니다.

가장 깔끔한 구조는 이렇습니다:

// 1. 이미 검사한 몹인지 확인
if (!living.getPersistentData().contains("BrutalBossTier")) {
    
    BrutalBossRank rank = BrutalBossBuilder.calculateTier();

    if (rank != null) {
        // [당첨] 보스 스탯을 적용하고, 내부에서 "BrutalBossTier" 태그를 저장함
        BrutalBossBuilder.applyBossStats(living, rank);
    } else {
        // [꽝] 일반 몹임을 표시하기 위해 "NONE"이라는 태그를 직접 저장함
        // 이제 이 몹은 다음 검사 때 contains()에서 true가 되어 이 if문을 건너뜁니다.
        living.getPersistentData().putString("BrutalBossTier", "NONE");
    }
}

4. 만약 applyBossStats 안에서 태그를 저장하고 있다면?

보통 applyBossStats 함수 안에서 persistentData.putString("BrutalBossTier", rank.name()) 같은 코드를 넣어두실 텐데요.

일반 몹(null)일 때는 그 함수를 호출하지 않으므로, 위 코드처럼 else 문에서 수동으로 "NONE"이라는 값을 넣어줘야 합니다.

 

 

 

 

보통 applyBossStats 함수 안에서 persistentData.putString("BrutalBossTier", rank.name()) 같은 코드를 넣어두실 텐데요.

일반 몹(null)일 때는 그 함수를 호출하지 않으므로, 위 코드처럼 else 문에서 수동으로 "NONE"이라는 값을 넣어줘야 합니다.

 

 

 

 

 

1. 가장 중요한 문제: 데이터 타입 충돌 (String vs Int)

지금 작성하신 코드를 보면 두 군데에서 데이터 타입이 다릅니다.

  • 호출부: putString("BrutalBossTier", "NONE") → **문자열(String)**로 저장
  • 함수 내부: putInt("BrutalBossTier", rank.tier) → **숫자(Integer)**로 저장

마인크래프트 NBT 시스템에서 같은 이름의 태그를 하나는 숫자로, 하나는 문자로 쓰면 나중에 읽을 때 에러가 나거나 데이터가 깨질 수 있습니다. 하나로 통일해야 합니다. (숫자로 통일하는 것이 성능상 좋습니다.)


2. applyBossStats 내부의 if (rank == BrutalBossRank.NONE)은 필요한가?

네, 있으면 좋습니다. 하지만 단순히 return;만 하면 안 되고, "얘는 검사가 끝난 일반 몹이다"라는 표시(0 또는 -1)를 저장하고 리턴해야 중복 계산을 막을 수 있습니다.

3. 권장하는 수정 코드

[BrutalBossRank Enum 수정 제안]

NONE이라는 타입을 추가하고, 그 값을 0으로 두는 것이 가장 깔끔합니다.

public enum BrutalBossRank {
    NONE(0, 0f), // 일반 몹 (티어 0)
    TIER_1(1, 1.5f),
    TIER_2(2, 2.0f),
    TIER_3(3, 3.0f),
    TIER_4(4, 5.0f);

    public final int tier;
    public final float multiplier;
    BrutalBossRank(int tier, float multiplier) {
        this.tier = tier;
        this.multiplier = multiplier;
    }
}

[BrutalBossBuilder.java 수정]

applyBossStats 함수가 "일반 몹 처리"까지 담당하게 만들면 호출하는 쪽 코드가 훨씬 짧아집니다.

public static void applyBossStats(LivingEntity entity, BrutalBossRank rank) {
    // 1. rank가 null이거나 NONE이면 "일반 몹"으로 태그 저장하고 종료
    if (rank == null || rank == BrutalBossRank.NONE) {
        entity.getPersistentData().putInt("BrutalBossTier", 0); // 0은 일반 몹이라는 뜻
        return;
    }

    // 2. 여기서부터는 진짜 보스(Tier 1~4) 처리
    entity.getPersistentData().putInt("BrutalBossTier", rank.tier);

    entity.getCapability(BossDataProvider.BOSS_DATA).ifPresent(data -> {
        data.setTier(rank.tier);
        data.addOrUpgradeAbility("boss_shield");

        int initialSkills = rank.tier - 1;
        for (int i = 0; i < initialSkills; i++) {
            giveRandomAbility(entity, data);
        }

        if (!entity.level.isClientSide()) {
            BossNetwork.sendToTracking(new ClientboundBossDataSyncPacket(
                entity.getId(), data.getLevel(), data.getKills(), 
                data.getTier(), data.getShieldAmount(), data.getAbilitiesMap()
            ), entity);
        }
    });

    // 능력치 강화 (생략...)
}

[호출하는 부분 (이벤트 핸들러 등)]

이제 호출하는 쪽은 아주 단순해집니다.

if (!living.getPersistentData().contains("BrutalBossTier")) {
    BrutalBossRank rank = BrutalBossBuilder.calculateTier();
    // rank가 null(꽝)이어도 applyBossStats 내부에서 "0"으로 저장해주므로 안전함!
    BrutalBossBuilder.applyBossStats(living, rank);
}

 

 

 

 

 

 

 

 

 

 

핵심은 calculateTier()에서 90% 확률(꽝)일 때 null 대신 BrutalBossRank.NONE을 리턴하게 만드는 것입니다.

이렇게 하면 applyBossStats와 호출부의 로직이 딱 맞아떨어집니다.

1. calculateTier() 수정 (null 대신 NONE 리턴)

확률 4, 3, 2, 1%를 적용하고 나머지는 모두 NONE으로 보냅니다

public static BrutalBossRank calculateTier() {
    float roll = RAND.nextFloat();
    if (roll < 0.04f) return BrutalBossRank.TIER_1; // 4%
    if (roll < 0.07f) return BrutalBossRank.TIER_2; // 3%
    if (roll < 0.09f) return BrutalBossRank.TIER_3; // 2%
    if (roll < 0.10f) return BrutalBossRank.TIER_4; // 1%
    
    return BrutalBossRank.NONE; // 나머지 90%는 꽝(NONE)
}

 

이미 설정ㅎ래둔, enum 사용하기 

 

 

 

// 1. 무조건 태그를 먼저 저장! (NONE이면 0이 저장됨)
    // 이제 이 몹은 다시는 calculateTier()를 실행하지 않음
    entity.getPersistentData().putInt("BrutalBossTier", rank.tier);

    // 2. 만약 NONE(일반 몹)이라면, 태그만 저장하고 여기서 진짜 종료
    if (rank == BrutalBossRank.NONE) return;

 

 

 

 오류의 원인

람다(data -> { ... }) 안에서 외부 변수(rank)를 사용하려면, 그 변수는 **값이 한 번도 바뀌지 않은 상태(effectively final)**여야 합니다.

그런데 코드 윗부분에서 if (rank == null) rank = BrutalBossRank.NONE;와 같이 rank 변수에 새로운 값을 대입했기 때문에, 자바는 "이 변수는 나중에 바뀔 수도 있겠네? 람다 안에서는 안전하게 쓸 수 없어!"라고 판단하고 에러를 내뱉는 것입니다.

 

 

 

 

        // 1. 기존 NBT 저장 (백업용)
        entity.getPersistentData().putInt("BrutalBossTier", rank.tier);
        //태그 저장을 먼저 

        //이게 보스로 만들어주는, 변신 코드네
        if (rank == BrutalBossRank.NONE) return;

 

 

 

마녀: 해로운, 물약 효과만 던지도록, 

일단, 마녀 텍스쳐만 가져오고 ai는 새로 짜는게 낫게지 [   ]

그 좀비 다이아 좀비처럼