第二十三部分:NPC 日程系统基础:按时间移动、白天开店、晚上回家、雨天待在室内

2026-05-19
416114 分钟
...

这一部分开始做 NPC 日程系统。

前面我们已经有了:

时间系统
天气系统
场景切换
NPC 对话
商店
任务
地图状态保存

现在可以让 NPC 从“站桩工具人”升级成“会生活的人”。

比如:

早上 8村民从家里出来
9 点到广场
12 点去商店附近
18 点回家
雨天不出门
晚上商店关门

这会让世界更有生活感。

1. NPC 日程系统要解决什么

NPC 日程系统核心就是:

根据时间天气季节任务状态
决定 NPC 当前应该在哪里做什么能不能交互

比如:

白天
商人在店里玩家可以交易

晚上
商人回家商店关闭

雨天
村民待在室内不去广场

任务完成后
某个 NPC 离开村庄

所以 NPC 日程不是简单的“走来走去”。

它本质上是:

时间条件 + 行为目标 + 场景位置 + 交互状态

2. 第一版 NPC 日程先做什么

第一版先做:

1. NPC schedule 列表
2. 每条 schedule 指定开始时间
3. 每条 schedule 指定目标地图
4. 每条 schedule 指定目标点
5. NPC 根据当前时间选择当前日程
6. 如果 NPC 在当前地图就显示出来
7. 如果 NPC 不在当前地图就隐藏或移除
8. NPC 可以移动到目标点
9. 商人晚上不可交易
10. 雨天可以使用特殊日程

先不要做:

复杂路径动画
跨地图真实行走
NPC 碰撞避让
NPC 情绪系统
NPC 记忆系统
NPC 对玩家关系变化
多层条件编辑器
完整剧情分支

第一版目标是:

NPC 不再永远站在同一个地方

3. 日程数据应该怎么表示

一个 NPC 一天可能有多条日程。

比如商人:

06:00 在家
08:00 去商店
09:00 开店
18:00 关店
20:00 回家

每一条可以叫:

ScheduleEntry

它需要这些数据:

开始小时
开始分钟
目标地图 ID
目标点 ID
行为类型
是否允许交互
是否允许开店
天气条件

比如:

09:00
map_id = village
point_id = shop_counter
action = SHOP
can_interact = true

4. ScheduleEntry 用 Resource

因为日程本质是数据,适合用 Resource。

创建:

res://scripts/npc/schedule_entry.gd
class_name ScheduleEntry
extends Resource

enum ActionType {
    IDLE,
    WALK,
    WORK,
    SHOP,
    SLEEP,
    EAT,
    SOCIAL
}

@export_range(0, 23) var hour: int = 6
@export_range(0, 59) var minute: int = 0

@export var map_id: String = ""
@export var point_id: String = ""

@export var action_type: ActionType = ActionType.IDLE

@export var can_interact: bool = true
@export var can_shop: bool = false

@export var use_only_on_rainy_day: bool = false
@export var use_only_on_clear_day: bool = false

解释:

hour / minute
这条日程从几点开始生效

map_id
NPC 应该在哪张地图

point_id
NPC 应该去哪个日程点

action_type
NPC 当前行为比如工作睡觉开店

can_interact
玩家能不能和这个 NPC 交互

can_shop
这个 NPC 当前能不能打开商店

use_only_on_rainy_day
只在雨天使用

use_only_on_clear_day
只在非雨天使用

5. ScheduleEntry 的时间比较

为了方便比较,可以加一个方法:

func get_minutes_of_day() -> int:
    return hour * 60 + minute

比如:

06:00 = 360
09:30 = 570
18:00 = 1080

这样比较时间就非常简单。

完整:

class_name ScheduleEntry
extends Resource

enum ActionType {
    IDLE,
    WALK,
    WORK,
    SHOP,
    SLEEP,
    EAT,
    SOCIAL
}

@export_range(0, 23) var hour: int = 6
@export_range(0, 59) var minute: int = 0

@export var map_id: String = ""
@export var point_id: String = ""

@export var action_type: ActionType = ActionType.IDLE

@export var can_interact: bool = true
@export var can_shop: bool = false

@export var use_only_on_rainy_day: bool = false
@export var use_only_on_clear_day: bool = false

func get_minutes_of_day() -> int:
    return hour * 60 + minute

6. NPCScheduleData

一个 NPC 会有很多条日程。

创建:

res://scripts/npc/npc_schedule_data.gd
class_name NPCScheduleData
extends Resource

@export var npc_id: String = ""
@export var entries: Array[ScheduleEntry] = []

然后可以创建资源:

res://data/npcs/schedules/merchant_schedule.tres
res://data/npcs/schedules/villager_schedule.tres

这样每个 NPC 可以拖自己的日程资源。

7. 日程资源示例:商人

merchant_schedule.tres

npc_id = village_merchant
entries:
  06:00 house_interior merchant_bed SLEEP can_interact=false can_shop=false
  08:00 village merchant_shop_counter SHOP can_interact=true can_shop=true
  18:00 village merchant_shop_counter IDLE can_interact=true can_shop=false
  20:00 house_interior merchant_bed SLEEP can_interact=false can_shop=false

解释:

6 点在家睡觉不能交互
8 点到村庄商店柜台可以开店
18 点仍在店里但不能交易
20 点回家睡觉

第一版可以不做真实“从家走到商店”的过程。

