From 236fef1f1add3109efab8abc6aec215f71e8c67e Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:18:15 +0200 Subject: [PATCH 1/3] feat(mhfu-port): sequences and clip continuity sequence: the chain of moves linked by Move.after, its heads and step names. continuity: ranks a donor's clips by how well one's first pose continues another's last, by mean joint rotation angle with the body heading removed. Co-Authored-By: Claude Sonnet 5.5 --- .../mhfu-port/src/mhfu_port/continuity.py | 121 +++++++++++++++++ packages/mhfu-port/src/mhfu_port/sequence.py | 57 ++++++++ packages/mhfu-port/tests/test_continuity.py | 127 ++++++++++++++++++ packages/mhfu-port/tests/test_sequence.py | 63 +++++++++ 4 files changed, 368 insertions(+) create mode 100644 packages/mhfu-port/src/mhfu_port/continuity.py create mode 100644 packages/mhfu-port/src/mhfu_port/sequence.py create mode 100644 packages/mhfu-port/tests/test_continuity.py create mode 100644 packages/mhfu-port/tests/test_sequence.py diff --git a/packages/mhfu-port/src/mhfu_port/continuity.py b/packages/mhfu-port/src/mhfu_port/continuity.py new file mode 100644 index 0000000..b791c6f --- /dev/null +++ b/packages/mhfu-port/src/mhfu_port/continuity.py @@ -0,0 +1,121 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2026 sp00ktober +"""Which clip fits after which: how well clip B's first pose continues clip A's last. + +A pose is each animated joint's local rotation as the engine's FK sees it (`fk`), with the +body's heading about y taken out (YAW owns it, `travel`); the root's travel is location, which +no rotation carries. Two poses are the mean angle between their joints, in degrees: smaller fits +better. It ranks poses, not intent: a clip that hands to the next through a blend, or through a +third clip, scores high. + +A channel holds past its last key, so a clip's first and last poses are its first and last keys' +values: ranking a moveset takes two poses per clip, once (`Ends`). +""" + +from __future__ import annotations + +from collections.abc import Collection, Mapping + +import numpy as np +from mhp_formats.anim import Clip +from mhp_formats.skeleton import Skeleton + +from . import build, fk, travel +from .data import Data +from .manifest import Manifest +from .motion import SOURCE_BANK + +GOOD, FAIR = 10.0, 20.0 +"""Degrees. A fifth of a donor's clip pairs lie within GOOD (clips that start and end on the same +stance); the Zinogre's backflip parts meet at 15, FAIR; beyond it the poses do not meet.""" + +Scores = list[tuple[int, float]] + + +def word(score: float) -> str: + """`good`, `fair` or `poor`.""" + return "good" if score <= GOOD else "fair" if score <= FAIR else "poor" + + +class Ends: + """The first and last pose of each clip: `(clips, joints, 3, 3)` rotations, the joints being + those some pose turns.""" + + def __init__(self, ids: tuple[int, ...], first: fk.Floats, last: fk.Floats) -> None: + self.ids, self.first, self.last = ids, first, last + + def scores(self, a: int) -> fk.Floats: + """Degrees from the end of clip `a` to the start of each clip, in `ids` order.""" + if a not in self.ids: + raise KeyError(f"no clip {a}") + if not self.first.shape[1]: + return np.zeros(len(self.ids)) + trace = np.einsum("jik,njik->nj", self.last[self.ids.index(a)], self.first) + angle: fk.Floats = np.degrees(np.arccos(np.clip((trace - 1) / 2, -1, 1))) + mean: fk.Floats = angle.mean(axis=1) + return mean + + +def ends( + clips: Mapping[int, Clip | fk.Curves], + skeleton: Skeleton, + record_of: Mapping[int, int] | None = None, +) -> Ends: + """The end poses of `clips` (by id) on `skeleton`. `record_of` maps a donor's bones to its + clips' tracks (`build.record_map`); without it, track i drives joint i.""" + rig = fk.Rig.from_skeleton(skeleton) + ids = tuple(sorted(clips)) + rot, loc = [], [] + for i in ids: + c = clips[i] + curves = c if isinstance(c, fk.Curves) else fk.Curves(c, rig, record_of) + r, t = curves.at(np.array([0.0, float(curves.last_frame)])) + rot.append(r) + loc.append(t) + if not ids: + return Ends(ids, np.zeros((0, 0, 3, 3)), np.zeros((0, 0, 3, 3))) + rotations, locations = np.stack(rot), np.stack(loc) + pose = fk.euler_xyz(rotations) + body = np.flatnonzero(rig.parents == travel.root(skeleton)) + pose[..., body, :, :] = _unturned(rig.world(rotations, locations)[..., body, :3, :3]) + moving = np.flatnonzero(np.abs(pose - np.eye(3)).max(axis=(0, 1, 3, 4)) > 1e-9) + return Ends(ids, pose[:, 0, moving], pose[:, 1, moving]) + + +def _unturned(body: fk.Floats) -> fk.Floats: + """`(..., body, 3, 3)` rotations with the heading about y they share taken out.""" + heading = np.arctan2( + (body[..., 0, 2] - body[..., 2, 0]).sum(axis=-1), + (body[..., 0, 0] + body[..., 2, 2]).sum(axis=-1), + ) + c, s = np.cos(heading), np.sin(heading) + back = np.zeros((*heading.shape, 3, 3)) # a turn of -heading about y + back[..., 0, 0] = back[..., 2, 2] = c + back[..., 0, 2], back[..., 2, 0], back[..., 1, 1] = -s, s, 1.0 + out: fk.Floats = back[..., None, :, :] @ body + return out + + +def donor_ends(m: Manifest, data: Data) -> Ends: + """The end poses of the manifest's donor moveset, on the donor's own skeleton.""" + d = build.donor(m, data) + return ends(d.clips, d.skeleton, build.record_map(d, m.build)) + + +def fits_after(e: Ends, a: int, *, top: int | None = None) -> Scores: + """Every clip but `a` as `(id, degrees)`, best first: how far its first pose is from the + last of `a`. Equal scores go nearest the id of `a` first. `top` keeps that many.""" + rows = [(i, float(s)) for i, s in zip(e.ids, e.scores(a), strict=True) if i != a] + rows.sort(key=lambda r: (round(r[1], 3), abs(r[0] - a), r[0])) + return rows if top is None else rows[:top] + + +def neighbours(a: int, ids: Collection[int]) -> list[int]: + """`ids` but `a`, nearest first: its own stream by slot number, then the other streams by + how far off they are.""" + stream, slot = divmod(a, SOURCE_BANK) + + def apart(i: int) -> tuple[int, int, int]: + return abs(i // SOURCE_BANK - stream), abs(i % SOURCE_BANK - slot), i + + return sorted((i for i in ids if i != a), key=apart) diff --git a/packages/mhfu-port/src/mhfu_port/sequence.py b/packages/mhfu-port/src/mhfu_port/sequence.py new file mode 100644 index 0000000..1ad187d --- /dev/null +++ b/packages/mhfu-port/src/mhfu_port/sequence.py @@ -0,0 +1,57 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2026 sp00ktober +"""Sequences: moves that run one after another through `Move.after`. + +A sequence is a chain of moves, each handing to the next. Its head is a move no other move's +`after` names; a ring of moves with no head is headed by its alphabetically first. A move that +two chains reach belongs to the first head, in the manifest's order. +""" + +from __future__ import annotations + +from .manifest import Manifest, ManifestError + + +def chain(m: Manifest, head: str) -> list[str]: + """`head` and the moves it hands to in turn, up to the first repeat.""" + out: list[str] = [] + name: str | None = head + while name is not None and name in m.moves and name not in out: + out.append(name) + name = m.moves[name].after + return out + + +def loops(m: Manifest, head: str) -> bool: + """Whether the chain from `head` ends by handing back to one of its own moves.""" + steps = chain(m, head) + return bool(steps) and m.moves[steps[-1]].after is not None + + +def heads(m: Manifest) -> list[str]: + """Each sequence's head, in the manifest's order.""" + named = {mv.after for mv in m.moves.values() if mv.after is not None} + out = [n for n in m.moves if n not in named] + reached = {s for h in out for s in chain(m, h)} + for n in sorted(m.moves): # what is left is rings: every move in one is named by another + if n not in reached: + out.append(n) + reached.update(chain(m, n)) + order = list(m.moves) + return sorted(out, key=order.index) + + +def head_of(m: Manifest, name: str) -> str: + """The head of the sequence `name` belongs to.""" + for h in heads(m): + if name in chain(m, h): + return h + raise ManifestError(f"no move {name!r}") + + +def step_name(m: Manifest, head: str) -> str: + """The lowest `_` from k = 2 that no move has.""" + k = 2 + while f"{head}_{k}" in m.moves: + k += 1 + return f"{head}_{k}" diff --git a/packages/mhfu-port/tests/test_continuity.py b/packages/mhfu-port/tests/test_continuity.py new file mode 100644 index 0000000..1813e8c --- /dev/null +++ b/packages/mhfu-port/tests/test_continuity.py @@ -0,0 +1,127 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2026 sp00ktober +import math +from pathlib import Path + +import pytest +from mhfu_port import continuity, manifest +from mhfu_port.data import Data +from mhp_formats.anim import Channel, Clip, Keyframe, Track, quantize +from mhp_formats.skeleton import Bone, Skeleton + +PORTS = Path(__file__).parents[3] / "ports" +ROT = (0x008, 0x010, 0x020) + + +def skeleton() -> Skeleton: + """Joint 0 and the root 1 carry the travel; the body 2 and a limb 3 turn.""" + return Skeleton( + [ + Bone(parent=-1, child=1), + Bone(parent=0, child=2), + Bone(parent=1, child=3, position=(0.0, 0.0, 50.0)), + Bone(parent=2, position=(0.0, 30.0, 100.0)), + ] + ) + + +def turn(axis: int, degrees: float) -> Channel: + return Channel(ROT[axis], [Keyframe(quantize("rot", math.radians(degrees)), 0)]) + + +def held(channels: list[Channel], frames: int) -> Track: + """Each channel at its first key's value from frame 0 to `frames`.""" + return Track( + [Channel(c.bit, [c.keyframes[0], c.keyframes[0]._replace(frame=frames)]) for c in channels] + ) + + +def clip(body: list[Channel], limb: list[Channel], frames: int = 10) -> Clip: + """A clip whose joints 2 and 3 hold the given channels.""" + return Clip([Track(), Track(), held(body, frames), held(limb, frames)]) + + +def moveset(**poses: tuple[float, float]) -> dict[int, Clip]: + """Clip i holds the body at x degrees and the limb at y degrees about x.""" + return {int(k[1:]): clip([turn(0, b)], [turn(0, ll)]) for k, (b, ll) in poses.items()} + + +def test_a_clip_fits_itself_best_and_a_rotated_one_worse(): + e = continuity.ends(moveset(c1=(0, 0), c2=(0, 0), c3=(0, 90), c4=(90, 90)), skeleton()) + got = dict(continuity.fits_after(e, 1)) + assert got[2] == pytest.approx(0.0) and got[3] == pytest.approx(45.0) + assert got[4] == pytest.approx(90.0), "both joints turn 90 degrees" + assert [i for i, _ in continuity.fits_after(e, 1)] == [2, 3, 4] + assert continuity.fits_after(e, 1, top=1) == [(2, pytest.approx(0.0, abs=1e-6))] + + +def test_equal_scores_go_nearest_first(): + e = continuity.ends(moveset(c1=(0, 0), c9=(0, 0), c3=(0, 0), c2=(0, 0)), skeleton()) + assert [i for i, _ in continuity.fits_after(e, 1)] == [2, 3, 9] + + +def test_the_body_heading_is_not_a_difference(): + still = clip([turn(0, 30)], [turn(0, 10)]) + faced = clip([turn(0, 30), turn(1, 70)], [turn(0, 10)]) # the same pose, turned about y + e = continuity.ends({1: still, 2: faced}, skeleton()) + assert dict(continuity.fits_after(e, 1))[2] < 1.0 + + +def test_the_end_poses_are_the_clips_ends(): + start = Track([Channel(ROT[0], [Keyframe(0, 0), Keyframe(quantize("rot", math.pi / 2), 8)])]) + swing = Clip([Track(), Track(), start, Track()]) + rest = clip([turn(0, 0)], []) + e = continuity.ends({1: swing, 2: rest, 3: clip([turn(0, 90)], [])}, skeleton()) + after_swing, before_swing = dict(continuity.fits_after(e, 1)), dict(continuity.fits_after(e, 2)) + assert after_swing[3] == pytest.approx(0.0, abs=0.1), "the swing ends where 3 begins" + assert before_swing[1] == pytest.approx(0.0), "and begins where the rest ends" + + +def test_words(): + assert [continuity.word(x) for x in (0.0, 10.0, 15.0, 20.0, 31.0)] == [ + "good", + "good", + "fair", + "fair", + "poor", + ] + + +def test_neighbours(): + ids = [101, 103, 104, 200, 201, 230, 231, 232, 5] + assert continuity.neighbours(231, ids) == [230, 232, 201, 200, 104, 103, 101, 5] + assert continuity.neighbours(103, [103, 101, 104, 5]) == [104, 101, 5] + + +# ---- the Zinogre's donor (MHP3RD_DATA) ---- + + +@pytest.fixture(scope="module") +def zinogre(data: Data) -> continuity.Ends: + return continuity.donor_ends(manifest.load(PORTS / "zinogre.toml"), data) + + +def rank(e: continuity.Ends, a: int, b: int) -> int: + return [i for i, _ in continuity.fits_after(e, a)].index(b) + 1 + + +@pytest.mark.parametrize("a, b", [(218, 219), (219, 220), (221, 222), (222, 223)]) +def test_the_backflip_parts_chain(zinogre, a, b): + assert rank(zinogre, a, b) <= 3 + + +def test_the_flinch_roll_parts_chain_crossed(zinogre): + """The donor's part 2s are crossed with its part 1s: 103 (_left_) ends where 108 (_right_) + begins, 4 degrees off, and 107 is 42 off.""" + names = {103: "flinch_roll_left_part_1", 108: "flinch_roll_right_part_2"} + assert rank(zinogre, 103, 108) <= 3 and rank(zinogre, 104, 107) <= 3, names + assert dict(continuity.fits_after(zinogre, 103))[108] < continuity.GOOD + + +def test_the_whole_moveset_ranks_fast(zinogre): + import time + + t = time.perf_counter() + for a in zinogre.ids: + continuity.fits_after(zinogre, a) + assert time.perf_counter() - t < 1.0 diff --git a/packages/mhfu-port/tests/test_sequence.py b/packages/mhfu-port/tests/test_sequence.py new file mode 100644 index 0000000..1704672 --- /dev/null +++ b/packages/mhfu-port/tests/test_sequence.py @@ -0,0 +1,63 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2026 sp00ktober +from mhfu_port import manifest, sequence + +HEAD = """ +[port] +name = "z" +host_species = 75 +pac = "z.bin" +[source] +model = 5339 +[clips.a] +slot = 1 +""" + + +def moves(**after: str | None) -> manifest.Manifest: + """Own moves in the given order, each `after` the move named (None: no `after`).""" + body = "".join( + f'[moves.{n}]\nclip = "a"\n' + (f'after = "{a}"\n' if a else "") for n, a in after.items() + ) + return manifest.loads(HEAD + body) + + +def test_chain(): + m = moves(c="d", a="b", b="c", d=None, solo=None) + assert sequence.chain(m, "a") == ["a", "b", "c", "d"] + assert sequence.chain(m, "c") == ["c", "d"] + assert sequence.chain(m, "solo") == ["solo"] + assert sequence.chain(m, "nope") == [] + + +def test_chain_stops_at_the_first_repeat(): + m = moves(h="a", a="b", b="c", c="a") + assert sequence.chain(m, "h") == ["h", "a", "b", "c"] + assert sequence.loops(m, "h") + assert not sequence.loops(moves(a="b", b=None), "a") + + +def test_heads(): + m = moves(c="d", a="b", b="c", d=None, solo=None) + assert sequence.heads(m) == ["a", "solo"], "in manifest order" + assert [sequence.head_of(m, n) for n in "abcd"] == ["a"] * 4 + assert sequence.head_of(m, "solo") == "solo" + + +def test_a_ring_is_headed_by_its_first_name(): + m = moves(z="y", y="x", x="z", lone=None) + assert sequence.heads(m) == ["x", "lone"] and sequence.head_of(m, "y") == "x" + assert sequence.chain(m, "x") == ["x", "z", "y"] and sequence.loops(m, "x") + + +def test_a_join_belongs_to_the_first_head(): + m = moves(p="join", q="join", join="end", end=None) + assert sequence.heads(m) == ["p", "q"] + assert sequence.head_of(m, "join") == "p" and sequence.head_of(m, "q") == "q" + assert sequence.chain(m, "q") == ["q", "join", "end"] + + +def test_step_name(): + m = moves(topple="topple_2", topple_2="topple_4", topple_4=None) + assert sequence.step_name(m, "topple") == "topple_3" + assert sequence.step_name(m, "fresh") == "fresh_2" From 7d81137ff2d3c2ba9575346284ae516841992c20 Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:37:15 +0200 Subject: [PATCH 2/3] 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 --- .../src/mhfu_studio/monster/clip_browser.py | 7 +- .../src/mhfu_studio/monster/panels/clips.py | 57 ++- .../src/mhfu_studio/monster/panels/moves.py | 147 +++++++- .../mhfu_studio/monster/panels/sequence.py | 206 +++++++++++ .../mhfu_studio/monster/panels/timeline.py | 18 +- .../src/mhfu_studio/monster/sequences.py | 183 ++++++++++ .../src/mhfu_studio/monster/workspace.py | 166 ++++++++- apps/studio/tests/monster/test_sequences.py | 238 +++++++++++++ .../studio/tests/monster/ui/test_sequences.py | 332 ++++++++++++++++++ .../mhfu-port/src/mhfu_port/continuity.py | 14 +- packages/mhfu-port/src/mhfu_port/sequence.py | 14 +- packages/mhfu-port/tests/test_continuity.py | 14 +- packages/mhfu-port/tests/test_sequence.py | 1 + 13 files changed, 1369 insertions(+), 28 deletions(-) create mode 100644 apps/studio/src/mhfu_studio/monster/panels/sequence.py create mode 100644 apps/studio/src/mhfu_studio/monster/sequences.py create mode 100644 apps/studio/tests/monster/test_sequences.py create mode 100644 apps/studio/tests/monster/ui/test_sequences.py diff --git a/apps/studio/src/mhfu_studio/monster/clip_browser.py b/apps/studio/src/mhfu_studio/monster/clip_browser.py index 8f0f438..abbb086 100644 --- a/apps/studio/src/mhfu_studio/monster/clip_browser.py +++ b/apps/studio/src/mhfu_studio/monster/clip_browser.py @@ -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 @@ -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: @@ -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: diff --git a/apps/studio/src/mhfu_studio/monster/panels/clips.py b/apps/studio/src/mhfu_studio/monster/panels/clips.py index 5a0f800..d82dc19 100644 --- a/apps/studio/src/mhfu_studio/monster/panels/clips.py +++ b/apps/studio/src/mhfu_studio/monster/panels/clips.py @@ -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 @@ -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]: @@ -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) @@ -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( [ @@ -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(): @@ -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): @@ -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) @@ -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) diff --git a/apps/studio/src/mhfu_studio/monster/panels/moves.py b/apps/studio/src/mhfu_studio/monster/panels/moves.py index fbe7b0d..f120574 100644 --- a/apps/studio/src/mhfu_studio/monster/panels/moves.py +++ b/apps/studio/src/mhfu_studio/monster/panels/moves.py @@ -2,12 +2,14 @@ # SPDX-FileCopyrightText: 2026 sp00ktober """The Moves dock: every move of the port, on a base monster's action or its own; an own move made from the clip on screen, renamed, deleted, its fields and steer edited, and played in the -running game. Its attack windows are drawn on the Timeline. Under them, the rules.""" +running game. Its attack windows are drawn on the Timeline. The moves that follow one another are +a sequence, grouped in the table and edited under the picked move. Under them, the rules.""" from __future__ import annotations from typing import TYPE_CHECKING +from mhfu_port import sequence from mhfu_port.manifest import MOVE_ATTACKS, TURNS, Move from PySide6.QtWidgets import QAbstractSpinBox, QSpinBox, QVBoxLayout, QWidget @@ -15,6 +17,7 @@ from mhfu_studio.monster import validate as V from mhfu_studio.monster.panels.common import kind from mhfu_studio.monster.panels.rules import RulesSection +from mhfu_studio.monster.panels.sequence import ClipPicker, StepChips from mhfu_studio.monster.panels.widgets import NoScene from mhfu_studio.shell.findings import Finding from mhfu_studio.shell.text import plain @@ -38,6 +41,13 @@ ) +def row_name(name: str, place: int, count: int) -> str: + """A move's cell in the table: a step under its head, or a head with its step count.""" + if place: + return f" \u2514 {name}" + return name if count == 1 else f"{name} \u00b7 {count} steps" + + def special(box: QSpinBox, text: str) -> QSpinBox: """`box` shows `text` at its lowest value, which means "none".""" box.setSpecialValueText(text) @@ -55,7 +65,8 @@ def __init__(self, ws: MonsterWorkspace, studio: Studio) -> None: self.table = kit.Table( ["Move", "Plays on", "Clip", "Attacks"], tip="Your moves: on one of the base monster's actions, or your own, which the move" - " player plays whole. Click one to watch it.", + " player plays whole. The moves that follow one another are grouped under the first." + " Click one to watch it.", ) self.table.picked.connect( 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: self.editor = kit.Section("This move", tip="The picked own move: what the game plays") self.title = kit.label(role="title", wrap=False) + self.seq = self._sequence_section() self.clip = kit.label(role="muted") self.play_clip = kit.button( "Play its clip", @@ -230,6 +242,7 @@ def __init__(self, ws: MonsterWorkspace, studio: Studio) -> None: body.addWidget(w) top: tuple[QWidget, ...] = ( self.title, + self.seq, kit.row(self.clip, self.play_clip, self.in_game, self.force, stretch=True), self.game_hint, self.pair_note, @@ -265,8 +278,111 @@ def __init__(self, ws: MonsterWorkspace, studio: Studio) -> None: for box in (self.length, self.main, self.sub, self.rate, self.angle, self.frames, self.dir): box.setButtonSymbols(QAbstractSpinBox.ButtonSymbols.NoButtons) + def _sequence_section(self) -> kit.Section: + """The picked move's sequence: a chip per step, the edits, the clip picker.""" + ws, act = self.ws, self.studio.act + sec = kit.Section( + "Sequence", tip="The moves that follow one another, each handing to the next by Then" + ) + #: how the picker was opened, and for which move + self._picking: tuple[str, str] | None = None + self.chips = StepChips() + self.chips.picked.connect(lambda n: act("pick step", lambda: ws.select_move(n))()) + self.add_step = kit.button( + "Add step after", + tip="Lists the clips best fit first after this move's clip, and puts the one you click" + " in a new own move that follows this one", + on=lambda: self._open("add"), + icon="ph.plus", + ) + self.change_clip = kit.button( + "Change clip", + tip="Lists the clips best fit first after the step before, and plays the one you" + " click in this step", + on=lambda: self._open("change"), + icon="ph.swap", + ) + self.remove_step = kit.button( + "Remove step", + tip="Deletes this step; the move before it hands to the move after it. Refused while" + " a rule or an effect names it.", + on=act("remove step", ws.remove_step), + icon="ph.trash", + ) + self.earlier = kit.button( + "Earlier", + tip="Swaps this step with the one before it", + on=act("earlier", lambda: ws.move_step(-1)), + icon="ph.arrow-left", + ) + self.later = kit.button( + "Later", + tip="Swaps this step with the one after it", + on=act("later", lambda: ws.move_step(1)), + icon="ph.arrow-right", + ) + self.split = kit.button( + "Split here", + tip="Ends the sequence before this step, which starts one of its own", + on=act("split sequence", ws.split_step), + icon="ph.link-break", + ) + self.play_seq = kit.button( + "Play sequence", + tip="Plays the sequence from its first step, each clip after the other at the game's" + " speed, each turning as its move steers", + on=act("play sequence", ws.play_sequence), + icon="ph.play", + ) + self.picker = ClipPicker(ws, self.studio) + self.picker.picked.connect(self._picked) + self.picker.cancelled.connect(self._close_picker) + self.picker.hide() + for w in ( + self.chips, + kit.row(self.add_step, self.change_clip, self.remove_step, stretch=True), + kit.row(self.earlier, self.later, self.split, self.play_seq, stretch=True), + self.picker, + ): + sec.body.addWidget(w) + return sec + # acting + def _open(self, mode: str) -> None: + if self.ws.move is not None: + self._picking = mode, self.ws.move + self._show_picker() + + def _close_picker(self) -> None: + self._picking = None + self.picker.hide() + + def _picked(self, clip: str) -> None: + mode = self._picking[0] if self._picking else "" + self._close_picker() + if mode == "add": + self.studio.act("add step", lambda: self.ws.add_step(clip))() + elif mode == "change": + self.studio.act("change clip", lambda: self.ws.set_step_clip(clip))() + + def _show_picker(self) -> None: + """The picker for the move it was opened for, ranked after the clip it follows.""" + ws, m = self.ws, self.ws.manifest + if self._picking is None or m is None or ws.move != self._picking[1]: + self._close_picker() + return + mode, name = self._picking + steps = ws.sequence_of() + i = steps.index(name) + if mode == "add": + after, title = m.moves[name].clip, f"A step after {name}" + else: + after = m.moves[steps[i - 1]].clip if i else None + title = f"The clip of {name}" + self.picker.show_after(after, title) + self.picker.show() + def _rename(self) -> None: new = self.name.text().strip() if new and new != self.ws.move: @@ -294,10 +410,13 @@ def sync(self) -> None: own = sum(mv.own for mv in m.moves.values()) self.count.setText(f"{len(m.moves)} moves, {own} of them own") rows, data, tips = [], [], [] - for n, mv in m.moves.items(): - rows.append([n, kind(mv), mv.clip or f"anim {mv.anim}", self._attacks(mv)]) - data.append(n) - tips.append(mv.label or ("" if mv.pair is None else "edit it in Actions")) + for steps in sequence.groups(m).values(): + for k, n in enumerate(steps): + mv = m.moves[n] + shown = row_name(n, k, len(steps)) + rows.append([shown, kind(mv), mv.clip or f"anim {mv.anim}", self._attacks(mv)]) + data.append(n) + tips.append(mv.label or ("" if mv.pair is None else "edit it in Actions")) self.table.set_rows(rows, data, tips=tips) self.table.fit(ROWS) self.table.select_data(ws.move) @@ -306,6 +425,8 @@ def sync(self) -> None: picked = None if ws.move is None else m.moves.get(ws.move) self.delete.setEnabled(picked is not None) self.editor.setVisible(picked is not None) + if picked is None: + self._close_picker() if picked is not None and ws.move is not None: self._sync_move(ws.move, picked) self.rules.sync(self._checks()) @@ -345,8 +466,22 @@ def _sync_move(self, name: str, mv: Move) -> None: kit.put(self.name, name) if own: self._sync_own(name, mv) + self._sync_sequence(name, own) self._findings(name) + def _sync_sequence(self, name: str, own: bool) -> None: + ws = self.ws + steps = ws.sequence_of(name) + i = steps.index(name) + self.chips.show_steps([ws.step_of(n) for n in steps], name) + for w in (self.add_step, self.change_clip, self.remove_step): + w.setEnabled(own) + self.earlier.setEnabled(i > 0) + self.later.setEnabled(i < len(steps) - 1) + self.split.setEnabled(i > 0) + if self._picking is not None: + self._show_picker() + def _sync_own(self, name: str, mv: Move) -> None: ws, m = self.ws, self.ws.manifest assert m is not None diff --git a/apps/studio/src/mhfu_studio/monster/panels/sequence.py b/apps/studio/src/mhfu_studio/monster/panels/sequence.py new file mode 100644 index 0000000..e01b339 --- /dev/null +++ b/apps/studio/src/mhfu_studio/monster/panels/sequence.py @@ -0,0 +1,206 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2026 sp00ktober +"""What a sequence is made of on screen: the chips of its steps (Moves), the clip picker that +ranks what fits after a step (Moves), and the bar of its steps above the transport (Timeline).""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import TYPE_CHECKING + +from mhfu_port import continuity +from PySide6.QtCore import QEvent, QRectF, Qt, Signal +from PySide6.QtGui import QHelpEvent, QMouseEvent, QPainter, QPaintEvent +from PySide6.QtWidgets import QFrame, QScrollArea, QSizePolicy, QToolTip, QVBoxLayout, QWidget + +from mhfu_studio.monster.sequences import BY, PickRow, Step +from mhfu_studio.shell.overlay import Ink +from mhfu_studio.ui import kit, theme + +if TYPE_CHECKING: + from mhfu_studio.monster.workspace import MonsterWorkspace + from mhfu_studio.shell.studio import Studio + +BAR_H = 20.0 +#: the least width of a step on the bar, and the gap between two +MIN_STEP, GAP = 14.0, 2.0 +PAIR_TIP = "rides the base monster's pair: its place in the sequence is edited in Actions" +PICKER_ROWS = 8 + + +def frames_text(frames: int | None) -> str: + return "" if frames is None else f"{frames}f" + + +def step_tip(s: Step) -> str: + lines = [s.name, f"plays {s.clip}" if s.clip else "plays no clip", s.label] + return "\n".join([x for x in lines if x] + ([] if s.own else [PAIR_TIP])) + + +class StepChips(QScrollArea): + """One chip per step, clip name and frames; the picked one lit. A click picks a step.""" + + picked = Signal(str) + + def __init__(self) -> None: + super().__init__() + self.setFrameShape(QFrame.Shape.NoFrame) + self.setWidgetResizable(True) + self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self._key: object = None + self.chips: kit.Segmented | None = None + + def show_steps(self, steps: Sequence[Step], picked: str | None) -> None: + key = tuple(steps) + if not steps: + return + if key != self._key: + self._key = key + self.chips = kit.Segmented( + [ + (s.name, " ".join(filter(None, [s.clip or s.name, frames_text(s.frames)]))) + for s in steps + ], + tip="The steps of this sequence, in the order they play: click one to pick it", + on=self.picked.emit, + tips={s.name: step_tip(s) for s in steps}, + ) + self.setWidget(self.chips) + self.setFixedHeight( + self.chips.sizeHint().height() + self.horizontalScrollBar().sizeHint().height() + 4 + ) + if self.chips is not None and picked is not None: + self.chips.set(picked) + + +class ClipPicker(QWidget): + """The manifest's clips as the next step, best fit first: a row picked is `picked(name)`.""" + + picked = Signal(str) + cancelled = Signal() + COLUMNS = ("Clip", "Shows", "Frames", "Fit", "Used in") + + def __init__(self, ws: MonsterWorkspace, studio: Studio) -> None: + super().__init__() + self.ws = ws + self.after: str | None = None + lay = QVBoxLayout(self) + lay.setContentsMargins(0, 0, 0, 0) + lay.setSpacing(6) + self.title = kit.label(role="caps", wrap=False) + self.by = kit.Segmented( + list(BY), + tip="Fits after: the clips whose first pose continues where the clip before ends," + " best first. Neighbours: the clips numbered next to it in the original.", + on=lambda v: studio.act("order clips", lambda: setattr(ws, "pick_by", v))(), + current=ws.pick_by, + ) + self.cancel = kit.button( + "Cancel", + tip="Closes the list without changing the sequence", + on=self.cancelled.emit, + icon="ph.x", + ) + self.note = kit.label(role="muted") + self.table = kit.Table( + list(self.COLUMNS), + tip="Click a clip to use it. Fit is the mean angle between the two poses where the" + " clips meet: the lower, the smoother the join. Used in names the moves playing it.", + ) + self.table.picked.connect(lambda n: self.picked.emit(str(n))) + for w in (kit.row(self.title, self.by, self.cancel, stretch=True), self.note, self.table): + lay.addWidget(w) + + def show_after(self, after: str | None, title: str) -> None: + """Ranks the clips as a step after clip `after` (None: unranked).""" + self.after = after + self.title.setText(title) + self.refresh() + + def refresh(self) -> None: + rows = self.ws.pick_rows(self.after) + kit.put(self.by, self.ws.pick_by) + rated = any(r.fit is not None for r in rows) + self.note.setText( + "" if rated else self.ws.ends_note or "No clip before this one to rank by" + ) + self.note.setVisible(not rated) + self.table.set_rows( + [self._cells(r) for r in rows], + [r.name for r in rows], + tips=[f"{r.name}\n{r.label}".strip() for r in rows], + ) + self.table.fit(PICKER_ROWS) + + @staticmethod + def _cells(r: PickRow) -> tuple[str, ...]: + fit = "·" if r.fit is None else f"{r.fit:.0f}° {continuity.word(r.fit)}" + return r.name, r.label, frames_text(r.frames), fit, ", ".join(r.used) + + +class SequenceBar(QWidget): + """The picked move's sequence as one bar, each step as long as its clip; the picked step lit. + A click picks a step.""" + + def __init__(self, ws: MonsterWorkspace, studio: Studio) -> None: + super().__init__() + self.ws, self.studio = ws, studio + self.steps: list[Step] = [] + self.picked: str | None = None + self.setMouseTracking(True) + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.setFixedHeight(int(BAR_H)) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + self.setToolTip( + "The steps of the picked move's sequence, as long as their clips: click one" + ) + + def show_steps(self, steps: list[Step], picked: str | None) -> None: + if (steps, picked) != (self.steps, self.picked): + self.steps, self.picked = list(steps), picked + self.update() + + def segments(self) -> list[tuple[Step, float, float]]: + """Each step with its left and right edge on the bar.""" + weights = [float(s.frames or 1) for s in self.steps] + room = max(self.width() - GAP * (len(weights) - 1), 1.0) + out, x = [], 0.0 + for s, w in zip(self.steps, weights, strict=True): + span = max(room * w / sum(weights), MIN_STEP) + out.append((s, x, x + span)) + x += span + GAP + return out + + def step_at(self, x: float) -> Step | None: + return next((s for s, a, b in self.segments() if a <= x <= b), None) + + def paintEvent(self, e: QPaintEvent) -> None: # noqa: N802 + p = QPainter(self) + p.setRenderHint(QPainter.RenderHint.Antialiasing) + font = p.font() + font.setPixelSize(10) + p.setFont(font) + for s, a, b in self.segments(): + lit = s.name == self.picked + p.setPen(Qt.PenStyle.NoPen) + p.setBrush(theme.color(Ink.HOT) if lit else theme.color(theme.current().view)) + p.drawRoundedRect(QRectF(a, 0, b - a, BAR_H), 3, 3) + p.setPen(theme.color(theme.current().view) if lit else theme.color(Ink.TEXT)) + text = p.fontMetrics().elidedText(s.name, Qt.TextElideMode.ElideRight, int(b - a - 8)) + p.drawText(QRectF(a + 4, 0, b - a - 8, BAR_H), Qt.AlignmentFlag.AlignVCenter, text) + p.end() + + def event(self, e: QEvent) -> bool: + if e.type() == QEvent.Type.ToolTip and isinstance(e, QHelpEvent): + s = self.step_at(e.x()) + if s is None: + QToolTip.hideText() + else: + QToolTip.showText(e.globalPos(), step_tip(s), self) + return True + return super().event(e) + + def mousePressEvent(self, e: QMouseEvent) -> None: # noqa: N802 + s = self.step_at(e.position().x()) + if e.button() == Qt.MouseButton.LeftButton and s is not None: + self.studio.act("pick step", lambda: self.ws.select_move(s.name))() diff --git a/apps/studio/src/mhfu_studio/monster/panels/timeline.py b/apps/studio/src/mhfu_studio/monster/panels/timeline.py index 2be1ab6..350a3cf 100644 --- a/apps/studio/src/mhfu_studio/monster/panels/timeline.py +++ b/apps/studio/src/mhfu_studio/monster/panels/timeline.py @@ -1,7 +1,7 @@ # SPDX-License-Identifier: GPL-3.0-or-later # SPDX-FileCopyrightText: 2026 sp00ktober """The Timeline: the transport at the engine's rate, the base monster's action's frames on the -clip, and an own move's attack windows under it. +clip, and an own move's attack windows under it; above them, the steps of its sequence. The RATE is the action's (one monster plays some actions at 2.0, others at 2.4); the clip owns only its SPAN. A gate past the clip's last frame never runs: the strip runs on past the end, @@ -23,6 +23,7 @@ from mhfu_studio.monster.align import EFFECT, GATE, IMPACT, OURS, WINDOW, Marker from mhfu_studio.monster.authoring import EDGE_PX from mhfu_studio.monster.panels.common import ATTACK, MARKERS +from mhfu_studio.monster.panels.sequence import SequenceBar from mhfu_studio.monster.panels.widgets import NoScene from mhfu_studio.monster.render.playback import GAME_HZ, OBSERVED_SPEEDS from mhfu_studio.shell.overlay import Ink @@ -409,6 +410,8 @@ def __init__(self, ws: MonsterWorkspace, studio: Studio) -> None: page = QWidget() lay = QVBoxLayout(page) lay.setContentsMargins(0, 0, 0, 0) + self.steps = SequenceBar(ws, studio) + lay.addWidget(self.steps) lay.addWidget( kit.row( self.rewind, self.back, self.play, self.fwd, self.frame, self.loop, @@ -487,6 +490,7 @@ def sync(self) -> None: kit.put(self.in_place, vp.strip_root) kit.put(self.speed, pb.speed) self._show_phase() + self._sequence() self._attacks() self.hint.setVisible(not ws.markers and ws.own_move_on_screen() is None) self.timing.setText( @@ -507,6 +511,13 @@ def sync(self) -> None: else: self.timer.stop() + def _sequence(self) -> None: + """The picked move's steps above the transport, when it has company.""" + ws = self.ws + steps = ws.sequence_of() + self.steps.setVisible(len(steps) > 1) + self.steps.show_steps([ws.step_of(n) for n in steps] if len(steps) > 1 else [], ws.move) + def _attacks(self) -> None: """The lanes and the picked attack's row, while an own move's clip is on screen.""" ws = self.ws @@ -545,7 +556,10 @@ def _show_phase(self) -> None: self._show_live() def _tick(self) -> None: - """The playhead follows playback; a stop by itself (the end, no loop) re-syncs.""" + """The playhead follows playback; a stop by itself (the end, no loop) re-syncs, and so + does a played sequence going on to its next step.""" + if self.ws.take_stepped(): + self.studio.changed() vp = self.ws.vp if vp is None or vp.clip is None or not vp.playback.playing: self.timer.stop() diff --git a/apps/studio/src/mhfu_studio/monster/sequences.py b/apps/studio/src/mhfu_studio/monster/sequences.py new file mode 100644 index 0000000..9263630 --- /dev/null +++ b/apps/studio/src/mhfu_studio/monster/sequences.py @@ -0,0 +1,183 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2026 sp00ktober +"""A port's sequences (`mhfu_port.sequence`) as document edits, each as a sentence. + +No toolkit: the Moves panel calls these through the workspace, one undo step per edit; a refusal +is a `ManifestError` saying why. A pair move may be a step but is never edited here: an edit +that would change its `after` is refused. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from mhfu_port import continuity, sequence +from mhfu_port.continuity import Ends +from mhfu_port.manifest import Manifest, ManifestError, Move, Steer + +from mhfu_studio.monster import authoring, clips +from mhfu_studio.monster.document import PortDocument + +#: the clip picker's orders: by fit after a clip, by the source numbers near it +BY = (("fit", "Fits after"), ("near", "Neighbours")) + + +@dataclass(frozen=True) +class Step: + """A move of a sequence as the strip and the bar show it.""" + + name: str + clip: str | None + frames: int | None + label: str + own: bool + + +@dataclass(frozen=True) +class PickRow: + """A clip of the manifest offered as a step.""" + + name: str + label: str + frames: int | None + fit: float | None + """Degrees from the end of the clip before; None: not rated.""" + used: tuple[str, ...] + + +def used_in(m: Manifest, clip: str) -> list[str]: + """The moves that play clip `clip`.""" + return [n for n, mv in m.moves.items() if mv.clip == clip] + + +def _place(m: Manifest, name: str) -> tuple[list[str], int]: + """The steps of the sequence `name` is in, and its place among them.""" + steps = sequence.chain(m, sequence.head_of(m, name)) + return steps, steps.index(name) + + +def _own(m: Manifest, names: list[str]) -> None: + for n in names: + authoring.own(m, n) + + +def _clip(m: Manifest, clip: str) -> str: + if clip not in m.clips: + raise ManifestError(f"no clip {clip!r}") + return clip + + +def append_step(doc: PortDocument, after_move: str, clip: str) -> str: + """A new own move playing `clip`, handed to when `after_move` ends and handing on to what + `after_move` handed to. It rides its sequence's head's carrier and stops for no wall.""" + m = doc.manifest + _own(m, [after_move]) + _clip(m, clip) + head = sequence.head_of(m, after_move) + name = sequence.step_name(m, head) + carrier = m.moves[head].carrier + + def add(m: Manifest) -> None: + prev = m.moves[after_move] + step = Move(clip=clip, after=prev.after, carrier=carrier, steer=Steer(walls=False)) + prev.after = name + order = list(m.moves.items()) + order.insert(list(m.moves).index(after_move) + 1, (name, step)) + m.moves = dict(order) + + doc.edit(add) + return f"moves.{name}: a step after {after_move}, playing {clip}" + + +def remove_step(doc: PortDocument, name: str) -> str: + """Removes step `name`; the move before it hands to the move after it. Refused while a rule + or an effect names it, or two moves hand to it.""" + m = doc.manifest + _own(m, [name]) + users = clips.move_users(m, name) + before = [k for k, mv in m.moves.items() if mv.after == name] + if len(users) > len(before) or len(before) > 1: + raise ManifestError(f"{name} is still used by {', '.join(users)}: change that first") + _own(m, before) + then = m.moves[name].after + + def drop(m: Manifest) -> None: + for k in before: + m.moves[k].after = None if then == k else then + del m.moves[name] + + doc.edit(drop) + return f"moves.{name} removed" + (f"; {before[0]} hands to {then}" if before and then else "") + + +def move_step(doc: PortDocument, name: str, delta: int) -> str: + """Swaps step `name` with the step `delta` (-1 or 1) places on; the names stay.""" + m = doc.manifest + steps, i = _place(m, name) + j = i + delta + if abs(delta) != 1 or not 0 <= j < len(steps): + raise ManifestError(f"{name} is already the {'first' if delta < 0 else 'last'} step") + now = steps.copy() + now[i], now[j] = now[j], now[i] + tail = m.moves[steps[-1]].after + then: dict[str, str | None] = {n: now[k + 1] for k, n in enumerate(now[:-1])} + then[now[-1]] = None if tail is None else now[steps.index(tail)] + changed = [n for n in steps if m.moves[n].after != then[n]] + _own(m, changed) + + def swap(m: Manifest) -> None: + for n in changed: + m.moves[n].after = then[n] + + doc.edit(swap) + return f"{name} is now step {j + 1} of {len(steps)}" + + +def split(doc: PortDocument, name: str) -> str: + """Ends the sequence before step `name`, which heads one of its own.""" + m = doc.manifest + steps, i = _place(m, name) + if i == 0: + raise ManifestError(f"{name} already starts its sequence") + _own(m, [steps[i - 1]]) + + def cut(m: Manifest) -> None: + m.moves[steps[i - 1]].after = None + + doc.edit(cut) + return f"{name} starts a sequence of its own" + + +def set_clip(doc: PortDocument, name: str, clip: str) -> str: + """Step `name` plays clip `clip`.""" + m = doc.manifest + authoring.own(m, name) + _clip(m, clip) + + def play(m: Manifest) -> None: + m.moves[name].clip, m.moves[name].anim = clip, None + + doc.edit(play) + return f"moves.{name} plays {clip}" + + +def pick_rows(m: Manifest, e: Ends | None, after: str | None, by: str = "fit") -> list[PickRow]: + """The manifest's clips as steps to put after clip `after`, best first by `by` (`BY`); in + the manifest's order when nothing rates them. `e`: the donor's end poses.""" + ids = {n: c.id for n, c in m.clips.items()} + a = ids.get(after) if after is not None else None + fit: dict[int, float] = {} + if e is not None and a is not None and a in e.ids: + fit = dict(continuity.fits_after(e, a, itself=True)) + if a is None: + order: list[int] = [] + elif by == "fit": + order = list(fit) + else: + order = continuity.neighbours(a, ids.values()) + place = {i: k for k, i in enumerate(order)} + names = sorted(m.clips, key=lambda n: place.get(ids[n], len(place))) + return [ + PickRow(n, m.clips[n].label, m.clips[n].frames, fit.get(ids[n]), tuple(used_in(m, n))) + for n in names + ] diff --git a/apps/studio/src/mhfu_studio/monster/workspace.py b/apps/studio/src/mhfu_studio/monster/workspace.py index 1501eb9..a9e80ec 100644 --- a/apps/studio/src/mhfu_studio/monster/workspace.py +++ b/apps/studio/src/mhfu_studio/monster/workspace.py @@ -30,7 +30,7 @@ SpeciesIntel, ) from mhfu.files import Extracted -from mhfu_port import layout, slots +from mhfu_port import continuity, layout, sequence, slots from mhfu_port.data import Data from mhfu_port.manifest import MOVE_ATTACKS, AttackWindow, Manifest, ManifestError, Move from mhfu_port.manifest import Clip as ManifestClip @@ -49,6 +49,7 @@ inputs, move_game, rules, + sequences, species, ) from mhfu_studio.monster.attacks import AttackSession, hitbox_of @@ -202,6 +203,13 @@ def _reset(self) -> None: self.host_clip: int | None = None self.pair: Pair | None = None self.move: str | None = None + #: the step of a played sequence on screen (`play_sequence`) + self.playing_step: str | None = None + #: the played sequence went on to a step since the Timeline last looked + self._stepped = False + #: how the clip picker orders the clips (`sequences.BY`), and the Clips table (id, fit) + self.pick_by = "fit" + self.clip_order = "id" self.graph = MoveGraph() self.clip_filter = "" self.show_parts = False @@ -238,6 +246,10 @@ def _reset(self) -> None: self._browser: ClipBrowser | None = None #: why there is no browser self.browser_note = "" + #: the donor's end poses and what they were read for (`ends`) + self._ends: tuple[object, continuity.Ends | None] | None = None + #: why there are none + self.ends_note = "" self._previews: dict[int, SceneClip] = {} #: the scene and the layout it was built with (`_relayout`) self._built_for: Scene | None = None @@ -433,6 +445,7 @@ def frame(self, dt: float) -> None: self.sync() if self.vp is not None: self.vp.tick(dt) + self._advance_sequence() self.light_live() def animating(self) -> bool: @@ -792,7 +805,7 @@ def locate(self) -> None: self.intel_errors.clear() self.host_scenes.clear() self.hosts, self._survey = None, None - self._coverage = self._vocab = None + self._coverage = self._vocab = self._ends = None if self.doc is not None: self.doc.intel = self.host_intel() @@ -1211,12 +1224,16 @@ def select_move(self, name: str) -> None: self.clear_pair() self.move, self.picked_window, self.next_window_id = name, None, None self.graph.picked = None + self.play_move_clip(mv) + self.sync_steer() + + def play_move_clip(self, mv: Move) -> None: + """Plays `mv`'s clip from frame 0.""" slot = self.move_slot(mv) if slot is not None and slot < 0: self.play_source(-1 - slot) elif slot is not None: self.play_slot(slot) - self.sync_steer() def new_move(self, name: str = "") -> None: """An own move playing the clip on screen, named after it unless `name`; selected.""" @@ -1315,6 +1332,146 @@ def change(m: Manifest) -> None: vp.repose() return ok + # sequences (the Moves panel, the Timeline's bar, the Clips table) + + def sequence_of(self, name: str | None = None) -> list[str]: + """The moves of the sequence `name` (else the selected move) is in, in order.""" + m, name = self.manifest, name or self.move + if m is None or name is None or name not in m.moves: + return [] + return sequence.chain(m, sequence.head_of(m, name)) + + def step_of(self, name: str) -> sequences.Step: + """Move `name` as the strip and the bar show it: its clip's frames, else the scene's.""" + m = self.manifest + assert m is not None + mv = m.moves[name] + c = m.clips.get(mv.clip or "") + frames = None if c is None else c.frames + slot = self.move_slot(mv) + if frames is None and self.scene is not None and slot is not None and slot >= 0: + frames = next((k.frames for k in self.scene.clips if k.slot == slot), None) + return sequences.Step(name, mv.clip, frames, c.label if c else mv.label, mv.own) + + def ends(self) -> continuity.Ends | None: + """The donor's clips' end poses, read once per donor; None with `ends_note` saying why.""" + m = self.manifest + if m is None: + return None + key = (m.source, m.build) + if self._ends is None or self._ends[0] != key: + found, note = None, "" + try: + found = continuity.donor_ends(m, self.games()) + except (OSError, ValueError) as e: + note = f"no original moveset to rate the clips ({e})" + self._ends, self.ends_note = (key, found), note + return self._ends[1] + + def fits_after(self, cid: int | None = None) -> dict[int, float]: + """Degrees from the end of donor clip `cid` (else the one on screen) to the start of + each donor clip, best first; empty without the donor or the clip.""" + cid = self.playing_clip() if cid is None else cid + e = self.ends() + if e is None or cid is None or cid not in e.ids: + return {} + return dict(continuity.fits_after(e, cid, itself=True)) + + def pick_rows(self, after: str | None) -> list[sequences.PickRow]: + """The clips of the manifest as a step after clip `after`, in the picker's order.""" + m = self.manifest + return [] if m is None else sequences.pick_rows(m, self.ends(), after, self.pick_by) + + def add_step(self, clip: str) -> None: + """A step playing `clip` after the selected move; selected.""" + doc, m, after = self.doc, self.manifest, self.move + if doc is None or m is None or after is None: + return + new = sequence.step_name(m, sequence.head_of(m, after)) + if self.edit("", lambda: sequences.append_step(doc, after, clip)): + self.select_move(new) + + def remove_step(self) -> None: + """The selected step goes; the one before it, else after it, is selected.""" + doc, name, steps = self.doc, self.move, self.sequence_of() + if doc is None or name not in steps: + return + i = steps.index(name) + near = steps[i - 1] if i else steps[1] if len(steps) > 1 else None + if self.edit("", lambda: sequences.remove_step(doc, name)): + if near is not None: + self.select_move(near) + else: + self.move = None + self.sync_steer() + + def move_step(self, delta: int) -> None: + """The selected step one place earlier (-1) or later (1).""" + doc, name = self.doc, self.move + if doc is not None and name is not None: + self.edit("", lambda: sequences.move_step(doc, name, delta)) + + def split_step(self) -> None: + """The selected step starts a sequence of its own.""" + doc, name = self.doc, self.move + if doc is not None and name is not None: + self.edit("", lambda: sequences.split(doc, name)) + + def set_step_clip(self, clip: str) -> None: + """The selected step plays `clip` instead, from its start.""" + doc, name = self.doc, self.move + if ( + doc is not None + and name is not None + and self.edit("", lambda: sequences.set_clip(doc, name, clip)) + ): + self.select_move(name) + + def play_sequence(self) -> None: + """Plays the selected move's sequence from its head, step after step.""" + steps = self.sequence_of() + if not steps: + self.message = "pick a move first" + return + self._play_step(steps[0]) + + def _play_step(self, name: str) -> None: + """Plays move `name` once through, turning as it steers.""" + m, vp = self.manifest, self.vp + assert m is not None + mv = m.moves[name] + self.select_move(name) + if mv.pair is not None: + self.play_move_clip(mv) + if vp is not None and vp.clip is not None: + vp.playback.loop = False + vp.playback.play() + self.playing_step = name + + def _advance_sequence(self) -> None: + """The played sequence on to the step its step hands to once that has ended: the clip + played out, or the move's Length reached. Something else picked or played stops it.""" + m, vp, name = self.manifest, self.vp, self.playing_step + mv = None if m is None or name is None else m.moves.get(name) + if mv is None or vp is None or vp.clip is None: + self.playing_step = None + return + if self.move != name or self.move_slot(mv) != vp.clip.slot: + self.playing_step = None + return + pb = vp.playback + if not (pb.at_end or (mv.length and pb.phase >= mv.length * pb.speed)): + return + self.playing_step = None + self._stepped = True + if mv.after is not None: + self._play_step(mv.after) + + def take_stepped(self) -> bool: + """Whether the played sequence went on (or ended) since the last ask.""" + stepped, self._stepped = self._stepped, False + return stepped + # the attack windows of the selected own move def window_frames(self) -> int: @@ -1659,7 +1816,8 @@ def _anim_row(self, slot: int) -> SourceClip: assert self.scene is not None c, found = self.scene.clip(slot), self.manifest_clip(slot) name, label = (found[0], found[1].label) if found else ("", "") - return SourceClip(None, slot, c.frames, c.loop, name, label) + used = tuple(sequences.used_in(self.manifest, name)) if name and self.manifest else () + return SourceClip(None, slot, c.frames, c.loop, name, label, used) def source_clip(self, cid: int) -> SceneClip: """What plays donor clip `cid`: the open build's anim holding it, else its preview.""" diff --git a/apps/studio/tests/monster/test_sequences.py b/apps/studio/tests/monster/test_sequences.py new file mode 100644 index 0000000..2315634 --- /dev/null +++ b/apps/studio/tests/monster/test_sequences.py @@ -0,0 +1,238 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2026 sp00ktober +"""Sequences as document edits, over manifests of own moves.""" + +from __future__ import annotations + +from collections.abc import Callable + +import numpy as np +import pytest +from mhfu_port import continuity, sequence +from mhfu_port.manifest import Manifest, ManifestError, Move, Steer +from mhfu_studio.monster import sequences +from mhfu_studio.monster.document import PortDocument + +LOOP = '[moves.topple_3]\nclip = "kick"' +BODY = """ +[clips.walk] +slot = 1 +frames = 10 +label = "walks" +[clips.roar] +slot = 2 +frames = 6 +[clips.kick] +slot = 3 +[moves.charge] +main = 1 +sub = 4 +clip = "walk" +after = "stamp" +[moves.stamp] +clip = "roar" +after = "stamp_2" +[moves.stamp_2] +clip = "kick" +[moves.topple] +clip = "walk" +carrier = [0, 3] +after = "topple_2" +[moves.topple_2] +clip = "roar" +after = "topple_3" +[moves.topple_3] +clip = "kick" +[moves.solo] +clip = "roar" +""" +Open = Callable[[str], PortDocument] + + +def chain(d: PortDocument, head: str = "topple") -> list[str]: + return sequence.chain(d.manifest, head) + + +@pytest.fixture +def seq(doc: Open) -> PortDocument: + return doc(BODY) + + +def undone(d: PortDocument, before: Manifest) -> bool: + d.undo() + return d.manifest == before + + +def test_append_in_the_middle(seq: PortDocument) -> None: + before = seq.manifest + msg = sequences.append_step(seq, "topple_2", "kick") + assert msg == "moves.topple_4: a step after topple_2, playing kick" + assert chain(seq) == ["topple", "topple_2", "topple_4", "topple_3"] + step = seq.manifest.moves["topple_4"] + assert (step.clip, step.carrier, step.steer) == ("kick", (0, 3), Steer(walls=False)) + names = list(seq.manifest.moves) + assert names.index("topple_4") == names.index("topple_2") + 1, "it sits by its predecessor" + assert undone(seq, before) + + +def test_append_at_the_end_and_to_a_lone_move(seq: PortDocument) -> None: + sequences.append_step(seq, "topple_3", "walk") + assert chain(seq)[-2:] == ["topple_3", "topple_4"] + assert seq.manifest.moves["topple_4"].after is None + sequences.append_step(seq, "solo", "walk") + assert chain(seq, "solo") == ["solo", "solo_2"] + assert seq.manifest.moves["solo_2"].carrier is None, "solo has none to copy" + sequences.append_step(seq, "stamp_2", "walk") + assert chain(seq, "charge")[-2:] == ["stamp_2", "charge_2"], "named after the head, a pair" + + +def test_append_keeps_a_loop(doc: Open) -> None: + d = doc(BODY.replace(LOOP, LOOP + '\nafter = "topple_2"')) + sequences.append_step(d, "topple_3", "walk") + assert chain(d) == ["topple", "topple_2", "topple_3", "topple_4"] + assert d.manifest.moves["topple_4"].after == "topple_2" and sequence.loops(d.manifest, "topple") + + +def test_append_refusals(seq: PortDocument) -> None: + before = seq.manifest + with pytest.raises(ManifestError, match="not an own move"): + sequences.append_step(seq, "charge", "walk") + with pytest.raises(ManifestError, match="no clip 'nope'"): + sequences.append_step(seq, "solo", "nope") + with pytest.raises(ManifestError, match="no move"): + sequences.append_step(seq, "ghost", "walk") + assert seq.manifest is before and not seq.can_undo() + + +def test_remove_relinks(seq: PortDocument) -> None: + before = seq.manifest + msg = sequences.remove_step(seq, "topple_2") + assert msg == "moves.topple_2 removed; topple hands to topple_3" + assert chain(seq) == ["topple", "topple_3"] and undone(seq, before) + sequences.remove_step(seq, "topple_3") + assert seq.manifest.moves["topple_2"].after is None + + +def test_remove_a_head_and_a_ring(doc: Open) -> None: + d = doc(BODY) + sequences.remove_step(d, "topple") + assert sequence.heads(d.manifest) == ["charge", "topple_2", "solo"] + ring = doc( + '[clips.walk]\nslot = 1\n[clips.roar]\nslot = 2\n[moves.a]\nclip = "walk"\nafter = "b"\n' + '[moves.b]\nclip = "roar"\nafter = "a"\n' + ) + sequences.remove_step(ring, "b") + assert ring.manifest.moves["a"].after is None, "a ring of two leaves a lone move" + + +def test_remove_refusals(seq: PortDocument) -> None: + with pytest.raises(ManifestError, match="not an own move"): + sequences.remove_step(seq, "charge") + with pytest.raises(ManifestError, match="rides"): + sequences.remove_step(seq, "stamp") # the move before it is a pair move + seq.edit(lambda m: m.moves.__setitem__("solo", Move(clip="roar", after="topple_3"))) + with pytest.raises(ManifestError, match="still used by .*solo"): + sequences.remove_step(seq, "topple_3") # two moves hand to it + + +def test_remove_refused_while_a_rule_or_effect_names_it(doc: Open) -> None: + d = doc(BODY + '[[rule]]\nplay = "topple_2"\non = "noticed"\n') + with pytest.raises(ManifestError, match="still used by .*rule 0"): + sequences.remove_step(d, "topple_2") + d = doc(BODY + '[[effect]]\nmove = "solo"\nframe = 1\nid = 5\nbone = 1\n') + with pytest.raises(ManifestError, match="still used by effect 5"): + sequences.remove_step(d, "solo") + + +def test_move_swaps_neighbours(seq: PortDocument) -> None: + before = seq.manifest + assert sequences.move_step(seq, "topple_2", 1) == "topple_2 is now step 3 of 3" + assert chain(seq) == ["topple", "topple_3", "topple_2"] + assert sorted(seq.manifest.moves) == sorted(before.moves), "no name changes" + assert undone(seq, before) + sequences.move_step(seq, "topple_2", -1) + assert chain(seq, "topple_2") == ["topple_2", "topple", "topple_3"] + assert sequence.heads(seq.manifest) == ["charge", "topple_2", "solo"] + + +def test_move_keeps_a_loop(doc: Open) -> None: + d = doc(BODY.replace(LOOP, LOOP + '\nafter = "topple_2"')) + sequences.move_step(d, "topple_2", 1) + assert chain(d) == ["topple", "topple_3", "topple_2"] + assert d.manifest.moves["topple_2"].after == "topple_3", "the loop still goes to step 2" + + +def test_move_refusals(seq: PortDocument) -> None: + with pytest.raises(ManifestError, match="already the last step"): + sequences.move_step(seq, "topple_3", 1) + with pytest.raises(ManifestError, match="already the first step"): + sequences.move_step(seq, "topple", -1) + with pytest.raises(ManifestError, match="not an own move"): + sequences.move_step(seq, "stamp", -1) # the pair move's `after` would change + with pytest.raises(ManifestError, match="already the last"): + sequences.move_step(seq, "solo", 1) + with pytest.raises(ManifestError, match="already"): + sequences.move_step(seq, "topple_2", 2) + + +def test_split(seq: PortDocument) -> None: + before = seq.manifest + assert sequences.split(seq, "topple_2") == "topple_2 starts a sequence of its own" + assert sequence.heads(seq.manifest) == ["charge", "topple", "topple_2", "solo"] + assert chain(seq) == ["topple"] and chain(seq, "topple_2") == ["topple_2", "topple_3"] + assert undone(seq, before) + with pytest.raises(ManifestError, match="already starts"): + sequences.split(seq, "topple") + with pytest.raises(ManifestError, match="not an own move"): + sequences.split(seq, "stamp") + + +def test_set_clip(seq: PortDocument) -> None: + before = seq.manifest + assert sequences.set_clip(seq, "topple_2", "walk") == "moves.topple_2 plays walk" + assert seq.manifest.moves["topple_2"].clip == "walk" and undone(seq, before) + with pytest.raises(ManifestError, match="no clip"): + sequences.set_clip(seq, "topple_2", "nope") + with pytest.raises(ManifestError, match="not an own move"): + sequences.set_clip(seq, "charge", "roar") + + +def test_used_in(seq: PortDocument) -> None: + m = seq.manifest + assert sequences.used_in(m, "walk") == ["charge", "topple"] + assert sequences.used_in(m, "roar") == ["stamp", "topple_2", "solo"] + assert sequences.used_in(m, "kick") == ["stamp_2", "topple_3"] + + +def ends() -> continuity.Ends: + """Clips 1, 2, 3 and 9 with one joint each: 2 starts where 1 ends, 3 a quarter turn off, 9 a + half turn.""" + turn = np.array([[0.0, -1, 0], [1, 0, 0], [0, 0, 1]]) + half = np.diag([-1.0, 1, -1]) + eye = np.eye(3) + first = np.stack([eye, eye, turn, half])[:, None] + return continuity.Ends((1, 2, 3, 9), first, np.stack([eye] * 4)[:, None]) + + +def test_pick_rows_rank_by_fit(seq: PortDocument) -> None: + rows = sequences.pick_rows(seq.manifest, ends(), "walk") + assert [r.name for r in rows] == ["walk", "roar", "kick"], "clips 1, 2, 3" + assert [None if r.fit is None else round(r.fit) for r in rows] == [0, 0, 90] + assert rows[0].used == ("charge", "topple") and rows[0].label == "walks" + assert rows[1].frames == 6 and rows[1].used == ("stamp", "topple_2", "solo") + worse = sequences.pick_rows(seq.manifest, ends(), "kick") + assert [r.name for r in worse] == ["roar", "walk", "kick"], "kick ends where 1 and 2 begin" + + +def test_pick_rows_by_neighbours_and_unrated(seq: PortDocument) -> None: + m = seq.manifest + near = sequences.pick_rows(m, ends(), "kick", "near") + assert [r.name for r in near] == ["roar", "walk", "kick"] and near[0].fit == pytest.approx(0) + bare = sequences.pick_rows(m, None, "roar") + assert [r.name for r in bare] == ["walk", "roar", "kick"] and {r.fit for r in bare} == {None} + assert [r.name for r in sequences.pick_rows(m, None, "roar", "near")] == [ + "walk", + "kick", + "roar", + ] + assert [r.name for r in sequences.pick_rows(m, ends(), None)] == ["walk", "roar", "kick"] diff --git a/apps/studio/tests/monster/ui/test_sequences.py b/apps/studio/tests/monster/ui/test_sequences.py new file mode 100644 index 0000000..e1f5b9a --- /dev/null +++ b/apps/studio/tests/monster/ui/test_sequences.py @@ -0,0 +1,332 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2026 sp00ktober +"""Sequences on screen: the Moves strip and picker, the Timeline's bar, playing a chain, the Clips +sort, over the synthetic port.""" + +from pathlib import Path +from typing import Any + +import numpy as np +import pytest +from mhfu_port import continuity +from mhfu_port.manifest import Clip +from mhfu_studio.monster.clip_browser import SourceClip +from mhfu_studio.monster.panels.clips import ClipsPanel +from mhfu_studio.monster.panels.moves import MovesPanel +from mhfu_studio.monster.panels.timeline import TimelinePanel +from mhfu_studio.monster.workspace import MonsterWorkspace +from mhfu_studio.shell.studio import Studio +from mhfu_studio.ui import kit +from PySide6.QtCore import QPoint, Qt +from PySide6.QtTest import QTest + + +def show(qtbot: Any, ws: MonsterWorkspace, cls: Any, size: tuple[int, int]) -> Any: + """A panel shown and synced on every change, as the window would.""" + studio = Studio([ws]) + p = cls(ws, studio) + studio.listen(p.sync) + qtbot.addWidget(p) + p.resize(*size) + p.show() + qtbot.waitExposed(p) + p.sync() + return p + + +@pytest.fixture +def ws(workspace: MonsterWorkspace) -> MonsterWorkspace: + """The synthetic port with clips walk (anim 1, 10 frames) and roar (anim 2, 6) and the own + moves a (walk), a_2 (roar), a_3 (walk), a sequence; a_3 selected.""" + assert workspace.doc is not None + workspace.doc.edit(lambda m: m.clips.__setitem__("roar", Clip(slot=2, frames=6))) + workspace.sync() + workspace.play_slot(1) + workspace.new_move("a") + workspace.add_step("roar") + workspace.add_step("walk") + assert workspace.sequence_of() == ["a", "a_2", "a_3"] and workspace.move == "a_3" + return workspace + + +@pytest.fixture +def moves(qtbot: Any, ws: MonsterWorkspace) -> MovesPanel: + return show(qtbot, ws, MovesPanel, (460, 1000)) + + +def names(p: MovesPanel) -> list[str]: + t = p.table + return [t.item(r, 0).text() for r in range(t.rowCount())] + + +def poses(*turns: float) -> continuity.Ends: + """Clips 1, 2, ... each starting turned by `turns` degrees about x and ending at rest.""" + rad = np.radians(turns) + c, s = np.cos(rad), np.sin(rad) + first = np.zeros((len(turns), 1, 3, 3)) + first[:, 0] = np.eye(3) + first[:, 0, 1, 1], first[:, 0, 1, 2], first[:, 0, 2, 1], first[:, 0, 2, 2] = c, -s, s, c + last = np.broadcast_to(np.eye(3), first.shape).copy() + return continuity.Ends(tuple(range(1, len(turns) + 1)), first, last) + + +def test_the_table_groups_a_sequence(moves: MovesPanel) -> None: + assert names(moves) == ["charge", "a · 3 steps", " └ a_2", " └ a_3"] + assert moves.table.item(1, 0).data(Qt.ItemDataRole.UserRole) == "a" + assert moves.table.currentRow() == 3, "the picked step" + assert kit.missing_tips(moves) == [] + + +def test_the_strip_shows_the_chain(moves: MovesPanel, ws: MonsterWorkspace) -> None: + chips = moves.chips.chips + assert chips is not None and list(chips.buttons) == ["a", "a_2", "a_3"] + assert [b.text() for b in chips.buttons.values()] == ["walk 10f", "roar 6f", "walk 10f"] + assert chips.value == "a_3" + chips.buttons["a_2"].click() + assert ws.move == "a_2" and chips.value == "a_2" and ws.vp.clip.slot == 2 # type: ignore[union-attr] + moves.table.picked.emit("a") + assert chips.value == "a" + assert kit.missing_tips(moves) == [] + + +def test_the_strip_of_a_pair_move_is_read_only(moves: MovesPanel, ws: MonsterWorkspace) -> None: + moves.table.picked.emit("charge") + assert list(moves.chips.chips.buttons) == ["charge"] # type: ignore[union-attr] + assert not moves.add_step.isEnabled() and not moves.remove_step.isEnabled() + assert not moves.change_clip.isEnabled() + assert not moves.earlier.isEnabled() and not moves.split.isEnabled() + + +def test_add_a_step_through_the_picker( + moves: MovesPanel, ws: MonsterWorkspace, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(ws, "ends", lambda: poses(0.0, 90.0)) + moves.table.picked.emit("a") + assert not moves.picker.isVisible() + moves.add_step.click() + t = moves.picker.table + cells = [[t.item(r, c).text() for c in range(t.columnCount())] for r in range(t.rowCount())] + assert cells == [ + ["walk", "", "10f", "0° good", "charge, a, a_3"], + ["roar", "", "6f", "90° poor", "a_2"], + ] + assert moves.picker.title.text() == "A step after a" + moves.picker.by.buttons["near"].click() + assert ws.pick_by == "near" and t.item(0, 0).text() == "roar", "clip 2 is next to clip 1" + before = ws.manifest + moves.picker.table.picked.emit("roar") + assert ws.sequence_of() == ["a", "a_4", "a_2", "a_3"] and ws.move == "a_4" + assert not moves.picker.isVisible() and moves.chips.chips.value == "a_4" # type: ignore[union-attr] + assert ws.manifest is not before and ws.manifest.moves["a_4"].clip == "roar" # type: ignore[union-attr] + ws.doc.undo() # type: ignore[union-attr] + ws.refresh() + assert ws.sequence_of("a") == ["a", "a_2", "a_3"], "one undo step" + + +def test_the_picker_cancels_and_follows_the_move(moves: MovesPanel, ws: MonsterWorkspace) -> None: + moves.add_step.click() + assert moves.picker.isVisible() and moves.picker.note.isVisible(), "no donor to rate by" + moves.picker.cancel.click() + assert not moves.picker.isVisible() + moves.add_step.click() + moves.table.picked.emit("a") + assert not moves.picker.isVisible(), "another step was picked" + + +def test_change_the_clip_of_a_step(moves: MovesPanel, ws: MonsterWorkspace) -> None: + moves.table.picked.emit("a_2") + moves.change_clip.click() + assert moves.picker.title.text() == "The clip of a_2" + moves.picker.table.picked.emit("walk") + assert ws.manifest.moves["a_2"].clip == "walk" # type: ignore[union-attr] + assert moves.chips.chips.buttons["a_2"].text() == "walk 10f" # type: ignore[union-attr] + + +def test_earlier_later_split_remove(moves: MovesPanel, ws: MonsterWorkspace) -> None: + assert not moves.later.isEnabled() and moves.earlier.isEnabled() and moves.split.isEnabled() + moves.earlier.click() + assert ws.sequence_of() == ["a", "a_3", "a_2"] and ws.move == "a_3" + moves.later.click() + assert ws.sequence_of() == ["a", "a_2", "a_3"] + moves.table.picked.emit("a") + assert not moves.earlier.isEnabled() and not moves.split.isEnabled() + moves.table.picked.emit("a_2") + moves.split.click() + assert names(moves) == ["charge", "a", "a_2 · 2 steps", " └ a_3"] + moves.remove_step.click() + assert names(moves) == ["charge", "a", "a_3"] and ws.move == "a_3" + + +def test_play_sequence_button(moves: MovesPanel, ws: MonsterWorkspace) -> None: + moves.play_seq.click() + assert ws.playing_step == "a" and moves.chips.chips.value == "a" # type: ignore[union-attr] + + +def test_remove_picks_a_neighbour(moves: MovesPanel, ws: MonsterWorkspace) -> None: + moves.table.picked.emit("a_2") + moves.remove_step.click() + assert ws.sequence_of("a") == ["a", "a_3"] and ws.move == "a" + moves.table.picked.emit("a") + moves.remove_step.click() + assert ws.move == "a_3" + moves.remove_step.click() + assert ws.move is None and not moves.editor.isVisible() + + +def test_a_refusal_reaches_the_status(moves: MovesPanel, ws: MonsterWorkspace) -> None: + assert ws.doc is not None + ws.new_rule() + ws.set_rule(play="a_3") + moves.table.picked.emit("a_3") + moves.remove_step.click() + assert "still used by" in ws.message and ws.sequence_of() == ["a", "a_2", "a_3"] + + +# ---- the Timeline ---- + + +@pytest.fixture +def timeline(qtbot: Any, ws: MonsterWorkspace) -> TimelinePanel: + return show(qtbot, ws, TimelinePanel, (900, 320)) + + +def test_the_bar_shows_the_steps(timeline: TimelinePanel, ws: MonsterWorkspace) -> None: + bar = timeline.steps + assert bar.isVisible() and bar.picked == "a_3" + segs = bar.segments() + assert [s.name for s, _, _ in segs] == ["a", "a_2", "a_3"] + widths = [b - a for _, a, b in segs] + assert widths[1] < widths[0] and widths[0] == pytest.approx(widths[2]), "as long as the clips" + assert segs[2][2] == pytest.approx(bar.width()) + x = round((segs[1][1] + segs[1][2]) / 2) + QTest.mouseClick(bar, Qt.MouseButton.LeftButton, Qt.KeyboardModifier.NoModifier, QPoint(x, 5)) + assert ws.move == "a_2" and bar.picked == "a_2" + bar.grab() # paints + assert kit.missing_tips(timeline) == [] + + +def test_the_bar_hides_for_one_move(timeline: TimelinePanel, ws: MonsterWorkspace) -> None: + ws.select_move("charge") + timeline.sync() + assert not timeline.steps.isVisibleTo(timeline) + + +# ---- playing a sequence ---- + + +def test_a_sequence_plays_step_after_step(ws: MonsterWorkspace) -> None: + vp = ws.vp + assert vp is not None + ws.select_move("a_2") + ws.play_sequence() + assert ws.move == "a" and ws.playing_step == "a" and vp.clip.slot == 1 # type: ignore[union-attr] + assert vp.playback.playing and not vp.playback.loop, "a looping clip plays once" + ws.frame(0.05) + assert ws.playing_step == "a" and not ws.take_stepped() + for want, slot in (("a_2", 2), ("a_3", 1)): + ws.frame(1.0) + assert (ws.playing_step, ws.move, vp.clip.slot) == (want, want, slot) # type: ignore[union-attr] + assert vp.playback.playing and vp.playback.phase == 0.0 and ws.take_stepped() + ws.frame(1.0) + assert ws.playing_step is None and not vp.playback.playing and ws.take_stepped() + assert ws.move == "a_3" + + +def test_a_loop_plays_on(ws: MonsterWorkspace) -> None: + assert ws.set_move(after="a") + ws.select_move("a") + ws.play_sequence() + seen = [] + for _ in range(5): + ws.frame(1.0) + seen.append(ws.move) + assert seen == ["a_2", "a_3", "a", "a_2", "a_3"] + + +def test_the_length_ends_a_step(ws: MonsterWorkspace) -> None: + ws.select_move("a") + assert ws.set_move(length=2) # 2 AI frames at speed 2.0: clip frame 4 of 10 + ws.play_sequence() + ws.frame(0.1) # 3 game frames + assert ws.move == "a_2" + + +def test_picking_something_else_stops_it(ws: MonsterWorkspace) -> None: + ws.play_sequence() + ws.select_move("a_3") + ws.frame(1.0) + assert ws.playing_step is None and ws.move == "a_3" + ws.play_sequence() + ws.play_slot(2) + ws.frame(1.0) + assert ws.playing_step is None + + +def test_the_timeline_follows_the_sequence(timeline: TimelinePanel, ws: MonsterWorkspace) -> None: + ws.play_sequence() + timeline.sync() + assert timeline.timer.isActive() and timeline.steps.picked == "a" + ws.frame(1.0) + timeline._tick() + assert timeline.steps.picked == "a_2" and timeline.frame.text() == "frame 0.0 / 6" + + +# ---- the Clips table ---- + + +@pytest.fixture +def clips(qtbot: Any, ws: MonsterWorkspace, monkeypatch: pytest.MonkeyPatch) -> ClipsPanel: + """The Clips panel over three donor clips, 7 the one on screen: 9 fits after it best.""" + rows = [ + SourceClip(i, None, 10, False, n, "", used) + for i, n, used in ((5, "five", ()), (7, "seven", ("a",)), (9, "nine", ("a_2", "a_3"))) + ] + monkeypatch.setattr(ws, "source_rows", lambda: rows) + monkeypatch.setattr(ws, "browser", lambda: object()) + monkeypatch.setattr(ws, "playing_clip", lambda: 7) + monkeypatch.setattr(ws, "ends", lambda: poses(80.0, 30.0, 10.0, 0.0, 5.0, 90.0, 45.0, 0.0, 2.0)) + return show(qtbot, ws, ClipsPanel, (460, 800)) + + +def ids(p: ClipsPanel) -> list[str]: + return [p.table.item(r, 0).text() for r in range(p.table.rowCount())] + + +def test_the_clips_sort_by_fit(clips: ClipsPanel, ws: MonsterWorkspace) -> None: + p = clips + assert ids(p) == ["5", "7", "9"] and p.table.isColumnHidden(5) + assert [p.table.item(r, 6).text() for r in range(3)] == ["", "a", "a_2, a_3"] + p.order.buttons["fit"].click() + assert ws.clip_order == "fit" and not p.table.isColumnHidden(5) + assert ids(p) == ["9", "5", "7"], "clip 9 starts nearest where 7 ends" + fits = [p.table.item(r, 5).text() for r in range(3)] + assert fits == ["2° good", "5° good", "45° poor"] + assert "from the end of the clip on screen" in p.table.item(0, 0).toolTip() + p.order.buttons["id"].click() + assert ids(p) == ["5", "7", "9"] + assert kit.missing_tips(p) == [] + + +def test_the_sort_says_why_it_cannot(clips: ClipsPanel, ws: MonsterWorkspace) -> None: + ws.playing_clip = lambda: None # type: ignore[method-assign] + clips.order.buttons["fit"].click() + assert ids(clips) == ["5", "7", "9"] and clips.order_note.isVisibleTo(clips) + + +# ---- the Zinogre's donor (MHP3RD_DATA) ---- + + +def test_the_zinogre_backflip_sorts_to_its_next_part( + gl: Any, games: Any, qtbot: Any, zinogre_toml: Path +) -> None: + ws = MonsterWorkspace(games) + ws.intel_cache[75] = None + ws.setup(gl) + ws.open(zinogre_toml) + try: + p = show(qtbot, ws, ClipsPanel, (460, 800)) + ws.play_source(218) + p.order.buttons["fit"].click() + assert ids(p)[0] == "219" and p.table.item(0, 5).text().endswith("fair") + finally: + ws.close() diff --git a/packages/mhfu-port/src/mhfu_port/continuity.py b/packages/mhfu-port/src/mhfu_port/continuity.py index b791c6f..15793d9 100644 --- a/packages/mhfu-port/src/mhfu_port/continuity.py +++ b/packages/mhfu-port/src/mhfu_port/continuity.py @@ -26,8 +26,9 @@ from .motion import SOURCE_BANK GOOD, FAIR = 10.0, 20.0 -"""Degrees. A fifth of a donor's clip pairs lie within GOOD (clips that start and end on the same -stance); the Zinogre's backflip parts meet at 15, FAIR; beyond it the poses do not meet.""" +"""Degrees. An eighth to a fifth of a donor's clip pairs lie within GOOD (clips that start and +end on the same stance); the Zinogre's backflip parts meet at 14 to 15; past FAIR the poses do +not meet.""" Scores = list[tuple[int, float]] @@ -102,10 +103,11 @@ def donor_ends(m: Manifest, data: Data) -> Ends: return ends(d.clips, d.skeleton, build.record_map(d, m.build)) -def fits_after(e: Ends, a: int, *, top: int | None = None) -> Scores: - """Every clip but `a` as `(id, degrees)`, best first: how far its first pose is from the - last of `a`. Equal scores go nearest the id of `a` first. `top` keeps that many.""" - rows = [(i, float(s)) for i, s in zip(e.ids, e.scores(a), strict=True) if i != a] +def fits_after(e: Ends, a: int, *, top: int | None = None, itself: bool = False) -> Scores: + """Every clip but `a` (unless `itself`) as `(id, degrees)`, best first: how far its first + pose is from the last of `a`. Equal scores go nearest the id of `a` first. `top` keeps that + many.""" + rows = [(i, float(s)) for i, s in zip(e.ids, e.scores(a), strict=True) if itself or i != a] rows.sort(key=lambda r: (round(r[1], 3), abs(r[0] - a), r[0])) return rows if top is None else rows[:top] diff --git a/packages/mhfu-port/src/mhfu_port/sequence.py b/packages/mhfu-port/src/mhfu_port/sequence.py index 1ad187d..6df27f5 100644 --- a/packages/mhfu-port/src/mhfu_port/sequence.py +++ b/packages/mhfu-port/src/mhfu_port/sequence.py @@ -41,10 +41,20 @@ def heads(m: Manifest) -> list[str]: return sorted(out, key=order.index) +def groups(m: Manifest) -> dict[str, list[str]]: + """Each head and the moves of its chain that no earlier head's chain has.""" + out: dict[str, list[str]] = {} + seen: set[str] = set() + for h in heads(m): + out[h] = [n for n in chain(m, h) if n not in seen] + seen.update(out[h]) + return out + + def head_of(m: Manifest, name: str) -> str: """The head of the sequence `name` belongs to.""" - for h in heads(m): - if name in chain(m, h): + for h, steps in groups(m).items(): + if name in steps: return h raise ManifestError(f"no move {name!r}") diff --git a/packages/mhfu-port/tests/test_continuity.py b/packages/mhfu-port/tests/test_continuity.py index 1813e8c..c60cd42 100644 --- a/packages/mhfu-port/tests/test_continuity.py +++ b/packages/mhfu-port/tests/test_continuity.py @@ -4,7 +4,7 @@ from pathlib import Path import pytest -from mhfu_port import continuity, manifest +from mhfu_port import continuity, fk, manifest from mhfu_port.data import Data from mhp_formats.anim import Channel, Clip, Keyframe, Track, quantize from mhp_formats.skeleton import Bone, Skeleton @@ -77,6 +77,18 @@ def test_the_end_poses_are_the_clips_ends(): assert before_swing[1] == pytest.approx(0.0), "and begins where the rest ends" +def test_curves_stand_in_for_clips_and_an_unknown_clip_is_refused(): + clips = moveset(c1=(0, 0), c2=(0, 40)) + rig = fk.Rig.from_skeleton(skeleton()) + given = continuity.ends({i: fk.Curves(c, rig) for i, c in clips.items()}, skeleton()) + assert continuity.fits_after(given, 1) == continuity.fits_after( + continuity.ends(clips, skeleton()), 1 + ) + with pytest.raises(KeyError): + continuity.fits_after(given, 7) + assert continuity.ends({}, skeleton()).ids == () + + def test_words(): assert [continuity.word(x) for x in (0.0, 10.0, 15.0, 20.0, 31.0)] == [ "good", diff --git a/packages/mhfu-port/tests/test_sequence.py b/packages/mhfu-port/tests/test_sequence.py index 1704672..0104298 100644 --- a/packages/mhfu-port/tests/test_sequence.py +++ b/packages/mhfu-port/tests/test_sequence.py @@ -55,6 +55,7 @@ def test_a_join_belongs_to_the_first_head(): assert sequence.heads(m) == ["p", "q"] assert sequence.head_of(m, "join") == "p" and sequence.head_of(m, "q") == "q" assert sequence.chain(m, "q") == ["q", "join", "end"] + assert sequence.groups(m) == {"p": ["p", "join", "end"], "q": ["q"]} def test_step_name(): From 5cbb8fbac59bd4fb469fad8b07c6d9bfeded53f3 Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:52:47 +0200 Subject: [PATCH 3/3] feat(studio): a played sequence carries the body across its steps Each step starts where the one before stood: facing as it faced, its root on the ground where the root was. The carry is pose_at's own turn (yaw_at) and root, held on the actor and dropped whenever a clip is played, so picking or playing anything else starts clips where they normally start. The README describes sequences, the fit picker and the Clips sort. Co-Authored-By: Claude Sonnet 5.5 --- apps/studio/README.md | 15 +++++ .../mhfu_studio/monster/render/playback.py | 48 +++++++++++++- .../mhfu_studio/monster/render/viewport.py | 15 ++++- .../src/mhfu_studio/monster/workspace.py | 24 ++++++- .../tests/monster/render/test_playback.py | 23 +++++++ .../studio/tests/monster/ui/test_sequences.py | 63 +++++++++++++++++++ 6 files changed, 182 insertions(+), 6 deletions(-) diff --git a/apps/studio/README.md b/apps/studio/README.md index edfb32f..2a8c158 100644 --- a/apps/studio/README.md +++ b/apps/studio/README.md @@ -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 @@ -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 diff --git a/apps/studio/src/mhfu_studio/monster/render/playback.py b/apps/studio/src/mhfu_studio/monster/render/playback.py index e60dbbd..4bba060 100644 --- a/apps/studio/src/mhfu_studio/monster/render/playback.py +++ b/apps/studio/src/mhfu_studio/monster/render/playback.py @@ -11,6 +11,7 @@ from __future__ import annotations import math +from dataclasses import dataclass from typing import Protocol from weakref import WeakKeyDictionary @@ -170,6 +171,20 @@ def travel_joints(scene: Scene) -> tuple[int, ...]: return leading_chain(scene.rig.parents) or root_joints(scene) +@dataclass(frozen=True) +class Carry: + """Where a step of a played sequence starts: the body turned `yaw` radians about y and moved + `shift` (x, z) on the ground, as the steps before it left it.""" + + yaw: float = 0.0 + shift: tuple[float, float] = (0.0, 0.0) + + def matrix(self) -> np.ndarray: + m = yaw_matrix(self.yaw) + m[0, 3], m[2, 3] = self.shift + return m + + def pose_at( scene: Scene, clip: Clip | None, @@ -178,10 +193,12 @@ def pose_at( strip_root: bool = False, steer: Steer | None = None, speed: float = DEFAULT_SPEED, + carry: Carry | None = None, ) -> Pose: - """The rig at `frame`, turned as YAW turns while the clip plays (`yaw_at`); `strip_root` - holds the travel joints' location at frame 0, so the clip plays in place without dropping - the animal to its bind height.""" + """The rig at `frame`, turned as YAW turns while the clip plays (`yaw_at`) and, under a + `carry`, where the steps before left the body; `strip_root` holds the travel joints' + location at frame 0, so the clip plays in place without dropping the animal to its bind + height.""" if clip is None: return scene.bind_pose() curves = scene.curves(clip) @@ -195,9 +212,34 @@ def pose_at( th = yaw_at(scene, clip, frame, steer, speed) if th: world = yaw_matrix(th) @ world + if carry is not None: + world = carry.matrix() @ world return Pose(scene.rig, float(frame), world, clip.slot) +def carry_after( + scene: Scene, + clip: Clip, + frame: float, + then: Clip, + *, + strip_root: bool = False, + steer: Steer | None = None, + speed: float = DEFAULT_SPEED, + carry: Carry | None = None, +) -> Carry: + """The carry that starts clip `then` where `clip` stands at `frame` (played under `steer` and + `carry`): facing as it faces, its first pose's root on the ground where the root is.""" + root = travel_joints(scene)[-1] + end = pose_at( + scene, clip, frame, strip_root=strip_root, steer=steer, speed=speed, carry=carry + ).joints[root] + start = pose_at(scene, then, 0.0, strip_root=strip_root, speed=speed).joints[root] + yaw = (carry.yaw if carry is not None else 0.0) + yaw_at(scene, clip, frame, steer, speed) + turned = Carry(yaw).matrix() @ np.append(start, 1.0) + return Carry(yaw, (float(end[0] - turned[0]), float(end[2] - turned[2]))) + + def yaw_matrix(th: float) -> np.ndarray: """A turn of `th` radians about y, positive the way YAW grows: +z toward +x.""" yaw = np.eye(4) diff --git a/apps/studio/src/mhfu_studio/monster/render/viewport.py b/apps/studio/src/mhfu_studio/monster/render/viewport.py index ddffd52..6e7aed3 100644 --- a/apps/studio/src/mhfu_studio/monster/render/viewport.py +++ b/apps/studio/src/mhfu_studio/monster/render/viewport.py @@ -25,7 +25,7 @@ from .ground import Ground, point_cloud_geometry from .hitboxes import HitboxOverlay, Palette, Volume from .mesh import SkinnedMesh, default_pose -from .playback import Playback, pose_at +from .playback import Carry, Playback, pose_at from .skeleton import SkeletonOverlay OVERLAYS: tuple[tuple[str, Palette], ...] = (("hitboxes", "part"), ("attacks", "set")) @@ -65,6 +65,8 @@ def __init__(self, ctx: moderngl.Context, scene: Scene) -> None: self.strip_root = False #: an own move's turn instead of the clip's self.steer: Steer | None = None + #: where the steps of a played sequence before this clip left the body + self.carry: Carry | None = None def pose(self, clip: Clip | None, frame: float = 0.0) -> None: self.clip, self.frame = clip, float(frame) @@ -75,6 +77,7 @@ def pose(self, clip: Clip | None, frame: float = 0.0) -> None: strip_root=self.strip_root, steer=self.steer, speed=self.playback.speed, + carry=self.carry, ) self.mesh.set_pose(p) # bind included: the tail tip moves onto the tail self.skeleton.set_positions(p.joints) @@ -88,7 +91,8 @@ def repose(self) -> None: self.pose(self.clip, self.frame) def play(self, clip: Clip | None, frame: float = 0.0) -> None: - """Binds `clip` to the transport, posed at `frame`.""" + """Binds `clip` to the transport, posed at `frame`, where the clip normally starts.""" + self.carry = None self.playback.set_clip(clip) if clip is None: self.pose(None) @@ -258,6 +262,13 @@ def set_steer(self, steer: Steer | None) -> None: self.actor.steer = steer self.actor.repose() + def set_carry(self, carry: Carry | None) -> None: + """The port starts its clip where a played sequence's steps left it; the next clip played + starts where clips start.""" + if self.actor is not None and self.actor.carry != carry: + self.actor.carry = carry + self.actor.repose() + def set_pose(self, clip: Clip | None, frame: float = 0.0) -> None: if self.actor is not None: self.actor.pose(clip, frame) diff --git a/apps/studio/src/mhfu_studio/monster/workspace.py b/apps/studio/src/mhfu_studio/monster/workspace.py index a9e80ec..0724355 100644 --- a/apps/studio/src/mhfu_studio/monster/workspace.py +++ b/apps/studio/src/mhfu_studio/monster/workspace.py @@ -1465,7 +1465,29 @@ def _advance_sequence(self) -> None: self.playing_step = None self._stepped = True if mv.after is not None: - self._play_step(mv.after) + self._play_after(mv.after) + + def _play_after(self, name: str) -> None: + """Plays step `name` where the step on screen stands: facing as it faces, from its root.""" + from mhfu_studio.monster.render.playback import carry_after + + vp = self.vp + assert vp is not None and vp.actor is not None and vp.clip is not None + a, clip, phase = vp.actor, vp.clip, vp.playback.phase + was = a.steer, a.carry + self._play_step(name) + if self.playing_step == name and vp.clip is not None: + carry = carry_after( + a.scene, + clip, + phase, + vp.clip, + strip_root=a.strip_root, + steer=was[0], + speed=vp.playback.speed, + carry=was[1], + ) + vp.set_carry(carry) def take_stepped(self) -> bool: """Whether the played sequence went on (or ended) since the last ask.""" diff --git a/apps/studio/tests/monster/render/test_playback.py b/apps/studio/tests/monster/render/test_playback.py index 287fb04..a17c301 100644 --- a/apps/studio/tests/monster/render/test_playback.py +++ b/apps/studio/tests/monster/render/test_playback.py @@ -10,7 +10,9 @@ from mhfu_studio.monster.core.scene import Scene from mhfu_studio.monster.render.playback import ( GAME_HZ, + Carry, Playback, + carry_after, leading_chain, pose_at, root_joints, @@ -187,3 +189,24 @@ def test_own_moves_steer_the_preview() -> None: port = '[port]\nname = "t"\nhost_species = 75\npac = "t.bin"\n[source]\nmodel = 1\n' sc.attach_manifest(manifest.loads(port + "[clips.c]\nslot = 5\nturn = -45.0\n")) assert end_turn(sc, slot) == pytest.approx(-45.0, abs=0.1), "the manifest's turn wins" + + +def test_a_carry_starts_a_clip_where_the_last_one_stood(rig: Scene) -> None: + from mhfu_port.manifest import Steer + from mhfu_studio.monster.render.playback import yaw_matrix + + walk, root = rig.clip(1), travel_joints(rig)[-1] + fixed = Steer("fixed", angle=90.0, frames=10) + end = pose_at(rig, walk, 20.0, steer=fixed, speed=2.0).joints + c = carry_after(rig, walk, 20.0, walk, steer=fixed, speed=2.0) + assert c.yaw == pytest.approx(math.pi / 2) and abs(c.shift[0]) > 50.0 + plain = pose_at(rig, walk, 0.0).joints + start = pose_at(rig, walk, 0.0, carry=c).joints + assert np.allclose(start[root][[0, 2]], end[root][[0, 2]], atol=1e-6), "from the same spot" + turned = (yaw_matrix(c.yaw)[:3, :3] @ (plain - plain[root]).T).T + assert np.allclose(start - start[root], turned, atol=1e-6), "facing the same way" + assert np.allclose(pose_at(rig, walk, 0.0, carry=Carry()).joints, plain) + again = carry_after(rig, walk, 20.0, walk, steer=fixed, speed=2.0, carry=c) + assert again.yaw == pytest.approx(math.pi), "the turns add up" + far = pose_at(rig, walk, 20.0, steer=fixed, speed=2.0, carry=c).joints[root] + assert np.allclose(pose_at(rig, walk, 0.0, carry=again).joints[root][[0, 2]], far[[0, 2]]) diff --git a/apps/studio/tests/monster/ui/test_sequences.py b/apps/studio/tests/monster/ui/test_sequences.py index e1f5b9a..9067807 100644 --- a/apps/studio/tests/monster/ui/test_sequences.py +++ b/apps/studio/tests/monster/ui/test_sequences.py @@ -3,6 +3,7 @@ """Sequences on screen: the Moves strip and picker, the Timeline's bar, playing a chain, the Clips sort, over the synthetic port.""" +import math from pathlib import Path from typing import Any @@ -11,12 +12,16 @@ from mhfu_port import continuity from mhfu_port.manifest import Clip from mhfu_studio.monster.clip_browser import SourceClip +from mhfu_studio.monster.core.scene import Scene from mhfu_studio.monster.panels.clips import ClipsPanel from mhfu_studio.monster.panels.moves import MovesPanel from mhfu_studio.monster.panels.timeline import TimelinePanel +from mhfu_studio.monster.render.playback import pose_at from mhfu_studio.monster.workspace import MonsterWorkspace from mhfu_studio.shell.studio import Studio from mhfu_studio.ui import kit +from mhp_formats.anim import Channel, Keyframe, Track, quantize +from mhp_formats.anim import Clip as AnimClip from PySide6.QtCore import QPoint, Qt from PySide6.QtTest import QTest @@ -271,6 +276,64 @@ def test_the_timeline_follows_the_sequence(timeline: TimelinePanel, ws: MonsterW assert timeline.steps.picked == "a_2" and timeline.frame.text() == "frame 0.0 / 6" +# ---- the body carried across steps ---- + + +@pytest.fixture +def walking(ws: MonsterWorkspace, synthetic_pac: bytes) -> MonsterWorkspace: + """`ws` on a rig whose clips both walk the root 100 units forward by frame 10; a turns 90 + degrees (fixed steer), a_2 and a_3 do not.""" + assert ws.doc is not None + scene = Scene.from_bytes(synthetic_pac, "t", manifest=ws.doc.manifest) + track = Track([Channel(0x100, [Keyframe(0, 0), Keyframe(quantize("loc", 100.0), 10)])]) + for c in scene.clips: + c.source = AnimClip([track, Track(), Track()]) + ws.load(scene, ws.doc) + ws.select_move("a") + assert ws.set_steer(turn="fixed", angle=90.0) + return ws + + +def test_a_sequence_carries_the_body(walking: MonsterWorkspace) -> None: + ws = walking + vp = ws.vp + assert vp is not None and vp.actor is not None and vp.clip is not None + first, speed = vp.clip, vp.playback.speed + ws.play_sequence() + assert vp.actor.carry is None, "the first step starts where clips start" + at = pose_at(vp.actor.scene, first, first.frames, steer=vp.actor.steer, speed=speed).joints[0] + ws.frame(1.0) + c = vp.actor.carry + assert ws.move == "a_2" and c is not None and c.yaw == pytest.approx(math.pi / 2) + start = pose_at(vp.actor.scene, vp.clip, 0.0, carry=c).joints[0] + assert abs(at[0]) > 90.0 and np.allclose(start, at, atol=1e-6), "from where a ended, turned" + ws.frame(1.0) + c2 = vp.actor.carry + assert ws.move == "a_3" and c2 is not None + assert c2.yaw == pytest.approx(math.pi / 2), "a_2 does not turn" + assert c2.shift[0] > c.shift[0] + 50.0, "and walked on from there" + + +def test_anything_else_played_starts_where_clips_start(walking: MonsterWorkspace) -> None: + ws = walking + vp = ws.vp + assert vp is not None and vp.actor is not None + ws.play_sequence() + ws.frame(1.0) + assert vp.actor.carry is not None + ws.select_move("a_2") # the same step, picked again + assert vp.actor.carry is None + ws.play_sequence() + ws.frame(1.0) + assert vp.actor.carry is not None + ws.play_slot(2) + assert vp.actor.carry is None + ws.play_sequence() + ws.frame(1.0) + ws.play_sequence() + assert vp.actor.carry is None, "again from the head" + + # ---- the Clips table ----