Программная архитектура последовательности логических вентилей

Обучающее приложение по логическим операциям: игрок может собирать цепи (схемы) из логических вентилей. Я хочу, чтобы, собрав какую-то цепь, он мог объединить ее в один.

Например, собираем схему из двух вентилей (AND и NOT) и объединяем в вентиль NAND:

введите сюда описание изображения

введите сюда описание изображения

При этом каждый вентиль может иметь один или несколько входов (вводов) и один или несколько выходов (выводов).

Я не могу придумать, как мне прописать такой класс вентиля. Вот мои идеи:

  1. Создать делегат: 'delegate List GateFunction(List)'

  2. Класс вентиля содержит поле 'GateFunction func', которое определяет функцию, по которой вводы (inputs) преобразуются в выводы (outputs).

  3. Класс вентиля содержит массив 'List _inputs', который содержит ссылки на выводы предыдущих вентилей, которые подсоединяются проводами к вводам данного вентиля.

Очень важно, что один вентиль может иметь НЕСКОЛЬКО выводов, и разные выводы одного вентиля могут потом идти к разным вентилям.

Мне нужна такая архитектура, чтобы каждый экземпляр класса хранил в себе КОНКРЕТНУЮ функцию func, оперирующую базовыми функциями.

ПРИ ЭТОМ ГЛАВНОЕ, что в рантайме пользователь будет строить и сохранять свои, новые, кастомные вентили, состоящие из ранее созданных (или базовых: and/or/not).

Буду благодарен как общим советам так и коду. Возможно, я упускаю какой-то удобный очевидный паттерн...

Идея и скриншоты взяты из видео Sebastian Lague (https://youtu.be/QZwneRb-zqA). В нем отлично показано, что мне нужно с 4 по 5 минуту.




UPD: Пример-пояснение. Игрок на поле может собрать из имеющихся блоков схемы слева, нажать на кнопочку "Create" и получить соответствующие блоки справа. При этом, как видно, все функции слева открыты, то есть могут быть подсоединены к первоначальным контактам (на 1-м скриншоте слева на экране). Эти контакты кнопкой мыши включаются и выключаются. То есть эти контакты НЕ входят в сам вентиль. Таким образом, нужна возможность указать готовому, собранному вентилю, к каким контактам присоединяются его инпуты. введите сюда описание изображения


UPD2: А по кнопке "Create" вентиль должен сохраняться в менюшку, чтобы потом его оттуда можно было взять и использовать:

введите сюда описание изображения


Ответы (3 шт):

Автор решения: Andrew_STOP_RU_AGRESSION_IN_UA

пишу псевдокодом т.к. шарп уже забыл)

class Valve {
   private var type: ValveType
   var maxInputs: Int { type.MaxInputs() }
   var maxOutputs: Int { type.MaxOutputs() }
   var inputs: List<Point> = new List<Point>
   var outputs: List<Point> = new List<Point>
   
   func actionFunc() -> Void{
      switch type {
          case And: 
             andFunc();
             break;
          case Or:
             orFunc()
             break;
          default:
             defaultFunc()
      }
   }
}

enum ValveType {
    case And
    case Or
    case Xor
    case Not
    case Nand
}

extension ValveType {
    func MaxInputs() -> Int {
        switch self {
            case Nand:
               return 2;
               break;
            default: 
               return 2;
               break;
        }
    }

    func MaxOutputs() -> Int {
        return 1
    }
}




Ну или заделай себе фабрику которая будет выдавать вентили с необходимыми настройками

→ Ссылка
Автор решения: Yaroslav
public abstract class BoolChain
{
    public abstract bool GetOutValue ();
}

public class TrueChain : BoolChain
{
    public override bool GetOutValue () => true;

    public override string ToString () => "True";
}

public class NotChain : BoolChain
{
    public BoolChain In;

    public NotChain (BoolChain inChain = null)
    {
        In = inChain;
    }

    public override bool GetOutValue () => In.GetOutValue() == false;

    public override string ToString () => $"Not({In})";
}

public class AndChain : BoolChain
{
    public readonly BoolChain[] In;

    public AndChain (BoolChain[] inChain)
    {
        In = inChain;
    }

    public AndChain (int inCapacity = 2) : this(new BoolChain[inCapacity]) { }

    public override bool GetOutValue () 
    { 
        foreach (var chain in In)
            if (chain.GetOutValue() == false)
                return false;
        return true;
    }

    public override string ToString () => $"And({string.Join(", ", In.Select(i => i.ToString()))})";
}

public class OrChain : BoolChain
{
    public readonly BoolChain[] In;

    public OrChain (BoolChain[] inChain)
    {
        In = inChain;
    }

    public OrChain (int inCapacity = 2) : this(new BoolChain[inCapacity]) { }

    public override bool GetOutValue ()
    {
        foreach (var chain in In)
            if (chain.GetOutValue())
                return true;
        return false;
    }

    public override string ToString () => $"Or({string.Join(", ", In.Select(i => i.ToString()))})";
}

