Ir al contenido
wannacode

El bucle del juego

En resumen: El bucle del juego repite los mismos pasos hasta que el juego termina: dibuja el mundo, lee un comando, lo procesa y actualiza el estado, y una semilla hace que cada partida se pueda repetir.

Problema

Un programa que termina

Un programa normal corre de arriba abajo y termina. Lee los números, calcula, imprime, listo. Un juego no puede funcionar así. Tiene que esperar al jugador, y entre dos comandos hay un mundo: dónde está el héroe, dónde está el goblin, cuántos turnos han pasado. Ese mundo tiene que sobrevivir hasta el siguiente comando.

Lo primero que se le ocurre a cualquiera es un while (true) que lee una línea y lo hace todo dentro. Un pasillo de 12 celdas: tú eres @, el goblin g te persigue, la salida es >:

int hero = 4;
int goblin = 0;
while (true)
{
string command = Console.ReadLine()!;
if (command == "q") break;
if (command == "a") hero--;
if (command == "d") hero++;
string row = "";
for (int x = 0; x <= 11; x++)
{
row += x == hero ? "@" : x == goblin ? "g" : x == 11 ? ">" : ".";
}
Console.WriteLine(row);
if (hero == 11)
{
Console.WriteLine("¡Salida!");
break;
}
goblin += goblin < hero ? 1 : -1;
if (Math.Abs(hero - goblin) <= 1)
{
Console.WriteLine("El goblin te atrapó.");
break;
}
}

El jugador teclea x (tecla equivocada) y luego a:

Salida
g...@......>
.g.@.......>
El goblin te atrapó.

Hay tres cosas mal:

  • Antes del primer comando la pantalla está vacía: el programa dibuja solo después de leer una línea.
  • El error de tecleo x es un turno: el goblin ha avanzado. El programa no sabe distinguir «el jugador hizo algo» de «el jugador se equivocó de tecla».
  • El último fotograma es .g.@: hay una celda libre entre ustedes, y el goblin te atrapa. El fotograma se imprimió después de tu paso y antes del suyo. Nadie vio nunca la posición en la que terminó el juego.

Y hay dos cosas imposibles. No puedes dibujar la pantalla de nuevo sin un turno: después de la ayuda con ? o del inventario, hay que copiar el dibujo. Y no puedes comprobar la regla «el goblin avanza hacia el héroe» sin teclear comandos y leer texto. Hay una sola causa: el mundo, las reglas y el dibujo están mezclados en un mismo lugar.

La idea

Cuatro pasos en círculo

Los separamos:

  • El estado es todo lo que el mundo es, en un solo objeto: el héroe, el goblin, la salida, el contador de turnos, el mensaje, el final. La pantalla no forma parte de él. La pantalla se calcula a partir de él.
  • Draw solo lee el estado e imprime un fotograma. Puedes llamarlo tantas veces como quieras.
  • Handle convierte el comando en una intención: Move vale -1, 1 o 0. No mueve a nadie.
  • Update cambia el mundo: el turno, el héroe, el goblin, el final. No imprime nada.

El bucle los llama en círculo: dibujar, leer, procesar, actualizar. Este es el bucle del juego. Todo juego tiene uno, desde este pasillo hasta Arcanum.

class State
{
public int Hero = 4;
public int Goblin = 0;
public int Exit = 11;
public int Turn = 0;
public int Move = 0;
public string Message = "";
public bool Over = false;
}
var state = new State();
while (!state.Over)
{
Draw(state);
string? command = Read();
Handle(state, command);
Update(state);
}
Draw(state);

Over detiene el bucle, y el último Draw después de él muestra el final: la salida o el goblin, con el mensaje.

Leer es una función aparte, porque la entrada se puede acabar:

static string? Read()
{
string? line = Console.ReadLine();
if (line != null) Console.WriteLine("> " + line);
return line;
}

Console.ReadLine() devuelve null al final de la entrada. Read lo pasa, y Handle lo trata como el final del juego. El eco > a está para que la salida muestre qué comando llegó. La consola del jugador lo muestra por sí sola.

Más precisamente, para curiosos

