Como fiz o Infinite Craft ser multiplayer com algumas linhas de código

vtnorton

Vitor Norton

Posted on March 20, 2024

Como fiz o Infinite Craft ser multiplayer com algumas linhas de código

Sexta-feira, 14h, eu vi esse jogo. A premissa é simples, combinar os quatro elementos iniciais "água", "fogo", "terra" e "vento" para criar basicamente tudo. Junte "Água" e "Terra" e você obtém uma "Planta", "Planta" e "Água" você obtém uma "Árvore", "Árvore" + "Árvore" você obtém uma "Floresta" e assim por diante. De alguma forma, ao final de uma hora de jogo, fui capaz de recriar todos os bens gregos e Michael Jackson. A parte engraçada é quando misturo Michael Jackson com Zeus: ei, descobri algo novo que ninguém nunca fez, a sensação foi incrível!

É um jogo de navegador, que usa IA para misturar as coisas e escolher um emoji para representá-las. Está bem treinado e lindamente elaborado: simples, responsivo. Mas algo estava faltando para mim. Você sabe quando está interessado em algo e não consegue parar de ver isso em qualquer lugar? Bem, acontece que alguns meses atrás comecei a trabalhar como Developer Advocate na SuperViz, e realmente tive que me aprofundar em nossos produtos e coisas... e estou te contando isso porque eu estava jogando o jogo e tudo o que conseguia imaginar era adicionar este produto que eu estava documentando a ele. Em outras palavras: criar uma versão multiplayer do Infinite Craft.

Então, fiz isso. Principalmente porque precisava de uma maneira de justificar para o meu chefe que estava jogando um jogo a tarde toda de sexta-feira, mas fiz. Como? TL/DR: Criei uma extensão para o Chrome que injeta algum script no jogo e usei o broker de eventos da SuperViz, o Real-time Data Engine, para fazer o resto.

Primeiro, é claro, tentei ver se o código era de código aberto. Não era, ok. Devo criar um site com um iFrame do jogo e injetar algo lá? Fiz isso uma vez no passado e não deve ser tão difícil. Espera: uma ideia melhor! Uma extensão de navegador. Claro, em dispositivos móveis seria um problema, mas eu só preciso saber se o que estava tentando realizar funcionaria.

Há alguns anos que queria escrever uma extensão para o Microsoft Edge novamente, para ver como evoluiu desde a última vez em 2015. Não muito. Mesmo que minha primeira Extensão do Edge fosse para sua versão antiga, sempre foi baseada e totalmente compatível com o Google Chrome.

Desenvolver uma extensão é fácil, é tudo sobre o arquivo manifest.json. Você define o nome, a descrição, uma ação para o pop-up padrão (quando você clica no ícone da extensão), o ícone e algumas coisas que vou te contar depois.

{
    "manifest_version": 3,
    "name": "Infinite Craft - Extensão multiplayer",
    "description": "Esta extensão permite que você jogue Infinite Craft com seus amigos!",
    "version": "1.0",
    "action": {
        "default_popup": "popup.html"
    }
}
Enter fullscreen mode Exit fullscreen mode

Coloquei um arquivo HTML para carregar quando você clica no pop-up, mas o conteúdo é irrelevante para este artigo. A verdadeira mágica acontece nos content_scripts, que são os scripts que injetarei na página. Eu tenho um matches e os arquivos JS, neste último eu posso filtrar para injetar apenas no jogo Infinite Craft, no primeiro eu posso criar um alert("Olá Mundo") para verificar se está funcionando.

 "contente scripts": [
    {
      "matches": [
        "<all_urls>" // I allowed every page because I like to live dangerously
      ],
      "js": [
        "js/vendor.js",
        "js/content_script.js"
      ]
    }
  ],
Enter fullscreen mode Exit fullscreen mode