直接在目标时间出现在目标点。

后面再做跨地图真实行走。

8. 雨天日程示例

村民晴天去广场,雨天待家里。

晴天
08:00 village plaza SOCIAL

雨天
08:00 house_interior villager_home IDLE use_only_on_rainy_day=true

具体资源里可以这样:

08:00 village plaza SOCIAL use_only_on_clear_day=true
08:00 house_interior villager_home IDLE use_only_on_rainy_day=true

然后 ScheduleManager 根据天气过滤。

9. NPCScheduleManager 是什么

日程选择可以写在每个 NPC 里。

但如果后面 NPC 多了,最好有一个统一的管理器。

第一版可以先做:

NPC 自己根据 TimeManager WeatherManager 选择日程

不用单独做 ScheduleManager。

这样更直观:

每个 NPC 拿着自己的 schedule_data
自己判断自己当前该去哪

等 NPC 数量多了,再抽出 NPCScheduleManager。

10. NPC 场景结构

第一版 NPC 用 CharacterBody2D 更好。

因为它可能要移动。

NPC CharacterBody2D
├── AnimatedSprite2D
├── CollisionShape2D
├── NavigationAgent2D
├── InteractArea Area2D
└── Label Marker

如果 NPC 暂时不移动,只是瞬移到目标点,用 Area2D 也可以。

但为了后面能走路,建议从现在开始用:

CharacterBody2D

结构:

NPC CharacterBody2D
├── AnimatedSprite2D
├── CollisionShape2D
├── NavigationAgent2D
└── InteractArea Area2D

NavigationAgent2D 不会自动移动父节点;设置 target_position 后,需要每个物理帧调用 get_next_path_position() 获取下一个路径点,然后由你自己的移动代码推动 NPC。(Godot Engine documentation)

11. NPCScheduleController.gd

给 NPC 挂脚本:

res://scripts/npc/npc_schedule_controller.gd
extends CharacterBody2D

@export var npc_id: String = ""
@export var npc_name: String = "NPC"
@export var prompt_text: String = "按 E 对话"

@export var schedule_data: NPCScheduleData
@export var dialogue_box: Control
@export var shop_panel: Control
@export var shop_data: ShopData

@export var move_speed: float = 45.0

@onready var animated_sprite: AnimatedSprite2D = $AnimatedSprite2D
@onready var navigation_agent: NavigationAgent2D = $NavigationAgent2D

var current_entry: ScheduleEntry = null
var target_position: Vector2
var is_available_on_current_map: bool = true

这里先保留:

dialogue_box
shop_panel
shop_data

因为有些 NPC 是普通对话,有些 NPC 是商人。

12. NPC 连接时间和天气信号

func _ready():
    TimeManager.time_changed.connect(_on_time_changed)
    WeatherManager.weather_changed.connect(_on_weather_changed)
    SceneManager.map_changed.connect(_on_map_changed)

    update_schedule()

这里监听:

time_changed
时间走了可能要换日程

weather_changed
天气变了雨天日程可能要生效

map_changed
切地图后NPC 要判断自己是否应该在当前地图出现

不过 time_changed 每分钟触发一次,如果 NPC 很多,可能会频繁更新。

第一版没关系。

后面可以每 10 分钟更新一次,或者只在小时变化时更新。

13. 获取当前时间分钟数

TimeManager 可以加:

func get_minutes_of_day() -> int:
    return hour * 60 + minute

这样 NPC 可以用:

var now = TimeManager.get_minutes_of_day()

14. update_schedule()

核心逻辑:

获取当前时间
 schedule_data.entries 里找到当前应该使用的 entry
如果 entry 变了就应用新日程

代码:

func update_schedule():
    if schedule_data == null:
        return

    var next_entry = get_current_schedule_entry()

    if next_entry == current_entry:
        return

    current_entry = next_entry
    apply_schedule_entry(current_entry)

15. get_current_schedule_entry()

func get_current_schedule_entry() -> ScheduleEntry:
    if schedule_data == null:
        return null

    var now_minutes = TimeManager.get_minutes_of_day()
    var valid_entries: Array[ScheduleEntry] = []

    for entry in schedule_data.entries:
        if entry == null:
            continue

        if not is_entry_allowed_by_weather(entry):
            continue

        if entry.get_minutes_of_day() <= now_minutes:
            valid_entries.append(entry)

    if valid_entries.is_empty():
        return get_last_valid_entry_before_midnight()

    valid_entries.sort_custom(func(a, b):
        return a.get_minutes_of_day() < b.get_minutes_of_day()
    )

    return valid_entries[valid_entries.size() - 1]

这个逻辑是:

找出所有开始时间 <= 当前时间的日程
取其中最晚的一条

比如现在 13:30:

06:00 睡觉
08:00 开店
12:00 吃饭
18:00 回家

当前应该选:

12:00 吃饭

16. 跨午夜的问题

如果现在是凌晨 02:00。

当天还没有任何:

时间 <= 02:00

的日程,怎么办?

应该使用昨天最后一条日程。

比如:

20:00 睡觉

所以写:

func get_last_valid_entry_before_midnight() -> ScheduleEntry:
    var valid_entries: Array[ScheduleEntry] = []

    for entry in schedule_data.entries:
        if entry == null:
            continue

        if not is_entry_allowed_by_weather(entry):
            continue

        valid_entries.append(entry)

    if valid_entries.is_empty():
        return null

    valid_entries.sort_custom(func(a, b):
        return a.get_minutes_of_day() < b.get_minutes_of_day()
    )

    return valid_entries[valid_entries.size() - 1]

