Skip to content

Commit e19370e

Browse files
authored
Merge pull request #56 from MHFU-ModKit/hybrid/wave4
Hybrid ports wave 4: own moves and reactions as data, monster events, studio authoring
2 parents 05c1bdc + f16f02c commit e19370e

96 files changed

Lines changed: 9225 additions & 1314 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/studio/README.md‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,31 @@ 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+
## A port's own moves
58+
59+
An own move plays one clip on every body part through the framework's move player, with attacks of
60+
its own. In Moves (View > Panels), New from the clip on screen makes one (Clips has New move too);
61+
its fields are the manifest's: length, carrier, the base monster's attacks kept or not, what follows
62+
it, and how it turns. The Timeline draws its attacks in lanes under the clip: drag on the empty lane
63+
to add one (a click adds one without an end), drag an edge or the span to move it; the playhead
64+
lights the attacks that are out, and their hit group in the view when hit groups are shown. The ring
65+
on the floor of the view (T hides it) is the turn: an arrow for frame 0, one for the facing now, and
66+
a handle for the end, which drags the clip's `turn`, or a fixed steer's angle, in whole degrees
67+
(Shift: 15). Every edit is one undo step.
68+
69+
Play in game saves, writes the port's clips and moves modules to the memory stick's `mods/lib` as
70+
`mhfu-port inject` does, and asks the running port to play the move by name; it refuses a move
71+
whose clip sits in another anim than in the saved manifest, since the game holds the build
72+
injected from the file. It writes only the modules that changed, and asks for the move from the
73+
moves module just built, so a new or edited move plays once the game has re-run that module.
74+
A move asked while the monster has noticed the hunter but is not yet in combat waits for combat,
75+
so the "!" and the howl play out; Force beside the button plays it at once.
76+
77+
Under the moves are the rules (`[[rule]]`), each read as a sentence ("after the lunge for 20
78+
frames, within 1200, play stamp"): New rule plays the picked move once when the monster notices
79+
the hunter, and every field is a control, the part only for a flinch or a break, the mode only
80+
for a pair move, `force` as above. A refusal is the manifest's own words, in the status line.
81+
5782
## Tests
5883

5984
`uv run pytest apps/studio` runs offscreen; the Qt tests skip without the `qt` group, and the

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

Lines changed: 11 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,12 @@
1010

1111
from __future__ import annotations
1212

13-
import re
1413
from dataclasses import dataclass
1514

1615
from mhfu.em.intel import AttackIntel, PairIntel, SpeciesIntel
1716
from mhfu_port import layout
18-
from mhfu_port.manifest import Claim, Manifest, Move
17+
from mhfu_port.manifest import Manifest
18+
from mhfu_port.moves import lua_key, lua_value, pair_move
1919

2020
from mhfu_studio.monster import clips
2121
from mhfu_studio.monster.align import Timing, timing
@@ -24,11 +24,6 @@
2424
#: row groups, in the order they are listed
2525
MOVE, ATTACK, ENTERED = "move", "attack", "entered"
2626
NONE = "–"
27-
LUA_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
28-
LUA_KEYWORDS = frozenset(
29-
"and break do else elseif end false for function goto if in local nil not or repeat return"
30-
" then true until while".split()
31-
)
3227

3328

3429
@dataclass(frozen=True)
@@ -243,12 +238,13 @@ def pair_of(main: int, sub: int) -> PairIntel | None:
243238
return None if intel is None else intel.pair(main, sub)
244239

245240
out = [
246-
row(m, pair_of(mv.main, mv.sub), mv.main, mv.sub, MOVE, cov, attacks, species, name)
241+
row(m, pair_of(*pr), *pr, MOVE, cov, attacks, species, name)
247242
for name, mv in m.moves.items()
243+
if (pr := mv.pair) is not None
248244
]
249245
if intel is None:
250246
return out
251-
bound = {(mv.main, mv.sub) for mv in m.moves.values()}
247+
bound = {mv.pair for mv in m.moves.values()}
252248
hubs = set(intel.hubs)
253249
groups: dict[object, list[PairIntel]] = {}
254250
for p in sorted(intel, key=lambda p: (p.main, p.sub)):
@@ -271,49 +267,20 @@ def pair_of(main: int, sub: int) -> PairIntel | None:
271267
# the moves as a mod's Lua declares them
272268

273269

274-
def _lua_key(name: str) -> str:
275-
return name if LUA_NAME.fullmatch(name) and name not in LUA_KEYWORDS else f'["{name}"]'
276-
277-
278-
def _lua_str(s: str) -> str:
279-
return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"'
280-
281-
282-
def _lua_claim(c: Claim) -> str:
283-
mains = str(c.mains[0]) if len(c.mains) == 1 else "{ " + ", ".join(map(str, c.mains)) + " }"
284-
return "{ main = " + mains + ("" if c.sub is None else f", sub = {c.sub}") + " }"
285-
286-
287-
def lua_move(mv: Move) -> str:
288-
"""One move's table: the fields `mhfu_port.lua` reads, defaults left out."""
289-
bits = [f"main = {mv.main}", f"sub = {mv.sub}"]
290-
if mv.clip is not None:
291-
bits.append(f"clip = {_lua_str(mv.clip)}")
292-
if mv.anim is not None:
293-
bits.append(f"anim = {mv.anim}")
294-
if mv.latch != 1:
295-
bits.append(f"latch = {mv.latch}")
296-
if mv.after is not None:
297-
bits.append(f"after = {_lua_str(mv.after)}")
298-
if mv.hold_max is not None:
299-
bits.append(f"hold_max = {mv.hold_max}")
300-
if mv.claim is not None:
301-
bits.append(f"claim = {_lua_claim(mv.claim)}")
302-
return "{ " + ", ".join(bits) + " }"
303-
304-
305270
def lua_moves(m: Manifest) -> str:
306-
"""The `moves` field of a mod's `P.define{…}`; empty without moves. Its clips come from the
307-
layout module `mhfu-port inject` writes with the PAC, not from a copy here."""
308-
if not m.moves:
271+
"""The `moves` field of a mod's `P.define{…}` with the pair moves; empty without one. Its
272+
clips come from the layout module `mhfu-port inject` writes with the PAC, not from a copy
273+
here; own moves come from the module mhfu-port generates for them."""
274+
pairs = {n: mv for n, mv in m.moves.items() if mv.pair is not None}
275+
if not pairs:
309276
return ""
310277
source = m.path.name if m.path is not None else m.port.name
311278
return "\n".join(
312279
[
313280
f"-- from {source}, for your mod's P.define{{ ... }}; the clips are in "
314281
f"{layout.module_name(m)}",
315282
"moves = {",
316-
*(f" {_lua_key(n)} = {lua_move(mv)}," for n, mv in m.moves.items()),
283+
*(f" {lua_key(n)} = {lua_value(pair_move(mv))}," for n, mv in pairs.items()),
317284
"},",
318285
]
319286
)

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,11 +118,12 @@ def align(
118118
mv = m.moves.get(move)
119119
if mv is None:
120120
raise KeyError(f"no move named {move!r} (have: {', '.join(sorted(m.moves)) or 'none'})")
121+
if mv.pair is None:
122+
raise KeyError(f"{move!r} is an own move: it rides no host action")
121123
c = m.clips.get(mv.clip) if mv.clip else None
122124
return align_pair(
123125
m,
124-
mv.main,
125-
mv.sub,
126+
*mv.pair,
126127
intel,
127128
move=move,
128129
clip=mv.clip,
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
# SPDX-License-Identifier: GPL-3.0-or-later
2+
# SPDX-FileCopyrightText: 2026 sp00ktober
3+
"""Own moves as document edits (`mhfu_port.manifest.Move` without a pair): their fields, their
4+
steer and their attack windows; and a drag on the Timeline's attack lanes, in clip frames.
5+
6+
No toolkit: the Moves panel and the Timeline call these through the workspace, one undo step
7+
per edit; a refusal is a `ManifestError` saying why.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import dataclasses
13+
from collections.abc import Callable, Sequence
14+
from dataclasses import dataclass
15+
from typing import Any, Literal
16+
17+
from mhfu_port.manifest import (
18+
MOVE_ATTACKS,
19+
AttackWindow,
20+
Manifest,
21+
ManifestError,
22+
Move,
23+
Steer,
24+
)
25+
26+
from mhfu_studio.monster.document import PortDocument
27+
28+
#: the Move fields the Moves panel edits
29+
FIELDS = ("clip", "length", "carrier", "host_attacks", "after", "label")
30+
STEER_FIELDS = tuple(f.name for f in dataclasses.fields(Steer))
31+
Part = Literal["new", "start", "end", "body"]
32+
#: points from a window's edge that grab the edge rather than the span
33+
EDGE_PX = 5.0
34+
35+
36+
def own(m: Manifest, name: str) -> Move:
37+
mv = m.moves.get(name)
38+
if mv is None:
39+
raise ManifestError(f"no move {name!r}")
40+
if not mv.own:
41+
raise ManifestError(
42+
f"{name} rides the base monster's ({mv.main},{mv.sub}): not an own move"
43+
)
44+
return mv
45+
46+
47+
def free_name(m: Manifest, base: str) -> str:
48+
"""`base`, else `base_2`, `base_3` ...: a move name not taken."""
49+
n, name = 1, base
50+
while name in m.moves:
51+
n += 1
52+
name = f"{base}_{n}"
53+
return name
54+
55+
56+
def new_move(doc: PortDocument, name: str, clip: str) -> str:
57+
"""A new own move `[moves.<name>]` playing clip `clip`."""
58+
if name in doc.manifest.moves:
59+
raise ManifestError(f"moves.{name} already exists")
60+
61+
def make(m: Manifest) -> None:
62+
m.moves[name] = Move(clip=clip)
63+
64+
doc.edit(make)
65+
return f"moves.{name}: an own move on {clip}"
66+
67+
68+
def _edit(doc: PortDocument, name: str, fn: Callable[[Move], object]) -> None:
69+
own(doc.manifest, name)
70+
doc.edit(lambda m: fn(m.moves[name]))
71+
72+
73+
def set_fields(doc: PortDocument, name: str, **fields: Any) -> str:
74+
"""`FIELDS` of own move `name`."""
75+
bad = sorted(set(fields) - set(FIELDS))
76+
if bad:
77+
raise ManifestError(f"not a move field: {', '.join(bad)}")
78+
if "carrier" in fields and fields["carrier"] is not None:
79+
fields["carrier"] = tuple(int(x) for x in fields["carrier"])
80+
81+
def change(mv: Move) -> None:
82+
for k, v in fields.items():
83+
setattr(mv, k, v)
84+
85+
_edit(doc, name, change)
86+
return f"moves.{name}: " + ", ".join(f"{k} = {v}" for k, v in fields.items())
87+
88+
89+
def set_steer(doc: PortDocument, name: str, frames_hint: int = 1, **fields: Any) -> str:
90+
"""`STEER_FIELDS` of own move `name`. Turning `fixed` on brings an angle (0) and `frames`
91+
(`frames_hint`, the clip's AI frames) with it, turning it off takes them away: the loader
92+
wants both with `fixed` and neither without."""
93+
bad = sorted(set(fields) - set(STEER_FIELDS))
94+
if bad:
95+
raise ManifestError(f"not a steer field: {', '.join(bad)}")
96+
97+
def change(mv: Move) -> None:
98+
s = dataclasses.replace(mv.steer, **fields)
99+
if s.turn == "fixed":
100+
s.angle = 0.0 if s.angle is None else float(s.angle)
101+
s.frames = max(1, frames_hint) if s.frames is None else s.frames
102+
elif "turn" in fields:
103+
s.angle = s.frames = None
104+
if s.turn not in ("hunter", "away"):
105+
s.rate = None
106+
mv.steer = s
107+
108+
_edit(doc, name, change)
109+
return f"moves.{name}.steer: " + ", ".join(f"{k} = {v}" for k, v in fields.items())
110+
111+
112+
def add_window(doc: PortDocument, name: str, w: AttackWindow) -> int:
113+
"""Appends `w` to own move `name`'s attacks; its index."""
114+
n = len(own(doc.manifest, name).attacks)
115+
if n >= MOVE_ATTACKS:
116+
raise ManifestError(f"the move player holds {MOVE_ATTACKS} attacks a move: {name} has them")
117+
_edit(doc, name, lambda mv: mv.attacks.append(dataclasses.replace(w)))
118+
return n
119+
120+
121+
def set_window(doc: PortDocument, name: str, index: int, **fields: Any) -> str:
122+
"""Fields (`id`, `frame`, `end`, `label`) of attack `index` of own move `name`."""
123+
attacks = own(doc.manifest, name).attacks
124+
if not 0 <= index < len(attacks):
125+
raise ManifestError(f"{name} has no attack {index}")
126+
127+
def change(mv: Move) -> None:
128+
mv.attacks[index] = dataclasses.replace(mv.attacks[index], **fields)
129+
130+
_edit(doc, name, change)
131+
return f"moves.{name} attack {index}: " + ", ".join(f"{k} = {v}" for k, v in fields.items())
132+
133+
134+
def remove_window(doc: PortDocument, name: str, index: int) -> str:
135+
attacks = own(doc.manifest, name).attacks
136+
if not 0 <= index < len(attacks):
137+
raise ManifestError(f"{name} has no attack {index}")
138+
_edit(doc, name, lambda mv: mv.attacks.pop(index))
139+
return f"moves.{name}: attack {index} removed"
140+
141+
142+
def live(attacks: Sequence[AttackWindow], phase: float) -> list[int]:
143+
"""The windows whose attack is out at clip frame `phase`: spawned, not yet ended (one
144+
without an end lives as its record says, here to the clip's end)."""
145+
return [
146+
i for i, a in enumerate(attacks) if a.frame <= phase and (a.end is None or phase < a.end)
147+
]
148+
149+
150+
@dataclass(frozen=True)
151+
class Grab:
152+
"""What a press on the attack lanes took: window `index` (None: a new one) by `part`, at
153+
clip frame `at`."""
154+
155+
index: int | None
156+
part: Part
157+
at: float
158+
was: AttackWindow | None
159+
160+
161+
class WindowDrag:
162+
"""A drag on the Timeline's attack lanes, one lane per window and one to add on: a drag on
163+
the add lane draws a new window (a click: one without an end), an edge drags that edge, the
164+
span drags both; whole clip frames, inside the clip. `commit` takes the result on release;
165+
`preview` is what the lanes draw meanwhile."""
166+
167+
def __init__(self, commit: Callable[[int | None, AttackWindow], object]) -> None:
168+
self._commit = commit
169+
self.grab: Grab | None = None
170+
self.preview: AttackWindow | None = None
171+
#: clip frames the lanes span, and the id a new window gets
172+
self.frames = 0
173+
self.id = 0
174+
175+
def part_at(
176+
self, attacks: Sequence[AttackWindow], lane: int, frame: float, slop: float
177+
) -> tuple[int | None, Part] | None:
178+
"""What `frame` on `lane` grabs, `slop` frames from an edge counting as the edge."""
179+
if lane == len(attacks):
180+
return (None, "new") if lane < MOVE_ATTACKS else None
181+
if not 0 <= lane < len(attacks):
182+
return None
183+
a = attacks[lane]
184+
end = self.frames if a.end is None else a.end
185+
if abs(frame - a.frame) <= slop:
186+
return lane, "start"
187+
if abs(frame - end) <= slop:
188+
return lane, "end"
189+
if a.frame < frame < end:
190+
return lane, "body"
191+
return None
192+
193+
def press(self, attacks: Sequence[AttackWindow], lane: int, frame: float, slop: float) -> bool:
194+
"""Starts a drag where `part_at` finds something; whether it did."""
195+
got = self.part_at(attacks, lane, frame, slop)
196+
if got is None or self.frames <= 0:
197+
return False
198+
index, part = got
199+
self.grab = Grab(index, part, frame, None if index is None else attacks[index])
200+
self.preview = self._moved(frame)
201+
return True
202+
203+
def move(self, frame: float) -> None:
204+
if self.grab is not None:
205+
self.preview = self._moved(frame)
206+
207+
def release(self, frame: float) -> bool:
208+
"""Commits the drag; whether it changed anything."""
209+
g = self.grab
210+
if g is None:
211+
return False
212+
w = self._moved(frame)
213+
self.cancel()
214+
if w == g.was:
215+
return False
216+
self._commit(g.index, w)
217+
return True
218+
219+
def cancel(self) -> None:
220+
self.grab, self.preview = None, None
221+
222+
@property
223+
def dragging(self) -> bool:
224+
return self.grab is not None
225+
226+
def _moved(self, frame: float) -> AttackWindow:
227+
g = self.grab
228+
assert g is not None
229+
n = self.frames
230+
f = min(max(round(frame), 0), n)
231+
if g.part == "new" or g.was is None:
232+
a, b = sorted((min(max(round(g.at), 0), n), f))
233+
a = min(a, n - 1)
234+
return AttackWindow(self.id, a, None if b <= a else b)
235+
w = g.was
236+
if g.part == "start":
237+
hi = (n if w.end is None else w.end) - 1
238+
return dataclasses.replace(w, frame=min(f, hi))
239+
if g.part == "end":
240+
return dataclasses.replace(w, end=max(f, w.frame + 1))
241+
span = 0 if w.end is None else w.end - w.frame
242+
start = min(max(w.frame + round(frame - g.at), 0), n - 1 - max(span - 1, 0))
243+
return dataclasses.replace(w, frame=start, end=None if w.end is None else start + span)

0 commit comments

Comments
 (0)