Como criar um jogo se você nunca é um artista


Houve momentos na vida de todo programador quando ele sonhava em fazer um jogo interessante. Muitos programadores realizam esses sonhos e até com sucesso, mas isso não é sobre eles. É sobre quem gosta de jogar, que (mesmo sem conhecimento e experiência) tentou criá-los uma vez, inspirado em exemplos de heróis únicos que alcançaram fama mundial (e enormes lucros), mas no fundo entendiam que competir com o guru igrostroya que ele não pode pagar.

E não ...

Pequena introdução


Farei uma reserva imediatamente: nosso objetivo não é ganhar dinheiro - há muitos artigos sobre esse tópico em Habré. Não, vamos fazer um jogo dos sonhos.

Digressão lírica sobre o jogo dos sonhos
Quantas vezes ouvi essa palavra de desenvolvedores individuais e pequenos estúdios. Onde quer que você olhe, todos os novatos igrodelov se apressam em revelar seus sonhos e "visão perfeita" para o mundo e, em seguida, escrevem longos artigos sobre seus esforços heróicos, processo de trabalho, dificuldades financeiras inevitáveis, problemas com editores e geralmente "jogadores-ingratos-cachorros-im-" dê gráfico e moedas e tudo de graça e pague-não-quer-um-jogo-piratas-e-perdemos-lucros-por causa deles-aqui. "

Pessoas, não se deixe enganar. Você não está criando um jogo dos sonhos, mas um jogo que venderá bem - essas são duas coisas diferentes. Os jogadores (e especialmente os sofisticados) não se importam com o seu sonho e não pagarão por ele. Se você deseja lucros - estude tendências, veja o que é popular agora, faça algo único, faça melhor, mais incomum que outros, leia artigos (são muitos), comunique-se com editores - em geral, realize os sonhos dos usuários finais, não os seus.

Se você ainda não fugiu e ainda deseja realizar o jogo dos seus sonhos, desista dos lucros com antecedência. Não venda o seu sonho, compartilhe-o gratuitamente. Dê às pessoas o seu sonho, traga-as para ele, e se o seu sonho vale alguma coisa, você receberá, se não dinheiro, mas amor e reconhecimento. Isso às vezes é muito mais valioso.

Muitas pessoas pensam que os jogos são uma perda de tempo e energia, e que pessoas sérias não devem falar sobre esse assunto. Mas as pessoas reunidas aqui não são sérias, então concordamos apenas em parte - os jogos levam muito tempo se você os jogar. No entanto, o desenvolvimento de jogos, embora demore muitas vezes mais, pode trazer muitos benefícios. Por exemplo, ele permite que você se familiarize com os princípios, abordagens e algoritmos que não são encontrados no desenvolvimento de aplicativos que não sejam de jogos. Ou aprofundar as habilidades de possuir ferramentas (por exemplo, uma linguagem de programação), fazendo algo incomum e emocionante. Por conta própria, posso acrescentar (e muitos concordarão) que o desenvolvimento de jogos (mesmo sem sucesso) é sempre uma experiência especial e incomparável, que você se lembra com apreensão e amor, que quero experimentar para cada desenvolvedor pelo menos uma vez na vida.

Não usaremos mecanismos de jogo, estruturas, bibliotecas de novos fãs - examinaremos a essência da jogabilidade e a sentiremos por dentro. Desistimos de metodologias flexíveis de desenvolvimento (a tarefa é simplificada pela necessidade de organizar o trabalho de apenas uma pessoa). Não gastaremos tempo e energia procurando designers, artistas, compositores e especialistas em som - faremos tudo o que pudermos (mas, ao mesmo tempo, faremos tudo com sabedoria) - se, de repente, tivermos um artista, não faremos muito esforço para fixar a moda. gráficos no quadro acabado). No final, nem sequer estudamos as ferramentas e escolhemos a correta - faremos a que conhecemos e sabemos como usar. Por exemplo, em Java, para que mais tarde, se necessário, transfira-o para Android (ou para uma cafeteira).

Ah !!! Horror! Um pesadelo! Como você pode gastar tempo com essas bobagens! Saia daqui, vou ler algo mais interessante!

Por que fazer isso? Quero dizer, reinventar a roda? Por que não usar um mecanismo de jogo pronto? A resposta é simples: não sabemos nada sobre ele, mas queremos o jogo agora. Imagine a mentalidade do programador médio: "Eu quero fazer um jogo! Haverá carne, explosões e bombeamento, e você pode roubar um korovan , e a trama está bombardeando, e isso nunca aconteceu em nenhum outro lugar! Vou começar a escrever agora! .. E sobre o que? Vamos ver o que é popular entre nós agora ... Sim, X, Y e Z. Vamos pegar o Z, agora todo mundo escreve nele ... ". E começa a estudar o motor. E ele lança a ideia, porque já não há tempo suficiente para isso. Fin. Ou, ok, não desiste, mas sem realmente aprender o mecanismo, ele é usado para o jogo. Bem, se ele tem consciência de não mostrar a ninguém o seu primeiro "ofício". Geralmente não (vá a qualquer loja de aplicativos, veja por si mesmo) - bem, bem, quero lucros, sem forças para suportar. Uma vez que a criação de jogos era um monte de pessoas criativas entusiasmadas. Infelizmente, esse tempo passou irrevogavelmente - agora a principal coisa do jogo não é a alma, mas o modelo de negócios (pelo menos há muito mais conversas sobre isso). Nosso objetivo é simples: vamos fazer jogos com a alma. Portanto, abstraímos da ferramenta (qualquer um fará) e focamos na tarefa.

Então, vamos continuar.
Não vou entrar em detalhes da minha própria experiência amarga, mas direi que um dos principais problemas para um programador no desenvolvimento de jogos é o gráfico. Os programadores geralmente não sabem desenhar (embora haja exceções) e os artistas geralmente não sabem como programar (embora haja exceções). E sem gráficos, você deve admitir, um jogo raro é ignorado. O que fazer?

Existem opções:

1. Desenhe tudo você mesmo em um editor gráfico simples

Screenshots do jogo "Kill Him All", 2003

2. Desenhe tudo sozinho em um vetor

Screenshots do jogo "Raven", 2001


Screenshots do jogo "Inferno", 2002

3. Pergunte a um irmão que também não sabe desenhar (mas o faz um pouco melhor)

Screenshots do jogo "Fucking", 2004

4. Faça o download de algum programa para modelagem 3D e arraste ativos de lá

Screenshots do jogo "Fucking 2. Demo", 2006

5. Em desespero, arrancando cabelos na cabeça


Screenshots do jogo "Fucking", 2004

6. Desenhe tudo sozinho em pseudografia (ASCII)

Screenshots do jogo "Fifa", 2000


Screenshots do jogo "Sumo", 1998

Vamos nos debruçar sobre este último (em parte porque ele não parece tão deprimente quanto o resto). Muitos jogadores inexperientes acreditam que jogos sem gráficos modernos legais não são capazes de conquistar o coração dos jogadores - até o nome do jogo nem os transforma em jogos. Desenvolvedores de obras-primas como ADOM , NetHack e Dwarf Fortress se opõem tacitamente a esses argumentos. A aparência nem sempre é um fator decisivo, o uso do ASCII oferece algumas vantagens interessantes:

  • no processo de desenvolvimento, o programador se concentra na jogabilidade, na mecânica do jogo, no componente da trama e muito mais, sem se distrair com coisas menores;
  • o desenvolvimento de um componente gráfico não leva muito tempo - um protótipo funcional (isto é, uma versão em execução que você pode entender, mas vale a pena continuar) estará pronto muito antes;
  • não há necessidade de aprender estruturas e mecanismos gráficos;
  • seus gráficos não ficarão obsoletos nos cinco anos em que você desenvolverá o jogo;
  • os trabalhadores hardcore poderão avaliar seu produto mesmo em plataformas que não possuem um ambiente gráfico;
  • se tudo for feito corretamente, os gráficos legais poderão ser fixados mais tarde, mais tarde.

A longa introdução acima teve como objetivo ajudar o novato igrodelov a superar medos e preconceitos, parar de se preocupar e ainda tentar fazer algo assim. Você está pronta? Então vamos começar.

Primeiro passo Idéia


Como Ainda não faz ideia?

Desligue o computador, vá comer, caminhar, exercitar-se. Ou dormir, na pior das hipóteses. Inventar um jogo não é lavar janelas - a percepção do processo não vem. Normalmente, a ideia de um jogo nasce repentinamente, inesperadamente, quando você não pensa sobre isso. Se isso acontecer repentinamente, pegue um lápis mais rápido e anote até que a idéia se esvai. Qualquer processo criativo é implementado dessa maneira.

E você pode copiar os jogos de outras pessoas. Bem, copie. Obviamente, não rasgue descaradamente, dizendo em todos os cantos como você é inteligente, mas use a experiência de outras pessoas em seu produto. Quanto depois disso permanecerá especificamente do seu sonho, é uma questão secundária, porque muitas vezes os jogadores têm isso: eles gostam de tudo no jogo, exceto por duas ou três coisas irritantes, mas se isso fosse diferente ... Quem sabe talvez trazer à mente a boa ideia de alguém seja o seu sonho.

Mas seguiremos o caminho simples - suponha que já tenhamos uma idéia e não pensemos nisso por um longo tempo. Como nosso primeiro projeto grandioso, criaremos um clone de um bom jogo da Obsidian - Pathfinder Adventures .

“Que diabos é isso! Alguma mesa?

Como se costuma dizer, pourquoi pas? Parece que já abandonamos preconceitos e, com ousadia, começamos a refinar a idéia. Naturalmente, não vamos clonar o jogo individualmente, mas emprestaremos a mecânica básica. Além disso, a implementação de um jogo cooperativo de tabuleiro baseado em turnos tem suas vantagens:

  • é passo a passo - isso permite que você não se preocupe com temporizadores, sincronização, otimização, FPS e outras coisas tristes;
  • é cooperativo, ou seja, o jogador ou jogadores não competem entre si, mas contra um certo "ambiente" jogando de acordo com regras determinísticas - isso elimina a necessidade de programar a IA ( AI ) - uma das etapas mais difíceis do desenvolvimento do jogo;
  • é significativo - as mesas são geralmente pessoas extravagantes, não jogam nada: dê mecânicas pensadas e jogabilidade interessante - você não sai em uma imagem bonita (dá algo aos amigos, certo?);
  • é com a trama - muitos esportistas eletrônicos não concordam, mas para mim, pessoalmente, o jogo deve contar uma história interessante - como um livro, apenas usando seus meios artísticos especiais.
  • ela é divertida, o que não é para todos - as abordagens descritas podem ser aplicadas a qualquer sonho subsequente, não importa quantos você tenha.

Para quem não conhece as regras, uma breve introdução:
O Pathfinder Adventures é uma versão digital de um jogo de cartas criado com base em um jogo de role-playing (ou melhor, em todo um sistema de role-playing) Pathfinder. Os jogadores (no valor de 1 a 6) escolhem um personagem para si e, junto com ele, partem para uma aventura dividida em vários cenários. Cada personagem tem à sua disposição cartas de vários tipos (como: armas, armaduras, feitiços, aliados, itens etc.), com a ajuda da qual em cada cenário ele deve encontrar e punir brutalmente o Scoundrel - uma carta especial com propriedades especiais.

Cada cenário fornece vários locais ou locais (o número deles depende do número de jogadores) que os jogadores precisam visitar e explorar. Cada local contém um baralho de cartas virado para baixo, que os personagens exploram por sua vez - ou seja, eles abrem a carta superior e tentam vencê-la de acordo com as regras relevantes. Além de cartas inofensivas que reabastecem o baralho do jogador, esses decks também contêm inimigos e obstáculos ruins - eles devem ser derrotados para avançar ainda mais. A carta Scoundrel também se encontra em um dos decks, mas os jogadores não sabem qual - precisa ser encontrada.

Para derrotar as cartas (e adquirir novas), os personagens devem passar no teste de uma de suas características (padrão para RPGs, força, destreza, sabedoria etc.) jogando um dado cujo tamanho é determinado pelo valor da característica correspondente (de d4 a d12), adicionando modificadores (definidos regras e o nível de desenvolvimento do personagem) e jogar para aumentar o efeito das cartas apropriadas da mão. Após a vitória, a carta encontrada é removida do jogo (se for um inimigo) ou reabastece a mão de um jogador (se for um item) e a jogada é direcionada para outro jogador. Ao perder, o personagem geralmente é danificado, fazendo com que ele descarte as cartas de sua mão. Uma mecânica interessante é que a saúde do personagem é determinada pelo número de cartas no seu baralho - assim que o jogador precisar tirar uma carta do baralho, mas elas não estiverem lá, ele morrerá.

O objetivo é, tendo percorrido os mapas de localização, encontrar e derrotar o Scoundrel, tendo anteriormente bloqueado seu caminho para recuar (você pode aprender mais sobre isso e muito mais lendo as regras). Isso precisa ser feito por um tempo, que é a principal dificuldade do jogo. O número de jogadas é estritamente limitado e uma simples enumeração de todas as cartas disponíveis não atinge a meta. Portanto, você deve aplicar vários truques e técnicas inteligentes.

À medida que os cenários são preenchidos, os personagens crescem e se desenvolvem, melhorando suas características e adquirindo novas habilidades úteis. Gerenciar o baralho também é um elemento muito importante do jogo, já que o resultado do cenário (especialmente nas fases posteriores) geralmente depende das cartas selecionadas corretamente (e de muita sorte, mas o que você quer de um jogo com dados?).

Em geral, o jogo é interessante, digno, digno de atenção e, o que é importante para nós, bastante complicado (observe que eu digo "difícil", não no significado de "difícil") para torná-lo interessante para implementar seu clone.

No nosso caso, faremos uma mudança conceitual global - abandonaremos os cartões. Em vez disso, não recusaremos, mas substituiremos os cartões por cubos, ainda de tamanhos e cores diferentes (tecnicamente, não é correto usar seus "cubos", pois existem outras formas além do hexágono correto, mas é incomum chamá-los de "ossos" e é desagradável, mas usar margarida americana é um sinal de mau gosto, então deixe como está). Agora, em vez de decks, os jogadores terão sacolas. E os locais também terão sacolas, das quais os jogadores no processo de pesquisa puxarão cubos arbitrários. A cor do cubo determinará seu tipo e, consequentemente, as regras para passar no teste. As características pessoais do personagem (força, destreza, etc.), como resultado, serão eliminadas, mas novas mecânicas interessantes aparecerão (mais sobre as quais mais tarde).

Vai ser divertido jogar? Eu não tenho idéia, e ninguém pode entender isso até que um protótipo de trabalho esteja pronto. Mas nós não gostamos do jogo, mas do desenvolvimento, certo? Portanto, não deve haver dúvida de sucesso.

Etapa dois Desenho


Ter uma ideia é apenas um terço da história. Agora é importante desenvolver essa idéia. Ou seja, não dê um passeio no parque ou tome um banho de vapor, mas sente-se à mesa, pegue papel com uma caneta (ou abra seu editor de texto favorito) e escreva cuidadosamente um documento de design, trabalhando minuciosamente todos os aspectos da mecânica do jogo. O tempo para isso será um avanço, portanto, não espere concluir a redação de uma só vez. E nem espere pensar em tudo de uma só vez - à medida que você implementa, verá a necessidade de fazer várias mudanças e mudanças (e algumas vezes retrabalhar algo globalmente), mas alguma base deve estar presente antes do início do processo de desenvolvimento.

Inicialmente, seu documento de design será semelhante a este




E somente depois de lidar com a primeira onda de idéias grandiosas, você assume a cabeça, decide a estrutura do documento e começa a preenchê-lo metodicamente com conteúdo (verificando a cada segundo o que já foi escrito para evitar repetições desnecessárias e especialmente contradições). Gradualmente, passo a passo, você obtém algo significativo e conciso, como este .

Ao descrever o design, escolha o idioma em que é mais fácil expressar seus pensamentos, especialmente se você trabalha sozinho. Se você precisar envolver desenvolvedores de terceiros no projeto, verifique se eles entendem toda a bobagem criativa que está acontecendo em sua cabeça.

Para continuar, eu recomendo fortemente que você leia o documento citado pelo menos na diagonal, porque no futuro vou me referir aos termos e conceitos apresentados lá, sem me deter em detalhes sobre sua interpretação.

“Autor, mate-se contra a parede. Muitas cartas.

Etapa três Modelagem


Ou seja, com o mesmo design, apenas mais detalhado.
Eu sei que muitos já estão ansiosos para abrir um IDE e começar a codificar, mas seja paciente um pouco mais. Quando as idéias sobrecarregam nossas cabeças, parece-nos que precisamos tocar o teclado e nossas mãos correm para grandes distâncias - antes que o café tenha tempo de ferver no fogão, quando a versão de trabalho do aplicativo estiver pronta ... para ir ao lixo. Para não reescrever a mesma coisa muitas vezes (e especialmente para garantir, após três horas de desenvolvimento, que o layout não esteja funcionando e precise ser reiniciado novamente), sugiro que você pense primeiro (e documente) na estrutura principal do aplicativo.

Como nós, como desenvolvedores, estamos familiarizados com a programação orientada a objetos (OOP), usaremos seus princípios em nosso projeto. Mas para OOP, não há nada mais esperado do que iniciar o desenvolvimento com vários diagramas UML chatos. (Você não sabe o que é UML ? Quase esqueci também, mas vou lembrar com prazer - apenas para mostrar que programador sou eu, hehe.)

Vamos começar com o diagrama de casos de uso. Iremos descrever as maneiras pelas quais nosso usuário (jogador) interage com o futuro sistema:

Casos de uso


"Uh ... o que é isso tudo?"

Brincadeira, brincadeira ... e, talvez, pare de brincar - isso é um assunto sério (um sonho, afinal). No diagrama de casos de uso, é necessário exibir as possibilidades que o sistema oferece ao usuário. Em detalhes. Mas aconteceu historicamente que esse tipo particular de diagramas é o pior para mim - aparentemente, a paciência não é suficiente. E você não precisa me olhar assim - não estamos na universidade protegendo o diploma, mas gostamos do processo de trabalho. E para esse processo, os casos de uso não são tão importantes. É muito mais importante dividir corretamente o aplicativo em módulos independentes, ou seja, implementar o jogo de forma que os recursos da interface visual não afetem a mecânica do jogo e que o componente gráfico possa ser facilmente alterado, se desejado.

Este ponto pode ser detalhado no seguinte diagrama de componentes:

Componentes do sistema


Aqui já identificamos subsistemas específicos que fazem parte de nosso aplicativo e, como será mostrado mais adiante, todos serão desenvolvidos independentemente um do outro.

Além disso, no mesmo estágio, descobriremos como será o ciclo principal do jogo (ou melhor, sua parte mais interessante é a que implementa os personagens no script). Para isso, um diagrama de atividades é adequado para nós:

Se você ficar em pé, sente-se


E, finalmente, seria bom apresentar em termos gerais a sequência da interação do usuário final com o mecanismo do jogo por meio de um sistema de entrada e saída.

Enchidos


, . , — , , , , , -, ( , ?).

(class) — . , .

.


, , , . Java, Kotlin, , , ( , ). JVM , , ( , -), Windows, UNIX, SSH- ( — , ). , , .

Bibliotecas (não podemos chegar a lugar nenhum sem elas) escolheremos de acordo com nossos requisitos de plataforma cruzada. Usaremos o Maven como sistema de compilação. Ou Gradle. Ou mesmo assim, Maven, vamos começar com isso. Imediatamente, aconselho a configurar um sistema de controle de versão (qualquer um que você goste mais), para que, depois de muitos anos, seja mais fácil recordar com sentimentos nostálgicos o quão bom era antes. O IDE também escolhe o familiar, favorito e conveniente.

Na verdade, não precisamos de mais nada. Você pode começar a desenvolver.

Quinto passo Criando e configurando um projeto


Se você usa um IDE, a criação de um projeto é trivial. Você só precisa escolher um nome sonoro (por exemplo, Dados ) para nossa futura obra-prima, não se esqueça de ativar o suporte ao Maven nas configurações e escreva os identificadores necessários no arquivo pom.xml :

 <modelVersion>4.0.0</modelVersion> <groupId>my.company</groupId> <artifactId>dice</artifactId> <version>1.0</version> <packaging>jar</packaging> 

Adicione também o suporte ao Kotlin, que está ausente por padrão:

 <dependency> <groupId>org.jetbrains.kotlin</groupId> <artifactId>kotlin-stdlib</artifactId> <version>${kotlin.version}</version> </dependency> 

e algumas configurações nas quais não vamos nos concentrar em detalhes:

 <properties> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <maven.compiler.source>1.8</maven.compiler.source> <maven.compiler.target>1.8</maven.compiler.target> <kotlin.version>1.3.20</kotlin.version> <kotlin.compiler.incremental>true</kotlin.compiler.incremental> </properties> 

Um pouco de informação sobre projetos híbridos
Se você planeja usar Java e Kotlin em seu projeto, além da src/main/kotlin , também terá a pasta src/main/java . Os desenvolvedores do Kotlin afirmam que os arquivos de origem da primeira pasta ( *.kt ) devem ser compilados antes dos arquivos de origem da segunda pasta ( *.java ) e, portanto, recomendamos fortemente que você altere as configurações dos destinos padrão do Maven:

 <build> <plugins> <plugin> <groupId>org.jetbrains.kotlin</groupId> <artifactId>kotlin-maven-plugin</artifactId> <version>${kotlin.version}</version> <executions> <execution> <id>compile</id> <phase>process-sources</phase> <goals> <goal>compile</goal> </goals> <configuration> <sourceDirs> <sourceDir>${project.basedir}/src/main/kotlin</sourceDir> <sourceDir>${project.basedir}/src/main/java</sourceDir> </sourceDirs> </configuration> </execution> <execution> <id>test-compile</id> <goals> <goal>test-compile</goal> </goals> <configuration> <sourceDirs> <sourceDir>${project.basedir}/src/test/kotlin</sourceDir> <sourceDir>${project.basedir}/src/test/java</sourceDir> </sourceDirs> </configuration> </execution> </executions> </plugin> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>3.5.1</version> <executions> <!-- Replacing default-compile --> <execution> <id>default-compile</id> <phase>none</phase> </execution> <!-- Replacing default-testCompile --> <execution> <id>default-testCompile</id> <phase>none</phase> </execution> <execution> <id>java-compile</id> <phase>compile</phase> <goals> <goal>compile</goal> </goals> </execution> <execution> <id>java-test-compile</id> <phase>test-compile</phase> <goals> <goal>testCompile</goal> </goals> </execution> </executions> </plugin> </plugins> </build> 

Não sei dizer o quanto isso é importante - os projetos estão indo muito bem sem essa planilha. Mas por precaução, você será avisado.

Vamos criar três pacotes de uma vez (por que brincar algo?):

  • model - para classes que descrevem objetos do mundo do jogo;
  • game - para classes que implementam jogabilidade;
  • ui - para classes responsáveis ​​pela interação do usuário.

Este último conterá apenas interfaces, cujos métodos serão utilizados para entrada e saída de dados. Armazenaremos implementações específicas em um projeto separado, mas mais sobre isso posteriormente. Enquanto isso, para não borrifar muito, adicionaremos essas classes aqui, lado a lado.

Não tente fazê-lo imediatamente de maneira perfeita: pense nos detalhes dos nomes dos pacotes, interfaces, classes e métodos; prescrever minuciosamente a interação dos objetos entre si - tudo isso mudará e mais de uma dúzia de vezes. À medida que o projeto se desenvolve, muitas coisas parecerão feias, volumosas, ineficazes para você e similares - fique à vontade para alterá-las, pois a refatoração nos IDEs modernos é uma operação muito barata.

Também criaremos uma classe com a função main e estamos prontos para grandes conquistas. Você pode usar o próprio IDE para iniciar, mas como você verá mais adiante, esse método não é adequado para nossos propósitos (o console IDE padrão não pode exibir nossas descobertas gráficas como deveria), portanto, configuraremos o lançamento de fora usando lote (ou shell nos sistemas UNIX) arquivo Mas antes disso, faremos algumas configurações adicionais.

Após a operação do mvn package ser concluída, obtemos a saída do archive JAR com todas as classes compiladas. Primeiro, por padrão, esse arquivo não inclui as dependências necessárias para o projeto funcionar (até o momento não as temos, mas elas certamente aparecerão no futuro). Em segundo lugar, o caminho para a classe principal que contém o método main não está especificado no arquivo de manifesto do archive, portanto, não java -jar dice-1.0.jar iniciar o projeto com o java -jar dice-1.0.jar . Corrija isso adicionando configurações adicionais ao pom.xml :

 <build> <plugins> <plugin> <artifactId>maven-assembly-plugin</artifactId> <version>2.6</version> <executions> <execution> <phase>package</phase> <goals> <goal>single</goal> </goals> </execution> </executions> <configuration> <descriptorRefs> <descriptorRef>jar-with-dependencies</descriptorRef> </descriptorRefs> <archive> <manifest> <mainClass>my.company.dice.MainKt</mainClass> </manifest> </archive> </configuration> </plugin> </plugins> </build> 

Preste atenção ao nome da classe principal. Para funções Kotlin contidas fora das classes (como funções main ), as classes são criadas de qualquer maneira durante a compilação (porque a JVM não sabe nada e não quer saber). O nome desta classe é o nome do arquivo com a adição de Kt . Ou seja, se você nomeou a classe principal Main , ela será compilada no arquivo MainKt.class . É este último que devemos indicar no manifesto do arquivo jar.

Agora, ao criar o projeto, obteremos dois arquivos jar na saída: dice-1.0.jar e dice-1.0-jar-with-dependencies.jar . Estamos interessados ​​no segundo. Vamos escrever um script de lançamento para ele.

dice.bat (para Windows)

 @ECHO OFF rem Compiling call "path_to_maven\mvn.bat" -f "path_to_project\Dice\pom.xml" package if errorlevel 1 echo Project compilation failed! & pause & goto :EOF rem Running java -jar path_to_project\Dice\target\dice-1.0-jar-with-dependencies.jar pause 

dice.sh (para UNIX)

 #!/bin/sh # Compiling mvn -f "path_to_project/Dice/pom.xml" package if [[ "$?" -ne 0 ]] ; then echo 'Project compilation failed!'; exit $rc fi # Running java -jar path_to_project/Dice/target/dice-1.0-jar-with-dependencies.jar 

Observe que, se a compilação falhar, somos forçados a interromper o script. Caso contrário, não será lançada a última harpa, mas o arquivo restante da montagem anterior bem-sucedida (às vezes nem encontraremos a diferença). Freqüentemente, os desenvolvedores usam o comando mvn clean package para excluir todos os arquivos compilados anteriormente, mas, neste caso, todo o processo de compilação sempre será iniciado desde o início (mesmo que o código-fonte não tenha sido alterado), o que levará muito tempo. Mas mal podemos esperar - precisamos fazer um jogo.

Portanto, o projeto inicia bem, mas até agora não faz nada. Não se preocupe, vamos corrigir isso em breve.

Etapa seis Objetos principais


Gradualmente, começaremos a preencher o pacote do model com as classes necessárias para a jogabilidade.

