纯前端让 AI 学会玩贪吃蛇,不使用任何库

引言

还记得小时候在诺基亚上玩得不亦乐乎的贪吃蛇吗?那个简单却又魔性的游戏,一不小心就撞墙或者咬到自己

现在,我将用 10 分钟时间让你学会 AI 自学习玩贪吃蛇代码,而且越玩越6,是不是很帅

代码直接发,不搞花里胡哨的

"机器学习"和"强化学习"是啥?听起来好高级!

别怕,咱们用大白话聊聊。

  • 人工智能 (AI):这个大家应该都听过,就是让机器模仿人的智能,能思考、能学习。比如 Siri、小爱同学,或者下棋的 AlphaGo。
  • 机器学习 (Machine Learning, ML) :这是 AI 的一种实现方式。不是我们写死规则告诉机器"如果这样就这样",而是让机器自己从数据中"学习"规律。就像你看多了猫猫狗狗的照片,下次就能认出新的猫狗一样。
  • 强化学习 (Reinforcement Learning, RL) :这是机器学习的一个分支,特别有意思。它模仿的是我们(或者小动物)学习新技能的方式------试错

想象一下你在训练一只小狗学握手。

  • 当它碰巧抬起爪子,你就给它一块零食(狠狠地奖励 Reward)。
  • 如果它乱动或者咬你,你可能会说"不行"(狠狠地惩罚 Penalty,或者说负奖励)。
  • 小狗不知道"握手"指令一开始是啥意思,它只是瞎尝试 (Action) 各种动作。
  • 慢慢地,它发现在某个情况 (State) 下(比如你伸出手说"握手"),做出"抬爪子"这个动作 (Action) 能得到好吃的(奖励 Reward),它就会更倾向于做这个动作。

强化学习就是这个原理。

  1. 试试看:随便走一步,看看会怎么样。
  2. 得反馈:如果吃到食物,就很开心(得奖励);如果撞墙了,就很疼(受惩罚)。
  3. 记下来:把这次经验记在小本本上,下次遇到类似情况就知道该怎么办。

AI 在一个环境 (Environment) (比如贪吃蛇游戏)中,观察当前的状态 (State) (蛇头在哪?食物在哪?周围有没有危险?),尝试做出一个动作 (Action) (向上、向下、向左、或向右),然后环境会给它一个奖励 (Reward)(吃到食物 +10分!撞墙 -10分!离食物近了 +0.1分!)。

AI 的目标就是学会一套策略 (Policy) ,在不同的状态下选择能获得最大总奖励的动作。

我们今天的主角------Q-Learning------就是强化学习中的一种著名算法。它厉害的地方在于,它会学习一个叫做 "Q-table" 的东西,其实就是一个映射表罢了。


Q-Learning 和 "Q 表":AI 的小抄秘籍

Q-Learning 的核心思想是学习一个"动作价值函数 "(Action-Value Function),我们通常叫它 Q 函数

它特别适合贪吃蛇这种游戏。Q-learning的核心就是那个Q表,里面存了每个状态下每个动作的"价值"。比如,蛇头前面有墙,往左转可能是0分(不好),往右转可能是10分(很好),AI就会选右转。

听起来是不是很牛逼?其实很简单

你可以把 Q 函数想象成一张巨大的备忘录 或者小抄 ,我们叫它 Q 表 (Q-Table)

  • 这张表的 代表了所有可能遇到的状态 (State)。在贪吃蛇里,一个状态可能就是:"蛇头上方是墙,右边是身体,食物在左下方,当前蛇向右走"。(当然,为了让电脑理解,我们会把它数字化,后面代码里会看到)。
  • 这张表的 代表了所有可以采取的动作 (Action):上、下、左、右。
  • 表里的每个格子 Q(s, a) 里的数值 ,就代表在状态 s 下,采取动作 a预计未来能获得的总奖励有多好。这个数值越高,说明这个动作在这个状态下越"划算"。

(一个极其简化的 Q 表示意图)

状态 (State) 向上 (Q值) 向下 (Q值) 向左 (Q值) 向右 (Q值)
"前面是墙,食物在右边" -100 5 3 15
"前面没路,食物在上面" 20 -5 -100 -100
... ... ... ... ...

