ff9mapkit 1.0.0b3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ff9mapkit/__init__.py +18 -0
- ff9mapkit/__main__.py +36 -0
- ff9mapkit/_animdb.py +2994 -0
- ff9mapkit/_animdb_all.py +14125 -0
- ff9mapkit/_fieldtable.py +1516 -0
- ff9mapkit/_fieldtext.py +845 -0
- ff9mapkit/_held_poses.py +44 -0
- ff9mapkit/_itemdb.py +65 -0
- ff9mapkit/_modeldb.py +725 -0
- ff9mapkit/_narrowmap_data.py +10 -0
- ff9mapkit/_npcparams.py +634 -0
- ff9mapkit/_regen_animdb.py +72 -0
- ff9mapkit/_regen_animdb_all.py +66 -0
- ff9mapkit/_regen_fieldtable.py +95 -0
- ff9mapkit/_regen_fieldtext.py +66 -0
- ff9mapkit/_regen_modeldb.py +67 -0
- ff9mapkit/_regen_npcparams.py +123 -0
- ff9mapkit/_regen_scenedb.py +57 -0
- ff9mapkit/_scenedb.py +869 -0
- ff9mapkit/abilities.py +225 -0
- ff9mapkit/animations.py +120 -0
- ff9mapkit/archetypes.py +218 -0
- ff9mapkit/areatitle.py +76 -0
- ff9mapkit/battle/__init__.py +21 -0
- ff9mapkit/battle/abilityfeatures.py +294 -0
- ff9mapkit/battle/actiondelta.py +441 -0
- ff9mapkit/battle/aiauthor.py +305 -0
- ff9mapkit/battle/ailint.py +140 -0
- ff9mapkit/battle/aipatch.py +175 -0
- ff9mapkit/battle/battleai.py +148 -0
- ff9mapkit/battle/battlecsv.py +390 -0
- ff9mapkit/battle/battlepatch.py +395 -0
- ff9mapkit/battle/build.py +558 -0
- ff9mapkit/battle/camera_codec.py +332 -0
- ff9mapkit/battle/camera_data.py +128 -0
- ff9mapkit/battle/characterdelta.py +789 -0
- ff9mapkit/battle/event_data.py +72 -0
- ff9mapkit/battle/extract.py +540 -0
- ff9mapkit/battle/fbx.py +223 -0
- ff9mapkit/battle/reskin.py +149 -0
- ff9mapkit/battle/scene_codec.py +314 -0
- ff9mapkit/battle/scene_data.py +369 -0
- ff9mapkit/battle/scenelint.py +125 -0
- ff9mapkit/battle/seqasm.py +131 -0
- ff9mapkit/battle/seqauthor.py +220 -0
- ff9mapkit/battle/seqcodec.py +300 -0
- ff9mapkit/battle/seqdis.py +106 -0
- ff9mapkit/battle/seqpatch.py +137 -0
- ff9mapkit/battle_bgm.py +133 -0
- ff9mapkit/binutils.py +60 -0
- ff9mapkit/build.py +5445 -0
- ff9mapkit/campaign.py +1276 -0
- ff9mapkit/catalog.py +316 -0
- ff9mapkit/chain.py +358 -0
- ff9mapkit/cli.py +3114 -0
- ff9mapkit/config.py +360 -0
- ff9mapkit/content/__init__.py +13 -0
- ff9mapkit/content/areatitle.py +36 -0
- ff9mapkit/content/ate.py +118 -0
- ff9mapkit/content/camera.py +123 -0
- ff9mapkit/content/chest.py +186 -0
- ff9mapkit/content/choice.py +163 -0
- ff9mapkit/content/conductor.py +217 -0
- ff9mapkit/content/cutscene.py +290 -0
- ff9mapkit/content/encounter.py +41 -0
- ff9mapkit/content/entry_settle.py +50 -0
- ff9mapkit/content/equipment.py +93 -0
- ff9mapkit/content/event.py +191 -0
- ff9mapkit/content/gateway.py +101 -0
- ff9mapkit/content/inventory.py +59 -0
- ff9mapkit/content/itemdata.py +644 -0
- ff9mapkit/content/itemtext.py +168 -0
- ff9mapkit/content/jump.py +114 -0
- ff9mapkit/content/ladder.py +633 -0
- ff9mapkit/content/movement.py +53 -0
- ff9mapkit/content/music.py +97 -0
- ff9mapkit/content/npc.py +348 -0
- ff9mapkit/content/object.py +340 -0
- ff9mapkit/content/onentry.py +135 -0
- ff9mapkit/content/party.py +111 -0
- ff9mapkit/content/pathfind.py +138 -0
- ff9mapkit/content/platform.py +314 -0
- ff9mapkit/content/player.py +168 -0
- ff9mapkit/content/prop.py +75 -0
- ff9mapkit/content/region.py +340 -0
- ff9mapkit/content/reinit.py +59 -0
- ff9mapkit/content/savepoint.py +90 -0
- ff9mapkit/content/shop.py +178 -0
- ff9mapkit/content/sps_trigger.py +66 -0
- ff9mapkit/content/startup.py +71 -0
- ff9mapkit/content/synthesis.py +106 -0
- ff9mapkit/content/text.py +183 -0
- ff9mapkit/content/textcarry.py +290 -0
- ff9mapkit/content/verbatim.py +86 -0
- ff9mapkit/content/walkmesh_hotfix.py +38 -0
- ff9mapkit/data/__init__.py +48 -0
- ff9mapkit/data/_regen_provenance.py +142 -0
- ff9mapkit/data/provenance/blank.es.patch +1 -0
- ff9mapkit/data/provenance/blank.fr.patch +1 -0
- ff9mapkit/data/provenance/blank.gr.patch +1 -0
- ff9mapkit/data/provenance/blank.it.patch +1 -0
- ff9mapkit/data/provenance/blank.jp.patch +1 -0
- ff9mapkit/data/provenance/blank.uk.patch +1 -0
- ff9mapkit/data/provenance/blank.us.patch +1 -0
- ff9mapkit/data/provenance/manifest.json +65 -0
- ff9mapkit/data/provenance/region_template.patch +1 -0
- ff9mapkit/data/reference_arcs.toml +89 -0
- ff9mapkit/data/region_catalog.toml +593 -0
- ff9mapkit/deploystack.py +358 -0
- ff9mapkit/dialogue.py +803 -0
- ff9mapkit/eb/__init__.py +12 -0
- ff9mapkit/eb/_exprtable.py +59 -0
- ff9mapkit/eb/_membertable.py +38 -0
- ff9mapkit/eb/_optables.py +537 -0
- ff9mapkit/eb/_regen_optables.py +76 -0
- ff9mapkit/eb/cmdasm.py +323 -0
- ff9mapkit/eb/disasm.py +332 -0
- ff9mapkit/eb/edit.py +439 -0
- ff9mapkit/eb/exprasm.py +158 -0
- ff9mapkit/eb/model.py +178 -0
- ff9mapkit/eb/opcodes.py +463 -0
- ff9mapkit/eblint.py +177 -0
- ff9mapkit/editor/__init__.py +20 -0
- ff9mapkit/editor/app.py +950 -0
- ff9mapkit/editor/battle_forms.py +240 -0
- ff9mapkit/editor/breadcrumb.py +89 -0
- ff9mapkit/editor/dialogs.py +116 -0
- ff9mapkit/editor/feedback.py +208 -0
- ff9mapkit/editor/forms.py +632 -0
- ff9mapkit/editor/graphview.py +350 -0
- ff9mapkit/editor/jobs.py +342 -0
- ff9mapkit/editor/model.py +243 -0
- ff9mapkit/editor/picker.py +120 -0
- ff9mapkit/editor/theme.py +212 -0
- ff9mapkit/eventscan.py +1441 -0
- ff9mapkit/extract.py +2279 -0
- ff9mapkit/flags.py +693 -0
- ff9mapkit/forkreport.py +1383 -0
- ff9mapkit/hub.py +477 -0
- ff9mapkit/idgated.py +101 -0
- ff9mapkit/infohub.py +580 -0
- ff9mapkit/items.py +63 -0
- ff9mapkit/itemstats.py +346 -0
- ff9mapkit/journey.py +1902 -0
- ff9mapkit/keyitems.py +93 -0
- ff9mapkit/logic_add.py +632 -0
- ff9mapkit/logic_edit.py +728 -0
- ff9mapkit/logic_map.py +526 -0
- ff9mapkit/pack.py +175 -0
- ff9mapkit/playerswap.py +231 -0
- ff9mapkit/prop_archetypes.py +228 -0
- ff9mapkit/provision.py +282 -0
- ff9mapkit/refarc.py +825 -0
- ff9mapkit/save.py +337 -0
- ff9mapkit/save_items.py +1673 -0
- ff9mapkit/scene/__init__.py +11 -0
- ff9mapkit/scene/arena.py +63 -0
- ff9mapkit/scene/bgart.py +140 -0
- ff9mapkit/scene/bgi.py +732 -0
- ff9mapkit/scene/bgs.py +174 -0
- ff9mapkit/scene/bgx.py +185 -0
- ff9mapkit/scene/cam.py +345 -0
- ff9mapkit/scene/guide.py +311 -0
- ff9mapkit/scene/paint.py +506 -0
- ff9mapkit/scene/placeholder.py +107 -0
- ff9mapkit/sjbinary.py +285 -0
- ff9mapkit/sps/__init__.py +17 -0
- ff9mapkit/sps/author.py +294 -0
- ff9mapkit/sps/catalog.py +88 -0
- ff9mapkit/sps/codec.py +264 -0
- ff9mapkit/sps/edit.py +184 -0
- ff9mapkit/sps/lint.py +58 -0
- ff9mapkit/sps/render.py +116 -0
- ff9mapkit/sps/templates.py +47 -0
- ff9mapkit/sps/texture.py +131 -0
- ff9mapkit/walkmesh_hotfixes.py +163 -0
- ff9mapkit/workspace/__init__.py +18 -0
- ff9mapkit/workspace/battledoc.py +985 -0
- ff9mapkit/workspace/builddoc.py +607 -0
- ff9mapkit/workspace/forms_qt.py +586 -0
- ff9mapkit/workspace/importdoc.py +665 -0
- ff9mapkit/workspace/mapview.py +131 -0
- ff9mapkit/workspace/palette.py +85 -0
- ff9mapkit/workspace/savedoc.py +664 -0
- ff9mapkit/workspace/shell.py +6907 -0
- ff9mapkit/workspace/style.py +105 -0
- ff9mapkit/workspace/tuningdialog.py +223 -0
- ff9mapkit-1.0.0b3.dist-info/METADATA +155 -0
- ff9mapkit-1.0.0b3.dist-info/RECORD +193 -0
- ff9mapkit-1.0.0b3.dist-info/WHEEL +5 -0
- ff9mapkit-1.0.0b3.dist-info/entry_points.txt +5 -0
- ff9mapkit-1.0.0b3.dist-info/licenses/LICENSE +31 -0
- ff9mapkit-1.0.0b3.dist-info/top_level.txt +1 -0
ff9mapkit/editor/app.py
ADDED
|
@@ -0,0 +1,950 @@
|
|
|
1
|
+
"""The FF9 Map Kit field-logic editor (Tkinter UI).
|
|
2
|
+
|
|
3
|
+
A friendly form-based front-end so authors edit a field's LOGIC (dialogue, events, story flags,
|
|
4
|
+
encounters, music, cutscenes) without hand-writing TOML. Spatial placement (camera / walkmesh /
|
|
5
|
+
positions / zones) stays in Blender; this edits the ``<field>.field.toml`` and never touches a sibling
|
|
6
|
+
``<field>.scene.toml``.
|
|
7
|
+
|
|
8
|
+
All the non-UI logic lives in the tk-free :mod:`.model` (load/save/serialize) and :mod:`.forms`
|
|
9
|
+
(specs/parsers), which are unit-tested; this file is the thin Tk wiring over them. Launch with
|
|
10
|
+
``ff9mapkit edit [field.toml]``.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import copy
|
|
16
|
+
import queue
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
import threading
|
|
20
|
+
import traceback
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
import tkinter as tk
|
|
24
|
+
from tkinter import filedialog, messagebox, ttk
|
|
25
|
+
|
|
26
|
+
from . import forms, picker
|
|
27
|
+
from .model import FieldDoc, protected_reason
|
|
28
|
+
from .theme import apply_theme
|
|
29
|
+
|
|
30
|
+
# single-table logic sections and their specs; the rest are arrays-of-tables.
|
|
31
|
+
SINGLE_SPECS = {"field": forms.FIELD_SPEC, "encounter": forms.ENCOUNTER_SPEC,
|
|
32
|
+
"music": forms.MUSIC_SPEC, "dialogue": forms.DIALOGUE_SPEC}
|
|
33
|
+
LIST_SPECS = {"npc": forms.NPC_SPEC, "gateway": forms.GATEWAY_SPEC, "event": forms.EVENT_SPEC,
|
|
34
|
+
"marker": forms.MARKER_SPEC}
|
|
35
|
+
LIST_LABELS = {"npc": "NPCs", "gateway": "Gateways", "event": "Events", "marker": "Markers"}
|
|
36
|
+
OPTIONAL_SINGLES = ("encounter", "music", "cutscene", "dialogue") # add/remove-able
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _find_tool(name):
|
|
40
|
+
"""Locate a dev tool script (deploy_field.py / revert_deploy.py) relative to the repo, or None.
|
|
41
|
+
Only present in the dev checkout; a distributed kit just won't show the in-game test buttons."""
|
|
42
|
+
here = Path(__file__).resolve()
|
|
43
|
+
for base in here.parents:
|
|
44
|
+
for cand in (base / "tools" / name, base / "tools" / "scroll_out" / name):
|
|
45
|
+
if cand.is_file():
|
|
46
|
+
return cand
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _find_app(name):
|
|
51
|
+
"""Locate a GUI app (e.g. ff9_dialogue.pyw) under a repo-root ``apps/`` dir, or None (a packaged
|
|
52
|
+
install without the apps/ dir just won't offer the standalone hand-off)."""
|
|
53
|
+
here = Path(__file__).resolve()
|
|
54
|
+
for base in here.parents:
|
|
55
|
+
cand = base / "apps" / name
|
|
56
|
+
if cand.is_file():
|
|
57
|
+
return cand
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class EditorApp:
|
|
62
|
+
def __init__(self, parent, path=None):
|
|
63
|
+
self.container = parent # where the UI mounts (a window OR a notebook tab)
|
|
64
|
+
self.root = parent.winfo_toplevel() # the real Tk root (after / dialogs / theme)
|
|
65
|
+
self.doc: FieldDoc | None = None
|
|
66
|
+
self.active = None # {"type":..., "index":...} currently-edited node
|
|
67
|
+
self.getters = {} # key -> widget reader for the active form
|
|
68
|
+
self.step_widgets = None # cutscene step editor state
|
|
69
|
+
self.opt_widgets = None # choice-options sub-editor state
|
|
70
|
+
self.active_choice = None # index of the choice whose options are being edited
|
|
71
|
+
self.campaign_idmap = None # optional {field_id: member_name} set by the Campaign workspace
|
|
72
|
+
self.campaign_plan = None # optional CampaignPlan -> flag picker source + name resolution
|
|
73
|
+
self.dialogue_opener = None # optional hook: hand the current field to the Dialogue tab (unified window)
|
|
74
|
+
self.busy = False
|
|
75
|
+
self.deploy = _find_tool("deploy_field.py")
|
|
76
|
+
self.revert = _find_tool("revert_deploy.py")
|
|
77
|
+
self.q: queue.Queue = queue.Queue()
|
|
78
|
+
|
|
79
|
+
self.palette = apply_theme(self.root) # modern look (auto light/dark) -> palette colours
|
|
80
|
+
self._build_toolbar()
|
|
81
|
+
ttk.Separator(self.container, orient="horizontal").pack(fill="x")
|
|
82
|
+
panes = ttk.PanedWindow(self.container, orient="horizontal")
|
|
83
|
+
panes.pack(fill="both", expand=True, padx=8, pady=8)
|
|
84
|
+
left = ttk.Frame(panes)
|
|
85
|
+
self.tree = ttk.Treeview(left, show="tree", selectmode="browse")
|
|
86
|
+
self.tree.pack(fill="both", expand=True, side="left")
|
|
87
|
+
sb = ttk.Scrollbar(left, orient="vertical", command=self.tree.yview)
|
|
88
|
+
sb.pack(fill="y", side="right")
|
|
89
|
+
self.tree.configure(yscrollcommand=sb.set)
|
|
90
|
+
self.tree.bind("<<TreeviewSelect>>", self._on_select)
|
|
91
|
+
panes.add(left, weight=1)
|
|
92
|
+
self.form = ttk.Frame(panes)
|
|
93
|
+
panes.add(self.form, weight=3)
|
|
94
|
+
|
|
95
|
+
self.status = self._build_log(self.container)
|
|
96
|
+
self._log("Open a .field.toml, or New. Edit logic on the right; placement stays in Blender. "
|
|
97
|
+
"New here? Click Help for a 30-second tour.")
|
|
98
|
+
self.root.after(120, self._drain)
|
|
99
|
+
if path:
|
|
100
|
+
self._load(Path(path))
|
|
101
|
+
else:
|
|
102
|
+
self._show_welcome()
|
|
103
|
+
|
|
104
|
+
# --------------------------------------------------------------- toolbar
|
|
105
|
+
def _build_toolbar(self):
|
|
106
|
+
bar = ttk.Frame(self.container)
|
|
107
|
+
bar.pack(fill="x", padx=6, pady=6)
|
|
108
|
+
ttk.Button(bar, text="Open", command=self.on_open).pack(side="left")
|
|
109
|
+
ttk.Button(bar, text="New", command=self.on_new).pack(side="left", padx=(6, 0))
|
|
110
|
+
self.btn_save = ttk.Button(bar, text="Save", command=self.on_save, style="Accent.TButton")
|
|
111
|
+
self.btn_save.pack(side="left", padx=(6, 0))
|
|
112
|
+
ttk.Separator(bar, orient="vertical").pack(side="left", fill="y", padx=8)
|
|
113
|
+
self.btn_check = ttk.Button(bar, text="Check logic", command=self.on_check)
|
|
114
|
+
self.btn_check.pack(side="left")
|
|
115
|
+
self.btn_build = ttk.Button(bar, text="Build to game", command=self.on_build_game)
|
|
116
|
+
self.btn_build.pack(side="left", padx=(6, 0))
|
|
117
|
+
ttk.Button(bar, text="Dialogue...", command=self.on_open_dialogue).pack(side="left", padx=(6, 0))
|
|
118
|
+
if self.deploy:
|
|
119
|
+
self.btn_test = ttk.Button(bar, text="Build & Test (4003)", command=self.on_test,
|
|
120
|
+
style="Accent.TButton")
|
|
121
|
+
self.btn_test.pack(side="left", padx=(6, 0))
|
|
122
|
+
if self.revert:
|
|
123
|
+
ttk.Button(bar, text="Revert test", command=self.on_revert).pack(side="left", padx=(6, 0))
|
|
124
|
+
self.title_lbl = ttk.Label(bar, text="(no file)")
|
|
125
|
+
self.title_lbl.pack(side="right")
|
|
126
|
+
ttk.Button(bar, text="Help", command=self.on_help).pack(side="right", padx=(0, 8))
|
|
127
|
+
|
|
128
|
+
def on_help(self):
|
|
129
|
+
messagebox.showinfo(
|
|
130
|
+
"FF9 Map Kit - Field Editor",
|
|
131
|
+
"WHAT THIS IS\n"
|
|
132
|
+
"A form-based editor for a field's LOGIC: dialogue, NPCs, events, story flags, "
|
|
133
|
+
"encounters, music, and cutscenes. No hand-writing TOML.\n\n"
|
|
134
|
+
"WHERE PLACEMENT LIVES\n"
|
|
135
|
+
"The camera, the walkmesh, and where things stand are SPATIAL -- you set those in "
|
|
136
|
+
"Blender (the FF9 Map Kit add-on). This editor only touches the logic file "
|
|
137
|
+
"(<name>.field.toml) and never your Blender scene (<name>.scene.toml).\n\n"
|
|
138
|
+
"GETTING A FILE TO OPEN\n"
|
|
139
|
+
" - ff9mapkit new MY_ROOM scaffold a blank room\n"
|
|
140
|
+
" - ff9mapkit import <field> fork a real FF9 field (add --editable to repaint it)\n"
|
|
141
|
+
" - the Blender add-on's Export\n\n"
|
|
142
|
+
"WORKFLOW\n"
|
|
143
|
+
"Open -> pick a section on the left -> fill the form -> Save -> Check logic -> Build.\n"
|
|
144
|
+
"A section marked (+) can be added; NPCs / Gateways / Events / Markers each hold a list.\n\n"
|
|
145
|
+
"SECTIONS\n"
|
|
146
|
+
" - NPCs / Gateways / Events: people, exits, and walk-in triggers.\n"
|
|
147
|
+
" - Markers: named floor points a cutscene can walk to by name.\n"
|
|
148
|
+
" - Choices: talk to an NPC -> a menu -> branch (each option: reply / item / gil / flag).\n"
|
|
149
|
+
" - Cutscene: ordered steps (control locks). An 'actor' NPC can walk / emote.\n"
|
|
150
|
+
" - Dialogue: auto-wrap width for long lines. Encounter / Music: battles + BGM.\n\n"
|
|
151
|
+
"CUTSCENE STEPS\n"
|
|
152
|
+
" - walk/teleport: a marker name, @player, or \"x, z\" (walk auto-routes around things).\n"
|
|
153
|
+
" - path: a route, \"a; b; c\". animation: a gesture name (glad) or id. say: a line.\n\n"
|
|
154
|
+
"A FEW FIELDS\n"
|
|
155
|
+
" - Field ID: any unique number >= 4000.\n"
|
|
156
|
+
" - Area: must be >= 10 (lower areas don't render).\n"
|
|
157
|
+
" - Text block: leave at 1073 unless you know otherwise.\n"
|
|
158
|
+
" - NPC preset: vivi or zidane is the easy path (a custom model also needs anims set "
|
|
159
|
+
"in the .toml).")
|
|
160
|
+
|
|
161
|
+
def on_open_dialogue(self):
|
|
162
|
+
"""Hand the current field to the Dialogue editor (the focused word-smithing + stock-dialogue view).
|
|
163
|
+
In the unified Campaign Editor the host wires ``dialogue_opener`` to flip to the Dialogue tab (which
|
|
164
|
+
shares this very doc); standalone, it saves + launches the Dialogue app on the file."""
|
|
165
|
+
if self.doc is None:
|
|
166
|
+
messagebox.showinfo("No field", "Open or create a field first.")
|
|
167
|
+
return
|
|
168
|
+
if not self._commit_active():
|
|
169
|
+
return
|
|
170
|
+
if self.dialogue_opener is not None: # unified window: same shared doc, just flip tabs
|
|
171
|
+
self.dialogue_opener()
|
|
172
|
+
return
|
|
173
|
+
if not self._ensure_saved(): # standalone: persist, then open the app on the file
|
|
174
|
+
return
|
|
175
|
+
app = _find_app("ff9_dialogue.pyw")
|
|
176
|
+
if app is None:
|
|
177
|
+
messagebox.showinfo("Dialogue editor",
|
|
178
|
+
"The standalone Dialogue app wasn't found (apps/ff9_dialogue.pyw).")
|
|
179
|
+
return
|
|
180
|
+
subprocess.Popen([sys.executable, str(app), str(self.doc.path)])
|
|
181
|
+
self._log("opened the Dialogue editor on this field.")
|
|
182
|
+
|
|
183
|
+
# --------------------------------------------------------------- logging
|
|
184
|
+
def _build_log(self, root):
|
|
185
|
+
"""A flat console log (Text + ttk scrollbar) with severity colour tags."""
|
|
186
|
+
pal = self.palette
|
|
187
|
+
wrap = ttk.Frame(root)
|
|
188
|
+
wrap.pack(fill="x", padx=8, pady=(0, 8))
|
|
189
|
+
txt = tk.Text(wrap, height=7, state="disabled", wrap="word", relief="flat", borderwidth=0,
|
|
190
|
+
background=pal["log_bg"], foreground=pal["log_fg"], padx=10, pady=8,
|
|
191
|
+
highlightthickness=1, highlightbackground=pal["border"],
|
|
192
|
+
highlightcolor=pal["border"], insertbackground=pal["text"])
|
|
193
|
+
txt.pack(side="left", fill="both", expand=True)
|
|
194
|
+
sb = ttk.Scrollbar(wrap, orient="vertical", command=txt.yview)
|
|
195
|
+
sb.pack(side="right", fill="y")
|
|
196
|
+
txt.configure(yscrollcommand=sb.set)
|
|
197
|
+
for tag, col in (("error", pal["error"]), ("warn", pal["warn"]),
|
|
198
|
+
("ok", pal["success"]), ("muted", pal["muted"])):
|
|
199
|
+
txt.tag_configure(tag, foreground=col)
|
|
200
|
+
return txt
|
|
201
|
+
|
|
202
|
+
def _log(self, msg):
|
|
203
|
+
text = str(msg).rstrip() + "\n"
|
|
204
|
+
probe = text.strip()
|
|
205
|
+
if probe.startswith(("ERROR", "INVALID")) or " ERROR " in text:
|
|
206
|
+
tag = "error"
|
|
207
|
+
elif probe.startswith("warn") or probe[:12].startswith("warning"):
|
|
208
|
+
tag = "warn"
|
|
209
|
+
elif probe.startswith(("OK", ">>>")):
|
|
210
|
+
tag = "ok"
|
|
211
|
+
elif probe.startswith("---"):
|
|
212
|
+
tag = "muted"
|
|
213
|
+
else:
|
|
214
|
+
tag = None
|
|
215
|
+
self.status.configure(state="normal")
|
|
216
|
+
self.status.insert("end", text, (tag,) if tag else ())
|
|
217
|
+
self.status.see("end")
|
|
218
|
+
self.status.configure(state="disabled")
|
|
219
|
+
|
|
220
|
+
def post(self, msg):
|
|
221
|
+
self.q.put(msg)
|
|
222
|
+
|
|
223
|
+
def _drain(self):
|
|
224
|
+
try:
|
|
225
|
+
while True:
|
|
226
|
+
self._log(self.q.get_nowait())
|
|
227
|
+
except queue.Empty:
|
|
228
|
+
pass
|
|
229
|
+
self.root.after(120, self._drain)
|
|
230
|
+
|
|
231
|
+
# --------------------------------------------------------------- file io
|
|
232
|
+
def on_open(self):
|
|
233
|
+
f = filedialog.askopenfilename(title="Open field.toml",
|
|
234
|
+
filetypes=[("Field project", "*.field.toml"),
|
|
235
|
+
("TOML", "*.toml"), ("All files", "*.*")])
|
|
236
|
+
if f:
|
|
237
|
+
self.open_path(Path(f))
|
|
238
|
+
|
|
239
|
+
def open_path(self, path) -> bool:
|
|
240
|
+
"""Load a field.toml -- the SINGLE load entry point (the toolbar Open and the campaign navigator
|
|
241
|
+
both route here). If another file is already open, offers to save it first; returns False if the
|
|
242
|
+
user cancels the switch (or a requested save fails) so a caller can keep the prior selection."""
|
|
243
|
+
path = Path(path)
|
|
244
|
+
cur = getattr(self.doc, "path", None) if self.doc is not None else None
|
|
245
|
+
if cur is not None and Path(cur) != path and not self._offer_save_before_switch(path):
|
|
246
|
+
return False
|
|
247
|
+
self._load(path)
|
|
248
|
+
return True
|
|
249
|
+
|
|
250
|
+
def _offer_save_before_switch(self, newpath) -> bool:
|
|
251
|
+
"""Give the user a chance to save the open doc before it's replaced. Returns False to abort the
|
|
252
|
+
switch (Cancel, or a save the user asked for that then failed). No prompt when nothing changed."""
|
|
253
|
+
if not self._commit_active(): # fold the active form in first (may report a parse error)
|
|
254
|
+
return False
|
|
255
|
+
if not self._dirty(): # unchanged since load/save -> switch freely, no nag
|
|
256
|
+
return True
|
|
257
|
+
ans = messagebox.askyesnocancel(
|
|
258
|
+
"Save changes?",
|
|
259
|
+
f"Save changes to {self.doc.path.name} before opening {Path(newpath).name}?\n\n"
|
|
260
|
+
"Unsaved edits are lost if you don't.")
|
|
261
|
+
if ans is None: # Cancel -> stay on the current file
|
|
262
|
+
return False
|
|
263
|
+
if ans: # Yes -> save (abort the switch if the save fails)
|
|
264
|
+
return self.on_save()
|
|
265
|
+
return True # No -> discard and switch
|
|
266
|
+
|
|
267
|
+
def _mark_clean(self):
|
|
268
|
+
"""Snapshot the doc's data as the 'saved' baseline -- :meth:`_dirty` compares against this."""
|
|
269
|
+
self._clean = copy.deepcopy(self.doc.data) if self.doc is not None else None
|
|
270
|
+
|
|
271
|
+
def _dirty(self) -> bool:
|
|
272
|
+
"""True if the (committed) doc differs from its last load/save baseline. Used to skip the
|
|
273
|
+
save-before-switch prompt when the user only navigated. Commit the active form before calling."""
|
|
274
|
+
return self.doc is not None and self.doc.data != getattr(self, "_clean", None)
|
|
275
|
+
|
|
276
|
+
def _load(self, path):
|
|
277
|
+
try:
|
|
278
|
+
self.doc = FieldDoc.load(path)
|
|
279
|
+
except Exception as e: # noqa: BLE001
|
|
280
|
+
messagebox.showerror("Open failed", f"{path}\n\n{e}")
|
|
281
|
+
return
|
|
282
|
+
self.active = None
|
|
283
|
+
self._mark_clean()
|
|
284
|
+
split = " (+ scene.toml)" if self.doc.scene_data is not None else ""
|
|
285
|
+
self.title_lbl.configure(text=path.name + split)
|
|
286
|
+
self._log(f"opened {path.name}{split}")
|
|
287
|
+
self._refresh_tree(reselect="field") # land on the Field form (clears the welcome)
|
|
288
|
+
|
|
289
|
+
def on_new(self):
|
|
290
|
+
f = filedialog.asksaveasfilename(title="New field.toml", defaultextension=".field.toml",
|
|
291
|
+
filetypes=[("Field project", "*.field.toml")])
|
|
292
|
+
if not f:
|
|
293
|
+
return
|
|
294
|
+
p = Path(f)
|
|
295
|
+
reason = protected_reason(p)
|
|
296
|
+
if reason:
|
|
297
|
+
messagebox.showerror("Can't create here", f"{p}\n\n{reason}.\n\nPick a folder of your own.")
|
|
298
|
+
return
|
|
299
|
+
name = p.name[:-len(".field.toml")] if p.name.endswith(".field.toml") else p.stem
|
|
300
|
+
self.doc = FieldDoc.new(p, name=name.upper())
|
|
301
|
+
self.active = None
|
|
302
|
+
self._mark_clean()
|
|
303
|
+
self.title_lbl.configure(text=p.name + " (new, unsaved)")
|
|
304
|
+
self._refresh_tree(reselect="field") # land on the Field form (clears the welcome)
|
|
305
|
+
self._log(f"new field {name.upper()} -- fill in [field], add content, then Save.")
|
|
306
|
+
|
|
307
|
+
def on_save(self):
|
|
308
|
+
if not self._commit_active():
|
|
309
|
+
return False
|
|
310
|
+
if self.doc is None:
|
|
311
|
+
return False
|
|
312
|
+
reason = protected_reason(self.doc.path)
|
|
313
|
+
if reason:
|
|
314
|
+
messagebox.showerror("Can't save here", f"{self.doc.path}\n\n{reason}.\n\n"
|
|
315
|
+
"Save a copy in a folder of your own first.")
|
|
316
|
+
return False
|
|
317
|
+
try:
|
|
318
|
+
self._cleanup_empty()
|
|
319
|
+
self.doc.save()
|
|
320
|
+
self._mark_clean()
|
|
321
|
+
self._log(f"saved {self.doc.path.name}")
|
|
322
|
+
except Exception as e: # noqa: BLE001
|
|
323
|
+
messagebox.showerror("Save failed", str(e))
|
|
324
|
+
return False
|
|
325
|
+
return True
|
|
326
|
+
|
|
327
|
+
def _cleanup_empty(self):
|
|
328
|
+
"""Drop empty array sections so we don't write ``npc = []`` etc."""
|
|
329
|
+
for key in list(self.doc.data.keys()):
|
|
330
|
+
v = self.doc.data[key]
|
|
331
|
+
if isinstance(v, list) and not v:
|
|
332
|
+
self.doc.data.pop(key)
|
|
333
|
+
|
|
334
|
+
# --------------------------------------------------------------- tree
|
|
335
|
+
def _refresh_tree(self, reselect=None):
|
|
336
|
+
self.tree.delete(*self.tree.get_children())
|
|
337
|
+
if self.doc is None:
|
|
338
|
+
return
|
|
339
|
+
self.tree.insert("", "end", iid="field", text="Field")
|
|
340
|
+
self.tree.insert("", "end", iid="camera", text="Camera (Blender)")
|
|
341
|
+
for key in ("dialogue", "encounter", "music", "cutscene"):
|
|
342
|
+
present = key in self.doc.data
|
|
343
|
+
label = {"dialogue": "Dialogue", "encounter": "Encounter",
|
|
344
|
+
"music": "Music", "cutscene": "Cutscene"}[key]
|
|
345
|
+
self.tree.insert("", "end", iid=key, text=label + ("" if present else " (+)"))
|
|
346
|
+
for key in ("npc", "gateway", "event", "marker"):
|
|
347
|
+
parent = self.tree.insert("", "end", iid=key, text=f"{LIST_LABELS[key]} (+)", open=True)
|
|
348
|
+
for i, e in enumerate(self.doc.data.get(key, [])):
|
|
349
|
+
nm = e.get("name") or f"#{i}"
|
|
350
|
+
self.tree.insert(parent, "end", iid=f"{key}:{i}", text=nm)
|
|
351
|
+
cparent = self.tree.insert("", "end", iid="choice", text="Choices (+)", open=True)
|
|
352
|
+
for i, ch in enumerate(self.doc.data.get("choice", [])):
|
|
353
|
+
self.tree.insert(cparent, "end", iid=f"choice:{i}", text=forms.choice_summary(ch))
|
|
354
|
+
if reselect and self.tree.exists(reselect):
|
|
355
|
+
self.tree.selection_set(reselect)
|
|
356
|
+
self.tree.see(reselect)
|
|
357
|
+
|
|
358
|
+
def _on_select(self, _evt):
|
|
359
|
+
sel = self.tree.selection()
|
|
360
|
+
if not sel:
|
|
361
|
+
return
|
|
362
|
+
iid = sel[0]
|
|
363
|
+
if self.active and not self._commit_active():
|
|
364
|
+
return # a parse error -- stay put
|
|
365
|
+
self._show(iid)
|
|
366
|
+
|
|
367
|
+
# --------------------------------------------------------------- forms
|
|
368
|
+
def _clear_form(self):
|
|
369
|
+
for w in self.form.winfo_children():
|
|
370
|
+
w.destroy()
|
|
371
|
+
self.getters = {}
|
|
372
|
+
self.step_widgets = None
|
|
373
|
+
self.opt_widgets = None
|
|
374
|
+
self.active = None
|
|
375
|
+
|
|
376
|
+
def _show_welcome(self):
|
|
377
|
+
"""Empty-state guidance shown before any file is open (newcomer's first screen)."""
|
|
378
|
+
self._clear_form()
|
|
379
|
+
msg = (
|
|
380
|
+
"FF9 Map Kit - Field Editor\n\n"
|
|
381
|
+
"This edits a field's LOGIC: dialogue, NPCs, events, story flags, encounters, music, and "
|
|
382
|
+
"cutscenes. The SPATIAL side -- the camera, the walkmesh, and where things stand -- is "
|
|
383
|
+
"authored in Blender; this editor never changes it.\n\n"
|
|
384
|
+
"Open a .field.toml to start. You can get one from:\n"
|
|
385
|
+
" - ff9mapkit new MY_ROOM a blank room\n"
|
|
386
|
+
" - ff9mapkit import <field> fork a real FF9 field\n"
|
|
387
|
+
" - the Blender add-on's Export\n\n"
|
|
388
|
+
"Then: pick a section on the left, fill in the form, Save, Check logic, Build.\n\n"
|
|
389
|
+
"On the left, a section marked (+) is one you can add (Encounter, Music, Cutscene) or add "
|
|
390
|
+
"items to (NPCs, Gateways, Events). Click Help in the toolbar any time."
|
|
391
|
+
)
|
|
392
|
+
ttk.Label(self.form, text=msg, justify="left", wraplength=560).pack(anchor="nw", padx=14, pady=14)
|
|
393
|
+
|
|
394
|
+
def _show(self, iid):
|
|
395
|
+
self._clear_form()
|
|
396
|
+
if iid == "field":
|
|
397
|
+
self._show_single("field", forms.FIELD_SPEC, "Field")
|
|
398
|
+
elif iid == "camera":
|
|
399
|
+
self._show_camera()
|
|
400
|
+
elif iid in ("encounter", "music", "dialogue"):
|
|
401
|
+
self._show_optional_single(iid, SINGLE_SPECS[iid], iid.capitalize())
|
|
402
|
+
elif iid == "cutscene":
|
|
403
|
+
self._show_cutscene()
|
|
404
|
+
elif iid == "choice":
|
|
405
|
+
self._show_choice_list()
|
|
406
|
+
elif iid.startswith("choice:"):
|
|
407
|
+
self._show_choice(int(iid.split(":", 1)[1]))
|
|
408
|
+
elif iid in LIST_SPECS:
|
|
409
|
+
self._show_list_parent(iid)
|
|
410
|
+
elif ":" in iid:
|
|
411
|
+
kind, idx = iid.split(":")
|
|
412
|
+
self._show_entity(kind, int(idx))
|
|
413
|
+
|
|
414
|
+
def _header(self, text, key=None):
|
|
415
|
+
ttk.Label(self.form, text=text, font=("", 11, "bold")).pack(anchor="w", padx=8, pady=(8, 2))
|
|
416
|
+
note = forms.SECTION_HELP.get(key) if key else None
|
|
417
|
+
if note:
|
|
418
|
+
ttk.Label(self.form, text=note, foreground=self.palette["muted"], wraplength=580,
|
|
419
|
+
justify="left").pack(anchor="w", padx=10, pady=(0, 4))
|
|
420
|
+
|
|
421
|
+
def _form_grid(self):
|
|
422
|
+
g = ttk.Frame(self.form)
|
|
423
|
+
g.pack(fill="x", padx=8, pady=4)
|
|
424
|
+
return g
|
|
425
|
+
|
|
426
|
+
def _render_spec(self, parent, spec, values, vars_out=None):
|
|
427
|
+
getters = {}
|
|
428
|
+
for r, f in enumerate(spec):
|
|
429
|
+
ttk.Label(parent, text=f.label + ":").grid(row=r, column=0, sticky="ne", padx=4, pady=2)
|
|
430
|
+
if f.kind == forms.BOOL:
|
|
431
|
+
var = tk.BooleanVar(value=bool(values.get(f.key, f.default)))
|
|
432
|
+
ttk.Checkbutton(parent, variable=var).grid(row=r, column=1, sticky="w")
|
|
433
|
+
else:
|
|
434
|
+
var = tk.StringVar(value=str(values.get(f.key, "") or ""))
|
|
435
|
+
mk = ((lambda h: ttk.Combobox(h, textvariable=var, values=forms.PRESETS))
|
|
436
|
+
if f.kind == forms.PRESET else (lambda h: ttk.Entry(h, textvariable=var)))
|
|
437
|
+
if getattr(f, "catalog", None): # a catalog-backed field gets a "Browse..." picker
|
|
438
|
+
host = ttk.Frame(parent)
|
|
439
|
+
host.grid(row=r, column=1, sticky="we")
|
|
440
|
+
host.columnconfigure(0, weight=1, minsize=130) # don't let the button squash the widget
|
|
441
|
+
mk(host).grid(row=0, column=0, sticky="we")
|
|
442
|
+
ttk.Button(host, text="Browse...", width=9,
|
|
443
|
+
command=lambda fk=f, v=var: self._pick_catalog(fk, v)).grid(
|
|
444
|
+
row=0, column=1, padx=(4, 0))
|
|
445
|
+
else:
|
|
446
|
+
mk(parent).grid(row=r, column=1, sticky="we")
|
|
447
|
+
getters[f.key] = var.get
|
|
448
|
+
if vars_out is not None: # expose the vars so a caller can re-populate (option editor)
|
|
449
|
+
vars_out[f.key] = var
|
|
450
|
+
if f.help:
|
|
451
|
+
ttk.Label(parent, text=f.help, foreground=self.palette["muted"]).grid(
|
|
452
|
+
row=r, column=2, sticky="w", padx=6)
|
|
453
|
+
parent.columnconfigure(1, weight=1, minsize=230) # the widget column stays usable even with a Browse button
|
|
454
|
+
return getters
|
|
455
|
+
|
|
456
|
+
def _pick_catalog(self, field, var):
|
|
457
|
+
"""Open the Info Hub picker for a catalog-backed field; write the chosen name into its widget.
|
|
458
|
+
Passes the open campaign so a ``catalog="flag"`` field can pick a shared [[flag]] by name."""
|
|
459
|
+
kinds = [k.strip() for k in field.catalog.split(",")] if field.catalog else None
|
|
460
|
+
name = picker.pick(self.root, kinds=kinds, title=f"Pick {field.label}", initial=var.get().strip(),
|
|
461
|
+
campaign_context=self.campaign_plan)
|
|
462
|
+
if name:
|
|
463
|
+
var.set(name)
|
|
464
|
+
|
|
465
|
+
def _campaign_flag_names(self):
|
|
466
|
+
"""The open campaign's shared [[flag]] name->index map (or None standalone), so Check/Build resolve
|
|
467
|
+
cross-field flag NAMES exactly as the campaign build does -- else a name would choke _gate_of's int()."""
|
|
468
|
+
plan = self.campaign_plan
|
|
469
|
+
if plan is None:
|
|
470
|
+
return None
|
|
471
|
+
try:
|
|
472
|
+
from ..flags import collect_flag_defs
|
|
473
|
+
return collect_flag_defs({"flag": getattr(plan, "flags", [])})
|
|
474
|
+
except ValueError as e: # a malformed campaign [[flag]] -- surface it, don't hide it
|
|
475
|
+
self.post(f" warn campaign [[flag]] table invalid: {e}")
|
|
476
|
+
return {}
|
|
477
|
+
|
|
478
|
+
def _show_single(self, key, spec, title):
|
|
479
|
+
self._header(title, key)
|
|
480
|
+
values = forms.entity_to_values(spec, self.doc.data.get(key, {}))
|
|
481
|
+
self.getters = self._render_spec(self._form_grid(), spec, values)
|
|
482
|
+
self.active = {"type": key, "section": key}
|
|
483
|
+
|
|
484
|
+
def _show_optional_single(self, key, spec, title):
|
|
485
|
+
if key not in self.doc.data:
|
|
486
|
+
self._header(title + " (not set)", key)
|
|
487
|
+
ttk.Label(self.form, text=f"No {title.lower()} on this field.").pack(anchor="w", padx=10)
|
|
488
|
+
ttk.Button(self.form, text=f"Add {title}",
|
|
489
|
+
command=lambda: self._add_single(key)).pack(anchor="w", padx=10, pady=6)
|
|
490
|
+
self.active = None
|
|
491
|
+
return
|
|
492
|
+
self._header(title, key)
|
|
493
|
+
values = forms.entity_to_values(spec, self.doc.data.get(key, {}))
|
|
494
|
+
self.getters = self._render_spec(self._form_grid(), spec, values)
|
|
495
|
+
self.active = {"type": key, "section": key}
|
|
496
|
+
ttk.Button(self.form, text=f"Remove {title}",
|
|
497
|
+
command=lambda: self._remove_single(key)).pack(anchor="w", padx=10, pady=8)
|
|
498
|
+
|
|
499
|
+
def _add_single(self, key):
|
|
500
|
+
self.doc.data.setdefault(key, {} if key != "cutscene" else {"steps": []})
|
|
501
|
+
self._refresh_tree(reselect=key)
|
|
502
|
+
self._show(key)
|
|
503
|
+
|
|
504
|
+
def _remove_single(self, key):
|
|
505
|
+
self.active = None
|
|
506
|
+
self.doc.remove_section(key)
|
|
507
|
+
self._refresh_tree(reselect=key)
|
|
508
|
+
self._show(key)
|
|
509
|
+
|
|
510
|
+
def _show_list_parent(self, kind):
|
|
511
|
+
self._header(LIST_LABELS[kind], kind)
|
|
512
|
+
ttk.Button(self.form, text=f"Add {kind}", command=lambda: self._add_entity(kind)).pack(
|
|
513
|
+
anchor="w", padx=10, pady=6)
|
|
514
|
+
n = len(self.doc.data.get(kind, []))
|
|
515
|
+
ttk.Label(self.form, text=f"{n} {kind}(s). Select one on the left to edit, or Add.").pack(
|
|
516
|
+
anchor="w", padx=10)
|
|
517
|
+
self.active = None
|
|
518
|
+
|
|
519
|
+
def _add_entity(self, kind):
|
|
520
|
+
defaults = {"npc": {"name": "NPC", "preset": "vivi", "dialogue": "..."},
|
|
521
|
+
"gateway": {"name": "door", "to": 100, "entrance": 0},
|
|
522
|
+
"event": {"name": "event", "message": "..."},
|
|
523
|
+
"marker": {"name": "spot", "pos": [0, 0]}}[kind]
|
|
524
|
+
lst = self.doc.list_section(kind)
|
|
525
|
+
lst.append(dict(defaults))
|
|
526
|
+
self._refresh_tree(reselect=f"{kind}:{len(lst) - 1}")
|
|
527
|
+
self._show_entity(kind, len(lst) - 1)
|
|
528
|
+
|
|
529
|
+
def _show_entity(self, kind, idx):
|
|
530
|
+
spec = LIST_SPECS[kind]
|
|
531
|
+
lst = self.doc.data.get(kind, [])
|
|
532
|
+
if idx >= len(lst):
|
|
533
|
+
return
|
|
534
|
+
entity = lst[idx]
|
|
535
|
+
self._header(f"{LIST_LABELS[kind][:-1]}: {entity.get('name') or '#' + str(idx)}", kind)
|
|
536
|
+
self.getters = self._render_spec(self._form_grid(), spec, forms.entity_to_values(spec, entity))
|
|
537
|
+
self.active = {"type": kind, "section": kind, "index": idx}
|
|
538
|
+
# show the Blender-placed spatial value (read-only hint) if it's in the scene file
|
|
539
|
+
scene_e = self.doc.scene_entities(kind).get(entity.get("name", ""))
|
|
540
|
+
if scene_e:
|
|
541
|
+
spatial = scene_e.get("pos") or scene_e.get("zone")
|
|
542
|
+
ttk.Label(self.form, text=f"placed in Blender: {spatial}",
|
|
543
|
+
foreground=self.palette["success"]).pack(anchor="w", padx=10, pady=(2, 0))
|
|
544
|
+
# in a campaign, resolve a gateway's numeric target to the member it leads to (read-only hint)
|
|
545
|
+
if kind == "gateway" and self.campaign_idmap:
|
|
546
|
+
member = self.campaign_idmap.get(entity.get("to"))
|
|
547
|
+
if member:
|
|
548
|
+
ttk.Label(self.form, text=f"→ leads to campaign member: {member}",
|
|
549
|
+
foreground=self.palette["success"]).pack(anchor="w", padx=10, pady=(2, 0))
|
|
550
|
+
ttk.Button(self.form, text=f"Delete this {kind}",
|
|
551
|
+
command=lambda: self._delete_entity(kind, idx)).pack(anchor="w", padx=10, pady=8)
|
|
552
|
+
|
|
553
|
+
def _delete_entity(self, kind, idx):
|
|
554
|
+
self.active = None
|
|
555
|
+
lst = self.doc.data.get(kind, [])
|
|
556
|
+
if idx < len(lst):
|
|
557
|
+
lst.pop(idx)
|
|
558
|
+
self._refresh_tree(reselect=kind)
|
|
559
|
+
self._show(kind)
|
|
560
|
+
|
|
561
|
+
def _show_camera(self):
|
|
562
|
+
self._header("Camera & placement", "camera")
|
|
563
|
+
msg = ("Camera, walkmesh, layers, and entity positions/zones are SPATIAL — author them in "
|
|
564
|
+
"Blender (FF9 Map Kit add-on), which writes the sibling scene.toml. This editor owns "
|
|
565
|
+
"the logic only.")
|
|
566
|
+
ttk.Label(self.form, text=msg, wraplength=520, justify="left").pack(anchor="w", padx=10, pady=8)
|
|
567
|
+
cam = (self.doc.merged() if self.doc else {}).get("camera", {})
|
|
568
|
+
if cam:
|
|
569
|
+
ttk.Label(self.form, text=f"current: {cam}", foreground=self.palette["muted"],
|
|
570
|
+
wraplength=520, justify="left").pack(anchor="w", padx=10)
|
|
571
|
+
self.active = None
|
|
572
|
+
|
|
573
|
+
# --------------------------------------------------------------- cutscene (steps)
|
|
574
|
+
def _show_cutscene(self):
|
|
575
|
+
if "cutscene" not in self.doc.data:
|
|
576
|
+
self._show_optional_single("cutscene", forms.CUTSCENE_SPEC, "Cutscene")
|
|
577
|
+
return
|
|
578
|
+
self._header("Cutscene", "cutscene")
|
|
579
|
+
cs = self.doc.data["cutscene"]
|
|
580
|
+
pal = self.palette
|
|
581
|
+
self.getters = self._render_spec(self._form_grid(), forms.CUTSCENE_SPEC,
|
|
582
|
+
forms.entity_to_values(forms.CUTSCENE_SPEC, cs))
|
|
583
|
+
# --- the step list ---
|
|
584
|
+
ttk.Label(self.form, text="Steps (run in order; control is locked):",
|
|
585
|
+
font=("", 10, "bold")).pack(anchor="w", padx=10, pady=(8, 2))
|
|
586
|
+
body = ttk.Frame(self.form)
|
|
587
|
+
body.pack(fill="both", expand=True, padx=10)
|
|
588
|
+
lb = tk.Listbox(body, height=8, exportselection=False, relief="flat", borderwidth=0,
|
|
589
|
+
background=pal["field"], foreground=pal["text"], activestyle="none",
|
|
590
|
+
selectbackground=pal["accent"], selectforeground=pal["accent_fg"],
|
|
591
|
+
highlightthickness=1, highlightbackground=pal["border"],
|
|
592
|
+
highlightcolor=pal["accent"])
|
|
593
|
+
lb.pack(side="left", fill="both", expand=True)
|
|
594
|
+
for st in cs.get("steps", []):
|
|
595
|
+
lb.insert("end", forms.step_summary(st))
|
|
596
|
+
side = ttk.Frame(body)
|
|
597
|
+
side.pack(side="left", fill="y", padx=(8, 0))
|
|
598
|
+
kind = tk.StringVar(value="say")
|
|
599
|
+
ttk.Label(side, text="Type:").pack(anchor="w")
|
|
600
|
+
ttk.Combobox(side, textvariable=kind, values=list(forms.STEP_KIND),
|
|
601
|
+
state="readonly", width=14).pack(anchor="w")
|
|
602
|
+
val = tk.StringVar()
|
|
603
|
+
ttk.Label(side, text="Value:").pack(anchor="w", pady=(6, 0))
|
|
604
|
+
ttk.Entry(side, textvariable=val, width=16).pack(anchor="w")
|
|
605
|
+
hint = ttk.Label(side, text=forms.STEP_HELP.get(kind.get(), ""), foreground=pal["muted"],
|
|
606
|
+
wraplength=150, justify="left")
|
|
607
|
+
hint.pack(anchor="w", pady=(3, 0))
|
|
608
|
+
kind.trace_add("write", lambda *_: hint.configure(text=forms.STEP_HELP.get(kind.get(), "")))
|
|
609
|
+
ttk.Label(side, text="(walk/path/teleport/animation/\nturn/face need an actor)",
|
|
610
|
+
foreground=pal["muted"], justify="left").pack(anchor="w", pady=(4, 0))
|
|
611
|
+
self.step_widgets = {"listbox": lb, "kind": kind, "val": val}
|
|
612
|
+
ttk.Button(side, text="Add / Update", command=self._step_add).pack(fill="x", pady=(8, 2))
|
|
613
|
+
ttk.Button(side, text="Remove", command=self._step_remove).pack(fill="x", pady=2)
|
|
614
|
+
ttk.Button(side, text="Up", command=lambda: self._step_move(-1)).pack(fill="x", pady=2)
|
|
615
|
+
ttk.Button(side, text="Down", command=lambda: self._step_move(1)).pack(fill="x", pady=2)
|
|
616
|
+
lb.bind("<<ListboxSelect>>", self._step_selected)
|
|
617
|
+
ttk.Button(self.form, text="Remove Cutscene",
|
|
618
|
+
command=lambda: self._remove_single("cutscene")).pack(anchor="w", padx=10, pady=8)
|
|
619
|
+
self.active = {"type": "cutscene", "section": "cutscene"}
|
|
620
|
+
|
|
621
|
+
def _steps(self):
|
|
622
|
+
return self.doc.data.setdefault("cutscene", {}).setdefault("steps", [])
|
|
623
|
+
|
|
624
|
+
def _step_selected(self, _evt):
|
|
625
|
+
w = self.step_widgets
|
|
626
|
+
sel = w["listbox"].curselection()
|
|
627
|
+
if not sel:
|
|
628
|
+
return
|
|
629
|
+
st = self._steps()[sel[0]]
|
|
630
|
+
w["kind"].set(forms.step_key(st))
|
|
631
|
+
w["val"].set(forms.step_value_text(st))
|
|
632
|
+
|
|
633
|
+
def _step_add(self):
|
|
634
|
+
w = self.step_widgets
|
|
635
|
+
try:
|
|
636
|
+
step = forms.make_step(w["kind"].get(), w["val"].get())
|
|
637
|
+
except ValueError as e:
|
|
638
|
+
messagebox.showerror("Bad step", str(e))
|
|
639
|
+
return
|
|
640
|
+
steps = self._steps()
|
|
641
|
+
sel = w["listbox"].curselection()
|
|
642
|
+
if sel and forms.step_key(steps[sel[0]]) == forms.step_key(step):
|
|
643
|
+
steps[sel[0]] = step # update the selected same-type step
|
|
644
|
+
else:
|
|
645
|
+
steps.append(step)
|
|
646
|
+
self._reload_steps()
|
|
647
|
+
|
|
648
|
+
def _step_remove(self):
|
|
649
|
+
w = self.step_widgets
|
|
650
|
+
sel = w["listbox"].curselection()
|
|
651
|
+
if sel:
|
|
652
|
+
self._steps().pop(sel[0])
|
|
653
|
+
self._reload_steps()
|
|
654
|
+
|
|
655
|
+
def _step_move(self, d):
|
|
656
|
+
w = self.step_widgets
|
|
657
|
+
sel = w["listbox"].curselection()
|
|
658
|
+
if not sel:
|
|
659
|
+
return
|
|
660
|
+
i = sel[0]
|
|
661
|
+
steps = self._steps()
|
|
662
|
+
j = i + d
|
|
663
|
+
if 0 <= j < len(steps):
|
|
664
|
+
steps[i], steps[j] = steps[j], steps[i]
|
|
665
|
+
self._reload_steps(select=j)
|
|
666
|
+
|
|
667
|
+
def _reload_steps(self, select=None):
|
|
668
|
+
w = self.step_widgets
|
|
669
|
+
lb = w["listbox"]
|
|
670
|
+
lb.delete(0, "end")
|
|
671
|
+
for st in self._steps():
|
|
672
|
+
lb.insert("end", forms.step_summary(st))
|
|
673
|
+
if select is not None and 0 <= select < lb.size():
|
|
674
|
+
lb.selection_set(select)
|
|
675
|
+
|
|
676
|
+
# --------------------------------------------------------------- choices (npc + options)
|
|
677
|
+
def _show_choice_list(self):
|
|
678
|
+
self._header("Choices", "choice")
|
|
679
|
+
ttk.Button(self.form, text="Add choice", command=self._add_choice).pack(anchor="w", padx=10, pady=6)
|
|
680
|
+
n = len(self.doc.data.get("choice", []))
|
|
681
|
+
ttk.Label(self.form, text=f"{n} choice(s). Select one on the left to edit, or Add. A choice "
|
|
682
|
+
"attaches to an NPC by name; talking to it shows the menu.").pack(anchor="w", padx=10)
|
|
683
|
+
self.active = None
|
|
684
|
+
|
|
685
|
+
def _add_choice(self):
|
|
686
|
+
lst = self.doc.data.setdefault("choice", [])
|
|
687
|
+
lst.append({"npc": "", "prompt": "What'll it be?", "options": [{"text": "Yes"}, {"text": "No"}]})
|
|
688
|
+
self._refresh_tree(reselect=f"choice:{len(lst) - 1}")
|
|
689
|
+
self._show_choice(len(lst) - 1)
|
|
690
|
+
|
|
691
|
+
def _show_choice(self, idx):
|
|
692
|
+
lst = self.doc.data.get("choice", [])
|
|
693
|
+
if idx >= len(lst):
|
|
694
|
+
return
|
|
695
|
+
ch = lst[idx]
|
|
696
|
+
pal = self.palette
|
|
697
|
+
self.active_choice = idx
|
|
698
|
+
self._header(f"Choice: {ch.get('npc') or '#' + str(idx)}", "choice")
|
|
699
|
+
self.getters = self._render_spec(self._form_grid(), forms.CHOICE_SPEC,
|
|
700
|
+
forms.entity_to_values(forms.CHOICE_SPEC, ch))
|
|
701
|
+
self.active = {"type": "choice", "section": "choice", "index": idx}
|
|
702
|
+
# --- the options list + a per-option form ---
|
|
703
|
+
ttk.Label(self.form, text="Options (top-to-bottom; Cancel/B picks the LAST):",
|
|
704
|
+
font=("", 10, "bold")).pack(anchor="w", padx=10, pady=(10, 2))
|
|
705
|
+
row = ttk.Frame(self.form)
|
|
706
|
+
row.pack(fill="x", padx=10)
|
|
707
|
+
lb = tk.Listbox(row, height=5, exportselection=False, relief="flat", borderwidth=0,
|
|
708
|
+
background=pal["field"], foreground=pal["text"], activestyle="none",
|
|
709
|
+
selectbackground=pal["accent"], selectforeground=pal["accent_fg"],
|
|
710
|
+
highlightthickness=1, highlightbackground=pal["border"], highlightcolor=pal["accent"])
|
|
711
|
+
lb.pack(side="left", fill="both", expand=True)
|
|
712
|
+
for o in ch.get("options", []):
|
|
713
|
+
lb.insert("end", forms.option_summary(o))
|
|
714
|
+
side = ttk.Frame(row)
|
|
715
|
+
side.pack(side="left", fill="y", padx=(8, 0))
|
|
716
|
+
ttk.Button(side, text="Add new", command=lambda: self._opt_apply(True)).pack(fill="x")
|
|
717
|
+
ttk.Button(side, text="Update sel.", command=lambda: self._opt_apply(False)).pack(fill="x", pady=2)
|
|
718
|
+
ttk.Button(side, text="Remove", command=self._opt_remove).pack(fill="x", pady=2)
|
|
719
|
+
ttk.Button(side, text="Up", command=lambda: self._opt_move(-1)).pack(fill="x", pady=2)
|
|
720
|
+
ttk.Button(side, text="Down", command=lambda: self._opt_move(1)).pack(fill="x", pady=2)
|
|
721
|
+
ttk.Label(self.form, text="Edit the selected option below, then Update (or Add new):",
|
|
722
|
+
foreground=pal["muted"]).pack(anchor="w", padx=10, pady=(6, 0))
|
|
723
|
+
ovars = {}
|
|
724
|
+
og = self._render_spec(self._form_grid(), forms.CHOICE_OPTION_SPEC,
|
|
725
|
+
forms.entity_to_values(forms.CHOICE_OPTION_SPEC, {}), vars_out=ovars)
|
|
726
|
+
self.opt_widgets = {"listbox": lb, "getters": og, "vars": ovars}
|
|
727
|
+
lb.bind("<<ListboxSelect>>", self._opt_load)
|
|
728
|
+
ttk.Button(self.form, text="Delete this choice",
|
|
729
|
+
command=lambda: self._delete_choice(idx)).pack(anchor="w", padx=10, pady=8)
|
|
730
|
+
|
|
731
|
+
def _opt_options(self):
|
|
732
|
+
return self.doc.data["choice"][self.active_choice].setdefault("options", [])
|
|
733
|
+
|
|
734
|
+
def _opt_load(self, _evt):
|
|
735
|
+
w = self.opt_widgets
|
|
736
|
+
sel = w["listbox"].curselection()
|
|
737
|
+
if not sel:
|
|
738
|
+
return
|
|
739
|
+
vals = forms.entity_to_values(forms.CHOICE_OPTION_SPEC, self._opt_options()[sel[0]])
|
|
740
|
+
for k, var in w["vars"].items():
|
|
741
|
+
var.set(vals.get(k, ""))
|
|
742
|
+
|
|
743
|
+
def _opt_apply(self, append):
|
|
744
|
+
w = self.opt_widgets
|
|
745
|
+
try:
|
|
746
|
+
opt = forms.build_entity(forms.CHOICE_OPTION_SPEC, {k: g() for k, g in w["getters"].items()})
|
|
747
|
+
except ValueError as e:
|
|
748
|
+
messagebox.showerror("Bad option", str(e))
|
|
749
|
+
return
|
|
750
|
+
if not opt.get("text"):
|
|
751
|
+
messagebox.showerror("Bad option", "An option needs 'text' (the menu row shown to the player).")
|
|
752
|
+
return
|
|
753
|
+
opts = self._opt_options()
|
|
754
|
+
sel = w["listbox"].curselection()
|
|
755
|
+
if append or not sel:
|
|
756
|
+
opts.append(opt)
|
|
757
|
+
self._reload_opts(select=len(opts) - 1)
|
|
758
|
+
else:
|
|
759
|
+
opts[sel[0]] = opt
|
|
760
|
+
self._reload_opts(select=sel[0])
|
|
761
|
+
|
|
762
|
+
def _opt_remove(self):
|
|
763
|
+
sel = self.opt_widgets["listbox"].curselection()
|
|
764
|
+
if sel:
|
|
765
|
+
self._opt_options().pop(sel[0])
|
|
766
|
+
self._reload_opts()
|
|
767
|
+
|
|
768
|
+
def _opt_move(self, d):
|
|
769
|
+
sel = self.opt_widgets["listbox"].curselection()
|
|
770
|
+
if not sel:
|
|
771
|
+
return
|
|
772
|
+
i = sel[0]
|
|
773
|
+
opts = self._opt_options()
|
|
774
|
+
j = i + d
|
|
775
|
+
if 0 <= j < len(opts):
|
|
776
|
+
opts[i], opts[j] = opts[j], opts[i]
|
|
777
|
+
self._reload_opts(select=j)
|
|
778
|
+
|
|
779
|
+
def _reload_opts(self, select=None):
|
|
780
|
+
lb = self.opt_widgets["listbox"]
|
|
781
|
+
lb.delete(0, "end")
|
|
782
|
+
for o in self._opt_options():
|
|
783
|
+
lb.insert("end", forms.option_summary(o))
|
|
784
|
+
if select is not None and 0 <= select < lb.size():
|
|
785
|
+
lb.selection_set(select)
|
|
786
|
+
|
|
787
|
+
def _delete_choice(self, idx):
|
|
788
|
+
self.active = None
|
|
789
|
+
lst = self.doc.data.get("choice", [])
|
|
790
|
+
if idx < len(lst):
|
|
791
|
+
lst.pop(idx)
|
|
792
|
+
self._refresh_tree(reselect="choice")
|
|
793
|
+
self._show("choice")
|
|
794
|
+
|
|
795
|
+
# --------------------------------------------------------------- commit
|
|
796
|
+
def _commit_active(self) -> bool:
|
|
797
|
+
"""Write the active form back into the doc. Returns False (and reports) on a parse error."""
|
|
798
|
+
if not self.active or not self.getters:
|
|
799
|
+
return True
|
|
800
|
+
a = self.active
|
|
801
|
+
spec = (SINGLE_SPECS.get(a["type"]) or LIST_SPECS.get(a["type"])
|
|
802
|
+
or {"field": forms.FIELD_SPEC, "cutscene": forms.CUTSCENE_SPEC,
|
|
803
|
+
"choice": forms.CHOICE_SPEC}.get(a["type"], forms.CUTSCENE_SPEC))
|
|
804
|
+
values = {k: g() for k, g in self.getters.items()}
|
|
805
|
+
try:
|
|
806
|
+
entity = forms.build_entity(spec, values)
|
|
807
|
+
except ValueError as e:
|
|
808
|
+
messagebox.showerror("Invalid value", str(e))
|
|
809
|
+
return False
|
|
810
|
+
if "index" in a: # an array-of-tables entity
|
|
811
|
+
lst = self.doc.data.get(a["section"], [])
|
|
812
|
+
if a["index"] < len(lst):
|
|
813
|
+
_apply(lst[a["index"]], spec, entity)
|
|
814
|
+
elif a["type"] == "cutscene":
|
|
815
|
+
cs = self.doc.data.setdefault("cutscene", {})
|
|
816
|
+
steps = cs.get("steps", [])
|
|
817
|
+
_apply(cs, spec, entity)
|
|
818
|
+
cs["steps"] = steps # keep the steps the list editor manages
|
|
819
|
+
else: # a single table (field/encounter/music)
|
|
820
|
+
_apply(self.doc.data.setdefault(a["section"], {}), spec, entity)
|
|
821
|
+
return True
|
|
822
|
+
|
|
823
|
+
# --------------------------------------------------------------- build / check / deploy
|
|
824
|
+
def _ensure_saved(self):
|
|
825
|
+
if self.doc is None:
|
|
826
|
+
messagebox.showinfo("No field", "Open or create a field first.")
|
|
827
|
+
return False
|
|
828
|
+
return self.on_save() is not False and self.doc is not None
|
|
829
|
+
|
|
830
|
+
def on_check(self):
|
|
831
|
+
if self.busy or not self._ensure_saved():
|
|
832
|
+
return
|
|
833
|
+
self._run(self._check)
|
|
834
|
+
|
|
835
|
+
def _check(self):
|
|
836
|
+
from ..build import FieldProject, lint_logic, validate
|
|
837
|
+
self.post(f"\n--- check {self.doc.path.name} ---")
|
|
838
|
+
try:
|
|
839
|
+
p = FieldProject.load(self.doc.path, flag_names=self._campaign_flag_names()) # resolve names
|
|
840
|
+
except ValueError as e:
|
|
841
|
+
self.post(f" ERROR flag name didn't resolve: {e}")
|
|
842
|
+
self.post(" (a campaign-shared flag? open its campaign in the Campaign Editor, or define "
|
|
843
|
+
"it in a [[flag]] table / use a numeric index.)")
|
|
844
|
+
return
|
|
845
|
+
probs, lints = validate(p), lint_logic(p)
|
|
846
|
+
for m in probs:
|
|
847
|
+
self.post(" ERROR " + m)
|
|
848
|
+
for m in lints:
|
|
849
|
+
self.post(" warn " + m)
|
|
850
|
+
self.post(" OK — no problems." if not (probs or lints)
|
|
851
|
+
else f" {len(probs)} error(s), {len(lints)} warning(s)")
|
|
852
|
+
|
|
853
|
+
def on_build_game(self):
|
|
854
|
+
if self.busy or not self._ensure_saved():
|
|
855
|
+
return
|
|
856
|
+
try:
|
|
857
|
+
from .. import config
|
|
858
|
+
out = config.find_game_path() / "FF9CustomMap"
|
|
859
|
+
except Exception: # noqa: BLE001
|
|
860
|
+
d = filedialog.askdirectory(title="Build output folder")
|
|
861
|
+
if not d:
|
|
862
|
+
return
|
|
863
|
+
out = Path(d)
|
|
864
|
+
if messagebox.askyesno("Build", f"Build this field into:\n{out}\n\n(installs at its real id)"):
|
|
865
|
+
self._run(self._build, str(out))
|
|
866
|
+
|
|
867
|
+
def _build(self, out):
|
|
868
|
+
from ..build import FieldProject, build_mod, validate
|
|
869
|
+
self.post(f"\n--- build {self.doc.path.name} -> {out} ---")
|
|
870
|
+
try:
|
|
871
|
+
p = FieldProject.load(self.doc.path, flag_names=self._campaign_flag_names()) # resolve names
|
|
872
|
+
except ValueError as e:
|
|
873
|
+
self.post(f"INVALID: flag name didn't resolve: {e} (open its campaign, or use a [[flag]]/index)")
|
|
874
|
+
return
|
|
875
|
+
probs = validate(p)
|
|
876
|
+
if probs:
|
|
877
|
+
self.post("INVALID:\n - " + "\n - ".join(probs))
|
|
878
|
+
return
|
|
879
|
+
info = build_mod([p], out, mod_name="FF9CustomMap")
|
|
880
|
+
self.post("OK: " + info["dictionary"][0])
|
|
881
|
+
for w in info.get("warnings", []):
|
|
882
|
+
self.post(" warning: " + w)
|
|
883
|
+
|
|
884
|
+
def on_test(self):
|
|
885
|
+
if self.busy or not self._ensure_saved():
|
|
886
|
+
return
|
|
887
|
+
if messagebox.askyesno("Build & Test", "Build + deploy to the in-game test field (4003)?"):
|
|
888
|
+
self._run(self._deploy)
|
|
889
|
+
|
|
890
|
+
def _deploy(self):
|
|
891
|
+
self.post(f"\n--- deploy {self.doc.path.name} -> test field 4003 ---")
|
|
892
|
+
r = subprocess.run([sys.executable, str(self.deploy), str(self.doc.path)],
|
|
893
|
+
cwd=str(self.deploy.parents[1]), capture_output=True, text=True)
|
|
894
|
+
if r.stdout:
|
|
895
|
+
self.post(r.stdout)
|
|
896
|
+
if r.returncode:
|
|
897
|
+
self.post("ERROR:\n" + (r.stderr or "(no detail)"))
|
|
898
|
+
else:
|
|
899
|
+
self.post(">>> In-game: New Game -> walk to the hut door -> your field.")
|
|
900
|
+
|
|
901
|
+
def on_revert(self):
|
|
902
|
+
if self.busy:
|
|
903
|
+
return
|
|
904
|
+
if messagebox.askyesno("Revert", "Restore the game to before the last test deploy?"):
|
|
905
|
+
self._run(self._do_revert)
|
|
906
|
+
|
|
907
|
+
def _do_revert(self):
|
|
908
|
+
self.post("\n--- revert test field ---")
|
|
909
|
+
r = subprocess.run([sys.executable, str(self.revert)], cwd=str(self.revert.parents[1]),
|
|
910
|
+
capture_output=True, text=True)
|
|
911
|
+
self.post(r.stdout or "")
|
|
912
|
+
if r.returncode:
|
|
913
|
+
self.post("ERROR:\n" + (r.stderr or "(no detail)"))
|
|
914
|
+
|
|
915
|
+
def _run(self, fn, *args):
|
|
916
|
+
self.busy = True
|
|
917
|
+
|
|
918
|
+
def work():
|
|
919
|
+
try:
|
|
920
|
+
fn(*args)
|
|
921
|
+
except Exception: # noqa: BLE001
|
|
922
|
+
self.post("ERROR:\n" + traceback.format_exc())
|
|
923
|
+
finally:
|
|
924
|
+
self.busy = False
|
|
925
|
+
threading.Thread(target=work, daemon=True).start()
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
def _apply(target: dict, spec, entity: dict):
|
|
929
|
+
"""Update ``target`` in place: clear this spec's keys, set the present ones, keep any others
|
|
930
|
+
(so a single-file project's spatial keys and unknown future keys survive an edit)."""
|
|
931
|
+
for f in spec:
|
|
932
|
+
target.pop(f.key, None)
|
|
933
|
+
target.update(entity)
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
def main(path=None):
|
|
937
|
+
root = tk.Tk()
|
|
938
|
+
root.title("FF9 Map Kit - Field Editor")
|
|
939
|
+
root.minsize(960, 600)
|
|
940
|
+
root.geometry("1180x720") # roomy default: tree + form + the help column + pickers
|
|
941
|
+
try:
|
|
942
|
+
EditorApp(root, path)
|
|
943
|
+
except Exception: # noqa: BLE001
|
|
944
|
+
messagebox.showerror("FF9 Map Kit", traceback.format_exc())
|
|
945
|
+
raise
|
|
946
|
+
root.mainloop()
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
if __name__ == "__main__":
|
|
950
|
+
main(sys.argv[1] if len(sys.argv) > 1 else None)
|