这样凌晨 02:00 会继续使用前一天晚上那条日程。

17. 天气过滤

func is_entry_allowed_by_weather(entry: ScheduleEntry) -> bool:
    var weather = WeatherManager.current_weather

    var is_rainy = weather == WeatherManager.Weather.RAIN or weather == WeatherManager.Weather.STORM

    if entry.use_only_on_rainy_day and not is_rainy:
        return false

    if entry.use_only_on_clear_day and is_rainy:
        return false

    return true

这里先把:

RAIN
STORM

视为雨天。

你也可以把 SNOW 也算恶劣天气:

var is_bad_weather = weather == WeatherManager.Weather.RAIN \
    or weather == WeatherManager.Weather.STORM \
    or weather == WeatherManager.Weather.SNOW

第一版按你的设计来。

18. apply_schedule_entry()

func apply_schedule_entry(entry: ScheduleEntry):
    if entry == null:
        visible = false
        set_physics_process(false)
        return

    is_available_on_current_map = entry.map_id == SceneManager.current_map_id

    visible = is_available_on_current_map
    set_physics_process(is_available_on_current_map)

    if not is_available_on_current_map:
        return

    var point = find_schedule_point(entry.point_id)

    if point == null:
        push_warning("找不到日程点:" + entry.point_id)
        return

    target_position = point.global_position
    navigation_agent.target_position = target_position

    update_action_visual(entry)

这里做了:

判断 NPC 是否在当前地图
如果不在当前地图隐藏并停止处理
如果在当前地图找到目标点
 NPC 朝目标点移动
更新动作表现

19. NPC 在不在当前地图

这个设计非常重要。

如果 NPC 的当前日程是:

map_id = house_interior

而玩家现在在:

village

那这个 NPC 不应该出现在 village。

所以:

is_available_on_current_map = entry.map_id == SceneManager.current_map_id
visible = is_available_on_current_map
set_physics_process(is_available_on_current_map)

第一版用隐藏就够了。

以后如果你把 NPC 做成全局实体,可以在切地图时重新挂到对应地图。

现在先简单。

20. find_schedule_point()

每张地图需要放日程点。

比如 VillageMap:

SchedulePoints Node2D
├── merchant_shop_counter Marker2D
├── plaza Marker2D
├── village_well Marker2D
└── blacksmith_workplace Marker2D

HouseInterior:

SchedulePoints Node2D
├── merchant_bed Marker2D
├── villager_home Marker2D
└── dining_table Marker2D

查找:

func find_schedule_point(point_id: String) -> Node2D:
    if SceneManager.current_map == null:
        return null

    var points = SceneManager.current_map.get_node_or_null("SchedulePoints")

    if points == null:
        return null

    return points.get_node_or_null(point_id) as Node2D

21. NPC 移动到目标点

_physics_process

func _physics_process(delta):
    if current_entry == null:
        return

    if not is_available_on_current_map:
        return

    move_to_schedule_target(delta)

移动:

func move_to_schedule_target(delta):
    if global_position.distance_to(target_position) <= 4.0:
        velocity = Vector2.ZERO
        move_and_slide()
        play_idle_animation()
        return

    if navigation_agent.is_navigation_finished():
        velocity = Vector2.ZERO
        move_and_slide()
        play_idle_animation()
        return

    var next_path_position = navigation_agent.get_next_path_position()
    var direction = global_position.direction_to(next_path_position)

    velocity = direction * move_speed
    move_and_slide()

    play_walk_animation(direction)

注意:NavigationAgent2D 的路径不会自动让 NPC 动起来,你还是要自己设置 velocitymove_and_slide()。(Godot Engine documentation)

22. 简化版:瞬移到目标点

如果你觉得 NPC 寻路移动暂时麻烦,第一版可以直接瞬移:

func apply_schedule_entry(entry: ScheduleEntry):
    ...
    var point = find_schedule_point(entry.point_id)

    if point == null:
        return

    global_position = point.global_position

这样 NPC 会在时间点直接出现在目标位置。

这不够自然,但非常适合第一版验证日程系统。

建议路线:

第一步瞬移版验证日程正确
第二步移动版加入 NavigationAgent2D
第三步跨地图移动和真实路程

别上来就做最难的。

23. update_action_visual()

根据行为播放不同动画。

func update_action_visual(entry: ScheduleEntry):
    match entry.action_type:
        ScheduleEntry.ActionType.SLEEP:
            play_animation("sleep")

        ScheduleEntry.ActionType.SHOP:
            play_animation("idle")

        ScheduleEntry.ActionType.WORK:
            play_animation("work")

        ScheduleEntry.ActionType.EAT:
            play_animation("eat")

        _:
            play_animation("idle")

安全播放:

func play_animation(anim_name: String):
    if animated_sprite == null:
        return

    if animated_sprite.sprite_frames == null:
        return

    if not animated_sprite.sprite_frames.has_animation(anim_name):
        anim_name = "idle"

    if animated_sprite.animation != anim_name:
        animated_sprite.play(anim_name)

如果你暂时只有 idle/walk,就都 fallback 到 idle。

