去做题

第四单元:向量运算 · 方向合成与智能避障

Unit 4: Vector Math — Direction Composition & Smart Avoidance
计算机科学 6 · 安息云峰 Cloudrip Mountain · 绕圈 → 滑走 → 精准逆转 → 冰球 → 蛇法储蓄

4.1 向量运算概述

Overview of Vector Operations

这是安息云峰上最"数学"、也最强大的一个单元。前面你都是用 move 直接走向某个点,但真实的移动往往更复杂——要绕着一个中心镜像转圈、要一边奔向目标一边躲开牦牛、要精确算出该从哪个角度把球踢向骨架。这些都需要一个全新的工具:向量(Vector)。向量能同时表达方向和距离,还能像积木一样相加、缩放、组合。掌握了向量运算,你就拥有了控制"往哪走、走多远"的完整语言。

本单元主线:用 Vector.subtract 求两点之间的方向与距离向量;用 Vector.add 合成新位置或叠加多个向量;用 Vector.normalize 把向量归一化成长度 1、只保留方向;用 Vector.multiply 缩放向量长度;把"奔向目标"和"远离障碍"两个向量相加,实现避障算法;用 Math.atan2 计算角度;用 findFlag 和 removeFlag 获取与移除旗帜来动态指挥单位。

4.1.1 什么是向量(What Is a Vector)

4.1.1.1 一个带箭头的量 A quantity with an arrow

普通数字(如"5")只有大小,叫标量;向量则同时带有方向和大小,可以想象成一支箭头——箭头指向哪儿是"方向",箭头多长是"距离(幅值 magnitude)"。在游戏里,一个向量常写成 Vector(x, y),其中 x 和 y 是它在两个坐标轴上的分量。同一个向量,既能表示"一个位置坐标",也能表示"从一点到另一点要走的方向和路程"。

向量 = 方向 + 长度 起点 终点 方向(箭头朝向) 长度 = 幅值 magnitude

4.1.2 位置就是向量(A Position IS a Vector)

4.1.2.1 hero.pos 本身就是一个 Vector hero.pos is itself a Vector

一个关键认知:单位的位置 hero.pos 本身就是一个向量——它带着 .x 和 .y 两个分量。所以你可以直接把 hero.pos、partner.pos、ball.pos 当作向量来做加减运算。这也是向量强大的原因:位置、方向、位移都用同一套 Vector 语言表达,可以自由地相互运算。

两种含义要分清:同样是一个 Vector(40, 34),当我们把它当"位置"用时,它表示地图上坐标 (40, 34) 那个点;当我们把它当"位移/方向"用时,它表示"向右走 40、向上走 34"这段移动。它到底是哪种含义,取决于你怎么用它。

4.2 减法与加法

Subtraction and Addition

4.2.1 subtract 求方向(Finding Direction with subtract)

4.2.1.1 终点减起点,得到"从起点指向终点"的向量 End minus start gives the vector pointing from start to end

向量减法是本单元最重要的操作。Vector.subtract(A, B) 得到的是一个从 B 指向 A 的向量——它同时告诉你"往哪个方向"和"有多远"。记忆口诀是"目标减自己":想得到"从我这里指向目标"的向量,就写 Vector.subtract(目标.pos, 我.pos)。

Vector.subtract(goalPoint, hero.pos) goalPoint 目标(终点) − hero.pos 自己(起点) = 从英雄指向目标的方向+距离向量
# 得到"从英雄指向目标点"的向量
goal = Vector.subtract(goalPoint, hero.pos)

# 得到"从牦牛指向英雄"的向量(用于逃离)
toHero = Vector.subtract(hero.pos, yak.pos)
减法顺序决定方向:subtract(A, B) 和 subtract(B, A) 方向正好相反!想"朝着目标去"用 subtract(目标, 自己);想"远离障碍"用 subtract(自己, 障碍)。顺序写反,就会朝反方向走——这是本单元最致命的错误。

4.2.2 add 合成位置(Composing Positions with add)

4.2.2.1 起点加位移,得到终点 Start plus displacement gives the destination