Твой BoolValve должен содержать BoolChain в котором может быть что угодно. например тот-же [False] вентиль будет содержать new NotChain(new TrueChain()) "Not(True)". Добавь прототип для копирования целой цепочки Clone(), если добавить какой ни будь флаг IsSelected, можно ограничить копирование CloneSelected() и положить ее в новый BoolValve, что бы там ни было.

→ Ссылка
Автор решения: iiKuzmychov

Не Unity-программист, но, с учётом того что блоки должны поддерживать возможность изменения из GUI, на обычном C# это можно реализовать как-то так.

Генераторы сигнала:

// генератор сигналов (ничего не принимает на вход)
public interface ISignalGenerator
{
    public bool GetOutput();
}
// генератор True-сигналов
// реализует синглтон, так как более чем в одном таком генераторе смысла нет, он может даже не быть отдельным блоком в GUI
public sealed class TrueGenerator : ISignalGenerator
{
    private static TrueGenerator? s_instance;

    public static TrueGenerator Instance => s_instance ??= new();

    private TrueGenerator() { }

    public bool GetOutput() => true;
}
// генератор False-сигналов
// реализует синглтон, так как более чем в одном таком генераторе смысла нет, он может даже не быть отдельным блоком в GUI
public sealed class FalseGenerator : ISignalGenerator
{
    private static FalseGenerator? s_instance;

    public static FalseGenerator Instance => s_instance ??= new();

    private FalseGenerator() { }

    public bool GetOutput() => false;
}

Вентиль:

// вентиль (генерирует сигнал на основе входных сигналов)
public interface ILogicGate : ISignalGenerator
{
    // список генераторов для каждого из входов
    public IReadOnlyList<ISignalGenerator> InputGenerators { get; }

    // присоединить вход под номером inputIndex к генератору inputGenerator
    public void ConnectInputWithOutput(int inputIndex, ISignalGenerator inputGenerator);

    // отсоединить вход под номером inputIndex от генератора (по-сути, это тоже самое что и присоединить к False-генератору) 
    public void DisconnectInputGenerator(int inputIndex);
}

Вентили с множеством входом:

public abstract class MultiInputGate : ILogicGate
{
    private readonly ISignalGenerator[] _inputGenerators;

    public IReadOnlyList<ISignalGenerator> InputGenerators { get; }

    public MultiInputGate(int inputsCount)
    {
        if (inputsCount <= 1)
            throw new ArgumentOutOfRangeException(nameof(inputsCount));

        _inputGenerators = new ISignalGenerator[inputsCount];
        InputGenerators  = new ReadOnlyCollection<ISignalGenerator>(_inputGenerators);

        // подсоединяем все входы к False-генератору
        for (int i = 0; i < inputsCount; i++)
            _inputGenerators[i] = FalseGenerator.Instance;
    }

    public void DisconnectInputGenerator(int inputIndex)
    {
        if (inputIndex < 0 || inputIndex >= InputGenerators.Count)
            throw new ArgumentOutOfRangeException(nameof(inputIndex));

        ConnectInputWithOutput(inputIndex, FalseGenerator.Instance);
    }

    public virtual void ConnectInputWithOutput(int inputIndex, ISignalGenerator inputGenerator)
    {
        if (inputIndex < 0 || inputIndex >= InputGenerators.Count)
            throw new ArgumentOutOfRangeException(nameof(inputIndex));

        _inputGenerators[inputIndex] = inputGenerator ?? throw new ArgumentNullException(nameof(inputGenerator));
    }

    public abstract bool GetOutput();

    public override string ToString()
    {
        var inputs = string.Join(", ", InputGenerators.Select(generator => Convert.ToInt32(generator.GetOutput())));
        var output = Convert.ToInt32(GetOutput());

        return $"({inputs}) ---{GetType().Name}--> {output}";
    }
}
public sealed class AndGate : MultiInputGate
{
    public AndGate() : base(2) { }

    public override bool GetOutput() => InputGenerators[0].GetOutput() & InputGenerators[1].GetOutput();
}

Вентили с 1-м входом:

Класс SingleInputGate создан для удобного взаимодействия (например, позволяет писать gate.InputGenerator вместо gate.InputGenerators[0]).

public abstract class SingleInputGate : ILogicGate
{
    private readonly ISignalGenerator[] _inputGenerators;

    public ISignalGenerator InputGenerator
    {
        get => _inputGenerators[0];
        private set => _inputGenerators[0] = value;
    }
    IReadOnlyList<ISignalGenerator> ILogicGate.InputGenerators => _inputGenerators;

    public SingleInputGate()
    {
        _inputGenerators = new ISignalGenerator[] { FalseGenerator.Instance };
    }

    public void DisconnectInputGenerator() => ConnectInputWithOutput(FalseGenerator.Instance);

    public virtual void ConnectInputWithOutput(ISignalGenerator inputGenerator) =>
        InputGenerator = inputGenerator ?? throw new ArgumentNullException(nameof(inputGenerator));

    public abstract bool GetOutput();