24. 普通对话 NPC 怎么处理交互

func interact(player: Node):
    if current_entry == null:
        return

    if not current_entry.can_interact:
        print(npc_name, "现在不能交互")
        return

    if current_entry.can_shop:
        open_shop(player)
        return

    start_dialogue(player)

这样日程可以控制:

睡觉时不能交互
工作时可以对话
开店时打开商店
晚上在店里但不交易

25. open_shop()

func open_shop(player: Node):
    if shop_panel == null:
        print("没有设置 shop_panel")
        return

    if shop_data == null:
        print("没有设置 shop_data")
        return

    if shop_panel.has_method("open_shop"):
        shop_panel.open_shop(shop_data, player)

26. start_dialogue()

@export var default_dialogue_lines: Array[String] = [
    "你好。"
]

@export var after_hours_lines: Array[String] = [
    "今天已经打烊了,明天再来吧。"
]
func start_dialogue(player: Node):
    if dialogue_box == null:
        print("没有设置 dialogue_box")
        return

    var lines = get_dialogue_lines()

    if dialogue_box.has_method("start_dialogue"):
        dialogue_box.start_dialogue(npc_name, lines)
func get_dialogue_lines() -> Array[String]:
    if current_entry != null:
        if current_entry.action_type == ScheduleEntry.ActionType.SLEEP:
            return ["呼……"]

        if current_entry.action_type == ScheduleEntry.ActionType.SHOP and not current_entry.can_shop:
            return after_hours_lines

    return default_dialogue_lines

27. 商人晚上关店

商人的日程可以这样:

08:00 village merchant_shop_counter SHOP can_interact=true can_shop=true
18:00 village merchant_shop_counter SHOP can_interact=true can_shop=false
20:00 house_interior merchant_bed SLEEP can_interact=false can_shop=false

于是:

08:00 - 17:59
玩家交互打开商店

18:00 - 19:59
玩家交互NPC打烊了

20:00 以后
NPC 在家睡觉不在商店地图出现

这就已经很像 RPG 了。

28. MapBase 动态注入 UI 引用

前面地图动态加载后,需要给 NPC 注入:

dialogue_box
shop_panel

MapBase.gd:

func setup_interactable(node: Node):
    if "dialogue_box" in node and ui != null:
        node.dialogue_box = ui.get_node_or_null("DialogueBox")

    if "shop_panel" in node and ui != null:
        node.shop_panel = ui.get_node_or_null("ShopPanel")

    if "quest_manager" in node:
        node.quest_manager = quest_manager

对于 NPCScheduleController 也一样能用。

29. NPC 跨地图的问题

第一版我们用:

如果 NPC 当前日程 map_id != 当前地图
就隐藏

这很简单。

但有个现实问题:

NPC 节点放在哪张地图里

如果 NPC 是放在 VillageMap 里的,那么玩家进入 HouseInterior 时,这个 NPC 节点被旧地图卸载,就不存在了。

所以有两种设计。

30. 设计 A:NPC 放在各自地图里

比如:

VillageMap 里放商人 NPC
HouseInterior 里也放一个商人 NPC

它们使用同一个 npc_id 和 schedule_data。

根据日程判断是否显示。

优点:

简单
不用跨地图搬运 NPC
每张地图自己管理显示

缺点:

同一个 NPC 可能在多个场景里有多个副本
要确保只有当前日程所在地图的副本显示

第一版推荐这个。

31. 设计 B:NPC 是全局实体

NPC 放在 Main 下,切地图时根据当前地图挂到地图里或显示隐藏。

优点:

同一个 NPC 只有一个实例
状态更统一

缺点:

管理复杂
跨地图移动麻烦
需要 NPCManager

第一版不推荐。

你现在要的是能跑,不是模拟一个镇政府人口迁移数据库。

32. 多副本 NPC 如何避免重复

假设商人在 VillageMap 和 HouseInterior 都有一个副本。

两个副本都用:

npc_id = village_merchant
schedule_data = merchant_schedule

当前日程:

map_id = village

那么:

VillageMap 的商人副本显示
HouseInterior 的商人副本因为地图没加载所以不用管

当玩家进入 HouseInterior:

HouseInterior 的商人副本加载
它检查当前日程 map_id
如果当前日程不是 house_interior就隐藏

这样能跑。

缺点是如果 NPC 有临时状态,比如“正在对话中”“心情变化”,多副本会麻烦。

但第一版够用。

33. NPC 日程和任务状态

以后可以让任务影响日程。

比如:

任务完成前
NPC 在村庄

任务完成后
NPC 去森林入口

剧情后
NPC 消失

ScheduleEntry 可以加:

@export var required_completed_quest_id: String = ""
@export var forbidden_completed_quest_id: String = ""

过滤时:

if entry.required_completed_quest_id != "":
    if not quest_manager.is_completed(entry.required_completed_quest_id):
        return false

第一版先不做。

但你要知道日程本质上就是“条件选择”。

条件可以来自:

时间
天气
季节
任务
地图状态
玩家关系

34. NPC 日程和天气

现在我们只做:

雨天专用日程
晴天专用日程

后面可以细分:

雪天在家
暴雨不开店
雾天不去森林
晴天去广场

ScheduleEntry 可以扩展:

@export var allowed_weathers: Array[WeatherManager.Weather] = []

但 GDScript Resource 导出自定义枚举数组有时编辑体验不一定最顺。

