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/*(m01m12, t1t5), content/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
합계 약 2227ms/s → 60fps에서 프레임당 약 0.40.5ms
  • 예산: 데스크톱 ≤ 4ms/프레임 — 여유 충분(추정). 모바일은 34배 느리다고 보고 프레임당 약 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분 동안 파도 스폰이 계속돼 유닛 약 600700개가 생긴다(추정: 분당 사망 유닛 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 단계별로.