Files
Hugo/content/blog/prolog-pokemon.md
T

410 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: "用宝可梦解释 Prolog 基础"
date: 2026-01-18T13:30:00+08:00
draft: false
description: "通过宝可梦对战系统理解逻辑编程语言 Prolog 的核心概念——事实、规则、查询,以及它为什么比 SQL 和电子表格更灵活。"
tags:
- Prolog
- 宝可梦
- 编程语言
- 翻译
poet: |
众里寻他千百度,
蓦然回首,那人却在,灯火阑珊处。
——辛弃疾,中国,1140–1207
---
> 原文作者:Alexander Petros
> 原文链接:[Prolog Basics Explained with Pokémon](https://unplannedobsolescence.com/blog/prolog-basics-pokemon/)
> 本文已获授权翻译,有删节和注释。
启发这篇文章的项目有点傻——我要详细描述一个儿童电子游戏的机制——但正是这个特定问题最终让我真正理解了 Prolog。这是自从读了 Bruce Tate 的《七周七语言》以来一直在追寻的顿悟。
对于某些类型的关系,逻辑编程是我用过的最简洁、最具表现力的编程系统。要理解为什么,让我们来聊聊宝可梦。
## 宝可梦基础
宝可梦是一个电子游戏系列,设定在一个人类与各种色彩缤纷的动物角色共存的世界。"Pokémon" 既是系列名称,也是这些动物角色的统称,每个角色都有自己的物种名称。从妙蛙种子(#1)到桃歹郎(#1025),共有超过一千种不同的宝可梦物种。
<div style="display:flex;gap:8px;justify-content:center;margin:1rem 0"><img src="/images/pokemon/pikachu.png" alt="皮卡丘" style="width:32%"><img src="/images/pokemon/archeops.png" alt="始祖大鸟" style="width:32%"><img src="/images/pokemon/dipplin.png" alt="裹蜜虫" style="width:32%"></div>
{{< caption >}}受欢迎的宝可梦包括(从左到右):皮卡丘(#25)、始祖大鸟(#567)和裹蜜虫(#1101)。{{< /caption >}}
现在有各种各样的宝可梦游戏,但主系列始终围绕着捕捉和对战。在对战中,你的六只宝可梦队伍与另一支队伍交锋。每只宝可梦配备四个招式,通常用来对对手造成伤害。你需要将对方所有宝可梦的 HP 减到零,同时防止对方先对你做同样的事。
每只宝可梦都有独特的特性影响对战表现:基础能力值、可学的招式、特性和属性组合。这里的庞大组合数量正是试图用软件来追踪这些信息的动机。
![巨钳螳螂的能力值(来自 Smogon)](/images/pokemon/scizor-stats.png)
{{< caption >}}巨钳螳螂是虫/钢属性,攻击力高但速度低(数据来自 Smogon)。{{< /caption >}}
**速度**决定哪个招式先出手;**攻击和特攻**分别影响物理招式和特殊招式的伤害;**防御和特防**影响受到的伤害。
**属性**尤其重要。招式有属性(如火或岩石),宝可梦可以拥有最多两种属性。如果招式属性对对方宝可梦效果绝佳,造成双倍伤害;效果不佳则只造成一半伤害。
![冲浪对月石效果绝佳](/images/pokemon/surf.png)
{{< caption >}}月石是岩石/超能力属性。岩石弱水,超能力对水中性,所以冲浪会造成 2 倍伤害。{{< /caption >}}
举个直观的例子:火属性的喷射火焰对草属性宝可梦造成 2 倍伤害(草弱火),但水属性的冲浪只造成 ½ 伤害(草抗水)。
属性修正可以叠加。巨钳螳螂是虫/钢属性,虫和钢都弱火,所以火属性招式对它造成 4 倍伤害。电弱水,但地面免疫电——如果你对水/地面的巨沼怪使用电属性招式,伤害为零,因为 0×2 还是 0。
![宝可梦属性相克表(来自 Wikimedia)](/images/pokemon/type-chart.svg)
{{< caption >}}宝可梦属性相克表。{{< /caption >}}
这些基本上就是我 8 岁时理解的宝可梦机制。点击招式造成伤害,尽量选择属性相克有利的招式。这些游戏是为儿童设计的,表面上看并不难。
## Prolog 基础
在解释宝可梦机制底层有多复杂之前,先解释逻辑编程的工作原理。宝可梦非常适合逻辑编程,因为宝可梦对战本质上是一个极其精密的规则引擎。
首先创建一个包含事实的文件:
```prolog
pokemon(bulbasaur).
pokemon(ivysaur).
pokemon(venusaur).
pokemon(charmander).
pokemon(charmeleon).
pokemon(charizard).
pokemon(squirtle).
pokemon(wartortle).
pokemon(blastoise).
```
在 Prolog 中,我们声明"谓词"(Predicate)。谓词定义关系:`bulbasaur` 是一个 `pokemon`,`charmander` 是一个 `pokemon`,以此类推。我们称这个谓词为 `pokemon/1`,因为它有一个参数。
这些事实被加载到一个交互式提示符——"顶层"(top-level)中。你输入一个语句到提示符,Prolog 试图找出所有使该语句为真的方式:
```
?- pokemon(squirtle).
true.
```
不是所有东西都是宝可梦。
```
?- pokemon(alex).
false.
```
接着添加宝可梦的属性,作为谓词 `type/2`:
```prolog
type(bulbasaur, grass).
type(bulbasaur, poison).
type(ivysaur, grass).
type(ivysaur, poison).
type(charmander, fire).
type(squirtle, water).
type(blastoise, water).
```
有些宝可梦只有一种属性,有些有两种。后一种情况用两个 `type` 事实建模。妙蛙种子是草属性,也是毒属性——两者都为真。
交互式查询:
```
?- type(squirtle, water).
true.
?- type(squirtle, grass).
false.
```
Prolog 中首字母大写的名字是**变量**。Prolog 尝试将谓词与变量的所有可能匹配进行"合一":
```
?- type(squirtle, Type).
Type = water.
```
对于有两种属性的宝可梦,谓词会合一两次:
```
?- type(venusaur, Type).
Type = grass
; Type = poison.
```
分号 `;` 表示"或"。任何参数都可以是变量,这意味着我们可以从任意方向提问。所有草属性宝可梦有哪些?只需把第一个参数设为变量:
```
?- type(Pokemon, grass).
Pokemon = bulbasaur
; Pokemon = ivysaur
; Pokemon = venusaur
; ...
```
逗号用于列出多个谓词——Prolog 会合一变量使得所有谓词都为真。列出所有水/冰属性的宝可梦:
```
?- type(Pokemon, water), type(Pokemon, ice).
Pokemon = dewgong
; Pokemon = cloyster
; Pokemon = lapras
; Pokemon = ironbundle
; false.
```
Iron Bundle 是一只水/冰属性宝可梦,特攻很高。具体多高?
```
?- pokemon_spa(ironbundle, SpA).
SpA = 124.
```
特攻这么高,我们想利用强力的特殊招式。Iron Bundle 会哪些特殊招式?
```
?- learns(ironbundle, Move), move_category(Move, special).
Move = blizzard
; Move = freezedry
; Move = hydropump
; Move = icebeam
; Move = icywind
; ...
```
Freeze-Dry 是一个特别好的特殊招式。找出所有特攻大于 120、会 Freeze-Dry 的冰属性宝可梦:
```
?- pokemon_spa(Pokemon, SpA), SpA #> 120, learns(Pokemon, freezedry), type(Pokemon, ice).
Pokemon = glaceon, SpA = 130
; Pokemon = kyurem, SpA = 130
; Pokemon = kyuremwhite, SpA = 170
; Pokemon = ironbundle, SpA = 124.
```
最后一个概念:**规则**(Rules)。规则有头部和主体,如果主体为真,则规则合一:
```prolog
damaging_move(Move) :-
move_category(Move, physical)
; move_category(Move, special).
```
```
?- damaging_move(tackle).
true.
?- damaging_move(rest).
false.
```
## SQL 对比
到目前为止展示的内容在逻辑上并不复杂——只是关于各种事实的"与"和"或"语句。不过请花一点时间体会一下,查询这个数据库比 SQL 舒服多少。
我会这样设置 SQL 表:
```sql
CREATE TABLE pokemon (pokemon_name TEXT, special_attack INTEGER);
CREATE TABLE pokemon_types(pokemon_name TEXT, type TEXT);
CREATE TABLE pokemon_moves(pokemon_name TEXT, move TEXT, category TEXT);
```
然后查询特攻 > 120、会 Freeze-Dry 的冰属性宝可梦:
```sql
SELECT DISTINCT pokemon, special_attack
FROM pokemon as p
WHERE p.special_attack > 120
AND EXISTS (SELECT 1 FROM pokemon_moves as pm
WHERE p.pokemon_name = pm.pokemon_name AND move = 'freezedry')
AND EXISTS (SELECT 1 FROM pokemon_types as pt
WHERE p.pokemon_name = pt.pokemon_name AND type = 'ice');
```
对比等效的 Prolog 查询:
```
?- pokemon_spa(Pokemon, SpA), SpA #> 120, learns(Pokemon, freezedry), type(Pokemon, ice).
```
我不是在贬低 SQL——我很喜欢 SQL——但 Prolog 版本简单和灵活得令人惊叹。如果我们继续添加更多条件,SQL 查询会变得难以管理,而 Prolog 查询仍然易于阅读和编辑。
## 升级
宝可梦对战拥有数量惊人的机制,以复杂且概率性的方式相互影响。我尚未提及的部分机制:
- 某些招式有一定概率落空
- 某些招式会提升或降低能力值
- 宝可梦可以携带具有各种效果的物品
- 伤害计算不是恒定的,而是呈正态分布
- 宝可梦可以被冰冻、灼伤、麻痹、中毒或陷入睡眠
- 各种场地效果(天气、戏法空间等)会改变招式伤害和出手顺序
- 每只宝可梦都有一个特性(如漂浮免疫地面招式、降雨改变天气、强行增伤 1.3 倍)
- 玩家分配努力值提升选定能力值
如果你想为这个游戏构建软件,挑战在于在所有复杂性的建模过程中不疯掉。Prolog 在这方面出奇地擅长,主要有两个原因:
- 查询模型擅长描述临时组合
- 数据模型非常适合以一致的方式分层叠加规则
为了说明这一点,以下是我为宝可梦选秀联赛实现优先度招式的过程。
宝可梦选秀大致就是字面意思。宝可梦根据实力被赋予分值,每个玩家获得一定分数,轮流选直到用完。你的队伍最终会有大约 8-11 只宝可梦,每周你和联赛中的另一个人正面交锋。
我定义的队伍:
```prolog
alex(meowscarada).
alex(weezinggalar).
alex(swampertmega).
alex(latios).
alex(volcarona).
alex(tornadus).
alex(politoed).
alex(archaludon).
alex(beartic).
alex(dusclops).
```
我的宝可梦里有哪些会 Freeze-Dry?
```
?- alex(Pokemon), learns(Pokemon, freezedry).
false.
```
一个都没有。唉。
非常重要的一类招式为**优先度招式**。大多数招式的优先度为零:
```
?- move_priority(Move, P).
Move = absorb, P = 0
; Move = accelerock, P = 1
; Move = acid, P = 0
; ...
```
Accelerock 的优先度为 1。使用它的宝可梦会先于任何使用优先度 0 招式的宝可梦行动,即使后者速度更高。
我定义一个 `learns_priority/3` 谓词:
```prolog
learns_priority(Pokemon, Move, P) :-
learns(Pokemon, Move),
move_priority(Move, P),
P #> 0.
```
"我的队伍学会了哪些优先度招式"——返回了大量答案,但很多是双打专用招式。过滤掉双打招式和保护招式:
```prolog
learns_priority(Mon, Move, Priority) :-
learns(Mon, Move),
\+ doubles_move(Move),
\+ protection_move(Move),
Move \= bide,
move_priority(Move, Priority),
Priority #> 0.
doubles_move(helpinghand).
doubles_move(allyswitch).
doubles_move(ragepowder).
protection_move(detect).
protection_move(protect).
protection_move(endure).
protection_move(magiccoat).
```
搞定!结果精简为真正有用的优先度招式:
```
?- alex(Pokemon), learns_priority(Pokemon, Move, Priority).
Pokemon = meowscarada, Move = quickattack, Priority = 1
; Pokemon = meowscarada, Move = suckerpunch, Priority = 1
; Pokemon = beartic, Move = aquajet, Priority = 1
; Pokemon = dusclops, Move = shadowsneak, Priority = 1
; Pokemon = dusclops, Move = suckerpunch, Priority = 1.
```
优先度招式的缺乏实际上是我的队伍的一大弱点。
同样地查询对手的优先度招式也非常有用:
```
?- morry(Pokemon), learns_priority(Pokemon, Move, Priority).
Pokemon = mawilemega, Move = suckerpunch, Priority = 1
; Pokemon = walkingwake, Move = aquajet, Priority = 1
; ...
```
此时,Morry 提了一个挑战:恶作剧之心特性的宝可梦,其变化招式额外获得 +1 优先度。能否在规则中体现?
我的队伍恰好有一只这样的宝可梦——Tornadus。使用 Prolog 的 if/then 结构 `->/2`,三分钟搞定:
```prolog
learns_priority(Mon, Move, Priority) :-
learns(Mon, Move),
\+ doubles_move(Move),
\+ protection_move(Move),
Move \= bide,
move_priority(Move, BasePriority),
(
pokemon_ability(Mon, prankster), move_category(Move, status) ->
Priority #= BasePriority + 1
; Priority #= BasePriority
),
Priority #> 0.
```
现在查询会包含 Tornadus 所有变化招式的提升后优先度。
## 与电子表格的比较
宝可梦社区已经有类似的工具,构建在最优秀的编程界面上:朴素的电子表格。
![Techno 的准备文档](/images/pokemon/matchup-sheet.png)
{{< caption >}}Techno 的准备文档,一份功能强大的 Google Sheets。{{< /caption >}}
我使用一份被称为"Techno's Prep Doc"的 Google Sheets,输入队伍后自动生成海量对阵信息。
我好奇它的优先度招式查找公式是怎样的。
![优先级招式后端列表](/images/pokemon/priority-backend-list.png)
{{< caption >}}电子表格后端的硬编码招式列表。{{< /caption >}}
结果是——相当复杂:
```
={IFERROR(ARRAYFORMULA(VLOOKUP(FILTER(INDIRECT(Matchup!$S$3&"!$AV$4:$AV"),
INDIRECT(Matchup!$S$3&"!$AT$4:$AT")="X"),
{Backend!$L$2:$L,Backend!$F$2:$F},2,FALSE))),
IFERROR(FILTER(INDIRECT(Matchup!$S$3&"!$AW$4:$AW"),
INDIRECT(Matchup!$S$3&"!$AT$4:$AT")="X"))}
```
Prolog 的范式显然更具可扩展性。电子表格后端是一个硬编码的显要招式列表,而我的数据库可以查找**任何**招式。这个查询——找出 Tornadus 学会的、对 Justin 队伍中任何成员效果绝佳的特殊招式——在现有工具中根本不存在。我只用了 30 秒就拼出来了:
```
?- justin(Target), learns(tornadus, Move),
super_effective_move(Move, Target), move_category(Move, special).
Target = charizardmegay, Move = chillingwater
; Target = scizor, Move = heatwave
; Target = scizor, Move = incinerate
; Target = screamtail, Move = sludgebomb
; ...
```
## 最后的思考
这个项目始于一个玩笑——"如果我拿 Prolog 来做宝可梦组队会怎样"——但它教给我的逻辑编程知识比任何教科书都多。
我最大的领悟是:**Prolog 迫使你思考什么是真的,而不是做什么。** 在命令式编程中,大部分精力花在控制流上。在 Prolog 中,你只需陈述什么是真的,让求解器处理剩下的事。
对于一个有着数千条互动规则的规则引擎(比如宝可梦对战),这简直是天启。与其写 if/else 链检查属性相克,不如声明属性表,让 Prolog 处理其余部分。
如果你感兴趣,推荐:
1. 安装 SWI-Prolog 并玩玩 [prologdex](https://github.com/alexpetros/prologdex) 数据集
2. 阅读 [Adventure in Prolog](http://www.amzi.com/AdventureInProlog/) 教程
3. 用 Prolog 对你熟悉的领域建模——它会彻底改变你对数据和关系的思考方式
祝编码愉快,愿你的属性相克永远是效果绝佳。