Skip to content

Commit 7d81137

Browse files
sp00ktoberclaude
andcommitted
feat(studio): sequences in the Moves, Timeline and Clips docks
Moves groups its table by sequence and shows the picked move's chain as chips, with Add step after (a clip picker ranked by fit), Change clip, Remove, Earlier, Later, Split and Play sequence. The Timeline gets a bar of the steps; the workspace plays them back to back. Clips can be ordered by fit after the clip on screen and lists which moves use each clip. sequences.py holds the document edits (one undo step each, a pair move is never edited) and the picker's rows. mhfu_port.sequence gains groups, and continuity.fits_after can include the clip itself. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1 parent 236fef1 commit 7d81137

13 files changed

Lines changed: 1369 additions & 28 deletions

File tree

‎apps/studio/src/mhfu_studio/monster/clip_browser.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
from mhp_formats import anim
2222
from mhp_formats.anim import AnimPack
2323

24-
from mhfu_studio.monster import clips
24+
from mhfu_studio.monster import clips, sequences
2525
from mhfu_studio.monster.document import PortDocument
2626

2727

@@ -36,6 +36,8 @@ class SourceClip:
3636
loop: bool
3737
name: str = ""
3838
label: str = ""
39+
used: tuple[str, ...] = ()
40+
"""The moves that play it."""
3941

4042
@property
4143
def stream(self) -> int | None:
@@ -80,7 +82,8 @@ def rows(self, others: Iterable[SourceClip] = ()) -> list[SourceClip]:
8082
for cid in sorted(self.donor):
8183
frames, loop = self.prints[cid]
8284
name, label = named.get(cid, ("", ""))
83-
out.append(SourceClip(cid, ids.get(cid), frames, loop, name, label))
85+
used = tuple(sequences.used_in(m, name)) if name else ()
86+
out.append(SourceClip(cid, ids.get(cid), frames, loop, name, label, used))
8487
return [*out, *others]
8588

8689
def name(self, cid: int, name: str, label: str, build: str | None = None) -> str:

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

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from typing import TYPE_CHECKING
1010

11+
from mhfu_port import continuity
1112
from PySide6.QtCore import QSignalBlocker, Qt
1213
from PySide6.QtWidgets import QVBoxLayout, QWidget
1314

@@ -50,6 +51,8 @@
5051
clips.UNCHECKABLE: "unchecked",
5152
}
5253
UNPLACED = "in no anim: the base monster has no anim left for it"
54+
ORDERS = (("id", "By id"), ("fit", "Fits after the one on screen"))
55+
FIT_COLUMN = 5
5356

5457

5558
def _page() -> tuple[QWidget, QVBoxLayout]:
@@ -110,12 +113,27 @@ def __init__(self, ws: MonsterWorkspace, studio: Studio) -> None:
110113
)
111114
self.filter.textChanged.connect(self._filter)
112115
lay.addWidget(self.filter)
116+
self.order = kit.Segmented(
117+
list(ORDERS),
118+
tip="By id: the original's order. Fits after the one on screen: best first by how"
119+
" well each clip's first pose continues where the clip on screen ends, to find the"
120+
" next part of a chain.",
121+
on=lambda v: self.studio.act(
122+
"order clips", lambda: setattr(self.ws, "clip_order", v)
123+
)(),
124+
current=ws.clip_order,
125+
)
126+
self.order_note = kit.label(role="muted")
127+
lay.addWidget(kit.row(self.order, stretch=True))
128+
lay.addWidget(self.order_note)
113129
self.table = kit.Table(
114-
["Id", "Anim", "Frames", "Travel", "Name"],
130+
["Id", "Anim", "Frames", "Travel", "Name", "Fit", "Used in"],
115131
tip="Every clip of the original by its MHP3rd id (stream x 100 + slot), and the anim"
116132
" that plays it: what a script passes to force it. Click one, or step with the arrow"
117133
" keys, to play it from the start; a clip that repeats says loop after its frames;"
118-
" Travel is how far the clip carries the body.",
134+
" Travel is how far the clip carries the body; Fit, when ordered by it, is the mean"
135+
" angle between the clip on screen's last pose and this clip's first; Used in names"
136+
" the moves that play it.",
119137
swatch_column=1,
120138
)
121139
self.table.picked.connect(self._clicked)
@@ -374,8 +392,9 @@ def _table(self, vocab: Vocabulary) -> None:
374392
kit.put(self.filter, ws.clip_filter)
375393
needle = ws.clip_filter.strip().lower()
376394
speed = DEFAULT_SPEED if vp is None or vp.actor is None else vp.playback.speed
395+
fit = self._fits()
377396
rows, data, colors, tips, levels = [], [], [], [], []
378-
for r in self._rows:
397+
for r in rated_first(self._rows, fit):
379398
cov = None if r.entry is None else vocab.coverage.slots.get(r.entry)
380399
text = " ".join(
381400
[
@@ -384,6 +403,7 @@ def _table(self, vocab: Vocabulary) -> None:
384403
r.name or "unnamed",
385404
kind_text(cov),
386405
r.label,
406+
*r.used,
387407
]
388408
)
389409
if needle and needle not in text.lower():
@@ -396,11 +416,13 @@ def _table(self, vocab: Vocabulary) -> None:
396416
f"{r.frames} loop" if r.loop else str(r.frames),
397417
f"{net:.0f}" if net >= 1.0 else "",
398418
r.name,
419+
fit_text(fit.get(r.id)) if r.id is not None else "",
420+
", ".join(r.used),
399421
)
400422
)
401423
data.append(r.key)
402424
colors.append(None if cov is None else COVERAGE[cov.kind])
403-
tips.append(row_tip(r, cov, speed))
425+
tips.append(row_tip(r, cov, speed, fit.get(r.id) if r.id is not None else None))
404426
levels.append(level_of(cov))
405427
playing = self._playing()
406428
with QSignalBlocker(self.table):
@@ -410,6 +432,19 @@ def _table(self, vocab: Vocabulary) -> None:
410432
else:
411433
self.table.select_data(playing)
412434

