巡逻状态机

一个 enum 加一个 match 块驱动巡逻、追击和返回。底部那条显示的是最近几秒的状态。

上方的编辑器正在运行这个项目。改一行代码,它就会重新加载。

用到的 Godot 类

Node2DLabelLine2D

代码

scripts/main.gd
extends Node

enum State { PATROL, CHASE, RETURN }

const PATROL_SPEED: float = 210.0
const CHASE_SPEED: float = 300.0
const INTRUDER_SPEED: float = 260.0
const SIGHT: float = 175.0
const GIVE_UP: float = 290.0
const LEASH: float = 300.0
const KEEP_BACK: float = 52.0
const REACHED: float = 16.0
const HISTORY_SLOT: float = 0.06
const HISTORY_BARS: int = 110

var state: State = State.PATROL
var waypoints: PackedVector2Array = PackedVector2Array()
var waypoint_index: int = 0

var guard: Node2D
var sight_ring: Line2D
var intruder: Node2D
var manual_target: Vector2 = Vector2.INF
var orbit_center: Vector2 = Vector2.ZERO
var orbit_size: Vector2 = Vector2.ZERO
var orbit_phase: float = 1.98

var banner: Label
var bars: Array[Polygon2D] = []
var filled: int = 0
var slot_timer: float = 0.0


func _ready() -> void:
	_add_background()

	var view: Vector2 = get_viewport().get_visible_rect().size
	orbit_center = view * 0.5
	orbit_size = Vector2(view.x * 0.38, view.y * 0.36)

	waypoints = PackedVector2Array(
		[
			Vector2(view.x * 0.35, view.y * 0.35),
			Vector2(view.x * 0.65, view.y * 0.35),
			Vector2(view.x * 0.65, view.y * 0.65),
			Vector2(view.x * 0.35, view.y * 0.65),
		]
	)

	var route: Line2D = Line2D.new()
	route.points = waypoints + PackedVector2Array([waypoints[0]])
	route.width = 3.0
	route.default_color = Color(0.55, 0.65, 0.95, 0.55)
	add_child(route)

	for point in waypoints:
		var pin: Polygon2D = _make_circle(9.0, Color('#8da5f3'))
		pin.position = point
		add_child(pin)

	guard = Node2D.new()
	guard.position = waypoints[0]
	add_child(guard)

	sight_ring = Line2D.new()
	sight_ring.width = 2.0
	sight_ring.closed = true

	for i in 40:
		sight_ring.add_point(Vector2(cos(TAU * i / 40.0), sin(TAU * i / 40.0)) * SIGHT)

	guard.add_child(sight_ring)
	guard.add_child(_make_circle(16.0, Color.WHITE))

	intruder = Node2D.new()
	intruder.position = _orbit_point(orbit_phase)
	intruder.add_child(_make_circle(13.0, Color('#8eef97')))
	add_child(intruder)

	banner = Label.new()
	banner.position = Vector2(60, view.y - 122.0)
	banner.add_theme_font_size_override('font_size', 38)
	add_child(banner)

	for i in HISTORY_BARS:
		var bar: Polygon2D = _make_rect(Vector2(9, 26), Color(1, 1, 1, 0))
		bar.position = Vector2(66 + i * 10.0, view.y - 48.0)
		add_child(bar)
		bars.append(bar)

	_apply_state(state)

	print('the guard chases anything inside %d pixels and turns back once it strays %d from its post, click to move the green intruder' % [int(SIGHT), int(LEASH)])


func _process(delta: float) -> void:
	_move_intruder(delta)

	var to_intruder: float = guard.position.distance_to(intruder.position)

	match state:
		State.PATROL:
			_walk_guard(waypoints[waypoint_index], PATROL_SPEED, delta)

			if guard.position.distance_to(waypoints[waypoint_index]) < REACHED:
				waypoint_index = (waypoint_index + 1) % waypoints.size()

			if to_intruder < SIGHT:
				_apply_state(State.CHASE)
		State.CHASE:
			_walk_guard(intruder.position + (guard.position - intruder.position).normalized() * KEEP_BACK, CHASE_SPEED, delta)

			if to_intruder > GIVE_UP or guard.position.distance_to(orbit_center) > LEASH:
				_apply_state(State.RETURN)
		State.RETURN:
			_walk_guard(waypoints[waypoint_index], PATROL_SPEED, delta)

			if guard.position.distance_to(waypoints[waypoint_index]) < REACHED:
				_apply_state(State.PATROL)

	_record(delta)


func _unhandled_input(event: InputEvent) -> void:
	if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
		manual_target = event.position


func _apply_state(next: State) -> void:
	state = next
	banner.text = State.keys()[state].to_lower()
	banner.add_theme_color_override('font_color', _state_color())
	sight_ring.default_color = Color(_state_color(), 0.45)


func _state_color() -> Color:
	match state:
		State.CHASE:
			return Color('#fc7f7f')
		State.RETURN:
			return Color('#f3d48d')
		_:
			return Color('#8da5f3')


func _walk_guard(to: Vector2, speed: float, delta: float) -> void:
	guard.position = guard.position.move_toward(to, speed * delta)


func _move_intruder(delta: float) -> void:
	if manual_target != Vector2.INF:
		intruder.position = intruder.position.move_toward(manual_target, INTRUDER_SPEED * delta)

		if intruder.position.distance_to(manual_target) < REACHED:
			manual_target = Vector2.INF
			orbit_phase = (intruder.position - orbit_center).angle()

		return

	orbit_phase += delta * INTRUDER_SPEED / orbit_size.x
	intruder.position = _orbit_point(orbit_phase)


func _orbit_point(phase: float) -> Vector2:
	return orbit_center + Vector2(cos(phase), sin(phase * 1.6)) * orbit_size


func _record(delta: float) -> void:
	slot_timer += delta

	if slot_timer < HISTORY_SLOT:
		return

	slot_timer = 0.0

	if filled < bars.size():
		bars[filled].color = _state_color()
		filled += 1
		return

	for i in range(bars.size() - 1):
		bars[i].color = bars[i + 1].color

	bars[bars.size() - 1].color = _state_color()


func _make_rect(size: Vector2, color: Color) -> Polygon2D:
	var rect: Polygon2D = Polygon2D.new()
	rect.polygon = PackedVector2Array([-size * 0.5, Vector2(size.x, -size.y) * 0.5, size * 0.5, Vector2(-size.x, size.y) * 0.5])
	rect.color = color

	return rect


func _make_circle(radius: float, color: Color) -> Polygon2D:
	var points: PackedVector2Array = PackedVector2Array()

	for i in 20:
		points.append(Vector2(cos(TAU * i / 20.0), sin(TAU * i / 20.0)) * radius)

	var circle: Polygon2D = Polygon2D.new()
	circle.polygon = points
	circle.color = color

	return circle


func _add_background() -> void:
	var background: ColorRect = ColorRect.new()
	background.color = Color('#12141a')
	add_child(background)
	# ColorRect defaults to MOUSE_FILTER_STOP and would eat the click before _unhandled_input
	background.mouse_filter = Control.MOUSE_FILTER_IGNORE
	background.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)

更多寻路示例