第一版用两个 bool:

use_only_on_rainy_day
use_only_on_clear_day

更简单。

35. NPC 到达目标点后做什么

当前代码到达目标点后:

停止移动
播放 idle

后面可以让它根据 action 播放:

SHOP站立
WORK工作动画
SLEEP睡觉
EAT吃饭
SOCIAL闲聊

可以写:

func play_idle_animation():
    if current_entry == null:
        play_animation("idle")
        return

    match current_entry.action_type:
        ScheduleEntry.ActionType.SLEEP:
            play_animation("sleep")
        ScheduleEntry.ActionType.WORK:
            play_animation("work")
        ScheduleEntry.ActionType.EAT:
            play_animation("eat")
        _:
            play_animation("idle")

36. NPC 走路动画

func play_walk_animation(direction: Vector2):
    play_animation("walk")

    if direction.x != 0:
        animated_sprite.flip_h = direction.x < 0

如果你有四方向动画,可以像 Player 一样:

walk_down
walk_up
walk_left
walk_right

但第一版 NPC 可以先用左右 flip。

37. NPCScheduleController 完整基础版

extends CharacterBody2D

@export var npc_id: String = ""
@export var npc_name: String = "NPC"
@export var prompt_text: String = "按 E 对话"

@export var schedule_data: NPCScheduleData

@export var dialogue_box: Control
@export var shop_panel: Control
@export var shop_data: ShopData

@export var default_dialogue_lines: Array[String] = [
    "你好。"
]

@export var after_hours_lines: Array[String] = [
    "今天已经打烊了,明天再来吧。"
]

@export var move_speed: float = 45.0

@onready var animated_sprite: AnimatedSprite2D = $AnimatedSprite2D
@onready var navigation_agent: NavigationAgent2D = $NavigationAgent2D

var current_entry: ScheduleEntry = null
var target_position: Vector2
var is_available_on_current_map: bool = true

func _ready():
    TimeManager.time_changed.connect(_on_time_changed)
    WeatherManager.weather_changed.connect(_on_weather_changed)
    SceneManager.map_changed.connect(_on_map_changed)

    update_schedule()

func _physics_process(delta):
    if current_entry == null:
        return

    if not is_available_on_current_map:
        return

    move_to_schedule_target(delta)

func _on_time_changed(_hour: int, _minute: int):
    update_schedule()

func _on_weather_changed(_weather):
    update_schedule()

func _on_map_changed(_map_id: String):
    update_schedule()

func update_schedule():
    if schedule_data == null:
        return

    var next_entry = get_current_schedule_entry()

    if next_entry == current_entry:
        return

    current_entry = next_entry
    apply_schedule_entry(current_entry)

func get_current_schedule_entry() -> ScheduleEntry:
    if schedule_data == null:
        return null

    var now_minutes = TimeManager.get_minutes_of_day()
    var valid_entries: Array[ScheduleEntry] = []

    for entry in schedule_data.entries:
        if entry == null:
            continue

        if not is_entry_allowed_by_weather(entry):
            continue

        if entry.get_minutes_of_day() <= now_minutes:
            valid_entries.append(entry)

    if valid_entries.is_empty():
        return get_last_valid_entry_before_midnight()

    valid_entries.sort_custom(func(a, b):
        return a.get_minutes_of_day() < b.get_minutes_of_day()
    )

    return valid_entries[valid_entries.size() - 1]

func get_last_valid_entry_before_midnight() -> ScheduleEntry:
    var valid_entries: Array[ScheduleEntry] = []

    for entry in schedule_data.entries:
        if entry == null:
            continue

        if not is_entry_allowed_by_weather(entry):
            continue

        valid_entries.append(entry)

    if valid_entries.is_empty():
        return null

    valid_entries.sort_custom(func(a, b):
        return a.get_minutes_of_day() < b.get_minutes_of_day()
    )

    return valid_entries[valid_entries.size() - 1]

func is_entry_allowed_by_weather(entry: ScheduleEntry) -> bool:
    var weather = WeatherManager.current_weather

    var is_rainy = weather == WeatherManager.Weather.RAIN or weather == WeatherManager.Weather.STORM

    if entry.use_only_on_rainy_day and not is_rainy:
        return false

    if entry.use_only_on_clear_day and is_rainy:
        return false

    return true

func apply_schedule_entry(entry: ScheduleEntry):
    if entry == null:
        visible = false
        set_physics_process(false)
        return

    is_available_on_current_map = entry.map_id == SceneManager.current_map_id

    visible = is_available_on_current_map
    set_physics_process(is_available_on_current_map)

    if not is_available_on_current_map:
        return

    var point = find_schedule_point(entry.point_id)

    if point == null:
        push_warning("找不到日程点:" + entry.point_id)
        return

    target_position = point.global_position
    navigation_agent.target_position = target_position

    update_action_visual(entry)

func find_schedule_point(point_id: String) -> Node2D:
    if SceneManager.current_map == null:
        return null

    var points = SceneManager.current_map.get_node_or_null("SchedulePoints")

    if points == null:
        return null

    return points.get_node_or_null(point_id) as Node2D

