Skip to content

Commit fb1e255

Browse files
authored
Merge pull request #66 from MHFU-ModKit/moveset/rules
Behaviour blocks: conditions, effects, counters and Lua signals in the brain
2 parents 7960540 + 778bab5 commit fb1e255

52 files changed

Lines changed: 4329 additions & 409 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: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -90,16 +90,26 @@ shows the steps (click one to pick it). Picking or playing anything else starts
9090
they normally start.
9191

9292
The Behaviour tab edits the port's `[behaviour]` as rules, grouped by what starts them: an event, a
93-
state between moves, or a move playing. The game checks rules in rank order, `#1` first. Rank is the
94-
stored canvas order, each block's `(y, x)`, top first: `+ Rule` adds a rule last, dragging a rule's
95-
frame trades places with its own trigger's rules only, and in All rules › Canvas dragging a node can
96-
change it. In Canvas a block's `out` wires to the next block's `in` or to a move's `play`; a move's
97-
`while playing` starts a rule, and `then` sets the move's `after`. The palette is
98-
`mhfu_port.behaviour.KINDS`. Watch the game, in the header, reads the running game once a second
99-
while the tab is shown and marks each frame and trigger with how often the game fired its rules
100-
(`7×`; a frame lights up when its count rises): the game must run this port's rules as Play in game
101-
sends them, and the header says when it does not. The tab keeps its own docks; Findings stays open
102-
across tabs.
93+
state between moves, or a move playing. The game checks rules in rank order, `#1` first, rules that
94+
play nothing before all others. Rank is the stored canvas order, each block's `(y, x)`, top first:
95+
`+ Rule` adds a rule last, dragging a rule's frame trades places with its own trigger's rules only,
96+
and in All rules › Canvas dragging a node can change it. In Canvas a block's `out` wires to the next
97+
block's `in` or to a move's `play`; a move's `while playing` starts a rule, and `then` sets the
98+
move's `after`. The palette is `mhfu_port.behaviour.KINDS`. Watch the game, in the header, reads
99+
the running game once a second while the tab is shown and marks each frame and trigger with how
100+
often the game fired its rules (`7×`; a frame lights up when its count rises): the game must run
101+
this port's rules as Play in game sends them, and the header says when it does not. On a
102+
Signal trigger, Fire raises that signal in the running game, once Watch the game has found it: the
103+
rules' counts then show whether their conditions held. It is no edit and has no undo step. The tab
104+
keeps its own docks; Findings stays open across tabs.
105+
106+
Effect blocks (violet) change the monster's counters and flags when their path fires. A path may
107+
end at an effect with nothing to play: it applies its effects, keeps its `#n` on that block, and
108+
the scan goes on to the next path. A counter, flag or signal name is a box of the names the graph
109+
already uses; type a new one (lowercase letters, digits, `_`, starting with a letter) or it is put
110+
back. An On signal block fires when Lua raises that name: `port:fire("name")`; `port:var("name")`
111+
reads a counter or flag and `port:var("name", v)` sets it. Hunter side ticks any of front, left,
112+
right, behind.
103113

104114
## Tests
105115

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

Lines changed: 42 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
from __future__ import annotations
1313

14+
import itertools
1415
import typing
1516
from collections import Counter
1617
from collections.abc import Callable, Collection, Mapping, Sequence
@@ -35,12 +36,8 @@
3536
stored."""
3637
INSERTABLE = ("condition", "modifier", "effect")
3738
"""The roles of the blocks a rule holds besides its first."""
38-
ROLE_TITLES = {
39-
"event": "Events",
40-
"state": "State",
41-
"condition": "Conditions",
42-
"modifier": "Modifiers",
43-
}
39+
ROLE_TITLES = {"state": "State"}
40+
"""A palette group's title where the role in plural is not it."""
4441

4542

4643
class Wire(NamedTuple):
@@ -208,7 +205,8 @@ class Reading(NamedTuple):
208205

209206
paths: list[Path]
210207
priority: dict[str, list[int]]
211-
"""Block id -> the 1-based places in `paths` of the paths that play from it."""
208+
"""Block id -> the 1-based places in `paths` of the paths that end at it; a path that plays
209+
nothing ends at its last block too."""
212210
loose: list[str]
213211
refused: dict[str, str]
214212
"""Block id -> why, for the blocks on a path `compile` refuses."""
@@ -242,13 +240,16 @@ def main_options() -> list[tuple[str, int]]:
242240

243241