Vector.add 做的是相反的事——把一个位置和一个位移向量相加,得到新的位置。比如"从我当前位置出发,沿着 goal 向量走",落点就是 Vector.add(hero.pos, goal)。加法还能把多个方向向量叠加成一个合成方向,这正是避障算法的核心(后面详述)。

# 从当前位置沿 goal 向量前进,算出落点
moveToPos = Vector.add(hero.pos, goal)
hero.move(moveToPos)
➕➖ 减法与加法是一对搭档
减法回答"我该往哪个方向、走多远"(求出位移向量);加法回答"沿这个位移走,会到哪里"(求出目标位置)。几乎所有关卡都是这个套路:先 subtract 求出方向,加工一下,再 add 回当前位置得到落脚点,最后 move 过去。

4.3 归一化与缩放

Normalizing and Scaling

4.3.1 normalize 归一化(Normalize to Unit Length)

4.3.1.1 只留方向,长度归为 1 Keep the direction, set length to 1

有时我们只关心一个向量指向哪儿,不关心它多长。Vector.normalize(v) 会保留 v 的方向,但把它的长度统一变成 1 米(这叫单位向量)。归一化后,我们就能用一个"纯方向",再乘上任意想要的距离,精确控制移动步长——这是"想往这个方向走 X 米"的标准做法。

归一化:方向不变,长度变成 1 原向量(长 8) → 单位向量(长 1,同方向)

4.3.2 multiply 缩放长度(Scale Length with multiply)

4.3.2.1 方向不变,长度乘以倍数 Same direction, length times a factor

Vector.multiply(v, X) 把向量 v 的长度乘以 X 倍,方向保持不变。把它和归一化连用,就是控制"移动多远"的黄金组合:先 normalize 归一化成长度 1,再 multiply 乘以想要的米数,就得到一个"朝目标方向、长度恰好 X 米"的向量。

# 黄金三步:subtract 求方向 → normalize 归一化 → multiply 定长度
goal = Vector.subtract(goalPoint, hero.pos)   # ① 得到指向目标的向量
goal = Vector.normalize(goal)                # ② 压成长度 1,只留方向
goal = Vector.multiply(goal, 10)            # ③ 放大到 10 米长
subtract 求方向 normalize 长度归 1 multiply 放大到 X 米
两种写法风格:关卡里既有 goal = Vector.normalize(goal) 这种"返回新向量再赋值"的写法,也有 toYak.normalize() 这种"直接修改原向量"的链式写法。两者效果相同,链式写法还能连写成 chip.pos.copy().subtract(ballPos).normalize()。用 .copy() 是为了避免改动原始位置。

4.4 避障算法

Obstacle Avoidance

4.4.1 远离方向叠加(Adding a "Flee" Vector)

4.4.1.1 一边奔向目标,一边推离障碍 Move toward the goal while being pushed off the obstacle

"滑走"关卡的核心难题是:直线冲向目标会正好撞上牦牛。解决思路极其优雅——把两个向量相加。一个是"奔向目标"的向量 goal,另一个是"远离障碍"的向量(从障碍指向自己,即 subtract(hero.pos, yak.pos))。两者相加得到的合成向量,会让英雄整体朝目标前进、但被障碍"推"得偏开一点,从而绕过去。

合成方向 = 奔向目标 + 远离障碍 英雄 goal 奔向目标 远离牦牛 合成:绕开牦牛奔向目标
# 若牦牛太近,叠加一个"远离牦牛"的向量
if distance < 10:
    toYak = Vector.subtract(hero.pos, yak.pos)  # 从牦牛指向英雄
    toYak.normalize()                         # 只留方向
    toYak.multiply(10)                        # 放大成 10 米的"斥力"
    goal = Vector.add(goal, toYak)          # 叠加到目标向量上
moveToPos = Vector.add(hero.pos, goal)
hero.move(moveToPos)

4.4.2 力的合成模型(The Force-Composition Model)

4.4.2.1 把向量想象成"拉力"和"斥力" Think of vectors as attraction and repulsion

理解避障最直观的方式,是把它想象成物理里的力。目标像磁铁一样对英雄有吸引力(goal 向量),障碍则像同极磁铁一样有排斥力(远离向量)。英雄实际的移动方向,是所有力加起来的合力。障碍越近,斥力应当越强——所以关卡里通常只在"距离小于某个阈值"时才施加斥力,且可以让斥力大小随距离变化。

