tkunity 2026.10.31__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.
- tkunity/__init__.py +27 -0
- tkunity/__main__.py +8 -0
- tkunity/api.py +112 -0
- tkunity/cli.py +106 -0
- tkunity/components.py +125 -0
- tkunity/editor.py +702 -0
- tkunity/export.py +740 -0
- tkunity/py.typed +0 -0
- tkunity/pyu.py +125 -0
- tkunity/runtime.py +232 -0
- tkunity-2026.10.31.dist-info/LICENSE +216 -0
- tkunity-2026.10.31.dist-info/METADATA +194 -0
- tkunity-2026.10.31.dist-info/RECORD +16 -0
- tkunity-2026.10.31.dist-info/WHEEL +5 -0
- tkunity-2026.10.31.dist-info/entry_points.txt +2 -0
- tkunity-2026.10.31.dist-info/top_level.txt +1 -0
tkunity/editor.py
ADDED
|
@@ -0,0 +1,702 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Unity 风格图形编辑器(纯 tkinter,无第三方依赖)。
|
|
3
|
+
|
|
4
|
+
布局:
|
|
5
|
+
左侧 零件库 + 物体列表
|
|
6
|
+
中间 可拖拽/缩放的网格画布(场景区)
|
|
7
|
+
右侧 动态属性面板
|
|
8
|
+
顶部工具栏:新建 / 打开 / 保存 / 另存为 / 预览 / 导出APK / 关于
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import tkinter as tk
|
|
14
|
+
from tkinter import colorchooser, filedialog, messagebox
|
|
15
|
+
|
|
16
|
+
from . import __version__, __author__, __author_email__, __license__
|
|
17
|
+
from . import export, pyu
|
|
18
|
+
from .components import ADDABLE_COMPONENTS, COMPONENT_SCHEMAS, PARTS
|
|
19
|
+
|
|
20
|
+
__all__ = ["launch_editor", "GRID"]
|
|
21
|
+
|
|
22
|
+
GRID = 20
|
|
23
|
+
PART_SIZES = {"矩形": (80, 60), "椭圆": (80, 60), "文本": (160, 40),
|
|
24
|
+
"图片": (120, 120), "点击触发器": (80, 60), "定时器": (80, 60)}
|
|
25
|
+
BG_OUTSIDE = "#b9bec9"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class EditorApp(object):
|
|
29
|
+
"""编辑器主窗口。"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, root, file=None):
|
|
32
|
+
self.root = root
|
|
33
|
+
self.project = pyu.new_project("未命名项目")
|
|
34
|
+
self.current_path = None
|
|
35
|
+
self.selection = None # 选中的 entity id
|
|
36
|
+
self.pending_part = None # 待放置的零件名(点画布放置)
|
|
37
|
+
self._photos = {} # 资源名 -> PhotoImage
|
|
38
|
+
self._item_ids = {} # eid -> canvas item id 列表
|
|
39
|
+
self._drag = None # {"eid", "mode"(move/resize), "ox","oy","sx","sy"}
|
|
40
|
+
self._prop_vars = {} # 属性面板当前 widget 的 var 收集
|
|
41
|
+
self._prop_widgets = []
|
|
42
|
+
|
|
43
|
+
root.title("tkunity 编辑器 v%s - oiiaio-web" % __version__)
|
|
44
|
+
root.geometry("1280x760")
|
|
45
|
+
root.minsize(960, 560)
|
|
46
|
+
|
|
47
|
+
self._build_toolbar()
|
|
48
|
+
self._build_body()
|
|
49
|
+
self._build_statusbar()
|
|
50
|
+
root.bind("<Delete>", lambda e: self.delete_selection())
|
|
51
|
+
root.protocol("WM_DELETE_WINDOW", self._quit)
|
|
52
|
+
|
|
53
|
+
if file:
|
|
54
|
+
self.open_path(file)
|
|
55
|
+
|
|
56
|
+
# ---------------------------------------------------------- UI 构建
|
|
57
|
+
def _build_toolbar(self):
|
|
58
|
+
bar = tk.Frame(self.root, bd=0, relief=tk.FLAT)
|
|
59
|
+
bar.pack(side=tk.TOP, fill=tk.X, padx=6, pady=6)
|
|
60
|
+
btns = [
|
|
61
|
+
("新建", self.new_project),
|
|
62
|
+
("打开", self.open_project),
|
|
63
|
+
("保存", self.save_project),
|
|
64
|
+
("另存为", self.save_project_as),
|
|
65
|
+
("|", None),
|
|
66
|
+
("预览", self.preview),
|
|
67
|
+
("导出APK", self.export_apk),
|
|
68
|
+
("|", None),
|
|
69
|
+
("关于", self.about),
|
|
70
|
+
]
|
|
71
|
+
for text, cmd in btns:
|
|
72
|
+
if text == "|":
|
|
73
|
+
tk.Frame(bar, width=2, bg="#cccccc").pack(side=tk.LEFT, padx=6, fill=tk.Y)
|
|
74
|
+
else:
|
|
75
|
+
tk.Button(bar, text=text, command=cmd, padx=10, pady=2,
|
|
76
|
+
relief=tk.RAISED, bd=1).pack(side=tk.LEFT, padx=2)
|
|
77
|
+
|
|
78
|
+
def _build_body(self):
|
|
79
|
+
body = tk.Frame(self.root)
|
|
80
|
+
body.pack(side=tk.TOP, fill=tk.BOTH, expand=True, padx=6)
|
|
81
|
+
|
|
82
|
+
# 左:零件库 + 物体列表
|
|
83
|
+
left = tk.Frame(body, width=180)
|
|
84
|
+
left.pack(side=tk.LEFT, fill=tk.Y, padx=(0, 6))
|
|
85
|
+
left.pack_propagate(False)
|
|
86
|
+
tk.Label(left, text="零件库", font=("Helvetica", 12, "bold")).pack(anchor="w", pady=(0, 4))
|
|
87
|
+
tk.Label(left, text="点选后在画布上单击放置\n双击放到画布中心",
|
|
88
|
+
fg="#666666", justify="left", font=("Helvetica", 9)).pack(anchor="w")
|
|
89
|
+
self.parts_list = tk.Listbox(left, height=7, exportselection=False)
|
|
90
|
+
for name, _ in PARTS:
|
|
91
|
+
self.parts_list.insert(tk.END, name)
|
|
92
|
+
self.parts_list.pack(fill=tk.X)
|
|
93
|
+
self.parts_list.bind("<<ListboxSelect>>", self._on_parts_select)
|
|
94
|
+
self.parts_list.bind("<Double-Button-1>", self._on_parts_double)
|
|
95
|
+
|
|
96
|
+
tk.Label(left, text="物体列表", font=("Helvetica", 12, "bold")).pack(anchor="w", pady=(10, 4))
|
|
97
|
+
self.objects_list = tk.Listbox(left, exportselection=False)
|
|
98
|
+
self.objects_list.pack(fill=tk.BOTH, expand=True)
|
|
99
|
+
self.objects_list.bind("<<ListboxSelect>>", self._on_objects_select)
|
|
100
|
+
|
|
101
|
+
# 中:网格画布
|
|
102
|
+
center = tk.Frame(body)
|
|
103
|
+
center.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
|
104
|
+
self.canvas = tk.Canvas(center, bg=BG_OUTSIDE, highlightthickness=0)
|
|
105
|
+
vbar = tk.Scrollbar(center, orient=tk.VERTICAL, command=self.canvas.yview)
|
|
106
|
+
hbar = tk.Scrollbar(center, orient=tk.HORIZONTAL, command=self.canvas.xview)
|
|
107
|
+
self.canvas.configure(xscrollcommand=hbar.set, yscrollcommand=vbar.set)
|
|
108
|
+
vbar.pack(side=tk.RIGHT, fill=tk.Y)
|
|
109
|
+
hbar.pack(side=tk.BOTTOM, fill=tk.X)
|
|
110
|
+
self.canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
|
111
|
+
self.canvas.bind("<Button-1>", self._on_canvas_press)
|
|
112
|
+
self.canvas.bind("<B1-Motion>", self._on_canvas_drag)
|
|
113
|
+
self.canvas.bind("<ButtonRelease-1>", self._on_canvas_release)
|
|
114
|
+
|
|
115
|
+
# 右:属性面板
|
|
116
|
+
right = tk.Frame(body, width=270, bg="#f4f5f7")
|
|
117
|
+
right.pack(side=tk.LEFT, fill=tk.Y, padx=(6, 0))
|
|
118
|
+
right.pack_propagate(False)
|
|
119
|
+
tk.Label(right, text="属性面板", font=("Helvetica", 12, "bold"),
|
|
120
|
+
bg="#f4f5f7").pack(anchor="w", pady=(0, 4))
|
|
121
|
+
self.prop_canvas = tk.Canvas(right, bg="#f4f5f7", highlightthickness=0)
|
|
122
|
+
prop_scroll = tk.Scrollbar(right, orient=tk.VERTICAL, command=self.prop_canvas.yview)
|
|
123
|
+
self.prop_frame = tk.Frame(self.prop_canvas, bg="#f4f5f7")
|
|
124
|
+
self.prop_canvas.configure(yscrollcommand=prop_scroll.set)
|
|
125
|
+
prop_scroll.pack(side=tk.RIGHT, fill=tk.Y)
|
|
126
|
+
self.prop_canvas.pack(fill=tk.BOTH, expand=True)
|
|
127
|
+
self.prop_canvas.create_window((0, 0), window=self.prop_frame, anchor="nw")
|
|
128
|
+
self.prop_frame.bind("<Configure>",
|
|
129
|
+
lambda e: self.prop_canvas.configure(scrollregion=self.prop_canvas.bbox("all")))
|
|
130
|
+
|
|
131
|
+
def _build_statusbar(self):
|
|
132
|
+
self.status = tk.Label(self.root, text="就绪", anchor="w", fg="#444444",
|
|
133
|
+
font=("Helvetica", 9), bd=1, relief=tk.SUNKEN)
|
|
134
|
+
self.status.pack(side=tk.BOTTOM, fill=tk.X)
|
|
135
|
+
|
|
136
|
+
def _set_status(self, text):
|
|
137
|
+
self.status.config(text=text)
|
|
138
|
+
|
|
139
|
+
# ---------------------------------------------------------- 项目操作
|
|
140
|
+
def new_project(self):
|
|
141
|
+
self.project = pyu.new_project("未命名项目")
|
|
142
|
+
self.current_path = None
|
|
143
|
+
self.selection = None
|
|
144
|
+
self.pending_part = None
|
|
145
|
+
self._photos.clear()
|
|
146
|
+
self.redraw_all()
|
|
147
|
+
self._refresh_objects_list()
|
|
148
|
+
self._clear_props()
|
|
149
|
+
self._set_status("已新建项目")
|
|
150
|
+
|
|
151
|
+
def open_project(self):
|
|
152
|
+
path = filedialog.askopenfilename(
|
|
153
|
+
title="打开 tkunity 项目", defaultextension=".pyu",
|
|
154
|
+
filetypes=[("tkunity 项目", "*.pyu"), ("所有文件", "*.*")])
|
|
155
|
+
if path:
|
|
156
|
+
self.open_path(path)
|
|
157
|
+
|
|
158
|
+
def open_path(self, path):
|
|
159
|
+
try:
|
|
160
|
+
self.project = pyu.load(path)
|
|
161
|
+
except Exception as exc:
|
|
162
|
+
messagebox.showerror("打开失败", "无法读取 %s\n%s" % (path, exc))
|
|
163
|
+
return
|
|
164
|
+
self.current_path = path
|
|
165
|
+
self.selection = None
|
|
166
|
+
self.pending_part = None
|
|
167
|
+
self._photos.clear()
|
|
168
|
+
self.redraw_all()
|
|
169
|
+
self._refresh_objects_list()
|
|
170
|
+
self._clear_props()
|
|
171
|
+
self._set_status("已打开: %s" % path)
|
|
172
|
+
|
|
173
|
+
def save_project(self):
|
|
174
|
+
if self.current_path:
|
|
175
|
+
self._write_pyu(self.current_path)
|
|
176
|
+
else:
|
|
177
|
+
self.save_project_as()
|
|
178
|
+
|
|
179
|
+
def save_project_as(self):
|
|
180
|
+
path = filedialog.asksaveasfilename(
|
|
181
|
+
title="保存 tkunity 项目", defaultextension=".pyu",
|
|
182
|
+
initialfile=self.project["meta"].get("name", "game") + ".pyu",
|
|
183
|
+
filetypes=[("tkunity 项目", "*.pyu"), ("所有文件", "*.*")])
|
|
184
|
+
if path:
|
|
185
|
+
self._write_pyu(path)
|
|
186
|
+
|
|
187
|
+
def _write_pyu(self, path):
|
|
188
|
+
try:
|
|
189
|
+
pyu.save(self.project, path)
|
|
190
|
+
except Exception as exc:
|
|
191
|
+
messagebox.showerror("保存失败", str(exc))
|
|
192
|
+
return
|
|
193
|
+
self.current_path = path
|
|
194
|
+
self._set_status("已保存: %s" % path)
|
|
195
|
+
|
|
196
|
+
def preview(self):
|
|
197
|
+
win = tk.Toplevel(self.root)
|
|
198
|
+
win.title("预览 - %s" % self.project["meta"].get("name", "游戏"))
|
|
199
|
+
from .runtime import Scene
|
|
200
|
+
try:
|
|
201
|
+
scene = Scene(win, self.project)
|
|
202
|
+
except Exception as exc:
|
|
203
|
+
messagebox.showerror("预览失败", str(exc))
|
|
204
|
+
win.destroy()
|
|
205
|
+
return
|
|
206
|
+
scene.canvas.pack(fill="both", expand=True)
|
|
207
|
+
scene.start()
|
|
208
|
+
tk.Button(win, text="关闭预览", command=lambda: (scene.stop(), win.destroy())).pack()
|
|
209
|
+
win.protocol("WM_DELETE_WINDOW", lambda: (scene.stop(), win.destroy()))
|
|
210
|
+
|
|
211
|
+
def export_apk(self):
|
|
212
|
+
outdir = filedialog.askdirectory(title="选择导出目录(将生成 Android 工程)")
|
|
213
|
+
if not outdir:
|
|
214
|
+
return
|
|
215
|
+
target = os.path.join(outdir, "android_" + self.project["meta"].get("name", "game"))
|
|
216
|
+
try:
|
|
217
|
+
files = export.build(self.project, target)
|
|
218
|
+
except Exception as exc:
|
|
219
|
+
messagebox.showerror("导出失败", str(exc))
|
|
220
|
+
return
|
|
221
|
+
messagebox.showinfo("导出成功",
|
|
222
|
+
"已生成 %d 个文件到:\n%s\n\n"
|
|
223
|
+
"下一步:设置 ANDROID_HOME 后运行 build_apk.bat / build_apk.sh" % (len(files), target))
|
|
224
|
+
self._set_status("已导出 Android 工程: %s" % target)
|
|
225
|
+
|
|
226
|
+
def about(self):
|
|
227
|
+
messagebox.showinfo(
|
|
228
|
+
"关于 tkunity",
|
|
229
|
+
"tkunity v%s\n%s\n\n"
|
|
230
|
+
"Apache License 2.0\n"
|
|
231
|
+
"Copyright 2026 %s <%s>\n\n"
|
|
232
|
+
"纯 Python 标准库实现(无 pygame / 无任何第三方依赖)\n"
|
|
233
|
+
"支持把 .pyu 游戏导出为 Android APK 工程" %
|
|
234
|
+
(__version__, "Unity 风格 2D 游戏编辑器与运行时", __author__, __author_email__))
|
|
235
|
+
|
|
236
|
+
def _quit(self):
|
|
237
|
+
self.root.destroy()
|
|
238
|
+
|
|
239
|
+
# ---------------------------------------------------------- 画布渲染
|
|
240
|
+
@property
|
|
241
|
+
def scene_w(self):
|
|
242
|
+
return int(self.project["meta"].get("scene_width", 960))
|
|
243
|
+
|
|
244
|
+
@property
|
|
245
|
+
def scene_h(self):
|
|
246
|
+
return int(self.project["meta"].get("scene_height", 640))
|
|
247
|
+
|
|
248
|
+
def redraw_all(self):
|
|
249
|
+
self.canvas.delete("all")
|
|
250
|
+
self._item_ids.clear()
|
|
251
|
+
self.canvas.configure(scrollregion=(0, 0, self.scene_w, self.scene_h))
|
|
252
|
+
self.canvas.create_rectangle(0, 0, self.scene_w, self.scene_h,
|
|
253
|
+
fill=self.project["meta"].get("background", "#1e1e2e"),
|
|
254
|
+
outline="#888888", width=1, tags=("scene",))
|
|
255
|
+
self._draw_grid()
|
|
256
|
+
entities = sorted(self.project["entities"], key=lambda e: self._layer_of(e))
|
|
257
|
+
for entity in entities:
|
|
258
|
+
self._draw_entity(entity)
|
|
259
|
+
self._draw_selection()
|
|
260
|
+
|
|
261
|
+
def _layer_of(self, entity):
|
|
262
|
+
return entity.get("components", {}).get("Transform", {}).get("layer", 0)
|
|
263
|
+
|
|
264
|
+
def _draw_grid(self):
|
|
265
|
+
for x in range(0, self.scene_w + 1, GRID):
|
|
266
|
+
self.canvas.create_line(x, 0, x, self.scene_h, fill="#3a3a3a",
|
|
267
|
+
tags=("grid",))
|
|
268
|
+
for y in range(0, self.scene_h + 1, GRID):
|
|
269
|
+
self.canvas.create_line(0, y, self.scene_w, y, fill="#3a3a3a",
|
|
270
|
+
tags=("grid",))
|
|
271
|
+
|
|
272
|
+
def _draw_entity(self, entity):
|
|
273
|
+
eid = entity["id"]
|
|
274
|
+
comps = entity.get("components", {})
|
|
275
|
+
t = comps.get("Transform", {})
|
|
276
|
+
x, y = int(t.get("x", 0)), int(t.get("y", 0))
|
|
277
|
+
w, h = int(t.get("width", 80)), int(t.get("height", 60))
|
|
278
|
+
ids = []
|
|
279
|
+
sprite = comps.get("Sprite", {})
|
|
280
|
+
shape = sprite.get("shape", "rect")
|
|
281
|
+
fill = sprite.get("fill", "")
|
|
282
|
+
outline = sprite.get("outline", "")
|
|
283
|
+
|
|
284
|
+
if shape == "image":
|
|
285
|
+
photo = self._get_photo(sprite.get("image", ""))
|
|
286
|
+
if photo:
|
|
287
|
+
ids.append(self.canvas.create_image(x + w // 2, y + h // 2,
|
|
288
|
+
image=photo, tags=("ent%d" % eid,)))
|
|
289
|
+
elif shape == "oval":
|
|
290
|
+
ids.append(self.canvas.create_oval(x, y, x + w, y + h, fill=fill,
|
|
291
|
+
outline=outline, width=2, tags=("ent%d" % eid,)))
|
|
292
|
+
else:
|
|
293
|
+
ids.append(self.canvas.create_rectangle(x, y, x + w, y + h, fill=fill,
|
|
294
|
+
outline=outline, width=2, tags=("ent%d" % eid,)))
|
|
295
|
+
label = comps.get("Label")
|
|
296
|
+
if label and label.get("text"):
|
|
297
|
+
font = ("Helvetica", int(label.get("font_size", 18)),
|
|
298
|
+
"bold" if label.get("bold") else "normal")
|
|
299
|
+
ids.append(self.canvas.create_text(x + w // 2, y + h // 2,
|
|
300
|
+
text=label.get("text", ""), font=font,
|
|
301
|
+
fill=label.get("color", "#000000"),
|
|
302
|
+
tags=("ent%d" % eid,)))
|
|
303
|
+
# 物体名小标签(左上角)
|
|
304
|
+
ids.append(self.canvas.create_text(x + 2, y - 2, text=entity.get("name", ""),
|
|
305
|
+
anchor="sw", font=("Helvetica", 8),
|
|
306
|
+
fill="#e8e8e8", tags=("ent%d" % eid,)))
|
|
307
|
+
self._item_ids[eid] = ids
|
|
308
|
+
|
|
309
|
+
def _get_photo(self, asset_name):
|
|
310
|
+
if not asset_name:
|
|
311
|
+
return None
|
|
312
|
+
if asset_name in self._photos:
|
|
313
|
+
return self._photos[asset_name]
|
|
314
|
+
b64 = self.project.get("assets", {}).get(asset_name)
|
|
315
|
+
if not b64:
|
|
316
|
+
return None
|
|
317
|
+
try:
|
|
318
|
+
photo = tk.PhotoImage(data=b64)
|
|
319
|
+
except Exception:
|
|
320
|
+
return None
|
|
321
|
+
self._photos[asset_name] = photo
|
|
322
|
+
return photo
|
|
323
|
+
|
|
324
|
+
def _draw_selection(self):
|
|
325
|
+
self.canvas.delete("sel")
|
|
326
|
+
if self.selection is None:
|
|
327
|
+
return
|
|
328
|
+
entity = self._find(self.selection)
|
|
329
|
+
if entity is None:
|
|
330
|
+
return
|
|
331
|
+
t = entity.get("components", {}).get("Transform", {})
|
|
332
|
+
x, y = int(t.get("x", 0)), int(t.get("y", 0))
|
|
333
|
+
w, h = int(t.get("width", 80)), int(t.get("height", 60))
|
|
334
|
+
self.canvas.create_rectangle(x - 3, y - 3, x + w + 3, y + h + 3,
|
|
335
|
+
outline="#00c853", width=2, tags=("sel",))
|
|
336
|
+
# 右下角缩放把手
|
|
337
|
+
self.canvas.create_rectangle(x + w - 6, y + h - 6, x + w + 4, y + h + 4,
|
|
338
|
+
fill="#00c853", outline="#00c853", tags=("sel",))
|
|
339
|
+
|
|
340
|
+
def redraw_entity(self, entity):
|
|
341
|
+
eid = entity["id"]
|
|
342
|
+
for iid in self._item_ids.get(eid, []):
|
|
343
|
+
try:
|
|
344
|
+
self.canvas.delete(iid)
|
|
345
|
+
except Exception:
|
|
346
|
+
pass
|
|
347
|
+
self._item_ids[eid] = []
|
|
348
|
+
self._draw_entity(entity)
|
|
349
|
+
self._draw_selection()
|
|
350
|
+
|
|
351
|
+
# ---------------------------------------------------------- 交互
|
|
352
|
+
def _on_parts_select(self, event):
|
|
353
|
+
sel = self.parts_list.curselection()
|
|
354
|
+
self.pending_part = PARTS[sel[0]][0] if sel else None
|
|
355
|
+
if self.pending_part:
|
|
356
|
+
self._set_status("请点击画布放置「%s」(点已选中物体则为其添加组件)" % self.pending_part)
|
|
357
|
+
|
|
358
|
+
def _on_parts_double(self, event):
|
|
359
|
+
idx = self.parts_list.nearest(event.y)
|
|
360
|
+
if idx is None or idx < 0:
|
|
361
|
+
return
|
|
362
|
+
name = PARTS[idx][0]
|
|
363
|
+
self._place_part(name, self.scene_w // 2 - PART_SIZES[name][0] // 2,
|
|
364
|
+
self.scene_h // 2 - PART_SIZES[name][1] // 2)
|
|
365
|
+
|
|
366
|
+
def _place_part(self, part_name, x, y):
|
|
367
|
+
comps = dict(PARTS[[p[0] for p in PARTS].index(part_name)][1])
|
|
368
|
+
if part_name in ("点击触发器", "定时器"):
|
|
369
|
+
if self.selection is not None and self._find(self.selection) is not None:
|
|
370
|
+
comp_key = "TapHandler" if part_name == "点击触发器" else "Timer"
|
|
371
|
+
entity = self._find(self.selection)
|
|
372
|
+
if comp_key not in entity["components"]:
|
|
373
|
+
from .components import new_component
|
|
374
|
+
entity["components"][comp_key] = new_component(comp_key)
|
|
375
|
+
self.redraw_entity(entity)
|
|
376
|
+
self._refresh_props()
|
|
377
|
+
self.pending_part = None
|
|
378
|
+
self._set_status("已为「%s」添加%s" % (entity.get("name"), comp_key))
|
|
379
|
+
return
|
|
380
|
+
size = PART_SIZES[part_name]
|
|
381
|
+
count = sum(1 for e in self.project["entities"] if e.get("name", "").startswith(part_name))
|
|
382
|
+
entity = pyu.add_entity(self.project, name="%s%d" % (part_name, count + 1),
|
|
383
|
+
components=comps, transform={"x": x, "y": y,
|
|
384
|
+
"width": size[0], "height": size[1]})
|
|
385
|
+
self._draw_entity(entity)
|
|
386
|
+
self._refresh_objects_list()
|
|
387
|
+
self.select(entity["id"])
|
|
388
|
+
self.pending_part = None
|
|
389
|
+
self._set_status("已放置「%s」" % entity["name"])
|
|
390
|
+
|
|
391
|
+
def _on_canvas_press(self, event):
|
|
392
|
+
x, y = self.canvas.canvasx(event.x), self.canvas.canvasy(event.y)
|
|
393
|
+
if self.pending_part:
|
|
394
|
+
sx = int(x // GRID) * GRID
|
|
395
|
+
sy = int(y // GRID) * GRID
|
|
396
|
+
self._place_part(self.pending_part, sx, sy)
|
|
397
|
+
return
|
|
398
|
+
eid = self._hit_test(x, y)
|
|
399
|
+
if eid is None:
|
|
400
|
+
self.select(None)
|
|
401
|
+
return
|
|
402
|
+
self.select(eid)
|
|
403
|
+
entity = self._find(eid)
|
|
404
|
+
t = entity["components"]["Transform"]
|
|
405
|
+
# 缩放把手命中?
|
|
406
|
+
if abs(x - (t["x"] + t["width"])) < 12 and abs(y - (t["y"] + t["height"])) < 12:
|
|
407
|
+
self._drag = {"eid": eid, "mode": "resize",
|
|
408
|
+
"ox": t["width"], "oy": t["height"], "sx": x, "sy": y}
|
|
409
|
+
else:
|
|
410
|
+
self._drag = {"eid": eid, "mode": "move",
|
|
411
|
+
"ox": t["x"], "oy": t["y"], "sx": x, "sy": y}
|
|
412
|
+
|
|
413
|
+
def _on_canvas_drag(self, event):
|
|
414
|
+
if not self._drag:
|
|
415
|
+
return
|
|
416
|
+
x, y = self.canvas.canvasx(event.x), self.canvas.canvasy(event.y)
|
|
417
|
+
d = self._drag
|
|
418
|
+
entity = self._find(d["eid"])
|
|
419
|
+
if entity is None:
|
|
420
|
+
self._drag = None
|
|
421
|
+
return
|
|
422
|
+
t = entity["components"]["Transform"]
|
|
423
|
+
if d["mode"] == "move":
|
|
424
|
+
nx = d["ox"] + (x - d["sx"])
|
|
425
|
+
ny = d["oy"] + (y - d["sy"])
|
|
426
|
+
nx = int(round(nx / GRID) * GRID)
|
|
427
|
+
ny = int(round(ny / GRID) * GRID)
|
|
428
|
+
t["x"], t["y"] = nx, ny
|
|
429
|
+
else:
|
|
430
|
+
nw = max(8, d["ox"] + (x - d["sx"]))
|
|
431
|
+
nh = max(8, d["oy"] + (y - d["sy"]))
|
|
432
|
+
t["width"], t["height"] = int(round(nw / GRID) * GRID) or 8, int(round(nh / GRID) * GRID) or 8
|
|
433
|
+
self.redraw_entity(entity)
|
|
434
|
+
self._refresh_props()
|
|
435
|
+
|
|
436
|
+
def _on_canvas_release(self, event):
|
|
437
|
+
self._drag = None
|
|
438
|
+
|
|
439
|
+
def _hit_test(self, x, y):
|
|
440
|
+
entities = sorted(self.project["entities"], key=self._layer_of, reverse=True)
|
|
441
|
+
for entity in entities:
|
|
442
|
+
t = entity.get("components", {}).get("Transform", {})
|
|
443
|
+
if (t.get("x", 0) <= x <= t.get("x", 0) + t.get("width", 0) and
|
|
444
|
+
t.get("y", 0) <= y <= t.get("y", 0) + t.get("height", 0)):
|
|
445
|
+
return entity["id"]
|
|
446
|
+
return None
|
|
447
|
+
|
|
448
|
+
def _find(self, eid):
|
|
449
|
+
for entity in self.project["entities"]:
|
|
450
|
+
if entity["id"] == eid:
|
|
451
|
+
return entity
|
|
452
|
+
return None
|
|
453
|
+
|
|
454
|
+
def select(self, eid):
|
|
455
|
+
self.selection = eid
|
|
456
|
+
self._draw_selection()
|
|
457
|
+
self._refresh_props()
|
|
458
|
+
self._sync_objects_list()
|
|
459
|
+
|
|
460
|
+
def delete_selection(self):
|
|
461
|
+
if self.selection is None:
|
|
462
|
+
return
|
|
463
|
+
entity = self._find(self.selection)
|
|
464
|
+
if entity is None:
|
|
465
|
+
self.selection = None
|
|
466
|
+
return
|
|
467
|
+
self.project["entities"].remove(entity)
|
|
468
|
+
for iid in self._item_ids.pop(entity["id"], []):
|
|
469
|
+
try:
|
|
470
|
+
self.canvas.delete(iid)
|
|
471
|
+
except Exception:
|
|
472
|
+
pass
|
|
473
|
+
self._photos.pop(entity.get("components", {}).get("Sprite", {}).get("image", ""), None)
|
|
474
|
+
self.selection = None
|
|
475
|
+
self._refresh_objects_list()
|
|
476
|
+
self._clear_props()
|
|
477
|
+
self._set_status("已删除「%s」" % entity["name"])
|
|
478
|
+
|
|
479
|
+
def duplicate_selection(self):
|
|
480
|
+
if self.selection is None:
|
|
481
|
+
return
|
|
482
|
+
src = self._find(self.selection)
|
|
483
|
+
if src is None:
|
|
484
|
+
return
|
|
485
|
+
import copy
|
|
486
|
+
comps = copy.deepcopy(src["components"])
|
|
487
|
+
t = comps["Transform"]
|
|
488
|
+
t["x"] = t.get("x", 0) + GRID
|
|
489
|
+
t["y"] = t.get("y", 0) + GRID
|
|
490
|
+
entity = pyu.add_entity(self.project, name=src["name"] + "_副本", components=comps)
|
|
491
|
+
self._draw_entity(entity)
|
|
492
|
+
self._refresh_objects_list()
|
|
493
|
+
self.select(entity["id"])
|
|
494
|
+
self._set_status("已复制「%s」" % src["name"])
|
|
495
|
+
|
|
496
|
+
# ---------------------------------------------------------- 列表同步
|
|
497
|
+
def _refresh_objects_list(self):
|
|
498
|
+
self.objects_list.delete(0, tk.END)
|
|
499
|
+
for entity in sorted(self.project["entities"], key=lambda e: e["id"]):
|
|
500
|
+
self.objects_list.insert(tk.END, "%s" % entity.get("name", "?"))
|
|
501
|
+
self._sync_objects_list()
|
|
502
|
+
|
|
503
|
+
def _sync_objects_list(self):
|
|
504
|
+
for i, entity in enumerate(sorted(self.project["entities"], key=lambda e: e["id"])):
|
|
505
|
+
if entity["id"] == self.selection:
|
|
506
|
+
self.objects_list.selection_clear(0, tk.END)
|
|
507
|
+
self.objects_list.selection_set(i)
|
|
508
|
+
self.objects_list.see(i)
|
|
509
|
+
return
|
|
510
|
+
|
|
511
|
+
def _on_objects_select(self, event):
|
|
512
|
+
sel = self.objects_list.curselection()
|
|
513
|
+
if not sel:
|
|
514
|
+
return
|
|
515
|
+
entities = sorted(self.project["entities"], key=lambda e: e["id"])
|
|
516
|
+
self.select(entities[sel[0]]["id"])
|
|
517
|
+
|
|
518
|
+
# ---------------------------------------------------------- 属性面板
|
|
519
|
+
def _clear_props(self):
|
|
520
|
+
for w in self._prop_widgets:
|
|
521
|
+
try:
|
|
522
|
+
w.destroy()
|
|
523
|
+
except Exception:
|
|
524
|
+
pass
|
|
525
|
+
self._prop_widgets = []
|
|
526
|
+
self._prop_vars = {}
|
|
527
|
+
|
|
528
|
+
def _refresh_props(self):
|
|
529
|
+
self._clear_props()
|
|
530
|
+
entity = self._find(self.selection) if self.selection is not None else None
|
|
531
|
+
if entity is None:
|
|
532
|
+
tk.Label(self.prop_frame, text="未选中物体", bg="#f4f5f7", fg="#888888").pack(anchor="w", pady=6)
|
|
533
|
+
return
|
|
534
|
+
|
|
535
|
+
f = self.prop_frame
|
|
536
|
+
|
|
537
|
+
def section(text):
|
|
538
|
+
tk.Label(f, text=text, bg="#dfe3ea", fg="#222222", anchor="w",
|
|
539
|
+
font=("Helvetica", 10, "bold")).pack(fill=tk.X, pady=(6, 2))
|
|
540
|
+
|
|
541
|
+
def row(key, label, var, widget_builder, field_spec):
|
|
542
|
+
line = tk.Frame(f, bg="#f4f5f7")
|
|
543
|
+
line.pack(fill=tk.X, pady=1)
|
|
544
|
+
tk.Label(line, text=label, bg="#f4f5f7", width=12, anchor="w",
|
|
545
|
+
font=("Helvetica", 9)).pack(side=tk.LEFT)
|
|
546
|
+
widget_builder(line, var, field_spec)
|
|
547
|
+
self._prop_widgets.append(line)
|
|
548
|
+
|
|
549
|
+
# 基本信息
|
|
550
|
+
section("物体")
|
|
551
|
+
name_var = tk.StringVar(value=entity.get("name", ""))
|
|
552
|
+
self._prop_vars[("name", None)] = name_var
|
|
553
|
+
row("name", "名称", name_var,
|
|
554
|
+
lambda l, v, s: tk.Entry(l, textvariable=v, width=14).pack(side=tk.LEFT), None)
|
|
555
|
+
|
|
556
|
+
for comp_name in ["Transform"] + [c for c in ADDABLE_COMPONENTS if c in entity["components"]]:
|
|
557
|
+
section(comp_name)
|
|
558
|
+
schema = COMPONENT_SCHEMAS[comp_name]["fields"]
|
|
559
|
+
comp = entity["components"][comp_name]
|
|
560
|
+
for key, spec in schema.items():
|
|
561
|
+
value = comp.get(key, spec.get("default"))
|
|
562
|
+
var = tk.StringVar()
|
|
563
|
+
if isinstance(value, bool):
|
|
564
|
+
var.set("1" if value else "0")
|
|
565
|
+
else:
|
|
566
|
+
var.set(str(value))
|
|
567
|
+
self._prop_vars[(comp_name, key)] = var
|
|
568
|
+
|
|
569
|
+
if spec["type"] in ("int", "float", "text"):
|
|
570
|
+
if spec.get("multiline"):
|
|
571
|
+
line = tk.Frame(f, bg="#f4f5f7")
|
|
572
|
+
line.pack(fill=tk.X, pady=1)
|
|
573
|
+
tk.Label(line, text=spec.get("hint", key), bg="#f4f5f7", width=12,
|
|
574
|
+
anchor="w", font=("Helvetica", 9)).pack(side=tk.LEFT, anchor="n")
|
|
575
|
+
txt = tk.Text(line, height=3, width=20, font=("Helvetica", 9))
|
|
576
|
+
txt.insert("1.0", str(value))
|
|
577
|
+
txt.pack(side=tk.LEFT)
|
|
578
|
+
self._prop_widgets.append(line)
|
|
579
|
+
self._prop_vars[(comp_name, key)] = txt # Text 对象直接存
|
|
580
|
+
else:
|
|
581
|
+
row((comp_name, key), spec.get("hint", key), var,
|
|
582
|
+
lambda l, v, s: tk.Entry(l, textvariable=v, width=14).pack(side=tk.LEFT), spec)
|
|
583
|
+
elif spec["type"] == "choice":
|
|
584
|
+
row((comp_name, key), spec.get("hint", key), var,
|
|
585
|
+
lambda l, v, s: tk.OptionMenu(l, v, *s["options"]).pack(side=tk.LEFT), spec)
|
|
586
|
+
elif spec["type"] == "bool":
|
|
587
|
+
def mk_check(l, v, s):
|
|
588
|
+
cv = tk.BooleanVar(value=(v.get() == "1"))
|
|
589
|
+
tk.Checkbutton(l, variable=cv, command=lambda: v.set("1" if cv.get() else "0"),
|
|
590
|
+
bg="#f4f5f7").pack(side=tk.LEFT)
|
|
591
|
+
row((comp_name, key), spec.get("hint", key), var, mk_check, spec)
|
|
592
|
+
elif spec["type"] == "color":
|
|
593
|
+
def mk_color(l, v, s):
|
|
594
|
+
def pick():
|
|
595
|
+
_, hexcolor = colorchooser.askcolor(color=v.get() or s["default"])
|
|
596
|
+
if hexcolor:
|
|
597
|
+
v.set(hexcolor)
|
|
598
|
+
swatch.config(bg=hexcolor)
|
|
599
|
+
swatch = tk.Button(l, text=v.get() or s["default"], command=pick,
|
|
600
|
+
bg=v.get() or s["default"], width=8, relief=tk.GROOVE)
|
|
601
|
+
swatch.pack(side=tk.LEFT)
|
|
602
|
+
row((comp_name, key), spec.get("hint", key), var, mk_color, spec)
|
|
603
|
+
elif spec["type"] == "file":
|
|
604
|
+
def mk_file(l, v, s):
|
|
605
|
+
def browse():
|
|
606
|
+
path = filedialog.askopenfilename(
|
|
607
|
+
title="选择图片 (PNG/GIF)",
|
|
608
|
+
filetypes=[("图片", "*.png *.gif"), ("所有文件", "*.*")])
|
|
609
|
+
if path:
|
|
610
|
+
try:
|
|
611
|
+
asset = pyu.embed_image(self.project, path)
|
|
612
|
+
except Exception as exc:
|
|
613
|
+
messagebox.showerror("图片", str(exc))
|
|
614
|
+
return
|
|
615
|
+
v.set(asset)
|
|
616
|
+
ent = self._find(self.selection)
|
|
617
|
+
ent["components"]["Sprite"]["image"] = asset
|
|
618
|
+
self.redraw_entity(ent)
|
|
619
|
+
tk.Entry(l, textvariable=v, width=14).pack(side=tk.LEFT)
|
|
620
|
+
tk.Button(l, text="浏览", command=browse, font=("Helvetica", 8)).pack(side=tk.LEFT)
|
|
621
|
+
row((comp_name, key), spec.get("hint", key), var, mk_file, spec)
|
|
622
|
+
|
|
623
|
+
# 操作按钮
|
|
624
|
+
section("操作")
|
|
625
|
+
ops = tk.Frame(f, bg="#f4f5f7")
|
|
626
|
+
ops.pack(fill=tk.X, pady=4)
|
|
627
|
+
tk.Button(ops, text="应用", command=self.apply_props, bg="#00c853", fg="white",
|
|
628
|
+
font=("Helvetica", 10, "bold")).pack(side=tk.LEFT, padx=2)
|
|
629
|
+
tk.Button(ops, text="复制", command=self.duplicate_selection).pack(side=tk.LEFT, padx=2)
|
|
630
|
+
tk.Button(ops, text="删除", command=self.delete_selection, fg="#d32f2f").pack(side=tk.LEFT, padx=2)
|
|
631
|
+
|
|
632
|
+
add_row = tk.Frame(f, bg="#f4f5f7")
|
|
633
|
+
add_row.pack(fill=tk.X, pady=(8, 2))
|
|
634
|
+
add_var = tk.StringVar(value=ADDABLE_COMPONENTS[0])
|
|
635
|
+
tk.OptionMenu(add_row, add_var, *ADDABLE_COMPONENTS).pack(side=tk.LEFT)
|
|
636
|
+
tk.Button(add_row, text="添加组件", command=lambda: self.add_component(add_var.get())).pack(side=tk.LEFT, padx=4)
|
|
637
|
+
self._prop_vars["_add"] = add_var
|
|
638
|
+
|
|
639
|
+
def add_component(self, comp_name):
|
|
640
|
+
entity = self._find(self.selection)
|
|
641
|
+
if entity is None or comp_name in entity["components"]:
|
|
642
|
+
return
|
|
643
|
+
from .components import new_component
|
|
644
|
+
entity["components"][comp_name] = new_component(comp_name)
|
|
645
|
+
self.redraw_entity(entity)
|
|
646
|
+
self._refresh_props()
|
|
647
|
+
self._set_status("已添加 %s" % comp_name)
|
|
648
|
+
|
|
649
|
+
def remove_component(self, comp_name):
|
|
650
|
+
entity = self._find(self.selection)
|
|
651
|
+
if entity is None or comp_name == "Transform":
|
|
652
|
+
return
|
|
653
|
+
if comp_name in entity["components"]:
|
|
654
|
+
del entity["components"][comp_name]
|
|
655
|
+
self.redraw_entity(entity)
|
|
656
|
+
self._refresh_props()
|
|
657
|
+
|
|
658
|
+
def apply_props(self):
|
|
659
|
+
entity = self._find(self.selection)
|
|
660
|
+
if entity is None:
|
|
661
|
+
return
|
|
662
|
+
from .components import validate_component
|
|
663
|
+
# 名称
|
|
664
|
+
if ("name", None) in self._prop_vars:
|
|
665
|
+
entity["name"] = self._prop_vars[("name", None)].get() or "未命名"
|
|
666
|
+
changed = False
|
|
667
|
+
for key_item, var in self._prop_vars.items():
|
|
668
|
+
if not isinstance(key_item, tuple) or len(key_item) != 2:
|
|
669
|
+
continue
|
|
670
|
+
comp_name, key = key_item
|
|
671
|
+
comp = entity["components"].get(comp_name)
|
|
672
|
+
if comp is None:
|
|
673
|
+
continue
|
|
674
|
+
schema = COMPONENT_SCHEMAS[comp_name]["fields"][key]
|
|
675
|
+
if schema["type"] == "bool":
|
|
676
|
+
comp[key] = (var.get() == "1")
|
|
677
|
+
changed = True
|
|
678
|
+
elif schema["type"] in ("int", "float"):
|
|
679
|
+
ok, fixed, err = validate_component(comp_name, {key: var.get()})
|
|
680
|
+
if ok:
|
|
681
|
+
comp[key] = fixed[key]
|
|
682
|
+
changed = True
|
|
683
|
+
else:
|
|
684
|
+
messagebox.showerror("属性错误", err)
|
|
685
|
+
return
|
|
686
|
+
elif isinstance(var, tk.Text):
|
|
687
|
+
comp[key] = var.get("1.0", "end").rstrip("\n")
|
|
688
|
+
changed = True
|
|
689
|
+
else:
|
|
690
|
+
comp[key] = var.get()
|
|
691
|
+
changed = True
|
|
692
|
+
if changed:
|
|
693
|
+
self.redraw_entity(entity)
|
|
694
|
+
self._refresh_objects_list()
|
|
695
|
+
self._set_status("已应用属性")
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def launch_editor(file=None):
|
|
699
|
+
"""启动编辑器(GUI)。"""
|
|
700
|
+
root = tk.Tk()
|
|
701
|
+
EditorApp(root, file)
|
|
702
|
+
root.mainloop()
|