Q-learning有个公式,用来更新Q表

  • Q(s,a) = Q(s,a) + α [r + γ max(Q(s',a')) - Q(s,a)]
  • 新 Q 值 = 旧 Q 值 + 学习率 * ( 当前奖励 + 折扣因子 * (下一个状态最好的 Q 值) - 旧 Q 值 )
  • 学习率 (Learning Rate, α):表示 AI 对新学到的知识有多"信任"。值越大,越容易被新经验改变;值越小,越"固执",更相信过去的经验。
  • 折扣因子 (Discount Factor, γ):表示 AI 对"未来奖励"的重视程度。值越接近 1,说明 AI 越有"远见",会为了未来的大奖励而放弃眼前的小奖励;值越接近 0,说明 AI 越"短视",只关心眼前的奖励。

别怕这个公式,我来解释得简单点:

  • 现在状态:比如蛇头在哪儿,食物在哪儿。
  • 动作:往上走、往下走之类。
  • 奖励:吃到食物得 +10,撞墙得 -10。
  • 未来最大价值:AI想象下一步能拿到的最好成绩。
  • 学习速度:控制AI学得多快,像调音量一样。

一开始 Q 表是空的(或者随机值),AI 像个无头苍蝇一样乱撞。 但每次行动后,它都会根据得到的奖励和看到的新状态,用上面那个类似的思想去更新 Q 表里对应格子的数值。 玩的游戏次数越多(我们叫 episodes训练轮数),Q 表就记录得越准确,AI 就知道在什么情况下该做什么动作了。


贪吃蛇AI的"眼睛"和"脑子"

状态:AI的"眼睛"

AI要玩游戏,得先看清周围的情况。我在代码里设计了getState方法,让AI知道:

  1. 危险在哪儿:蛇头往上、右、下、左走会不会撞墙或自己。
  2. 食物在哪儿:食物在蛇头的上、右、下、左哪个方向。
  3. 现在朝哪儿走:蛇当前的方向是上、右、下、左。

这些信息组合起来就是一个"状态",有点像AI的"眼睛",帮它看清世界。

简化逻辑如下

ts 复制代码
export type Dir = 'up' | 'down' | 'left' | 'right'

/**
 * 获取当前游戏状态,用于 AI 决策
 * @returns 游戏状态对象
 */
getState(): {
  dangers: { [K in Dir]: boolean }
  foodDirection: { [K in Dir]: boolean }
  currentDirection: { [K in Dir]: boolean }
} {
  const head = this.snake[0]

  return {
    /** 蛇头在四个方向是否会撞到墙壁或自身 */
    dangers: {
      up: this.isDanger(head.x, head.y - 1),
      right: this.isDanger(head.x + 1, head.y),
      down: this.isDanger(head.x, head.y + 1),
      left: this.isDanger(head.x - 1, head.y),
    },
    /** 食物相对于蛇头的位置 */
    foodDirection: {
      up: this.food.y < head.y,
      right: this.food.x > head.x,
      down: this.food.y > head.y,
      left: this.food.x < head.x,
    },
    /** 蛇当前的移动方向 */
    currentDirection: {
      up: this.direction === 'up',
      right: this.direction === 'right',
      down: this.direction === 'down',
      left: this.direction === 'left',
    },
  }
}

/**
 * 检查指定位置是否危险
 * @param x x坐标
 * @param y y坐标
 * @returns 是否危险
 */
private isDanger(x: number, y: number): boolean {
  return (
    x < 0 ||
    x >= this.config.gridSize ||
    y < 0 ||
    y >= this.config.gridSize ||
    this.isOnSnake({ x, y })
  )
}

动作和奖励:AI的"脑子"

AI能做的动作很简单,就是选一个方向走。我在getAction方法里让它有时随机试试(探索),有时挑Q表里最好的(利用)。奖励是这样的:

  • 吃到食物:+10分,太棒了!
  • 撞墙或自己:-10分,太惨了!
  • 每走一步:离食物近了+0.1,远了-0.1,鼓励它快点找吃的。

简化逻辑如下

ts 复制代码
/**
 * 获取 AI 决策动作
 * @param state 当前游戏状态
 * @returns 动作方向
 */
getAction(state: ReturnType<Snake['getState']>): Dir {
  const stateKey = this.getStateKey(state)

  /** 探索:随机选择动作 */
  if (Math.random() < this.config.explorationRate) {
    const actions: (Dir)[] = ['up', 'right', 'down', 'left']
    return actions[Math.floor(Math.random() * actions.length)]
  }

  /** 利用:选择Q值最高的动作 */
  if (!this.qTable[stateKey]) {
    this.qTable[stateKey] = { up: 0, right: 0, down: 0, left: 0 }
  }

  const qValues = this.qTable[stateKey]
  let bestAction: Dir = 'right'
  let bestValue = -Infinity

  /** 选择期望值最高的动作 */
  for (const action in qValues) {
    const a = action as keyof typeof qValues
    if (qValues[a] > bestValue) {
      bestValue = qValues[a]
      bestAction = action as Dir
    }
  }

  return bestAction
}

