Enemigos que piensan
En resumen: Un enemigo en el mapa es una máquina de estados (duerme, deambula, persigue, huye) que persigue por el camino más corto hallado con búsqueda en anchura; en el combate elige la acción con la puntuación de utilidad más alta.
Problema
El goblin que choca contra la pared
Hasta ahora los enemigos de la mina estaban donde el mapa los había puesto. Un jugador que ve eso entiende enseguida que el juego es de cartón. Un enemigo vivo tiene que hacer tres cosas: decidir qué hacer (dormir, deambular, perseguir, huir), encontrar su camino y elegir en el combate.
La persecución más simple es: «da el paso que más te acerque al héroe». Aquí hay un goblin (g) y un héroe (@) a ambos lados de una pared con un hueco abajo:
#################......#.......##......#.......##.@....#g......##......#.......##..............#################En línea recta hay 6 celdas, pero la pared está en medio. La persecución simple solo compara la distancia: el goblin quiere ir a la izquierda, allí hay una pared, y se queda pegado a ella. Aquí se detiene:
#################......#.......##......#.......##.@....#g......##......#.......##..............#################Una línea recta no sabe nada de paredes. Hace falta un camino que las rodee, y el camino más corto en una cuadrícula se encuentra con la búsqueda en anchura. La lección la construye primero en el nivel pequeño de 16 × 7 (el hueco está abajo), luego pone encima una máquina de estados que decide cuándo se mueve el enemigo y, por último, muestra el segundo cerebro del juego: la puntuación de utilidad en el combate.
La idea
La ola: a cuántos pasos está cada celda
Imagina que lanzas una piedra al agua: desde donde cayó se extienden círculos. La búsqueda en anchura hace lo mismo sobre una cuadrícula. Empieza en el goblin: está a distancia 0. Sus vecinas están a 1. Las vecinas de las vecinas (aún no vistas) están a 2. Y así hasta que la ola cubre todo. Cada celda recibe un número: a cuántos pasos está del goblin por el suelo, no en línea recta.
Hace falta una cola: las celdas que la ola ha alcanzado pero aún no ha examinado. Del principio de la cola sale la celda más antigua (por eso la ola avanza en orden) y sus vecinas nuevas van al final. Una celda que ya tiene número no se añade por segunda vez.
const MAP = `#################......#.......##......#.......##......#.......##......#.......##..............#################`.trim();
const DIRECTIONS = [[0, -1], [1, 0], [0, 1], [-1, 0]];
// La ola: a cuántos pasos está cada celda del inicio.function distances(level, startX, startY) { const dist = Array.from({ length: level.height }, () => new Array(level.width).fill(-1)); dist[startY][startX] = 0; const queue = [{ x: startX, y: startY }]; let head = 0; while (head < queue.length) { const cell = queue[head]; head++; for (const [dx, dy] of DIRECTIONS) { const nextX = cell.x + dx; const nextY = cell.y + dy; if (!level.walkable(nextX, nextY) || dist[nextY][nextX] !== -1) continue; dist[nextY][nextX] = dist[cell.y][cell.x] + 1; queue.push({ x: nextX, y: nextY }); } } return dist;}const level = new Level(MAP);const dist = distances(level, ENEMY_X, ENEMY_Y);console.log("La ola desde el enemigo (a = 10, b = 11, ...):");printWave(level, dist);La ola desde el enemigo (a = 10, b = 11, ...):#################dcba98#2345678##cba987#1234567##ba9876#0123456##a98765#1234567##98765432345678#################a es 10, b es 11 y así sucesivamente. El goblin está en 0 (junto a la pared, en el borde izquierdo de la sala derecha) y la pared es #. La ola primero llena la sala derecha, luego pasa por el hueco de abajo y solo entonces sube a la izquierda. El héroe en (2, 3) está en a, es decir, a 10 pasos del goblin, aunque en línea recta está a 6: el número es la distancia real por el suelo.
shift(): sacar del principio de un arreglo desplaza todos los demás elementos, así que avanzamos con un índice head.La comprobación dist[nextY][nextX] != -1 es todo el «ya estuvo aquí»: la celda tiene un número, así que está en la cola o ha estado en ella. Sin ella la ola retrocedería y nunca terminaría.
Más precisamente, para curiosos
Las cuatro direcciones están en un orden fijo: arriba, derecha, abajo, izquierda. El orden no cambia las distancias (son iguales con cualquier orden), pero cambia cuál de los caminos igual de cortos se encuentra. En el juego el orden es fijo justo por eso: la misma entrada debe dar el mismo camino en Java, C#, Python y JavaScript; si no, la grabación de un combate y la comprobación con el recorrido grabado se desviarían.
Esta ola de distancias se llama distance field (también Dijkstra map): una búsqueda desde el héroe da un número para cada celda, y cada enemigo simplemente da un paso hacia la vecina con el número menor. Con muchos enemigos eso es mucho más barato que una búsqueda para cada uno.
La idea
El camino: de dónde vino cada celda
Los números muestran la distancia, pero el enemigo necesita los pasos. Añadimos una tabla: para cada celda recordamos de qué celda vino la ola. Cuando la ola llega a la meta, retrocedemos: la meta, de dónde vino, de dónde vino esa, y así hasta el inicio. Resulta el camino de atrás hacia delante, por eso al final lo invertimos.
function findPath(level, startX, startY, goalX, goalY) { const cameFrom = new Array(level.width * level.height).fill(-1); const start = startY * level.width + startX; cameFrom[start] = start; const queue = [start]; let head = 0; while (head < queue.length) { const current = queue[head]; head++; const x = current % level.width; const y = Math.floor(current / level.width); if (current !== start && x === goalX && y === goalY) { const path = []; let index = current; while (index !== start) { path.push({ x: index % level.width, y: Math.floor(index / level.width) }); index = cameFrom[index]; } path.reverse(); return path; } for (const [dx, dy] of DIRECTIONS) { const nextX = x + dx; const nextY = y + dy; if (!level.walkable(nextX, nextY)) continue; const next = nextY * level.width + nextX; if (cameFrom[next] !== -1) continue; cameFrom[next] = current; queue.push(next); } } return null;}
function distance(ax, ay, bx, by) { return Math.abs(ax - bx) + Math.abs(ay - by);}const level = new Level(MAP);const path = findPath(level, ENEMY_X, ENEMY_Y, HERO_X, HERO_Y);console.log("el enemigo (" + ENEMY_X + ", " + ENEMY_Y + "), el héroe (" + HERO_X + ", " + HERO_Y + "): en línea recta " + distance(ENEMY_X, ENEMY_Y, HERO_X, HERO_Y) + ", por el camino " + path.length);const dist = distances(level, ENEMY_X, ENEMY_Y);console.log("La ola desde el enemigo (a = 10, b = 11, ...):");printWave(level, dist);console.log("El camino hasta el héroe (*):");printPath(level, path, ENEMY_X, ENEMY_Y, HERO_X, HERO_Y);el enemigo (8, 3), el héroe (2, 3): en línea recta 6, por el camino 10La ola desde el enemigo (a = 10, b = 11, ...):#################dcba98#2345678##cba987#1234567##ba9876#0123456##a98765#1234567##98765432345678#################El camino hasta el héroe (*):#################......#.......##......#.......##.@****#g......##.....*#*......##.....***......#################El camino (*) baja por el hueco, sube junto a la pared a la sala izquierda y avanza a la izquierda hasta el héroe: 10 pasos frente a 6 en línea recta. La pared no molesta al algoritmo, porque la ola simplemente no entra en ella.
La búsqueda termina en cuanto saca la meta de la cola: después ya no necesita nada. Por eso la primera meta encontrada es la más cercana. Y si no hay camino (el héroe está tras una pared sin hueco), la cola se acaba y la función devuelve null.
Más precisamente, para curiosos
El primer paso del camino es solo una celda, pero el enemigo lo da y vuelve a preguntar en el turno siguiente. Así no puede quedarse pegado a un camino viejo: el héroe se ha movido, otro enemigo ha ocupado la celda, la puerta se ha cerrado.
El coste es bajo. El nivel del juego es de 50 × 20, unas 1000 celdas, y aunque se visiten todas, son unos miles de comprobaciones sencillas por enemigo y turno.
La idea
Una máquina de estados: duerme, deambula, persigue, huye
¿Cuándo usa el enemigo el camino? Depende de lo que sea ahora. Un durmiente no se mueve. Un vagabundo deambula. Un perseguidor sigue el camino. Uno que huye se aleja. Esto es una máquina de estados: un enemigo tiene un estado (una cadena: "sleep", "wander", "chase", "flee"), y en cada turno se dice «en este estado, en esta situación, qué hago y a qué estado paso».
| De | A | Cuándo |
|---|---|---|
sleep |
chase |
el camino hasta el héroe es como mucho 5 |
wander |
chase |
el enemigo «ve» al héroe: el camino es como mucho 8 |
chase |
wander |
más de 3 turnos sin verlo (lost > LOST) |
chase |
flee |
vida × 4 < vida máxima (menos de un cuarto) |
El programa usa el mismo mapa y un enemigo con 16 de vida. «Ver» está simplificado: un enemigo ve al héroe si el camino hasta él es como mucho 8. En el juego es una línea de visión de la lección del mapa (hasta 8 celdas), y aquí el camino da lo mismo sin otro algoritmo. El guion está en SCRIPT: el héroe se acerca, se esconde tras una esquina (hidden), vuelve a aparecer y por fin recibe un golpe (hit).
const SIGHT = 8;const WAKE = 5;const LOST = 3;const FAR = 20;const NONE = 99;const ENEMY_LIFE = 16;
// El enemigo «ve» al héroe si el camino hasta él es corto (en el juego: la línea de visión de la lección del mapa).function enemyTurn(level, enemy, heroX, heroY, turn, hidden) { const path = findPath(level, enemy.x, enemy.y, heroX, heroY); const pathLength = path === null ? NONE : path.length; const sees = !hidden && pathLength <= SIGHT; if (enemy.state === "sleep") { if (sees && pathLength <= WAKE) enemy.state = "chase"; return; } if (enemy.state === "wander") { if (sees) { enemy.state = "chase"; enemy.lost = 0; return; } // Deambula en el orden de las direcciones (el juego tira un dado). const [dx, dy] = DIRECTIONS[turn % 4]; const nextX = enemy.x + dx; const nextY = enemy.y + dy; if (level.walkable(nextX, nextY) && !(nextX === heroX && nextY === heroY)) { enemy.x = nextX; enemy.y = nextY; } return; } if (enemy.state === "chase") { if (enemy.life * 4 < enemy.maxLife) { enemy.state = "flee"; return; } if (!sees) { enemy.lost++; if (enemy.lost > LOST) { enemy.state = "wander"; return; } } else { enemy.lost = 0; } if (pathLength <= 1) return; if (pathLength <= FAR) { enemy.x = path[0].x; enemy.y = path[0].y; } return; } // Huye: el paso que más lo aleja del héroe. let bestX = enemy.x; let bestY = enemy.y; let bestDistance = distance(enemy.x, enemy.y, heroX, heroY); for (const [dx, dy] of DIRECTIONS) { const nextX = enemy.x + dx; const nextY = enemy.y + dy; if (!level.walkable(nextX, nextY) || (nextX === heroX && nextY === heroY)) continue; const away = distance(nextX, nextY, heroX, heroY); if (away > bestDistance) { bestDistance = away; bestX = nextX; bestY = nextY; } } enemy.x = bestX; enemy.y = bestY;}Cada estado es una rama de un if que retorna al final: en un turno ocurre exactamente una cosa. Se lee de arriba abajo como la tabla. El contador lost es la memoria de la máquina: sin él, el enemigo olvidaría al héroe en cuanto se esconde por primera vez.
const level = new Level(MAP);const enemy = new Enemy(ENEMY_X, ENEMY_Y, "sleep", ENEMY_LIFE);SCRIPT.forEach(([heroX, heroY, hit, hidden], i) => { enemy.life = Math.max(0, enemy.life - hit); const before = enemy.state; enemyTurn(level, enemy, heroX, heroY, i + 1, hidden); const path = findPath(level, enemy.x, enemy.y, heroX, heroY); const length = path === null ? NONE : path.length; const state = before === enemy.state ? before : before + " → " + enemy.state; console.log("turno " + (i + 1) + ": " + state + ", enemigo (" + enemy.x + ", " + enemy.y + "), camino " + length + ", vida " + enemy.life);});turno 1: sleep, enemigo (8, 3), camino 7, vida 16turno 2: sleep, enemigo (8, 3), camino 6, vida 16turno 3: sleep → chase, enemigo (8, 3), camino 5, vida 16turno 4: chase, enemigo (8, 4), camino 4, vida 16turno 5: chase, enemigo (8, 5), camino 3, vida 16turno 6: chase, enemigo (7, 5), camino 2, vida 16turno 7: chase, enemigo (6, 5), camino 1, vida 16turno 8: chase → wander, enemigo (6, 5), camino 1, vida 16turno 9: wander, enemigo (7, 5), camino 2, vida 16turno 10: wander → chase, enemigo (7, 5), camino 2, vida 16turno 11: chase, enemigo (6, 5), camino 1, vida 16turno 12: chase → flee, enemigo (6, 5), camino 1, vida 3turno 13: flee, enemigo (7, 5), camino 2, vida 3turno 14: flee, enemigo (8, 5), camino 3, vida 3turno 15: flee, enemigo (9, 5), camino 4, vida 3Turno 3: el camino pasa a 5 y sleep → chase. Turnos 4–7: el goblin avanza por el camino. En el turno 8 lost supera 3 y pasa a wander; en el turno 9 da un paso como vagabundo y en el turno 10 vuelve a ver al héroe. En el turno 12 el héroe lo golpea y le quita 13 de sus 16 de vida: 3 × 4 = 12 < 16 y chase → flee. Huir no usa el camino: de las cuatro vecinas toma la que más lo aleja (away > bestDistance).
Más precisamente, para curiosos
¿Por qué cadenas y no un enum? El canon usa cadenas porque los estados se escriben en la partida guardada y se leen de un archivo. Así, un nuevo tipo de enemigo con un estado nuevo no necesita un tipo nuevo. El precio es que un error en el nombre ("chaes") no se detecta al compilar.
Una máquina de estados es la forma más simple de describir un comportamiento, pero crece: diez estados con cinco transiciones cada uno son cincuenta líneas difíciles de leer. Entonces se pasa a un behavior tree o a la puntuación de utilidad, como la del combate más abajo.
Míralo
La persecución en acción
Aquí está lo mismo turno a turno. El goblin duerme al otro lado de la pared. El héroe se acerca; el camino pasa a 5 y el goblin despierta. Entonces se ve la búsqueda: una ola parte del goblin y rodea la pared por el hueco hasta llegar al héroe. Después el goblin avanza, pierde al héroe, lo vuelve a notar y por fin huye.
function findPath(level, startX, startY, goalX, goalY) { const cameFrom = new Array(level.width * level.height).fill(-1); const start = startY * level.width + startX; cameFrom[start] = start; const queue = [start]; let head = 0; while (head < queue.length) { const current = queue[head]; head++; const x = current % level.width; const y = Math.floor(current / level.width); if (current !== start && x === goalX && y === goalY) { const path = []; let index = current; while (index !== start) { path.push({ x: index % level.width, y: Math.floor(index / level.width) }); index = cameFrom[index]; } path.reverse(); return path; } for (const [dx, dy] of DIRECTIONS) { const nextX = x + dx; const nextY = y + dy; if (!level.walkable(nextX, nextY)) continue; const next = nextY * level.width + nextX; if (cameFrom[next] !== -1) continue; cameFrom[next] = current; queue.push(next); } } return null;}
// El enemigo «ve» al héroe si el camino hasta él es corto (en el juego: la línea de visión de la lección del mapa).function enemyTurn(level, enemy, heroX, heroY, turn, hidden) { const path = findPath(level, enemy.x, enemy.y, heroX, heroY); const pathLength = path === null ? NONE : path.length; const sees = !hidden && pathLength <= SIGHT; if (enemy.state === "sleep") { if (sees && pathLength <= WAKE) enemy.state = "chase"; return; } if (enemy.state === "wander") { if (sees) { enemy.state = "chase"; enemy.lost = 0; return; } // Deambula en el orden de las direcciones (el juego tira un dado). const [dx, dy] = DIRECTIONS[turn % 4]; const nextX = enemy.x + dx; const nextY = enemy.y + dy; if (level.walkable(nextX, nextY) && !(nextX === heroX && nextY === heroY)) { enemy.x = nextX; enemy.y = nextY; } return; } if (enemy.state === "chase") { if (enemy.life * 4 < enemy.maxLife) { enemy.state = "flee"; return; } if (!sees) { enemy.lost++; if (enemy.lost > LOST) { enemy.state = "wander"; return; } } else { enemy.lost = 0; } if (pathLength <= 1) return; if (pathLength <= FAR) { enemy.x = path[0].x; enemy.y = path[0].y; } return; } // Huye: el paso que más lo aleja del héroe. let bestX = enemy.x; let bestY = enemy.y; let bestDistance = distance(enemy.x, enemy.y, heroX, heroY); for (const [dx, dy] of DIRECTIONS) { const nextX = enemy.x + dx; const nextY = enemy.y + dy; if (!level.walkable(nextX, nextY) || (nextX === heroX && nextY === heroY)) continue; const away = distance(nextX, nextY, heroX, heroY); if (away > bestDistance) { bestDistance = away; bestX = nextX; bestY = nextY; } } enemy.x = bestX; enemy.y = bestY;}Paso a paso, en palabras
- El goblin duerme en (8, 3). El héroe está al otro lado de la pared. En el turno 2 el camino hasta el héroe es 6, y para despertar hace falta como máximo 5: el goblin sigue durmiendo, aunque en línea recta el héroe está a solo 4 celdas.
- Turno 3: el héroe está en (6, 4). En línea recta, a través de la pared, son 3 celdas, pero el camino es 5 ≤ 5: sleep → chase.
- Búsqueda en anchura desde el goblin: una cola de la que sale primero la celda más antigua. La ola se extiende por el suelo celda a celda y rodea la pared por el hueco. Salen de la cola 27 de 66 celdas hasta llegar al héroe.
- El héroe está encontrado. Desde el héroe se retrocede por cameFrom, celda a celda hasta el goblin, y se invierte el camino: 5 pasos. El goblin dará el primero.
- En cada turno el goblin pregunta de nuevo y da solo el primer paso del camino: 4, 3, 2, 1 hasta el héroe. Si el héroe se mueve, el camino es otro.
- El héroe se oculta tras una esquina (la bola pálida). El goblin aún avanza hacia él, pero cuenta: tras 3 turnos sin verlo (lost > 3) en el turno 8 chase → wander.
- Turno 9: el goblin deambula. La dirección sale del número de turno (el juego tira un dado), así que para el héroe el paso parece aleatorio, no dirigido.
- Turno 10: el héroe aparece de nuevo, el camino es 2 ≤ 8: wander → chase. El goblin vuelve a perseguirlo y llega hasta el héroe.
- Turno 12: el golpe del héroe deja al goblin con 3 de 16 de vida. La prueba es vida × 4 < vida máxima: 12 < 16, así que chase → flee.
- Huir no necesita camino ni búsqueda: de los cuatro vecinos toma el que más lo aleja del héroe (2, 3, 4 celdas).
El código junto a la escena es findPath y enemyTurn del programa. En cada paso se iluminan las líneas que trabajan en ese momento.
La idea
En el juego
Lo que construimos es lo mismo que en el canon, solo que más pequeño. Aquí está la búsqueda en anchura real. Recibe dos funciones en lugar de una meta ya hecha y un mapa de lo transitable ya hecho: isPassable dice dónde puede pisar un enemigo (no a través de otros enemigos ni del héroe), e isGoal dice cuál es la celda meta:
// Búsqueda en anchura: una cola de casillas, cada una recuerda de dónde vino. El primer objetivo encontrado es// el más cercano. Las direcciones van en un orden fijo (arriba, derecha, abajo, izquierda), así que el camino es el mismo// en todos los lenguajes.const DIRECTIONS = [[0, -1], [1, 0], [0, 1], [-1, 0]];
// isPassable(x, y) e isGoal(x, y) son funciones; devuelve el camino sin el inicio, o null.function findPath(level, startX, startY, isPassable, isGoal) { const cameFrom = new Array(level.width * level.height).fill(-1); const startIndex = startY * level.width + startX; cameFrom[startIndex] = startIndex; const queue = [startIndex]; let head = 0; while (head < queue.length) { const current = queue[head]; head++; const x = current % level.width; const y = Math.floor(current / level.width); if (current !== startIndex && isGoal(x, y)) { const path = []; let index = current; while (index !== startIndex) { path.push({ x: index % level.width, y: Math.floor(index / level.width) }); index = cameFrom[index]; } path.reverse(); return path; } for (const direction of DIRECTIONS) { const nextX = x + direction[0]; const nextY = y + direction[1]; if (!level.inBounds(nextX, nextY)) continue; const nextIndex = nextY * level.width + nextX; if (cameFrom[nextIndex] !== -1) continue; if (!isPassable(nextX, nextY) && !isGoal(nextX, nextY)) continue; cameFrom[nextIndex] = current; queue.push(nextIndex); } } return null;}
function distance(ax, ay, bx, by) { return Math.abs(ax - bx) + Math.abs(ay - by);}Y aquí está la máquina de estados en el mapa. Además de nuestros estados hay guard (un guardián en su puesto), calm (cuando el héroe huye de un combate, los enemigos deambulan y no lo notan durante 6 turnos) y un dado para despertar:
// La IA del mapa es una máquina de estados: duerme → deambula → persigue → huye con poca vida.// El Guardián se queda en su puesto (guard) hasta que ve al héroe.function enemySeesHero(level, enemy, hero) { if (distance(enemy.x, enemy.y, hero.x, hero.y) > 8) return false; return lineOfSight(level, enemy.x, enemy.y, hero.x, hero.y);}
function canEnemyStep(game, level, x, y) { if (!level.isWalkable(x, y)) return false; if (level.enemyAt(x, y) !== null || level.npcAt(x, y) !== null) return false; return !(game.hero.x === x && game.hero.y === y);}
function moveEnemy(game, level, enemy, x, y) { if (enemy.hasEffect('bleed')) level.addBlood(enemy.x, enemy.y); enemy.x = x; enemy.y = y;}
function enemyTurnOnMap(game, level, enemy) { const hero = game.hero; const rng = game.rng; const sees = enemySeesHero(level, enemy, hero); const heroSeesIt = level.visible[enemy.y][enemy.x]; if (enemy.calm > 0) enemy.calm -= 1; if (enemy.state === 'sleep') { if (sees && distance(enemy.x, enemy.y, hero.x, hero.y) <= 5 && rng.chance(50)) { enemy.state = 'chase'; if (heroSeesIt) game.say(t('ai.wakes', { name: enemy.name })); } return; } if (enemy.state === 'guard') { if (sees && distance(enemy.x, enemy.y, hero.x, hero.y) <= 6) { enemy.state = 'chase'; game.say(t('ai.guard', { name: enemy.name })); } return; } if (enemy.state === 'wander') { if (sees && enemy.calm === 0) { enemy.state = 'chase'; enemy.lostTurns = 0; if (heroSeesIt) game.say(t('ai.notices', { name: enemy.name })); return; } const roll = rng.nextInt(5); if (roll < 4) { const nextX = enemy.x + DIRECTIONS[roll][0]; const nextY = enemy.y + DIRECTIONS[roll][1]; if (canEnemyStep(game, level, nextX, nextY)) moveEnemy(game, level, enemy, nextX, nextY); } return; } if (enemy.state === 'chase') { if (enemy.kind.special !== 'warden' && enemy.isHeavilyWounded()) { enemy.state = 'flee'; if (heroSeesIt) game.say(t('ai.flees', { name: enemy.name })); return; } if (!sees) { enemy.lostTurns += 1; if (enemy.lostTurns > 5) { enemy.state = 'wander'; return; } } else { enemy.lostTurns = 0; } if (distance(enemy.x, enemy.y, hero.x, hero.y) <= 1) return; const path = findPath(level, enemy.x, enemy.y, (x, y) => canEnemyStep(game, level, x, y), (x, y) => x === hero.x && y === hero.y); if (path !== null && path.length > 1 && path.length <= 20) moveEnemy(game, level, enemy, path[0].x, path[0].y); return; } // flee: el paso que más aleja del héroe let bestX = enemy.x; let bestY = enemy.y; let bestDistance = distance(enemy.x, enemy.y, hero.x, hero.y); for (const direction of DIRECTIONS) { const nextX = enemy.x + direction[0]; const nextY = enemy.y + direction[1]; if (!canEnemyStep(game, level, nextX, nextY)) continue; const away = distance(nextX, nextY, hero.x, hero.y); if (away > bestDistance) { bestDistance = away; bestX = nextX; bestY = nextY; } } if (bestX !== enemy.x || bestY !== enemy.y) moveEnemy(game, level, enemy, bestX, bestY);}Las diferencias con nuestro programa son pocas, pero importantes:
enemySeesHerousa la línea de visión (Bresenham de la lección del mapa), no la longitud de un camino. Así, un enemigo puede verte por una puerta abierta, pero no a través de una pared.- Un durmiente despierta con un 50 % de probabilidad por turno (
rng.chance(50)), y un vagabundo se mueve solo en 4 de 5 tiradas del dado (rng.nextInt(5)). El dado viene del generador con semilla del juego, así que el juego sigue siendo repetible. lostTurns > 5, no 3. El camino se limita conpath.length <= 20: un enemigo más lejano no persigue.- El jefe (
warden) nunca huye, por muy herido que esté. - Los mensajes (
ai.wakes,ai.notices,ai.flees) solo se escriben si el héroe ve al enemigo: el juego no revela más de lo que ves.
La idea
En el combate: puntuación de utilidad
En el mapa un enemigo tiene un estado. En el combate no hay adónde ir: hay una elección entre varias acciones, y tiene que ser sensata. Una máquina de estados aquí significaría una línea por cada par «situación — acción». En lugar de eso, cada acción recibe un número: cuán útil es ahora. El enemigo toma el mayor.
La regla es corta: lo predeterminado es un ataque con 40. Cada otra acción posible tiene su propia puntuación: curar a un amigo herido 80, escupir 60. Si una puntuación es mayor que la mejor hasta ahora, la acción la reemplaza.
class Chooser { constructor(attack) { this.best = attack; this.bestScore = 40; this.trace = attack + " 40"; }
consider(score, action) { this.trace += ", " + action + " " + score; if (score > this.bestScore) { this.best = action; this.bestScore = score; } }}
function percent(a, b) { return Math.floor((a * 100) / b);}
function chooseEnemyAction(hero, heroPoisoned, allies, healWait, spitWait) { const chooser = new Chooser("ataque a " + hero.name); let weakest = null; for (const ally of allies) { if (percent(ally.hp, ally.maxHp) < 50 && (weakest === null || percent(ally.hp, ally.maxHp) < percent(weakest.hp, weakest.maxHp))) { weakest = ally; } } if (weakest !== null && healWait === 0) chooser.consider(80, "cura a " + weakest.name); if (spitWait === 0 && !heroPoisoned) chooser.consider(60, "escupe a " + hero.name); return chooser;}
function weakestOf(enemies) { let weakest = null; for (const enemy of enemies) { if (weakest === null || enemy.hp < weakest.hp) weakest = enemy; } return weakest;}
function chooseHeroAction(hero, hasPotion, enemies) { const chooser = new Chooser("ataque a " + weakestOf(enemies).name); if (percent(hero.hp, hero.maxHp) < 35 && hasPotion) chooser.consider(90, "poción"); return chooser;}
function show(chooser) { console.log(chooser.trace + " → " + chooser.best);}El programa muestra seis decisiones. Cada línea es: las acciones consideradas con sus puntuaciones, una flecha y la elegida.
const hero = { name: "Iva", hp: 30, maxHp: 40 };const hurtHero = { name: "Iva", hp: 10, maxHp: 40 };const woundedRat = { name: "rata", hp: 3, maxHp: 9 };const rat = { name: "rata", hp: 9, maxHp: 9 };const goblin = { name: "goblin", hp: 16, maxHp: 16 };const woundedGoblin = { name: "goblin", hp: 6, maxHp: 16 };const cultist = { name: "cultista", hp: 3, maxHp: 20 };
show(chooseEnemyAction(hero, false, [woundedRat, goblin], 0, 0));show(chooseEnemyAction(hero, false, [woundedRat, goblin], 2, 0));show(chooseEnemyAction(hero, true, [rat, goblin], 0, 0));show(chooseEnemyAction(hero, false, [woundedRat, woundedGoblin], 0, 3));show(chooseHeroAction(hero, true, [woundedRat, goblin, cultist]));show(chooseHeroAction(hurtHero, true, [woundedRat, goblin, cultist]));ataque a Iva 40, cura a rata 80, escupe a Iva 60 → cura a rataataque a Iva 40, escupe a Iva 60 → escupe a Ivaataque a Iva 40 → ataque a Ivaataque a Iva 40, cura a rata 80 → cura a rataataque a rata 40 → ataque a rataataque a rata 40, poción 90 → pociónLas primeras cuatro son del chamán. Con una rata herida y la cura lista, cura (80 > 60 > 40). Si la cura tiene que esperar (healWait es 2), escupe (60). Si el héroe ya está envenenado y ningún amigo está herido, queda el ataque (40). En la cuarta línea están heridos tanto la rata (33 %) como el goblin (37 %); se cura el porcentaje menor, es decir, la rata.
Las dos últimas líneas son del héroe en piloto automático (en la simulación del juego y en el combate automático). Golpea al más débil: el que tiene menos vida. La rata (3) y el cultista (3) están empatados y gana el primero, porque < no reemplaza en un empate. Con un 25 % de vida (menos del 35 %) y con una poción, la puntuación de la poción (90) supera 40 y el héroe bebe.
class Chooser {
constructor(attack) {
this.best = attack;
this.bestScore = 40;
this.trace = attack + " 40";
}
consider(score, action) {
this.trace += ", " + action + " " + score;
if (score this.bestScore) {
this.best = action;
this.bestScore = score;
}
}
}Una acción nueva reemplaza a la mejor solo si es mejor: >. Con >= las puntuaciones iguales cambiarían la elección y el orden de consideración decidiría lo contrario. Por eso el ataque con 40 sigue elegido hasta que algo lo supera.
Los números 80, 60, 40 no son una ley. Son un ajuste: si el chamán cura demasiado a menudo, el juego se vuelve aburrido, y si lo hace muy poco, imposible. Por eso la última lección (el juego entero y el equilibrio) ejecuta mil combates para afinar estos números.
// En combate la IA puntúa cada acción posible con un número (utilidad) y elige// la más útil: se cura con poca vida, usa una habilidad cuando está lista y, si no, ataca.function chooseEnemyAction(battle, enemy) { const hero = battle.hero; let best = new AttackAction(enemy, hero); let bestScore = 40; const consider = (score, action) => { if (score > bestScore) { best = action; bestScore = score; } }; const special = enemy.kind.special; if (special === 'shaman') { let weakest = null; for (const ally of battle.aliveEnemies()) { if (percent(ally.hp, ally.maxHp()) < 50 && (weakest === null || percent(ally.hp, ally.maxHp()) < percent(weakest.hp, weakest.maxHp()))) weakest = ally; } if (weakest !== null && enemy.cooldownOf('heal') === 0) consider(80, new SpecialAction(enemy, 'heal', weakest)); if (enemy.cooldownOf('shaman') === 0 && !hero.hasEffect('poison')) consider(60, new SpecialAction(enemy, 'spit', hero)); } else if (special === 'firebolt' && enemy.cooldownOf('firebolt') === 0) { consider(65, new SpecialAction(enemy, 'firebolt', hero)); } else if (special === 'rend' && enemy.cooldownOf('rend') === 0 && !hero.hasEffect('bleed')) { consider(55, new SpecialAction(enemy, 'rend', hero)); } else if (special === 'slam' && enemy.cooldownOf('slam') === 0) { consider(60, new SpecialAction(enemy, 'slam', hero)); } else if (special === 'warden') { if (enemy.phase === 1 && enemy.hp * 2 < enemy.maxHp()) consider(100, new SpecialAction(enemy, 'summon', hero)); else if (enemy.cooldownOf('warden') === 0) consider(60, new SpecialAction(enemy, 'slam', hero)); } return best;}
function usableSkills(hero) { const result = []; for (const skillId of hero.skills) { const skill = SKILLS.get(skillId); if (hero.mana >= skill.mana && hero.cooldownOf(skill.id) === 0) result.push(skill); } return result;}
function weakestEnemy(battle) { let weakest = null; for (const enemy of battle.aliveEnemies()) { if (weakest === null || enemy.hp < weakest.hp) weakest = enemy; } return weakest;}
// El héroe en piloto automático (la simulación): la misma puntuación de utilidad.function chooseHeroAction(battle) { const hero = battle.hero; const target = weakestEnemy(battle); let best = new AttackAction(hero, target); let bestScore = 40; const consider = (score, action) => { if (score > bestScore) { best = action; bestScore = score; } }; const lifeShare = percent(hero.hp, hero.maxHp()); if (lifeShare < 35 && hero.inventory.count('potion_heal') > 0) consider(90, new ItemAction(hero, 'potion_heal')); if ((hero.hasEffect('poison') || hero.hasEffect('bleed')) && lifeShare < 50 && hero.inventory.count('antidote') > 0) { consider(70, new ItemAction(hero, 'antidote')); } const many = battle.aliveEnemies().length >= 2; for (const skill of usableSkills(hero)) { let score = 0; if (skill.kind === 'heal') score = lifeShare < 50 ? 75 : 0; else if (skill.kind === 'buff') score = hero.hasEffect(skill.effect) ? 0 : 50; else if (skill.kind === 'debuff') score = many ? 55 : 45; else if (skill.target === 'all') score = many ? 65 : 52; else score = 55; consider(score, new SkillAction(hero, skill, target)); } return best;}El canon añade más: la flecha de fuego del cultista 65, desgarrar 55 (solo si el héroe aún no sangra), un golpe pesado 60 y el jefe, que con menos de la mitad de vida invoca ayudantes con una puntuación de 100. Pero la forma es la misma: consider(puntuación, acción). Las acciones son objetos de la lección sobre habilidades y acciones como comandos: AttackAction, SkillAction y así sucesivamente. Por eso un enemigo, un héroe en piloto automático y una simulación eligen de la misma manera.
Pruébalo
Predice la salida
Un mapa pequeño con un obstáculo. La búsqueda empieza en (1, 1). ¿Qué imprime el programa?
const TINY = `########.#...##...#.########`.trim();const level = new Level(TINY);printWave(level, distances(level, 1, 1));La ola avanza por el suelo: desde (1, 1) hacia abajo (1), a la derecha (2 y 3), hacia arriba (4), a la derecha (5 y 6) y otra vez hacia abajo (7). La pared # entre 0 y 4 bloquea el camino directo, por eso 4 viene solo después de 1, 2 y 3. La segunda respuesta es la distancia en línea recta, que no sabe nada de la pared; la tercera empieza a contar en 1 en vez de en 0.
Pruébalo
Encuentra el error
Un colega cambió la búsqueda y ahora el camino hasta el héroe es más largo:
const level = new Level(MAP);const path = findPath(level, ENEMY_X, ENEMY_Y, HERO_X, HERO_Y);console.log("el enemigo (" + ENEMY_X + ", " + ENEMY_Y + "), el héroe (" + HERO_X + ", " + HERO_Y + "): en línea recta " + distance(ENEMY_X, ENEMY_Y, HERO_X, HERO_Y) + ", por el camino " + path.length);console.log("El camino hasta el héroe (*):");printPath(level, path, ENEMY_X, ENEMY_Y, HERO_X, HERO_Y);el enemigo (8, 3), el héroe (2, 3): en línea recta 6, por el camino 12El camino hasta el héroe (*):#################......#.......##......#.......##*@....#g......##*.....#*......##********......#################El camino es de 12 pasos en lugar de 10 y da un rodeo innecesario. ¿Qué línea toma las celdas del extremo equivocado?
Elige la línea con el error.
La celda se toma del final de la lista, no del principio. Así la lista es una pila y no una cola: la celda más nueva se examina primero y la búsqueda sigue un pasillo hasta el fondo (búsqueda en profundidad). El camino es válido, pero no el más corto. Lo correcto: tomar del principio (con head), y las celdas nuevas van al final: «el primero en entrar es el primero en salir».
Más precisamente
Con más precisión: A*, campos y puntos débiles
A*. La búsqueda en anchura mira por igual en todas las direcciones, incluso lejos de la meta. A* añade una pista: para cada celda calcula f = g + h, donde g son los pasos hasta ahora y h es una estimación de cuántos quedan (casi siempre la distancia en línea recta a lo largo de los ejes). Toma la celda con el menor f, la que parece más prometedora. Hace falta una cola de prioridad en lugar de una común, y el resultado es el mismo camino más corto mientras h no sobrestime. En nuestro mapa, del goblin al héroe, la búsqueda en anchura saca 59 celdas de la cola y A* saca 25. En una sala abierta, de (2, 2) a (6, 2), son 19 frente a 5. Los números vienen del modelo de la lección (aquí A* no está en los cuatro lenguajes). El canon usa búsqueda en anchura: los niveles son de 50 × 20, no hay costes de movimiento, y A* cuesta una cola de prioridad y más código en cuatro lenguajes. Si el agua poco profunda cuesta 2 (la lección del mapa), la búsqueda en anchura ya no es correcta y se pasa a Dijkstra; si los niveles son grandes y abiertos, a A*.
Un campo en lugar de un camino. Una búsqueda desde el héroe da un número para cada celda (la ola de este mapa). Entonces cada enemigo da un paso hacia la vecina con el número menor y no busca por su cuenta. Con veinte enemigos es una búsqueda por turno en lugar de veinte. Muchos juegos con hordas lo hacen así. Para huir se va al revés: hacia el número mayor.
Huir es tonto. Nuestro fugitivo toma el paso que más aumenta la distancia. En la escena corre por la fila de abajo, pero en una esquina se queda sin movimiento y se queda parado: no hay un camino que piense por adelantado. Una huida inteligente sigue el campo de distancias (desde el héroe), donde todo el panorama se ve de una vez y se evita la esquina.
Una máquina de estados crece con sus transiciones. Cada comportamiento nuevo es un estado nuevo y transiciones hacia él y desde él (mira el ejercicio: guard y home son dos estados con varias transiciones). La puntuación de utilidad crece con más suavidad: una acción nueva es un consider nuevo, sin transiciones.
Una puntuación es un número tosco. Aquí las puntuaciones son 40, 60, 80, escritas a mano. Los sistemas mejores multiplican varias «consideraciones» con curvas (cuán herido está, a qué distancia, cuánto maná tiene) y las puntuaciones cambian con suavidad. La regla sigue siendo la misma: el número decide, y la decisión es repetible.
Ejercicio
El guardián que vuelve
Resuelve la tarea en tu editor. Parte del programa de la máquina de estados.
Un guardián en su puesto. Cambia la máquina para que un enemigo empiece en el estado guard en su lugar. El guardián está quieto y no deambula. Cuando el camino hasta el héroe es como mucho 6, pasa a chase. Persigue por el camino; si lost supera 3, pasa a home y vuelve a su puesto por el camino más corto (un paso por turno), y cuando llega vuelve a ser guard. El guardián no tiene sleep, wander ni flee. El héroe no golpea: no hay hit.
El mapa es el mismo, el guardián está en su puesto (8, 3) y el guion es este (héroe, hit, hidden; el héroe está oculto del turno 4 al turno 12):
const SCRIPT = [ [3, 5, 0, false], [4, 5, 0, false], [4, 5, 0, false], [4, 5, 0, true], [4, 5, 0, true], [4, 5, 0, true], [4, 5, 0, true], [4, 5, 0, true], [4, 5, 0, true], [4, 5, 0, true], [4, 5, 0, true], [4, 5, 0, true],];La salida es:
turno 1: guard, enemigo (8, 3), camino 7, vida 16turno 2: guard → chase, enemigo (8, 3), camino 6, vida 16turno 3: chase, enemigo (8, 4), camino 5, vida 16turno 4: chase, enemigo (8, 5), camino 4, vida 16turno 5: chase, enemigo (7, 5), camino 3, vida 16turno 6: chase, enemigo (6, 5), camino 2, vida 16turno 7: chase → home, enemigo (6, 5), camino 2, vida 16turno 8: home, enemigo (7, 5), camino 3, vida 16turno 9: home, enemigo (8, 5), camino 4, vida 16turno 10: home, enemigo (8, 4), camino 5, vida 16turno 11: home → guard, enemigo (8, 3), camino 6, vida 16turno 12: guard, enemigo (8, 3), camino 6, vida 16Más tareas
- Calma. En el canon, tras una huida de un combate los enemigos pasan a
wanderconcalm = 6: mientrascalmsea mayor que 0, no notan al héroe. Añade al enemigo un contadorcalmque disminuya en cada turno. - Rodear a otro enemigo. Dale a
findPathuna lista de celdas ocupadas (los demás enemigos) y trátalas como paredes. ¿Qué pasa si dos enemigos persiguen al héroe en un pasillo estrecho? - Un campo de distancias. Resuelve la persecución sin
findPath: ejecutadistancesdesde el héroe y que el enemigo dé un paso hacia la vecina con el número menor. ¿Cuántas búsquedas por turno son con cinco enemigos y cuántas confindPath?
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Excepciones · Lanzar excepciones y crear las tuyas · Arrays: push, splice y sort · Datos, switch y desestructuración
- Ejercicios sobre el tema: Las crónicas de Arcanum