让弹球对手会算落点,但算不太准
一个能打赢的 Pong 对手需要两件事:把球的落点算准,再故意算错一点。前者用三角波一次算完,后者每次接球重抽一个偏差。
一个不会预测的对手很无聊,一个算得完全准的对手没法赢。弹球的 AI 只需要两件事:把落点算对,以及每次都错一点。
落点预测不用逐帧模拟
球在上下壁之间来回折返,如果每帧推进再判断碰撞,预测就成了「模拟到未来」—— 又慢又容易和真实物理走偏。更省的办法是把球场沿两面墙镜像展开成无限条带:球在展开后的空间里走一条直线,落点就是这条直线的终点,再把它折回球场即可。
折回用的是三角波:
const low = radius;
const high = courtH - radius;
const span = high - low;
const period = span * 2;
const folded = ((raw - low) % period + period) % period;
return low + (folded <= span ? folded : period - folded);
一次求值,没有循环,也不依赖球当前的速度方向。这里有个容易写错的细节:球速为零时要直接返回当前 y,否则浮点折算会给出 20.000000000000007 这种值 —— 它不影响手感,但会让自检的相等断言随机失败。
失误要抽在正确的位置
AI 每次接球的目标是 落点 + 偏差。偏差在玩家每次击球时重抽一次,而不是每帧抖动:每帧抖动只是让挡板发抖,看不出判断失误;每次对拉换一次偏差,才会出现「这一拍预判偏了、下一拍又准了」的节奏。
偏差幅度取球场高度的十分之一左右。太小打不赢,太大显得没在算。玩家的挡板速度略高于 AI,所以赢的方式不是比手速,而是把球打到远角,让对手带着偏差多跑一段。
纯逻辑与渲染分家
规则全在 src/games/pong/logic/game.ts,零 import,不碰 DOM;step(state, { target, dt }) 返回新状态,随机数状态也带在状态里,所以同一个种子跑两百帧必然得到同一个结果 —— 这一条直接变成了自检里的断言。
渲染那边只做三件事:把 100×60 的抽象坐标映射到画布、从 CSS 变量读当前主题的颜色、把比分写进状态栏。球的颜色、挡板的颜色都读 --c-accent 与 --c-text,所以站点切到深色模式,球场跟着变。
球画成方块而不是圆,倒不是为了省事 —— 全站没有圆角,一个圆球会立刻显得不属于这里。
收尾
AI 的价值不在强度,在可读性。玩家要能感觉到对手在算,也要能感觉到它会错。这两件事都做到了,难度就不需要靠速度去堆。

评论
…