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)
- TIER 1 (확률 4%) 검사:
- "너 4cm 안에 있니?" -> 아니오 (5cm니까요)
- 자르기: "그럼 앞의 4cm는 필요 없으니 잘라버릴게!"
- 이제 내 숫자는 5cm에서 4cm를 뺀 1cm가 됩니다. (roll -= 0.04)
- 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는 새로 짜는게 낫게지 [ ]
그 좀비 다이아 좀비처럼
'모딩 > 마인크래프트 모드 개발 일지' 카테고리의 다른 글
| 마크 모딩) 26.1.31 (1) | 2026.01.31 |
|---|---|
| 마크 모딩) brutal boss처럼 (0) | 2026.01.30 |
| 마크 모딩) 디펜스 타워 만들기 (1) | 2026.01.12 |
| 맠모딩) 벌몹 (0) | 2025.12.28 |
| 마크) 1.18.2 게코립 설정 시 1.18.2가 아닌 1.18을 써야 하는 이유 (1) | 2025.12.28 |