From 9aed36eaba923a7fda57e94ce0e76c23d5395bda Mon Sep 17 00:00:00 2001 From: Alex Morson Date: Mon, 19 Jan 2026 19:23:03 +0000 Subject: [PATCH] Remove grid assumption from representation of inputs Now the underlying representation of the inputs is a list of intents, and the grid is merely a view on top of that data. --- src/dusted/cursor.py | 6 +- src/dusted/gui.py | 42 +++- src/dusted/inputs.py | 190 ++++----------- src/dusted/inputs_grid.py | 219 ++++++++++++++++++ src/dusted/inputs_view.py | 12 +- src/dusted/replay_diagnostics.py | 116 +++++----- src/dusted/undo_stack.py | 10 +- tests/test_cursor.py | 6 +- tests/{test_inputs.py => test_inputs_grid.py} | 36 ++- tests/test_replay_diagnostics.py | 6 +- 10 files changed, 406 insertions(+), 237 deletions(-) create mode 100644 src/dusted/inputs_grid.py rename tests/{test_inputs.py => test_inputs_grid.py} (84%) diff --git a/src/dusted/cursor.py b/src/dusted/cursor.py index 71058b1..cacf2f5 100644 --- a/src/dusted/cursor.py +++ b/src/dusted/cursor.py @@ -1,11 +1,11 @@ from dusted.broadcaster import Broadcaster -from dusted.inputs import INTENT_COUNT, Inputs +from dusted.inputs_grid import GRID_INTENTS, InputsGrid class Cursor(Broadcaster): """Manages the cursor and current selection.""" - def __init__(self, inputs: Inputs) -> None: + def __init__(self, inputs: InputsGrid) -> None: super().__init__() self.inputs = inputs @@ -22,7 +22,7 @@ def is_selected(self, row: int, col: int) -> bool: ) def set(self, row: int, col: int, keep_selection: bool = False) -> None: - new_row = max(0, min(INTENT_COUNT - 1, row)) + new_row = max(0, min(len(GRID_INTENTS) - 1, row)) new_col = max(0, min(len(self.inputs), col)) if new_row == self.current_row and new_col == self.current_col: diff --git a/src/dusted/gui.py b/src/dusted/gui.py index c3600f5..b6ee2b4 100644 --- a/src/dusted/gui.py +++ b/src/dusted/gui.py @@ -8,14 +8,15 @@ import tkinter.filedialog import tkinter.messagebox -from dustmaker.replay import Character, PlayerData, Replay +from dustmaker.replay import Character, IntentStream, PlayerData, Replay from dusted import dustforce, utils from dusted.config import config from dusted.cursor import Cursor from dusted.diagnostics_summary_view import DiagnosticsSummaryView from dusted.dialog import SimpleDialog -from dusted.inputs import Inputs +from dusted.inputs import Inputs, Intents +from dusted.inputs_grid import InputsGrid from dusted.inputs_view import InputsView from dusted.jump_to_frame import JumpToFrameDialog from dusted.level import Level @@ -50,9 +51,9 @@ def __init__(self): self.level = Level("downhill") self.character = Character.DUSTMAN - self.inputs = Inputs() + self.inputs = Inputs([Intents.default()] * 55) self.diagnostics = ReplayDiagnostics(self.inputs) - self.cursor = Cursor(self.inputs) + self.cursor = Cursor(InputsGrid(self.inputs)) self.undo_stack = UndoStack(self.inputs, self.cursor) self.diagnostics.subscribe(self.on_diagnostics_change) @@ -290,10 +291,20 @@ def save_file(self, save_as: bool = False): elif not self.undo_stack.is_modified: return True + intent_streams = { + IntentStream.X: [intents.x for intents in self.inputs], + IntentStream.Y: [intents.y for intents in self.inputs], + IntentStream.JUMP: [intents.jump for intents in self.inputs], + IntentStream.DASH: [intents.dash for intents in self.inputs], + IntentStream.FALL: [intents.fall for intents in self.inputs], + IntentStream.LIGHT: [intents.light for intents in self.inputs], + IntentStream.HEAVY: [intents.heavy for intents in self.inputs], + IntentStream.TAUNT: [intents.taunt for intents in self.inputs], + } replay = Replay( username=b"TAS", level=self.level.get().encode(), - players=[PlayerData(self.character, self.inputs.get_intents())], + players=[PlayerData(self.character, intent_streams)], ) utils.write_replay_to_file(self.file, replay) @@ -306,7 +317,7 @@ def callback(metadata: ReplayMetadata): self.file = None self.level.set(metadata.level) self.character = metadata.character - self.inputs.reset() + self.inputs[:] = [Intents.default()] * 55 self.undo_stack.clear() ReplayMetadataDialog(self, callback, creating=True) @@ -404,7 +415,24 @@ def load_replay(self, replay: Replay, filepath: str | None = None) -> None: self.file = filepath self.level.set(replay.level.decode()) self.character = replay.players[0].character - self.inputs.set_intents(replay.players[0].intents) + + inputs: list[Intents] = [] + player_data = replay.players[0] + frame_count = max(len(stream) for stream in player_data.intents.values()) + for frame in range(frame_count): + inputs.append( + Intents( + x=player_data.get_intent_value(IntentStream.X, frame), + y=player_data.get_intent_value(IntentStream.Y, frame), + jump=player_data.get_intent_value(IntentStream.JUMP, frame), + dash=player_data.get_intent_value(IntentStream.DASH, frame), + fall=player_data.get_intent_value(IntentStream.FALL, frame), + light=player_data.get_intent_value(IntentStream.LIGHT, frame), + heavy=player_data.get_intent_value(IntentStream.HEAVY, frame), + taunt=player_data.get_intent_value(IntentStream.TAUNT, frame), + ) + ) + self.inputs[:] = inputs self.undo_stack.clear() if filepath is not None: diff --git a/src/dusted/inputs.py b/src/dusted/inputs.py index d070e7e..bc79e5e 100644 --- a/src/dusted/inputs.py +++ b/src/dusted/inputs.py @@ -1,153 +1,65 @@ -from collections.abc import Collection +from __future__ import annotations -from dustmaker.replay import IntentStream +from collections.abc import Iterable, Iterator +from dataclasses import dataclass +from typing import overload from dusted.broadcaster import Broadcaster -INTENT_COUNT = 8 - -DEFAULT_INPUTS = "11000000" -VALID_INPUTS = [ - "012", - "012", - "012", - "01", - "01", - "0123456789ab", - "0123456789ab", - "012", -] -INPUT_TO_TEXT = [ - lambda x: str(x + 1), - lambda x: str(x + 1), - lambda x: str(x), - lambda x: str(x), - lambda x: str(x), - lambda x: hex(x)[2:], - lambda x: hex(x)[2:], - lambda x: str(x), -] -TEXT_TO_INPUT = [ - lambda x: int(x) - 1, - lambda x: int(x) - 1, - lambda x: int(x), - lambda x: int(x), - lambda x: int(x), - lambda x: int(x, 16), - lambda x: int(x, 16), - lambda x: int(x), -] - class Inputs(Broadcaster): - """Stores a rectangular grid of inputs.""" - - def __init__(self, inputs: Collection[Collection[str]] | None = None) -> None: + def __init__(self, inputs: list[Intents] | None = None) -> None: super().__init__() - self.length = 0 - self.inputs: list[list[str]] = [] - if inputs is not None: - self.set(inputs) - else: - self.reset() + self._frames = inputs if inputs is not None else [] def __len__(self) -> int: - """Return the number of frames that the inputs cover.""" - return self.length - - def set_intents(self, intents: dict[IntentStream, list[int]]) -> None: - inputs = [] - for intent, input_to_text in enumerate(INPUT_TO_TEXT): - inputs.append( - [input_to_text(x) for x in intents.get(IntentStream(intent), [])] - ) - self.set(inputs) - - def get_intents(self) -> dict[IntentStream, list[int]]: - intents = {} - for intent, text_to_input in enumerate(TEXT_TO_INPUT): - intents[IntentStream(intent)] = [ - text_to_input(c) for c in self.inputs[intent] - ] - return intents - - def reset(self) -> None: - """Reset to default inputs.""" - self.set(list(zip(*[DEFAULT_INPUTS for _ in range(55)]))) - - def set(self, inputs: Collection[Collection[str]]) -> None: - """Load a (not necessarily rectangular) grid of inputs.""" - self.length = max(len(line) for line in inputs) - self.inputs = [] - for line, default in zip(inputs, DEFAULT_INPUTS): - # Pad rows to the same length - self.inputs.append(list(line) + [default] * (self.length - len(line))) - self.broadcast() - - def write(self, position: tuple[int, int], block: list[list[str]]) -> None: - """Paste a block of inputs into the grid, validating intents.""" - top, left = position - assert ( - top >= 0 - and left >= 0 - and top + len(block) <= INTENT_COUNT - and left + len(block[0]) <= self.length - ) - for row, line in enumerate(block, start=top): - for col, char in enumerate(line, start=left): - if char in VALID_INPUTS[row]: - self.inputs[row][col] = char - self.broadcast() - - def fill(self, selection: tuple[int, int, int, int], char: str) -> None: - """Fill a block of the grid with the same input.""" - top, left, bottom, right = selection - assert 0 <= top <= bottom <= INTENT_COUNT and 0 <= left <= right - for row in range(top, bottom + 1): - for col in range(left, min(right + 1, self.length)): - if char in VALID_INPUTS[row]: - self.inputs[row][col] = char + return len(self._frames) + + def __iter__(self) -> Iterator[Intents]: + return iter(self._frames) + + @overload + def __getitem__(self, index: int) -> Intents: ... + @overload + def __getitem__(self, index: slice) -> list[Intents]: ... + def __getitem__(self, index: int | slice) -> Intents | list[Intents]: + return self._frames[index] + + @overload + def __setitem__(self, index: int, value: Intents) -> None: ... + @overload + def __setitem__(self, index: slice, value: Iterable[Intents]) -> None: ... + def __setitem__( + self, index: int | slice, value: Intents | Iterable[Intents] + ) -> None: + if isinstance(index, int): + assert isinstance(value, Intents) + self._frames[index] = value + else: + assert isinstance(value, Iterable) + self._frames[index] = value self.broadcast() - def clear(self, selection: tuple[int, int, int, int]) -> None: - """Reset a block of the grid to the default inputs.""" - top, left, bottom, right = selection - assert 0 <= top <= bottom < INTENT_COUNT and 0 <= left <= right - for row in range(top, bottom + 1): - char = DEFAULT_INPUTS[row] - for col in range(left, min(right + 1, self.length)): - self.inputs[row][col] = char + @overload + def __delitem__(self, index: int) -> None: ... + @overload + def __delitem__(self, index: slice) -> None: ... + def __delitem__(self, index: int | slice) -> None: + del self._frames[index] self.broadcast() - def get(self) -> list[list[str]]: - """Return all inputs.""" - return self.inputs - - def read(self, selection: tuple[int, int, int, int]) -> list[list[str]]: - """Return a block of the grid.""" - top, left, bottom, right = selection - assert 0 <= top and bottom < INTENT_COUNT and 0 <= left <= right - return [ - list(self.inputs[row][left : right + 1]) for row in range(top, bottom + 1) - ] - def at(self, row: int, col: int) -> str: - """Return a single cell of the grid.""" - assert 0 <= row < INTENT_COUNT and 0 <= col < self.length - return self.inputs[row][col] - - def delete_frames(self, start: int, count: int) -> None: - """Delete some frames.""" - assert 0 <= start and count >= 0 - for row in range(0, INTENT_COUNT): - del self.inputs[row][start : start + count] - self.length -= count - self.broadcast() - - def insert_frames(self, start: int, count: int) -> None: - """Insert default-initialised frames.""" - assert 0 <= start <= self.length and count >= 0 - for row in range(0, INTENT_COUNT): - self.inputs[row][start:start] = [DEFAULT_INPUTS[row]] * count - self.length += count - self.broadcast() +@dataclass(frozen=True, slots=True) +class Intents: + x: int + y: int + jump: int + dash: int + fall: int + light: int + heavy: int + taunt: int + + @classmethod + def default(cls) -> Intents: + return cls(0, 0, 0, 0, 0, 0, 0, 0) diff --git a/src/dusted/inputs_grid.py b/src/dusted/inputs_grid.py new file mode 100644 index 0000000..5528da6 --- /dev/null +++ b/src/dusted/inputs_grid.py @@ -0,0 +1,219 @@ +import dataclasses +from abc import ABC, abstractmethod +from collections.abc import Collection + +from dusted.broadcaster import Broadcaster +from dusted.inputs import Inputs, Intents + + +class GridIntent(ABC): + @staticmethod + @abstractmethod + def set_value(intents: Intents, value: str) -> Intents: ... + + @staticmethod + @abstractmethod + def get_value(intents: Intents) -> str: ... + + +class XGridIntent(GridIntent): + @staticmethod + def set_value(intents: Intents, value: str) -> Intents: + if value not in "012": + return intents + return dataclasses.replace(intents, x=int(value) - 1) + + @staticmethod + def get_value(intents: Intents) -> str: + return str(intents.x + 1) + + +class YGridIntent(GridIntent): + @staticmethod + def set_value(intents: Intents, value: str) -> Intents: + if value not in "012": + return intents + return dataclasses.replace(intents, y=int(value) - 1) + + @staticmethod + def get_value(intents: Intents) -> str: + return str(intents.y + 1) + + +class JumpGridIntent(GridIntent): + @staticmethod + def set_value(intents: Intents, value: str) -> Intents: + if value not in "012": + return intents + return dataclasses.replace(intents, jump=int(value)) + + @staticmethod + def get_value(intents: Intents) -> str: + return str(intents.jump) + + +class DashGridIntent(GridIntent): + @staticmethod + def set_value(intents: Intents, value: str) -> Intents: + if value not in "01": + return intents + return dataclasses.replace(intents, dash=int(value)) + + @staticmethod + def get_value(intents: Intents) -> str: + return str(intents.dash) + + +class FallGridIntent(GridIntent): + @staticmethod + def set_value(intents: Intents, value: str) -> Intents: + if value not in "01": + return intents + return dataclasses.replace(intents, fall=int(value)) + + @staticmethod + def get_value(intents: Intents) -> str: + return str(intents.fall) + + +class LightGridIntent(GridIntent): + @staticmethod + def set_value(intents: Intents, value: str) -> Intents: + if value not in "0123456789ab": + return intents + return dataclasses.replace(intents, light=int(value, 16)) + + @staticmethod + def get_value(intents: Intents) -> str: + return hex(intents.light)[2:] + + +class HeavyGridIntent(GridIntent): + @staticmethod + def set_value(intents: Intents, value: str) -> Intents: + if value not in "0123456789ab": + return intents + return dataclasses.replace(intents, heavy=int(value, 16)) + + @staticmethod + def get_value(intents: Intents) -> str: + return hex(intents.heavy)[2:] + + +class TauntGridIntent(GridIntent): + @staticmethod + def set_value(intents: Intents, value: str) -> Intents: + if value not in "012": + return intents + return dataclasses.replace(intents, taunt=int(value)) + + @staticmethod + def get_value(intents: Intents) -> str: + return str(intents.taunt) + + +GRID_INTENTS: list[GridIntent] = [ + XGridIntent(), + YGridIntent(), + JumpGridIntent(), + DashGridIntent(), + FallGridIntent(), + LightGridIntent(), + HeavyGridIntent(), + TauntGridIntent(), +] + + +class InputsGrid(Broadcaster): + """Wrapper that represents inputs as a grid of characters.""" + + def __init__(self, inputs: Inputs) -> None: + super().__init__() + + self._inputs = inputs + self._inputs.subscribe(self.broadcast) + + def __len__(self) -> int: + """Return the number of frames that the inputs cover.""" + return len(self._inputs) + + def _get_cell(self, row: int, col: int) -> str: + assert 0 <= row < len(GRID_INTENTS) and 0 <= col < len(self._inputs) + return GRID_INTENTS[row].get_value(self._inputs[col]) + + def _set_cell(self, row: int, col: int, value: str) -> None: + assert 0 <= row < len(GRID_INTENTS) and 0 <= col < len(self._inputs) + self._inputs[col] = GRID_INTENTS[row].set_value(self._inputs[col], value) + + def set(self, inputs: Collection[Collection[str]]) -> None: + """Load a (not necessarily rectangular) grid of inputs.""" + with self._inputs.batch(): + self._inputs[:] = [ + Intents.default() for _ in range(max(len(row) for row in inputs)) + ] + for intent, row in zip(GRID_INTENTS, inputs): + for frame, value in enumerate(row): + self._inputs[frame] = intent.set_value(self._inputs[frame], value) + + def write(self, position: tuple[int, int], block: list[list[str]]) -> None: + """Paste a block of inputs into the grid, only writing valid intents.""" + top, left = position + assert ( + top >= 0 + and left >= 0 + and top + len(block) <= len(GRID_INTENTS) + and left + len(block[0]) <= len(self._inputs) + ) + with self._inputs.batch(): + for row, line in enumerate(block, start=top): + for col, char in enumerate(line, start=left): + self._set_cell(row, col, char) + + def fill(self, selection: tuple[int, int, int, int], char: str) -> None: + """Fill a block of the grid with the same input.""" + top, left, bottom, right = selection + assert 0 <= top <= bottom <= len(GRID_INTENTS) and 0 <= left <= right + with self._inputs.batch(): + for row in range(top, bottom + 1): + for col in range(left, min(right + 1, len(self._inputs))): + self._set_cell(row, col, char) + + def clear(self, selection: tuple[int, int, int, int]) -> None: + """Reset a block of the grid to the default inputs.""" + top, left, bottom, right = selection + assert 0 <= top <= bottom < len(GRID_INTENTS) and 0 <= left <= right + with self._inputs.batch(): + for row in range(top, bottom + 1): + char = GRID_INTENTS[row].get_value(Intents.default()) + for col in range(left, min(right + 1, len(self._inputs))): + self._set_cell(row, col, char) + + def get(self) -> list[list[str]]: + """Return all inputs.""" + return [ + [intent.get_value(intents) for intents in self._inputs] + for intent in GRID_INTENTS + ] + + def read(self, selection: tuple[int, int, int, int]) -> list[list[str]]: + """Return a block of the grid.""" + top, left, bottom, right = selection + assert 0 <= top and bottom < len(GRID_INTENTS) and 0 <= left <= right + return [ + [self._get_cell(row, col) for col in range(left, right + 1)] + for row in range(top, bottom + 1) + ] + + def at(self, row: int, col: int) -> str: + """Return a single cell of the grid.""" + return self._get_cell(row, col) + + def delete_frames(self, start: int, count: int) -> None: + """Delete some frames.""" + assert 0 <= start and count >= 0 + del self._inputs[start : start + count] + + def insert_frames(self, start: int, count: int) -> None: + """Insert default-initialised frames.""" + assert 0 <= start <= len(self._inputs) and count >= 0 + self._inputs[start:start] = [Intents.default() for _ in range(count)] diff --git a/src/dusted/inputs_view.py b/src/dusted/inputs_view.py index ea68f8a..ea08338 100644 --- a/src/dusted/inputs_view.py +++ b/src/dusted/inputs_view.py @@ -5,12 +5,16 @@ from dusted.cursor import Cursor from dusted.dialog import SimpleDialog -from dusted.inputs import DEFAULT_INPUTS, INTENT_COUNT, Inputs +from dusted.inputs import Inputs +from dusted.inputs_grid import InputsGrid from dusted.jump_to_frame import JumpToFrameDialog from dusted.replay_diagnostics import ReplayDiagnostics from dusted.undo_stack import UndoStack from dusted.utils import modifier_held +DEFAULT_INPUTS = "11000000" +INTENT_COUNT = 8 + GRID_ROWS = INTENT_COUNT + 1 GRID_SIZE = 20 @@ -94,7 +98,7 @@ def __init__( ) self._scrollbar = scrollbar - self._inputs = inputs + self._inputs = InputsGrid(inputs) self._diagnostics = diagnostics self._cursor = cursor self._undo_stack = undo_stack @@ -260,11 +264,11 @@ def paste(self) -> None: # Check if the input grid needs to be resized extra_frames = max( - 0, self._cursor.selection_left + len(block[0]) - self._inputs.length + 0, self._cursor.selection_left + len(block[0]) - len(self._inputs) ) with self._undo_stack.execute("Paste inputs"): - self._inputs.insert_frames(self._inputs.length, extra_frames) + self._inputs.insert_frames(len(self._inputs), extra_frames) self._inputs.write(self._cursor.selection_start, block) def clear_selection(self) -> None: diff --git a/src/dusted/replay_diagnostics.py b/src/dusted/replay_diagnostics.py index 036dca5..13204ec 100644 --- a/src/dusted/replay_diagnostics.py +++ b/src/dusted/replay_diagnostics.py @@ -10,18 +10,18 @@ # The attack intents that are allowed to come next. VALID_NEXT_ATTACK_INTENT = { - "0": {"a", "0"}, - "a": {"a", "b", "9", "0"}, - "b": {"b", "0"}, - "9": {"a", "b", "8", "0"}, - "8": {"a", "b", "7", "0"}, - "7": {"a", "b", "6", "0"}, - "6": {"a", "b", "5", "0"}, - "5": {"a", "b", "4", "0"}, - "4": {"a", "b", "3", "0"}, - "3": {"a", "b", "2", "0"}, - "2": {"a", "b", "1", "0"}, - "1": {"a", "b", "0"}, + 0: {10, 0}, + 10: {10, 11, 9, 0}, + 11: {11, 0}, + 9: {10, 11, 8, 0}, + 8: {10, 11, 7, 0}, + 7: {10, 11, 6, 0}, + 6: {10, 11, 5, 0}, + 5: {10, 11, 4, 0}, + 4: {10, 11, 3, 0}, + 3: {10, 11, 2, 0}, + 2: {10, 11, 1, 0}, + 1: {10, 11, 0}, } @@ -70,29 +70,29 @@ def _recalculate(self) -> None: # Frame and direction of the first tap of a potential double tap dash. first_tap: tuple[int, Direction] | None = None - prev_x = "1" - prev_y = "1" - prev_jump = "0" - prev_dash = "0" - prev_fall = "0" - prev_light = "0" - prev_heavy = "0" - prev_taunt = "0" - - for frame in range(len(self._inputs)): - x = self._inputs.at(0, frame) - y = self._inputs.at(1, frame) - jump = self._inputs.at(2, frame) - dash = self._inputs.at(3, frame) - fall = self._inputs.at(4, frame) - light = self._inputs.at(5, frame) - heavy = self._inputs.at(6, frame) - taunt = self._inputs.at(7, frame) - - left_pressed = prev_x != "0" and x == "0" - right_pressed = prev_x != "2" and x == "2" - up_pressed = prev_y != "0" and y == "0" - down_pressed = prev_y != "2" and y == "2" + prev_x = 0 + prev_y = 0 + prev_jump = 0 + prev_dash = 0 + prev_fall = 0 + prev_light = 0 + prev_heavy = 0 + prev_taunt = 0 + + for frame, intents in enumerate(self._inputs): + x = intents.x + y = intents.y + jump = intents.jump + dash = intents.dash + fall = intents.fall + light = intents.light + heavy = intents.heavy + taunt = intents.taunt + + left_pressed = prev_x != -1 and x == -1 + right_pressed = prev_x != 1 and x == 1 + up_pressed = prev_y != -1 and y == -1 + down_pressed = prev_y != 1 and y == 1 double_tap: Direction | None = None @@ -120,10 +120,10 @@ def _recalculate(self) -> None: first_tap = frame, direction if double_tap in (Direction.LEFT, Direction.RIGHT): - if dash != "0": + if dash != 0: # This dash was caused by a double tap, and (probably) not # by pressing the dash key. - dash = "0" + dash = 0 else: # It looks like there should be a dash intent on this # frame. This is only a warning because it is possible to @@ -132,10 +132,10 @@ def _recalculate(self) -> None: self._warnings.add((3, frame)) if double_tap is Direction.DOWN: - if fall != "0": + if fall != 0: # This fall was caused by a double tap, and (probably) not # by pressing the dash key. - fall = "0" + fall = 0 else: # It looks like there should be a fall intent on this # frame. This is only a warning because it is possible to @@ -143,25 +143,25 @@ def _recalculate(self) -> None: # both up and down at the same time. self._warnings.add((4, frame)) - jump_pressed = prev_jump == "0" and jump != "0" + jump_pressed = prev_jump == 0 and jump != 0 dash_pressed = ( - prev_dash == "0" and prev_fall == "0" and (dash != "0" or fall != "0") + prev_dash == 0 and prev_fall == 0 and (dash != 0 or fall != 0) ) - light_pressed = prev_light == "0" and light != "0" - heavy_pressed = prev_heavy == "0" and heavy != "0" - taunt_pressed = prev_taunt == "0" and taunt != "0" + light_pressed = prev_light == 0 and light != 0 + heavy_pressed = prev_heavy == 0 and heavy != 0 + taunt_pressed = prev_taunt == 0 and taunt != 0 if double_tap is None: - if dash != "0" and not dash_pressed: + if dash != 0 and not dash_pressed: # This is a non double tapped dash without a dash press. self._errors.add((3, frame)) - if fall != "0" and (not dash_pressed or y != "2"): + if fall != 0 and (not dash_pressed or y != 1): # This is a non double tapped fall without a dash press or # down intent. self._errors.add((4, frame)) - if dash != "0" and fall == "0" and y == "2": + if dash != 0 and fall == 0 and y == 1: # This is a non double tapped dash with down held, which # should result in a fall input, but hasn't. self._errors.add((4, frame)) @@ -188,34 +188,38 @@ def _recalculate(self) -> None: DirectionState.DOUBLE_TAPPED if double_tap is Direction.LEFT else ( - DirectionState.HELD if x == "0" else DirectionState.RELEASED + DirectionState.HELD if x == -1 else DirectionState.RELEASED ) ), right=( DirectionState.DOUBLE_TAPPED if double_tap is Direction.RIGHT else ( - DirectionState.HELD if x == "2" else DirectionState.RELEASED + DirectionState.HELD if x == 1 else DirectionState.RELEASED ) ), - up=(DirectionState.HELD if y == "0" else DirectionState.RELEASED), + up=(DirectionState.HELD if y == -1 else DirectionState.RELEASED), down=( DirectionState.DOUBLE_TAPPED if double_tap is Direction.DOWN else ( - DirectionState.HELD if y == "2" else DirectionState.RELEASED + DirectionState.HELD if y == 1 else DirectionState.RELEASED ) ), - jump=ButtonState.HELD if jump != "0" else ButtonState.RELEASED, + jump=ButtonState.HELD if jump != 0 else ButtonState.RELEASED, dash=( ButtonState.HELD - if (dash != "0" or fall != "0") + if (dash != 0 or fall != 0) else ButtonState.RELEASED ), - light=ButtonState.HELD if light in "ab" else ButtonState.RELEASED, - heavy=ButtonState.HELD if heavy in "ab" else ButtonState.RELEASED, + light=ButtonState.HELD + if light in (10, 11) + else ButtonState.RELEASED, + heavy=ButtonState.HELD + if heavy in (10, 11) + else ButtonState.RELEASED, escape=ButtonState.RELEASED, - taunt=ButtonState.HELD if taunt != "0" else ButtonState.RELEASED, + taunt=ButtonState.HELD if taunt != 0 else ButtonState.RELEASED, ) ) diff --git a/src/dusted/undo_stack.py b/src/dusted/undo_stack.py index 6eb81c5..e1e9d44 100644 --- a/src/dusted/undo_stack.py +++ b/src/dusted/undo_stack.py @@ -6,7 +6,7 @@ from dusted.broadcaster import Broadcaster from dusted.cursor import Cursor -from dusted.inputs import Inputs +from dusted.inputs import Inputs, Intents @dataclass(frozen=True, slots=True) @@ -28,7 +28,7 @@ class Action: class Snapshot: """A snapshot of the application state.""" - inputs: tuple[tuple[str, ...], ...] + inputs: tuple[Intents, ...] cursor: tuple[int, int, int, int] @@ -52,7 +52,7 @@ def clear(self) -> None: def _snapshot(self) -> Snapshot: return Snapshot( - inputs=tuple(tuple(row) for row in self._inputs.get()), + inputs=tuple(intents for intents in self._inputs), cursor=self._cursor.selection, ) @@ -110,7 +110,7 @@ def undo(self) -> None: self._index -= 1 snapshot = self._stack[self._index].before - self._inputs.set(snapshot.inputs) + self._inputs[:] = snapshot.inputs self._cursor.select(snapshot.cursor) self.broadcast() @@ -120,7 +120,7 @@ def redo(self) -> None: return snapshot = self._stack[self._index].after - self._inputs.set(snapshot.inputs) + self._inputs[:] = snapshot.inputs self._cursor.select(snapshot.cursor) self._index += 1 diff --git a/tests/test_cursor.py b/tests/test_cursor.py index b35b50d..6061cfc 100644 --- a/tests/test_cursor.py +++ b/tests/test_cursor.py @@ -1,13 +1,15 @@ from unittest import TestCase, mock from dusted.cursor import Cursor -from dusted.inputs import Inputs +from dusted.inputs import Inputs, Intents +from dusted.inputs_grid import InputsGrid class TestCursor(TestCase): def setUp(self): inputs = Inputs() - self.cursor = Cursor(inputs) + inputs[:] = [Intents.default()] * 55 + self.cursor = Cursor(InputsGrid(inputs)) self.callback = mock.Mock() self.cursor.subscribe(self.callback) diff --git a/tests/test_inputs.py b/tests/test_inputs_grid.py similarity index 84% rename from tests/test_inputs.py rename to tests/test_inputs_grid.py index f94ab3c..355f785 100644 --- a/tests/test_inputs.py +++ b/tests/test_inputs_grid.py @@ -1,31 +1,24 @@ from unittest import TestCase, mock -from dusted.inputs import Inputs +from dusted.inputs import Inputs, Intents +from dusted.inputs_grid import InputsGrid -class TestInputs(TestCase): +class TestInputsGrid(TestCase): def setUp(self): - self.default = Inputs() - self.custom = Inputs( - ["".join(str((row + col) % 2) for col in range(100)) for row in range(8)] - ) + inputs = Inputs() + inputs[:] = [ + Intents(-1, 0, 0, 1, 0, 1, 0, 1), + Intents(0, -1, 1, 0, 1, 0, 1, 0), + ] * 50 + self.custom = InputsGrid(inputs) self.callback = mock.Mock() - self.default.subscribe(self.callback) - self.custom.subscribe(self.callback) + inputs.subscribe(self.callback) def test_length(self): - self.assertEqual(len(self.default), 55) self.assertEqual(len(self.custom), 100) - def test_get(self): - self.assertEqual(self.default.get(), [list(c * 55) for c in "11000000"]) - - def test_reset(self): - self.custom.reset() - self.callback.assert_called() - self.assertEqual(self.custom.get(), [list(c * 55) for c in "11000000"]) - def test_insert_frames(self): self.custom.insert_frames(1, 2) self.callback.assert_called() @@ -118,5 +111,10 @@ def test_read(self): ) def test_at(self): - self.assertEqual(self.default.at(2, 1), "0") - self.assertEqual(self.default.at(1, 2), "1") + self.assertEqual(self.custom.at(0, 0), "0") + self.assertEqual(self.custom.at(1, 0), "1") + self.assertEqual(self.custom.at(0, 1), "1") + self.assertEqual(self.custom.at(1, 1), "0") + self.assertEqual(self.custom.at(0, 50), "0") + with self.assertRaises(AssertionError): + self.custom.at(50, 0) diff --git a/tests/test_replay_diagnostics.py b/tests/test_replay_diagnostics.py index 890c440..4491c47 100644 --- a/tests/test_replay_diagnostics.py +++ b/tests/test_replay_diagnostics.py @@ -1,13 +1,15 @@ from unittest import TestCase from dusted.inputs import Inputs +from dusted.inputs_grid import InputsGrid from dusted.replay_diagnostics import ReplayDiagnostics class TestReplayDiagnostics(TestCase): def setUp(self) -> None: - self.inputs = Inputs() - self.diagnostics = ReplayDiagnostics(self.inputs) + inputs = Inputs() + self.inputs = InputsGrid(inputs) + self.diagnostics = ReplayDiagnostics(inputs) def test_error_fall_without_down(self): """Test that a fall intent without down being held errors."""