🧲 磁铁模型的威力
这个"吸引+排斥"模型不只用于躲牦牛。在"蛇法储蓄"里,硬币是吸引力、自己的尾巴是排斥力;在"调剂团"里,药水是吸引力、火焰陷阱是排斥力。只要把"想去的"和"想躲的"各做成一个向量再相加,就能让单位在复杂环境里自动导航。这正是机器人和游戏 AI 里真实使用的势场法(potential field)思想。
斥力方向别搞反:"远离障碍"的向量必须是 subtract(自己.pos, 障碍.pos)(从障碍指向自己)。如果写成 subtract(障碍.pos, 自己.pos),就变成"扑向障碍",会直接撞上去。这又一次说明减法顺序至关重要。

4.5 角度与旗帜

Angles and Flags

4.5.1 atan2 求角度(Computing Angles with atan2)

4.5.1.1 从向量分量还原方向角 Recover the angle from a vector's components

有时我们需要的不是向量本身,而是它指向的角度(比如判断球会往哪个方向飞、或让单位面朝某处)。Math.atan2(y, x) 能从一个向量的 y 和 x 分量算出它与水平方向的夹角(弧度)。它比普通的 atan 更聪明——能根据 x、y 的正负正确区分四个象限,给出 -π 到 π 的完整角度。

# 求"从英雄指向敌人"这个方向的角度
diff = Vector.subtract(enemy.pos, hero.pos)
angle = Math.atan2(diff.y, diff.x)   # 注意:先 y 后 x
参数顺序是 (y, x):Math.atan2 的参数顺序很反直觉——先传 y,再传 x,写成 atan2(diff.y, diff.x)。写反成 atan2(x, y) 会得到错误的角度。这是使用它时最容易踩的坑。
角度用来做什么:在"精准逆转""冰球"这类推球关卡里,向量的方向/角度决定了球会朝哪儿飞。要让球命中特定骨架,就得让农民站到球的正确一侧、从正确角度去推——这背后就是对方向向量(及其角度)的精确计算。

4.5.2 findFlag 与 removeFlag(Getting and Removing Flags)

4.5.2.1 用旗帜动态下达指令 Give dynamic orders with flags

旗帜(flag)是一种动态、临时的指令标记——你在游戏中点击放下一面旗,程序就能读到它并据此行动。hero.findFlag("green") 获取当前放置的绿旗(没有则返回空);hero.removeFlag(flag) 则在用完后把它移除。典型套路是:"如果检测到旗帜,就让最近的单位移向旗帜位置,等它靠近后移除旗帜",这样你就能实时、灵活地指挥球员跑位。

# 检测旗帜 → 派最近单位过去 → 到位后移除旗帜
flag = hero.findFlag("green")
if flag:
    # ...找出离旗帜最近的 closestFriend...
    hero.command(closestFriend, "move", flag.pos)
    hero.removeFlag(flag)   # 用完立即移除,避免重复触发
用完一定要 removeFlag:如果读取旗帜后不 removeFlag,那面旗会一直存在,导致每一帧都重复派单位过去、逻辑卡死。"接收指令 → 执行 → 清除指令"是使用旗帜的固定节奏。

4.6 实战关卡精讲

Level Strategy & Analysis

这一节聚焦每一关的策略、思路与算法设计,只展示用于说明的关键片段,完整通关程序请你自己动手完成。

关卡 · 绕圈

🔄 绕圈 —— 用向量镜像伙伴的移动

扣哒世界 绕圈关卡 - koudashijie.com
图2:绕圈——以中心 X 为对称点,用向量减法与加法,让英雄始终镜像伙伴绕中心移动

策略:以中心 X 为对称点镜像移动

本关要让英雄的位置永远和伙伴关于中心 X 点对称——伙伴在中心一侧,英雄就要在正对面的另一侧。这是理解"位置就是向量、减加互为逆运算"的最佳入门关。核心只有两步向量运算。

思路拆解:subtract 求出偏移,add 镜像回去

