Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions apps/studio/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ builds the port again. Play in game holds the clip's anim on the running game's
plays the build last injected, so a clip placed since the manifest was saved is refused until it
is saved and injected again (`mhfu-port inject`).

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

## A port's own moves

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

Moves that hand to one another by Then are a sequence: the table groups them under the first,
and the picked move's Sequence strip shows one chip per step. Add step after lists the clips,
best fit after the step's clip first, and makes the one you click a new own move that follows it;
Change clip, Remove step, Earlier, Later and Split here edit the chain, each one undo step; a
pair move is shown but never edited here. Play sequence plays the steps back to back, each
starting where the last stood, facing as it faced, and the Timeline's bar above the transport
shows the steps (click one to pick it). Picking or playing anything else starts clips where
they normally start.

Under the moves are the rules (`[[rule]]`), each read as a sentence ("after the lunge for 20
frames, within 1200, play stamp"): New rule plays the picked move once when the monster notices
the hunter, and every field is a control, the part only for a flinch or a break, the mode only
Expand Down
7 changes: 5 additions & 2 deletions apps/studio/src/mhfu_studio/monster/clip_browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
from mhp_formats import anim
from mhp_formats.anim import AnimPack

from mhfu_studio.monster import clips
from mhfu_studio.monster import clips, sequences
from mhfu_studio.monster.document import PortDocument


Expand All @@ -36,6 +36,8 @@ class SourceClip:
loop: bool
name: str = ""
label: str = ""
used: tuple[str, ...] = ()
"""The moves that play it."""

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

def name(self, cid: int, name: str, label: str, build: str | None = None) -> str:
Expand Down
57 changes: 52 additions & 5 deletions apps/studio/src/mhfu_studio/monster/panels/clips.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from typing import TYPE_CHECKING

from mhfu_port import continuity
from PySide6.QtCore import QSignalBlocker, Qt
from PySide6.QtWidgets import QVBoxLayout, QWidget

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


def _page() -> tuple[QWidget, QVBoxLayout]:
Expand Down Expand Up @@ -110,12 +113,27 @@ def __init__(self, ws: MonsterWorkspace, studio: Studio) -> None:
)
self.filter.textChanged.connect(self._filter)
lay.addWidget(self.filter)
self.order = kit.Segmented(
list(ORDERS),
tip="By id: the original's order. Fits after the one on screen: best first by how"
" well each clip's first pose continues where the clip on screen ends, to find the"
" next part of a chain.",
on=lambda v: self.studio.act(
"order clips", lambda: setattr(self.ws, "clip_order", v)
)(),
current=ws.clip_order,
)
self.order_note = kit.label(role="muted")
lay.addWidget(kit.row(self.order, stretch=True))
lay.addWidget(self.order_note)
self.table = kit.Table(
["Id", "Anim", "Frames", "Travel", "Name"],
["Id", "Anim", "Frames", "Travel", "Name", "Fit", "Used in"],
tip="Every clip of the original by its MHP3rd id (stream x 100 + slot), and the anim"
" that plays it: what a script passes to force it. Click one, or step with the arrow"
" keys, to play it from the start; a clip that repeats says loop after its frames;"
" Travel is how far the clip carries the body.",
" Travel is how far the clip carries the body; Fit, when ordered by it, is the mean"
" angle between the clip on screen's last pose and this clip's first; Used in names"
" the moves that play it.",
swatch_column=1,
)
self.table.picked.connect(self._clicked)
Expand Down Expand Up @@ -374,8 +392,9 @@ def _table(self, vocab: Vocabulary) -> None:
kit.put(self.filter, ws.clip_filter)
needle = ws.clip_filter.strip().lower()
speed = DEFAULT_SPEED if vp is None or vp.actor is None else vp.playback.speed
fit = self._fits()
rows, data, colors, tips, levels = [], [], [], [], []
for r in self._rows:
for r in rated_first(self._rows, fit):
cov = None if r.entry is None else vocab.coverage.slots.get(r.entry)
text = " ".join(
[
Expand All @@ -384,6 +403,7 @@ def _table(self, vocab: Vocabulary) -> None:
r.name or "unnamed",
kind_text(cov),
r.label,
*r.used,
]
)
if needle and needle not in text.lower():
Expand All @@ -396,11 +416,13 @@ def _table(self, vocab: Vocabulary) -> None:
f"{r.frames} loop" if r.loop else str(r.frames),
f"{net:.0f}" if net >= 1.0 else "",
r.name,
fit_text(fit.get(r.id)) if r.id is not None else "",
", ".join(r.used),
)
)
data.append(r.key)
colors.append(None if cov is None else COVERAGE[cov.kind])
tips.append(row_tip(r, cov, speed))
tips.append(row_tip(r, cov, speed, fit.get(r.id) if r.id is not None else None))
levels.append(level_of(cov))
playing = self._playing()
with QSignalBlocker(self.table):
Expand All @@ -410,6 +432,19 @@ def _table(self, vocab: Vocabulary) -> None:
else:
self.table.select_data(playing)

def _fits(self) -> dict[int, float]:
"""How well each clip follows the one on screen, when the table is ordered by it."""
ws = self.ws
kit.put(self.order, ws.clip_order)
by_fit = ws.clip_order == "fit"
self.table.setColumnHidden(FIT_COLUMN, not by_fit)
fit = ws.fits_after() if by_fit else {}
self.order_note.setVisible(by_fit and not fit)
self.order_note.setText(
ws.ends_note or "Play a clip of the original to rank the rest by it"
)
return fit

def _editor(self, vocab: Vocabulary) -> None:
ws = self.ws
row = next((r for r in self._rows if r.key == self._picked), None)
Expand Down Expand Up @@ -476,12 +511,24 @@ def level_of(cov: SlotCoverage | None) -> Level | None:
return "warning" if cov is not None and cov.kind == clips.FILLER else None


def row_tip(r: SourceClip, cov: SlotCoverage | None, speed: float) -> str:
def rated_first(rows: list[SourceClip], fit: dict[int, float]) -> list[SourceClip]:
"""`rows` with the clips `fit` rates first, best first, the rest as they were."""
place = {cid: k for k, cid in enumerate(fit)}
return sorted(rows, key=lambda r: len(place) if r.id is None else place.get(r.id, len(place)))


def fit_text(degrees: float | None) -> str:
return "" if degrees is None else f"{degrees:.0f}\u00b0 {continuity.word(degrees)}"


def row_tip(r: SourceClip, cov: SlotCoverage | None, speed: float, fit: float | None = None) -> str:
lines = []
if r.id is not None:
lines.append(f"MHP3rd clip {r.id}: stream {r.stream}, slot {r.id % 100}")
lines.append(cov.why() if cov else UNPLACED if r.entry is None else "Kind unknown")
lines.append(f"{wall_clock(r.frames, speed):.2f} s at speed {speed:.2f}")
if fit is not None:
lines.append(f"{fit_text(fit)} from the end of the clip on screen")
if r.label:
lines.append(r.label)
return "\n".join(lines)
Loading
Loading