Sin la comprobación del final de la entrada, cada lenguaje se rompe a su manera. Java lanza una excepción, y Python también. C# es el más traicionero: Console.ReadLine() devuelve null, la comparación command == "q" es falsa, y el primer intento no se da cuenta de que la entrada terminó: imprime fotogramas de turnos vacíos hasta que el goblin llega solo hasta el héroe. JavaScript recibe undefined y hace lo mismo. La entrada se acaba más a menudo de lo que crees: un archivo de comandos, una terminal cerrada, la comprobación del sitio.

Míralo

Un turno de cerca

Aquí están las tres funciones. En ellas está todo el juego:

static void Draw(State s)
{
string row = "";
for (int x = 0; x <= s.Exit; x++)
{
if (x == s.Hero) row += "@";
else if (x == s.Goblin) row += "g";
else if (x == s.Exit) row += ">";
else row += ".";
}
Console.WriteLine("Turno " + s.Turn + " " + row);
if (s.Message != "") Console.WriteLine(s.Message);
}
static void Handle(State s, string? command)
{
s.Move = 0;
s.Message = "";
if (command == null)
{
s.Message = "La entrada terminó.";
s.Over = true;
}
else if (command == "a")
{
s.Move = -1;
}
else if (command == "d")
{
s.Move = 1;
}
else if (command == "q")
{
s.Message = "Te fuiste.";
s.Over = true;
}
else
{
s.Message = "No entiendo «" + command + "»: el turno no cuenta.";
}
}
static void Update(State s)
{
if (s.Move == 0) return;
s.Turn++;
s.Hero = Math.Max(0, s.Hero + s.Move);
if (s.Hero == s.Exit)
{
s.Message = "¡Salida! Escapaste en " + s.Turn + " turnos.";
s.Over = true;
return;
}
if (Math.Abs(s.Hero - s.Goblin) > 1) s.Goblin += s.Goblin < s.Hero ? 1 : -1;
if (Math.Abs(s.Hero - s.Goblin) <= 1)
{
s.Message = "El goblin te atrapó.";
s.Over = true;
}
}

var state = new State();
while (!state.Over)
{
Draw(state);
string? command = Read();
Handle(state, command);
Update(state);
}
Draw(state);
static void Draw(State s)
{
string row = "";
for (int x = 0; x <= s.Exit; x++)
{
if (x == s.Hero) row += "@";
else if (x == s.Goblin) row += "g";
else if (x == s.Exit) row += ">";
else row += ".";
}
Console.WriteLine("Turno " + s.Turn + " " + row);
if (s.Message != "") Console.WriteLine(s.Message);
}
static void Handle(State s, string? command)
{
s.Move = 0;
s.Message = "";
if (command == null)
{
s.Message = "La entrada terminó.";
s.Over = true;
}
else if (command == "a")
{
s.Move = -1;
}
else if (command == "d")
{
s.Move = 1;
}
else if (command == "q")
{
s.Message = "Te fuiste.";
s.Over = true;
}
else
{
s.Message = "No entiendo «" + command + "»: el turno no cuenta.";
}
}
static void Update(State s)
{
if (s.Move == 0) return;
s.Turn++;
s.Hero = Math.Max(0, s.Hero + s.Move);
if (s.Hero == s.Exit)
{
s.Message = "¡Salida! Escapaste en " + s.Turn + " turnos.";
s.Over = true;
return;
}
if (Math.Abs(s.Hero - s.Goblin) > 1) s.Goblin += s.Goblin < s.Hero ? 1 : -1;
if (Math.Abs(s.Hero - s.Goblin) <= 1)
{
s.Message = "El goblin te atrapó.";
s.Over = true;
}
}
Paso a paso, en palabras
  1. El turno 1 terminó: el héroe está en la celda 5, el goblin en la celda 1. Todo el mundo es el objeto de estado; el bucle vuelve a estar arriba.
  2. Draw recorre las 12 celdas e imprime el fotograma .g...@.....>. Solo lee el estado: en el panel no cambia nada.
  3. Read espera. El mundo se queda quieto hasta que el jugador pulse Enter. Llega la línea «a».
  4. Handle solo traduce el comando en una intención: Move = -1. Todavía nadie se ha movido.
  5. Update cambia el mundo. El contador de turnos pasa a 2 y el héroe avanza de 5 a 4. No es la salida, así que el turno continúa.
  6. El goblin está a 3 celdas, así que avanza hacia el héroe: de 1 a 2. Ahora son 2 celdas: no lo atrapó, el juego continúa.
  7. De vuelta arriba, Draw imprime ..g.@......>: el fotograma llega después de todo el turno, así que el jugador ve dónde está realmente el goblin.

