Mecanismo, linguagem de script e romance visual - em 45 horas

romance visual, mecanismo e linguagem de script por 45 horas


Saudações. Aconteceu que por três anos seguidos, como presente de Ano Novo para certas pessoas, eu tenho feito um jogo. Em 2018, foi um jogo de plataforma com elementos de quebra-cabeça, sobre o qual escrevi em um hub Em 2019 - uma rede RTS para dois jogadores, sobre a qual eu não escrevi nada. E, finalmente, em 2020 - uma história curta visual, que será discutida mais adiante, criada em um tempo muito limitado.


Neste artigo:


  • projeto e implementação do mecanismo de histórias curtas visuais,
  • um jogo com um enredo não linear em 8 horas,
  • remoção da lógica do jogo em scripts em seu próprio idioma.

Interessante? Então seja bem-vindo ao gato.


Cuidado: há muito texto e ~ imagens de 3,5mb


Conteúdo:


0. Justificativa para o desenvolvimento do motor.


  1. A escolha da plataforma.
  2. Arquitetura do mecanismo e sua implementação:
    2.1 Declaração do problema.
    2.2 Arquitetura e implementação.
  3. Linguagem do script:
    3.1 Idioma.
    3.2 Intérprete
  4. Desenvolvimento de jogos:
    4.1 A história e o desenvolvimento da lógica do jogo.
    4.2 Gráficos
  5. Estatísticas e resultados.

Nota: Se, por algum motivo, você não estiver interessado em detalhes técnicos, poderá pular para a etapa 4 "Desenvolvimento de Jogos", mas pulará a maior parte do conteúdo


0. Justificação para o desenvolvimento do motor


Obviamente, há um grande número de mecanismos prontos para histórias curtas visuais, que, sem dúvida, são melhores que a solução descrita abaixo. No entanto, não importa que tipo de programador eu fosse, se não tivesse escrito outro. Portanto, vamos fingir que seu desenvolvimento foi justificado.


1. Seleção de plataforma


De fato, a escolha foi pequena: Java ou C ++. Sem pensar duas vezes, decidi implementar meu plano em Java, porque para desenvolvimento rápido, fornece todas as possibilidades (ou seja, gerenciamento automático de memória e maior simplicidade em comparação com o C ++, que oculta muitos detalhes de baixo nível e, como resultado, permite menos ênfase na própria linguagem e pensa apenas na lógica de negócios), e também fornece suporte para janelas, gráficos e áudio prontos para uso.


O Swing foi escolhido para implementar a interface gráfica, já que eu usei o Java 13, onde o JavaFX não faz mais parte da biblioteca, e adicionar dezenas de megabytes de OpenJFX, dependendo da preguiça. Talvez essa não fosse a melhor solução, mas mesmo assim.


A questão provavelmente surge: que tipo de mecanismo de jogo é esse, mas sem a aceleração do hardware? A resposta está na falta de tempo para lidar com o OpenGL, bem como sua absoluta falta de sentido: o FPS não é importante para um romance visual (em qualquer caso, com tanta animação e gráficos quanto neste caso).


2. A arquitetura do mecanismo e sua implementação


2.1 declaração do problema


Para decidir como fazer algo, você precisa decidir o porquê. Este sou eu sobre a declaração do problema, porque a arquitetura não é universal, mas um mecanismo "específico do domínio", por definição, depende diretamente do jogo pretendido.


Por mecanismo universal, entendo o mecanismo que suporta conceitos de nível relativamente baixo, como "Objeto de Jogo", "Cena", "Componente". Foi decidido torná-lo não um mecanismo universal, pois isso reduziria significativamente o tempo de desenvolvimento.


Conforme planejado, o jogo deve consistir nas seguintes partes:


estrutura do jogo visual novel


Ou seja, existe um plano de fundo para cada cena, o texto principal e um campo de texto para a entrada do usuário (a novela visual foi pensada precisamente com entrada arbitrária do usuário, e não uma escolha entre as opções propostas, como costuma ser o caso. Mais tarde, vou dizer por que foi ruim decisão). O diagrama também mostra que pode haver várias cenas no jogo e, como resultado, transições podem ser feitas entre elas.


Nota: Por cena, quero dizer a parte lógica do jogo. O critério para a cena pode ser o mesmo plano de fundo ao longo dessa mesma parte.


