Pular para o conteúdo

5. Adicione turnos e o goblin

Fonte atual: curriculum/arrowfall/checkpoints/05-goblin · Flecha 0.3.0 · 5/7.

O programa neste checkpoint

Até aqui: O checkpoint 04 tem paredes, saída, turnos, módulos separados e seu primeiro teste.

O checkpoint 04 torna movimento significativo, mas a rota ideal nunca muda e vida não teria função. Um goblin adiciona pressão, desde que o jogador consiga entender exatamente quando o inimigo pode mover ou atacar.

Este checkpoint define esse tempo como dado do motor. Um turno efetivo do jogador permite uma resposta do goblin. Entrar em perigo gera aviso; dano só é possível quando o jogador começa uma ação posterior já adjacente e não consegue escapar.

Limitação atual

A masmorra é segura, então a rota mais curta é automática e HP não significaria nada.

Próximo comportamento necessário: Adicionar pressão justa: um goblin responde uma vez por turno efetivo, avisa primeiro e só depois ataca.

Modelo e invariantes

Retornar TurnResult com eventos do jogador/inimigo e se o tempo avançou. A CLI renderiza fatos do motor em vez de adivinhar.

TurnResult registra todas as consequências de um comando em um valor. Métodos privados mantêm perseguição e dano dentro de Game, e a CLI apenas renderiza os eventos retornados.

TurnResult é o resultado observável completo de um comando: evento do jogador, evento do inimigo e spentTurn. A CLI exibe esses fatos, mas nunca recalcula adjacência, custo de turno ou dano. Manter a política em Game impede que o texto do terminal vire um segundo motor de regras.

Game::apply captura DANGER_BEFORE antes de tentar a ação do jogador. Esse snapshot é o contrato temporal. Nova adjacência criada pela ação atual pode avisar, mas não autoriza retroativamente um ataque no mesmo turno.

spentTurn separa tempo de entrada. Help, Quit e colisão segura não avançam o goblin. Uma ação bloqueada quando já há perigo gasta tempo, pois não fazer nada sob ameaça imediata ainda é a resposta do jogador naquele turno.

goblinTurn considera primeiro ataque autorizado, depois adjacência atual e por último movimento. Essa ordem garante “avisar antes de ferir” e “mover ou atacar, nunca os dois”. tryGoblinStep também recusa jogador, paredes e saída.

A perseguição prioriza progresso horizontal antes do vertical nesta versão. Variedade fica adiada; o checkpoint final muda a ordem de empate com seed sem alterar o contrato de turno.

Implementação

A implementação está dividida nas mudanças abaixo. Cada trecho vem literalmente do checkpoint qualificado e é explicado na parte do fluxo de dados em que participa. Os trechos não são programas independentes; o código completo compilado está disponível depois da execução.

  • Adicionar goblin, HP, derrota e ameaça.
  • Distinguir no-ops gratuitos de turnos gastos.
  • Testar aviso antes do dano.

Eventos tornam o turno observável

TurnResult nomeia as três informações que o chamador precisa depois de apply. O resultado é um valor, não estado compartilhado mutável, então o renderer recebe um relato coerente de um comando.

Trecho: src/game.fl:47–80. O arquivo completo aparece na seção de código abaixo.