244242
def options(m: Manifest, kind: str) -> dict[str, list[tuple[str, object]]]:
245-
"""The (label, value) choices of `kind`'s parts and main states, by param."""
243+
"""The (label, value) choices of `kind`'s parts and main states, and the names in use, by
244+
param."""
246245
out: dict[str, list[tuple[str, object]]] = {}
247246
for p in KINDS[kind].params:
248247
if p.type == "part":
249248
out[p.name] = list(part_options(m, p.optional))
250249
elif p.type == "mains":
251250
out[p.name] = list(main_options())
251+
elif p.type in behaviour.NAMES:
252+
out[p.name] = [(n, n) for n in behaviour.names(m, p.type)]
252253
return out
253254

254255

@@ -271,19 +272,31 @@ def insertable() -> list[tuple[str, list[tuple[str, str, str]]]]:
271272
return palette(INSERTABLE)
272273

273274

274-
def start(p: Param) -> object:
275-
"""A new block's value for `p`: the default, else the least, else the first choice; none
276-
for an optional param."""
275+
def fresh(stem: str, taken: Collection[str]) -> str:
276+
"""The first of `stem1`, `stem2`, ... not in `taken`."""
277+
return next(f"{stem}{k}" for k in itertools.count(1) if f"{stem}{k}" not in taken)
278+
279+
280+
def start(p: Param, taken: Collection[str] = ()) -> object:
281+
"""A new block's value for `p`: the default, else a name not in `taken`, the first main
282+
state or side, the least, or the first choice; none for an optional param."""
277283
if p.optional:
278284
return None
279285
if p.default is not None:
280286
return p.default
281-
if p.type == "mains":
287+
kind: str = p.type
288+
if kind == "mains":
282289
return [0]
290+
if kind == "sides":
291+
return list(p.choices[:1])
292+
if kind == "var":
293+
return fresh(p.name, taken)
294+
if kind == "signal":
295+
return fresh(kind, taken)
283296
if p.choices:
284297
return p.choices[0]
285298
lo = 0 if p.lo is None else p.lo
286-
return float(lo) if p.type == "float" else int(lo)
299+
return float(lo) if kind == "float" else int(lo)
287300

288301

289302
# edits
@@ -293,8 +306,15 @@ def add_block(doc: Edits, kind: str, at: Point) -> str:
293306
"""A block of `kind` at `at`, with valid values."""
294307
if kind not in KINDS:
295308
raise ManifestError(f"{kind!r} is not a kind of block")
296-
params = {p.name: v for p in KINDS[kind].params if (v := start(p)) is not None}
297-
i = behaviour.new_id(doc.manifest.behaviour)
309+
m = doc.manifest
310+
taken = {n for p in KINDS[kind].params for n in behaviour.names(m, p.type)}
311+
params: dict[str, object] = {}
312+
for p in KINDS[kind].params:
313+
if (v := start(p, taken)) is not None:
314+
params[p.name] = v
315+
if isinstance(v, str):
316+
taken.add(v)
317+
i = behaviour.new_id(m.behaviour)
298318

299319
def add(m: Manifest) -> None:
300320
m.behaviour.blocks[i] = Block(kind, _at(at), params)
@@ -417,11 +437,15 @@ def change(m: Manifest) -> None:
417437
def _norm(p: Param, value: object) -> object:
418438
if value is None:
419439
return None
420-
if p.type == "mains":
440+
kind: str = p.type
441+
if kind == "mains":
421442
return sorted({int(k) for k in typing.cast(Sequence[int], value)})
422-
if p.type in ("int", "part"):
443+
if kind == "sides":
444+
picked = set(typing.cast(Sequence[str], value))
445+
return [c for c in p.choices if c in picked]
446+
if kind in ("int", "part"):
423447
return int(typing.cast(int, value))
424-
if p.type == "float":
448+
if kind == "float":
425449
return float(typing.cast(float, value))
426450
return value
427451

‎apps/studio/src/mhfu_studio/monster/panels/behaviour.py‎

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,24 +33,28 @@
3333
from mhfu_studio.monster.panels.node_graph import FrameSpec, GraphView, LinkSpec, NodeSpec
3434
from mhfu_studio.monster.panels.properties import Detail, Play, Plays, PropertyBar
3535
from mhfu_studio.monster.panels.widgets import NoScene
36-
from mhfu_studio.shell.overlay import Ink
36+
from mhfu_studio.shell.overlay import Color, Ink
3737
from mhfu_studio.ui import kit, theme
3838

3939
if TYPE_CHECKING:
4040
from mhfu_studio.monster.workspace import MonsterWorkspace
4141
from mhfu_studio.shell.studio import Studio
4242