Também estava entre os requisitos para o mecanismo a capacidade de reproduzir áudio e exibir mensagens (com a função opcional de entrada do usuário).


Talvez o desejo mais importante tenha sido o desejo de escrever a lógica do jogo não em Java, mas em alguma linguagem declarativa simples.


Havia também um desejo de perceber a possibilidade de animação procedural, ou seja, o movimento elementar de imagens, para que, no nível Java, fosse possível determinar a função pela qual a velocidade de movimento atual é considerada (por exemplo, para que o gráfico de velocidade seja reto, ou um sinusóide ou qualquer outra coisa).


Conforme planejado, toda a interação do usuário deveria ser feita através de um sistema de diálogos. Nesse caso, o diálogo foi considerado não necessariamente um diálogo com o NPC ou algo semelhante, mas geralmente uma reação a qualquer entrada do usuário para a qual o manipulador correspondente foi registrado. Não está claro Isso ficará mais claro em breve.


2.2 Arquitetura e implementação


Dado todo o exposto, você pode dividir o mecanismo em três partes relativamente grandes que correspondem aos mesmos pacotes java:


  1. display - contém tudo o que diz respeito à saída para o usuário de qualquer informação (gráfico, texto e som), além de receber informações dele. Uma espécie de (View), se falamos de MVC / MVP / etc.
  2. initializer - contém classes nas quais o mecanismo é inicializado e iniciado.
  3. sl - contém ferramentas para trabalhar com a linguagem de script (daqui em diante - SL).

Neste parágrafo, considerarei as duas primeiras partes. Vou começar com o segundo.


A classe inicializador possui dois métodos principais: initialize() e run() . Inicialmente, o controle chega à classe do iniciador, de onde initialize() chamado initialize() . Após a chamada, o inicializador analisa os parâmetros passados ​​para o programa (o caminho para o diretório com as missões e o nome da missão a ser executada), carrega o manifesto da missão selecionada (um pouco mais tarde), inicializa a exibição, verifica se a versão do idioma (SL) exigida pela busca é suportada pelos dados intérprete e, finalmente, lança um thread separado para o console do desenvolvedor.


Imediatamente depois disso, se tudo correu bem, o lançador chama o método run() , que aciona o carregamento real da missão. Primeiro, existem todos os scripts relacionados à missão baixada (sobre a estrutura do arquivo da missão - abaixo), eles são alimentados ao analisador, cujo resultado é fornecido ao intérprete. Em seguida, a inicialização de todas as cenas é iniciada e o inicializador conclui a execução de seu fluxo, suspendendo finalmente o manipulador de teclas Enter no visor. E assim, quando o usuário pressiona Enter, a primeira cena é carregada, mas mais sobre isso mais tarde.


A estrutura de arquivos da missão é a seguinte:


estrutura de arquivos da pesquisa


Há uma pasta separada para a missão, cuja raiz é o manifesto, além de três pastas adicionais: audio - para som, graphics - para a parte visual e scenes - para scripts que descrevem cenas.


Eu gostaria de descrever brevemente o manifesto. Ele contém os seguintes campos:


  • sl_version_req - versão SL necessária para iniciar a missão,
  • init_scene - o nome da cena a partir da qual a missão começa,
  • quest_name - um belo nome de missão que aparece no título da janela,
  • resolution - a resolução da tela para a qual a missão se destina (algumas palavras sobre isso posteriormente),
  • font_size - tamanho da fonte para todo o texto,
  • font_name é o nome da fonte para todo o texto.

Vale ressaltar que durante a inicialização da tela, entre outras coisas, foi realizado o cálculo da resolução de renderização: ou seja, a resolução necessária foi obtida do manifesto e espremida no espaço disponível para a janela para que:


  • a proporção permaneceu a mesma da resolução do manifesto,
  • todo o espaço disponível era ocupado em largura ou altura.
    Graças a isso, o desenvolvedor da missão pode ter certeza de que suas imagens, por exemplo 16: 9, serão exibidas em qualquer tela nessa proporção.

Além disso, quando a exibição é inicializada, o cursor fica oculto, pois não está envolvido na jogabilidade.


Em poucas palavras sobre o console do desenvolvedor. Foi desenvolvido pelos seguintes motivos:


  1. Para depuração.
  2. Se algo der errado durante o jogo, ele poderá ser corrigido no console do desenvolvedor.
    Ele implementou apenas alguns comandos, a saber: a saída de descritores de um tipo específico e seu status, a saída de threads de trabalho, a reinicialização da exibição e o comando mais importante - exec , que permitiu executar qualquer código SL na cena atual.