func move_to_schedule_target(delta):
    if global_position.distance_to(target_position) <= 4.0:
        velocity = Vector2.ZERO
        move_and_slide()
        play_idle_animation()
        return

    if navigation_agent.is_navigation_finished():
        velocity = Vector2.ZERO
        move_and_slide()
        play_idle_animation()
        return

    var next_path_position = navigation_agent.get_next_path_position()
    var direction = global_position.direction_to(next_path_position)

    velocity = direction * move_speed
    move_and_slide()

    play_walk_animation(direction)

func interact(player: Node):
    if current_entry == null:
        return

    if not current_entry.can_interact:
        print(npc_name, "现在不能交互")
        return

    if current_entry.can_shop:
        open_shop(player)
        return

    start_dialogue(player)

func open_shop(player: Node):
    if shop_panel == null:
        print("没有设置 shop_panel")
        return

    if shop_data == null:
        print("没有设置 shop_data")
        return

    if shop_panel.has_method("open_shop"):
        shop_panel.open_shop(shop_data, player)

func start_dialogue(player: Node):
    if dialogue_box == null:
        print("没有设置 dialogue_box")
        return

    var lines = get_dialogue_lines()

    if dialogue_box.has_method("start_dialogue"):
        dialogue_box.start_dialogue(npc_name, lines)

func get_dialogue_lines() -> Array[String]:
    if current_entry != null:
        if current_entry.action_type == ScheduleEntry.ActionType.SLEEP:
            return ["呼……"]

        if current_entry.action_type == ScheduleEntry.ActionType.SHOP and not current_entry.can_shop:
            return after_hours_lines

    return default_dialogue_lines

func update_action_visual(entry: ScheduleEntry):
    play_idle_animation()

func play_idle_animation():
    if current_entry == null:
        play_animation("idle")
        return

    match current_entry.action_type:
        ScheduleEntry.ActionType.SLEEP:
            play_animation("sleep")
        ScheduleEntry.ActionType.WORK:
            play_animation("work")
        ScheduleEntry.ActionType.EAT:
            play_animation("eat")
        _:
            play_animation("idle")

func play_walk_animation(direction: Vector2):
    play_animation("walk")

    if direction.x != 0:
        animated_sprite.flip_h = direction.x < 0

func play_animation(anim_name: String):
    if animated_sprite == null:
        return

    if animated_sprite.sprite_frames == null:
        return

    if not animated_sprite.sprite_frames.has_animation(anim_name):
        anim_name = "idle"

    if animated_sprite.animation != anim_name:
        animated_sprite.play(anim_name)

38. 先用瞬移版调试

如果上面移动版太多,你可以先把 apply_schedule_entry() 改成瞬移:

func apply_schedule_entry(entry: ScheduleEntry):
    if entry == null:
        visible = false
        set_physics_process(false)
        return

    is_available_on_current_map = entry.map_id == SceneManager.current_map_id

    visible = is_available_on_current_map
    set_physics_process(false)

    if not is_available_on_current_map:
        return

    var point = find_schedule_point(entry.point_id)

    if point == null:
        push_warning("找不到日程点:" + entry.point_id)
        return

    global_position = point.global_position
    target_position = point.global_position

    update_action_visual(entry)

先验证:

时间到了NPC 会换位置
雨天NPC 会换日程
晚上商店会关闭

等这些都对了,再打开移动。

39. NPC 日程点怎么放

每张地图加:

SchedulePoints Node2D
├── merchant_shop_counter Marker2D
├── merchant_home_bed Marker2D
├── villager_plaza Marker2D
├── villager_home Marker2D
└── blacksmith_workplace Marker2D

注意:

point_id 必须和 Marker2D 节点名一致

比如 entry.point_id 是:

merchant_shop_counter

地图里就要有:

SchedulePoints/merchant_shop_counter

大小写也要一致。

40. 日程系统和 groups

NPC 可以加入 group:

npc

这样以后可以批量刷新 NPC:

for npc in get_tree().get_nodes_in_group("npc"):
    if npc.has_method("update_schedule"):
        npc.update_schedule()

Godot 的 group 像标签一样,一个节点可以加入多个 group,代码中可以通过 SceneTree 获取组内节点、调用组内节点方法或发送通知,适合组织大型场景并降低耦合。(Godot Engine documentation)

第一版 NPC 自己连 TimeManager 就够。

但以后 NPC 多了,可以由 NPCScheduleManager 批量调用。

41. NPC 太多时怎么优化

如果你有几十上百个 NPC,每分钟所有 NPC 都更新日程也许没问题。

但如果更多,可以优化:

只在小时变化时更新日程
只在玩家当前地图更新 NPC
只让 NPCScheduleManager 统一更新
远离玩家的 NPC 不做寻路移动
不在当前地图的 NPC 不实例化

第一版不用管。

你现在 NPC 很少,先让逻辑跑起来。

42. NPC 真实跨地图移动以后怎么做

以后如果想让 NPC 真正从家走到商店,需要复杂很多:

室内走到门口
切到村庄
从房门口出现
走到商店柜台

这会牵涉:

跨地图路径
门连接图
NPCManager
离屏模拟
地图未加载时的位置推算

第一版不做。

用“当前日程在哪张地图,当前地图对应副本显示”就够。

43. NPC 日程是否要存档

一般来说,NPC 日程本身不需要保存。

因为它是根据:

当前时间
天气
任务状态

算出来的。

读档时只要恢复:

TimeManager
WeatherManager
QuestManager

NPC 加载后会自动算出自己应该在哪里。

