Files
DS-Combat_analy/legacy/분석도구/v2/extract_stalker_data_v2.py
2025-11-05 11:09:16 +09:00

869 lines
34 KiB
Python

#!/usr/bin/env python3
"""
스토커 데이터 통합 추출 스크립트 v2
모든 JSON 소스에서 데이터를 추출하여 중간 데이터 파일 생성
- DT_Skill: 스킬 상세 정보
- DT_CharacterStat/Ability: 스토커 기본 정보
- Blueprint: 스킬 변수 (ActivationOrderGroup 등)
- AnimMontage: 평타/스킬 타이밍, 공격 노티파이
"""
import json
import sys
import re
from pathlib import Path
from typing import Dict, List, Any, Optional
# config 임포트
sys.path.append(str(Path(__file__).parent))
import config
def format_description(desc: str, desc_values: List) -> str:
"""
desc 문자열의 {0}, {1}, {2} 등을 descValues 배열 값으로 치환
Args:
desc: 원본 설명 문자열 (예: "방패를 들어 {0}초 동안 반격 자세를 취합니다. 반격 성공 시 {1}%만큼 물리 피해를 줍니다.")
desc_values: 값 배열 (예: [5, 80])
Returns:
치환된 설명 문자열 (예: "방패를 들어 5초 동안 반격 자세를 취합니다. 반격 성공 시 80%만큼 물리 피해를 줍니다.")
"""
if not desc or not desc_values:
return desc
# {0}, {1}, {2} 등을 descValues로 치환
result = desc
for i, value in enumerate(desc_values):
placeholder = f"{{{i}}}"
result = result.replace(placeholder, str(value))
# 줄바꿈 제거 (마크다운 호환성)
result = result.replace('\n', ' ').replace('\r', ' ')
return result
def load_json(file_path: Path) -> Dict:
"""JSON 파일 로드"""
print(f"Loading: {file_path.name}")
with open(file_path, 'r', encoding='utf-8') as f:
data = json.load(f)
return data
def find_table(datatables: List[Dict], table_name: str) -> Optional[Dict]:
"""DataTable.json에서 특정 테이블 찾기"""
for dt in datatables:
if dt.get('AssetName') == table_name:
return dt
return None
def find_asset_by_name(assets: List[Dict], name_pattern: str) -> List[Dict]:
"""에셋 이름 패턴으로 검색"""
return [a for a in assets if name_pattern in a.get('AssetName', '')]
def extract_character_stats(datatables: List[Dict]) -> Dict[str, Dict]:
"""
DT_CharacterStat에서 스토커 기본 정보 추출
Returns:
{stalker_id: {name, job, stats, skills, ...}}
"""
print("\n=== DT_CharacterStat 추출 ===")
char_stat_table = find_table(datatables, 'DT_CharacterStat')
if not char_stat_table:
print("[WARN] DT_CharacterStat 테이블을 찾을 수 없습니다.")
return {}
stalker_data = {}
for row in char_stat_table.get('Rows', []):
row_name = row['RowName']
if row_name not in config.STALKERS:
continue
data = row['Data']
# 스토커 이름 포맷: "English (Korean)"
korean_name = data.get('name', '')
info = config.STALKER_INFO.get(row_name, {})
english_name = info.get('english', row_name.capitalize())
formatted_name = f"{english_name} ({korean_name})" if korean_name else english_name
stalker_data[row_name] = {
'id': row_name,
'name': formatted_name, # 영문(한글) 형식
'koreanName': korean_name, # 순수 한글 이름
'englishName': english_name, # 순수 영문 이름
'jobName': data.get('jobName', ''),
'stats': {
'str': data.get('str', 0),
'dex': data.get('dex', 0),
'int': data.get('int', 0),
'con': data.get('con', 0),
'wis': data.get('wis', 0)
},
'hp': data.get('hP', 0),
'mp': data.get('mP', 0),
'manaRegen': round(data.get('manaRegen', 0), 2), # 소수점 2자리
'physicalDamage': data.get('physicalDamage', 0),
'magicalDamage': data.get('magicalDamage', 0),
'criticalPer': data.get('criticalPer', 5), # 크리티컬 확률
'criticalDamage': data.get('criticalDamage', 0), # 크리티컬 추가 피해
'defaultSkills': data.get('defaultSkills', []),
'subSkill': data.get('subSkill', ''),
'ultimateSkill': data.get('ultimateSkill', ''),
'equipableTypes': data.get('equipableTypes', []),
'ultimatePoint': data.get('ultimatePoint', 0),
'source': 'DT_CharacterStat'
}
print(f" [OK] {stalker_data[row_name]['name']} ({row_name})")
return stalker_data
def extract_character_abilities(datatables: List[Dict]) -> Dict[str, Dict]:
"""
DT_CharacterAbility에서 평타 몽타주 정보 추출
Returns:
{stalker_id: {attackMontageMap, abilities}}
"""
print("\n=== DT_CharacterAbility 추출 ===")
char_ability_table = find_table(datatables, 'DT_CharacterAbility')
if not char_ability_table:
print("⚠️ DT_CharacterAbility 테이블을 찾을 수 없습니다.")
return {}
stalker_abilities = {}
for row in char_ability_table.get('Rows', []):
row_name = row['RowName']
if row_name not in config.STALKERS:
continue
data = row['Data']
stalker_abilities[row_name] = {
'attackMontageMap': data.get('attackMontageMap', {}),
'abilities': data.get('abilities', []),
'source': 'DT_CharacterAbility'
}
# 평타 콤보 수 계산
combo_counts = {}
for weapon_type, montage_data in stalker_abilities[row_name]['attackMontageMap'].items():
montage_array = montage_data.get('montageArray', [])
combo_counts[weapon_type] = len(montage_array)
print(f" [OK] {row_name}: {combo_counts}")
return stalker_abilities
def extract_skills(datatables: List[Dict]) -> Dict[str, Dict]:
"""
DT_Skill에서 모든 스킬 정보 추출
Returns:
{skill_id: {skill data}}
"""
print("\n=== DT_Skill 추출 ===")
skill_table = find_table(datatables, 'DT_Skill')
if not skill_table:
print("⚠️ DT_Skill 테이블을 찾을 수 없습니다.")
return {}
all_skills = {}
for row in skill_table.get('Rows', []):
skill_id = row['RowName']
data = row['Data']
# 스토커 스킬만 추출
stalker_name = data.get('stalkerName', '')
if stalker_name not in config.STALKERS:
continue
# 설명 처리: desc에서 {0}, {1} 등을 descValues로 치환
desc_raw = data.get('desc', '')
desc_values_raw = data.get('descValues', [])
# descValues의 float 값을 소수점 둘째자리로 반올림
desc_values = []
for val in desc_values_raw:
if isinstance(val, float):
desc_values.append(round(val, 2))
else:
desc_values.append(val)
desc_formatted = format_description(desc_raw, desc_values)
all_skills[skill_id] = {
'skillId': skill_id,
'stalkerName': stalker_name,
'name': data.get('name', ''),
'desc': desc_raw, # 원본 desc (변수 포함)
'descFormatted': desc_formatted, # 변수 치환된 desc
'descValues': desc_values, # descValues 배열
'simpleDesc': data.get('simpleDesc', ''),
'bIsUltimate': data.get('bIsUltimate', False),
'bIsStackable': data.get('bIsStackable', False),
'maxStackCount': data.get('maxStackCount', 0),
'skillDamageRate': data.get('skillDamageRate', 0),
'skillAttackType': data.get('skillAttackType', ''),
'skillElementType': data.get('skillElementType', ''),
'manaCost': data.get('manaCost', 0),
'coolTime': data.get('coolTime', 0),
'castingTime': data.get('castingTime', 0),
'activeDuration': data.get('activeDuration', 0), # 소환수 지속시간
'activeRange': data.get('activeRange', {}), # tick, count, dist 등
'useMontages': data.get('useMontages', []),
'gameplayEffectSet': data.get('gameplayEffectSet', []),
'abilityClass': data.get('abilityClass', ''),
'icon': data.get('icon', ''),
'bUsable': data.get('bUsable', False),
'bUnSelectable': data.get('bUnSelectable', False),
'source': 'DT_Skill'
}
print(f" [OK] 총 {len(all_skills)}개 스킬 추출")
# 스토커별 카운트
stalker_counts = {}
for skill in all_skills.values():
stalker = skill['stalkerName']
stalker_counts[stalker] = stalker_counts.get(stalker, 0) + 1
for stalker_id in config.STALKERS:
count = stalker_counts.get(stalker_id, 0)
print(f" - {stalker_id}: {count}")
return all_skills
def extract_skill_blueprints(blueprints: List[Dict]) -> Dict[str, Dict]:
"""
Blueprint.json에서 GA_Skill_ 블루프린트의 변수 추출
Returns:
{blueprint_name: {variables}}
"""
print("\n=== GA_Skill Blueprint 추출 ===")
skill_blueprints = {}
ga_skills = [bp for bp in blueprints if 'GA_Skill' in bp.get('AssetName', '')]
for bp in ga_skills:
asset_name = bp['AssetName']
variables = {}
for var in bp.get('Variables', []):
var_name = var.get('Name', '')
variables[var_name] = {
'name': var_name,
'type': var.get('Type', var.get('Category', 'unknown')),
'defaultValue': var.get('DefaultValue', 'N/A'),
'source': var.get('Source', 'Blueprint'),
'category': var.get('CategoryName', ''),
'isEditable': var.get('IsEditable', False),
'isBlueprintVisible': var.get('IsBlueprintVisible', False)
}
skill_blueprints[asset_name] = {
'assetName': asset_name,
'assetPath': bp.get('AssetPath', ''),
'parentClass': bp.get('ParentClass', ''),
'variables': variables,
'source': 'Blueprint'
}
print(f" [OK] 총 {len(skill_blueprints)}개 GA_Skill Blueprint 추출")
return skill_blueprints
def extract_anim_montages(montages: List[Dict]) -> Dict[str, Dict]:
"""
AnimMontage.json에서 몽타주 타이밍 및 노티파이 추출
- AddNormalAttackPer 추출 (ANS_AttackState_C 노티파이)
- attackStateEndTime 추출 (ANS_AttackState_C 종료 시점)
Returns:
{montage_name: {timing, notifies, attackMultiplier, attackStateEndTime}}
"""
print("\n=== AnimMontage 추출 ===")
all_montages = {}
pc_montages = [m for m in montages if 'AM_PC_' in m.get('AssetName', '') or 'AM_Sum_' in m.get('AssetName', '')]
for montage in pc_montages:
asset_name = montage['AssetName']
# 공격 노티파이 추출
attack_notifies = []
attack_multiplier = 0.0 # AddNormalAttackPer (기본값 0)
attack_state_end_time = None # ANS_AttackState 종료 시점 (평타용)
for notify in montage.get('AnimNotifies', []):
notify_class = notify.get('NotifyClass', '')
notify_state_class = notify.get('NotifyStateClass', '')
notify_name = notify.get('NotifyName', '')
custom_props = notify.get('CustomProperties', {})
# ANS_AttackState_C에서 AddNormalAttackPer 및 종료 시점 추출
if 'ANS_AttackState' in notify_state_class:
add_normal_attack_str = custom_props.get('AddNormalAttackPer', '0')
try:
attack_multiplier = float(add_normal_attack_str)
except (ValueError, TypeError):
attack_multiplier = 0.0
# ANS_AttackState 종료 시점 = TriggerTime + Duration
trigger_time = notify.get('TriggerTime', 0)
duration = notify.get('Duration', 0)
attack_state_end_time = trigger_time + duration
# 공격 판정 로직 (우선순위)
is_attack_notify = False
# 1. NotifyName에 키워드 포함 (부분 매칭)
if any(keyword in notify_name for keyword in config.ATTACK_NOTIFY_KEYWORDS):
is_attack_notify = True
# 2. CustomProperties의 NotifyName 확인 - 1순위 실패 시
if not is_attack_notify:
custom_notify_name = custom_props.get('NotifyName', '')
if custom_notify_name and any(keyword in custom_notify_name for keyword in config.ATTACK_NOTIFY_KEYWORDS):
is_attack_notify = True
# 3. NotifyClass에 키워드 포함 (부분 매칭) - 1, 2순위 실패 시
if not is_attack_notify:
if any(keyword in notify_class for keyword in config.ATTACK_NOTIFY_KEYWORDS):
is_attack_notify = True
# 4. SimpleSendEvent의 Event Tag 확인 (1, 2, 3순위 실패 시)
if not is_attack_notify and 'SimpleSendEvent' in notify_class:
event_tag = custom_props.get('Event Tag', '')
if any(attack_tag in event_tag for attack_tag in config.ATTACK_EVENT_TAGS):
is_attack_notify = True
if is_attack_notify:
attack_notifies.append({
'notifyName': notify_name,
'notifyClass': notify_class,
'notifyStateClass': notify_state_class,
'triggerTime': notify.get('TriggerTime', 0),
'duration': notify.get('Duration', 0),
'notifyType': notify.get('NotifyType', ''),
'customProperties': custom_props
})
# 시퀀스 길이 = SequenceLength / RateScale (actualDuration)
seq_len = montage.get('SequenceLength', 0)
rate_scale = montage.get('RateScale', 1.0)
actual_duration = seq_len / rate_scale if rate_scale > 0 else seq_len
all_montages[asset_name] = {
'assetName': asset_name,
'assetPath': montage.get('AssetPath', ''),
'sequenceLength': seq_len,
'rateScale': rate_scale,
'actualDuration': actual_duration, # 시퀀스 길이 (SequenceLength / RateScale)
'attackStateEndTime': attack_state_end_time, # ANS_AttackState 종료 시점 (평타용)
'attackMultiplier': attack_multiplier, # AddNormalAttackPer
'sections': montage.get('Sections', []),
'numSections': montage.get('NumSections', 0),
'allNotifies': montage.get('AnimNotifies', []),
'attackNotifies': attack_notifies,
'hasAttack': len(attack_notifies) > 0,
'blendInTime': montage.get('BlendInTime', 0),
'blendOutTime': montage.get('BlendOutTime', 0),
'source': 'AnimMontage'
}
print(f" [OK] 총 {len(all_montages)}개 몽타주 추출 (PC + Summon)")
# 소환수 몽타주 확인
summon_montages = [m for m in all_montages.keys() if 'Summon' in m or 'Sum_' in m]
if summon_montages:
print(f" [INFO] 소환수 관련 몽타주: {len(summon_montages)}")
for sm in summon_montages:
seq_len = all_montages[sm]['sequenceLength']
actual_dur = all_montages[sm]['actualDuration']
has_attack = all_montages[sm]['hasAttack']
print(f" - {sm}: {seq_len:.2f}초 (실제: {actual_dur:.2f}초), 공격={has_attack}")
return all_montages
def extract_npc_abilities(datatables: List[Dict]) -> Dict[str, Dict]:
"""
DT_NPCAbility에서 소환수(Ifrit, Shiva) 정보 추출
Returns:
{npc_name: {attackMontageMap}}
"""
print("\n=== DT_NPCAbility 추출 ===")
npc_ability_table = find_table(datatables, 'DT_NPCAbility')
if not npc_ability_table:
print("[WARN] DT_NPCAbility 테이블을 찾을 수 없습니다.")
return {}
npc_abilities = {}
summon_names = ['ifrit', 'shiva'] # Rene의 소환수
for row in npc_ability_table.get('Rows', []):
row_name = row['RowName'].lower()
if row_name not in summon_names:
continue
data = row['Data']
attack_map = data.get('attackMontageMap', {})
npc_abilities[row_name] = {
'npcName': row['RowName'],
'attackMontageMap': attack_map,
'source': 'DT_NPCAbility'
}
# 몽타주 개수 출력
for weapon_type, montage_data in attack_map.items():
montage_array = montage_data.get('montageArray', [])
print(f" [OK] {row['RowName']} ({weapon_type}): {len(montage_array)}개 몽타주")
for i, montage_path in enumerate(montage_array):
montage_name = montage_path.split('/')[-1].replace("'", "").split('.')[0]
print(f" {i+1}. {montage_name}")
return npc_abilities
def extract_runes(datatables: List[Dict]) -> Dict[str, Dict]:
"""
DT_Rune에서 룬 데이터 추출
Returns:
{runeId: {runeSet, level, name, desc, attributeModifies, ...}}
"""
print("\n=== DT_Rune 추출 ===")
rune_table = find_table(datatables, 'DT_Rune')
if not rune_table:
print("[WARN] DT_Rune 테이블을 찾을 수 없습니다.")
return {}
runes = {}
for row in rune_table.get('Rows', []):
rune_id = row['RowName']
data = row['Data']
# attributeModifies 파싱
attr_modifies = []
for mod in data.get('attributeModifies', []):
attr = mod.get('attribute', {})
attr_modifies.append({
'attributeName': attr.get('attributeName', ''),
'value': mod.get('value', 0)
})
runes[rune_id] = {
'runeId': rune_id,
'runeSet': data.get('runeSet', ''),
'level': data.get('level', 1),
'name': data.get('runeName', ''),
'desc': format_description(data.get('desc', ''), data.get('descValue', [])),
'descValue': data.get('descValue', []),
'attributeModifies': attr_modifies,
'unlockGold': data.get('unlockGold', 0),
'unlockSkillPoint': data.get('unlockSkillPoint', 0)
}
print(f" [OK] {len(runes)}개 룬 데이터 추출 완료")
return runes
def extract_rune_groups(datatables: List[Dict]) -> Dict[str, Dict]:
"""
DT_RuneGroup에서 룬 그룹 데이터 추출
Returns:
{groupId: {name, type, coreLine, sub1Line, sub2Line}}
"""
print("\n=== DT_RuneGroup 추출 ===")
rune_group_table = find_table(datatables, 'DT_RuneGroup')
if not rune_group_table:
print("[WARN] DT_RuneGroup 테이블을 찾을 수 없습니다.")
return {}
groups = {}
for row in rune_group_table.get('Rows', []):
group_id = row['RowName']
data = row['Data']
groups[group_id] = {
'groupId': group_id,
'name': data.get('name', ''),
'type': data.get('type', ''),
'coreLine': data.get('coreLine', []),
'sub1Line': data.get('sub1Line', []),
'sub2Line': data.get('sub2Line', [])
}
print(f" [OK] {data.get('name', group_id)}: Core({len(data.get('coreLine', []))}), Sub1({len(data.get('sub1Line', []))}), Sub2({len(data.get('sub2Line', []))})")
return groups
def extract_equipment(datatables: List[Dict]) -> Dict[str, Dict]:
"""
DT_Equip에서 장비 데이터 추출
Returns:
{equipId: {name, equipSlotType, equipType, rarity, stats, ...}}
"""
print("\n=== DT_Equip 추출 ===")
equip_table = find_table(datatables, 'DT_Equip')
if not equip_table:
print("[WARN] DT_Equip 테이블을 찾을 수 없습니다.")
return {}
equipment = {}
for row in equip_table.get('Rows', []):
equip_id = row['RowName']
data = row['Data']
# stats 파싱
stats = []
for stat in data.get('stats', []):
attr = stat.get('attribute', {})
stats.append({
'attributeName': attr.get('attributeName', ''),
'value': stat.get('value', 0),
'visible': stat.get('visible', False)
})
equipment[equip_id] = {
'equipId': equip_id,
'name': data.get('name', ''),
'desc': data.get('desc', ''),
'equipSlotType': data.get('equipSlotType', ''),
'equipType': data.get('equipType', ''),
'rarity': data.get('rarity', ''),
'price': data.get('price', 0),
'sellPrice': data.get('sellPrice', 0),
'stats': stats,
'armor': data.get('armor', 0)
}
print(f" [OK] {len(equipment)}개 장비 데이터 추출 완료")
return equipment
def extract_float_constants(datatables: List[Dict]) -> Dict[str, float]:
"""
DT_Float에서 기어스코어 공식 상수 추출
Returns:
{constantName: value}
"""
print("\n=== DT_Float (기어스코어 상수) 추출 ===")
float_table = find_table(datatables, 'DT_Float')
if not float_table:
print("[WARN] DT_Float 테이블을 찾을 수 없습니다.")
return {}
constants = {}
gearscore_keys = [
'GearScoreEquipCommon',
'GearScoreEquipUncommon',
'GearScoreEquipRare',
'GearScoreEquipLegendary',
'GearScoreSkillPassive',
'GearScoreSkillPerk'
]
for row in float_table.get('Rows', []):
row_name = row['RowName']
if row_name in gearscore_keys:
constants[row_name] = row['Data'].get('value', 0)
print(f" [OK] {row_name}: {constants[row_name]}")
return constants
def organize_stalker_data(
stalker_stats: Dict,
stalker_abilities: Dict,
all_skills: Dict,
skill_blueprints: Dict,
anim_montages: Dict,
npc_abilities: Dict,
runes: Dict,
rune_groups: Dict,
equipment: Dict,
float_constants: Dict
) -> Dict[str, Dict]:
"""
스토커별로 모든 데이터를 통합 정리
Returns:
{stalker_id: {모든 데이터}}
"""
print("\n=== 스토커별 데이터 통합 ===")
organized = {}
for stalker_id in config.STALKERS:
if stalker_id not in stalker_stats:
print(f" [WARN] {stalker_id}: 기본 스탯 없음")
continue
stats = stalker_stats[stalker_id]
abilities = stalker_abilities.get(stalker_id, {})
# 스토커의 스킬 목록
skill_ids = stats['defaultSkills'] + [stats['subSkill'], stats['ultimateSkill']]
skill_ids = [sid for sid in skill_ids if sid] # 빈 문자열 제거
# 스킬 상세 정보
skills = {}
for skill_id in skill_ids:
if skill_id not in all_skills:
print(f" [WARN] {stalker_id}: 스킬 {skill_id} 정보 없음")
continue
skill_data = all_skills[skill_id].copy()
# Blueprint 정보 매칭
ability_class = skill_data.get('abilityClass', '')
if ability_class:
# '/Game/Blueprints/Abilities/GA_Skill_XXX.GA_Skill_XXX_C' -> 'GA_Skill_XXX'
bp_name = ability_class.split('/')[-1].split('.')[0]
if bp_name in skill_blueprints:
skill_data['blueprintVariables'] = skill_blueprints[bp_name]['variables']
else:
skill_data['blueprintVariables'] = {}
# AnimMontage 정보 매칭
use_montages = skill_data.get('useMontages', [])
skill_data['montageData'] = []
for montage_path in use_montages:
# '/Script/Engine.AnimMontage'/Game/_Art/.../ AM_XXX.AM_XXX' -> 'AM_XXX'
montage_name = montage_path.split('/')[-1].replace("'", "").split('.')[0]
if montage_name in anim_montages:
skill_data['montageData'].append(anim_montages[montage_name])
else:
print(f" [WARN] {skill_id}: 몽타주 {montage_name} 정보 없음")
# 바란 궁극기 특수 처리: AN_SimpleSendEvent 시점을 castingTime으로 사용
if skill_id == 'SK130301': # 바란 궁극기 '일격분쇄'
for montage_data in skill_data['montageData']:
for notify in montage_data.get('allNotifies', []):
if 'SimpleSendEvent' in notify.get('NotifyClass', ''):
event_tag = notify.get('CustomProperties', {}).get('Event Tag', '')
if 'Ability.Attack.Ready' in event_tag:
trigger_time = notify.get('TriggerTime', 0)
skill_data['castingTime'] = round(trigger_time, 2)
print(f" [INFO] {skill_id}: castingTime 오버라이드 {skill_data['castingTime']}초 (AN_SimpleSendEvent)")
break
# DoT 스킬 체크
skill_data['isDot'] = skill_id in config.DOT_SKILLS
# 소환수 스킬 체크 (유틸리티 판별보다 먼저 설정)
skill_data['isSummon'] = skill_id in config.SUMMON_SKILLS
if skill_data['isSummon']:
summon_info = config.SUMMON_SKILLS.get(skill_id, {})
summon_type = summon_info.get('type', 'npc')
if summon_type == 'special':
# Shiva 특수 처리: 직접 몽타주 지정
skill_data['summonMontageData'] = []
montage_name = summon_info.get('montage')
attack_interval_bonus = summon_info.get('attack_interval_bonus', 0)
if montage_name and montage_name in anim_montages:
montage_info = anim_montages[montage_name].copy()
# 공격 주기 = 실제 시간 + 보너스
original_duration = montage_info['actualDuration']
montage_info['attackInterval'] = original_duration + attack_interval_bonus
skill_data['summonMontageData'].append(montage_info)
skill_data['summonType'] = 'special'
else:
# Ifrit 등: DT_NPCAbility에서 추출
summon_name = summon_info.get('summon', '').lower()
if summon_name in npc_abilities:
npc_data = npc_abilities[summon_name]
attack_map = npc_data.get('attackMontageMap', {})
skill_data['summonAttackMap'] = attack_map
# 소환수 몽타주 데이터 추가
skill_data['summonMontageData'] = []
for weapon_type, montage_data in attack_map.items():
montage_array = montage_data.get('montageArray', [])
for montage_path in montage_array:
montage_name = montage_path.split('/')[-1].replace("'", "").split('.')[0]
if montage_name in anim_montages:
skill_data['summonMontageData'].append(anim_montages[montage_name])
skill_data['summonType'] = 'npc'
# 유틸리티 스킬 판별 (isSummon 설정 이후에 실행)
skill_data['isUtility'] = is_utility_skill(skill_data)
skills[skill_id] = skill_data
# 평타 몽타주 상세 정보 매칭
attack_montage_map = abilities.get('attackMontageMap', {})
basic_attacks = {}
for weapon_type, montage_data in attack_montage_map.items():
montage_array = montage_data.get('montageArray', [])
basic_attacks[weapon_type] = []
for idx, montage_path in enumerate(montage_array):
montage_name = montage_path.split('/')[-1].replace("'", "").split('.')[0]
if montage_name in anim_montages:
montage_info = anim_montages[montage_name]
# 평타는 ANS_AttackState 종료 시점을 우선 사용
# 없으면 actualDuration 폴백
attack_state_end = montage_info.get('attackStateEndTime')
effective_duration = attack_state_end if attack_state_end is not None else montage_info['actualDuration']
basic_attacks[weapon_type].append({
'index': idx + 1,
'montageName': montage_name,
'sequenceLength': montage_info['sequenceLength'],
'rateScale': montage_info['rateScale'],
'actualDuration': montage_info['actualDuration'], # 원본 몽타주 시간
'attackStateEndTime': attack_state_end, # ANS_AttackState 종료 시점
'effectiveDuration': effective_duration, # 실제 평타 시간 (ANS_AttackState 우선)
'attackMultiplier': montage_info['attackMultiplier'],
'hasAttack': montage_info['hasAttack']
})
# 소환체 데이터 생성 (레네만)
summons = {}
for skill_id, skill_data in skills.items():
if skill_data.get('isSummon'):
summon_config = config.SUMMON_SKILLS.get(skill_id, {})
summon_name = summon_config.get('summon', 'Unknown')
# 공격 몽타주 정보 추출
attack_montages = []
for montage_data in skill_data.get('summonMontageData', []):
attack_montages.append({
'montageName': montage_data.get('assetName', 'N/A'),
'actualDuration': montage_data.get('actualDuration', 0)
})
summons[summon_name] = {
'summonSkillId': skill_id,
'summonSkillName': skill_data.get('name', ''),
'activeDuration': skill_data.get('activeDuration', 0),
'skillDamageRate': skill_data.get('skillDamageRate', 0), # 피해 배율 추가
'attackMontages': attack_montages,
'dotType': config.DOT_SKILLS.get(skill_id, {}).get('dot_type', '')
}
organized[stalker_id] = {
'id': stalker_id,
'stats': stats,
'abilities': abilities,
'basicAttacks': basic_attacks, # 평타 상세 정보
'skills': skills,
'defaultSkills': [skills.get(sid) for sid in stats['defaultSkills'] if sid in skills],
'subSkill': skills.get(stats['subSkill']),
'ultimateSkill': skills.get(stats['ultimateSkill']),
'summons': summons # 소환체 정보
}
skill_count = len(skills)
print(f" [OK] {stats['name']} ({stalker_id}): {skill_count}개 스킬")
# 공통 데이터 추가
organized['_metadata'] = {
'runes': runes,
'runeGroups': rune_groups,
'equipment': equipment,
'gearScoreConstants': float_constants
}
return organized
def is_utility_skill(skill_data: Dict) -> bool:
"""
유틸리티 스킬 판별 (DPS 계산 제외 대상)
판별 기준:
1. config.UTILITY_SKILLS에 명시적으로 등록
2. skillAttackType == "Normal" AND skillDamageRate == 0
3. 몽타주에 공격 노티파이 없음 (montageData 확인)
예외: 소환 스킬은 항상 공격 스킬로 간주
"""
skill_id = skill_data['skillId']
# 소환 스킬은 공격 스킬 (유틸리티 아님)
if skill_data.get('isSummon', False):
return False
# 1. 수동 지정
if skill_id in config.UTILITY_SKILLS:
return True
# 2. Normal 타입 + Rate 0
if skill_data['skillAttackType'] == 'Normal' and skill_data['skillDamageRate'] == 0:
return True
# 3. 몽타주에 공격 노티파이 없음
montage_data_list = skill_data.get('montageData', [])
if montage_data_list:
has_attack = any(m.get('hasAttack', False) for m in montage_data_list)
if not has_attack and skill_data['skillDamageRate'] > 0:
# Rate는 있지만 공격 노티파이 없음 -> 유틸리티
return True
return False
def main():
"""메인 실행 함수"""
print("="*80)
print("스토커 데이터 통합 추출 v2")
print("="*80)
# 1. JSON 파일 로드
print("\n[ JSON 파일 로드 ]")
datatable_data = load_json(config.DATATABLE_JSON)
blueprint_data = load_json(config.BLUEPRINT_JSON)
animmontage_data = load_json(config.ANIMMONTAGE_JSON)
datatables = datatable_data.get('Assets', [])
blueprints = blueprint_data.get('Assets', [])
montages = animmontage_data.get('Assets', [])
# 2. 데이터 추출
stalker_stats = extract_character_stats(datatables)
stalker_abilities = extract_character_abilities(datatables)
all_skills = extract_skills(datatables)
skill_blueprints = extract_skill_blueprints(blueprints)
anim_montages = extract_anim_montages(montages)
npc_abilities = extract_npc_abilities(datatables) # 소환수 데이터
runes = extract_runes(datatables) # 룬 데이터
rune_groups = extract_rune_groups(datatables) # 룬 그룹 데이터
equipment = extract_equipment(datatables) # 장비 데이터
float_constants = extract_float_constants(datatables) # 기어스코어 상수
# 3. 데이터 통합
organized_data = organize_stalker_data(
stalker_stats,
stalker_abilities,
all_skills,
skill_blueprints,
anim_montages,
npc_abilities,
runes,
rune_groups,
equipment,
float_constants
)
# 4. 결과 저장 (새 디렉토리 생성)
output_dir = config.get_output_dir(create_new=True)
output_dir.mkdir(parents=True, exist_ok=True)
output_file = output_dir / "intermediate_data.json"
with open(output_file, 'w', encoding='utf-8') as f:
json.dump(organized_data, f, ensure_ascii=False, indent=2)
print(f"\n[OK] 중간 데이터 저장 완료: {output_file}")
print(f" - 출력 디렉토리: {output_dir}")
print(f" - 총 {len(organized_data)}명 스토커 데이터")
return organized_data
if __name__ == "__main__":
main()