Isso encerra a descrição do inicializador e coisas relacionadas, e podemos prosseguir com a descrição da exibição.


Sua estrutura final é a seguinte:


estrutura de exibição


A partir da declaração do problema, podemos concluir que tudo o que precisa ser feito é desenhar imagens, desenhar texto, reproduzir áudio.


Como o texto / imagem geralmente é desenhado em mecanismos universais e além? Existe um método do tipo update() , chamado de tick / step / frame / render / frame / etc e no qual existe uma chamada para um método do tipo drawText() / drawImage() - isso garante a aparência do texto / imagem nesse quadro. No entanto, assim que a chamada para esses métodos é interrompida, a renderização dos itens correspondentes é interrompida.


No meu caso, foi decidido fazer um pouco diferente. Como nos romances visuais, o texto e as imagens são relativamente permanentes e também são quase tudo o que o usuário vê (ou seja, são importantes o suficiente), eles foram criados como objetos de jogo - isto é, coisas que você só precisa gerar e elas não desaparecerão até você perguntar a eles. Além disso, esta solução simplificou a implementação.


Um objeto (do ponto de vista do OOP) que descreve o texto / imagem é chamado de descritor. Ou seja, para o usuário do mecanismo da API, existem apenas descritores que podem ser adicionados ao estado de exibição e removidos dele. Assim, na versão final da tela, existem os seguintes descritores (eles correspondem às classes com o mesmo nome):


Nome do descritor:Descrição do descritor:
ImageDescriptorDescritor de imagens: contém a imagem ( BufferedImage ), posição na tela e largura com altura. No entanto, ao criar, apenas a largura é especificada - a altura é calculada proporcionalmente à altura original (e não há como esticar / compactar manualmente a imagem de maneira desproporcional, portanto, essa foi uma decisão ruim).
TextDescriptorDescritor de texto: contém o texto, sua posição e tamanho. Além disso, o texto não aumenta em largura e altura, mas é cortado ao ultrapassar as bordas. O texto pode ser transferido por sílabas e simplesmente por caracteres de espaço em branco, e também rolar pelas linhas.
AudioDescriptorUm descritor de áudio que pode reproduzir áudio (uma vez ou em loop), pausá-lo e removê-lo.
AnimationDescriptorUm identificador de animação que, como o áudio, pode ser repetido. Contém um objeto que implementa a animação e um descritor de imagem para o qual a animação é executada. O método principal é a update(long) , que leva o número de milissegundos que passaram desde a última chamada da update(long) . O número deles é usado para calcular o estado atual da animação.
InputDescriptorDescritor de entrada: é um campo de texto em que o cursor está sempre no final do texto. Também é importante notar que o armazenamento e a renderização de texto de um descritor de entrada são feitos por meio de um descritor de texto criado implicitamente para não duplicar a lógica. O engraçado é que eu levei em conta a possibilidade de pressionar Backspace, mas não levei em conta Excluir; e quando Excluir ainda era pressionado durante o jogo, ▯▯▯ apareceu no campo, pois nenhum processamento foi feito para Excluir e o personagem tentou exibir como texto.
KeyAwaitDescriptorUm identificador para o qual a tecla necessária é passada (do KeyEvent ) e um retorno de chamada com alguma lógica que será iniciada quando a tecla correspondente for pressionada.
PostWorkDescriptorUm identificador que aceita um retorno de chamada que será chamado após o processamento de cada tick.

O visor também contém campos para o atual receptor de entrada (descritor de entrada) e um campo indicando qual descritor de texto agora está focado e cujo texto será rolado sob as ações correspondentes da parte do usuário.


O ciclo do jogo é mais ou menos assim:


  1. Processamento de áudio - chamar o método update() nos descritores de áudio, que verifica o estado atual do áudio, libera memória (se necessário) e realiza outros trabalhos técnicos.
  2. Processando pressionamentos de tecla - transfira os caracteres inseridos para um descritor para receber entrada, processando pressionamentos de teclas para teclas de rolagem (setas para cima e para baixo) e Backspace.
  3. Processamento de animação.
  4. Limpando o plano de fundo no buffer de renderização ( BufferedImage serviu como buffer).
  5. Desenhando imagens.
  6. Renderização de texto.
  7. Desenho de campos para entrada.
  8. A saída do buffer para a tela.
  9. Manipulando PostWorkDescriptor 's.
  10. Alguns trabalhos sobre a substituição de estados de exibição, os quais discutirei mais adiante (na seção sobre o intérprete do SL).
  11. Pare o fluxo pelo tempo calculado dinamicamente para que o FPS seja igual ao valor especificado (30 por padrão).

