673 lines
26 KiB
Python
673 lines
26 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 노티파이)
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
{montage_name: {timing, notifies, attackMultiplier}}
|
||
|
|
"""
|
||
|
|
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)
|
||
|
|
|
||
|
|
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
|
||
|
|
|
||
|
|
# 공격 판정 로직 (우선순위)
|
||
|
|
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)
|
||
|
|
'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 organize_stalker_data(
|
||
|
|
stalker_stats: Dict,
|
||
|
|
stalker_abilities: Dict,
|
||
|
|
all_skills: Dict,
|
||
|
|
skill_blueprints: Dict,
|
||
|
|
anim_montages: Dict,
|
||
|
|
npc_abilities: 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} 정보 없음")
|
||
|
|
|
||
|
|
# 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]
|
||
|
|
basic_attacks[weapon_type].append({
|
||
|
|
'index': idx + 1,
|
||
|
|
'montageName': montage_name,
|
||
|
|
'sequenceLength': montage_info['sequenceLength'],
|
||
|
|
'rateScale': montage_info['rateScale'],
|
||
|
|
'actualDuration': montage_info['actualDuration'],
|
||
|
|
'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}개 스킬")
|
||
|
|
|
||
|
|
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) # 소환수 데이터
|
||
|
|
|
||
|
|
# 3. 데이터 통합
|
||
|
|
organized_data = organize_stalker_data(
|
||
|
|
stalker_stats,
|
||
|
|
stalker_abilities,
|
||
|
|
all_skills,
|
||
|
|
skill_blueprints,
|
||
|
|
anim_montages,
|
||
|
|
npc_abilities
|
||
|
|
)
|
||
|
|
|
||
|
|
# 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()
|