Design aprimorado para um jogo multiplayer baseado em turnos usando AI?

6

Estou tentando decidir qual é a melhor arquitetura para um jogo multiplayer baseado em turnos, onde os jogadores podem ser humanos ou AI e a interface do usuário é opcional, por exemplo, porque o jogo pode ser usado apenas para fazer com que os AIs lutem uns contra os outros.

Vamos pegar o jogo mais simples possível, tic-tac-toe, e eu usei uma classe como essa:

class TicTacToeGame {
    mark(cell) {
        //make something happen
    }
}

Na implementação mais simples do meu jogo, posso ter uma interface do usuário com um manipulador de cliques:

function onClick(cell) {
    ticTacToeGame.mark(cell);
    refreshUI();
}

Esse código talvez funcione bem quando houver apenas jogadores humanos, mas se tivermos jogadores com IA e jogos "sem cabeça", isso se torna insuficiente.

Quais são algumas ideias para expandir este código para os outros casos de uso (AI, jogo sem cabeça)?

Uma primeira solução seria usar o padrão clássico do observador. Ao usar essa ideia, vários jogadores se inscreveriam no jogo e seriam notificados quando chegasse a vez deles. Da mesma forma, a interface pode se inscrever e ser notificada quando novas configurações diferentes precisam ser exibidas.

Então, nesse caso, a classe do jogo mudaria para se tornar assim:

class TicTacToeGame {
    constructor() {
        this.observers = [];
    }
    subscribe(observer) {
        this.observers.push(observer);
    }
    mark(cell) {
        //make something happen

        this.observers.forEach(o => o.notify(this));
    }
}

onde os observadores seriam os jogadores e a interface:

...
ticTacToeGame.register(AI);
ticTactoeGame.register(UI);
...

mas esta solução parece um pouco genérica demais e eu não tenho certeza da melhor maneira de descrever o fato de que as IAs podem representar (por exemplo) o primeiro e o terceiro jogadores em um jogo.

Uma solução mais avançada seria usar o padrão de observador para a interface do usuário, mas manter um sistema dedicado para os jogadores:

class TicTacToeGame {
    constructor() {
        this.observers = [];
        this.players = [];
    }
    subscribe(observer) {
        this.observers.push(observer);
    }
    addPlayer(player) {
        this.players.push(player);
    }
    mark(cell) {
        //make something happen

        this.players[this.currentPlayerIndex].notify(this);
        this.observers.forEach(o => o.notify(this));
    }
}

Mas as coisas começam a ficar mais complexas, e não tenho certeza se modelar um jogador humano faria muito sentido agora.

Eu nunca escrevi um jogo na minha vida, então não tenho certeza se há talvez padrões que eu deveria saber ou se a solução é mais dependente do contexto.

Quais são suas opiniões sobre o meu design inicial?

Também pode ser importante acrescentar que o contexto em que eu gostaria de escrever o jogo é a web, e o framework da interface do usuário seria React.

    
por heapOverflow 05.12.2018 / 16:41
fonte

4 respostas

5

Eu tentaria manter o TicTacToeGame totalmente agnóstico da interface do usuário. Nenhum observador, nenhum editor-assinante dentro dessa classe. Apenas a "lógica de negócios" (ou chame-a de "lógica do jogo") dentro dessa classe, sem responsabilidades mistas que possam levar à complexidade que você analisou em sua pergunta.

Em vez disso, você pode implementar a lógica de turno utilizando sua própria fila de eventos. Eu dou um exemplo em pseudo-código usando sondagem por questão de simplicidade, dependendo do seu ambiente, você pode implementá-lo sem pesquisa:

  MainLoop()
  {
     while(queue.IsEmpty())
        WaitSomeMiliseconds(); // or use some queue.WaitForEvent() command, if available

     var nextEvent=queue.getNextEvent();
     if(nextEvent==Event.MoveCompleted)
     {
          Display(ticTacToeGame);
          if(ticTacToeGame.GameOver())
              break;
          nextPlayer=PickNextPlayer();
          if(nextPlayer.Type()==PlayerType.Human)
          {
             AllowMoveByUI();  // enable UI controls for entering moves by human
          }
          else
          { 
             LetAIMakeMove(ticTacToeGame);
             queue.Insert(Event.MoveCompleted);
          }
      }
  }