记录环境:让 AI 学会根据环境判断

  • 一开始没有 qTable,AI 会随机一个方向

  • 根据这个方向对于目标的影响,计算是奖励还是惩罚

  • updateQValue:记录当前环境下,各个动作(上下左右)的最佳决策(计算奖励)

  • 比如当前环境信息如下(这时最佳方案是向左):

    json 复制代码
    {
      "dangerUp": false,
      "dangerRight": false,
      "dangerDown": false,
      "dangerLeft": false,
      "foodUp": false,
      "foodRight": false,
      "foodDown": false,
      "foodLeft": true,
      "dirUp": false,
      "dirRight": false,
      "dirDown": false,
      "dirLeft": true
    }

    同时计算每个方向的奖励值

    json 复制代码
    {
      "up": 7.811,
      "right": 12.154,
      "down": 7.376,
      "left": 12.981
    }

    随着环境信息与奖励值的不断迭代,AI 就能学会如何赢取高分

    为了减少内存占用,我用 01 来记录状态

ts 复制代码
/**
 * 更新 Q 值
 * ### Q(s,a) = Q(s,a) + α [r + γ max(Q(s',a')) - Q(s,a)]
 * ### 新 Q 值 = 旧 Q 值 + 学习率 * ( 当前奖励 + 折扣因子 * (下一个状态最好的 Q 值) - 旧 Q 值 )
 *
 * @param state 当前状态
 * @param action 执行的动作
 * @param reward 获得的奖励
 * @param nextState 下一状态
 */
updateQValue(
  state: ReturnType<Snake['getState']>,
  action: Dir,
  reward: number,
  nextState: ReturnType<Snake['getState']>,
): void {
  const stateKey = this.getStateKey(state)
  const nextStateKey = this.getStateKey(nextState)

  if (!this.qTable[stateKey]) {
    this.qTable[stateKey] = { up: 0, right: 0, down: 0, left: 0 }
  }
  if (!this.qTable[nextStateKey]) {
    this.qTable[nextStateKey] = { up: 0, right: 0, down: 0, left: 0 }
  }

  const maxNextQ = Math.max(...Object.values(this.qTable[nextStateKey]))

  /**
   * 键为当前的环境情况,如:
   * ```json
   * {
   *   "dangerUp": false,
   *   "dangerRight": false,
   *   "dangerDown": false,
   *   "dangerLeft": false,
   *   "foodUp": false,
   *   "foodRight": false,
   *   "foodDown": false,
   *   "foodLeft": true,
   *   "dirUp": false,
   *   "dirRight": false,
   *   "dirDown": false,
   *   "dirLeft": true
   * }
   * ```
   *
   * 值为当前环境情况下,四个方向的 Q 值,如:
   * ```json
   * {
   *   "up": 7.811,
   *   "right": 12.154,
   *   "down": 7.376,
   *   "left": 12.981
   * }
   * ```
   *
   * 通过不断更新某个环境下的 Q 值,可以使 AI 学会更好的决策
   */
  this.qTable[stateKey][action] += this.config.learningRate * (
    reward + this.config.discountFactor *
    maxNextQ - this.qTable[stateKey][action]
  )
}

/**
 * 计算奖励值
 * @param snake 蛇实例
 * @param prevScore 前一次得分
 * @param didMove 是否成功移动 (没撞墙/自己)
 * @returns 奖励值
 */
calculateReward(snake: Snake, prevScore: number, didMove: boolean): number {
  /** 游戏结束,狠狠地惩罚 */
  if (!didMove)
    return -10
  /** 吃到食物,狠狠地奖励 */
  if (snake.score > prevScore)
    return 10

  /** 计算蛇头与食物的距离 */
  const head = snake.snake[0]
  const food = snake.food
  const distance = Math.abs(head.x - food.x) + Math.abs(head.y - food.y)

  const prevDistance = this.prevDistance
  /** 如果蛇头距离食物更近,返回微小正奖励(+0.1) */
  if (prevDistance && distance < prevDistance) {
    this.prevDistance = distance
    return 0.1
  }

  /** 如果距离变远,则返回微小负奖励(-0.1) */
  this.prevDistance = distance
  return -0.1
}

/**
 * 训练 AI
 * @param snake 蛇实例
 * @param episodes 训练轮数
 **/
