Design de um jogo baseado em turnos em que as ações têm efeitos colaterais

19

Estou escrevendo uma versão em computador do jogo Dominion . É um jogo de cartas baseado em turnos, onde cartas de ação, cartas de tesouro e cartas de pontos de vitória são acumuladas no baralho pessoal de um jogador. Eu tenho a estrutura de classes muito bem desenvolvida e estou começando a projetar a lógica do jogo. Estou usando python, e posso adicionar uma GUI simples com pygame mais tarde.

A sequência de turnos dos jogadores é governada por uma máquina de estado muito simples. Os turnos passam no sentido horário e um jogador não pode sair do jogo antes que ele termine. A jogada de um único turno também é uma máquina de estado; em geral, os jogadores passam por uma "fase de ação", uma "fase de compra" e uma "fase de limpeza" (nessa ordem). Com base na resposta à pergunta Como implementar o mecanismo de jogo baseado em turnos? , a máquina de estado é uma técnica padrão para essa situação.

Meu problema é que, durante a fase de ação de um jogador, ele pode usar uma carta de ação que tenha efeitos colaterais, seja em si mesma ou em um ou mais dos outros jogadores. Por exemplo, uma carta de ação permite que um jogador faça um segundo turno imediatamente após a conclusão do turno atual. Outra carta de ação faz com que todos os outros jogadores descartem duas cartas de suas mãos. Ainda outra carta de ação não faz nada no turno atual, mas permite que um jogador compre cartas extras no próximo turno. Para tornar as coisas ainda mais complicadas, frequentemente existem novas expansões no jogo que adicionam novas cartas. Parece-me que codificar os resultados de todas as cartas de ação na máquina de estado do jogo seria feio e não adaptável. A resposta ao loop estratégico baseado em turnos não entra em um nível de detalhe que aborda os projetos para resolver esse problema.

Que tipo de modelo de programação devo usar para abranger o fato de que o padrão geral de revezamento pode ser modificado por ações que ocorrem dentro do turno? O objeto do jogo deve acompanhar os efeitos de cada carta de ação? Ou, se os cartões implementarem seus próprios efeitos (por exemplo, implementando uma interface), que configuração é necessária para fornecer energia suficiente? Eu pensei em algumas soluções para esse problema, mas estou me perguntando se existe uma maneira padrão de resolvê-lo. Especificamente, eu gostaria de saber qual objeto / classe / o que é responsável por acompanhar as ações que todo jogador deve executar como consequência de uma carta de ação ser jogada, e também como isso se relaciona a mudanças temporárias na sequência normal de a máquina de estado de virada.

Apis Utilis
fonte
2
Olá Apis Utilis, e bem-vindo ao GDSE. Sua pergunta está bem escrita e é ótimo que você tenha mencionado as questões relacionadas. No entanto, sua pergunta está abordando muitos problemas diferentes e, para cobri-lo completamente, uma pergunta provavelmente precisará ser enorme. Você ainda pode obter uma boa resposta, mas você e o site se beneficiarão se você resolver um pouco mais o seu problema. Talvez comece construindo um jogo mais simples e desenvolva o Dominion?
precisa saber é o seguinte
1
Eu começaria a partir dando a cada cartão de um script que modifica o estado do jogo, e se nada de estranho está acontecendo, cair para trás sobre as regras turno padrão ...
Jari Komppa

Respostas:

11

Concordo com Jari Komppa que definir efeitos de cartão com uma linguagem de script poderosa é o caminho a percorrer. Mas acredito que a chave para a máxima flexibilidade é a manipulação de eventos com script.

Para permitir que os cartões interajam com eventos posteriores do jogo, você pode adicionar uma API de script para adicionar "ganchos de script" a determinados eventos, como o início e o final das fases do jogo ou determinadas ações que os jogadores podem executar. Isso significa que o script que é executado quando uma carta é jogada é capaz de registrar uma função que é chamada na próxima vez que uma fase específica for atingida. O número de funções que podem ser registradas para cada evento deve ser ilimitado. Quando houver mais de um, eles serão chamados em sua ordem de registro (a menos que haja uma regra básica do jogo que diga algo diferente).