第一步,用 Vector.subtract(center, partner.pos) 求出"从伙伴指向中心"的向量——它描述了伙伴相对中心的偏移。第二步,把这个偏移从中心再加一次:Vector.add(center, vector),就得到了中心另一侧的对称点。第三步,移动到这个点。因为偏移方向从伙伴→中心,再从中心继续同方向延伸,落点自然落在伙伴的正对面。

# 关键片段:镜像伙伴绕中心 X 移动
center = Vector(40, 34)
partner = hero.findByType("peasant")[0]
while True:
    vector = Vector.subtract(center, partner.pos)  # 伙伴→中心的偏移
    moveToPos = Vector.add(center, vector)       # 中心另一侧的对称点
    hero.move(moveToPos)
为什么这样就对称:从中心到英雄的向量,恰好等于从伙伴到中心的向量。也就是说中心正好是伙伴和英雄连线的中点,两者永远关于中心镜像。这就是向量减加运算最直观的几何意义。
关卡 · 滑走

🏃 滑走 —— 奔向目标同时躲开牦牛

扣哒世界 滑走关卡 - koudashijie.com
图3:滑走——用"黄金三步"造出奔向 X 的向量,牦牛过近时叠加远离向量,合成出绕行路线

策略:吸引力 + 斥力的合成导航

本关要走到红色 X,但直线过去会撞上牦牛。策略是经典的势场避障:始终有一个"奔向目标"的向量,一旦牦牛进入 10 米范围,就再叠加一个"远离牦牛"的向量,两者相加得到既朝目标、又绕开牦牛的合成方向。关键是每一步都必须整体朝目标推进,否则永远到不了。

思路拆解:两个向量各自加工再相加

目标向量用黄金三步造出:subtract(goalPoint, hero.pos) 求方向、normalize 归一化、multiply(goal, 10) 定长 10 米。避障向量只在 distance < 10 时才造:subtract(hero.pos, yak.pos)(从牦牛指向自己)、归一化、乘 10,得到 10 米的斥力。最后 Vector.add(goal, toYak) 把两者合成,再 add(hero.pos, goal) 算出落点并移动。

# 关键片段:目标向量 + 条件性避障向量
goal = Vector.subtract(goalPoint, hero.pos)
goal = Vector.normalize(goal)
goal = Vector.multiply(goal, 10)
yak = hero.findNearest(hero.findEnemies())
if hero.distanceTo(yak) < 10:
    toYak = Vector.subtract(hero.pos, yak.pos)
    toYak.normalize()
    toYak.multiply(10)
    goal = Vector.add(goal, toYak)
hero.move(Vector.add(hero.pos, goal))
斥力别盖过引力:如果避障向量比目标向量强太多,英雄会光顾着逃、原地打转到不了 X。让两者长度相当(都是 10 米)能保证"边躲边前进"。这也是为什么要先把两个向量都归一化再乘同样的倍数——好让它们"势均力敌"。
关卡 · 精准逆转

🎯 精准逆转 —— 从正确角度把球推向目标

扣哒世界 精准逆转关卡 - koudashijie.com
图4:精准逆转——用向量算出每个蓝骨架方向,令农民绕到球的反方向一侧,把球精准推向蓝骨架

策略:站到球背后,把球推向目标

本关要用球撞倒所有蓝骨架、不碰红骨架。要让球朝某个蓝骨架飞,农民必须站到球相对目标的"反方向"一侧去推——就像台球,想让球往前走,得从后面击打。所以关键是为每个蓝骨架算出正确的击球点(kickPoint)。

思路拆解:用方向向量反推击球点

对每个蓝骨架 chip,先求"从球指向骨架"的方向:chip.pos.copy().subtract(ballPos).normalize()(用 copy() 避免改动骨架真实坐标)。这个单位向量指向骨架。击球点要在球的相反一侧,所以把方向乘以 -8 再加到球位上:Vector.add(ballPos, direction.multiply(-8))。农民走到这个点后再推向球,球就会朝骨架飞去。逐个处理完所有蓝骨架,并在每次击球后回到中心。

