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/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()