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.
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.
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.
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.
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.
longbow run tutorial/checkpoints/05-goblin < tutorial/checkpoints/05-goblin/tests/input.txtEntrada padrão registrada
dadadassssA 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.
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
##########.......##@.###..##g......##..###..##>......##########HP: 0 Turn: 10Stdout registrada completa
##########@......##..###..##......g##..###..##>......##########HP: 3 Turn: 0command>You move.The goblin moves closer.##########.@.....##..###..##.....g.##..###..##>......##########HP: 3 Turn: 1command>You move.The goblin moves closer.##########@......##..###..##....g..##..###..##>......##########HP: 3 Turn: 2command>You move.The goblin moves closer.##########.@.....##..###..##...g...##..###..##>......##########HP: 3 Turn: 3command>You move.The goblin moves closer.##########@......##..###..##..g....##..###..##>......##########HP: 3 Turn: 4command>You move.The goblin moves closer.##########.@.....##..###..##.g.....##..###..##>......##########HP: 3 Turn: 5command>You move.The goblin moves closer.##########@......##..###..##g......##..###..##>......##########HP: 3 Turn: 6command>You move.The goblin closes in! Move before its next turn.##########.......##@.###..##g......##..###..##>......##########HP: 3 Turn: 7command>The goblin blocks that tile.The goblin attacks: -1 HP.##########.......##@.###..##g......##..###..##>......##########HP: 2 Turn: 8command>The goblin blocks that tile.The goblin attacks: -1 HP.##########.......##@.###..##g......##..###..##>......##########HP: 1 Turn: 9command>The goblin blocks that tile.The goblin attacks: -1 HP.##########.......##@.###..##g......##..###..##>......##########HP: 0 Turn: 10The 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
[package]name = "arrowfall-step-05"version = "0.1.0"kind = "program"flecha = ">=0.3.0, <0.4.0"
[dependencies]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
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
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
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
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
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">#>......#</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: 10diff -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+adiff -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
longbow check tutorial/checkpoints/05-goblinlongbow test tutorial/checkpoints/05-goblinlongbow build tutorial/checkpoints/05-goblinlongbow run tutorial/checkpoints/05-goblin < tutorial/checkpoints/05-goblin/tests/input.txtVersões deste tutorial
- 1. Renderize a primeira sala
- 2. Represente o estado do jogo
- 3. Leia comandos de movimento
- 4. Adicione o mapa e a colisão
- 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.