# 关键片段:为每个蓝骨架算出球背后的击球点
for chip in blueChips:
    direction = chip.pos.copy().subtract(ballPos).normalize()
    kickPoint = Vector.add(ballPos, direction.multiply(-8))  # 负号→球的另一侧
    hero.command(kicker, "move", kickPoint)
    # ...等农民到位后,再命令它 move 向 ballPos 把球推出...
负号的妙用:multiply(direction, -8) 里的负号让向量反向——原本指向骨架,取负后就指向骨架的相反侧。加到球位上,就得到"球的背后 8 米处"这个击球点。用正负号翻转方向,是向量运算里非常实用的小技巧。
用 magnitude 判断球是否停下:关卡提示可检查 ball.velocity.magnitude() 来判断球是否已经停回中心。幅值接近 0 表示球停住了,此时才好进行下一次击球,避免在球还在滚时乱推。
关卡 · 冰球

🏒 冰球 —— 用旗帜动态指挥球员

扣哒世界 冰球关卡 - koudashijie.com
图5:冰球——通过放置/移除旗帜实时调度最近球员,并用向量算好击球点把球射向对方球门

策略:旗帜跑位 + 向量击球

本关要在食人魔进三球前先得三分。它融合了本单元几乎所有技能:用旗帜动态指挥球员跑位、用"找最近单位"的擂台算法选人、用向量算出正确的击球点把球射向前方。这是一关综合演练。

思路拆解:三段逻辑

第一段·旗帜调度:每帧 findFlag("green"),若有旗,就用"最近距离擂台"找出离旗最近的球员,命令它 move 到旗位,然后 removeFlag 清除旗帜。第二段·选控球者:再用一次擂台找出离球最近的球员 closestFriend。第三段·向量击球:当控球者站在球的正确一侧且足够近时,用 subtract 求出击球方向、normalize、multiply(3) 定长,再 add 出击球点,命令球员移过去把球顶向前方球门。

# 关键片段:旗帜调度最近球员
flag = hero.findFlag("green")
if flag:
    closest = None
    closestDist = 9999
    for friend in friends:
        if friend.distanceTo(flag) < closestDist:
            closest = friend
            closestDist = friend.distanceTo(flag)
    if closest:
        hero.command(closest, "move", flag.pos)
    hero.removeFlag(flag)
击球点要在球的后方:和"精准逆转"一样,想把球往前推,球员要站在球的后侧。通过 goalPoint = Vector(ball.pos.x - 3, ball.pos.y) 之类的计算,让球员绕到球的一侧再推,就能把球送向正确方向。多试几次找手感——正如关卡玩笑说的,"至少游戏很快就会结束"。
关卡 · 蛇法储蓄(挑战)

🐍 蛇法储蓄 —— 一边贪吃蛇一边避开自己的尾巴

扣哒世界 蛇法储蓄关卡 - koudashijie.com
图6:蛇法储蓄——农民像贪吃蛇般收集硬币,身后拖出会致命的尾巴,需用向量避障绕开自己的尾巴

策略:性价比选币 + 避开自己的尾巴

这是本单元的高难挑战关,是前面所有技能的集大成。农民像贪吃蛇一样收集硬币,身后拖出不断变长的尾巴,一旦碰到尾巴(或墙、或别的蛇)就会被摧毁,要凑够 200 金币。策略是把第三单元的价值/距离选币和本单元的向量避障结合起来——朝最划算的硬币去,同时用斥力向量绕开自己的尾巴。

思路拆解:吸引力(硬币)+ 斥力(尾巴)

选币:用 findBestItem 按 value / distance 打擂台选出性价比最高的硬币。造吸引力:subtract(coin.pos, friend.pos) → 归一化 → 乘 7,得到奔向硬币的向量 goal。造斥力:读取农民身后的 friend.snakeBackward(尾巴),用 subtract(friend.pos, tail.pos)(从尾巴指向自己)→ 归一化 → 乘 7,得到远离尾巴的向量 toTail。合成:Vector.add(goal, toTail) 相加,再加回当前位置得到落点。这正是"滑走"避障模型在动态障碍上的复用。

