|
| 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