
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.
- A escolha da plataforma.
- Arquitetura do mecanismo e sua implementação:
2.1 Declaração do problema.
2.2 Arquitetura e implementação. - Linguagem do script:
3.1 Idioma.
3.2 Intérprete - Desenvolvimento de jogos:
4.1 A história e o desenvolvimento da lógica do jogo.
4.2 Gráficos - 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.
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:

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:
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.initializer
- contém classes nas quais o mecanismo é inicializado e iniciado.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:

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:
- Para depuração.
- 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:

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):
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:
- 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. - 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.
- Processamento de animação.
- Limpando o plano de fundo no buffer de renderização (
BufferedImage
serviu como buffer). - Desenhando imagens.
- Renderização de texto.
- Desenho de campos para entrada.
- A saída do buffer para a tela.
- Manipulando
PostWorkDescriptor
's. - 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).
- 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.

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 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:
Algumas palavras sobre a análise de idiomas. Existem vários níveis de "carregamento" do código (diagrama abaixo):
- 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).
- 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 ...
- ... 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.

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:
Operadores para trabalhar com contadores - variáveis inteiras específicas da cena.
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
, . , : , , , ( ).
, , ( ) ( ) , ( ). : , , , "" "".

, ( , )
, "":
(||((|||) ( )?(||)?)|(||)( )?| )
***
, : — , , — .
, :
3.2
: , — "" ( ). .
SL , - . :
init
— , ( , , , ).first_come
— , . , , .- , :
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").

4.2
, :

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


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" .