NetHack/D&D 스타일의 스탯 기반 전투 시스템. 모든 전투 계산은 서버에서 처리한다.
다음 기본 공격을 두 번의 독립적인 타격으로 대체한다. 장착 조건, 피해·애니메이션 타이밍과 타격 취소 규칙은 Double Slash에 정리한다. 다른 어빌리티는 어빌리티 문서 목록을 참고한다.
마나 도입과 전투 기술·마법의 구분은 마나·스킬·마법 설계 초안에 정리한다. WIS에 따른 최대 MP·레벨당 증가량·자연 회복과 Guardian Ward의 MP 2 비용은 구현되어 있다. INT 기반 공격 마법·마법 Guard는 후속 설계다.
6개의 기본 능력치. 범위는 3~18.
| 스탯 | 약자 | 설명 |
|---|---|---|
| Strength | STR | 근접 공격력, 장비 제한 |
| Dexterity | DEX | 명중, 회피, 원거리 공격 |
| Constitution | CON | HP 보너스, 체력 |
| Intelligence | INT | 마법 효과, 스킬 |
| Wisdom | WIS | 회복력, 저항력 |
| Charisma | CHA | NPC 반응, 거래 |
- 클래스를 먼저 선택한다.
- 각 능력치마다 주사위 4개(d6)를 굴려 가장 낮은 값을 제외한 3개를 합산한다.
- 클래스별 스탯 보정을 적용한다.
- 6개 스탯의 합계를 72로 리밸런싱한다. 합계가 72 미만이면 낮은 스탯을 올리고, 초과하면 높은 스탯을 낮춘다. 각 스탯은 3~18 범위를 벗어날 수 없다.
예) 3, 5, 2, 4 → 2 제외 → 3+5+4 = 12
리밸런싱이 보정 이후에 적용되므로, 총합 72가 항상 보장된다.
NetHack/D&D 스타일로, 클래스마다 고유한 능력치 보정을 적용한다. 보정을 먼저 적용한 뒤 72로 리밸런싱하므로, 총합 72가 항상 보장된다.
| 클래스 | STR | DEX | CON | INT | WIS | CHA |
|---|---|---|---|---|---|---|
| Barbarian (M) | +3 | 0 | +2 | -2 | -2 | -1 |
| Barbarian (F) | +2 | +1 | +1 | -2 | -1 | -1 |
| Caveman (M) | +2 | 0 | +2 | -2 | 0 | -2 |
| Caveman (F) | +1 | +1 | +1 | -2 | +1 | -2 |
| Knight (M) | +1 | -1 | +1 | -1 | 0 | 0 |
| Knight (F) | 0 | 0 | 0 | -1 | +1 | 0 |
| Valkyrie | +2 | +1 | +1 | -1 | -2 | -1 |
| Ranger | +1 | +2 | 0 | -1 | 0 | -2 |
| Samurai | +1 | 0 | +2 | -1 | 0 | -2 |
| Monk | -1 | +2 | 0 | -1 | +2 | -2 |
| Priest | -1 | -1 | +1 | -1 | +3 | -1 |
| Archaeologist | -1 | +1 | 0 | +2 | +1 | -3 |
| Healer | -2 | -1 | +1 | +1 | +2 | -1 |
| Rogue | -1 | +3 | 0 | +1 | -1 | -2 |
| Wizard | -2 | 0 | -1 | +3 | +2 | -2 |
| Tourist | -1 | 0 | -1 | +1 | -1 | +2 |
히든 클래스 (NPC 전용, 플레이어 선택 불가)
| 클래스 | STR | DEX | CON | INT | WIS | CHA |
|---|---|---|---|---|---|---|
| Merchant | -2 | 0 | -1 | +1 | -1 | +3 |
| Guard | +2 | 0 | +2 | -2 | -1 | -1 |
예) Barbarian, 롤 후 STR=12 → 12 + 3 = 15
Wizard, 롤 후 STR=12 → 12 - 2 = 10
적용 순서:
- 4d6 drop lowest로 6개 스탯 생성
- 클래스 보정 적용
- 합계 72로 리밸런싱 (3~18 범위 유지)
- 최종 DEX로 GUARD 계산
캐릭터를 생성할 때, 최종 DEX (클래스 보정 적용 후)로 GUARD를 계산해 저장한다.
dex_mod = (DEX - 10) / 2
GUARD = clamp(10 + dex_mod, 1, 20)
- 현재 구현은 Rust 정수 나눗셈을 사용하므로 0 쪽으로 버림된다.
- 현재 스탯 범위(DEX 3
18) 기준, 실제 캐릭터 GUARD 범위는 대략 714다.
예시:
| DEX | dex_mod | GUARD |
|---|---|---|
| 8 | -1 | 9 |
| 10 | 0 | 10 |
| 14 | +2 | 12 |
| 18 | +4 | 14 |
레벨 1 기준: max_hp = HD_max + con_mod + 종족 보너스
con_mod = (CON - 10) / 2
con_mod는 정수 나눗셈을 사용해 0 쪽으로 버림된다.
| 클래스 | HD |
|---|---|
| Knight, Barbarian, Caveman, Valkyrie | d10 |
| Ranger, Samurai, Monk, Priest | d8 |
| Archaeologist, Healer, Rogue, Wizard | d6 |
| Tourist | d4 |
| 종족 | 보너스 |
|---|---|
| Dwarf | +4 |
| Human | +2 |
| Elf, Gnome, Orc | +1 |
레벨 1 예시: Human Knight, CON 14
HD_max(10) + con_mod(+2) + 종족 보너스(+2) = 14 HP
NetHack과 D&D의 자연 회복 시스템에서 영감을 받은 시간 기반 자동 회복 시스템.
- 16초(2 Ticks): 서버의 기본 게임 시간 틱(8초) 두 번마다 회복이 발생한다.
- 고전적인 "기다림"의 느낌을 주기 위해 리듬은 8초(Clock Sync)를 유지하되 회복 주기는 16초로 설정하였다.
회복량은 **기본 회복량(1)**에 캐릭터의 **레벨(Level)**과 건강(CON) 보정치를 더해 결정된다.
con_mod = (CON - 10) / 2
regeneration_amount = max(1, 1 + floor(Level / 5) + con_mod)
con_mod는 정수 나눗셈을 사용해 0 쪽으로 버림된다.- 최소 회복량은 1 HP로 보장된다.
- 예시 (레벨 6, CON 12 기준):
1(기본) + 1(레벨 6/5) + 1(CON 12 보정) = 3 HP
-
캐릭터가 살아있는 상태(
health > 0)여야 한다. -
현재 체력이 최대 체력보다 낮아야(
health < max_health) 한다. -
비전투 상태: 마지막 공격 또는 피격으로부터 10초 이상 경과해야 한다.
-
허기·디버프: 쇠약(Weak) 상태이거나
blocksRegen디버프(식중독, 출혈)에 걸려 있으면 회복이 멈춘다 (HUNGER.md, DEBUFF.md). -
구현: server/src/game_state/mod.rs (메서드:
tick_regeneration)
- 레벨 2부터 적용
- HD를 굴린 뒤 최소 50% 보장, 그 다음
con_mod를 더한다
roll = dX
min_roll = X / 2
hp_gain = max(roll, min_roll) + con_mod
max_hp += hp_gain
예시 (전사 계열 d10):
roll = 3 → min_roll = 5 → hp_gain = 5 + con_mod
굴림 합계 + attack_bonus > target_guard → 명중
굴림 합계 + attack_bonus ≤ target_guard → 빗나감
guard가 곧 명중 목표값이다.- 플레이어
attack_bonus = level / 2(내림) + STR modifier + 무기 인챈트 + 디버프hitMod합(취기, DEBUFF.md) — 무기가rangedAbility를 선언하면 STR 대신 그 능력치를 쓴다(아래 "원거리 전투") - 몬스터
attack_bonus = level— 플레이어보다 가파르다. 플레이어 guard는 레벨이 아니라 장비로 오르기 때문이다(아래 "장소 명중 보너스").attackBonus를 monsters.csv에 적으면 그 값이 우선하고, 던전 깊이 스케일링은 그 값에 올라간 레벨만큼을 더한다.
d20을 굴린다. 20이 나오면 한 번 더 굴려서 더한다. 그 굴림도 20이면 또 더한다 (최대 5회까지 — 합계 120이면 어떤 guard보다 높으므로 실질 무제한).
굴림이 13 → 합계 13
굴림이 20, 10 → 합계 30
굴림이 20, 20, 4 → 합계 44
합계 30이 나왔고 공격보너스가 +2라면 32가 되고, 목표 guard가 31 이하면 명중이다.
왜 이렇게 하나. "자연 20이면 무조건 명중"으로 두면 아무리 두꺼운 갑옷을 입어도 명중률이 정확히 5%에서 멈춘다. 그 5%는 밸런스 판단이 아니라 주사위 면이 20개라서 생긴 숫자일 뿐이다 — 코볼트가 판금 갑옷 플레이어를 스무 번에 한 번 때리게 된다. 폭발 주사위는 모자란 만큼 확률이 줄어들되 0은 되지 않게 한다. 자연 1은 특별 취급하지 않는다.
(폭발이 아예 없으면 guard 22 이상은 공격보너스 +2 이하 몬스터에게 수학적으로 무적이 된다.)
확률. 합계가 목표치 t 이상 나올 확률은 20점 구간마다 나눠서 본다.
t ≤ 20: 그냥 d20 한 번이므로P(t) = (21 − t) / 20(예:P(15) = 6/20 = 30%— 15,16,…,20 여섯 눈)t > 20: 첫 굴림이 반드시 20이어야 하고(1/20) 나머지를 같은 방식으로 다시 본다 →P(t) = P(t − 20) / 20
즉 20점을 넘길 때마다 확률이 1/20로 꺾인다. 예를 들어 합계 30 이상은
P(30) = P(10) / 20 = (11/20) / 20 = 11/400 = 2.75%
(21 − 30)/20처럼 t가 20을 넘은 채로 첫 번째 식에 넣으면 안 된다 — 20을
넘는 순간 두 번째 식으로 넘어간다.
명중률로 정리하면:
| 몬스터 (보너스) | G15 | G21 | G24 | G30 | G40 | G50 |
|---|---|---|---|---|---|---|
| Kobold (+1) | 30% | 5% | 4.25% | 2.75% | 0.25% | 0.14% |
| Orc (+4) | 45% | 15% | 5% | 3.5% | 1% | 0.18% |
| Hobgoblin d10 (+8) | 65% | 35% | 20% | 4.5% | 2% | 0.23% |
| Orc Warlord (+10) | 75% | 45% | 30% | 5% | 2.5% | 0.25% |
guard 40짜리 플레이어를 코볼트가 한 번 맞히려면 평균 400회, 공격 쿨다운 1.9초 기준 13분을 때려야 한다. 무적은 아니되 사실상 무의미한 수준이다.
플레이어 guard는 레벨이 아니라 획득한 방어구와 인챈트로 오르고, 상한이 없다. 프로드 실측(2026-08-20)에서 Lv13~16 활동 사냥꾼의 guard 중앙값은 40, 최고는 67이었고 그중 인챈트가 평균 14를 차지했다. 몬스터 명중 보너스는 레벨이므로 최대 20에서 멈춘다 — 폭 28짜리 분포를 20칸짜리 d20 창으로 덮을 수 없다. 그래서 난이도는 무엇을 만나느냐가 아니라 어디서 사냥하느냐가 정한다.
던전: 보너스 = max(0, 깊이 − DUNGEON_SAFE_DEPTH(5)) × DUNGEON_DEPTH_ATTACK_BONUS(3)
표면: 보너스 = max(0, 마을 거리 − SURFACE_SAFE_METERS(300m)) / SURFACE_METERS_PER_ATTACK_BONUS(20m)
- 구현:
place_attack_bonus, 적용은 game_state/combat.rs의 몬스터 공격 - 몬스터 레벨·HP·피해·XP는 건드리지 않는다. 명중만 장소를 탄다 — 깊이가
레벨을 올리면(
monster_level_for_depth) XP가1 + level²로 함께 뛴다. - 안전 구간이 초보 보호다. 티어1 던전(Old Crypt, 5층)과 마을 반경 300m는 보너스 0이라 기존 밸런스 그대로다. 설계 앵커: 상점 장비를 갖춘 guard 15가 Old Crypt 5층에서 orc(lvl 4)에게 45% — 대상 guard를 보는 하한·클램프 없이, 안전 구간 상수 두 개로 끝난다.
실이용자 킬 가중 명중률(프로드 실측 대비):
| 장소 | 보너스 | 현행 | 적용 후 |
|---|---|---|---|
| Old Crypt 1~5층 | 0 | 4~19% | 변화 없음 |
| Orc Warrens 8층 | 9 | 4% | 14% |
| Orc Warrens 10층 | 15 | 8% | 45% |
| Ogre Stronghold 10층 | 15 | 4% | 23% |
| Ogre Stronghold 15층 | 30 | 2% | 49% |
층당 3은 진행 사다리에 맞춘 값이다(hobgoblin 깊이 보정 기준): 상자
졸업(guard 21)은 67층에서 4055%, 인챈트를 약간 얹으면(26) 89층에서
5070%, 인챈트 확정 구간을 채우면(39) 10층도 20~30%로 버틴다. 8층부터는
인챈트가 입장료가 되도록 — 연마유 골드 싱크가 진행 화폐다.
표면은 같은 곡선을 거리로 읽는다 — 300m까지 0, 560m에서 +13, 800m에서 +25. 인챈트를 두른 guard 61은 근거리 표면에서 여전히 잘 안 맞는데, 그쪽은 명중이 아니라 보상으로 다룬다(아래 거리 게이트).
표면 몬스터 종류도 플레이어 레벨이 아니라 마을로부터의 거리가 정한다.
레벨 1칸당 AMBIENT_SPAWN_METERS_PER_LEVEL(70m)씩 밀린다.
| 몬스터 | 레벨 | 해금 거리 |
|---|---|---|
| Kobold | 1 | 0m |
| Goblin | 2 | 70m |
| Orc | 4 | 210m |
| Hobgoblin | 5 | 280m |
| Gnoll | 6 | 350m |
| Bugbear | 7 | 420m |
| Ogre | 8 | 490m |
| Troll | 9 | 560m |
- 구현:
min_ambient_town_distance. 거리는 플레이어가 아니라 몬스터가 놓이는 지점에서 잰다 (ambient_spawn.rs) - 마을 근처는 저레벨 몬스터만 나오므로 XP도 드랍 등급도 낮다. 고레벨 플레이어를 명중률로 쫓아내는 게 아니라 보상 구배로 밀어낸다 — 실측상 표면 킬의 94%가 마을 반경 100~400m 안에서 일어나고 있었다.
- 앰비언트 최고가 Troll(레벨 9)이라 560m 밖은 보상이 늘지 않는다. 새 상위 몬스터가 들어오면 그만큼 프론티어가 밀린다.
명중 시에만 굴린다.
대미지 = dice notation 파싱 후 합산
예) "2d6" → d6 두 번 굴려 합산 (2~12)
주사위 표기법: {count}d{sides} (예: 1d6, 2d8, 3d4)
무기가 range를 선언하면 그 거리까지 히트스캔으로 때린다. 화살 같은
투사체는 날아가지 않고, 공격 시점에 즉시 판정한다.
| 필드 | 타입 | 설명 |
|---|---|---|
range |
f32? | 사거리(m). 비우면 근접 — 서버의 고정 2m 리치를 그대로 쓴다 |
rangedAbility |
string? | 명중·대미지 보너스를 읽을 능력치 (dex, int, ...). 비우면 STR |
hands |
u8? | 무기가 차지하는 손. 비우면 1, 2면 오프핸드를 봉인한다 |
활(bow): main_hand, hands 2, range 10, rangedAbility dex, 1d6.
validate_player_attack이 max(근접 리치, 무기 range)로 판정한다. 벽 검사
(ranged_attack_line_blocked)는 울타리를 통과시키지만, 벽·닫힌 문·가구로 막힌
칸 너머로는 쏠 수 없다. 울타리는 이동과 근접 공격을 계속 막는다. 사거리 밖 공격은 종전대로
PLAYER_ATTACK_PROVOKE_RANGE_METERS(10m) 안에서 어그로만 끈다.
클라이언트도 같은 data/items.json 열을 읽는다(weaponRangeMeters). 클릭
공격·추격 중단·거절 판정이 한 정의를 공유하므로 서버와 어긋나지 않는다.
추격은 사거리 끝에서 멈춰 서서 쏜다(카이팅 없음).
rangedAbility가 있으면 히트 롤의 attack bonus와 대미지 보너스를 STR 대신
그 능력치의 modifier로 계산한다. 그 외 판정 경로는 근접과 완전히 같다.
data-src/player_anim_timing.csv의 값은 전부 slash1 기준이라 활에 그대로 쓸 수
없다. 활은 클립 후반에야 화살이 떠나기 때문이다(bow_shoot 1.033초 중 활을 든
팔의 반동이 0.767–0.800초에 시작).
| 항목 | 근접 | 원거리 |
|---|---|---|
| 화살이 떠나는 순간 | — | player_ranged_impact 780 |
| 명중 순간 (피해 텍스트·몬스터 반응·전리품 낙하) | player_attack_impact 540 |
780 + 비행 시간 |
| 피해 숫자 | player_attack_damage_text 750 |
명중 + 210 (근접의 540→750 간격을 그대로) |
원거리는 화살이 보이므로 떠나는 순간과 닿는 순간이 갈라진다. 비행은 고정
속도 30 m/s라 거리에 비례한다(2 m면 약 67 ms, 10 m면 333 ms) — 고정 시간으로
하면 근거리에서 화살이 기어간다. 서버의 전리품 지연만은 거리를 받지 않고
player_ranged_flight(333, 10 m 기준)를 더한 고정값을 쓴다. 가까운 사격에서
전리품이 화살보다 최대 그만큼 늦게 떨어지지만 보이지 않는 차이이고, 시각 효과
하나 때문에 spawn_kill_loot_after_impact에 거리를 흘려보내지 않는다.
공격 간격(player_attack_interval 1380)은 원거리도 그대로 쓴다 — 활의 DPS를
근접과 같은 타이밍에 묶어 둔다. 클립이 간격보다 짧아 끝 자세로 잠깐 머무는 것은
slash4(1.3초)도 마찬가지다.
다른 플레이어가 쏜 화살도 같은 타이밍으로 처리한다 — PlayerMainHandChanged가
남의 주손 장비를 브로드캐스트하고 클라이언트가 그것으로 남의 무기를 그리므로,
원거리인지 아닌지도 그 값으로 안다.
소리는 draw(player_ranged_draw 460 ms에 시작해 루스 직후까지 이어진다) + release(화살이 떠나는 순간)뿐이고, 원거리 명중은 재질
타격음을 내지 않는다. 재질 표는 "이 무기가 저 대상을 때리면 어떤 소리인가"를
답하는데 활의 wood는 활 몸이지 화살이 아니라, 규칙이 없어 기본값인
칼-가죽 소리로 떨어져 활시위 소리를 덮어 버렸다. 화살 명중음이 생기면
material-impact-sounds.json에 규칙을 넣고 되살린다.
활은 쏠 때마다 화살을 한 발 쓴다. items.csv의 ammoKind가 무기와 탄약
양쪽에 같은 종류 이름을 적어 짝을 짓는다 — 활과 두 화살 모두 arrow. 무기에
ammoKind가 없으면 쏘는 데 아무것도 들지 않으므로 근접 무기는 손댈 필요가
없다.
| 주사위 | 활과 합쳐 | 대응 | 가격 | |
|---|---|---|---|---|
| bow | 1d1 | — | — | 4000 |
| iron_arrow | 1d6 | 4.5 | iron_sword 1d8 | 3 |
| steel_arrow | 1d8 | 5.5 | steel_longsword 1d10 | 10 |
피해는 활 주사위 + 화살 주사위다(roll_attack_with_extra_damage_roll,
몬스터가 자기 주사위에 무기 주사위를 더하는 것과 같은 경로). 활의 1d1은 일부러
토큰이다 — 아프게 하는 것은 화살이고, 활은 사거리와 능력치를 정한다. 능력치
보정과 활의 인챈트는 종전대로 더해진다. 화살은 인챈트할 수 없다 — 장비
슬롯에 들어가지 않으므로 두루마리가 닿지 않는다.
화살은 겹쳐 쌓이고, 겹치는 물건은 장비 슬롯에 들어갈 수 없다(슬롯은 한 개만
저장하므로 나머지가 사라진다 — item_defs.rs의 부팅 단언). 그래서 화살은
가방에 남고 PlayerInventory.active_ammo가 어느 더미에서 뽑을지를 가리킨다.
- 활을 착용할 때 아직 유효한 선택이 없으면 가장 센 화살이 자동으로 걸린다.
- 더 좋은 화살을 주워도 직접 고른 선택을 덮지 않는다 — 싼 화살을 쓰겠다는 결정이 그 자리에서 무효가 되면 고를 수 있다는 것이 의미가 없다.
- 고른 더미가 떨어지면 다음으로 센 것으로 자동으로 내려간다. 가방에 화살이 남았는데 "화살 없음"이 뜨면 그건 버그로 보인다.
- 활을 벗어도 선택은 남는다. 순위는 주사위의 기댓값으로 매긴다 — 따로 등급 칸을 두면 실제 피해와 어긋날 수 있다.
가방이 비면 OutOfAmmo로 거절하고, 클라이언트는 invalid_target과 같이 자동
공격을 멈춘다(멈추지 않으면 쿨다운마다 거절 메시지가 쌓인다). 거절된 공격은
화살을 쓰지 않는다 — 소비는 모든 게이트를 지나 쿨다운을 확보한 뒤에 일어난다.
화살은 순수 시각 효과다. 명중 판정은 활을 놓기도 전에 서버에서 끝나 있고
(validate_player_attack), 화살은 그 결과를 납득시키는 역할만 한다 — 지금까지
근거 없이 기다리던 지연에 눈에 보이는 이유를 주는 것이 목적이다.
- 명중이면 매 프레임 몬스터의 현재 위치로 조준을 고쳐 반드시 닿는다. 몬스터는 비행 중 최대 2.7 m 움직인다(scp939 8 m/s × 333 ms). 발사 시점 위치로 직선 비행시키면 화살은 허공에 꽂히는데 피해 숫자는 뜨는 모순이 생긴다.
- 빗나가면 조준을 고치지 않고 발사 시점 방향 그대로 목표를 3 m 지나쳐 사라진다.
- 활의 실제 월드 좌표에서 출발한다(
PlayerModel.getBowWorld). 발사 요청은monsterManager가 놓는 순간에 큐에 넣고, 활 위치를 아는GameScene이 다음 프레임에 꺼내 쏜다. - 사수당 한 발이면 충분하다 — 공격 간격 1380 ms가 최장 비행 333 ms보다 길다.
- 실제로 쓴 화살의 모델을 그린다.
PlayerAttacked.ammo_item_def_id가 그것을 실어 오므로, 남의 가방을 볼 수 없는 클라이언트도 남이 쏜 화살을 맞게 그린다.
땅에 꽂히기·꼬리 궤적·시위에 걸린 화살·명중음은 모두 범위 밖이다.
거절된 공격만 어그로를 끌던 종전 경로로는 원거리에서 명중한 공격이 몬스터를
깨우지 못한다. 근접 리치 밖에서 명중하면 MonsterProvoked를 몬스터 소유
클라이언트에 따로 보낸다(서버 AI는 brain_hit로 이미 깨어난다).
hands: 2 무기를 착용하면 오프핸드 아이템이 가방으로 내려간다. 양손 무기를 든
상태에서 토치(torch·worn_torch)를 장착하면 양손 무기를 가방으로 옮기고 왼손에
토치를 든다. 인벤토리·퀵슬롯 사용과 왼손 슬롯으로 끌어놓기에 적용된다.
방패 등 다른 오프핸드 아이템은 양손 무기를 든 상태에서 장착할 수 없다.
- 구현: server/src/game_state/combat.rs, server/src/game_state/inventory.rs, client/src/lib/data/itemDefs.ts
NetHack의 AC를 반전시킨 방어 수치이자 명중 목표값. 높을수록 방어력이 좋다.
- 캐릭터: 생성 시 DEX 기반 공식으로 계산 (위 섹션 참고)
- 몬스터:
data-src/monsters.csv에 정의하고data/monsters.json으로 생성 - 장비: 착용 아이템의
guard와 방어구의 인챈트 +N을 더한다 (ENCHANT.md) - 10이 기준점이다.
| GUARD | 의미 |
|---|---|
| 0~7 | 무방비 / 매우 취약 |
| 8~9 | 약한 방어 |
| 10 | 보통 방어 |
| 11~13 | 단단한 방어 |
| 14+ | 중장갑 이상 |
NetHack AC와의 대응:
GUARD = 10 − AC(NetHack AC 0 → GUARD 10, AC -5 → GUARD 15)
몬스터는 data-src/monsters.csv에 정의하고, 빌드/개발 도구가 data/monsters.json을 생성한다.
| 필드 | 타입 | 설명 |
|---|---|---|
health |
u32? | 최대 HP override. 비우면 레벨 기반 기본값 (level d8 평균 반올림) |
level |
u8 | 몬스터 레벨 (기본 HP/명중/피해/XP 계산에 사용) |
guard |
u8 | 명중 목표값. 높을수록 맞히기 어렵고, 10 초과분은 XP 보너스에 영향 |
attackBonus |
i32? | 몬스터 명중 보너스 override. 비우면 level / 2 |
damageRoll |
string? | 대미지 주사위 override. 비우면 레벨 기반 기본값 |
behavior |
string | 몬스터 행동 트리 이름 (data-src/behavior_trees.json, 없으면 brave 사용) |
attackRange |
f32 | 근접 공격 가능 거리 |
chaseRange |
f32 | 플레이어 추적 시작 거리 |
attackCooldown |
u32 | 공격 간격 (밀리초) |
현재 몬스터 예시 (SCP-939):
{
"level": 3,
"guard": 10,
"behavior": "timid",
"attackRange": 3,
"chaseRange": 25,
"attackCooldown": 4100
}- 클라이언트가
PlayerAttack { monster_id }전송 - 서버에서 히트 롤:
roll_attack(player_attack_bonus, monster_guard, weapon_damage) - 결과를 전체 클라이언트에 브로드캐스트 (
PlayerAttacked) - 명중 시 몬스터 HP 차감
- HP가 0이 되면
MonsterDead브로드캐스트, 30초 후 제거
- 클라이언트(몬스터 owner)가
MonsterAttack { monster_id, target_player_id }전송 - 서버에서 히트 롤:
roll_attack(monster_attack_bonus + place_attack_bonus, player_guard, monster_damage) - 결과를 전체 클라이언트에 브로드캐스트 (
MonsterAttackedPlayer) - 명중 시 플레이어 HP 차감
- HP가 0이 되면
PlayerDead브로드캐스트
- 클라이언트가
RequestRespawn전송 - 서버에서 HP 0 확인 후 최대 HP로 회복, 여관 병실(
data-src/world.json의respawn)로 이동 respawn.bedIds의 침대 중 다른 플레이어가 차지하지 않은 첫 침대에 누운 상태로 (object_type/object_id설정). 침대 위치·회전은 해당 지역 오브젝트 파일에서 읽으므로 에디터로 옮겨도 따라간다- 침대가 모두 차 있으면
respawn좌표에 서서 부활 PlayerRespawned { player }브로드캐스트.player.object_type이 있으면 클라이언트는 그 침대에 눕는다 (본인은InteractObject를 다시 보내지 않음)
- 가방에
phoenix_talisman이 있으면 사망 대화상자에 "Use Phoenix Talisman" 버튼이 뜬다 - 클라이언트는 일반 소모품처럼
UseItem { instance_id }를 보낸다 (UseEffect::ReviveInPlace) - 서버는 HP 0일 때만 제자리·같은 층에서 최대 HP의 70%(items.csv
reviveHpPercent)로 되살리고 부적 1개를 소모. 살아 있을 때 쓰면 거절하고 부적은 남긴다 - 사망 시 이미 적용된 XP 페널티는 그대로. 무적 시간·쿨타임은 없다
- 같은
PlayerRespawned { player }를 브로드캐스트하므로 클라이언트는 받은 위치·층을 그대로 따른다 - 획득: Rica 판매(basePrice 5,000c), 월드 드랍 0.2% (
data-src/world_drop.csv)
xp = 1 + level² + guard_bonus
guard_bonus:
| GUARD | 보너스 |
|---|---|
| 0 ~ 10 | 없음 |
| 11 | +2 |
| 12 | +4 |
| 13 | +6 |
| 10 + i | 2i |
일반 공식: guard_bonus = max(guard - 10, 0) × 2
예시:
| 몬스터 | level | GUARD | xp |
|---|---|---|---|
| 약한 적 | 1 | 8 | 1 + 1 = 2 |
| 보통 적 | 3 | 10 | 1 + 9 = 10 |
| 강한 적 | 5 | 12 | 1 + 25 + 4 = 30 |
| 보스 | 8 | 13 | 1 + 64 + 6 = 71 |
플레이 시간에서 역산한 2~50레벨 표(LEVEL_XP, shared/src/xp.rs)를 쓴다. 표와 근거,
생성 스크립트는 LEVEL_CURVE.md. 50 이후는 XP(n) = XP(50) × 2^(n−50).
| 레벨 | 필요 누적 XP |
|---|---|
| 1 | 0 |
| 2 | 17 |
| 5 | 586 |
| 10 | 9,956 |
| 20 | 651,156 |
| 30 | 3,991,156 |
| 40 | 12,571,156 |
| 50 | 26,971,156 |
| 51 | 53,942,312 |
사망 시, 현재 레벨 구간 XP의 15%를 차감한다.
level_start_xp = XP(L)
next_level_xp = XP(L + 1)
level_band = next_level_xp - level_start_xp
penalty = max(1, floor(level_band * 0.15))
new_xp = max(0, current_xp - penalty)
사망 후 XP가 현재 레벨 시작 XP보다 작아지면 레벨을 1 내린다.
if new_xp < XP(L):
L = max(1, L - 1) // 1회 사망당 최대 1레벨 하락
레벨 하락이 발생하면, 하위 레벨 구간의 최소 30% 진행도는 보장한다.
lower_start_xp = XP(L)
lower_next_xp = XP(L + 1)
lower_band = lower_next_xp - lower_start_xp
recovery_floor = lower_start_xp + floor(lower_band * 0.30)
new_xp = max(new_xp, recovery_floor)
레벨 업/다운 반복에서 통계적 이득이 없도록, 레벨 다운 시 HP 감소량 분포를 레벨 업 증가량 분포와 동일하게 한다.
con_mod = (CON - 10) / 2
hp_delta(HD, CON):
roll = dHD
min_roll = HD / 2
return max(roll, min_roll) + con_mod
hp_loss = hp_delta(HD(class), CON) // 레벨업과 동일 분포
new_max_hp = max(level1_max_hp, current_max_hp - hp_loss)
current_hp = min(current_hp, new_max_hp)
- 레벨이 내려가지 않은 경우에는
max_hp를 깎지 않는다. - 통계적으로
E(hp_gain) = E(hp_loss)이므로, 레벨 업/다운 반복의 기대 순이득은 0이다. - 클래스별
E(max(roll, HD/2))는 다음과 같다: d10=6.5, d8=5.25, d6=4.0, d4=2.75.
- 레벨 1에서는 레벨 하락이 발생하지 않는다.
- 1회 사망으로 연속 레벨 하락(2레벨 이상)은 발생하지 않는다.
Client → Server:
PlayerAttack { monster_id }
MonsterAttack { monster_id, target_player_id }
RequestRespawn
UseItem { instance_id } (phoenix_talisman → 제자리 부활)
Server → Client (broadcast):
PlayerAttacked { player_id, monster_id, hit, roll, damage }
MonsterAttackedPlayer { monster_id, player_id, hit, roll, damage }
MonsterDead { monster_id }
PlayerDead { player_id }
PlayerRespawned { player }
클라이언트가 몬스터 AI를 처리하고, 공격 판정은 서버에 요청한다.
| 상태 | 설명 |
|---|---|
idle |
대기 (30% 확률로 랜덤 이동) |
walk |
이동 중 |
run |
플레이어 추적 중 (chaseRange 이내) |
attack |
공격 중 (attackRange 이내) |
hit |
피격 경직 (~800ms) |
dead |
사망 |