Nota: Talvez surja a pergunta: "Por que renderizar campos de entrada se descritores de texto apropriados foram criados para eles que serão renderizados uma etapa anteriormente?" De fato, a renderização na etapa 7 não ocorre - apenas os InputDescriptor do InputDescriptor são sincronizados com os InputDescriptor do InputDescriptor - como visibilidade da tela, posição, tamanho e outros. Isso foi feito, conforme indicado acima, pelo motivo de o usuário não controlar diretamente o descritor de entrada correspondente com um descritor de texto e geralmente não sabe nada sobre ele.


Vale ressaltar que o tamanho e a posição dos elementos na tela não são definidos em pixels, mas em tamanhos relativos - números de 0 a 1 (diagrama abaixo). Ou seja, toda a largura da renderização é 1 e a altura inteira é 1 (e elas não são iguais, o que eu esqueci várias vezes e depois me arrependi). Também valeria a pena fazer (0,0) ser o centro, e a largura / altura deve ser igual a dois, mas por alguma razão eu esqueci / não pensei nisso. No entanto, mesmo a opção com largura / altura igual a 1 simplificou a vida do desenvolvedor da missão.


sistema de coordenadas relativas


Algumas palavras sobre o sistema para liberar memória.


Cada descritor tinha um setDoFree(boolean) , que o usuário tinha que chamar se quisesse destruir o descritor fornecido. A coleta de lixo para descritores de algum tipo ocorreu imediatamente após o processamento de todos os descritores desse tipo. Além disso, o áudio reproduzido uma vez foi excluído automaticamente após o término da reprodução. Exatamente o mesmo que a animação sem loop.


Portanto, no momento, você pode desenhar o que quiser, mas essa não é a figura acima, na qual há apenas um plano de fundo, o texto principal e um campo de entrada. E aqui vem o wrapper sobre a exibição, que corresponde à classe DefaultDisplayToolkit .


Quando inicializado, ele adiciona apenas descritores para o plano de fundo, texto etc., além de exibir mensagens com o ícone opcional, campo de entrada e retorno de chamada.


Surgiu então um pequeno bug, cuja correção total exigiria refazer metade do sistema de renderização: se você observar a ordem de renderização no loop do jogo, poderá ver que as imagens são desenhadas primeiro e somente depois o texto. Ao mesmo tempo, quando o kit de ferramentas mostra a imagem, coloca-a no meio da tela em largura e altura . E se houver muito texto na mensagem, ela deverá se sobrepor parcialmente ao texto principal da cena. No entanto, como o plano de fundo da mensagem é uma imagem (completamente preta, mas mesmo assim) e as imagens são desenhadas antes do texto, um texto é sobreposto a outro (captura de tela abaixo). O problema foi parcialmente resolvido pela centralização vertical, não na tela, mas na área acima do texto principal. Uma solução completa incluiria a introdução de um parâmetro de profundidade e refazer os renderizadores da palavra "completamente".


Demonstração de sobreposição

sobreposição de texto


Talvez isso seja sobre a exibição, finalmente, tudo. Você pode passar para o idioma, a API inteira para trabalhar com a qual está contida no pacote sl .


3. Linguagem de script


Nota: Se o respeitado% USERNAME% o leu aqui, ele se saiu bem, e eu pediria que ele não parasse de fazê-lo: agora será muito mais interessante do que antes.


3.1 Linguagem


Inicialmente, eu queria criar uma linguagem declarativa na qual seria necessário apenas indicar todos os parâmetros necessários para a cena, e isso é tudo. O mecanismo levaria toda a lógica. No entanto, no final, cheguei à linguagem processual, mesmo com elementos de POO (pouco distinguíveis), e essa foi uma boa solução, pois, em comparação com a opção declarativa, permitiu muito mais flexibilidade na lógica dos jogos.


A sintaxe da linguagem foi pensada de modo a ser o mais simples possível para análise, o que é lógico, dada a quantidade de tempo disponível.