Con los comandos d, a, x y siete veces d, el programa imprime:

Salida
Turno 0 g...@......>
> d
Turno 1 .g...@.....>
> a
Turno 2 ..g.@......>
> x
Turno 2 ..g.@......>
No entiendo «x»: el turno no cuenta.
> d
Turno 3 ...g.@.....>
> d
Turno 4 ....g.@....>
> d
Turno 5 .....g.@...>
> d
Turno 6 ......g.@..>
> d
Turno 7 .......g.@.>
> d
Turno 8 ........g.@>
> d
Turno 9 ........g..@
¡Salida! Escapaste en 9 turnos.

Con x el fotograma se dibuja otra vez, pero el turno sigue siendo 2 y el goblin está quieto: Update ve Move = 0 y no hace nada. Eso funciona porque Draw no toca el mundo. El goblin avanza después del héroe, y el fotograma llega después de todo el turno, así que ves exactamente dónde está. Al llegar a la salida el goblin no avanza: el juego termina antes de su paso.

Ahora las reglas se pueden comprobar sin pantalla y sin teclear comandos. Armas un estado a mano, llamas a Update y miras los números:

var near = new State { Hero = 5, Goblin = 3, Move = -1 };
Update(near);
Console.WriteLine(near.Hero + " " + near.Goblin + " " + near.Message);
var far = new State { Move = 1 };
Update(far);
Console.WriteLine(far.Hero + " " + far.Goblin + " " + far.Turn);
Salida
4 3 El goblin te atrapó.
5 1 1

Esto es una prueba de la regla «el goblin te atrapa si está a tu lado». Un juego real tiene cientos de comprobaciones como esta. Con el primer intento no podrías escribir ni una.

Más precisamente, para curiosos

¿Por qué Handle no mueve al héroe directamente? En el pasillo podría. Pero en Arcanum la misma a significa «izquierda» en el mapa, «atrás» en el menú y «nada» en un diálogo. La pantalla entiende el comando, y las reglas cambian el mundo. Segundo: un comando que no es un turno (un error de tecleo, abrir el inventario, la ayuda) no debe mover el mundo. Por eso la intención se guarda aparte, y update decide si hay un turno o no.

Pruébalo

Ordena las líneas

Pon en orden las líneas del bucle:

  1. Handle(state, command);
  2. {
  3. while (!state.Over)
  4. string? command = Read();
  5. }
  6. Draw(state);
  7. Update(state);

Pruébalo

¿Qué saldrá?

El mismo programa recibe los comandos a, x, a. Estas son las reglas:

static void Handle(State s, string? command)
{
s.Move = 0;
s.Message = "";
if (command == null)
{
s.Message = "La entrada terminó.";
s.Over = true;
}
else if (command == "a")
{
s.Move = -1;
}
else if (command == "d")
{
s.Move = 1;
}
else if (command == "q")
{
s.Message = "Te fuiste.";
s.Over = true;
}
else
{
s.Message = "No entiendo «" + command + "»: el turno no cuenta.";
}
}
static void Update(State s)
{
if (s.Move == 0) return;
s.Turn++;
s.Hero = Math.Max(0, s.Hero + s.Move);
if (s.Hero == s.Exit)
{
s.Message = "¡Salida! Escapaste en " + s.Turn + " turnos.";
s.Over = true;
return;
}
if (Math.Abs(s.Hero - s.Goblin) > 1) s.Goblin += s.Goblin < s.Hero ? 1 : -1;
if (Math.Abs(s.Hero - s.Goblin) <= 1)
{
s.Message = "El goblin te atrapó.";
s.Over = true;
}
}
Respuesta