Então, no primeiro momento, estava tentando fazer tudo em JavaScript Vanilla, sem nenhum pacote npm. A SuperViz tem uma maneira de CDN de usar em arquivos HTML, então pensei que seria mais fácil. Eu estava errado: precisava de algumas permissões no manifest. Eu poderia ter descoberto, mas estava chateado por não ter TypeScript para me ajudar, sem .eslint, não tava um ambiente familiar para mim. Isso já era um desafio para escrever um jogo multiplayer, então por que me desafiar a algo que dificultasse ainda mais as coisas? De jeito nenhum.

Então, você pode fazer isso com JS simples, mas para mim, tive que adicionar TypeScript e React. Continuando...

A ideia era criar um cabeçalho para o jogo onde teria as opções para o multiplayer, então preciso injetar um elemento (ou neste caso, um componente React) dentro da página, e neste caso antes do seu contêiner. Meu script era algo assim:

const initInfiteCraftMultiplayer = () => {
    const container = document.querySelector('.container')

    if (container && container.parentNode) {
        const multiplayerHeader = document.createElement('div')
        const root = createRoot(multiplayerHeader)

        root.render(<Extension />)

        container.parentNode.insertBefore(multiplayerHeader, container)
    }
}

;(() => {
    initInfiteCraftMultiplayer()
})()
Enter fullscreen mode Exit fullscreen mode

Incrível! Agora tenho uma interface feita em React para brincar junto.

No cabeçalho (e depois de algumas injeções de CSS também), eu tinha um botão para entrar em uma sala. Quando clicado, ele começaria a sala (mostrarei depois). Depois que a sala começasse, era hora de criar um link com um ID único para todos poderem se juntar a ela.

export default function ShareRoom({ roomId }: { roomId: string }) {
    const [buttonText, setButtonText] = React.useState('Share room')
    const urlToShare = `https://neal.fun/infinite-craft/?roomId=${roomId}`

    const copyToClipboard = () => {
        setButtonText('Copied!')
        navigator.clipboard.writeText(urlToShare)
        setTimeout(() => {
            setButtonText('Share room')
        }, 1000)
    }

    return <button onClick={() => copyToClipboard()}>{buttonText}</button>
}

Enter fullscreen mode Exit fullscreen mode

Eu usaria este roomId da URL posteriormente para definir quem estava jogando com quem.

Agora eu precisava fazer acontecer: iniciar uma sala para as pessoas jogarem. Eu sabia que capturar os movimentos e elementos não seria trivial, então eu precisava fazer algo primeiro, para ter certeza de que tudo estava funcionando bem. O primeiro componente que fiz foi um indicador de presença: Who-is-Online.

Ele mostra os avatares das pessoas que estão na mesma sala com você, e é bastante simples de adicionar, mas primeiro eu precisava de uma sala.

const room = await SuperVizRoom(DEVELOPER_KEY, {
    roomId: roomId,
    group: {
        id: 'vtn-multiplayer',
        name: 'Grupo Multiplayer Vitor Norton',
    },
    participant: {
        id: participantId,
        name: 'Vitor Norton',
    },
})
Enter fullscreen mode Exit fullscreen mode

O roomId é algo que gero automaticamente ao clicar no botão de compartilhar, mas se você entrou em uma página com este parâmetro, ele preferirá usar o parâmetro (como visto acima) e não esperará até clicar no botão "Criar uma sala".

Como este é um teste simples para mim, não criei uma interface ou algo que permitisse ao usuário mudar seu nome. Eu poderia fazer isso no arquivo index.html que mencionei anteriormente ou ao entrar em um pop-up de sala, um modal pedindo o nome ou até mesmo gerar nomes aleatórios como o Google Docs faz.

Também precisava de uma chave SuperViz, pois é um SDK que facilitou tudo para este projeto. Para testar e desenvolver, é grátis. Nice!

Agora tenho uma sala, vamos adicionar o componente que mencionei anteriormente:

const whoisonline = new WhoIsOnline('room-list')
room.addComponent(whoisonline)
Enter fullscreen mode Exit fullscreen mode

É isso! O room-list é o ID de um elemento HTML onde eu quero que ele seja renderizado. Agora, quando outras pessoas se juntarem à sala, saberei que não estou sozinho.

Lista de participantes em uma sala

Ok, então agora, tudo o que tenho é a parte mais difícil. Peguei um pouco de café, dividi em pequenas tarefas e então estava pronto para começar a codar.

A mágica será feita, como eu disse antes, com o Real-time Data Engine. É um event broker, então posso me inscrever em eventos e publicar novos. Isso significa que se eu arrastar algo no canvas, posso publicar um evento para que cada participante na sala tenha a mesma partícula na mesma posição. Este é o passo fácil, depois adicionaremos as funções de fusão e se algo for criado em uma tela, deverá ser criado para todas as pessoas na sala.

Então, a primeira coisa a saber é identificar o que é a partícula, e como quero usar o mesmo nome do desenvolvedor original Neal, usei instance para nomeá-lo.

export interface Instance {
    id: string
    name: string
    emoji: string
    position: Position
}

export interface Position {
    x: number
    y: number
    z: number
}

Enter fullscreen mode Exit fullscreen mode

Feito. Tem mais atributos para isso, mas isso é o que precisamos. Agora nossa missão é capturar quando arrastamos algo por aí. Para isso, usei o MutationObserver. Ele observa as mutações dos elementos DOM. Então, se algo mudar dentro de um nó HTML, eu saberei. Comecei a observar a área de instâncias onde as partículas foram arrastadas, e é claro que estava ouvindo seus filhos porque é esse o ponto.

const instances = document.querySelector('.instances div') as Element
const observer = new MutationObserver(function (mutations: MutationRecord[]) {
    // ALGUM CÓDIGO AQUI
})
observer.observe(instances, { childList: true })
Enter fullscreen mode Exit fullscreen mode

Depois disso, criei algumas validações para garantir que fosse o elemento que eu queria. Seria desonesto dizer que isso não é uma gambiarra. É feio e talvez haja maneiras melhores de fazer isso, mas para mim, funcionou.

const lastMutation = mutations[mutations.length - 1]
const lastInstance = lastMutation.previousSibling as HTMLElement
if (!lastInstance) return

const style = window.getComputedStyle(lastInstance)
const translate = style.translate
if (!translate) return

const parts = translate.split(' ')
if (parts.length !== 2) return
Enter fullscreen mode Exit fullscreen mode

O que este código faz é escolher o último item na lista de mutação, então obter seu HTMLElement. Se não for, então não faça nada. Em seguida, eu pegaria o estilo (a posição do elemento está localizada na definição de estilo inline sob a propriedade translate). Se não contiver nada, retorne, porque não seria o que queremos.

Mas às vezes o translate teria algo e ainda não era nosso elemento, então eu me certificaria de que ele tinha duas informações (o topo e a esquerda: posição x e y).

Depois disso, é fácil criar nossa instância que definimos antes.

const instance: Instance = {
    id: lastInstance.id,
    name: lastInstance.textContent?.trim().split('\\n')[1].trim() || '',
    emoji: lastInstance.querySelector('.instance-emoji')?.textContent || '',
    position: {
        x: x,
        y: y,
        z: z,
    },
}
Enter fullscreen mode Exit fullscreen mode

Feito, temos nossa instância. Então, vamos apenas publicá-la para todos na sala. Para fazer isso, usei a Real-time Data Engine, usando este código:

const [realtime] = React.useState<Realtime>(new Realtime())

// Ao iniciar a sala
room.addComponent(realtime)
realtime.subscribe('item-added', newItemAdded)

// Ao publicar um evento
if (lastInstance.getAttribute('data-multiplayed')) return
realtime.publish('item-added', instance)

Enter fullscreen mode Exit fullscreen mode