435+
def _fits(self) -> dict[int, float]:
436+
"""How well each clip follows the one on screen, when the table is ordered by it."""
437+
ws = self.ws
438+
kit.put(self.order, ws.clip_order)
439+
by_fit = ws.clip_order == "fit"
440+
self.table.setColumnHidden(FIT_COLUMN, not by_fit)
441+
fit = ws.fits_after() if by_fit else {}
442+
self.order_note.setVisible(by_fit and not fit)
443+
self.order_note.setText(
444+
ws.ends_note or "Play a clip of the original to rank the rest by it"
445+
)
446+
return fit
447+
413448
def _editor(self, vocab: Vocabulary) -> None:
414449
ws = self.ws
415450
row = next((r for r in self._rows if r.key == self._picked), None)
@@ -476,12 +511,24 @@ def level_of(cov: SlotCoverage | None) -> Level | None:
476511
return "warning" if cov is not None and cov.kind == clips.FILLER else None
477512

478513

479-
def row_tip(r: SourceClip, cov: SlotCoverage | None, speed: float) -> str:
514+
def rated_first(rows: list[SourceClip], fit: dict[int, float]) -> list[SourceClip]:
515+
"""`rows` with the clips `fit` rates first, best first, the rest as they were."""
516+
place = {cid: k for k, cid in enumerate(fit)}
517+
return sorted(rows, key=lambda r: len(place) if r.id is None else place.get(r.id, len(place)))
518+
519+
520+
def fit_text(degrees: float | None) -> str:
521+
return "" if degrees is None else f"{degrees:.0f}\u00b0 {continuity.word(degrees)}"
522+
523+
524+
def row_tip(r: SourceClip, cov: SlotCoverage | None, speed: float, fit: float | None = None) -> str:
480525
lines = []
481526
if r.id is not None:
482527
lines.append(f"MHP3rd clip {r.id}: stream {r.stream}, slot {r.id % 100}")
483528
lines.append(cov.why() if cov else UNPLACED if r.entry is None else "Kind unknown")
484529
lines.append(f"{wall_clock(r.frames, speed):.2f} s at speed {speed:.2f}")
530+
if fit is not None:
531+
lines.append(f"{fit_text(fit)} from the end of the clip on screen")
485532
if r.label:
486533
lines.append(r.label)
487534
return "\n".join(lines)

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

