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
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"""A visual node-link MAP of a campaign's field graph -- a tk Canvas view + a tk-free layout core.
|
|
2
|
+
|
|
3
|
+
The Campaign Editor's left navigator shows the chain as a TREE; this is the same connectivity as a
|
|
4
|
+
MAP: members are nodes, live gateways are arrows (dashed when story-gated), onward seams are dashed
|
|
5
|
+
stubs, with the same entry / unreachable / dead-end / needs-art cues as the tree. :func:`compute_layout`
|
|
6
|
+
is PURE over a :class:`campaign.CampaignGraph` (no tk) so the placement is unit-testable headless;
|
|
7
|
+
:class:`GraphView` wraps a scrollable Canvas that draws a layout and turns clicks into open-member
|
|
8
|
+
calls. Nothing here that isn't already derivable from the plan via ``campaign.campaign_graph``.
|
|
9
|
+
|
|
10
|
+
Layout: top-down BFS levels from the entry (depth 0 at the top), each level centred horizontally;
|
|
11
|
+
unreachable members are packed into a row below the reachable band. Edge endpoints are clipped to the
|
|
12
|
+
node borders so an arrow touches the rectangle regardless of the two nodes' relative positions.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class LaidNode:
|
|
21
|
+
name: str
|
|
22
|
+
new_id: int
|
|
23
|
+
mode: str
|
|
24
|
+
x: float # top-left
|
|
25
|
+
y: float
|
|
26
|
+
w: float
|
|
27
|
+
h: float
|
|
28
|
+
is_entry: bool
|
|
29
|
+
reachable: bool
|
|
30
|
+
dead_end: bool
|
|
31
|
+
needs_export: bool
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def cx(self) -> float:
|
|
35
|
+
return self.x + self.w / 2
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def cy(self) -> float:
|
|
39
|
+
return self.y + self.h / 2
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class LaidEdge:
|
|
44
|
+
frm: str
|
|
45
|
+
to: str
|
|
46
|
+
gated: bool
|
|
47
|
+
entrance: int
|
|
48
|
+
x1: float # on the frm border
|
|
49
|
+
y1: float
|
|
50
|
+
x2: float # on the to border
|
|
51
|
+
y2: float
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class LaidSeam:
|
|
56
|
+
frm: str
|
|
57
|
+
label: str
|
|
58
|
+
nx: float # stub start (the member's right edge)
|
|
59
|
+
ny: float
|
|
60
|
+
x: float # label anchor
|
|
61
|
+
y: float
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class GraphLayout:
|
|
66
|
+
nodes: list # list[LaidNode]
|
|
67
|
+
edges: list # list[LaidEdge]
|
|
68
|
+
seams: list # list[LaidSeam]
|
|
69
|
+
width: float
|
|
70
|
+
height: float
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def by_name(self) -> dict:
|
|
74
|
+
return {n.name: n for n in self.nodes}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _clip(cx, cy, hw, hh, tx, ty):
|
|
78
|
+
"""The point on the border of the rect (centre cx,cy; half-extents hw,hh) along the ray toward (tx,ty)."""
|
|
79
|
+
dx, dy = tx - cx, ty - cy
|
|
80
|
+
if dx == 0 and dy == 0:
|
|
81
|
+
return cx, cy + hh
|
|
82
|
+
sx = hw / abs(dx) if dx else float("inf")
|
|
83
|
+
sy = hh / abs(dy) if dy else float("inf")
|
|
84
|
+
t = min(sx, sy)
|
|
85
|
+
return cx + dx * t, cy + dy * t
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def compute_layout(graph, *, node_w=160, node_h=50, hgap=38, vgap=72, margin=30, seam_gap=24):
|
|
89
|
+
"""Lay a CampaignGraph out top-down in BFS levels from the entry; unreachable members go in a row
|
|
90
|
+
below. PURE -- returns a GraphLayout of absolute coords (y by depth, each level centred). An empty
|
|
91
|
+
campaign yields a small empty canvas."""
|
|
92
|
+
nodes_by_name = {n.name: n for n in graph.nodes}
|
|
93
|
+
order = [n.name for n in graph.nodes] # member/id order == deterministic tie-break
|
|
94
|
+
if not order:
|
|
95
|
+
return GraphLayout([], [], [], width=margin * 2 + node_w, height=margin * 2 + node_h)
|
|
96
|
+
|
|
97
|
+
out_by = {n.name: [oe["to"] for oe in n.out_edges] for n in graph.nodes}
|
|
98
|
+
depth = {} # BFS depth from the entry over live edges
|
|
99
|
+
if graph.entry in nodes_by_name:
|
|
100
|
+
depth[graph.entry] = 0
|
|
101
|
+
queue = [graph.entry]
|
|
102
|
+
while queue:
|
|
103
|
+
cur = queue.pop(0)
|
|
104
|
+
for nxt in out_by.get(cur, []):
|
|
105
|
+
if nxt not in depth:
|
|
106
|
+
depth[nxt] = depth[cur] + 1
|
|
107
|
+
queue.append(nxt)
|
|
108
|
+
max_reach = max(depth.values(), default=-1)
|
|
109
|
+
for nm in order: # unreachable -> a row below the reachable band
|
|
110
|
+
depth.setdefault(nm, max_reach + 1)
|
|
111
|
+
|
|
112
|
+
levels = {}
|
|
113
|
+
for nm in order: # group by depth, preserving member order in-row
|
|
114
|
+
levels.setdefault(depth[nm], []).append(nm)
|
|
115
|
+
widest = max(len(v) for v in levels.values())
|
|
116
|
+
total_w = widest * node_w + (widest - 1) * hgap
|
|
117
|
+
|
|
118
|
+
laid = {}
|
|
119
|
+
for d in sorted(levels):
|
|
120
|
+
row = levels[d]
|
|
121
|
+
row_w = len(row) * node_w + (len(row) - 1) * hgap
|
|
122
|
+
start_x = margin + (total_w - row_w) / 2
|
|
123
|
+
y = margin + d * (node_h + vgap)
|
|
124
|
+
for i, nm in enumerate(row):
|
|
125
|
+
src = nodes_by_name[nm]
|
|
126
|
+
laid[nm] = LaidNode(name=nm, new_id=src.new_id, mode=src.mode,
|
|
127
|
+
x=start_x + i * (node_w + hgap), y=y, w=node_w, h=node_h,
|
|
128
|
+
is_entry=src.is_entry, reachable=src.reachable,
|
|
129
|
+
dead_end=src.dead_end, needs_export=src.needs_export)
|
|
130
|
+
|
|
131
|
+
edges = []
|
|
132
|
+
for n in graph.nodes:
|
|
133
|
+
a = laid[n.name]
|
|
134
|
+
for oe in n.out_edges:
|
|
135
|
+
b = laid.get(oe["to"])
|
|
136
|
+
if b is None:
|
|
137
|
+
continue
|
|
138
|
+
x1, y1 = _clip(a.cx, a.cy, a.w / 2, a.h / 2, b.cx, b.cy)
|
|
139
|
+
x2, y2 = _clip(b.cx, b.cy, b.w / 2, b.h / 2, a.cx, a.cy)
|
|
140
|
+
edges.append(LaidEdge(frm=n.name, to=oe["to"], gated=oe["gated"],
|
|
141
|
+
entrance=oe["entrance"], x1=x1, y1=y1, x2=x2, y2=y2))
|
|
142
|
+
|
|
143
|
+
seams = []
|
|
144
|
+
for n in graph.nodes:
|
|
145
|
+
a = laid[n.name]
|
|
146
|
+
for i, s in enumerate(n.seams):
|
|
147
|
+
tgt = s.get("to_member") or ("WORLDMAP" if s.get("to_real") == "WORLDMAP" else s.get("to_real"))
|
|
148
|
+
sy = a.cy + (i - (len(n.seams) - 1) / 2) * 18
|
|
149
|
+
seams.append(LaidSeam(frm=n.name, label=f"{s.get('kind')} -> {tgt}",
|
|
150
|
+
nx=a.x + a.w, ny=sy, x=a.x + a.w + seam_gap, y=sy))
|
|
151
|
+
|
|
152
|
+
nlev = max(levels) + 1
|
|
153
|
+
width = margin * 2 + total_w
|
|
154
|
+
if seams:
|
|
155
|
+
width = max(width, max(s.x for s in seams) + 180)
|
|
156
|
+
height = margin * 2 + nlev * node_h + (nlev - 1) * vgap
|
|
157
|
+
return GraphLayout(nodes=list(laid.values()), edges=edges, seams=seams, width=width, height=height)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _round_rect(canvas, x1, y1, x2, y2, r, **kw):
|
|
161
|
+
"""A rounded rectangle as a smoothed polygon (Tk has no native rounded rect)."""
|
|
162
|
+
pts = [x1 + r, y1, x2 - r, y1, x2, y1, x2, y1 + r, x2, y2 - r, x2, y2,
|
|
163
|
+
x2 - r, y2, x1 + r, y2, x1, y2, x1, y2 - r, x1, y1 + r, x1, y1]
|
|
164
|
+
return canvas.create_polygon(pts, smooth=True, **kw)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class GraphView:
|
|
168
|
+
"""A scrollable Canvas rendering a campaign's node-link map. Single-click a node to highlight it
|
|
169
|
+
(+ a status line); double-click to open it (``on_open(name)``). Wheel / drag (middle button) pan."""
|
|
170
|
+
|
|
171
|
+
def __init__(self, parent, palette, *, on_open=None):
|
|
172
|
+
import tkinter as tk
|
|
173
|
+
from tkinter import ttk
|
|
174
|
+
|
|
175
|
+
self.pal = palette
|
|
176
|
+
self.on_open = on_open
|
|
177
|
+
self._layout = None
|
|
178
|
+
self._graph = None
|
|
179
|
+
self._current = None
|
|
180
|
+
|
|
181
|
+
wrap = ttk.Frame(parent)
|
|
182
|
+
wrap.pack(fill="both", expand=True)
|
|
183
|
+
self.canvas = tk.Canvas(wrap, background=palette["surface"], highlightthickness=0)
|
|
184
|
+
vs = ttk.Scrollbar(wrap, orient="vertical", command=self.canvas.yview)
|
|
185
|
+
hs = ttk.Scrollbar(wrap, orient="horizontal", command=self.canvas.xview)
|
|
186
|
+
self.canvas.configure(yscrollcommand=vs.set, xscrollcommand=hs.set)
|
|
187
|
+
self.canvas.grid(row=0, column=0, sticky="nsew")
|
|
188
|
+
vs.grid(row=0, column=1, sticky="ns")
|
|
189
|
+
hs.grid(row=1, column=0, sticky="we")
|
|
190
|
+
wrap.rowconfigure(0, weight=1)
|
|
191
|
+
wrap.columnconfigure(0, weight=1)
|
|
192
|
+
|
|
193
|
+
self.status = ttk.Label(parent, anchor="w", padding=(8, 4), foreground=palette["muted"],
|
|
194
|
+
text="Open a campaign to see its map. "
|
|
195
|
+
"green=entry · red=unreachable · amber=needs art · dashed=gated/seam")
|
|
196
|
+
self.status.pack(fill="x")
|
|
197
|
+
|
|
198
|
+
self.canvas.bind("<Button-1>", self._click)
|
|
199
|
+
self.canvas.bind("<Double-Button-1>", self._double)
|
|
200
|
+
self.canvas.bind("<ButtonPress-2>", lambda e: self.canvas.scan_mark(e.x, e.y))
|
|
201
|
+
self.canvas.bind("<B2-Motion>", lambda e: self.canvas.scan_dragto(e.x, e.y, gain=1))
|
|
202
|
+
self.canvas.bind("<MouseWheel>",
|
|
203
|
+
lambda e: self.canvas.yview_scroll(-1 if e.delta > 0 else 1, "units"))
|
|
204
|
+
self.canvas.bind("<Shift-MouseWheel>",
|
|
205
|
+
lambda e: self.canvas.xview_scroll(-1 if e.delta > 0 else 1, "units"))
|
|
206
|
+
|
|
207
|
+
# ------------------------------------------------------------------ public
|
|
208
|
+
def render(self, graph, current=None):
|
|
209
|
+
"""Lay out + draw a CampaignGraph; ``current`` highlights the open member."""
|
|
210
|
+
self._graph = graph
|
|
211
|
+
self._current = current
|
|
212
|
+
self._layout = compute_layout(graph)
|
|
213
|
+
self._draw()
|
|
214
|
+
|
|
215
|
+
def highlight(self, name):
|
|
216
|
+
"""Mark ``name`` as the open member (re-draws; campaigns are small)."""
|
|
217
|
+
if self._layout is None:
|
|
218
|
+
return
|
|
219
|
+
self._current = name
|
|
220
|
+
self._draw()
|
|
221
|
+
self._set_status(name)
|
|
222
|
+
|
|
223
|
+
def clear(self):
|
|
224
|
+
self._graph = self._layout = self._current = None
|
|
225
|
+
self.canvas.delete("all")
|
|
226
|
+
|
|
227
|
+
# ------------------------------------------------------------------ drawing
|
|
228
|
+
def _draw(self):
|
|
229
|
+
c, pal, lay = self.canvas, self.pal, self._layout
|
|
230
|
+
c.delete("all")
|
|
231
|
+
if lay is None:
|
|
232
|
+
return
|
|
233
|
+
c.configure(scrollregion=(0, 0, lay.width, lay.height))
|
|
234
|
+
for e in lay.edges: # edges under nodes
|
|
235
|
+
kw = dict(fill=pal["muted"], width=2, arrow="last", arrowshape=(11, 13, 5))
|
|
236
|
+
if e.gated:
|
|
237
|
+
kw["dash"] = (5, 3)
|
|
238
|
+
c.create_line(e.x1, e.y1, e.x2, e.y2, **kw)
|
|
239
|
+
for s in lay.seams:
|
|
240
|
+
c.create_line(s.nx, s.ny, s.x, s.y, fill=pal["muted"], dash=(2, 3))
|
|
241
|
+
c.create_text(s.x + 4, s.y, text="~ " + s.label, anchor="w",
|
|
242
|
+
fill=pal["muted"], font=("Segoe UI", 9))
|
|
243
|
+
for n in lay.nodes:
|
|
244
|
+
self._node(n)
|
|
245
|
+
|
|
246
|
+
def _node(self, n):
|
|
247
|
+
c, pal = self.canvas, self.pal
|
|
248
|
+
if not n.reachable:
|
|
249
|
+
outline, width = pal["error"], 2
|
|
250
|
+
elif n.needs_export:
|
|
251
|
+
outline, width = pal["warn"], 2
|
|
252
|
+
elif n.is_entry:
|
|
253
|
+
outline, width = pal["success"], 2
|
|
254
|
+
else:
|
|
255
|
+
outline, width = pal["border"], 1
|
|
256
|
+
current = (n.name == self._current)
|
|
257
|
+
fill = pal["accent"] if current else pal["surface_btn"]
|
|
258
|
+
tcol = pal["accent_fg"] if current else pal["text"]
|
|
259
|
+
tag = f"node::{n.name}"
|
|
260
|
+
_round_rect(c, n.x, n.y, n.x + n.w, n.y + n.h, r=11, fill=fill,
|
|
261
|
+
outline=outline, width=width, tags=("node", tag))
|
|
262
|
+
c.create_text(n.cx, n.y + 17, text=n.name, fill=tcol,
|
|
263
|
+
font=("Segoe UI", 10, "bold"), tags=("node", tag))
|
|
264
|
+
sub = f"id {n.new_id}" + ("" if n.mode == "borrow" else f" · {n.mode}")
|
|
265
|
+
sub_col = pal["accent_fg"] if current else pal["muted"]
|
|
266
|
+
c.create_text(n.cx, n.y + 34, text=sub, fill=sub_col, font=("Segoe UI", 9), tags=("node", tag))
|
|
267
|
+
|
|
268
|
+
# ------------------------------------------------------------------ interaction
|
|
269
|
+
def _node_at(self, ev):
|
|
270
|
+
x, y = self.canvas.canvasx(ev.x), self.canvas.canvasy(ev.y)
|
|
271
|
+
for item in self.canvas.find_overlapping(x - 1, y - 1, x + 1, y + 1):
|
|
272
|
+
for t in self.canvas.gettags(item):
|
|
273
|
+
if t.startswith("node::"):
|
|
274
|
+
return t[len("node::"):]
|
|
275
|
+
return None
|
|
276
|
+
|
|
277
|
+
def _click(self, ev):
|
|
278
|
+
name = self._node_at(ev)
|
|
279
|
+
if name:
|
|
280
|
+
self.highlight(name)
|
|
281
|
+
|
|
282
|
+
def _double(self, ev):
|
|
283
|
+
name = self._node_at(ev)
|
|
284
|
+
if name and self.on_open:
|
|
285
|
+
self.on_open(name)
|
|
286
|
+
|
|
287
|
+
def _set_status(self, name):
|
|
288
|
+
node = (self._graph.by_name.get(name) if self._graph else None)
|
|
289
|
+
if node is None:
|
|
290
|
+
return
|
|
291
|
+
flags = []
|
|
292
|
+
if node.is_entry:
|
|
293
|
+
flags.append("entry")
|
|
294
|
+
if not node.reachable:
|
|
295
|
+
flags.append("UNREACHABLE")
|
|
296
|
+
elif node.dead_end:
|
|
297
|
+
flags.append("dead-end")
|
|
298
|
+
if node.needs_export:
|
|
299
|
+
flags.append("needs art")
|
|
300
|
+
tail = (" · " + ", ".join(flags)) if flags else ""
|
|
301
|
+
self.status.configure(
|
|
302
|
+
text=f"{node.name} · id {node.new_id} ({node.mode}) · "
|
|
303
|
+
f"{len(node.out_edges)} out / {len(node.in_edges)} in / {len(node.seams)} seam(s)"
|
|
304
|
+
f"{tail} — double-click to edit")
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _smoke():
|
|
308
|
+
"""Headless-ish self-test: pure layout asserts (no display) + a Tk render+click if a display exists."""
|
|
309
|
+
from .. import campaign
|
|
310
|
+
M = campaign.Member
|
|
311
|
+
members = [M(300, 30100, "ENT", "borrow", 11, "", "ENT/ent.field.toml", False),
|
|
312
|
+
M(301, 30101, "COR", "borrow", 11, "", "COR/cor.field.toml", False),
|
|
313
|
+
M(302, 30102, "LOST", "borrow", 11, "", "LOST/lost.field.toml", False)]
|
|
314
|
+
plan = campaign.CampaignPlan(name="ICE", mod_folder="M", id_base=30100,
|
|
315
|
+
flag_base=campaign.FIRST_SAFE_FLAG, flags_per_field=64,
|
|
316
|
+
entry_name="ENT", entry_entrance=0, members=members,
|
|
317
|
+
edges=[{"frm": "ENT", "to": "COR", "entrance": 2}],
|
|
318
|
+
seams=[{"frm": "COR", "to_real": "WORLDMAP", "kind": "overworld",
|
|
319
|
+
"note": "", "to_member": None}])
|
|
320
|
+
g = campaign.campaign_graph(plan)
|
|
321
|
+
lay = compute_layout(g)
|
|
322
|
+
by = lay.by_name
|
|
323
|
+
assert by["ENT"].y < by["COR"].y, "entry above its child"
|
|
324
|
+
assert by["LOST"].y > by["COR"].y, "unreachable below the reachable band"
|
|
325
|
+
assert len(lay.edges) == 1 and len(lay.seams) == 1
|
|
326
|
+
print(f"graphview pure smoke ok: {len(lay.nodes)} nodes, {len(lay.edges)} edge(s), "
|
|
327
|
+
f"{len(lay.seams)} seam(s), canvas {lay.width:.0f}x{lay.height:.0f}")
|
|
328
|
+
|
|
329
|
+
import tkinter as tk
|
|
330
|
+
from .theme import apply_theme
|
|
331
|
+
root = tk.Tk()
|
|
332
|
+
root.withdraw()
|
|
333
|
+
pal = apply_theme(root)
|
|
334
|
+
opened = []
|
|
335
|
+
gv = GraphView(root, pal, on_open=opened.append)
|
|
336
|
+
gv.render(g, current="ENT")
|
|
337
|
+
items = gv.canvas.find_withtag("node::COR")
|
|
338
|
+
assert items, "COR drawn"
|
|
339
|
+
gv.highlight("COR")
|
|
340
|
+
assert gv._current == "COR"
|
|
341
|
+
gv._double(type("E", (), {"x": 0, "y": 0})()) # no node at (0,0) -> no open
|
|
342
|
+
assert opened == []
|
|
343
|
+
print("graphview tk smoke ok: rendered + highlighted")
|
|
344
|
+
root.destroy()
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
if __name__ == "__main__":
|
|
348
|
+
import sys
|
|
349
|
+
if "--smoke" in sys.argv:
|
|
350
|
+
_smoke()
|