Как правильно писать игры?


#1

Здраствуйте я написал простенькую игру в которой надо управлять каким-то предметом. Можно перемещаться влево/вправо а так же самое главное есть прыжок. Программа написано довольно просто и без говнокода, вся игра занимает 100 строк. Использую библиотеку WPFObjects. Игра не тормозит, но когда тело двигается появляются какие-то мерцания. При этом компьютер у меня довольно средний.

Вот ссылка на код: https://pastebin.com/DetCVHz4 (Не нашел тут скобки для вставки кода)

Или я неправильно все же пишу данную программу, или данная библиотека не подходит для таких игр?


#2

```
код
```

Потоко-безопастности нету никакой:

    'Left':
      begin
        LeftButton := true;
        RightButton := false;
      end;

Выполняется не в том же потоке, что и рисование. А значит - проверка в move может сработать между этими 2 строчками.

Лучший вариант, в данном случае - создать запись со всеми 4 значениеми, и копировать её в глобальное значение. При этом при копировании лучше использовать lock.
И так что в move - из глобального значения копировать в локальную переменную. И тоже с lock, при чём для того же объекта.

Не вижу. Сделайте видео. Если не знаете как - могу порекомендовать бандикам, он условно бесплатный и, если что, для него легко найти пиратку.


#6

Мерцания появляются в той стороне предмета куда он движется. Еще на видео есть подтормаживания, но при работе программы их нет. Я еще не исправил ту проблему которую вы написали, но я не думаю, что это как-то влияет на отображение предмета


#7

Разложил ваше видео на отдельные кадры - никаких миганий нет.
У меня 2 теории:

  1. Что то не так с вашим монитором/драйверами.

  2. Это визуальный эффект, из за того что объект маленький и всё остальное одноцветное - внимание больше фокусируется. А чем меньше поле зрения - тем больше кадров в секунду можно заметить. Если видеть больше 60fps - получаете мигание.

Я склоняюсь ко второму варианту, потому что тоже могу видеть что то похожее на мигание, но только если присматриваюсь к квадрату.


#8
procedure ButtonClickDown(k: key);
begin
  case k.ToString of
    'Left':
      begin
        LeftButton := true;
        //RightButton := false;
      end;
    'Right':
      begin
        //LeftButton := false;
        RightButton := true;
      end;
    'Up':
      begin
        UpButton := true;
        //DownButton := false;
      end;
    'Down':
      begin
        //UpButton := false;
        DownButton := true;
      end;
  end;
end;

Вот так нет никаких мерцаний (по-крайней мере их заметно меньше), так что серёга прав


#9

Кстати, перевод в строку для case - тоже говнокод. Key это уже энум:

  case k of
    Key.Left:
    ...

Вообще не вижу разницы.


#10

А вот как можно сравнивать с клавишами)


#11

Можно статью с описанием оператора lock. Есть пару статей в интернете, но там не особо подробно описывается. В них приводится такой код: image

Что за объект в этом коде? И какого он должен быть типа


#12
  1. lock описан и в справке паскаля. Другим ресурсам по паскалю в интернете доверять не стоит. Они скорее будут описывать какой то другой паскаль, к примеру дельфи.
  2. Из ресурсов, которым можно доверять - я знаю только msdn (справка) и StackOverflow (форум типа вопрос-ответ). На них вы вряд ли найдёте паскаль, надо искать для C#. А так как C# это чистый .Net без примесей - практически всё сказанное о нём будет правдой и о данном паскале (ибо он является надстройкой на .Net).

Вот соответствующая страница msdn:


Теперь моё объяснение:

Любого не_размерного.
Обычно в lock используют или объект полученный через new object, или объект который всё равно участвует в операции.

К примеру:

begin
  var otp_lock := new object;
  
  System.Threading.Thread.Create(()->
  while true do// lock otp_lock do
  begin
    writeln(1);
  end).Start;
  
  System.Threading.Thread.Create(()->
  while true do// lock otp_lock do
  begin
    writeln(2);
  end).Start;
  
end.

writeln кидает текст в вывод 2 кусками: сам текст (2 превращается в '2') и знак переноса строки.

Поэтому если вы запустите этот код - в некоторых местах перенос строки будет пропущен, а в некоторых их будет 2.

Если раскомментировать lock - это перестанет происходить, потому что lock объекта otp_lock запрещает все действия с этим объектом в других потоках. И другая блокировка тоже считается за действие с ним. Поэтому хоть 2 вызова writeln и находятся в разных потоках - они никогда не будут выполнены одновременно.

А в данном случае - у нас уже есть объект, поэтому new object создавать не обязательно:

begin
  var l := new List<integer>;
  
  System.Threading.Thread.Create(()->
  while true do
  begin
    for var i := 1 to 10 do
      lock l do l.Add(i);
    lock l do l.Clear;
  end).Start;
  
  System.Threading.Thread.Create(()->
  while true do
  begin
    
    lock l do
    begin
      l.Println;
      Sleep(200);
      l.Println;
      writeln('-'*50);
    end;
    
    Sleep(300);
  end).Start;
  