E os manipuladores de eventos da interface do usuário (impulsionados pelo loop de eventos da interface do usuário, não seus) devem ter alguma lógica para marcar uma célula pelo usuário e inserir um Event.MoveCompleted na fila também:

  HandleUserInputEvent(CellType cell)
  {
      if(ticTacToeGame.IsMarkingValid(cell))
      {
         ticTacToeGame.Mark(cell);
         DisableMoveByUI();
         queue.Insert(Event.MoveCompleted);
      }
  }

Naturalmente, usar uma fila é um pouco overengineered no exemplo acima, uma vez que atualmente existe apenas um tipo de evento, portanto, um simples sinalizador booleano global também faria o truque. Mas no seu sistema real, eu suponho que haverá diferentes tipos de eventos, então eu tentei dar um esboço sobre como o sistema pode parecer. Eu espero que você entenda a ideia.

    
por 05.12.2018 / 17:28
fonte
3

Eu usaria o padrão de estratégia .

class Player {
    async getNextMove() {
        throw new Error('not implemented');
    };
}

class AiPlayer extends Player {
    async getNextMove() {
        /* Your AI LOGIC*/
        return 0;
    };
}

class HumanPlayer extends Player {
    async getNextMove() {
        await /*deal with user input*/
    };
}

// gameLogic:
let playerOne = new AiPlayer();
let playerTwo = new HumanPlayer();
let players = [playerOne, playerTwo];
let currentPlayer = 0;
let gameIsRuning =  true;
while (gameIsRuning) {
    let playerMove = await players[currentPlayer].getNextMove();
    // validate the input
    // recalculate the game state 
    // display board if not headless

    if (/*function to check game is over*/) {
        gameIsRuning = false;
    }
    currentPlayer = (currentPlayer++) % 2;
}

Nesse caso, esperar pelas entradas do player é bloquear o loop, ai não é.

    
por 05.12.2018 / 18:01
fonte
0

Você pode usar iteráveis & envie valores (em Python).

O código usa vários recursos avançados do Python, como dataclass es , sending valores para o gerador-iteradores e usando deque s para consumir iteradores , mas pode ser possível traduzir para outros idiomas.

from dataclasses import dataclass
from itertools import tee
from typing import Any, Callable, Generator, Iterable, MutableSequence, TypeVar

T = TypeVar('T')

# Utility classes

class Tee(Iterable[T]):  # Allows for an indefinite number of tees
    iterator: Iterable[T]
    previous: MutableSequence[T]

    def __init__(self, iterable: Iterable[T]):
        self.iterator = iter(iterable)
        self.previous = []

    def __iter__(self) -> '_TeeIterator[T]':
        return _TeeIterator(self)


class _TeeIterator(Iterator[T]):
    tee: Tee[T]
    i: int

    def __init__(self, tee: Tee[T]):
        self.tee = tee
        self.i = 0

    def __iter__(self) -> '_TeeIterator[T]':
        return self

    def __next__(self) -> T:
        try:
            return self.tee.previous[self.i]
        except IndexError:
            self.tee.previous.append(next(self.tee.iterable))
            return self.tee.previous[self.i]
        finally:
            self.i += 1

# Your code

@dataclass(frozen=True)
class Event:
    ...

@dataclass(frozen=True)
class Action:
    ...

def play_game(players: Callable[[], Generator[Action, Event, Any]]):  # Add additional parameters if necessary
    def game_iterable():
        activated_players = Tee(p() for p in players)
        activated_player_cycle = cycle(activated_players)

        deque(map(next, activated_players), 0)  # Allow each player to initialize itself. deque(..., 0) efficiently iterates through the given iterable

        def send_event(event: Event):
            for player in activated_players:
                player.send(event)


        for player in cycle(activated_players):
            move = next(player)
            # Process move and call send_event for each event

def example_player() -> Generator[Action, Event, None]:
    # Initialize

    while True:
        event = yield

        if event is None:
            pass  # yield actions
        else:
            pass  # Process the event
    
por 06.12.2018 / 04:01
fonte
0

Pessoalmente, eu não iria generalizar e abstrair sua IA e seu jogador controlado por humanos em termos de fazer jogadas. Ou seja, eu não modelaria uma interface onde "Human" e "AI" são subtipos diferentes. A razão principal pela qual eu penso é porque eu não acho que isso simplifica muito, mas também impõe algumas restrições estranhas para se locomover:

  1. Pode tornar-se complicado fazer coisas como sugerir que a IA faça movimentos para o jogador ou que a IA assuma o controle se o jogador humano desistir ou adormecer e não fizer uma jogada por 10 segundos, por exemplo
  2. Poderia impor dificuldades na implementação, pois isso implicaria que a interface do usuário funciona bloqueando as funções de entrada quando um jogador é solicitado a fazer um movimento e isso pode ser um pouco estranho se não for prático se você estiver usando uma GUI de tratamento de eventos API.