所以不用保存:

NPC 当前坐标
NPC 当前日程

除非某个 NPC 有特殊临时状态。

原则还是:

能算出来的状态尽量不要存

44. 读档后 NPC 状态不对怎么办

优先检查读档顺序:

恢复 TimeManager
恢复 WeatherManager
恢复 QuestManager
切地图
地图加载 NPC
NPC update_schedule

如果 NPC 在地图加载前读取了旧时间/旧天气,就会错。

所以读档顺序要保持:

时间天气任务这些全局状态先恢复
地图再加载

45. 日程与商店营业时间

商店现在不需要单独判断营业时间。

让日程控制即可。

08:00 SHOP can_shop=true
18:00 SHOP can_shop=false

NPC.interact:

can_shop = true打开 ShopPanel
can_shop = false显示打烊对话

这样营业时间是 NPC 日程的一部分。

比在 ShopPanel 里硬判断时间更灵活。

46. 日程与对话内容

不同日程可以有不同对话。

第一版只用:

默认对话
打烊对话
睡觉对话

以后可以让 ScheduleEntry 加:

@export var dialogue_lines: Array[String] = []

然后:

if not current_entry.dialogue_lines.is_empty():
    return current_entry.dialogue_lines

这样 NPC 在不同地点会说不同话。

比如:

在广场今天阳光不错
在雨天家里这种天气还是别出门了
在商店需要买点什么吗
晚上已经打烊了

47. 日程与雨天对话

可以简单写:

@export var rainy_dialogue_lines: Array[String] = [
    "外面雨太大了,今天我就不出门了。"
]

get_dialogue_lines()

func get_dialogue_lines() -> Array[String]:
    var weather = WeatherManager.current_weather
    var is_rainy = weather == WeatherManager.Weather.RAIN or weather == WeatherManager.Weather.STORM

    if is_rainy and not rainy_dialogue_lines.is_empty():
        return rainy_dialogue_lines

    if current_entry != null:
        if current_entry.action_type == ScheduleEntry.ActionType.SLEEP:
            return ["呼……"]

        if current_entry.action_type == ScheduleEntry.ActionType.SHOP and not current_entry.can_shop:
            return after_hours_lines

    return default_dialogue_lines

这样雨天 NPC 会更有生活感。

48. 常见错误 1:NPC 不出现

排查:

NPC schedule_data 是否设置
ScheduleEntry.map_id 是否等于 SceneManager.current_map_id
current_map_id 是否正确
SchedulePoints 是否有对应 point_id
NPC visible 是否被设置为 false
当前时间是否匹配到了预期日程
天气条件是否过滤掉了所有日程

临时打印:

print(npc_name, " 当前地图:", SceneManager.current_map_id)
print("日程地图:", current_entry.map_id)
print("日程点:", current_entry.point_id)

49. 常见错误 2:NPC 出现在错误位置

排查:

point_id 是否拼错
SchedulePoints Marker2D 名称是否正确
目标地图是否有同名日程点
NPC 是不是旧地图副本
apply_schedule_entry 是否执行

最常见就是:

entry.point_id = merchant_shop_counter
但地图里节点叫 MerchantShopCounter

大小写不一致也不行。

50. 常见错误 3:雨天日程不生效

排查:

WeatherManager.current_weather 是否真的是 RAIN/STORM
ScheduleEntry.use_only_on_rainy_day 是否勾选
晴天日程是否也同时满足
get_current_schedule_entry 是否过滤天气

如果晴天和雨天日程同一时间都满足,要确认过滤逻辑只留下正确的。

51. 常见错误 4:商店晚上还能打开

排查:

18:00 之后的 ScheduleEntry.can_shop 是否为 false
NPC.interact 是否判断 current_entry.can_shop
是否还有更晚的 SHOP can_shop=true 日程覆盖
当前时间是否真的已经过了 18:00

52. 常见错误 5:NPC 移动时撞墙

排查:

地图是否有 NavigationRegion2D TileMap 导航
NPC 是否有 NavigationAgent2D
NPC 是否在导航区域内
目标 SchedulePoint 是否在导航区域内
碰撞体是否太大
导航区域是否太贴墙

先用瞬移版确认日程没问题,再排查寻路。

53. 常见错误 6:NPC 一直抖动

尝试:

增大到达距离
调整 NavigationAgent2D.path_desired_distance
调整 target_desired_distance

比如:

navigation_agent.path_desired_distance = 4.0
navigation_agent.target_desired_distance = 6.0

并且到达目标后手动:

velocity = Vector2.ZERO

54. 常见错误 7:读档后 NPC 站错地方

排查:

读档时是否先恢复 TimeManager
是否先恢复 WeatherManager
是否先恢复 QuestManager
 SceneManager.change_map
NPC._ready 是否调用 update_schedule
SceneManager.map_changed 是否触发 update_schedule

日程是算出来的,不是存出来的。

所以要保证它读取到的是正确的全局状态。

55. 这一部分最重要的记忆点

NPC 日程本质是时间条件 + 地图位置 + 行为状态
ScheduleEntry 表示一条日程
NPCScheduleData 保存某个 NPC 的所有日程
第一版可以让 NPC 根据 TimeManager 自己更新日程
NPC 当前日程不在当前地图时隐藏
SchedulePoints Marker2D 放日程目标点
商店营业时间可以通过 can_shop 控制
雨天日程可以通过天气过滤控制
第一版可以先瞬移后面再用 NavigationAgent2D 移动
NPC 日程通常不需要存档因为可以根据时间天气任务状态计算

