09 기술 설계 - kyhsa93/karda GitHub Wiki
09. 기술 설계
docs/design 08장의 스택·의존 규칙·결정론을 그대로 지킨다: 새 런타임 의존성 없음, sim은 렌더·DOM·React 비의존, sim 안 무작위는 world.rng만, 유형별 배열 + 숫자 ID, PWA 무캐시.
9.0 출발점 — 기존 콘텐츠 제거(D13) 뒤의 코드
2026-09-30 제거 커밋(439ab27) 기준으로 확정. 추가 사실: ui/screens/Flight.tsx는 임무 코드를 걷어 낸 범용 비행 셸(session을 props로 받음)이고 아직 어느 화면에도 연결되지 않았다. 적 기관포 위력 회귀 테스트는 src/sim/ai/lethality.test.ts로 옮겼다. B1-1에서 코드와 대조해 확인했다: 제거 전 JS 합계 360.5KB gzip(첫 번들 206.2 + 비행 청크 132.4) → 제거 뒤 149.2KB(첫 번들 128.5, 비행 셸은 연결 전이라 번들에 없음), 테스트 471개 → 325개, 한국어 글꼴 약 95KB. world.player를 직접 읽는 적 AI는 awareness·brain·air·searchlight이고 enemyMissile은 표적을 인자로 받는다(9.2 v1 교체 범위). 상세 수치와 회귀 기준 테스트 목록은 저장소 docs/battle/baseline.md.
| 상태 | 모듈 |
|---|---|
| 제거됨 | content/missions/*(m01content/campaign*, content/missions.ts, sim/mission/*(runtime·schema·scoring·instant), sim/ai/wingman.ts, save/campaign.ts, ui/mission/*(Briefing·Debrief·InstantSetup·Loadout·MissionMap), ui/screens/Campaign.tsx·Training.tsx, ui/report.ts, render/rings.ts, 캠페인 콘텐츠 테스트(playthrough·placement·onboarding) |
| 남음(수정됨) | sim/world.ts, sim/objective.ts, sim/events.ts, sim/ai/brain.ts, render/renderer.ts·unitRenderer.ts·quality.ts·cockpit/pages/tsd.ts, ui/screens/Flight.tsx·SettingsScreen.tsx, ui/flight/messages.ts |
| 남음(그대로) | sim/heli/*(비행·계통·피해·로드아웃·자동 호버), sim/weapons/*, sim/sensors/*, sim/ai/awareness.ts·air.ts·movement.ts·searchlight.ts, sim/terrain.ts·los.ts·units.ts·farp.ts·session.ts·assists.ts·difficulty.ts, render/*(조종석·IHADSS·TADS·효과·지형 청크), input/*, audio/*, assets/*, content/units.json·weapons.json·aircraft.json·strings.ko.json |
| 신규(제거 작업) | save/save.ts(설정 저장으로 보임) |
- 결과: 제거 직후 게임은 타이틀·설정·크레딧만 있다. 아파치를 띄울 "목표(Objective)"가 없다 → B1의
BattleRuntime이 첫 목표 구현이 된다. - 캠페인 회귀 테스트(
playthrough.test.ts등)가 사라졌으므로, 이 로드맵의 회귀 기준은 ① 남은 아파치 계통 테스트(비행·무장·센서·피해) ②sw.test.ts③ 새 전장 결정론 테스트(9.7)다.
9.1 전체 그림
FlightSession (mode: deploy* | play | crashed | over | done)
└─ objective: BattleRuntime* (Objective 인터페이스)
├─ state: BattleState* (거점, 티켓, 편제, 소대, 탈것 풀, 요청, 점수)
└─ world.battleHooks* = { tick10Hz, tick1Hz } ← world.ts는 sim/battle을 import하지 않는다
World (기존 + 아바타)
avatar* ─┬ heli → world.player: HeliState (기존 경로 — 아파치·수송·VPA 헬기)
├ soldier → SoldierState* (sim/infantry)
├ ground → units[id] (플레이어가 조종하는 지상 탈것 유닛)
├ jet → JetState* (sim/jet)
└ dead (배치 화면 대기)
units[] · los · groups · enemyMissiles · projectiles … (기존)
* = 신규
- 의존 역전:
World.step은 10Hz·1Hz 지점에서 주입된battleHooks만 부른다. 전장 코드는 지연 청크로 분리된다(9.10).arch.test.ts에 "sim/world.ts는./battle을 import하지 않는다" 규칙. - 리스폰:
FlightSession에deploy모드. 사망하면crashed(연출) →deploy, 월드는 계속 스텝. 출격하면world.spawnAvatar(kind, spawn, kit). - React 스냅샷: HUD 요약(티켓·거점·체력·탄약·표식 목록)은 10Hz, 바뀔 때만 새 객체. 조준선·표식 위치처럼 매 프레임 바뀌는 것은 React를 거치지 않고 HUD 캔버스가
world를 직접 읽는다(기존 IHADSS 방식).
9.2 아바타 추상화 — 가장 큰 리팩터
지금 World는 플레이어가 항상 헬기라고 가정한다(world.player: HeliState, controls, arms, loadout, tads, fcr, laser …가 모두 World 필드). 그리고 적 AI(awareness.ts·brain.ts·air.ts·searchlight.ts)가 world.player를 직접 읽는다(enemyMissile.ts는 표적을 인자로 받는다).
| 단계 | 내용 | 완료 기준 |
|---|---|---|
| v1 (B1-7) | PlayerBody 인터페이스 { pos, vel, alive, kind, heat, radius, agl }를 만들고 AI가 world.player 대신 world.playerBody()를 읽게 바꾼다. 헬기일 때는 world.player를 감싼다. world.avatar = { kind: 'heli' } | { kind: 'dead' } |
기존 아파치 테스트 전부 통과 + 아바타 헬기/사망 전환 테스트 |
| v2 (B2-1) | soldier 추가. 헬기 전용 World 필드(arms, tads, fcr, laser, loadout, cm)는 헬기 아바타일 때만 스텝 |
보병 스폰·사망·헬기 탑승 전환 테스트 |
| v3 (B4-1) | ground 추가: 플레이어가 탄 지상 유닛은 unit.controller = 'player', 두뇌 스텝 건너뜀 |
탑승·하차·좌석 전환 테스트 |
| v4 (B6-1) | 헬기 기체 정의 주입(AirframeDef) — 수송·VPA 헬기 |
아파치 스텝 결과 해시가 리팩터 전과 같다 |
| v5 (B7-2) | jet 추가 |
전투기 스폰·손실 테스트 |
v1 구현 메모 (B1-7)
-
sim/avatar.ts:Avatar·PlayerBody { kind, pos, vel, alive, agl, heat, radius }·AvatarSpawn.world.playerBody()는 객체 하나를 재사용한다(할당 없음). 적 AI 네 파일(awareness·brain·air·searchlight)은 이제world.player를 읽지 않는다. -
world.active는 "전장이 돌고 있다", 플레이어 비행 스텝은avatar.kind === 'heli'일 때만. 그래서 배치 화면에서도 봇 이동·전투가 계속된다. -
FlightSession:objective.respawnDelay가 있으면 리스폰 경기. 시작하면deploy(첫 배치만 월드 시계 정지 —frozen), 추락 연출 2.2초 뒤deploy,deploy(spawn)은 대기(10초)가 끝나야 받는다. 목표(objective)의tick이 낸 이벤트는 다음 스텝에 전달된다. -
BattleRuntime(첫Objective구현): 진영 설정, 봇 두뇌 훅 설치, 경기 방식의 본진 고정 대공 배치, 스폰 지점(본진 FARP 패드 — 엔진 켠 채 / 본진 상공 150m 공중 투입, 기수는 적 본진 쪽), 전투 구역 경계(A3:zone이벤트로 남은 초 알림, 10초면outOfBounds추락).createBattleSession(map, mode, { side, seed })는 맵 지형 시드와 경기 시드를 따로 쓴다(World의terrainSeed). -
입력:
input/input.ts의FlightInput구조는 유지하고, 역할별 바인딩 세트(input/roles.ts: soldier·ground·heli·jet)를 아바타에 따라 바꾼다. 공통 명령(전황·무전·일시정지·탑승)은 모든 세트에. -
명령 → sim: 역할별
AvatarCommands(보병: 이동 벡터·자세·조준·발사…, 지상: 스로틀·조향·포탑…)를world.commands에 두고 스텝이 읽는다(기존world.controls방식의 확장).
9.3 스텝 순서 (World.step, 120Hz)
1. 명령 적용 (아바타별)
2. 아바타 스텝: heli → 기존 비행·계통 / soldier → 이동·자세·충돌 / ground → ground.ts / jet → jet/flight.ts
3. 발사체 · 미사일 (기존) — 인형 캡슐 충돌 추가
4. [10Hz] 격자 재구축, 센서, AI 인지(대 플레이어, 아바타별 모델), 두뇌(대 플레이어)
+ battle.tick10Hz: 표적 선택 1/10 묶음(id % 10), 봇 대 봇 사격, 탑승·하차, 봇 전투기 운동학
5. 유닛 이동 적분 (기존, 매 스텝)
6. [1Hz] battle.tick1Hz: 점령 값, 티켓 출혈, 소대 상태, 분대원 슬롯, 파도 스폰(30초), 탈것 풀 재생성,
지원 요청, 표식 만료, 지휘관(연합 0초·VPA 2.5초 교대)
7. 이벤트 flush (기존)
9.4 sim 비용 추정 (대규모)
| 작업 | 빈도 | 규모 | 건당 (추정) | 초당 (추정) |
|---|---|---|---|---|
| 봇 표적 선택 가시선 | 유닛당 1Hz, 후보 ≤ 4, 캐시 0.5초·5m | 88 유닛 → ≤ 350건/s | 10~15µs | 4~5ms/s |
| 대 플레이어 인지 | 10Hz, 캐시 | 적 진영 약 44 유닛 × 실제 재계산 약 2회/s | 15µs | 1~2ms/s |
| 분대원 → 플레이어 보병 사격 판정 | 10Hz, 가시선 있는 분대원 | ≤ 40명(근처 분대) | 가시선 캐시 공유 < 2µs | < 1ms/s |
| 봇 사격 판정 | 10Hz | ≤ 70 | < 1µs | 무시 |
| 이동 적분 | 120Hz | ≤ 90 유닛 | 0.3µs | 약 3ms/s |
| 도로·보병 격자 A* | 명령 바뀔 때(5초에 ≤ 12회) | 노드 300 / 셀 57,600 | ≤ 1ms / ≤ 2ms | ≤ 5ms/s |
| 플레이어 발사체(보병 연사) | 120Hz | ≤ 60발 비행 중 | 1µs(격자로 후보 제한) | 약 7ms/s |
| 소품·연막 가시선 추가 검사 | 가시선마다 거점 100m 안만 | +2µs | +1ms/s | |
| 합계 | 약 22 |
- 예산: 데스크톱 ≤ 4ms/프레임 — 여유 충분(추정). 모바일은 3
4배 느리다고 보고 프레임당 약 1.52ms(≤ 8ms 개정 예산 안). - 스파이크: 10Hz 틱에 모든 유닛을 몰지 않고 표적 선택을 id % 10으로 분산. 목표: AI 틱 프레임 최악 ≤ 2ms(데스크톱).
- LOS 캐시 키: 쌍 키
observerId * 65536 + targetId(경기당 유닛 생성 약 700 추정 < 65,536). 10초마다 2초 넘은 항목 삭제. - 측정으로 확인(B1-14, B5-7). 넘으면: 플레이어 3km 밖·가시선 없는 교전을 1Hz 통계로 합침, 3km 밖 비교전 지상 유닛 30Hz 이동.
9.5 LOD
sim (결정론 — 기준은 sim 상태인 플레이어 위치)
- 분대원 개인 판정은 플레이어 300m 안 분대만 켠다(분대원 캡슐·개별 사격). 그 밖은 분대 단위 통계. 플레이어 위치는 sim 상태라 결정론 유지.
- 봇 사격
fire이벤트(예광탄)는 사수가 플레이어 2km 안일 때만(전투기일 때 4km).explosion·unitDestroyed는 항상. - 먼 전투도 같은 규칙으로 싸운다(추상화하지 않음). 측정에서 예산 초과 시 9.4의 대안.
렌더 — 인형과 탈것
| 대상 | < 120m | 120~500m | 500~1,500m | > 1,500m |
|---|---|---|---|---|
| 인형 | 부위 7개 전체 애니메이션(매 프레임) | 부위 3개(15Hz) | 상자 1개(5Hz) | 안 그림 |
| 지상 탈것 | 전체 모델 + 포탑 회전 | 전체 모델 | 전체 모델 | 6km까지 전체, 그 밖 안 그림 |
| 항공기 | 전체 + 로터 | 같음 | 같음 | 12km(전투기 시점)·7km(그 외) |
- 협각 예외: 조준경·쌍안경·TADS·조준 포드 시야각 ≤ 10°이면 그 카메라 기준 거리로 다시 판단(식별 대상이 사라지면 안 됨, P3′).
- 인스턴스 상한: 근거리 인형 ≤ 48, 중거리 ≤ 160, 원거리 ≤ 240(넘으면 먼 것부터 한 단계 낮춤).
- 매 프레임
new Map등 할당 제거(08장 8.9 관례).UnitRenderer의 분대원 인스턴스 채우기를 인형 LOD 렌더러(render/battle/puppets.ts)로 대체.
9.6 유닛 수명 — 잔해 은퇴
- ID는 증가만, 죽은 유닛은 배열에 남는다(기존 규칙). 전장은 35분 동안 파도 스폰이 계속돼 유닛 약 600
700개가 생긴다(추정: 분당 사망 유닛 1518 × 35분 + 초기 90). - 죽은 지 90초 지난 유닛은
world.retire(u)로units에서 빼retired목록(통계용)으로 — 순서 유지 제거로 반복 순서 결정론적. ID 재사용 없음. - 콘텐츠 검증기가 "편제 + 고정 + 겹침 ≤ 150"을 맵·경기 방식·규모 조합마다 검사.
9.7 결정론
- 전장 코드도
world.rng하나. 뽑는 순서 고정: 지휘관(연합 → VPA) → 소대(id 순) → 유닛(id 순) → 분대원(슬롯 순) → 스폰(편제 순). 반동·탄 퍼짐도world.rng. - 렌더 전용 무작위(인형 걸음 위상, 예광탄 흔들림)는 sim에 영향을 주지 않는 별도 난수.
Date·Math.random금지(sim). 오늘의 전장 날짜 → 시드는 UI 층.- 테스트: 같은 시드·같은 입력 → 10분 뒤 (티켓, 점령 값, 살아 있는 유닛 id·위치 0.01m, 분대원 체력) 해시 동일 / 다른 시드면 다름 / 진영을 바꾼 같은 시드 경기도 결정론적.
9.8 데이터 형식
// src/content/battle/maps/<id>.json — 검증: src/sim/battle/schema.ts
interface BattleMapDef {
id: string; title: string;
environment: { seed: number; times: ('day' | 'dusk' | 'night')[]; fog: 'never' | 'optional' | 'always';
wind: { dirDeg: number; speed: number; gust: number } };
terrain: { size: 12000; lift?: number; features: TerrainFeature[]; roads: [number, number][][]; // features에 river(B1-6) 추가
outer: { size: 32000; cell: 100; seed: number } }; // 외곽 공역 (A2)
combatZone: [number, number][]; // 지상·헬기 경계 다각형
bases: { side: 'coalition' | 'veros'; position: [number, number]; farp: string;
jetSpawn: { position: [number, number, number]; headingDeg: number };
rearmCorridor: { from: [number, number]; to: [number, number]; width: number } }[];
farps: { id: string; position: [number, number]; point?: string }[];
points: { id: string; name: string; position: [number, number]; radius: number; value: number;
asset?: 'farp' | 'radar' | 'artillery'; lz?: [number, number];
props: { kind: 'sandbags' | 'crates' | 'barracks' | 'wall'; position: [number, number]; yawDeg: number }[] }[];
waypoints: { id: string; name: string; position: [number, number] }[]; // 헬기 BP
fixed: UnitSpawn[]; // { unit, side, position, yawDeg, modes? } — 본진 고정 대공, 구조물
modes: {
conquest?: { points: string[]; start: Record<string, 'coalition' | 'veros' | 'neutral'> };
quick?: { points: string[]; start: Record<string, 'coalition' | 'veros' | 'neutral'>; combatZone: [number, number][] };
breakthrough?: { sectors: { points: string[]; attackerBase: [number, number]; defenderRoster: string }[] };
};
}
content/battle/modes.json: 경기 방식 수치(게임 모드 3.6).content/battle/rosters.json: 소대 틀·진영 편제(대칭)·규모 배율·플레이어 진영 보정.content/battle/classes.json: 병과·무기·장비 수치(보병 12.4), 선택 가능한 장비 목록(A6: 모두 처음부터 열림).content/battle/vehicles.json: 플레이어 조종 수치(지상 탈것 13.3, 전투기 15.2).content/airframes/*.json: 헬기 기체 정의(apache.json= 기존aircraft.json,transport.json).content/units.json: 무기vs·penetration, 신규 유닛, 분대 인원 5.validateUnitDefs확장.- 검증: 경로 포함 오류, 참조 무결성, 지도 안 좌표, 40KB, 전장 맵 6.2 원칙.
9.9 성능 예산 개정안 (D4 대규모 기본)
| 항목 | 기존 (08장 8.7) | 전장 개정안 | 근거 |
|---|---|---|---|
| 동시 sim 유닛 | ≤ 150 | ≤ 150 유지 | 분대 = 유닛 1개, 최대 134(5.1) |
| 인형(분대원 + 플레이어) | — | sim ≤ 240 / 렌더 근 48 · 중 160 · 원 240 | 편제 190 + 겹침 |
| 드로우콜 | ≤ 200 | 데스크톱 ≤ 200 / 모바일 low ≤ 120 (추정) | 아래 추정 |
| 삼각형 | 없음 | 데스크톱 ≤ 1.2M / 모바일 low ≤ 350k (추정, B5-7 측정으로 확정) | |
| 물리+AI | ≤ 4ms | 데스크톱 ≤ 4ms / 모바일 ≤ 8ms | 30fps 프레임 33ms의 1/4 |
| 발사체 / 미사일 | ≤ 300 / ≤ 24 | 유지 | 봇 대 봇은 통계 — 실제 탄은 플레이어 관련만 |
| JS 번들 (gzip) | ≤ 350KB | 경로별(D11) — 9.10, 수치는 추가 결정 A1 | |
| 첫 로드 에셋 | ≤ 600KB | 유지 | |
| 지연 로드 에셋 | 임무당 ≤ 1MB | 경기당 ≤ 1.5MB | 새 에셋 분류(외부 에셋) |
드로우콜 추정 (외부 시점, 데스크톱)
| 항목 | 드로우콜 |
|---|---|
| 기존 외부 시점(M7-3 측정) | 129 |
| 인형 부위(근 7 + 중 3 + 원 1) | +11 |
| 탈것·항공기 유형(모델당 1, 진영 색은 인스턴스 색): 전차·장갑차·픽업·트럭·방공·전투기 2·수송 헬기 2·VPA 헬기 | +10 |
| 엄폐 소품 3종 · 깃발 3 · 외곽 공역 지형 4 | +10 |
| 뷰모델 2(보병) / 조종석(헬기·전투기, 기존 조종석 98 경우는 외부 모델 숨김) | +2 |
| 합계 | 약 162 (≤ 200) |
- 모바일 low: 그림자 끔(그림자 패스 제거), 나무 0.5(기존), 원거리 인형 끔(> 500m), 외곽 공역 지형 1겹 → 약 100~110(추정).
- 삼각형 비교: 채택
soldier.glb(1,077)로 인형 190을 그리면 약 20만, 코드 인형(근 150·중 60·원 12)이면 약 2만 — 인형 방식이 대규모를 가능하게 한다.
모바일 조정 (D4 "측정 후 조정")
- B5-7에서 헤드리스 비교 측정, B10-1에서 소유자 실기기 측정. 30fps에 못 미치면 렌더부터 줄인다(인형 LOD 거리 ×0.5 → 원거리 인형 끔 → 그림자·나무). 그래도 부족하면 모바일 기본 규모를 표준(× 0.6)으로 — 이 마지막 단계는 추가 결정 A9(소유자가 측정 결과를 보고 결정).
9.10 번들 — 경로별 예산 (D11)
| 항목 | 크기 gzip (추정) | 근거 |
|---|---|---|
| 현재(제거 전) | 첫 번들 205.8 + 비행 청크 131.2 = 337KB | 08장 8.7 |
| 제거로 줄어드는 것 | −30~50KB | 임무 JSON 17개(약 7,000줄), 임무 런타임·스키마·점수·UI 약 2,000줄 — B1-1에서 측정 |
| 새 전장 코드 | 약 9,600줄 → 약 80KB | 이전 초안 비율(1,800줄 ≈ 15KB) |
└ battle-core 청크(sim/battle·infantry·아바타·HUD·UI) |
약 50KB | 첫 출격에 필요 |
└ vehicles 청크(지상 탈것) |
약 12KB | 지상 탈것 첫 탑승 시 |
└ jet 청크 |
약 11KB | 전투기 첫 선택 시 |
└ heli-extra 청크(수송·VPA 조종석) |
약 7KB | 해당 헬기 첫 선택 시 |
- 전장 첫 출격 경로 ≈ 337 − 40 + 50 ≈ 약 347KB, 모든 청크까지 ≈ 약 377KB(추정).
- 제안 예산(추가 결정 A1): 첫 번들 ≤ 210KB / 전장 첫 출격 경로 ≤ 400KB / 모든 청크 ≤ 440KB. 빌드 산출 크기 검사 테스트로 강제(B10-3, 단 B1부터 경고 로그).
- 주의: 쓰지 않는 export도 번들에 남을 수 있다 — 청크 크기는 추정이 아니라
vite build출력으로 매 마일스톤 확인.
9.11 모듈 구조
src/
sim/avatar.ts # Avatar, PlayerBody, spawnAvatar
sim/battle/ # 규칙은 sim과 같다(렌더·DOM·React 금지)
runtime.ts schema.ts terrain.ts(battleTerrain·외곽 공역)
conquest.ts roster.ts spawner.ts pool.ts(탈것 풀)
commander.ts platoon.ts squad.ts(분대원) targeting.ts combat.ts
cargo.ts requests.ts spotting.ts(표식) score.ts jetAi.ts
sim/infantry/ soldier.ts weapons.ts gadgets.ts awareness.ts
sim/vehicles/ ground.ts turret.ts seats.ts
sim/jet/ flight.ts arms.ts
sim/heli/ (기존) + airframeDef.ts
render/battle/ puppets.ts viewmodel.ts flags.ts props.ts jetModels.ts cockpits/(transport·vpa·jet) outerTerrain.ts
ui/battle/ BattleSetup Deploy BattleMap hud/(Tickets·Minimap·Markers·Killfeed·RoleStatus·Coach) Scoreboard BattleReport Radio
input/roles.ts
save/battle.ts
content/battle/ maps/*.json modes.json rosters.json classes.json vehicles.json
content/airframes/*.json
scripts/battle-harness.ts battle-bench.ts battle-map-gen.ts # 빌드 미포함
의존 규칙(08장 8.3 확장):
ui/battle ──→ render/battle ──→ sim/battle ──→ sim/infantry, sim/vehicles, sim/jet, sim/ai, sim/units, sim/los, sim/weapons ──→ core
↑
sim/world.ts ─ (sim/battle import 금지, battleHooks 주입만)
9.12 검증 도구
- 헤드리스 하니스
scripts/battle-harness.ts(node,World직접 스텝):--map --mode --scale --side coalition|veros --seeds 20 --player idle|proxy|popup|hover|soldier.idle: 플레이어 아바타dead고정(스폰 안 함).proxy: 플레이어 슬롯 아파치를 봇 공격 헬기 행동(air.ts) + 통계 사격으로(윙맨 AI가 제거되어air.ts로 대체).popup/hover: 대리 조종사 전술 차이(P2 확인).soldier(B2~): 플레이어 보병을 봇 분대원 행동으로 대리.- 출력: 한 판 길이, 승자, 티켓 추이, 거점 소유 변경 횟수, 대리 기여도, sim 평균·최악 ms.
- 벤치
scripts/battle-bench.ts: 대규모 10분 스텝, 평균·최악 ms, 유닛·인형 수. - 하니스·벤치는 CI에 넣지 않는다(시간·변동). 완료 조건 확인 때 돌린 결과표를 이슈에 붙인다. CI에는 결정론·기능 테스트만.
- 렌더·UI는 헤드리스 스크린샷(08장 8.8 방식:
?debug로 sim을 직접 스텝해 상황을 만든 뒤 찍음). 대규모 장면 스크린샷은 인형 LOD 단계별로.