4343
#: a node's colour by role; a move has a role of its own
44-
ROLES = {
44+
ROLES: dict[str, Color] = {
4545
"event": Ink.AXIS_X,
4646
"state": Ink.AXIS_Y,
4747
"condition": Ink.AXIS_Z,
4848
"modifier": Ink.WARNING,
49+
"effect": Ink.EFFECT,
4950
"move": Ink.SELECTION,
5051
}
5152
MOVE_ROLE = "move"
5253
WARN = chr(0x26A0)
53-
HINT = "Right-click adds a block · drag from a port to wire · higher blocks are checked first"
54+
HINT = (
55+
"Right-click adds a block · drag from a port to wire · higher blocks are checked first,"
56+
" but paths that play nothing run before all of them"
57+
)
5458
#: over a trigger's rows, and what a wire dragged there is told
5559
SCOPED_HINT = (
5660
"+ Rule adds a rule · right-click a block to insert one · Delete removes · drag a frame to"
@@ -59,6 +63,7 @@
5963
ORDER_HINT = (
6064
"Right-click a block to insert one · Delete removes · drag a frame to reorder · wire in Canvas"
6165
)
66+
FIRE_TIP = "Raises {} in the running game now; the rules' counts show whether their conditions held"
6267
SCOPED = "Wire by dragging ports in All, Canvas"
6368
UNPLACED = "A rule with no move yet is checked by nothing, so it has no place to move"
6469
CROSSED = "; a rule moves only among its own trigger's rules, the others keep their places"
@@ -108,6 +113,12 @@ def __init__(self, ws: MonsterWorkspace, studio: Studio) -> None:
108113
" frame up to raise it",
109114
on=self._add_rule,
110115
)
116+
self.fire = kit.button(
117+
"Fire",
118+
tip=FIRE_TIP,
119+
on=studio.act("fire in the game", self._fire),
120+
icon="ph.lightning",
121+
)
111122
self.mode = kit.Segmented(
112123
[(ORDER, "In order"), (CANVAS, "Canvas")],
113124
tip="All rules: each as a framed row in the order they are checked, or the free"
@@ -178,6 +189,7 @@ def __init__(self, ws: MonsterWorkspace, studio: Studio) -> None:
178189
self.hint,
179190
self.mode,
180191
self.add_rule,
192+
self.fire,
181193
self.fit,
182194
self.spread,
183195
self.watch,
@@ -512,8 +524,23 @@ def _marks(self) -> None:
512524
if self.ws.watch.lit():
513525
self.glow.start(round(rule_game.FLASH * 1000) + 50)
514526

527+
def _fire(self) -> None:
528+
"""Raises the scoped trigger in the running game; the game is not the document, so there
529+
is no undo step."""
530+
t = self._current()
531+
if t is not None:
532+
self.ws.fire(t)
533+
515534
def _status(self) -> None:
516-
"""The header's words on the watch, and the outline's totals."""
535+
"""The header's words on the watch, Fire's state, and the outline's totals."""
536+
t = self._current()
537+
self.fire.setVisible(t is not None and t.kind in rule_game.FIRE)
538+
if t is not None and t.kind in rule_game.FIRE:
539+
why = self.ws.fire_blocker(t)
540+
self.fire.setEnabled(why is None)
541+
self.fire.setToolTip(
542+
FIRE_TIP.format(triggers.title(t)) if why is None else f"Fire: {why}"
543+
)
517544
w = self.ws.watch
518545
self.live.setText(w.status)
519546
self.live.setToolTip(w.status)

‎apps/studio/src/mhfu_studio/monster/panels/moves.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
made from the clip on screen, renamed, deleted, its fields and steer edited, and played in the
55
running game. Its attack windows are drawn on the Timeline. The moves that follow one another are
66
a sequence, grouped in the table and edited under the picked move. What plays them, and when, is
7-
the Behaviour dock's graph."""
7+
the Behaviour tab's graph."""
88

99
from __future__ import annotations
1010

‎apps/studio/src/mhfu_studio/monster/panels/properties.py‎

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from dataclasses import dataclass, field
1414
from typing import Any, cast
1515

16-
from mhfu_port.behaviour import Param
16+
from mhfu_port.behaviour import ID, NAMES, Param
1717
from PySide6.QtCore import QObject, QSignalBlocker, Signal
1818
from PySide6.QtWidgets import (
1919
QComboBox,
@@ -30,6 +30,9 @@
3030
from mhfu_studio.ui import kit
3131

3232
Option = tuple[str, object]
33+
NUMBERS = ("int", "float")
34+
#: a multi-select over the options
35+
PICKS = ("mains", "sides")
3336
NONE = "none"
3437
#: where a number field with no bound ends
3538
_FAR = 10**9
@@ -44,6 +47,9 @@ class ParamControl(QObject):
4447
def __init__(self, param: Param | None, options: Sequence[Option] = ()) -> None:
4548
super().__init__()
4649
self.optional = param is not None and param.optional
50+
self._names = param is not None and param.type in NAMES
51+
#: a name combo's committed text; what its edit box shows may be ahead of it
52+
self._held = ""
4753
self._vals: list[object] = []
4854
self._opts: list[Option] = []
4955
self._built = False
@@ -59,9 +65,9 @@ def _make(self, param: Param | None) -> QWidget:
5965
line.setMinimumWidth(160)
6066
line.editingFinished.connect(self._changed)
6167
return line
62-
if param.type in ("int", "float"):
68+
if param.type in NUMBERS:
6369
return self._number(param)
64-
if param.type == "mains":
70+
if param.type in PICKS:
6571
button = QToolButton()
6672
button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
6773
button.setMinimumWidth(120)
@@ -71,7 +77,18 @@ def _make(self, param: Param | None) -> QWidget:
7177
return button
7278
combo = QComboBox()
7379
combo.setMinimumWidth(120)
74-
combo.currentIndexChanged.connect(self._changed)
80+
if param.type in NAMES:
81+
combo.setEditable(True)
82+
combo.setInsertPolicy(QComboBox.InsertPolicy.NoInsert)
83+
combo.setCompleter(cast(Any, None)) # no inline completion: a name may start another
84+
combo.currentIndexChanged.connect(self._commit)
85+
edit = combo.lineEdit()
86+
if edit is not None:
87+
edit.editingFinished.connect(self._commit)
88+
elif param.type in ("choice", "part"):
89+
combo.currentIndexChanged.connect(self._changed)
90+
else:
91+
raise ValueError(f"parameter {param.name!r}: unknown type {param.type!r}")
7592
return combo
7693

7794
def _number(self, param: Param) -> QWidget:
@@ -105,6 +122,8 @@ def get_value(self) -> object:
105122
return None
106123
return int(ctl.value()) if isinstance(ctl, QSpinBox) else float(ctl.value())
107124
if isinstance(ctl, QComboBox):
125+
if self._names:
126+
return ctl.currentText()
108127
return (
109128
self._vals[ctl.currentIndex()]
110129
if 0 <= ctl.currentIndex() < len(self._vals)
@@ -121,6 +140,12 @@ def set_value(self, value: object) -> None:
121140
ctl.setText("" if value is None else str(value))
122141
elif isinstance(ctl, QSpinBox | QDoubleSpinBox):
123142
ctl.setValue(cast(Any, ctl.minimum() if value is None else value))
143+
elif isinstance(ctl, QComboBox) and self._names:
144+
text = "" if value is None else str(value)
145+
ctl.setCurrentIndex(ctl.findText(text)) # -1 clears the edit box
146+
if ctl.currentText() != text: # a name the options lack still shows
147+
ctl.setEditText(text)
148+
self._held = text
124149
elif isinstance(ctl, QComboBox):
125150
if value not in self._vals: # a value the options lack still shows
126151
self._vals.append(value)
@@ -149,9 +174,13 @@ def set_options(self, options: Sequence[Option]) -> None:
149174
built = self._built
150175
if built and list(options) == self._opts:
151176
return
152-
keep = self.get_value() if built else None
177+
keep = (self._held if self._names else self.get_value()) if built else None
153178
self._built, self._opts = True, list(options)
154-
if isinstance(ctl, QComboBox):
179+
if isinstance(ctl, QComboBox) and self._names:
180+
with QSignalBlocker(ctl):
181+
ctl.clear()
182+
ctl.addItems([str(v) for _, v in self._opts])
183+
elif isinstance(ctl, QComboBox):
155184
none = self.optional and all(v is not None for _, v in self._opts)
156185
self._vals = ([None] if none else []) + [v for _, v in self._opts]
157186
with QSignalBlocker(ctl):
@@ -179,6 +208,17 @@ def _summary(self) -> None:
179208
text = ", ".join(names) if 0 < len(names) <= 2 else f"{len(names)} picked"
180209
cast(QToolButton, self.ctl).setText(text if names else (NONE if self.optional else "-"))
181210

211+
def _commit(self, *_: object) -> None:
212+
"""A name combo's pick or typed text: a name that is not one is put back, unheard."""
213+
text = cast(QComboBox, self.ctl).currentText().strip()
214+
if text == self._held:
215+
return
216+
if ID.fullmatch(text) is None:
217+
self.set_value(self._held)
218+
return
219+
self.set_value(text)
220+
self._changed()
221+
182222
def _changed(self, *_: object) -> None:
183223
if isinstance(self.ctl, QToolButton):
184224
self._summary()

0 commit comments

Comments
 (0)