GitHub avatar

Fox's Blog

My dumb AI for Nausicaa

A heuristic-coefficient AI, hyper-parameters that

My dumb AI for Nausicaa

Some projects start with "hey what if I made a chess game with mythologies?" and end with a system where an AI picks its own hyper-parameters every 5 turns.

Nausicaa is that. A turn-based board game where you build your deck of mythological creatures, manage your mana, deploy units on a 10x8 board. And there's an AI with personality crises.

I spent quite a while on this AI, and the result still escapes me sometimes, and that's what I like best about it.

The game for real

Before talking about the brain, you need to understand the body:

  • 10x8 board, 2-row deployment zone per player
  • Mana starts at 1, +1 per turn, max 6. You spend it to summon, attack, use abilities
  • Goal: kill the enemy Oracle

12 units, with different costs and movement patterns:

Unit Cost Movement HP
Oracle 0 King (8 directions) 1
Goblin 1 Forward 3 squares 1
Harpy 1 King (8 directions) 1
Naiad 1 Diagonal 1
Griffin 2 Hop 2 squares 2
Siren 2 Lateral 1
Centaur 2 Knight (L shape) 2
Archer 3 Lateral 1
Phoenix 3 Diagonal (dark squares) 1
Shapeshifter 4 Swap places 1
Seer 4 None (generates mana) 1
Titan 6 Limited (zone attack) 3

Each unit has its own attack pattern. The Siren hits in the 4 diagonals, the Archer at range over 3 squares, the Titan destroys everything around it on summon. Basically a chess game with mythology and deckbuilding.

How I made the CPU think

The core idea is dumb simple: every enemy unit has an attractiveness coefficient. The more dangerous it is, the more the AI wants to deal with it.

const UNITS_ATTRACTIVENESS = {
    "oracle": 100,
    "titan": 95,
    "shapeshifter": 90,
    "phoenix": 80,
    "siren": 70,
    "archer": 70,
    "seer": 70,
    "griffin": 60,
    "centaur": 60,
    "harpy": 50,
    "naiad": 30,
    "gobelin": 20
};

Oracle at 100 -- makes sense, it's the win condition. Titan at 95 because it one-shots everything next to it on summon. Goblin at 20, it's a foot soldier, who cares.

Then for each pair of units (one ally, one enemy), I compute:

interest = attractiveness × coeff_attract / (distance × coeff_dist)

Basically: the more dangerous and close you are, the more the AI wants to smash you.

calculateAttackCoefficient(x1, y1, x2, y2) {
    const distance = this.calculateEuclideanDistance(x1, y1, x2, y2);
    if (distance === 0) return Infinity;
    return (UNITS_ATTRACTIVENESS[unit.type] * COEFFICIENTS_IMPORTANCE["attractiveness"]) / distance;
}

The trick with the changing coefficients

The important bit is that the importance coefficients change randomly every 5 turns.

if (this.turnCount % 5 === 0) {
    const distanceCoefficient = parseInt(Math.random() * 100);
    const attractivenessCoefficient = parseInt(Math.random() * 100);
    this.regulateImportanceCoefficients({
        distance: distanceCoefficient,
        attractiveness: attractivenessCoefficient
    });
}

One turn the AI goes hyper aggressive (attract at 95, distance at 5), it crosses everything to kill your Oracle. The next turn it prioritizes distance and repositions.

It's borrowed from the Pac-Man ghosts -- Blinky chases, Pinky ambushes. Here the AI switches "personality" every phase.

Result: impossible to predict the AI over a whole game. The CPU never plays the same match twice.

The Oracle is a coward

The enemy Oracle flees. Literally.

const awayFromTarget = {
    row: Math.max(0, Math.min(7, botUnitElement.row + (botUnitElement.row - targetUnitElement.row))),
    col: Math.max(0, Math.min(9, botUnitElement.col + (botUnitElement.col - targetUnitElement.col)))
};

It computes the opposite direction from the threat and runs away. If there's a wall, it looks for the closest free square in that direction.

You spend 3 turns approaching the Oracle, and poof it's already fled to the other side.

The decision loop

Here's how the AI decides:

  1. If my Oracle is gone (dead), place a new one
  2. Compute the coefficient for each allied unit → enemy unit pair
  3. Pick the best pair
  4. If the unit can attack the target from its position → attack
  5. If I have fewer than 4 units → summon the cheapest available from hand
  6. Otherwise, move toward the target (closest movement square to the enemy)
  7. If enough mana (> 2), dash (double move) to get closer
  8. If the unit is the Oracle → flee
flowchart TD
    A[CPU turn start] --> B{Oracle alive?}
    B -->|No| C[Place new Oracle]
    B -->|Yes| D[Compute attack\ncoefficients for each\nunit pair]
    D --> E[Pick best pair\nattacker → target]
    E --> F{Attack possible\nfrom position?}
    F -->|Yes| G[Attack the target]
    F -->|No| H{Fewer than 4\nunits?}
    H -->|Yes| I[Summon cheapest available\nfrom hand]
    H -->|No| J[Move toward target\nclosest square]
    J --> K{Mana > 2?}
    K -->|Yes| L[Dash: double move\ntoward target]
    K -->|No| M[End turn]
    G --> M
    I --> M
    L --> M
async makeAction(dash=false) {
    // all that in sequence
    // the CPU dashes if it has enough mana
    if(botPlayer.mana > 2) {
        this.makeAction(true);
    }
}

Why Euclidean distance

I use Euclidean distance:

calculateEuclideanDistance(x1, y1, x2, y2) {
    const deltaX = Math.pow(x1 - x2, 2);
    const deltaY = Math.pow(y1 - y2, 2);
    return Math.sqrt(deltaX + deltaY) * COEFFICIENTS_IMPORTANCE["distance"];
}

Why not Manhattan? Because units have varied movement patterns (L like the knight, diagonal, etc). Bird-flight distance is a better approximation of danger.

Why not minimax

I could have coded a classic minimax. But with 12 unit types, different movement patterns, special abilities... the game tree explodes so fast it becomes unplayable. The heuristic approach makes smart picks without exploring 10 million states.

What's cool

The attractiveness system creates funny dilemmas:

  • The Seer (70) generates mana. If you let it live, the opponent gets more resources. But the Titan (95) is even more dangerous.
  • The Shapeshifter (90) can swap places with any unit. It can steal your Oracle.
  • The Harpy (50) has an explosive attack that kills it too. It stays secondary... until it ends up next to 3 of your units.

The AI evaluates overall danger based on positions, not just raw stats.

There's also an activateSimulation() function to test scenarios without replaying a game:

activateSimulation() {
    // Place specific units on the board
    // Useful for debugging the AI
    this.game.board = simulation.board;
    this.game.players = simulation.players;
}

What's missing

If I had more time:

  • The AI reacts to the current state, it doesn't predict what the player will do
  • It doesn't plan its hand over several turns
  • The Shapeshifter and Centaur have abilities it underuses
  • Reinforcement learning: make it play against itself to tune the coeffs

But for a browser game it holds up. Friends manage to lose against it, so it's validated.

Try it

Available at nausicaa-game.github.io. You click "PLAY", CPU mode ON, and you watch the AI play.

Tip: let the AI play against itself. You'll see aggressive phases, then pfft it backs off.

The code is on GitHub in js/cpu.js.

3 things to remember:

  1. Heuristic coefficients -- no minimax, each unit has an attractiveness
  2. Coeffs changing every 5 turns -- the AI alternates aggression and control, Pac-Man style
  3. The Oracle flees -- it computes the opposite direction from the threat and runs

If you have ideas to make the AI even nastier, open an issue. I have plans for a version that learns from its defeats, but that'll be for a future article.

Mon IA à la con pour Nausicaa

Une IA à coefficients heuristiques, des hyper-paramètres qui

Mon IA à la con pour Nausicaa

Y'a des projets qui commencent par "tiens si je faisais un jeu d'échecs avec des mythologies ?" et qui finissent par un système avec une IA qui décide de ses propres hyper-paramètres tous les 5 tours.

Nausicaa c'est ça. Un jeu de plateau au tour par tour où tu construis ton deck de créatures mythologiques, tu gères ton mana, tu déploies des unités sur un plateau 10x8. Et y'a une IA qui a des crises de personnalité.

J'ai passé pas mal de temps sur cette IA, et le résultat m'échappe encore parfois, et c'est ce que je préfère.

Le jeu en vrai

Avant de parler du cerveau, faut comprendre le corps :

  • Plateau 10x8, zone de déploiement de 2 rangées par joueur
  • Mana commence à 1, +1 par tour, max 6. Tu dépenses pour invoquer, attaquer, utiliser des capacités
  • But : buter l'Oracle adverse

12 unités, des coûts et des patterns de mouvement différents :

Unit Coût Mouvement PV
Oracle 0 Roi (8 directions) 1
Gobelin 1 Avant 3 cases 1
Harpie 1 Roi (8 directions) 1
Naïade 1 Diagonale 1
Griffin 2 Hop 2 cases 2
Sirène 2 Latéral 1
Centaure 2 Cavalier (en L) 2
Archer 3 Latéral 1
Phénix 3 Diagonale (cases sombres) 1
Métamorphe 4 Échange de place 1
Voyant 4 Aucun (genère du mana) 1
Titan 6 Limité (attaque zone) 3

Chaque unité a son propre pattern d'attaque. La Sirène tape dans les 4 diagonales, l'Archer à distance sur 3 cases, le Titan détruit tout autour à l'invocation. Bref, un jeu d'échecs avec de la mythologie et du deckbuilding.

Comment j'ai fait réfléchir le CPU

L'idée de base est débilement simple : chaque unité ennemie a un coefficient d'attractivité. Plus elle est dangereuse, plus l'IA veut s'en occuper.

const UNITS_ATTRACTIVENESS = {
    "oracle": 100,
    "titan": 95,
    "shapeshifter": 90,
    "phoenix": 80,
    "siren": 70,
    "archer": 70,
    "seer": 70,
    "griffin": 60,
    "centaur": 60,
    "harpy": 50,
    "naiad": 30,
    "gobelin": 20
};

Oracle à 100 -- logique, c'est la win condition. Titan à 95 parce qu'il OS tout à côté à l'invocation. Gobelin à 20, c'est un fantassin, on s'en branle.

Ensuite pour chaque paire d'unités (une alliée, une ennemie), je calcule :

interet = attractivite × coeff_attract / (distance × coeff_dist)

En gros : plus t'es dangereux et proche, plus l'IA veut te défoncer.

calculateAttackCoefficient(x1, y1, x2, y2) {
    const distance = this.calculateEuclideanDistance(x1, y1, x2, y2);
    if (distance === 0) return Infinity;
    return (UNITS_ATTRACTIVENESS[unit.type] * COEFFICIENTS_IMPORTANCE["attractiveness"]) / distance;
}

Le coup des coefficients qui changent

Le point important, c'est que les coefficients d'importance changent aléatoirement tous les 5 tours.

if (this.turnCount % 5 === 0) {
    const distanceCoefficient = parseInt(Math.random() * 100);
    const attractivenessCoefficient = parseInt(Math.random() * 100);
    this.regulateImportanceCoefficients({
        distance: distanceCoefficient,
        attractiveness: attractivenessCoefficient
    });
}

Un coup l'IA va hyper agressive (attract à 95, distance à 5), elle traverse tout pour buter ton Oracle. Le coup d'après elle priorise la distance et se repositionne.

C'est piqué aux fantômes de Pac-Man -- Blinky chasse, Pinky embusque. Ici l'IA change de "personnalité" toutes les phases.

Résultat : impossible de prédire l'IA sur une partie entière. Le CPU fait jamais deux fois le même match.

L'Oracle est une lopette

L'Oracle ennemi fuit. Littéralement.

const awayFromTarget = {
    row: Math.max(0, Math.min(7, botUnitElement.row + (botUnitElement.row - targetUnitElement.row))),
    col: Math.max(0, Math.min(9, botUnitElement.col + (botUnitElement.col - targetUnitElement.col)))
};

Il calcule la direction opposée à la menace et se barre. Si y'a un mur, il cherche la case libre la plus proche dans cette direction.

Tu passes 3 tours à t'approcher de l'Oracle, et paf il s'est déjà enfui à l'autre bout.

La boucle de décision

