Fonte atual: curriculum/arrowfall/checkpoints/06-archery · Flecha 0.3.0 · 6/7.
O programa neste checkpoint
Até aqui: O checkpoint 05 tem perseguidor justo, perigo visível, HP e derrota determinística.
O checkpoint 05 cria ameaça justa, mas o arqueiro só tem movimento. O mapa já contém corredores e paredes, então pode responder a outra pergunta: o que existe ao longo de uma direção?
Este checkpoint adiciona três flechas e comandos de disparo I, J, K, L. Um disparo consome uma flecha, avança célula a célula, para numa parede e mata o goblin se o cursor alcançar sua posição primeiro.
Limitação atual
O jogador só pode fugir; “arqueiro” é tema e as linhas de visão não fazem trabalho.
Próximo comportamento necessário: Trocar uma das três flechas por disparo direcional com acerto, erro e aljava vazia distintos.
Modelo e invariantes
Reutilizar Direction para I/J/K/L. Um disparo percorre uma célula verificada por vez até parede ou goblin.
Movimento e disparo reutilizam Direction, mas continuam comandos diferentes porque consomem recursos distintos e param em condições diferentes. Testes preservam essa distinção.
Movimento e disparo compartilham Direction porque ambos escolhem cima, esquerda, baixo ou direita. Continuam variantes Command distintas porque recursos e condições de parada diferem. Reutilizar direção não transforma duas ações em uma.
Game::apply preserva o contrato TurnResult do checkpoint 05. Registra DANGER_BEFORE, delega a movePlayer ou shoot e decide se um no-op é gratuito ou consome um turno ameaçado. O chamador ainda recebe um evento do jogador e uma resposta inimiga.
shoot verifica a aljava vazia antes de decrementar. Com flecha disponível, o cursor começa na célula adjacente e avança até tile_at retornar #. Encontrar o goblin vivo primeiro altera goblinAlive e retorna ShotHit; chegar à parede retorna ShotMiss.
O loop de linha de visão depende do invariante do checkpoint 04: coordenadas externas são paredes. Portanto ele termina mesmo disparando para a borda. Flechas não atravessam paredes e um comando consome no máximo uma flecha.
O jogador não recupera flechas neste projeto. Isso mantém o inventário finito e visível sem adicionar pickups ou alocação, fora da regra de combate introduzida agora.
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 disparos e inventário.
- Percorrer linha de visão até a parede.
- Tornar parsing testável.
Movimento e disparo compartilham o contrato de turno
apply classifica o comando depois de preservar o perigo anterior. is_shot escolhe a família de ação; os dois helpers retornam PlayerEvent, permitindo que a política de turnos processe os resultados de modo uniforme.
Trecho: src/game.fl:119–158. 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 = self->goblinAlive and adjacent(self->goblin, self->player); PlayerEvent action = PlayerEvent.None; if is_shot(command) { action = self->shoot(command_direction(command)); } else { action = self->movePlayer(command_direction(command)); } if action == PlayerEvent.Wall or action == PlayerEvent.GoblinBlocks or action == PlayerEvent.NoArrows { if !DANGER_BEFORE { return TurnResult::make(action); } self->turnCount++; return TurnResult::make( action, self->goblinTurn(true), true ); } self->turnCount++; if tile_at(self->player) == '>' { self->gameOutcome = Outcome.Won; return TurnResult::make(PlayerEvent.Won, EnemyEvent.None, true); } return TurnResult::make( action, self->goblinTurn(DANGER_BEFORE), true );}Wall, GoblinBlocks e NoArrows são no-ops. Permanecem gratuitos em segurança, mas permitem ataque se o perigo existia antes do comando. Acerto ou erro gasta turno normal e permite resposta do goblin se ele sobreviveu.
A verificação da saída continua depois da ação. Disparo não move o jogador, então não vence por acidente; movimento pode entrar em > pela mesma regra anterior.
Linha de visão consome uma flecha
movePlayer e shoot isolam transições de estado diferentes. O disparo consome o recurso uma vez, antes de percorrer a linha de visão.
Trecho: src/game.fl:160–186. O arquivo completo aparece na seção de código abaixo.
private PlayerEvent Game::movePlayer(&self, Direction direction) { next := self->player.moved(direction); if tile_at(next) == '#' { return PlayerEvent.Wall; } if self->goblinAlive and next.equals(&self->goblin) { return PlayerEvent.GoblinBlocks; } self->player = next; return PlayerEvent.Moved;}
private PlayerEvent Game::shoot(&self, Direction direction) { if self->arrowCount == 0 { return PlayerEvent.NoArrows; } self->arrowCount--; cursor := self->player.moved(direction); while tile_at(cursor) != '#' { if self->goblinAlive and cursor.equals(&self->goblin) { self->goblinAlive = false; return PlayerEvent.ShotHit; } cursor = cursor.moved(direction); } return PlayerEvent.ShotMiss;}NoArrows retorna antes de decrementar ou criar cursor. Caso contrário, arrowCount diminui exatamente uma vez independentemente de acerto ou erro. Cada iteração examina uma célula antes de avançar na mesma Direction.
Definir goblinAlive como false é a única mutação do combate. O goblinTurn posterior começa verificando essa flag, portanto um goblin morto pelo disparo não responde no mesmo turno.
Os dois losangos são testados
Os testes de entrada mantêm os dois losangos direcionais distintos na fronteira do parser. W maiúsculo deve virar movimento, enquanto l minúsculo vira disparo à direita.
Trecho: src/input_test.fl:1–18. O arquivo completo aparece na seção de código abaixo.
module input_test;
@imports :: root::game :: root::input :: lib::std::tests::asserts :: lib::std::tests::testing;
test direct_key_diamonds_parse(&testing::UnitTest t) { move, moveError := input::parse("W"); shot, shotError := input::parse("l"); asserts::is_true( t, moveError == none and move == game::Command.MoveUp and shotError == none and shot == game::Command.ShootRight, "WASD moves and IJKL shoots after one lowercase normalization" );}A asserção verifica os dois comandos e os dois erros. Uma normalização para minúsculas aceita ambas as caixas sem alterar o enum visto por Game.
Testar parse diretamente separa regressões de teclado das de combate; os testes de jogo podem focar linha de visão, munição e efeitos de turno.
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/06-archery < tutorial/checkpoints/06-archery/tests/input.txtEntrada padrão registrada
sslssA rota registrada S, S, L, S, S alinha jogador e goblin, dispara à direita e chega à saída. A morte ocorre no turno 3 e a vitória no turno 5, com 3 HP e 2 flechas.
Um disparo para cima na célula inicial demonstra o outro ramo: consome uma flecha, encontra a parede externa e retorna ShotMiss. O transcript distingue acerto, erro e aljava vazia em vez de reduzi-los a booleano.
Transcript acessível do frame
##########.......##..###..##.......##..###..##@......##########HP: 3 Arrows: 2 Turn: 5Stdout registrada completa
##########@......##..###..##......g##..###..##>......##########HP: 3 Arrows: 3 Turn: 0command>You move.The goblin moves closer.##########.......##@.###..##.....g.##..###..##>......##########HP: 3 Arrows: 3 Turn: 1command>You move.The goblin moves closer.##########.......##..###..##@...g..##..###..##>......##########HP: 3 Arrows: 3 Turn: 2command>Your arrow hits! The goblin falls.##########.......##..###..##@......##..###..##>......##########HP: 3 Arrows: 2 Turn: 3command>You move.##########.......##..###..##.......##@.###..##>......##########HP: 3 Arrows: 2 Turn: 4command>You reach the exit.##########.......##..###..##.......##..###..##@......##########HP: 3 Arrows: 2 Turn: 5You escaped Arrowfall!O que este checkpoint estabelece
Arrowfall agora tem movimento, perseguição, perigo, ação à distância limitada e duas respostas bem-sucedidas: escapar ou derrotar a ameaça antes de escapar.
As regras estão completas, mas empates sempre escolhem o mesmo eixo e redesenho interativo é evidência golden ruim. O checkpoint final adiciona variação controlada e opções de release sem alterar a política de combate.
Exercício
S S L S S deve matar no turno 3 e sair no turno 5 com 3 HP e 2 flechas. Disparo para cima no início deve errar.
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 (8 arquivos)
longbow.toml
[package]name = "arrowfall-step-06"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_06", "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, ShootUp, ShootLeft, ShootDown, ShootRight, Help, Quit}
export enum Outcome { default Playing, Won, Lost, Quit}
export enum PlayerEvent { default None, Moved, Wall, GoblinBlocks, ShotHit, ShotMiss, NoArrows, 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 bool goblinAlive; private i32 playerHealth; private u32 arrowCount; 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.goblinAlive = true; self.playerHealth = 3; self.arrowCount = 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 = self->goblinAlive and adjacent(self->goblin, self->player); PlayerEvent action = PlayerEvent.None; if is_shot(command) { action = self->shoot(command_direction(command)); } else { action = self->movePlayer(command_direction(command)); } if action == PlayerEvent.Wall or action == PlayerEvent.GoblinBlocks or action == PlayerEvent.NoArrows { if !DANGER_BEFORE { return TurnResult::make(action); } self->turnCount++; return TurnResult::make( action, self->goblinTurn(true), true ); } self->turnCount++; if tile_at(self->player) == '>' { self->gameOutcome = Outcome.Won; return TurnResult::make(PlayerEvent.Won, EnemyEvent.None, true); } return TurnResult::make( action, self->goblinTurn(DANGER_BEFORE), true );}
private PlayerEvent Game::movePlayer(&self, Direction direction) { next := self->player.moved(direction); if tile_at(next) == '#' { return PlayerEvent.Wall; } if self->goblinAlive and next.equals(&self->goblin) { return PlayerEvent.GoblinBlocks; } self->player = next; return PlayerEvent.Moved;}
private PlayerEvent Game::shoot(&self, Direction direction) { if self->arrowCount == 0 { return PlayerEvent.NoArrows; } self->arrowCount--; cursor := self->player.moved(direction); while tile_at(cursor) != '#' { if self->goblinAlive and cursor.equals(&self->goblin) { self->goblinAlive = false; return PlayerEvent.ShotHit; } cursor = cursor.moved(direction); } return PlayerEvent.ShotMiss;}
private EnemyEvent Game::goblinTurn(&self, bool canAttack) { if !self->goblinAlive { return EnemyEvent.None; } 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;}
bool Game::isGoblinAlive(&const self) { return self->goblinAlive;}
i32 Game::health(&const self) { return self->playerHealth;}
u32 Game::arrows(&const self) { return self->arrowCount;}
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.ShootUp => Direction.Up, Command.ShootLeft => Direction.Left, Command.ShootDown => Direction.Down, Command.ShootRight => Direction.Right, Command.Help => Direction.Up, Command.Quit => Direction.Up, };}
bool is_shot(Command command) { return command == Command.ShootUp or command == Command.ShootLeft or command == Command.ShootDown or command == Command.ShootRight;}
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) parse(stringv text) { value := strings::trim_ascii(text); 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 == 'i' { return (game::Command.ShootUp, none); } if KEY == 'j' { return (game::Command.ShootLeft, none); } if KEY == 'k' { return (game::Command.ShootDown, none); } if KEY == 'l' { return (game::Command.ShootRight, none); } if KEY == 'h' { return (game::Command.Help, none); } if KEY == 'q' { return (game::Command.Quit, none); } return (none, CommandError.InvalidCommand);}
export (game::Command, error) read_command() { string line; inputError := stdin("command>", &line); if inputError != none { return (game::Command.Quit, none); } return parse(line.view());}src/input_test.fl
module input_test;
@imports :: root::game :: root::input :: lib::std::tests::asserts :: lib::std::tests::testing;
test direct_key_diamonds_parse(&testing::UnitTest t) { move, moveError := input::parse("W"); shot, shotError := input::parse("l"); asserts::is_true( t, moveError == none and move == game::Command.MoveUp and shotError == none and shot == game::Command.ShootRight, "WASD moves and IJKL shoots after one lowercase normalization" );}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 state->isGoblinAlive() and 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(" Arrows: ") != none or canvas.appendInteger(state->arrows()) != 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.ShotHit { return "Your arrow hits! The goblin falls."; } if event == game::PlayerEvent.ShotMiss { return "Your arrow misses; a wall stops it."; } if event == game::PlayerEvent.NoArrows { return "Your quiver is empty."; } if event == game::PlayerEvent.Help { return "Move Shoot\n W I\nA S D J K L\nH 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/05-goblin/README.md tutorial/checkpoints/06-archery/README.md--- tutorial/checkpoints/05-goblin/README.md+++ tutorial/checkpoints/06-archery/README.md@@ -1 +1 @@-# 05 — A turn now has consequences+# 06 — The archer earns the name@@ -5,2 +5 @@-Checkpoint 04 has meaningful walls and an exit, but the room is safe and the-shortest path is automatic.+Checkpoint 05 adds pressure and fair warning, but the only response is to run.@@ -10,2 +9,2 @@-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.+An archer should trade a scarce resource for safety. The existing map already+makes direction and line of sight meaningful.@@ -15,4 +14,4 @@-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.+I/J/K/L shoots up/left/down/right. A shot consumes one of three arrows, walks+cell by cell until a wall, and kills the goblin if the line crosses it. Hit,+miss, and empty-quiver outcomes are distinct; twin control diamonds make both+directional action families visible.@@ -27,0 +27 @@+- `src/input_test.fl`@@ -35 +35 @@-Exact changes: `../../diffs/05-goblin.patch`.+Exact changes: `../../diffs/06-archery.patch`.@@ -40,4 +40,4 @@-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+longbow check tutorial/checkpoints/06-archery+longbow test tutorial/checkpoints/06-archery+longbow build tutorial/checkpoints/06-archery+longbow run tutorial/checkpoints/06-archery < tutorial/checkpoints/06-archery/tests/input.txt@@ -52 +52,2 @@-5. **A turn has consequences** — pursuit, warning, HP, defeat, events.+5. A goblin creates turns, threat, and defeat.+6. **The archer earns the name** — IJKL, arrows, line of sight, combat win.@@ -56,2 +57,2 @@-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.+The recorded `S S L S S` run must kill the goblin on turn 3 and reach the exit+on turn 5 with 3 HP and 2 arrows. An upward shot from start must miss.diff -rN -U0 --exclude target tutorial/checkpoints/05-goblin/longbow.lock tutorial/checkpoints/06-archery/longbow.lock--- tutorial/checkpoints/05-goblin/longbow.lock+++ tutorial/checkpoints/06-archery/longbow.lock@@ -7 +7 @@- "identity": "local/arrowfall_step_05",+ "identity": "local/arrowfall_step_06",diff -rN -U0 --exclude target tutorial/checkpoints/05-goblin/longbow.toml tutorial/checkpoints/06-archery/longbow.toml--- tutorial/checkpoints/05-goblin/longbow.toml+++ tutorial/checkpoints/06-archery/longbow.toml@@ -2 +2 @@-name = "arrowfall-step-05"+name = "arrowfall-step-06"diff -rN -U0 --exclude target tutorial/checkpoints/05-goblin/src/game.fl tutorial/checkpoints/06-archery/src/game.fl--- tutorial/checkpoints/05-goblin/src/game.fl+++ tutorial/checkpoints/06-archery/src/game.fl@@ -17,0 +18,4 @@+ ShootUp,+ ShootLeft,+ ShootDown,+ ShootRight,@@ -33,0 +38,3 @@+ ShotHit,+ ShotMiss,+ NoArrows,@@ -59,0 +67 @@+ private bool goblinAlive;@@ -60,0 +69 @@+ private u32 arrowCount;@@ -101,0 +111 @@+ self.goblinAlive = true;@@ -102,0 +113 @@+ self.arrowCount = 3;@@ -116,10 +127,11 @@- 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) {+ const bool DANGER_BEFORE = self->goblinAlive and+ adjacent(self->goblin, self->player);+ PlayerEvent action = PlayerEvent.None;+ if is_shot(command) {+ action = self->shoot(command_direction(command));+ } else {+ action = self->movePlayer(command_direction(command));+ }+ if action == PlayerEvent.Wall or+ action == PlayerEvent.GoblinBlocks or+ action == PlayerEvent.NoArrows {@@ -127 +139 @@- return TurnResult::make(PlayerEvent.GoblinBlocks);+ return TurnResult::make(action);@@ -131 +143 @@- PlayerEvent.GoblinBlocks,+ action,@@ -136 +147,0 @@- self->player = next;@@ -143 +154 @@- PlayerEvent.Moved,+ action,@@ -148,0 +160,28 @@+private PlayerEvent Game::movePlayer(&self, Direction direction) {+ next := self->player.moved(direction);+ if tile_at(next) == '#' {+ return PlayerEvent.Wall;+ }+ if self->goblinAlive and next.equals(&self->goblin) {+ return PlayerEvent.GoblinBlocks;+ }+ self->player = next;+ return PlayerEvent.Moved;+}++private PlayerEvent Game::shoot(&self, Direction direction) {+ if self->arrowCount == 0 {+ return PlayerEvent.NoArrows;+ }+ self->arrowCount--;+ cursor := self->player.moved(direction);+ while tile_at(cursor) != '#' {+ if self->goblinAlive and cursor.equals(&self->goblin) {+ self->goblinAlive = false;+ return PlayerEvent.ShotHit;+ }+ cursor = cursor.moved(direction);+ }+ return PlayerEvent.ShotMiss;+}+@@ -149,0 +189,3 @@+ if !self->goblinAlive {+ return EnemyEvent.None;+ }@@ -205,0 +248,4 @@+bool Game::isGoblinAlive(&const self) {+ return self->goblinAlive;+}+@@ -209,0 +256,4 @@+u32 Game::arrows(&const self) {+ return self->arrowCount;+}+@@ -244,0 +295,4 @@+ Command.ShootUp => Direction.Up,+ Command.ShootLeft => Direction.Left,+ Command.ShootDown => Direction.Down,+ Command.ShootRight => Direction.Right,@@ -247,0 +302,5 @@+}++bool is_shot(Command command) {+ return command == Command.ShootUp or command == Command.ShootLeft or+ command == Command.ShootDown or command == Command.ShootRight;diff -rN -U0 --exclude target tutorial/checkpoints/05-goblin/src/input.fl tutorial/checkpoints/06-archery/src/input.fl--- tutorial/checkpoints/05-goblin/src/input.fl+++ tutorial/checkpoints/06-archery/src/input.fl@@ -9,7 +9,2 @@-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());+export (game::Command, error) parse(stringv text) {+ value := strings::trim_ascii(text);@@ -31,0 +27,12 @@+ if KEY == 'i' {+ return (game::Command.ShootUp, none);+ }+ if KEY == 'j' {+ return (game::Command.ShootLeft, none);+ }+ if KEY == 'k' {+ return (game::Command.ShootDown, none);+ }+ if KEY == 'l' {+ return (game::Command.ShootRight, none);+ }@@ -38,0 +46,9 @@+}++export (game::Command, error) read_command() {+ string line;+ inputError := stdin("command>", &line);+ if inputError != none {+ return (game::Command.Quit, none);+ }+ return parse(line.view());diff -rN -U0 --exclude target tutorial/checkpoints/05-goblin/src/input_test.fl tutorial/checkpoints/06-archery/src/input_test.fl--- tutorial/checkpoints/05-goblin/src/input_test.fl+++ tutorial/checkpoints/06-archery/src/input_test.fl@@ -0,0 +1,18 @@+module input_test;++@imports+ :: root::game+ :: root::input+ :: lib::std::tests::asserts+ :: lib::std::tests::testing;++test direct_key_diamonds_parse(&testing::UnitTest t) {+ move, moveError := input::parse("W");+ shot, shotError := input::parse("l");+ asserts::is_true(+ t,+ moveError == none and move == game::Command.MoveUp and+ shotError == none and shot == game::Command.ShootRight,+ "WASD moves and IJKL shoots after one lowercase normalization"+ );+}diff -rN -U0 --exclude target tutorial/checkpoints/05-goblin/src/render.fl tutorial/checkpoints/06-archery/src/render.fl--- tutorial/checkpoints/05-goblin/src/render.fl+++ tutorial/checkpoints/06-archery/src/render.fl@@ -18 +18 @@- } else if position.equals(&goblin) {+ } else if state->isGoblinAlive() and position.equals(&goblin) {@@ -30,0 +31,2 @@+ canvas.append(" Arrows: ") != none or+ canvas.appendInteger(state->arrows()) != none or@@ -44,0 +47,9 @@+ if event == game::PlayerEvent.ShotHit {+ return "Your arrow hits! The goblin falls.";+ }+ if event == game::PlayerEvent.ShotMiss {+ return "Your arrow misses; a wall stops it.";+ }+ if event == game::PlayerEvent.NoArrows {+ return "Your quiver is empty.";+ }@@ -46 +57 @@- return "Move: W/A/S/D | H help | Q quit";+ return "Move Shoot\n W I\nA S D J K L\nH help | Q quit";diff -rN -U0 --exclude target tutorial/checkpoints/05-goblin/tests/frame.svg tutorial/checkpoints/06-archery/tests/frame.svg--- tutorial/checkpoints/05-goblin/tests/frame.svg+++ tutorial/checkpoints/06-archery/tests/frame.svg@@ -3 +3 @@- <title>05-goblin representative frame</title>+ <title>06-archery representative frame</title>@@ -8,2 +8,2 @@- <text x="24" y="75" class="terminal">#@.###..#</text>- <text x="24" y="94" class="terminal">#g......#</text>+ <text x="24" y="75" class="terminal">#..###..#</text>+ <text x="24" y="94" class="terminal">#.......#</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">HP: 0 Turn: 10</text>+ <text x="24" y="170" class="terminal">HP: 3 Arrows: 2 Turn: 5</text>diff -rN -U0 --exclude target tutorial/checkpoints/05-goblin/tests/frame.txt tutorial/checkpoints/06-archery/tests/frame.txt--- tutorial/checkpoints/05-goblin/tests/frame.txt+++ tutorial/checkpoints/06-archery/tests/frame.txt@@ -3,2 +2,0 @@-#@.###..#-#g......#@@ -6 +4,3 @@-#>......#+#.......#+#..###..#+#@......#@@ -8 +8 @@-HP: 0 Turn: 10+HP: 3 Arrows: 2 Turn: 5diff -rN -U0 --exclude target tutorial/checkpoints/05-goblin/tests/input.txt tutorial/checkpoints/06-archery/tests/input.txt--- tutorial/checkpoints/05-goblin/tests/input.txt+++ tutorial/checkpoints/06-archery/tests/input.txt@@ -1,6 +0,0 @@-d-a-d-a-d-a@@ -8,0 +3 @@+ldiff -rN -U0 --exclude target tutorial/checkpoints/05-goblin/tests/output.txt tutorial/checkpoints/06-archery/tests/output.txt--- tutorial/checkpoints/05-goblin/tests/output.txt+++ tutorial/checkpoints/06-archery/tests/output.txt@@ -8 +8 @@-HP: 3 Turn: 0+HP: 3 Arrows: 3 Turn: 0@@ -13,2 +13,2 @@-#.@.....#-#..###..#+#.......#+#@.###..#@@ -19,12 +19 @@-HP: 3 Turn: 1-command>-You move.-The goblin moves closer.-#########-#@......#-#..###..#-#....g..#-#..###..#-#>......#-#########-HP: 3 Turn: 2+HP: 3 Arrows: 3 Turn: 1@@ -35,12 +24 @@-#.@.....#-#..###..#-#...g...#-#..###..#-#>......#-#########-HP: 3 Turn: 3-command>-You move.-The goblin moves closer.-#########-#@......#+#.......#@@ -48 +26 @@-#..g....#+#@...g..#@@ -52 +30 @@-HP: 3 Turn: 4+HP: 3 Arrows: 3 Turn: 2@@ -54,2 +32 @@-You move.-The goblin moves closer.+Your arrow hits! The goblin falls.@@ -57,3 +34 @@-#.@.....#-#..###..#-#.g.....#+#.......#@@ -61,7 +35,0 @@-#>......#-#########-HP: 3 Turn: 5-command>-You move.-The goblin moves closer.-#########@@ -70,2 +37,0 @@-#g......#-#..###..#@@ -74 +40 @@-HP: 3 Turn: 6+HP: 3 Arrows: 2 Turn: 3@@ -77 +42,0 @@-The goblin closes in! Move before its next turn.@@ -80,2 +44,0 @@-#@.###..#-#g......#@@ -83,7 +45,0 @@-#>......#-#########-HP: 3 Turn: 7-command>-The goblin blocks that tile.-The goblin attacks: -1 HP.-#########@@ -92,2 +47,0 @@-#g......#-#..###..#@@ -96 +50 @@-HP: 2 Turn: 8+HP: 3 Arrows: 2 Turn: 4@@ -98,2 +52 @@-The goblin blocks that tile.-The goblin attacks: -1 HP.+You reach the exit.@@ -102,2 +54,0 @@-#@.###..#-#g......#@@ -105,7 +55,0 @@-#>......#-#########-HP: 1 Turn: 9-command>-The goblin blocks that tile.-The goblin attacks: -1 HP.-#########@@ -113,2 +56,0 @@-#@.###..#-#g......#@@ -116 +58 @@-#>......#+#@......#@@ -118,2 +60,2 @@-HP: 0 Turn: 10-The goblin caught you.+HP: 3 Arrows: 2 Turn: 5+You escaped Arrowfall!Comandos de check, teste, build e execução
longbow check tutorial/checkpoints/06-archerylongbow test tutorial/checkpoints/06-archerylongbow build tutorial/checkpoints/06-archerylongbow run tutorial/checkpoints/06-archery < tutorial/checkpoints/06-archery/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
- 6. Adicione arco e linha de visão
Próxima versão
As regras estão completas, mas empates de perseguição sempre são resolvidos da mesma forma e a limpeza interativa da tela não serve para saída golden. A versão final trata as duas limitações.
Adicione seeds determinísticas e verificações de release
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.