async train(snake: Snake, episodes: number, cb?: VoidFunction): Promise<void> {
  console.log(`开始训练 ${episodes} 次...`)
  const initialExplorationRate = this.config.explorationRate

  for (let i = 0; i < episodes; i++) {
    snake.reset()
    /** 重置距离比较基准 */
    this.prevDistance = 0
    /** 记录当前局的步数 */
    let step = 0

    while (!snake.gameOver) {
      const state = snake.getState()
      const action = this.getAction(state)
      const prevScore = snake.score

      snake.changeDirection(action)
      const didMove = snake.move()
      const reward = this.calculateReward(snake, prevScore, didMove)
      const nextState = snake.getState()
      this.updateQValue(state, action, reward, nextState)

      snake.draw()
      cb?.()

      step++
      /** 可以加一个最大步数限制,防止蛇陷入死循环或无意义的绕圈 */
      if (step > snake.config.gridSize * snake.config.gridSize * 2) {
        console.log(`Episode ${this.trainCount + 1} reached max steps, ending.`)
        snake.gameOver = true // 强制结束本局
      }

      /** 每 10 局,每 100 步延时一次,避免完全卡死 */
      if (i % 10 === 0 && step % 100 === 0) {
        await new Promise(resolve => setTimeout(resolve, 10))
      }
    }

    // --- 每局结束后的处理 ---
    this.trainCount++
    this.totalScore += snake.score

    /** 每隔一定局数保存一次,避免频繁 IO 操作,也减少数据丢失风险 */
    if (i % 10 === 0 || i === episodes - 1) {
      this.saveQTable()
      this.saveTrainCount()
      this.saveTotalScore()
    }

    /** 随着训练进行,减少探索率 */
    this.config.explorationRate = Math.max(
      this.config.minExplorationRate,
      initialExplorationRate * this.config.explorationDecay ** i,
    )
  }
}

总结流程

markdown 复制代码
开始训练
   │
   ▼
重置游戏(snake.reset())
   │
   ▼
获取当前状态 state = snake.getState()
   │
   ▼
判断是否探索:如果随机值 < 探索率,则随机选择动作,否则选择 Q 值最高的动作
   │
   ▼
执行动作:snake.changeDirection(action) -> snake.move()
   │
   ▼
判断是否游戏结束
   ├── 是 → 游戏结束,给予负奖励,更新 Q 表,结束当前回合
   └── 否 → 计算奖励 reward(根据是否吃到食物、是否接近食物等)
            │
            ▼
      获取下一状态 nextState = snake.getState()
            │
            ▼
  更新 Q 值:Q(s, a) = Q(s, a) + α (reward + γ max(Q(nextState)) - Q(s, a))
            │
            ▼
      绘制游戏(snake.draw())
            │
            ▼
         继续循环

我的代码分两部分:Snake类管游戏规则,QLearnSnakeAI类管AI学习。

Snake类:游戏的地盘

Snake类负责画蛇、移动蛇、生成食物。比如:

  • move():让蛇走一步,吃到食物就变长,撞墙就游戏结束。
  • draw():把蛇和食物画在屏幕上,游戏结束还显示得分。

QLearnSnakeAI类:AI的大脑

QLearnSnakeAI类是AI的核心,里面有几个关键方法:

  • getAction:根据状态选动作,像AI的"决策中心"。
  • updateQValue:用Q-learning公式更新Q表,像AI的"记笔记"。
  • calculateReward:算奖励,像AI的"老师"。
  • train:让AI玩很多轮游戏,每次都学一点。

训练时,AI会玩好多次游戏(比如1000轮),每轮都记下经验,更新Q表。慢慢地,它就从"乱撞"变成"高手"了。

相关推荐
excel1 分钟前
webpack 模块 第 五 节
前端
pen-ai3 分钟前
【NLP】 18. Tokenlisation 分词 BPE, WordPiece, Unigram/SentencePiece
人工智能·自然语言处理
excel10 分钟前
webpack 模块 第 四 节
前端
好_快19 分钟前
Lodash源码阅读-take
前端·javascript·源码阅读
taoqick19 分钟前
Deepseek Bart模型相比Bert的优势
人工智能·深度学习·bert
好_快20 分钟前
Lodash源码阅读-takeRight
前端·javascript·源码阅读
好_快21 分钟前
Lodash源码阅读-takeRightWhile
前端·javascript·源码阅读
烂蜻蜓22 分钟前
在 HTML5 中使用 MathML 展示数学公式
前端·html·html5
好_快24 分钟前
Lodash源码阅读-takeWhile
前端·javascript·源码阅读
风筝超冷1 小时前
Seq2Seq - 编码器(Encoder)和解码器(Decoder)
人工智能·深度学习·seq2seq