Lines changed: 141 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,22 @@
22
# SPDX-FileCopyrightText: 2026 sp00ktober
33
"""The Moves dock: every move of the port, on a base monster's action or its own; an own move
44
made from the clip on screen, renamed, deleted, its fields and steer edited, and played in the
5-
running game. Its attack windows are drawn on the Timeline. Under them, the rules."""
5+
running game. Its attack windows are drawn on the Timeline. The moves that follow one another are
6+
a sequence, grouped in the table and edited under the picked move. Under them, the rules."""
67

78
from __future__ import annotations
89

910
from typing import TYPE_CHECKING
1011

12+
from mhfu_port import sequence
1113
from mhfu_port.manifest import MOVE_ATTACKS, TURNS, Move
1214
from PySide6.QtWidgets import QAbstractSpinBox, QSpinBox, QVBoxLayout, QWidget
1315

1416
from mhfu_studio.monster import rules
1517
from mhfu_studio.monster import validate as V
1618
from mhfu_studio.monster.panels.common import kind
1719
from mhfu_studio.monster.panels.rules import RulesSection
20+
from mhfu_studio.monster.panels.sequence import ClipPicker, StepChips
1821
from mhfu_studio.monster.panels.widgets import NoScene
1922
from mhfu_studio.shell.findings import Finding
2023
from mhfu_studio.shell.text import plain
@@ -38,6 +41,13 @@
3841
)
3942

4043

44+
def row_name(name: str, place: int, count: int) -> str:
45+
"""A move's cell in the table: a step under its head, or a head with its step count."""
46+
if place:
47+
return f" \u2514 {name}"
48+
return name if count == 1 else f"{name} \u00b7 {count} steps"
49+
50+
4151
def special(box: QSpinBox, text: str) -> QSpinBox:
4252
"""`box` shows `text` at its lowest value, which means "none"."""
4353
box.setSpecialValueText(text)
@@ -55,7 +65,8 @@ def __init__(self, ws: MonsterWorkspace, studio: Studio) -> None:
5565
self.table = kit.Table(
5666
["Move", "Plays on", "Clip", "Attacks"],
5767
tip="Your moves: on one of the base monster's actions, or your own, which the move"
58-
" player plays whole. Click one to watch it.",
68+
" player plays whole. The moves that follow one another are grouped under the first."
69+
" Click one to watch it.",
5970
)
6071
self.table.picked.connect(
6172
lambda n: act("pick move", lambda: ws.select_move(str(n)))() if n else None
@@ -76,6 +87,7 @@ def __init__(self, ws: MonsterWorkspace, studio: Studio) -> None:
7687

7788
self.editor = kit.Section("This move", tip="The picked own move: what the game plays")
7889
self.title = kit.label(role="title", wrap=False)
90+
self.seq = self._sequence_section()
7991
self.clip = kit.label(role="muted")
8092
self.play_clip = kit.button(
8193
"Play its clip",
@@ -230,6 +242,7 @@ def __init__(self, ws: MonsterWorkspace, studio: Studio) -> None:
230242
body.addWidget(w)
231243
top: tuple[QWidget, ...] = (
232244
self.title,
245+
self.seq,
233246
kit.row(self.clip, self.play_clip, self.in_game, self.force, stretch=True),
234247
self.game_hint,
235248
self.pair_note,
@@ -265,8 +278,111 @@ def __init__(self, ws: MonsterWorkspace, studio: Studio) -> None:
265278
for box in (self.length, self.main, self.sub, self.rate, self.angle, self.frames, self.dir):
266279
box.setButtonSymbols(QAbstractSpinBox.ButtonSymbols.NoButtons)
267280

281+
def _sequence_section(self) -> kit.Section:
282+
"""The picked move's sequence: a chip per step, the edits, the clip picker."""
283+
ws, act = self.ws, self.studio.act
284+
sec = kit.Section(
285+
"Sequence", tip="The moves that follow one another, each handing to the next by Then"
286+
)
287+
#: how the picker was opened, and for which move
288+
self._picking: tuple[str, str] | None = None
289+
self.chips = StepChips()
290+
self.chips.picked.connect(lambda n: act("pick step", lambda: ws.select_move(n))())
291+
self.add_step = kit.button(
292+
"Add step after",
293+
tip="Lists the clips best fit first after this move's clip, and puts the one you click"
294+
" in a new own move that follows this one",
295+
on=lambda: self._open("add"),
296+
icon="ph.plus",
297+
)
298+
self.change_clip = kit.button(
299+
"Change clip",
300+
tip="Lists the clips best fit first after the step before, and plays the one you"
301+
" click in this step",
302+
on=lambda: self._open("change"),
303+
icon="ph.swap",
304+
)
305+
self.remove_step = kit.button(
306+
"Remove step",
307+
tip="Deletes this step; the move before it hands to the move after it. Refused while"
308+
" a rule or an effect names it.",
309+
on=act("remove step", ws.remove_step),
310+
icon="ph.trash",
311+
)
312+
self.earlier = kit.button(
313+
"Earlier",
314+
tip="Swaps this step with the one before it",
315+
on=act("earlier", lambda: ws.move_step(-1)),
316+
icon="ph.arrow-left",
317+
)
318+
self.later = kit.button(
319+
"Later",
320+
tip="Swaps this step with the one after it",
321+
on=act("later", lambda: ws.move_step(1)),
322+
icon="ph.arrow-right",
323+
)
324+
self.split = kit.button(
325+
"Split here",
326+
tip="Ends the sequence before this step, which starts one of its own",
327+
on=act("split sequence", ws.split_step),
328+
icon="ph.link-break",
329+
)
330+
self.play_seq = kit.button(
331+
"Play sequence",
332+
tip="Plays the sequence from its first step, each clip after the other at the game's"
333+
" speed, each turning as its move steers",
334+
on=act("play sequence", ws.play_sequence),
335+
icon="ph.play",
336+
)
337+
self.picker = ClipPicker(ws, self.studio)
338+
self.picker.picked.connect(self._picked)
339+
self.picker.cancelled.connect(self._close_picker)
340+
self.picker.hide()
341+
for w in (
342+
self.chips,
343+
kit.row(self.add_step, self.change_clip, self.remove_step, stretch=True),
344+
kit.row(self.earlier, self.later, self.split, self.play_seq, stretch=True),
345+
self.picker,
346+
):
347+
sec.body.addWidget(w)
348+
return sec
349+
268350
# acting
269351

352+
def _open(self, mode: str) -> None:
353+
if self.ws.move is not None:
354+
self._picking = mode, self.ws.move
355+
self._show_picker()
356+
357+
def _close_picker(self) -> None:
358+
self._picking = None
359+
self.picker.hide()
360+
361+
def _picked(self, clip: str) -> None:
362+
mode = self._picking[0] if self._picking else ""
363+
self._close_picker()
364+
if mode == "add":
365+
self.studio.act("add step", lambda: self.ws.add_step(clip))()
366+
elif mode == "change":
367+
self.studio.act("change clip", lambda: self.ws.set_step_clip(clip))()
368+
369+
def _show_picker(self) -> None:
370+
"""The picker for the move it was opened for, ranked after the clip it follows."""
371+
ws, m = self.ws, self.ws.manifest
372+
if self._picking is None or m is None or ws.move != self._picking[1]:
373+
self._close_picker()
374+
return
375+
mode, name = self._picking
376+
steps = ws.sequence_of()
377+
i = steps.index(name)
378+
if mode == "add":
379+
after, title = m.moves[name].clip, f"A step after {name}"
380+
else:
381+
after = m.moves[steps[i - 1]].clip if i else None
382+
title = f"The clip of {name}"
383+
self.picker.show_after(after, title)
384+
self.picker.show()
385+
270386
def _rename(self) -> None:
271387
new = self.name.text().strip()
272388
if new and new != self.ws.move:
@@ -294,10 +410,13 @@ def sync(self) -> None:
294410
own = sum(mv.own for mv in m.moves.values())
295411
self.count.setText(f"{len(m.moves)} moves, {own} of them own")
296412
rows, data, tips = [], [], []
297-
for n, mv in m.moves.items():
298-
rows.append([n, kind(mv), mv.clip or f"anim {mv.anim}", self._attacks(mv)])
299-
data.append(n)
300-
tips.append(mv.label or ("" if mv.pair is None else "edit it in Actions"))
413+
for steps in sequence.groups(m).values():
414+
for k, n in enumerate(steps):
415+
mv = m.moves[n]
416+
shown = row_name(n, k, len(steps))
417+
rows.append([shown, kind(mv), mv.clip or f"anim {mv.anim}", self._attacks(mv)])
418+
data.append(n)
419+
tips.append(mv.label or ("" if mv.pair is None else "edit it in Actions"))
301420
self.table.set_rows(rows, data, tips=tips)
302421
self.table.fit(ROWS)
303422
self.table.select_data(ws.move)
@@ -306,6 +425,8 @@ def sync(self) -> None:
306425
picked = None if ws.move is None else m.moves.get(ws.move)
307426
self.delete.setEnabled(picked is not None)
308427
self.editor.setVisible(picked is not None)
428+
if picked is None:
429+
self._close_picker()
309430
if picked is not None and ws.move is not None:
310431
self._sync_move(ws.move, picked)
311432
self.rules.sync(self._checks())
@@ -345,8 +466,22 @@ def _sync_move(self, name: str, mv: Move) -> None:
345466
kit.put(self.name, name)
346467
if own:
347468
self._sync_own(name, mv)
469+
self._sync_sequence(name, own)
348470
self._findings(name)
349471

472+
def _sync_sequence(self, name: str, own: bool) -> None:
473+
ws = self.ws
474+
steps = ws.sequence_of(name)
475+
i = steps.index(name)
476+
self.chips.show_steps([ws.step_of(n) for n in steps], name)
477+
for w in (self.add_step, self.change_clip, self.remove_step):
478+
w.setEnabled(own)
479+
self.earlier.setEnabled(i > 0)
480+
self.later.setEnabled(i < len(steps) - 1)
481+
self.split.setEnabled(i > 0)
482+
if self._picking is not None:
483+
self._show_picker()
484+
350485
def _sync_own(self, name: str, mv: Move) -> None:
351486
ws, m = self.ws, self.ws.manifest
352487
assert m is not None

0 commit comments

Comments
 (0)