You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Flutter+Flame:如何实现带视野半径、盲区、受遮挡的敌人视野

Flutter + Flame 潜行类游戏敌人遮挡视野实现方案

潜行游戏敌人视野效果参考

核心实现逻辑

整个功能不用引入额外依赖,靠Flame自带的射线检测、多边形渲染能力就能实现,拆成三个核心模块:

  • 基于扇形范围发射射线,被障碍物截断后生成带遮挡的视野轮廓
  • 排除近身/角度外的盲区范围
  • 基于轮廓渲染可视区域,同时做玩家进入视野的判定

分步实现

1. 敌人基础属性定义

先给敌人组件配置视野相关的基础参数,后续所有逻辑都围绕这些参数计算:

import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/extensions.dart';

class StealthEnemy extends PositionComponent with HasGameRef<StealthGame> {
  StealthEnemy({required super.position, required super.size}) : super(anchor: Anchor.center);

  // 视野配置参数,可根据不同敌人类型调整
  final double viewMaxRadius = 160; // 视野最大距离
  final double viewFOV = 110 * degrees2Radians; // 视野张角(110度,左右各55度)
  final double blindRadius = 28; // 近身盲区半径,进入这个范围敌人看不见
  final double turnSpeed = 0.8; // 巡逻时转头速度
  Vector2 faceDir = Vector2(0, -1); // 初始面朝方向(这里是朝上)

  List<Vector2> viewPolygon = []; // 存最终生成的视野轮廓点
  late PolygonComponent viewRenderComp; // 视野渲染组件
}

2. 射线检测生成带遮挡的视野轮廓

在敌人面朝的视野角度范围内,每隔固定小角度发射一条射线,射线碰到障碍物就取碰撞点,没碰到就取最大视野半径处的点,所有点连起来就是不规则的视野形状:

List<Vector2> _calcViewShape() {
  final shapePoints = <Vector2>[];
  final baseAngle = faceDir.screenAngle() - viewFOV / 2;
  // 每1.5度发一条射线,平衡精度和性能,移动端可以调到2度
  final rayStep = 1.5 * degrees2Radians;
  final rayCount = (viewFOV / rayStep).round();

  for (var i = 0; i <= rayCount; i++) {
    final curAngle = baseAngle + i * rayStep;
    final rayDir = Vector2(cos(curAngle), sin(curAngle));
    // 射线只检测场景里的障碍物碰撞盒
    final hitResult = game.collisionDetection.raycast(
      Ray2(origin: absolutePosition, direction: rayDir),
      maxDistance: viewMaxRadius,
      hitboxes: game.obstacleHitboxList,
    );

    Vector2 hitPoint;
    if (hitResult != null) {
      hitPoint = hitResult.hitpoint!;
    } else {
      hitPoint = absolutePosition + rayDir * viewMaxRadius;
    }

    // 处理近身盲区:碰撞点在盲区内的话,取盲区边界作为点
    final dist = absolutePosition.distanceTo(hitPoint);
    if (dist < blindRadius) {
      hitPoint = absolutePosition + rayDir * blindRadius;
    }
    shapePoints.add(hitPoint);
  }
  return shapePoints;
}

提前把场景里所有墙体、障碍物的碰撞盒存在游戏全局的obstacleHitboxList里,不用每次射线检测都遍历所有组件,性能提升很明显。

3. 视野区域渲染

用多边形组件渲染半透明的视野区域,记得调整渲染层级,让视野在障碍物下层、地面上层,自然呈现被遮挡的效果:

@override
Future<void> onLoad() async {
  // 初始化视野渲染组件,默认半透明黄色,警觉状态可以改成红色
  viewRenderComp = PolygonComponent(
    [Vector2.zero()],
    paint: Paint()..color = const Color.fromARGB(90, 255, 230, 0),
    anchor: Anchor.center,
    priority: 10, // 优先级比地面高,比障碍物(priority>=20)低
  );
  add(viewRenderComp);
  // 敌人自身的碰撞盒、巡逻逻辑省略
  return super.onLoad();
}

@override
void update(double dt) {
  super.update(dt);
  // 这里可以加巡逻转头、移动的逻辑
  // 只有当敌人移动/转头时才重新计算视野,静止时直接缓存不用重复算
  viewPolygon = _calcViewShape();
  // 世界坐标转组件本地坐标,给多边形组件用
  viewRenderComp.points = viewPolygon.map((p) => p - absolutePosition).toList();
}

如果要做更自然的视野边缘渐变,可以给Paint加径向渐变shader,从靠近敌人的半透明过渡到边缘完全透明,更贴近参考图的效果。

4. 玩家被发现判定

不用做点在多边形内的复杂计算,三个条件同时满足就判定玩家被发现:

  • 玩家和敌人的距离在盲区外、最大视野半径内
  • 玩家相对于敌人的角度在视野张角范围内
  • 敌人到玩家的射线没有被障碍物挡住
bool checkPlayerDetected(Player player) {
  final toPlayer = player.absolutePosition - absolutePosition;
  final dist = toPlayer.length;
  // 距离校验
  if (dist < blindRadius || dist > viewMaxRadius) return false;
  // 角度校验
  final angleDiff = toPlayer.angleToSigned(faceDir).abs();
  if (angleDiff > viewFOV / 2) return false;
  // 遮挡校验
  final blockHit = game.collisionDetection.raycast(
    Ray2(origin: absolutePosition, direction: toPlayer.normalized()),
    maxDistance: dist,
    hitboxes: game.obstacleHitboxList,
  );
  return blockHit == null;
}

性能优化建议

  • 敌人静止、朝向不变时直接缓存视野多边形,不用每帧重算射线,只有移动/转头/障碍物位移时再更新
  • 玩家检测不用每帧跑,每3~4帧检测一次完全不会有感知延迟
  • 低端设备可以把射线间隔调到2~2.5度,减少射线数量,视觉上几乎没有差异

常见坑

  • 所有射线计算统一用世界坐标,渲染时再转组件本地坐标,不然会出现视野偏移错位
  • 障碍物碰撞盒要和美术资源完全对齐,不然会出现视野穿模、该挡住的地方没挡住的问题
  • 如果要做背后盲区,只要调整射线的角度覆盖范围即可,不用额外加逻辑

内容的提问来源于stack exchange,提问作者Abdurraheem Abdulhakeem

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.26 14:45:32