Voilà comment l'IA décide :

  1. Si j'ai plus d'Oracle (mort), en placer un nouveau
  2. Calculer le coefficient pour chaque couple unité alliée → unité ennemie
  3. Choisir la meilleure paire
  4. Si l'unité peut attaquer la cible depuis sa position → attaque
  5. Si j'ai moins de 4 unités → invoquer la moins chère disponible depuis la main
  6. Sinon, se déplacer vers la cible (case de mouvement la plus proche de l'ennemi)
  7. Si assez de mana (> 2), dash (double mouvement) pour se rapprocher encore
  8. Si l'unité est l'Oracle → fuir
mermaid diagram
async makeAction(dash=false) {
    // tout ça en séquence
    // le CPU dash si il a assez de mana
    if(botPlayer.mana > 2) {
        this.makeAction(true);
    }
}

Pourquoi la distance euclidienne

J'utilise la distance euclidienne :

calculateEuclideanDistance(x1, y1, x2, y2) {
    const deltaX = Math.pow(x1 - x2, 2);
    const deltaY = Math.pow(y1 - y2, 2);
    return Math.sqrt(deltaX + deltaY) * COEFFICIENTS_IMPORTANCE["distance"];
}

Pourquoi pas Manhattan ? Parce que les unités ont des patterns de mouvement variés (L comme le cavalier, diagonale, etc). La distance à vol d'oiseau est une meilleure approximation du danger.

Pourquoi pas du minimax

J'aurais pu coder un minimax classique. Mais avec 12 types d'unités, des patterns de mouvement différents, des capacités spéciales... l'arbre de jeu explose tellement vite que ça devient injouable. L'approche heuristique fait des choix intelligents sans explorer 10 millions d'états.

Ce qui est cool

Le système d'attractivité crée des dilemmes rigolos :

  • Le Voyant (70) génère du mana. Si tu le laisses vivre, l'adversaire a plus de ressources. Mais le Titan (95) est encore plus dangereux.
  • Le Métamorphe (90) peut échanger sa place avec n'importe quelle unité. Il peut voler ton Oracle.
  • L'Harpie (50) a une attaque explosive qui la tue aussi. Elle reste secondaire... jusqu'à ce qu'elle se retrouve à côté de 3 de tes unités.

L'IA évalue le danger global selon les positions, pas juste les stats brutes.

Y'a aussi une fonction activateSimulation() pour tester des scénarios sans refaire une partie :

activateSimulation() {
    // Place des unités spécifiques sur le plateau
    // Utile pour debugger l'IA
    this.game.board = simulation.board;
    this.game.players = simulation.players;
}

Ce qui manque

Si j'avais plus de temps :

  • L'IA réagit à l'état actuel, elle prédit pas ce que le joueur va faire
  • Elle planifie pas sa main sur plusieurs tours
  • Le Métamorphe et le Centaure ont des capacités qu'elle sous-exploit
  • Apprentissage par renforcement : la faire jouer contre elle-même pour ajuster les coeffs

Mais pour un jeu de navigateur ça tient la route. Des potes arrivent à perdre contre, donc c'est validé.

Teste

Dispo sur nausicaa-game.github.io. Tu cliques sur "JOUER", CPU mode ON, et tu regardes l'IA faire.

Conseil : laisse l'IA jouer contre elle-même. Tu vas voir des phases agressives, puis pfft elle recule tout.

Le code est sur GitHub dans js/cpu.js.

3 points à retenir :

  1. Coefficients heuristiques -- pas de minimax, chaque unité a une attractivité
  2. Coeffs qui changent tous les 5 tours -- l'IA alterne agressivité et contrôle, façon Pac-Man
  3. L'Oracle fuit -- il calcule la direction opposée à la menace et se casse

Si t'as des idées pour rendre l'IA encore plus vicieuse, ouvre une issue. J'ai des plans pour une version qui apprend de ses défaites, mais ça sera pour un prochain article.

我给 Nausicaa 写的憨憨 AI

启发式吸引力系数的 AI,每 5 回合换一套超参数,还有一个会透视的 Oracle,深入这款策略桌游的大脑。

我给 Nausicaa 写的憨憨 AI

有些项目一开始是"要不做个神话版国际象棋?",最后长成一套每 5 回合给自己换超参数的系统。

Nausicaa 就是这个。它是回合制桌游:组一套神话生物卡组,管 mana,在 10x8 的棋盘上布子。还有个会人格分裂的 AI。

我在这个 AI 上花了不少时间,它的结果到现在偶尔还让我意外,而这正是我最喜欢的部分。

先看游戏本体,再聊大脑

  • 棋盘 10x8,每家 2 排部署区
  • Mana 从 1 开始,每回合 +1,上限 6。召唤、攻击、放技能都要花
  • 目标:干掉对方的 Oracle

12 种单位,费用和走法各不相同:

单位 费用 移动 血量
Oracle 0 王(8 方向) 1
Gobelin 1 向前 3 格 1
Harpie 1 王(8 方向) 1
Naïade 1 对角 1
Griffin 2 跳 2 格 2
Sirène 2 横向 1
Centaure 2 马步(L 形) 2
Archer 3 横向 1
Phénix 3 对角(深色格) 1
Métamorphe 4 换位 1
Voyant 4 不动(产 mana) 1
Titan 6 受限(范围攻击) 3

每个单位攻击模式不同。Sirène 打四个对角,Archer 射 3 格,Titan 落地炸周围一圈。简单说就是神话版象棋加卡组构筑。

我怎么让 CPU 动脑子的

思路简单到离谱:每个敌方单位都有一个吸引力系数。越危险,AI 越想处理它。

const UNITS_ATTRACTIVENESS = {
    "oracle": 100,
    "titan": 95,
    "shapeshifter": 90,
    "phoenix": 80,
    "siren": 70,
    "archer": 70,
    "seer": 70,
    "griffin": 60,
    "centaur": 60,
    "harpy": 50,
    "naiad": 30,
    "gobelin": 20
};

Oracle 100,很合理,它是胜利条件。Titan 95,落地就清场。Gobelin 20,步兵而已,谁在乎。

然后对每一对单位(一个我方,一个敌方)算:

兴趣 = 吸引力 × attract 系数 / (距离 × distance 系数)

说白了:越危险越近,AI 越想揍它。

calculateAttackCoefficient(x1, y1, x2, y2) {
    const distance = this.calculateEuclideanDistance(x1, y1, x2, y2);
    if (distance === 0) return Infinity;
    return (UNITS_ATTRACTIVENESS[unit.type] * COEFFICIENTS_IMPORTANCE["attractiveness"]) / distance;
}

系数每 5 回合一变

关键在这里,重要性系数每 5 回合随机换一次。

if (this.turnCount % 5 === 0) {
    const distanceCoefficient = parseInt(Math.random() * 100);
    const attractivenessCoefficient = parseInt(Math.random() * 100);
    this.regulateImportanceCoefficients({
        distance: distanceCoefficient,
        attractiveness: attractivenessCoefficient
    });
}

一会儿 AI 极度好战(attract 95,distance 5),全军直扑你的 Oracle。下一阶段它又看重距离,开始走位。

这是从吃豆人幽灵那里偷的,Blinky 追杀,Pinky 埋伏。这里 AI 每几个阶段换一次"人格"。

结果:整局下来你永远猜不到 AI。 CPU 绝不打两盘一样的。

Oracle 是个胆小鬼

敌方 Oracle 会跑。字面意思。

const awayFromTarget = {
    row: Math.max(0, Math.min(7, botUnitElement.row + (botUnitElement.row - targetUnitElement.row))),
    col: Math.max(0, Math.min(9, botUnitElement.col + (botUnitElement.col - targetUnitElement.col)))
};

它算出远离威胁的方向,直接开溜。撞墙就找那个方向上最近的空格。

你花 3 回合摸过去,啪,它已经跑到另一头了。

决策循环

AI 是这么决策的:

  1. Oracle 没了(死了)就先放一个新的
  2. 给每对我方-敌方单位算系数
  3. 选出最赚的一对
  4. 站原地能打到就打
  5. 场上不到 4 个单位就从手牌召最便宜的
  6. 否则向目标走(走到离敌人最近的格子)
  7. mana 够(> 2)就 dash(二连动)再贴近
  8. 如果这个单位是 Oracle 就跑
flowchart TD
    A[CPU 回合开始] --> B{Oracle 活着吗?}
    B -->|否| C[放新 Oracle]
    B -->|是| D[给每对单位算\n攻击系数]
    D --> E[选最赚的一对\n攻击者 → 目标]
    E --> F{原地能打到吗?}
    F -->|是| G[攻击目标]
    F -->|否| H{场上不到 4\n个单位?}
    H -->|是| I[从手牌召\n最便宜的]
    H -->|否| J[向目标移动\n最近的格子]
    J --> K{Mana > 2 ?}
    K -->|是| L[Dash:二连动\n贴近目标]
    K -->|否| M[回合结束]
    G --> M
    I --> M
    L --> M
async makeAction(dash=false) {
    // 全按顺序来
    // CPU 的 mana 够就 dash
    if(botPlayer.mana > 2) {
        this.makeAction(true);
    }
}

为什么用欧几里得距离

我用的是欧几里得距离:

calculateEuclideanDistance(x1, y1, x2, y2) {
    const deltaX = Math.pow(x1 - x2, 2);
    const deltaY = Math.pow(y1 - y2, 2);
    return Math.sqrt(deltaX + deltaY) * COEFFICIENTS_IMPORTANCE["distance"];
}

为什么不用曼哈顿?因为单位走法五花八门(马步、对角等等)。直线距离更接近真实威胁。

为什么不用 minimax

我本可以写经典 minimax。但 12 种单位,走法各异,还有主动技能,博弈树爆炸得太快,根本玩不了。启发式做法不用搜一千万个状态,照样做出聪明的选择。

好玩的地方

吸引力系统会造出很好笑的 dilemma:

  • Voyant(70)产 mana。放着不管,对面资源越来越多。但 Titan(95)更危险。
  • Métamorphe(90)能和任何单位换位。它能偷走你的 Oracle。
  • Harpie(50)自爆式攻击,炸完自己也死。平时次要,但它站到你三个单位中间时另说。

AI 看的是结合位置的全局危险,而不是裸数值。

还有个 activateSimulation() 可以摆特定局面调试,不用重开一局:

activateSimulation() {
    // 在棋盘上摆特定单位
    // 调试 AI 用
    this.game.board = simulation.board;
    this.game.players = simulation.players;
}

缺的东西

如果有更多时间:

  • AI 只看现在,不预测玩家下一步
  • 不做多回合的手牌规划
  • Métamorphe 和 Centaure 的技能它玩不明白
  • 强化学习:让它和自己打,调系数

但对浏览器游戏来说够了。有朋友真能输给它,验证通过。

来玩

在 nausicaa-game.github.io 上玩。点"JOUER",打开 CPU 模式,看 AI 操作。

建议:让 AI 自己和自己打。你会看到它一会儿猛攻,一会儿 pfft 又缩回去。

代码在 GitHub 的 js/cpu.js 里。

三句话总结:

  1. 启发式系数,不用 minimax,每个单位一个吸引力
  2. 系数每 5 回合一变,AI 在好战和控场之间切换,吃豆人式
  3. Oracle 会跑,算出远离威胁的方向就溜

如果你有让 AI 更阴的点子,开个 issue。我还有个让它从失败中学习的版本的计划,下篇文章再说。

Nausicaa用の変な私のAI

発見的係数のAI、5ターンごとに変わるハイパーパラメータ、逃げるOracle。戦略ボードゲームの頭の中への潜入。

Nausicaa用の変な私のAI

「神話でチェス作ったらどうなる」から始まって、5ターンごとに独自ハイパーパラメータを決めるAI付きシステムで終わるプロジェクトがある。

Nausicaaがそれだ。神話クリーチャーのデッキを作って、マナを管理して、10x8盤にユニット展開するターンベースのボードゲームだ。人格危機のAIがいる。

このAIにはかなり時間かけた。結果は今もときどき掴みきれない。それがいちばん好きなところだ。

実際のゲーム

頭を話す前に体を理解しなきゃいけない。

  • 10x8盤、プレイヤーごとに2列の展開 zone
  • マナは1開始、1ターンに+1、最大6だ。召喚にも攻撃にも能力使用にも使う
  • 目標:相手のOracleを倒す

12ユニットで、コストも移動パターンも違う。

ユニット コスト 移動 HP
Oracle 0 王(8方向) 1
Gobelin 1 前3マス 1
Harpie 1 王(8方向) 1
Naïade 1 斜め 1
Griffin 2 2マス跳び 2
Sirène 2 横 1
Centaure 2 ナイト(L字) 2
Archer 3 横 1
Phénix 3 斜め(暗マス) 1
Métamorphe 4 位置交換 1
Voyant 4 なし(マナ生成) 1
Titan 6 制限(範囲攻撃) 3

各ユニットの攻撃パターンは独自だ。Sirèneは4斜めに殴る、Archerは3マス先まで遠距離、Titanは召喚時に周囲を破壊する。つまり神話とデッキ構築付きチェスってことだ。

CPUに考えさせた方法

基本発想はばかみたいに単純だ。各敵ユニットに魅力度係数がある。危険なほどAIは相手したくなる。

const UNITS_ATTRACTIVENESS = {
    "oracle": 100,
    "titan": 95,
    "shapeshifter": 90,
    "phoenix": 80,
    "siren": 70,
    "archer": 70,
    "seer": 70,
    "griffin": 60,
    "centaur": 60,
    "harpy": 50,
    "naiad": 30,
    "gobelin": 20
};

Oracleは100。道理だ。勝利条件だからね。Titanは95。召喚時に隣を全部ワンパンするからね。Gobelinは20。歩兵だ。どうでもいい。

次に各ユニットペア(味方1つ、敵1つ)にこう計算する。

interet = attractivite × coeff_attract / (distance × coeff_dist)

ざっくり、危険で近いほどAIはぶっ潰したくなる。

calculateAttackCoefficient(x1, y1, x2, y2) {
    const distance = this.calculateEuclideanDistance(x1, y1, x2, y2);
    if (distance === 0) return Infinity;
    return (UNITS_ATTRACTIVENESS[unit.type] * COEFFICIENTS_IMPORTANCE["attractiveness"]) / distance;
}

係数が変わる仕掛け

大事な点は、重要度係数が5ターンごとにランダムに変わることだ。

if (this.turnCount % 5 === 0) {
    const distanceCoefficient = parseInt(Math.random() * 100);
    const attractivenessCoefficient = parseInt(Math.random() * 100);
    this.regulateImportanceCoefficients({
        distance: distanceCoefficient,
        attractiveness: attractivenessCoefficient
    });
}

あるときAIは超攻撃的になる(魅力95、距離5)。君のOracleを倒すため全部横断する。次は距離優先になって陣形を整える。

パックマンのゴーストからぱくった。Blinkyは追って、Pinkyは待ち伏せる。ここではAIが各段階で「人格」を替える。

結果:対局全体でAIを予測するのは無理だ。 CPUは同じ試合を2度しない。

Oracleは腰抜けだ

敵Oracleは逃げる。文字通りね。

const awayFromTarget = {
    row: Math.max(0, Math.min(7, botUnitElement.row + (botUnitElement.row - targetUnitElement.row))),
    col: Math.max(0, Math.min(9, botUnitElement.col + (botUnitElement.col - targetUnitElement.col)))
};

脅威と逆方向を計算して逃げる。壁があったら、その方向でいちばん近い空きマスを探す。

Oracleに近づくのに3ターンかけて、ぱっと向こう端まで逃げられてる。

決定ループ

AIの決め方はこうだ。

  1. Oracleがいない(死亡)なら新しいのを置く
  2. 味方ユニットから敵ユニットの各ペアに係数を計算する
  3. 最良ペアを選ぶ
  4. ユニットがその位置から標的を攻撃できる → 攻撃する
  5. ユニット4未満 → 手札からいちばん安いのを召喚する
  6. そうじゃなければ標的へ移動する(敵にいちばん近い移動マス)
  7. マナ十分(2超)ならダッシュ(二重移動)でもっと寄る
  8. ユニットがOracle → 逃げる
flowchart TD
    A[CPUターン開始] --> B{Oracle生存?}
    B -->|いいえ| C[新Oracleを置く]
    B -->|はい| D[各\nユニットペアに攻撃\n係数を計算]
    D --> E[最良ペアを選ぶ\n攻撃者から標的へ]
    E --> F{その位置から\n攻撃可能?}
    F -->|はい| G[標的を攻撃]
    F -->|いいえ| H{ユニット\n4未満?}
    H -->|はい| I[手札から召喚\nいちばん安いの]
    H -->|いいえ| J[標的へ移動\nいちばん近いマス]
    J --> K{マナ2超?}
    K -->|はい| L[ダッシュ:二重移動\n標的へ]
    K -->|いいえ| M[ターン終了]
    G --> M
    I --> M
    L --> M
async makeAction(dash=false) {
    // 全部順番に
    // CPUはマナ十分ならダッシュする
    if(botPlayer.mana > 2) {
        this.makeAction(true);
    }
}

なぜユークリッド距離か

ユークリッド距離を使う。

calculateEuclideanDistance(x1, y1, x2, y2) {
    const deltaX = Math.pow(x1 - x2, 2);
    const deltaY = Math.pow(y1 - y2, 2);
    return Math.sqrt(deltaX + deltaY) * COEFFICIENTS_IMPORTANCE["distance"];
}

マンハッタンじゃないのはなぜか。ユニットの移動パターンが多様だからだ(ナイトみたいなL字、斜めなど)。直線距離の方が危険の近似に良い。

なぜミニマックスじゃないか

古典ミニマックスも書けた。でも12種ユニットに違う移動パターンに特殊能力だと、ゲーム木が速く爆発して遊べなくなる。発見的手法は1,000万状態を探さず賢い選択をする。

かっこいいところ

魅力度システムは面白いジレンマを作る。

  • Voyant(70)はマナを作る。生かしとけば相手の資源が増える。でもTitan(95)はもっと危険だ。
  • Métamorphe(90)は任意ユニットと位置を交換できる。君のOracleを盗める。
  • Harpie(50)は自分も死ぬ爆発攻撃を持つ。副次的ままだ。君の3ユニット隣に来るまではね。

AIは素 stats じゃなく位置に応じた全局危険を評価する。

シナリオ試し用のactivateSimulation()関数もある。対局やり直しなしでね。

activateSimulation() {
    // 盤に特定ユニットを置く
    // AIデバッグに便利
    this.game.board = simulation.board;
    this.game.players = simulation.players;
}

足りないもの

もっと時間があればね。

  • AIは現状に反応する。プレイヤーが何するか予測しない
  • 手札を複数ターンで計画しない
  • MétamorpheとCentaureの能力を活かしきれてない
  • 強化学習:自分と対戦させて係数を調整する

でもブラウザゲームには十分だ。友達が負けるから検証済みだ。

試す

nausicaa-game.github.ioで出してる。「JOUER」を押して、CPUモードONにして、AIの動きを見る。

おすすめ:AI同士で遊ばせる。攻撃段階が見えて、次にぷっと後退するのが見られる。

コードはGitHubのjs/cpu.jsにある。

覚えるべき3点:

  1. 発見的係数。ミニマックスなし、各ユニットに魅力度
  2. 5ターンごとに変わる係数。AIは攻撃性と管理を交互にする。パックマン式だ
  3. Oracleは逃げる。脅威と逆方向を計算して逃げる

AIをもっと意地悪くする案があったらissue開いて。負けから学ぶ版の計画があるけど、次の記事でね。

Nausicaa용 엉뚱한 내 AI

휴리스틱 계수 AI, 5턴마다 바뀌는 하이퍼파라미터, 도망치는 오라클. 전략 보드 게임의 두뇌 속으로 들어가 봐요.

Nausicaa용 엉뚱한 내 AI

어떤 프로젝트는 "신화로 체스 게임을 만들면 어떨까?"로 시작해서, 5턴마다 스스로 하이퍼파라미터를 정하는 AI가 있는 시스템으로 끝나곤 해요.

Nausicaa가 그래요. 신화 속 생물 덱을 만들고, 마나를 관리하고, 10x8 보드에 유닛을 배치하는 턴제 보드 게임이에요. 그리고 성격 위기를 겪는 AI가 있어요.

이 AI에 꽤 많은 시간을 썼는데, 결과가 아직도 가끔 제 손을 벗어나요. 그 점이 가장 마음에 들어요.

실제 게임 모습

두뇌를 이야기하기 전에 몸을 이해해야 해요.

  • 10x8 보드, 플레이어당 2줄 배치 구역
  • 마나는 1에서 시작해서 턴마다 +1, 최대 6. 소환과 공격, 능력 사용에 써요
  • 목표: 상대 오라클 처치

12개 유닛, 비용과 이동 패턴이 각각 달라요.

Unit 비용 이동 체력
Oracle 0 킹 (8방향) 1
Gobelin 1 앞으로 3칸 1
Harpie 1 킹 (8방향) 1
Naïade 1 대각선 1
Griffin 2 2칸 점프 2
Sirène 2 옆으로 1
Centaure 2 나이트 (L자) 2
Archer 3 옆으로 1
Phénix 3 대각선 (어두운 칸) 1
Métamorphe 4 자리 바꾸기 1
Voyant 4 없음 (마나 생성) 1
Titan 6 제한적 (범위 공격) 3

각 유닛은 고유한 공격 패턴을 가지고 있어요. 사이렌은 4개 대각선을 때리고, 궁수는 3칸 원거리에서 쏘고, 타이탄은 소환될 때 주변을 전부 파괴해요. 한마디로 신화와 덱빌딩이 들어간 체스예요.

CPU에게 생각하는 법을 가르친 방법

기본 아이디어는 바보스러울 정도로 단순해요. 적 유닛마다 매력 계수가 있어요. 위험할수록 AI가 더 처리하고 싶어 해요.

const UNITS_ATTRACTIVENESS = {
    "oracle": 100,
    "titan": 95,
    "shapeshifter": 90,
    "phoenix": 80,
    "siren": 70,
    "archer": 70,
    "seer": 70,
    "griffin": 60,
    "centaur": 60,
    "harpy": 50,
    "naiad": 30,
    "gobelin": 20
};

오라클이 100인 건 당연해요. 승리 조건이니까요. 타이탄이 95인 건 소환될 때 옆에 있는 것을 원샷으로 잡기 때문이에요. 고블린은 20이에요. 보병일 뿐이니 신경 안 써요.

다음으로 각 유닛 쌍(아군 하나, 적 하나)마다 이렇게 계산해요.

interet = attractivite × coeff_attract / (distance × coeff_dist)

대충 말하면, 위험하고 가까울수록 AI가 더 때려잡고 싶어 해요.

calculateAttackCoefficient(x1, y1, x2, y2) {
    const distance = this.calculateEuclideanDistance(x1, y1, x2, y2);
    if (distance === 0) return Infinity;
    return (UNITS_ATTRACTIVENESS[unit.type] * COEFFICIENTS_IMPORTANCE["attractiveness"]) / distance;
}

바뀌는 계수 이야기

중요한 점은 중요도 계수가 5턴마다 무작위로 바뀐다는 거예요.

if (this.turnCount % 5 === 0) {
    const distanceCoefficient = parseInt(Math.random() * 100);
    const attractivenessCoefficient = parseInt(Math.random() * 100);
    this.regulateImportanceCoefficients({
        distance: distanceCoefficient,
        attractiveness: attractivenessCoefficient
    });
}

한번은 AI가 초공격적(매력 95, 거리 5)이 되어서 여러분의 오라클을 잡으려고 전부 가로질러요. 다음번에는 거리를 우선해서 재배치해요.

팩맨 유령에서 따온 거예요. 블링키는 사냥하고 핑키는 매복하죠. 여기서 AI는 페이즈마다 "성격"을 바꿔요.

결과: 한 판 내내 AI를 예측하는 것은 불가능해요. CPU는 같은 경기를 두 번 절대 안 해요.

오라클은 겁쟁이예요

적 오라클은 도망쳐요. 말 그대로요.

const awayFromTarget = {
    row: Math.max(0, Math.min(7, botUnitElement.row + (botUnitElement.row - targetUnitElement.row))),
    col: Math.max(0, Math.min(9, botUnitElement.col + (botUnitElement.col - targetUnitElement.col)))
};

위협과 반대 방향을 계산해서 튀어요. 벽이 있으면 그 방향에서 가장 가까운 빈 칸을 찾아요.

오라클에게 다가가려고 3턴을 쓰는데, 팡 하고 벌써 반대편 끝으로 도망가 있어요.

의사결정 루프

AI가 결정하는 방법은 이렇습니다.

  1. 오라클이 없으면(죽었으면) 새로 배치
  2. 아군 유닛에서 적 유닛으로 가는 각 쌍마다 계수 계산
  3. 최고의 쌍 선택
  4. 유닛이 현재 위치에서 목표를 공격할 수 있으면 공격
  5. 유닛이 4개 미만이면 손에서 가장 싼 것을 소환
  6. 아니면 목표 쪽으로 이동(적에게 가장 가까운 이동 칸)
  7. 마나가 충분하면(2 초과) 더 가까워지려고 대시(이중 이동)
  8. 유닛이 오라클이면 도망
flowchart TD
    A[CPU 턴 시작] --> B{오라클 생존?}
    B -->|아니요| C[새 오라클 배치]
    B -->|예| D[각 유닛 쌍마다 공격 계수 계산]
    D --> E[최고의 쌍 선택 공격자 → 목표]
    E --> F{현재 위치에서 공격 가능?}
    F -->|예| G[목표 공격]
    F -->|아니요| H{유닛 4개 미만?}
    H -->|예| I[손에서 가장 싼 것 소환]
    H -->|아니요| J[목표 쪽으로 이동 가장 가까운 칸]
    J --> K{마나 > 2?}
    K -->|예| L[대시: 목표 쪽으로 이중 이동]
    K -->|아니요| M[턴 종료]
    G --> M
    I --> M
    L --> M
async makeAction(dash=false) {
    // tout ça en séquence
    // le CPU dash si il a assez de mana
    if(botPlayer.mana > 2) {
        this.makeAction(true);
    }
}

왜 유클리드 거리인가요

유클리드 거리를 써요.

calculateEuclideanDistance(x1, y1, x2, y2) {
    const deltaX = Math.pow(x1 - x2, 2);
    const deltaY = Math.pow(y1 - y2, 2);
    return Math.sqrt(deltaX + deltaY) * COEFFICIENTS_IMPORTANCE["distance"];
}

맨해튼은 왜 안 쓰냐고요? 유닛들의 이동 패턴이 제각각이라서요 (나이트처럼 L자, 대각선 등). 직선 거리가 위험도를 더 잘 근사해요.

왜 미니맥스가 아닌가요

고전적인 미니맥스를 코딩할 수도 있었어요. 하지만 유닛 종류가 12개에 이동 패턴도 다르고 특수 능력도 있어서, 게임 트리가 너무 빨리 폭발해서 플레이가 불가능해져요. 휴리스틱 접근은 수천만 개 상태를 탐색하지 않고도 현명한 선택을 해요.

멋진 점

매력 시스템이 재미있는 딜레마를 만들어요.

  • 선견자(70)는 마나를 만들어내요. 살려 두면 상대가 자원을 더 얻어요. 하지만 타이탄(95)이 훨씬 더 위험해요.
  • 변신술사(90)는 어떤 유닛과도 자리를 바꿀 수 있어요. 여러분의 오라클을 훔칠 수 있어요.
  • 하피(50)는 자신을 죽이는 폭발 공격이 있어요. 부차적으로 남아요. 하지만 여러분 유닛 셋 옆에 서는 순간까지는요.

AI는 단순 스탯이 아니라 위치에 따른 전체 위험을 평가해요.

판을 새로 시작하지 않고 시나리오를 테스트하는 activateSimulation() 함수도 있어요.

activateSimulation() {
    // 보드에 특정 유닛들을 배치해요
    // AI를 디버깅하는 데 유용해요
    this.game.board = simulation.board;
    this.game.players = simulation.players;
}

부족한 점

시간이 더 있었다면요.

  • AI는 현재 상태에 반응할 뿐, 플레이어가 무엇을 할지 예측하지 않아요
  • 여러 턴에 걸쳐 손패를 계획하지 않아요
  • 변신술사와 켄타우로스의 능력을 충분히 활용하지 않아요
  • 강화 학습: 스스로와 대결시켜서 계수를 조정하기

하지만 브라우저 게임으로는 충분히 통행해요. 친구들이 지고 오니 검증된 셈이에요.

테스트해 보세요

nausicaa-game.github.io에서 할 수 있어요. "JOUER"를 누르고 CPU 모드를 켠 뒤 AI가 하는 것을 보세요.

팁: AI끼리 대결시켜 보세요. 공격적인 페이즈를 보다가, 푸슥 하고 전부 후퇴하는 것을 보게 될 거예요.

코드는 js/cpu.js의 GitHub에 있어요.

기억할 3가지:

  1. 휴리스틱 계수: 미니맥스 없이, 각 유닛에 매력도가 있어요
  2. 5턴마다 바뀌는 계수: AI는 팩맨처럼 공격성과 컨트롤을 오가요
  3. 도망치는 오라클: 위협과 반대 방향을 계산해서 줄행랑쳐요

AI를 더 교활하게 만들 아이디어가 있으면 이슈를 열어 주세요. 패배에서 배우는 버전을 계획 중인데, 다음 글에서 다룰게요.

Nausicaa için saçma yapay zekâm

Sezgisel katsayılı bir yapay zekâ, her 5 turda bir değişen hiper

Nausicaa için saçma yapay zekâm

Bazı projeler "mitolojili bir satranç oyunu yapsam mı?" diye başlar ve her 5 turda bir kendi hiper parametrelerine karar veren bir yapay zekâlı sistemle biter.

Nausicaa bu. Mitolojik yaratıklardan desteni kurduğun, mananı yönettiğin, birimleri 10x8 tahtaya konuşlandırdığın sıra tabanlı bir masa oyunu. Ve kişilik krizleri geçiren bir yapay zekâ var.

Bu yapay zekâya epey zaman harcadım ve sonuç bazen hâlâ beni aşıyor, en sevdiğim şey de bu.

Oyunun gerçeği

Beyinden bahsetmeden önce bedeni anlamak lazım:

  • 10x8 tahta, oyuncu başına 2 sıra konuşlanma bölgesi
  • Mana 1den başlar, tur başına +1, maksimum 6. Çağırmak, saldırmak, yetenek kullanmak için harcarsın
  • Amaç: rakip Oracleı öldürmek

12 birim, farklı maliyetler ve hareket desenleri:

Birim Maliyet Hareket Can
Oracle 0 Şah (8 yön) 1
Gobelin 1 İleri 3 kare 1
Harpie 1 Şah (8 yön) 1
Naïade 1 Çapraz 1
Griffin 2 2 kare zıplama 2
Sirène 2 Yatay 1
Centaure 2 At (L şeklinde) 2
Archer 3 Yatay 1
Phénix 3 Çapraz (koyu kareler) 1
Métamorphe 4 Yer değiştirme 1
Voyant 4 Yok (mana üretir) 1
Titan 6 Sınırlı (alan saldırısı) 3

Her birimin kendine özel saldırı deseni var. Siren 4 çapraza vurur, Okçu 3 kareden menzilli vurur, Titan çağrıldığında etrafındaki her şeyi yok eder. Kısacası mitolojili ve deste kurmalı bir satranç oyunu.

CPUya düşünmeyi nasıl öğrettim

Temel fikir aptalca basit: her düşman biriminin bir çekicilik katsayısı vardır. Ne kadar tehlikeliyse yapay zekâ onunla o kadar ilgilenmek ister.

const UNITS_ATTRACTIVENESS = {
    "oracle": 100,
    "titan": 95,
    "shapeshifter": 90,
    "phoenix": 80,
    "siren": 70,
    "archer": 70,
    "seer": 70,
    "griffin": 60,
    "centaur": 60,
    "harpy": 50,
    "naiad": 30,
    "gobelin": 20
};

Oracle 100, mantıklı, bu kazanma koşulu. Titan 95 çünkü çağrıldığında yanındakileri tekler. Gobelin 20, piyade işte, umurumuzda değil.

Sonra her birim çifti için (bir dost, bir düşman) şunu hesaplıyorum:

interet = attractivite × coeff_attract / (distance × coeff_dist)

Kabaca: ne kadar tehlikeli ve yakınsan yapay zekâ seni o kadar pataklamak istiyor.

calculateAttackCoefficient(x1, y1, x2, y2) {
    const distance = this.calculateEuclideanDistance(x1, y1, x2, y2);
    if (distance === 0) return Infinity;
    return (UNITS_ATTRACTIVENESS[unit.type] * COEFFICIENTS_IMPORTANCE["attractiveness"]) / distance;
}

Değişen katsayılar numarası

Önemli nokta şu: önem katsayıları her 5 turda bir rastgele değişir.

if (this.turnCount % 5 === 0) {
    const distanceCoefficient = parseInt(Math.random() * 100);
    const attractivenessCoefficient = parseInt(Math.random() * 100);
    this.regulateImportanceCoefficients({
        distance: distanceCoefficient,
        attractiveness: attractivenessCoefficient
    });
}

Bir seferde yapay zekâ hiper agresif olur (çekicilik 95, mesafe 5), Oracleını öldürmek için her şeyi geçer. Sonraki sefer mesafeyi önceler ve yeniden konumlanır.

Bu Pac-Man hayaletlerinden çalıntı: Blinky kovalar, Pinky pusuya yatar. Burada yapay zekâ her fazda "kişilik" değiştiriyor.

Sonuç: yapay zekâyı bütün bir maç boyunca tahmin etmek imkânsız. CPU asla iki kez aynı maçı yapmaz.

Oracle bir korkaktır

Düşman Oracle kaçar. Kelimenin tam anlamıyla.

const awayFromTarget = {
    row: Math.max(0, Math.min(7, botUnitElement.row + (botUnitElement.row - targetUnitElement.row))),
    col: Math.max(0, Math.min(9, botUnitElement.col + (botUnitElement.col - targetUnitElement.col)))
};

Tehdidin tersi yönü hesaplar ve tüymek için koşar. Duvar varsa o yönde en yakın boş kareyi arar.

Oraclea yaklaşmak için 3 tur harcarsın ve pat, çoktan öbür uca kaçmıştır.

Karar döngüsü

Yapay zekâ şöyle karar verir:

  1. Oraclım kalmadıysa (ölmüşse) yenisini yerleştir
  2. Her dost birim → düşman birim çifti için katsayıyı hesapla
  3. En iyi çifti seç
  4. Birim konumundan hedefe saldırabiliyorsa → saldır
  5. 4ten az birimim varsa → elden mevcut en ucuzu çağır
  6. Yoksa hedefe doğru ilerle (düşmana en yakın hareket karesi)
  7. Yeterli mana varsa (> 2), daha da yaklaşmak için dash (çift hareket)
  8. Birim Oracle ise → kaç
flowchart TD
    A[CPU turunun başı] --> B{Oracle hayatta mı?}
    B -->|Hayır| C[Yeni Oracle yerleştir]
    B -->|Evet| D[Her\nbirim çifti için\nsaldırı katsayılarını\nhesapla]
    D --> E[En iyi çifti seç\nsaldırgan → hedef]
    E --> F{Konumdan\nsaldırı mümkün mü?}
    F -->|Evet| G[Hedefe saldır]
    F -->|Hayır| H{4ten az\nbirim mi?}
    H -->|Evet| I[Elden çağır\mevcut en ucuz]
    H -->|Hayır| J[Hedefe ilerle\nen yakın kare]
    J --> K{Mana > 2 mi?}
    K -->|Evet| L[Dash: çift hareket\nhedefe doğru]
    K -->|Hayır| M[Turun sonu]
    G --> M
    I --> M
    L --> M
async makeAction(dash=false) {
    // hepsi sırayla
    // CPU yeterli manası varsa dash yapar
    if(botPlayer.mana > 2) {
        this.makeAction(true);
    }
}

Neden öklid mesafesi

Öklid mesafesini kullanıyorum:

calculateEuclideanDistance(x1, y1, x2, y2) {
    const deltaX = Math.pow(x1 - x2, 2);
    const deltaY = Math.pow(y1 - y2, 2);
    return Math.sqrt(deltaX + deltaY) * COEFFICIENTS_IMPORTANCE["distance"];
}

Neden Manhattan değil? Çünkü birimlerin çeşitli hareket desenleri var (at gibi L, çapraz vs.). Kuş uçuşu mesafe tehlikenin daha iyi bir yaklaşımı.

Neden minimax değil

Klasik bir minimax kodlayabilirdim. Ama 12 birim türü, farklı hareket desenleri, özel yeteneklerle oyun ağacı o kadar hızlı patlar ki oynanamaz olur. Sezgisel yaklaşım 10 milyon durumu keşfetmeden akıllı seçimler yapar.

Havalı olan şey

Çekicilik sistemi eğlenceli ikilemler yaratır:

  • Kâhin (70) mana üretir. Yaşamasına izin verirsen rakibin daha fazla kaynağı olur. Ama Titan (95) daha da tehlikelidir.
  • Biçim değiştiren (90) herhangi bir birimle yer değiştirebilir. Oracleını çalabilir.
  • Harpi (50) kendini de öldüren patlayıcı bir saldırıya sahiptir. 3 biriminin yanında bitene kadar ikincil kalır.

Yapay zekâ küresel tehlikeyi konumlara göre değerlendirir, sadece ham istatistiklere göre değil.

Senaryoları bütün bir maçı tekrarlamadan test etmek için bir activateSimulation() fonksiyonu da var:

activateSimulation() {
    // Tahtaya belirli birimleri yerleştirir
    // Yapay zekâyı hata ayıklamak için kullanışlıdır
    this.game.board = simulation.board;
    this.game.players = simulation.players;
}

Eksik olan şey

Daha fazla zamanım olsaydı:

  • Yapay zekâ mevcut duruma tepki verir, oyuncunun ne yapacağını tahmin etmez
  • Elini birkaç tur ileriden planlamaz
  • Biçim değiştiren ve Sentorun yeteneklerini az kullanır
  • Pekiştirmeli öğrenme: katsayıları ayarlamak için kendine karşı oynatmak

Ama bir tarayıcı oyunu için iş görür. Kaybeden arkadaşlarım var, yani onaylandı.

Dene

nausicaa-game.github.io adresinde mevcut. "JOUER" düğmesine tıkla, CPU modunu aç ve yapay zekâyı izle.

Tavsiye: yapay zekâyı kendine karşı oynat. Agresif fazlar göreceksin, sonra pfft her şeyi geri çeker.

Kod GitHub üzerinde, js/cpu.js içinde.

Akılda tutulacak 3 nokta:

  1. Sezgisel katsayılar: minimax yok, her birimin bir çekiciliği var
  2. Her 5 turda bir değişen katsayılar: yapay zekâ Pac-Man tarzı agresiflik ve kontrol arasında geçiş yapar
  3. Oracle kaçar: tehdidin tersi yönünü hesaplar ve tüyer

Yapay zekâyı daha da gaddar yapmak için fikirlerin varsa bir issue aç. Yenilgilerinden öğrenen bir sürüm için planlarım var ama o sonraki bir yazıya kalacak.

La mia IA scema per Nausicaa

Un'IA a coefficienti euristici, iper-parametri che cambiano ogni 5 turni, e un Oracolo che scappa: immersione nel cervello di un gioco da tavolo strategico.

La mia IA scema per Nausicaa

Ci sono progetti che iniziano con "e se facessi un gioco di scacchi con mitologie?" e finiscono con un sistema con un'IA che decide i suoi iper-parametri ogni 5 turni.

Nausicaa è questo. Un gioco da tavolo a turni dove costruisci il tuo deck di creature mitologiche, gestisci il tuo mana, schieri unità su un tavolo 10x8. E c'è un'IA che ha crisi di personalità.

Ho passato parecchio tempo su questa IA, e il risultato a volte mi sfugge ancora, ed è ciò che preferisco.

Il gioco in vero

Prima di parlare del cervello, bisogna capire il corpo:

  • Tavolo 10x8, zona di schieramento di 2 righe per giocatore
  • Mana inizia a 1, +1 per turno, max 6. Spendi per evocare, attaccare, usare capacità
  • Scopo: uccidere l'Oracolo avversario

12 unità, costi e pattern di movimento diversi:

Unità Costo Movimento PV
Oracolo 0 Re (8 direzioni) 1
Goblin 1 Avanti 3 caselle 1
Arpia 1 Re (8 direzioni) 1
Naiade 1 Diagonale 1
Grifone 2 Salto 2 caselle 2
Sirena 2 Laterale 1
Centauro 2 Cavaliere (a L) 2
Arciere 3 Laterale 1
Fenice 3 Diagonale (caselle scure) 1
Mutante 4 Scambio di posto 1
Veggente 4 Nessuno (genera mana) 1
Titano 6 Limitato (attacco a zona) 3

Ogni unità ha il suo pattern di attacco. La Sirena colpisce nelle 4 diagonali, l'Arciere a distanza su 3 caselle, il Titano distrugge tutto intorno all'evocazione. Insomma, un gioco di scacchi con mitologia e deckbuilding.

Come ho fatto ragionare la CPU

L'idea di base è scemamente semplice: ogni unità nemica ha un coefficiente di attrattività. Più è pericolosa, più l'IA vuole occuparsene.

// attrattività per tipo di unità: Oracolo = win condition
const UNITS_ATTRACTIVENESS = {
    "oracle": 100,
    "titan": 95,
    "shapeshifter": 90,
    "phoenix": 80,
    "siren": 70,
    "archer": 70,
    "seer": 70,
    "griffin": 60,
    "centaur": 60,
    "harpy": 50,
    "naiad": 30,
    "gobelin": 20
};

Oracolo a 100: logico, è la win condition. Titano a 95 perché ammazza tutto accanto all'evocazione. Goblin a 20, è un fante, chi se ne frega.

Poi per ogni coppia di unità (una alleata, una nemica), calcolo:

interesse = attrattività × coeff_attratt / (distanza × coeff_dist)

In pratica: più sei pericoloso e vicino, più l'IA vuole spaccarti.

// interesse = attrattività pesata / distanza
calculateAttackCoefficient(x1, y1, x2, y2) {
    const distance = this.calculateEuclideanDistance(x1, y1, x2, y2);
    if (distance === 0) return Infinity;
    return (UNITS_ATTRACTIVENESS[unit.type] * COEFFICIENTS_IMPORTANCE["attractiveness"]) / distance;
}

Il colpo dei coefficienti che cambiano

Il punto importante, è che i coefficienti di importanza cambiano casualmente ogni 5 turni.

// ogni 5 turni, la "personalità" IA cambia
if (this.turnCount % 5 === 0) {
    const distanceCoefficient = parseInt(Math.random() * 100);
    const attractivenessCoefficient = parseInt(Math.random() * 100);
    this.regulateImportanceCoefficients({
        distance: distanceCoefficient,
        attractiveness: attractivenessCoefficient
    });
}

Una volta l'IA va iper aggressiva (attratt a 95, distanza a 5), attraversa tutto per uccidere il tuo Oracolo. La volta dopo priorizza la distanza e si riposiziona.

È rubato ai fantasmi di Pac-Man: Blinky caccia, Pinky tende agguati. Qui l'IA cambia "personalità" ogni fase.

Risultato: impossibile prevedere l'IA su una partita intera. La CPU non fa mai due volte lo stesso match.

L'Oracolo è un fifone

L'Oracolo nemico scappa. Letteralmente.

// l'Oracolo fugge nella direzione opposta alla minaccia
const awayFromTarget = {
    row: Math.max(0, Math.min(7, botUnitElement.row + (botUnitElement.row - targetUnitElement.row))),
    col: Math.max(0, Math.min(9, botUnitElement.col + (botUnitElement.col - targetUnitElement.col)))
};

Calcola la direzione opposta alla minaccia e scappa. Se c'è un muro, cerca la casella libera più vicina in quella direzione.

Passi 3 turni ad avvicinarti all'Oracolo, e paf si è già rifugiato dall'altra parte.

Il ciclo di decisione

Ecco come l'IA decide:

  1. Se non ho più Oracolo (morto), piazzarne uno nuovo
  2. Calcolare il coefficiente per ogni coppia unità alleata → unità nemica
  3. Scegliere la coppia migliore
  4. Se l'unità può attaccare il bersaglio dalla sua posizione → attacca
  5. Se ho meno di 4 unità → evocare la meno cara disponibile dalla mano
  6. Altrimenti, spostarsi verso il bersaglio (casella di movimento più vicina al nemico)
  7. Se abbastanza mana (> 2), dash (doppio movimento) per avvicinarsi ancora
  8. Se l'unità è l'Oracolo → fuggi
flowchart TD
    A[Inizio turno CPU] --> B{Oracolo vivo?}
    B -->|No| C[Metti nuovo Oracolo]
    B -->|Sì| D[Calcola coefficienti\ndi attacco per ogni\ncoppia di unità]
    D --> E[Scegli la coppia migliore\nattaccante → bersaglio]
    E --> F{Attacco possibile\ndalla posizione?}
    F -->|Sì| G[Attacca il bersaglio]
    F -->|No| H{Meno di 4\nunità?}
    H -->|Sì| I[Evoca dalla mano\nla meno cara disp.]
    H -->|No| J[Spostati verso il bersaglio\ncasella più vicina]
    J --> K{Mana > 2?}
    K -->|Sì| L[Dash: doppio movimento\nverso il bersaglio]
    K -->|No| M[Fine turno]
    G --> M
    I --> M
    L --> M
// la CPU fa dash se ha abbastanza mana
async makeAction(dash=false) {
    // tutto questo in sequenza
    // la CPU fa dash se ha abbastanza mana
    if(botPlayer.mana > 2) {
        this.makeAction(true);
    }
}

Perché la distanza euclidea

Uso la distanza euclidea:

// distanza in linea d'aria pesata
calculateEuclideanDistance(x1, y1, x2, y2) {
    const deltaX = Math.pow(x1 - x2, 2);
    const deltaY = Math.pow(y1 - y2, 2);
    return Math.sqrt(deltaX + deltaY) * COEFFICIENTS_IMPORTANCE["distance"];
}

Perché non Manhattan? Perché le unità hanno pattern di movimento vari (L come il cavaliere, diagonale, ecc). La distanza in linea d'aria è un'approssimazione migliore del pericolo.

Perché non minimax

Avrei potuto programmare un minimax classico. Ma con 12 tipi di unità, pattern di movimento diversi, capacità speciali... l'albero di gioco esplode così in fretta da diventare ingiocabile. L'approccio euristico fa scelte intelligenti senza esplorare 10 milioni di stati.

Cosa è figo

Il sistema di attrattività crea dilemmi divertenti:

  • Il Veggente (70) genera mana. Se lo lasci vivere, l'avversario ha più risorse. Ma il Titano (95) è ancora più pericoloso.
  • Il Mutante (90) può scambiare il suo posto con qualsiasi unità. Può rubarti l'Oracolo.
  • L'Arpia (50) ha un attacco esplosivo che uccide anche lei. Resta secondaria... finché si ritrova accanto a 3 tue unità.

L'IA valuta il pericolo globale secondo le posizioni, non solo le stat grezze.

C'è anche una funzione activateSimulation() per testare scenari senza rifare una partita:

// piazza unità specifiche per debuggare l'IA
activateSimulation() {
    // Piazza unità specifiche sul tavolo
    // Utile per debuggare l'IA
    this.game.board = simulation.board;
    this.game.players = simulation.players;
}

Cosa manca

Se avessi più tempo:

  • L'IA reagisce allo stato attuale, non prevede cosa farà il giocatore
  • Non pianifica la sua mano su più turni
  • Il Mutante e il Centauro hanno capacità che sotto-sfrutta
  • Apprendimento per rinforzo: farla giocare contro se stessa per regolare i coeff

Ma per un gioco da browser tiene botta. Amici riescono a perderci contro, quindi è validato.

Prova

Disponibile su nausicaa-game.github.io. Clicchi su "GIOCA", modalità CPU ON, e guardi l'IA fare.

Consiglio: lascia l'IA giocare contro se stessa. Vedrai fasi aggressive, poi pfft indietreggia tutto.

Il codice è su GitHub in js/cpu.js.

3 punti da trattenere:

  1. Coefficienti euristici: niente minimax, ogni unità ha un'attrattività
  2. Coeff che cambiano ogni 5 turni: l'IA alterna aggressività e controllo, stile Pac-Man
  3. L'Oracolo scappa: calcola la direzione opposta alla minaccia e scappa

Se hai idee per rendere l'IA ancora più perfida, apri una issue. Ho piani per una versione che impara dalle sue sconfitte, ma sarà per un prossimo articolo.

Meine bescheuerte KI für Nausicaa

Eine KI mit heuristischen Koeffizienten, Hyper-Parametern, die

Meine bescheuerte KI für Nausicaa

Es gibt Projekte, die mit "was, wenn ich Schach mit Mythologien machen würde ?" anfangen und mit einem System enden, das eine KI enthält, die alle 5 Züge ihre eigenen Hyper-Parameter entscheidet.

Nausicaa ist genau das. Ein rundenbasiertes Brettspiel, in dem du dein Deck mythologischer Kreaturen baust, dein Mana verwaltest, Einheiten auf einem 10x8-Brett entfaltest. Und es gibt eine KI, die Persönlichkeitskrisen hat.

Ich habe viel Zeit in diese KI gesteckt, und das Ergebnis entwischt mir manchmal noch, und das mag ich am liebsten.

Das Spiel in echt

Bevor wir über das Gehirn reden, muss man den Körper verstehen:

  • 10x8-Brett, Entfaltungszone von 2 Reihen pro Spieler
  • Mana startet bei 1, +1 pro Zug, max 6. Du gibst aus, um zu beschwören, anzugreifen, Fähigkeiten zu nutzen
  • Ziel: das gegnerische Orakel killen

12 Einheiten, unterschiedliche Kosten und Bewegungsmuster:

Unit Kosten Bewegung LP
Oracle 0 König (8 Richtungen) 1
Goblin 1 Vorwärts 3 Felder 1
Harpyie 1 König (8 Richtungen) 1
Najade 1 Diagonale 1
Greif 2 Sprung 2 Felder 2
Sirene 2 Lateral 1
Zentaur 2 Springer (in L) 2
Bogenschütze 3 Lateral 1
Phönix 3 Diagonale (dunkle Felder) 1
Metamorph 4 Platztausch 1
Seher 4 Keiner (generiert Mana) 1
Titan 6 Begrenzt (Zonenangriff) 3

Jede Einheit hat ihr eigenes Angriffsmuster. Die Sirene schlägt in die 4 Diagonalen, der Bogenschütze auf Distanz über 3 Felder, der Titan zerstört bei der Beschwörung alles drumherum. Kurz gesagt, ein Schachspiel mit Mythologie und Deckbuilding.

Wie ich die CPU zum Nachdenken gebracht habe

Die Grundidee ist dämlich einfach: Jede gegnerische Einheit hat einen Attraktivitätskoeffizienten. Je gefährlicher sie ist, desto mehr will die KI sich um sie kümmern.

const UNITS_ATTRACTIVENESS = {
    "oracle": 100,
    "titan": 95,
    "shapeshifter": 90,
    "phoenix": 80,
    "siren": 70,
    "archer": 70,
    "seer": 70,
    "griffin": 60,
    "centaur": 60,
    "harpy": 50,
    "naiad": 30,
    "gobelin": 20
};

Orakel bei 100, logisch, es ist die Win Condition. Titan bei 95, weil er bei der Beschwörung alles daneben one-shottet. Goblin bei 20, das ist ein Fußsoldat, der ist uns egal.

Dann berechne ich für jedes Einheitenpaar (eine verbündete, eine gegnerische):

interet = attractivite × coeff_attract / (distance × coeff_dist)

Grob gesagt: Je gefährlicher und näher du bist, desto mehr will die KI dich zerlegen.

calculateAttackCoefficient(x1, y1, x2, y2) {
    const distance = this.calculateEuclideanDistance(x1, y1, x2, y2);
    if (distance === 0) return Infinity;
    return (UNITS_ATTRACTIVENESS[unit.type] * COEFFICIENTS_IMPORTANCE["attractiveness"]) / distance;
}

Der Trick mit den wechselnden Koeffizienten

Der wichtige Punkt ist, dass die Wichtigkeitskoeffizienten sich alle 5 Züge zufällig ändern.

if (this.turnCount % 5 === 0) {
    const distanceCoefficient = parseInt(Math.random() * 100);
    const attractivenessCoefficient = parseInt(Math.random() * 100);
    this.regulateImportanceCoefficients({
        distance: distanceCoefficient,
        attractiveness: attractivenessCoefficient
    });
}

Einmal wird die KI hyperaggressiv (Attraktivität bei 95, Distanz bei 5), sie rennt durch alles, um dein Orakel zu killen. Das nächste Mal priorisiert sie die Distanz und positioniert sich neu.

Das ist von den Pac-Man-Geistern geklaut, Blinky jagt, Pinky lauert auf. Hier wechselt die KI alle Phasen ihre "Persönlichkeit".

Ergebnis: Unmöglich, die KI über eine ganze Partie vorherzusagen. Die CPU macht nie zweimal dasselbe Match.

Das Orakel ist ein Feigling

Das gegnerische Orakel haut ab. Buchstäblich.

const awayFromTarget = {
    row: Math.max(0, Math.min(7, botUnitElement.row + (botUnitElement.row - targetUnitElement.row))),
    col: Math.max(0, Math.min(9, botUnitElement.col + (botUnitElement.col - targetUnitElement.col)))
};

Es berechnet die Richtung entgegengesetzt zur Bedrohung und haut ab. Wenn da eine Mauer ist, sucht es das nächste freie Feld in dieser Richtung.

Du brauchst 3 Züge, um dich dem Orakel zu nähern, und zack ist es schon ans andere Ende abgehauen.

Die Entscheidungsschleife

So entscheidet die KI:

  1. Wenn ich kein Orakel mehr habe (tot), ein neues platzieren
  2. Den Koeffizienten für jedes Paar verbündete Einheit → gegnerische Einheit berechnen
  3. Das beste Paar wählen
  4. Wenn die Einheit das Ziel von ihrer Position angreifen kann → Angriff
  5. Wenn ich weniger als 4 Einheiten habe → die billigste verfügbare aus der Hand beschwören
  6. Sonst sich zum Ziel bewegen (Bewegungsfeld am nächsten zum Gegner)
  7. Wenn genug Mana (> 2), Dash (Doppelbewegung), um noch näher zu kommen
  8. Wenn die Einheit das Orakel ist → abhauen
mermaid diagram
async makeAction(dash=false) {
    // alles der Reihe nach
    // die CPU dasht, wenn sie genug Mana hat
    if(botPlayer.mana > 2) {
        this.makeAction(true);
    }
}

Warum die euklidische Distanz

Ich nutze die euklidische Distanz:

calculateEuclideanDistance(x1, y1, x2, y2) {
    const deltaX = Math.pow(x1 - x2, 2);
    const deltaY = Math.pow(y1 - y2, 2);
    return Math.sqrt(deltaX + deltaY) * COEFFICIENTS_IMPORTANCE["distance"];
}

Warum nicht Manhattan? Weil die Einheiten abwechslungsreiche Bewegungsmuster haben (L wie der Springer, Diagonale usw.). Die Luftliniendistanz ist eine bessere Annäherung an die Gefahr.

Warum kein Minimax

Ich hätte einen klassischen Minimax coden können. Aber mit 12 Einheitentypen, unterschiedlichen Bewegungsmustern, Spezialfähigkeiten... explodiert der Spielbaum so schnell, dass es unspielbar wird. Der heuristische Ansatz trifft intelligente Entscheidungen, ohne 10 Millionen Zustände zu erkunden.

Was cool ist

Das Attraktivitätssystem erzeugt witzige Dilemmas:

  • Der Seher (70) generiert Mana. Wenn du ihn leben lässt, hat der Gegner mehr Ressourcen. Aber der Titan (95) ist noch gefährlicher.
  • Der Metamorph (90) kann seinen Platz mit einer beliebigen Einheit tauschen. Er kann dein Orakel klauen.
  • Die Harpyie (50) hat einen explosiven Angriff, der sie auch tötet. Sie bleibt nebensächlich... bis sie neben 3 deiner Einheiten steht.

Die KI bewertet die globale Gefahr nach den Positionen, nicht nur nach den rohen Stats.

Es gibt auch eine activateSimulation()-Funktion, um Szenarien zu testen, ohne eine Partie neu zu machen:

activateSimulation() {
    // Spezifische Einheiten auf dem Brett platzieren
    // Nützlich, um die KI zu debuggen
    this.game.board = simulation.board;
    this.game.players = simulation.players;
}

Was fehlt

Wenn ich mehr Zeit hätte:

  • Die KI reagiert auf den aktuellen Zustand, sie sagt nicht vorher, was der Spieler tun wird
  • Sie plant ihre Hand nicht über mehrere Züge
  • Der Metamorph und der Zentaur haben Fähigkeiten, die sie unter-nutzt
  • Reinforcement Learning: sie gegen sich selbst spielen lassen, um die Koeffizienten anzupassen

Aber für ein Browserspiel hält es stand. Kumpels schaffen es, dagegen zu verlieren, also ist es validiert.

Teste

Verfügbar auf nausicaa-game.github.io. Du klickst auf "JOUER", CPU-Modus ON, und du schaust der KI zu.

Tipp: Lass die KI gegen sich selbst spielen. Du wirst aggressive Phasen sehen, dann pfft zieht sie sich zurück.

Der Code ist auf GitHub in js/cpu.js.

3 Punkte zum Mitnehmen :

  1. Heuristische Koeffizienten, kein Minimax, jede Einheit hat eine Attraktivität
  2. Koeffizienten, die sich alle 5 Züge ändern, die KI wechselt Aggressivität und Kontrolle, nach Art von Pac-Man
  3. Das Orakel haut ab, es berechnet die Richtung entgegengesetzt zur Bedrohung und verzieht sich

Wenn du Ideen hast, um die KI noch fieser zu machen, öffne ein Issue. Ich habe Pläne für eine Version, die aus ihren Niederlagen lernt, aber das kommt in einen nächsten Artikel.

Мой дурацкий ИИ для Nausicaa

ИИ на эвристических коэффициентах, гиперпараметры, меняющиеся

Мой дурацкий ИИ для Nausicaa

Есть проекты, которые начинаются с "а что если сделать шахматы с мифологиями?" и заканчиваются системой с ИИ, который сам решает свои гиперпараметры каждые 5 ходов.

Nausicaa это оно. Пошаговая настольная игра, где ты строишь колоду мифологических существ, управляешь маной, выставляешь юнитов на поле 10x8. И есть ИИ с кризисами личности.

Я провозился с этим ИИ немало, и результат до сих пор иногда от меня ускользает, и это мне нравится больше всего.

Игра вживую

Прежде чем говорить о мозге, надо понять тело :

  • Поле 10x8, зона высадки в 2 ряда на игрока
  • Мана начинается с 1, +1 за ход, макс 6. Тратишь на призыв, атаку, использование способностей
  • Цель : убить чужого Оракула

12 юнитов, разные стоимости и паттерны движения :

Unit Coût Mouvement PV
Oracle 0 Roi (8 directions) 1
Gobelin 1 Avant 3 cases 1
Harpie 1 Roi (8 directions) 1
Naïade 1 Diagonale 1
Griffin 2 Hop 2 cases 2
Sirène 2 Latéral 1
Centaure 2 Cavalier (en L) 2
Archer 3 Latéral 1
Phénix 3 Diagonale (cases sombres) 1
Métamorphe 4 Échange de place 1
Voyant 4 Aucun (genère du mana) 1
Titan 6 Limité (attaque zone) 3

У каждого юнита свой паттерн атаки. Сирена бьёт по 4 диагоналям, Лучник бьёт дистанционно на 3 клетки, Титан уничтожает всё вокруг при призыве. Короче, шахматы с мифологией и построением колоды.

Как я заставил CPU думать

Базовая идея глупо проста : у каждого вражеского юнита есть коэффициент привлекательности. Чем он опаснее, тем сильнее ИИ хочет им заняться.

const UNITS_ATTRACTIVENESS = {
    "oracle": 100,
    "titan": 95,
    "shapeshifter": 90,
    "phoenix": 80,
    "siren": 70,
    "archer": 70,
    "seer": 70,
    "griffin": 60,
    "centaur": 60,
    "harpy": 50,
    "naiad": 30,
    "gobelin": 20
};

Оракул на 100 -- логично, это условие победы. Титан на 95, потому что он ваншотит всё рядом при призыве. Гоблин на 20, это пехотинец, наплевать.

Дальше для каждой пары юнитов (один свой, один вражеский) я считаю :

interet = attractivite × coeff_attract / (distance × coeff_dist)

Грубо : чем ты опаснее и ближе, тем сильнее ИИ хочет тебя разнести.

calculateAttackCoefficient(x1, y1, x2, y2) {
    const distance = this.calculateEuclideanDistance(x1, y1, x2, y2);
    if (distance === 0) return Infinity;
    return (UNITS_ATTRACTIVENESS[unit.type] * COEFFICIENTS_IMPORTANCE["attractiveness"]) / distance;
}

Фокус с меняющимися коэффициентами

Важный момент : коэффициенты важности случайно меняются каждые 5 ходов.

if (this.turnCount % 5 === 0) {
    const distanceCoefficient = parseInt(Math.random() * 100);
    const attractivenessCoefficient = parseInt(Math.random() * 100);
    this.regulateImportanceCoefficients({
        distance: distanceCoefficient,
        attractiveness: attractivenessCoefficient
    });
}

То ИИ прёт в гиперагрессию (привлекательность на 95, дистанция на 5), он идёт через всё, чтобы убить твоего Оракула. В следующий раз он приоритизирует дистанцию и перестраивается.

Это содрано с призраков Pac-Man -- Блинки охотится, Пинки устраивает засаду. Здесь ИИ меняет "личность" каждую фазу.

Итог : предсказать ИИ на целую партию невозможно. CPU никогда не играет два одинаковых матча.

Оракул трус

Вражеский Оракул убегает. Буквально.

const awayFromTarget = {
    row: Math.max(0, Math.min(7, botUnitElement.row + (botUnitElement.row - targetUnitElement.row))),
    col: Math.max(0, Math.min(9, botUnitElement.col + (botUnitElement.col - targetUnitElement.col)))
};

Он вычисляет направление от угрозы и сваливает. Если там стена, ищет ближайшую свободную клетку в этом направлении.

Ты тратишь 3 хода, чтобы подойти к Оракулу, и бац, он уже убежал на другой конец.

Цикл решения

Вот как ИИ решает :

  1. Если Оракула больше нет (умер), поставить нового
  2. Посчитать коэффициент для каждой пары свой юнит → вражеский юнит
  3. Выбрать лучшую пару
  4. Если юнит может атаковать цель со своей позиции → атака
  5. Если юнитов меньше 4 → призвать самого дешёвого доступного из руки
  6. Иначе двигаться к цели (клетка движения, ближайшая к врагу)
  7. Если маны достаточно (> 2), рывок (двойное движение), чтобы подойти ещё ближе
  8. Если юнит это Оракул → бежать
mermaid diagram
async makeAction(dash=false) {
    // всё это по порядку
    // CPU делает рывок, если маны достаточно
    if(botPlayer.mana > 2) {
        this.makeAction(true);
    }
}

Почему евклидово расстояние

Я использую евклидово расстояние :

calculateEuclideanDistance(x1, y1, x2, y2) {
    const deltaX = Math.pow(x1 - x2, 2);
    const deltaY = Math.pow(y1 - y2, 2);
    return Math.sqrt(deltaX + deltaY) * COEFFICIENTS_IMPORTANCE["distance"];
}

Почему не Манхэттен? Потому что у юнитов разные паттерны движения (Г как конь, диагональ и т.д). Расстояние по прямой лучше приближает опасность.

Почему не минимакс

Я мог бы закодить классический минимакс. Но с 12 типами юнитов, разными паттернами движения, особыми способностями... дерево игры взрывается так быстро, что играть становится невозможно. Эвристический подход делает умные выборы, не исследуя 10 миллионов состояний.

Что круто

Система привлекательности создаёт забавные дилеммы :

  • Провидец (70) генерирует ману. Если оставить его жить, у противника больше ресурсов. Но Титан (95) ещё опаснее.
  • Метаморф (90) может поменяться местом с любым юнитом. Он может украсть твоего Оракула.
  • Гарпия (50) имеет взрывную атаку, которая убивает и её саму. Она остаётся второстепенной... пока не окажется рядом с 3 твоими юнитами.

ИИ оценивает глобальную опасность по позициям, а не только по голым статам.

Есть ещё функция activateSimulation() для проверки сценариев без переигрывания партии :

activateSimulation() {
    // Расставляет конкретных юнитов на поле
    // Удобно для отладки ИИ
    this.game.board = simulation.board;
    this.game.players = simulation.players;
}

Чего не хватает

Если бы было больше времени :

  • ИИ реагирует на текущее состояние, он не предсказывает, что сделает игрок
  • Он не планирует руку на несколько ходов
  • Способности Метаморфа и Кентавра он недоиспользует
  • Обучение с подкреплением : заставить его играть против себя, чтобы подстроить коэффициенты

Но для браузерной игры держится достойно. Друзья умудряются ему проигрывать, так что валидировано.

Тестируй

Доступно на nausicaa-game.github.io. Жмёшь "JOUER", режим CPU включён, и смотришь, как играет ИИ.

Совет : дай ИИ поиграть против себя. Увидишь агрессивные фазы, а потом пффф, он всё отводит.

Код на GitHub в js/cpu.js.

3 пункта запомнить :

  1. Эвристические коэффициенты -- никакого минимакса, у каждого юнита своя привлекательность
  2. Коэффициенты меняются каждые 5 ходов -- ИИ чередует агрессию и контроль, в духе Pac-Man
  3. Оракул убегает -- он вычисляет направление от угрозы и сваливает

Если есть идеи, как сделать ИИ ещё злее, открывай issue. У меня есть планы на версию, которая учится на поражениях, но это будет в следующей статье.

Mi IA cutre para Nausicaa

Una IA con coeficientes heurísticos, hiperparámetros que cambian cada 5 turnos, y un Oráculo que huye: inmersión en el cerebro de un juego de mesa estratégico.

Mi IA cutre para Nausicaa

Hay proyectos que empiezan con "¿y si hiciera un ajedrez con mitologías?" y terminan con un sistema con una IA que decide sus propios hiperparámetros cada 5 turnos.

Nausicaa es eso. Un juego de mesa por turnos donde construyes tu mazo de criaturas mitológicas, gestionas tu maná, despliegas unidades en un tablero 10x8. Y hay una IA que tiene crisis de personalidad.

Pasé bastante tiempo en esa IA, y el resultado aún se me escapa a veces, y eso es lo que más me gusta.

El juego de verdad

Antes de hablar del cerebro, hay que entender el cuerpo:

  • Tablero 10x8, zona de despliegue de 2 filas por jugador
  • Maná empieza en 1, +1 por turno, máx 6. Gastas para invocar, atacar, usar capacidades
  • Objetivo: matar al Oráculo adverso

12 unidades, costes y patrones de movimiento distintos:

Unit Coût Mouvement PV
Oracle 0 Rey (8 direcciones) 1
Gobelin 1 Adelante 3 casillas 1
Harpie 1 Rey (8 direcciones) 1
Naïade 1 Diagonal 1
Griffin 2 Salto 2 casillas 2
Sirène 2 Lateral 1
Centaure 2 Caballo (en L) 2
Archer 3 Lateral 1
Phénix 3 Diagonal (casillas oscuras) 1
Métamorphe 4 Intercambio de sitio 1
Voyant 4 Ninguno (genera maná) 1
Titan 6 Limitado (ataque zona) 3

Cada unidad tiene su propio patrón de ataque. La Sirena pega en las 4 diagonales, el Arquero a distancia en 3 casillas, el Titán destruye todo alrededor al invocar. En resumen, un ajedrez con mitología y deckbuilding.

Cómo hice pensar la CPU

La idea base es tontamente simple: cada unidad enemiga tiene un coeficiente de atractivo. Cuanto más peligrosa, más la IA quiere ocuparse de ella.

const UNITS_ATTRACTIVENESS = {
    "oracle": 100,
    "titan": 95,
    "shapeshifter": 90,
    "phoenix": 80,
    "siren": 70,
    "archer": 70,
    "seer": 70,
    "griffin": 60,
    "centaur": 60,
    "harpy": 50,
    "naiad": 30,
    "gobelin": 20
};

Oráculo a 100: lógico, es la condición de victoria. Titán a 95 porque mata todo lo de al lado al invocar. Gobelin a 20, es un infante, nos importa un bledo.

Luego para cada par de unidades (una aliada, una enemiga), calculo:

interes = atractivo × coeff_attract / (distancia × coeff_dist)

En resumen: cuanto más peligroso y cercano, más la IA quiere destrozarte.

calculateAttackCoefficient(x1, y1, x2, y2) {
    const distance = this.calculateEuclideanDistance(x1, y1, x2, y2);
    if (distance === 0) return Infinity;
    return (UNITS_ATTRACTIVENESS[unit.type] * COEFFICIENTS_IMPORTANCE["attractiveness"]) / distance;
}

El truco de los coeficientes que cambian

El punto importante es que los coeficientes de importancia cambian aleatoriamente cada 5 turnos.

if (this.turnCount % 5 === 0) {
    const distanceCoefficient = parseInt(Math.random() * 100);
    const attractivenessCoefficient = parseInt(Math.random() * 100);
    this.regulateImportanceCoefficients({
        distance: distanceCoefficient,
        attractiveness: attractivenessCoefficient
    });
}

Una vez la IA va hiperagresiva (atractivo a 95, distancia a 5), atraviesa todo para matar tu Oráculo. La vez siguiente prioriza la distancia y se reposiciona.

Está copiado de los fantasmas de Pac-Man: Blinky caza, Pinky embosca. Aquí la IA cambia de "personalidad" cada fase.

Resultado: imposible predecir la IA en una partida entera. La CPU nunca hace dos veces el mismo match.

El Oráculo es un gallina

El Oráculo enemigo huye. Literalmente.

const awayFromTarget = {
    row: Math.max(0, Math.min(7, botUnitElement.row + (botUnitElement.row - targetUnitElement.row))),
    col: Math.max(0, Math.min(9, botUnitElement.col + (botUnitElement.col - targetUnitElement.col)))
};

Calcula la dirección opuesta a la amenaza y se pira. Si hay un muro, busca la casilla libre más cercana en esa dirección.

Pasas 3 turnos acercándote al Oráculo, y paf ya huyó al otro extremo.

El bucle de decisión

Así decide la IA:

  1. Si ya no tengo Oráculo (muerto), colocar uno nuevo
  2. Calcular el coeficiente para cada par unidad aliada → unidad enemiga
  3. Elegir la mejor pareja
  4. Si la unidad puede atacar al objetivo desde su posición → ataca
  5. Si tengo menos de 4 unidades → invocar la más barata disponible desde la mano
  6. Si no, moverse hacia el objetivo (casilla de movimiento más cercana al enemigo)
  7. Si hay bastante maná (> 2), dash (doble movimiento) para acercarse más
  8. Si la unidad es el Oráculo → huir
mermaid diagram
async makeAction(dash=false) {
    // todo eso en secuencia
    // la CPU hace dash si tiene bastante maná
    if(botPlayer.mana > 2) {
        this.makeAction(true);
    }
}

Por qué la distancia euclidiana

Uso la distancia euclidiana:

calculateEuclideanDistance(x1, y1, x2, y2) {
    const deltaX = Math.pow(x1 - x2, 2);
    const deltaY = Math.pow(y1 - y2, 2);
    return Math.sqrt(deltaX + deltaY) * COEFFICIENTS_IMPORTANCE["distance"];
}

¿Por qué no Manhattan? Porque las unidades tienen patrones de movimiento variados (L como el caballo, diagonal, etc). La distancia a vuelo de pájaro es una mejor aproximación del peligro.

Por qué no minimax

Podría haber programado un minimax clásico. Pero con 12 tipos de unidades, patrones de movimiento distintos, capacidades especiales... el árbol de juego explota tan rápido que se vuelve injugable. El enfoque heurístico toma decisiones inteligentes sin explorar 10 millones de estados.

Lo que mola

El sistema de atractivo crea dilemas divertidos:

  • El Vidente (70) genera maná. Si lo dejas vivir, el adversario tiene más recursos. Pero el Titán (95) es aún más peligroso.
  • El Metamorfo (90) puede intercambiar su sitio con cualquier unidad. Puede robar tu Oráculo.
  • La Harpía (50) tiene un ataque explosivo que también la mata. Queda secundaria... hasta que acaba junto a 3 de tus unidades.

La IA evalúa el peligro global según las posiciones, no solo las stats en bruto.

También hay una función activateSimulation() para probar escenarios sin repetir una partida:

activateSimulation() {
    // Coloca unidades específicas en el tablero
    // Útil para depurar la IA
    this.game.board = simulation.board;
    this.game.players = simulation.players;
}

Lo que falta

Si tuviera más tiempo:

  • La IA reacciona al estado actual, no predice lo que el jugador va a hacer
  • No planifica su mano en varios turnos
  • El Metamorfo y el Centauro tienen capacidades que subexploita
  • Aprendizaje por refuerzo: hacerla jugar contra sí misma para ajustar los coeffs

Pero para un juego de navegador aguanta el tipo. Hay colegas que logran perder contra ella, así que está validada.

Prueba

Disponible en nausicaa-game.github.io. Haces clic en "JOUER", modo CPU ON, y miras la IA hacer.

Consejo: deja la IA jugar contra sí misma. Vas a ver fases agresivas, y luego pfft retrocede todo.

El código está en GitHub en js/cpu.js.

3 puntos a recordar:

  1. Coeficientes heurísticos: sin minimax, cada unidad tiene un atractivo
  2. Coeffs que cambian cada 5 turnos: la IA alterna agresividad y control, estilo Pac-Man
  3. El Oráculo huye: calcula la dirección opuesta a la amenaza y se pira

Si tienes ideas para hacer la IA aún más retorcida, abre una issue. Tengo planes para una versión que aprenda de sus derrotas, pero eso será para un próximo artículo.

Minha IA maluca para Nausicaa

Uma IA com coeficientes heurísticos, hiperparâmetros que

Minha IA maluca para Nausicaa

Tem projeto que começa com "e se eu fizesse um xadrez com mitologias ?" e termina com um sistema com uma IA que decide seus próprios hiperparâmetros a cada 5 turnos.

Nausicaa é isso. Um jogo de tabuleiro por turnos onde você monta seu deck de criaturas mitológicas, gerencia sua mana, posiciona unidades em um tabuleiro 10x8. E tem uma IA que tem crises de personalidade.

Passei bastante tempo nessa IA, e o resultado ainda me escapa às vezes, e é disso que mais gosto.

O jogo de verdade

Antes de falar do cérebro, preciso mostrar o corpo :

  • Tabuleiro 10x8, zona de posicionamento de 2 fileiras por jogador
  • Mana começa em 1, +1 por turno, máx 6. Você gasta para invocar, atacar, usar habilidades
  • Objetivo : matar o Oráculo adversário

12 unidades, com custos e padrões de movimento diferentes :

Unidade Custo Movimento PV
Oráculo 0 Rei (8 direções) 1
Goblin 1 Para frente 3 casas 1
Harpia 1 Rei (8 direções) 1
Náiade 1 Diagonal 1
Grifo 2 Pulo 2 casas 2
Sereia 2 Lateral 1
Centauro 2 Cavalo (em L) 2
Arqueiro 3 Lateral 1
Fênix 3 Diagonal (casas escuras) 1
Metamorfo 4 Troca de lugar 1
Vidente 4 Nenhum (gera mana) 1
Titã 6 Limitado (ataque em área) 3

Cada unidade tem seu próprio padrão de ataque. A Sereia bate nas 4 diagonais, o Arqueiro à distância em 3 casas, o Titã destrói tudo ao redor na invocação. Resumindo, um xadrez com mitologia e deckbuilding.

Como fiz a CPU pensar

A ideia base é bobamente simples : cada unidade inimiga tem um coeficiente de atratividade. Quanto mais perigosa, mais a IA quer cuidar dela.

// Atratividade por unidade : Oráculo no topo, Goblin na base
const UNITS_ATTRACTIVENESS = {
    "oracle": 100,
    "titan": 95,
    "shapeshifter": 90,
    "phoenix": 80,
    "siren": 70,
    "archer": 70,
    "seer": 70,
    "griffin": 60,
    "centaur": 60,
    "harpy": 50,
    "naiad": 30,
    "gobelin": 20
};

Oráculo em 100, lógico, é a condição de vitória. Titã em 95 porque mata tudo ao lado na invocação. Goblin em 20, é um soldado raso, ninguém liga.

Depois para cada par de unidades (uma aliada, uma inimiga), calculo :

interesse = atratividade × coef_atrat / (distância × coef_dist)

Resumindo : quanto mais perigoso e perto você está, mais a IA quer te destruir.

// Interesse de ataque : atratividade ponderada dividida pela distância
calculateAttackCoefficient(x1, y1, x2, y2) {
    const distance = this.calculateEuclideanDistance(x1, y1, x2, y2);
    if (distance === 0) return Infinity;
    return (UNITS_ATTRACTIVENESS[unit.type] * COEFFICIENTS_IMPORTANCE["attractiveness"]) / distance;
}

O truque dos coeficientes que mudam

O ponto importante é que os coeficientes de importância mudam aleatoriamente a cada 5 turnos.

// A cada 5 turnos, novos coeficientes aleatórios mudam a "personalidade"
if (this.turnCount % 5 === 0) {
    const distanceCoefficient = parseInt(Math.random() * 100);
    const attractivenessCoefficient = parseInt(Math.random() * 100);
    this.regulateImportanceCoefficients({
        distance: distanceCoefficient,
        attractiveness: attractivenessCoefficient
    });
}

Uma hora a IA fica hiper agressiva (atrat em 95, distância em 5), atravessa tudo para matar seu Oráculo. Na vez seguinte prioriza a distância e se reposiciona.

É roubado dos fantasmas do Pac-Man, Blinky caça, Pinky embosca. Aqui a IA muda de "personalidade" a cada fase.

Resultado : impossível prever a IA em uma partida inteira. A CPU nunca faz dois jogos iguais.

O Oráculo é um medroso

O Oráculo inimigo foge. Literalmente.

// Fuga do Oráculo : calcula a direção oposta à ameaça
const awayFromTarget = {
    row: Math.max(0, Math.min(7, botUnitElement.row + (botUnitElement.row - targetUnitElement.row))),
    col: Math.max(0, Math.min(9, botUnitElement.col + (botUnitElement.col - targetUnitElement.col)))
};

Ele calcula a direção oposta à ameaça e vaza. Se tem uma parede, procura a casa livre mais próxima nessa direção.

Você passa 3 turnos se aproximando do Oráculo, e paf ele já fugiu para o outro lado.

O loop de decisão

Eis como a IA decide :

  1. Se não tenho mais Oráculo (morto), colocar um novo
  2. Calcular o coeficiente para cada par unidade aliada → unidade inimiga
  3. Escolher o melhor par
  4. Se a unidade pode atacar o alvo da sua posição → ataca
  5. Se tenho menos de 4 unidades → invocar a mais barata disponível na mão
  6. Senão, mover-se para o alvo (casa de movimento mais próxima do inimigo)
  7. Se mana suficiente (> 2), dash (movimento duplo) para se aproximar ainda
  8. Se a unidade é o Oráculo → fugir
flowchart TD
    A[Início do turno da CPU] --> B{Oráculo vivo ?}
    B -->|Não| C[Colocar novo Oráculo]
    B -->|Sim| D[Calcular coeficientes\nde ataque para cada\npar de unidades]
    D --> E[Escolher o melhor par\natacante → alvo]
    E --> F{Ataque possível\nda posição ?}
    F -->|Sim| G[Atacar o alvo]
    F -->|Não| H{Menos de 4\nunidades ?}
    H -->|Sim| I[Invocar da mão\na mais barata disp.]
    H -->|Não| J[Mover-se para o alvo\ncasa mais próxima]
    J --> K{Mana > 2 ?}
    K -->|Sim| L[Dash : movimento duplo\npara o alvo]
    K -->|Não| M[Fim do turno]
    G --> M
    I --> M
    L --> M
// Ação da CPU em sequência, com dash se houver mana suficiente
async makeAction(dash=false) {
    // tudo isso em sequência
    // a CPU dá dash se tiver mana suficiente
    if(botPlayer.mana > 2) {
        this.makeAction(true);
    }
}

Por que a distância euclidiana

Uso a distância euclidiana :

// Distância em linha reta ponderada pela importância da distância
calculateEuclideanDistance(x1, y1, x2, y2) {
    const deltaX = Math.pow(x1 - x2, 2);
    const deltaY = Math.pow(y1 - y2, 2);
    return Math.sqrt(deltaX + deltaY) * COEFFICIENTS_IMPORTANCE["distance"];
}

Por que não Manhattan ? Porque as unidades têm padrões de movimento variados (L como o cavalo, diagonal, etc). A distância em linha reta é uma aproximação melhor do perigo.

Por que não minimax

Eu poderia ter codado um minimax clássico. Mas com 12 tipos de unidades, padrões de movimento diferentes, habilidades especiais... a árvore do jogo explode tão rápido que fica injogável. A abordagem heurística faz escolhas inteligentes sem explorar 10 milhões de estados.

O sistema de atratividade cria dilemas engraçados :

  • O Vidente (70) gera mana. Se você deixá-lo viver, o adversário tem mais recursos. Mas o Titã (95) é ainda mais perigoso.
  • O Metamorfo (90) pode trocar de lugar com qualquer unidade. Pode roubar seu Oráculo.
  • A Harpia (50) tem um ataque explosivo que também a mata. Ela fica secundária... até aparecer ao lado de 3 das suas unidades.

A IA avalia o perigo global conforme as posições, não só os stats brutos.

Também há uma função activateSimulation() para testar cenários sem refazer uma partida :

// Coloca unidades específicas no tabuleiro para debugar a IA
activateSimulation() {
    // Coloca unidades específicas no tabuleiro
    // Útil para debugar a IA
    this.game.board = simulation.board;
    this.game.players = simulation.players;
}

O que falta

Se eu tivesse mais tempo :

  • A IA reage ao estado atual, não prevê o que o jogador vai fazer
  • Ela não planeja a mão em vários turnos
  • O Metamorfo e o Centauro têm habilidades que ela subexplora
  • Aprendizado por reforço : fazê-la jogar contra si mesma para ajustar os coefs

Mas para um jogo de navegador segura bem a onda. Uns amigos conseguem perder para ela, então está validado.

Teste

Disponível em nausicaa-game.github.io. Você clica em "JOUER", modo CPU ON, e assiste a IA jogar.

Conselho : deixa a IA jogar contra si mesma. Você vai ver fases agressivas, e depois pfft ela recua tudo.

O código está no GitHub em js/cpu.js.

3 pontos para lembrar :

  1. Coeficientes heurísticos, sem minimax, cada unidade tem uma atratividade
  2. Coefs que mudam a cada 5 turnos, a IA alterna agressividade e controle, estilo Pac-Man
  3. O Oráculo foge, ele calcula a direção oposta à ameaça e vaza

Se tiver ideias para deixar a IA ainda mais cruel, abra uma issue. Tenho planos para uma versão que aprende com as derrotas, mas isso fica para um próximo artigo.

AI Jelekku untuk Nausicaa

AI berkoefisien heuristik, hyper-parameter yang

AI jelekku untuk Nausicaa

Ada proyek yang dimulai dengan "eh gimana kalau saya bikin catur dengan mitologi?" dan berakhir dengan sistem yang AI-nya memutuskan hyper-parameternya sendiri setiap 5 giliran.

Nausicaa itu begitu. Permainan papan bergiliran di mana kamu membangun dek makhluk mitologismu, mengatur manamu, menempatkan unit di papan 10x8. Dan ada AI yang mengalami krisis kepribadian.

Saya menghabiskan cukup banyak waktu untuk AI ini, dan hasilnya kadang masih lolos dari saya, dan itulah yang paling saya suka.

Permainannya secara nyata

Sebelum bicara soal otak, harus paham badannya dulu :

  • Papan 10x8, zona penempatan 2 baris per pemain
  • Mana mulai dari 1, +1 per giliran, maks 6. Kamu belanjakan untuk memanggil, menyerang, memakai kemampuan
  • Tujuan : membunuh Oracle lawan

12 unit, dengan biaya dan pola gerakan berbeda :

Unit Biaya Gerakan HP
Oracle 0 Raja (8 arah) 1
Gobelin 1 Maju 3 petak 1
Harpie 1 Raja (8 arah) 1
Naïade 1 Diagonal 1
Griffin 2 Lompat 2 petak 2
Sirène 2 Lateral 1
Centaure 2 Kuda (L) 2
Archer 3 Lateral 1
Phénix 3 Diagonal (petak gelap) 1
Métamorphe 4 Tukar tempat 1
Voyant 4 Tidak ada (menghasilkan mana) 1
Titan 6 Terbatas (serangan area) 3

Setiap unit punya pola serangannya sendiri. Sirène memukul ke 4 diagonal, Archer jarak jauh 3 petak, Titan menghancurkan semua di sekitar saat dipanggil. Singkatnya, permainan catur dengan mitologi dan deckbuilding.

Cara saya membuat CPU berpikir

Ide dasarnya sangat sederhana : setiap unit musuh punya koefisien daya tarik. Makin berbahaya dia, makin ingin AI mengurusnya.

const UNITS_ATTRACTIVENESS = {
    "oracle": 100,
    "titan": 95,
    "shapeshifter": 90,
    "phoenix": 80,
    "siren": 70,
    "archer": 70,
    "seer": 70,
    "griffin": 60,
    "centaur": 60,
    "harpy": 50,
    "naiad": 30,
    "gobelin": 20
};

Oracle 100 -- logis, itu win condition. Titan 95 karena ia menghabisi semua di sebelahnya saat dipanggil. Gobelin 20, itu prajurit biasa, tidak peduli.

Lalu untuk setiap pasangan unit (satu sekutu, satu musuh), saya hitung :

interet = attractivite × coeff_attract / (distance × coeff_dist)

Intinya : makin berbahaya dan dekat kamu, makin ingin AI menghajarmu.

calculateAttackCoefficient(x1, y1, x2, y2) {
    const distance = this.calculateEuclideanDistance(x1, y1, x2, y2);
    if (distance === 0) return Infinity;
    return (UNITS_ATTRACTIVENESS[unit.type] * COEFFICIENTS_IMPORTANCE["attractiveness"]) / distance;
}

Trik koefisien yang berubah

Poin pentingnya adalah koefisien kepentingan berubah acak setiap 5 giliran.

if (this.turnCount % 5 === 0) {
    const distanceCoefficient = parseInt(Math.random() * 100);
    const attractivenessCoefficient = parseInt(Math.random() * 100);
    this.regulateImportanceCoefficients({
        distance: distanceCoefficient,
        attractiveness: attractivenessCoefficient
    });
}

Sekali AI jadi hiper agresif (attract 95, distance 5), ia menerobos segalanya untuk membunuh Oracle-mu. Berikutnya ia memprioritaskan jarak dan memposisikan ulang.

Ini dicuri dari hantu Pac-Man -- Blinky mengejar, Pinky menyergap. Di sini AI berganti "kepribadian" tiap fase.

Hasilnya : mustahil memprediksi AI selama satu permainan penuh. CPU tidak pernah memainkan dua match yang sama.

Oracle itu pengecut

Oracle musuh kabur. Secara harfiah.

const awayFromTarget = {
    row: Math.max(0, Math.min(7, botUnitElement.row + (botUnitElement.row - targetUnitElement.row))),
    col: Math.max(0, Math.min(9, botUnitElement.col + (botUnitElement.col - targetUnitElement.col)))
};

Ia menghitung arah berlawanan dari ancaman dan kabur. Kalau ada tembok, ia mencari petak bebas terdekat ke arah itu.

Kamu menghabiskan 3 giliran mendekati Oracle, dan tiba-tiba ia sudah kabur ke ujung lain.

Loop keputusan

Begini cara AI memutuskan :

  1. Kalau saya tidak punya Oracle lagi (mati), tempatkan yang baru
  2. Hitung koefisien untuk setiap pasangan unit sekutu → unit musuh
  3. Pilih pasangan terbaik
  4. Kalau unit bisa menyerang target dari posisinya → serang
  5. Kalau saya punya kurang dari 4 unit → panggil yang termurah yang tersedia dari tangan
  6. Kalau tidak, bergerak ke target (petak gerakan terdekat ke musuh)
  7. Kalau mana cukup (> 2), dash (gerakan ganda) untuk makin mendekat
  8. Kalau unitnya Oracle → kabur
flowchart TD
    A[Début du tour CPU] --> B{Oracle en vie ?}
    B -->|Non| C[Placer nouvel Oracle]
    B -->|Oui| D[Calculer coefficients\nd'attaque pour chaque\npaire d'unités]
    D --> E[Choisir la meilleure paire\nattaquant → cible]
    E --> F{Attaque possible\ndepuis la position ?}
    F -->|Oui| G[Attaquer la cible]
    F -->|Non| H{Moins de 4\nunités ?}
    H -->|Oui| I[Invoquer depuis la main\nla moins chère dispo]
    H -->|Non| J[Bergerak ke target\npetak terdekat]
    J --> K{Mana > 2 ?}
    K -->|Oui| L[Dash : double mouvement\nvers la cible]
    K -->|Non| M[Fin du tour]
    G --> M
    I --> M
    L --> M
async makeAction(dash=false) {
    // semua itu berurutan
    // le CPU dash si il a assez de mana
    if(botPlayer.mana > 2) {
        this.makeAction(true);
    }
}

Kenapa jarak euclidean

Saya memakai jarak euclidean :

calculateEuclideanDistance(x1, y1, x2, y2) {
    const deltaX = Math.pow(x1 - x2, 2);
    const deltaY = Math.pow(y1 - y2, 2);
    return Math.sqrt(deltaX + deltaY) * COEFFICIENTS_IMPORTANCE["distance"];
}

Kenapa bukan Manhattan? Karena unit-unitnya punya pola gerakan beragam (L seperti kuda, diagonal, dll). Jarak garis lurus adalah perkiraan bahaya yang lebih baik.

Kenapa bukan minimax

Saya bisa saja mengoding minimax klasik. Tapi dengan 12 tipe unit, pola gerakan berbeda, kemampuan khusus... pohon permainannya meledak begitu cepat sampai tidak bisa dimainkan. Pendekatan heuristik membuat pilihan cerdas tanpa menjelajahi 10 juta keadaan.

Yang keren

Sistem daya tarik menciptakan dilema yang lucu :

  • Voyant (70) menghasilkan mana. Kalau kamu biarkan hidup, lawan punya lebih banyak sumber daya. Tapi Titan (95) masih lebih berbahaya.
  • Métamorphe (90) bisa bertukar tempat dengan unit mana pun. Ia bisa mencuri Oracle-mu.
  • Harpie (50) punya serangan meledak yang juga membunuhnya. Ia tetap sekunder... sampai ia berakhir di samping 3 unitmu.

AI menilai bahaya global menurut posisi, bukan sekadar stat mentah.

Ada juga fungsi activateSimulation() untuk menguji skenario tanpa mengulang permainan :

activateSimulation() {
    // Place des unités spécifiques sur le plateau
    // Berguna untuk men-debug AI
    this.game.board = simulation.board;
    this.game.players = simulation.players;
}

Yang kurang

Kalau saya punya lebih banyak waktu :

  • AI bereaksi pada keadaan saat ini, ia tidak memprediksi apa yang akan dilakukan pemain
  • Ia tidak merencanakan tangannya selama beberapa giliran
  • Métamorphe dan Centaure punya kemampuan yang kurang dieksploitasi
  • Pembelajaran penguatan : membuatnya bermain melawan dirinya sendiri untuk menyesuaikan koefisien

Tapi untuk permainan peramban ini sudah cukup oke. Teman-teman bisa kalah melawannya, jadi sudah tervalidasi.

Coba

Tersedia di nausicaa-game.github.io. Kamu klik "JOUER", mode CPU ON, dan kamu lihat AI beraksi.

Saran : biarkan AI bermain melawan dirinya sendiri. Kamu akan melihat fase agresif, lalu tiba-tiba ia mundur semua.

Kodenya ada di GitHub di js/cpu.js.

3 poin yang perlu diingat :

  1. Koefisien heuristik -- tanpa minimax, setiap unit punya daya tarik
  2. Koefisien yang berubah setiap 5 giliran -- AI bergantian antara agresivitas dan kontrol, ala Pac-Man
  3. Oracle kabur -- ia menghitung arah berlawanan dari ancaman dan kabur

Kalau kamu punya ide untuk membuat AI makin licik, buka issue. Saya punya rencana untuk versi yang belajar dari kekalahannya, tapi itu untuk artikel berikutnya.

Nausicaa के लिए मेरा बेवकूफ AI

अनुमानी गुणांक वाला AI, हाइपर पैरामीटर जो हर 5 चाल में बदलते हैं, और Oracle जो भागता है, रणनीतिक बोर्ड गेम के दिमाग में गोता।

Nausicaa के लिए मेरा बेवकूफ AI

कुछ प्रोजेक्ट "क्या हो अगर मैं पौराणिक कथाओं के साथ शतरंज बनाऊँ ?" से शुरू होते हैं और ऐसी प्रणाली पर खत्म होते हैं जिसमें AI हर 5 चाल में अपने हाइपर पैरामीटर खुद तय करता है।

Nausicaa यही है। बारी आधारित बोर्ड गेम जहाँ तुम पौराणिक प्राणियों का डेक बनाते हो, अपना mana प्रबंधित करते हो, 10x8 बोर्ड पर इकाइयाँ तैनात करते हो। और एक AI है जिसे व्यक्तित्व संकट होते हैं।

मैंने इस AI पर काफी समय बिताया, और परिणाम कभी कभी मुझे भी चकमा देता है, और यही मुझे सबसे ज्यादा पसंद है।

असली खेल

दिमाग की बात से पहले, शरीर समझना होगा :

  • 10x8 बोर्ड, प्रति खिलाड़ी 2 पंक्तियों का तैनाती क्षेत्र
  • Mana 1 से शुरू, प्रति चाल +1, अधिकतम 6। तुम आह्वान करने, हमला करने, क्षमताएँ इस्तेमाल करने के लिए खर्च करते हो
  • लक्ष्य : विरोधी Oracle को मारना

12 इकाइयाँ, अलग लागत और गति पैटर्न :

इकाई लागत गति HP
Oracle 0 राजा (8 दिशाएँ) 1
Gobelin 1 आगे 3 खाने 1
Harpie 1 राजा (8 दिशाएँ) 1
Naïade 1 विकर्ण 1
Griffin 2 2 खाने छलांग 2
Sirène 2 पार्श्व 1
Centaure 2 घोड़ा (L में) 2
Archer 3 पार्श्व 1
Phénix 3 विकर्ण (गहरे खाने) 1
Métamorphe 4 जगह अदला बदली 1
Voyant 4 कोई नहीं (mana बनाता है) 1
Titan 6 सीमित (क्षेत्र हमला) 3

हर इकाई का अपना हमला पैटर्न है। Sirène 4 विकर्णों में मारती है, Archer 3 खानों पर दूरी से, Titan आह्वान पर आसपास सब नष्ट करता है। संक्षेप में, पौराणिक कथाओं और डेकबिल्डिंग वाला शतरंज खेल।

मैंने CPU को कैसे सोचवाया

बुनियादी विचार मूर्खतापूर्ण सरल है : हर शत्रु इकाई का आकर्षण गुणांक होता है। जितनी खतरनाक, उतना AI उससे निपटना चाहता है।

const UNITS_ATTRACTIVENESS = {
    "oracle": 100,
    "titan": 95,
    "shapeshifter": 90,
    "phoenix": 80,
    "siren": 70,
    "archer": 70,
    "seer": 70,
    "griffin": 60,
    "centaur": 60,
    "harpy": 50,
    "naiad": 30,
    "gobelin": 20
};

Oracle 100, तर्कसंगत, यह जीत शर्त है। Titan 95 क्योंकि वह आह्वान पर पास सब कुछ एक बार में मारता है। Gobelin 20, यह पैदल सैनिक है, हमें परवाह नहीं।

फिर इकाइयों की हर जोड़ी (एक मित्र, एक शत्रु) के लिए, मैं गणना करता हूँ :

रुचि = आकर्षण × गुणांक_आकर्षण / (दूरी × गुणांक_दूरी)

मोटे तौर पर : तुम जितने खतरनाक और करीब, उतना AI तुम्हें पीटना चाहता है।

calculateAttackCoefficient(x1, y1, x2, y2) {
    const distance = this.calculateEuclideanDistance(x1, y1, x2, y2);
    if (distance === 0) return Infinity;
    return (UNITS_ATTRACTIVENESS[unit.type] * COEFFICIENTS_IMPORTANCE["attractiveness"]) / distance;
}

बदलते गुणांकों वाली चाल

महत्वपूर्ण बिंदु यह है कि महत्व गुणांक हर 5 चाल में बेतरतीब बदलते हैं।

if (this.turnCount % 5 === 0) {
    const distanceCoefficient = parseInt(Math.random() * 100);
    const attractivenessCoefficient = parseInt(Math.random() * 100);
    this.regulateImportanceCoefficients({
        distance: distanceCoefficient,
        attractiveness: attractivenessCoefficient
    });
}

एक बार AI अति आक्रामक हो जाता है (आकर्षण 95, दूरी 5), वह तुम्हारे Oracle को मारने के लिए सब पार करता है। अगली बार वह दूरी प्राथमिकता देता है और फिर से स्थिति लेता है।

यह Pac-Man भूतों से उठाया गया है, Blinky शिकार करता है, Pinky घात लगाता है। यहाँ AI हर चरण में "व्यक्तित्व" बदलता है।

परिणाम : पूरी बाजी में AI की भविष्यवाणी असंभव। CPU कभी दो बार एक जैसा मैच नहीं खेलता।

Oracle कायर है

शत्रु Oracle भागता है। सचमुच।

const awayFromTarget = {
    row: Math.max(0, Math.min(7, botUnitElement.row + (botUnitElement.row - targetUnitElement.row))),
    col: Math.max(0, Math.min(9, botUnitElement.col + (botUnitElement.col - targetUnitElement.col)))
};

वह खतरे की विपरीत दिशा गणना करता है और भाग जाता है। अगर दीवार है, तो वह उस दिशा में निकटतम खाली खाना खोजता है।

तुम Oracle के करीब जाने में 3 चाल बिताते हो, और धड़ाम वह पहले ही दूसरे छोर पर भाग चुका है।

निर्णय लूप

AI कैसे तय करता है :

  1. अगर मेरा Oracle नहीं रहा (मरा), नया रखना
  2. हर मित्र इकाई → शत्रु इकाई जोड़े के लिए गुणांक गणना करना
  3. सबसे अच्छी जोड़ी चुनना
  4. अगर इकाई अपनी स्थिति से लक्ष्य पर हमला कर सकती है → हमला
  5. अगर मेरी 4 से कम इकाइयाँ हैं → हाथ से सबसे सस्ती उपलब्ध आह्वान करना
  6. वरना, लक्ष्य की ओर बढ़ना (शत्रु के निकटतम गति खाना)
  7. अगर पर्याप्त mana (> 2), dash (दोहरा गति) ताकि और करीब आए
  8. अगर इकाई Oracle है → भागना
flowchart TD
    A[CPU चाल की शुरुआत] --> B{Oracle जीवित है ?}
    B -->|नहीं| C[नया Oracle रखो]
    B -->|हाँ| D[हर<br/>इकाई जोड़ी के लिए हमला<br/>गुणांक गणना करो]
    D --> E[सबसे अच्छी जोड़ी चुनो<br/>हमलावर → लक्ष्य]
    E --> F{स्थिति से<br/>हमला संभव ?}
    F -->|हाँ| G[लक्ष्य पर हमला करो]
    F -->|नहीं| H{4 से कम<br/>इकाइयाँ ?}
    H -->|हाँ| I[हाथ से आह्वान करो<br/>सबसे सस्ती उपलब्ध]
    H -->|नहीं| J[लक्ष्य की ओर बढ़ो<br/>निकटतम खाना]
    J --> K{Mana > 2 ?}
    K -->|हाँ| L[Dash : दोहरा गति<br/>लक्ष्य की ओर]
    K -->|नहीं| M[चाल का अंत]
    G --> M
    I --> M
    L --> M
async makeAction(dash=false) {
    // यह सब क्रम में
    // CPU dash करता है अगर उसके पास पर्याप्त mana है
    if(botPlayer.mana > 2) {
        this.makeAction(true);
    }
}

यूक्लिडीय दूरी क्यों

मैं यूक्लिडीय दूरी इस्तेमाल करता हूँ :

calculateEuclideanDistance(x1, y1, x2, y2) {
    const deltaX = Math.pow(x1 - x2, 2);
    const deltaY = Math.pow(y1 - y2, 2);
    return Math.sqrt(deltaX + deltaY) * COEFFICIENTS_IMPORTANCE["distance"];
}

Manhattan क्यों नहीं ? क्योंकि इकाइयों के विविध गति पैटर्न हैं (घोड़े जैसा L, विकर्ण, आदि)। सीधी दूरी खतरे का बेहतर अनुमान है।

minimax क्यों नहीं

मैं क्लासिक minimax कोड कर सकता था। लेकिन 12 इकाई प्रकार, अलग गति पैटर्न, विशेष क्षमताओं के साथ... खेल वृक्ष इतनी जल्दी फटता है कि खेलना असंभव हो जाता है। अनुमानी दृष्टिकोण 10 मिलियन अवस्थाएँ खोजे बिना बुद्धिमान चुनाव करता है।

क्या अच्छा है

आकर्षण प्रणाली मजेदार दुविधाएँ बनाती है :

  • Voyant (70) mana बनाता है। अगर तुम उसे जीने देते हो, तो विरोधी के पास ज्यादा संसाधन हैं। लेकिन Titan (95) और भी खतरनाक है।
  • Métamorphe (90) किसी भी इकाई से जगह बदल सकता है। वह तुम्हारा Oracle चुरा सकता है।
  • Harpie (50) का विस्फोटक हमला है जो उसे भी मारता है। वह गौण रहती है... जब तक वह तुम्हारी 3 इकाइयों के बगल में न हो।

AI सिर्फ कच्चे आँकड़ों से नहीं, स्थितियों के अनुसार वैश्विक खतरा मूल्यांकित करता है।

बिना बाजी दोहराए परिदृश्य परखने के लिए activateSimulation() फंक्शन भी है :

activateSimulation() {
    // बोर्ड पर विशिष्ट इकाइयाँ रखता है
    // AI डिबग करने के लिए उपयोगी
    this.game.board = simulation.board;
    this.game.players = simulation.players;
}

क्या कमी है

अगर मेरे पास ज्यादा समय होता :

  • AI वर्तमान स्थिति पर प्रतिक्रिया करता है, वह भविष्यवाणी नहीं करता कि खिलाड़ी क्या करेगा
  • वह कई चालों पर अपना हाथ योजनाबद्ध नहीं करता
  • Métamorphe और Centaure की क्षमताओं का वह कम उपयोग करता है
  • सुदृढ़ीकरण सीखना : गुणांक समायोजित करने के लिए उसे खुद से खेलवाना

लेकिन ब्राउजर गेम के लिए यह चलता है। दोस्त इससे हार जाते हैं, इसलिए मान्य है।

परखो

nausicaa-game.github.io पर उपलब्ध। तुम "JOUER" क्लिक करते हो, CPU मोड ON, और AI को करते देखते हो।

सलाह : AI को खुद से खेलने दो। तुम आक्रामक चरण देखोगे, फिर फुस्स वह सब पीछे हटता है।

कोड GitHub पर js/cpu.js में है।

याद रखने के 3 बिंदु :

  1. अनुमानी गुणांक , minimax नहीं, हर इकाई का आकर्षण है
  2. गुणांक जो हर 5 चाल में बदलते हैं , AI आक्रामकता और नियंत्रण बदलता है, Pac-Man तरह
  3. Oracle भागता है , वह खतरे की विपरीत दिशा गणना करता है और निकल जाता है

अगर AI को और दुष्ट बनाने के विचार हैं, तो issue खोलो। मेरे पास हार से सीखने वाले संस्करण की योजनाएँ हैं, लेकिन वह अगले लेख के लिए होगा।

ذكائي الاصطناعي التافه من أجل Nausicaa

ذكاء اصطناعي بمعاملات استدلالية، ومعلمات فائقة تتغير كل 5 أدوار،

ذكائي الاصطناعي التافه من أجل Nausicaa

هناك مشاريع تبدأ بـ"ماذا لو صنعت لعبة شطرنج بميثولوجيات ؟" وتنتهي بنظام مع ذكاء اصطناعي يقرر معلماته الفائقة الخاصة كل 5 أدوار.

Nausicaa هي ذلك. لعبة رقعة بالأدوار تبني فيها مجموعة مخلوقات ميثولوجية، وتدير mana خاصتك، وتنشر وحدات على رقعة 10x8. وهناك ذكاء اصطناعي تصيبه نوبات شخصية.

قضيت وقتا طويلا على هذا الذكاء، وما زالت النتيجة تفوتني أحيانا، وهذا ما أفضله.

اللعبة فعلا

قبل الكلام عن الدماغ، يجب فهم الجسم :

  • رقعة 10x8، ومنطقة نشر صفين لكل لاعب
  • تبدأ Mana عند 1، و+1 كل دور، وحد 6. تنفق للاستدعاء والهجوم واستخدام القدرات
  • الهدف : قتل Oracle الخصم

12 وحدة، بتكاليف وأنماط حركة مختلفة :

الوحدة Kost الحركة PV
Oracle 0 ملك (8 اتجاهات) 1
Gobelin 1 أمام 3 خانات 1
Harpie 1 ملك (8 اتجاهات) 1
Naïade 1 قطري 1
Griffin 2 قفز خانتين 2
Sirène 2 جانبي 1
Centaure 2 فارس (حرف L) 2
Archer 3 جانبي 1
Phénix 3 قطري (خانات داكنة) 1
Métamorphe 4 تبادل مكان 1
Voyant 4 لا شيء (يولد mana) 1
Titan 6 محدود (هجوم منطقة) 3

لكل وحدة نمط هجوم خاص بها. تضرب Sirène في الأقطار الأربعة، وArcher عن بعد على 3 خانات، ويدمر Titan كل ما حوله عند الاستدعاء. باختصار، لعبة شطرنج بميثولوجيا وبناء مجموعات.

كيف جعلت CPU يفكر

الفكرة الأساسية غبية البساطة : لكل وحدة عدوة معامل جاذبية. كلما كانت أخطر، أراد الذكاء الاهتمام بها أكثر.

const UNITS_ATTRACTIVENESS = {
    "oracle": 100,
    "titan": 95,
    "shapeshifter": 90,
    "phoenix": 80,
    "siren": 70,
    "archer": 70,
    "seer": 70,
    "griffin": 60,
    "centaur": 60,
    "harpy": 50,
    "naiad": 30,
    "gobelin": 20
};

Oracle عند 100، منطقي، إنه شرط الفوز. Titan عند 95 لأنه يقضي على كل ما بجانبه عند الاستدعاء. Gobelin عند 20، إنه جندي مشاة، لا نبالي به.

ثم لكل زوج وحدات (واحدة حليفة وواحدة عدوة)، أحسب :

interet = attractivite × coeff_attract / (distance × coeff_dist)

باختصار : كلما كنت أخطر وأقرب، أراد الذكاء تحطيمك أكثر.

calculateAttackCoefficient(x1, y1, x2, y2) {
    const distance = this.calculateEuclideanDistance(x1, y1, x2, y2);
    if (distance === 0) return Infinity;
    return (UNITS_ATTRACTIVENESS[unit.type] * COEFFICIENTS_IMPORTANCE["attractiveness"]) / distance;
}

حيلة المعاملات المتغيرة

النقطة المهمة أن معاملات الأهمية تتغير عشوائيا كل 5 أدوار.

if (this.turnCount % 5 === 0) {
    const distanceCoefficient = parseInt(Math.random() * 100);
    const attractivenessCoefficient = parseInt(Math.random() * 100);
    this.regulateImportanceCoefficients({
        distance: distanceCoefficient,
        attractiveness: attractivenessCoefficient
    });
}

مرة يصبح الذكاء عدوانيا جدا (جاذبية عند 95 ومسافة عند 5)، فيعبر كل شيء لقتل Oracle خاصتك. والمرة التالية يعطي الأولوية للمسافة ويعيد التموضع.

إنها مسروقة من أشباح Pac-Man، يطارد Blinky ويكمن Pinky. هنا يغيّر الذكاء "شخصيته" كل مرحلة.

النتيجة : مستحيل توقع الذكاء على مباراة كاملة. لا يلعب CPU نفس المباراة مرتين أبدا.

Oracle جبان

يهرب Oracle العدو. حرفيا.

const awayFromTarget = {
    row: Math.max(0, Math.min(7, botUnitElement.row + (botUnitElement.row - targetUnitElement.row))),
    col: Math.max(0, Math.min(9, botUnitElement.col + (botUnitElement.col - targetUnitElement.col)))
};

يحسب الاتجاه المعاكس للتهديد ويهرب. وإذا كان هناك جدار، يبحث عن أقرب خانة حرة في هذا الاتجاه.

تقضي 3 أدوار تقترب من Oracle، وفجأة يكون قد هرب للطرف الآخر.

حلقة القرار

إليكم كيف يقرر الذكاء :

  1. إذا لم يعد لدي Oracle (ميت)، ضع واحدا جديدا
  2. حساب المعامل لكل زوج وحدة حليفة ← وحدة عدوة
  3. اختيار أفضل زوج
  4. إذا استطاعت الوحدة مهاجمة الهدف من موضعها ← هجوم
  5. إذا كان لدي أقل من 4 وحدات ← استدعاء الأرخص المتاح من اليد
  6. وإلا، التحرك نحو الهدف (خانة الحركة الأقرب للعدو)
  7. إذا كان mana كافيا (> 2)، اندفاع (حركة مزدوجة) للاقتراب أكثر
  8. إذا كانت الوحدة Oracle ← هروب
flowchart TD
    A[بداية دور CPU] --> B{Oracle حي ؟}
    B -->|لا| C[وضع Oracle جديد]
    B -->|نعم| D[حساب معاملات\nالهجوم لكل\nزوج وحدات]
    D --> E[اختيار أفضل زوج\nمهاجم ← هدف]
    E --> F{هجوم ممكن\nمن الموضع ؟}
    F -->|نعم| G[مهاجمة الهدف]
    F -->|لا| H{أقل من 4\nوحدات ؟}
    H -->|نعم| I[استدعاء من اليد\nالأرخص المتاح]
    H -->|لا| J[التحرك نحو الهدف\nأقرب خانة]
    J --> K{Mana > 2 ؟}
    K -->|نعم| L[اندفاع : حركة مزدوجة\nنحو الهدف]
    K -->|لا| M[نهاية الدور]
    G --> M
    I --> M
    L --> M
async makeAction(dash=false) {
    // كل ذلك بتسلسل
    // CPU يندفع إذا كان mana كافيا
    if(botPlayer.mana > 2) {
        this.makeAction(true);
    }
}

لماذا المسافة الإقليدية

أستخدم المسافة الإقليدية :

calculateEuclideanDistance(x1, y1, x2, y2) {
    const deltaX = Math.pow(x1 - x2, 2);
    const deltaY = Math.pow(y1 - y2, 2);
    return Math.sqrt(deltaX + deltaY) * COEFFICIENTS_IMPORTANCE["distance"];
}

لماذا لا Manhattan ؟ لأن الوحدات لها أنماط حركة متنوعة (حرف L كالفارس وقطري، إلخ). المسافة بخط الطير تقريب أفضل للخطر.

لماذا لا minimax

كان بإمكاني برمجة minimax تقليدي. لكن مع 12 نوع وحدات وأنماط حركة مختلفة وقدرات خاصة... تنفجر شجرة اللعب بسرعة حتى تصبح غير قابلة للعب. تقوم المقاربة الاستدلالية بخيارات ذكية دون استكشاف 10 ملايين حالة.

ما هو الرائع

يخلق نظام الجاذبية معضلات مضحكة :

  • Voyant (70) يولد mana. إذا تركته حيا، يحصل الخصم على موارد أكثر. لكن Titan (95) أخطر بعد.
  • يستطيع Métamorphe (90) تبادل مكانه مع أي وحدة. يستطيع سرقة Oracle خاصتك.
  • لدى Harpie (50) هجوم متفجر يقتلها أيضا. تبقى ثانوية... حتى تجد نفسها بجانب 3 من وحداتك.

يقيّم الذكاء الخطر الشامل حسب المواضع، لا فقط الإحصاءات الخام.

وهناك أيضا دالة activateSimulation() لاختبار سيناريوهات دون إعادة مباراة :

activateSimulation() {
    // وضع وحدات بعينها على الرقعة
    // مفيد لتصحيح الذكاء
    this.game.board = simulation.board;
    this.game.players = simulation.players;
}

ما الذي ينقص

لو كان لدي وقت أكثر :

  • يتفاعل الذكاء مع الحالة الراهنة، ولا يتوقع ما سيفعله اللاعب
  • لا يخطط ليده على عدة أدوار
  • لدى Métamorphe وCentaure قدرات يستغلها ناقصا
  • تعلم معزز : جعله يلعب ضد نفسه لضبط المعاملات

لكن للعبة متصفح يصمد. أصدقاء يخسرون ضده، إذن مُصادَق عليه.

جرّب

متاح على nausicaa-game.github.io. تنقر "JOUER" ووضع CPU مفعّل، وتشاهد الذكاء يفعل.

نصيحة : دع الذكاء يلعب ضد نفسه. سترى مراحل عدوانية، ثم يتراجع فجأة.

الكود على GitHub في js/cpu.js.

3 نقاط للتذكر :

  1. معاملات استدلالية، لا minimax، ولكل وحدة جاذبية
  2. معاملات تتغير كل 5 أدوار، يبدّل الذكاء العدوانية والتحكم، على طريقة Pac-Man
  3. Oracle يهرب، يحسب الاتجاه المعاكس للتهديد ويهرب

إذا كانت لديك أفكار لجعل الذكاء أخبث بعد، افتح issue. لدي خطط لنسخة تتعلم من هزائمها، لكن ذلك لمقال تالٍ.

AI ngu ngốc của tôi cho Nausicaa

Một AI hệ số heuristic, các siêu tham số đổi

AI ngu ngốc của tôi cho Nausicaa

Có những dự án khởi đầu bằng "hay làm game cờ kiểu thần thoại nhỉ?" và kết thúc bằng hệ thống có AI tự quyết siêu tham số của chính nó mỗi 5 lượt.

Nausicaa là vậy. Game cờ theo lượt nơi bạn xây deck sinh vật thần thoại, quản lý mana, triển khai đơn vị trên bàn 10x8. Và có AI lên cơn đa nhân cách.

Tôi đã dành khá nhiều thời gian cho AI này, và kết quả đôi khi vẫn vượt khỏi tôi, và đó là điều tôi thích nhất.

Game thật

Trước khi nói về bộ não, phải hiểu cơ thể :

  • Bàn 10x8, vùng triển khai 2 hàng cho mỗi người chơi
  • Mana bắt đầu ở 1, +1 mỗi lượt, tối đa 6. Bạn tiêu để triệu hồi, tấn công, dùng kỹ năng
  • Mục tiêu : giết Oracle đối phương

12 đơn vị, chi phí và mẫu di chuyển khác nhau :

Đơn vị Giá Di chuyển Máu
Oracle 0 Vua (8 hướng) 1
Gobelin 1 Tiến 3 ô 1
Harpie 1 Vua (8 hướng) 1
Naïade 1 Chéo 1
Griffin 2 Nhảy 2 ô 2
Sirène 2 Ngang 1
Centaure 2 Mã (chữ L) 2
Archer 3 Ngang 1
Phénix 3 Chéo (ô tối) 1
Métamorphe 4 Đổi chỗ 1
Voyant 4 Không (sinh mana) 1
Titan 6 Hạn chế (tấn công vùng) 3

Mỗi đơn vị có mẫu tấn công riêng. Sirène đánh 4 đường chéo, Archer đánh xa 3 ô, Titan phá mọi thứ xung quanh khi triệu hồi. Tóm lại, game cờ kiểu thần thoại kèm deckbuilding.

Cách tôi khiến CPU suy nghĩ

Ý tưởng nền ngớ ngẩn đơn giản : mỗi đơn vị địch có hệ số hấp dẫn. Nó càng nguy hiểm, AI càng muốn xử lý nó.

const UNITS_ATTRACTIVENESS = {
    "oracle": 100,
    "titan": 95,
    "shapeshifter": 90,
    "phoenix": 80,
    "siren": 70,
    "archer": 70,
    "seer": 70,
    "griffin": 60,
    "centaur": 60,
    "harpy": 50,
    "naiad": 30,
    "gobelin": 20
};

Oracle ở 100 -- hợp lý, đó là điều kiện thắng. Titan ở 95 vì nó oneshot mọi thứ bên cạnh khi triệu hồi. Gobelin ở 20, đó là lính bộ, kệ nó.

Rồi với mỗi cặp đơn vị (một phe ta, một phe địch), tôi tính :

hứng_thú = hấp_dẫn × hệ_số_hấp_dẫn / (khoảng_cách × hệ_số_khoảng_cách)

Đại khái : bạn càng nguy hiểm và gần, AI càng muốn đập bạn.

calculateAttackCoefficient(x1, y1, x2, y2) {
    const distance = this.calculateEuclideanDistance(x1, y1, x2, y2);
    if (distance === 0) return Infinity;
    return (UNITS_ATTRACTIVENESS[unit.type] * COEFFICIENTS_IMPORTANCE["attractiveness"]) / distance;
}

Chiêu hệ số thay đổi

Điểm quan trọng là các hệ số tầm quan trọng đổi ngẫu nhiên mỗi 5 lượt.

if (this.turnCount % 5 === 0) {
    const distanceCoefficient = parseInt(Math.random() * 100);
    const attractivenessCoefficient = parseInt(Math.random() * 100);
    this.regulateImportanceCoefficients({
        distance: distanceCoefficient,
        attractiveness: attractivenessCoefficient
    });
}

Lúc AI cực hiếu chiến (hấp dẫn 95, khoảng cách 5), nó băng qua mọi thứ để giết Oracle của bạn. Lúc sau nó ưu tiên khoảng cách và tái bố trí.

Việc này chôm từ ma Pac-Man -- Blinky săn, Pinky phục kích. Ở đây AI đổi "nhân cách" mỗi pha.

Kết quả : không thể đoán AI suốt cả ván. CPU không bao giờ chơi hai trận giống nhau.

Oracle là kẻ nhát

Oracle địch bỏ chạy. Theo nghĩa đen.

const awayFromTarget = {
    row: Math.max(0, Math.min(7, botUnitElement.row + (botUnitElement.row - targetUnitElement.row))),
    col: Math.max(0, Math.min(9, botUnitElement.col + (botUnitElement.col - targetUnitElement.col)))
};

Nó tính hướng ngược mối đe dọa và chuồn. Nếu có tường, nó tìm ô trống gần nhất theo hướng đó.

Bạn mất 3 lượt áp sát Oracle, và paf nó đã chạy sang đầu bên kia.

Vòng lặp quyết định

Đây là cách AI quyết định :

  1. Nếu tôi mất Oracle (chết), đặt cái mới
  2. Tính hệ số cho mỗi cặp đơn vị phe ta → đơn vị phe địch
  3. Chọn cặp tốt nhất
  4. Nếu đơn vị có thể tấn công mục tiêu từ vị trí của nó → tấn công
  5. Nếu tôi có dưới 4 đơn vị → triệu hồi đơn vị rẻ nhất có từ tay
  6. Ngược lại, di chuyển về mục tiêu (ô di chuyển gần địch nhất)
  7. Nếu đủ mana (> 2), dash (di chuyển đôi) để áp sát thêm
  8. Nếu đơn vị là Oracle → bỏ chạy
flowchart TD
    A[Bắt đầu lượt CPU] --> B{Oracle còn sống?}
    B -->|Không| C[Đặt Oracle mới]
    B -->|Có| D[Tính hệ số\ntấn công cho mỗi\ncặp đơn vị]
    D --> E[Chọn cặp tốt nhất\ntấn công → mục tiêu]
    E --> F{Tấn công được\ntừ vị trí?}
    F -->|Có| G[Tấn công mục tiêu]
    F -->|Không| H{Ít hơn 4\nđơn vị?}
    H -->|Có| I[Triệu hồi từ tay\nđơn vị rẻ nhất có]
    H -->|Không| J[Di chuyển về mục tiêu\nô gần nhất]
    J --> K{Mana > 2?}
    K -->|Có| L[Dash : di chuyển đôi\nvề mục tiêu]
    K -->|Không| M[Kết thúc lượt]
    G --> M
    I --> M
    L --> M
async makeAction(dash=false) {
    // tất cả nối tiếp
    // CPU dash nếu đủ mana
    if(botPlayer.mana > 2) {
        this.makeAction(true);
    }
}

Vì sao khoảng cách Euclid

Tôi dùng khoảng cách Euclid :

calculateEuclideanDistance(x1, y1, x2, y2) {
    const deltaX = Math.pow(x1 - x2, 2);
    const deltaY = Math.pow(y1 - y2, 2);
    return Math.sqrt(deltaX + deltaY) * COEFFICIENTS_IMPORTANCE["distance"];
}

Vì sao không phải Manhattan? Vì các đơn vị có mẫu di chuyển đa dạng (L như mã, chéo, v.v.). Khoảng cách chim bay là xấp xỉ nguy hiểm tốt hơn.

Vì sao không minimax

Tôi có thể code minimax cổ điển. Nhưng với 12 kiểu đơn vị, mẫu di chuyển khác nhau, kỹ năng đặc biệt... cây game nổ nhanh tới mức không chơi được. Cách heuristic đưa ra lựa chọn thông minh mà không duyệt 10 triệu trạng thái.

Điều hay

Hệ thống hấp dẫn tạo ra các thế khó vui :

  • Voyant (70) sinh mana. Nếu bạn để nó sống, đối thủ có thêm tài nguyên. Nhưng Titan (95) còn nguy hiểm hơn.
  • Métamorphe (90) có thể đổi chỗ với bất kỳ đơn vị nào. Nó có thể cướp Oracle của bạn.
  • Harpie (50) có đòn nổ tự sát. Nó vẫn thứ yếu... tới khi nó đứng cạnh 3 đơn vị của bạn.

AI đánh giá nguy hiểm tổng thể theo vị trí, không chỉ stat thô.

Ngoài ra có hàm activateSimulation() để test kịch bản mà không chơi lại ván :

activateSimulation() {
    // Đặt các đơn vị cụ thể lên bàn
    // Hữu ích để gỡ lỗi AI
    this.game.board = simulation.board;
    this.game.players = simulation.players;
}

Điều thiếu

Nếu tôi có thêm thời gian :

  • AI phản ứng với trạng thái hiện tại, nó không đoán điều người chơi sẽ làm
  • Nó không lập kế hoạch tay bài nhiều lượt
  • Métamorphe và Centaure có kỹ năng nó khai thác chưa hết
  • Học tăng cường : cho nó tự đấu với chính nó để chỉnh hệ số

Nhưng với game trình duyệt thế là ổn. Bạn bè tôi thua được nó, nên coi như đạt.

Thử

Có trên nausicaa-game.github.io. Bạn bấm "JOUER", bật CPU mode ON, và xem AI làm.

Lời khuyên : để AI tự đấu với chính nó. Bạn sẽ thấy các pha hiếu chiến, rồi pfft nó rút hết.

Mã trên GitHub trong js/cpu.js.

3 điểm cần nhớ :

  1. Hệ số heuristic -- không minimax, mỗi đơn vị có độ hấp dẫn
  2. Hệ số đổi mỗi 5 lượt -- AI luân phiên hiếu chiến và kiểm soát, kiểu Pac-Man
  3. Oracle bỏ chạy -- nó tính hướng ngược mối đe dọa và chuồn

Nếu bạn có ý tưởng khiến AI nham hiểm hơn, hãy mở issue. Tôi có kế hoạch cho phiên bản học từ thất bại, nhưng đó sẽ là bài sau.

>-

>-

เอไอของฉันที่โง่สำหรับ Nausicaa

มีโครงการที่เริ่มต้นด้วย “เอ๊ะ ถ้าฉันทำเกมหมากรุกกับตำนานต่าง ๆ” และสิ้นสุดด้วยระบบที่มีเอไอที่ตัดสินค่าพารามิเตอร์สูงของตัวเองทุก ๆ 5 เทิร์น.

นาซิไซอา นั่นแหละ เหมือนเกมกระดานแบบเดินเปลี่ยนเป็นสลับที่คุณสร้างดัก์ของสิ่งมีชีวิตตำนาน, คุณจัดการมานา, คุณประจุหน่วยบนกระดาน 10x8. และมีเอไอที่มีปัญหาสุขภาพจิต.

ฉันใช้เวลาหลายชั่วโมงกับเอไอนี้ ผลลัพธ์บางครั้งยังคงหลุดไป และฉันชอบแบบนั้น

เกมจริง

ก่อนที่จะพูดถึงสมอง, ต้องเข้าใจร่างกาย:

  • กระดาน 10x8, โซนประจุน 2 แถวต่อผู้เล่น
  • มานาเริ่มที่ 1, เพิ่ม 1 ต่อเทิร์น, สูงสุด 6. คุณใช้จ่ายเพื่อเรียก, โจมตี, ใช้ความสามารถ
  • เป้าหมาย: ฆ่า Oracle ของฝ่ายตรงข้าม

12 หน่วย, ค่าใช้จ่ายและรูปแบบการเคลื่อนที่ต่าง ๆ :

หน่วย ค่าใช้จ่าย ความเคลื่อนไหว HP
โอรเคิล 0 ราชา (8 ทิศ) 1
กาบลิน 1 เดินหน้า 3 ช่อง 1
ฮาร์ปี้ 1 ราชา (8 ทิศ) 1
นาไอยด์ 1 ทางทแยง 1
กรีฟฟิน 2 กระโดด 2 ช่อง 2
ซิเรน 2 ข้างเคียง 1
เซนตัวร์ 2 ขี่ (รูป L) 2
อาร์เชอร์ 3 ข้างเคียง 1
เฟนิกซ์ 3 ทแยง (ช่องมืด) 1
เมตามอร์เฟ 4 แลกตำแหน่ง 1
โวอียน 4 ไม่มี (สร้างมานา) 1
ไททัน 6 จำกัด (โจมตี

แต่ละหน่วยมีรูปแบบการโจมตีของตนเอง. Sirène โค่นใน 4 แนวทแยง, Archer ยิงจากระยะไกลใน 3 ช่อง, Titan ทำลายทุกสิ่งรอบๆ เมื่อนำมาเรียก. สรุปแล้ว เป็นเกมหมากรุกผสมเทพนิยายและการสร้างชุดไพ่.

วิธีที่ฉันทำให้ CPU คิด

แนวคิดหลักธรรมดาที่ตลกขนาด: แต่ละหน่วยศัตรูมีค่าความดึงดูด. ยิ่งมันอันตรายมากเท่าไหร่ AI ก็อยากจัดการกับมันมากขึ้น

const UNITS_ATTRACTIVENESS = {
    "oracle": 100,
    "titan": 95,
    "shapeshifter": 90,
    "phoenix": 80,
    "siren": 70,
    "archer": 70,
    "seer": 70,
    "griffin": 60,
    "centaur": 60,
    "harpy": 50,
    "naiad": 30,
    "gobelin": 20
};

Oracle มีค่า 100 -- ลอจิกนั้นคือเงื่อนไขชนะ. Titan มีค่า 95 เพราะเขา OS ทุกอย่างข้าง ๆ การเรียก. Gobelin มีค่า 20, มันเป็นกองทัพฐาน, เราไม่สนใจ

interet = attractivite × coeff_attract / (distance × coeff_dist)

สรุป: ยิ่งคุณอันตรายและใกล้ชิดมากขึ้น, ยิ่ง AI จะอยากทำลายคุณ.

calculateAttackCoefficient(x1, y1, x2, y2) {
    const distance = this.calculateEuclideanDistance(x1, y1, x2, y2);
    if (distance === 0) return Infinity;
    return (UNITS_ATTRACTIVENESS[unit.type] * COEFFICIENTS_IMPORTANCE["attractiveness"]) / distance;
}

เคล็ดลับของค่าตัวกำหนดที่เปลี่ยนแปลง

ประเด็นสำคัญคือค่าตัวกำหนดความสำคัญ เปลี่ยนแบบสุ่มทุก 5 วง.

if (this.turnCount % 5 === 0) {
    const distanceCoefficient = parseInt(Math.random() * 100);
    const attractivenessCoefficient = parseInt(Math.random() * 100);
    this.regulateImportanceCoefficients({
        distance: distanceCoefficient,
        attractiveness: attractivenessCoefficient
    });
}

Un coup l'IA va hyper agressive (attract à 95, distance à 5), elle traverse tout pour buter ton Oracle. Le coup d'après elle priorise la distance et se repositionne.

C'est piqué aux fantômes de Pac-Man -- Blinky chasse, Pinky embusque. Ici l'IA change de "personnalité" toutes les phases.

Résultat : impossible de prédire l'IA sur une partie entière. Le CPU fait jamais deux fois le même match.

L'Oracle est une lopette

L'Oracle ennemi fuit. Littéralement.

const awayFromTarget = {
    row: Math.max(0, Math.min(7, botUnitElement.row + (botUnitElement.row - targetUnitElement.row))),
    col: Math.max(0, Math.min(9, botUnitElement.col + (botUnitElement.col - targetUnitElement.col)))
};

เขาคำนวณทิศทางตรงข้ามกับอันตรายและลุยออกไป ถ้ามีกำแพงฉันจะหาเซลล์ว่างที่ใกล้ที่สุดในทิศทางนั้น

คุณใช้เวลา 3 เทิร์นเพื่อเข้าใกล้ออแลกส์ แล้วอืม หนีไปด้านหนึ่งแล้ว

วนลูปรับการตัดสินใจ

นี่คือวิธีที่ปัญญาประดิษฐ์ตัดสินใจ :

  1. ถ้าฉันมีอัลคล็อคที่ตายหลายตัว ให้สร้างอัลคล็อคใหม่
  2. คำนวณค่าสัมประสิทธิ์สำหรับแต่ละคู่ยูนิตพันธมิตร → ยูนิตศัตร
mermaid diagram
async makeAction(dash=false) {
    // ทั้งหมดนี้เป็นลำดับ
    // CPU dash ถ้ามีมานาเพียงพอ
    if(botPlayer.mana > 2) {
        this.makeAction(true);
    }
}

ทำไมต้องใช้ระยะห่างยูคลิด

ฉันใช้ระยะห่างยูคลิด :

calculateEuclideanDistance(x1, y1, x2, y2) {
    const deltaX = Math.pow(x1 - x2, 2);
    const deltaY = Math.pow(y1 - y2, 2);
    return Math.sqrt(deltaX + deltaY) * COEFFICIENTS_IMPORTANCE["distance"];
}

ทำไมไม่ใช้ Manhattan? เพราะหน่วยมีรูปแบบการเคลื่อนที่หลากหลาย (รูป L เช่น ชี่พนง, แนวทแยง ฯลฯ) ระยะทางระหว่างจุดเป็นการประมาณความเสี่ยงที่ดีกว่า

ทำไมไม่ใช้ Minimax

ฉันอาจเขียน Minimax แบบคลาสสิกได้ แต่ด้วยหน่วย 12 ประเภท, แบบเคลื่อนที่ต่าง ๆ, ความสามารถพิเศษ... ต้นไม้เกมระเบิดออกเร็วจนมากเกินไปจึงไม่ได้น่าเล่น วิธีเฮอร์วิสติกทำการเลือกอย่างฉลาดโดยไม่ต้องสำรวจ 10 ล้านสถานะ

สิ่งที่น่าตื่นเต้น

  • ผู้มอง (70) สร้างมานา ถ้าคุณปล่อยให้มันอยู่ต่อ ผู้เล่นฝ่ายตรงข้ามจะมีทรัพยากรมากขึ้น แต่ไททัน (95) เป็นอันตรายยิ่งกว่า

  • เมทาโมฟ์ (90) สามารถแลกเปลี่ยนตำแหน่งกับหน่วยใด ๆ ได้ เขาอาจขโมยโอโรคของคุณ

  • ฮาร์ปี้ (50) มีการโจมตีระเบิดที่ทำให้ตัวมันก็เสียชีวิตด้วย แต่ยังมีบทบาทรอง... จนกว่ามันจะอยู่ข้าง ๆ หน่วยของคุณ 3 ตัว

ระบบ AI ประเมินความเสี่ยงโดยรวมตามตำแหน่ง ไม่ใช่แค่สถิติเข้เปรียบ

ยังมีฟังก์ชัน activateSimulation() เพื่อทดสอบสถานการณ์โดยไม่ต้องเล่นใหม่ :

activateSimulation() {
    // วางหน่วยเฉพาะบนกระดาน
    // ใช้เพื่อ debug AI
    this.game.board = simulation.board;
    this.game.players = simulation.players;
}

สิ่งที่ขาดหายไป

ถ้าฉันมีเวลามากขึ้น :

  • ปัญญาประดิษฐ์ตอบสนองต่อสถานะปัจจุบัน แต่ไม่ทำนายสิ่งที่ผู้เล่นจะทำ
  • ไม่วางแผนมือของมันบนหลายเทิร์น
  • เมตามอร์ฟและเซนทาอุเรมีมีความสามารถที่ฉันยังไม่ใช้เต็มที่
  • การเรียนรู้แบบพลังกำลังให้: ทำให้มันเล่นต่อตัวเองเพื่อปรับค่าความสัมพันธ์

แต่สำหรับเกมในบราวเซอร์ก็ยังครอบได้ ร่างกายผู้เล่นบางคนกำลังได้พ่าย ถ้าใช่ก็ได้ยืนยันแล้ว

ทดสอบ

พร้อมใช้งานที่ nausicaa-game.github.io. คุณคลิก "เล่น", เปิดโหมด CPU แล้วดูปัญญาประดิษฐ์ทำงาน

คำแนะนำ : ให้ปัญญาประดิษฐ์เล่นต่อตัวเอง คุณจะเห็นช่วงที่กระทำอย่างบุกเร้า จากนั้นเสียงแพร่หลังแต่ละแรง

โค้ดอยู่ที่ GitHub ไว้ใน js/cpu.js

3 จุดสำคัญที่ต้องจำ :

  1. ค่าความสัมพันธ์แบบอัลกอริทึม -- ไม่มี minimax แต่ทุกยูนิตมีค่าที่น่าดึงดูด
  2. ค่าความสัมพันธ์ที่เปลี่ยนทุก 5 เทิร์น -- ปัญญาประดิษฐ์สลับระหว่างความหวาดกลัวและการควบคุม เหมือน Pac‑Man
  3. ออราเคิลหลบหนี -- มันคำนวณทิศทางตรงกันข้ามกับอันตรายแล้วก้าวออก

ถ้าคุณมีความคิดเพื่อให้ปัญญาประดิษฐ์หยิ่งยิ่งขึ้น ให้เปิดประเด็นใหม่ ฉันมีแผนสำหรับเวอร์ชันที่เรียนรู้จากความพ่ายแพ้ของมัน แต่จะเป็นเรื่องของบทความถัดไปเท่านั้น

Related Articles