第二十三部分:NPC 日程系统基础:按时间移动、白天开店、晚上回家、雨天待在室内
这一部分开始做 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 动起来,你还是要自己设置 velocity 并 move_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 就从“站着等玩家点”变成了“会根据时间和天气生活”的角色。
如果您觉得这篇文章有帮助,请点个赞吧~
评论
请登录后发表评论
去登录