FelipeRes
Posted on November 8, 2020
Data-Driven é uma abordagem muito comum no mundo do desenvolvimento de jogos. Ela permite que programadores e criadores de conteudo possam trabalhar de forma paralela e com bastante autonomia, contanto que respeitem os protocolos estabelecidos para os dois lados. A ideia aqui é simples: ao invés de compilar todo o projeto e solicitar uma atualização para seu jogador porque você precisa adicionar uma roupinha nova, o update pode ser apenas um novo arquivo baixado pelo jogo e guardado em algum lugar.
Eu penso que a maior parte da graça dos jogos de carta em si são as cartas de fato e não exatamente suas regras. Jogos como Magic: The Gathering, por exemplo, tiveram as regras refinadas por anos, mas o jogo só é interessante de se jogar se o projeto de design das cartas promover boas dinâmicas entre os jogadores, atendendo a suas espectativas e estilos pessoais. A Wizards of the Coast atualmente mantém uma equipe de 20 pessoas que são responsáveis só pelo desenvolvimento e teste do jogo e as coleções são projetadas com 2 anos de antecedência. Para o jogador, isso evita as temidas Banned List e assegura uma melhor qualidade e saude para o jogo além de manter estável a economia da compra, venda e troca de cartas avulsas.
Mas Magic: The Gathering é essencialmente um jogo de cartas físico e apesar de possuir uma versão digital com Magic: The Gathering Arena, existem propiedades e possibildades que somente um jogo físico pode promover. Uma das primeiras regras de Magic é que se a descrição de uma carta for contraditória as regras do jogo, os jogadores devem literalmente fazer o que está escrito na carta. Por exemplo, Goblin Game:
Resumidamente, os jogadores devem esconder o máximo de objetos que conseguirem e ao revelar os objetos simultaneamente, além de perderem vida igual a quantidade de objetos, o jogador que revelar menos irá perder metade da vida. Agora explica pro programador como ele vai implementar isso? Dá para abstrarir para uma versão resumida em computador, mas não é a mesma coisa da vida real. Além disso, existem cartas como Shahrazad:
A não ser que a sua engine ou arquitetura dê suporte a multiplas instancias de jogo, este tipo de efeito é uma tremenda dor de cabeça não só para programadores como jogadores. Em Yu-Gi-Oh!, há casos mais extremos como Yu-Jo Friendship:
Você deve oferecer um aperto de mão para seu oponente e se ele aceitar, os pontos de vida dos dois jogadores se tornam a média da soma dos dois?
Esses são casos extremos e essas cartas normalmente são banidas do competitivo, mas a discursão que quero trazer aqui é como podemos computacionalmente implementar tanta variedade de efeitos de carta dentro de uma abordagem Data-Driven. Vamos começar com o básico que é como representar as cartas em arquivos que poderão ser lidos pelo seu jogo.
Os dois formatos de arquivo mais populares para representar dados são XML e JSON, muito utilizados em projetos web e para configurações de frameworks, cada um com suas vantagens e desvantagens, embora o JSON tenha ficado bem mais popular devido a popularização do javascript como liguagen de front-end, back-end e até desktop.
Outra abordagem é utilizar bancos de dado SQL e dependendo dos tipos de jogos, os dados ficam armazenados em bancos locais como SQLite e assim é possivel aproveitar as diversas ferramentas de consulta que é possivel fazer com SQL. Dentro dessa abordagem, basta uma atualização no banco para que novos conteúdos estejam disponíveis no banco.
Ainda há uma terceira opção bem conhecida que é o uso de scripts em Lua. Se provavelmente você já ouviu que Lua é muito utilizada em jogos mas nunca viu uma aplicação prática, aguarde um momento que você entenderá porque Lua é uma das melhores opções e porque eu decidi não utilizá-la.
Por fim, além de outros formatos mais obscuros, e é possivel criar seus próprios formatos de arquivo ou recorrer ao bom e velho txt. A desvantagem de se fazer isso seria ter que escrever seus próprios interpretadores e gerenciadores de arquivo o que pode ser custoso e difícil de se manter.
Digamos que tenhamos um jogo de carta bem simples onde as cartas tem um id, nome, poder, descrição, tipo e uma imagem. Vamos agora representar cartas dentro dos diferentes formatos já citados e analisar cada uma das vantagens e desvantagens das implementações.
JSON
{
"id":2
"name":"The mighty cat",
"power":10,
"description":"This is the strongest cat in the multiverse",
"type":"Cat",
"image":"2.png"
}
O JSON é de longe o formato mais legível e um dos mais compactos para se armazenar dados na forma de chave e valor. A maioria das linguagens dão suporte para conversão de JSON em objetos de alguma forma, e outras como javascript já o tem incorporado em sua própria sintaxe. Para jogos feitos em javascript, jogos online com servidor autoritativo ou que atualizam dados com um padrão REST este formato se torna ótimo para serialização. Além disso, ele é rápido para fazer consultas e ocupa pouco espaço de armazenamento se comparado com a maioria dos proximos exemplos. Porém nem todas as liguagens são amigaveis para esse padrão e pode não ser tão trivial realizar buscas, as vezes necessitando realizar muitos forloops para encontrar dados mais específicos.
XML
<card>
<id> 2 </id>
<name> The mighty cat </name>
<power>10 </power>
<description> This is the strongest cat in the multiverse </description>
<type> Cat </type>
<image>2.png</image>
</card>
Como primo mais velho do JSON, o XML era um formato muito popular antes da ascensão do javascript e por conta disso exitem muitas ferramentas consolidadas para utilizar. Seu formato é facilmente legível e ele é facilmente integrável com bancos de dados SQL além de ter ótimas ferramentas de busca como o XQuery que podem ser importante se seu jogo de cartas tiver mais de 1000 cartas únicas. Porém o XML gera um grande volume de texto e costuma ter buscas mais lentas o que pode impactar na performance uma vez que toda a estrutura de texto será convertida em uma arvore que fica armazenada em memória.
CSV
id,name,power,description,type,image
2,The mighty cat,10,This is the strongest cat in the multiverse,Cat,2.png
Isso aqui é basicamente uma planilha exel, apesar de ser o formato mais compacto e fácil de se ler pois toda linguagem tem uma biblioteca para ler CSV(na maioria das vezes nativa), o ruim aqui é transformar esses dados em uma instância de objeto e o pior de tudo é fazer consultas. Você também precisaria criar suas próprias ferramentas de insersão, leitura e deleção de dados.
SQL
CREATE TALBE 'cards'(
id INT(32) UNSIGNED NOT NULL AUTO_INCREMENT,
name VARCHAR(128) NOT NULL,
power INT(16),
description VARCHAR(256),
type VARCHAR(32),
image VARCHAR(256),
PRIMARY KEY ('id)
);
INSERT INTO 'cards' VALUES("The mighty cat",10,"This is the strongest cat in the multiverse","Cat","2.png");
Bancos SQL e suas implementações mais simples como sqlite3 possui todo o arsenal necessário para criar, manter e gerenciar dados com padrões consolidados na indústria. Mas como você pode ver no exemplo acima, não é a coisa mais simples do mundo. Os dados em SQL são bem estruturados em arquivos binários então você não poderia aleterá-los apenas editando um arquivo de texto, embora inserir novos dados pode ser feito com uma simples linha de comando. Além disso, SQL pode guardar os registros de id de forma mais inteligente e tem de longe as melhores ferramentas de busca. Um possivel problema é que os dados em bancos SQL é sua rigidez com relação a mudança em comparação a exemplos anteriores. No exemplo acima, foi definido que o tipo "Cat" era apenas um texto, mas e se o jogo tiver uma quantidade de tipos bem definidos? Seria interessante ter uma tabela para tipo e até mesmo uma para image caso haja mais metadados. Caso seja necessário alterar estruturas de dados chaves estrangeiras, seria necessário um bom conhecimento e experiência em SQL para conseguir alterar tudo sem destruir os dados das cartas.
Esse formato pode ser o mais custoso de se implementar em um primeiro momento, mas depois de feito, pode ser muito bem aproveitado embora sua portabilidade para plataformas como mobile e consoles possa ser problemática uma vez que implementações como postgresql e mysql costumam ser um servidor e muitas plataformas não permitiram a instalação de um banco de dados. Apesar de ser possivel implementar em arquivo local como sqlite3 ou via stream de dados, o sucesso do uso de bancos SQL irá depender muito do ambiente onde o jogo será disponibilzado.
Lua Language
function create(card)
card:setId(2)
card:setName("The mighty cat")
card:setPower(10)
card:setDescription("This is the strongest cat in the multiverse")
card:setType("Cat")
card:setImage("2.png")
end
Como eu amo essa linguagem. Lua é uma linda pérola no mundo da programação pois ela permite exetender funcionalidades através arquivos de script externo, sem a necessidade de recompilar o projeto inteiro. Lembra que muitos sabem que ela é bastante usada em jogos, mas muitos não sabem como? Bom, imagine que você cria um RPG com diversos itens com efeitos buffs e nerfs varidos.
Toda vez que você precisar criar um item novo como uma espada que aumenta seu ataque em 5%, você irá implementar um método novo para o item e recompilar todo o projeto? Não com Lua! Os scripts de Lua podem acompanhar o jogo externamente e se o jogo externaliza uma API para modificação, tudo pode ser feito em tempo de execução. O jogo carrega o script, interpreta a linguagem e roda as instruções nelas contidas. Ainda é possível usar condicionais, forloops e todas ferramentas que uma linguagem de programação normal teria, tudo de forma externa.
Isso é ótimo para designers poderem testar o jogo sem a necessidade de acesso ao código fonte ou de compilar o jogo em si. Se você jogou jogos mais antigos de RPG no PC e fuçou seus arquivos ou mesmo já fez mods para jogos como Ragnarok e Warcraft 3, provavelmente já deve ter visto ou mexido em diversos scripts Lua nas pastas do jogo.
Porém, apesar de ser compativel com diversas linguagens de programação, não é tão assegurado sua portabilidade para plataformas propietárias como IOS ou para consoles pois as vezes existem políticas para a leitura de arquivos locais que possam de alguma forma prejudicar o uso do jogo via hacking.
O Real Problema
A escolha da tecnologia usada para seu modelo Data-Driven de jogo irá depender muito da plataforma que você deseja usar e da experiência que você e sua equipe possui com a tecnologia. Mas se seu card game utilizar do mesmo conceito de Magic, Yu-Gi-Oh!, Hearthstone, Pokemon TCG e tantos outros jogos similares, você irá cair no problema do efeito de carta.
Como eu citei no começo do artigo, em card games físicos, as regras das cartas se sobrepoem as regras do jogo e com a adição a longo prazo de mais cartas ao jogo, não é possivel prever o que pode acontecer. Você pode tentar implementar cada efeito de carta como um método a ser chamado no jogo, mas isso necessitaría recompilar o jogo inteiro a cada nova carta adicionada além de nem toda linguagem facilitar a busca por nome de métodos implementados, sejam eles estáticos ou orientado a objetos.
Eu não sei como grandes empresas realizam a implementação de seus card games, portanto irei citar como exemplo aqui o EDOPro, uma simulador de Yu-Gi-Oh! bem completo e atualizado e Forge, um simulador para Magic The Gathering. Ambos possuem seu código fonte no GitHub.
EDOPro
Esse é a nova versão do ygopro, escrita em C++ e que é mantida por uma comunidade bem engajada, dando suporte até mesmo a partidas online e fugindo de possíveis processos da Konami. A ferramenta costuma ser utilizada por jogadores profissionais para montar e testar diferentes tipos de decks e estratégias antes de comprar as cartas de fato.
EDOPro utiliza um banco de dados SQLite para guardar os dados das cartas que recebem um ID de seu criador e as cartas normalmente são criadas e atualizadas pela comunidade. Associados ao banco, há uma pasta no projeto do jogo com todas as imagens das cartas que recebem no nome, o mesmo ID das cartas no banco. Para os efeitos de carta, ainda há uma segunda pasta que contém todos os scripts em Lua para cada efeito de carta único dentro do jogo.
O core do jogo em C++ disponibiliza para scripts Lua diversas funções genéricas para buscar, modificar e realizar jogadas. Dependendo da carta, o código em Lua pode ou não ficar complicado. Veja o exemplo do Pot of Greed, carta mágica que permite o jogador comprar duas cartas de seu deck:
local s,id=GetID()
function s.initial_effect(c)
--Activate
local e1=Effect.CreateEffect(c)
e1:SetCategory(CATEGORY_DRAW)
e1:SetType(EFFECT_TYPE_ACTIVATE)
e1:SetProperty(EFFECT_FLAG_PLAYER_TARGET)
e1:SetCode(EVENT_FREE_CHAIN)
e1:SetTarget(s.target)
e1:SetOperation(s.activate)
c:RegisterEffect(e1)
end
function s.target(e,tp,eg,ep,ev,re,r,rp,chk)
if chk==0 then return Duel.IsPlayerCanDraw(tp,2) end
Duel.SetTargetPlayer(tp)
Duel.SetTargetParam(2)
Duel.SetOperationInfo(0,CATEGORY_DRAW,nil,0,tp,2)
end
function s.activate(e,tp,eg,ep,ev,re,r,rp)
local p,d=Duel.GetChainInfo(0,CHAININFO_TARGET_PLAYER,CHAININFO_TARGET_PARAM)
Duel.Draw(p,d,REASON_EFFECT)
end
Assim como Magic e outros card games, Yu-Gi-Oh! tem o conceito de pilha que o mesmo chama de corrente onde um efeito de carta pode ser disparado em resposta ao outro, normalmente neutralizando-o. A primeira função em lua aqui tem como objetivo configurar o jogo e prepará-lo para caso aconteça alguma efeito em resposta. A segunda função configura os parâmetros da ativação do efeito e caso seja necessário o jogador apontar os alvos do efeito, isso pode gerar ativar outros efeitos de corrente no jogo. Por fim, a ultima função é a ativação do efeito de fato.
Como você pode imaginar, portar toda essa arquitetura para plataformas como Android pode ser quase que totalmente inviável. Houve ainda uma versão intitulada Yu-Gi-Oh! Pro 2 que era feita com Unity e usava C# em sua base de código base e teoricamente permitiria o port para Android, reaproveitando o mesmo banco de dados de carta, mas aparentemente essa versão foi descontinuada por conter muitos bugs e ser bem mais pesada e lenta que a original.
Forge
Esse é um entre vários simuladores de Magic, mas eu não sei porque não há tanto engajamento da comunidade para sua manutenção igual há na comunidade de Yu-Gi-Oh. Esse é escrito em Java e suas cartas são representadas em arquivos de texto similares a txt.
O interessante desse simulador é que os efeitos de carta são interpretados a partir de um texto com uma sintaxe única, o que permite ser facilmente legível mostrando que ter tipos customizados podem ser uma vantagem para quem sabe escrever bons interpretadores. Veja o exemplo da carta Black Lotus:
Name:Black Lotus
ManaCost:0
Types:Artifact
A:AB$ Mana | Cost$ T Sac<1/CARDNAME> | Produced$ Any | Amount$ 3 | AILogic$ BlackLotus | SpellDescription$ Add three mana of any one color.
SVar:Picture:http://www.wizards.com/global/images/magic/general/black_lotus.jpg
Oracle:{T}, Sacrifice Black Lotus: Add three mana of any one color.
Não vou mentir que a interface desse jogo é horrorosa e sei que exite diversas skins para mesma, mas eles ainda conseguiram portar para Android uma versão a pouco tempo atrás, mostrando outra vantagem de ter tipos de arquivo de dados customizados. O jogo ainda utiliza diversos outros arquivos de configuração, coleção de cartas, decks pré feitos e outras cosias com arquvivos muito similares a CSV.
Minha Solução
Recentemente decidir criar o meu próprio card game baseado em Magic (que original...) e tive que enfrentar todos os desafios que citei acima e cheguei particularmente em uma solução diferente dos demais que me concedeu algumas vantagens e desvantagens. O jogo que desenvolvi também possui cartas com efeitos únicos e a plaraforma que escolhi como princiapis foi PC e Android. Comecei desenvolvimento utilziando Unity pois já sou experiente com essa engine e obviamente todo o meu código seria escrito em C#.
De início, a minha ideia era criar um servidor autoritativo onde o jogo em si funcionaria no servidor e através de requisições http na Unity, o jogador poderia visualizar e interagir com o servidor. Dessa forma, escolhi python como linguagem de servidor, flask como framework web e JSON para guardar os dados das cartas.Por ser uma linguagem interpretada, python permite a execução de statements ou expressões em temo de excução e dessa forma eu poderia adicionar efeitos nas cartas, escevendo o corpo da função do efeito no arquivo de JSON:
#método que cria e adiciona a carta
def AddEffect(self,newEffect):
if newEffect != "pass":
exec("def fun(player,targets):\n\t"+newEffect)
exec("self.effect = fun", globals(),locals())
exec("del fun")
{
"id":3,
"uniqueName":"zangao1",
"name":"Zangão 1",
"power":2,
"life":2,
"types":["inseto","abelha"],
"color":"YELLOW",
"effect":"card = player.getCardHand(targets[0])\n\tplayer.discard(card)\n\tplayer.pullCards(1)"
}
OBS: As cartas originalmente tem bem mais atributes do que eu mostrei acima.
Quando o arquivo JSON era lido e objeto da carta criado, o campo "effect" no JSON era interpretado e encapsulado dentro de um método que por sua vez era associado ao campo "effect" do objeto python. O metodo ainda passava através do parâmetro "targets" uma lista de possiveis alvos que aquele efeito poderia fazer.
Tudo ia muito bem até eu ver que haveria a necessidade de jogar localmente contra uma IA, e bem... não tinha como eu rodar o que eu tinha feito em python localmente. Eu pensei em algumas soluções:
Utilizar Lua para escrever o core do game. Com o jogo todo implementado em Lua, eu utilizaria uma API para interagir com o jogo e toda a regra de negócio estaria implementado em scripts externos que eu poderia utilizar tanto localmente como em servidor remoto. O problema seria a compatilibade para exportar isso para outras plataformas e todo o trabalho reescrever e manter o jogo em Lua que não é a melhor linguagem para implementar um jogo desse tipo.
Utilizar um interpretador python dentro da Unity. Além disso me fornecer uma péssima performance, os intepretadores não eram perfeitamentes compatíveis e poderiam ocasionar bugs.
Reescrever o jogo em C# e usar o mesmo código do servidor no cliente. Isso até me daria a vantagem de reaproveitar muito código que já estava implementado, mas C# é uma linguagem compilada e eu teria que repensar como algumas coisas iriam funcionar.
Eu escolhi a 3º opção entao depois de umas duas semanas fazendo essa conversão, a arquitetura ficou mais ou menos assim:
Chegou a hora da verdade: Como eu iria implementar os efeitos de carta em arquivos externos ao JSON?
Primeiramente pensei em fazer uma abordagem parecida com o que ocorre no EDOPro, utilizando scripts em Lua ou até mesmo tags especificas em JSON e uma API para efeitos genéricos com parâmetros externos, mas isso atrapalha muito quando você precisa comparar valores ou realizar qualquer operação matemática no efeito da carta.
Então eu pensei: Será que dá para eu interpretar C# em tempo de execução, compilar e adiconar o efeito a carta? E depois de várias pesquisas, descobri que sim! A Microsoft disponibiliza um conjunto de funcionalidades de interpretação e depuração da linguagem no namespace Microsoft.CSharp para que as pessoas possam construir seus proprios interpretadores costumizados e o namespace System.CodeDom.Compiler contém um compilador que pode retornar objetos e classes instanciadas em tempo de execução.
Veja abaixo o método que converte uma string no corpo de uma função em C#:
public string effect;
protected MethodInfo method;
protected object obj;
protected void PerformInjection(string newBody) {
CompilerParameters parameters = new CompilerParameters();
parameters.GenerateInMemory = true;
parameters.ReferencedAssemblies.Add("System.dll");
parameters.ReferencedAssemblies.Add(typeof(Hermetica.Card).Assembly.Location);
parameters.ReferencedAssemblies.Add(typeof(Hermetica.Player).Assembly.Location);
parameters.ReferencedAssemblies.Add(typeof(UnityEngine.MonoBehaviour).Assembly.Location);
using (var codeProvider = new CSharpCodeProvider()) {
var res = codeProvider.CompileAssemblyFromSource(parameters,
"using Hermetica; using System; public class Effect { public void Active(Hermetica.Player player, Hermetica.Card[] targets) { " + newBody + " }}"
);
var type = res.CompiledAssembly.GetType("Effect");
obj = Activator.CreateInstance(type);
method = obj.GetType().GetMethod("Active");
}
//instance method who active the effect
public void Active(Hermetica.Player player, Hermetica.Card[] targets) {
if (method != null){
method.Invoke(obj, new object[] { player,targets });
}
}
Não é a coisa mais bonita do mundo e não é tão seguro ainda, mas funciona muito bem. É o mesmo conceito aplicado em python onde eu posso escrever o corpo de uma função em C# dentro de um arquivo JSON e na hora de desserializar a carta, o código é compilado e o objeto gerado tem o seu método copiado e embutido no campo MethodInfo da carta.
A desvantagem aqui é apenas o tempo de carregamento e desserialização das cartas uma vez que ao mesmo tempo que o jogo irá ler um arquivo de texto para desserializar ele vai compilar um código em tempo de execução. Esses arquivos JSON ficam dentro da pasta Resources do Unity que é compilada junto com toda a aplicação e eu tenho assim a vantagem de usar somente uma linguagem para desenvolver todo o jogo.
Há um detalhe que devo mencionar: o código não rodava em um servidor linux pois os namespaces citados só estavam disponíveis para .NET 4 em diante, e a versão de linux possui até pouco tempo .NET 3. Então tive que escrever o servidor com .NET Core 5 que foi recentemente lançado e além de ser multiplataforma, é a promessa da Microsfot para unificar toda o ambiente .NET.
O jogo ainda não está completo pois falta muitas cartas para desenhar e testar, mas todas a build para PC já funciona muito bem, incluindo o modo online. Caso queiram me acompanhar em mais artigos e pesquisas, podem me seguir aqui mesmo no Dev.to, em breve o jogo terá uma página só para ele. Em breve pretendo escrever mais sobre a arquitetura de um card game, game design, implementações de card games na unity, sistemas de busca de cartas e a importancia das exceptions. Caso eu tenha errado alguma coisa, por favor, comentem para que eu atualize o artigo e muito obrigado por ler até aqui!
Posted on November 8, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.