Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/dusted/cursor.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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:
Expand Down
42 changes: 35 additions & 7 deletions src/dusted/gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
190 changes: 51 additions & 139 deletions src/dusted/inputs.py
Original file line number Diff line number Diff line change
@@ -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)
Loading