src/game.fl:47-80 (excerpt)
i32 x;
i32 y;
};
export structure TurnResult {
PlayerEvent player;
EnemyEvent enemy;
bool spentTurn;
};
export structure Game {
private Position player;
private Position goblin;
private i32 playerHealth;
private u32 turnCount;
private Outcome gameOutcome;
};
TurnResult TurnResult::make(
PlayerEvent player,
EnemyEvent enemy = EnemyEvent.None,
bool spentTurn = false
) {
return build TurnResult {
self.player = player;
self.enemy = enemy;
self.spentTurn = spentTurn;
};
}
Position Position::at(i32 x, i32 y) {
return build Position {
self.x = x;
self.y = y;

PlayerEvent e EnemyEvent são separados porque os dois lados podem agir no mesmo turno efetivo. Valores padrão representam ausência de evento inimigo e ação gratuita sem exigir vários construtores.

spentTurn é explícito embora Game também guarde turnCount. O primeiro explica este comando ao chamador; o segundo registra estado acumulado. A CLI não deduz um a partir do outro.

Um comando, um TurnResult

apply registra a adjacência em DANGER_BEFORE antes de calcular next. Os ramos então distinguem no-op seguro do mesmo no-op realizado quando o goblin já estava adjacente.

Trecho: src/game.fl:108–147. O arquivo completo aparece na seção de código abaixo.

src/game.fl:108-147 (excerpt)
TurnResult Game::apply(&self, Command command) {
if command == Command.Help {
return TurnResult::make(PlayerEvent.Help);
}
if command == Command.Quit {
self->gameOutcome = Outcome.Quit;
return TurnResult::make(PlayerEvent.Quit);
}
const bool DANGER_BEFORE = adjacent(self->goblin, self->player);
next := self->player.moved(command_direction(command));
if tile_at(next) == '#' {
if !DANGER_BEFORE {
return TurnResult::make(PlayerEvent.Wall);
}
self->turnCount++;
return TurnResult::make(PlayerEvent.Wall, self->goblinTurn(true), true);
}
if next.equals(&self->goblin) {
if !DANGER_BEFORE {
return TurnResult::make(PlayerEvent.GoblinBlocks);
}
self->turnCount++;
return TurnResult::make(
PlayerEvent.GoblinBlocks,
self->goblinTurn(true),
true
);
}
self->player = next;
self->turnCount++;
if tile_at(self->player) == '>' {
self->gameOutcome = Outcome.Won;
return TurnResult::make(PlayerEvent.Won, EnemyEvent.None, true);
}
return TurnResult::make(
PlayerEvent.Moved,
self->goblinTurn(DANGER_BEFORE),
true
);
}

Parede ou colisão segura retorna com spentTurn false e não chama goblinTurn. Sob perigo anterior, o mesmo movimento falho incrementa turnCount e chama goblinTurn(true), permitindo o ataque prometido.

Movimento bem-sucedido sempre gasta turno. Passar DANGER_BEFORE—e não adjacência recalculada—permite que aproximação produza Threatened sem perder HP até uma resposta posterior. Alcançar a saída encerra antes de ação inimiga.

Avisar antes de atacar

goblinTurn recebe de apply a permissão de ataque em vez de deduzir histórico do tabuleiro atual. Assim distingue “já era perigoso” de “ficou adjacente agora”.

Trecho: src/game.fl:149–196. O arquivo completo aparece na seção de código abaixo.

src/game.fl:149-196 (excerpt)
private EnemyEvent Game::goblinTurn(&self, bool canAttack) {
if canAttack and adjacent(self->goblin, self->player) {
self->playerHealth--;
if self->playerHealth == 0 {
self->gameOutcome = Outcome.Lost;
}
return EnemyEvent.Attacked;
}
if adjacent(self->goblin, self->player) {
return EnemyEvent.Threatened;
}
const i32 DX = self->player.x - self->goblin.x;
const i32 DY = self->player.y - self->goblin.y;
Direction horizontal = Direction.Right;
Direction vertical = Direction.Down;
if DX < 0 {
horizontal = Direction.Left;
}
if DY < 0 {
vertical = Direction.Up;
}
bool moved = false;
if DX != 0 {
moved = self->tryGoblinStep(horizontal);
}
if !moved and DY != 0 {
moved = self->tryGoblinStep(vertical);
}
if moved and adjacent(self->goblin, self->player) {
return EnemyEvent.Threatened;
}
if moved {
return EnemyEvent.Moved;
}
return EnemyEvent.None;
}
private bool Game::tryGoblinStep(&self, Direction direction) {
next := self->goblin.moved(direction);
if next.equals(&self->player) {
return false;
}
if tile_at(next) != '#' and tile_at(next) != '>' {
self->goblin = next;
return true;
}
return false;
}

O ramo de ataque só executa quando canAttack e adjacência são verdadeiros. Caso contrário, adjacência atual retorna Threatened sem dano. A perseguição ocorre apenas quando nenhuma condição se aplica.

Depois de um passo, o método relata Threatened se alcançou adjacência ou Moved caso contrário. Ele retorna sem cair em ataque, preservando uma consequência inimiga por turno.

O contrato temporal é testado

Os testes focados codificam os dois lados do contrato temporal: parede é gratuita quando segura, e a primeira adjacência avisa antes de uma resposta falha posterior causar dano.

Trecho: src/game_test.fl:8–36. O arquivo completo aparece na seção de código abaixo.

src/game_test.fl:8-36 (excerpt)
test safe_wall_is_free(&testing::UnitTest t) {
state := game::Game::start();
wall := state.apply(game::Command.MoveUp);
asserts::is_true(
t,
wall.player == game::PlayerEvent.Wall and !wall.spentTurn and
state.turn() == 0,
"a safe wall bump does not advance the goblin"
);
}
test approach_warns_before_a_later_attack(&testing::UnitTest t) {
state := game::Game::start();
state.apply(game::Command.MoveRight);
state.apply(game::Command.MoveLeft);
state.apply(game::Command.MoveRight);
state.apply(game::Command.MoveLeft);
state.apply(game::Command.MoveRight);
state.apply(game::Command.MoveLeft);
warning := state.apply(game::Command.MoveDown);
blocked := state.apply(game::Command.MoveDown);
asserts::is_true(
t,
warning.enemy == game::EnemyEvent.Threatened and
blocked.enemy == game::EnemyEvent.Attacked and
state.health() == 2,
"the first adjacency warns and a later failed response takes one HP"
);
}

safe_wall_is_free verifica evento retornado, spentTurn e turno acumulado juntos. O segundo teste conduz uma aproximação determinística, captura o aviso e verifica que a próxima ação bloqueada ataca exatamente uma vez.

As asserções testam Game diretamente. A saída do terminal pode mudar de texto depois sem enfraquecer a regra de que aviso precede dano.

Execute e examine o resultado

O comando usa a entrada registrada do checkpoint, portanto o comportamento descrito acima pode ser reproduzido sem depender de tempo manual.

Comando de execução
longbow run tutorial/checkpoints/05-goblin < tutorial/checkpoints/05-goblin/tests/input.txt
Entrada padrão registrada
Entrada padrão
d
a
d
a
d
a
s
s
s
s

A rota registrada permite a aproximação do goblin, expõe o aviso com vida cheia e demonstra dano em perigo não resolvido posterior. O frame representativo final é a derrota determinística com HP 0 no turno 10.

Frame de terminal produzido pela versão 5: Adicione turnos e o goblin
Frame representativo da execução registrada do checkpoint 5.

O transcript não deve mostrar evento combinado de movimento e ataque. Ações falhas seguras preservam o turno; a mesma falha depois de um aviso consome a resposta e pode subtrair um HP.

Transcript acessível do frame
Transcript do frame
#########
#.......#
#@.###..#
#g......#
#..###..#
#>......#
#########
HP: 0 Turn: 10
Stdout registrada completa
Stdout esperada
#########
#@......#
#..###..#
#......g#
#..###..#
#>......#
#########
HP: 3 Turn: 0
command>
You move.
The goblin moves closer.
#########
#.@.....#
#..###..#
#.....g.#
#..###..#
#>......#
#########
HP: 3 Turn: 1
command>
You move.
The goblin moves closer.
#########
#@......#
#..###..#
#....g..#
#..###..#
#>......#
#########
HP: 3 Turn: 2
command>
You move.
The goblin moves closer.
#########
#.@.....#
#..###..#
#...g...#
#..###..#
#>......#
#########
HP: 3 Turn: 3
command>
You move.
The goblin moves closer.
#########
#@......#
#..###..#
#..g....#
#..###..#
#>......#
#########
HP: 3 Turn: 4
command>
You move.
The goblin moves closer.
#########
#.@.....#
#..###..#
#.g.....#
#..###..#
#>......#
#########
HP: 3 Turn: 5
command>
You move.
The goblin moves closer.
#########
#@......#
#..###..#
#g......#
#..###..#
#>......#
#########
HP: 3 Turn: 6
command>
You move.
The goblin closes in! Move before its next turn.
#########
#.......#
#@.###..#
#g......#
#..###..#
#>......#
#########
HP: 3 Turn: 7
command>
The goblin blocks that tile.
The goblin attacks: -1 HP.
#########
#.......#
#@.###..#
#g......#
#..###..#
#>......#
#########
HP: 2 Turn: 8
command>
The goblin blocks that tile.
The goblin attacks: -1 HP.
#########
#.......#
#@.###..#
#g......#
#..###..#
#>......#
#########
HP: 1 Turn: 9
command>
The goblin blocks that tile.
The goblin attacks: -1 HP.
#########
#.......#
#@.###..#
#g......#
#..###..#
#>......#
#########
HP: 0 Turn: 10
The goblin caught you.

O que este checkpoint estabelece

Tempo agora é contrato explícito do motor. Game relata o que cada lado fez e se o tempo avançou; a CLI continua camada de apresentação incapaz de inventar regras.

O jogador entende o perigo, mas só pode fugir. O próximo checkpoint reutiliza direção para uma segunda família de ação: gastar uma flecha limitada para percorrer linha de visão.

Exercício

O primeiro frame adjacente fica em 3 HP. Só a próxima resposta falha imprime -1 HP; o goblin nunca move e ataca junto.

Código completo e verificação

As seções abaixo preservam o código qualificado completo, o diff exato desde o predecessor e a sequência de comandos usada pela CI. São evidência de apoio para a implementação explicada acima.

Código completo testado (7 arquivos)

longbow.toml

longbow.toml
[package]
name = "arrowfall-step-05"
version = "0.1.0"
kind = "program"
flecha = ">=0.3.0, <0.4.0"
[dependencies]

longbow.lock

longbow.lock
{
"schema": 1,
"root": "root",
"packages": [
{
"id": "root",
"identity": "local/arrowfall_step_05",
"source": {
"kind": "workspace",
"path": "."
},
"version": "0.1.0",
"flecha": ">=0.3.0, <0.4.0",
"dependencies": {
"std": "toolchain-std"
}
},
{
"id": "toolchain-std",
"identity": "flecha/std",
"source": {
"kind": "toolchain"
},
"dependencies": {}
}
]
}

src/game.fl

src/game.fl
module game;
const i32 WIDTH = 9;
const i32 HEIGHT = 7;
export enum Direction {
default Up,
Left,
Down,
Right
}
export enum Command {
default MoveUp,
MoveLeft,
MoveDown,
MoveRight,
Help,
Quit
}
export enum Outcome {
default Playing,
Won,
Lost,
Quit
}
export enum PlayerEvent {
default None,
Moved,
Wall,
GoblinBlocks,
Help,
Won,
Quit
}
export enum EnemyEvent {
default None,
Moved,
Threatened,
Attacked
}
export structure Position {
i32 x;
i32 y;
};
export structure TurnResult {
PlayerEvent player;
EnemyEvent enemy;
bool spentTurn;
};
export structure Game {
private Position player;
private Position goblin;
private i32 playerHealth;
private u32 turnCount;
private Outcome gameOutcome;
};
TurnResult TurnResult::make(
PlayerEvent player,
EnemyEvent enemy = EnemyEvent.None,
bool spentTurn = false
) {
return build TurnResult {
self.player = player;
self.enemy = enemy;
self.spentTurn = spentTurn;
};
}
Position Position::at(i32 x, i32 y) {
return build Position {
self.x = x;
self.y = y;
};
}
bool Position::equals(&const self, &const Position other) {
return self->x == other->x and self->y == other->y;
}
Position Position::moved(&const self, Direction direction) {
Position delta = match direction {
Direction.Up => Position::at(0, -1),
Direction.Left => Position::at(-1, 0),
Direction.Down => Position::at(0, 1),
Direction.Right => Position::at(1, 0),
};
return Position::at(self->x + delta.x, self->y + delta.y);
}
Game Game::start() {
return build Game {
self.player = Position::at(1, 1);
self.goblin = Position::at(7, 3);
self.playerHealth = 3;
self.turnCount = 0;
self.gameOutcome = Outcome.Playing;
};
}
TurnResult Game::apply(&self, Command command) {
if command == Command.Help {
return TurnResult::make(PlayerEvent.Help);
}
if command == Command.Quit {
self->gameOutcome = Outcome.Quit;
return TurnResult::make(PlayerEvent.Quit);
}
const bool DANGER_BEFORE = adjacent(self->goblin, self->player);
next := self->player.moved(command_direction(command));
if tile_at(next) == '#' {
if !DANGER_BEFORE {
return TurnResult::make(PlayerEvent.Wall);
}
self->turnCount++;
return TurnResult::make(PlayerEvent.Wall, self->goblinTurn(true), true);
}
if next.equals(&self->goblin) {
if !DANGER_BEFORE {
return TurnResult::make(PlayerEvent.GoblinBlocks);
}
self->turnCount++;
return TurnResult::make(
PlayerEvent.GoblinBlocks,
self->goblinTurn(true),
true
);
}
self->player = next;
self->turnCount++;
if tile_at(self->player) == '>' {
self->gameOutcome = Outcome.Won;
return TurnResult::make(PlayerEvent.Won, EnemyEvent.None, true);
}
return TurnResult::make(
PlayerEvent.Moved,
self->goblinTurn(DANGER_BEFORE),
true
);
}
private EnemyEvent Game::goblinTurn(&self, bool canAttack) {
if canAttack and adjacent(self->goblin, self->player) {
self->playerHealth--;
if self->playerHealth == 0 {
self->gameOutcome = Outcome.Lost;
}
return EnemyEvent.Attacked;
}
if adjacent(self->goblin, self->player) {
return EnemyEvent.Threatened;
}
const i32 DX = self->player.x - self->goblin.x;
const i32 DY = self->player.y - self->goblin.y;
Direction horizontal = Direction.Right;
Direction vertical = Direction.Down;
if DX < 0 {
horizontal = Direction.Left;
}
if DY < 0 {
vertical = Direction.Up;
}
bool moved = false;
if DX != 0 {
moved = self->tryGoblinStep(horizontal);
}
if !moved and DY != 0 {
moved = self->tryGoblinStep(vertical);
}
if moved and adjacent(self->goblin, self->player) {
return EnemyEvent.Threatened;
}
if moved {
return EnemyEvent.Moved;
}
return EnemyEvent.None;
}
private bool Game::tryGoblinStep(&self, Direction direction) {
next := self->goblin.moved(direction);
if next.equals(&self->player) {
return false;
}
if tile_at(next) != '#' and tile_at(next) != '>' {
self->goblin = next;
return true;
}
return false;
}
Position Game::playerPosition(&const self) {
return self->player;
}
Position Game::goblinPosition(&const self) {
return self->goblin;
}
i32 Game::health(&const self) {
return self->playerHealth;
}
u32 Game::turn(&const self) {
return self->turnCount;
}
Outcome Game::outcome(&const self) {
return self->gameOutcome;
}
export char tile_at(Position position) {
[63]char tiles = [
'#','#','#','#','#','#','#','#','#',
'#','.','.','.','.','.','.','.','#',
'#','.','.','#','#','#','.','.','#',
'#','.','.','.','.','.','.','.','#',
'#','.','.','#','#','#','.','.','#',
'#','>','.','.','.','.','.','.','#',
'#','#','#','#','#','#','#','#','#'
];
return tile_from_cells(tiles[:], position);
}
char tile_from_cells([]const char tiles, Position position) {
if position.x < 0 or position.x >= WIDTH or
position.y < 0 or position.y >= HEIGHT {
return '#';
}
return tiles[(position.y * WIDTH + position.x).cast<usize>];
}
Direction command_direction(Command command) {
return match command {
Command.MoveUp => Direction.Up,
Command.MoveLeft => Direction.Left,
Command.MoveDown => Direction.Down,
Command.MoveRight => Direction.Right,
Command.Help => Direction.Up,
Command.Quit => Direction.Up,
};
}
bool adjacent(Position left, Position right) {
const i32 DX = left.x - right.x;
const i32 DY = left.y - right.y;
return (DX == 0 and (DY == 1 or DY == -1)) or
(DY == 0 and (DX == 1 or DX == -1));
}

src/game_test.fl

src/game_test.fl
module game_test;
@imports
:: root::game
:: lib::std::tests::asserts
:: lib::std::tests::testing;
test safe_wall_is_free(&testing::UnitTest t) {
state := game::Game::start();
wall := state.apply(game::Command.MoveUp);
asserts::is_true(
t,
wall.player == game::PlayerEvent.Wall and !wall.spentTurn and
state.turn() == 0,
"a safe wall bump does not advance the goblin"
);
}
test approach_warns_before_a_later_attack(&testing::UnitTest t) {
state := game::Game::start();
state.apply(game::Command.MoveRight);
state.apply(game::Command.MoveLeft);
state.apply(game::Command.MoveRight);
state.apply(game::Command.MoveLeft);
state.apply(game::Command.MoveRight);
state.apply(game::Command.MoveLeft);
warning := state.apply(game::Command.MoveDown);
blocked := state.apply(game::Command.MoveDown);
asserts::is_true(
t,
warning.enemy == game::EnemyEvent.Threatened and
blocked.enemy == game::EnemyEvent.Attacked and
state.health() == 2,
"the first adjacency warns and a later failed response takes one HP"
);
}

src/input.fl

src/input.fl
module input;
@imports :: root::game :: lib::std::text::{ascii, strings};
export error CommandError {
InvalidCommand
}
export (game::Command, error) read_command() {
string line;
inputError := stdin("command>", &line);
if inputError != none {
return (game::Command.Quit, none);
}
value := strings::trim_ascii(line.view());
if value.len() != 1 {
return (none, CommandError.InvalidCommand);
}
const char KEY = ascii::to_ascii_lowercase(value[0].cast<char>);
if KEY == 'w' {
return (game::Command.MoveUp, none);
}
if KEY == 'a' {
return (game::Command.MoveLeft, none);
}
if KEY == 's' {
return (game::Command.MoveDown, none);
}
if KEY == 'd' {
return (game::Command.MoveRight, none);
}
if KEY == 'h' {
return (game::Command.Help, none);
}
if KEY == 'q' {
return (game::Command.Quit, none);
}
return (none, CommandError.InvalidCommand);
}

src/main.fl

src/main.fl
module main;
@imports :: root::game :: root::input :: root::render;
void main() {
state := game::Game::start();
while state.outcome() == game::Outcome.Playing {
frame, frameError := render::draw(&state);
if frameError != none {
stdoutln("Could not render the room.");
return;
}
stdoutln(frame);
command, commandError := input::read_command();
stdoutln();
if commandError != none {
stdoutln("Unknown command. No turn spent.");
proceed;
}
result := state.apply(command);
stdoutln(render::player_message(result.player));
enemyText := render::enemy_message(result.enemy);
if enemyText.len() > 0 {
stdoutln(enemyText);
}
}
finalFrame, finalError := render::draw(&state);
if finalError == none {
stdoutln(finalFrame);
}
if state.outcome() == game::Outcome.Won {
stdoutln("You escaped Arrowfall!");
} else if state.outcome() == game::Outcome.Lost {
stdoutln("The goblin caught you.");
}
}

src/render.fl

src/render.fl
module render;
@imports :: root::game :: lib::std::text::strings;
export (string, error) draw(&const game::Game state) {
canvas, allocationError := strings::StringBuilder::withCapacity(112);
if allocationError != none {
return (none, allocationError);
}
player := state->playerPosition();
goblin := state->goblinPosition();
loop i32 y = 0; y < 7; y++ {
loop i32 x = 0; x < 9; x++ {
position := game::Position::at(x, y);
char tile = game::tile_at(position);
if position.equals(&player) {
tile = '@';
} else if position.equals(&goblin) {
tile = 'g';
}
if canvas.appendChar(tile) != none {
return (none, AllocationError.OutOfMemory);
}
}
if canvas.appendChar('\n') != none {
return (none, AllocationError.OutOfMemory);
}
}
if canvas.append("HP: ") != none or
canvas.appendInteger(state->health()) != none or
canvas.append(" Turn: ") != none or
canvas.appendInteger(state->turn()) != none {
return (none, AllocationError.OutOfMemory);
}
return canvas.toString();
}
export stringv player_message(game::PlayerEvent event) {
if event == game::PlayerEvent.Wall {
return "A wall blocks your way. No turn spent.";
}
if event == game::PlayerEvent.GoblinBlocks {
return "The goblin blocks that tile.";
}
if event == game::PlayerEvent.Help {
return "Move: W/A/S/D | H help | Q quit";
}
if event == game::PlayerEvent.Won {
return "You reach the exit.";
}
if event == game::PlayerEvent.Quit {
return "You leave the room.";
}
return "You move.";
}
export stringv enemy_message(game::EnemyEvent event) {
if event == game::EnemyEvent.Moved {
return "The goblin moves closer.";
}
if event == game::EnemyEvent.Threatened {
return "The goblin closes in! Move before its next turn.";
}
if event == game::EnemyEvent.Attacked {
return "The goblin attacks: -1 HP.";
}
return "";
}
Diff exato desde a versão anterior
diff -rN -U0 --exclude target tutorial/checkpoints/04-map/README.md tutorial/checkpoints/05-goblin/README.md
--- tutorial/checkpoints/04-map/README.md
+++ tutorial/checkpoints/05-goblin/README.md
@@ -1 +1 @@
-# 04 — Walls make movement meaningful
+# 05 — A turn now has consequences
@@ -5,2 +5,2 @@
-Checkpoint 03 moves on an open rectangle. Every in-bounds direction succeeds,
-so movement has no spatial decision.
+Checkpoint 04 has meaningful walls and an exit, but the room is safe and the
+shortest path is automatic.
@@ -10,2 +10,2 @@
-The symbols introduced in checkpoint 01 should become rules. A real room needs
-solid walls, a reachable exit, and feedback when an action cannot happen.
+Without pressure there is no reason to weigh one movement against another. A
+single pursuer makes the turn model visible before combat adds another action.
@@ -15,4 +15,4 @@
-The fixed 9x7 map is stored as a checked slice over a fixed array. Walls reject
-movement without spending a turn; entering `>` wins. Engine, input, and render
-modules now separate rules from terminal I/O, and the first unit test protects
-collision and victory.
+A goblin takes one response after each effective player turn. First adjacency
+only warns. If the player begins the next action adjacent and fails to escape,
+the goblin deals one HP; safe no-ops remain free. `TurnResult` carries player
+and enemy events so rules are observable instead of inferred by the CLI.
@@ -35 +35 @@
-Exact changes: `../../diffs/04-map.patch`.
+Exact changes: `../../diffs/05-goblin.patch`.
@@ -40,4 +40,4 @@
-longbow check tutorial/checkpoints/04-map
-longbow test tutorial/checkpoints/04-map
-longbow build tutorial/checkpoints/04-map
-longbow run tutorial/checkpoints/04-map < tutorial/checkpoints/04-map/tests/input.txt
+longbow check tutorial/checkpoints/05-goblin
+longbow test tutorial/checkpoints/05-goblin
+longbow build tutorial/checkpoints/05-goblin
+longbow run tutorial/checkpoints/05-goblin < tutorial/checkpoints/05-goblin/tests/input.txt
@@ -48,4 +48,5 @@
-1. A room appears — static scene.
-2. The picture becomes state — structs own position.
-3. The player moves — stdin drives an open floor.
-4. **Walls make movement meaningful** — map, collision, exit, modules, test.
+1. A room appears.
+2. The picture becomes state.
+3. The player moves.
+4. Walls create collision and victory.
+5. **A turn has consequences** — pursuit, warning, HP, defeat, events.
@@ -55,2 +56,2 @@
-The recorded leading `W` must print the wall message while the next frame still
-shows `Turn: 0`; four `S` moves must then win on turn 4.
+The first adjacent frame must show a warning at 3 HP. Only a later failed
+response may show `-1 HP`; the goblin must never move and attack together.
diff -rN -U0 --exclude target tutorial/checkpoints/04-map/longbow.lock tutorial/checkpoints/05-goblin/longbow.lock
--- tutorial/checkpoints/04-map/longbow.lock
+++ tutorial/checkpoints/05-goblin/longbow.lock
@@ -7 +7 @@
- "identity": "local/arrowfall_step_04",
+ "identity": "local/arrowfall_step_05",
diff -rN -U0 --exclude target tutorial/checkpoints/04-map/longbow.toml tutorial/checkpoints/05-goblin/longbow.toml
--- tutorial/checkpoints/04-map/longbow.toml
+++ tutorial/checkpoints/05-goblin/longbow.toml
@@ -2 +2 @@
-name = "arrowfall-step-04"
+name = "arrowfall-step-05"
diff -rN -U0 --exclude target tutorial/checkpoints/04-map/src/game.fl tutorial/checkpoints/05-goblin/src/game.fl
--- tutorial/checkpoints/04-map/src/game.fl
+++ tutorial/checkpoints/05-goblin/src/game.fl
@@ -17,0 +18 @@
+ Help,
@@ -23,0 +25 @@
+ Lost,
@@ -27,2 +29,3 @@
-export enum Event {
- default Moved,
+export enum PlayerEvent {
+ default None,
+ Moved,
@@ -29,0 +33,2 @@
+ GoblinBlocks,
+ Help,
@@ -33,0 +39,7 @@
+export enum EnemyEvent {
+ default None,
+ Moved,
+ Threatened,
+ Attacked
+}
+
@@ -38,0 +51,6 @@
+export structure TurnResult {
+ PlayerEvent player;
+ EnemyEvent enemy;
+ bool spentTurn;
+};
+
@@ -40,0 +59,2 @@
+ private Position goblin;
+ private i32 playerHealth;
@@ -44,0 +65,12 @@
+TurnResult TurnResult::make(
+ PlayerEvent player,
+ EnemyEvent enemy = EnemyEvent.None,
+ bool spentTurn = false
+) {
+ return build TurnResult {
+ self.player = player;
+ self.enemy = enemy;
+ self.spentTurn = spentTurn;
+ };
+}
+
@@ -68,0 +101,2 @@
+ self.goblin = Position::at(7, 3);
+ self.playerHealth = 3;
@@ -74 +108,4 @@
-Event Game::apply(&self, Command command) {
+TurnResult Game::apply(&self, Command command) {
+ if command == Command.Help {
+ return TurnResult::make(PlayerEvent.Help);
+ }
@@ -77 +114 @@
- return Event.Quit;
+ return TurnResult::make(PlayerEvent.Quit);
@@ -78,0 +116 @@
+ const bool DANGER_BEFORE = adjacent(self->goblin, self->player);
@@ -81 +119,16 @@
- return Event.Wall;
+ if !DANGER_BEFORE {
+ return TurnResult::make(PlayerEvent.Wall);
+ }
+ self->turnCount++;
+ return TurnResult::make(PlayerEvent.Wall, self->goblinTurn(true), true);
+ }
+ if next.equals(&self->goblin) {
+ if !DANGER_BEFORE {
+ return TurnResult::make(PlayerEvent.GoblinBlocks);
+ }
+ self->turnCount++;
+ return TurnResult::make(
+ PlayerEvent.GoblinBlocks,
+ self->goblinTurn(true),
+ true
+ );
@@ -87 +140 @@
- return Event.Won;
+ return TurnResult::make(PlayerEvent.Won, EnemyEvent.None, true);
@@ -89 +142,54 @@
- return Event.Moved;
+ return TurnResult::make(
+ PlayerEvent.Moved,
+ self->goblinTurn(DANGER_BEFORE),
+ true
+ );
+}
+
+private EnemyEvent Game::goblinTurn(&self, bool canAttack) {
+ if canAttack and adjacent(self->goblin, self->player) {
+ self->playerHealth--;
+ if self->playerHealth == 0 {
+ self->gameOutcome = Outcome.Lost;
+ }
+ return EnemyEvent.Attacked;
+ }
+ if adjacent(self->goblin, self->player) {
+ return EnemyEvent.Threatened;
+ }
+ const i32 DX = self->player.x - self->goblin.x;
+ const i32 DY = self->player.y - self->goblin.y;
+ Direction horizontal = Direction.Right;
+ Direction vertical = Direction.Down;
+ if DX < 0 {
+ horizontal = Direction.Left;
+ }
+ if DY < 0 {
+ vertical = Direction.Up;
+ }
+ bool moved = false;
+ if DX != 0 {
+ moved = self->tryGoblinStep(horizontal);
+ }
+ if !moved and DY != 0 {
+ moved = self->tryGoblinStep(vertical);
+ }
+ if moved and adjacent(self->goblin, self->player) {
+ return EnemyEvent.Threatened;
+ }
+ if moved {
+ return EnemyEvent.Moved;
+ }
+ return EnemyEvent.None;
+}
+
+private bool Game::tryGoblinStep(&self, Direction direction) {
+ next := self->goblin.moved(direction);
+ if next.equals(&self->player) {
+ return false;
+ }
+ if tile_at(next) != '#' and tile_at(next) != '>' {
+ self->goblin = next;
+ return true;
+ }
+ return false;
@@ -95,0 +202,8 @@
+Position Game::goblinPosition(&const self) {
+ return self->goblin;
+}
+
+i32 Game::health(&const self) {
+ return self->playerHealth;
+}
+
@@ -130,0 +245 @@
+ Command.Help => Direction.Up,
@@ -132,0 +248,7 @@
+}
+
+bool adjacent(Position left, Position right) {
+ const i32 DX = left.x - right.x;
+ const i32 DY = left.y - right.y;
+ return (DX == 0 and (DY == 1 or DY == -1)) or
+ (DY == 0 and (DX == 1 or DX == -1));
diff -rN -U0 --exclude target tutorial/checkpoints/04-map/src/game_test.fl tutorial/checkpoints/05-goblin/src/game_test.fl
--- tutorial/checkpoints/04-map/src/game_test.fl
+++ tutorial/checkpoints/05-goblin/src/game_test.fl
@@ -8 +8 @@
-test walls_are_free_and_the_exit_wins(&testing::UnitTest t) {
+test safe_wall_is_free(&testing::UnitTest t) {
@@ -11,4 +10,0 @@
- state.apply(game::Command.MoveDown);
- state.apply(game::Command.MoveDown);
- state.apply(game::Command.MoveDown);
- won := state.apply(game::Command.MoveDown);
@@ -17,3 +13,22 @@
- wall == game::Event.Wall and won == game::Event.Won and
- state.turn() == 4 and state.outcome() == game::Outcome.Won,
- "walls are free while entering the exit wins"
+ wall.player == game::PlayerEvent.Wall and !wall.spentTurn and
+ state.turn() == 0,
+ "a safe wall bump does not advance the goblin"
+ );
+}
+
+test approach_warns_before_a_later_attack(&testing::UnitTest t) {
+ state := game::Game::start();
+ state.apply(game::Command.MoveRight);
+ state.apply(game::Command.MoveLeft);
+ state.apply(game::Command.MoveRight);
+ state.apply(game::Command.MoveLeft);
+ state.apply(game::Command.MoveRight);
+ state.apply(game::Command.MoveLeft);
+ warning := state.apply(game::Command.MoveDown);
+ blocked := state.apply(game::Command.MoveDown);
+ asserts::is_true(
+ t,
+ warning.enemy == game::EnemyEvent.Threatened and
+ blocked.enemy == game::EnemyEvent.Attacked and
+ state.health() == 2,
+ "the first adjacency warns and a later failed response takes one HP"
diff -rN -U0 --exclude target tutorial/checkpoints/04-map/src/input.fl tutorial/checkpoints/05-goblin/src/input.fl
--- tutorial/checkpoints/04-map/src/input.fl
+++ tutorial/checkpoints/05-goblin/src/input.fl
@@ -31,0 +32,3 @@
+ if KEY == 'h' {
+ return (game::Command.Help, none);
+ }
diff -rN -U0 --exclude target tutorial/checkpoints/04-map/src/main.fl tutorial/checkpoints/05-goblin/src/main.fl
--- tutorial/checkpoints/04-map/src/main.fl
+++ tutorial/checkpoints/05-goblin/src/main.fl
@@ -20,2 +20,6 @@
- event := state.apply(command);
- stdoutln(render::event_message(event));
+ result := state.apply(command);
+ stdoutln(render::player_message(result.player));
+ enemyText := render::enemy_message(result.enemy);
+ if enemyText.len() > 0 {
+ stdoutln(enemyText);
+ }
@@ -28,0 +33,2 @@
+ } else if state.outcome() == game::Outcome.Lost {
+ stdoutln("The goblin caught you.");
diff -rN -U0 --exclude target tutorial/checkpoints/04-map/src/render.fl tutorial/checkpoints/05-goblin/src/render.fl
--- tutorial/checkpoints/04-map/src/render.fl
+++ tutorial/checkpoints/05-goblin/src/render.fl
@@ -10,0 +11 @@
+ goblin := state->goblinPosition();
@@ -16,0 +18,2 @@
+ } else if position.equals(&goblin) {
+ tile = 'g';
@@ -26 +29,3 @@
- if canvas.append("Turn: ") != none or
+ if canvas.append("HP: ") != none or
+ canvas.appendInteger(state->health()) != none or
+ canvas.append(" Turn: ") != none or
@@ -33,2 +38,2 @@
-export stringv event_message(game::Event event) {
- if event == game::Event.Wall {
+export stringv player_message(game::PlayerEvent event) {
+ if event == game::PlayerEvent.Wall {
@@ -37 +42,7 @@
- if event == game::Event.Won {
+ if event == game::PlayerEvent.GoblinBlocks {
+ return "The goblin blocks that tile.";
+ }
+ if event == game::PlayerEvent.Help {
+ return "Move: W/A/S/D | H help | Q quit";
+ }
+ if event == game::PlayerEvent.Won {
@@ -40 +51 @@
- if event == game::Event.Quit {
+ if event == game::PlayerEvent.Quit {
@@ -43,0 +55,13 @@
+}
+
+export stringv enemy_message(game::EnemyEvent event) {
+ if event == game::EnemyEvent.Moved {
+ return "The goblin moves closer.";
+ }
+ if event == game::EnemyEvent.Threatened {
+ return "The goblin closes in! Move before its next turn.";
+ }
+ if event == game::EnemyEvent.Attacked {
+ return "The goblin attacks: -1 HP.";
+ }
+ return "";
diff -rN -U0 --exclude target tutorial/checkpoints/04-map/tests/frame.svg tutorial/checkpoints/05-goblin/tests/frame.svg
--- tutorial/checkpoints/04-map/tests/frame.svg
+++ tutorial/checkpoints/05-goblin/tests/frame.svg
@@ -3 +3 @@
- <title>04-map representative frame</title>
+ <title>05-goblin representative frame</title>
@@ -8,2 +8,2 @@
- <text x="24" y="75" class="terminal">#..###..#</text>
- <text x="24" y="94" class="terminal">#.......#</text>
+ <text x="24" y="75" class="terminal">#@.###..#</text>
+ <text x="24" y="94" class="terminal">#g......#</text>
@@ -11 +11 @@
- <text x="24" y="132" class="terminal">#@......#</text>
+ <text x="24" y="132" class="terminal">#&gt;......#</text>
@@ -13 +13 @@
- <text x="24" y="170" class="terminal">Turn: 4</text>
+ <text x="24" y="170" class="terminal">HP: 0 Turn: 10</text>
diff -rN -U0 --exclude target tutorial/checkpoints/04-map/tests/frame.txt tutorial/checkpoints/05-goblin/tests/frame.txt
--- tutorial/checkpoints/04-map/tests/frame.txt
+++ tutorial/checkpoints/05-goblin/tests/frame.txt
@@ -2,0 +3,2 @@
+#@.###..#
+#g......#
@@ -4,3 +6 @@
-#.......#
-#..###..#
-#@......#
+#>......#
@@ -8 +8 @@
-Turn: 4
+HP: 0 Turn: 10
diff -rN -U0 --exclude target tutorial/checkpoints/04-map/tests/input.txt tutorial/checkpoints/05-goblin/tests/input.txt
--- tutorial/checkpoints/04-map/tests/input.txt
+++ tutorial/checkpoints/05-goblin/tests/input.txt
@@ -1 +1,6 @@
-w
+d
+a
+d
+a
+d
+a
diff -rN -U0 --exclude target tutorial/checkpoints/04-map/tests/output.txt tutorial/checkpoints/05-goblin/tests/output.txt
--- tutorial/checkpoints/04-map/tests/output.txt
+++ tutorial/checkpoints/05-goblin/tests/output.txt
@@ -4 +4 @@
-#.......#
+#......g#
@@ -8 +8 @@
-Turn: 0
+HP: 3 Turn: 0
@@ -10 +10,13 @@
-A wall blocks your way. No turn spent.
+You move.
+The goblin moves closer.
+#########
+#.@.....#
+#..###..#
+#.....g.#
+#..###..#
+#>......#
+#########
+HP: 3 Turn: 1
+command>
+You move.
+The goblin moves closer.
@@ -14 +26 @@
-#.......#
+#....g..#
@@ -18 +30 @@
-Turn: 0
+HP: 3 Turn: 2
@@ -20,0 +33 @@
+The goblin moves closer.
@@ -22,3 +35,3 @@
-#.......#
-#@.###..#
-#.......#
+#.@.....#
+#..###..#
+#...g...#
@@ -28 +41 @@
-Turn: 1
+HP: 3 Turn: 3
@@ -30,0 +44 @@
+The goblin moves closer.
@@ -32 +46,14 @@
-#.......#
+#@......#
+#..###..#
+#..g....#
+#..###..#
+#>......#
+#########
+HP: 3 Turn: 4
+command>
+You move.
+The goblin moves closer.
+#########
+#.@.....#
+#..###..#
+#.g.....#
@@ -33,0 +61,7 @@
+#>......#
+#########
+HP: 3 Turn: 5
+command>
+You move.
+The goblin moves closer.
+#########
@@ -35,0 +70,2 @@
+#g......#
+#..###..#
@@ -38 +74 @@
-Turn: 2
+HP: 3 Turn: 6
@@ -40,0 +77 @@
+The goblin closes in! Move before its next turn.
@@ -42,0 +80,2 @@
+#@.###..#
+#g......#
@@ -43,0 +83,7 @@
+#>......#
+#########
+HP: 3 Turn: 7
+command>
+The goblin blocks that tile.
+The goblin attacks: -1 HP.
+#########
@@ -45,0 +92,2 @@
+#g......#
+#..###..#
@@ -48 +96 @@
-Turn: 3
+HP: 2 Turn: 8
@@ -50 +98,2 @@
-You reach the exit.
+The goblin blocks that tile.
+The goblin attacks: -1 HP.
@@ -52,0 +102,2 @@
+#@.###..#
+#g......#
@@ -53,0 +105,7 @@
+#>......#
+#########
+HP: 1 Turn: 9
+command>
+The goblin blocks that tile.
+The goblin attacks: -1 HP.
+#########
@@ -54,0 +113,2 @@
+#@.###..#
+#g......#
@@ -56 +116 @@
-#@......#
+#>......#
@@ -58,2 +118,2 @@
-Turn: 4
-You escaped Arrowfall!
+HP: 0 Turn: 10
+The goblin caught you.
Comandos de check, teste, build e execução
Comandos de verificação
longbow check tutorial/checkpoints/05-goblin
longbow test tutorial/checkpoints/05-goblin
longbow build tutorial/checkpoints/05-goblin
longbow run tutorial/checkpoints/05-goblin < tutorial/checkpoints/05-goblin/tests/input.txt

Versões deste tutorial

  1. 1. Renderize a primeira sala
  2. 2. Represente o estado do jogo
  3. 3. Leia comandos de movimento
  4. 4. Adicione o mapa e a colisão
  5. 5. Adicione turnos e o goblin

Próxima versão

O goblin agora pode causar dano, mas movimento é a única resposta. A próxima versão adiciona disparos direcionais e uma quantidade limitada de flechas.

Adicione arco e linha de visão

Referências

Fonte: commit Arrowfall 56bb003, verificado com Flecha oficial 0.3.0. Código, entrada, saída, diff e frame desta página são lidos desse snapshot versionado.