Pruébalo

Encuentra el error

Un compañero decidió que el goblin debía moverse «en cada fotograma» y pasó su paso de Update a Draw. Con los comandos d, x, x, d, el programa imprime:

Salida
Turno 0 .g..@......>
> d
Turno 1 ..g..@.....>
> x
Turno 1 ...g.@.....>
No entiendo «x»: el turno no cuenta.
> x
Turno 1 ....g@.....>
No entiendo «x»: el turno no cuenta.
> d
Turno 2 .....g@....>
Turno 2 .....g@....>
La entrada terminó.

Ya en el fotograma 0 el goblin está en la celda 1. En los dos errores de tecleo el turno se queda en 1, y el goblin corre. Al final está junto al héroe y no pasa nada. ¿En qué línea está el error?

Elige la línea con el error.

La idea

Turno y fotograma

El pasillo es por turnos: el mundo está quieto hasta que llega un comando. Un turno, un fotograma. La consola entrega una línea entera solo después de Enter, y la lectura espera, así que un juego de consola es por turnos por naturaleza. También lo son el ajedrez, los roguelikes (Rogue, NetHack) y los combates de muchos juegos de estrategia.

Un juego en tiempo real no espera. El mundo se mueve según el reloj: si no haces nada, el goblin viene igualmente. El bucle corre decenas de veces por segundo: comprueba si hay una tecla pulsada (sin esperar), actualiza el mundo con el tiempo que ha pasado y dibuja. Un dibujo es un fotograma, y un paso del mundo es otra cosa.

Las cuatro partes son las mismas. Lo que cambia es quién maneja el bucle: la línea del jugador o el reloj. Ahí separar Draw de Update importa todavía más: un monitor rápido dibuja 144 fotogramas por segundo, pero el mundo tiene que avanzar al mismo ritmo en cada computadora.

Más precisamente, para curiosos

Un paso de tiempo fijo. Si el mundo avanza «lo que haya pasado desde el último fotograma», la física depende de los fotogramas: en un fotograma lento la flecha salta la pared, y dos computadoras nunca dan el mismo resultado. Por eso el mundo avanza en porciones iguales, por ejemplo de 50 ms. El tiempo transcurrido se acumula (un acumulador), y el bucle da tantos pasos como porciones enteras haya. El resto espera al siguiente fotograma.

Aquí el reloj es inventado (una lista de tiempos de fotograma), así que la salida es siempre la misma. La computadora rápida dibuja a intervalos desiguales, la lenta pocas veces:

static void Run(string name, int[] times)
{
int step = 50;
int acc = 0;
int x = 0;
int last = times[0];
foreach (int now in times)
{
acc += now - last;
last = now;
int steps = 0;
while (acc >= step)
{
x += 3;
steps++;
acc -= step;
}
Console.WriteLine(name + " " + now + " ms: pasos " + steps + ", x = " + x + ", resto " + acc + " ms");
}
}
Run("rápido", [0, 16, 40, 45, 110, 126, 150]);
Run("lento", [0, 90, 150]);
Salida
rápido 0 ms: pasos 0, x = 0, resto 0 ms
rápido 16 ms: pasos 0, x = 0, resto 16 ms
rápido 40 ms: pasos 0, x = 0, resto 40 ms
rápido 45 ms: pasos 0, x = 0, resto 45 ms
rápido 110 ms: pasos 2, x = 6, resto 10 ms
rápido 126 ms: pasos 0, x = 6, resto 26 ms
rápido 150 ms: pasos 1, x = 9, resto 0 ms
lento 0 ms: pasos 0, x = 0, resto 0 ms
lento 90 ms: pasos 1, x = 3, resto 40 ms
lento 150 ms: pasos 2, x = 9, resto 0 ms

A los 150 ms las dos han dado 3 pasos y la flecha está en 9. Los fotogramas son distintos, el mundo es el mismo. El tiempo sobrante también se usa para dibujar: con 10 ms de 50, la flecha se dibuja al 20 % del camino hacia su siguiente posición (interpolación), para que no vibre.