Portanto, o código é armazenado em arquivos de texto com a extensão SSF; cada arquivo contém uma descrição de uma ou mais cenas; cada cena contém zero ou mais ações; cada ação contém zero ou mais operadores.


Uma pequena explicação sobre os termos. Uma ação é apenas um procedimento sem a possibilidade de passar argumentos (de forma alguma impediu o desenvolvimento do jogo). Aparentemente, o operador não é exatamente o que essa palavra significa em idiomas comuns (+, -, /, *), mas a forma é a mesma: o operador é a totalidade de seu nome e todos os seus argumentos.


Talvez você esteja ansioso para finalmente ver o código fonte do SL, aqui está:


 scene dungeon { action init { load_image "background" "dungeon/background.png" load_image "key" "dungeon/key.png" load_audio "background" "dungeon/background.wav" load_audio "got_key" "dungeon/got_key.wav" } action first_come { play "background" loop set_background "background" set_text "some text" add_dialog "(||(|) (||-))" "dial_look_around" dial_look_around on } //some comment action dial_look_around { play "got_key" once show "some text 2" "key" none tag "key" switch_dialog "dial_look_around" off } } 

Agora fica claro qual é o operador. Também é visto que cada ação é um bloco de declarações (uma declaração pode ser um bloco de declarações), bem como o fato de que comentários de linha única são suportados (não fazia sentido inserir comentários de várias linhas, além disso, eu não usei comentários de linha única).


Por uma questão de simplificação, um conceito como "variável" não foi introduzido na linguagem; como resultado, todos os valores usados ​​no código são literais. Dependendo do tipo, os seguintes literais são diferenciados:


Nome literal:Nota:
String literalA capacidade de escapar aspas e barras (\\) está incluída, também é possível inserir um hífen no texto
Literal inteiroSuporta números negativos
Literal de ponto flutuanteSuporta números negativos
Nenhum literalO código é representado como none
Literal booleanoNo código - on / off para verdadeiro / falso, respectivamente
Literal geralSe um literal não se enquadra em nenhum dos tipos acima e consiste nas letras do alfabeto inglês, números e sublinhado, é um literal geral.

Algumas palavras sobre a análise de idiomas. Existem vários níveis de "carregamento" do código (diagrama abaixo):


  1. Um tokenizer é uma classe modular para dividir o código-fonte em tokens (as unidades semânticas mínimas da linguagem). Cada tipo de token está associado a um número - seu tipo. Por que modular? Como as partes do tokenizer que verificam se alguma parte do código-fonte é um token de um determinado tipo são isoladas do tokenizer e baixadas de fora (a partir do segundo parágrafo).
  2. O complemento do tokenizer é uma classe que define a aparência de cada tipo de token no SL; no nível mais baixo usa um tokenizer. Também aqui está a triagem de tokens de espaço e a omissão de comentários de linha única. A saída fornece um fluxo limpo de tokens, usado em ...
  3. ... um analisador (também é modular), que produz uma árvore de sintaxe abstrata na saída. Modular - porque por si só pode analisar cenas e ações, mas não sabe como analisar operadores. Portanto, os módulos são carregados nele (de fato, ele próprio os carrega no construtor, o que não é muito bom), que pode analisar cada um de seus operadores.

pipeline de análise de linguagem de script


Agora, brevemente sobre os operadores, para que uma idéia da funcionalidade do idioma apareça. Inicialmente, havia 11 operadores, então, no processo de refletir sobre o jogo, alguns deles se fundiram em um, outros mudaram e outros 9 foram adicionados. Aqui está a tabela de resumo:


Nome do Operador:Nota:
load_imageCarrega uma imagem na memória
load_audioCarrega áudio na memória
set_textDefine o texto principal da cena.
set_backgroundDefine o plano de fundo para a cena.
playReproduz áudio (pode reproduzir uma vez ou repetir a reprodução)
showMostra uma mensagem com retorno de chamada (no SL) e uma imagem opcional (chamada após o usuário fechar a mensagem)
tagDefine uma etiqueta (etiqueta). Pode ser considerada como uma variável global com o nome especificado, que não armazena nenhum valor. Isso é útil em diferentes casos: por exemplo, você pode marcar de tal maneira se o jogador encontrou a chave da porta, se ele já estava nesse local etc.
if_tag / if_tag_nOperadores de ramificação que permitem que você faça algo dependendo se a tag correspondente está instalada. else branch é suportado. if_tag é executado se a tag estiver definida, if_tag_n - vice-versa
add_dialogPermite adicionar diálogo à cena. Sobre eles um pouco mais tarde
gotoTransição para outra cena
callChamada de ação personalizada
call_externInvocando uma ação de outra cena.
stop_allPare de reproduzir todos os sons / músicas
show_motionMostra e move a imagem de um ponto para outro no tempo especificado (duração) (com um retorno de chamada opcional chamado quando o movimento termina)
animateAnima uma imagem mostrada anteriormente por show_motion . Nas opções: você pode especificar o tipo de animação - v_motion / h_motion (movimento vertical / horizontal por função 2t33t2), a possibilidade de loop, uma indicação do tempo (duração) durante o qual a animação deve ser reproduzida. É possível transferir um valor numérico (um, já que não há como passar um número variável de argumentos, portanto isso é parcialmente uma muleta) para a animação (para cada animação significa coisas diferentes) e o retorno de chamada opcional (chamado quando a animação é reproduzida).

Operadores para trabalhar com contadores - variáveis ​​inteiras específicas da cena.


Nome do Operador:Nota:
counter_setCriando um contador e inicializando-o com algum valor
counter_addAdicionando um valor a um contador
if_counter <modifier>Capaz de comparar os valores de dois contadores / números / números e um contador. <modifier> é um literal geral e tem o formato eq/gr/ls[_n] , onde eq são iguais, gr é maior que, ls é menor que, ls é menor que, _n é negação (por exemplo, gr_n não é maior). Como você pode ver, tudo foi simplificado o máximo possível.

Havia também o pensamento de introduzir uma return (a funcionalidade correspondente foi adicionada no nível principal do intérprete), mas eu esqueci, e não foi útil.


, , : show_motion (, , 0.01) duration .


, (lookup) ( ): ///, load_audio / load_image / counter_set / add_dialog . , , , , — . . , . , : " scene_coast.dialog_1 " — dialog_1 scene_coast .


SL-, . , , , — . : (-, ), , lookup ', , , . , goto lookup ', .


- — - , , n ( ) . , , n . , .


. :


 add_dialog "regexp" "dialog_name" callback on/off 

, . , : , , , ( ).


, , ( ) ( ) , ( ). : , , , "" "".


sistema de diálogos de trabalho


, ( , )


, "":


 (||((|||) ( )?(||)?)|(||)( )?|  ) 

***


, : — , , — .


, :


3.2


: , — "" ( ). .


SL , - . :


  1. init — , ( , , , ).
  2. first_come — , . , , .
  3. , : come — , ( ).

: init first_come — , .


. : , , init -. , ( ) .


, n , first_come - ( - - ). . , : , , first_come come , come ( ). : , , , .


(, "", " ", " " . .). , , - - . , ( ), .


(, , ). : ? , , . provideState , ; , .


, , , , ( , ), (, , , ).


4.


. 2019- 2018-, , , .


4.1


, , , — . , . ( ), , - , 9 (), - ( , ( , , ) .


, : , , , . , , .


, 25% (5) , : , ; ( animate ), ( call_extern ).


, - ( ), (, , — , "You won").


demo visual novel


4.2


, :


esboços para gráficos


, , - - " ". :


  • (4x2.23''), .
  • : , , — .
  • ////etc.

uso de pincéis de arte


  • , , .

um personagem do romance visual


  • , . , , , . .

5.


( 11 ) 30 40 . 9 4 55 . ( ) 7 41 . — ~4-6 ( 45 ).


: "Darkyen's Time Tracker" JetBrains ( ).
: 2 , — . 45 8 .


: 4777, ( ) — 637.


: cloc .


30 . ( ) : — ~8 , — ~24 , ( ) — ~8 . .


— 232 ( - , WAV).


WAV?

javax.sound.sampled.AudioSystem , WAV AU , WAV.


28 ( 3 ). — 17 /.


- : , . , , " ", " ". (, ), ( ""/"" - ).


?

— , . : . . , , "" : NPC, , (, — ..).


, : , .


— . , : , , , . . , , , , , .


. ( ), :


  • . . .
  • . , .
  • , .

, , .


GitHub .


(assets) "Releases" "v1.0" .

Source: https://habr.com/ru/post/pt483818/


All Articles