Skip to content

Commit 5cbb8fb

Browse files
sp00ktoberclaude
andcommitted
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 <noreply@anthropic.com>
1 parent 7d81137 commit 5cbb8fb

6 files changed

Lines changed: 182 additions & 6 deletions

File tree

‎apps/studio/README.md‎

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

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

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

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

‎apps/studio/src/mhfu_studio/monster/render/playback.py‎

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from __future__ import annotations
1212

1313
import math
14+
from dataclasses import dataclass
1415
from typing import Protocol
1516
from weakref import WeakKeyDictionary
1617

@@ -170,6 +171,20 @@ def travel_joints(scene: Scene) -> tuple[int, ...]:
170171
return leading_chain(scene.rig.parents) or root_joints(scene)
171172

172173

174+
@dataclass(frozen=True)
175+
class Carry:
176+
"""Where a step of a played sequence starts: the body turned `yaw` radians about y and moved
177+
`shift` (x, z) on the ground, as the steps before it left it."""
178+
179+
yaw: float = 0.0
180+
shift: tuple[float, float] = (0.0, 0.0)
181+
182+
def matrix(self) -> np.ndarray:
183+
m = yaw_matrix(self.yaw)
184+
m[0, 3], m[2, 3] = self.shift
185+
return m
186+
187+
173188
def pose_at(
174189
scene: Scene,
175190
clip: Clip | None,
@@ -178,10 +193,12 @@ def pose_at(
178193
strip_root: bool = False,
179194
steer: Steer | None = None,
180195
speed: float = DEFAULT_SPEED,
196+
carry: Carry | None = None,
181197
) -> Pose:
182-
"""The rig at `frame`, turned as YAW turns while the clip plays (`yaw_at`); `strip_root`
183-
holds the travel joints' location at frame 0, so the clip plays in place without dropping
184-
the animal to its bind height."""
198+
"""The rig at `frame`, turned as YAW turns while the clip plays (`yaw_at`) and, under a
199+
`carry`, where the steps before left the body; `strip_root` holds the travel joints'
200+
location at frame 0, so the clip plays in place without dropping the animal to its bind
201+
height."""
185202
if clip is None:
186203
return scene.bind_pose()
187204
curves = scene.curves(clip)
@@ -195,9 +212,34 @@ def pose_at(
195212
th = yaw_at(scene, clip, frame, steer, speed)
196213
if th:
197214
world = yaw_matrix(th) @ world
215+
if carry is not None:
216+
world = carry.matrix() @ world
198217
return Pose(scene.rig, float(frame), world, clip.slot)
199218

200219

220+
def carry_after(
221+
scene: Scene,
222+
clip: Clip,
223+
frame: float,
224+
then: Clip,
225+
*,
226+
strip_root: bool = False,
227+
steer: Steer | None = None,
228+
speed: float = DEFAULT_SPEED,
229+
carry: Carry | None = None,
230+
) -> Carry:
231+
"""The carry that starts clip `then` where `clip` stands at `frame` (played under `steer` and
232+
`carry`): facing as it faces, its first pose's root on the ground where the root is."""
233+
root = travel_joints(scene)[-1]
234+
end = pose_at(
235+
scene, clip, frame, strip_root=strip_root, steer=steer, speed=speed, carry=carry
236+
).joints[root]
237+
start = pose_at(scene, then, 0.0, strip_root=strip_root, speed=speed).joints[root]
238+
yaw = (carry.yaw if carry is not None else 0.0) + yaw_at(scene, clip, frame, steer, speed)
239+
turned = Carry(yaw).matrix() @ np.append(start, 1.0)
240+
return Carry(yaw, (float(end[0] - turned[0]), float(end[2] - turned[2])))
241+
242+
201243
def yaw_matrix(th: float) -> np.ndarray:
202244
"""A turn of `th` radians about y, positive the way YAW grows: +z toward +x."""
203245
yaw = np.eye(4)

‎apps/studio/src/mhfu_studio/monster/render/viewport.py‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
from .ground import Ground, point_cloud_geometry
2626
from .hitboxes import HitboxOverlay, Palette, Volume
2727
from .mesh import SkinnedMesh, default_pose
28-
from .playback import Playback, pose_at
28+
from .playback import Carry, Playback, pose_at
2929
from .skeleton import SkeletonOverlay
3030

3131
OVERLAYS: tuple[tuple[str, Palette], ...] = (("hitboxes", "part"), ("attacks", "set"))
@@ -65,6 +65,8 @@ def __init__(self, ctx: moderngl.Context, scene: Scene) -> None:
6565
self.strip_root = False
6666
#: an own move's turn instead of the clip's
6767
self.steer: Steer | None = None
68+
#: where the steps of a played sequence before this clip left the body
69+
self.carry: Carry | None = None
6870

6971
def pose(self, clip: Clip | None, frame: float = 0.0) -> None:
7072
self.clip, self.frame = clip, float(frame)
@@ -75,6 +77,7 @@ def pose(self, clip: Clip | None, frame: float = 0.0) -> None:
7577
strip_root=self.strip_root,
7678
steer=self.steer,
7779
speed=self.playback.speed,
80+
carry=self.carry,
7881
)
7982
self.mesh.set_pose(p) # bind included: the tail tip moves onto the tail
8083
self.skeleton.set_positions(p.joints)
@@ -88,7 +91,8 @@ def repose(self) -> None:
8891
self.pose(self.clip, self.frame)
8992

9093
def play(self, clip: Clip | None, frame: float = 0.0) -> None:
91-
"""Binds `clip` to the transport, posed at `frame`."""
94+
"""Binds `clip` to the transport, posed at `frame`, where the clip normally starts."""
95+
self.carry = None
9296
self.playback.set_clip(clip)
9397
if clip is None:
9498
self.pose(None)
@@ -258,6 +262,13 @@ def set_steer(self, steer: Steer | None) -> None:
258262
self.actor.steer = steer
259263
self.actor.repose()
260264

265+
def set_carry(self, carry: Carry | None) -> None:
266+
"""The port starts its clip where a played sequence's steps left it; the next clip played
267+
starts where clips start."""
268+
if self.actor is not None and self.actor.carry != carry:
269+
self.actor.carry = carry
270+
self.actor.repose()
271+
261272
def set_pose(self, clip: Clip | None, frame: float = 0.0) -> None:
262273
if self.actor is not None:
263274
self.actor.pose(clip, frame)

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

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1465,7 +1465,29 @@ def _advance_sequence(self) -> None:
14651465
self.playing_step = None
14661466
self._stepped = True
14671467
if mv.after is not None:
1468-
self._play_step(mv.after)
1468+
self._play_after(mv.after)
1469+
1470+
def _play_after(self, name: str) -> None:
1471+
"""Plays step `name` where the step on screen stands: facing as it faces, from its root."""
1472+
from mhfu_studio.monster.render.playback import carry_after
1473+
1474+
vp = self.vp
1475+
assert vp is not None and vp.actor is not None and vp.clip is not None
1476+
a, clip, phase = vp.actor, vp.clip, vp.playback.phase
1477+
was = a.steer, a.carry
1478+
self._play_step(name)
1479+
if self.playing_step == name and vp.clip is not None:
1480+
carry = carry_after(
1481+
a.scene,
1482+
clip,
1483+
phase,
1484+
vp.clip,
1485+
strip_root=a.strip_root,
1486+
steer=was[0],
1487+
speed=vp.playback.speed,
1488+
carry=was[1],
1489+
)
1490+
vp.set_carry(carry)
14691491

14701492
def take_stepped(self) -> bool:
14711493
"""Whether the played sequence went on (or ended) since the last ask."""

‎apps/studio/tests/monster/render/test_playback.py‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
from mhfu_studio.monster.core.scene import Scene
1111
from mhfu_studio.monster.render.playback import (
1212
GAME_HZ,
13+
Carry,
1314
Playback,
15+
carry_after,
1416
leading_chain,
1517
pose_at,
1618
root_joints,
@@ -187,3 +189,24 @@ def test_own_moves_steer_the_preview() -> None:
187189
port = '[port]\nname = "t"\nhost_species = 75\npac = "t.bin"\n[source]\nmodel = 1\n'
188190
sc.attach_manifest(manifest.loads(port + "[clips.c]\nslot = 5\nturn = -45.0\n"))
189191
assert end_turn(sc, slot) == pytest.approx(-45.0, abs=0.1), "the manifest's turn wins"
192+
193+
194+
def test_a_carry_starts_a_clip_where_the_last_one_stood(rig: Scene) -> None:
195+
from mhfu_port.manifest import Steer
196+
from mhfu_studio.monster.render.playback import yaw_matrix
197+
198+
walk, root = rig.clip(1), travel_joints(rig)[-1]
199+
fixed = Steer("fixed", angle=90.0, frames=10)
200+
end = pose_at(rig, walk, 20.0, steer=fixed, speed=2.0).joints
201+
c = carry_after(rig, walk, 20.0, walk, steer=fixed, speed=2.0)
202+
assert c.yaw == pytest.approx(math.pi / 2) and abs(c.shift[0]) > 50.0
203+
plain = pose_at(rig, walk, 0.0).joints
204+
start = pose_at(rig, walk, 0.0, carry=c).joints
205+
assert np.allclose(start[root][[0, 2]], end[root][[0, 2]], atol=1e-6), "from the same spot"
206+
turned = (yaw_matrix(c.yaw)[:3, :3] @ (plain - plain[root]).T).T
207+
assert np.allclose(start - start[root], turned, atol=1e-6), "facing the same way"
208+
assert np.allclose(pose_at(rig, walk, 0.0, carry=Carry()).joints, plain)
209+
again = carry_after(rig, walk, 20.0, walk, steer=fixed, speed=2.0, carry=c)
210+
assert again.yaw == pytest.approx(math.pi), "the turns add up"
211+
far = pose_at(rig, walk, 20.0, steer=fixed, speed=2.0, carry=c).joints[root]
212+
assert np.allclose(pose_at(rig, walk, 0.0, carry=again).joints[root][[0, 2]], far[[0, 2]])

‎apps/studio/tests/monster/ui/test_sequences.py‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"""Sequences on screen: the Moves strip and picker, the Timeline's bar, playing a chain, the Clips
44
sort, over the synthetic port."""
55

6+
import math
67
from pathlib import Path
78
from typing import Any
89

@@ -11,12 +12,16 @@
1112
from mhfu_port import continuity
1213
from mhfu_port.manifest import Clip
1314
from mhfu_studio.monster.clip_browser import SourceClip
15+
from mhfu_studio.monster.core.scene import Scene
1416
from mhfu_studio.monster.panels.clips import ClipsPanel
1517
from mhfu_studio.monster.panels.moves import MovesPanel
1618
from mhfu_studio.monster.panels.timeline import TimelinePanel
19+
from mhfu_studio.monster.render.playback import pose_at
1720
from mhfu_studio.monster.workspace import MonsterWorkspace
1821
from mhfu_studio.shell.studio import Studio
1922
from mhfu_studio.ui import kit
23+
from mhp_formats.anim import Channel, Keyframe, Track, quantize
24+
from mhp_formats.anim import Clip as AnimClip
2025
from PySide6.QtCore import QPoint, Qt
2126
from PySide6.QtTest import QTest
2227

@@ -271,6 +276,64 @@ def test_the_timeline_follows_the_sequence(timeline: TimelinePanel, ws: MonsterW
271276
assert timeline.steps.picked == "a_2" and timeline.frame.text() == "frame 0.0 / 6"
272277

273278

279+
# ---- the body carried across steps ----
280+
281+
282+
@pytest.fixture
283+
def walking(ws: MonsterWorkspace, synthetic_pac: bytes) -> MonsterWorkspace:
284+
"""`ws` on a rig whose clips both walk the root 100 units forward by frame 10; a turns 90
285+
degrees (fixed steer), a_2 and a_3 do not."""
286+
assert ws.doc is not None
287+
scene = Scene.from_bytes(synthetic_pac, "t", manifest=ws.doc.manifest)
288+
track = Track([Channel(0x100, [Keyframe(0, 0), Keyframe(quantize("loc", 100.0), 10)])])
289+
for c in scene.clips:
290+
c.source = AnimClip([track, Track(), Track()])
291+
ws.load(scene, ws.doc)
292+
ws.select_move("a")
293+
assert ws.set_steer(turn="fixed", angle=90.0)
294+
return ws
295+
296+
297+
def test_a_sequence_carries_the_body(walking: MonsterWorkspace) -> None:
298+
ws = walking
299+
vp = ws.vp
300+
assert vp is not None and vp.actor is not None and vp.clip is not None
301+
first, speed = vp.clip, vp.playback.speed
302+
ws.play_sequence()
303+
assert vp.actor.carry is None, "the first step starts where clips start"
304+
at = pose_at(vp.actor.scene, first, first.frames, steer=vp.actor.steer, speed=speed).joints[0]
305+
ws.frame(1.0)
306+
c = vp.actor.carry
307+
assert ws.move == "a_2" and c is not None and c.yaw == pytest.approx(math.pi / 2)
308+
start = pose_at(vp.actor.scene, vp.clip, 0.0, carry=c).joints[0]
309+
assert abs(at[0]) > 90.0 and np.allclose(start, at, atol=1e-6), "from where a ended, turned"
310+
ws.frame(1.0)
311+
c2 = vp.actor.carry
312+
assert ws.move == "a_3" and c2 is not None
313+
assert c2.yaw == pytest.approx(math.pi / 2), "a_2 does not turn"
314+
assert c2.shift[0] > c.shift[0] + 50.0, "and walked on from there"
315+
316+
317+
def test_anything_else_played_starts_where_clips_start(walking: MonsterWorkspace) -> None:
318+
ws = walking
319+
vp = ws.vp
320+
assert vp is not None and vp.actor is not None
321+
ws.play_sequence()
322+
ws.frame(1.0)
323+
assert vp.actor.carry is not None
324+
ws.select_move("a_2") # the same step, picked again
325+
assert vp.actor.carry is None
326+
ws.play_sequence()
327+
ws.frame(1.0)
328+
assert vp.actor.carry is not None
329+
ws.play_slot(2)
330+
assert vp.actor.carry is None
331+
ws.play_sequence()
332+
ws.frame(1.0)
333+
ws.play_sequence()
334+
assert vp.actor.carry is None, "again from the head"
335+
336+
274337
# ---- the Clips table ----
275338

276339

0 commit comments

Comments
 (0)