Leer sin esperar, y teclas en bruto. La terminal misma retiene la línea hasta que pulsas Enter. El programa no recibe nada hasta que llega la línea entera. Para el tiempo real la terminal tiene que pasar al modo «en bruto», donde cada tecla llega enseguida, y el programa tiene que poder preguntar «¿hay una tecla?» sin esperar.

Console.KeyAvailable dice si hay una tecla esperando sin bloquear, y Console.ReadKey(true) toma una tecla sin Enter y sin imprimirla. Un bucle en tiempo real: if (Console.KeyAvailable) { var key = Console.ReadKey(true); … }, luego actualizar y dibujar.

Arcanum sigue siendo por turnos. El mismo código tiene que correr en el navegador, en la comprobación (los comandos vienen de un archivo) y en la terminal de cualquiera. Una línea de texto funciona en todas partes. El modo en bruto no existe para un archivo.

La idea

Semilla y repetibilidad

Un juego necesita azar: un golpe acertado o fallado, qué botín cae, hacia dónde va el lobo. Pero un error que depende del azar no se puede repetir. Por eso el generador tiene una semilla: la misma semilla da los mismos números.

Este es el generador de Arcanum:

// mulberry32: un generador pequeño con semilla. Solo enteros de 32 bits (uint y unchecked), por eso
// da la misma secuencia en todos los lenguajes.
class Random
{
public uint State;
public Random(uint seed)
{
State = seed;
}
public uint Next32()
{
unchecked
{
State = State + 0x6d2b79f5;
uint z = State;
z = (z ^ (z >> 15)) * (z | 1);
z = z ^ (z + (z ^ (z >> 7)) * (z | 61));
return z ^ (z >> 14);
}
}
// De 0 a n-1, sin fracciones.
public int NextInt(int n)
{
return (int)(Next32() % (uint)n);
}
// De min a max, ambos incluidos.
public int Range(int min, int max)
{
return min + NextInt(max - min + 1);
}
public bool Chance(int percent)
{
return NextInt(100) < percent;
}
}

El generador recuerda un solo número, State. Cada Next32() lo mueve una constante grande y mezcla sus bits. NextInt(n) toma el resto de dividir entre n, es decir, un número de 0 a n − 1. Un dado de 20 caras es Range(1, 20). Lo tiramos cinco veces con la semilla 2026, luego otra vez con 2026 y luego con 7:

static void Roll(uint seed)
{
var dice = new Random(seed);
string line = "semilla " + seed + ":";
for (int i = 0; i < 5; i++) line += " " + dice.Range(1, 20);
Console.WriteLine(line);
}
Roll(2026);
Roll(2026);
Roll(7);
Salida
semilla 2026: 16 19 3 17 18
semilla 2026: 16 19 3 17 18
semilla 7: 13 11 15 11 16

La misma semilla, los mismos números. En los cuatro lenguajes.

public int Range(int min, int max)
{
    return min + NextInt();
}
Más precisamente, para curiosos

¿Por qué no el Random integrado? Cada lenguaje tiene su propio algoritmo. Java usa un generador congruencial lineal de 48 bits, .NET otro, Python el Mersenne Twister, y Math.random() en JavaScript no admite semilla. La misma semilla da números distintos en lenguajes distintos, y en JavaScript ni siquiera se puede fijar. mulberry32 son unas diez líneas, así que está escrito cuatro veces de la misma manera.

Los números tienen que ser enteros de 32 bits sin signo en todas partes. C# tiene uint, y unchecked dice que el desbordamiento es a propósito: los bits de arriba simplemente se pierden.

La idea

Solo números enteros

La semilla no basta. Toma la misma división en los cuatro lenguajes:

Console.WriteLine(-7 / 2);
Console.WriteLine(10.0 / 2);
Salida
-3
5

En los cuatro lenguajes la salida es:

Lenguajedivisión entera de −7 entre 210 / 2 como fracción
Java-35.0
C#-35
Python-45.0
JavaScript-35

Python redondea la división entera hacia abajo, los otros hacia cero. Un número de coma flotante se imprime con .0 o sin él. Un solo carácter distinto en la salida, y el combate de Python ya no es igual que el de Java. Por eso los cánones calculan solo con enteros, dividen explícitamente y nunca dividen números negativos. Ordenar siempre tiene una segunda regla, y las colecciones conservan el orden de inserción.