Deveria ser possível registrar esses ganchos para todos os jogadores ou apenas para determinados jogadores. Eu também sugeriria adicionar a possibilidade de os ganchos decidirem por si mesmos se devem continuar sendo chamados ou não. Nestes exemplos, o valor de retorno da função hook (true ou false) é usado para expressar isso.

Seu cartão de volta dupla faria algo assim:

add_event_hook('cleanup_phase_end', current_player, function {
     setNextPlayer(current_player); // make the player take another turn
     return false; // unregister this hook afterwards
});

(Eu não tenho idéia se Dominion tem algo como uma "fase de limpeza" - neste exemplo, é a última fase hipotética da vez dos jogadores)

Uma carta que permita a cada jogador comprar uma carta adicional no início de sua fase de compra ficaria assim:

add_event_hook('draw_phase_begin', NULL, function {
    drawCard(current_player); // draw a card
    return true; // keep doing this until the hook is removed explicitely
});

Uma carta que faz com que o jogador alvo perca um ponto de vida sempre que jogar uma carta seria assim:

add_event_hook('play_card', target_player, function {
    changeHitPoints(target_player, -1); // remove a hit point
    return true; 
});

Você não terá que codificar algumas ações do jogo, como comprar cartas ou perder pontos de vida, porque sua definição completa - o que exatamente significa "comprar uma carta" - faz parte da mecânica principal do jogo. Por exemplo, eu conheço alguns TCGs em que quando você precisa comprar uma carta por qualquer motivo e seu baralho estiver vazio, você perde o jogo. Essa regra não é impressa em todos os cartões, o que faz você comprar cartões, porque está no livro de regras. Portanto, você também não deve procurar essa condição perdida no script de todos os cartões. Verificar coisas assim deve fazer parte do código embutidodrawCard() função (que, a propósito, também seria um bom candidato para um evento que pode ser conectado).

A propósito: É improvável que você consiga planejar com antecedência todas as futuras edições obscuras de mecânicos que possam surgir ; portanto, não importa o que faça, você ainda precisará adicionar novas funcionalidades para futuras edições de vez em quando (neste caso, um minijogo de confete).

Philipp
fonte
1
Uau. Aquela coisa de confetes do caos.
Jari Komppa
Excelente resposta, @ Phillip, e isso cuida de muitas coisas feitas no Dominion. No entanto, existem ações que devem ocorrer imediatamente quando uma carta é jogada, ou seja, uma carta é jogada que força outro jogador a virar a carta do topo da sua biblioteca e permitindo que o jogador atual diga "Mantenha" ou "Descarte". Você escreveria ganchos de eventos para cuidar de tais ações imediatas ou precisaria de métodos adicionais para criar scripts dos cartões?
Fnord
2
Quando algo precisa acontecer imediatamente, o script deve chamar diretamente as funções apropriadas e não registrar uma função de gancho.
Philipp
@JariKomppa: O set Unglued era deliberadamente sem sentido e cheio de cartas malucas que não faziam sentido. O meu favorito era um cartão que fazia todos sofrerem um dano quando diziam uma palavra específica. Eu escolhi 'o'.
Jack Aidley 31/03
9

Eu dei esse problema - mecanismo de jogo de cartas computadorizado flexível - alguns pensaram há algum tempo.

Primeiro, um jogo de cartas complexo como Chez Geek ou Fluxx (e, acredito, Dominion) exigiria que os cartões fossem escrituráveis. Basicamente, cada cartão viria com seu próprio monte de scripts que podem mudar o estado do jogo de várias maneiras. Isso permitiria que o sistema fosse preparado para o futuro, pois os scripts podem fazer coisas que você não consegue pensar agora, mas podem vir em uma expansão futura.

Segundo, a rígida "curva" pode estar causando problemas.

Você precisa de algum tipo de "turn stack" que contenha "turnos especiais", como "descartar 2 cartas". Quando a pilha está vazia, a volta normal padrão continua.