Por isso, sugiro não pensar em coisas como "Jogadores" e "IA" como objetos conceituais "dentro" de seu campo de jogo ou tabuleiro. Eles estão fora dos "controladores" do jogo. Isso faz sentido o suficiente? Meu texto pode ser um pouco estranho.

Mas se você fizer isso da maneira que eu sugiro, então deve ser fácil em qualquer momento permitir que os movimentos de um jogador humano sejam tomados pela IA simplesmente chamando uma função que avalie as unidades / tabuleiro do jogador e faça um movimento para ele / ela, se você quer esse recurso ou não. Eu sugiro isso mesmo se você não precisar desse recurso, porque eu realmente acho que a implementação resultante seria ainda mais simples, apesar dessa flexibilidade adicional.

Como um exemplo mais complexo, considere um jogo de estratégia baseado em turnos. Você pode ter units no tabuleiro e talvez com propriedades como pontos de vida, pontos mágicos, quanto dano eles causam, etc. Aqueles que você pode modelar como objetos em alguma interface abstrata. E grupos dessas unidades podem pertencer a reinos particulares, e os reinos fazem movimentos alternados.

Mas o que controla os movimentos para um reino em particular é algo que eu separaria completamente da lógica do "campo de batalha" e seria associado externamente, como Kingdom 1 pode atualmente ser controlado por cliques do mouse na interface do usuário (talvez com o AI fazendo um movimento se nenhum movimento for realizado após dez segundos), Kingdom 2 pode ser controlado estritamente por AI, Kingdom 3 pode ser controlado por mensagens de soquete em uma rede.

What are some ideas to expand this code for the other use cases (AI, headless game)?

Pessoalmente, não vejo isso como um problema particularmente interessante para alcançar padrões de design como o observador. Você passa por turnos. Se espera-se que um determinado turno seja controlado pela entrada do usuário, não faça nada até que a entrada do usuário seja fornecida (ex: onClick event). Depois que um movimento é feito, você vai para o próximo turno. Se o "Jogador" ou "Reino" ou "Time" do próximo turno for marcado como controlado por IA, basta chamar a função AI para avaliar o campo de batalha / tabuleiro e fazer um movimento e passar para o próximo movimento e repetir até o jogo terminar.

[...] I'm not entirely sure about the best way to describe the fact that the AIs may represent (for instance) the first and third players in a game.

Isso pode ser tão simples quanto uma variável booleana, como ai=true . Ao avançar um turno, verifique o estado do booleano associado a Player N . Se ai is true , então apenas chame uma função para deixar a IA fazer um movimento para aquele jogador e depois avançar novamente. Se não estiver configurado como true, não faça nada até que o usuário forneça entrada (ex: cliques em algo).

Abstrair a noção de como um "Controlador" aqui parece estranho, com pouco ou nenhum benefício para extensão em oposição a apenas uma propriedade que a IU pode consultar, por exemplo, porque novamente você começa a trabalhar no sentido de bloquear funções de entrada. pesado com muitas APIs de GUI (também se você alguma vez se interessar e quiser fazer isso através de uma rede, bloquear até que uma mensagem de soquete seja recebida também é um pouco estranho). Então é mais fácil simplesmente transformar isso em estado que você pode consultar. Este jogador está sendo controlado por um humano usando a GUI ou AI ou alguma outra coisa? Isso é algo para consultar, como eu vejo em cada turno para decidir o que fazer externamente, em vez de inverter o fluxo.

Para jogos sem cabeça controlados estritamente pela IA, você pode querer desacelerar deliberadamente a velocidade com que a IA faz movimentos. Nesse caso você pode simplesmente desabilitar os controles GUI do jogador se um turno avança e ai is true para aquele jogador (habilitando-os novamente se o turno avança para um jogador controlado por humanos), por assim dizer, e ter, digamos, um evento timer Na verdade, chame a função para fazer com que o AI faça um movimento. Isso está entrando em território sutil mas com a mesma abordagem geral.

    
por 06.12.2018 / 08:12
fonte