La recompensa es grande. Con la semilla, los comandos y la versión del juego, una partida es una función: la misma entrada da la misma salida. Un jugador escribe: «En el turno 412 el lobo me golpeó a través de la pared». Si el juego guarda su semilla y sus comandos, los ejecutas y llegas exactamente al turno 412, tantas veces como necesites, en un depurador. La comprobación del sitio hace lo mismo: los comandos grabados y la semilla 2026 dan byte por byte la misma salida en los cuatro lenguajes.

La idea

El bucle real de Arcanum

Este es el bucle del juego, sin recortar nada:

// El bucle del juego: dibujar → leer un comando → procesarlo → actualizar el mundo → aplicar los cambios
// a la pila. Se repite hasta que la pila queda vacía.
// Entrada y salida: Write imprime, ReadLine devuelve la línea o null al final, Save y Load guardan las partidas.
interface IGameIo
{
void Write(string text);
string? ReadLine();
void Save(string slot, string text);
string? Load(string slot);
}
class Options
{
public bool Plain;
public long Seed = 2026;
public string Lang = "bg";
public long Simulate;
public bool MemorySaves;
}
static partial class Arcanum
{
// Main(io, options) es el juego; el punto de entrada Main(args) está en el bloque boot.
#pragma warning disable CS0028
public static void Main(IGameIo io, Options options)
#pragma warning restore CS0028
{
uint seed = unchecked((uint)options.Seed);
if (options.Lang != "bg" && options.Lang != "en" && options.Lang != "de" && options.Lang != "es")
{
throw new Exception("unknown language: " + options.Lang + " (use bg, en, de or es)");
}
LANG = options.Lang;
if (options.MemorySaves) io.Save("3", TamperedSave());
LoadData();
long simulateCount = options.Simulate;
if (simulateCount > 0)
{
Simulate(io, (int)simulateCount, seed);
return;
}
ITerminal terminal = options.Plain ? new PlainTerminal(io) : new AnsiTerminal(io);
Game game = new Game(io, seed);
CellBuffer buffer = new CellBuffer(SCREEN_WIDTH, SCREEN_HEIGHT);
game.Stack.Push(new TitleScreen(game));
game.Stack.Apply();
while (!game.Stack.IsEmpty())
{
buffer.Clear();
game.Stack.Draw(buffer);
terminal.Present(buffer);
string? line = io.ReadLine();
if (line == null)
{
io.Write(T("ui.endOfInput") + "\n");
return;
}
terminal.Echo(line);
game.RawInput = TrimSpaces(line);
Screen top = game.Stack.Top()!;
top.Handle(game.RawInput.ToLowerInvariant());
top.Update();
game.Stack.Apply();
}
io.Write(T("ui.goodbye") + "\n");
}
}

Línea por línea:

  • game.Stack.Push(new TitleScreen(game)) pone la primera pantalla. Todo el mundo está en game, igual que en el pasillo está en state.
  • El bucle corre mientras haya algo en la pila de pantallas. Salir desde el menú quita todas las pantallas y el juego termina. Ese es el tema de la lección siguiente.
  • Dibujar. Las pantallas dibujan en un búfer de celdas (un carácter y un color), y la terminal decide cómo mostrarlo: como texto simple o con colores. Eso está en la lección sobre dibujar en la consola.
  • Leer. Si la entrada se ha acabado, el juego dice «fin de la entrada» y se detiene. Igual que nuestro null. El eco de la línea es como nuestro > a.
  • Procesar y actualizar. Solo la pantalla de arriba recibe el comando: el mapa, el combate o el menú.
  • Aplicar. El quinto paso, que el pasillo no tiene. Las pantallas piden cambios en la pila mientras procesan un comando (empezar un combate, cerrar el menú), y esos cambios se aplican solo al final del turno. Por qué no enseguida está en la lección sobre pantallas que devuelven una respuesta.