No Fluxx, é perfeitamente possível que um turno seja algo como:

  • Escolha cartões N (como declarado pelas regras atuais, alteráveis ​​por meio de cartões)
  • Jogar N cards (como declarado pelas regras atuais, alteráveis ​​via cards)
    • Uma das cartas pode ser "pegue 3, jogue 2 delas"
      • Uma dessas cartas pode ser "dar outra volta"
    • Uma das cartas pode ser "descartar e comprar"
  • Se você alterar as regras para escolher mais cartas do que quando iniciou o seu turno, escolha mais cartas
  • Se você alterar as regras para menos cartões em mãos, todos os outros deverão descartar cartões imediatamente
  • Quando o seu turno terminar, descarte as cartas até que você tenha N cartas (que podem ser trocadas por cartas novamente) e, em seguida, faça outra jogada (se você jogou a carta "faça outra jogada" em algum momento na bagunça acima).

..e assim por diante. Portanto, projetar uma estrutura de curvas que possa lidar com os abusos acima pode ser bastante complicado. Acrescente a isso os inúmeros jogos com cartões "sempre" (como em "chez geek"), onde os cartões "sempre" podem atrapalhar o fluxo normal, por exemplo, cancelando o que foi jogado pela última vez.

Então, basicamente, eu começaria projetando uma estrutura de turnos muito flexível, projetando-a para que ela possa ser descrita como um script (já que cada jogo precisaria de seu próprio "script mestre" para lidar com a estrutura básica do jogo). Então, qualquer cartão deve ser programável; a maioria das cartas provavelmente não faz nada de estranho, mas outras fazem. As cartas também podem ter vários atributos - se elas podem ser mantidas em mãos, jogadas "sempre", se podem ser armazenadas como ativos (como 'keepers' do fluxx, ou várias coisas no 'chez geek' como comida) ...

Na verdade, nunca comecei a implementar nada disso; portanto, na prática, você pode encontrar muitos outros desafios. A maneira mais fácil de começar seria começar com o que você sabe sobre o sistema que deseja implementar e implementá-los de maneira programável, definindo o mínimo possível de pedras. Portanto, quando uma expansão ocorrer, você não precisará revisar o sistema básico - muito. =)

Jari Komppa
fonte
Esta é uma ótima resposta, e eu teria aceito as duas, se pudesse. Eu quebrei o empate ao aceitar a resposta pela pessoa com a reputação menor :)
Apis Utilis
Sem problemas, estou acostumado com isso agora .. =)
Jari Komppa
0

Hearthstone parece fazer coisas relacionáveis ​​e, honestamente, acho que a melhor maneira de obter flexibilidade é através de um mecanismo ECS com um design orientado a dados. Tentou criar um clone de Hearthstone e, de outra forma, é impossível. Todos os casos extremos. Se você está enfrentando muitos desses casos estranhos, provavelmente essa é a melhor maneira de fazer isso. Sou bastante tendencioso, apesar da experiência recente de experimentar essa técnica.

Edit: ECS pode até não ser necessário, dependendo do tipo de flexibilidade e otimização que você deseja. É apenas uma maneira de conseguir isso. DOD eu pensei erroneamente como programação procedural, embora eles se relacionem muito. O que eu quero dizer é. Que você deve considerar acabar com o OOP total ou principalmente pelo menos e, em vez disso, concentrar sua atenção nos dados e em como eles são organizados. Evite herança e métodos. Em vez disso, concentre-se em funções públicas (sistemas) para manipular os dados do seu cartão. Cada ação não é uma coisa ou lógica modelada de qualquer tipo, mas sim dados brutos. Onde seus sistemas o usam para executar a lógica. A caixa de chave inteira ou o uso de um número inteiro para acessar uma matriz de ponteiros de função ajudam a descobrir com eficiência a lógica desejada dos dados de entrada.

As regras básicas a seguir são: você deve evitar vincular a lógica diretamente aos dados, evitar que os dados dependam um do outro o máximo possível (exceções podem ser aplicadas) e que quando você deseja uma lógica flexível que pareça inacessível ... Considere convertê-lo em dados.

