Skip to content

Commit 6eddd5e

Browse files
authored
Merge pull request #64 from MHFU-ModKit/moveset/sequences
Sequences: chains of moves in the studio, with a fits-after clip picker
2 parents 394f2a2 + 5cbb8fb commit 6eddd5e

17 files changed

Lines changed: 1909 additions & 24 deletions

File tree

‎apps/studio/README.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,12 @@ builds the port again. Play in game holds the clip's anim on the running game's
5454
plays the build last injected, so a clip placed since the manifest was saved is refused until it
5555
is saved and injected again (`mhfu-port inject`).
5656

57+
Used in names the moves that play a clip. Order by "Fits after the one on screen" sorts the list
58+
by how well each clip's first pose continues where the playing clip ends (the mean angle between
59+
the two poses, the body's heading left out), to find the next part of a chain: up to 10 degrees is
60+
good, up to 20 fair, past it poor. It rates poses only, so a part the engine blends into can score
61+
poor; the picker's Neighbours order lists clips by source number instead.
62+
5763
## A port's own moves
5864

5965
An own move plays one clip on every body part through the framework's move player, with attacks of
@@ -74,6 +80,15 @@ moves module just built, so a new or edited move plays once the game has re-run
7480
A move asked while the monster has noticed the hunter but is not yet in combat waits for combat,
7581
so the "!" and the howl play out; Force beside the button plays it at once.
7682

83+
Moves that hand to one another by Then are a sequence: the table groups them under the first,
84+
and the picked move's Sequence strip shows one chip per step. Add step after lists the clips,
85+
best fit after the step's clip first, and makes the one you click a new own move that follows it;
86+
Change clip, Remove step, Earlier, Later and Split here edit the chain, each one undo step; a
87+
pair move is shown but never edited here. Play sequence plays the steps back to back, each
88+
starting where the last stood, facing as it faced, and the Timeline's bar above the transport
89+
shows the steps (click one to pick it). Picking or playing anything else starts clips where
90+
they normally start.
91+
7792
Under the moves are the rules (`[[rule]]`), each read as a sentence ("after the lunge for 20
7893
frames, within 1200, play stamp"): New rule plays the picked move once when the monster notices
7994
the hunter, and every field is a control, the part only for a flinch or a break, the mode only

‎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)

0 commit comments

Comments
 (0)