巡逻状态机
一个 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)