Há benefícios a serem feitos ao fazer isso. Cada carta pode ter um valor de enumeração ou sequência (s) para representar sua (s) ação (ões). Esse estagiário permite criar cartões por meio de arquivos de texto ou json e permitir que o programa os importe automaticamente. Se você faz das ações dos jogadores uma lista de dados, isso oferece ainda mais flexibilidade, especialmente se um cartão depende da lógica do passado, como o Hearthstone, ou se você deseja salvar o jogo ou a repetição de um jogo a qualquer momento. Existe potencial para criar IA mais facilmente. Especialmente ao usar um "sistema utilitário" em vez de uma "árvore de comportamento". A rede também se torna mais fácil porque, em vez de precisar descobrir como transferir objetos inteiros, possivelmente polimórficos, através da conexão e como a serialização seria configurada após o fato, o fato de você já ter seus objetos de jogo não passa de dados simples, o que acaba sendo muito fácil de se movimentar. E por último, mas definitivamente não menos importante, isso permite que você otimize mais facilmente porque, em vez de perder tempo se preocupando com o código, é capaz de organizar melhor seus dados, para que o processador tenha mais facilidade em lidar com eles. O Python pode ter problemas aqui, mas consulte "linha de cache" e como isso se relaciona com o desenvolvedor do jogo. Talvez não seja importante para a criação de protótipos, mas, no futuro, será muito útil.

Alguns links úteis.

Nota: O ECS permite adicionar / remover dinamicamente variáveis ​​(chamadas componentes) em tempo de execução. Um exemplo de programa c de como o ECS "pode" parecer (há várias maneiras de fazê-lo).

unsigned int textureID = ECSRegisterComponent("texture", sizeof(struct Texture));
unsigned int positionID = ECSRegisterComponent("position", sizeof(struct Point2DI));
for (unsigned int i = 0; i < 10; i++) {
    void *newEnt = ECSGetNewEntity();
    struct Point2DI pos = { 0 + i * 64, 0 };
    struct Texture tex;
    getTexture("test.png", &tex);
    ECSAddComponentToEntity(newEnt, &pos, positionID);
    ECSAddComponentToEntity(newEnt, &tex, textureID);
}
void *ent = ECSGetParentEntity(textureID, 3);
ECSDestroyEntity(ent);

Cria um monte de entidades com dados de textura e posição e, no final, destrói uma entidade que possui um componente de textura que está no terceiro índice da matriz de componentes de textura. Parece peculiar, mas é uma maneira de fazer as coisas. Aqui está um exemplo de como você renderizaria tudo o que possui um componente de textura.

unsigned int textureCount;
unsigned int positionID = ECSGetComponentTypeFromName("position");
unsigned int textureID = ECSGetComponentTypeFromName("texture");
struct Texture *textures = ECSGetAllComponentsOfType(textureID, &textureCount);
for (unsigned int i = 0; i < textureCount; i++) {
    void *parentEntity = ECSGetParentEntity(textureID, i);
    struct Point2DI *drawPos = ECSGetComponentFromEntity(positionID, parentEntity);
    if (drawPos) {
        struct Texture *t = &textures[i];
        drawTexture(t, drawPos->x, drawPos->y);
    }
}
Blue_Pyro
fonte
1
Essa resposta seria melhor se tivesse mais detalhes sobre como você recomendaria configurar o ECS orientado a dados e aplicá-lo para resolver esse problema específico.
DMGregory
Atualizado obrigado por apontar isso.
Blue_Pyro 30/03
Em geral, acho ruim dizer a alguém "como" configurar esse tipo de abordagem, mas, em vez disso, permita que eles projetem sua própria solução. Isso prova ser uma boa maneira de praticar e permite uma solução potencialmente melhor para o problema. Ao pensar nos dados mais do que na lógica dessa maneira, acaba sendo que existem várias maneiras de realizar a mesma coisa e tudo depende das necessidades do aplicativo. Bem como tempo / conhecimento do programador.
Blue_Pyro 30/03