¿Dónde está el turno del mundo? En el pasillo está en Update. En Arcanum el mapa llama a WorldTurn() desde su propio handle, solo cuando el héroe de verdad hizo algo: dio un paso, abrió una puerta, esperó. Con un error de tecleo o con i para el inventario, el mundo no se mueve. Esto es nuestro «move es 0, no hay turno»:

// El turno del mundo tras el turno del héroe: efectos, recuperación lenta, enemigos, la emboscada.
public void WorldTurn()
{
Level level = Level()!;
Hero hero = Hero;
Turn += 1;
Stats.Turns += 1;
ComputeFov(level, hero.X, hero.Y);
TickEffects(hero, this);
if (!hero.IsAlive())
{
HeroDied();
return;
}
if (Turn % 3 == 0 && !hero.HasEffect("bleed") && !hero.HasEffect("poison"))
{
hero.Heal(1);
if (hero.Mana < hero.MaxMana()) hero.Mana += 1;
}
foreach (Enemy enemy in new List<Enemy>(level.Enemies))
{
if (!enemy.IsAlive()) continue;
EnemyTurnOnMap(this, level, enemy);
bool visible = level.Visible[enemy.Y][enemy.X];
TickEffects(enemy, visible ? this : Quiet);
if (!enemy.IsAlive())
{
enemy.Dead = true;
if (visible) Say(T("map.bledOut", ("name", enemy.Name)));
// Un enemigo que se desangra fuera del combate no suelta botín: no había nadie para recogerlo.
EnemyKilled(enemy, new Spoils { Gold = 0, Drop = null });
}
}
PruneDead();
ComputeFov(level, hero.X, hero.Y);
foreach (Enemy enemy in level.Enemies)
{
if (enemy.State == "chase" && Distance(enemy.X, enemy.Y, hero.X, hero.Y) <= 1)
{
StartBattle(enemy);
return;
}
}
}

El contador de turnos, los efectos (el sangrado corre cada turno), la recuperación lenta cada tercer turno, el turno de cada enemigo, los enemigos que se han desangrado. Y el final, igual que con el goblin: un enemigo que persigue y está junto al héroe empieza un combate. En combate los turnos siguen otra regla, por rapidez, así que cada pantalla decide por sí misma cuándo hay un turno, y update en el mapa solo recalcula lo que se puede ver.

Ejercicio

El goblin dormido

Resuélvelo en tu editor.

El goblin dormido. Al principio el goblin está dormido y se dibuja como z. Se despierta cuando el héroe está a 3 celdas o menos. En el turno en que se despierta no se mueve; después persigue como antes y se dibuja como g. El bucle no cambia: necesitas un campo en el estado, un carácter en Draw y unas líneas en Update.

La entrada:

Entrada
d
d
a
a
a
a
Salida
Turno 0 z...@......>
> d
Turno 1 z....@.....>
> d
Turno 2 z.....@....>
> a
Turno 3 z....@.....>
> a
Turno 4 z...@......>
> a
Turno 5 g..@.......>
> a
Turno 6 .g@........>
El goblin te atrapó.

Más ejercicios

  • Esperar. Añade un comando s: el héroe se queda quieto, pero el turno pasa y el goblin avanza. ¿Cómo distinguirás «quedarse quieto» de «error de tecleo» en Handle?
  • Grabar los comandos. Guarda en una lista cada comando que leas y al final escríbela en un archivo, un comando por línea. Vuelve a ejecutar el programa con ese archivo como entrada: la salida debe ser la misma. Así funciona la repetición del combate en Arcanum.
  • Dos goblins. Un segundo goblin empieza en la celda 11 y vigila la salida. ¿Quién avanza primero cuando los dos están junto al héroe?

Test

Ponte a prueba

1. ¿Por qué draw en el pasillo solo lee el estado y no cambia nada?
2. El jugador teclea x en el pasillo. ¿Qué pasa?
3. ¿Por qué un juego de consola es por turnos?
4. Un jugador reporta: «En el turno 412 el lobo me golpeó a través de la pared». ¿Qué necesitas para ver lo mismo?
5. Un bucle en tiempo real avanza la física cada 50 ms. Sobran 10 ms del último fotograma, y el fotograma nuevo llega 120 ms después. ¿Cuántos pasos da la física?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·