56. 你现在的小练习

按这个顺序做:

1. 创建 ScheduleEntry.gd
2. 创建 NPCScheduleData.gd
3. 创建 merchant_schedule.tres
4. merchant_schedule 添加 06:00 在家睡觉
5. 添加 08:00 在村庄商店开店
6. 添加 18:00 在商店但 can_shop=false
7. 添加 20:00 回家睡觉
8. VillageMap 添加 SchedulePoints
9. 添加 merchant_shop_counter Marker2D
10. HouseInterior 添加 SchedulePoints
11. 添加 merchant_bed Marker2D
12. 创建 NPCScheduleController.gd
13. 商人 NPC 设置 schedule_data
14. 商人 NPC 设置 shop_data
15. MapBase 给商人注入 ShopPanel
16. 先用瞬移版 apply_schedule_entry
17. 测试 08:00 商人在商店
18. 测试 18:00 商人还在商店但打不开商店
19. 测试 20:00 商人在家
20. 创建雨天日程
21. 雨天时村民待在家里
22. 晴天时村民去广场
23. 再把瞬移版改成 NavigationAgent2D 移动版

完成这一部分之后,你的 NPC 就从“站着等玩家点”变成了“会根据时间和天气生活”的角色。

如果您觉得这篇文章有帮助,请点个赞吧~

分享文章

相关文章

更多文章 →
godot2026-07-27
Godot 4 常用 UI 节点详解
Godot 4 常用 UI 节点详解 在 Godot 4 中,UI 系统基于 Control(控件) 节点构建。所有 UI 节点都继承自 Control,形成一棵完整的 UI 树。与游戏引擎中常见的"Canvas + DOM"模式不同,Godot 的 UI 系统是声明式的——你在场景中搭好节点树,引擎自动完成布局计算。 一、布局系统:Container 家族 Container 是 Godot UI 的 骨架 。它决定了子节点的大小和位...
学习
godot2026-07-09
Godot 4 自动地形系统(AutoTileSet / Terrains)完全指南
前言 在 2D 游戏开发中,地形瓦片(tile)的拼接是一个绕不开的问题。想象一下:你有一片草地、一条河流、一段平台——如果每一块边缘、角落、过渡区域都要手动选择对应的瓦片图,工作量将是巨大的。 自动地形系统 就是为了解决这个问题而生的。 一、什么是自动地形(Autotiling)? 自动地形的核心思想很简单: 你只管画,引擎帮你选对瓦片 。 当你在 TileMap 上绘制地形时,引擎会自动检测每个瓦片的上下左右邻居,然后根据预设的规则...
学习
godot2026-06-25
Godot 中 zindex 和 ysort 的区别总结
在 Godot 2D 游戏开发中,角色、树木、怪物、地面、技能特效、UI 都需要正确的显示顺序。比如角色走到树前面时,角色应该挡住树;角色走到树后面时,树又应该挡住角色。 这种显示顺序主要和两个概念有关: 和 。 其中 用来手动控制图层顺序, 用来根据物体的 Y 坐标自动排序。 一句话理解 是手动分层。 是根据 Y 坐标自动排序。 简单来说: | 属性 | 作用 | 适合场景 | | | | | | | 数值越大,显示越靠前 | 地面、...
学习
godot2026-06-11
Tileset 资源图的标准和规范
一、什么是 Tileset 资源图 Tileset,中文通常叫“图块资源图”或“瓦片图”,是 2D 游戏中非常常见的一种地图资源组织方式。 简单来说,Tileset 就是把很多小图块按照固定尺寸排列在一张图片里。游戏引擎会按照固定的格子大小去切割这张图片,然后把每个小格子当成一个独立的地图块使用。 比如一个 32×32 像素的 Tileset 中,每一个 tile 都是 32×32 像素。地图编辑器或游戏引擎会按照 32×32 的网格,...
学习
godot2026-05-29
用户角色精灵图制作角色的完整流程
在 2D RPG 游戏里,角色通常不是用一张单独图片完成的,而是用一张“角色精灵图”来做。 所谓角色精灵图,通常是一张包含多个动作帧的大图。比如角色向下走有 4 帧,向左走有 4 帧,向右走有 4 帧,向上走有 4 帧。Godot 会根据这些帧不断切换图片,看起来角色就动起来了。 这篇文章主要介绍:拿到一张角色精灵图之后,如何在 Godot 中把它做成一个可以正常移动、播放动画、和地图产生遮挡关系的角色。 一、先理解角色精灵图是什么 角...
学习
godot2026-05-26
Godot 节点系统详细介绍
Godot 里最核心的东西不是“类”,也不是“组件”,而是 节点 Node 。 你可以把 Godot 的节点理解成: 在前端里,一个页面是由很多 DOM 元素组成的; 在 Godot 里,一个游戏场景是由很多 Node 节点组成的。 比如一个玩家角色,可能不是一个单独对象,而是这样的结构: 这里的 是根节点,下面挂着显示图片、播放动画、碰撞检测、摄像机、音效等子节点。 Godot 官方文档也把节点和场景放在一起讲:多个节点组成树状结构后...
学习

评论

请登录后发表评论

去登录
加载评论中...