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.
- exp_runtime/creator/__init__.py +8 -0
- exp_runtime/creator/__main__.py +69 -0
- exp_runtime/creator/assets.py +56 -0
- exp_runtime/creator/catalog.py +30 -0
- exp_runtime/creator/compiler.py +448 -0
- exp_runtime/creator/debugger.py +123 -0
- exp_runtime/creator/diagnostics.py +86 -0
- exp_runtime/creator/editing.py +80 -0
- exp_runtime/creator/expressions.py +81 -0
- exp_runtime/creator/features.py +90 -0
- exp_runtime/creator/graph.py +124 -0
- exp_runtime/creator/kiwi-hammer.svg +73 -0
- exp_runtime/creator/live.py +116 -0
- exp_runtime/creator/mcp_results.py +53 -0
- exp_runtime/creator/mcp_server.py +428 -0
- exp_runtime/creator/mcp_types.py +49 -0
- exp_runtime/creator/media.py +144 -0
- exp_runtime/creator/minigames.py +149 -0
- exp_runtime/creator/model.py +377 -0
- exp_runtime/creator/native.py +174 -0
- exp_runtime/creator/operations.py +83 -0
- exp_runtime/creator/panels.py +221 -0
- exp_runtime/creator/playtest.py +185 -0
- exp_runtime/creator/preview.py +30 -0
- exp_runtime/creator/schema.py +102 -0
- exp_runtime/creator/sharing.py +169 -0
- exp_runtime/creator/ui.py +1041 -0
- exp_runtime/creator/workspace.py +271 -0
- exp_runtime_creator-0.6.0.dist-info/METADATA +75 -0
- exp_runtime_creator-0.6.0.dist-info/RECORD +34 -0
- exp_runtime_creator-0.6.0.dist-info/WHEEL +5 -0
- exp_runtime_creator-0.6.0.dist-info/entry_points.txt +5 -0
- exp_runtime_creator-0.6.0.dist-info/licenses/LICENSE +21 -0
- exp_runtime_creator-0.6.0.dist-info/top_level.txt +1 -0
|
@@ -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()
|