Fácil. Mas o que é esse data-multiplayed? Eu criei para garantir que não publicasse algo que já estivesse no quadro de alguém. Como você criou isso? Bem, ao adicionar o novo item.

Quando recebo um novo evento com o nome item-added, daí uso a função de retorno de chamada newItemAdded para adicionar o elemento.

const newItemAdded = (item: any) => {
    const itemToAdd = getLastElementIfIsList(item)

    if (itemToAdd.participantId === participantId) return
    InsertInstance(itemToAdd.data as Instance)
}

Enter fullscreen mode Exit fullscreen mode

Faço apenas algumas validações, como saber que o participante que adicionou este item não é o mesmo participante na tela. Isso criaria um loop infinito e travaria seu navegador, acredite em mim. Não pergunte como sei disso.

O InsertInstance é a parte fácil. Eu uso o InstanceGenerator (mostrado abaixo) para inserir um novo HTML adjacente antes do final para o seletor .instances div. Isso gerará o HTML exato do usuário original.

const InstanceGenerator = (instance: Instance) => {
    return `
    <div data-v-32430ce5="" data-multiplayed="true" id="${instance.id}" class="item instance" style="translate: ${instance.position.x}px ${instance.position.y}px; z-index: ${instance.position.z};">
        <span data-v-32430ce5="" class="instance-emoji">${instance.emoji}</span>
    ${instance.name}
    </div>`
}
Enter fullscreen mode Exit fullscreen mode

Duas coisas a serem observadas aqui: primeiro, aqui é onde adiciono o data-multiplayed="true", e por último, ele usa esse data-v-32430ce5 que muda em cada nova versão/compilação que o desenvolvedor do jogo faz. Este é um ponto de melhoria de código aqui, ao carregar uma sala, para armazenar o novo valor deste nome de propriedade e usar este novo valor em vez dessa solução codificada.

Agora podemos ver quem está online na página, quando um participante cria uma nova instância, ela é criada para todos os outros participantes na sala. Legal!

É perfeito? Não. Aqui está o porquê:

  • Neal, que é o desenvolvedor do jogo, pode fazer alterações no jogo e isso quebrará minha extensão. Posso manter o código funcionando tentando fazer alterações ao mesmo tempo, mas uma parceria com o desenvolvedor seria incrível (Ei, Neal, me mande uma mensagem!)

  • Os IDs das instâncias podem ser duplicados porque a maneira como o jogo gera é sequencial. Isso significa que se alguém criar uma instância no Participante-A, ela receberá um id de instance-10. Se um novo elemento do Participante-A for criado, será instance-11. O problema com isso é que para todos os outros participantes em uma sala (Participante-B, Participante-C), a extensão criará um elemento instance-10, mas isso não é gerado pelo jogo, então se algum desses participantes criar uma nova coisa, adivinhe qual será o id? Outro instance-10.

Existem maneiras de corrigir isso, mas ter uma parceria com o desenvolvedor seria incrível.

  • O tempo necessário para eu continuar refinando e atualizando a extensão para acompanhar as alterações feitas pelo desenvolvedor original é significativo. Isso inclui entender as novas mudanças, implementar atualizações correspondentes na extensão e testar minuciosamente para garantir a compatibilidade.

Em conclusão, este projeto foi uma jornada emocionante e desafiadora, mas foi realmente divertido fazê-lo. Ele demonstra as possibilidades infinitas ao combinar conceitos de jogos criativos com ferramentas poderosas como a Real-Time Data Engine da SuperViz.

Como em qualquer projeto de código aberto, contribuições são bem-vindas e muito apreciadas. O repositório do GitHub para a extensão multiplayer do Infinite Craft está aberto para quem quiser contribuir, seja corrigindo bugs, adicionando novos recursos, melhorando o código existente ou apenas brincando com ele.

💖 💪 🙅 🚩
vtnorton
Vitor Norton

Posted on March 20, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related