Diagrama de classe


Os cubos são tudo, adicione-os primeiro. Cada dado (uma instância da classe Die ) é caracterizado por seu tipo (cor) e tamanho. Para os tipos de cubo, faremos uma enumeração separada ( Die.Type ), marcaremos o tamanho com um número inteiro de 4 a 12. Também implementamos o método roll() , que produzirá um número arbitrário e uniformemente distribuído a partir do intervalo disponível para o cubo (de 1 ao valor inclusive).

A classe implementa a interface Comparable que os cubos possam ser comparados entre si (útil mais tarde quando exibiremos vários cubos em uma linha ordenada). Cubos maiores serão colocados anteriormente.

 class Die(val type: Type, val size: Int) : Comparable<Die> { enum class Type { PHYSICAL, //Blue SOMATIC, //Green MENTAL, //Purple VERBAL, //Yellow DIVINE, //Cyan WOUND, //Gray ENEMY, //Red VILLAIN, //Orange OBSTACLE, //Brown ALLY //White } fun roll() = (1.. size).random() override fun toString() = "d$size" override fun compareTo(other: Die): Int { return compareValuesBy(this, other, Die::type, { -it.size }) } } 

Para não acumular poeira, os cubos são armazenados em bolsas (cópias da classe Bag ). Só se pode adivinhar o que está acontecendo dentro da bolsa; portanto, não faz sentido usar uma coleção ordenada. Parece ser. Os conjuntos (conjuntos) implementam bem a ideia de que precisamos, mas não se encaixam por dois motivos. Primeiro, ao usá-los, você precisará implementar os métodos equals() e hashCode() , e não está claro como, uma vez que é incorreto comparar os tipos e tamanhos de cubos - qualquer número de cubos idênticos pode ser armazenado em nosso conjunto. Em segundo lugar, puxando o cubo da sacola, esperamos obter não apenas algo não determinístico, mas aleatório, cada vez que for diferente. Portanto, recomendo que você use uma coleção ordenada (lista) e a embaralhe toda vez que adicionar um novo elemento (no método put() ) ou imediatamente antes da emissão (no método draw() ).

O método examine() é adequado para casos em que um jogador cansado da incerteza sacode o conteúdo da sacola sobre a mesa nos corações (preste atenção à classificação) e o método clear() - se os cubos sacudidos não retornarem à sacola.

 open class Bag { protected val dice = LinkedList<Die>() val size get() = dice.size fun put(vararg dice: Die) { dice.forEach(this.dice::addLast) this.dice.shuffle() } fun draw(): Die = dice.pollFirst() fun clear() = dice.clear() fun examine() = dice.sorted().toList() } 

Além de sacos com cubos, você também precisa de pilhas com cubos (instâncias da classe Pile ). No primeiro, os segundos diferem em que seu conteúdo é visível para os jogadores e, portanto, se necessário, remova um cubo da pilha, o jogador pode selecionar uma instância específica de interesse. Implementamos essa ideia usando o método removeDie() .

 class Pile : Bag() { fun removeDie(die: Die) = dice.remove(die) } 

Agora nos voltamos para nossos personagens principais - heróis. Ou seja, caracteres que agora chamaremos de heróis (há um bom motivo para não chamar sua classe com o nome Character em Java). Existem diferentes tipos de caracteres (para classificá-lo em classes, embora seja melhor não usar a palavra class ), mas para o nosso protótipo de trabalho, levaremos apenas dois: Brawler (ou seja, Lutador com ênfase em força e força) e Hunter (também conhecido como Ranger / Ladrão, com ênfase destreza e discrição). A classe do herói determina suas características, habilidades e o conjunto inicial de cubos, mas como será visto mais adiante, os heróis não estarão estritamente ligados às classes e, portanto, suas configurações pessoais podem ser facilmente alteradas em um único local.

Adicionaremos as propriedades necessárias ao herói de acordo com o documento de design: nome, tipo favorito de cubo, limites de cubo, habilidades aprendidas e não estudadas, mão, bolsa e pilha para redefinição. Preste atenção aos recursos de implementação das propriedades da coleção. Em todo o mundo civilizado, é considerado uma má forma fornecer acesso externo (com a ajuda de um getter) a coleções armazenadas dentro do objeto - programadores inescrupulosos poderão alterar o conteúdo dessas coleções sem o conhecimento da classe. Uma maneira de lidar com isso é implementar métodos separados para adicionar e remover elementos, obter seu número e acessar por índice. Você pode implementar o getter, mas ao mesmo tempo retornar não a coleção em si, mas sua cópia imutável - para um pequeno número de elementos, não é particularmente assustador fazer exatamente isso.

 data class Hero(val type: Type) { enum class Type { BRAWLER HUNTER } var name = "" var isAlive = true var favoredDieType: Die.Type = Die.Type.ALLY val hand = Hand(0) val bag: Bag = Bag() val discardPile: Pile = Pile() private val diceLimits = mutableListOf<DiceLimit>() private val skills = mutableListOf<Skill>() private val dormantSkills = mutableListOf<Skill>() fun addDiceLimit(limit: DiceLimit) = diceLimits.add(limit) fun getDiceLimits(): List<DiceLimit> = Collections.unmodifiableList(diceLimits) fun addSkill(skill: Skill) = skills.add(skill) fun getSkills(): List<Skill> = Collections.unmodifiableList(skills) fun addDormantSkill(skill: Skill) = dormantSkills.add(skill) fun getDormantSkills(): List<Skill> = Collections.unmodifiableList(dormantSkills) fun increaseDiceLimit(type: Die.Type) { diceLimits.find { it.type == type }?.let { when { it.current < it.maximal -> it.current++ else -> throw IllegalArgumentException("Already at maximum") } } ?: throw IllegalArgumentException("Incorrect type specified") } fun hideDieFromHand(die: Die) { bag.put(die) hand.removeDie(die) } fun discardDieFromHand(die: Die) { discardPile.put(die) hand.removeDie(die) } fun hasSkill(type: Skill.Type) = skills.any { it.type == type } fun improveSkill(type: Skill.Type) { dormantSkills .find { it.type == type } ?.let { skills.add(it) dormantSkills.remove(it) } skills .find { it.type == type } ?.let { when { it.level < it.maxLevel -> it.level += 1 else -> throw IllegalStateException("Skill already maxed out") } } ?: throw IllegalArgumentException("Skill not found") } } 

A mão do herói (os cubos que ele possui no momento) é descrita por um objeto separado (classe Hand ). A decisão do projeto de manter os cubos aliados separados do braço principal foi uma das primeiras que lhe vieram à mente. No começo, parecia um recurso super legal, mas depois gerou um grande número de problemas e inconvenientes. No entanto, não estamos procurando maneiras fáceis e, portanto, as listas de dice e allies estão ao nosso serviço, com todos os métodos que você precisa adicionar, receber e excluir (alguns deles determinam inteligentemente qual das duas listas acessar). Quando você remove um cubo da sua mão, todos os cubos subsequentes serão movidos para o topo da lista, preenchendo os espaços em branco - no futuro, isso facilitará bastante a pesquisa (não é necessário lidar com situações com null ).

 class Hand(var capacity: Int) { private val dice = LinkedList<Die>() private val allies = LinkedList<Die>() val dieCount get() = dice.size val allyDieCount get() = allies.size fun dieAt(index: Int) = when { (index in 0 until dieCount) -> dice[index] else -> null } fun allyDieAt(index: Int) = when { (index in 0 until allyDieCount) -> allies[index] else -> null } fun addDie(die: Die) = when { die.type == Die.Type.ALLY -> allies.addLast(die) else -> dice.addLast(die) } fun removeDie(die: Die) = when { die.type == Die.Type.ALLY -> allies.remove(die) else -> dice.remove(die) } fun findDieOfType(type: Die.Type): Die? = when (type) { Die.Type.ALLY -> if (allies.isNotEmpty()) allies.first else null else -> dice.firstOrNull { it.type == type } } fun examine(): List<Die> = (dice + allies).sorted() } 

A coleção de objetos da classe DiceLimit define limites para o número de cubos de cada tipo que o herói pode ter no início do script. Não há nada de especial a dizer, determinamos inicialmente os valores máximos e atuais para cada tipo.

 class DiceLimit(val type: Die.Type, val initial: Int, val maximal: Int, var current: Int) 

Mas com habilidades é mais interessante. Cada um deles terá que ser implementado individualmente (sobre o que mais tarde), mas consideraremos apenas dois: Acertar e Atirar (um para cada classe, respectivamente). Habilidades podem ser desenvolvidas (“bombeadas”) do nível inicial ao máximo, o que geralmente afeta os modificadores que são adicionados às jogadas de dados. Isso é maxLevel no level propriedades, maxLevel , modifier1 e modifier2 .

 class Skill(val type: Type) { enum class Type { //Brawler HIT, //Hunter SHOOT, } var level = 1 var maxLevel = 3 var isActive = true var modifier1 = 0 var modifier2 = 0 } 

Preste atenção aos métodos auxiliares da classe Hero , que permitem ocultar ou rolar um dado de sua mão, verifique se o herói possui uma determinada habilidade e também aumente o nível da habilidade aprendida ou aprenda uma nova. Todos eles serão necessários mais cedo ou mais tarde, mas agora não iremos nos aprofundar neles em detalhes.

Por favor, não tenha medo do número de classes que temos que criar. Para um projeto dessa complexidade, várias centenas são comuns. Aqui, como em qualquer ocupação séria - começamos pequenos, aumentamos gradualmente o ritmo, em um mês temos medo do escopo. Não se esqueça, ainda somos um pequeno estúdio de uma pessoa - não somos confrontados com tarefas esmagadoras.

“Algo me cansou. Vou fumar ou algo assim ...

E nós continuaremos.
Os heróis e suas habilidades são descritos, é hora de passar para as forças opostas - a grande e terrível Mecânica de Jogo. Ou melhor, objetos com os quais nossos heróis precisam interagir.

Outro diagrama de classes


Nossos protagonistas valentes serão confrontados com três tipos de cubos e cartas: vilões (classe Villain ), inimigos (classe Enemy ) e obstáculos (classe Obstacle ), unidos sob o termo geral de "ameaças" ( Threat é uma classe abstrata "bloqueada", a lista de seus possíveis herdeiros é estritamente limitado). Cada ameaça tem um conjunto de características distintivas ( Trait ) que descrevem regras especiais de comportamento quando confrontadas com essa ameaça e adicionam variedade à jogabilidade.

 sealed class Threat { var name: String = "" var description: String = "" private val traits = mutableListOf<Trait>() fun addTrait(trait: Trait) = traits.add(trait) fun getTraits(): List<Trait> = traits } class Obstacle(val tier: Int, vararg val dieTypes: Die.Type) : Threat() class Villain : Threat() class Enemy : Threat() enum class Trait { MODIFIER_PLUS_ONE, //Add +1 modifier MODIFIER_PLUS_TWO, //Add +2 modifier } 

Observe que a lista de objetos da classe Trait é definida como mutável ( MutableList ), mas é fornecida como uma interface imutável da List . Embora isso funcione no Kotlin, no entanto, a abordagem é insegura, pois não há nada que impeça a conversão da lista resultante em uma interface mutável e faça várias modificações - é especialmente fácil fazer isso se você acessar a classe a partir do código Java (onde a interface da List é mutável). A maneira mais paranóica de proteger sua coleção é fazer algo assim:

 fun getTraits(): List<Trait> = Collections.unmodifiableList(traits) 

mas não seremos tão escrupulosos ao abordar o problema (você, no entanto, é avisado).

Devido às peculiaridades da mecânica do jogo, a classe Obstacle difere de suas contrapartes na presença de campos adicionais, mas não vamos nos concentrar neles.

Cartas de ameaça (e se você ler atentamente o documento de design, lembre-se de que são cartas) são combinadas nos decks representados pela classe Deck :

 class Deck<E: Threat> { private val cards = LinkedList<E>() val size get() = cards.size fun addToTop(card: E) = cards.addFirst(card) fun addToBottom(card: E) = cards.addLast(card) fun revealTop(): E = cards.first fun drawFromTop(): E = cards.removeFirst() fun shuffle() = cards.shuffle() fun clear() = cards.clear() fun examine() = cards.toList() } 

Não há nada incomum aqui, exceto que a classe é parametrizada e contém uma lista ordenada (ou melhor, uma fila de mão dupla), que pode ser combinada usando o método apropriado. Baralhos de inimigos e obstáculos serão necessários para nós literalmente em um segundo, quando chegarmos à consideração ...

... da classe Location , em que cada instância descreve uma localidade única que nossos heróis terão que visitar como parte do script.

 class Location { var name: String = "" var description: String = "" var isOpen = true var closingDifficulty = 0 lateinit var bag: Bag var villain: Villain? = null lateinit var enemies: Deck<Enemy> lateinit var obstacles: Deck<Obstacle> private val specialRules = mutableListOf<SpecialRule>() fun addSpecialRule(rule: SpecialRule) = specialRules.add(rule) fun getSpecialRules() = specialRules } 

Cada localidade tem um nome, descrição, dificuldade de fechamento e o sinal de "aberto / fechado". Em algum lugar aqui o vilão pode estar à espreita (ou pode não estar à espreita, como resultado, a propriedade do villain pode ser null ). Em cada área, há uma sacola com cubos e um baralho de cartas com ameaças. O terreno também pode ter seus próprios recursos exclusivos de jogo ( SpecialRule ), que, como as propriedades das ameaças, adicionam variedade à jogabilidade. Como você pode ver, estamos lançando as bases para a funcionalidade futura, mesmo que não planejemos implementá-la em um futuro próximo (para o qual, de fato, precisamos do estágio de modelagem).

Por fim, resta implementar os scripts (classe Scenario ):

 class Scenario { var name = "" var description = "" var level = 0 var initialTimer = 0 private val allySkills = mutableListOf<AllySkill>() private val specialRules = mutableListOf<SpecialRule>() fun addAllySkill(skill: AllySkill) = allySkills.add(skill) fun getAllySkills(): List<AllySkill> = Collections.unmodifiableList(allySkills) fun addSpecialRule(rule: SpecialRule) = specialRules.add(rule) fun getSpecialRules(): List<SpecialRule> = Collections.unmodifiableList(specialRules) } 

Cada cenário é caracterizado pelo nível e valor inicial do cronômetro. Similar ao que foi visto anteriormente, regras especiais ( specialRules ) e habilidades dos aliados são definidas (não consideraremos a consideração). Você pode pensar que o script também deve conter uma lista de locais (objetos da classe Location ) e, logicamente, é realmente isso. Mas, como será visto mais adiante, não usaremos essa conexão em nenhum lugar e ela não oferece nenhuma vantagem técnica.

Lembro que todas as classes consideradas até agora estão contidas no pacote do model - quando criança, em antecipação a uma batalha épica de brinquedos, colocamos soldados na superfície da mesa.E agora, depois de alguns momentos dolorosos, ao sinal do comandante-em-chefe, vamos nos apressar para a batalha, juntando nossos brinquedos e aproveitando as consequências da jogabilidade. Mas antes disso, um pouco sobre o arranjo em si.

"Bem, muuuito ..."

Sétimo passo. Padrões e Geradores


Vamos imaginar por um segundo qual será o processo de geração de qualquer um dos objetos considerados anteriormente, por exemplo, localização (terreno). Precisamos criar uma instância da classe Location, inicializar seus campos com valores e, portanto, para cada localidade que queremos usar no jogo. Mas espere: cada local deve ter uma sacola, que também precisa ser gerada. E os sacos têm cubos - também são instâncias da classe correspondente ( Die). Não estou falando sobre inimigos e obstáculos - eles geralmente precisam ser coletados em decks. E o vilão não determina o terreno em si, mas as características do cenário localizam um nível acima. Bem, você entendeu. O código fonte para o acima pode ser assim:

 val location = Location().apply { name = "Some location" description = "Some description" isOpen = true closingDifficulty = 4 bag = Bag().apply { put(Die(Die.Type.PHYSICAL, 4)) put(Die(Die.Type.SOMATIC, 4)) put(Die(Die.Type.MENTAL, 4)) put(Die(Die.Type.ENEMY, 6)) put(Die(Die.Type.OBSTACLE, 6)) put(Die(Die.Type.VILLAIN, 6)) } villain = Villain().apply { name = "Some villain" description = "Some description" addTrait(Trait.MODIFIER_PLUS_ONE) } enemies = Deck<Enemy>().apply { addToTop(Enemy().apply { name = "Some enemy" description = "Some description" }) addToTop(Enemy().apply { name = "Other enemy" description = "Some description" }) shuffle() } obstacles = Deck<Obstacle>().apply { addToTop(Obstacle(1, Die.Type.PHYSICAL, Die.Type.VERBAL).apply { name = "Some obstacle" description = "Some Description" }) } } 

Isso também se deve à linguagem e ao design Kotlin apply{}- em Java, o código seria duas vezes mais volumoso. Além disso, haverá muitos lugares, como dissemos, e além deles também existem cenários, aventuras e heróis com suas habilidades e características - em geral, há algo para o designer de jogos fazer.

Mas o designer do jogo não escreverá código e é inconveniente recompilar o projeto com a menor mudança no mundo do jogo. Aqui, qualquer programador competente objetará que as descrições dos objetos do código da classe sejam separadas - idealmente, para que instâncias do último sejam geradas dinamicamente com base no primeiro, conforme necessário, semelhante à forma como uma peça é feita a partir de uma planta de desenho. Também implementamos esses desenhos, apenas os chamamos de modelos e os representamos como instâncias de uma classe especial. Tendo esses padrões, um código de programa especial (gerador) criará os objetos finais a partir do modelo descrito anteriormente.

O processo de gerar um objeto a partir de um modelo


Assim, para cada classe de nossos objetos, duas novas entidades devem ser definidas: a interface do modelo e a classe do gerador. E como uma quantidade decente de objetos se acumulou, também haverá várias entidades ... indecentes:

Diagrama de classe


Respire fundo, ouça com atenção e não se distraia. Primeiro, o diagrama não mostra todos os objetos do mundo do jogo, mas apenas os principais, dos quais você não pode prescindir. Em segundo lugar, para não sobrecarregar o circuito com detalhes desnecessários, algumas das conexões já mencionadas anteriormente em outros diagramas foram omitidas.

Vamos começar com algo simples - gerar cubos. Como? você diz. - Não somos construtores suficientes? Sim, esse é o tipo e tamanho ". Não, eu vou responder, não o suficiente. De fato, em muitos casos (leia as regras), os cubos devem ser gerados arbitrariamente em uma quantidade arbitrária (por exemplo: “de um a três cubos de azul ou verde”). Além disso, o tamanho deve ser selecionado dependendo do nível de complexidade do script. Portanto, apresentamos uma interface especial DieTypeFilter.

 interface DieTypeFilter { fun test(type: Die.Type): Boolean } 

Diferentes implementações dessa interface verificarão se o tipo de cubo corresponde a diferentes conjuntos de regras (qualquer um que venha à mente). Por exemplo, se o tipo corresponde a um valor estritamente especificado ("azul") ou a um intervalo de valores ("azul, amarelo ou verde"); ou, inversamente, corresponde a qualquer outro tipo que não o fornecido ("se não fosse branco em nenhum caso" - qualquer coisa, apenas não isso). Mesmo que não esteja claro com antecedência quais implementações específicas são necessárias, isso não importa - elas podem ser adicionadas mais tarde, o sistema não interromperá isso (polimorfismo, lembra?).

 class SingleDieTypeFilter(val type: Die.Type): DieTypeFilter { override fun test(type: Die.Type) = (this.type == type) } class InvertedSingleDieTypeFilter(val type: Die.Type): DieTypeFilter { override fun test(type: Die.Type) = (this.type != type) } class MultipleDieTypeFilter(vararg val types: Die.Type): DieTypeFilter { override fun test(type: Die.Type) = (type in types) } class InvertedMultipleDieTypeFilter(vararg val types: Die.Type): DieTypeFilter { override fun test(type: Die.Type) = (type !in types) } 

O tamanho do cubo também será definido arbitrariamente, mas mais sobre isso posteriormente. Enquanto isso, escreveremos um gerador de cubos ( DieGenerator), que, diferentemente do construtor de classes Die, não aceitará o tipo e tamanho explícitos do cubo, mas o filtro e o nível de complexidade.

 private val DISTRIBUTION_LEVEL1 = intArrayOf(4, 4, 4, 4, 6, 6, 6, 6, 8) private val DISTRIBUTION_LEVEL2 = intArrayOf(4, 6, 6, 6, 6, 8, 8, 8, 8, 10) private val DISTRIBUTION_LEVEL3 = intArrayOf(6, 8, 8, 8, 10, 10, 10, 10, 12, 12, 12) private val DISTRIBUTIONS = arrayOf( intArrayOf(4), DISTRIBUTION_LEVEL1, DISTRIBUTION_LEVEL2, DISTRIBUTION_LEVEL3 ) fun getMaxLevel() = DISTRIBUTIONS.size - 1 fun generateDie(filter: DieTypeFilter, level: Int) = Die(generateDieType(filter), generateDieSize(level)) private fun generateDieType(filter: DieTypeFilter): Die.Type { var type: Die.Type do { type = Die.Type.values().random() } while (!filter.test(type)) return type } private fun generateDieSize(level: Int) = DISTRIBUTIONS[if (level < 1 || level > getMaxLevel()) 0 else level].random() 

Em Java, esses métodos seriam estáticos, mas, como estamos lidando com o Kotlin, não precisamos da classe como tal, o que também é válido para os outros geradores discutidos abaixo (no entanto, no nível lógico, ainda usaremos o conceito de classe).

Dois métodos particulares geram separadamente o tipo e o tamanho do cubo - algo interessante pode ser dito sobre cada um. O método generateDieType()pode ser direcionado para um loop infinito passando um filtro de entrada com

 override fun test(filter: DieTypeFilter) = false 

( , , ). generateDieSize() , , ( ). , Dice , ( , ). , , . - ( ), , .

E como estamos falando de malas, desenvolveremos um modelo para elas. Ao contrário de seus companheiros, este modelo ( BagTemplate) será uma classe específica. Ele contém outros modelos - cada um deles descreve as regras (ou Plan) pelas quais um ou mais cubos (lembra-se dos requisitos feitos anteriormente?) São adicionados ao saco.

 class BagTemplate { class Plan(val minQuantity: Int, val maxQuantity: Int, val filter: DieTypeFilter) val plans = mutableListOf<Plan>() fun addPlan(minQuantity: Int, maxQuantity: Int, filter: DieTypeFilter) { plans.add(Plan(minQuantity, maxQuantity, filter)) } } 

Cada plano define um padrão para o tipo de cubos, bem como o número (mínimo e máximo) de cubos que satisfazem esse padrão. Graças a essa abordagem, você pode gerar sacolas de acordo com regras bizarras (e novamente choro amargamente pela velhice, porque meu vizinho se recusa categoricamente a me ajudar). Algo assim:

 private fun realizePlan(plan: BagTemplate.Plan, level: Int): Array<Die> { val count = (plan.minQuantity..plan.maxQuantity).shuffled().last() return (1..count).map { generateDie(plan.filter, level) }.toTypedArray() } fun generateBag(template: BagTemplate, level: Int): Bag { return template.plans.asSequence() .map { realizePlan(it, level) } .fold(Bag()) { b, d -> b.put(*d); b } } } 

Se você, assim como eu, está cansado de todo esse funcionalismo, aperte-se - isso só vai piorar. Porém, diferentemente de muitos tutoriais indistintos na Internet, temos a oportunidade de estudar o uso de vários métodos inteligentes em relação a uma área de assunto real e compreensível.

Por si só, as malas não estarão no campo - você precisa entregá-las aos heróis e locais. Vamos começar com o último.

 interface LocationTemplate { val name: String val description: String val bagTemplate: BagTemplate val basicClosingDifficulty: Int val enemyCardsCount: Int val obstacleCardsCount: Int val enemyCardPool: Collection<EnemyTemplate> val obstacleCardPool: Collection<ObstacleTemplate> val specialRules: List<SpecialRule> } 

Na linguagem Kotlin, em vez de métodos, get()você pode usar propriedades da interface - isso é muito mais conciso. Já estamos familiarizados com o modelo de bolsa, considere os métodos restantes. A propriedade basicClosingDifficultydefinirá a complexidade básica da verificação para fechar o terreno. A palavra "básico" aqui significa apenas que a complexidade final dependerá do nível do cenário e não é clara nesta fase. Além disso, precisamos definir padrões para inimigos e obstáculos (e vilões ao mesmo tempo). Além disso, da variedade de inimigos e obstáculos descritos no modelo, nem todos serão usados, mas apenas um número limitado (para aumentar o valor de repetição). Observe que as regras especiais ( SpecialRule) da área são implementadas por uma enumeração simples ( enum class) e, portanto, não requerem um modelo separado.

 interface EnemyTemplate { val name: String val description: String val traits: List<Trait> } interface ObstacleTemplate { val name: String val description: String val tier: Int val dieTypes: Array<Die.Type> val traits: List<Trait> } interface VillainTemplate { val name: String val description: String val traits: List<Trait> } 

E deixe o gerador criar não apenas objetos individuais, mas também decks inteiros com eles.

 fun generateVillain(template: VillainTemplate) = Villain().apply { name = template.name description = template.description template.traits.forEach { addTrait(it) } } fun generateEnemy(template: EnemyTemplate) = Enemy().apply { name = template.name description = template.description template.traits.forEach { addTrait(it) } } fun generateObstacle(template: ObstacleTemplate) = Obstacle(template.tier, *template.dieTypes).apply { name = template.name description = template.description template.traits.forEach { addTrait(it) } } fun generateEnemyDeck(types: Collection<EnemyTemplate>, limit: Int?): Deck<Enemy> { val deck = types .map { generateEnemy(it) } .shuffled() .fold(Deck<Enemy>()) { d, c -> d.addToTop(c); d } limit?.let { while (deck.size > it) deck.drawFromTop() } return deck } fun generateObstacleDeck(templates: Collection<ObstacleTemplate>, limit: Int?): Deck<Obstacle> { val deck = templates .map { generateObstacle(it) } .shuffled() .fold(Deck<Obstacle>()) { d, c -> d.addToTop(c); d } limit?.let { while (deck.size > it) deck.drawFromTop() } return deck } 

Se houver mais cartas no baralho do que precisamos (parâmetro limit), as removeremos de lá. Sendo capaz de gerar sacolas com cubos e baralhos de cartas, finalmente podemos criar terrenos:

 fun generateLocation(template: LocationTemplate, level: Int) = Location().apply { name = template.name description = template.description bag = generateBag(template.bagTemplate, level) closingDifficulty = template.basicClosingDifficulty + level * 2 enemies = generateEnemyDeck(template.enemyCardPool, template.enemyCardsCount) obstacles = generateObstacleDeck(template.obstacleCardPool, template.obstacleCardsCount) template.specialRules.forEach { addSpecialRule(it) } } 

O terreno que definimos explicitamente no código no início do capítulo agora terá uma aparência completamente diferente:

 class SomeLocationTemplate: LocationTemplate { override val name = "Some location" override val description = "Some description" override val bagTemplate = BagTemplate().apply { addPlan(1, 1, SingleDieTypeFilter(Die.Type.PHYSICAL)) addPlan(1, 1, SingleDieTypeFilter(Die.Type.SOMATIC)) addPlan(1, 2, SingleDieTypeFilter(Die.Type.MENTAL)) addPlan(2, 2, MultipleDieTypeFilter(Die.Type.ENEMY, Die.Type.OBSTACLE)) } override val basicClosingDifficulty = 2 override val enemyCardsCount = 2 override val obstacleCardsCount = 1 override val enemyCardPool = listOf( SomeEnemyTemplate(), OtherEnemyTemplate() ) override val obstacleCardPool = listOf( SomeObstacleTemplate() ) override val specialRules = emptyList<SpecialRule>() } class SomeEnemyTemplate: EnemyTemplate { override val name = "Some enemy" override val description = "Some description" override val traits = emptyList<Trait>() } class OtherEnemyTemplate: EnemyTemplate { override val name = "Other enemy" override val description = "Some description" override val traits = emptyList<Trait>() } class SomeObstacleTemplate: ObstacleTemplate { override val name = "Some obstacle" override val description = "Some description" override val traits = emptyList<Trait>() override val tier = 1 override val dieTypes = arrayOf( Die.Type.PHYSICAL, Die.Type.VERBAL ) } val location = generateLocation(SomeLocationTemplate(), 1) 

A geração do cenário ocorrerá de maneira semelhante.

 interface ScenarioTemplate { val name: String val description: String val initialTimer: Int val staticLocations: List<LocationTemplate> val dynamicLocationsPool: List<LocationTemplate> val villains: List<VillainTemplate> val specialRules: List<SpecialRule> fun calculateDynamicLocationsCount(numberOfHeroes: Int) = numberOfHeroes + 2 } 

De acordo com as regras, o número de locais gerados dinamicamente depende do número de heróis. A interface define uma função de cálculo padrão, que, se desejado, pode ser redefinida em implementações específicas. Em conexão com esse requisito, o gerador de cenários também gerará terreno para esses cenários - no mesmo local os vilões serão distribuídos aleatoriamente entre as localidades.

 fun generateScenario(template: ScenarioTemplate, level: Int) = Scenario().apply { name =template.name description = template.description this.level = level initialTimer = template.initialTimer template.specialRules.forEach { addSpecialRule(it) } } fun generateLocations(template: ScenarioTemplate, level: Int, numberOfHeroes: Int): List<Location> { val locations = template.staticLocations.map { generateLocation(it, level) } + template.dynamicLocationsPool .map { generateLocation(it, level) } .shuffled() .take(template.calculateDynamicLocationsCount(numberOfHeroes)) val villains = template.villains .map(::generateVillain) .shuffled() locations.forEachIndexed { index, location -> if (index < villains.size) { location.villain = villains[index] location.bag.put(generateDie(SingleDieTypeFilter(Die.Type.VILLAIN), level)) } } return locations } 

Muitos leitores atentos objetam que os modelos precisam ser armazenados não no código fonte das classes, mas em alguns arquivos de texto (scripts), para que mesmo aqueles longe da programação possam criá-los e mantê-los. Concordo, tiro o chapéu, mas não aspiro cinzas na cabeça - pois um não interfere no outro. Se desejar, basta definir uma implementação especial do modelo, cujos valores de propriedade serão carregados de um arquivo externo. O processo de geração não mudará nem um pouco disso.

Bem, parece que eles não esqueceram nada ... Ah, sim, heróis - eles também precisam ser gerados, o que significa que também precisam de seus próprios modelos. Aqui estão alguns, por exemplo:

 interface HeroTemplate { val type: Hero.Type val initialHandCapacity: Int val favoredDieType: Die.Type val initialDice: Collection<Die> val initialSkills: List<SkillTemplate> val dormantSkills: List<SkillTemplate> fun getDiceCount(type: Die.Type): Pair<Int, Int>? } 

E imediatamente notamos duas esquisitices. Em primeiro lugar, não usamos modelos para gerar sacolas e cubos neles. Porque () — . -, getDiceCount() — ??? , DiceLimit , . , . :

 class BrawlerHeroTemplate : HeroTemplate { override val type = Hero.Type.BRAWLER override val favoredDieType = PHYSICAL override val initialHandCapacity = 4 override val initialDice = listOf( Die(PHYSICAL, 6), Die(PHYSICAL, 6), Die(PHYSICAL, 4), Die(PHYSICAL, 4), Die(PHYSICAL, 4), Die(PHYSICAL, 4), Die(PHYSICAL, 4), Die(PHYSICAL, 4), Die(SOMATIC, 6), Die(SOMATIC, 4), Die(SOMATIC, 4), Die(SOMATIC, 4), Die(MENTAL, 4), Die(VERBAL, 4), Die(VERBAL, 4) ) override fun getDiceCount(type: Die.Type) = when (type) { PHYSICAL -> 8 to 12 SOMATIC -> 4 to 7 MENTAL -> 1 to 2 VERBAL -> 2 to 4 else -> null } override val initialSkills = listOf( HitSkillTemplate() ) override val dormantSkills = listOf<SkillTemplate>() } class HunterHeroTemplate : HeroTemplate { override val type = Hero.Type.HUNTER override val favoredDieType = SOMATIC override val initialHandCapacity = 5 override val initialDice = listOf( Die(PHYSICAL, 4), Die(PHYSICAL, 4), Die(PHYSICAL, 4), Die(SOMATIC, 6), Die(SOMATIC, 6), Die(SOMATIC, 4), Die(SOMATIC, 4), Die(SOMATIC, 4), Die(SOMATIC, 4), Die(SOMATIC, 4), Die(MENTAL, 6), Die(MENTAL, 4), Die(MENTAL, 4), Die(MENTAL, 4), Die(VERBAL, 4) ) override fun getDiceCount(type: Die.Type) = when (type) { PHYSICAL -> 3 to 5 SOMATIC -> 7 to 11 MENTAL -> 4 to 7 VERBAL -> 1 to 2 else -> null } override val initialSkills = listOf( ShootSkillTemplate() ) override val dormantSkills = listOf<SkillTemplate>() } 

, .

 interface SkillTemplate { val type: Skill.Type val maxLevel: Int val modifier1: Int val modifier2: Int val isActive get() = true } class HitSkillTemplate : SkillTemplate { override val type = Skill.Type.HIT override val maxLevel = 3 override val modifier1 = +1 override val modifier2 = +3 } class ShootSkillTemplate : SkillTemplate { override val type = Skill.Type.SHOOT override val maxLevel = 3 override val modifier1 = +0 override val modifier2 = +2 } 

, , , . , — . , , . , .

 fun generateSkill(template: SkillTemplate, initialLevel: Int = 1): Skill { val skill = Skill(template.type) skill.isActive = template.isActive skill.level = initialLevel skill.maxLevel = template.maxLevel skill.modifier1 = template.modifier1 skill.modifier2 = template.modifier2 return skill } fun generateHero(type: Hero.Type, name: String = ""): Hero { val template = when (type) { BRAWLER -> BrawlerHeroTemplate() HUNTER -> HunterHeroTemplate() } val hero = Hero(type) hero.name = name hero.isAlive = true hero.favoredDieType = template.favoredDieType hero.hand.capacity = template.initialHandCapacity template.initialDice.forEach { hero.bag.put(it) } for ((t, l) in Die.Type.values().map { it to template.getDiceCount(it) }) { l?.let { hero.addDiceLimit(DiceLimit(t, it.first, it.second, it.first)) } } template.initialSkills .map { generateSkill(it) } .forEach { hero.addSkill(it) } template.dormantSkills .map { generateSkill(it, 0) } .forEach { hero.addDormantSkill(it) } return hero } 

Apenas alguns momentos são impressionantes. Primeiramente, o próprio método de geração seleciona o modelo desejado, dependendo da classe do herói. Em segundo lugar, não é necessário especificar um nome imediatamente (às vezes no estágio de geração ainda não o saberemos). Em terceiro lugar, Kotlin trouxe uma quantidade sem precedentes de açúcar sintático, que alguns desenvolvedores abusam irracionalmente. E nem um pouco envergonhado.

Passo Oito. Ciclo de jogo


Finalmente, chegamos ao mais interessante - a implementação do ciclo do jogo. Em termos simples, eles começaram a "fazer o jogo". Muitos desenvolvedores iniciantes geralmente iniciam precisamente a partir desse estágio, além da criação de jogos, tudo o mais. Especialmente todos os tipos de pequenos esquemas sem sentido para desenhar, pfff ... Mas não vamos nos apressar (ainda está longe da manhã) e, portanto, um pouco mais de modelagem. Sim de novo.

Gráfico de atividades


, , . , ( ) . (, ) — ? , - . , — , . , , . , , . . — , , ( — , — dos seus sonhos).

Portanto, a primeira coisa a fazer é decidir de quais objetos precisamos.

Heróis O script. Locais.
Já revisamos o processo de sua criação - não o repetiremos. Apenas observamos o padrão de terreno que usaremos em nosso pequeno exemplo.

 class TestLocationTemplate : LocationTemplate { override val name = "Test" override val description = "Some Description" override val basicClosingDifficulty = 0 override val enemyCardsCount = 0 override val obstacleCardsCount = 0 override val bagTemplate = BagTemplate().apply { addPlan(2, 2, SingleDieTypeFilter(Die.Type.PHYSICAL)) addPlan(2, 2, SingleDieTypeFilter(Die.Type.SOMATIC)) addPlan(2, 2, SingleDieTypeFilter(Die.Type.MENTAL)) addPlan(2, 2, SingleDieTypeFilter(Die.Type.VERBAL)) addPlan(2, 2, SingleDieTypeFilter(Die.Type.DIVINE)) } override val enemyCardPool = emptyList<EnemyTemplate>() override val obstacleCardPool = emptyList<ObstacleTemplate>() override val specialRules = emptyList<SpecialRule>() } 

Como você pode ver, na bolsa existem apenas cubos "positivos" - azul, verde, roxo, amarelo e azul. Não há inimigos e obstáculos na área, vilões e feridas não são encontradas. Também não há regras especiais - sua implementação é muito secundária.

Pilha para cubos retidos.
Ou uma pilha de dissuasão. Como colocamos os cubos azuis na sacola do terreno, eles podem ser usados ​​em cheques e após o uso, mantidos em uma pilha especial. Uma instância da classe é útil para isso Pile.

Modificadores.
Ou seja, os valores numéricos que precisam ser adicionados ou subtraídos do resultado do rolo de matriz. Você pode implementar um modificador global ou um modificador separado para cada cubo. Escolheremos a segunda opção (com mais clareza), portanto, criaremos uma classe simples DiePair.

 class DiePair(val die: Die, var modifier: Int = 0) 

A localização dos caracteres na área.
De uma maneira boa, esse momento precisa ser rastreado usando uma estrutura especial. Por exemplo, mapas da forma em Map<Location, List<Hero>>que cada localidade conterá uma lista de heróis atualmente nela (bem como um método para o oposto - determinar a localidade em que um herói específico está localizado). Se você decidir seguir esse caminho, não esqueça de adicionar Locationmétodos à classe de implementação equals()e hashCode(), espero, não seja necessário explicar o porquê. Não vamos perder tempo com isso, já que a área é apenas uma e os heróis não a deixam em lugar nenhum.

Verificando as mãos do herói.
No processo do jogo, os heróis precisam passar constantemente por verificações (descritas a seguir), ou seja, pegar cubos da mão, jogá-los (adicionar modificadores), agregar os resultados se houver vários cubos (resumir, pegar o máximo / mínimo, média etc.), compará-los com o arremesso outro cubo (um que é removido da bolsa da área) e, dependendo do resultado, execute as seguintes ações. Mas, antes de tudo, é necessário entender se o herói é, em princípio, capaz de passar no teste, isto é, se ele tem os cubos necessários na mão. Para isso, fornecemos uma interface simples HandFilter.

 interface HandFilter { fun test(hand: Hand): Boolean } 

implementações de interface é recebida no braço de entrada do herói (o objecto de classe Hand) e de regresso trueou falsede acordo com os resultados do teste. Para o nosso fragmento do jogo, precisamos de uma única implementação: se um cubo azul, verde, roxo ou amarelo for encontrado, precisamos determinar se a mão do herói tem um cubo da mesma cor.

 class SingleDieHandFilter(private vararg val types: Die.Type) : HandFilter { override fun test(hand: Hand) = (0 until hand.dieCount).mapNotNull { hand.dieAt(it) }.any { it.type in types } || (Die.Type.ALLY in types && hand.allyDieCount > 0) } 

Sim, funcionalismo novamente.

Itens ativos / selecionados.
Agora que garantimos que a mão do herói é adequada para a realização do teste, é necessário que o jogador escolha da mão que dados (ou cubos) com os quais ele passará no teste. Primeiro, você precisa destacar (destacar) as posições apropriadas (nas quais existem cubos do tipo desejado). Em segundo lugar, você precisa marcar de alguma forma os cubos selecionados. Para ambos os requisitos, é adequada uma classe HandMaskque, de fato, contém um conjunto de números inteiros (número de posições selecionadas) e métodos para adicioná-los e removê-los.

 class HandMask { private val positions = mutableSetOf<Int>() private val allyPositions = mutableSetOf<Int>() val positionCount get() = positions.size val allyPositionCount get() = allyPositions.size fun addPosition(position: Int) = positions.add(position) fun removePosition(position: Int) = positions.remove(position) fun addAllyPosition(position: Int) = allyPositions.add(position) fun removeAllyPosition(position: Int) = allyPositions.remove(position) fun checkPosition(position: Int) = position in positions fun checkAllyPosition(position: Int) = position in allyPositions fun switchPosition(position: Int) { if (!removePosition(position)) { addPosition(position) } } fun switchAllyPosition(position: Int) { if (!removeAllyPosition(position)) { addAllyPosition(position) } } fun clear() { positions.clear() allyPositions.clear() } } 

, «» ? - . - , (, , — - ) — .

, ( PileMask ), .

.
Mas não basta "destacar" posições aceitáveis; é importante alterar esse "destaque" no processo de escolha de cubos. Ou seja, se um jogador precisar retirar apenas um dado da mão, ao escolher esse dado, todas as outras posições deverão ficar inacessíveis. Além disso, em cada estágio, é necessário controlar o cumprimento do objetivo do jogador - ou seja, entender se os cubos selecionados são suficientes para passar em um ou outro teste. Uma tarefa tão difícil requer uma instância complexa de uma classe complexa.

 abstract class HandMaskRule(val hand: Hand) { abstract fun checkMask(mask: HandMask): Boolean abstract fun isPositionActive(mask: HandMask, position: Int): Boolean abstract fun isAllyPositionActive(mask: HandMask, position: Int): Boolean fun getCheckedDice(mask: HandMask): List<Die> { return ((0 until hand.dieCount).filter(mask::checkPosition).map(hand::dieAt)) .plus((0 until hand.allyDieCount).filter(mask::checkAllyPosition).map(hand::allyDieAt)) .filterNotNull() } } 

Lógica bastante complicada, entenderei e perdoarei você se essa classe for incompreensível para você. E ainda tenta explicar. As implementações dessa classe sempre armazenam uma referência à mão (objeto Hand) com a qual elas irão lidar. Cada um dos métodos recebe uma máscara ( HandMask), que reflete o estado atual da seleção (quais posições são selecionadas pelo jogador e quais não são). O método checkMask()relata se os cubos selecionados são suficientes para passar no teste. O método isPositionActive()diz se é necessário destacar uma posição específica - se é possível adicionar um cubo nessa posição ao teste (ou remover um cubo que já esteja selecionado). O método isAllyPositionActive()é o mesmo para dados brancos (sim, eu sei, sou um idiota). Bem e o método auxiliargetCheckedDice()simplesmente retorna uma lista de todos os cubos da mão que correspondem à máscara - isso é necessário para pegá-los todos de uma vez, jogá-los na mesa e aproveitar a batida engraçada, com a qual eles se espalham em diferentes direções.

Vamos precisar de duas realizações dessa classe abstrata (surpresa, surpresa!). O primeiro controla o processo de aprovação no teste ao adquirir um novo cubo de um tipo específico (não branco). Como você se lembra, qualquer número de cubos azuis pode ser adicionado a essa verificação.

 class StatDieAcquireHandMaskRule(hand: Hand, private val requiredType: Die.Type) : HandMaskRule(hand) { /** * Define how many dice of specified type are currently checked */ private fun checkedDieCount(mask: HandMask) = (0 until hand.dieCount) .filter(mask::checkPosition) .mapNotNull(hand::dieAt) .count { it.type === requiredType } override fun checkMask(mask: HandMask) = (mask.allyPositionCount == 0 && checkedDieCount(mask) == 1) override fun isPositionActive(mask: HandMask, position: Int) = with(hand.dieAt(position)) { when { mask.checkPosition(position) -> true this == null -> false this.type === Die.Type.DIVINE -> true this.type === requiredType && checkedDieCount(mask) < 1 -> true else -> false } } override fun isAllyPositionActive(mask: HandMask, position: Int) = false } 

A segunda implementação é mais complicada. Ela controla a rolagem no final do turno. Nesse caso, duas opções são possíveis. Se o número de cubos na mão exceder seu tamanho máximo permitido (capacidade), devemos descartar todos os cubos extras mais qualquer número de cubos adicionais (se quisermos). Se o tamanho não for excedido, não será possível redefinir nada (ou redefinir, se desejado). Em nenhum caso os dados cinzentos podem ser descartados.

 class DiscardExtraDiceHandMaskRule(hand: Hand) : HandMaskRule(hand) { private val minDiceToDiscard = if (hand.dieCount > hand.capacity) min(hand.dieCount - hand.woundCount, hand.dieCount - hand.capacity) else 0 private val maxDiceToDiscard = hand.dieCount - hand.woundCount override fun checkMask(mask: HandMask) = (mask.positionCount in minDiceToDiscard..maxDiceToDiscard) && (mask.allyPositionCount in 0..hand.allyDieCount) override fun isPositionActive(mask: HandMask, position: Int) = when { mask.checkPosition(position) -> true hand.dieAt(position) == null -> false hand.dieAt(position)!!.type == Die.Type.WOUND -> false mask.positionCount < maxDiceToDiscard -> true else -> false } override fun isAllyPositionActive(mask: HandMask, position: Int) = hand.allyDieAt(position) != null } 

Nezhdanchik: Handuma propriedade apareceu de repente na classe woundCountque não existia antes. Você pode escrever sua implementação, é fácil. Pratique ao mesmo tempo.

Passando cheques.
Finalmente cheguei a eles. Quando os dados são retirados da mão, é hora de jogá-los. Para cada cubo é necessário considerar: seu tamanho, seus modificadores, o resultado de seu lançamento. Embora apenas um cubo possa ser retirado da sacola de cada vez, vários dados podem ser jogados contra ela, agregando os resultados de seus testes. Em geral, vamos abstrair dos dados e representar as tropas no campo de batalha. Por um lado, temos um inimigo - ele é apenas um, mas é forte e feroz. Por outro lado, um oponente igual em força a ele, mas com apoio. O resultado da batalha será decidido em um curto conflito, o vencedor pode ser apenas um ...

Desculpe, empolgado. Para simular nossa batalha geral, implementamos uma classe especial.

 class DieBattleCheck(val method: Method, opponent: DiePair? = null) { enum class Method { SUM, AVG_UP, AVG_DOWN, MAX, MIN } private inner class Wrap(val pair: DiePair, var roll: Int) private infix fun DiePair.with(roll: Int) = Wrap(this, roll) private val opponent: Wrap? = opponent?.with(0) private val heroics = ArrayList<Wrap>() var isRolled = false var result: Int? = null val heroPairCount get() = heroics.size fun getOpponentPair() = opponent?.pair fun getOpponentResult() = when { isRolled -> opponent?.roll ?: 0 else -> throw IllegalStateException("Not rolled yet") } fun addHeroPair(pair: DiePair) { if (method == Method.SUM && heroics.size > 0) { pair.modifier = 0 } heroics.add(pair with 0) } fun addHeroPair(die: Die, modifier: Int) = addHeroPair(DiePair(die, modifier)) fun clearHeroPairs() = heroics.clear() fun getHeroPairAt(index: Int) = heroics[index].pair fun getHeroResultAt(index: Int) = when { isRolled -> when { (index in 0 until heroics.size) -> heroics[index].roll else -> 0 } else -> throw IllegalStateException("Not rolled yet") } fun roll() { fun roll(wrap: Wrap) { wrap.roll = wrap.pair.die.roll() } isRolled = true opponent?.let { roll(it) } heroics.forEach { roll(it) } } fun calculateResult() { if (!isRolled) { throw IllegalStateException("Not rolled yet") } val opponentResult = opponent?.let { it.roll + it.pair.modifier } ?: 0 val stats = heroics.map { it.roll + it.pair.modifier } val heroResult = when (method) { DieBattleCheck.Method.SUM -> stats.sum() DieBattleCheck.Method.AVG_UP -> ceil(stats.average()).toInt() DieBattleCheck.Method.AVG_DOWN -> floor(stats.average()).toInt() DieBattleCheck.Method.MAX -> stats.max() ?: 0 DieBattleCheck.Method.MIN -> stats.min() ?: 0 } result = heroResult - opponentResult } } 

Como cada cubo pode ter um modificador, armazenaremos dados em objetos DiePair. Parece ser. Na verdade, não, porque além do cubo e do modificador, você também precisa armazenar o resultado de seu lançamento (lembre-se, embora o próprio cubo gere esse valor, ele não o armazena entre suas propriedades). Portanto, envolva cada par em um invólucro ( Wrap). Preste atenção ao método infix with, hehe.

O construtor da classe define o método de agregação (uma instância da enumeração interna Method) e o oponente (que pode não existir). A lista de cubos de heróis é formada usando os métodos apropriados. Ele também fornece vários métodos para envolver os pares no teste e os resultados de seus lances (se houver).

Métodoroll()chama o método com o mesmo nome de cada cubo, salva os resultados intermediários e marca o fato de sua execução com um sinalizador isRolled. Observe que o resultado final do lançamento não é calculado imediatamente - existe um método especial para isso calculateResult(), cujo resultado é gravar o valor final na propriedade result. Por que isso é necessário? Para um efeito dramático. O método roll()será executado várias vezes, sempre nas faces dos cubos diferentes valores serão exibidos (como na vida real). E somente quando os cubos se acalmam em cima da mesa, aprendemos nosso destino como resultado final (a diferença entre os valores dos cubos do herói e os cubos do oponente). Para aliviar o estresse, direi que um resultado de 0 será considerado uma aprovação bem-sucedida do teste.

O estado do mecanismo do jogo.
Objetos sofisticados resolvidos, agora as coisas são mais simples. Não será uma grande descoberta dizer que precisamos controlar o "progresso" atual do mecanismo de jogo, o estágio ou a fase em que ele está localizado. Uma enumeração especial é útil para isso.

 enum class GamePhase { SCENARIO_START, HERO_TURN_START, HERO_TURN_END, LOCATION_BEFORE_EXPLORATION, LOCATION_ENCOUNTER_STAT, LOCATION_ENCOUNTER_DIVINE, LOCATION_AFTER_EXPLORATION, GAME_LOSS } 

Na verdade, existem mais fases, mas selecionamos apenas as que são usadas em nosso exemplo. Para alterar a fase do mecanismo do jogo, usaremos métodos changePhaseX(), onde Xé o valor da lista acima. Nesses métodos, todas as variáveis ​​internas do mecanismo serão reduzidas a valores adequados para o início da fase correspondente, mas mais sobre isso posteriormente.

Mensagens
Manter o estado do mecanismo de jogo não é suficiente. Também é importante que o usuário informe-o de alguma forma - caso contrário, como ele saberá o que está acontecendo na tela? É por isso que precisamos de outra listagem.

 enum class StatusMessage { EMPTY, CHOOSE_DICE_PERFORM_CHECK, END_OF_TURN_DISCARD_EXTRA, END_OF_TURN_DISCARD_OPTIONAL, CHOOSE_ACTION_BEFORE_EXPLORATION, CHOOSE_ACTION_AFTER_EXPLORATION, ENCOUNTER_PHYSICAL, ENCOUNTER_SOMATIC, ENCOUNTER_MENTAL, ENCOUNTER_VERBAL, ENCOUNTER_DIVINE, DIE_ACQUIRE_SUCCESS, DIE_ACQUIRE_FAILURE, GAME_LOSS_OUT_OF_TIME } 

Como você pode ver, todos os estados possíveis do nosso exemplo são descritos pelos valores dessa enumeração. Para cada um deles, é fornecida uma linha de texto, que será exibida na tela (exceto EMPTY- este é um significado especial), mas aprenderemos sobre isso um pouco mais tarde.

Acções
Para comunicação entre o usuário e o mecanismo do jogo, mensagens simples não são suficientes. Também é importante informar a primeira das ações que ele pode executar no momento (pesquisar, ultrapassar os bloqueios, concluir a jogada - tudo bem). Para isso, desenvolveremos uma aula especial.

 class Action( val type: Type, var isEnabled: Boolean = true, val data: Int = 0 ) { enum class Type { NONE, //Blank type CONFIRM, //Confirm some action CANCEL, //Cancel action HAND_POSITION, //Some position in hand HAND_ALLY_POSITION, //Some ally position in hand EXPLORE_LOCATION, //Explore current location FINISH_TURN, //Finish current turn ACQUIRE, //Acquire (DIVINE) die FORFEIT, //Remove die from game HIDE, //Put die into bag DISCARD, //Put die to discard pile } } 

Uma enumeração interna Typedescreve o tipo de ação executada. O campo é isEnablednecessário para exibir ações em um estado inativo. Ou seja, relatar que essa ação geralmente está disponível, mas no momento, por algum motivo, não pode ser executada (essa exibição é muito mais informativa do que quando a ação não é exibida). A propriedade data(necessária para alguns tipos de ações) armazena um valor especial que comunica alguns detalhes adicionais (por exemplo, o índice da posição selecionada pelo usuário ou o número do item selecionado da lista).

KlasActioné a principal "interface" entre o mecanismo do jogo e os sistemas de entrada e saída (sobre os quais abaixo). Como geralmente existem várias ações (caso contrário, por que escolher?), Elas serão combinadas em grupos (listas). Em vez de usar coleções padrão, escreveremos nossa própria coleção estendida.

 class ActionList : Iterable<Action> { private val actions = mutableListOf<Action>() val size get() = actions.size fun add(action: Action): ActionList { actions.add(action) return this } fun add(type: Action.Type, enabled: Boolean = true): ActionList { add(Action(type, enabled)) return this } fun addAll(actions: ActionList): ActionList { actions.forEach { add(it) } return this } fun remove(type: Action.Type): ActionList { actions.removeIf { it.type == type } return this } operator fun get(index: Int) = actions[index] operator fun get(type: Action.Type) = actions.find { it.type == type } override fun iterator(): Iterator<Action> = ActionListIterator() private inner class ActionListIterator : Iterator<Action> { private var position = -1 override fun hasNext() = (actions.size > position + 1) override fun next() = actions[++position] } companion object { val EMPTY get() = ActionList() } } 

A classe contém muitos métodos diferentes para adicionar e remover ações da lista (que podem ser encadeados), além de obter tanto por índice quanto por tipo (observe a “sobrecarga” get()- o operador de colchete é aplicável à nossa lista). A implementação da interface Iteratornos permite fazer várias manipulações de fluxo (funcionalidade, aha) com todos os tipos de classe de merda . Um valor VAZIO também é fornecido para criar rapidamente uma lista vazia.

Telas.
Por fim, outra lista que descreve os vários tipos de conteúdo atualmente sendo exibidos ... Você olha para mim e pisca os olhos, eu sei. Quando comecei a pensar em como descrever com mais clareza essa aula, bati minha cabeça na mesa, porque não conseguia entender nada. Entenda a si mesmo, espero.

 enum class GameScreen { HERO_TURN_START, LOCATION_INTERIOR, GAME_LOSS } 

Selecionados apenas aqueles usados ​​no exemplo. Um método de renderização separado será fornecido para cada um deles ... eu novamente explico inexplicavelmente.

"Display" e "input".
E agora finalmente chegamos ao ponto mais importante - a interação do mecanismo de jogo com o usuário (jogador). Se uma introdução tão longa ainda não o aborreceu, é provável que você se lembre de que concordamos em separar funcionalmente essas duas partes uma da outra. Portanto, em vez de uma implementação específica do sistema de E / S, forneceremos apenas uma interface. Mais precisamente, dois.

Primeira interfaceGameRenderer, projetado para exibir imagens na tela. Lembro que abstraímos de tamanhos de tela, de bibliotecas gráficas específicas etc. Simplesmente enviamos o comando: "desenhe-me isso" - e aqueles de vocês que entenderam nossa conversa arrastada sobre telas já imaginaram que cada uma dessas telas tem seu próprio método dentro da interface.

 interface GameRenderer { fun drawHeroTurnStart(hero: Hero) fun drawLocationInteriorScreen( location: Location, heroesAtLocation: List<Hero>, timer: Int, currentHero: Hero, battleCheck: DieBattleCheck?, encounteredDie: DiePair?, pickedDice: HandMask, activePositions: HandMask, statusMessage: StatusMessage, actions: ActionList ) fun drawGameLoss(message: StatusMessage) } 

Acho que não há necessidade de explicações adicionais aqui - o objetivo de todos os objetos transferidos é discutido em detalhes acima.

Para entrada do usuário, implementamos uma interface diferente - GameInteractor(sim, os scripts de verificação ortográfica sempre enfatizam sempre essa palavra, embora pareça ...). Seus métodos solicitarão ao jogador os comandos necessários para várias situações: selecione uma ação da lista de propostas, selecione um elemento da lista, selecione cubos da mão, apenas pelo menos pressione alguma coisa, etc. Deve-se notar imediatamente que a entrada ocorre de forma síncrona (o jogo é passo a passo), ou seja, a execução do loop do jogo é suspensa até que o usuário responda à solicitação.

 interface GameInteractor{ fun anyInput() fun pickAction(list: ActionList): Action fun pickDiceFromHand(activePositions: HandMask, actions: ActionList): Action } 

Sobre o último método um pouco mais. Como o nome indica, from convida o usuário a selecionar cubos da mão, fornecendo um objeto HandMask- o número de posições ativas. A execução do método continuará até que alguns deles sejam selecionados - nesse caso, o método retornará uma ação do tipo HAND_POSITION(ou HAND_ALLY_POSITIONmda) com o número da posição selecionada no campo data. Além disso, é possível selecionar outra ação (por exemplo, CONFIRMou CANCEL) do objeto ActionList. As implementações dos métodos de entrada devem distinguir entre situações em que o campo está isEnableddefinido falsee ignorar a entrada do usuário de tais ações.

Classe de mecanismo de jogo.
Examinamos tudo o que é necessário para o trabalho, chegou a hora e o mecanismo para implementar. Crie uma turmaGame com o seguinte conteúdo:

,
 class Game( private val renderer: GameRenderer, private val interactor: GameInteractor, private val scenario: Scenario, private val locations: List<Location>, private val heroes: List<Hero>) { private var timer = 0 private var currentHeroIndex = -1 private lateinit var currentHero: Hero private lateinit var currentLocation: Location private val deterrentPile = Pile() private var encounteredDie: DiePair? = null private var battleCheck: DieBattleCheck? = null private val activeHandPositions = HandMask() private val pickedHandPositions = HandMask() private var phase: GamePhase = GamePhase.SCENARIO_START private var screen = GameScreen.SCENARIO_INTRO private var statusMessage = StatusMessage.EMPTY private var actions: ActionList = ActionList.EMPTY fun start() { if (heroes.isEmpty()) throw IllegalStateException("Heroes list is empty!") if (locations.isEmpty()) throw IllegalStateException("Location list is empty!") heroes.forEach { it.isAlive = true } timer = scenario.initialTimer //Draw initial hand for each hero heroes.forEach(::drawInitialHand) //First hero turn currentHeroIndex = -1 changePhaseHeroTurnStart() processCycle() } private fun drawInitialHand(hero: Hero) { val hand = hero.hand val favoredDie = hero.bag.drawOfType(hero.favoredDieType) hand.addDie(favoredDie!!) refillHeroHand(hero, false) } private fun refillHeroHand(hero: Hero, redrawScreen: Boolean = true) { val hand = hero.hand while (hand.dieCount < hand.capacity && hero.bag.size > 0) { val die = hero.bag.draw() hand.addDie(die) if (redrawScreen) { Audio.playSound(Sound.DIE_DRAW) drawScreen() Thread.sleep(500) } } } private fun changePhaseHeroTurnEnd() { battleCheck = null encounteredDie = null phase = GamePhase.HERO_TURN_END //Discard extra dice (or optional dice) val hand = currentHero.hand pickedHandPositions.clear() activeHandPositions.clear() val allowCancel = if (hand.dieCount > hand.capacity) { statusMessage = StatusMessage.END_OF_TURN_DISCARD_EXTRA false } else { statusMessage = StatusMessage.END_OF_TURN_DISCARD_OPTIONAL true } val result = pickDiceFromHand(DiscardExtraDiceHandMaskRule(hand), allowCancel) statusMessage = StatusMessage.EMPTY actions = ActionList.EMPTY if (result) { val discardDice = collectPickedDice(hand) val discardAllyDice = collectPickedAllyDice(hand) pickedHandPositions.clear() (discardDice + discardAllyDice).forEach { die -> Audio.playSound(Sound.DIE_DISCARD) currentHero.discardDieFromHand(die) drawScreen() Thread.sleep(500) } } pickedHandPositions.clear() //Replenish hand refillHeroHand(currentHero) changePhaseHeroTurnStart() } private fun changePhaseHeroTurnStart() { phase = GamePhase.HERO_TURN_START screen = GameScreen.HERO_TURN_START //Tick timer timer-- if (timer < 0) { changePhaseGameLost(StatusMessage.GAME_LOSS_OUT_OF_TIME) return } //Pick next hero do { currentHeroIndex = ++currentHeroIndex % heroes.size currentHero = heroes[currentHeroIndex] } while (!currentHero.isAlive) currentLocation = locations[0] //Setup Audio.playMusic(Music.SCENARIO_MUSIC_1) Audio.playSound(Sound.TURN_START) } private fun changePhaseLocationBeforeExploration() { phase = GamePhase.LOCATION_BEFORE_EXPLORATION screen = GameScreen.LOCATION_INTERIOR encounteredDie = null battleCheck = null pickedHandPositions.clear() activeHandPositions.clear() statusMessage = StatusMessage.CHOOSE_ACTION_BEFORE_EXPLORATION actions = ActionList() actions.add(Action.Type.EXPLORE_LOCATION, checkLocationCanBeExplored(currentLocation)) actions.add(Action.Type.FINISH_TURN) } private fun changePhaseLocationEncounterStatDie() { Audio.playSound(Sound.ENCOUNTER_STAT) phase = GamePhase.LOCATION_ENCOUNTER_STAT screen = GameScreen.LOCATION_INTERIOR battleCheck = null pickedHandPositions.clear() activeHandPositions.clear() statusMessage = when (encounteredDie!!.die.type) { Die.Type.PHYSICAL -> StatusMessage.ENCOUNTER_PHYSICAL Die.Type.SOMATIC -> StatusMessage.ENCOUNTER_SOMATIC Die.Type.MENTAL -> StatusMessage.ENCOUNTER_MENTAL Die.Type.VERBAL -> StatusMessage.ENCOUNTER_VERBAL else -> throw AssertionError("Should not happen") } val canAttemptCheck = checkHeroCanAttemptStatCheck(currentHero, encounteredDie!!.die.type) actions = ActionList() actions.add(Action.Type.HIDE, canAttemptCheck) actions.add(Action.Type.DISCARD, canAttemptCheck) actions.add(Action.Type.FORFEIT) } private fun changePhaseLocationEncounterDivineDie() { Audio.playSound(Sound.ENCOUNTER_DIVINE) phase = GamePhase.LOCATION_ENCOUNTER_DIVINE screen = GameScreen.LOCATION_INTERIOR battleCheck = null pickedHandPositions.clear() activeHandPositions.clear() statusMessage = StatusMessage.ENCOUNTER_DIVINE actions = ActionList() actions.add(Action.Type.ACQUIRE, checkHeroCanAcquireDie(currentHero, Die.Type.DIVINE)) actions.add(Action.Type.FORFEIT) } private fun changePhaseLocationAfterExploration() { phase = GamePhase.LOCATION_AFTER_EXPLORATION screen = GameScreen.LOCATION_INTERIOR encounteredDie = null battleCheck = null pickedHandPositions.clear() activeHandPositions.clear() statusMessage = StatusMessage.CHOOSE_ACTION_AFTER_EXPLORATION actions = ActionList() actions.add(Action.Type.FINISH_TURN) } private fun changePhaseGameLost(message: StatusMessage) { Audio.stopMusic() Audio.playSound(Sound.GAME_LOSS) phase = GamePhase.GAME_LOSS screen = GameScreen.GAME_LOSS statusMessage = message } private fun pickDiceFromHand(rule: HandMaskRule, allowCancel: Boolean = true, onEachLoop: (() -> Unit)? = null): Boolean { //Preparations pickedHandPositions.clear() actions = ActionList().add(Action.Type.CONFIRM, false) if (allowCancel) { actions.add(Action.Type.CANCEL) } val hand = rule.hand while (true) { //Recurring action onEachLoop?.invoke() //Define success condition val canProceed = rule.checkMask(pickedHandPositions) actions[Action.Type.CONFIRM]?.isEnabled = canProceed //Prepare active hand commands activeHandPositions.clear() (0 until hand.dieCount) .filter { rule.isPositionActive(pickedHandPositions, it) } .forEach { activeHandPositions.addPosition(it) } (0 until hand.allyDieCount) .filter { rule.isAllyPositionActive(pickedHandPositions, it) } .forEach { activeHandPositions.addAllyPosition(it) } //Draw current phase drawScreen() //Process interaction result val result = interactor.pickDiceFromHand(activeHandPositions, actions) when (result.type) { Action.Type.CONFIRM -> if (canProceed) { activeHandPositions.clear() return true } Action.Type.CANCEL -> if (allowCancel) { activeHandPositions.clear() pickedHandPositions.clear() return false } Action.Type.HAND_POSITION -> { Audio.playSound(Sound.DIE_PICK) pickedHandPositions.switchPosition(result.data) } Action.Type.HAND_ALLY_POSITION -> { Audio.playSound(Sound.DIE_PICK) pickedHandPositions.switchAllyPosition(result.data) } else -> throw AssertionError("Should not happen") } } } private fun collectPickedDice(hand: Hand) = (0 until hand.dieCount) .filter(pickedHandPositions::checkPosition) .mapNotNull(hand::dieAt) private fun collectPickedAllyDice(hand: Hand) = (0 until hand.allyDieCount) .filter(pickedHandPositions::checkAllyPosition) .mapNotNull(hand::allyDieAt) private fun performStatDieAcquireCheck(shouldDiscard: Boolean): Boolean { //Prepare check battleCheck = DieBattleCheck(DieBattleCheck.Method.SUM, encounteredDie) pickedHandPositions.clear() statusMessage = StatusMessage.CHOOSE_DICE_PERFORM_CHECK val hand = currentHero.hand //Try to pick dice from performer's hand if (!pickDiceFromHand(StatDieAcquireHandMaskRule(currentHero.hand, encounteredDie!!.die.type), true) { battleCheck!!.clearHeroPairs() (collectPickedDice(hand) + collectPickedAllyDice(hand)) .map { DiePair(it, if (shouldDiscard) 1 else 0) } .forEach(battleCheck!!::addHeroPair) }) { battleCheck = null pickedHandPositions.clear() return false } //Remove dice from hand collectPickedDice(hand).forEach { hand.removeDie(it) } collectPickedAllyDice(hand).forEach { hand.removeDie(it) } pickedHandPositions.clear() //Perform check Audio.playSound(Sound.BATTLE_CHECK_ROLL) for (i in 0..7) { battleCheck!!.roll() drawScreen() Thread.sleep(100) } battleCheck!!.calculateResult() val result = battleCheck?.result ?: -1 val success = result >= 0 //Process dice which participated in the check (0 until battleCheck!!.heroPairCount) .map(battleCheck!!::getHeroPairAt) .map(DiePair::die) .forEach { d -> if (d.type === Die.Type.DIVINE) { currentHero.hand.removeDie(d) deterrentPile.put(d) } else { if (shouldDiscard) { currentHero.discardDieFromHand(d) } else { currentHero.hideDieFromHand(d) } } } //Show message to user Audio.playSound(if (success) Sound.BATTLE_CHECK_SUCCESS else Sound.BATTLE_CHECK_FAILURE) statusMessage = if (success) StatusMessage.DIE_ACQUIRE_SUCCESS else StatusMessage.DIE_ACQUIRE_FAILURE actions = ActionList.EMPTY drawScreen() interactor.anyInput() //Clean up battleCheck = null //Resolve consequences of the check if (success) { Audio.playSound(Sound.DIE_DRAW) currentHero.hand.addDie(encounteredDie!!.die) } return true } private fun processCycle() { while (true) { drawScreen() when (phase) { GamePhase.HERO_TURN_START -> { interactor.anyInput() changePhaseLocationBeforeExploration() } GamePhase.GAME_LOSS -> { interactor.anyInput() return } GamePhase.LOCATION_BEFORE_EXPLORATION -> when (interactor.pickAction(actions).type) { Action.Type.EXPLORE_LOCATION -> { val die = currentLocation.bag.draw() encounteredDie = DiePair(die, 0) when (die.type) { Die.Type.PHYSICAL, Die.Type.SOMATIC, Die.Type.MENTAL, Die.Type.VERBAL -> changePhaseLocationEncounterStatDie() Die.Type.DIVINE -> changePhaseLocationEncounterDivineDie() else -> TODO("Others") } } Action.Type.FINISH_TURN -> changePhaseHeroTurnEnd() else -> throw AssertionError("Should not happen") } GamePhase.LOCATION_ENCOUNTER_STAT -> { val type = interactor.pickAction(actions).type when (type) { Action.Type.DISCARD, Action.Type.HIDE -> { performStatDieAcquireCheck(type === Action.Type.DISCARD) changePhaseLocationAfterExploration() } Action.Type.FORFEIT -> { Audio.playSound(Sound.DIE_REMOVE) changePhaseLocationAfterExploration() } else -> throw AssertionError("Should not happen") } } GamePhase.LOCATION_ENCOUNTER_DIVINE -> when (interactor.pickAction(actions).type) { Action.Type.ACQUIRE -> { Audio.playSound(Sound.DIE_DRAW) currentHero.hand.addDie(encounteredDie!!.die) changePhaseLocationAfterExploration() } Action.Type.FORFEIT -> { Audio.playSound(Sound.DIE_REMOVE) changePhaseLocationAfterExploration() } else -> throw AssertionError("Should not happen") } GamePhase.LOCATION_AFTER_EXPLORATION -> when (interactor.pickAction(actions).type) { Action.Type.FINISH_TURN -> changePhaseHeroTurnEnd() else -> throw AssertionError("Should not happen") } else -> throw AssertionError("Should not happen") } } } private fun drawScreen() { when (screen) { GameScreen.HERO_TURN_START -> renderer.drawHeroTurnStart(currentHero) GameScreen.LOCATION_INTERIOR -> renderer.drawLocationInteriorScreen(currentLocation, heroes, timer, currentHero, battleCheck, encounteredDie, null, pickedHandPositions, activeHandPositions, statusMessage, actions) GameScreen.GAME_LOSS -> renderer.drawGameLoss(statusMessage) } } private fun checkLocationCanBeExplored(location: Location) = location.isOpen && location.bag.size > 0 private fun checkHeroCanAttemptStatCheck(hero: Hero, type: Die.Type): Boolean { return hero.isAlive && SingleDieHandFilter(type).test(hero.hand) } private fun checkHeroCanAcquireDie(hero: Hero, type: Die.Type): Boolean { if (!hero.isAlive) { return false } return when (type) { Die.Type.ALLY -> hero.hand.allyDieCount < MAX_HAND_ALLY_SIZE else -> hero.hand.dieCount < MAX_HAND_SIZE } } } 

start() — . , , , . , . drawInitialHand() (, , drawOfType() Bag , , ). refillHeroHand() ( redrawScreen ): ( ), , , .

, changePhase, - como já dissemos, eles servem para alterar a fase atual do jogo e estão envolvidos na atribuição dos valores correspondentes das variáveis ​​do jogo. Aqui, uma lista é formada actionsonde as ações características desta fase são adicionadas.

O método de utilidade de pickDiceFromHand()uma forma generalizada está envolvido na seleção de cubos da mão. Um objeto de uma classe familiar HandMaskRuleque define as regras de seleção é passado aqui . Também indica a capacidade de recusar a seleção ( allowCancel), bem como uma função onEachLoopcujo código deve ser chamado sempre que a lista de cubos selecionados é alterada (geralmente uma tela redesenhada). Os cubos selecionados por esse método podem ser montados manualmente, usando os métodos collectPickedDice()e collectPickedAllyDice().

Outro método utilitárioperformStatDieAcquireCheck() . DieBattleCheck . pickDiceFromHand() ( «» DieBattleCheck ). , «» — ( ), . . ( ), ( shouldDiscard = true ), ( shouldDiscard = false ).

processCycle()contém um loop infinito (pergunto sem desmaiar) no qual a tela é desenhada primeiro, depois o usuário é solicitado a inserir e, em seguida, essa entrada é processada - com todas as conseqüências resultantes. O método drawScreen()chama o método de interface desejado GameRenderer(dependendo do valor atual screen), passando os objetos necessários para a entrada.

Além disso, a classe contém vários métodos auxiliares: checkLocationCanBeExplored(), checkHeroCanAttemptStatCheck()e checkHeroCanAcquireDie(). Seus nomes falam por si mesmos, portanto, não iremos nos deter neles em detalhes. E também há chamadas de método de classe Audio, sublinhadas por uma linha ondulada vermelha. Comente-os por enquanto - consideraremos seu objetivo posteriormente.

Quem não entende nada, aqui está um diagrama (para maior clareza, por assim dizer):


Isso é tudo, o jogo está pronto (hehe). Havia pequenas coisas reais sobre eles abaixo.

Etapa nove. Exibir imagem


Então, chegamos ao tópico principal da conversa de hoje - o componente gráfico do aplicativo. Como você se lembra, nossa tarefa é implementar a interface GameRenderere seus três métodos e, como ainda não há nenhum artista talentoso em nossa equipe, faremos isso sozinhos usando pseudográficos. Mas, para começar, seria bom entender o que geralmente esperamos ver na saída. E queremos ver três telas com aproximadamente o seguinte conteúdo:

Tela 1. ID de Turno do Jogador


Tela 2. Informações sobre a área e o herói atual


Tela 3. Mensagem de perda de script


Acho que a maioria já percebeu que as imagens apresentadas são diferentes de tudo o que costumamos ver no console de aplicativos Java e que os recursos habituais prinltn()obviamente não serão suficientes para nós. Eu também gostaria de poder pular para lugares arbitrários na tela e desenhar símbolos em cores diferentes. Os códigos Chip e Dale ANSI

correm em nosso auxílio . , : /, , . , — . — , . - , , Jansi :

 <dependency> <groupId>org.fusesource.jansi</groupId> <artifactId>jansi</artifactId> <version>1.17.1</version> <scope>compile</scope> </dependency> 

. Ansi ( Ansi.ansi() ) , . StringBuilder ' — , . :

  • a() — ;
  • cursor() — ;
  • eraseLine() — - ;
  • eraseScreen() — ;
  • fg(), bg(), fgBright(), bgBright() — — , ;
  • reset() — , .

ConsoleRenderer , . :

 abstract class ConsoleRenderer() { protected lateinit var ansi: Ansi init { AnsiConsole.systemInstall() clearScreen() resetAnsi() } private fun resetAnsi() { ansi = Ansi.ansi() } fun clearScreen() { print(Ansi.ansi().eraseScreen(Ansi.Erase.ALL).cursor(1, 1)) } protected fun render() { print(ansi.toString()) resetAnsi() } } 

O método resetAnsi()cria um novo objeto (vazio) Ansi, que será preenchido com os comandos necessários (movimentação, saída, etc.). Após a conclusão do preenchimento, o objeto gerado é enviado para impressão pelo método render()e a variável é inicializada com um novo objeto. Nada complicado ainda, certo? E se sim, então começaremos a preencher esta classe com outros métodos úteis.

Vamos começar com os tamanhos. O console padrão da maioria dos terminais tem 80x24 de tamanho. Notamos esse fato com duas constantes CONSOLE_WIDTHe CONSOLE_HEIGHT. Não estaremos apegados a valores específicos e tentaremos tornar o design o mais emborrachado possível (como na Web). A numeração das coordenadas começa com uma, a primeira coordenada é uma linha e a segunda é uma coluna. Sabendo tudo isso, escrevemos um método utilitáriodrawHorizontalLine() para preencher a sequência especificada com o caractere especificado.

 protected fun drawHorizontalLine(offsetY: Int, filler: Char) { ansi.cursor(offsetY, 1) (1..CONSOLE_WIDTH).forEach { ansi.a(filler) } //for (i in 1..CONSOLE_WIDTH) { ansi.a(filler) } } 

, a() cursor() , Ansi . , .

for ClosedRange forEach{} — , . , , .

drawBlankLine() , , drawHorizontalLine(offsetY, ' '), apenas com extensão. Às vezes, precisamos deixar a linha vazia não completamente, mas deixar uma linha vertical no início e no final (quadro, sim). O código será algo como isto:

 protected fun drawBlankLine(offsetY: Int, drawBorders: Boolean = true) { ansi.cursor(offsetY, 1) if (drawBorders) { ansi.a('│') (2 until CONSOLE_WIDTH).forEach { ansi.a(' ') } ansi.a('│') } else { ansi.eraseLine(Ansi.Erase.ALL) } } 

Como, você nunca desenhou quadros de pseudografia? Os símbolos podem ser inseridos diretamente no código fonte. Mantenha pressionada a tecla Alt e digite o código de caractere no teclado numérico. Então deixe ir. Os códigos ASCII que precisamos em qualquer codificação são os mesmos, aqui está o conjunto mínimo de cavalheiros:


E então, como em minecraft, as possibilidades são limitadas apenas pelos limites da sua imaginação. E o tamanho da tela.

 protected fun drawCenteredCaption(offsetY: Int, text: String, color: Color, drawBorders: Boolean = true) { val center = (CONSOLE_WIDTH - text.length) / 2 ansi.cursor(offsetY, 1) ansi.a(if (drawBorders) '│' else ' ') (2 until center).forEach { ansi.a(' ') } ansi.color(color).a(text).reset() (text.length + center until CONSOLE_WIDTH).forEach { ansi.a(' ') } ansi.a(if (drawBorders) '│' else ' ') } 

Vamos conversar um pouco sobre as flores. A classe Ansicontém constantes Colorpara oito cores primárias (preto, azul, verde, ciano, vermelho, violeta, amarelo, cinza), que devem ser passadas para a entrada de métodos fg()/bg()para a versão escura ou fgBright()/bgBright()para a clara, o que é terrivelmente inconveniente, pois identificar a cor por assim, um valor não é suficiente para nós - precisamos de pelo menos dois (cor e brilho). Portanto, criaremos nossa lista de constantes e nossos métodos de extensão (além de cores de ligação de mapa para tipos de cubos e classes de heróis):

 protected enum class Color { BLACK, DARK_BLUE, DARK_GREEN, DARK_CYAN, DARK_RED, DARK_MAGENTA, DARK_YELLOW, LIGHT_GRAY, DARK_GRAY, LIGHT_BLUE, LIGHT_GREEN, LIGHT_CYAN, LIGHT_RED, LIGHT_MAGENTA, LIGHT_YELLOW, WHITE } protected fun Ansi.color(color: Color?): Ansi = when (color) { Color.BLACK -> fgBlack() Color.DARK_BLUE -> fgBlue() Color.DARK_GREEN -> fgGreen() Color.DARK_CYAN -> fgCyan() Color.DARK_RED -> fgRed() Color.DARK_MAGENTA -> fgMagenta() Color.DARK_YELLOW -> fgYellow() Color.LIGHT_GRAY -> fg(Ansi.Color.WHITE) Color.DARK_GRAY -> fgBrightBlack() Color.LIGHT_BLUE -> fgBrightBlue() Color.LIGHT_GREEN -> fgBrightGreen() Color.LIGHT_CYAN -> fgBrightCyan() Color.LIGHT_RED -> fgBrightRed() Color.LIGHT_MAGENTA -> fgBrightMagenta() Color.LIGHT_YELLOW -> fgBrightYellow() Color.WHITE -> fgBright(Ansi.Color.WHITE) else -> this } protected fun Ansi.background(color: Color?): Ansi = when (color) { Color.BLACK -> ansi.bg(Ansi.Color.BLACK) Color.DARK_BLUE -> ansi.bg(Ansi.Color.BLUE) Color.DARK_GREEN -> ansi.bgGreen() Color.DARK_CYAN -> ansi.bg(Ansi.Color.CYAN) Color.DARK_RED -> ansi.bgRed() Color.DARK_MAGENTA -> ansi.bgMagenta() Color.DARK_YELLOW -> ansi.bgYellow() Color.LIGHT_GRAY -> ansi.bg(Ansi.Color.WHITE) Color.DARK_GRAY -> ansi.bgBright(Ansi.Color.BLACK) Color.LIGHT_BLUE -> ansi.bgBright(Ansi.Color.BLUE) Color.LIGHT_GREEN -> ansi.bgBrightGreen() Color.LIGHT_CYAN -> ansi.bgBright(Ansi.Color.CYAN) Color.LIGHT_RED -> ansi.bgBrightRed() Color.LIGHT_MAGENTA -> ansi.bgBright(Ansi.Color.MAGENTA) Color.LIGHT_YELLOW -> ansi.bgBrightYellow() Color.WHITE -> ansi.bgBright(Ansi.Color.WHITE) else -> this } protected val dieColors = mapOf( Die.Type.PHYSICAL to Color.LIGHT_BLUE, Die.Type.SOMATIC to Color.LIGHT_GREEN, Die.Type.MENTAL to Color.LIGHT_MAGENTA, Die.Type.VERBAL to Color.LIGHT_YELLOW, Die.Type.DIVINE to Color.LIGHT_CYAN, Die.Type.WOUND to Color.DARK_GRAY, Die.Type.ENEMY to Color.DARK_RED, Die.Type.VILLAIN to Color.LIGHT_RED, Die.Type.OBSTACLE to Color.DARK_YELLOW, Die.Type.ALLY to Color.WHITE ) protected val heroColors = mapOf( Hero.Type.BRAWLER to Color.LIGHT_BLUE, Hero.Type.HUNTER to Color.LIGHT_GREEN ) 

Agora, cada uma das 16 cores disponíveis é identificada exclusivamente por uma única constante. Escreveremos mais alguns métodos de utilidade, mas antes descobriremos mais uma coisa:

onde armazenar as constantes para cadeias de texto?

“As constantes de string precisam ser retiradas em arquivos separados, para que sejam armazenadas em um único local - isso facilita a manutenção. E também é importante para a localização ... ”

As constantes de string precisam ser movidas para arquivos separados ... bem, sim. Nós vamos suportar. O mecanismo Java padrão para trabalhar com esse tipo de recursos são os objetos java.util.ResourceBundleque trabalham com arquivos .properties. Aqui começamos com esse arquivo:

 # Game status messages choose_dice_perform_check=Choose dice to perform check: end_of_turn_discard_extra=END OF TURN: Discard extra dice: end_of_turn_discard_optional=END OF TURN: Discard any dice, if needed: choose_action_before_exploration=Choose your action: choose_action_after_exploration=Already explored this turn. Choose what to do now: encounter_physical=Encountered PHYSICAL die. Need to pass respective check or lose this die. encounter_somatic=Encountered SOMATIC die. Need to pass respective check or lose this die. encounter_mental=Encountered MENTAL die. Need to pass respective check or lose this die. encounter_verbal=Encountered VERBAL die. Need to pass respective check or lose this die. encounter_divine=Encountered DIVINE die. Can be acquired automatically (no checks needed): die_acquire_success=You have acquired the die! die_acquire_failure=You have failed to acquire the die. game_loss_out_of_time=You ran out of time # Die types physical=PHYSICAL somatic=SOMATIC mental=MENTAL verbal=VERBAL divine=DIVINE ally=ALLY wound=WOUND enemy=ENEMY villain=VILLAIN obstacle=OBSTACLE # Hero types and descriptions brawler=Brawler hunter=Hunter # Various labels avg=avg bag=Bag bag_size=Bag size class=Class closed=Closed discard=Discard empty=Empty encountered=Encountered fail=Fail hand=Hand heros_turn=%s's turn max=max min=min perform_check=Perform check: pile=Pile received_new_die=Received new die result=Result success=Success sum=sum time=Time total=Total # Action names and descriptions action_confirm_key=ENTER action_confirm_name=Confirm action_cancel_key=ESC action_cancel_name=Cancel action_explore_location_key=E action_explore_location_name=xplore action_finish_turn_key=F action_finish_turn_name=inish action_hide_key=H action_hide_name=ide action_discard_key=D action_discard_name=iscard action_acquire_key=A action_acquire_name=cquire action_leave_key=L action_leave_name=eave action_forfeit_key=F action_forfeit_name=orfeit 

Cada linha contém um par de valores-chave, separado por um caractere =. Você pode colocar o arquivo em qualquer lugar - o principal é que o caminho para ele faça parte do caminho de classe. Observe que o texto para ações consiste em duas partes: a primeira letra não é destacada apenas em amarelo quando exibida na tela, mas também determina a tecla que deve ser pressionada para executar esta ação. Portanto, é conveniente armazená-los separadamente.

Porém, abstraímos de um formato específico (no Android, por exemplo, as strings são armazenadas de maneira diferente) e descrevemos a interface para carregar constantes de strings.

 interface StringLoader { fun loadString(key: String): String } 

A chave é transmitida para a entrada, a saída é uma linha específica. A implementação é tão direta quanto a própria interface (suponha que o arquivo esteja no caminho src/main/resources/text/strings.properties).

 class PropertiesStringLoader() : StringLoader { private val properties = ResourceBundle.getBundle("text.strings") override fun loadString(key: String) = properties.getString(key) ?: "" } 

Agora, não será difícil implementar um método drawStatusMessage()para exibir o estado atual do mecanismo de jogo ( StatusMessage) na tela e um método drawActionList()para exibir uma lista de ações disponíveis ( ActionList). Assim como outros métodos oficiais que somente a alma deseja.

Há muito código, parte dele já vimos ... então aqui está um spoiler para você
 abstract class ConsoleRenderer(private val strings: StringLoader) { protected lateinit var ansi: Ansi init { AnsiConsole.systemInstall() clearScreen() resetAnsi() } protected fun loadString(key: String) = strings.loadString(key) private fun resetAnsi() { ansi = Ansi.ansi() } fun clearScreen() { print(Ansi.ansi().eraseScreen(Ansi.Erase.ALL).cursor(1, 1)) } protected fun render() { ansi.cursor(CONSOLE_HEIGHT, CONSOLE_WIDTH) System.out.print(ansi.toString()) resetAnsi() } protected fun drawBigNumber(offsetX: Int, offsetY: Int, number: Int): Unit = with(ansi) { var currentX = offsetX cursor(offsetY, currentX) val text = number.toString() text.forEach { when (it) { '0' -> { cursor(offsetY, currentX) a(" ███ ") cursor(offsetY + 1, currentX) a("█ █ ") cursor(offsetY + 2, currentX) a("█ █ ") cursor(offsetY + 3, currentX) a("█ █ ") cursor(offsetY + 4, currentX) a(" ███ ") } '1' -> { cursor(offsetY, currentX) a(" █ ") cursor(offsetY + 1, currentX) a(" ██ ") cursor(offsetY + 2, currentX) a("█ █ ") cursor(offsetY + 3, currentX) a(" █ ") cursor(offsetY + 4, currentX) a("█████ ") } '2' -> { cursor(offsetY, currentX) a(" ███ ") cursor(offsetY + 1, currentX) a("█ █ ") cursor(offsetY + 2, currentX) a(" █ ") cursor(offsetY + 3, currentX) a(" █ ") cursor(offsetY + 4, currentX) a("█████ ") } '3' -> { cursor(offsetY, currentX) a("████ ") cursor(offsetY + 1, currentX) a(" █ ") cursor(offsetY + 2, currentX) a(" ██ ") cursor(offsetY + 3, currentX) a(" █ ") cursor(offsetY + 4, currentX) a("████ ") } '4' -> { cursor(offsetY, currentX) a(" █ ") cursor(offsetY + 1, currentX) a(" ██ ") cursor(offsetY + 2, currentX) a(" █ █ ") cursor(offsetY + 3, currentX) a("█████ ") cursor(offsetY + 4, currentX) a(" █ ") } '5' -> { cursor(offsetY, currentX) a("█████ ") cursor(offsetY + 1, currentX) a("█ ") cursor(offsetY + 2, currentX) a("████ ") cursor(offsetY + 3, currentX) a(" █ ") cursor(offsetY + 4, currentX) a("████ ") } '6' -> { cursor(offsetY, currentX) a(" ███ ") cursor(offsetY + 1, currentX) a("█ ") cursor(offsetY + 2, currentX) a("████ ") cursor(offsetY + 3, currentX) a("█ █ ") cursor(offsetY + 4, currentX) a(" ███ ") } '7' -> { cursor(offsetY, currentX) a("█████ ") cursor(offsetY + 1, currentX) a(" █ ") cursor(offsetY + 2, currentX) a(" █ ") cursor(offsetY + 3, currentX) a(" █ ") cursor(offsetY + 4, currentX) a(" █ ") } '8' -> { cursor(offsetY, currentX) a(" ███ ") cursor(offsetY + 1, currentX) a("█ █ ") cursor(offsetY + 2, currentX) a(" ███ ") cursor(offsetY + 3, currentX) a("█ █ ") cursor(offsetY + 4, currentX) a(" ███ ") } '9' -> { cursor(offsetY, currentX) a(" ███ ") cursor(offsetY + 1, currentX) a("█ █ ") cursor(offsetY + 2, currentX) a(" ████ ") cursor(offsetY + 3, currentX) a(" █ ") cursor(offsetY + 4, currentX) a(" ███ ") } } currentX += 6 } } protected fun drawHorizontalLine(offsetY: Int, filler: Char) { ansi.cursor(offsetY, 1) (1..CONSOLE_WIDTH).forEach { ansi.a(filler) } } protected fun drawBlankLine(offsetY: Int, drawBorders: Boolean = true) { ansi.cursor(offsetY, 1) if (drawBorders) { ansi.a('│') (2 until CONSOLE_WIDTH).forEach { ansi.a(' ') } ansi.a('│') } else { ansi.eraseLine(Ansi.Erase.ALL) } } protected fun drawCenteredCaption(offsetY: Int, text: String, color: Color, drawBorders: Boolean = true) { val center = (CONSOLE_WIDTH - text.length) / 2 ansi.cursor(offsetY, 1) ansi.a(if (drawBorders) '│' else ' ') (2 until center).forEach { ansi.a(' ') } ansi.color(color).a(text).reset() (text.length + center until CONSOLE_WIDTH).forEach { ansi.a(' ') } ansi.a(if (drawBorders) '│' else ' ') } protected fun drawStatusMessage(offsetY: Int, message: StatusMessage, drawBorders: Boolean = true) { //Setup val messageText = loadString(message.toString().toLowerCase()) var currentX = 1 val rightBorder = CONSOLE_WIDTH - if (drawBorders) 1 else 0 //Left border ansi.cursor(offsetY, 1) if (drawBorders) { ansi.a('│') currentX++ } ansi.a(' ') currentX++ //Text ansi.a(messageText) currentX += messageText.length //Right border (currentX..rightBorder).forEach { ansi.a(' ') } if (drawBorders) { ansi.a('│') } } protected fun drawActionList(offsetY: Int, actions: ActionList, drawBorders: Boolean = true) { val rightBorder = CONSOLE_WIDTH - if (drawBorders) 1 else 0 var currentX = 1 //Left border ansi.cursor(offsetY, 1) if (drawBorders) { ansi.a('│') currentX++ } ansi.a(' ') currentX++ //List of actions actions.forEach { action -> val key = loadString("action_${action.toString().toLowerCase()}_key") val name = loadString("action_${action.toString().toLowerCase()}_name") val length = key.length + 2 + name.length if (currentX + length >= rightBorder) { (currentX..rightBorder).forEach { ansi.a(' ') } if (drawBorders) { ansi.a('│') } ansi.cursor(offsetY + 1, 1) currentX = 1 if (drawBorders) { ansi.a('│') currentX++ } ansi.a(' ') currentX++ } if (action.isEnabled) { ansi.color(Color.LIGHT_YELLOW) } ansi.a('(').a(key).a(')').reset() ansi.a(name) ansi.a(" ") currentX += length + 2 } //Right border (currentX..rightBorder).forEach { ansi.a(' ') } if (drawBorders) { ansi.a('│') } } protected enum class Color { BLACK, DARK_BLUE, DARK_GREEN, DARK_CYAN, DARK_RED, DARK_MAGENTA, DARK_YELLOW, LIGHT_GRAY, DARK_GRAY, LIGHT_BLUE, LIGHT_GREEN, LIGHT_CYAN, LIGHT_RED, LIGHT_MAGENTA, LIGHT_YELLOW, WHITE } protected fun Ansi.color(color: Color?): Ansi = when (color) { Color.BLACK -> fgBlack() Color.DARK_BLUE -> fgBlue() Color.DARK_GREEN -> fgGreen() Color.DARK_CYAN -> fgCyan() Color.DARK_RED -> fgRed() Color.DARK_MAGENTA -> fgMagenta() Color.DARK_YELLOW -> fgYellow() Color.LIGHT_GRAY -> fg(Ansi.Color.WHITE) Color.DARK_GRAY -> fgBrightBlack() Color.LIGHT_BLUE -> fgBrightBlue() Color.LIGHT_GREEN -> fgBrightGreen() Color.LIGHT_CYAN -> fgBrightCyan() Color.LIGHT_RED -> fgBrightRed() Color.LIGHT_MAGENTA -> fgBrightMagenta() Color.LIGHT_YELLOW -> fgBrightYellow() Color.WHITE -> fgBright(Ansi.Color.WHITE) else -> this } protected fun Ansi.background(color: Color?): Ansi = when (color) { Color.BLACK -> ansi.bg(Ansi.Color.BLACK) Color.DARK_BLUE -> ansi.bg(Ansi.Color.BLUE) Color.DARK_GREEN -> ansi.bgGreen() Color.DARK_CYAN -> ansi.bg(Ansi.Color.CYAN) Color.DARK_RED -> ansi.bgRed() Color.DARK_MAGENTA -> ansi.bgMagenta() Color.DARK_YELLOW -> ansi.bgYellow() Color.LIGHT_GRAY -> ansi.bg(Ansi.Color.WHITE) Color.DARK_GRAY -> ansi.bgBright(Ansi.Color.BLACK) Color.LIGHT_BLUE -> ansi.bgBright(Ansi.Color.BLUE) Color.LIGHT_GREEN -> ansi.bgBrightGreen() Color.LIGHT_CYAN -> ansi.bgBright(Ansi.Color.CYAN) Color.LIGHT_RED -> ansi.bgBrightRed() Color.LIGHT_MAGENTA -> ansi.bgBright(Ansi.Color.MAGENTA) Color.LIGHT_YELLOW -> ansi.bgBrightYellow() Color.WHITE -> ansi.bgBright(Ansi.Color.WHITE) else -> this } protected val dieColors = mapOf( Die.Type.PHYSICAL to Color.LIGHT_BLUE, Die.Type.SOMATIC to Color.LIGHT_GREEN, Die.Type.MENTAL to Color.LIGHT_MAGENTA, Die.Type.VERBAL to Color.LIGHT_YELLOW, Die.Type.DIVINE to Color.LIGHT_CYAN, Die.Type.WOUND to Color.DARK_GRAY, Die.Type.ENEMY to Color.DARK_RED, Die.Type.VILLAIN to Color.LIGHT_RED, Die.Type.OBSTACLE to Color.DARK_YELLOW, Die.Type.ALLY to Color.WHITE ) protected val heroColors = mapOf( Hero.Type.BRAWLER to Color.LIGHT_BLUE, Hero.Type.HUNTER to Color.LIGHT_GREEN ) protected open fun shortcut(index: Int) = "1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ"[index] } 

Por que todos nós fizemos isso, você pergunta? Sim, para herdar nossa implementação de interface desta maravilhosa classe GameRenderer.

Diagrama de classe


É assim que a implementação do primeiro método mais simples se parecerá:

 override fun drawGameLoss(message: StatusMessage) { val centerY = CONSOLE_HEIGHT / 2 (1 until centerY).forEach { drawBlankLine(it, false) } val data = loadString(message.toString().toLowerCase()).toUpperCase() drawCenteredCaption(centerY, data, LIGHT_RED, false) (centerY + 1..CONSOLE_HEIGHT).forEach { drawBlankLine(it, false) } render() } 

Nada sobrenatural, apenas uma linha de texto ( data) desenhada em vermelho no centro da tela ( drawCenteredCaption()). O restante do código preenche o restante da tela com linhas em branco. Talvez alguém pergunte por que isso é necessário - afinal clearScreen(), existe um método , basta chamá-lo no início do método, limpar a tela e desenhar o texto desejado. Infelizmente, essa é uma abordagem preguiçosa que não usaremos. O motivo é muito simples: com essa abordagem, algumas posições na tela são desenhadas duas vezes, o que leva a um tremor perceptível, especialmente quando a tela é sequencialmente desenhada várias vezes seguidas (durante as animações). Portanto, nossa tarefa não é apenas desenhar os caracteres certos nos lugares certos, mas preencher todo oo restante da tela com caracteres vazios (para que artefatos de outras renderizações não permaneçam nela). E essa tarefa não é tão simples.

O método a seguir segue esse princípio:

 override fun drawHeroTurnStart(hero: Hero) { val centerY = (CONSOLE_HEIGHT - 5) / 2 (1 until centerY).forEach { drawBlankLine(it, false) } ansi.color(heroColors[hero.type]) drawHorizontalLine(centerY, '─') drawHorizontalLine(centerY + 4, '─') ansi.reset() ansi.cursor(centerY + 1, 1).eraseLine() ansi.cursor(centerY + 3, 1).eraseLine() ansi.cursor(centerY + 2, 1) val text = String.format(loadString("heros_turn"), hero.name.toUpperCase()) val index = text.indexOf(hero.name.toUpperCase()) val center = (CONSOLE_WIDTH - text.length) / 2 ansi.cursor(centerY + 2, center) ansi.eraseLine(Ansi.Erase.BACKWARD) ansi.a(text.substring(0, index)) ansi.color(heroColors[hero.type]).a(hero.name.toUpperCase()).reset() ansi.a(text.substring(index + hero.name.length)) ansi.eraseLine(Ansi.Erase.FORWARD) (centerY + 5..CONSOLE_HEIGHT).forEach { drawBlankLine(it, false) } render() } 

Aqui, além do texto centralizado, há também duas linhas horizontais (veja as capturas de tela acima). Observe que as letras centrais são exibidas em duas cores. E também verifique se o aprendizado de matemática na escola ainda é útil.

Bem, analisamos os métodos mais simples e é hora de conhecer a implementação drawLocationInteriorScreen(). Como você mesmo entende, haverá uma ordem de magnitude mais código aqui. Além disso, o conteúdo da tela mudará dinamicamente em resposta às ações do usuário e precisará ser constantemente redesenhado (às vezes com animação). Bem, para finalmente acabar com você: imagine que, além da captura de tela acima, no âmbito deste método, é necessário implementar a exibição de mais três:

1. Reunião com o cubo removido da sacola


2. Selecionando dados para passar no teste


3. Exibindo Resultados do Teste


Portanto, aqui está o meu grande conselho para você: não coloque todo o código em um método. Divida a implementação em vários métodos (mesmo que cada um deles seja chamado apenas uma vez). Bem, não se esqueça da "borracha".

Se começar a ondular nos seus olhos, pisque por alguns segundos - isso deve ajudar
 class ConsoleGameRenderer(loader: StringLoader) : ConsoleRenderer(loader), GameRenderer { private fun drawLocationTopPanel(location: Location, heroesAtLocation: List<Hero>, currentHero: Hero, timer: Int) { val closedString = loadString("closed").toLowerCase() val timeString = loadString("time") val locationName = location.name.toString().toUpperCase() val separatorX1 = locationName.length + if (location.isOpen) { 6 + if (location.bag.size >= 10) 2 else 1 } else { closedString.length + 7 } val separatorX2 = CONSOLE_WIDTH - timeString.length - 6 - if (timer >= 10) 1 else 0 //Top border ansi.cursor(1, 1) ansi.a('┌') (2 until CONSOLE_WIDTH).forEach { ansi.a(if (it == separatorX1 || it == separatorX2) '┬' else '─') } ansi.a('┐') //Center row ansi.cursor(2, 1) ansi.a("│ ") if (location.isOpen) { ansi.color(WHITE).a(locationName).reset() ansi.a(": ").a(location.bag.size) } else { ansi.a(locationName).reset() ansi.color(DARK_GRAY).a(" (").a(closedString).a(')').reset() } ansi.a(" │") var currentX = separatorX1 + 2 heroesAtLocation.forEach { hero -> ansi.a(' ') ansi.color(heroColors[hero.type]) ansi.a(if (hero === currentHero) '☻' else '').reset() currentX += 2 } (currentX..separatorX2).forEach { ansi.a(' ') } ansi.a("│ ").a(timeString).a(": ") when { timer <= 5 -> ansi.color(LIGHT_RED) timer <= 15 -> ansi.color(LIGHT_YELLOW) else -> ansi.color(LIGHT_GREEN) } ansi.bold().a(timer).reset().a(" │") //Bottom border ansi.cursor(3, 1) ansi.a('├') (2 until CONSOLE_WIDTH).forEach { ansi.a(if (it == separatorX1 || it == separatorX2) '┴' else '─') } ansi.a('┤') } private fun drawLocationHeroPanel(offsetY: Int, hero: Hero) { val bagString = loadString("bag").toUpperCase() val discardString = loadString("discard").toUpperCase() val separatorX1 = hero.name.length + 4 val separatorX3 = CONSOLE_WIDTH - discardString.length - 6 - if (hero.discardPile.size >= 10) 1 else 0 val separatorX2 = separatorX3 - bagString.length - 6 - if (hero.bag.size >= 10) 1 else 0 //Top border ansi.cursor(offsetY, 1) ansi.a('├') (2 until CONSOLE_WIDTH).forEach { ansi.a(if (it == separatorX1 || it == separatorX2 || it == separatorX3) '┬' else '─') } ansi.a('┤') //Center row ansi.cursor(offsetY + 1, 1) ansi.a("│ ") ansi.color(heroColors[hero.type]).a(hero.name.toUpperCase()).reset() ansi.a(" │") val currentX = separatorX1 + 1 (currentX until separatorX2).forEach { ansi.a(' ') } ansi.a("│ ").a(bagString).a(": ") when { hero.bag.size <= hero.hand.capacity -> ansi.color(LIGHT_RED) else -> ansi.color(LIGHT_YELLOW) } ansi.a(hero.bag.size).reset() ansi.a(" │ ").a(discardString).a(": ") ansi.a(hero.discardPile.size) ansi.a(" │") //Bottom border ansi.cursor(offsetY + 2, 1) ansi.a('├') (2 until CONSOLE_WIDTH).forEach { ansi.a(if (it == separatorX1 || it == separatorX2 || it == separatorX3) '┴' else '─') } ansi.a('┤') } private fun drawDieSize(die: Die, checked: Boolean = false) { when { checked -> ansi.background(dieColors[die.type]).color(BLACK) else -> ansi.color(dieColors[die.type]) } ansi.a(die.toString()).reset() } private fun drawDieFrameSmall(offsetX: Int, offsetY: Int, longDieSize: Boolean) { //Top border ansi.cursor(offsetY, offsetX) ansi.a('╔') (0 until if (longDieSize) 5 else 4).forEach { ansi.a('═') } ansi.a('╗') //Left border ansi.cursor(offsetY + 1, offsetX) ansi.a("║ ") //Bottom border ansi.cursor(offsetY + 2, offsetX) ansi.a("╚") (0 until if (longDieSize) 5 else 4).forEach { ansi.a('═') } ansi.a('╝') //Right border ansi.cursor(offsetY + 1, offsetX + if (longDieSize) 6 else 5) ansi.a('║') } private fun drawDieSmall(offsetX: Int, offsetY: Int, pair: DiePair, rollResult: Int? = null) { ansi.color(dieColors[pair.die.type]) val longDieSize = pair.die.size >= 10 drawDieFrameSmall(offsetX, offsetY, longDieSize) //Roll result or die size ansi.cursor(offsetY + 1, offsetX + 1) if (rollResult != null) { ansi.a(String.format(" %2d %s", rollResult, if (longDieSize) " " else "")) } else { ansi.a(' ').a(pair.die.toString()).a(' ') } //Draw modifier ansi.cursor(offsetY + 3, offsetX) val modString = if (pair.modifier == 0) "" else String.format("%+d", pair.modifier) val frameLength = 4 + if (longDieSize) 3 else 2 var spaces = (frameLength - modString.length) / 2 (0 until spaces).forEach { ansi.a(' ') } ansi.a(modString) spaces = frameLength - spaces - modString.length (0 until spaces).forEach { ansi.a(' ') } ansi.reset() } private fun drawDieFrameBig(offsetX: Int, offsetY: Int, longDieSize: Boolean) { //Top border ansi.cursor(offsetY, offsetX) ansi.a('╔') (0 until if (longDieSize) 3 else 2).forEach { ansi.a("══════") } ansi.a("═╗") //Left border (1..5).forEach { ansi.cursor(offsetY + it, offsetX) ansi.a('║') } //Bottom border ansi.cursor(offsetY + 6, offsetX) ansi.a('╚') (0 until if (longDieSize) 3 else 2).forEach { ansi.a("══════") } ansi.a("═╝") //Right border val currentX = offsetX + if (longDieSize) 20 else 14 (1..5).forEach { ansi.cursor(offsetY + it, currentX) ansi.a('║') } } private fun drawDieSizeBig(offsetX: Int, offsetY: Int, pair: DiePair) { ansi.color(dieColors[pair.die.type]) val longDieSize = pair.die.size >= 10 drawDieFrameBig(offsetX, offsetY, longDieSize) //Die size ansi.cursor(offsetY + 1, offsetX + 1) ansi.a(" ████ ") ansi.cursor(offsetY + 2, offsetX + 1) ansi.a(" █ █ ") ansi.cursor(offsetY + 3, offsetX + 1) ansi.a(" █ █ ") ansi.cursor(offsetY + 4, offsetX + 1) ansi.a(" █ █ ") ansi.cursor(offsetY + 5, offsetX + 1) ansi.a(" ████ ") drawBigNumber(offsetX + 8, offsetY + 1, pair.die.size) //Draw modifier ansi.cursor(offsetY + 7, offsetX) val modString = if (pair.modifier == 0) "" else String.format("%+d", pair.modifier) val frameLength = 4 + 6 * if (longDieSize) 3 else 2 var spaces = (frameLength - modString.length) / 2 (0 until spaces).forEach { ansi.a(' ') } ansi.a(modString) spaces = frameLength - spaces - modString.length - 1 (0 until spaces).forEach { ansi.a(' ') } ansi.reset() } private fun drawBattleCheck(offsetY: Int, battleCheck: DieBattleCheck) { val performCheck = loadString("perform_check") var currentX = 4 var currentY = offsetY //Top message ansi.cursor(offsetY, 1) ansi.a("│ ").a(performCheck) (performCheck.length + 4 until CONSOLE_WIDTH).forEach { ansi.a(' ') } ansi.a('│') //Left border (1..4).forEach { ansi.cursor(offsetY + it, 1) ansi.a("│ ") } //Opponent var opponentWidth = 0 var vsWidth = 0 (battleCheck.getOpponentPair())?.let { //Die if (battleCheck.isRolled) { drawDieSmall(4, offsetY + 1, it, battleCheck.getOpponentResult()) } else { drawDieSmall(4, offsetY + 1, it) } opponentWidth = 4 + if (it.die.size >= 10) 3 else 2 currentX += opponentWidth //VS ansi.cursor(currentY + 1, currentX) ansi.a(" ") ansi.cursor(currentY + 2, currentX) ansi.color(LIGHT_YELLOW).a(" VS ").reset() ansi.cursor(currentY + 3, currentX) ansi.a(" ") ansi.cursor(currentY + 4, currentX) ansi.a(" ") vsWidth = 4 currentX += vsWidth } //Clear below for (row in currentY + 5..currentY + 8) { ansi.cursor(row, 1) ansi.a('│') (2 until currentX).forEach { ansi.a(' ') } } //Dice for (index in 0 until battleCheck.heroPairCount) { if (index > 0) { ansi.cursor(currentY + 1, currentX) ansi.a(" ") ansi.cursor(currentY + 2, currentX) ansi.a(if (battleCheck.method == DieBattleCheck.Method.SUM) " + " else " / ").reset() ansi.cursor(currentY + 3, currentX) ansi.a(" ") ansi.cursor(currentY + 4, currentX) ansi.a(" ") currentX += 3 } val pair = battleCheck.getHeroPairAt(index) val width = 4 + if (pair.die.size >= 10) 3 else 2 if (currentX + width + 3 > CONSOLE_WIDTH) { //Out of space for (row in currentY + 1..currentY + 4) { ansi.cursor(row, currentX) (currentX until CONSOLE_WIDTH).forEach { ansi.a(' ') } ansi.a('│') } currentY += 4 currentX = 4 + vsWidth + opponentWidth } if (battleCheck.isRolled) { drawDieSmall(currentX, currentY + 1, pair, battleCheck.getHeroResultAt(index)) } else { drawDieSmall(currentX, currentY + 1, pair) } currentX += width } //Clear the rest (currentY + 1..currentY + 4).forEach { row -> ansi.cursor(row, currentX) (currentX until CONSOLE_WIDTH).forEach { ansi.a(' ') } ansi.a('│') } if (currentY == offsetY) { //Still on the first line currentX = 4 + vsWidth + opponentWidth (currentY + 5..currentY + 8).forEach { row -> ansi.cursor(row, currentX) (currentX until CONSOLE_WIDTH).forEach { ansi.a(' ') } ansi.a('│') } } //Draw result (battleCheck.result)?.let { r -> val frameTopY = offsetY + 5 val result = String.format("%+d", r) val message = loadString(if (r >= 0) "success" else "fail").toUpperCase() val color = if (r >= 0) DARK_GREEN else DARK_RED //Frame ansi.color(color) drawHorizontalLine(frameTopY, '▒') drawHorizontalLine(frameTopY + 3, '▒') ansi.cursor(frameTopY + 1, 1).a("▒▒") ansi.cursor(frameTopY + 1, CONSOLE_WIDTH - 1).a("▒▒") ansi.cursor(frameTopY + 2, 1).a("▒▒") ansi.cursor(frameTopY + 2, CONSOLE_WIDTH - 1).a("▒▒") ansi.reset() //Top message val resultString = loadString("result") var center = (CONSOLE_WIDTH - result.length - resultString.length - 2) / 2 ansi.cursor(frameTopY + 1, 3) (3 until center).forEach { ansi.a(' ') } ansi.a(resultString).a(": ") ansi.color(color).a(result).reset() (center + result.length + resultString.length + 2 until CONSOLE_WIDTH - 1).forEach { ansi.a(' ') } //Bottom message center = (CONSOLE_WIDTH - message.length) / 2 ansi.cursor(frameTopY + 2, 3) (3 until center).forEach { ansi.a(' ') } ansi.color(color).a(message).reset() (center + message.length until CONSOLE_WIDTH - 1).forEach { ansi.a(' ') } } } private fun drawExplorationResult(offsetY: Int, pair: DiePair) { val encountered = loadString("encountered") ansi.cursor(offsetY, 1) ansi.a("│ ").a(encountered).a(':') (encountered.length + 5 until CONSOLE_WIDTH).forEach { ansi.a(' ') } ansi.a('│') val dieFrameWidth = 3 + 6 * if (pair.die.size >= 10) 3 else 2 for (row in 1..8) { ansi.cursor(offsetY + row, 1) ansi.a("│ ") ansi.cursor(offsetY + row, dieFrameWidth + 4) (dieFrameWidth + 4 until CONSOLE_WIDTH).forEach { ansi.a(' ') } ansi.a('│') } drawDieSizeBig(4, offsetY + 1, pair) } private fun drawHand(offsetY: Int, hand: Hand, checkedDice: HandMask, activePositions: HandMask) { val handString = loadString("hand").toUpperCase() val alliesString = loadString("allies").toUpperCase() val capacity = hand.capacity val size = hand.dieCount val slots = max(size, capacity) val alliesSize = hand.allyDieCount var currentY = offsetY var currentX = 1 //Hand title ansi.cursor(currentY, currentX) ansi.a("│ ").a(handString) //Left border currentY += 1 currentX = 1 ansi.cursor(currentY, currentX) ansi.a("│ ╔") ansi.cursor(currentY + 1, currentX) ansi.a("│ ║") ansi.cursor(currentY + 2, currentX) ansi.a("│ ╚") ansi.cursor(currentY + 3, currentX) ansi.a("│ ") currentX += 3 //Main hand for (i in 0 until min(slots, MAX_HAND_SIZE)) { val die = hand.dieAt(i) val longDieName = die != null && die.size >= 10 //Top border ansi.cursor(currentY, currentX) if (i < capacity) { ansi.a("════").a(if (longDieName) "═" else "") } else { ansi.a("────").a(if (longDieName) "─" else "") } ansi.a(if (i < capacity - 1) '╤' else if (i == capacity - 1) '╗' else if (i < size - 1) '┬' else '┐') //Center row ansi.cursor(currentY + 1, currentX) ansi.a(' ') if (die != null) { drawDieSize(die, checkedDice.checkPosition(i)) } else { ansi.a(" ") } ansi.a(' ') ansi.a(if (i < capacity - 1) '│' else if (i == capacity - 1) '║' else '│') //Bottom border ansi.cursor(currentY + 2, currentX) if (i < capacity) { ansi.a("════").a(if (longDieName) '═' else "") } else { ansi.a("────").a(if (longDieName) '─' else "") } ansi.a(if (i < capacity - 1) '╧' else if (i == capacity - 1) '╝' else if (i < size - 1) '┴' else '┘') //Die number ansi.cursor(currentY + 3, currentX) if (activePositions.checkPosition(i)) { ansi.color(LIGHT_YELLOW) } ansi.a(String.format(" (%s) %s", shortcut(i), if (longDieName) " " else "")) ansi.reset() currentX += 5 + if (longDieName) 1 else 0 } //Ally subhand if (alliesSize > 0) { currentY = offsetY //Ally title ansi.cursor(currentY, handString.length + 5) (handString.length + 5 until currentX).forEach { ansi.a(' ') } ansi.a(" ").a(alliesString) (currentX + alliesString.length + 5 until CONSOLE_WIDTH).forEach { ansi.a(' ') } ansi.a('│') //Left border currentY += 1 ansi.cursor(currentY, currentX) ansi.a(" ┌") ansi.cursor(currentY + 1, currentX) ansi.a(" │") ansi.cursor(currentY + 2, currentX) ansi.a(" └") ansi.cursor(currentY + 3, currentX) ansi.a(" ") currentX += 4 //Ally slots for (i in 0 until min(alliesSize, MAX_HAND_ALLY_SIZE)) { val allyDie = hand.allyDieAt(i)!! val longDieName = allyDie.size >= 10 //Top border ansi.cursor(currentY, currentX) ansi.a("────").a(if (longDieName) "─" else "") ansi.a(if (i < alliesSize - 1) '┬' else '┐') //Center row ansi.cursor(currentY + 1, currentX) ansi.a(' ') drawDieSize(allyDie, checkedDice.checkAllyPosition(i)) ansi.a(" │") //Bottom border ansi.cursor(currentY + 2, currentX) ansi.a("────").a(if (longDieName) "─" else "") ansi.a(if (i < alliesSize - 1) '┴' else '┘') //Die number ansi.cursor(currentY + 3, currentX) if (activePositions.checkAllyPosition(i)) { ansi.color(LIGHT_YELLOW) } ansi.a(String.format(" (%s) %s", shortcut(i + 10), if (longDieName) " " else "")).reset() currentX += 5 + if (longDieName) 1 else 0 } } else { ansi.cursor(offsetY, 9) (9 until CONSOLE_WIDTH).forEach { ansi.a(' ') } ansi.a('│') ansi.cursor(offsetY + 4, currentX) (currentX until CONSOLE_WIDTH).forEach { ansi.a(' ') } ansi.a('│') } //Clear the end of the line (0..3).forEach { row -> ansi.cursor(currentY + row, currentX) (currentX until CONSOLE_WIDTH).forEach { ansi.a(' ') } ansi.a('│') } } override fun drawHeroTurnStart(hero: Hero) { val centerY = (CONSOLE_HEIGHT - 5) / 2 (1 until centerY).forEach { drawBlankLine(it, false) } ansi.color(heroColors[hero.type]) drawHorizontalLine(centerY, '─') drawHorizontalLine(centerY + 4, '─') ansi.reset() ansi.cursor(centerY + 1, 1).eraseLine() ansi.cursor(centerY + 3, 1).eraseLine() ansi.cursor(centerY + 2, 1) val text = String.format(loadString("heros_turn"), hero.name.toUpperCase()) val index = text.indexOf(hero.name.toUpperCase()) val center = (CONSOLE_WIDTH - text.length) / 2 ansi.cursor(centerY + 2, center) ansi.eraseLine(Ansi.Erase.BACKWARD) ansi.a(text.substring(0, index)) ansi.color(heroColors[hero.type]).a(hero.name.toUpperCase()).reset() ansi.a(text.substring(index + hero.name.length)) ansi.eraseLine(Ansi.Erase.FORWARD) (centerY + 5..CONSOLE_HEIGHT).forEach { drawBlankLine(it, false) } render() } override fun drawLocationInteriorScreen( location: Location, heroesAtLocation: List<Hero>, timer: Int, currentHero: Hero, battleCheck: DieBattleCheck?, encounteredDie: DiePair?, pickedDice: HandMask, activePositions: HandMask, statusMessage: StatusMessage, actions: ActionList) { //Top panel drawLocationTopPanel(location, heroesAtLocation, currentHero, timer) //Encounter info when { battleCheck != null -> drawBattleCheck(4, battleCheck) encounteredDie != null -> drawExplorationResult(4, encounteredDie) else -> (4..12).forEach { drawBlankLine(it) } } //Fill blank space val bottomHalfTop = CONSOLE_HEIGHT - 11 (13 until bottomHalfTop).forEach { drawBlankLine(it) } //Hero-specific info drawLocationHeroPanel(bottomHalfTop, currentHero) drawHand(bottomHalfTop + 3, currentHero.hand, pickedDice, activePositions) //Separator ansi.cursor(bottomHalfTop + 8, 1) ansi.a('├') (2 until CONSOLE_WIDTH).forEach { ansi.a('─') } ansi.a('┤') //Status and actions drawStatusMessage(bottomHalfTop + 9, statusMessage) drawActionList(bottomHalfTop + 10, actions) //Bottom border ansi.cursor(CONSOLE_HEIGHT, 1) ansi.a('└') (2 until CONSOLE_WIDTH).forEach { ansi.a('─') } ansi.a('┘') //Finalize render() } override fun drawGameLoss(message: StatusMessage) { val centerY = CONSOLE_HEIGHT / 2 (1 until centerY).forEach { drawBlankLine(it, false) } val data = loadString(message.toString().toLowerCase()).toUpperCase() drawCenteredCaption(centerY, data, LIGHT_RED, false) (centerY + 1..CONSOLE_HEIGHT).forEach { drawBlankLine(it, false) } render() } } 

Há um pequeno problema associado à verificação da operação de todo esse código. Como o console IDE embutido não suporta sequências de escape ANSI, você terá que iniciar o aplicativo em um terminal externo (já escrevemos um script para iniciá-lo anteriormente). Além disso, com o suporte a ANSI, nem tudo está bem no Windows - até onde eu sei, apenas na versão 10, o cmd.exe padrão pode nos agradar com uma exibição de alta qualidade (e isso, com alguns problemas nos quais não focaremos). E o PowerShell não aprendeu imediatamente a reconhecer sequências (apesar da demanda atual). Se você tiver azar, não desanime - sempre existem soluções alternativas ( isso, por exemplo ). E seguimos em frente.

Etapa dez Entrada do usuário


A exibição da imagem na tela ainda é metade da batalha. É igualmente importante receber corretamente comandos de controle do usuário. E quero dizer que essa tarefa pode ser tecnicamente muito mais difícil de implementar do que todas as anteriores. Mas as primeiras coisas primeiro.

Como você se lembra, somos confrontados com a necessidade de implementar métodos de classe GameInteractor. Existem apenas três deles, mas eles requerem atenção especial. Em primeiro lugar, sincronização. O mecanismo do jogo deve ser suspenso até o jogador pressionar uma tecla. Em segundo lugar, clique em processamento. Infelizmente, a capacidade das classes padrão Reader, Scanner, Consolenão é suficiente para reconhecê-los mais prementes: nós não exigem que o usuário pressione ENTER depois de cada comando. Precisamos de algo comoKeyListenerMas, mas está intimamente ligado à estrutura Swing, e nosso aplicativo de console não possui todo esse enfeites gráficos.

O que fazer? , , . «, »… ? , , , . jLine , ( ). , , , Windows, Linux/UNIX ( ). , . , , .

 <dependency> <groupId>jline</groupId> <artifactId>jline</artifactId> <version>2.14.6</version> <scope>compile</scope> </dependency> 

Observe que não precisamos da terceira versão mais recente, mas da segunda, onde há uma classe ConsoleReadercom um método readCharacter(). Como o nome indica, esse método retorna o código do caractere pressionado no teclado (enquanto trabalhamos de forma síncrona, e é disso que precisamos). O restante é uma questão técnica: compile uma tabela de correspondências entre símbolos e tipos de ações ( Action.Type) e, clicando em uma, retorne a outra.

“Você sabia que nem todas as teclas do teclado podem ser representadas com um caractere? Muitas teclas usam seqüências de escape de dois, três, quatro caracteres diferentes. Como estar com eles?

Deve-se notar que a tarefa de entrada é complicada se queremos reconhecer “teclas sem caracteres”: setas, teclas F, Início, Inserir, PgUp / Dn, Fim, Excluir, teclado numérico e outros. Mas não queremos, portanto continuaremos. Vamos criar uma classe ConsoleInteractorcom os métodos de serviço necessários.

 abstract class ConsoleInteractor { private val reader = ConsoleReader() private val mapper = mapOf( CONFIRM to 13.toChar(), CANCEL to 27.toChar(), EXPLORE_LOCATION to 'e', FINISH_TURN to 'f', ACQUIRE to 'a', LEAVE to 'l', FORFEIT to 'f', HIDE to 'h', DISCARD to 'd', ) protected fun read() = reader.readCharacter().toChar() protected open fun getIndexForKey(key: Char) = "1234567890abcdefghijklmnopqrstuvw".indexOf(key) } 

Defina o mapa mappere o método read(). Além disso, forneceremos um método getIndexForKey()usado em situações em que precisamos selecionar um item de uma lista ou cubos de uma mão. Resta herdar nossa implementação de interface dessa classe GameInteractor.

Diagrama de classe


E, de fato, o código:

 class ConsoleGameInteractor : ConsoleInteractor(), GameInteractor { override fun anyInput() { read() } override fun pickAction(list: ActionList): Action { while (true) { val key = read() list .filter(Action::isEnabled) .find { mapper[it.type] == key } ?.let { return it } } } override fun pickDiceFromHand(activePositions: HandMask, actions: ActionList) : Action { while (true) { val key = read() actions.forEach { if (mapper[it.type] == key && it.isEnabled) return it } when (key) { in '1'..'9' -> { val index = key - '1' if (activePositions.checkPosition(index)) { return Action(HAND_POSITION, data = index) } } '0' -> { if (activePositions.checkPosition(9)) { return Action(HAND_POSITION, data = 9) } } in 'a'..'f' -> { val allyIndex = key - 'a' if (activePositions.checkAllyPosition(allyIndex)) { return Action(HAND_ALLY_POSITION, data = allyIndex) } } } } } } 

A implementação de nossos métodos é bastante educada e bem-educada, a fim de não trazer à tona várias bobagens inadequadas. Eles mesmos verificam se a ação selecionada está ativa e a posição da mão selecionada é incluída no conjunto de válidos. E desejo que todos nós sejamos tão educados com as pessoas ao nosso redor.

Etapa onze. Sons e música


-? (, , ), , . . , , (, , , ). , ( ), , ( -) , . , , .

, — , . , . , . , . , , — , — , . , : , , — , . , — , — . ? , . , ( , , ).

, , , . : , , ? — , . , . , ( — — ). freesound.orgonde centenas de outras pessoas fizeram isso por você há muito tempo. Preste atenção apenas à licença: muitos autores são muito sensíveis às gravações de áudio de sua tosse alta ou moedas jogadas no chão - você nunca deseja usar sem escrúpulos os frutos de seus trabalhos sem pagar o criador original ou sem mencionar seu pseudônimo criativo (às vezes muito bizarro) nos comentários.

Arraste os arquivos que você gosta e coloque-os em algum lugar do caminho de classe. Para identificá-los, usaremos a enumeração, onde cada instância corresponde a um efeito sonoro.

 enum class Sound { TURN_START, //Hero starts the turn BATTLE_CHECK_ROLL, //Perform check, type BATTLE_CHECK_SUCCESS, //Check was successful BATTLE_CHECK_FAILURE, //Check failed DIE_DRAW, //Draw die from bag DIE_HIDE, //Remove die to bag DIE_DISCARD, //Remove die to pile DIE_REMOVE, //Remove die entirely DIE_PICK, //Check/uncheck the die TRAVEL, //Move hero to another location ENCOUNTER_STAT, //Hero encounters STAT die ENCOUNTER_DIVINE, //Hero encounters DIVINE die ENCOUNTER_ALLY, //Hero encounters ALLY die ENCOUNTER_WOUND, //Hero encounters WOUND die ENCOUNTER_OBSTACLE, //Hero encounters OBSTACLE die ENCOUNTER_ENEMY, //Hero encounters ENEMY die ENCOUNTER_VILLAIN, //Hero encounters VILLAIN die DEFEAT_OBSTACLE, //Hero defeats OBSTACLE die DEFEAT_ENEMY, //Hero defeats ENEMY die DEFEAT_VILLAIN, //Hero defeats VILLAIN die TAKE_DAMAGE, //Hero takes damage HERO_DEATH, //Hero death CLOSE_LOCATION, //Location closed GAME_VICTORY, //Scenario completed GAME_LOSS, //Scenario failed ERROR, //When something unexpected happens } 

Como o método de reprodução de sons varia dependendo da plataforma de hardware, podemos ser abstraídos de uma implementação específica usando a interface. Por exemplo, este:

 interface SoundPlayer { fun play(sound: Sound) } 

Como as interfaces discutidas anteriormente GameRenderere GameInteractor, sua implementação também precisa ser passada para a entrada da instância da classe Game. Para iniciantes, uma implementação poderia ser assim:

 class MuteSoundPlayer : SoundPlayer { override fun play(sound: Sound) { //Do nothing } } 

, .
, , . , , ( , ) ( , - , ). , ( ), , , - . , - freemusicarchive.org ou soundcloud.com (ou até YouTube) e encontre algo ao seu gosto. Para desktops, o ambiente é uma boa escolha - música silenciosa e suave, sem uma melodia pronunciada, adequada para criar um plano de fundo. Preste atenção à licença: até a música de graça às vezes é escrita por compositores talentosos que merecem, se não uma recompensa monetária, pelo menos reconhecimento universal.

Vamos criar mais uma enumeração:

 enum class Music { SCENARIO_MUSIC_1, SCENARIO_MUSIC_2, SCENARIO_MUSIC_3, } 

Da mesma forma, definimos a interface e sua implementação padrão.

 interface MusicPlayer { fun play(music: Music) fun stop() } class MuteMusicPlayer : MusicPlayer { override fun play(music: Music) { //Do nothing } override fun stop() { //Do nothing } } 

, : , . , (/, ), .

- . , (singleton). . :



Audio — singleton. … , (facade) — , ( ) . , , - . :

 object Audio { private var soundPlayer: SoundPlayer = MuteSoundPlayer() private var musicPlayer: MusicPlayer = MuteMusicPlayer() fun init(soundPlayer: SoundPlayer, musicPlayer: MusicPlayer) { this.soundPlayer = soundPlayer this.musicPlayer = musicPlayer } fun playSound(sound: Sound) = this.soundPlayer.play(sound) fun playMusic(music: Music) = this.musicPlayer.play(music) fun stopMusic() = this.musicPlayer.stop() } 

init() - - ( ) , . , , — .

Isso é tudo. . (, , ), Java AudioSystem Clip . , , - ( classpath, ?):

 import javax.sound.sampled.AudioSystem class BasicSoundPlayer : SoundPlayer { private fun pathToFile(sound: Sound) = "/sound/${sound.toString().toLowerCase()}.wav" override fun play(sound: Sound) { val url = javaClass.getResource(pathToFile(sound)) val audioIn = AudioSystem.getAudioInputStream(url) val clip = AudioSystem.getClip() clip.open(audioIn) clip.start() } } 

open() IOException ( - — - ), try-catch , , .

« , ...»

. , (, mp3) Java , ( ). , JLayer . :

 <dependencies> <dependency> <groupId>com.googlecode.soundlibs</groupId> <artifactId>jlayer</artifactId> <version>1.0.1.4</version> <scope>compile</scope> </dependency> </dependencies> 

.

 class BasicMusicPlayer : MusicPlayer { private var currentMusic: Music? = null private var thread: PlayerThread? = null private fun pathToFile(music: Music) = "/music/${music.toString().toLowerCase()}.mp3" override fun play(music: Music) { if (currentMusic == music) { return } currentMusic = music thread?.finish() Thread.yield() thread = PlayerThread(pathToFile(music)) thread?.start() } override fun stop() { currentMusic = null thread?.finish() } // Thread responsible for playback private inner class PlayerThread(private val musicPath: String) : Thread() { private lateinit var player: Player private var isLoaded = false private var isFinished = false init { isDaemon = true } override fun run() { loop@ while (!isFinished) { try { player = Player(javaClass.getResource(musicPath).openConnection().apply { useCaches = false }.getInputStream()) isLoaded = true player.play() } catch (ex: Exception) { finish() break@loop } player.close() } } fun finish() { isFinished = true this.interrupt() if (isLoaded) { player.close() } } } } 

-, , , . ( PlayerThread ), «» (), . -, ( currentMusic ). , . -, — , finish()(ou até que outros threads sejam concluídos, como já mencionado). Quarto, embora o código acima esteja repleto de sinalizadores e comandos aparentemente desnecessários, ele é completamente depurado e testado - o player funciona como esperado, não diminui a velocidade do sistema, não interrompe repentinamente até a metade, não leva a vazamentos de memória, não contém objetos geneticamente modificados, brilha frescura e pureza. Pegue-o e use-o com ousadia em seus projetos.

Etapa Doze. Localização


Nosso jogo está quase pronto, mas ninguém vai jogar. Porque

"Não há russo! .. Não há russo! .. Adicione o idioma russo! .. Desenvolvido por cães!"

Abra a página de qualquer jogo de história interessante (especialmente para celular) no site da loja e leia as resenhas. Eles vão começar a elogiar incríveis gráficos desenhados à mão? Ou se maravilhar com o som atmosférico? Ou discuta uma história emocionante que é viciante desde o primeiro minuto e não deixa passar até o fim?

Não."Jogadores" insatisfeitos instruirão várias unidades e geralmente excluirão o jogo. E então eles também exigirão dinheiro de volta - e tudo isso por uma simples razão. Sim, você esqueceu de traduzir sua obra-prima para todos os 95 idiomas do mundo. Ou melhor, aquele cujas operadoras gritam mais alto. E é isso aí! Você entende?Meses de trabalho duro, longas noites sem dormir, colapsos nervosos constantes - tudo isso é um hamster debaixo da cauda. Você perdeu um grande número de jogadores e isso não pode ser corrigido.

Então pense à frente. Decida sobre o seu público-alvo, selecione vários idiomas principais, solicite serviços de tradução ... em geral, faça tudo o que outras pessoas descreveram mais de uma vez em artigos temáticos (mais espertos que eu). Vamos nos concentrar no lado técnico da questão e falar sobre como localizar o produto sem dor.

Primeiro entramos nos modelos. Lembre-se, antes que os nomes e descrições fossem armazenados como simples String? Agora não vai funcionar. Além do idioma padrão, você também precisa fornecer tradução para todos os idiomas que planeja oferecer suporte. Por exemplo, assim:

 class TestEnemyTemplate : EnemyTemplate { override val name = "Test enemy" override val description = "Some enemy standing in your way." override val nameLocalizations = mapOf( "ru" to " -", "ar" to "بعض العدو", "iw" to "איזה אויב", "zh" to "一些敵人", "ua" to "і " ) override val descriptionLocalizations = mapOf( "ru" to " - .", "ar" to "وصف العدو", "iw" to "תיאור האויב", "zh" to "一些敵人的描述", "ua" to " ї і   ." ) override val traits = listOf<Trait>() } 

. - , — . . , .

 class LocalizedString(defaultValue: String, localizations: Map<String, String>) { private val default: String = defaultValue private val values: Map<String, String> = localizations.toMap() operator fun get(lang: String) = values.getOrDefault(lang, default) override fun equals(other: Any?) = when { this === other -> true other !is LocalizedString -> false else -> default == other.default } override fun hashCode(): Int { return default.hashCode() } } 

.

 fun generateEnemy(template: EnemyTemplate) = Enemy().apply { name = LocalizedString(template.name, template.nameLocalizations) description = LocalizedString(template.description, template.descriptionLocalizations) template.traits.forEach { addTrait(it) } } 

, . , .

 val language = Locale.getDefault().language val enemyName = enemy.name[language] 

Em nosso exemplo, fornecemos uma versão simplificada da localização, na qual apenas o idioma é levado em consideração. Em geral, os objetos de classe Localetambém definem o país e a região. Se isso for importante no seu aplicativo, o seu LocalizedStringserá um pouco diferente, mas estamos felizes com isso de qualquer maneira.

Lidamos com os modelos, resta localizar as linhas de serviço usadas em nosso aplicativo. Felizmente, ele ResourceBundlejá contém todos os mecanismos necessários. Só é necessário preparar arquivos com traduções e alterar a maneira como eles são baixados.

 # Game status messages choose_dice_perform_check=    : end_of_turn_discard_extra= :   : end_of_turn_discard_optional= :    : choose_action_before_exploration=,  : choose_action_after_exploration= .   ? encounter_physical=  .   . encounter_somatic=  .   . encounter_mental=  .   . encounter_verbal=  .   . encounter_divine=  .    : die_acquire_success=   ! die_acquire_failure=    . game_loss_out_of_time=    # Die types physical= somatic= mental= verbal= divine= ally= wound= enemy= villain= obstacle= # Hero types and descriptions brawler= hunter= # Various labels avg= bag= bag_size=  class= closed= discard= empty= encountered=  fail= hand= heros_turn= %s max= min= perform_check= : pile= received_new_die=   result= success= sum= time= total= # Action names and descriptions action_confirm_key=ENTER action_confirm_name= action_cancel_key=ESC action_cancel_name= action_explore_location_key=E action_explore_location_name= action_finish_turn_key=F action_finish_turn_name=  action_hide_key=H action_bag_name= action_discard_key=D action_discard_name= action_acquire_key=A action_acquire_name= action_leave_key=L action_leave_name= action_forfeit_key=F action_forfeit_name= 

Eu não direi para o registro: escrever frases em russo é muito mais difícil do que em inglês. Se houver um requisito para usar um substantivo em um caso definitivo ou para se separar do gênero (e esses requisitos necessariamente permanecerão), você precisará suar muito antes de obter um resultado que, primeiro, atenda aos requisitos e, em segundo lugar, não pareça uma tradução mecânica feita por um ciborgue com cérebros de frango. Observe também que não alteramos as teclas de ação - como antes, os mesmos caracteres serão usados ​​para executá-los como no idioma inglês (que, a propósito, não funcionará em um layout de teclado diferente do latim, mas esse não é o nosso negócio. - por enquanto, vamos deixar como estão).

 class PropertiesStringLoader(locale: Locale) : StringLoader { private val properties = ResourceBundle.getBundle("text.strings", locale) override fun loadString(key: String) = properties.getString(key) ?: "" } 
.
Como já mencionado, ResourceBundleele próprio assumirá a responsabilidade de encontrar entre os arquivos de localização o que mais se aproxima do local atual. E se ele não encontrar, ele pegará o arquivo padrão ( string.properties). E tudo vai ficar bem ...

Sim! Lá estava!
, Unicode .properties Java 9. ISO-8859-1 — ResourceBundle . , , — . Unicode- — , , : '\uXXXX' . , , Java native2ascii , . :

 # Game status messages choose_dice_perform_check=\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u043a\u0443\u0431\u0438\u043a\u0438 \u0434\u043b\u044f \u043f\u0440\u043e\u0445\u043e\u0436\u0434\u0435\u043d\u0438\u044f \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0438: end_of_turn_discard_extra=\u041a\u041e\u041d\u0415\u0426 \u0425\u041e\u0414\u0410: \u0421\u0431\u0440\u043e\u0441\u044c\u0442\u0435 \u043b\u0438\u0448\u043d\u0438\u0435 \u043a\u0443\u0431\u0438\u043a\u0438: end_of_turn_discard_optional=\u041a\u041e\u041d\u0415\u0426 \u0425\u041e\u0414\u0410: \u0421\u0431\u0440\u043e\u0441\u044c\u0442\u0435 \u043a\u0443\u0431\u0438\u043a\u0438 \u043f\u043e \u0436\u0435\u043b\u0430\u043d\u0438\u044e: choose_action_before_exploration=\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435, \u0447\u0442\u043e \u0434\u0435\u043b\u0430\u0442\u044c: choose_action_after_exploration=\u0418\u0441\u0441\u043b\u0435\u0434\u043e\u0432\u0430\u043d\u0438\u0435 \u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u043e. \u0427\u0442\u043e \u0434\u0435\u043b\u0430\u0442\u044c \u0434\u0430\u043b\u044c\u0448\u0435? encounter_physical=\u0412\u0441\u0442\u0440\u0435\u0447\u0435\u043d \u0424\u0418\u0417\u0418\u0427\u0415\u0421\u041a\u0418\u0419 \u043a\u0443\u0431\u0438\u043a. \u041d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e \u043f\u0440\u043e\u0439\u0442\u0438 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0443. encounter_somatic=\u0412\u0441\u0442\u0440\u0435\u0447\u0435\u043d \u0421\u041e\u041c\u0410\u0422\u0418\u0427\u0415\u0421\u041a\u0418\u0419 \u043a\u0443\u0431\u0438\u043a. \u041d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e \u043f\u0440\u043e\u0439\u0442\u0438 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0443. encounter_mental=\u0412\u0441\u0442\u0440\u0435\u0447\u0435\u043d \u041c\u0415\u041d\u0422\u0410\u041b\u042c\u041d\u042b\u0419 \u043a\u0443\u0431\u0438\u043a. \u041d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e \u043f\u0440\u043e\u0439\u0442\u0438 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0443. encounter_verbal=\u0412\u0441\u0442\u0440\u0435\u0447\u0435\u043d \u0412\u0415\u0420\u0411\u0410\u041b\u042c\u041d\u042b\u0419 \u043a\u0443\u0431\u0438\u043a. \u041d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e \u043f\u0440\u043e\u0439\u0442\u0438 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0443. encounter_divine=\u0412\u0441\u0442\u0440\u0435\u0447\u0435\u043d \u0411\u041e\u0416\u0415\u0421\u0422\u0412\u0415\u041d\u041d\u042b\u0419 \u043a\u0443\u0431\u0438\u043a. \u041c\u043e\u0436\u043d\u043e \u0432\u0437\u044f\u0442\u044c \u0431\u0435\u0437 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0438: die_acquire_success=\u0412\u044b \u043f\u043e\u043b\u0443\u0447\u0438\u043b\u0438 \u043d\u043e\u0432\u044b\u0439 \u043a\u0443\u0431\u0438\u043a! die_acquire_failure=\u0412\u0430\u043c \u043d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u043a\u0443\u0431\u0438\u043a. game_loss_out_of_time=\u0423 \u0432\u0430\u0441 \u0437\u0430\u043a\u043e\u043d\u0447\u0438\u043b\u043e\u0441\u044c \u0432\u0440\u0435\u043c\u044f 

. — . — . , IDE ( ) « », — - ( ), IDE, .

, . getBundle() , , , ResourceBundle.Control — - .

 class PropertiesStringLoader(locale: Locale) : StringLoader { private val properties = ResourceBundle.getBundle( "text.strings", locale, Utf8ResourceBundleControl()) override fun loadString(key: String) = properties.getString(key) ?: "" } 

, , :

 class Utf8ResourceBundleControl : ResourceBundle.Control() { @Throws(IllegalAccessException::class, InstantiationException::class, IOException::class) override fun newBundle(baseName: String, locale: Locale, format: String, loader: ClassLoader, reload: Boolean): ResourceBundle? { val bundleName = toBundleName(baseName, locale) return when (format) { "java.class" -> super.newBundle(baseName, locale, format, loader, reload) "java.properties" -> with((if ("://" in bundleName) null else toResourceName(bundleName, "properties")) ?: return null) { when { reload -> reload(this, loader) else -> loader.getResourceAsStream(this) }?.let { stream -> InputStreamReader(stream, "UTF-8").use { r -> PropertyResourceBundle(r) } } } else -> throw IllegalArgumentException("Unknown format: $format") } } @Throws(IOException::class) private fun reload(resourceName: String, classLoader: ClassLoader): InputStream { classLoader.getResource(resourceName)?.let { url -> url.openConnection().let { connection -> connection.useCaches = false return connection.getInputStream() } } throw IOException("Unable to load data!") } } 

, … , ( ) — ( Kotlin ). — , .properties UTF-8 - .

Para testar a operação do aplicativo em diferentes idiomas, não é necessário alterar as configurações do sistema operacional - basta especificar o idioma necessário ao iniciar o JRE:

 java -Duser.language=ru -jar path_to_project\Dice\target\dice-1.0-jar-with-dependencies.jar 

Se você ainda estiver trabalhando no Windows, espere problemas
, Windows (cmd.exe) 437 ( DOSLatinUS), — . , UTF-8 , :

 chcp 65001 

Java , , . :

 java -Dfile.encoding=UTF-8 -Duser.language=ru -jar path_to_project\Dice\target\dice-1.0-jar-with-dependencies.jar 

, , Unicode- (, Lucida Console)

Depois de todas as nossas emocionantes aventuras, o resultado pode ser demonstrado com orgulho ao público em geral e declarado em voz alta: "Nós não somos cães!"

Opção fiel racial


E isso é bom.

Passo Treze Juntando tudo


, , , . -, , . -, , . -, ( , ), , , — ( , ?).

. , main() , . Vamos precisar de:

  • roteiro e terreno;
  • Heróis
  • implementação de interface GameInteractor;
  • implementação de interfaces GameRenderere StringLoader;
  • implementação de interfaces SoundPlayere MusicPlayer;
  • classe de objecto Game;
  • uma garrafa de champanhe.

Vamos lá!

 fun main(args: Array<String>) { Audio.init(BasicSoundPlayer(), BasicMusicPlayer()) val loader = PropertiesStringLoader(Locale.getDefault()) val renderer = ConsoleGameRenderer(loader) val interactor = ConsoleGameInteractor() val template = TestScenarioTemplate() val scenario = generateScenario(template, 1) val locations = generateLocations(template, 1, heroes.size) val heroes = listOf( generateHero(Hero.Type.BRAWLER, "Brawler"), generateHero(Hero.Type.HUNTER, "Hunter") ) val game = Game(renderer, interactor, scenario, locations, heroes) game.start() } 

Lançamos e desfrutamos do primeiro protótipo em funcionamento. Lá vai você.

Passo quatorze. Saldo do jogo


Ummm ...

Passo quinze. Testes


Agora que a maior parte do código do primeiro protótipo em funcionamento foi escrita, seria bom adicionar alguns testes de unidade ...

"Como? Agora mesmo? Sim, os testes tiveram que ser escritos no início e depois o código! ”

Muitos leitores percebem, com razão, que escrever testes de unidade deve preceder o desenvolvimento de código de trabalho ( TDD ). : , - , . - : « , — »… . ( ), . , ( , ), , (, ), , (-, ), , — .

Vamos apenas dizer: muitos programadores (especialmente iniciantes) negligenciam os testes. Muitos se justificam dizendo que a funcionalidade de seus aplicativos é mal coberta por testes. Por exemplo, é muito mais fácil iniciar o aplicativo e ver se tudo está em ordem com a aparência e a interação, em vez de cercar construções complexas com a participação de estruturas especializadas para testar a interface do usuário (e existem). E eu vou lhe dizer quando eu estava implementando as interfaces Renderer- fiz exatamente isso. No entanto, existem métodos no nosso código para os quais o conceito de teste de unidade é ótimo.

Por exemplo, geradores. E isso é tudo. Esta é uma caixa preta ideal: modelos são enviados para a entrada, objetos do mundo do jogo são obtidos na saída. Há algo acontecendo lá dentro, mas precisamos testá-lo. Por exemplo, assim:

 public class DieGeneratorTest { @Test public void testGetMaxLevel() { assertEquals("Max level should be 3", 3, DieGeneratorKt.getMaxLevel()); } @Test public void testDieGenerationSize() { DieTypeFilter filter = new SingleDieTypeFilter(Die.Type.ALLY); List<? extends List<Integer>> allowedSizes = Arrays.asList( null, Arrays.asList(4, 6, 8), Arrays.asList(4, 6, 8, 10), Arrays.asList(6, 8, 10, 12) ); IntStream.rangeClosed(1, 3).forEach(level -> { for (int i = 0; i < 10; i++) { int size = DieGeneratorKt.generateDie(filter, level).getSize(); assertTrue("Incorrect level of die generated: " + size, allowedSizes.get(level).contains(size)); assertTrue("Incorrect die size: " + size, size >= 4); assertTrue("Incorrect die size: " + size, size <= 12); assertTrue("Incorrect die size: " + size, size % 2 == 0); } }); } @Test public void testDieGenerationType() { List<Die.Type> allowedTypes1 = Arrays.asList(Die.Type.PHYSICAL); List<Die.Type> allowedTypes2 = Arrays.asList(Die.Type.PHYSICAL, Die.Type.SOMATIC, Die.Type.MENTAL, Die.Type.VERBAL); List<Die.Type> allowedTypes3 = Arrays.asList(Die.Type.ALLY, Die.Type.VILLAIN, Die.Type.ENEMY); for (int i = 0; i < 10; i++) { Die.Type type1 = DieGeneratorKt.generateDie(new SingleDieTypeFilter(Die.Type.PHYSICAL), 1).getType(); assertTrue("Incorrect die type: " + type1, allowedTypes1.contains(type1)); Die.Type type2 = DieGeneratorKt.generateDie(new StatsDieTypeFilter(), 1).getType(); assertTrue("Incorrect die type: " + type2, allowedTypes2.contains(type2)); Die.Type type3 = DieGeneratorKt.generateDie(new MultipleDieTypeFilter(Die.Type.ALLY, Die.Type.VILLAIN, Die.Type.ENEMY), 1).getType(); assertTrue("Incorrect die type: " + type3, allowedTypes3.contains(type3)); } } } 

Ou então:

 public class BagGeneratorTest { @Test public void testGenerateBag() { BagTemplate template1 = new BagTemplate(); template1.addPlan(0, 10, new SingleDieTypeFilter(Die.Type.PHYSICAL)); template1.addPlan(5, 5, new SingleDieTypeFilter(Die.Type.SOMATIC)); template1.setFixedDieCount(null); BagTemplate template2 = new BagTemplate(); template2.addPlan(10, 10, new SingleDieTypeFilter(Die.Type.DIVINE)); template2.setFixedDieCount(5); BagTemplate template3 = new BagTemplate(); template3.addPlan(10, 10, new SingleDieTypeFilter(Die.Type.ALLY)); template3.setFixedDieCount(50); for (int i = 0; i < 10; i++) { Bag bag1 = BagGeneratorKt.generateBag(template1, 1); assertTrue("Incorrect bag size: " + bag1.getSize(), bag1.getSize() >= 5 && bag1.getSize() <= 15); assertEquals("Incorrect number of SOMATIC dice", 5, bag1.examine().stream().filter(d -> d.getType() == Die.Type.SOMATIC).count()); Bag bag2 = BagGeneratorKt.generateBag(template2, 1); assertEquals("Incorrect bag size", 5, bag2.getSize()); Bag bag3 = BagGeneratorKt.generateBag(template3, 1); assertEquals("Incorrect bag size", 50, bag3.getSize()); List<Die.Type> dieTypes3 = bag3.examine().stream().map(Die::getType).distinct().collect(Collectors.toList()); assertEquals("Incorrect die types", 1, dieTypes3.size()); assertEquals("Incorrect die types", Die.Type.ALLY, dieTypes3.get(0)); } } } 

:

 public class LocationGeneratorTest { private void testLocationGeneration(String name, LocationTemplate template) { System.out.println("Template: " + template.getName()); assertEquals("Incorrect template type", name, template.getName()); IntStream.rangeClosed(1, 3).forEach(level -> { Location location = LocationGeneratorKt.generateLocation(template, level); assertEquals("Incorrect location type", name, location.getName().get("")); assertTrue("Location not open by default", location.isOpen()); int closingDifficulty = location.getClosingDifficulty(); assertTrue("Closing difficulty too small", closingDifficulty > 0); assertEquals("Incorrect closing difficulty", closingDifficulty, template.getBasicClosingDifficulty() + level * 2); Bag bag = location.getBag(); assertNotNull("Bag is null", bag); assertTrue("Bag is empty", location.getBag().getSize() > 0); Deck<Enemy> enemies = location.getEnemies(); assertNotNull("Enemies are null", enemies); assertEquals("Incorrect enemy threat count", enemies.getSize(), template.getEnemyCardsCount()); if (bag.drawOfType(Die.Type.ENEMY) != null) { assertTrue("Enemy cards not specified", enemies.getSize() > 0); } Deck<Obstacle> obstacles = location.getObstacles(); assertNotNull("Obstacles are null", obstacles); assertEquals("Incorrect obstacle threat count", obstacles.getSize(), template.getObstacleCardsCount()); List<SpecialRule> specialRules = location.getSpecialRules(); assertNotNull("SpecialRules are null", specialRules); }); } @Test public void testGenerateLocation() { testLocationGeneration("Test Location", new TestLocationTemplate()); testLocationGeneration("Test Location 2", new TestLocationTemplate2()); } } 

«, , ! ? Java???»

. , , , . , , ( , , ). - - , : ( ?).

. , HandMaskRulee seus herdeiros? Agora imagine que, em algum momento, para usar a habilidade, o herói precisa pegar três dados da mão e os tipos desses dados estão ocupados por restrições severas (por exemplo, “o primeiro dado deve ser azul, verde ou branco, o segundo - amarelo, branco ou azul, e o terceiro - azul ou roxo "- você sente dificuldade?). Como abordar a implementação de classe? Bem ... para iniciantes, você pode decidir sobre os parâmetros de entrada e saída. Obviamente, você precisa que a classe aceite três matrizes (ou conjuntos), cada uma contendo tipos válidos para, respectivamente, o primeiro, o segundo e o terceiro cubos, respectivamente. E depois o que? Rebentando? Recursões? E se eu perder alguma coisa? Faça uma entrada profunda. Agora adie a implementação dos métodos de classe e escreva um teste - já que os requisitos são simples, compreensíveis e bem formalizáveis.E melhor escrever alguns testes ... Mas vamos considerar um, aqui como por exemplo:

 public class TripleDieHandMaskRuleTest { private Hand hand; @Before public void init() { hand = new Hand(10); hand.addDie(new Die(Die.Type.PHYSICAL, 4)); //0 hand.addDie(new Die(Die.Type.PHYSICAL, 4)); //1 hand.addDie(new Die(Die.Type.SOMATIC, 4)); //2 hand.addDie(new Die(Die.Type.SOMATIC, 4)); //3 hand.addDie(new Die(Die.Type.MENTAL, 4)); //4 hand.addDie(new Die(Die.Type.MENTAL, 4)); //5 hand.addDie(new Die(Die.Type.VERBAL, 4)); //6 hand.addDie(new Die(Die.Type.VERBAL, 4)); //7 hand.addDie(new Die(Die.Type.DIVINE, 4)); //8 hand.addDie(new Die(Die.Type.DIVINE, 4)); //9 hand.addDie(new Die(Die.Type.ALLY, 4)); //A (0) hand.addDie(new Die(Die.Type.ALLY, 4)); //B (1) } @Test public void testRule1() { HandMaskRule rule = new TripleDieHandMaskRule( hand, new Die.Type[]{Die.Type.PHYSICAL, Die.Type.SOMATIC}, new Die.Type[]{Die.Type.MENTAL, Die.Type.VERBAL}, new Die.Type[]{Die.Type.PHYSICAL, Die.Type.ALLY} ); HandMask mask = new HandMask(); assertTrue("Ally should be on", rule.isAllyPositionActive(mask, 0)); assertTrue("Ally should be on", rule.isAllyPositionActive(mask, 1)); assertTrue("Should be on", rule.isPositionActive(mask, 0)); assertTrue("Should be on", rule.isPositionActive(mask, 1)); assertTrue("Should be on", rule.isPositionActive(mask, 2)); assertTrue("Should be on", rule.isPositionActive(mask, 3)); assertTrue("Should be on", rule.isPositionActive(mask, 4)); assertTrue("Should be on", rule.isPositionActive(mask, 5)); assertTrue("Should be on", rule.isPositionActive(mask, 6)); assertTrue("Should be on", rule.isPositionActive(mask, 7)); assertFalse("Should be off", rule.isPositionActive(mask, 8)); assertFalse("Should be off", rule.isPositionActive(mask, 9)); assertFalse("Rule should not be met yet", rule.checkMask(mask)); mask.addPosition(0); assertTrue("Ally should be on", rule.isAllyPositionActive(mask, 0)); assertTrue("Ally should be on", rule.isAllyPositionActive(mask, 1)); assertTrue("Should be on", rule.isPositionActive(mask, 0)); assertTrue("Should be on", rule.isPositionActive(mask, 1)); assertTrue("Should be on", rule.isPositionActive(mask, 2)); assertTrue("Should be on", rule.isPositionActive(mask, 3)); assertTrue("Should be on", rule.isPositionActive(mask, 4)); assertTrue("Should be on", rule.isPositionActive(mask, 5)); assertTrue("Should be on", rule.isPositionActive(mask, 6)); assertTrue("Should be on", rule.isPositionActive(mask, 7)); assertFalse("Should be off", rule.isPositionActive(mask, 8)); assertFalse("Should be off", rule.isPositionActive(mask, 9)); assertFalse("Rule should not be met yet", rule.checkMask(mask)); mask.addPosition(4); assertTrue("Ally should be on", rule.isAllyPositionActive(mask, 0)); assertTrue("Ally should be on", rule.isAllyPositionActive(mask, 1)); assertTrue("Should be on", rule.isPositionActive(mask, 0)); assertTrue("Should be on", rule.isPositionActive(mask, 1)); assertTrue("Should be on", rule.isPositionActive(mask, 2)); assertTrue("Should be on", rule.isPositionActive(mask, 3)); assertTrue("Should be on", rule.isPositionActive(mask, 4)); assertFalse("Should be off", rule.isPositionActive(mask, 5)); assertFalse("Should be off", rule.isPositionActive(mask, 6)); assertFalse("Should be off", rule.isPositionActive(mask, 7)); assertFalse("Should be off", rule.isPositionActive(mask, 8)); assertFalse("Should be off", rule.isPositionActive(mask, 9)); assertFalse("Rule should not be met yet", rule.checkMask(mask)); mask.addAllyPosition(0); assertTrue("Ally should be on", rule.isAllyPositionActive(mask, 0)); assertFalse("Ally should be off", rule.isAllyPositionActive(mask, 1)); assertTrue("Should be on", rule.isPositionActive(mask, 0)); assertFalse("Should be off", rule.isPositionActive(mask, 1)); assertFalse("Should be off", rule.isPositionActive(mask, 2)); assertFalse("Should be off", rule.isPositionActive(mask, 3)); assertTrue("Should be on", rule.isPositionActive(mask, 4)); assertFalse("Should be off", rule.isPositionActive(mask, 5)); assertFalse("Should be off", rule.isPositionActive(mask, 6)); assertFalse("Should be off", rule.isPositionActive(mask, 7)); assertFalse("Should be off", rule.isPositionActive(mask, 8)); assertFalse("Should be off", rule.isPositionActive(mask, 9)); assertTrue("Rule should be met", rule.checkMask(mask)); mask.removePosition(0); assertTrue("Ally should be on", rule.isAllyPositionActive(mask, 0)); assertFalse("Ally should be off", rule.isAllyPositionActive(mask, 1)); assertTrue("Should be on", rule.isPositionActive(mask, 0)); assertTrue("Should be on", rule.isPositionActive(mask, 1)); assertTrue("Should be on", rule.isPositionActive(mask, 2)); assertTrue("Should be on", rule.isPositionActive(mask, 3)); assertTrue("Should be on", rule.isPositionActive(mask, 4)); assertFalse("Should be off", rule.isPositionActive(mask, 5)); assertFalse("Should be off", rule.isPositionActive(mask, 6)); assertFalse("Should be off", rule.isPositionActive(mask, 7)); assertFalse("Should be off", rule.isPositionActive(mask, 8)); assertFalse("Should be off", rule.isPositionActive(mask, 9)); assertFalse("Rule should not be met again", rule.checkMask(mask)); } } 

Isso é cansativo, mas não tanto quanto parece, até você começar (em algum momento, torna-se ainda mais divertido). Mas, depois de escrever esse teste (e alguns outros, em diferentes ocasiões), você subitamente se sentirá calmo e autoconfiante. Agora, nenhum erro de digitação pode estragar seu método e levar a surpresas desagradáveis ​​que são muito mais difíceis de testar manualmente. Pouco a pouco, lentamente, começamos a implementar os métodos necessários da classe. E no final, executamos o teste para garantir que cometemos algum erro. Encontre o ponto do problema e reescreva. Repita até terminar.

 class TripleDieHandMaskRule( hand: Hand, types1: Array<Die.Type>, types2: Array<Die.Type>, types3: Array<Die.Type>) : HandMaskRule(hand) { private val types1 = types1.toSet() private val types2 = types2.toSet() private val types3 = types3.toSet() override fun checkMask(mask: HandMask): Boolean { if (mask.positionCount + mask.allyPositionCount != 3) { return false } return getCheckedDice(mask).asSequence() .filter { it.type in types1 } .any { d1 -> getCheckedDice(mask) .filter { d2 -> d2 !== d1 } .filter { it.type in types2 } .any { d2 -> getCheckedDice(mask) .filter { d3 -> d3 !== d1 } .filter { d3 -> d3 !== d2 } .any { it.type in types3 } } } } override fun isPositionActive(mask: HandMask, position: Int): Boolean { if (mask.checkPosition(position)) { return true } val die = hand.dieAt(position) ?: return false return when (mask.positionCount + mask.allyPositionCount) { 0 -> die.type in types1 || die.type in types2 || die.type in types3 1 -> with(getCheckedDice(mask).first()) { (this.type in types1 && (die.type in types2 || die.type in types3)) || (this.type in types2 && (die.type in types1 || die.type in types3)) || (this.type in types3 && (die.type in types1 || die.type in types2)) } 2-> with(getCheckedDice(mask)) { val d1 = this[0] val d2 = this[1] (d1.type in types1 && d2.type in types2 && die.type in types3) || (d2.type in types1 && d1.type in types2 && die.type in types3) || (d1.type in types1 && d2.type in types3 && die.type in types2) || (d2.type in types1 && d1.type in types3 && die.type in types2) || (d1.type in types2 && d2.type in types3 && die.type in types1) || (d2.type in types2 && d1.type in types3 && die.type in types1) } 3 -> false else -> false } } override fun isAllyPositionActive(mask: HandMask, position: Int): Boolean { if (mask.checkAllyPosition(position)) { return true } if (hand.allyDieAt(position) == null) { return false } return when (mask.positionCount + mask.allyPositionCount) { 0 -> ALLY in types1 || ALLY in types2 || ALLY in types3 1 -> with(getCheckedDice(mask).first()) { (this.type in types1 && (ALLY in types2 || ALLY in types3)) || (this.type in types2 && (ALLY in types1 || ALLY in types3)) || (this.type in types3 && (ALLY in types1 || ALLY in types2)) } 2-> with(getCheckedDice(mask)) { val d1 = this[0] val d2 = this[1] (d1.type in types1 && d2.type in types2 && ALLY in types3) || (d2.type in types1 && d1.type in types2 && ALLY in types3) || (d1.type in types1 && d2.type in types3 && ALLY in types2) || (d2.type in types1 && d1.type in types3 && ALLY in types2) || (d1.type in types2 && d2.type in types3 && ALLY in types1) || (d2.type in types2 && d1.type in types3 && ALLY in types1) } 3 -> false else -> false } } } 

, — . , .

« <...> <...> <...> <...>. ! <...> ! <...> !»

.


, — , . , . .

. :

  • : , , - ( core );
  • , , — «» ( adventure );
  • , : — ( cli ).

:

,


Crie projetos adicionais e transfira a classe correspondente. E só precisamos configurar corretamente a interação dos projetos entre si. Projeto

principal
Este projeto é um mecanismo puro. Todas as classes específicas foram transferidas para outros projetos - apenas a funcionalidade básica, o núcleo, permaneceu. Biblioteca se você quiser. Não há mais uma classe de inicialização, não há nem a necessidade de criar um pacote. As montagens deste projeto serão hospedadas no repositório local do Maven (mais sobre isso mais tarde) e usadas por outros projetos como dependências.

O arquivo pom.xmlé o seguinte:

 <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>my.company</groupId> <artifactId>dice-core</artifactId> <version>1.0</version> <packaging>jar</packaging> <dependencies> <dependency> <groupId>org.jetbrains.kotlin</groupId> <artifactId>kotlin-stdlib</artifactId> <version>${kotlin.version}</version> </dependency> <dependency> <groupId>junit</groupId> <artifactId>junit-dep</artifactId> <version>4.8.2</version> <scope>test</scope> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.jetbrains.kotlin</groupId> <!-- other Kotlin setup --> </plugin> </plugins> </build> <properties> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <maven.compiler.source>1.8</maven.compiler.source> <maven.compiler.target>1.8</maven.compiler.target> <kotlin.version>1.3.20</kotlin.version> <kotlin.compiler.incremental>true</kotlin.compiler.incremental> </properties> </project> 

A partir de agora iremos coletá-lo assim:

 mvn -f "path_to_project/DiceCore/pom.xml" install 

Cli
— . . , , ( , — ). (, .).

pom.xml :

 <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>my.company</groupId> <artifactId>dice-cli</artifactId> <version>1.0</version> <packaging>jar</packaging> <dependencies> <dependency> <groupId>org.jetbrains.kotlin</groupId> <artifactId>kotlin-stdlib</artifactId> <version>${kotlin.version}</version> </dependency> <dependency> <groupId>my.company</groupId> <artifactId>dice-core</artifactId> <version>1.0</version> <scope>compile</scope> </dependency> <dependency> <groupId>org.fusesource.jansi</groupId> <artifactId>jansi</artifactId> <version>1.17.1</version> <scope>compile</scope> </dependency> <dependency> <groupId>jline</groupId> <artifactId>jline</artifactId> <version>2.14.6</version> <scope>compile</scope> </dependency> <dependency> <groupId>com.googlecode.soundlibs</groupId> <artifactId>jlayer</artifactId> <version>1.0.1.4</version> <scope>compile</scope> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.jetbrains.kotlin</groupId> <!-- other Kotlin setup --> </plugin> <plugin> <artifactId>maven-assembly-plugin</artifactId> <version>2.6</version> <executions> <execution> <phase>package</phase> <goals> <goal>single</goal> </goals> </execution> </executions> <configuration> <descriptorRefs> <descriptorRef>jar-with-dependencies</descriptorRef> </descriptorRefs> <archive> <manifest> <mainClass>my.company.dice.MainKt</mainClass> </manifest> </archive> </configuration> </plugin> </plugins> </build> <properties> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <maven.compiler.source>1.8</maven.compiler.source> <maven.compiler.target>1.8</maven.compiler.target> <kotlin.version>1.3.20</kotlin.version> <kotlin.compiler.incremental>true</kotlin.compiler.incremental> </properties> </project> 

— .


Bem, finalmente, em um projeto separado, retiramos o enredo. Ou seja, todos os cenários, terrenos, inimigos e outros objetos únicos do mundo do jogo que a equipe do departamento de cenários da sua empresa possa imaginar (bem, ou até agora apenas nossa própria imaginação doentia - ainda somos o único designer de jogos na área). A idéia é agrupar os scripts em conjuntos (aventuras) e distribuir cada um desses conjuntos como um projeto separado (semelhante à maneira como é feito no mundo dos jogos de tabuleiro e de vídeo). Ou seja, colete os arquivos jar e coloque-os em uma pasta separada para que o mecanismo do jogo verifique essa pasta e conecte automaticamente todas as aventuras contidas nela. No entanto, a implementação técnica dessa abordagem está repleta de enormes dificuldades.

Por onde começar? Bem, primeiro, pelo fato de distribuirmos modelos na forma de classes java específicas (sim, me bata e me repreenda - eu previ isso). E, nesse caso, essas classes devem estar no caminho de classe do aplicativo na inicialização. Impor esse requisito não é difícil - você registra explicitamente seus arquivos jar na variável de ambiente apropriada (a partir do Java 6, você pode até usar caracteres curinga * ).

 java -classpath "path_to_project/DiceCli/target/adventures/*" -jar path_to_project/DiceCli/target/dice-1.0-jar-with-dependencies.jar 

«, ? -jar -classpath !»

. Classpath jar- META-INF/MANIFEST.MF ( — Claspath: ). , ( maven-compiler-plugin , , maven-assembly-plugin ). wildcards , , — jar-. , , .

, . , . adventures/você pode jogar qualquer número de aventuras e, assim, todas elas ficaram visíveis para o mecanismo do jogo durante a execução. Infelizmente, a funcionalidade aparentemente óbvia vai além das representações padrão do mundo Java. Portanto, não é bem-vindo. É preciso adotar uma abordagem diferente para espalhar a aventura independente. Qual? Não sei, escreva nos comentários - com certeza alguém tem idéias inteligentes.

Enquanto isso, não há idéias. Aqui está um pequeno (ou grande, dependendo da sua aparência), que permite adicionar dinamicamente dependências ao caminho de classe sem nem mesmo saber seus nomes e sem precisar recompilar o projeto:

No Windows:

 @ECHO OFF call "path_to_maven\mvn.bat" -f "path_to_project\DiceCore\pom.xml" install call "path_to_maven\mvn.bat" -f "path_to_project\DiceCli\pom.xml" package call "path_to_maven\mvn.bat" -f "path_to_project\TestAdventure\pom.xml" package mkdir path_to_project\DiceCli\target\adventures copy "path_to_project\TestAdventure\target\test-adventure-1.0.jar" path_to_project\DiceCli\target\adventures\ chcp 65001 cd path_to_project\DiceCli\target\ java -Dfile.encoding=UTF-8 -cp "dice-cli-1.0-jar-with-dependencies.jar;adventures\*" my.company.dice.MainKt pause 

E no Unix:

 #!/bin/sh mvn -f "path_to_project/DiceCore/pom.xml" install mvn -f "path_to_project/DiceCli/pom.xml" package mvn -f "path_to_project/TestAdventure/pom.xml" package mkdir path_to_project/DiceCli/target/adventures cp path_to_project/TestAdventure/target/test-adventure-1.0.jar path_to_project/DiceCli/target/adventures/ cd path_to_project/DiceCli/target/ java -cp "dice-cli-1.0-jar-with-dependencies.jar:adventures/*" my.company.dice.MainKt 

. -jar Cli classpath MainKt . adventures/ .

, — , . . Please . (ಥ﹏ಥ)

.


Um pouco de letra.
Nosso artigo é sobre o lado técnico do fluxo de trabalho, mas os jogos não são apenas códigos de software. São mundos emocionantes, com eventos interessantes e personagens animados, nos quais você mergulha com a cabeça, renunciando ao mundo real. Cada um desses mundos é incomum à sua maneira e interessante à sua maneira, muitos dos quais você ainda se lembra, depois de muitos anos. Se você deseja que seu mundo seja lembrado também com sentimentos calorosos, torne-o incomum e interessante.

, , -, - ( , ?). , ( ), , - , ( , ) ( ). , — .

— .

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

— , . , , .

, , - . , , , , . .

, , , , , , . , . , : , , , — : /, . ( ) (, ).

Eu queria me afastar dos clichês da trama e dos bens de consumo de fantasia - todos esses elfos, gnomos, dragões, senhores negros e o mal absoluto do mundo (assim como: heróis selecionados, profecias antigas, super artefatos, batalhas épicas ... embora o último possa ser deixado). Eu também realmente queria dar vida ao mundo, para que cada personagem encontrado (mesmo um menor) tenha sua própria história e motivação, que elementos da mecânica do jogo se encaixem nas leis do mundo, que o desenvolvimento de heróis ocorra naturalmente, que a presença de inimigos e obstáculos nos locais seja logicamente justificada pelas características do próprio local. ... e assim por diante. Infelizmente, esse desejo jogou uma piada cruel, retardando muito o processo de desenvolvimento e nem sempre era possível se afastar das convenções de jogos. No entanto, a satisfação com o produto final acabou sendo uma ordem de magnitude maior.

? — , , : , — . - , , .

O que vem a seguir?


Programação adicional termina e o design do jogo começa . Agora é hora de não escrever o código, mas pensar em cenários, locais, inimigos - você entende, todo esse lixo. Se você ainda trabalha sozinho, eu o parabenizo - você chegou ao estágio em que a maioria dos projetos de jogos se apressa. Nos grandes estúdios da AAA, pessoas especiais trabalham como designers e roteiristas que recebem dinheiro por isso - elas simplesmente não têm para onde ir. Mas temos muitas opções: dar um passeio, comer, dormir de uma maneira banal - mas o que pode ser, até começar um novo projeto usando a experiência e o conhecimento acumulados.

, . , , — - . (-, ), . . , , , - — . « » (roadmap) , ( ) . ( ) ( ). ( , ) . : , ( , ) , , ( ) — , , . , , . , — , .

« -, ?»

, . — , : « ?». , , : « ?». ( , ) . , (challenge) . , , , - - . , — , . , … .

Em outras palavras, o jogo precisa ser equilibrado. Isto é especialmente verdade no jogo de tabuleiro, onde as regras são claramente formalizadas. Como fazer isso? . -, ( , ) ( ), — playtesting . . — . , , , . — . , , : « feedback!». , - , , — ( , ?) (-).

Brincadeiras à parte, desejo a nós ... todos vocês sucesso. Leia mais (quem pensaria!) - sobre design de jogos e muito mais. Todas as questões que examinamos já foram abordadas de uma maneira ou de outra em artigos e literatura (embora, se você ainda estiver aqui, seja obviamente desnecessário insistir para que você leia). Compartilhe suas impressões, comunique-se nos fóruns - em geral, você já me conhece cada vez melhor. Não seja preguiçoso e você terá sucesso.

Nesta nota otimista, deixe-me despedir-se. Obrigado a todos pela atenção. Até breve!

“Eh! Que te vê? Como agora lançar tudo isso em um telefone celular? Eu esperei em vão, ou o quê?

Posfácio. Android


, Game , MainMenu . , , .



Game , , . . — «Exit».



, ? Sobre isso e fala. .

 class MainMenu( private val renderer: MenuRenderer, private val interactor: MenuInteractor ) { private var actions = ActionList.EMPTY fun start() { Audio.playMusic(Music.MENU_MAIN) actions = ActionList() actions.add(Action.Type.NEW_ADVENTURE) actions.add(Action.Type.CONTINUE_ADVENTURE, false) actions.add(Action.Type.MANUAL, false) actions.add(Action.Type.EXIT) processCycle() } private fun processCycle() { while (true) { renderer.drawMainMenu(actions) when (interactor.pickAction(actions).type) { Action.Type.NEW_ADVENTURE -> TODO() Action.Type.CONTINUE_ADVENTURE -> TODO() Action.Type.MANUAL -> TODO() Action.Type.EXIT -> { Audio.stopMusic() Audio.playSound(Sound.LEAVE) renderer.clearScreen() Thread.sleep(500) return } else -> throw AssertionError("Should not happen") } } } } 

A interação com o usuário é implementada usando interfaces MenuRenderere MenuInteractorfuncionando de maneira semelhante ao que foi visto anteriormente.

 interface MenuRenderer: Renderer { fun drawMainMenu(actions: ActionList) } interface Interactor { fun anyInput() fun pickAction(list: ActionList): Action } 

Como você já entendeu, separamos intencionalmente interfaces de implementações específicas. Tudo o que precisamos agora é substituir o projeto Cli por um novo projeto (vamos chamá-lo de Droid ), adicionando uma dependência ao projeto Core . Vamos fazer isso.

Execute o Android Studio (geralmente os projetos para Android são desenvolvidos nele), crie um projeto simples, removendo todo o ouropel padrão desnecessário e deixando apenas suporte para o idioma Kotlin. Também adicionamos uma dependência no projeto Core , que é armazenado no repositório local Maven da nossa máquina.

 apply plugin: 'com.android.application' apply plugin: 'kotlin-android' apply plugin: 'kotlin-android-extensions' android { compileSdkVersion 28 defaultConfig { applicationId "my.company.dice" minSdkVersion 14 targetSdkVersion 28 versionCode 1 versionName "1.0" } } dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" implementation "my.company:dice-core:1.0" } 

Por padrão, no entanto, ninguém verá nossa dependência - você deve indicar explicitamente a necessidade de usar um repositório local (mavenLocal) ao criar o projeto.

 buildscript { ext.kotlin_version = '1.3.20' repositories { google() jcenter() mavenLocal() } dependencies { classpath 'com.android.tools.build:gradle:3.3.0' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } allprojects { repositories { google() jcenter() mavenLocal() } } 

, , — . , , : SoundPlayer , MusicPlayer , MenuInteractor ( GameInteractor ), MenuRenderer ( GameRenderer ) StringLoader , , . , .

(, , ) Android — Canvas . - View — «». , , , . View — , ( , ).

View .

 <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="my.company.dice"> <application android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:theme="@style/AppTheme"> <activity android:name=".ui.MainActivity" android:screenOrientation="sensorLandscape" android:configChanges="orientation|keyboardHidden|screenSize"> <intent-filter> <category android:name="android.intent.category.LAUNCHER"/> <action android:name="android.intent.action.MAIN"/> </intent-filter> </activity> </application> </manifest> 

— , . , , .

 <resources> <style name="AppTheme" parent="android:Theme.Black.NoTitleBar.Fullscreen"/> </resources> 

E desde que entramos em recursos, transferimos as seqüências localizadas necessárias do projeto Cli , trazendo-as para o formato desejado:

 <resources> <string name="action_new_adventure_key">N</string> <string name="action_new_adventure_name">ew adventure</string> <string name="action_continue_adventure_key">C</string> <string name="action_continue_adventure_name">ontinue adventure</string> <string name="action_manual_key">M</string> <string name="action_manual_name">anual</string> <string name="action_exit_key">X</string> <string name="action_exit_name">Exit</string> </resources> 

Além dos arquivos de sons e músicas usados ​​no menu principal (um de cada tipo), coloque-os em /assets/sound/leave.wave, /assets/music/menu_main.mp3respectivamente.

Quando separamos os recursos, estava na hora de começar a projetar (sim, novamente). Diferentemente do console, a plataforma Android tem seus próprios recursos arquitetônicos, o que nos obriga a usar abordagens e métodos específicos.

Diagrama de classe e interface


, , .

, , — DiceSurfaceView , ( SurfaceViewGlSurfaceView — , , , , ). , : , . .

Quando pintamos no console, nosso Renderer enviou comandos de saída e formou uma imagem na tela. No caso do Android, a situação é oposta - a renderização é iniciada pelo próprio View, que no momento em que o método onDraw()é executado já deve saber o que, como e para onde desenhar. Mas e o método de drawMainMenu()interface MainMenu? Ele não controla a saída agora?

Vamos tentar resolver esse problema com a ajuda de interfaces funcionais. A classe DiceSurfaceconterá um parâmetro especial instructions- na verdade, um bloco de código que deve ser executado toda vez que o método for chamado onDraw(). O renderizador, usando um método público, indicará quais instruções específicas devem ser seguidas. Se você está interessado, use um padrão chamado de estratégia (estratégia). É assim:

 typealias RenderInstructions = (Canvas, Paint) -> Unit class DiceSurface(context: Context) : View(context) { private var instructions: RenderInstructions = { _, _ -> } private val paint = Paint().apply { color = Color.YELLOW style = Paint.Style.STROKE isAntiAlias = true } fun updateInstructions(instructions: RenderInstructions) { this.instructions = instructions this.postInvalidate() } override fun onDraw(canvas: Canvas) { super.onDraw(canvas) canvas.drawColor(Color.BLACK) //Fill background with black color instructions.invoke(canvas, paint) //Execute current render instructions } } class DroidMenuRenderer(private val surface: DiceSurface): MenuRenderer { override fun clearScreen() { surface.updateInstructions { _, _ -> } } override fun drawMainMenu(actions: ActionList) { surface.updateInstructions { c, p -> val canvasWidth = c.width val canvasHeight = c.height //Draw title text p.textSize = canvasHeight / 3f p.strokeWidth = 0f p.color = Color.parseColor("#ff808000") c.drawText( "DICE", (canvasWidth - p.measureText("DICE")) / 2f, (buttonTop - p.ascent() - p.descent()) / 2f, p ) //Other instructions... } } } 

Ou seja, toda a funcionalidade gráfica ainda está na classe Renderer, mas desta vez não executamos diretamente os comandos, mas os preparamos para execução pela nossa View. Preste atenção ao tipo de propriedade instructions- você pode criar uma interface separada e chamar seu único método, mas o Kotlin pode reduzir significativamente a quantidade de código.

Interactor. : (), () , . — Looper, , . Interactor - , Activity View , .

BlockingQueue . DroidMenuInteractor take() , , ( Action ). DiceSurface , , ( onTouchEvent() View ), offer() . :

 class DiceSurface(context: Context) : View(context) { private val actionQueue: BlockingQueue<Action> = LinkedBlockingQueue<Action>() fun awaitAction(): Action = actionQueue.take() override fun onTouchEvent(event: MotionEvent): Boolean { if (event.action == MotionEvent.ACTION_UP) { actionQueue.offer(Action(Action.Type.NONE), 200, TimeUnit.MILLISECONDS) } return true } } class DroidMenuInteractor(private val surface: DiceSurface) : Interactor { override fun anyInput() { surface.awaitAction() } override fun pickAction(list: ActionList): Action { while (true) { val type = surface.awaitAction().type list .filter(Action::isEnabled) .find { it.type == type } ?.let { return it } } } } 

, Interactor awaitAction() - , . , . UI- , , , (, ). / .

Claro, nós meio que transferimos os comandos, mas apenas o primeiro. Precisamos distinguir entre as coordenadas de pressionar e, dependendo de seus valores, chame este ou aquele comando. No entanto, isso é uma má sorte - o Interactor não tem idéia de em que lugar da tela os botões ativos são desenhados - o Renderer é responsável pela renderização. Estabeleceremos sua interação da seguinte maneira. A classe DiceSurfacearmazenará uma coleção especial - uma lista de retângulos ativos (ou outras formas, se chegarmos a esse ponto). Esses retângulos contêm as coordenadas dos vértices e o limite Action. O renderizador gera esses retângulos e os adiciona à lista, o método onTouchEvent()determina qual dos retângulos foi pressionado e adiciona o correspondente à fila Action.

 private class ActiveRect(val action: Action, left: Float, top: Float, right: Float, bottom: Float) { val rect = RectF(left, top, right, bottom) fun check(x: Float, y: Float, w: Float, h: Float) = rect.contains(x / w, y / h) } 

O método check()é responsável por verificar se as coordenadas especificadas estão dentro do retângulo. Observe que, no estágio do trabalho do renderizador (e esse é exatamente o momento em que os retângulos são criados), não temos a menor idéia sobre o tamanho da tela. Portanto, teremos que armazenar as coordenadas em valores relativos (porcentagem da largura ou altura da tela) com valores de 0 a 1 e recontar no momento da pressão. Essa abordagem não é totalmente precisa, pois não leva em consideração a proporção - no futuro, ela terá que ser refeita. No entanto, para nossa tarefa educacional a princípio, ela será suficiente.

Implementaremos DiceSurfaceum campo adicional na classe , adicionaremos dois métodos ( addRectangle()e clearRectangles()) para controlá-lo do lado de fora (do lado do renderizador) e expandiremos onTouchEvent()forçando as coordenadas dos retângulos em consideração.

 class DiceSurface(context: Context) : View(context) { private val actionQueue: BlockingQueue<Action> = LinkedBlockingQueue<Action>() private val rectangles: MutableSet<ActiveRect> = Collections.newSetFromMap(ConcurrentHashMap<ActiveRect, Boolean>()) private var instructions: RenderInstructions = { _, _ -> } private val paint = Paint().apply { color = Color.YELLOW style = Paint.Style.STROKE isAntiAlias = true } fun updateInstructions(instructions: RenderInstructions) { this.instructions = instructions this.postInvalidate() } fun clearRectangles() { rectangles.clear() } fun addRectangle(action: Action, left: Float, top: Float, right: Float, bottom: Float) { rectangles.add(ActiveRect(action, left, top, right, bottom)) } fun awaitAction(): Action = actionQueue.take() override fun onTouchEvent(event: MotionEvent): Boolean { if (event.action == MotionEvent.ACTION_UP) { with(rectangles.firstOrNull { it.check(event.x, event.y, width.toFloat(), height.toFloat()) }) { if (this != null) { actionQueue.put(action) } else { actionQueue.offer(Action(Action.Type.NONE), 200, TimeUnit.MILLISECONDS) } } } return true } override fun onDraw(canvas: Canvas) { super.onDraw(canvas) canvas.drawColor(Color.BLACK) instructions(canvas, paint) } } 

Uma coleção competitiva é usada para armazenar os retângulos - permitirá evitar a ocorrência ConcurrentModificationExceptionse o conjunto for atualizado e movido ao mesmo tempo por threads diferentes (o que, no nosso caso, ocorrerá).

O código da classe DroidMenuInteractorpermanecerá inalterado, mas DroidMenuRendererserá alterado. Adicione quatro botões à exibição para cada item ActionList. Coloque-os sob o título DICE, distribuídos uniformemente por toda a largura da tela. Bem, não vamos esquecer os retângulos ativos.

 class DroidMenuRenderer ( private val surface: DiceSurface, private val loader: StringLoader ) : MenuRenderer { protected val helper = StringLoadHelper(loader) override fun clearScreen() { surface.clearRectangles() surface.updateInstructions { _, _ -> } } override fun drawMainMenu(actions: ActionList) { //Prepare rectangles surface.clearRectangles() val percentage = 1.0f / actions.size actions.forEachIndexed { i, a -> surface.addRectangle(a, i * percentage, 0.45f, i * percentage + percentage, 1f) } //Prepare instructions surface.updateInstructions { c, p -> val canvasWidth = c.width val canvasHeight = c.height val buttonTop = canvasHeight * 0.45f val buttonWidth = canvasWidth / actions.size val padding = canvasHeight / 144f //Draw title text p.textSize = canvasHeight / 3f p.strokeWidth = 0f p.color = Color.parseColor("#ff808000") p.isFakeBoldText = true c.drawText( "DICE", (canvasWidth - p.measureText("DICE")) / 2f, (buttonTop - p.ascent() - p.descent()) / 2f, p ) p.isFakeBoldText = false //Draw action buttons p.textSize = canvasHeight / 24f actions.forEachIndexed { i, a -> p.color = if (a.isEnabled) Color.YELLOW else Color.LTGRAY p.strokeWidth = canvasHeight / 240f c.drawRect( i * buttonWidth + padding, buttonTop + padding, i * buttonWidth + buttonWidth - padding, canvasHeight - padding, p ) val name = mergeActionData(helper.loadActionData(a)) p.strokeWidth = 0f c.drawText( name, i * buttonWidth + (buttonWidth - p.measureText(name)) / 2f, (canvasHeight + buttonTop - p.ascent() - p.descent()) / 2f, p ) } } } private fun mergeActionData(data: Array<String>) = if (data.size > 1) { if (data[1].first().isLowerCase()) data[0] + data[1] else data[1] } else data.getOrNull(0) ?: "" } 

Aqui, retornamos novamente à interface StringLoadere aos recursos da classe auxiliar StringLoadHelper(não mostrada no diagrama). A implementação do primeiro tem um nome ResourceStringLoadere está envolvida no carregamento de cadeias localizadas a partir de (obviamente) recursos de aplicativos. No entanto, faz isso dinamicamente, uma vez que não conhecemos os identificadores de recursos com antecedência - somos forçados a construí-los em movimento.

 class ResourceStringLoader(context: Context) : StringLoader { private val packageName = context.packageName private val resources = context.resources override fun loadString(key: String): String = resources.getString(resources.getIdentifier(key, "string", packageName)) } 

Resta falar sobre sons e música. Há uma classe maravilhosa no Android MediaPlayerque lida com essas coisas. Não há nada melhor para tocar música:

 class DroidMusicPlayer(private val context: Context): MusicPlayer { private var currentMusic: Music? = null private val player = MediaPlayer() override fun play(music: Music) { if (currentMusic == music) { return } currentMusic = music player.setAudioStreamType(AudioManager.STREAM_MUSIC) val afd = context.assets.openFd("music/${music.toString().toLowerCase()}.mp3") player.setDataSource(afd.fileDescriptor, afd.startOffset, afd.length) player.setOnCompletionListener { it.seekTo(0) it.start() } player.prepare() player.start() } override fun stop() { currentMusic = null player.release() } } 

Dois pontos Primeiro, o método prepare()é executado de forma síncrona, que com um tamanho de arquivo grande (devido ao buffer) suspenderá o sistema. É recomendável que você o execute em um thread separado ou use o método assíncrono prepareAsync()e OnPreparedListener. Em segundo lugar, seria bom associar a reprodução ao ciclo de vida da atividade (pausa quando o usuário minimiza o aplicativo e retoma durante a recuperação), mas não o fizemos. Ai-ai-ai ...

Também é MediaPlayeradequado para sons , mas se forem poucos e simples (como no nosso caso), serão adequados SoundPool. Sua vantagem é que, quando os arquivos de som já estão carregados na memória, a reprodução é iniciada instantaneamente. A desvantagem é óbvia - pode não haver memória suficiente (mas suficiente para nós, somos modestos).

 class DroidSoundPlayer(context: Context) : SoundPlayer { private val soundPool: SoundPool = SoundPool(2, AudioManager.STREAM_MUSIC, 100) private val sounds = mutableMapOf<Sound, Int>() private val rate = 1f private val lock = ReentrantReadWriteLock() init { Thread(SoundLoader(context)).start() } override fun play(sound: Sound) { if (lock.readLock().tryLock()) { try { sounds[sound]?.let { s -> soundPool.play(s, 1f, 1f, 1, 0, rate) } } finally { lock.readLock().unlock() } } } private inner class SoundLoader(private val context: Context) : Runnable { override fun run() { val assets = context.assets lock.writeLock().lock() try { Sound.values().forEach { s -> sounds[s] = soundPool.load( assets.openFd("sound/${s.toString().toLowerCase()}.wav"), 1 ) } } finally { lock.writeLock().unlock() } } } } 

Sound . , ReentrantReadWriteLock .

- MainActivity — ? , MainMenu ( Game ) .

 class MainActivity : Activity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) Audio.init(DroidSoundPlayer(this), DroidMusicPlayer(this)) val surface = DiceSurface(this) val renderer = DroidMenuRenderer(surface) val interactor = DroidMenuInteractor(surface, ResourceStringLoader(this)) setContentView(surface) Thread { MainMenu(renderer, interactor).start() finish() }.start() } override fun onBackPressed() { } } 

Isso, de fato, é tudo. :



, , , .

Links úteis


Eu sei, muitos rolaram direto para esse ponto. Tudo bem - a maioria dos leitores fechou a guia completamente. Aquelas unidades que, no entanto, resistiram a todo esse fluxo de conversas incoerentes - respeito e respeito, amor e gratidão infinitos. Bem e links, é claro, onde sem eles. Primeiro, para o código-fonte dos projetos (lembre-se de que o estado atual dos projetos foi muito além do considerado no artigo):


- , , : !

launcher ( ). JavaFX OpenJDK ( — ), . readme.txt ( ?). , , , .

Se você estiver interessado em um projeto, ferramenta usada, mecânica ou alguma solução interessante ou, não sei, jogos de histórias, você pode examiná-lo com mais detalhes em outro artigo. Se você quiser E se você não quiser, basta enviar comentários, arrependimentos e sugestões. Ficarei feliz em conversar.

Tudo de bom.

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


All Articles