378 lines
12 KiB
Python
378 lines
12 KiB
Python
# -*- 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
|