From 9f206eb39dcfc4dc770b0222a8258254bbde687a Mon Sep 17 00:00:00 2001 From: DazedAnon Date: Sat, 13 Jun 2026 15:46:51 -0500 Subject: [PATCH] Forge --- .env.example | 3 + .gitignore | 1 + README.md | 1 + gui/workflow_tab.py | 511 ++++++++++---- util/forge/Forge_MZ.bat | 105 +++ util/forge/Forge_MZ.js | 1093 ++++++++++++++++++++++++++++++ util/forge/__init__.py | 12 + util/forge/config.py | 54 ++ util/forge/installer.py | 144 ++++ util/playtest/__init__.py | 3 + util/playtest/config.py | 55 ++ util/tl_inspector/TLInspector.js | 12 +- util/tl_inspector/config.py | 30 +- util/tl_inspector/installer.py | 5 +- 14 files changed, 1873 insertions(+), 156 deletions(-) create mode 100644 util/forge/Forge_MZ.bat create mode 100644 util/forge/Forge_MZ.js create mode 100644 util/forge/__init__.py create mode 100644 util/forge/config.py create mode 100644 util/forge/installer.py create mode 100644 util/playtest/__init__.py create mode 100644 util/playtest/config.py diff --git a/.env.example b/.env.example index 49f712e..17b8657 100644 --- a/.env.example +++ b/.env.example @@ -83,3 +83,6 @@ frequency_penalty= 0.2 # TL Inspector (Step 8 playtest) — editor opened when clicking a source line in-game # tlEditorCmd: 'auto' scans for VS Code / Cursor, or set an absolute path to any editor exe tlEditorCmd='auto' +# Hotkeys (event.key names, e.g. F9, F10, Control) +tlHotkey='F9' +forgeHotkey='F10' diff --git a/.gitignore b/.gitignore index 844c89b..55f2295 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ __pycache__ !vocab_base.txt !requirements.txt !util/tl_inspector/TLInspector.js +!util/forge/Forge_MZ.js util/ace/*.exe util/ace/.tools_version.json !util/ace/offline/*.exe diff --git a/README.md b/README.md index 01baf9e..cef930a 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ An AI-powered game translation tool with a GUI. Translate RPG Maker, Ren'Py, Tyr - **[Sinflower](https://github.com/Sinflower)** — [RV2JSON](https://github.com/Sinflower/RV2JSON) — enables RPGMaker Ace games to be translated the same way as MV/MZ by converting rvdata2 files to JSON and back. A copy is bundled offline in `util/ace/offline/`; newer builds are downloaded when online. - **Sakura & Kao_SSS** — TL Inspector (`util/tl_inspector/`) — in-game translation source inspector and live-edit plugin for RPG Maker MV/MZ playtesting. +- **Len** — Forge (`util/forge/`) — MZ playtest cheat & editor overlay (install alongside TL Inspector). - **Len** — batch translation mode — Anthropic Message Batches API integration (collect/consume pipeline, cost estimation, resume, and GUI/workflow support). ## Table of Contents diff --git a/gui/workflow_tab.py b/gui/workflow_tab.py index ada3d4f..c3d03cc 100644 --- a/gui/workflow_tab.py +++ b/gui/workflow_tab.py @@ -11,7 +11,7 @@ Provides a guided, step-by-step interface: Step 5 – Translation Phase 2 (risky codes) Step 6 – Translate visible strings in js/plugins.js (or Ace scripts) Step 7 – Export translated/ back to the game folder - Step 8 – Install TL Inspector for playtesting and live in-game edits (MV/MZ) + Step 8 – Install TL Inspector and/or Forge playtest plugins """ from __future__ import annotations @@ -729,8 +729,8 @@ class WorkflowTab(QWidget): if index == 5: self._populate_p2_checkboxes() if index == 8: - self._refresh_tl_inspector_status() - self._load_tli_editor_settings() + self._refresh_playtest_status() + self._load_playtest_settings() def _register_import_button(self, button: QPushButton) -> None: self._import_buttons.append(button) @@ -2491,131 +2491,212 @@ class WorkflowTab(QWidget): # ── Step 8: Playtest (TL Inspector) ───────────────────────────────────── def _build_step8_playtest(self, layout: QVBoxLayout): - self._step8_section_label = _make_section_label("Step 8 — Playtest with TL Inspector") + self._step8_section_label = _make_section_label("Step 8 — Playtest Tools") layout.addWidget(self._step8_section_label) - hint = QLabel( - "Install TL Inspector into your RPG Maker MV/MZ game for playtesting. " - "Press F10 in-game to see which source file each line of text comes from, " - "open it in VSCode, or edit the text live and save directly to the JSON file." + self._step8_main_hint = QLabel( + "Install playtest plugins into your RPG Maker game. " + "TL Inspector works on MV and MZ. " + "Forge is MZ-only. Use Install Both on MZ to add both plugins at once." ) - hint.setWordWrap(True) - hint.setTextFormat(Qt.RichText) - hint.setStyleSheet("color:#9d9d9d;font-size:13px;padding-bottom:4px;") - layout.addWidget(hint) + self._step8_main_hint.setWordWrap(True) + self._step8_main_hint.setTextFormat(Qt.RichText) + self._step8_main_hint.setStyleSheet("color:#9d9d9d;font-size:13px;padding-bottom:4px;") + layout.addWidget(self._step8_main_hint) - credits = QLabel("Idea by Sakura · Plugin by Kao_SSS") - credits.setStyleSheet("color:#6a6a6a;font-size:11px;font-style:italic;padding-bottom:6px;") - layout.addWidget(credits) + settings_box = QWidget() + settings_box.setObjectName("tbox") + settings_box.setStyleSheet(self._task_box_style()) + settings_inner = QVBoxLayout(settings_box) + settings_inner.setContentsMargins(12, 10, 12, 10) + settings_inner.setSpacing(8) - box = QWidget() - box.setObjectName("tbox") - box.setStyleSheet(self._task_box_style()) - inner = QVBoxLayout(box) - inner.setContentsMargins(10, 8, 10, 8) - inner.setSpacing(6) + _PT_LABEL_W = 118 + _PT_FIELD_W = 96 + _PT_BTN_W = 96 + _PT_LBL_STYLE = "color:#9d9d9d;font-size:12px;" + _PT_SECTION_STYLE = "color:#4ec9b0;font-size:12px;font-weight:bold;" - self._tli_status_label = QLabel("Status: (detect a project folder first)") - self._tli_status_label.setWordWrap(True) - self._tli_status_label.setStyleSheet("color:#7a7a7a;font-size:13px;") - inner.addWidget(self._tli_status_label) + hotkey_title = QLabel("Hotkeys") + hotkey_title.setStyleSheet(_PT_SECTION_STYLE) + settings_inner.addWidget(hotkey_title) + + hotkey_row = QHBoxLayout() + hotkey_row.setSpacing(8) + + insp_lbl = QLabel("Inspector toggle:") + insp_lbl.setFixedWidth(_PT_LABEL_W) + insp_lbl.setStyleSheet(_PT_LBL_STYLE) + insp_lbl.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + hotkey_row.addWidget(insp_lbl) + + self._pt_hotkey_edit = QLineEdit("F9") + self._pt_hotkey_edit.setFixedWidth(_PT_FIELD_W) + self._pt_hotkey_edit.setPlaceholderText("F9") + hotkey_row.addWidget(self._pt_hotkey_edit) + + hotkey_row.addSpacing(16) + + self._pt_forge_hotkey_lbl = QLabel("Forge toggle:") + self._pt_forge_hotkey_lbl.setFixedWidth(_PT_LABEL_W) + self._pt_forge_hotkey_lbl.setStyleSheet(_PT_LBL_STYLE) + self._pt_forge_hotkey_lbl.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + hotkey_row.addWidget(self._pt_forge_hotkey_lbl) + + self._pt_forge_hotkey_edit = QLineEdit("F10") + self._pt_forge_hotkey_edit.setFixedWidth(_PT_FIELD_W) + self._pt_forge_hotkey_edit.setPlaceholderText("F10") + hotkey_row.addWidget(self._pt_forge_hotkey_edit) + + hotkey_row.addStretch(1) + settings_inner.addLayout(hotkey_row) editor_title = QLabel("Editor settings") - editor_title.setStyleSheet("color:#4ec9b0;font-size:12px;font-weight:bold;padding-top:4px;") - inner.addWidget(editor_title) + editor_title.setStyleSheet(_PT_SECTION_STYLE + "padding-top:2px;") + settings_inner.addWidget(editor_title) - _TLI_LABEL_W = 80 - _TLI_BTN_W = 88 - _tli_lbl_style = "color:#9d9d9d;font-size:12px;" - - tli_grid = QGridLayout() - tli_grid.setHorizontalSpacing(6) - tli_grid.setVerticalSpacing(6) - tli_grid.setColumnStretch(1, 1) + editor_grid = QGridLayout() + editor_grid.setHorizontalSpacing(8) + editor_grid.setVerticalSpacing(6) + editor_grid.setColumnMinimumWidth(0, _PT_LABEL_W) + editor_grid.setColumnStretch(1, 1) editor_lbl = QLabel("Editor:") - editor_lbl.setFixedWidth(_TLI_LABEL_W) - editor_lbl.setStyleSheet(_tli_lbl_style) + editor_lbl.setStyleSheet(_PT_LBL_STYLE) editor_lbl.setAlignment(Qt.AlignRight | Qt.AlignVCenter) - tli_grid.addWidget(editor_lbl, 0, 0) + editor_grid.addWidget(editor_lbl, 0, 0) self._tli_editor_combo = QComboBox() self._tli_editor_combo.currentIndexChanged.connect(self._on_tli_editor_combo_changed) - tli_grid.addWidget(self._tli_editor_combo, 0, 1) + editor_grid.addWidget(self._tli_editor_combo, 0, 1) detect_btn = _make_btn("Detect", "#4a4a4a") - detect_btn.setFixedWidth(_TLI_BTN_W) + detect_btn.setFixedWidth(_PT_BTN_W) detect_btn.setToolTip("Scan this PC for VS Code, Insiders, or Cursor") detect_btn.clicked.connect(self._detect_tli_editors) - tli_grid.addWidget(detect_btn, 0, 2) + editor_grid.addWidget(detect_btn, 0, 2) + + custom_lbl = QLabel("Custom path:") + custom_lbl.setStyleSheet(_PT_LBL_STYLE) + custom_lbl.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + editor_grid.addWidget(custom_lbl, 1, 0) self._tli_editor_custom = QLineEdit() - self._tli_editor_custom.setPlaceholderText("Path to editor executable (when Custom is selected)") + self._tli_editor_custom.setPlaceholderText("Editor executable when Custom is selected") self._tli_editor_custom.setEnabled(False) - tli_grid.addWidget(self._tli_editor_custom, 1, 1) + editor_grid.addWidget(self._tli_editor_custom, 1, 1) browse_editor_btn = _make_btn("Browse…", "#4a4a4a") - browse_editor_btn.setFixedWidth(_TLI_BTN_W) + browse_editor_btn.setFixedWidth(_PT_BTN_W) browse_editor_btn.clicked.connect(self._browse_tli_editor) - tli_grid.addWidget(browse_editor_btn, 1, 2) + editor_grid.addWidget(browse_editor_btn, 1, 2) self._tli_detect_label = QLabel("") self._tli_detect_label.setWordWrap(True) self._tli_detect_label.setStyleSheet("color:#6a6a6a;font-size:11px;") - tli_grid.addWidget(self._tli_detect_label, 2, 1, 1, 2) + editor_grid.addWidget(self._tli_detect_label, 2, 1, 1, 2) - cfg_btn_wrap = QWidget() - cfg_btn_row = QHBoxLayout(cfg_btn_wrap) - cfg_btn_row.setContentsMargins(0, 0, 0, 0) - cfg_btn_row.setSpacing(8) - save_tli_btn = _make_btn("✔ Save settings", "#3a5a7a") - save_tli_btn.setFixedWidth(140) - save_tli_btn.setToolTip("Write editor settings to .env (used on Install / Apply)") - save_tli_btn.clicked.connect(self._save_tli_editor_settings) - cfg_btn_row.addWidget(save_tli_btn) - apply_tli_btn = _make_btn("↻ Apply to game", "#3a5a7a") - apply_tli_btn.setFixedWidth(140) - apply_tli_btn.setToolTip("Update the installed TLInspector.js in your game folder") - apply_tli_btn.clicked.connect(self._apply_tli_editor_settings) - cfg_btn_row.addWidget(apply_tli_btn) - cfg_btn_row.addStretch() - tli_grid.addWidget(cfg_btn_wrap, 3, 1, 1, 2) + settings_inner.addLayout(editor_grid) - inner.addLayout(tli_grid) + action_row = QHBoxLayout() + action_row.setSpacing(8) + save_pt_btn = _make_btn("✔ Save settings", "#3a5a7a") + save_pt_btn.setFixedHeight(30) + save_pt_btn.setToolTip("Write hotkeys and editor settings to .env (used on Install / Apply)") + save_pt_btn.clicked.connect(self._save_playtest_settings) + action_row.addWidget(save_pt_btn) - tips = QLabel( - "" + apply_pt_btn = _make_btn("↻ Apply to game", "#3a5a7a") + apply_pt_btn.setFixedHeight(30) + apply_pt_btn.setToolTip("Update the installed playtest plugin(s) in your game folder") + apply_pt_btn.clicked.connect(self._apply_playtest_settings) + action_row.addWidget(apply_pt_btn) + + action_row.addStretch(1) + + self._install_both_btn = _make_btn("⬇ Install Both (MZ)", "#3a5a7a") + self._install_both_btn.setFixedHeight(30) + self._install_both_btn.setToolTip( + "Install TL Inspector and Forge as separate plugins (MZ only)" ) - tips.setWordWrap(True) - tips.setTextFormat(Qt.RichText) - inner.addWidget(tips) + self._install_both_btn.clicked.connect(self._install_both_playtest) + action_row.addWidget(self._install_both_btn) - btn_row = QHBoxLayout() - btn_row.setSpacing(8) - _BTN_W = 200 + settings_inner.addLayout(action_row) + layout.addWidget(settings_box) + self._step8_settings_box = settings_box + + # ── Plugins (TL Inspector + Forge) ──────────────────────────────────── + plugins_box = QWidget() + plugins_box.setObjectName("tbox") + plugins_box.setStyleSheet(self._task_box_style()) + plugins_inner = QVBoxLayout(plugins_box) + plugins_inner.setContentsMargins(12, 10, 12, 10) + plugins_inner.setSpacing(8) + + _PT_SECTION_STYLE = "color:#4ec9b0;font-size:12px;font-weight:bold;" + + tli_title = QLabel("TL Inspector") + tli_title.setStyleSheet(_PT_SECTION_STYLE) + plugins_inner.addWidget(tli_title) + + self._tli_status_label = QLabel("Status: (detect a project folder first)") + self._tli_status_label.setWordWrap(True) + self._tli_status_label.setStyleSheet("color:#7a7a7a;font-size:13px;") + plugins_inner.addWidget(self._tli_status_label) + + tli_btn_row = QHBoxLayout() + tli_btn_row.setSpacing(8) self._tli_install_btn = _make_btn("⬇ Install TL Inspector", "#3a7a3a") - self._tli_install_btn.setFixedWidth(_BTN_W) + self._tli_install_btn.setMinimumHeight(30) self._tli_install_btn.clicked.connect(self._install_tl_inspector) - btn_row.addWidget(self._tli_install_btn) + tli_btn_row.addWidget(self._tli_install_btn, 1) self._tli_uninstall_btn = _make_btn("⬆ Uninstall TL Inspector", "#7a3a3a") - self._tli_uninstall_btn.setFixedWidth(_BTN_W) + self._tli_uninstall_btn.setMinimumHeight(30) self._tli_uninstall_btn.clicked.connect(self._uninstall_tl_inspector) - btn_row.addWidget(self._tli_uninstall_btn) - btn_row.addStretch() - inner.addLayout(btn_row) + tli_btn_row.addWidget(self._tli_uninstall_btn, 1) + plugins_inner.addLayout(tli_btn_row) + + self._step8_forge_section = QWidget() + forge_section_layout = QVBoxLayout(self._step8_forge_section) + forge_section_layout.setContentsMargins(0, 4, 0, 0) + forge_section_layout.setSpacing(8) + + forge_title = QLabel("Forge (MZ only)") + forge_title.setStyleSheet(_PT_SECTION_STYLE) + forge_section_layout.addWidget(forge_title) + + self._forge_status_label = QLabel("Status: (MZ project required)") + self._forge_status_label.setWordWrap(True) + self._forge_status_label.setStyleSheet("color:#7a7a7a;font-size:13px;") + forge_section_layout.addWidget(self._forge_status_label) + + forge_btn_row = QHBoxLayout() + forge_btn_row.setSpacing(8) + self._forge_install_btn = _make_btn("⬇ Install Forge", "#3a7a3a") + self._forge_install_btn.setMinimumHeight(30) + self._forge_install_btn.clicked.connect(self._install_forge) + forge_btn_row.addWidget(self._forge_install_btn, 1) + + self._forge_uninstall_btn = _make_btn("⬆ Uninstall Forge", "#7a3a3a") + self._forge_uninstall_btn.setMinimumHeight(30) + self._forge_uninstall_btn.clicked.connect(self._uninstall_forge) + forge_btn_row.addWidget(self._forge_uninstall_btn, 1) + forge_section_layout.addLayout(forge_btn_row) + + plugins_inner.addWidget(self._step8_forge_section) + + self._step8_tli_credits = QLabel("Idea by Sakura · Plugin by Kao_SSS") + self._step8_tli_credits.setStyleSheet("color:#6a6a6a;font-size:11px;font-style:italic;padding-top:2px;") + plugins_inner.addWidget(self._step8_tli_credits) + + layout.addWidget(plugins_box) + self._step8_playtest_box = plugins_box + self._step8_forge_box = self._step8_forge_section - layout.addWidget(box) - self._step8_playtest_box = box self._populate_tli_editor_combo() - self._load_tli_editor_settings() + self._load_playtest_settings() def _populate_tli_editor_combo(self, select: str | None = None): """Fill editor dropdown with auto-detect, found editors, and custom.""" @@ -2663,18 +2744,24 @@ class WorkflowTab(QWidget): "No VS Code / Cursor found — install one or choose Custom path." ) - def _load_tli_editor_settings(self): - """Load TL Inspector editor settings from .env into Step 8 controls.""" + def _load_playtest_settings(self): + """Load playtest hotkeys and editor settings from .env into Step 8 controls.""" try: - from util.tl_inspector.config import load_config + from util.playtest.config import load_config cfg = load_config() except Exception: - cfg = {"editorCmd": "auto"} + cfg = { + "hotkey": "F9", + "forgeHotkey": "F10", + "editorCmd": "auto", + } + self._pt_hotkey_edit.setText(cfg.get("hotkey", "F9")) + self._pt_forge_hotkey_edit.setText(cfg.get("forgeHotkey", "F10")) self._populate_tli_editor_combo(select=cfg.get("editorCmd", "auto")) - def _resolve_tli_config(self) -> dict: - """Build config dict from Step 8 editor controls.""" + def _resolve_playtest_config(self) -> dict: + """Build playtest config dict from Step 8 controls.""" mode = self._tli_editor_combo.currentData() if mode == "__custom__": editor = self._tli_editor_custom.text().strip() or "auto" @@ -2683,7 +2770,74 @@ class WorkflowTab(QWidget): else: editor = "auto" - return {"editorCmd": editor, "workspaceFolder": "auto"} + return { + "hotkey": self._pt_hotkey_edit.text().strip() or "F9", + "forgeHotkey": self._pt_forge_hotkey_edit.text().strip() or "F10", + "editorCmd": editor, + "workspaceFolder": "auto", + } + + def _save_playtest_settings(self): + cfg = self._resolve_playtest_config() + try: + from util.playtest.config import save_config + save_config(cfg) + self._log( + "✅ Playtest settings saved — " + f"inspector={cfg['hotkey']}, forge={cfg['forgeHotkey']}, " + f"editor={cfg['editorCmd']}" + ) + except Exception as exc: + self._log(f"❌ Could not save playtest settings: {exc}") + + def _apply_playtest_settings(self): + game_root = self.folder_edit.text().strip() + if not game_root: + self._log("⚠ No game folder set. Complete Step 0 first.") + return + cfg = self._resolve_playtest_config() + try: + from util.forge.installer import detect_mz + from util.playtest.config import save_config + + save_config(cfg) + is_mz = detect_mz(Path(game_root)) is not None + msgs: list[str] = [] + if is_mz: + from util.forge.installer import apply_config as apply_forge + from util.forge.installer import status as forge_status + if forge_status(Path(game_root)).get("plugin_file"): + ok, msg = apply_forge(Path(game_root), cfg) + msgs.append(("✅ " if ok else "❌ ") + msg) + from util.tl_inspector.installer import apply_config as apply_tli + from util.tl_inspector.installer import status as tli_status + if tli_status(Path(game_root)).get("plugin_file"): + ok, msg = apply_tli(Path(game_root), cfg) + msgs.append(("✅ " if ok else "❌ ") + msg) + if not msgs: + self._log("⚠ No playtest plugins installed in this game folder.") + return + for line in msgs: + self._log(line) + except Exception as exc: + self._log(f"❌ Could not apply playtest settings: {exc}") + return + self._refresh_playtest_status() + + def _refresh_playtest_status(self): + """Update Step 8 status labels for the current engine.""" + if getattr(self, "_step8_playtest_box", None) is not None: + self._refresh_tl_inspector_status() + game_root = self.folder_edit.text().strip() + is_mz = False + if game_root: + try: + from util.forge.installer import detect_mz + is_mz = detect_mz(Path(game_root)) is not None + except Exception: + is_mz = False + if is_mz: + self._refresh_forge_status() def _on_tli_editor_combo_changed(self, _index: int | None = None): custom = self._tli_editor_combo.currentData() == "__custom__" @@ -2715,32 +2869,13 @@ class WorkflowTab(QWidget): self._tli_editor_custom.setText(path) def _save_tli_editor_settings(self): - cfg = self._resolve_tli_config() - try: - from util.tl_inspector.config import save_config - save_config(cfg) - self._log(f"✅ TL Inspector settings saved — editor={cfg['editorCmd']}") - except Exception as exc: - self._log(f"❌ Could not save TL Inspector settings: {exc}") + self._save_playtest_settings() def _apply_tli_editor_settings(self): - game_root = self.folder_edit.text().strip() - if not game_root: - self._log("⚠ No game folder set. Complete Step 0 first.") - return - cfg = self._resolve_tli_config() - try: - from util.tl_inspector.config import save_config - from util.tl_inspector.installer import apply_config - save_config(cfg) - ok, msg = apply_config(Path(game_root), cfg) - except Exception as exc: - self._log(f"❌ Could not apply TL Inspector settings: {exc}") - return - self._log(("✅ " if ok else "❌ ") + msg) + self._apply_playtest_settings() def _refresh_tl_inspector_status(self): - """Update Step 8 status label from the current game folder.""" + """Update Step 8 TL Inspector status label from the current game folder.""" label = getattr(self, "_tli_status_label", None) if label is None: return @@ -2782,9 +2917,9 @@ class WorkflowTab(QWidget): if not game_root: self._log("⚠ No game folder set. Complete Step 0 first.") return - cfg = self._resolve_tli_config() + cfg = self._resolve_playtest_config() try: - from util.tl_inspector.config import save_config + from util.playtest.config import save_config from util.tl_inspector.installer import install save_config(cfg) ok, msg = install(Path(game_root), cfg=cfg) @@ -2792,7 +2927,7 @@ class WorkflowTab(QWidget): self._log(f"❌ TL Inspector install failed: {exc}") return self._log(("✅ " if ok else "❌ ") + msg) - self._refresh_tl_inspector_status() + self._refresh_playtest_status() def _uninstall_tl_inspector(self): game_root = self.folder_edit.text().strip() @@ -2815,7 +2950,114 @@ class WorkflowTab(QWidget): self._log(f"❌ TL Inspector uninstall failed: {exc}") return self._log(("✅ " if ok else "❌ ") + msg) - self._refresh_tl_inspector_status() + self._refresh_playtest_status() + + def _refresh_forge_status(self): + """Update Step 8 Forge status label from the current game folder.""" + label = getattr(self, "_forge_status_label", None) + if label is None: + return + game_root = self.folder_edit.text().strip() + if not game_root: + label.setText("Status: no game folder set — complete Step 0 first.") + label.setStyleSheet("color:#7a7a7a;font-size:13px;") + return + try: + from util.forge.installer import detect_mz, status + if detect_mz(Path(game_root)) is None: + label.setText("Status: not an MZ project (Forge is MZ-only).") + label.setStyleSheet("color:#e9a12a;font-size:13px;") + return + st = status(Path(game_root)) + except Exception as exc: + label.setText(f"Status: error — {exc}") + label.setStyleSheet("color:#f48771;font-size:13px;") + return + + msg = st.get("message", "") + if st.get("declared") and st.get("plugin_file"): + detail = "plugin declared in plugins.js and file present" + elif st.get("declared"): + detail = "declared in plugins.js (plugin file missing)" + elif st.get("plugin_file"): + detail = "plugin file present (not declared in plugins.js)" + else: + detail = "not installed" + label.setText(f"Status: RPG Maker MZ · {msg} — {detail}") + color = "#6a9a6a" if st.get("declared") and st.get("plugin_file") else "#9d9d9d" + label.setStyleSheet(f"color:{color};font-size:13px;") + + def _install_forge(self): + game_root = self.folder_edit.text().strip() + if not game_root: + self._log("⚠ No game folder set. Complete Step 0 first.") + return + cfg = self._resolve_playtest_config() + try: + from util.playtest.config import save_config + from util.forge.installer import install + save_config(cfg) + ok, msg = install(Path(game_root), cfg=cfg) + except Exception as exc: + self._log(f"❌ Forge install failed: {exc}") + return + self._log(("✅ " if ok else "❌ ") + msg) + self._refresh_playtest_status() + + def _install_both_playtest(self): + game_root = self.folder_edit.text().strip() + if not game_root: + self._log("⚠ No game folder set. Complete Step 0 first.") + return + try: + from util.forge.installer import detect_mz + if detect_mz(Path(game_root)) is None: + self._log("⚠ Install Both is for MZ projects only.") + return + except Exception as exc: + self._log(f"❌ Could not detect engine: {exc}") + return + + cfg = self._resolve_playtest_config() + try: + from util.playtest.config import save_config + from util.tl_inspector.installer import install as install_tli + from util.forge.installer import install as install_forge + + save_config(cfg) + ok_tli, msg_tli = install_tli(Path(game_root), cfg=cfg) + self._log(("✅ " if ok_tli else "❌ ") + msg_tli) + if not ok_tli: + return + ok_forge, msg_forge = install_forge(Path(game_root), cfg=cfg) + self._log(("✅ " if ok_forge else "❌ ") + msg_forge) + except Exception as exc: + self._log(f"❌ Install Both failed: {exc}") + return + self._refresh_playtest_status() + + def _uninstall_forge(self): + game_root = self.folder_edit.text().strip() + if not game_root: + self._log("⚠ No game folder set. Complete Step 0 first.") + return + reply = QMessageBox.question( + self, + "Uninstall Forge", + "Remove Forge_MZ from plugins.js and delete the plugin file?", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No, + ) + if reply != QMessageBox.Yes: + return + try: + from util.forge.installer import uninstall + ok, msg = uninstall(Path(game_root)) + except Exception as exc: + self._log(f"❌ Forge uninstall failed: {exc}") + return + self._log(("✅ " if ok else "❌ ") + msg) + self._refresh_playtest_status() # ───────────────────────────────────────────────────────────────────────── # Step 0 – Project Folder logic @@ -2930,10 +3172,32 @@ class WorkflowTab(QWidget): if is_ace and self._step_tabs.currentIndex() == 8: self._step_tabs.setCurrentIndex(7) box = getattr(self, "_step8_playtest_box", None) - if box is not None: - box.setEnabled(not is_ace) + forge_section = getattr(self, "_step8_forge_section", None) + is_mz = False if not is_ace: - self._refresh_tl_inspector_status() + game_root = self.folder_edit.text().strip() + if game_root: + try: + from util.forge.installer import detect_mz + is_mz = detect_mz(Path(game_root)) is not None + except Exception: + is_mz = False + if box is not None: + box.setVisible(not is_ace) + box.setEnabled(not is_ace) + if forge_section is not None: + forge_section.setVisible(is_mz) + install_both_btn = getattr(self, "_install_both_btn", None) + if install_both_btn is not None: + install_both_btn.setVisible(is_mz) + forge_hk_lbl = getattr(self, "_pt_forge_hotkey_lbl", None) + forge_hk_edit = getattr(self, "_pt_forge_hotkey_edit", None) + if forge_hk_lbl is not None: + forge_hk_lbl.setVisible(is_mz) + if forge_hk_edit is not None: + forge_hk_edit.setVisible(is_mz) + if not is_ace: + self._refresh_playtest_status() def _detect_folder(self): folder = self.folder_edit.text().strip() @@ -3060,6 +3324,7 @@ class WorkflowTab(QWidget): "border-radius:4px;margin:4px 0;" ) self._log(f"Detected data folder: {data_path} (engine: {engine})") + self._update_step6_for_engine(False) worker = _ScanWorker(self._data_path, self._engine) worker.done.connect(self._on_scan_done) diff --git a/util/forge/Forge_MZ.bat b/util/forge/Forge_MZ.bat new file mode 100644 index 0000000..705a64b --- /dev/null +++ b/util/forge/Forge_MZ.bat @@ -0,0 +1,105 @@ +@echo off +rem ============================================================================ +rem Forge_MZ installer / uninstaller (RPG Maker MZ only) +rem Put this file and Forge_MZ.js in the GAME ROOT (next to the .exe / +rem index.html), then double-click this file. +rem - Not installed yet -> installs it (copies the plugin into the plugins +rem folder and declares it at the end of plugins.js). +rem - Already installed -> asks whether to remove it. +rem ============================================================================ +chcp 65001 >nul +setlocal +set "FORGE_ROOT=%~dp0" +powershell -NoProfile -ExecutionPolicy Bypass -Command "$l=Get-Content -LiteralPath '%~f0'; $i=[Array]::IndexOf($l,'#:PSSTART'); Invoke-Expression (($l[($i+1)..($l.Count-1)]) -join [char]10)" +endlocal +exit /b +#:PSSTART +$ErrorActionPreference = 'Stop' +try { [Console]::OutputEncoding = [Text.Encoding]::UTF8 } catch {} +function PauseExit { [void](Read-Host "`nPress Enter to exit"); exit } + +$root = $env:FORGE_ROOT +Write-Host "============================================" +Write-Host " Forge_MZ installer (RPG Maker MZ only)" +Write-Host "============================================" + +# --- Detect MZ / locate plugins.js ------------------------------------------ +$mzJs = Join-Path $root 'js\plugins.js' +if (Test-Path -LiteralPath $mzJs) { + $pluginsJs = $mzJs; $pluginsDir = Join-Path $root 'js\plugins' +} else { + Write-Host "" + Write-Host "ERROR: No RPG Maker MZ game found here." -ForegroundColor Red + Write-Host "Could not find 'js\plugins.js'." + Write-Host "Forge is MZ-only. Place this installer and Forge_MZ.js in the" + Write-Host "game ROOT folder (the one containing the .exe / index.html)." + PauseExit +} +Write-Host "Detected RPG Maker MZ" +Write-Host ("plugins list: {0}" -f $pluginsJs) + +$target = Join-Path $pluginsDir 'Forge_MZ.js' +$srcRoot = Join-Path $root 'Forge_MZ.js' +$utf8 = New-Object System.Text.UTF8Encoding($false) # no BOM +$content = [IO.File]::ReadAllText($pluginsJs) +$nl = if ($content -match "`r`n") { "`r`n" } else { "`n" } +$declared = [regex]::IsMatch($content, '"name"\s*:\s*"Forge_MZ"') +$fileThere = Test-Path -LiteralPath $target + +# --- Already installed -> offer to remove ----------------------------------- +if ($declared -or $fileThere) { + Write-Host "" + Write-Host "Forge_MZ is currently INSTALLED." -ForegroundColor Yellow + $ans = Read-Host "Remove it? (Y/N)" + if ($ans -match '^(y|yes)$') { + if ($declared) { + $kept = ($content -split "`r?`n") | Where-Object { $_ -notmatch '"name"\s*:\s*"Forge_MZ"' } + $newText = ($kept -join $nl) + $newText = [regex]::Replace($newText, ',(\s*)\];', '$1];') + [IO.File]::WriteAllText($pluginsJs, $newText, $utf8) + Write-Host "Removed the Forge_MZ entry from plugins.js" + } + if ($fileThere) { + Remove-Item -LiteralPath $target -Force + Write-Host ("Deleted {0}" -f $target) + } + Write-Host "" + Write-Host "Uninstalled." -ForegroundColor Green + } else { + Write-Host "Cancelled - nothing changed." + } + PauseExit +} + +# --- Not installed -> install ----------------------------------------------- +if (-not (Test-Path -LiteralPath $srcRoot)) { + Write-Host "" + Write-Host "ERROR: Forge_MZ.js was not found next to this installer." -ForegroundColor Red + Write-Host ("Expected: {0}" -f $srcRoot) + Write-Host "Copy Forge_MZ.js into the game root and run this again." + PauseExit +} +if (-not (Test-Path -LiteralPath $pluginsDir)) { + New-Item -ItemType Directory -Path $pluginsDir -Force | Out-Null +} +Copy-Item -LiteralPath $srcRoot -Destination $target -Force +Write-Host ("Copied plugin -> {0}" -f $target) + +$entry = ' { "name": "Forge_MZ", "status": true, "description": "Forge — in-game cheat & editor overlay", "parameters": {} }' +$idx = $content.LastIndexOf('];') +if ($idx -lt 0) { + Write-Host "" + Write-Host "ERROR: could not find the end of the plugin list ( ]; ) in plugins.js." -ForegroundColor Red + Write-Host "The plugin file was copied; please add this line before the ']' yourself:" + Write-Host $entry + PauseExit +} +$before = $content.Substring(0, $idx).TrimEnd() +$after = $content.Substring($idx) +$sep = if ($before.EndsWith(',')) { $nl } else { ',' + $nl } +$newText = $before + $sep + $entry + $nl + ' ' + $after +[IO.File]::WriteAllText($pluginsJs, $newText, $utf8) +Write-Host "Declared Forge_MZ at the end of plugins.js" +Write-Host "" +Write-Host "Installed! Start the game and press F10 to open Forge." -ForegroundColor Green +PauseExit diff --git a/util/forge/Forge_MZ.js b/util/forge/Forge_MZ.js new file mode 100644 index 0000000..e73684e --- /dev/null +++ b/util/forge/Forge_MZ.js @@ -0,0 +1,1093 @@ +//============================================================================= +// Forge_MZ.js — In-game cheat & editor overlay for RPG Maker MZ +//============================================================================= +/*: + * @target MZ + * @plugindesc Forge — modern in-game editor: variables, switches, items, actors, gold, teleport, common events, freeze/lock & battle cheats. (MZ) + * @author len (Forge - clean-room, MTool-inspired) + * @url + * + * @param hotkey + * @text Toggle Hotkey + * @desc Key to toggle the panel. + * @type string + * @default F10 + * + * @param speedKey + * @text Speedhack Key + * @desc Key to toggle fast-forward (e.g. Control, Shift, Alt, or a letter). + * @type string + * @default Control + * + * @param startOpen + * @text Open on start + * @desc Open the panel automatically on new game / load. + * @type boolean + * @default false + * + * @param itemMaxOverride + * @text Item "Max" value + * @desc Value used by the "Max" button for item stacks (0 = use engine maxItems). + * @type number + * @min 0 + * @default 0 + * + * @command open + * @text Open / Toggle Panel + * @desc Toggle the Forge overlay. + * + * @help + * Forge — drop-in cheat & editor for RPG Maker MZ. + * Place in js/plugins/ and enable in the Plugin Manager. Press F10 to open. + * + * Sections: Variables · Switches · Items/Weapons/Armors · Actors · Party/Gold · + * Map/Teleport · Common Events · Data Lock (freeze) · Battle cheats. + * + * All edits are RAM-only and never written to save files. No external requests. + * Plugin command "Open / Toggle Panel" toggles the overlay from events. + */ + +/* =================================================================================== + * Forge — in-game cheat & editor overlay for RPG Maker MV / MZ (shared core) + * Clean-room reimplementation of MTool's variable/value editor. Vanilla JS, zero deps. + * This file is the engine-agnostic core; the MV/MZ ship files prepend the plugin header. + * Layers: ops (DOM-free, testable) · locks (prototype monkeypatches) · cheats · ui (Shadow DOM) + * =================================================================================== */ +(function (root) { + 'use strict'; + if (root && root.__FORGE_CORE__) return; + + // ---------- environment ---------- + var IS_MZ = (typeof Utils !== 'undefined' && Utils.RPGMAKER_NAME === 'MZ'); + var nodeFs = null, nodePath = null; + try { if (typeof require === 'function') { nodeFs = require('fs'); nodePath = require('path'); } } catch (e) {} + function gameCwd() { try { return process.cwd(); } catch (e) { return '.'; } } + + var PARAM_NAMES = ['Max HP', 'Max MP', 'Attack', 'Defense', 'M.Attack', 'M.Defense', 'Agility', 'Luck']; + + // ---------- small utils ---------- + function clampInt(v, lo, hi) { v = Math.floor(Number(v) || 0); if (v < lo) v = lo; if (hi != null && v > hi) v = hi; return v; } + function defined(x) { return typeof x !== 'undefined' && x !== null; } + function dataName(d) { return d && d.name ? d.name : ''; } + // actor.level may be a getter (number), a method, or only the _level field across engines/plugins. + function actorLevel(a) { if (!a) return 0; if (typeof a.level === 'number') return a.level; if (typeof a.level === 'function') return a.level(); return a._level || 1; } + + // =================================================================================== + // OPS — pure logic, only touches $game*/$data* globals. Unit-testable. + // =================================================================================== + var ops = {}; + + ops.vars = { + count: function () { return defined(window.$dataSystem) ? $dataSystem.variables.length - 1 : 0; }, + name: function (id) { return (defined(window.$dataSystem) && $dataSystem.variables[id]) || ('Variable ' + id); }, + get: function (id) { return defined(window.$gameVariables) ? $gameVariables.value(id) : 0; }, + set: function (id, v) { if (!defined(window.$gameVariables)) return; $gameVariables.setValue(id, typeof v === 'string' && v.trim() !== '' && !isNaN(v) ? Number(v) : v); }, + list: function () { + var out = [], n = this.count(); + for (var id = 1; id <= n; id++) out.push({ id: id, name: this.name(id), value: this.get(id) }); + return out; + } + }; + + ops.switches = { + count: function () { return defined(window.$dataSystem) ? $dataSystem.switches.length - 1 : 0; }, + name: function (id) { return (defined(window.$dataSystem) && $dataSystem.switches[id]) || ('Switch ' + id); }, + get: function (id) { return defined(window.$gameSwitches) ? $gameSwitches.value(id) : false; }, + set: function (id, v) { if (!defined(window.$gameSwitches)) return; $gameSwitches.setValue(id, !!v); }, + toggle: function (id) { this.set(id, !this.get(id)); }, + list: function () { + var out = [], n = this.count(); + for (var id = 1; id <= n; id++) out.push({ id: id, name: this.name(id), value: this.get(id) }); + return out; + } + }; + + var INV_TABLES = { item: '$dataItems', weapon: '$dataWeapons', armor: '$dataArmors' }; + ops.inv = { + table: function (kind) { return window[INV_TABLES[kind]] || []; }, + data: function (kind, id) { return this.table(kind)[id] || null; }, + count: function (kind, id) { var d = this.data(kind, id); return d && defined(window.$gameParty) ? $gameParty.numItems(d) : 0; }, + maxStack: function (kind, id) { if (MTC._itemMaxOverride > 0) return MTC._itemMaxOverride; var d = this.data(kind, id); return d && defined(window.$gameParty) ? $gameParty.maxItems(d) : 99; }, + grant: function (kind, id, n) { var d = this.data(kind, id); if (d && defined(window.$gameParty)) $gameParty.gainItem(d, n, false); if (locks.isItemFrozen(kind, id)) LOCK.items.set(itemKey(kind, id), { kind: kind, id: id, value: this.count(kind, id) }); }, + setCount: function (kind, id, target) { this.grant(kind, id, clampInt(target, 0, null) - this.count(kind, id)); }, + max: function (kind, id) { this.setCount(kind, id, this.maxStack(kind, id)); }, + list: function (kind) { + var t = this.table(kind), out = []; + for (var id = 1; id < t.length; id++) { + var d = t[id]; if (!d || !d.name) continue; + out.push({ id: id, name: d.name, description: d.description || '', iconIndex: d.iconIndex || 0, count: $gameParty.numItems(d) }); + } + return out; + } + }; + + ops.gold = { + get: function () { return defined(window.$gameParty) ? $gameParty.gold() : 0; }, + max: function () { return defined(window.$gameParty) && $gameParty.maxGold ? $gameParty.maxGold() : 99999999; }, + add: function (n) { + if (!defined(window.$gameParty)) return; + if (LOCK.gold != null) { LOCK.gold = clampInt(LOCK.gold + n, 0, this.max()); (locks._origGainGold || $gameParty.gainGold).call($gameParty, LOCK.gold - $gameParty.gold()); return; } + if (n >= 0) $gameParty.gainGold(n); else $gameParty.loseGold(-n); + }, + set: function (target) { this.add(clampInt(target, 0, this.max()) - this.get()); } + }; + + ops.actors = { + list: function () { + var out = []; + if (!defined(window.$dataActors)) return out; + for (var id = 1; id < $dataActors.length; id++) { + var d = $dataActors[id]; if (!d) continue; + var a = $gameActors.actor(id); + out.push({ id: id, name: a ? a.name() : d.name, level: a ? actorLevel(a) : (d.initialLevel || 1), inParty: $gameParty._actors ? $gameParty._actors.indexOf(id) >= 0 : false }); + } + return out; + }, + get: function (id) { + var a = $gameActors.actor(id); if (!a) return null; + var params = []; + for (var p = 0; p < 8; p++) params.push({ id: p, name: PARAM_NAMES[p], value: a.param(p) }); + var skills = []; + if (defined(window.$dataSkills)) for (var s = 1; s < $dataSkills.length; s++) { var sk = $dataSkills[s]; if (sk && sk.name) skills.push({ id: s, name: sk.name, learned: a.isLearnedSkill ? a.isLearnedSkill(s) : (a.skills().indexOf(sk) >= 0) }); } + var equips = [], slots = a.equipSlots ? a.equipSlots() : [], cur = a.equips(); + for (var e = 0; e < slots.length; e++) equips.push({ slot: e, etypeId: slots[e], current: cur[e] ? cur[e].name : '(none)' }); + return { + id: id, name: a.name(), level: actorLevel(a), exp: a.currentExp ? a.currentExp() : 0, + nextExp: a.nextLevelExp ? a.nextLevelExp() : 0, + hp: a.hp, mp: a.mp, tp: a.tp, mhp: a.mhp, mmp: a.mmp, + params: params, skills: skills, equips: equips, + inParty: $gameParty._actors ? $gameParty._actors.indexOf(id) >= 0 : false + }; + }, + setLevel: function (id, lv) { var a = $gameActors.actor(id); if (a) { var cap = a.maxLevel ? a.maxLevel() : 99; a.changeLevel(clampInt(lv, 1, cap), false); a.refresh(); } }, + setExp: function (id, exp) { var a = $gameActors.actor(id); if (a) { a.changeExp(clampInt(exp, 0, null), false); a.refresh(); } }, + setHp: function (id, v) { var a = $gameActors.actor(id); if (a) { a.setHp(clampInt(v, 0, a.mhp)); a.refresh(); } }, + setMp: function (id, v) { var a = $gameActors.actor(id); if (a) { a.setMp(clampInt(v, 0, a.mmp)); a.refresh(); } }, + setTp: function (id, v) { var a = $gameActors.actor(id); if (a && a.setTp) { a.setTp(clampInt(v, 0, 100)); a.refresh(); } }, + addParam: function (id, pid, delta) { var a = $gameActors.actor(id); if (a) { a.addParam(pid, Math.floor(delta)); a.refresh(); } }, + // param(pid) = round((paramBase+paramPlus) * paramRate * paramBuffRate) clamped to [paramMin,paramMax]. + // addParam only mutates paramPlus, so invert the rate to land the *visible* param on target. + setParam: function (id, pid, target) { + var a = $gameActors.actor(id); if (!a) return; + var rate = (a.paramRate ? a.paramRate(pid) : 1) * (a.paramBuffRate ? a.paramBuffRate(pid) : 1) || 1; + var cap = a.paramMax ? a.paramMax(pid) : null; + var want = clampInt(target, a.paramMin ? a.paramMin(pid) : 0, cap); + var base = a.paramBase ? a.paramBase(pid) : 0, plus = (a.paramPlus ? a.paramPlus(pid) : (a._paramPlus ? a._paramPlus[pid] : 0)) || 0; + a.addParam(pid, Math.round(want / rate) - base - plus); + a.refresh(); + }, + learn: function (id, skillId) { var a = $gameActors.actor(id); if (a) a.learnSkill(skillId); }, + forget: function (id, skillId) { var a = $gameActors.actor(id); if (a) a.forgetSkill(skillId); }, + equip: function (id, slotId, kind, itemId) { var a = $gameActors.actor(id); if (!a) return; var item = itemId ? ops.inv.data(kind, itemId) : null; if (a.forceChangeEquip) a.forceChangeEquip(slotId, item); else a.changeEquip(slotId, item); a.refresh(); }, + recover: function (id) { var a = $gameActors.actor(id); if (a) { a.recoverAll(); a.refresh(); } }, + setName: function (id, name) { var a = $gameActors.actor(id); if (a) a.setName(String(name)); }, + addToParty: function (id) { $gameParty.addActor(id); }, + removeFromParty: function (id) { $gameParty.removeActor(id); } + }; + + ops.party = { + members: function () { return defined(window.$gameParty) ? $gameParty.members().map(function (a) { return { id: a.actorId(), name: a.name(), level: actorLevel(a) }; }) : []; }, + add: function (id) { if (defined(window.$gameParty)) $gameParty.addActor(id); }, + remove: function (id) { if (defined(window.$gameParty)) $gameParty.removeActor(id); }, + steps: function () { return defined(window.$gameParty) ? $gameParty.steps() : 0; }, + // RPG Maker derives playtime from frame count (Game_System has no playtime()). + playtime: function () { return (typeof Graphics !== 'undefined' && Graphics.frameCount) ? Math.floor(Graphics.frameCount / 60) : 0; } + }; + + ops.map = { + list: function () { + var out = []; + if (!defined(window.$dataMapInfos)) return out; + for (var id = 1; id < $dataMapInfos.length; id++) { var m = $dataMapInfos[id]; if (m) out.push({ id: id, name: m.name || ('Map ' + id) }); } + return out; + }, + current: function () { if (!defined(window.$gameMap) || !defined(window.$gamePlayer)) return { mapId: 0, x: 0, y: 0, dir: 2 }; return { mapId: $gameMap.mapId(), x: $gamePlayer.x, y: $gamePlayer.y, dir: $gamePlayer.direction ? $gamePlayer.direction() : 2 }; }, + teleport: function (mapId, x, y, dir, fade) { $gamePlayer.reserveTransfer(mapId, clampInt(x, 0, null), clampInt(y, 0, null), dir || 0, fade == null ? 0 : fade); }, + loadMapFile: function (mapId) { + if (!nodeFs) return null; + try { var f = 'Map' + String(mapId).padStart(3, '0') + '.json'; return JSON.parse(nodeFs.readFileSync(nodePath.join(gameCwd(), 'data', f), 'utf8')); } + catch (e) { return null; } + }, + noclip: function (b) { locks.state.noclip = !!b; if (defined(window.$gamePlayer)) $gamePlayer.setThrough(!!b); }, + isNoclip: function () { return !!locks.state.noclip; }, + speed: function (n) { if (defined(window.$gamePlayer)) $gamePlayer.setMoveSpeed(clampInt(n, 1, 8)); } + }; + + ops.commonev = { + list: function () { + var out = []; + if (!defined(window.$dataCommonEvents)) return out; + for (var id = 1; id < $dataCommonEvents.length; id++) { var c = $dataCommonEvents[id]; if (c) out.push({ id: id, name: c.name || ('Common Event ' + id) }); } + return out; + }, + run: function (id) { if (defined(window.$gameTemp)) $gameTemp.reserveCommonEvent(id); } + }; + + ops.battle = { + inBattle: function () { + if (typeof SceneManager === 'undefined' || !SceneManager._scene) return false; + if (typeof Scene_Battle !== 'undefined' && SceneManager._scene instanceof Scene_Battle) return true; + var c = SceneManager._scene.constructor; // fallback if class binding unavailable + return !!(c && c.name === 'Scene_Battle'); + }, + instantWin: function () { + if (typeof $gameTroop === 'undefined' || !$gameTroop) return; + $gameTroop.members().forEach(function (e) { + e.setHp(0); + if (e.deathStateId && e.addState) e.addState(e.deathStateId()); // mark actually dead + else if (e.die) e.die(); + if (e.performCollapse) e.performCollapse(); // visual collapse + e.refresh(); + }); + if (typeof BattleManager !== 'undefined' && BattleManager.checkBattleEnd) BattleManager.checkBattleEnd(); + }, + flee: function () { if (typeof BattleManager !== 'undefined' && BattleManager.processAbort) BattleManager.processAbort(); } + }; + + // =================================================================================== + // LOCKS — prototype-level monkeypatches + per-frame re-assertion. Installed once. + // =================================================================================== + var LOCK = { vars: new Map(), switches: new Map(), items: new Map(), gold: null, stats: new Map(), noclip: false }; + function itemKey(kind, id) { return kind + ':' + id; } + + var locks = { + state: LOCK, + _installed: false, + install: function () { + if (this._installed) return; this._installed = true; + // Variables + if (typeof Game_Variables !== 'undefined') { + var _sv = Game_Variables.prototype.setValue; + Game_Variables.prototype.setValue = function (id, value) { if (LOCK.vars.has(id)) value = LOCK.vars.get(id); _sv.call(this, id, value); }; + } + // Switches + if (typeof Game_Switches !== 'undefined') { + var _ss = Game_Switches.prototype.setValue; + Game_Switches.prototype.setValue = function (id, value) { if (LOCK.switches.has(id)) value = LOCK.switches.get(id); _ss.call(this, id, value); }; + } + // Gold: substitute (not block) so freezeGold can re-snapshot on edit; item freeze is tick-based + // (NOT a gainItem block) so equip bookkeeping / give-take still runs, corrected next frame. + if (typeof Game_Party !== 'undefined') { + var _gg = Game_Party.prototype.gainGold; + locks._origGainGold = _gg; + Game_Party.prototype.gainGold = function (n) { if (LOCK.gold != null) return; _gg.call(this, n); }; + } + // Battle: god mode + OHKO via one executeHpDamage override + if (typeof Game_Action !== 'undefined') { + var _ehd = Game_Action.prototype.executeHpDamage; + Game_Action.prototype.executeHpDamage = function (target, value) { + if (CHEAT.god && target.isActor && target.isActor() && value > 0) value = 0; + if (CHEAT.ohko && this.subject().isActor && this.subject().isActor() && target.isEnemy && target.isEnemy()) value = target.hp; + _ehd.call(this, target, value); + }; + } + // God mode also blocks non-action HP loss for actors (slip/regen, event Change-HP) + floor damage. + if (typeof Game_Battler !== 'undefined' && Game_Battler.prototype.gainHp) { + var _gh = Game_Battler.prototype.gainHp; + Game_Battler.prototype.gainHp = function (value) { if (CHEAT.god && this.isActor && this.isActor() && value < 0) value = 0; _gh.call(this, value); }; + } + if (typeof Game_Actor !== 'undefined' && Game_Actor.prototype.executeFloorDamage) { + var _efd = Game_Actor.prototype.executeFloorDamage; + Game_Actor.prototype.executeFloorDamage = function () { if (CHEAT.god) return; _efd.call(this); }; + } + // No encounters + if (typeof Game_Player !== 'undefined') { + var _epv = Game_Player.prototype.encounterProgressValue; + Game_Player.prototype.encounterProgressValue = function () { return CHEAT.noEncounter ? 0 : _epv.call(this); }; + } + // Speedhack: drive the scene update extra times per frame (speed>1) or skip frames (speed<1). + // Input is still read once per real frame, so the whole game (movement, anim, battle, messages, + // events, timers) runs faster/slower. Works for MV and MZ (both expose SceneManager.updateScene). + if (typeof SceneManager !== 'undefined' && SceneManager.updateScene) { + var _updateScene = SceneManager.updateScene, _spdAcc = 0; + // Extra updates are only safe in a steady scene: the scene must be started, not changing, and + // (on a map) not mid-transfer — otherwise $dataMap is briefly null and the scroll code throws. + function speedSafe(mgr) { + if (mgr.isCurrentSceneStarted) { if (!mgr.isCurrentSceneStarted()) return false; } // MV + else if (mgr._scene && mgr._scene.isStarted) { if (!mgr._scene.isStarted()) return false; } // MZ + if (mgr.isSceneChanging && mgr.isSceneChanging()) return false; // mid scene transition + if (typeof $gamePlayer !== 'undefined' && $gamePlayer && $gamePlayer.isTransferring && $gamePlayer.isTransferring()) return false; + if (typeof DataManager !== 'undefined' && DataManager.isMapLoaded && !DataManager.isMapLoaded()) return false; // map (re)loading + return true; + } + SceneManager.updateScene = function () { + var sp = CHEAT.speed || 1; + if (sp >= 1) { + _updateScene.call(this); + if (sp > 1 && this._scene && typeof this._scene.update === 'function' && speedSafe(this)) { + _spdAcc += (sp - 1); + var guard = 0; + while (_spdAcc >= 1 && guard++ < 64) { _spdAcc -= 1; try { this._scene.update(); } catch (e) { _spdAcc = 0; break; } } + } else { _spdAcc = 0; } // not safe / not boosting -> don't bank up a burst of catch-up updates + } else { + _spdAcc += sp; + if (_spdAcc >= 1) { _spdAcc -= 1; _updateScene.call(this); } + else if (this._scene) { // skip the game advance this frame, keep start/ready bookkeeping + var u = this._scene.update; this._scene.update = function () {}; + try { _updateScene.call(this); } finally { this._scene.update = u; } + } + } + }; + } + // Clear stale freeze snapshots when the engine swaps in fresh game objects (new game / load). + if (typeof DataManager !== 'undefined') { + if (DataManager.setupNewGame) { var _sng = DataManager.setupNewGame; DataManager.setupNewGame = function () { locks.clearAll(); _sng.call(this); }; } + if (DataManager.extractSaveContents) { var _esc = DataManager.extractSaveContents; DataManager.extractSaveContents = function (c) { locks.clearAll(); return _esc.call(this, c); }; } + } + }, + // Freeze setters update both the lock map and the live value once. Maps store parsed objects so + // the per-frame tick never re-parses string keys. + freezeVar: function (id, on) { if (on) { LOCK.vars.set(id, ops.vars.get(id)); ops.vars.set(id, ops.vars.get(id)); } else LOCK.vars.delete(id); }, + freezeSwitch: function (id, on) { if (on) LOCK.switches.set(id, ops.switches.get(id)); else LOCK.switches.delete(id); }, + freezeItem: function (kind, id, on) { var k = itemKey(kind, id); if (on) LOCK.items.set(k, { kind: kind, id: id, value: ops.inv.count(kind, id) }); else LOCK.items.delete(k); }, + freezeGold: function (on) { LOCK.gold = on ? ops.gold.get() : null; }, + freezeStat: function (actorId, stat, on) { var k = actorId + ':' + stat; if (on) { var a = defined(window.$gameActors) ? $gameActors.actor(actorId) : null; LOCK.stats.set(k, { actorId: actorId, stat: stat, value: a ? a[stat] : 0 }); } else LOCK.stats.delete(k); }, + isVarFrozen: function (id) { return LOCK.vars.has(id); }, + isSwitchFrozen: function (id) { return LOCK.switches.has(id); }, + isItemFrozen: function (kind, id) { return LOCK.items.has(itemKey(kind, id)); }, + isStatFrozen: function (actorId, stat) { return LOCK.stats.has(actorId + ':' + stat); }, + // Per-frame: re-assert values the overrides can't catch (hp/mp/tp, gold target, item counts, noclip). + tick: function () { + if (!LOCK.noclip && LOCK.gold == null && !LOCK.stats.size && !LOCK.items.size) return; // early-out + if (LOCK.noclip && defined(window.$gamePlayer) && !$gamePlayer.isThrough()) $gamePlayer.setThrough(true); + if (LOCK.gold != null && defined(window.$gameParty) && $gameParty.gold() !== LOCK.gold) (locks._origGainGold || $gameParty.gainGold).call($gameParty, LOCK.gold - $gameParty.gold()); + if (LOCK.stats.size && defined(window.$gameActors)) { + LOCK.stats.forEach(function (e) { + var a = $gameActors.actor(e.actorId); if (!a) return; + if (e.stat === 'hp' && a.hp !== e.value) a.setHp(Math.min(e.value, a.mhp)); + else if (e.stat === 'mp' && a.mp !== e.value) a.setMp(Math.min(e.value, a.mmp)); + else if (e.stat === 'tp' && a.tp !== e.value && a.setTp) a.setTp(e.value); + }); + } + if (LOCK.items.size && defined(window.$gameParty)) { + LOCK.items.forEach(function (e) { if (ops.inv.count(e.kind, e.id) !== e.value) { var d = ops.inv.data(e.kind, e.id); if (d) $gameParty.gainItem(d, e.value - ops.inv.count(e.kind, e.id), false); } }); + } + }, + summary: function () { + var out = []; + LOCK.vars.forEach(function (v, id) { out.push({ type: 'var', id: id, label: 'Var ' + id + ' · ' + ops.vars.name(id), value: v }); }); + LOCK.switches.forEach(function (v, id) { out.push({ type: 'switch', id: id, label: 'Switch ' + id + ' · ' + ops.switches.name(id), value: v ? 'ON' : 'OFF' }); }); + LOCK.items.forEach(function (e, key) { out.push({ type: 'item', key: key, label: e.kind + ' ' + e.id + ' · ' + (ops.inv.data(e.kind, e.id) ? ops.inv.data(e.kind, e.id).name : ''), value: e.value }); }); + LOCK.stats.forEach(function (e, key) { out.push({ type: 'stat', key: key, label: 'Actor ' + e.actorId + ' · ' + e.stat.toUpperCase(), value: e.value }); }); + if (LOCK.gold != null) out.push({ type: 'gold', label: 'Gold (frozen)', value: LOCK.gold }); + if (LOCK.noclip) out.push({ type: 'noclip', label: 'Noclip (active)', value: 'ON' }); + return out; + }, + clearAll: function () { LOCK.vars.clear(); LOCK.switches.clear(); LOCK.items.clear(); LOCK.stats.clear(); LOCK.gold = null; if (LOCK.noclip) ops.map.noclip(false); } + }; + + // =================================================================================== + // CHEATS — global toggles consumed by the lock overrides + battle actions. + // =================================================================================== + var CHEAT = { god: false, ohko: false, noEncounter: false, speed: 1, uiFocused: false }; + var cheats = { + state: CHEAT, + setGod: function (b) { CHEAT.god = !!b; }, + setOhko: function (b) { CHEAT.ohko = !!b; }, + setNoEncounter: function (b) { CHEAT.noEncounter = !!b; }, + setSpeed: function (n) { CHEAT.speed = Math.max(0.1, Math.min(Number(n) || 1, 16)); }, + toggleSpeed: function () { this.setSpeed(CHEAT.speed !== 1 ? 1 : (MTC._speedBoost || 2)); return CHEAT.speed; }, + instantWin: function () { ops.battle.instantWin(); }, + flee: function () { ops.battle.flee(); } + }; + + // expose the assembled API (UI is attached below if a real DOM is present) + var MTC = { env: { IS_MZ: IS_MZ }, ops: ops, locks: locks, cheats: cheats, LOCK: LOCK, CHEAT: CHEAT, PARAM_NAMES: PARAM_NAMES, _itemMaxOverride: 0, _hotkey: 'F10', _speedKey: 'Control', _speedBoost: 2 }; + if (root) { root.Forge = MTC; root.__FORGE_CORE__ = true; } + if (typeof module !== 'undefined' && module.exports) module.exports = MTC; + + // ---- engine boot hooks (guarded; only when classes exist) ---- + if (typeof Scene_Base !== 'undefined') { + var _su = Scene_Base.prototype.update; + Scene_Base.prototype.update = function () { _su.call(this); try { locks.tick(); } catch (e) {} }; + } + // install lock overrides as soon as the game classes are available + try { locks.install(); } catch (e) {} + + // ---- UI: attach only when a real browser DOM is available (skipped in Node tests) ---- + if (typeof document !== 'undefined' && document.body && typeof document.body.attachShadow === 'function' && typeof window !== 'undefined') { + try { MTC.ui = buildUI(MTC); } catch (e) { try { console.error('[Forge] UI init failed', e); } catch (_) {} } + } + + // =================================================================================== + // UI — Shadow-DOM overlay. Inlined so the single ship file works with zero deps. + // =================================================================================== + function buildUI(API) { + var LS = 'forge:'; + function store(k, v) { try { localStorage.setItem(LS + k, JSON.stringify(v)); } catch (e) {} } + function load(k, d) { try { var s = localStorage.getItem(LS + k); return s == null ? d : JSON.parse(s); } catch (e) { return d; } } + // persisted speed settings (UI-controlled; override core/param defaults if the user changed them) + API._speedKey = load('speedKey', API._speedKey || 'Control'); + API._speedUseKey = load('speedUseKey', true); + API._speedBoost = load('speedBoost', API._speedBoost || 2); + if (!API._speedUseKey) API.cheats.setSpeed(API._speedBoost); // "always on" mode applies immediately + + function el(tag, props, kids) { + var n = document.createElement(tag); + if (props) for (var k in props) { + if (k === 'class') n.className = props[k]; + else if (k === 'text') n.textContent = props[k]; + else if (k === 'html') n.innerHTML = props[k]; + else if (k === 'style') n.setAttribute('style', props[k]); + else if (k.slice(0, 2) === 'on' && typeof props[k] === 'function') n.addEventListener(k.slice(2).toLowerCase(), props[k]); + else if (props[k] != null) n.setAttribute(k, props[k]); + } + if (kids) (Array.isArray(kids) ? kids : [kids]).forEach(function (c) { if (c != null) n.appendChild(typeof c === 'string' ? document.createTextNode(c) : c); }); + return n; + } + + var CSS = "\ +:host{position:fixed!important;top:0!important;left:0!important;width:0!important;height:0!important;z-index:2147483647!important;margin:0!important;padding:0!important;border:0!important;pointer-events:none!important;contain:none}\ +*{box-sizing:border-box;font-family:var(--font-ui);-webkit-user-select:none;user-select:none}\ +#forge-root{--font-ui:system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;--font-mono:ui-monospace,'Cascadia Code',Consolas,monospace;\ +--fg-bg:#15171c;--fg-bg-elev:#1c1f27;--fg-bg-input:#21242d;--fg-bg-row-alt:#181b22;--fg-border:#2b2f3a;--fg-border-strong:#3a3f4d;\ +--fg-text:#e6e9ef;--fg-text-dim:#9aa1b1;--fg-text-faint:#646b7d;--fg-accent:#5b8cff;--fg-accent-weak:#2a3a63;--fg-success:#3ecf8e;--fg-warn:#f0b132;--fg-danger:#f0556a;--fg-frozen:#8b5cf6;\ +--sp-1:4px;--sp-2:8px;--sp-3:12px;--sp-4:16px;--sp-5:24px;--rad-sm:4px;--rad-md:7px;--rad-lg:11px;--rad-pill:999px;\ +--sh-panel:0 18px 50px rgba(0,0,0,.5),0 0 0 1px var(--fg-border);--row-h:30px;--rail-w:64px;--ease:cubic-bezier(.2,.7,.3,1);--dur:120ms;\ +--fs-xs:11px;--fs-sm:12px;--fs-md:13px;--fs-lg:15px;color:var(--fg-text);font-size:var(--fs-md)}\ +#forge-root[data-theme=light]{--fg-bg:#f6f7f9;--fg-bg-elev:#fff;--fg-bg-input:#eef0f4;--fg-bg-row-alt:#f0f2f5;--fg-border:#dde0e7;--fg-border-strong:#c7ccd8;--fg-text:#1a1d24;--fg-text-dim:#5a6172;--fg-text-faint:#9097a6;--fg-accent-weak:#dce6ff}\ +.fg-panel{position:fixed;display:flex;flex-direction:column;background:var(--fg-bg);border-radius:var(--rad-lg);box-shadow:var(--sh-panel);overflow:hidden;z-index:2147483000;min-width:480px;min-height:360px;pointer-events:auto}\ +.fg-titlebar{display:flex;align-items:center;gap:var(--sp-2);height:38px;padding:0 var(--sp-2) 0 var(--sp-3);background:var(--fg-bg-elev);border-bottom:1px solid var(--fg-border);cursor:move;flex:0 0 auto}\ +.fg-brand{font-weight:700;font-size:var(--fs-md);letter-spacing:.3px;color:var(--fg-text)}\ +.fg-brand b{color:var(--fg-accent)}\ +.fg-search{flex:1;height:26px;background:var(--fg-bg-input);border:1px solid var(--fg-border);border-radius:var(--rad-md);color:var(--fg-text);padding:0 var(--sp-3);font-size:var(--fs-sm);outline:none;min-width:80px}\ +.fg-search:focus{border-color:var(--fg-accent)}\ +.fg-iconbtn{display:inline-flex;align-items:center;justify-content:center;width:26px;height:26px;border-radius:var(--rad-md);background:transparent;color:var(--fg-text-dim);border:0;cursor:pointer;font-size:14px;transition:background var(--dur)}\ +.fg-iconbtn:hover{background:var(--fg-bg-input);color:var(--fg-text)}\ +.fg-body{flex:1;display:flex;min-height:0}\ +.fg-rail{flex:0 0 var(--rail-w);display:flex;flex-direction:column;gap:2px;padding:var(--sp-2) var(--sp-1);background:var(--fg-bg-elev);border-right:1px solid var(--fg-border);overflow-y:auto}\ +.fg-rail__item{display:flex;flex-direction:column;align-items:center;gap:2px;padding:var(--sp-2) 0;border-radius:var(--rad-md);cursor:pointer;color:var(--fg-text-dim);font-size:10px;transition:all var(--dur);border:0;background:transparent}\ +.fg-rail__item .ic{font-size:17px;line-height:1}\ +.fg-rail__item:hover{background:var(--fg-bg-input);color:var(--fg-text)}\ +.fg-rail__item.is-active{background:var(--fg-accent-weak);color:var(--fg-accent)}\ +.fg-content{flex:1;display:flex;flex-direction:column;min-width:0}\ +.fg-section-head{display:flex;align-items:center;gap:var(--sp-2);padding:var(--sp-3) var(--sp-4) var(--sp-2);flex:0 0 auto}\ +.fg-section-head h2{margin:0;font-size:var(--fs-lg);font-weight:600}\ +.fg-count{font-size:var(--fs-xs);color:var(--fg-text-faint);background:var(--fg-bg-input);padding:2px 7px;border-radius:var(--rad-pill)}\ +.fg-spacer{flex:1}\ +.fg-subtoolbar{display:flex;align-items:center;gap:var(--sp-2);padding:0 var(--sp-4) var(--sp-2);flex-wrap:wrap}\ +.fg-segmented{display:inline-flex;background:var(--fg-bg-input);border-radius:var(--rad-md);padding:2px}\ +.fg-segmented button{border:0;background:transparent;color:var(--fg-text-dim);padding:3px 12px;border-radius:5px;cursor:pointer;font-size:var(--fs-sm)}\ +.fg-segmented button.is-active{background:var(--fg-accent);color:#fff}\ +.fg-btn{border:1px solid var(--fg-border-strong);background:var(--fg-bg-input);color:var(--fg-text);padding:4px 11px;border-radius:var(--rad-md);cursor:pointer;font-size:var(--fs-sm);transition:all var(--dur)}\ +.fg-btn:hover{border-color:var(--fg-accent);color:var(--fg-accent)}\ +.fg-btn--ghost{background:transparent;border-color:transparent;color:var(--fg-text-dim)}\ +.fg-btn--danger:hover{border-color:var(--fg-danger);color:var(--fg-danger)}\ +.fg-list{flex:1;overflow-y:auto;overflow-x:hidden;position:relative;border-top:1px solid var(--fg-border)}\ +.fg-list__sizer{position:relative;width:100%}\ +.fg-list__window{position:absolute;top:0;left:0;right:0}\ +.fg-row{display:flex;align-items:center;gap:var(--sp-2);height:var(--row-h);padding:0 var(--sp-4);border-bottom:1px solid var(--fg-border);font-size:var(--fs-sm)}\ +.fg-row:nth-child(even){background:var(--fg-bg-row-alt)}\ +.fg-row.is-frozen{box-shadow:inset 3px 0 0 var(--fg-frozen)}\ +.fg-row.is-selected{background:var(--fg-accent-weak)!important;box-shadow:inset 2px 0 0 var(--fg-accent)}\ +.fg-cell--id{flex:0 0 52px;color:var(--fg-text-faint);font-family:var(--font-mono);font-size:var(--fs-xs)}\ +.fg-cell--name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\ +.fg-cell--name .dot{display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--fg-accent);margin-right:6px;vertical-align:middle}\ +.fg-cell--val{flex:0 0 auto;display:flex;align-items:center;gap:4px}\ +.fg-stepper{display:inline-flex;align-items:center;gap:3px}\ +.fg-step{min-width:30px;width:auto;padding:0 5px;height:22px;white-space:nowrap;border:1px solid var(--fg-border-strong);background:var(--fg-bg-input);color:var(--fg-text-dim);border-radius:5px;cursor:pointer;font-size:var(--fs-xs);font-family:var(--font-mono)}\ +.fg-step:hover{color:var(--fg-accent);border-color:var(--fg-accent)}\ +.fg-num{width:68px;height:22px;background:var(--fg-bg-input);border:1px solid var(--fg-border);border-radius:5px;color:var(--fg-text);text-align:right;padding:0 6px;font-family:var(--font-mono);font-size:var(--fs-sm);outline:none}\ +.fg-num:focus{border-color:var(--fg-accent)}\ +.fg-toggle{width:38px;height:21px;border-radius:var(--rad-pill);background:var(--fg-border-strong);position:relative;cursor:pointer;transition:background var(--dur);border:0;flex:0 0 auto}\ +.fg-toggle::after{content:'';position:absolute;top:2px;left:2px;width:17px;height:17px;border-radius:50%;background:#fff;transition:transform var(--dur)}\ +.fg-toggle.is-on{background:var(--fg-success)}\ +.fg-toggle.is-on::after{transform:translateX(17px)}\ +.fg-lock{width:24px;height:24px;border:0;background:transparent;color:var(--fg-text-faint);cursor:pointer;border-radius:5px;font-size:13px;flex:0 0 auto}\ +.fg-lock:hover{color:var(--fg-frozen);background:var(--fg-bg-input)}\ +.fg-lock.is-frozen{color:var(--fg-frozen)}\ +.fg-statusbar{display:flex;align-items:center;gap:var(--sp-2);height:24px;padding:0 var(--sp-3);background:var(--fg-bg-elev);border-top:1px solid var(--fg-border);font-size:var(--fs-xs);color:var(--fg-text-faint);flex:0 0 auto}\ +.fg-detail{padding:var(--sp-3) var(--sp-4);overflow-y:auto;flex:1}\ +.fg-field{display:flex;align-items:center;gap:var(--sp-2);padding:5px 0;border-bottom:1px solid var(--fg-border);min-width:0}\n.fg-field .fg-stepper{margin-left:auto;flex:0 0 auto}\ +.fg-field label{flex:0 0 96px;color:var(--fg-text-dim);font-size:var(--fs-sm)}\ +.fg-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(330px,1fr));gap:var(--sp-2) var(--sp-4)}\ +.fg-toasts{position:fixed;bottom:16px;left:50%;transform:translateX(-50%);display:flex;flex-direction:column;gap:6px;z-index:2147483100;pointer-events:none}\ +.fg-toast{background:var(--fg-bg-elev);border:1px solid var(--fg-border);border-left:3px solid var(--fg-accent);color:var(--fg-text);padding:8px 14px;border-radius:var(--rad-md);font-size:var(--fs-sm);box-shadow:var(--sh-panel);animation:fgin var(--dur) var(--ease)}\ +.fg-toast.warn{border-left-color:var(--fg-warn)}.fg-toast.danger{border-left-color:var(--fg-danger)}.fg-toast.ok{border-left-color:var(--fg-success)}\ +@keyframes fgin{from{opacity:0;transform:translateY(6px)}to{opacity:1}}\ +.fg-launcher{position:fixed;pointer-events:auto;width:42px;height:42px;border-radius:50%;background:var(--fg-accent);color:#fff;border:0;cursor:pointer;font-size:18px;z-index:2147482999;box-shadow:var(--sh-panel);display:flex;align-items:center;justify-content:center}\ +.fg-launcher:hover{filter:brightness(1.1)}\ +.fg-resize{position:absolute;right:0;bottom:0;width:16px;height:16px;cursor:nwse-resize}\ +.fg-resize::after{content:'';position:absolute;right:3px;bottom:3px;width:7px;height:7px;border-right:2px solid var(--fg-text-faint);border-bottom:2px solid var(--fg-text-faint)}\ +.fg-empty{padding:var(--sp-5);text-align:center;color:var(--fg-text-faint);font-size:var(--fs-sm)}\ +.fg-confirm{position:fixed;pointer-events:auto;inset:0;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.45);z-index:2147483200}\ +.fg-confirm__box{background:var(--fg-bg-elev);border:1px solid var(--fg-border);border-radius:var(--rad-lg);padding:var(--sp-4);max-width:340px;box-shadow:var(--sh-panel)}\ +.fg-confirm__box p{margin:0 0 var(--sp-4)}.fg-confirm__row{display:flex;justify-content:flex-end;gap:var(--sp-2)}\ +input::placeholder{color:var(--fg-text-faint)}\ +.fg-list::-webkit-scrollbar,.fg-rail::-webkit-scrollbar,.fg-detail::-webkit-scrollbar{width:9px}\ +.fg-list::-webkit-scrollbar-thumb,.fg-detail::-webkit-scrollbar-thumb{background:var(--fg-border-strong);border-radius:5px}"; + + // ---------- shadow host ---------- + var host = el('div', { id: 'forge-host' }); + host.style.cssText = 'position:fixed!important;top:0!important;left:0!important;width:0!important;height:0!important;z-index:2147483647!important;margin:0!important;padding:0!important;border:0!important;pointer-events:none!important'; + document.body.appendChild(host); + var shadow = host.attachShadow({ mode: 'open' }); + var rootEl = el('div', { id: 'forge-root' }); + rootEl.setAttribute('data-theme', load('theme', 'dark')); + shadow.appendChild(el('style', { text: CSS })); + shadow.appendChild(rootEl); + + // Swallow events so panel interaction never leaks to the game. BUBBLE phase: the panel's own + // inputs receive the event first (target phase), then we stop it before it reaches document + // (where RPG Maker's Input/TouchInput listen). Capture phase would block the inputs themselves. + ['keydown', 'keyup', 'keypress', 'wheel', 'mousedown', 'mouseup', 'mousemove', 'click', 'dblclick', + 'pointerdown', 'pointerup', 'pointermove', 'pointercancel', 'touchstart', 'touchend', 'touchmove', 'touchcancel', 'contextmenu'].forEach(function (t) { + rootEl.addEventListener(t, function (e) { e.stopPropagation(); }, false); + }); + function isEditable(el) { return el && (el.tagName === 'INPUT' || el.tagName === 'SELECT' || el.tagName === 'TEXTAREA'); } + rootEl.addEventListener('focusin', function (e) { if (isEditable(e.target)) API.CHEAT.uiFocused = true; }, true); + rootEl.addEventListener('focusout', function () { setTimeout(function () { API.CHEAT.uiFocused = !!(shadow.activeElement && isEditable(shadow.activeElement)); }, 0); }, true); + window.addEventListener('blur', function () { API.CHEAT.uiFocused = false; }); // never latch the game-input gate + // Backup engine-level gate: while a panel input is focused, drop the game's keyboard handler. + if (typeof Input !== 'undefined' && Input._onKeyDown) { var _okd = Input._onKeyDown; Input._onKeyDown = function (e) { if (API.CHEAT.uiFocused) return; _okd.call(this, e); }; } + if (typeof Input !== 'undefined' && Input._onKeyUp) { var _oku = Input._onKeyUp; Input._onKeyUp = function (e) { if (API.CHEAT.uiFocused) return; _oku.call(this, e); }; } + + // ---------- toasts ---------- + var toasts = el('div', { class: 'fg-toasts' }); rootEl.appendChild(toasts); + function toast(msg, kind) { var t = el('div', { class: 'fg-toast ' + (kind || ''), text: msg }); toasts.appendChild(t); setTimeout(function () { t.remove(); }, 2200); } + function confirmBox(msg, onYes) { + var box = el('div', { class: 'fg-confirm' }, el('div', { class: 'fg-confirm__box' }, [ + el('p', { text: msg }), + el('div', { class: 'fg-confirm__row' }, [ + el('button', { class: 'fg-btn fg-btn--ghost', text: 'Cancel', onclick: function () { box.remove(); } }), + el('button', { class: 'fg-btn fg-btn--danger', text: 'Confirm', onclick: function () { box.remove(); onYes(); } }) + ]) + ])); + rootEl.appendChild(box); + } + + // ---------- panel skeleton ---------- + var vw = window.innerWidth || 816, vh = window.innerHeight || 624; + // Default size fits the window (many MV games are ~816x624); leave a margin. + var st = load('panel', { x: 16, y: 16, w: Math.min(880, vw - 32), h: Math.min(560, vh - 32) }); + st.w = Math.max(420, Math.min(st.w, vw - 8)); st.h = Math.max(320, Math.min(st.h, vh - 8)); + st.x = Math.max(0, Math.min(st.x, vw - st.w)); st.y = Math.max(0, Math.min(st.y, vh - st.h)); // keep fully on-screen + var panel = el('div', { class: 'fg-panel' }); + panel.style.left = st.x + 'px'; panel.style.top = st.y + 'px'; panel.style.width = st.w + 'px'; panel.style.height = st.h + 'px'; + var search = el('input', { class: 'fg-search', type: 'text', placeholder: 'Search… (Ctrl+K)' }); + var titlebar = el('div', { class: 'fg-titlebar' }, [ + el('span', { class: 'fg-brand', html: 'Forge' }), + search, + el('button', { class: 'fg-iconbtn', title: 'Rescan', text: '⟳', onclick: function () { render(); toast('Rescanned'); } }), + el('button', { class: 'fg-iconbtn', title: 'Theme', text: '◑', onclick: function () { var t = rootEl.getAttribute('data-theme') === 'light' ? 'dark' : 'light'; rootEl.setAttribute('data-theme', t); store('theme', t); } }), + el('button', { class: 'fg-iconbtn', title: 'Close (F10)', text: '✕', onclick: function () { hide(); } }) + ]); + var rail = el('div', { class: 'fg-rail' }); + var content = el('div', { class: 'fg-content' }); + var statusbar = el('div', { class: 'fg-statusbar' }); + panel.appendChild(titlebar); + panel.appendChild(el('div', { class: 'fg-body' }, [rail, content])); + panel.appendChild(statusbar); + var resize = el('div', { class: 'fg-resize' }); panel.appendChild(resize); + rootEl.appendChild(panel); + + // ---------- launcher (docks to bottom-right corner by default; 'launcherPos' is a fresh key so + // any stale mid-screen position from older builds is ignored) ---------- + var lp = load('launcherPos', { x: vw - 56, y: vh - 56 }); + lp.x = Math.max(4, Math.min(lp.x, vw - 46)); lp.y = Math.max(4, Math.min(lp.y, vh - 46)); + var launcher = el('button', { class: 'fg-launcher', text: '⚒', title: 'Forge (F10)' }); + launcher.style.left = lp.x + 'px'; launcher.style.top = lp.y + 'px'; + launcher.addEventListener('click', function (e) { if (!launcher._dragged) toggle(); }); + rootEl.appendChild(launcher); + makeDraggable(launcher, launcher, function (x, y) { store('launcherPos', { x: x, y: y }); }, true); + + // ---------- drag / resize ---------- + function makeDraggable(handle, target, onEnd, isLauncher) { + handle.addEventListener('mousedown', function (e) { + if (e.target.closest && e.target.closest('input,button.fg-iconbtn')) return; + e.preventDefault(); e.stopPropagation(); var sx = e.clientX, sy = e.clientY, ox = parseInt(target.style.left) || 0, oy = parseInt(target.style.top) || 0, moved = false; + if (isLauncher) target._dragged = false; + function mv(ev) { ev.stopPropagation(); var dx = ev.clientX - sx, dy = ev.clientY - sy; if (Math.abs(dx) + Math.abs(dy) > 3) moved = true; var nx = Math.max(0, Math.min(window.innerWidth - target.offsetWidth, ox + dx)), ny = Math.max(0, Math.min(window.innerHeight - 30, oy + dy)); target.style.left = nx + 'px'; target.style.top = ny + 'px'; if (isLauncher) target._dragged = moved; } + function up(ev) { if (ev) { ev.stopPropagation(); ev.preventDefault(); } document.removeEventListener('mousemove', mv, true); document.removeEventListener('mouseup', up, true); if (onEnd) onEnd(parseInt(target.style.left), parseInt(target.style.top)); setTimeout(function () { if (isLauncher) target._dragged = false; }, 0); } + document.addEventListener('mousemove', mv, true); document.addEventListener('mouseup', up, true); + }); + } + makeDraggable(titlebar, panel, function (x, y) { st.x = x; st.y = y; store('panel', st); }); + resize.addEventListener('mousedown', function (e) { + e.preventDefault(); e.stopPropagation(); var sx = e.clientX, sy = e.clientY, ow = panel.offsetWidth, oh = panel.offsetHeight; + function mv(ev) { ev.stopPropagation(); var w = Math.max(480, ow + ev.clientX - sx), h = Math.max(360, oh + ev.clientY - sy); panel.style.width = w + 'px'; panel.style.height = h + 'px'; if (activeRenderer && activeRenderer.onResize) activeRenderer.onResize(); } + function up(ev) { if (ev) { ev.stopPropagation(); ev.preventDefault(); } document.removeEventListener('mousemove', mv, true); document.removeEventListener('mouseup', up, true); st.w = panel.offsetWidth; st.h = panel.offsetHeight; store('panel', st); } + document.addEventListener('mousemove', mv, true); document.addEventListener('mouseup', up, true); + }); + + // ---------- reusable virtualized list ---------- + function VirtualList(opts) { + // opts: rowH, getModel()->[], renderRow(rowEl, item), matches(item, query) + var listEl = el('div', { class: 'fg-list' }); + var sizer = el('div', { class: 'fg-list__sizer' }); + var win = el('div', { class: 'fg-list__window' }); + sizer.appendChild(win); listEl.appendChild(sizer); + var rowH = opts.rowH || 30, pool = [], model = [], view = [], query = ''; + // ONE delegated row click (recycled rows must not accumulate per-draw listeners). + if (opts.onRowClick) listEl.addEventListener('click', function (e) { var r = e.target && e.target.closest ? e.target.closest('.fg-row') : null; if (r && r._item) opts.onRowClick(r._item); }); + function rebuild() { model = opts.getModel() || []; applyFilter(); } + function applyFilter() { + view = query ? model.filter(function (it) { return opts.matches(it, query); }) : model; + sizer.style.height = (view.length * rowH) + 'px'; + var em = listEl.querySelector('.fg-empty'); + if (!view.length) { pool.forEach(function (r) { r.style.display = 'none'; }); if (!em) listEl.appendChild(el('div', { class: 'fg-empty', text: 'Nothing here' })); } + else if (em) em.remove(); + lastFirst = -1; draw(true); // content changed -> force re-render + } + var lastFirst = -1; + function draw(force) { + var scroll = listEl.scrollTop, h = listEl.clientHeight || 400; + var first = Math.max(0, Math.floor(scroll / rowH) - 4); + if (!force && first === lastFirst) return; // window unchanged during scroll -> skip re-render + lastFirst = first; + var count = Math.ceil(h / rowH) + 8; + var last = Math.min(view.length, first + count); + win.style.transform = 'translateY(' + (first * rowH) + 'px)'; + var need = last - first; + while (pool.length < need) { var r = el('div', { class: 'fg-row' }); win.appendChild(r); pool.push(r); } + for (var i = 0; i < pool.length; i++) { + var r = pool[i]; + if (i < need) { r.style.display = 'flex'; r._item = view[first + i]; opts.renderRow(r, view[first + i]); } else { r.style.display = 'none'; r._item = null; } + } + } + var raf = null; + listEl.addEventListener('scroll', function () { if (raf) return; raf = requestAnimationFrame(function () { raf = null; draw(false); }); }); + return { el: listEl, rebuild: rebuild, applyFilter: applyFilter, draw: function () { draw(true); }, setQuery: function (q) { query = q; applyFilter(); }, onResize: function () { draw(true); }, + refresh: function () { draw(true); } }; + } + + // numeric stepper control + function stepper(getVal, setVal, opts) { + opts = opts || {}; + var wrap = el('div', { class: 'fg-stepper' }); + var input = el('input', { class: 'fg-num', type: 'text' }); + function sync() { input.value = getVal(); } + function commit(v) { setVal(v); sync(); if (opts.after) opts.after(); } + [['−100', -100], ['−1', -1]].forEach(function (s) { wrap.appendChild(el('button', { class: 'fg-step', text: s[0], onclick: function (e) { commit(getVal() + s[1] * (e.shiftKey ? 10 : 1)); } })); }); + wrap.appendChild(input); + [['+1', 1], ['+100', 100]].forEach(function (s) { wrap.appendChild(el('button', { class: 'fg-step', text: s[0], onclick: function (e) { commit(getVal() + s[1] * (e.shiftKey ? 10 : 1)); } })); }); + input.addEventListener('keydown', function (e) { if (e.key === 'Enter') { commit(parse(input.value)); input.blur(); } else if (e.key === 'Escape') { sync(); input.blur(); } }); + input.addEventListener('blur', function () { commit(parse(input.value)); }); + function parse(s) { var n = Number(String(s).replace(/[^0-9.\-]/g, '')); return isNaN(n) ? getVal() : n; } + sync(); + return { el: wrap, sync: sync }; + } + + // ---------- sections ---------- + var activeRenderer = null, currentSection = null; + var SECTIONS = []; + function section(id, icon, title, builder) { SECTIONS.push({ id: id, icon: icon, title: title, builder: builder }); } + + function listSection(getModel, renderRow, matches, headExtra) { + return function (body, head) { + if (headExtra) headExtra(head); + var vl = VirtualList({ getModel: getModel, renderRow: renderRow, matches: matches || defaultMatch }); + body.appendChild(vl.el); + vl.rebuild(); + return { vl: vl, onResize: vl.onResize, refresh: function () { vl.rebuild(); }, setQuery: vl.setQuery, count: function () { return (getModel() || []).length; } }; + }; + } + function defaultMatch(it, q) { q = q.toLowerCase(); return String(it.id) === q || (it.name && it.name.toLowerCase().indexOf(q) >= 0); } + + // Variables + section('vars', '▣', 'Variables', listSection( + function () { return API.ops.vars.list(); }, + function (row, it) { + row.className = 'fg-row' + (API.locks.isVarFrozen(it.id) ? ' is-frozen' : ''); + row.innerHTML = ''; + row.appendChild(el('span', { class: 'fg-cell--id', text: it.id })); + row.appendChild(el('span', { class: 'fg-cell--name', text: it.name, title: it.name })); + var val = el('span', { class: 'fg-cell--val' }); + var sp = stepper(function () { return API.ops.vars.get(it.id); }, function (v) { API.ops.vars.set(it.id, v); if (API.locks.isVarFrozen(it.id)) API.locks.freezeVar(it.id, true); }, {}); + val.appendChild(sp.el); row.appendChild(val); + row.appendChild(lockBtn(API.locks.isVarFrozen(it.id), function (on) { API.locks.freezeVar(it.id, on); refreshActive(); })); + } + )); + + // Switches + section('switches', '⇄', 'Switches', listSection( + function () { return API.ops.switches.list(); }, + function (row, it) { + row.className = 'fg-row' + (API.locks.isSwitchFrozen(it.id) ? ' is-frozen' : ''); + row.innerHTML = ''; + row.appendChild(el('span', { class: 'fg-cell--id', text: it.id })); + row.appendChild(el('span', { class: 'fg-cell--name', text: it.name, title: it.name })); + var val = el('span', { class: 'fg-cell--val' }); + var tg = el('button', { class: 'fg-toggle' + (API.ops.switches.get(it.id) ? ' is-on' : '') }); + tg.addEventListener('click', function () { API.ops.switches.set(it.id, !API.ops.switches.get(it.id)); if (API.locks.isSwitchFrozen(it.id)) API.locks.freezeSwitch(it.id, true); tg.classList.toggle('is-on', API.ops.switches.get(it.id)); }); + val.appendChild(tg); row.appendChild(val); + row.appendChild(lockBtn(API.locks.isSwitchFrozen(it.id), function (on) { API.locks.freezeSwitch(it.id, on); refreshActive(); })); + } + )); + + // Items / Weapons / Armors (segmented) + section('inv', '🜚', 'Items', function (body, head) { + var kind = 'item'; + var seg = el('div', { class: 'fg-segmented' }); + ['item', 'weapon', 'armor'].forEach(function (k) { + var b = el('button', { class: k === kind ? 'is-active' : '', text: k.charAt(0).toUpperCase() + k.slice(1) + 's' }); + b.addEventListener('click', function () { kind = k; seg.querySelectorAll('button').forEach(function (x) { x.classList.remove('is-active'); }); b.classList.add('is-active'); vl.rebuild(); }); + seg.appendChild(b); + }); + head.appendChild(seg); + var vl = VirtualList({ + getModel: function () { return API.ops.inv.list(kind); }, + matches: defaultMatch, + renderRow: function (row, it) { + row.className = 'fg-row' + (API.locks.isItemFrozen(kind, it.id) ? ' is-frozen' : ''); + row.innerHTML = ''; + row.appendChild(el('span', { class: 'fg-cell--id', text: it.id })); + row.appendChild(el('span', { class: 'fg-cell--name', text: it.name, title: it.description || it.name })); + var val = el('span', { class: 'fg-cell--val' }); + var sp = stepper(function () { return API.ops.inv.count(kind, it.id); }, function (v) { API.ops.inv.setCount(kind, it.id, v); }, {}); + val.appendChild(sp.el); + val.appendChild(el('button', { class: 'fg-step', text: 'Max', style: 'width:auto;padding:0 6px', onclick: function () { API.ops.inv.max(kind, it.id); sp.sync(); } })); + row.appendChild(val); + row.appendChild(lockBtn(API.locks.isItemFrozen(kind, it.id), function (on) { API.locks.freezeItem(kind, it.id, on); refreshActive(); })); + } + }); + body.appendChild(vl.el); vl.rebuild(); + return { onResize: vl.onResize, refresh: function () { vl.rebuild(); }, setQuery: vl.setQuery, count: function () { return API.ops.inv.list(kind).length; } }; + }); + + // Actors (master-detail) + section('actors', '👤', 'Actors', function (body) { + var detail = el('div', { class: 'fg-detail' }); + var selectedId = null; + var vl = VirtualList({ + rowH: 30, matches: defaultMatch, + getModel: function () { return API.ops.actors.list(); }, + onRowClick: function (it) { showActor(it.id); }, + renderRow: function (row, it) { + row.className = 'fg-row' + (it.id === selectedId ? ' is-selected' : ''); + row.style.cursor = 'pointer'; + row.innerHTML = ''; + row.appendChild(el('span', { class: 'fg-cell--id', text: it.id })); + row.appendChild(el('span', { class: 'fg-cell--name', html: (it.inParty ? '' : '') + escapeHtml(it.name) })); + row.appendChild(el('span', { class: 'fg-cell--val', text: 'Lv ' + it.level })); + } + }); + var split = el('div', { style: 'flex:1;display:flex;min-height:0' }); + var left = el('div', { style: 'flex:0 0 240px;display:flex;flex-direction:column;border-right:1px solid var(--fg-border)' }); + left.appendChild(vl.el); + split.appendChild(left); split.appendChild(detail); + body.appendChild(split); vl.rebuild(); + var firstA = API.ops.actors.list()[0]; // auto-select first actor so detail isn't empty + if (firstA) showActor(firstA.id); + function showActor(id) { + selectedId = id; vl.draw(); // re-highlight the selected row in the (virtualized) list + var a = API.ops.actors.get(id); detail.innerHTML = ''; + if (!a) { detail.appendChild(el('div', { class: 'fg-empty', text: 'Actor not initialized (start a game first)' })); return; } + detail.appendChild(el('h2', { text: a.name + ' · Lv ' + a.level, style: 'margin:0 0 12px;font-size:15px' })); + var grid = el('div', { class: 'fg-grid' }); + grid.appendChild(numField('Level', function () { return API.ops.actors.get(id).level; }, function (v) { API.ops.actors.setLevel(id, v); showActor(id); })); + grid.appendChild(numField('EXP', function () { return API.ops.actors.get(id).exp; }, function (v) { API.ops.actors.setExp(id, v); showActor(id); })); + grid.appendChild(statField('HP', id, 'hp', function () { return API.ops.actors.get(id).hp; }, function () { return API.ops.actors.get(id).mhp; }, API.ops.actors.setHp)); + grid.appendChild(statField('MP', id, 'mp', function () { return API.ops.actors.get(id).mp; }, function () { return API.ops.actors.get(id).mmp; }, API.ops.actors.setMp)); + grid.appendChild(statField('TP', id, 'tp', function () { return API.ops.actors.get(id).tp; }, function () { return 100; }, API.ops.actors.setTp)); + a.params.forEach(function (p) { grid.appendChild(numField(p.name, function () { return API.ops.actors.get(id).params[p.id].value; }, function (v) { API.ops.actors.setParam(id, p.id, v); })); }); + detail.appendChild(grid); + var actbar = el('div', { style: 'display:flex;gap:8px;margin:14px 0' }, [ + el('button', { class: 'fg-btn', text: 'Recover All', onclick: function () { API.ops.actors.recover(id); showActor(id); } }), + el('button', { class: 'fg-btn', text: a.inParty ? 'Remove from Party' : 'Add to Party', onclick: function () { if (a.inParty) API.ops.actors.removeFromParty(id); else API.ops.actors.addToParty(id); vl.rebuild(); showActor(id); } }) + ]); + detail.appendChild(actbar); + // skills + detail.appendChild(el('h2', { text: 'Skills', style: 'font-size:13px;margin:10px 0 4px;color:var(--fg-text-dim)' })); + var skbox = el('div', { style: 'max-height:140px;overflow:auto;border:1px solid var(--fg-border);border-radius:7px' }); + a.skills.forEach(function (s) { + var r = el('div', { class: 'fg-field', style: 'padding:3px 10px' }, [ + el('span', { style: 'flex:1;font-size:12px', text: s.id + ': ' + s.name }), + el('button', { class: 'fg-btn fg-btn--ghost', text: s.learned ? 'Forget' : 'Learn', onclick: function () { if (s.learned) API.ops.actors.forget(id, s.id); else API.ops.actors.learn(id, s.id); showActor(id); } }) + ]); + skbox.appendChild(r); + }); + detail.appendChild(skbox); + } + function numField(label, get, set) { + var f = el('div', { class: 'fg-field' }); f.appendChild(el('label', { text: label })); + f.appendChild(stepper(get, set, {}).el); + f.appendChild(el('span', { style: 'flex:0 0 24px;width:24px' })); // empty lock slot -> steppers align with HP/MP/TP rows + return f; + } + function statField(label, id, stat, get, getMax, setFn) { + var f = el('div', { class: 'fg-field' }); f.appendChild(el('label', { text: label })); + f.appendChild(stepper(get, function (v) { setFn(id, v); if (API.locks.isStatFrozen(id, stat)) API.locks.freezeStat(id, stat, true); }, {}).el); + f.appendChild(lockBtn(API.locks.isStatFrozen(id, stat), function (on) { API.locks.freezeStat(id, stat, on); })); + return f; + } + return { onResize: vl.onResize, refresh: function () { vl.rebuild(); }, setQuery: vl.setQuery, count: function () { return API.ops.actors.list().length; } }; + }); + + // Party / Main + section('party', '★', 'Party', function (body) { + var d = el('div', { class: 'fg-detail' }); + function rebuild() { + d.innerHTML = ''; + d.appendChild(el('div', { class: 'fg-field' }, [ + el('label', { text: 'Gold' }), + stepper(function () { return API.ops.gold.get(); }, function (v) { API.ops.gold.set(v); }, {}).el, + lockBtn(API.LOCK.gold != null, function (on) { API.locks.freezeGold(on); rebuild(); }) + ])); + d.appendChild(el('div', { class: 'fg-field' }, [el('label', { text: 'Steps' }), el('span', { text: API.ops.party.steps() })])); + d.appendChild(el('div', { class: 'fg-field' }, [el('label', { text: 'Members' }), el('span', { text: API.ops.party.members().map(function (m) { return m.name; }).join(', ') || '(none)' })])); + d.appendChild(el('h2', { text: 'Quick actions', style: 'font-size:13px;margin:14px 0 6px;color:var(--fg-text-dim)' })); + d.appendChild(el('div', { style: 'display:flex;gap:8px;flex-wrap:wrap' }, [ + el('button', { class: 'fg-btn', text: 'Gold = Max', onclick: function () { API.ops.gold.set(API.ops.gold.max()); rebuild(); toast('Gold maxed', 'ok'); } }), + el('button', { class: 'fg-btn', text: 'Heal Party', onclick: function () { API.ops.party.members().forEach(function (m) { API.ops.actors.recover(m.id); }); toast('Party healed', 'ok'); } }) + ])); + } + body.appendChild(d); rebuild(); + return { refresh: rebuild, count: function () { return API.ops.party.members().length; } }; + }); + + // Map / Teleport + section('map', '🗺', 'Map', function (body) { + var d = el('div', { class: 'fg-detail' }); + function rebuild() { + d.innerHTML = ''; + var cur = API.ops.map.current(); + d.appendChild(el('div', { class: 'fg-field' }, [el('label', { text: 'Current' }), el('span', { text: 'Map ' + cur.mapId + ' @ (' + cur.x + ',' + cur.y + ')' })])); + var sel = el('select', { class: 'fg-search', style: 'flex:1' }); + API.ops.map.list().forEach(function (m) { sel.appendChild(el('option', { value: m.id, text: m.id + ': ' + m.name })); }); + sel.value = cur.mapId; + var xIn = el('input', { class: 'fg-num', value: cur.x }), yIn = el('input', { class: 'fg-num', value: cur.y }); + d.appendChild(el('div', { class: 'fg-field' }, [el('label', { text: 'Teleport to' }), sel])); + d.appendChild(el('div', { class: 'fg-field' }, [el('label', { text: 'X / Y' }), xIn, yIn, + el('button', { class: 'fg-btn', text: 'Go', onclick: function () { API.ops.map.teleport(Number(sel.value), Number(xIn.value), Number(yIn.value), 0, 2); toast('Teleporting…', 'ok'); } })])); + d.appendChild(el('div', { class: 'fg-field' }, [el('label', { text: 'Noclip' }), + (function () { var t = el('button', { class: 'fg-toggle' + (API.ops.map.isNoclip() ? ' is-on' : '') }); t.addEventListener('click', function () { var on = !API.ops.map.isNoclip(); API.ops.map.noclip(on); t.classList.toggle('is-on', on); }); return t; })()])); + d.appendChild(el('div', { class: 'fg-field' }, [el('label', { text: 'Move speed' }), + stepper(function () { return (window.$gamePlayer && $gamePlayer._moveSpeed) || 4; }, function (v) { API.ops.map.speed(v); }, {}).el])); + } + body.appendChild(d); rebuild(); + return { refresh: rebuild, count: function () { return API.ops.map.list().length; } }; + }); + + // Common Events + section('commonev', '⚡', 'Common Ev.', listSection( + function () { return API.ops.commonev.list(); }, + function (row, it) { + row.className = 'fg-row'; row.innerHTML = ''; + row.appendChild(el('span', { class: 'fg-cell--id', text: it.id })); + row.appendChild(el('span', { class: 'fg-cell--name', text: it.name, title: it.name })); + row.appendChild(el('span', { class: 'fg-cell--val' }, el('button', { class: 'fg-btn', text: 'Run', onclick: function () { confirmBox('Run common event ' + it.id + ' (' + it.name + ')?', function () { API.ops.commonev.run(it.id); toast('Reserved CE ' + it.id, 'ok'); }); } }))); + } + )); + + // Data Lock (aggregated frozen targets) + section('locks', '🛡', 'Data Lock', function (body, head) { + head.appendChild(el('button', { class: 'fg-btn fg-btn--danger', text: 'Unfreeze all', onclick: function () { API.locks.clearAll(); rebuild(); toast('All unfrozen'); } })); + var d = el('div', { class: 'fg-detail' }); + function rebuild() { + d.innerHTML = ''; + var s = API.locks.summary(); + if (!s.length) { d.appendChild(el('div', { class: 'fg-empty', text: 'No frozen values. Use the lock icon on any row to freeze it.' })); return; } + s.forEach(function (row) { + d.appendChild(el('div', { class: 'fg-field' }, [ + el('span', { style: 'flex:1;font-size:12px', text: row.label }), + el('span', { style: 'color:var(--fg-frozen);font-family:var(--font-mono);margin-right:8px', text: String(row.value) }), + el('button', { class: 'fg-btn fg-btn--ghost', text: 'Unfreeze', onclick: function () { unfreeze(row); rebuild(); } }) + ])); + }); + } + function unfreeze(row) { + if (row.type === 'var') API.locks.freezeVar(row.id, false); + else if (row.type === 'switch') API.locks.freezeSwitch(row.id, false); + else if (row.type === 'item') { var p = row.key.split(':'); API.LOCK.items.delete(row.key); } + else if (row.type === 'stat') { API.LOCK.stats.delete(row.key); } + else if (row.type === 'gold') API.locks.freezeGold(false); + else if (row.type === 'noclip') API.ops.map.noclip(false); + } + body.appendChild(d); rebuild(); + return { refresh: rebuild, count: function () { return API.locks.summary().length; } }; + }); + + // Battle cheats + section('battle', '⚔', 'Cheats', function (body) { + var d = el('div', { class: 'fg-detail' }); + function tog(label, get, set) { + var t = el('button', { class: 'fg-toggle' + (get() ? ' is-on' : '') }); + t.addEventListener('click', function () { var v = !get(); set(v); t.classList.toggle('is-on', v); }); + return el('div', { class: 'fg-field' }, [el('label', { text: label }), t]); + } + d.appendChild(tog('God mode (blocks HP loss)', function () { return API.CHEAT.god; }, API.cheats.setGod)); + d.appendChild(tog('One-hit kill', function () { return API.CHEAT.ohko; }, API.cheats.setOhko)); + d.appendChild(tog('No random encounters', function () { return API.CHEAT.noEncounter; }, API.cheats.setNoEncounter)); + // ---- game speed (speedhack) ---- + d.appendChild(el('h2', { text: 'Game speed', style: 'font-size:13px;margin:14px 0 6px;color:var(--fg-text-dim)' })); + var speedRow = el('div', { class: 'fg-field' }); + speedRow.appendChild(el('label', { text: 'Speed' })); + var seg = el('div', { class: 'fg-segmented', style: 'flex-wrap:wrap' }); + var presets = [['0.5×', 0.5], ['1×', 1], ['2×', 2], ['3×', 3], ['5×', 5], ['8×', 8]]; + function keyLabel(k) { return k === 'Control' ? 'Ctrl' : (k === ' ' ? 'Space' : k); } + // In KEY mode the highlight = the ARMED target (_speedBoost); in ALWAYS-ON mode = the live speed. + function targetSpeed() { return API._speedUseKey ? (API._speedBoost || 2) : (API.CHEAT.speed || 1); } + function syncSeg() { var t = targetSpeed(); Array.prototype.forEach.call(seg.children, function (b) { b.classList.toggle('is-active', Number(b.dataset.v) === t); }); if (typeof custom !== 'undefined' && custom) custom.value = t; } + // Picking a speed ARMS the target. It only changes the live game speed if key-mode is OFF, or if + // the speedhack is already active (then it live-updates). So buttons respect the key toggle. + function pick(v) { + API._speedBoost = v; store('speedBoost', v); + if (!API._speedUseKey || API.CHEAT.speed !== 1) { API.cheats.setSpeed(v); toast('Speed ' + v + '×', 'ok'); } + else { toast('Armed ' + v + '× — hold ' + keyLabel(API._speedKey) + ' to fast-forward', 'ok'); } + syncSeg(); + } + presets.forEach(function (p) { + var b = el('button', { text: p[0] }); b.dataset.v = p[1]; + b.addEventListener('click', function () { pick(p[1]); }); + seg.appendChild(b); + }); + speedRow.appendChild(seg); + d.appendChild(speedRow); + var customRow = el('div', { class: 'fg-field' }); + customRow.appendChild(el('label', { text: 'Custom ×' })); + var custom = el('input', { class: 'fg-num', type: 'text', value: targetSpeed() }); + custom.addEventListener('keydown', function (e) { if (e.key === 'Enter') { pick(Number(custom.value) || 1); custom.blur(); } }); + custom.addEventListener('blur', function () { pick(Number(custom.value) || 1); }); + customRow.appendChild(custom); + d.appendChild(customRow); + + // ---- use-key toggle (off => speed is always on) ---- + var useKeyRow = el('div', { class: 'fg-field' }); + useKeyRow.appendChild(el('label', { text: 'Use hold key' })); + var ukTog = el('button', { class: 'fg-toggle' + (API._speedUseKey ? ' is-on' : '') }); + ukTog.addEventListener('click', function () { + speedHeld = false; // changing mode ends any active hold so a later key-release won't reset speed + var on = !API._speedUseKey; API._speedUseKey = on; store('speedUseKey', on); ukTog.classList.toggle('is-on', on); + keyRow.style.display = on ? '' : 'none'; + if (on) { API.cheats.setSpeed(1); toast('Speed: hold ' + keyLabel(API._speedKey) + ' to fast-forward', 'ok'); } + else { API.cheats.setSpeed(API._speedBoost || 2); toast('Speed: always on at ' + (API._speedBoost || 2) + '×', 'ok'); } + syncSeg(); + }); + useKeyRow.appendChild(ukTog); + d.appendChild(useKeyRow); + + // ---- rebindable key ---- + var keyRow = el('div', { class: 'fg-field' }); + keyRow.appendChild(el('label', { text: 'Hold key' })); + var keyBtn = el('button', { class: 'fg-btn', text: keyLabel(API._speedKey) }); + keyBtn.addEventListener('click', function () { + keyBtn.textContent = 'Press a key…'; API._capturingSpeedKey = true; + function cap(ev) { + ev.preventDefault(); ev.stopPropagation(); + window.removeEventListener('keydown', cap, true); API._capturingSpeedKey = false; + if (ev.key !== 'Escape') { API._speedKey = ev.key; store('speedKey', ev.key); } + keyBtn.textContent = keyLabel(API._speedKey); + } + window.addEventListener('keydown', cap, true); + }); + keyRow.appendChild(keyBtn); + keyRow.style.display = API._speedUseKey ? '' : 'none'; + d.appendChild(keyRow); + syncSeg(); + d.appendChild(el('h2', { text: 'In-battle actions', style: 'font-size:13px;margin:14px 0 6px;color:var(--fg-text-dim)' })); + d.appendChild(el('div', { style: 'display:flex;gap:8px;flex-wrap:wrap' }, [ + el('button', { class: 'fg-btn', text: 'Instant Win', onclick: function () { API.cheats.instantWin(); toast(API.ops.battle.inBattle() ? 'Enemies downed' : 'Not in battle', API.ops.battle.inBattle() ? 'ok' : 'warn'); } }), + el('button', { class: 'fg-btn', text: 'Flee / Abort', onclick: function () { API.cheats.flee(); } }) + ])); + body.appendChild(d); + return { refresh: function () {}, count: function () { return 0; } }; + }); + + // ---------- render / navigation ---------- + function escapeHtml(s) { return String(s).replace(/[&<>"]/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"' }[c]; }); } + function lockBtn(frozen, onToggle) { + var b = el('button', { class: 'fg-lock' + (frozen ? ' is-frozen' : ''), text: frozen ? '🔒' : '🔓', title: 'Freeze value' }); + b.addEventListener('click', function () { frozen = !frozen; b.className = 'fg-lock' + (frozen ? ' is-frozen' : ''); b.textContent = frozen ? '🔒' : '🔓'; onToggle(frozen); }); + return b; + } + function refreshActive() { if (activeRenderer && activeRenderer.refresh) activeRenderer.refresh(); updateStatus(); } + + SECTIONS.forEach(function (s) { + var item = el('button', { class: 'fg-rail__item', title: s.title }, [el('span', { class: 'ic', text: s.icon }), el('span', { text: s.title.split(' ')[0] })]); + item.addEventListener('click', function () { selectSection(s.id); }); + s._rail = item; rail.appendChild(item); + }); + + function selectSection(id) { + currentSection = id; + SECTIONS.forEach(function (s) { s._rail.classList.toggle('is-active', s.id === id); }); + render(); + store('lastSection', id); + } + function render() { + var s = SECTIONS.filter(function (x) { return x.id === currentSection; })[0]; if (!s) return; + content.innerHTML = ''; + var head = el('div', { class: 'fg-subtoolbar' }); + var sectionHead = el('div', { class: 'fg-section-head' }, [el('h2', { text: s.title }), el('span', { class: 'fg-count', text: '' }), el('span', { class: 'fg-spacer' })]); + content.appendChild(sectionHead); + content.appendChild(head); + var body = el('div', { style: 'flex:1;display:flex;flex-direction:column;min-height:0' }); + content.appendChild(body); + activeRenderer = s.builder(body, head) || {}; + if (head.children.length === 0) head.remove(); + var cnt = activeRenderer.count ? activeRenderer.count() : 0; + sectionHead.querySelector('.fg-count').textContent = cnt + (cnt === 1 ? ' entry' : ' entries'); + // Search only applies to list sections (those expose setQuery); hide it on form sections. + var searchable = !!activeRenderer.setQuery; + // keep the search box's flex space (invisible, not removed) so the titlebar buttons stay anchored right + search.style.visibility = searchable ? 'visible' : 'hidden'; + if (searchable && search.value) activeRenderer.setQuery(search.value.trim()); + updateStatus(); + } + function updateStatus() { + var frozen = API.locks.summary().length; + statusbar.innerHTML = ''; + statusbar.appendChild(el('span', { text: API.env.IS_MZ ? 'RPG Maker MZ' : 'RPG Maker MV' })); + statusbar.appendChild(el('span', { class: 'fg-spacer', style: 'flex:1' })); + statusbar.appendChild(el('span', { text: (frozen ? '🔒 ' + frozen + ' frozen · ' : '') + 'F10 to toggle' })); + } + + search.addEventListener('input', debounce(function () { if (activeRenderer && activeRenderer.setQuery) activeRenderer.setQuery(search.value.trim()); }, 120)); + function debounce(fn, ms) { var t; return function () { clearTimeout(t); t = setTimeout(fn, ms); }; } + + // ---------- show / hide / hotkeys ---------- + var visible = false; + function show() { visible = true; try { document.body.appendChild(host); } catch (e) {} panel.style.display = 'flex'; launcher.style.display = 'none'; if (!currentSection) selectSection(load('lastSection', 'vars')); else render(); } + function hide() { visible = false; panel.style.display = 'none'; launcher.style.display = 'flex'; API.CHEAT.uiFocused = false; if (typeof Input !== 'undefined' && Input.clear) Input.clear(); } + function toggle() { if (visible) hide(); else show(); } + panel.style.display = 'none'; + + // Speed key = HOLD to fast-forward: while the key is held the game runs at the armed boost speed; + // on release it returns to 1x. Only active when the "use key" option is on. Gated to !uiFocused so + // holding a key while typing in the panel never fast-forwards. + var speedHeld = false; + function isSpeedKey(k) { var sk = API._speedKey || 'Control'; return k === sk || (sk === 'Control' && k === 'Ctrl'); } + function holdStart() { if (!speedHeld) { speedHeld = true; API.cheats.setSpeed(API._speedBoost || 2); toast('Fast-forward ' + API.CHEAT.speed + '×', 'ok'); } } + function holdEnd() { if (speedHeld) { speedHeld = false; API.cheats.setSpeed(1); } } + window.addEventListener('keydown', function (e) { + if (API._capturingSpeedKey) return; // a key-rebind is in progress + var key = e.key || ''; + if (key === (API._hotkey || 'F10')) { e.preventDefault(); toggle(); return; } + if (visible && (e.ctrlKey || e.metaKey) && key.toLowerCase() === 'k') { e.preventDefault(); search.focus(); search.select(); } + else if (visible && key === 'Escape') { if (shadow.activeElement && shadow.activeElement.tagName === 'INPUT') shadow.activeElement.blur(); else hide(); } + if (API._speedUseKey && isSpeedKey(key) && !API.CHEAT.uiFocused) holdStart(); // hold to fast-forward + }, true); + window.addEventListener('keyup', function (e) { + if (API._capturingSpeedKey) return; + if (isSpeedKey(e.key || '')) holdEnd(); + }, true); + window.addEventListener('blur', holdEnd); // alt-tab / focus loss -> stop fast-forward (no stuck key) + + // expose + return { + show: show, hide: hide, toggle: toggle, toast: toast, render: render, + isVisible: function () { return visible; }, root: rootEl, host: host, + select: selectSection, sections: SECTIONS.map(function (s) { return s.id; }) + }; + } + +})(typeof window !== 'undefined' ? window : (typeof globalThis !== 'undefined' ? globalThis : this)); + +/* ---- MZ scaffolding (registerCommand + start-open) ---- */ +(function () { + var P = (typeof PluginManager !== 'undefined' && PluginManager.parameters) ? PluginManager.parameters('Forge_MZ') : {}; + if (window.Forge) { + window.Forge._hotkey = (P.hotkey || 'F10').trim(); + window.Forge._itemMaxOverride = Number(P.itemMaxOverride) || 0; + try { if (!localStorage.getItem('forge:speedKey')) window.Forge._speedKey = (P.speedKey || 'Control').trim(); } catch (e) { window.Forge._speedKey = (P.speedKey || 'Control').trim(); } + } + if (typeof PluginManager !== 'undefined' && PluginManager.registerCommand) { + PluginManager.registerCommand('Forge_MZ', 'open', function () { if (window.Forge && window.Forge.ui) window.Forge.ui.toggle(); }); + } + if (String(P.startOpen) === 'true' && typeof Scene_Map !== 'undefined') { + var _s = Scene_Map.prototype.start; + Scene_Map.prototype.start = function () { _s.call(this); if (window.Forge && window.Forge.ui && !window.Forge.ui.isVisible()) window.Forge.ui.show(); }; + } +})(); + diff --git a/util/forge/__init__.py b/util/forge/__init__.py new file mode 100644 index 0000000..a034b53 --- /dev/null +++ b/util/forge/__init__.py @@ -0,0 +1,12 @@ +"""Forge — in-game cheat & editor overlay for RPG Maker MZ.""" + +from util.forge.installer import apply_config, bundled_plugin_path, detect_mz, install, status, uninstall + +__all__ = [ + "apply_config", + "bundled_plugin_path", + "detect_mz", + "install", + "status", + "uninstall", +] diff --git a/util/forge/config.py b/util/forge/config.py new file mode 100644 index 0000000..880ad02 --- /dev/null +++ b/util/forge/config.py @@ -0,0 +1,54 @@ +"""Forge_MZ build — hotkey injection at install time.""" + +from __future__ import annotations + +import json +import re +from pathlib import Path + +from util.playtest.config import load_config + +_PKG_ROOT = Path(__file__).resolve().parent +BUNDLED_PLUGIN = _PKG_ROOT / "Forge_MZ.js" + + +def _js_literal(value) -> str: + if value is None: + return "null" + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, (int, float)): + return str(value) + return json.dumps(str(value)) + + +def plugin_entry(hotkey: str) -> str: + hk = _js_literal(hotkey.strip() or "F10") + return ( + f' {{ "name": "Forge_MZ", "status": true, ' + f'"description": "Forge — in-game cheat & editor overlay", ' + f'"parameters": {{ "hotkey": {hk}, "speedKey": "Control", ' + f'"startOpen": "false", "itemMaxOverride": "0" }} }}' + ) + + +def _patch_forge_hotkey(forge_text: str, hotkey: str) -> str: + hk = hotkey.strip() or "F10" + pattern = ( + r"(\* @param hotkey\s*\n" + r"(?:\s*\*[^\n]*\n)*?" + r"\s*\* @default )F10" + ) + forge_text, n = re.subn(pattern, rf"\g<1>{hk}", forge_text, count=1) + if n == 0: + raise ValueError("Could not patch @default hotkey in Forge_MZ.js") + forge_text = re.sub(r"\(P\.hotkey \|\| 'F10'\)", f"(P.hotkey || '{hk}')", forge_text) + forge_text = re.sub(r"API\._hotkey \|\| 'F10'", f"API._hotkey || '{hk}'", forge_text) + return forge_text + + +def prepare_forge_mz_js(source: Path | None = None, cfg: dict | None = None) -> str: + """Build Forge_MZ.js with the configured toggle hotkey.""" + src = source or BUNDLED_PLUGIN + effective = {**load_config(), **(cfg or {})} + return _patch_forge_hotkey(src.read_text(encoding="utf-8"), effective["forgeHotkey"]) diff --git a/util/forge/installer.py b/util/forge/installer.py new file mode 100644 index 0000000..f27b7a1 --- /dev/null +++ b/util/forge/installer.py @@ -0,0 +1,144 @@ +"""Install / uninstall Forge_MZ into an RPG Maker MZ game folder.""" + +from __future__ import annotations + +import re +from pathlib import Path + +from util.forge.config import plugin_entry, prepare_forge_mz_js + +PLUGIN_NAME = "Forge_MZ" + +_PKG_ROOT = Path(__file__).resolve().parent +DEFAULT_PLUGIN_SRC = _PKG_ROOT / "Forge_MZ.js" + + +def bundled_plugin_path() -> Path: + return DEFAULT_PLUGIN_SRC + + +def detect_mz(game_root: Path) -> tuple[Path, Path] | None: + """Return (plugins_js, plugins_dir) or None if not MZ.""" + root = Path(game_root) + mz_js = root / "js" / "plugins.js" + if mz_js.is_file(): + return mz_js, root / "js" / "plugins" + return None + + +def _read_plugins_js(plugins_js: Path) -> tuple[str, str]: + content = plugins_js.read_text(encoding="utf-8") + nl = "\r\n" if "\r\n" in content else "\n" + return content, nl + + +def _is_declared(content: str) -> bool: + return bool(re.search(rf'"name"\s*:\s*"{re.escape(PLUGIN_NAME)}"', content)) + + +def status(game_root: Path) -> dict: + """Return install state for the game folder.""" + info = detect_mz(game_root) + if info is None: + return { + "ok": False, + "engine": None, + "installed": False, + "declared": False, + "plugin_file": None, + "message": "No RPG Maker MZ game found (missing js/plugins.js).", + } + plugins_js, plugins_dir = info + target = plugins_dir / f"{PLUGIN_NAME}.js" + content, _ = _read_plugins_js(plugins_js) + declared = _is_declared(content) + file_there = target.is_file() + return { + "ok": True, + "engine": "MZ", + "installed": declared or file_there, + "declared": declared, + "plugin_file": file_there, + "plugins_js": str(plugins_js), + "target": str(target), + "message": ( + "Installed" + if declared and file_there + else "Partially installed" + if declared or file_there + else "Not installed" + ), + } + + +def install(game_root: Path, source_js: Path | None = None, cfg: dict | None = None) -> tuple[bool, str]: + """Copy Forge_MZ.js into the game and declare it in plugins.js.""" + info = detect_mz(game_root) + if info is None: + return False, "No RPG Maker MZ game found at that path (Forge is MZ-only)." + + plugins_js, plugins_dir = info + if source_js and not Path(source_js).is_file(): + return False, f"Forge_MZ.js not found: {source_js}" + if not source_js and not DEFAULT_PLUGIN_SRC.is_file(): + return False, f"Forge_MZ.js not found: {DEFAULT_PLUGIN_SRC}" + + target = plugins_dir / f"{PLUGIN_NAME}.js" + content, nl = _read_plugins_js(plugins_js) + + plugins_dir.mkdir(parents=True, exist_ok=True) + target.write_text(prepare_forge_mz_js(source_js, cfg), encoding="utf-8") + + hotkey = (cfg or {}).get("forgeHotkey", "F10") + entry = plugin_entry(hotkey) + if not _is_declared(content): + idx = content.rfind("];") + if idx < 0: + return False, "Could not find plugin list end ( ]; ) in plugins.js." + before = content[:idx].rstrip() + after = content[idx:] + sep = nl if before.endswith(",") else "," + nl + content = before + sep + entry + nl + " " + after + plugins_js.write_text(content, encoding="utf-8", newline="") + + return True, f"Forge installed for RPG Maker MZ. Press {hotkey} in-game to open." + + +def uninstall(game_root: Path) -> tuple[bool, str]: + """Remove Forge_MZ from plugins.js and delete the plugin file.""" + info = detect_mz(game_root) + if info is None: + return False, "No RPG Maker MZ game found at that path." + + plugins_js, plugins_dir = info + target = plugins_dir / f"{PLUGIN_NAME}.js" + content, nl = _read_plugins_js(plugins_js) + + if _is_declared(content): + kept = [ + line for line in re.split(r"\r?\n", content) + if not re.search(rf'"name"\s*:\s*"{re.escape(PLUGIN_NAME)}"', line) + ] + new_text = nl.join(kept) + new_text = re.sub(r",(\s*)\];", r"\1];", new_text) + plugins_js.write_text(new_text, encoding="utf-8", newline="") + + if target.is_file(): + target.unlink() + + return True, "Forge uninstalled." + + +def apply_config(game_root: Path, cfg: dict | None = None) -> tuple[bool, str]: + """Rewrite an installed Forge_MZ.js with the current Forge hotkey.""" + info = detect_mz(game_root) + if info is None: + return False, "No RPG Maker MZ game found at that path." + + _, plugins_dir = info + target = plugins_dir / f"{PLUGIN_NAME}.js" + if not target.is_file(): + return False, "Forge is not installed in this game folder." + + target.write_text(prepare_forge_mz_js(cfg=cfg), encoding="utf-8") + return True, "Forge hotkey applied to the installed plugin." diff --git a/util/playtest/__init__.py b/util/playtest/__init__.py new file mode 100644 index 0000000..9e70484 --- /dev/null +++ b/util/playtest/__init__.py @@ -0,0 +1,3 @@ +from util.playtest.config import load_config, save_config + +__all__ = ["load_config", "save_config"] diff --git a/util/playtest/config.py b/util/playtest/config.py new file mode 100644 index 0000000..a426e2e --- /dev/null +++ b/util/playtest/config.py @@ -0,0 +1,55 @@ +"""Shared playtest plugin settings — hotkeys and editor (.env).""" + +from __future__ import annotations + +import re +from pathlib import Path + +from dotenv import dotenv_values + +ENV_TL_HOTKEY = "tlHotkey" +ENV_FORGE_HOTKEY = "forgeHotkey" +ENV_EDITOR = "tlEditorCmd" + +DEFAULTS = { + "hotkey": "F9", + "forgeHotkey": "F10", + "editorCmd": "auto", + "workspaceFolder": "auto", +} + +ENV_PATH = Path(".env") + + +def load_config(env_path: Path | None = None) -> dict: + path = env_path or ENV_PATH + env = dotenv_values(path) if path.is_file() else {} + cfg = dict(DEFAULTS) + for key, env_key in ( + ("hotkey", ENV_TL_HOTKEY), + ("forgeHotkey", ENV_FORGE_HOTKEY), + ("editorCmd", ENV_EDITOR), + ): + val = (env.get(env_key) or "").strip() + if val: + cfg[key] = val + return cfg + + +def save_config(cfg: dict, env_path: Path | None = None) -> None: + path = env_path or ENV_PATH + text = path.read_text(encoding="utf-8") if path.is_file() else "" + updates = { + ENV_TL_HOTKEY: cfg.get("hotkey", DEFAULTS["hotkey"]), + ENV_FORGE_HOTKEY: cfg.get("forgeHotkey", DEFAULTS["forgeHotkey"]), + ENV_EDITOR: cfg.get("editorCmd", DEFAULTS["editorCmd"]), + } + for key, val in updates.items(): + quoted = f"'{val}'" + pattern = rf"^({re.escape(key)}\s*=\s*)(?:'[^']*'|\"[^\"]*\"|[^\n]*)" + text, n = re.subn(pattern, rf"\g<1>{quoted}", text, count=1, flags=re.MULTILINE) + if n == 0: + text = text.rstrip("\n") + f"\n{key}={quoted}\n" + if not path.exists(): + path.touch() + path.write_text(text, encoding="utf-8") diff --git a/util/tl_inspector/TLInspector.js b/util/tl_inspector/TLInspector.js index 6866462..9e562b7 100644 --- a/util/tl_inspector/TLInspector.js +++ b/util/tl_inspector/TLInspector.js @@ -30,7 +30,6 @@ * In-game overlay "save to file" reloads that JSON into memory immediately. * VSCode / external saves: we watch data/*.json; once the file mtime is stable * (save finished), reload instantly. The game cannot hook Ctrl+S in VSCode itself. - * F9 — manual reload of all database JSON + the current map * * Limitations: * - NW.js desktop playtest only (not browser) @@ -55,8 +54,7 @@ //========================================================================= var CFG = { enabled: true, - hotkey: 'F10', // key (event.key) that toggles the overlay - reloadHotkey: 'F9', // key that reloads on-screen game elements from disk + hotkey: 'F9', // key (event.key) that toggles the overlay editorCmd: 'auto', // 'auto' = auto-detect installed editors (VS Code, Cursor, // VSCodium, Insiders, Windsurf). Or set an absolute path to // force a specific editor exe. @@ -1821,7 +1819,7 @@ '' + '' + - '↻ Reload' + + '↻ Reload' + ''; root.appendChild(head); @@ -2330,10 +2328,6 @@ e.preventDefault(); e.stopPropagation(); toggle(); - } else if (CFG.reloadHotkey && e.key === CFG.reloadHotkey) { - e.preventDefault(); - e.stopPropagation(); - refreshGameElements(); } else if (e.key === 'Escape' && ui.pick) { e.preventDefault(); e.stopPropagation(); @@ -2356,7 +2350,7 @@ if (inPanel(e.target)) { return; } // our panel / built-in editor get input untouched var isKey = (e.type === 'keydown' || e.type === 'keyup' || e.type === 'keypress'); if (isKey) { - if (e.key === CFG.hotkey || e.key === CFG.reloadHotkey || e.key === 'Escape') { return; } // hotkeys always work + if (e.key === CFG.hotkey || e.key === 'Escape') { return; } // hotkeys always work // Always block the game from seeing the key (stopPropagation), but do NOT // cancel the browser default for copy/select shortcuts (Ctrl+C / Ctrl+A) so // the user can still copy selected text from the panel. diff --git a/util/tl_inspector/config.py b/util/tl_inspector/config.py index 90aef88..b026764 100644 --- a/util/tl_inspector/config.py +++ b/util/tl_inspector/config.py @@ -8,15 +8,16 @@ import re import sys from pathlib import Path -from dotenv import dotenv_values +from util.playtest.config import load_config as _load_playtest_config, save_config as _save_playtest_config ENV_EDITOR = "tlEditorCmd" -CFG_KEYS = ("editorCmd", "workspaceFolder") +CFG_KEYS = ("editorCmd", "workspaceFolder", "hotkey") DEFAULTS = { "editorCmd": "auto", "workspaceFolder": "auto", + "hotkey": "F9", } _PKG_ROOT = Path(__file__).resolve().parent @@ -100,30 +101,13 @@ def detect_primary_editor() -> Path | None: def load_config(env_path: Path | None = None) -> dict: - path = env_path or ENV_PATH - env = dotenv_values(path) if path.is_file() else {} - cfg = dict(DEFAULTS) - editor = (env.get(ENV_EDITOR) or "").strip() - if editor: - cfg["editorCmd"] = editor - return cfg + return _load_playtest_config(env_path) def save_config(cfg: dict, env_path: Path | None = None) -> None: - path = env_path or ENV_PATH - text = path.read_text(encoding="utf-8") if path.is_file() else "" - updates = { - ENV_EDITOR: cfg.get("editorCmd", "auto"), - } - for key, val in updates.items(): - quoted = f"'{val}'" - pattern = rf"^({re.escape(key)}\s*=\s*)(?:'[^']*'|\"[^\"]*\"|[^\n]*)" - text, n = re.subn(pattern, rf"\g<1>{quoted}", text, count=1, flags=re.MULTILINE) - if n == 0: - text = text.rstrip("\n") + f"\n{key}={quoted}\n" - if not path.exists(): - path.touch() - path.write_text(text, encoding="utf-8") + current = _load_playtest_config(env_path) + current.update(cfg) + _save_playtest_config(current, env_path) def _js_literal(value) -> str: diff --git a/util/tl_inspector/installer.py b/util/tl_inspector/installer.py index a20607e..c9da79e 100644 --- a/util/tl_inspector/installer.py +++ b/util/tl_inspector/installer.py @@ -109,7 +109,10 @@ def install(game_root: Path, source_js: Path | None = None, cfg: dict | None = N content = before + sep + PLUGIN_ENTRY + nl + " " + after plugins_js.write_text(content, encoding="utf-8", newline="") - return True, f"TLInspector installed for RPG Maker {engine}. Press F10 in-game to open." + return True, ( + f"TLInspector installed for RPG Maker {engine}. " + f"Press {(cfg or {}).get('hotkey', 'F9')} in-game to open." + ) def uninstall(game_root: Path) -> tuple[bool, str]: