Skip to content

Commit 67abf52

Browse files
sp00ktoberclaude
andcommitted
feat(studio): reorder a trigger's rules by their frames, and play a move in the game from the rows
Dragging a rule's frame up or down gives the rule the place the drop is at among its trigger's rules. The places of the rules' first blocks are handed round, so every other rule keeps its place in the whole order; a rule's own blocks move with its first, a move node never; one undo step. A drop that would not change the rank is refused and the status says why, and a rule with no move yet has no place to move. A reference, the move a while-playing trigger starts from and the bar carry a Play in game entry that goes through the workspace's existing action, so it says so when no game is there. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1 parent edbf02a commit 67abf52

4 files changed

Lines changed: 251 additions & 17 deletions

File tree

‎apps/studio/src/mhfu_studio/monster/panels/behaviour.py‎

Lines changed: 58 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
from mhfu_studio.monster.panels import rows, triggers
2929
from mhfu_studio.monster.panels.common import kind
3030
from mhfu_studio.monster.panels.node_graph import GraphView, LinkSpec, NodeSpec
31-
from mhfu_studio.monster.panels.properties import Detail, Plays, PropertyBar
31+
from mhfu_studio.monster.panels.properties import Detail, Play, Plays, PropertyBar
3232
from mhfu_studio.monster.panels.widgets import NoScene
3333
from mhfu_studio.shell.overlay import Ink
3434
from mhfu_studio.ui import kit, theme
@@ -54,6 +54,7 @@
5454
" ports in All"
5555
)
5656
SCOPED = "Wire by dragging ports in All"
57+
UNPLACED = "A rule with no move yet is checked by nothing, so it has no place to move"
5758

5859

5960
class BehaviourPanel(kit.Panel):
@@ -78,6 +79,7 @@ def __init__(self, ws: MonsterWorkspace, studio: Studio) -> None:
7879
#: the rows now drawn (None: All), and what to pick once the next edit is drawn
7980
self._plan: rows.Plan | None = None
8081
self._after: tuple[str, str | None] | None = None
82+
self._frame_after: str | None = None
8183
self._scope: str | None = None
8284
self._outlined: object = None
8385
self._palette = ALL
@@ -96,7 +98,6 @@ def __init__(self, ws: MonsterWorkspace, studio: Studio) -> None:
9698
tip="Adds a rule for this trigger. It is checked after every other rule: drag its"
9799
" frame up to raise it",
98100
on=self._add_rule,
99-
icon="ph.plus",
100101
)
101102
self.spread = kit.button(
102103
"Spread out",
@@ -116,10 +117,12 @@ def __init__(self, ws: MonsterWorkspace, studio: Studio) -> None:
116117
v.picked.connect(self._picked)
117118
v.settled.connect(self._settle)
118119
v.menu_requested.connect(self._menu)
120+
v.frame_moved.connect(self._frame_moved)
119121
self.bar = PropertyBar()
120122
self.bar.param_changed.connect(self._param)
121123
self.bar.label_changed.connect(self._label)
122124
self.bar.plays_changed.connect(self._plays)
125+
self.bar.play_requested.connect(self._play)
123126

124127
self.outline = triggers.Outline()
125128
self.outline.picked.connect(self._scoped)
@@ -177,6 +180,16 @@ def _plays(self, block: str, old: str | None, new: str | None) -> None:
177180
self._after = (block, new)
178181
self._settle()
179182

183+
def _play(self, move: str) -> None:
184+
"""Play in game, as the Moves panel does it, for `move`."""
185+
186+
def play() -> None:
187+
if self.ws.move != move:
188+
self.ws.select_move(move)
189+
self.ws.play_move_in_game()
190+
191+
self.studio.act("play move in game", play)()
192+
180193
def _add_rule(self) -> None:
181194
t, doc = self._current(), self.ws.doc
182195
if t is None or doc is None:
@@ -196,6 +209,9 @@ def _menu(self, node: str, menu: QMenu) -> None:
196209
row = plan.row_of(node) if plan is not None else None
197210
menu.setToolTipsVisible(True)
198211
if m is None or row is None:
212+
move = graph.move_name(node)
213+
if move is not None and plan is not None:
214+
self._play_entry(menu, move)
199215
return
200216
ref = rows.ref_of(node)
201217
if ref is not None: # a reference or the placeholder: before the play
@@ -205,6 +221,7 @@ def _menu(self, node: str, menu: QMenu) -> None:
205221
menu.addAction("Delete the play").triggered.connect(
206222
lambda: self._drop_play(block, move)
207223
)
224+
self._play_entry(menu, move)
208225
return
209226
shown = [n.id for n in row.nodes]
210227
k, block = shown.index(node), row.path.blocks[shown.index(node)]
@@ -227,6 +244,11 @@ def _insert_menu(
227244
act.setToolTip(tip)
228245
act.triggered.connect(lambda _=False, k=kind_: self._insert(after, k, to, play))
229246

247+
def _play_entry(self, menu: QMenu, move: str) -> None:
248+
act = menu.addAction("Play in game")
249+
act.setToolTip("Plays this move in the running game, as Play in game does in Moves")
250+
act.triggered.connect(lambda: self._play(move))
251+
230252
def _insert(self, after: str, kind_: str, to: str | None, play: str | None) -> None:
231253
doc = self.ws.doc
232254
if doc is None:
@@ -255,11 +277,7 @@ def _scope_all(self) -> bool:
255277

256278
def _wire(self, edit: Callable[..., str], *wire: str) -> Callable[[graph.Edits], str]:
257279
"""The edit of a wire intent, or the refusal a trigger's rows give for it."""
258-
return (lambda e: edit(e, *wire)) if self._scope_all() else self._not_here
259-
260-
@staticmethod
261-
def _not_here(_: graph.Edits) -> str:
262-
raise ManifestError(SCOPED)
280+
return (lambda e: edit(e, *wire)) if self._scope_all() else self._refuse(SCOPED)
263281

264282
def _delete(self, ids: list[str]) -> None:
265283
plan = self._plan
@@ -288,6 +306,31 @@ def _delete(self, ids: list[str]) -> None:
288306
else:
289307
self._collect(partial(graph.drop_block, block=rows.node_of(i)))
290308

309+
def _frame_moved(self, frame: str, x: float, y: float) -> None:
310+
"""A rule's frame dropped: the rule takes the place among its trigger's rules that the
311+
drop is at, or the drop is refused and the frame goes back."""
312+
plan = self._plan
313+
row = next((r for r in plan.rows if r.id == frame), None) if plan is not None else None
314+
if plan is None or row is None:
315+
return
316+
if row.rank is None:
317+
self._collect(self._refuse(UNPLACED))
318+
return
319+
ranked = [r for r in plan.rows if r.rank is not None]
320+
rects = self.view.frame_rects()
321+
middle = rects[frame].center().y()
322+
to = sum(1 for r in ranked if r is not row and rects[r.id].center().y() < middle)
323+
paths = [r.path for r in ranked]
324+
self._frame_after = f"{rows.FRAME}{ranked[to].rank}" # the places are handed round
325+
self._collect(lambda e: graph.move_rule(e, paths, ranked.index(row), to))
326+
327+
@staticmethod
328+
def _refuse(why: str) -> Callable[[graph.Edits], str]:
329+
def refuse(_: graph.Edits) -> str:
330+
raise ManifestError(why)
331+
332+
return refuse
333+
291334
def _delete_now(self, ids: list[str]) -> None:
292335
self._delete(ids)
293336
self._settle()
@@ -301,7 +344,7 @@ def _create(self, kind_: str, x: float, y: float) -> None:
301344
if doc is None:
302345
return
303346
if not self._scope_all():
304-
self._collect(self._not_here)
347+
self._collect(self._refuse(SCOPED))
305348
return
306349
self._made = model.new_id(doc.manifest.behaviour)
307350
self._collect(lambda e: graph.add_block(e, kind_, (x, y)))
@@ -311,14 +354,15 @@ def _settle(self) -> None:
311354
doc, gesture, dropped = self.ws.doc, self._gesture, self._dropped
312355
made, self._gesture, self._dropped, self._made = self._made, None, {}, None
313356
after, self._after = self._after, None
357+
frame, self._frame_after = self._frame_after, None
314358
if doc is None:
315359
return
316360
if dropped:
317361
gesture = gesture or graph.Gesture(doc)
318362
gesture.run(lambda e: graph.move_nodes(e, dropped))
319363
if gesture is None:
320364
return
321-
done = gesture
365+
done, was = gesture, doc.manifest
322366

323367
def edit() -> None:
324368
self.ws.edit("", done.commit)
@@ -329,6 +373,9 @@ def edit() -> None:
329373
if made is not None and m is not None and made in m.behaviour.blocks:
330374
self.view.select([made])
331375
self._show_bar()
376+
elif frame is not None and m is not was and frame in self.view.frame_rects():
377+
self.view.select([frame])
378+
self._show_bar()
332379
elif after is not None and self._plan is not None:
333380
ends = [n.id for r in self._plan.rows for n in r.nodes if rows.ref_of(n.id) == after]
334381
if ends:
@@ -418,7 +465,7 @@ def _detail(self, m: Manifest, shown: str) -> Detail | None:
418465
mv = m.moves.get(name)
419466
if mv is None:
420467
return None
421-
return Detail(node, name, self._move_tip(mv), label=mv.label)
468+
return Detail(node, name, self._move_tip(mv), label=mv.label, play=Play(name))
422469
blk = m.behaviour.blocks.get(node)
423470
if blk is None or self._read is None:
424471
return None
@@ -452,6 +499,7 @@ def _ref_detail(self, m: Manifest, shown: str, block: str, move: str | None) ->
452499
label=mv.label,
453500
note_tip="A note on this move, kept in the port manifest",
454501
plays=Plays(block, move, tuple(m.moves)),
502+
play=Play(move),
455503
)
456504

457505
@staticmethod

‎apps/studio/src/mhfu_studio/monster/panels/node_graph.py‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -402,13 +402,15 @@ def set_palette(self, groups: Sequence[PaletteGroup]) -> None:
402402
act.triggered.connect(lambda _=False, k=kind: self._create(k))
403403

404404
def select(self, ids: Sequence[str]) -> None:
405-
"""Selects exactly these nodes, without a `picked`."""
405+
"""Selects exactly these nodes and frames, without a `picked`."""
406406
self._busy += 1
407407
try:
408408
self.viewer.scene().clearSelection()
409409
for i in ids:
410410
if i in self._shown:
411411
self._shown[i].view.setSelected(True)
412+
elif i in self._backdrops:
413+
self._backdrops[i].view.setSelected(True)
412414
finally:
413415
self._busy -= 1
414416
self._pick = self._picked()
@@ -418,6 +420,10 @@ def selected(self) -> list[str]:
418420
picked = {n.id for n in self.viewer.selected_nodes()}
419421
return [n.id for n in self._nodes if n.id in picked]
420422

423+
def frame_rects(self) -> dict[str, QRectF]:
424+
"""Where each frame is now, in scene units."""
425+
return {i: bd.view.sceneBoundingRect() for i, bd in self._backdrops.items()}
426+
421427
def selected_frames(self) -> list[str]:
422428
"""The selected frames' ids, in the order of the last `show`. Clicking a frame selects
423429
the nodes it holds as well."""

‎apps/studio/src/mhfu_studio/monster/panels/properties.py‎

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
QHBoxLayout,
2222
QLineEdit,
2323
QMenu,
24+
QPushButton,
2425
QSpinBox,
2526
QToolButton,
2627
QWidget,
@@ -195,11 +196,18 @@ class Plays:
195196
none: str = "nothing"
196197

197198

199+
@dataclass(frozen=True)
200+
class Play:
201+
"""A Play in game button for `move`."""
202+
203+
move: str
204+
205+
198206
@dataclass(frozen=True)
199207
class Detail:
200208
"""What the bar shows of one node: its kind, params with their `values` and `options` (a
201209
combo's or a multi-select's (label, value) list, else the param's `choices`), a `plays`
202-
picker and its note. A node with no params and no `note` shows only
210+
picker, its note and a Play in game button. A node with no params and no `note` shows only
203211
its title and tip."""
204212

205213
id: str
@@ -212,6 +220,7 @@ class Detail:
212220
note: bool = True
213221
note_tip: str = "A note on this block, kept in the port manifest"
214222
plays: Plays | None = None
223+
play: Play | None = None
215224

216225

217226
class PropertyBar(QWidget):
@@ -221,6 +230,7 @@ class PropertyBar(QWidget):
221230
label_changed = Signal(str, str)
222231
#: the block, the move it played (None: none yet), the move it plays now (None: none)
223232
plays_changed = Signal(str, object, object)
233+
play_requested = Signal(str)
224234

225235
def __init__(self) -> None:
226236
super().__init__()
@@ -236,6 +246,7 @@ def __init__(self) -> None:
236246
self.controls: dict[str, ParamControl] = {}
237247
self.note: ParamControl | None = None
238248
self.plays: ParamControl | None = None
249+
self.play: QPushButton | None = None
239250
self._owned: list[QWidget] = []
240251

241252
def show_detail(self, d: Detail | None) -> None:
@@ -250,6 +261,7 @@ def show_detail(self, d: Detail | None) -> None:
250261
d.note,
251262
tuple((p.name, p.type) for p in d.params),
252263
None if d.plays is None else d.plays.moves,
264+
d.play,
253265
)
254266
if shape != self._shape:
255267
self._clear()
@@ -289,7 +301,7 @@ def _clear(self) -> None:
289301
]:
290302
c.deleteLater()
291303
self._owned, self.controls, self.note, self._shape = [], {}, None, None
292-
self.plays = None
304+
self.plays, self.play = None, None
293305

294306
def _add(self, w: QWidget, stretch: int = 0) -> None:
295307
self._lay.addWidget(w, stretch)
@@ -318,6 +330,15 @@ def _build(self, d: Detail) -> None:
318330
self._add(note.ctl, 1)
319331
else:
320332
self._add(kit.label(d.tip.split("\n")[0], role="muted", wrap=False), 1)
333+
if d.play is not None:
334+
move = d.play.move
335+
self.play = kit.button(
336+
"Play",
337+
tip=f"Plays {move} in the running game, as Play in game does in Moves",
338+
on=lambda: self.play_requested.emit(move),
339+
icon="ph.play",
340+
)
341+
self._add(self.play)
321342

322343
def _build_plays(self, plays: Plays) -> None:
323344
self._plays = plays

0 commit comments

Comments
 (0)