exp-runtime-creator 0.6.0__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.
@@ -0,0 +1,1041 @@
1
+ """Qt graph editor. The production player owns all game presentation."""
2
+ from copy import deepcopy
3
+ from html import escape
4
+ from pathlib import Path
5
+ import sys
6
+ import json
7
+ from dataclasses import asdict
8
+ import tempfile
9
+ import uuid
10
+
11
+ from PySide6.QtCore import QPointF, QProcess, Qt, Signal, QTimer, QStandardPaths
12
+ from PySide6.QtGui import QAction, QColor, QIcon, QKeySequence, QPainter, QPainterPath, QPen, QPixmap
13
+ from PySide6.QtWidgets import (QApplication, QCheckBox, QComboBox, QDialog, QDialogButtonBox,
14
+ QFileDialog, QFormLayout, QGraphicsItem, QGraphicsPathItem, QGraphicsRectItem,
15
+ QGraphicsScene, QGraphicsTextItem, QGraphicsView, QHBoxLayout, QLabel,
16
+ QLineEdit, QListWidget, QMainWindow, QMessageBox, QPushButton, QScrollArea,
17
+ QSpinBox, QSplitter, QTextEdit, QToolBar, QVBoxLayout, QWidget)
18
+
19
+ from exp_runtime.exp import ContentError
20
+ from .assets import image_png, import_library_image, player_library
21
+ from .compiler import compile_project, export_project, export_native_slot
22
+ from .model import (Choice, KINDS, OPERATORS, Node, Project, ProjectError,
23
+ project_from_bytes, save_project, Character, validate_structure)
24
+ from .features import NAMES, parameters
25
+ from .editing import Recovery, auto_layout, copy_nodes, paste_nodes
26
+ from .panels import DebugDialog, ListDialog, RecordDialog, TranslationsDialog
27
+ from .live import LiveEditor
28
+ from .sharing import SharingError, digest, guard, session_info
29
+
30
+ COLORS = dict(dialogue='#6093cb', choice='#b381c7', background='#67a68b',
31
+ set='#cc9d50', branch='#cc9d50', end='#aa7479')
32
+ COLORS = dict.fromkeys(KINDS, '#6093cb') | COLORS
33
+
34
+
35
+ class NodeItem(QGraphicsRectItem):
36
+ def __init__(self, node, graph):
37
+ super().__init__(0, 0, 228, 126)
38
+ self.node, self.graph = node, graph
39
+ self.setFlags(QGraphicsItem.GraphicsItemFlag.ItemIsMovable |
40
+ QGraphicsItem.GraphicsItemFlag.ItemIsSelectable)
41
+ self.setBrush(QColor('#263342'))
42
+ self.caption = QGraphicsTextItem(self)
43
+ self.caption.setTextWidth(212)
44
+ self.caption.setPos(8, 5)
45
+ self.caption.setAcceptedMouseButtons(Qt.MouseButton.NoButton)
46
+ self.setPos(node.x, node.y)
47
+ self.refresh()
48
+
49
+ def refresh(self):
50
+ node = self.node
51
+ marker = 'START · ' if node.id == self.graph.project.start else ''
52
+ text = node.text or (f'{node.variable} {node.operator if node.kind == "branch" else "="} {node.value}'
53
+ if node.kind in ('set', 'branch') else NAMES[node.kind])
54
+ self.setPen(QPen(QColor(COLORS[node.kind]), 2))
55
+ self.caption.setHtml(f'<span style="color:{COLORS[node.kind]}">{marker}{NAMES[node.kind]}</span>'
56
+ f'<br><b style="color:#ffffff">{escape(node.label or node.id)}</b>'
57
+ f'<br><span style="color:#d2dce8">{escape(text[:70])}</span>')
58
+
59
+ def mousePressEvent(self, event):
60
+ self.original = (self.node.x, self.node.y)
61
+ super().mousePressEvent(event)
62
+
63
+ def mouseMoveEvent(self, event):
64
+ super().mouseMoveEvent(event)
65
+ self.graph.draw_edges()
66
+
67
+ def mouseReleaseEvent(self, event):
68
+ super().mouseReleaseEvent(event)
69
+ if (self.pos().x(), self.pos().y()) != self.original:
70
+ self.graph.moved.emit(self.node.id, self.pos().x(), self.pos().y())
71
+
72
+
73
+ class Graph(QGraphicsScene):
74
+ moved = Signal(str, float, float)
75
+
76
+ def __init__(self, project, parent=None):
77
+ super().__init__(parent)
78
+ self.project = project
79
+ self.cards = {}
80
+ self.edges = []
81
+ self.rebuild()
82
+
83
+ def rebuild(self, selected=None):
84
+ self.blockSignals(True)
85
+ self.clear()
86
+ self.edges, self.cards = [], {}
87
+ for node in self.project.nodes:
88
+ card = NodeItem(node, self)
89
+ self.cards[node.id] = card
90
+ self.addItem(card)
91
+ card.setSelected(node.id == selected)
92
+ self.draw_edges()
93
+ self.blockSignals(False)
94
+
95
+ def draw_edges(self):
96
+ for edge in self.edges:
97
+ self.removeItem(edge)
98
+ self.edges = []
99
+ for source in self.cards.values():
100
+ for i, (label, target) in enumerate(source.node.links()):
101
+ if target not in self.cards:
102
+ continue
103
+ start = source.pos() + QPointF(228, 45 + i * 9)
104
+ end = self.cards[target].pos() + QPointF(0, 62)
105
+ path = QPainterPath(start)
106
+ if source.node.id == target:
107
+ end = source.pos() + QPointF(114, 0)
108
+ path.cubicTo(start + QPointF(100, -180), end + QPointF(50, -100), end)
109
+ else:
110
+ bend = max(65, abs(end.x() - start.x()) / 2)
111
+ path.cubicTo(start + QPointF(bend, 0), end - QPointF(bend, 0), end)
112
+ line = QGraphicsPathItem(path)
113
+ line.setPen(QPen(QColor('#8798aa'), 2))
114
+ line.setZValue(-1)
115
+ line.setAcceptedMouseButtons(Qt.MouseButton.NoButton)
116
+ self.addItem(line)
117
+ caption = self.addText(label[:22] + ' →')
118
+ caption.setDefaultTextColor(QColor('#d2dce8'))
119
+ caption.setPos(path.pointAtPercent(0.45))
120
+ caption.setZValue(-1)
121
+ caption.setAcceptedMouseButtons(Qt.MouseButton.NoButton)
122
+ self.edges.extend((line, caption))
123
+ self.setSceneRect(self.itemsBoundingRect().adjusted(-150, -150, 150, 150))
124
+
125
+
126
+ class LibraryBrowser(QDialog):
127
+ def __init__(self, library, parent=None):
128
+ super().__init__(parent)
129
+ self.library, self.episode, self.resource = library, None, None
130
+ self.setWindowTitle('Images from your imported library')
131
+ self.resize(740, 520)
132
+ layout = QVBoxLayout(self)
133
+ self.bank = QComboBox()
134
+ self.bank.addItem('Original game assets (reference only)', None)
135
+ for episode in library.episodes:
136
+ self.bank.addItem(episode['titles'][0] or episode['name'], episode['id'])
137
+ layout.addWidget(self.bank)
138
+ search = QLineEdit()
139
+ search.setPlaceholderText('Filter by resource ID')
140
+ layout.addWidget(search)
141
+ row = QHBoxLayout()
142
+ self.items = QListWidget()
143
+ row.addWidget(self.items, 1)
144
+ self.preview = QLabel('Select an image resource to preview it.')
145
+ self.preview.setAlignment(Qt.AlignmentFlag.AlignCenter)
146
+ self.preview.setMinimumWidth(380)
147
+ self.preview.setWordWrap(True)
148
+ row.addWidget(self.preview, 2)
149
+ layout.addLayout(row, 1)
150
+ self.buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
151
+ self.buttons.button(QDialogButtonBox.StandardButton.Ok).setText('Add image to project')
152
+ self.buttons.accepted.connect(self.accept)
153
+ self.buttons.rejected.connect(self.reject)
154
+ layout.addWidget(self.buttons)
155
+ self.bank.currentIndexChanged.connect(self.populate)
156
+ self.items.currentTextChanged.connect(self.show_image)
157
+ search.textChanged.connect(lambda text: [self.items.item(i).setHidden(text not in self.items.item(i).text())
158
+ for i in range(self.items.count())])
159
+ self.populate()
160
+
161
+ def populate(self):
162
+ selector = self.bank.currentData()
163
+ self.items.clear()
164
+ self.episode, self.resource = None, None
165
+ self.buttons.button(QDialogButtonBox.StandardButton.Ok).setEnabled(False)
166
+ try:
167
+ self.episode = self.library.open_episode(selector) if selector else None
168
+ ids = (self.episode.archive.entries if self.episode else self.library.base_members)
169
+ self.items.addItems([str(i) for i in sorted(ids) if 26000 <= i <= 32767] if self.episode
170
+ else [str(i) for i in sorted(ids) if i < 25000])
171
+ except (ContentError, OSError) as error:
172
+ self.preview.setText(str(error))
173
+
174
+ def show_image(self, text):
175
+ self.resource = None
176
+ try:
177
+ resource = int(text)
178
+ png = image_png((self.episode or self.library).read_asset(resource))
179
+ pixmap = QPixmap()
180
+ if not pixmap.loadFromData(png):
181
+ raise ProjectError('Image could not be decoded')
182
+ self.preview.setPixmap(pixmap.scaled(380, 360, Qt.AspectRatioMode.KeepAspectRatio,
183
+ Qt.TransformationMode.SmoothTransformation))
184
+ self.resource = resource
185
+ except (ValueError, OSError, ContentError) as error:
186
+ self.preview.setText(str(error) if text else 'Select an image resource.')
187
+ self.buttons.button(QDialogButtonBox.StandardButton.Ok).setEnabled(self.resource is not None)
188
+
189
+
190
+ class Editor(QMainWindow):
191
+ def __init__(self, project=None, path=None, *, discover_library=True):
192
+ super().__init__()
193
+ self.setWindowIcon(QIcon(str(Path(__file__).with_name('kiwi-hammer.svg'))))
194
+ self.project, self.path = project or Project(), Path(path) if path else None
195
+ self.live_editor = LiveEditor(self)
196
+ self.saved_path = self.disk_revision = None
197
+ if self.path is not None:
198
+ try:
199
+ self.live_editor.attach(self.path)
200
+ with guard(self.path):
201
+ data = self.path.read_bytes()
202
+ if project is None: self.project = project_from_bytes(data, self.path)
203
+ self.disk_revision = digest(data)
204
+ self.saved_path = self.path.resolve()
205
+ except BaseException:
206
+ self.live_editor.close()
207
+ raise
208
+ self.discover_library = discover_library
209
+ self.use_player_library()
210
+ self.dirty = False
211
+ self.undo_history, self.redo_history = [], []
212
+ self.selected = self.project.start
213
+ self.preview_process = None
214
+ self.preview_directory = None
215
+ self.recovery = Recovery(Path(QStandardPaths.writableLocation(QStandardPaths.StandardLocation.AppDataLocation)) / 'recovery')
216
+ self.autosave_timer = QTimer(self)
217
+ self.autosave_timer.timeout.connect(self.autosave)
218
+ self.autosave_timer.start(15000)
219
+ self.live_timer = QTimer(self)
220
+ self.live_timer.setSingleShot(True)
221
+ self.live_timer.timeout.connect(self.restart_preview)
222
+ self.resize(1320, 820)
223
+ self.setMinimumSize(1000, 650)
224
+ toolbar = QToolBar('Episode')
225
+ self.addToolBar(toolbar)
226
+ for label, callback, shortcut in (
227
+ ('New', self.new, QKeySequence.StandardKey.New),
228
+ ('Open…', self.open, QKeySequence.StandardKey.Open),
229
+ ('Save', self.save, QKeySequence.StandardKey.Save),
230
+ ('Save as…', self.save_as, QKeySequence.StandardKey.SaveAs),
231
+ ('Undo', self.undo, QKeySequence.StandardKey.Undo),
232
+ ('Redo', self.redo, QKeySequence.StandardKey.Redo),
233
+ ('Validate', self.validate, None), ('Export EXP…', self.export, None),
234
+ ('Original app export…', self.export_original, None),
235
+ ('Preview in player', self.preview, None),
236
+ ('Import EXP…', self.import_exp, None), ('Debug…', self.debug, None),
237
+ ):
238
+ action = QAction(label, self)
239
+ action.triggered.connect(callback)
240
+ if shortcut:
241
+ action.setShortcut(shortcut)
242
+ toolbar.addAction(action)
243
+ root = QWidget()
244
+ column = QVBoxLayout(root)
245
+ project_row = QHBoxLayout()
246
+ self.title = QLineEdit(self.project.title)
247
+ self.title.setPlaceholderText('Episode title')
248
+ self.title.setObjectName('project_title')
249
+ self.title.textEdited.connect(lambda value: self.set_project('title', value))
250
+ project_row.addWidget(QLabel('Title'))
251
+ project_row.addWidget(self.title, 1)
252
+ self.game = QComboBox()
253
+ self.game.addItem('Surviving High School', 'shs')
254
+ self.game.addItem('Cause of Death', 'cod')
255
+ self.game.currentIndexChanged.connect(self.change_game)
256
+ project_row.addWidget(self.game)
257
+ self.pack, self.episode_id = QSpinBox(), QSpinBox()
258
+ for widget, key, label in ((self.pack, 'pack_id', 'Pack'), (self.episode_id, 'episode_id', 'Episode')):
259
+ widget.setRange(1 if key == 'episode_id' else 0, 32767)
260
+ widget.valueChanged.connect(lambda value, key=key: self.set_project(key, value))
261
+ project_row.addWidget(QLabel(label))
262
+ project_row.addWidget(widget)
263
+ column.addLayout(project_row)
264
+ splitter = QSplitter()
265
+ left = QWidget()
266
+ toolbox = QVBoxLayout(left)
267
+ toolbox.addWidget(QLabel('ADD A STORY NODE'))
268
+ self.node_kind = QComboBox()
269
+ for kind in KINDS:
270
+ if kind != 'native': self.node_kind.addItem(NAMES[kind], kind)
271
+ toolbox.addWidget(self.node_kind)
272
+ button = QPushButton('Add node')
273
+ button.clicked.connect(lambda: self.add_node(self.node_kind.currentData()))
274
+ toolbox.addWidget(button)
275
+ for label, callback in (('Characters…', self.characters), ('Translations…', self.translations),
276
+ ('Auto arrange', self.arrange), ('Copy nodes', self.copy),
277
+ ('Paste nodes', self.paste), ('Duplicate nodes', self.duplicate)):
278
+ button = QPushButton(label); button.clicked.connect(callback); toolbox.addWidget(button)
279
+ search = QLineEdit(); search.setPlaceholderText('Find label, dialogue or speaker')
280
+ search.returnPressed.connect(lambda: self.search(search.text())); toolbox.addWidget(search)
281
+ self.live = QCheckBox('Restart preview after edits'); toolbox.addWidget(self.live)
282
+ self.preview_selected = QCheckBox('Preview from selected node'); toolbox.addWidget(self.preview_selected)
283
+ toolbox.addWidget(QLabel('PROJECT IMAGES'))
284
+ self.assets = QListWidget()
285
+ toolbox.addWidget(self.assets, 1)
286
+ for label, callback in (('Add PNG / JPEG…', self.add_image), ('Use imported library…', self.choose_library),
287
+ ('Browse library images…', self.browse_library), ('Remove image', self.remove_image)):
288
+ button = QPushButton(label)
289
+ button.clicked.connect(callback)
290
+ toolbox.addWidget(button)
291
+ self.library_label = QLabel()
292
+ self.library_label.setWordWrap(True)
293
+ toolbox.addWidget(self.library_label)
294
+ splitter.addWidget(left)
295
+ self.graph = Graph(self.project)
296
+ self.graph.moved.connect(self.move_node)
297
+ self.graph.selectionChanged.connect(self.select_node)
298
+ self.view = QGraphicsView(self.graph)
299
+ self.view.setRenderHint(QPainter.RenderHint.Antialiasing)
300
+ self.view.setBackgroundBrush(QColor('#19232f'))
301
+ self.view.setDragMode(QGraphicsView.DragMode.ScrollHandDrag)
302
+ for label, callback in (('Zoom in', lambda: self.view.scale(1.2, 1.2)),
303
+ ('Zoom out', lambda: self.view.scale(1 / 1.2, 1 / 1.2)),
304
+ ('Fit story', lambda: self.view.fitInView(self.graph.itemsBoundingRect().adjusted(
305
+ -30, -30, 30, 30), Qt.AspectRatioMode.KeepAspectRatio))):
306
+ action = QAction(label, self)
307
+ action.triggered.connect(callback)
308
+ toolbar.addAction(action)
309
+ splitter.addWidget(self.view)
310
+ self.inspector = QScrollArea()
311
+ self.inspector.setWidgetResizable(True)
312
+ splitter.addWidget(self.inspector)
313
+ splitter.setSizes([220, 730, 330])
314
+ column.addWidget(splitter, 1)
315
+ self.setCentralWidget(root)
316
+ self.sharing_label = QLabel()
317
+ self.statusBar().addPermanentWidget(self.sharing_label)
318
+ self.refresh()
319
+ self.statusBar().showMessage('Connect nodes with the destination fields. Drag cards to arrange your story.')
320
+
321
+ def error(self, error):
322
+ QMessageBox.warning(self, 'Episode creator', str(error))
323
+
324
+ def remember(self):
325
+ self.undo_history.append(deepcopy(self.project))
326
+ del self.undo_history[:-100]
327
+ self.redo_history.clear()
328
+ self.dirty = True
329
+
330
+ def changed(self):
331
+ self.setWindowTitle(f'{self.project.title}{" *" if self.dirty else ""} — EXP Runtime Creator')
332
+ self.sharing_label.setText('MCP: live editing ready' if self.live_editor.lease else 'MCP: save project to connect')
333
+ for card in self.graph.cards.values():
334
+ card.refresh()
335
+ self.graph.draw_edges()
336
+ if self.dirty and self.preview_process is not None and self.live.isChecked():
337
+ self.live_timer.start(800)
338
+
339
+ def refresh(self, *, preserve_focus=False):
340
+ focus = QApplication.focusWidget() if preserve_focus else None
341
+ focused_name = focus.objectName() if focus is not None else ''
342
+ cursor = focus.textCursor() if isinstance(focus, QTextEdit) else None
343
+ position = focus.cursorPosition() if isinstance(focus, QLineEdit) else None
344
+ scroll = self.inspector.verticalScrollBar().value()
345
+ for widget, value in ((self.title, self.project.title), (self.game, self.project.game),
346
+ (self.pack, self.project.pack_id), (self.episode_id, self.project.episode_id)):
347
+ widget.blockSignals(True)
348
+ if isinstance(widget, QComboBox):
349
+ widget.setCurrentIndex(widget.findData(value))
350
+ elif isinstance(widget, QSpinBox):
351
+ widget.setValue(value)
352
+ else:
353
+ widget.setText(value)
354
+ widget.blockSignals(False)
355
+ self.assets.clear()
356
+ self.assets.addItems([f'{a.id} · {a.name}{" (base)" if a.data is None else ""}' for a in self.project.assets])
357
+ self.library_label.setText('Game library: ' + (Path(self.project.library).name if self.project.library else 'none'))
358
+ self.library_label.setToolTip(self.project.library)
359
+ self.preview_selected.setEnabled(not self.project.native)
360
+ if self.project.native: self.preview_selected.setChecked(False)
361
+ self.graph.project = self.project
362
+ self.graph.rebuild(self.selected)
363
+ self.show_properties()
364
+ if focused_name:
365
+ restored = self.title if focus is self.title else self.inspector.widget().findChild(type(focus), focused_name)
366
+ if restored is not None:
367
+ restored.setFocus()
368
+ if isinstance(restored, QTextEdit) and cursor is not None:
369
+ current = restored.textCursor()
370
+ end = restored.document().characterCount() - 1
371
+ current.setPosition(min(cursor.anchor(), end))
372
+ current.setPosition(min(cursor.position(), end), current.MoveMode.KeepAnchor)
373
+ restored.setTextCursor(current)
374
+ elif isinstance(restored, QLineEdit) and position is not None:
375
+ restored.setCursorPosition(min(position, len(restored.text())))
376
+ if preserve_focus: self.inspector.verticalScrollBar().setValue(scroll)
377
+ self.changed()
378
+
379
+ def set_project(self, key, value):
380
+ if getattr(self.project, key) != value:
381
+ self.remember()
382
+ setattr(self.project, key, value)
383
+ self.changed()
384
+
385
+ def change_game(self):
386
+ game = self.game.currentData()
387
+ if game != self.project.game:
388
+ if (self.project.native or any(a.data is None for a in self.project.assets)
389
+ or any(n.kind in ('music', 'sound') for n in self.project.nodes)):
390
+ self.error('Start a new project to change games after using base assets or native scripts. '
391
+ 'Game libraries and base resource IDs must stay together.')
392
+ self.refresh()
393
+ else:
394
+ self.remember()
395
+ self.project.game, self.project.library = game, ''
396
+ self.use_player_library()
397
+ self.refresh()
398
+
399
+ def use_player_library(self):
400
+ if self.discover_library and not self.project.library:
401
+ self.project.library = player_library(self.project.game)
402
+
403
+ def set_node(self, key, value):
404
+ node = self.project.node(self.selected)
405
+ if getattr(node, key) != value:
406
+ self.remember()
407
+ setattr(node, key, value)
408
+ self.changed()
409
+ if key in ('portrait', 'character'):
410
+ self.show_properties()
411
+
412
+ def select_node(self):
413
+ selected = [item for item in self.graph.selectedItems() if isinstance(item, NodeItem)]
414
+ if selected:
415
+ self.selected = selected[0].node.id
416
+ self.show_properties()
417
+
418
+ def move_node(self, node_id, x, y):
419
+ self.remember()
420
+ node = self.project.node(node_id)
421
+ node.x, node.y = x, y
422
+ self.changed()
423
+
424
+ def add_node(self, kind):
425
+ if self.project.native:
426
+ self.error('Native blocks keep their original instruction addresses. Create a new story for high-level nodes.'); return
427
+ self.remember()
428
+ position = self.view.mapToScene(self.view.viewport().rect().center())
429
+ node = Node(uuid.uuid4().hex[:10], kind, NAMES[kind], position.x(), position.y())
430
+ if kind == 'choice':
431
+ node.options = [Choice('First choice'), Choice('Second choice')]
432
+ self.project.nodes.append(node)
433
+ self.selected = node.id
434
+ self.refresh()
435
+
436
+ def delete_node(self):
437
+ if self.project.native:
438
+ self.error('Native blocks cannot be removed without invalidating existing PCs.'); return
439
+ if len(self.project.nodes) == 1:
440
+ return
441
+ self.remember()
442
+ self.project.nodes = [n for n in self.project.nodes if n.id != self.selected]
443
+ self.selected = self.project.nodes[0].id
444
+ self.refresh()
445
+
446
+ def combo(self, values, current, callback):
447
+ widget = QComboBox()
448
+ for label, value in values:
449
+ widget.addItem(label, value)
450
+ index = widget.findData(current)
451
+ if index < 0:
452
+ widget.addItem(f'Missing: {current}', current)
453
+ index = widget.count() - 1
454
+ widget.setCurrentIndex(index)
455
+ widget.currentIndexChanged.connect(lambda: callback(widget.currentData()))
456
+ return widget
457
+
458
+ def destination(self, current, callback):
459
+ return self.combo([('Choose a node…', '')] + [(n.label or n.id, n.id) for n in self.project.nodes],
460
+ current, callback)
461
+
462
+ def show_properties(self):
463
+ panel = QWidget()
464
+ form = QFormLayout(panel)
465
+ node = next((n for n in self.project.nodes if n.id == self.selected), None)
466
+ if node is None:
467
+ form.addRow(QLabel('Select a story node.'))
468
+ else:
469
+ heading = QLabel(NAMES[node.kind])
470
+ heading.setStyleSheet('font-size: 18px; font-weight: bold')
471
+ form.addRow(heading)
472
+ label = QLineEdit(node.label)
473
+ label.setObjectName('node_label')
474
+ label.textEdited.connect(lambda value: self.set_node('label', value))
475
+ form.addRow('Label', label)
476
+ if node.kind in ('dialogue', 'choice', 'input'):
477
+ speaker = QLineEdit(node.speaker)
478
+ speaker.setPlaceholderText('Empty for narration')
479
+ speaker.setObjectName('speaker')
480
+ speaker.textEdited.connect(lambda value: self.set_node('speaker', value))
481
+ speaker.setEnabled(node.kind != 'dialogue' or not node.character)
482
+ form.addRow('Speaker / title', speaker)
483
+ if node.kind in ('dialogue', 'choice', 'title', 'message', 'notice', 'scene_label', 'word_game'):
484
+ text = QTextEdit()
485
+ text.setPlainText(node.text)
486
+ text.setAcceptRichText(False)
487
+ text.setObjectName('story_text')
488
+ text.setMinimumHeight(120)
489
+ text.textChanged.connect(lambda: self.set_node('text', text.toPlainText()))
490
+ form.addRow('Text', text)
491
+ if node.kind == 'background' or node.kind == 'dialogue' and not node.character:
492
+ key = 'portrait' if node.kind == 'dialogue' else 'asset'
493
+ images = [('None', None)] + [(f'{a.id} · {a.name}', a.id) for a in self.project.assets]
494
+ form.addRow('Portrait' if key == 'portrait' else 'Background', self.combo(
495
+ images, getattr(node, key), lambda value: self.set_node(key, value)))
496
+ if node.kind in ('dialogue', 'choice'):
497
+ color = self.combo([('Gray', 0), ('Pink', 1), ('Blue', 2), ('Gold', 3)],
498
+ node.theme, lambda value: self.set_node('theme', value))
499
+ color.setEnabled(not node.character and (node.kind == 'choice' or node.portrait is not None))
500
+ color.setToolTip('Portrait and choice color. Narration and dialogue without a portrait use the game\'s fixed style.')
501
+ form.addRow('Color', color)
502
+ if node.kind == 'choice':
503
+ for i, option in enumerate(node.options):
504
+ field = QLineEdit(option.text)
505
+ field.setObjectName(f'answer_{i}')
506
+ field.textEdited.connect(lambda text, i=i: self.set_option(i, 'text', text))
507
+ form.addRow(f'Answer {i + 1}', field)
508
+ condition = QLineEdit(option.condition)
509
+ condition.setObjectName(f'condition_{i}')
510
+ condition.setPlaceholderText('Optional: points >= 3')
511
+ condition.textEdited.connect(lambda value, i=i: self.set_option(i, 'condition', value))
512
+ form.addRow('Enabled when', condition)
513
+ form.addRow('Go to', self.destination(option.target, lambda target, i=i: self.set_option(i, 'target', target)))
514
+ buttons = QHBoxLayout()
515
+ for label, delta in (('Add answer', 1), ('Remove last', -1)):
516
+ button = QPushButton(label)
517
+ button.clicked.connect(lambda checked=False, delta=delta: self.resize_options(delta))
518
+ buttons.addWidget(button)
519
+ form.addRow(buttons)
520
+ timer = QSpinBox()
521
+ timer.setRange(0, 32767)
522
+ timer.setSuffix(' ms')
523
+ timer.setSpecialValueText('No timer')
524
+ timer.setValue(node.timeout_ms)
525
+ timer.valueChanged.connect(lambda value: self.set_node('timeout_ms', value))
526
+ form.addRow('Time limit', timer)
527
+ form.addRow('On expiry', self.combo([(f'Answer {i + 1}', i) for i in range(len(node.options))],
528
+ node.timeout_option, lambda value: self.set_node('timeout_option', value)))
529
+ if node.kind in ('set', 'branch'):
530
+ variable = QLineEdit(node.variable)
531
+ variable.setObjectName('variable')
532
+ variable.setPlaceholderText('e.g. helped_friend')
533
+ variable.textEdited.connect(lambda value: self.set_node('variable', value))
534
+ form.addRow('Variable', variable)
535
+ value = QSpinBox()
536
+ value.setRange(-32768, 32767)
537
+ value.setValue(node.value)
538
+ value.valueChanged.connect(lambda value: self.set_node('value', value))
539
+ if node.kind == 'branch':
540
+ form.addRow('Comparison', self.combo([(op, op) for op in OPERATORS], node.operator,
541
+ lambda value: self.set_node('operator', value)))
542
+ form.addRow('Value', value)
543
+ if node.kind not in ('choice', 'end', 'native'):
544
+ form.addRow('If true' if node.kind in ('branch', 'text_branch') else 'Next',
545
+ self.destination(node.next, lambda target: self.set_node('next', target)))
546
+ if node.kind in ('branch', 'text_branch'):
547
+ form.addRow('If false', self.destination(node.otherwise, lambda target: self.set_node('otherwise', target)))
548
+ if not self.project.native:
549
+ scene = QSpinBox(); scene.setRange(25001, 25999); scene.setValue(node.scene)
550
+ scene.valueChanged.connect(lambda value: self.set_node('scene', value)); form.addRow('Scene group', scene)
551
+ if node.kind in ('dialogue', 'choice', 'relationship', 'word_game'):
552
+ form.addRow('Character', self.combo([('Narrator / inline speaker', '')] +
553
+ [(c.name, c.id) for c in self.project.characters], node.character,
554
+ lambda value: self.set_node('character', value)))
555
+ if node.kind == 'dialogue':
556
+ form.addRow('Expression', self.combo([(str(i), i) for i in range(5)], node.expression,
557
+ lambda value: self.set_node('expression', value)))
558
+ if parameters(node):
559
+ settings = QPushButton('Edit ' + ('native instructions…' if node.kind == 'native' else 'settings…'))
560
+ settings.clicked.connect(self.node_settings); form.addRow(settings)
561
+ if node.kind == 'native':
562
+ data = QPushButton('Edit script data words…'); data.clicked.connect(self.native_data); form.addRow(data)
563
+ strings = QPushButton('Edit packed text…'); strings.clicked.connect(self.native_text); form.addRow(strings)
564
+ if node.kind in ('music', 'sound'):
565
+ audio = QPushButton('Listen to selected resource'); audio.clicked.connect(self.listen); form.addRow(audio)
566
+ stop = QPushButton('Stop listening'); stop.clicked.connect(self.stop_listening); form.addRow(stop)
567
+ start = QPushButton('Start episode here')
568
+ start.setEnabled(not self.project.native)
569
+ start.clicked.connect(lambda: self.set_project('start', self.selected))
570
+ form.addRow(start)
571
+ delete = QPushButton('Delete node')
572
+ delete.clicked.connect(self.delete_node)
573
+ form.addRow(delete)
574
+ old = self.inspector.takeWidget()
575
+ if old is not None:
576
+ old.deleteLater()
577
+ self.inspector.setWidget(panel)
578
+
579
+ def set_option(self, index, key, value):
580
+ option = self.project.node(self.selected).options[index]
581
+ if getattr(option, key) != value:
582
+ self.remember()
583
+ setattr(option, key, value)
584
+ self.changed()
585
+
586
+ def resize_options(self, delta):
587
+ node = self.project.node(self.selected)
588
+ if 2 <= len(node.options) + delta <= 8:
589
+ self.remember()
590
+ if delta > 0:
591
+ node.options.append(Choice())
592
+ else:
593
+ node.options.pop()
594
+ node.timeout_option = min(node.timeout_option, len(node.options) - 1)
595
+ self.refresh()
596
+
597
+ def undo(self):
598
+ if self.undo_history:
599
+ self.redo_history.append(deepcopy(self.project))
600
+ self.project = self.undo_history.pop()
601
+ self.dirty = True
602
+ self.refresh()
603
+
604
+ def redo(self):
605
+ if self.redo_history:
606
+ self.undo_history.append(deepcopy(self.project))
607
+ self.project = self.redo_history.pop()
608
+ self.dirty = True
609
+ self.refresh()
610
+
611
+ def add_image(self):
612
+ path, _ = QFileDialog.getOpenFileName(self, 'Add an image', '', 'Images (*.png *.jpg *.jpeg)')
613
+ if path:
614
+ try:
615
+ png = image_png(Path(path).read_bytes())
616
+ candidate = deepcopy(self.project)
617
+ candidate.add_image(Path(path).name, png)
618
+ self.remember()
619
+ self.project = candidate
620
+ self.refresh()
621
+ except (ValueError, OSError) as error:
622
+ self.error(error)
623
+
624
+ def remove_image(self):
625
+ row = self.assets.currentRow()
626
+ if row >= 0:
627
+ self.remember()
628
+ self.project.assets.pop(row)
629
+ self.refresh()
630
+
631
+ def choose_library(self):
632
+ path = QFileDialog.getExistingDirectory(self, 'Choose the game folder containing library.json', self.project.library)
633
+ if not path:
634
+ return
635
+ try:
636
+ from exp_runtime.content import ContentLibrary
637
+ with ContentLibrary(Path(path)) as library:
638
+ if library.game_id != self.project.game:
639
+ raise ProjectError('Choose a library for the selected game, or start a project for the other game')
640
+ self.set_project('library', str(Path(path).resolve()))
641
+ self.refresh()
642
+ except (ContentError, OSError) as error:
643
+ self.error(error)
644
+
645
+ def browse_library(self):
646
+ if not self.project.library:
647
+ self.choose_library()
648
+ if not self.project.library:
649
+ return
650
+ try:
651
+ from exp_runtime.content import ContentLibrary
652
+ with ContentLibrary(Path(self.project.library)) as library:
653
+ browser = LibraryBrowser(library, self)
654
+ if browser.exec() == QDialog.DialogCode.Accepted:
655
+ candidate = deepcopy(self.project)
656
+ import_library_image(candidate, library, browser.resource, browser.episode)
657
+ self.remember()
658
+ self.project = candidate
659
+ self.refresh()
660
+ except (ContentError, OSError) as error:
661
+ self.error(error)
662
+
663
+ def confirm_saved(self):
664
+ if not self.dirty:
665
+ return True
666
+ result = QMessageBox.question(self, 'Unsaved episode', 'Save your project before closing it?',
667
+ QMessageBox.StandardButton.Save | QMessageBox.StandardButton.Discard | QMessageBox.StandardButton.Cancel)
668
+ return self.save() if result == QMessageBox.StandardButton.Save else result == QMessageBox.StandardButton.Discard
669
+
670
+ def replace_project(self, project, path=None):
671
+ path = Path(path) if path else None
672
+ previous = self.live_editor.lease.path if self.live_editor.lease else None
673
+ self.live_editor.attach(path)
674
+ try:
675
+ if path:
676
+ with guard(path): revision = digest(path.read_bytes()) if path.exists() else None
677
+ else: revision = None
678
+ except (ContentError, OSError):
679
+ self.live_editor.attach(previous)
680
+ raise
681
+ self.recovery.remove()
682
+ self.project, self.path = project, path
683
+ self.saved_path = path.resolve() if path else None
684
+ self.disk_revision = revision
685
+ self.use_player_library()
686
+ self.selected = project.start
687
+ self.undo_history, self.redo_history = [], []
688
+ self.dirty = False
689
+ self.refresh()
690
+
691
+ def new(self):
692
+ if self.confirm_saved():
693
+ self.replace_project(Project())
694
+
695
+ def open(self):
696
+ if not self.confirm_saved():
697
+ return
698
+ path, _ = QFileDialog.getOpenFileName(self, 'Open an episode project', '', 'Episode projects (*.expproj)')
699
+ if path:
700
+ try:
701
+ # Claim the live document before reading; MCP writes now go
702
+ # through this window rather than racing the file load.
703
+ previous = self.live_editor.lease.path if self.live_editor.lease else None
704
+ self.live_editor.attach(path)
705
+ try:
706
+ with guard(path): data = Path(path).read_bytes()
707
+ self.replace_project(project_from_bytes(data, path), path)
708
+ self.disk_revision = digest(data)
709
+ except (ContentError, OSError):
710
+ self.live_editor.attach(previous)
711
+ raise
712
+ except (ContentError, OSError) as error:
713
+ self.error(error)
714
+
715
+ def save(self):
716
+ if self.path is None:
717
+ return self.save_as()
718
+ previous = self.live_editor.lease.path if self.live_editor.lease else None
719
+ try:
720
+ self.live_editor.attach(self.path)
721
+ with guard(self.path):
722
+ info = session_info(self.path)
723
+ if info is None or info['token'] != self.live_editor.lease.record['token']:
724
+ raise SharingError('Desktop ownership changed; reopen the project before saving')
725
+ actual = digest(self.path.read_bytes()) if self.path.exists() else None
726
+ if self.saved_path == self.path.resolve() and actual != self.disk_revision:
727
+ raise ProjectError('Project changed on disk; use Save as to keep your edits, or reopen the changed file.')
728
+ save_project(self.project, self.path)
729
+ self.disk_revision = digest(self.path.read_bytes())
730
+ self.saved_path = self.path.resolve()
731
+ self.dirty = False
732
+ self.recovery.remove()
733
+ self.changed()
734
+ self.statusBar().showMessage(f'Saved {self.path.name}')
735
+ return True
736
+ except (ContentError, OSError) as error:
737
+ self.live_editor.attach(previous)
738
+ self.error(error)
739
+ return False
740
+
741
+ def save_as(self):
742
+ path = self.save_destination('Save editable episode project',
743
+ self.path or Path('episode.expproj'), '.expproj', 'Episode projects')
744
+ if not path:
745
+ return False
746
+ previous = self.path
747
+ self.path = path
748
+ if self.save():
749
+ return True
750
+ self.path = previous
751
+ return False
752
+
753
+ def save_destination(self, title, initial, suffix, label):
754
+ path, _ = QFileDialog.getSaveFileName(self, title, str(initial), f'{label} (*{suffix})')
755
+ if not path:
756
+ return None
757
+ destination = Path(path if Path(path).suffix.lower() == suffix else path + suffix)
758
+ # The native dialog only confirmed the name it returned. If we add an
759
+ # extension, confirm any existing file at that final destination too.
760
+ if destination != Path(path) and destination.exists():
761
+ if QMessageBox.question(self, title, f'{destination.name} already exists. Replace it?',
762
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
763
+ QMessageBox.StandardButton.No) != QMessageBox.StandardButton.Yes:
764
+ return None
765
+ return destination
766
+
767
+ def validate(self):
768
+ try:
769
+ data = compile_project(self.project)
770
+ self.statusBar().showMessage(f'Ready to export: {len(self.project.nodes)} nodes, {len(data):,} bytes')
771
+ return True
772
+ except ContentError as error:
773
+ self.error(error)
774
+ return False
775
+
776
+ def export(self):
777
+ if not self.validate():
778
+ return
779
+ destination = self.save_destination('Export native EXP episode', 'episode.exp', '.exp', 'EXP episodes')
780
+ if destination:
781
+ try:
782
+ export_project(self.project, destination)
783
+ self.statusBar().showMessage(f'Exported {destination.name} for {self.project.game.upper()} in EXP Runtime')
784
+ except (ContentError, OSError) as error:
785
+ self.error(error)
786
+
787
+ def preview(self):
788
+ if self.preview_process is not None:
789
+ self.statusBar().showMessage('Close the existing preview window to start a new preview.')
790
+ return
791
+ if not self.project.library:
792
+ self.choose_library()
793
+ if not self.project.library or not self.validate():
794
+ return
795
+ try:
796
+ self.preview_directory = tempfile.TemporaryDirectory(prefix='exp-creator-')
797
+ path = Path(self.preview_directory.name) / 'preview.exp'
798
+ export_project(self.project, path) if not self.preview_selected.isChecked() else path.write_bytes(
799
+ compile_project(self.project, start=self.selected))
800
+ process = QProcess(self)
801
+ self.preview_process = process
802
+ process.setProgram(sys.executable)
803
+ process.setArguments(([] if getattr(sys, 'frozen', False) else ['-m', 'exp_runtime.creator']) + ['--preview', str(path),
804
+ '--library', self.project.library, '--game', self.project.game])
805
+ process.setProcessChannelMode(QProcess.ProcessChannelMode.MergedChannels)
806
+ self.preview_output = ''
807
+ process.readyReadStandardOutput.connect(self.read_preview_output)
808
+ process.finished.connect(self.preview_finished)
809
+ process.errorOccurred.connect(self.preview_error)
810
+ process.start()
811
+ except (ContentError, OSError) as error:
812
+ self.cleanup_preview()
813
+ self.error(error)
814
+
815
+ def export_original(self):
816
+ if not self.validate():
817
+ return
818
+ reference, _ = QFileDialog.getOpenFileName(self,
819
+ 'Choose an installed EXP from the matching original game to use its catalog identity', '', 'EXP episodes (*.exp)')
820
+ if not reference:
821
+ return
822
+ destination = self.save_destination('Save a separate replacement EXP',
823
+ Path(reference).name, '.exp', 'EXP episodes')
824
+ if destination:
825
+ try:
826
+ metadata = export_native_slot(self.project, Path(reference), destination)
827
+ QMessageBox.information(self, 'Original app export',
828
+ f'Exported pack {metadata.pack_id}, episode {metadata.episode_id}.\n\n'
829
+ f'Install the replacement under the original catalog filename: {Path(reference).name}\n'
830
+ 'Use a copy of your original game data and start a new playthrough. '
831
+ 'The original apps do not have an EXP file picker. See docs/CREATOR.md for installation and verification limits.')
832
+ except (ContentError, OSError) as error:
833
+ self.error(error)
834
+
835
+ def read_preview_output(self):
836
+ if self.preview_process is not None:
837
+ self.preview_output = (self.preview_output + bytes(self.preview_process.readAllStandardOutput()).decode(
838
+ 'utf-8', errors='replace'))[-12000:]
839
+
840
+ def preview_error(self, error):
841
+ if error == QProcess.ProcessError.FailedToStart:
842
+ message = self.preview_process.errorString()
843
+ self.cleanup_preview()
844
+ self.error(message)
845
+
846
+ def preview_finished(self, code, status):
847
+ self.read_preview_output()
848
+ output = self.preview_output
849
+ self.cleanup_preview()
850
+ if code:
851
+ self.error(output or 'The preview player could not start')
852
+
853
+ def cleanup_preview(self):
854
+ if self.preview_process is not None:
855
+ self.preview_process.deleteLater()
856
+ self.preview_process = None
857
+ if self.preview_directory is not None:
858
+ self.preview_directory.cleanup()
859
+ self.preview_directory = None
860
+
861
+ def autosave(self):
862
+ if self.dirty:
863
+ try: self.recovery.write(self.project, self.path)
864
+ except (ContentError, OSError) as error: self.statusBar().showMessage(f'Autosave failed: {error}')
865
+
866
+ def focus_node(self, key):
867
+ if key in self.graph.cards:
868
+ self.graph.clearSelection(); self.graph.cards[key].setSelected(True)
869
+ self.view.centerOn(self.graph.cards[key])
870
+
871
+ def search(self, query):
872
+ matches = [n.id for n in self.project.nodes if query.casefold() in
873
+ (n.id + ' ' + n.label + ' ' + n.speaker + ' ' + n.text).casefold()]
874
+ if matches:
875
+ index = (matches.index(self.selected) + 1) % len(matches) if self.selected in matches else 0
876
+ self.focus_node(matches[index])
877
+ else: self.statusBar().showMessage('No matching nodes')
878
+
879
+ def arrange(self):
880
+ try:
881
+ candidate = deepcopy(self.project); auto_layout(candidate)
882
+ self.remember(); self.project = candidate; self.refresh()
883
+ except ProjectError as error: self.error(error)
884
+
885
+ def copy(self):
886
+ try:
887
+ ids = {item.node.id for item in self.graph.selectedItems() if isinstance(item, NodeItem)} or {self.selected}
888
+ QApplication.clipboard().setText(copy_nodes(self.project, ids))
889
+ except ProjectError as error: self.error(error)
890
+
891
+ def paste(self):
892
+ try:
893
+ candidate = deepcopy(self.project)
894
+ ids = paste_nodes(candidate, QApplication.clipboard().text())
895
+ self.remember(); self.project = candidate; self.selected = ids[0]; self.refresh()
896
+ except ProjectError as error: self.error(error)
897
+
898
+ def duplicate(self):
899
+ if not self.project.native: self.copy(); self.paste()
900
+
901
+ def characters(self):
902
+ if self.project.native:
903
+ self.error('Original character definitions remain in their native instructions.'); return
904
+ dialog = ListDialog([asdict(c) for c in self.project.characters], self, 'Characters',
905
+ template=asdict(Character(uuid.uuid4().hex[:10])))
906
+ if dialog.exec() == QDialog.DialogCode.Accepted:
907
+ try:
908
+ candidate = deepcopy(self.project); candidate.characters = [Character(**r) for r in dialog.value]
909
+ validate_structure(candidate)
910
+ self.remember(); self.project = candidate; self.refresh()
911
+ except (ValueError, TypeError) as error: self.error(error)
912
+
913
+ def translations(self):
914
+ dialog = TranslationsDialog(self.project, self)
915
+ if dialog.exec() == QDialog.DialogCode.Accepted:
916
+ self.remember(); self.project.locales = dialog.locales
917
+ self.project.language = dialog.current; self.refresh()
918
+
919
+ def node_settings(self):
920
+ node = self.project.node(self.selected)
921
+ dialog = RecordDialog(parameters(node), self, NAMES[node.kind] + ' settings')
922
+ if dialog.exec() == QDialog.DialogCode.Accepted: self.set_node('params', dialog.value)
923
+
924
+ def native_text(self):
925
+ from .native import packed_strings, edit_packed_text
926
+ from PySide6.QtWidgets import QInputDialog
927
+ script = self.project.node(self.selected).params['script']
928
+ candidates = packed_strings(self.project, script)
929
+ labels = [f'{item["address"]}: {item["text"][:100]}' for item in candidates]
930
+ if not labels:
931
+ self.error('No printable packed strings found in this main data bank.'); return
932
+ label, accepted = QInputDialog.getItem(self, 'Packed text candidates',
933
+ 'Confirm the word address used by the instruction before editing.', labels, editable=False)
934
+ if not accepted: return
935
+ selected = candidates[labels.index(label)]
936
+ text, accepted = QInputDialog.getMultiLineText(self, 'Replace packed text',
937
+ 'The replacement must fit the original allocation; native addresses stay fixed.', selected['text'])
938
+ if accepted:
939
+ try:
940
+ candidate = deepcopy(self.project)
941
+ edit_packed_text(candidate, script, selected['address'], text)
942
+ self.remember(); self.project = candidate; self.refresh()
943
+ except ValueError as error: self.error(error)
944
+
945
+ def native_data(self):
946
+ from .native import _resources, set_data_words
947
+ from exp_runtime.decode.bytecode import decode_program
948
+ script = self.project.node(self.selected).params['script']
949
+ program = decode_program(_resources(self.project)[script])
950
+ dialog = RecordDialog({'main': list(program.main_words), 'extra': list(program.extra_words)}, self, 'Native data words')
951
+ if dialog.exec() == QDialog.DialogCode.Accepted:
952
+ try:
953
+ candidate = deepcopy(self.project)
954
+ set_data_words(candidate, script, dialog.value['main'])
955
+ set_data_words(candidate, script, dialog.value['extra'], extra=True)
956
+ self.remember(); self.project = candidate; self.refresh()
957
+ except ValueError as error: self.error(error)
958
+
959
+ def import_exp(self):
960
+ if not self.confirm_saved(): return
961
+ path, _ = QFileDialog.getOpenFileName(self, 'Import an EXP as an editable project', '', 'EXP episodes (*.exp)')
962
+ if path:
963
+ from .native import import_exp
964
+ try:
965
+ project = import_exp(Path(path).read_bytes(), game=self.project.game)
966
+ self.replace_project(project); self.dirty = True; self.changed()
967
+ self.statusBar().showMessage('Imported native blocks. Instruction addresses are fixed.' if project.native
968
+ else 'Restored the editable story graph from EXP.')
969
+ except (ContentError, OSError) as error: self.error(error)
970
+
971
+ def debug(self):
972
+ try:
973
+ dialog = DebugDialog(self.project, self, self.focus_node,
974
+ self.selected if self.preview_selected.isChecked() else None)
975
+ dialog.exec()
976
+ except (ValueError, OSError) as error: self.error(error)
977
+
978
+ def restart_preview(self):
979
+ if not self.live.isChecked() or self.preview_process is None: return
980
+ self.preview_process.blockSignals(True); self.preview_process.terminate()
981
+ if not self.preview_process.waitForFinished(1500):
982
+ self.preview_process.kill(); self.preview_process.waitForFinished(1500)
983
+ self.cleanup_preview(); self.preview()
984
+
985
+ def listen(self):
986
+ from io import BytesIO
987
+ from exp_runtime.content import ContentLibrary
988
+ from exp_runtime.audio import music_cue
989
+ import pygame
990
+ if not self.project.library: self.choose_library()
991
+ if not self.project.library: return
992
+ try:
993
+ node = self.project.node(self.selected); resource = parameters(node)['resource']
994
+ cue = music_cue(resource, game=self.project.game)
995
+ with ContentLibrary(Path(self.project.library)) as library:
996
+ data = library.read_asset(cue.asset_id if node.kind == 'music' else resource)
997
+ pygame.mixer.init()
998
+ if node.kind == 'music':
999
+ pygame.mixer.music.load(BytesIO(data)); pygame.mixer.music.play(start=cue.start_ms / 1000)
1000
+ else: pygame.mixer.Sound(BytesIO(data)).play()
1001
+ except (ContentError, OSError, pygame.error) as error: self.error(error)
1002
+
1003
+ def stop_listening(self):
1004
+ if 'pygame' in sys.modules:
1005
+ import pygame
1006
+ if pygame.mixer.get_init(): pygame.mixer.stop(); pygame.mixer.music.stop()
1007
+
1008
+ def closeEvent(self, event):
1009
+ if not self.confirm_saved():
1010
+ event.ignore()
1011
+ return
1012
+ if self.preview_process is not None:
1013
+ self.preview_process.blockSignals(True)
1014
+ self.preview_process.terminate()
1015
+ if not self.preview_process.waitForFinished(2000):
1016
+ self.preview_process.kill()
1017
+ self.preview_process.waitForFinished(2000)
1018
+ self.cleanup_preview()
1019
+ self.recovery.remove()
1020
+ self.stop_listening()
1021
+ self.live_editor.close()
1022
+ # QGraphicsScene emits selectionChanged while destroying selected
1023
+ # items. The scene's C++ object may already be gone at that point.
1024
+ self.graph.blockSignals(True)
1025
+ event.accept()
1026
+
1027
+
1028
+ def launch(path=None):
1029
+ app = QApplication.instance() or QApplication(sys.argv[:1])
1030
+ app.setApplicationName('EXP Runtime Creator')
1031
+ window = Editor(path=path)
1032
+ app.setWindowIcon(window.windowIcon())
1033
+ window.show()
1034
+ for path in sorted(window.recovery.path.parent.glob('*.recovery.json')):
1035
+ try:
1036
+ project, source = Recovery.read(path)
1037
+ if QMessageBox.question(window, 'Recover unsaved work', f'Recover "{project.title}"?') == QMessageBox.StandardButton.Yes:
1038
+ window.replace_project(project, source); window.dirty = True
1039
+ window.recovery.path = path; window.changed(); break
1040
+ except (ContentError, OSError) as error: window.statusBar().showMessage(str(error))
1041
+ app.exec()