디아블로2 화제 집중
거래 · 파티 찾기
디아블로2 커뮤니티
직업 게시판
미디어 게시판
디아블로2 관련 팟벤
공통 커뮤니티
- 오픈 이슈 갤러리
- 오늘의 핫벤
- 오늘의 팟벤
- AI 그림 그리기
- PC 견적 게시판
- 코스프레 갤러리
- (19)무인도는 첨이지?
- 게이밍 주변기기
- 지름/개봉 갤러리
- 게이머 토론장
- 게임 추천/소감
- 무엇이든 물어보세요
- 최근 논란중인 이야기
- 더보기
인기 팟벤
|
2026-06-12 14:11
조회: 85
추천: 1
D2RMT [모드 백업/롤백/추가 프로그램]======= D2RMT(Diablo 2 Resurrected Mod Toolkit) ======= [구글 드라이브 다운로드 링크] ![]() 오랜만에 시간이 나서 구상만 하고 있던 프로그램 만들어 공유합니다. 모드 백업 및 롤백, 모드 추가 등을 할 수 있는 툴킷입니다. 모드를 하나하나 백업해서 관리하고 롤백하는 게 꽤 번거로워 만든 프로그램입니다. [주요 기능] 1. 현재 모드 백업 / 롤백 / 바닐라 기능 현재 모드를 연월일시간을 초단위까지 기억해 백업합니다. 이렇게 백업된 모드는 내용을 제목으로 지정해 두고 언제든지 롤백시킬 수 있습니다(하단 스크린샷 참조). 추가로 바닐라 버튼을 조작해 바닐라(순정) 상태로 모드를 되돌리는 기능도 있습니다(모드 테스트용). ![]() ![]() 2. 모드 불러오기 / 설치 기능 인벤 애드온·모드 게시판에서 다운 받은 모더분들의 모드를 불러와 현재 모드에 설치 가능합니다. 백업 / 롤백 기능과 연계하면 언제든지 원하는 스킨이나 기능이 포함된 모드를 불러올 수 있습니다. ※ 불러온 모드는 별도의 mod 폴더에 정리되어 다운로드 받은 모드를 지우더라도 언제든지 앱에서 설치/삭제가 가능합니다. ![]() ▲ 드래그로 위치 변경 가능 ![]() ▲ 어떤 모드인지 직접 제목 설정하면 됩니다. 3. 모드 변경내역(로그) 기능 모드별로 변경 내역을 저장해서 앱 최하단의 로그 보기를 눌러 언제든 이력 확인이 가능합니다. 롤백으로 모드를 불러올 때에도 각 모드별로 로그를 불러옵니다. ![]() ![]() [사용법] 1. D2RMT_v0.1.exe를 원하는 경로에 폴더를 만들어 넣고 바로가기 생성 ![]() ▲ 처음 다운로드 받으면 해당 파일 하나지만, 앱 실행 후 설정을 진행하면 설정파일과 모드폴더가 같은 경로에 생성 됩니다. 따라서 따로 폴더에 넣어두시고 바로가기를 만들어 사용하시면 깔끔합니다. 2. 현재 모드 폴더 경로지정 디아블로2 레저렉션이 설치된 폴더/mods/모드폴더를 선택 - '폴더 선택' 클릭 (예시 : 저의 경우 C:/Program files (x86)/Diablo II Resurrected/Mods/drag 폴더) ![]() 3. 현재 모드 백업 1.에 따라 경로지정을 하셨다면 '백업' 버튼을 눌러 제목을 '원본'으로 백업해줍니다. ![]() ![]() ▲ 이제 언제든지 '롤백' 버튼을 이용해 방금 백업한 원본 상태로 되돌릴 수 있습니다. 4. 모드 불러오기 및 설치 (1) 다운 받은 모드의 압축을 풉니다. (2) 앱에서 ③추가모드 항목의 '+모드 불러오기' 버튼 클릭 (3) 압축풀어 나온 global, hd, local 폴더를 선택합니다 ※ data폴더에 바로 들어가는 global, hd, local 폴더만 해당합니다. 이외에는 에러 출력하도록 했습니다. 만약 받으신 폴더가 이보다 하위 폴더일 경우 직접 상위폴더를 생성해줘야 합니다. (예시 : 스킨을 받았는데 items 폴더만 있을 경우 hd 폴더 생성해서 안에 items 폴더 넣고 불러오기) ※ 다중 선택 기능은 안정성 이슈로 제외했고, 한 폴더를 선택하면 반복 팝업 되고 취소 누를 경우 제목 설정으로 넘어갑니다. (가령 적용하려는 모드가 hd와 global 폴더만 있다면 'hd폴더 선택 - 확인' 후 다시 뜨는 창에서 'global 폴더 선택 - 확인' 그리고 다시 뜨는 창에서 '취소'를 누르시면 됩니다.) ![]() ![]() ![]() ![]() ▲ 불러오기 및 제목 설정을 마친 다음, 설치 버튼을 누르면 현재 모드에 설치됩니다. 테스트 후 마음에 안들 경우 롤백하시면 되겠죠? ※ 모드들을 설치하신 후 주기적으로 백업 - 제목설정에 모드내용을 적어 두시면 언제든지 확인해 모드 전환 가능합니다! ★★ 보안 및 안전 안내 ★★
▼ 클릭 시 펼쳐집니다. """ D2R Mod Manager - Diablo II Resurrected 모드 관리 툴 """ import tkinter as tk from tkinter import ttk, filedialog, messagebox, simpledialog import os import json import shutil import re from datetime import datetime from pathlib import Path import sys # PyInstaller로 빌드된 exe는 __file__이 임시 폴더를 가리키므로 # sys.executable 기준(exe 실제 위치)으로 저장 경로를 잡음 if getattr(sys, "frozen", False): _BASE_DIR = Path(sys.executable).parent else: _BASE_DIR = Path(__file__).parent SAVE_FILE = _BASE_DIR / "d2r_mod_manager_state.json" APP_ICON = "app_icon.ico" def _get_ico_path() -> Path | None: """번들/스크립트 환경 모두에서 app_icon.ico 경로를 반환한다.""" if getattr(sys, "frozen", False): bundle_dir = Path(sys._MEIPASS) else: bundle_dir = Path(__file__).parent p = bundle_dir / APP_ICON return p if p.exists() else None def _set_icon(win): """타이틀바·Alt+Tab 아이콘을 설정한다. (작업표시줄은 _set_taskbar_icon 담당)""" ico_path = _get_ico_path() if ico_path: try: win.iconbitmap(str(ico_path)) except Exception: pass def _set_taskbar_icon(win): """ctypes로 HICON을 직접 윈도우 핸들에 전송해 작업표시줄 아이콘을 교체한다. 메인 Tk 창에만 호출하면 된다 (mainloop 진입 직후 after로 호출). wm_frame()으로 실제 최상위 데코레이션 윈도우 핸들을 획득하고, WS_EX_APPWINDOW를 명시적으로 설정해 작업표시줄 표시를 강제한다. """ if sys.platform != "win32": return ico_path = _get_ico_path() if not ico_path: return try: import ctypes user32 = ctypes.windll.user32 GWL_EXSTYLE = -20 WS_EX_APPWINDOW = 0x00040000 WS_EX_TOOLWINDOW = 0x00000080 LR_LOADFROMFILE = 0x10 IMAGE_ICON = 1 WM_SETICON = 0x0080 ICON_SMALL = 0 ICON_BIG = 1 # winfo_id()는 내부 child 핸들을 반환할 수 있으므로 # wm_frame()으로 실제 최상위(데코레이션 포함) 윈도우 핸들을 획득 try: hwnd = int(win.wm_frame(), 16) except Exception: hwnd = win.winfo_id() # 큰 아이콘 (작업표시줄용) big_size = user32.GetSystemMetrics(11) # SM_CXICON hicon_big = user32.LoadImageW( None, str(ico_path), IMAGE_ICON, big_size, big_size, LR_LOADFROMFILE ) # 작은 아이콘 (타이틀바용) sm_size = user32.GetSystemMetrics(49) # SM_CXSMICON hicon_sm = user32.LoadImageW( None, str(ico_path), IMAGE_ICON, sm_size, sm_size, LR_LOADFROMFILE ) if hicon_big: user32.SendMessageW(hwnd, WM_SETICON, ICON_BIG, hicon_big) if hicon_sm: user32.SendMessageW(hwnd, WM_SETICON, ICON_SMALL, hicon_sm) # WS_EX_APPWINDOW 명시적 설정 → 작업표시줄에 항상 표시 ex_style = user32.GetWindowLongW(hwnd, GWL_EXSTYLE) ex_style = (ex_style | WS_EX_APPWINDOW) & ~WS_EX_TOOLWINDOW user32.SetWindowLongW(hwnd, GWL_EXSTYLE, ex_style) except Exception: pass def _set_appid(): """Windows 작업표시줄 아이콘을 exe 아이콘으로 고정시키기 위해 AppUserModelID를 등록한다. 메인 창 생성 전에 한 번만 호출하면 된다. """ if sys.platform != "win32": return try: import ctypes ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID( "D2RModManager.App.1" ) except Exception: pass # ────────────────────────────────────────────── # 커스텀 텍스트 입력 다이얼로그 # (simpledialog.askstring 대체 → 아이콘 적용 가능) # ────────────────────────────────────────────── class AskStringDialog(tk.Toplevel): """단일 텍스트 입력을 받는 커스텀 다이얼로그. result: 확인 시 입력 문자열, 취소/닫기 시 None. """ def __init__(self, parent, title: str, prompt: str, initial: str = ""): super().__init__(parent) self.title(title) self.configure(bg=BG_DARK) self.resizable(False, False) self.result = None _set_icon(self) pw, ph = 440, 200 self.update_idletasks() sw, sh = self.winfo_screenwidth(), self.winfo_screenheight() self.geometry(f"{pw}x{ph}+{max(0,(sw-pw)//2)}+{max(0,(sh-ph)//2)}") self.grab_set() tk.Label(self, text=prompt, bg=BG_DARK, fg=TEXT_MAIN, font=FONT_SMALL, wraplength=340, justify="left", padx=20, pady=14).pack(fill="x") self._var = tk.StringVar(value=initial) entry = tk.Entry(self, textvariable=self._var, bg=BG_CARD, fg=TEXT_MAIN, insertbackground=TEXT_MAIN, font=FONT_BODY, relief="flat", highlightthickness=1, highlightcolor=BORDER_GOLD, highlightbackground=BORDER) entry.pack(fill="x", padx=20) entry.icursor("end") entry.focus_set() btn_frame = tk.Frame(self, bg=BG_DARK) btn_frame.pack(fill="x", padx=20, pady=14) styled_button(btn_frame, "확인", self._ok, BTN_INSTALL, BTN_INSTALL_H, padx=22, pady=6).pack(side="left") tk.Frame(btn_frame, bg=BG_DARK, width=10).pack(side="left") styled_button(btn_frame, "취소", self.destroy, "#e0e0e0", "#d0d0d0", fg=TEXT_MAIN, padx=22, pady=6).pack(side="left") self.bind("<Return>", lambda e: self._ok()) self.bind("<Escape>", lambda e: self.destroy()) self.protocol("WM_DELETE_WINDOW", self.destroy) def _ok(self): self.result = self._var.get() self.destroy() @classmethod def ask(cls, parent, title: str, prompt: str, initial: str = ""): """블로킹 호출. 확인 → 입력값(str), 취소 → None 반환.""" dlg = cls(parent, title, prompt, initial) parent.wait_window(dlg) return dlg.result # 추가 모드를 영구 보관하는 폴더 (exe 옆 mod/ 디렉터리) MOD_STORE_DIR = _BASE_DIR / "mod" def _read_backup_labels(backup_root: Path) -> dict: """backup_root/backup_labels.json → {폴더명: 메모} dict""" label_file = backup_root / "backup_labels.json" if label_file.exists(): try: return json.loads(label_file.read_text(encoding="utf-8")) except Exception: pass return {} def _write_backup_labels(backup_root: Path, labels: dict): """labels dict를 backup_root/backup_labels.json 에 저장""" label_file = backup_root / "backup_labels.json" try: label_file.write_text(json.dumps(labels, ensure_ascii=False, indent=2), encoding="utf-8") except Exception: pass # ────────────────────────────────────────────── # 로그 파일 경로 helper # ────────────────────────────────────────────── def _log_path_for(mod_folder: Path) -> Path: """mod_folder 옆 BackUp/ 안의 <모드명>_activity_log.json 경로 (모드별 분리)""" mod_name = mod_folder.name return mod_folder.parent / "BackUp" / f"{mod_name}_activity_log.json" def _read_log(mod_folder: Path) -> list: p = _log_path_for(mod_folder) if p.exists(): try: return json.loads(p.read_text(encoding="utf-8")) except Exception: pass return [] def _write_log(mod_folder: Path, entries: list): backup_root = mod_folder.parent / "BackUp" backup_root.mkdir(exist_ok=True) p = _log_path_for(mod_folder) try: p.write_text(json.dumps(entries, ensure_ascii=False, indent=2), encoding="utf-8") except Exception: pass def _ts_now() -> str: """현재 시각을 'YY:MM:DD:HH:MM:SS' 표시용 문자열로 반환""" return datetime.now().strftime("%y:%m:%d %H:%M:%S") # ────────────────────────────────────────────── # 색상 & 폰트 테마 (Windows 11 네이티브 스타일) # ────────────────────────────────────────────── BG_DARK = "#f3f3f3" # 창 배경 BG_PANEL = "#e8e8e8" # 상태바 배경 BG_CARD = "#ffffff" # 카드 배경 BG_CARD_HOV = "#f5f5f5" # 카드 호버 BORDER = "#d0d0d0" # 일반 테두리 BORDER_GOLD = "#0078d4" # 강조 테두리 (Windows 블루) ACCENT_GOLD = "#0078d4" # 강조색 (Windows 블루) ACCENT_RED = "#c42b1c" # 위험 강조색 TEXT_MAIN = "#1a1a1a" # 기본 텍스트 TEXT_SUB = "#5a5a5a" # 보조 텍스트 TEXT_DIM = "#999999" # 흐린 텍스트 BTN_INSTALL = "#0078d4" # 설치 버튼 (Windows 블루) BTN_INSTALL_H= "#006cbe" BTN_REMOVE = "#c42b1c" # 제거 버튼 (빨간색) BTN_REMOVE_H = "#ae2518" BTN_BACKUP = "#107c10" # 백업 버튼 (초록색) BTN_BACKUP_H = "#0e6b0e" BTN_ROLL = "#7a5af8" # 롤백 버튼 (보라색) BTN_ROLL_H = "#6b4fe0" FONT_TITLE = ("Segoe UI", 18, "bold") FONT_HEAD = ("Segoe UI", 12, "bold") FONT_BODY = ("Segoe UI", 12) FONT_SMALL = ("Segoe UI", 11) FONT_MONO = ("Consolas", 11) VALID_FOLDERS = {"global", "hd", "local"} def styled_button(parent, text, command, bg, hover_bg, fg="#ffffff", padx=10, pady=4, font=FONT_SMALL, **kw): btn = tk.Label(parent, text=text, bg=bg, fg=fg, font=font, padx=padx, pady=pady, cursor="hand2", relief="flat", **kw) btn.bind("<Button-1>", lambda e: command()) btn.bind("<Enter>", lambda e: btn.config(bg=hover_bg)) btn.bind("<Leave>", lambda e: btn.config(bg=bg)) return btn # ────────────────────────────────────────────── # 활동 로그 다이얼로그 # ────────────────────────────────────────────── class LogDialog(tk.Toplevel): def __init__(self, parent, entries: list, mod_name: str): super().__init__(parent) self.title("활동 로그") self.configure(bg=BG_DARK) self.resizable(True, True) _set_icon(self) pw, ph = 660, 520 self.update_idletasks() sw, sh = self.winfo_screenwidth(), self.winfo_screenheight() self.geometry(f"{pw}x{ph}+{max(0,(sw-pw)//2)}+{max(0,(sh-ph)//2)}") self.grab_set() tk.Label(self, text=f"활동 로그 — {mod_name}", bg=BG_DARK, fg=ACCENT_GOLD, font=FONT_HEAD, pady=12).pack(fill="x") tk.Frame(self, bg=BORDER_GOLD, height=1).pack(fill="x", padx=20) frame = tk.Frame(self, bg=BG_DARK) frame.pack(fill="both", expand=True, padx=20, pady=10) sb = tk.Scrollbar(frame) sb.pack(side="right", fill="y") lb = tk.Listbox( frame, bg=BG_CARD, fg=TEXT_MAIN, font=FONT_MONO, selectbackground=BORDER_GOLD, selectforeground="#ffffff", relief="flat", bd=0, highlightthickness=1, highlightcolor=BORDER_GOLD, highlightbackground=BORDER_GOLD, yscrollcommand=sb.set, activestyle="none" ) lb.pack(side="left", fill="both", expand=True) sb.config(command=lb.yview) if entries: for e in reversed(entries): # 최신 항목이 위로 lb.insert("end", f" {e['ts']} {e['msg']}") else: lb.insert("end", " (기록 없음)") self.after(50, lb.focus_set) btn_frame = tk.Frame(self, bg=BG_DARK) btn_frame.pack(fill="x", padx=20, pady=(0, 14)) styled_button(btn_frame, "닫기", self.destroy, "#e0e0e0", "#d0d0d0", fg=TEXT_MAIN, padx=22, pady=6).pack(side="left") # ────────────────────────────────────────────── # 롤백 선택 다이얼로그 # ────────────────────────────────────────────── class RollbackDialog(tk.Toplevel): def __init__(self, parent, backup_folder: Path, current_mod_name: str): super().__init__(parent) self.title("롤백 선택") self.configure(bg=BG_DARK) self.resizable(False, False) self.selected = None _set_icon(self) self.current_mod_name = current_mod_name self._backup_folder = backup_folder self._labels = _read_backup_labels(backup_folder) if backup_folder.exists() else {} # 화면 중앙 배치 pw, ph = 560, 460 self.update_idletasks() sw = self.winfo_screenwidth() sh = self.winfo_screenheight() x = max(0, (sw - pw) // 2) y = max(0, (sh - ph) // 2) self.geometry(f"{pw}x{ph}+{x}+{y}") self.grab_set() # 제목 tk.Label(self, text="⟲ 롤백 포인트 선택", bg=BG_DARK, fg=ACCENT_GOLD, font=FONT_HEAD, pady=14).pack(fill="x") sep = tk.Frame(self, bg=BORDER_GOLD, height=1) sep.pack(fill="x", padx=20) tk.Label(self, text="복원할 백업을 선택하세요.", bg=BG_DARK, fg=TEXT_SUB, font=FONT_SMALL, pady=8).pack() # 리스트 frame = tk.Frame(self, bg=BG_DARK) frame.pack(fill="both", expand=True, padx=20, pady=(0, 10)) sb = tk.Scrollbar(frame) sb.pack(side="right", fill="y") self.listbox = tk.Listbox( frame, bg=BG_CARD, fg=TEXT_MAIN, font=FONT_MONO, selectbackground=BORDER_GOLD, selectforeground="#ffffff", relief="flat", bd=0, highlightthickness=1, highlightcolor=BORDER_GOLD, highlightbackground=BORDER_GOLD, yscrollcommand=sb.set, activestyle="none" ) self.listbox.pack(side="left", fill="both", expand=True) sb.config(command=self.listbox.yview) # BackUp 폴더 내 .mpq 폴더 목록 수집 self.entries = [] if backup_folder.exists(): pattern = re.compile( rf"^{re.escape(current_mod_name)}(d{{10,12}}).mpq$", re.IGNORECASE ) for item in sorted(backup_folder.iterdir(), reverse=True): if item.is_dir() and item.name.lower().endswith(".mpq"): m = pattern.match(item.name) if m: ts = m.group(1) # 예: 2606112100 display = self._format_ts(ts, item.name) self.entries.append((item.name, display)) self.listbox.insert("end", f" {display}") if not self.entries: self.listbox.insert("end", " (백업 없음)") self.after(50, self.listbox.focus_set) # 버튼 btn_frame = tk.Frame(self, bg=BG_DARK) btn_frame.pack(fill="x", padx=20, pady=(0, 16)) styled_button(btn_frame, "✔ 복원", self._confirm, BTN_INSTALL, BTN_INSTALL_H, padx=22, pady=6).pack(side="left") tk.Frame(btn_frame, bg=BG_DARK, width=10).pack(side="left") styled_button(btn_frame, "🗑 제거", self._delete_selected, BTN_REMOVE, BTN_REMOVE_H, padx=22, pady=6).pack(side="left") tk.Frame(btn_frame, bg=BG_DARK, width=10).pack(side="left") styled_button(btn_frame, "✕ 취소", self.destroy, "#e0e0e0", "#d0d0d0", fg=TEXT_MAIN, padx=22, pady=6).pack(side="left") def _format_ts(self, ts: str, raw_name: str) -> str: """260612091036 → 2026-06-12 09:10:36 | 파싱 실패 시 raw_name""" try: year = int("20" + ts[0:2]) month = int(ts[2:4]) day = int(ts[4:6]) hour = int(ts[6:8]) minute = int(ts[8:10]) second = int(ts[10:12]) if len(ts) >= 12 else 0 date_str = f"{year}-{month:02d}-{day:02d} {hour:02d}:{minute:02d}:{second:02d}" memo = self._labels.get(raw_name, "") if memo: return f"[{memo}] {date_str}" else: base = raw_name.replace(".mpq", "") mod_only = re.sub(r"d{10,12}$", "", base) return f"{mod_only} ({date_str})" except Exception: return raw_name.replace(".mpq", "") def _confirm(self): sel = self.listbox.curselection() if not sel or not self.entries: messagebox.showwarning("선택 없음", "복원할 백업을 선택하세요.", parent=self) return self.selected = self.entries[sel[0]][0] self.destroy() def _delete_selected(self): sel = self.listbox.curselection() if not sel or not self.entries: messagebox.showwarning("선택 없음", "제거할 백업을 선택하세요.", parent=self) return folder_name, display = self.entries[sel[0]] ok = messagebox.askyesno( "백업 제거", f"선택한 백업을 영구 삭제할까요?nn{display}nn이 작업은 되돌릴 수 없습니다.", parent=self ) if not ok: return target = self._backup_folder / folder_name try: shutil.rmtree(str(target)) except Exception as e: messagebox.showerror("삭제 실패", str(e), parent=self) return # labels.json 에서도 제거 labels = _read_backup_labels(self._backup_folder) if folder_name in labels: del labels[folder_name] _write_backup_labels(self._backup_folder, labels) self._labels = labels # 목록 갱신 idx = sel[0] self.entries.pop(idx) self.listbox.delete(idx) if not self.entries: self.listbox.insert("end", " (백업 없음)") # ────────────────────────────────────────────── # 메인 애플리케이션 # ────────────────────────────────────────────── class D2RModManager(tk.Tk): def __init__(self): super().__init__() self.title("D2RMT v0.1") self.configure(bg=BG_DARK) self.minsize(820, 620) self.geometry("940x720") _set_icon(self) self.mod_folder: Path | None = None self.mpq_folder: Path | None = None self.mod_name: str = "" self.addon_mods: list[dict] = [] self._activity_log: list[dict] = [] # 현재 mod_folder 세션 로그 self._build_ui() self._load_state() # 창이 완전히 그려진 뒤 작업표시줄 아이콘 교체 self.after(200, lambda: _set_taskbar_icon(self)) # ── 상태 저장 / 불러오기 ───────────────────────────────────────────── # 추가 모드는 exe 옆 mod/<모드명>/ 폴더에 실제 파일을 복사해 두고, # JSON 에는 mod_folder 경로만 저장한다. # 재실행 시 mod/ 폴더를 스캔해 목록을 복원하므로 경로 소실 문제가 없다. def _save_state(self): """mod_folder 경로만 JSON 에 저장 (addon_mods 는 mod/ 폴더가 정보 원본).""" try: data = { "mod_folder": str(self.mod_folder) if self.mod_folder else None, } save_path = SAVE_FILE.resolve() save_path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") except Exception as e: import traceback; traceback.print_exc() messagebox.showerror("저장 실패", f"상태 저장 중 오류가 발생했습니다:n{e}") def _load_state(self): """JSON 에서 mod_folder 복원, mod/ 폴더 스캔으로 addon_mods 복원.""" # ── mod_folder 복원 save_path = SAVE_FILE.resolve() if save_path.exists(): try: data = json.loads(save_path.read_text(encoding="utf-8")) if data.get("mod_folder"): p = Path(data["mod_folder"]) if p.exists(): mod_name = p.name mpq_candidate = p / f"{mod_name}.mpq" if not mpq_candidate.exists(): candidates = [x for x in p.iterdir() if x.name.lower().endswith(".mpq")] mpq_candidate = candidates[0] if candidates else None if mpq_candidate: self.mod_folder = p self.mpq_folder = mpq_candidate self.mod_name = mpq_candidate.name.replace(".mpq", "") self.path_var.set(str(p)) self._render_current_mod() # 저장된 로그 복원 self._activity_log = _read_log(p) except Exception: import traceback; traceback.print_exc() # ── addon_mods 복원: mod/ 폴더 스캔 try: if MOD_STORE_DIR.exists(): for mod_dir in sorted(MOD_STORE_DIR.iterdir()): if not mod_dir.is_dir(): continue mod_name = mod_dir.name # 하위 폴더(global / hd / local) 중 존재하는 것만 수집 folders = [mod_dir / fn for fn in ("global", "hd", "local") if (mod_dir / fn).is_dir()] if folders: self.addon_mods.append({"name": mod_name, "folders": folders}) except Exception: import traceback; traceback.print_exc() self._render_addon_list() # 재실행 후 상태바: mod_folder가 복원된 경우 로그 다시 읽어 최신 항목 표시 # (mod_folder 복원 블록 안에서 _activity_log를 set했더라도 여기서 확정) if self.mod_folder: self._activity_log = _read_log(self.mod_folder) fa = [e for e in self._activity_log if e.get("fa")] if fa: latest = fa[-1] self.status_var.set(f" {latest['ts']} {latest['msg']} ▲ 로그 보기") # ── UI 구성 ────────────────────────────── def _build_ui(self): # ── 하단 상태바를 먼저 pack(side=bottom) 해야 # outer(expand=True)가 남은 공간을 정확히 채움 → 스크롤바 상하 공백 없음 self.status_var = tk.StringVar(value=" 경로를 설정하여 시작하세요.") status_bar = tk.Frame(self, bg=BG_PANEL, pady=5) status_bar.pack(fill="x", side="bottom") self._status_lbl = tk.Label( status_bar, textvariable=self.status_var, bg=BG_PANEL, fg=TEXT_SUB, font=FONT_SMALL, padx=12, cursor="hand2", anchor="w", justify="left" ) self._status_lbl.pack(side="left", fill="both", expand=True) self._status_lbl.bind("<Button-1>", lambda e: self._open_log_dialog()) status_bar.bind("<Button-1>", lambda e: self._open_log_dialog()) # ── 우측 상단 고정, 스크롤 영역 밖 credit_bar = tk.Frame(self, bg=BG_DARK) credit_bar.pack(fill="x", side="top") tk.Label( credit_bar, text="디아블로2 인벤 애드온·모드 자료실 (닉네임 : 풍선 제작)", bg=BG_DARK, fg=TEXT_DIM, font=("Segoe UI", 9), anchor="e", padx=16, pady=2 ).pack(side="right") # ── 스크롤 가능 메인 영역 (상태바 다음에 pack → 공백 없이 꽉 참) outer = tk.Frame(self, bg=BG_DARK, bd=0) outer.pack(fill="both", expand=True) canvas = tk.Canvas(outer, bg=BG_DARK, bd=0, highlightthickness=0) vscroll = tk.Scrollbar(outer, orient="vertical", command=canvas.yview) canvas.configure(yscrollcommand=vscroll.set) vscroll.pack(side="right", fill="y") canvas.pack(side="left", fill="both", expand=True) # scroll_frame 자체에 좌우 padding 24 적용 → 스크롤바는 창 끝에 붙음 self.scroll_frame = tk.Frame(canvas, bg=BG_DARK, padx=24, pady=14) self.scroll_window = canvas.create_window((0, 0), window=self.scroll_frame, anchor="nw") def _update_scrollregion(e=None): canvas.update_idletasks() bbox = canvas.bbox("all") if bbox: # scrollregion 상단을 항상 0으로 고정 → 위로 스크롤 불가 canvas.configure(scrollregion=(0, 0, bbox[2], bbox[3])) self.scroll_frame.bind("<Configure>", _update_scrollregion) # canvas 너비 변경 시 scroll_frame 너비 동기화 def _on_canvas_configure(e): canvas.itemconfig(self.scroll_window, width=e.width) canvas.bind("<Configure>", _on_canvas_configure) self._main_canvas = canvas self._update_scrollregion = _update_scrollregion # ── 마우스 휠 스크롤: canvas 위에 포커스가 있을 때만 동작 # (RollbackDialog 등 별도 창 제외) def _on_mousewheel(e): # 이벤트 위젯이 이 창(self) 소속인지 확인 try: widget = e.widget # toplevel 찾기 top = widget.winfo_toplevel() if top is not self: return # 다른 창(RollbackDialog 등)이면 무시 except Exception: return # scrollregion 크기가 canvas 높이보다 클 때만 스크롤 scroll_region = canvas.cget("scrollregion") if not scroll_region: return try: _, _, _, content_h = map(float, str(scroll_region).split()) except Exception: return if content_h <= canvas.winfo_height(): return # 내용이 창보다 작으면 스크롤 불필요 canvas.yview_scroll(int(-1 * (e.delta / 120)), "units") self.bind_all("<MouseWheel>", _on_mousewheel) self._build_path_section() self._build_current_mod_section() self._build_addon_section() def _section_label(self, parent, text): f = tk.Frame(parent, bg=BG_DARK) f.pack(fill="x", pady=(14, 4)) tk.Label(f, text=text, bg=BG_DARK, fg=ACCENT_GOLD, font=FONT_HEAD).pack(side="left") tk.Frame(f, bg=BORDER, height=1).pack(side="left", fill="x", expand=True, padx=(10, 0)) def _build_path_section(self): self._section_label(self.scroll_frame, "① 모드 폴더 경로 설정") card = tk.Frame(self.scroll_frame, bg=BG_CARD, highlightbackground=BORDER, highlightthickness=1) card.pack(fill="x", pady=(0, 4)) inner = tk.Frame(card, bg=BG_CARD, padx=14, pady=10) inner.pack(fill="x") tk.Label(inner, text="경로", bg=BG_CARD, fg=TEXT_SUB, font=FONT_SMALL, width=6, anchor="w").pack(side="left") self.path_var = tk.StringVar(value="선택된 폴더 없음") path_lbl = tk.Label(inner, textvariable=self.path_var, bg=BG_CARD, fg=TEXT_MAIN, font=FONT_MONO, anchor="w") path_lbl.pack(side="left", fill="x", expand=True, padx=(0, 10)) styled_button(inner, "📁 폴더 선택", self._select_mod_folder, BORDER_GOLD, ACCENT_GOLD, fg="#ffffff", font=FONT_SMALL, padx=14, pady=6).pack(side="right") def _build_current_mod_section(self): self._section_label(self.scroll_frame, "② 현재 모드") self.current_mod_frame = tk.Frame(self.scroll_frame, bg=BG_DARK) self.current_mod_frame.pack(fill="x") self._render_current_mod() def _render_current_mod(self): for w in self.current_mod_frame.winfo_children(): w.destroy() if not self.mpq_folder: card = tk.Frame(self.current_mod_frame, bg=BG_CARD, highlightbackground=BORDER, highlightthickness=1) card.pack(fill="x") tk.Label(card, text="모드 폴더를 먼저 선택하세요.", bg=BG_CARD, fg=TEXT_DIM, font=FONT_SMALL, padx=14, pady=12).pack(anchor="w") return card = tk.Frame(self.current_mod_frame, bg=BG_CARD, highlightbackground=BORDER_GOLD, highlightthickness=1) card.pack(fill="x") inner = tk.Frame(card, bg=BG_CARD, padx=14, pady=10) inner.pack(fill="x") # 모드 아이콘 + 이름 left = tk.Frame(inner, bg=BG_CARD) left.pack(side="left", fill="x", expand=True) tk.Label(left, text="🗂", bg=BG_CARD, fg=ACCENT_GOLD, font=("Segoe UI Emoji", 18)).pack(side="left") tk.Label(left, text=self.mod_name, bg=BG_CARD, fg=TEXT_MAIN, font=FONT_HEAD, padx=8).pack(side="left") tk.Label(left, text=".mpq", bg=BG_CARD, fg=TEXT_DIM, font=FONT_SMALL).pack(side="left") # 버튼 우측 배치 right = tk.Frame(inner, bg=BG_CARD) right.pack(side="right") styled_button(right, "💾 백업", self._backup_mod, BTN_BACKUP, BTN_BACKUP_H, padx=14, pady=6).pack(side="left", padx=4) styled_button(right, "⟲ 롤백", self._rollback_mod, BTN_ROLL, BTN_ROLL_H, padx=14, pady=6).pack(side="left", padx=4) styled_button(right, "🛡 바닐라", self._restore_vanilla, "#6b6b6b", "#555555", padx=14, pady=6).pack(side="left", padx=4) # 경로 표시 path_frame = tk.Frame(card, bg=BG_CARD, padx=14, pady=(0, 8)) path_frame.pack(fill="x") tk.Label(path_frame, text=str(self.mpq_folder), bg=BG_CARD, fg=TEXT_DIM, font=FONT_MONO).pack(anchor="w") def _build_addon_section(self): self._section_label(self.scroll_frame, "③ 추가 모드 (스킨 / UI / 패치)") # 추가 버튼 add_frame = tk.Frame(self.scroll_frame, bg=BG_DARK) add_frame.pack(fill="x", pady=(0, 8)) styled_button(add_frame, "+ 모드 불러오기", self._load_addon_mod, BTN_INSTALL, BTN_INSTALL_H, padx=18, pady=7, font=FONT_BODY).pack(side="left") tk.Label(add_frame, text=" ※ global / hd / local 폴더만 허용", bg=BG_DARK, fg=TEXT_DIM, font=FONT_SMALL).pack(side="left") self.addon_list_frame = tk.Frame(self.scroll_frame, bg=BG_DARK) self.addon_list_frame.pack(fill="x") self._render_addon_list() def _render_addon_list(self): for w in self.addon_list_frame.winfo_children(): w.destroy() if not self.addon_mods: tk.Label(self.addon_list_frame, text="불러온 추가 모드가 없습니다.", bg=BG_DARK, fg=TEXT_DIM, font=FONT_SMALL, pady=8).pack(anchor="w") return for idx, mod in enumerate(self.addon_mods): self._render_addon_row(idx, mod) def _render_addon_row(self, idx: int, mod: dict): card = tk.Frame(self.addon_list_frame, bg=BG_CARD, highlightbackground=BORDER, highlightthickness=1) card.pack(fill="x", pady=2) inner = tk.Frame(card, bg=BG_CARD, padx=8, pady=8) inner.pack(fill="x") # ── 드래그 핸들 (≡ 아이콘) handle = tk.Label(inner, text="≡", bg=BG_CARD, fg=TEXT_DIM, font=("Segoe UI", 16), cursor="fleur", padx=6) handle.pack(side="left") # ── 모드명 left = tk.Frame(inner, bg=BG_CARD) left.pack(side="left", fill="x", expand=True) tk.Label(left, text=mod["name"], bg=BG_CARD, fg=TEXT_MAIN, font=FONT_HEAD, padx=6).pack(side="left") # 포함 폴더 태그 for folder_path in mod["folders"]: tag_color = {"global": "#d4edda", "hd": "#d0e8f8", "local": "#fce8d4"}.get(folder_path.name.lower(), BORDER) tk.Label(left, text=folder_path.name, bg=tag_color, fg=TEXT_MAIN, font=FONT_SMALL, padx=6, pady=2, relief="flat").pack(side="left", padx=2) # ── 버튼 영역 right = tk.Frame(inner, bg=BG_CARD) right.pack(side="right") styled_button(right, "설치", lambda i=idx: self._install_addon(i), BTN_BACKUP, BTN_BACKUP_H, padx=14, pady=5).pack(side="left", padx=3) styled_button(right, "이름변경", lambda i=idx: self._rename_addon(i), "#5a6472", "#4a5462", fg="#ffffff", padx=12, pady=5).pack(side="left", padx=3) styled_button(right, "제거", lambda i=idx: self._remove_addon(i), BTN_REMOVE, BTN_REMOVE_H, padx=14, pady=5).pack(side="left", padx=3) # ── 드래그 & 드롭 순서 변경 self._bind_drag(handle, card, idx) self._bind_drag(inner, card, idx) # ── 동작 ───────────────────────────────── def _select_mod_folder(self): path = filedialog.askdirectory(title="mods 안의 모드 폴더 선택") if not path: return mod_folder = Path(path) # .mpq 폴더/파일 탐색 (폴더와 모드명이 일치해야 함) mod_name = mod_folder.name mpq_candidate = mod_folder / f"{mod_name}.mpq" if not mpq_candidate.exists(): # .mpq 로 끝나는 것 아무거나 찾기 candidates = [p for p in mod_folder.iterdir() if p.name.lower().endswith(".mpq")] if not candidates: messagebox.showerror( "오류", f"'{mod_folder}' 안에 .mpq 폴더/파일이 없습니다.n" "올바른 모드 폴더를 선택하세요." ) return mpq_candidate = candidates[0] self.mod_folder = mod_folder self.mpq_folder = mpq_candidate self.mod_name = mpq_candidate.name.replace(".mpq", "") # 새 모드 로드 → 로그 초기화 후 첫 항목 기록 self._activity_log = _read_log(mod_folder) self.path_var.set(str(mod_folder)) self._render_current_mod() self._add_log(f"모드 로드: {self.mod_name}", file_affecting=True) self._save_state() def _backup_mod(self): if not self.mpq_folder or not self.mpq_folder.exists(): messagebox.showerror("오류", "유효한 .mpq 폴더가 없습니다.") return # 백업 메모 입력 (빈 값 허용 → 메모 없음) memo = AskStringDialog.ask( self, "백업 메모", "이 백업 상태를 설명하는 메모를 입력하세요.n(비워두면 날짜/시간만 표시됩니다)" ) if memo is None: # 취소 버튼 return memo = memo.strip() mods_folder = self.mod_folder.parent backup_root = mods_folder / "BackUp" backup_root.mkdir(exist_ok=True) now = datetime.now() ts = now.strftime("%y%m%d%H%M%S") # 예: 260612091036 dst_name = f"{self.mod_name}{ts}.mpq" dst_path = backup_root / dst_name if dst_path.exists(): messagebox.showwarning("중복", f"같은 이름의 백업이 이미 존재합니다:n{dst_name}") return try: shutil.copytree(str(self.mpq_folder), str(dst_path)) except Exception as e: messagebox.showerror("백업 실패", str(e)) return # 메모가 있으면 backup_labels.json 에 저장 if memo: labels = _read_backup_labels(backup_root) labels[dst_name] = memo _write_backup_labels(backup_root, labels) # 백업 시점 로그 항목 추가 후 스냅샷을 백업 이름으로 함께 저장 label_str = f" [{memo}]" if memo else "" self._add_log(f"백업{label_str} → {dst_name}", file_affecting=True) # 현재 로그를 이 백업 폴더에도 복사 (롤백 때 복원용) try: snap_path = dst_path / "_activity_log_snapshot.json" snap_path.write_text( json.dumps(self._activity_log, ensure_ascii=False, indent=2), encoding="utf-8" ) except Exception: pass messagebox.showinfo("백업 완료", f"백업이 저장되었습니다:n{dst_path}") def _rollback_mod(self): if not self.mod_folder: messagebox.showerror("오류", "모드 폴더가 설정되지 않았습니다.") return mods_folder = self.mod_folder.parent backup_root = mods_folder / "BackUp" if not backup_root.exists() or not any(backup_root.iterdir()): messagebox.showinfo("백업 없음", "BackUp 폴더에 저장된 백업이 없습니다.") return dlg = RollbackDialog(self, backup_root, self.mod_name) self.wait_window(dlg) if not dlg.selected: return selected_path = backup_root / dlg.selected confirm = messagebox.askyesno( "롤백 확인", f"현재 모드 '{self.mod_name}.mpq'를 삭제하고n" f"'{dlg.selected}'으로 복원하시겠습니까?nn" "이 작업은 되돌릴 수 없습니다." ) if not confirm: return try: # 현재 모드 삭제 if self.mpq_folder.exists(): shutil.rmtree(str(self.mpq_folder)) # 백업 복사 (원래 이름으로) target = self.mod_folder / f"{self.mod_name}.mpq" shutil.copytree(str(selected_path), str(target)) self.mpq_folder = target except Exception as e: messagebox.showerror("롤백 실패", str(e)) return # 백업에 저장된 로그 스냅샷 복원 snap = target / "_activity_log_snapshot.json" if snap.exists(): try: self._activity_log = json.loads(snap.read_text(encoding="utf-8")) _write_log(self.mod_folder, self._activity_log) except Exception: pass self._add_log(f"롤백 완료 → {dlg.selected}", file_affecting=True) messagebox.showinfo("롤백 완료", f"'{self.mod_name}.mpq'이 복원되었습니다.") self._render_current_mod() def _restore_vanilla(self): """현재 모드의 data 폴더 안 global / hd / local 폴더를 삭제해 바닐라 상태로 복원한다.""" if not self.mpq_folder or not self.mpq_folder.exists(): messagebox.showerror("오류", "현재 모드(.mpq)가 설정되지 않았습니다.") return data_folder = self.mpq_folder / "data" targets = [data_folder / name for name in ("global", "hd", "local") if (data_folder / name).exists()] if not targets: messagebox.showinfo( "이미 바닐라 상태", f"'{data_folder}' 안에 global / hd / local 폴더가 없습니다.n" "이미 바닐라(순정) 상태입니다." ) return folder_list = "n".join(f" • {t.name}" for t in targets) confirmed = messagebox.askyesno( "⚠️ 바닐라 복원 확인", f"아래 폴더를 영구적으로 삭제합니다.nn" f"{folder_list}nn" f"경로: {data_folder}nn" "삭제된 파일은 복구할 수 없습니다.n" "계속하시겠습니까?", icon="warning" ) if not confirmed: return errors = [] deleted = [] for t in targets: try: shutil.rmtree(str(t)) deleted.append(t.name) except Exception as e: errors.append(f"{t.name}: {e}") if errors: messagebox.showerror("삭제 오류", "n".join(errors)) else: deleted_str = ", ".join(deleted) messagebox.showinfo( "바닐라 복원 완료", f"다음 폴더가 삭제되었습니다: {deleted_str}nn" "클라이언트가 순정(바닐라) 상태로 실행됩니다." ) self._add_log(f"바닐라 복원 — 삭제: {deleted_str}", file_affecting=True) def _load_addon_mod(self): """폴더 다중 선택 → 유효성 검사 → 모드명 입력 → 등록""" folders: list[Path] = [] while True: path = filedialog.askdirectory( title=f"모드 폴더 선택 (global/hd/local) ― 현재 {len(folders)}개 선택됨 | 취소 시 완료" ) if not path: break p = Path(path) if p.name.lower() not in VALID_FOLDERS: messagebox.showerror( "잘못된 폴더", f"'{p.name}' 폴더는 허용되지 않습니다.n" "global, hd, local 폴더만 선택할 수 있습니다." ) continue if p.name.lower() in ("global", "local") and p.parent.name.lower() == "hd": messagebox.showerror( "경로 오류", f"선택한 '{p.name}' 폴더의 상위가 'hd' 입니다.nn" f"현재 경로: {p}nn" "이 경로는 data/hd/global (또는 local) 로 설치됩니다.n" "일반 모드에 필요한 경로는 data/global (또는 data/local) 이므로,n" "hd 폴더 바깥의 올바른 global / local 폴더를 선택하세요." ) continue if p not in folders: folders.append(p) if not folders: return mod_name = AskStringDialog.ask( self, "모드명 입력", "이 모드의 이름을 입력하세요:" ) if not mod_name or not mod_name.strip(): return name = mod_name.strip() # ── mod/<모드명>/ 폴더에 파일 복사 (영구 보관) store_dir = MOD_STORE_DIR / name if store_dir.exists(): overwrite = messagebox.askyesno( "이미 존재", f"'{name}' 모드가 이미 저장되어 있습니다.n덮어쓸까요?" ) if not overwrite: return shutil.rmtree(str(store_dir)) try: store_dir.mkdir(parents=True, exist_ok=True) for src in folders: dst = store_dir / src.name shutil.copytree(str(src), str(dst)) except Exception as e: messagebox.showerror("저장 실패", f"mod 폴더에 복사 중 오류:n{e}") return # ── addon_mods 에 등록 (folders는 mod/ 내부 경로) stored_folders = [store_dir / src.name for src in folders] self.addon_mods.append({"name": name, "folders": stored_folders}) self._render_addon_list() self._add_log(f"추가 모드 등록: {name}") self._save_state() def _install_addon(self, idx: int): if not self.mpq_folder or not self.mpq_folder.exists(): messagebox.showerror("오류", "현재 모드(.mpq)가 설정되지 않았습니다.") return mod = self.addon_mods[idx] data_folder = self.mpq_folder / "data" if not data_folder.exists(): confirm = messagebox.askyesno( "data 폴더 없음", f"'{data_folder}' 폴더가 없습니다. 생성하고 계속할까요?" ) if not confirm: return data_folder.mkdir(parents=True) errors = [] for src in mod["folders"]: dst = data_folder / src.name try: dst.mkdir(parents=True, exist_ok=True) shutil.copytree(str(src), str(dst), dirs_exist_ok=True) except Exception as e: errors.append(f"{src.name}: {e}") if errors: messagebox.showerror("설치 오류", "n".join(errors)) else: messagebox.showinfo("설치 완료", f"'{mod['name']}' 모드가 설치되었습니다.n" f"→ {data_folder}") self._add_log(f"설치 완료: {mod['name']}", file_affecting=True) def _remove_addon(self, idx: int): mod = self.addon_mods[idx] confirm = messagebox.askyesno( "제거 확인", f"'{mod['name']}' 모드를 목록에서 제거할까요?nn" "저장된 mod 폴더의 파일도 함께 삭제됩니다." ) if confirm: # mod/<모드명>/ 폴더 삭제 store_dir = MOD_STORE_DIR / mod["name"] if store_dir.exists(): try: shutil.rmtree(str(store_dir)) except Exception as e: messagebox.showerror("삭제 실패", f"mod 폴더 삭제 중 오류:n{e}") self.addon_mods.pop(idx) self._render_addon_list() self._add_log(f"모드 제거: {mod['name']}") self._save_state() def _rename_addon(self, idx: int): mod = self.addon_mods[idx] new_name = AskStringDialog.ask( self, "이름 변경", f"'{mod['name']}' 의 새 이름을 입력하세요:", initial=mod["name"] ) if not new_name or not new_name.strip(): return new_name = new_name.strip() if new_name == mod["name"]: return # mod/ 폴더 이름 변경 old_dir = MOD_STORE_DIR / mod["name"] new_dir = MOD_STORE_DIR / new_name if new_dir.exists(): messagebox.showerror("이름 충돌", f"'{new_name}' 이름의 모드가 이미 존재합니다.") return try: if old_dir.exists(): old_dir.rename(new_dir) except Exception as e: messagebox.showerror("이름 변경 실패", str(e)) return # addon_mods 내부 경로도 새 이름으로 업데이트 new_folders = [new_dir / f.name for f in mod["folders"]] self.addon_mods[idx] = {"name": new_name, "folders": new_folders} self._render_addon_list() self._add_log(f"이름 변경: {mod['name']} → {new_name}") # ── 드래그 & 드롭 순서 변경 ───────────────────────────────────────── def _bind_drag(self, widget: tk.Widget, card: tk.Frame, idx: int): """widget 에 드래그 이벤트를 바인딩해 addon_mods 순서를 변경한다.""" state = {} def on_press(e, i=idx): state["start_y"] = e.y_root state["idx"] = i # 드래그 중인 카드 강조 card.configure(highlightbackground=ACCENT_GOLD) def on_drag(e): pass # 시각 피드백은 release 시 처리 def on_release(e, i=idx): card.configure(highlightbackground=BORDER) if "start_y" not in state: return dy = e.y_root - state["start_y"] # 카드 높이를 동적으로 구함 (기본 ~42px) try: card_h = max(card.winfo_height(), 42) except Exception: card_h = 42 steps = round(dy / card_h) if steps == 0: return src = i dst = max(0, min(len(self.addon_mods) - 1, src + steps)) if src == dst: return # 순서 변경 item = self.addon_mods.pop(src) self.addon_mods.insert(dst, item) self._render_addon_list() self._add_log(f"순서 변경: '{item['name']}' {src+1}번 → {dst+1}번") widget.bind("<ButtonPress-1>", on_press) widget.bind("<B1-Motion>", on_drag) widget.bind("<ButtonRelease-1>", on_release) # ── 로그 & 상태바 ──────────────────────────────────────────────────── def _add_log(self, msg: str, file_affecting: bool = False): """로그 항목 추가, 상태바 갱신, 파일 저장. file_affecting=True 인 항목만 로그 다이얼로그에 표시된다. """ entry = {"ts": _ts_now(), "msg": msg, "fa": file_affecting} self._activity_log.append(entry) # 상태바에는 file_affecting 항목만 표시 (없으면 마지막 항목) fa_entries = [e for e in self._activity_log if e.get("fa")] latest = fa_entries[-1] if fa_entries else entry self.status_var.set(f" {latest['ts']} {latest['msg']} ▲ 로그 보기") # mod_folder 가 설정돼 있으면 즉시 파일에 저장 if self.mod_folder: _write_log(self.mod_folder, self._activity_log) def _open_log_dialog(self): fa_entries = [e for e in self._activity_log if e.get("fa")] if not fa_entries: messagebox.showinfo("로그 없음", "아직 기록된 파일 변경 이력이 없습니다.n" "모드를 설치하거나 롤백해보세요.") return mod_name = self.mod_name or "—" LogDialog(self, fa_entries, mod_name) # ────────────────────────────────────────────── # 진입점 # ────────────────────────────────────────────── if __name__ == "__main__": _set_appid() app = D2RModManager() app.mainloop() (14DLC) 피스트딘 플레이 및 모딩중... - Mod 입문 : 26. 3. 10 (Tue) - ![]() ★ 최초 베이스 Mod : 엽굵모드 v 4.7 (엽굵님 유튜브 : https://www.youtube.com/@YupGoolg) ★ 적용된 다른 모더님들의 스킨 // "항상 감사합니다." '구반다'님의 서릿발(일부 수정) / '폭풍아이언맨'님의 노바 히트 그래픽(일부 수정) / '즐겁고'님의 아이콘 표시(일부수정) / 'Killyoung'님의 성형 어쌔신(클래스 변경) / '설류혼'님의 미니맵과 전체지도 BTN / '추가아이템'님의 불길의 강 용암제거 《 자작 목록 》 * [악마술사] 헤파몬을 폴암 든 이주알로 변경하기 * [사신소서] 무공&스왑템을 우산과 핸드백으로 변경하기 * [사신소서] 무공을 버스터소드로 변경하기 * [사신소서] 무공을 쌍수로 변경하기 * 액트2 용병을 카우킹으로 변경하기 * [악마술사] 헤파몬과 용병을 은빛 풀템바바로 변경하기 https://www.inven.co.kr/board/diablo2/5842/7413 (액트2, 액트5 용병) * [악마술사] 염소인간을 은빛 액트3용병으로 변경하기 * 액트2 용병을 상탈한 네크로 변경하기 * [악마술사] 고서 - 수학의 정석 * [악마술사] 고서 - 게임 잡지 * 용병, 속박몹을 축구아마로 변경하기 * 심플한 Hud Panel * 광선검 & 경광봉 ※※ (저작권을 글 말머리에 표기한 자료를 제외한) 제가 직접 작업한 자료는 자유롭게 사용 & 배포하셔도 됩니다. ※※
|
지금 뜨는 핫벤
더보기+- 리니지 클래식 난닝구 사망원인에 대한 고찰 [18]
- 오버워치 26 시즌3 신영웅) 시온 스킬 한글번역(chatGPT) [9]
- 메이플 ㅇㅂ) 실시간 팡이요 극대노 ㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋ [57]
- 메이플 현거래 진짜 안되면 걍 좀하다 접을듯 [155]
- 와우 무슨 패치가 있었던 건지 [12]
지금 뜨는 팟벤
더보기+- 해외겜 PoE2 레벨 100 삭제하면 패시브 포인트 개방 [1]
- 해외겜 RTX 50 슈퍼는 2027년 초 등장 전망 [6]
- 해외겜 AMD “DDR5 정상화까지 2년 걸린다” [3]
- 명조 서리효과 응결 서브딜러 루실라 종합 가이드 | 에코, 조합, 딜 사이클 [1]
- 걸그룹 아일릿 모카 위버스