# 关键片段:奔向硬币 + 远离自己的尾巴
coin = findBestItem(friend, coins)       # 价值/距离选币
goal = Vector.subtract(coin.pos, friend.pos)
goal = Vector.normalize(goal)
goal = Vector.multiply(goal, 7)
if friend.snakeBackward:              # 身后的尾巴
    tail = friend.snakeBackward
    toTail = Vector.subtract(friend.pos, tail.pos)
    toTail = Vector.normalize(toTail)
    toTail = Vector.multiply(toTail, 7)
    goal = Vector.add(goal, toTail)   # 合成方向
moveToPos = Vector.add(friend.pos, goal)
hero.command(friend, "move", moveToPos)
这是可选挑战关:蛇法储蓄难度很高,要同时避开尾巴、墙壁和其他蛇。如果暂时攻克不下也没关系——它需要你把整个单元的向量与避障融会贯通。建议先把前四关吃透,再回头挑战它。
对尾巴的完整避障:更稳的做法是遍历尾巴上的多个节点,对每一节都叠加一个斥力向量(离得越近斥力越强),而不只避开最近一节。这就是 4.4 节"力的合成"在多个障碍上的推广——把所有斥力都 add 进合成方向里。

4.7 常见错误

Common Mistakes

4.7.1 新手最容易踩的坑(Pitfalls for Beginners)

4.7.1.1 向量运算的典型问题 Typical issues with vector math

扣哒世界 向量运算常见错误速查 - koudashijie.com
图7:减法顺序反、忘记归一化、atan2 参数反、旗帜不移除,是本单元最常见的失误
易错点清单:
  • 减法顺序写反——朝目标去用 subtract(目标, 自己),远离障碍用 subtract(自己, 障碍);写反会朝相反方向移动。
  • 忘记归一化就乘倍数——不先 normalize 就 multiply,长度会失控,移动步长忽大忽小。
  • 避障向量太强盖过目标——两个向量要长度相当(都归一化再乘同样倍数),否则英雄只顾逃跑到不了目标。
  • 直接改到原始位置——对 chip.pos 等真实坐标做运算前要先 .copy(),否则会污染单位的实际位置。
  • Math.atan2 参数顺序反——必须是 atan2(y, x)(先 y 后 x),写成 atan2(x, y) 角度全错。
  • 读取旗帜后不移除——用完 findFlag 要 removeFlag,否则每帧重复触发、逻辑卡死。
  • 击球点方向没取负——想把球推向目标,击球点要在球的相反侧,记得给方向向量乘负数。
  • 合成后忘记加回当前位置——算好合成方向向量后,还要 add(自己.pos, goal) 得到真正的落点才能 move。

本单元小结 ✨

这是安息云峰上分量最重的一个单元,你掌握了控制"往哪走、走多远"的完整向量语言!你学会了用 Vector.subtract 求两点间的方向与距离向量(牢记"目标减自己");用 Vector.add 合成位置与叠加向量;用 Vector.normalize 把向量压成长度 1 只留方向、再用 Vector.multiply 缩放到想要的长度——这"黄金三步"是所有移动的基石;你还理解了把"吸引力"和"斥力"相加的避障算法(势场思想),会用 Math.atan2 求方向角度,并用 findFlag、removeFlag 动态指挥单位。从绕圈的镜像、滑走的避障、精准逆转与冰球的击球,到蛇法储蓄的动态避障,你把向量运算用得淋漓尽致。来回顾一下核心概念:

核心概念关键点对应关卡
subtract 求方向目标减自己 / 自己减障碍绕圈·滑走
add 合成位置当前位置 + 位移 = 落点绕圈
normalize 归一化保方向、长度变 1滑走
multiply 缩放长度乘倍数(负号可反向)滑走·精准逆转
避障算法目标向量 + 远离向量 相加滑走·蛇法储蓄
Math.atan2 求角参数顺序 (y, x)精准逆转·冰球
findFlag/removeFlag读旗调度、用完即删冰球
综合避障选币吸引 + 尾巴排斥蛇法储蓄

懂得用向量精确表达方向与距离、能把吸引与排斥合成为一条智慧路线的英雄,才是真正驾驭空间与运动的云峰智者。
继续向雪山之巅挺进吧,勇敢的程序员!🏔️➡️🧲

扣哒世界 编程知识 · 计算机科学 6(安息云峰):第四单元

扣哒世界教研团队