    public override string ToString()
    {
        var input  = Convert.ToInt32(InputGenerator.GetOutput());
        var output = Convert.ToInt32(GetOutput());

        return $"({input}) ---{GetType().Name}--> {output}";
    }

    void ILogicGate.DisconnectInputGenerator(int inputIndex)
    {
        if (inputIndex != 0)
            throw new ArgumentOutOfRangeException(nameof(inputIndex));

        DisconnectInputGenerator();
    }

    void ILogicGate.ConnectInputWithOutput(int inputIndex, ISignalGenerator inputGenerator)
    {
        if (inputIndex != 0)
            throw new ArgumentOutOfRangeException(nameof(inputIndex));

        ConnectInputWithOutput(inputGenerator);
    }
}
public sealed class NotGate : SingleInputGate
{
    public override bool GetOutput() => !InputGenerator.GetOutput();
}
public class DirectGate : SingleInputGate
{
    public sealed override bool GetOutput() => InputGenerator.GetOutput();
}

Пользовательский вентиль:

public sealed class UserGate : ILogicGate
{
    private readonly DirectGate[] _inputGates;

    public DirectGate OutputGate { get; } = new();
    public IReadOnlyList<DirectGate> InputGates { get; }
    IReadOnlyList<ISignalGenerator> ILogicGate.InputGenerators =>
        InputGates.Select(gate => gate.InputGenerator).ToArray();

    public UserGate(int inputsCount)
    {
        if (inputsCount < 1)
            throw new ArgumentOutOfRangeException(nameof(inputsCount));

        _inputGates = new DirectGate[inputsCount];
        InputGates  = new ReadOnlyCollection<DirectGate>(_inputGates);

        for (int i = 0; i < inputsCount; i++)
            _inputGates[i] = new();
    }

    public bool GetOutput() => OutputGate.GetOutput();

    public override string ToString()
    {
        var inputs = string.Join(", ", InputGates.Select(gate => Convert.ToInt32(gate.GetOutput())));
        var output = Convert.ToInt32(GetOutput());

        return $"({inputs}) ---{GetType().Name}--> {output}";
    }

    void ILogicGate.DisconnectInputGenerator(int inputIndex)
    {
        if (inputIndex < 0 || inputIndex >= InputGates.Count)
            throw new ArgumentOutOfRangeException(nameof(inputIndex));

        InputGates[inputIndex].DisconnectInputGenerator();
    }

    void ILogicGate.ConnectInputWithOutput(int inputIndex, ISignalGenerator inputGenerator)
    {
        if (inputIndex < 0 || inputIndex >= InputGates.Count)
            throw new ArgumentOutOfRangeException(nameof(inputIndex));

        InputGates[inputIndex].ConnectInputWithOutput(inputGenerator);
    }
}

Тут сразу стоит пояснить, что весь сакральный смысл состоит в том, что каждый вход и выход пользовательского вентиля тоже является вентилем (DirectGate). Благодаря этому мы можем сделать связи как на картинке:

Генератор, подключённый к NAND Как видно из картинки, мы подключаем генератор (GEN) не напрямую к какому-то из внутренних вентелей (AND/NOT), а к входным вентилям (0/1), которые потом подключаются к внутренним (AND/NOT). Аналогично с выходом (R)

Пример из картинки в виде кода:

var and = new AndGate(); // создали блок AND
var not = new NotGate(); // создали блок NOT
not.ConnectInputWithOutput(and); // подключили вход NOT к выходу AND

var nand = new UserGate(inputsCount: 2); // создали блок NAND с 2-мя входами
nand.OutputGate.ConnectInputWithOutput(not); // поключили выход NAND к выходу NOT

and.ConnectInputWithOutput(0, nand.InputGates[0]); // подключаем 0-й вход AND к 0-му входу NAND
and.ConnectInputWithOutput(1, nand.InputGates[1]); // подключаем 1-й вход AND к 1-му входу NAND

// подключаем входные сигналы NAND к генератору
nand.InputGates[0].ConnectInputWithOutput(TrueGenerator.Instance);
nand.InputGates[1].ConnectInputWithOutput(TrueGenerator.Instance);

Console.WriteLine(nand); // (1, 1) ---UserGate--> 0

// отключаем входные сигналы NAND от генератора
//nand.InputGates[0].DisconnectInputGenerator();
//nand.InputGates[1].DisconnectInputGenerator();

//Console.WriteLine(nand); // (0, 0) ---UserGate--> 1

Протестировать онлайн можно тут.


СОХРАНЕНИЕ

Я не реализовывал сериализацию, но я думаю она вполне возможна с моим кодом. Я это вижу так:

  1. Добавить каждому генератору/вентилю свойство Guid для хранения его уникального идентификатора в пределах схемы
  2. Предположим, что мы работаем с System.Text.Json, тогда для классов ILogicGate нужно сделать конвертер, которые бы превращал все свои инпуты в массив Guid и наоборот. Для того чтобы работала десериализация, нужно будет изменить список IReadOnlyList<ISignalGenerator> InputGenerators на изменяемый. В идеале стоит сделать свой список, чтобы можно было безопасно изменять значения с проверкой на null.
→ Ссылка