# -*- coding: utf-8 -*- """ DataTable 및 AnimMontage 데이터 추출 모듈 """ import logging from typing import Dict, List, Optional, Any from custom_property_parser import extract_notify_properties, parse_custom_property logger = logging.getLogger(__name__) # 수집 대상 스토커 목록 (소문자) TARGET_STALKERS = [ 'hilda', 'urud', 'nave', 'baran', 'rio', 'clad', 'rene', 'sinobu', 'lian', 'cazimord', 'blackmaria' ] def round_float(value: Any) -> Any: """소수점 2자리로 반올림 (float만 처리)""" if isinstance(value, (int, float)): return round(float(value), 2) return value def extract_character_stat(data_table_assets: List[Dict], stalker_name: str) -> Dict: """ DT_CharacterStat에서 스토커의 기본 스탯 추출 Args: data_table_assets: DataTable.json의 Assets 배열 stalker_name: 스토커 이름 (소문자) Returns: 스탯 딕셔너리 """ # DT_CharacterStat 찾기 dt_char_stat = None for asset in data_table_assets: if asset.get('AssetName') == 'DT_CharacterStat': dt_char_stat = asset break if not dt_char_stat: logger.warning("DT_CharacterStat not found") return {} # 해당 스토커 행 찾기 rows = dt_char_stat.get('Rows', []) stalker_row = None for row in rows: if row.get('RowName', '').lower() == stalker_name.lower(): stalker_row = row break if not stalker_row: logger.warning(f"Stalker '{stalker_name}' not found in DT_CharacterStat") return {} data = stalker_row.get('Data', {}) # 모든 숫자 필드를 소수점 2자리로 반올림 stats = { 'name': data.get('name', ''), 'jobName': data.get('jobName', ''), # 기본 스탯 'str': round_float(data.get('str', 0)), 'dex': round_float(data.get('dex', 0)), 'int': round_float(data.get('int', 0)), 'con': round_float(data.get('con', 0)), 'wis': round_float(data.get('wis', 0)), # 체력/마나 'hp': round_float(data.get('hP', 0)), 'mp': round_float(data.get('mP', 0)), 'manaRegen': round_float(data.get('manaRegen', 0)), 'stamina': round_float(data.get('stamina', 0)), # 공격 'physicalDamage': round_float(data.get('physicalDamage', 0)), 'magicalDamage': round_float(data.get('magicalDamage', 0)), 'criticalPer': round_float(data.get('criticalPer', 0)), 'criticalDamage': round_float(data.get('criticalDamage', 0)), 'backAttackDamage': round_float(data.get('backAttackDamage', 0)), # 방어 'defense': round_float(data.get('defense', 0)), 'physicalResistancePer': round_float(data.get('physicalResistancePer', 0)), 'rangedResistancePer': round_float(data.get('rangedResistancePer', 0)), 'magicalResistancePer': round_float(data.get('magicalResistancePer', 0)), # 속성 저항 'fireResistancePer': round_float(data.get('fireResistancePer', 0)), 'poisonResistancePer': round_float(data.get('poisonResistancePer', 0)), 'waterResistancePer': round_float(data.get('waterResistancePer', 0)), 'lightningResistancePer': round_float(data.get('lightningResistancePer', 0)), 'holyResistancePer': round_float(data.get('holyResistancePer', 0)), 'darkResistancePer': round_float(data.get('darkResistancePer', 0)), 'dotReduceRatePer': round_float(data.get('dOTReduceRatePer', 0)), # 이동 'walkSpeed': round_float(data.get('walkSpeed', 0)), # 스킬 'defaultSkills': data.get('defaultSkills', []), 'subSkill': data.get('subSkill', ''), 'ultimateSkill': data.get('ultimateSkill', ''), 'ultimatePoint': data.get('ultimatePoint', 0), # 장비 'equipableTypes': data.get('equipableTypes', []), # 기타 'hitRadius': round_float(data.get('hitRadius', 0)) } return stats def extract_attack_montages(data_table_assets: List[Dict], stalker_name: str) -> Dict[str, List[str]]: """ DT_CharacterAbility에서 기본 공격 몽타주 추출 Args: data_table_assets: DataTable.json의 Assets 배열 stalker_name: 스토커 이름 (소문자) Returns: 무기타입별 몽타주 경로 리스트 딕셔너리 """ # DT_CharacterAbility 찾기 dt_char_ability = None for asset in data_table_assets: if asset.get('AssetName') == 'DT_CharacterAbility': dt_char_ability = asset break if not dt_char_ability: logger.warning("DT_CharacterAbility not found") return {} # 해당 스토커 행 찾기 rows = dt_char_ability.get('Rows', []) stalker_row = None for row in rows: if row.get('RowName', '').lower() == stalker_name.lower(): stalker_row = row break if not stalker_row: logger.warning(f"Stalker '{stalker_name}' not found in DT_CharacterAbility") return {} data = stalker_row.get('Data', {}) attack_montage_map = data.get('attackMontageMap', {}) # 무기타입별 몽타주 배열 추출 result = {} for weapon_type, weapon_data in attack_montage_map.items(): if isinstance(weapon_data, dict): montage_array = weapon_data.get('montageArray', []) if montage_array: result[weapon_type] = montage_array return result def should_exclude_skill_montage(montage_path: str) -> bool: """ 스킬 몽타주 제외 규칙 적용 제외 키워드: ready, Equip, Equipment, _E """ if not montage_path: return True exclude_keywords = ['ready', 'Equip', 'Equipment', '_E'] montage_name = montage_path.split('/')[-1].split('.')[0] for keyword in exclude_keywords: if keyword in montage_name: return True return False def extract_skill_data(data_table_assets: List[Dict], skill_id: str) -> Optional[Dict]: """ DT_Skill에서 스킬 데이터 추출 Args: data_table_assets: DataTable.json의 Assets 배열 skill_id: 스킬 ID (예: SK100201) Returns: 스킬 데이터 딕셔너리 """ # DT_Skill 찾기 dt_skill = None for asset in data_table_assets: if asset.get('AssetName') == 'DT_Skill': dt_skill = asset break if not dt_skill: logger.warning("DT_Skill not found") return None # 해당 스킬 행 찾기 rows = dt_skill.get('Rows', []) skill_row = None for row in rows: if row.get('RowName') == skill_id: skill_row = row break if not skill_row: logger.warning(f"Skill '{skill_id}' not found in DT_Skill") return None data = skill_row.get('Data', {}) # 스킬 몽타주 필터링 (제외 규칙 적용) use_montages = data.get('useMontages', []) filtered_montages = [m for m in use_montages if not should_exclude_skill_montage(m)] # GameplayEffect 정보 추출 (trigger와 gEClass만) gameplay_effects = [] for ge in data.get('gameplayEffectSet', []): if isinstance(ge, dict): gameplay_effects.append({ 'trigger': ge.get('trigger', ''), 'gEClass': ge.get('gEClass', '') }) skill_data = { 'skillId': skill_id, 'name': data.get('name', ''), 'desc': data.get('desc', ''), 'descValues': data.get('descValues', []), 'simpleDesc': data.get('simpleDesc', ''), 'skillAttackType': data.get('skillAttackType', ''), 'skillElementType': data.get('skillElementType', ''), 'skillDamageRate': data.get('skillDamageRate', 1.0), 'walkSpeedMultiplier': data.get('walkSpeedMultiplier', 0), 'castingTime': data.get('castingTime', 0), 'manaCost': data.get('manaCost', 0), 'coolTime': data.get('coolTime', 0), 'useMontages': filtered_montages, 'bIsStackable': data.get('bIsStackable', False), 'maxStackCount': data.get('maxStackCount', 0), 'bIsUltimate': data.get('bIsUltimate', False), 'abilityClass': data.get('abilityClass', ''), 'activeAbilityClass': data.get('activeAbilityClass', ''), 'activeDuration': data.get('activeDuration', 0), 'gameplayEffectSet': gameplay_effects } return skill_data def extract_montage_asset_name(montage_path: str) -> str: """ 몽타주 경로에서 에셋 이름 추출 Example: '/Script/Engine.AnimMontage'/Game/.../AM_PC_Hilda_B_Attack_W01_01.AM_PC_Hilda_B_Attack_W01_01'' -> 'AM_PC_Hilda_B_Attack_W01_01' """ if not montage_path: return '' # 마지막 .과 마지막 ' 사이의 문자열 추출 parts = montage_path.split('.') if len(parts) >= 2: asset_name = parts[-1].rstrip("'") return asset_name return montage_path def extract_anim_notifies(montage_data: Dict) -> List[Dict]: """ AnimMontage에서 주요 AnimNotifies 추출 수집 대상: - ANS_AttackState_C - AnimNotifyState_AttackWithEquip - ANS_SkillCancel_C - AN_Trigger_Projectile_Shot_C Args: montage_data: AnimMontage 에셋 딕셔너리 Returns: 추출된 AnimNotifies 리스트 """ target_classes = { 'ANS_AttackState_C': ['AddNormalAttackPer', 'AddPhysicalAttackPer'], 'AnimNotifyState_AttackWithEquip': ['AttackTag'], 'ANS_SkillCancel_C': [], 'AN_Trigger_Projectile_Shot_C': ['EventTag'] } anim_notifies = montage_data.get('AnimNotifies', []) result = [] for notify in anim_notifies: notify_class = notify.get('NotifyStateClass') or notify.get('NotifyClass') if notify_class in target_classes: extracted = { 'notifyClass': notify_class, 'triggerTime': notify.get('TriggerTime', 0), 'duration': notify.get('Duration', 0) } # CustomProperties에서 필요한 필드 추출 target_fields = target_classes[notify_class] if target_fields: props = extract_notify_properties(notify, target_fields) extracted['properties'] = props result.append(extracted) return result def find_montage_data(anim_montage_assets: List[Dict], montage_path: str) -> Optional[Dict]: """ 몽타주 경로로 AnimMontage 에셋 찾기 Args: anim_montage_assets: AnimMontage.json의 Assets 배열 montage_path: 몽타주 경로 Returns: 찾은 몽타주 데이터 또는 None """ asset_name = extract_montage_asset_name(montage_path) if not asset_name: return None for montage in anim_montage_assets: if montage.get('AssetName') == asset_name: return montage return None def extract_montage_info(anim_montage_assets: List[Dict], montage_path: str) -> Optional[Dict]: """ 몽타주 경로로 몽타주 정보 추출 (AnimNotifies 포함) Args: anim_montage_assets: AnimMontage.json의 Assets 배열 montage_path: 몽타주 경로 Returns: 몽타주 정보 딕셔너리 """ montage_data = find_montage_data(anim_montage_assets, montage_path) if not montage_data: asset_name = extract_montage_asset_name(montage_path) logger.warning(f"Montage not found: {asset_name}") return None sections = montage_data.get('Sections', []) section_info = [] for section in sections: section_info.append({ 'sectionName': section.get('SectionName', ''), 'startTime': section.get('StartTime', 0) }) montage_info = { 'assetName': montage_data.get('AssetName', ''), 'sequenceLength': montage_data.get('SequenceLength', 0), 'rateScale': montage_data.get('RateScale', 1.0), 'sections': section_info, 'animNotifies': extract_anim_notifies(montage_data) } return montage_info