end.

Список является особо примечательным примером, потому что он даёт жуткие ошибки, если вы попробуете добавить/убрать элемент когда идёт перечисление элементов (для вывода .Println, разумеется, проходит по всем элементам).

Поэтому если убрать lock - у вас рандомно (не обязательно на первом выводе) вылетит ошибка “коллекция изменена”. Это 1 из самых сложно вылавливаемых ошибок в многопоточности, но все они примечательны тем - что являются плавающими, то есть не обязательно воспроизводятся если выполнить те же действия в том же порядке.


#13

Покопавшись в вашем коде - нашёл ещё несколько серьёзных ошибок:

  1. Вы полагаетесь на то что нажатие кнопки вверх произойдёт 1 раз если пользователь нажал и держит, но это не так. Клавиатура посылает лишние события нажатий (не посылая события о том что эта клавиша была отжата) если держать почти любую клавишу слишком долго. Кроме того, это не единственная причина почему
    UpButton := false;

внутри move - говнокод и вообще не будет работать.

Более серьёзно - хоть у вас обработка ввода и графический вывод в разных потоках (что вообще хорошо), вы всё равно продолжаете обрабатывать ввод и в потоке графики, что уже не хорошо. С таким подходом - нереально синхронизировать lock-ами без серьёзных потерь производительности. Вся обработка ввода должна быть в своём потоке, а поток графики должен только читать полученную информацию.


#14

Когда я последний раз пробовал решить плавность проблемы ввода-расчёта-вывода, самый простой способ по реализации оказался через delta_time (кажется, переделка с явы).

На паскале я делал через stopwatch:

begin
  var sw := new System.Diagnostics.Stopwatch();
    
  loop 10 do // основной цикл
  begin
    sw.Start();
    
    sleep(1000); // или расчёты
    
    sw.Stop();
    
    println(sw.ElapsedMilliseconds);
 end; // основной цикл
  { 1000, 1999, 2999, 3999, 4999, 5998_, 6999, 7998_, 8998_, 9997_ }

end.

но отпугнуло потенциальное усложнения кода синхронизации потоков, а, оказывается, не так и сложно.

(как я понял, на некоторых мониторах различные фирменные технологии вроде Magic Bright могут визуально усиливать контраст даже там, где это не нужно)


#15

Stopwatch нужно для экстремально точных отмеров времени. VSync им не улучшить.


#16

Дельта-тайм касается не столько вертикальной синхронизации как расчётов с учётом отклонения покадровой скорости от эталона (разности), Для простых целей годится, хотя интересно как синхронизируют массовые онлайн-игры, где часто зависание на 5-15 секунд компенсируют прокруткой за пару секунд.

Кстати, по накоплению (ElapsedTicks или ElapsedMilliseconds) видно как плавает даже относительно стабильный цикл, не говоря о ветвистых.


#17

В смысле как? У меня сразу наоборот вопрос, как это можно НЕ_сделать?
Вот как я обычно реализовываю (именно это - кусок кода из нового OpenGL):

// инициализация, выполняется 1 раз в самом начале:

            var LastRedr := DateTime.Now;
            var FrameDuration := new TimeSpan(Trunc(TimeSpan.TicksPerSecond/vsync_fps));
            var MaxSlowDown := FrameDuration.Ticks*3;
            
...
// а это выполняется в цикле:

              LastRedr := LastRedr+FrameDuration;
              var time_left := LastRedr-DateTime.Now;
              
              if time_left.Ticks>0 then
                System.Threading.Thread.Sleep(time_left) else
              if -time_left.Ticks > MaxSlowDown then
                LastRedr := LastRedr.AddTicks(-time_left.Ticks - MaxSlowDown);

Сравнивать разницу времени с последней итерацией вместо ведения расчётов с LastRedr - маразм. Никакой стабильности VSync’а без LastRedr не добиться.

А эффект прокрутки после лагов - это вообще то плохо. И я с ним тут специально борюсь. Если пропущено больше 3 кадров (MaxSlowDown) - их и не пытается нагнать.

Вообще в данном случае (этот код из потока перерисовки) не так страшно, потому что перерисовка может только лишний раз насиловать GPU (неприятно ну да лан).

А вот в потоке физики прокрутку нельзя позволять ни в коем случае! Иначе становится возможна такая гадость.

P.S. Я только сейчас понял, перечитывая то что отправил, что допустил ужасную ошибку… DateTime.Now получает время системы, но затем добавляет время текущей выставленной таймзоны (и не забываем что таймзона может ещё и меняться в разные времена года).

Кроме вопросов производительности, с которыми всё очень плохо у DateTime.Now… Если поменять таймзону системы - получим или прыжок вперёт (3 кадра, потому что стоит защита), или наоборот, на несколько часов перерисовка тупо зависнет.

Правильно использовать DateTime.UtcNow. Оно берёт текущее время системы и ничего особого с ним не делает. Только оборачивает полученное значение (типа int64) в DateTime (которое хранит своё значение в тиках, тоже в поле типа int64), так что даже никаких преобразований там нет.