第二十一部分:时间系统基础:游戏内时钟、白天黑夜、日期、季节、每日刷新、睡觉存档

2026-05-19
375413 分钟
...

这一部分开始给游戏世界加“时间感”。

前面我们的世界已经有:

地图
NPC
怪物
任务
商店
背包
装备
存档
地图状态

但它还是比较静态:

永远是同一个白天
NPC 永远站在那里
草药采了就永久没了
商店永远不刷新
玩家没有睡觉到第二天的感觉

时间系统要解决的就是:

让世界开始流动

比如:

早晨
白天
傍晚
夜晚

 1
 2
 3

春天
夏天
秋天
冬天

每天刷新采集点
每天刷新商店库存
睡觉保存游戏
NPC 根据时间改变位置
夜晚出现特殊怪物

这套系统一出来,你的 RPG 就会开始有“生活模拟”的味道。

1. 游戏时间和真实时间不是一回事

先分清两个概念:

真实时间
现实世界过去了多少秒

游戏时间
游戏世界里过去了多少分钟小时

比如:

现实 1 = 游戏 1 分钟
现实 60 = 游戏 1 小时
现实 24 分钟 = 游戏 1

这就是很多 RPG / 农场游戏 / 模拟经营游戏的时间压缩。

不要用电脑系统时间直接当游戏时间。

原因:

玩家可以暂停
游戏可以加速
读档要恢复
睡觉可以直接跳到第二天
剧情可以锁定时间
不同地图可以控制时间流动

Godot 的 Time 单例可以读取系统时间,也能提供单调递增的 ticks;官方文档特别提醒,系统时间可能被用户或操作系统调整,做精确计时应使用 get_ticks_usec()get_ticks_msec() 这类单调时间。游戏内日夜循环这种系统,通常更适合自己维护一套“游戏时间”,而不是直接依赖系统时钟。(Godot Engine documentation)

2. 第一版时间系统要做什么

第一版先做:

游戏内分钟小时天数
时间自动流逝
白天 / 傍晚 / 夜晚阶段
UI 显示当前时间
睡觉跳到第二天早上
每日刷新事件
存档保存时间
读档恢复时间

先不要做:

复杂 NPC 日程
节日
天气概率
月相
农作物生长
商店营业时间
夜晚特殊事件
季节地图贴图切换

这些后面再慢慢加。

第一版目标是:

世界有时间
时间能显示
时间能推进
时间能跨天
每天能触发一次刷新

3. TimeManager 放在哪里

时间系统建议做成 Autoload。

因为时间属于全局游戏状态:

玩家在哪张地图时间都应该存在
切地图不能重置时间
存档要保存时间
UI 要随时显示时间
每日刷新要全局通知

创建:

res://scripts/time/time_manager.gd

设置为 Autoload:

Project Settings
Globals
Autoload
添加 time_manager.gd
命名 TimeManager

之后任何地方都可以调用:

TimeManager.hour
TimeManager.day
TimeManager.sleep_to_next_day()

4. TimeManager 需要哪些数据

第一版需要:

var minute: int = 0
var hour: int = 6
var day: int = 1
var season: Season = Season.SPRING
var year: int = 1

意思是:

 1
春季
 1
早上 6:00

为什么从 6 点开始?

因为很多 RPG / 农场游戏习惯让一天从早晨开始。

比如:

06:00 起床
12:00 中午
18:00 傍晚
22:00 夜晚

第一版从 6:00 开始最自然。

5. Season 季节枚举

enum Season {
    SPRING,
    SUMMER,
    AUTUMN,
    WINTER
}

显示时可以转成中文:

func get_season_name() -> String:
    match season:
        Season.SPRING:
            return "春"
        Season.SUMMER:
            return "夏"
        Season.AUTUMN:
            return "秋"
        Season.WINTER:
            return "冬"

    return "未知"

第一版季节先只是显示。

后面可以让季节影响:

地图颜色
天气概率
可采集物
怪物种类
商店商品
农作物生长
节日

6. 时间流速

我们定义:

@export var real_seconds_per_game_minute: float = 1.0

意思是:

现实 1 = 游戏 1 分钟

如果你想一天更快:

real_seconds_per_game_minute = 0.5

那就是:

现实 0.5 = 游戏 1 分钟
现实 12 分钟 = 游戏 1

如果你想一天更慢:

real_seconds_per_game_minute = 2.0

那就是:

现实 2 = 游戏 1 分钟
现实 48 分钟 = 游戏 1

第一版建议:

现实 1 = 游戏 1 分钟

测试起来比较方便。

7. 用 _process(delta) 推进游戏时间

TimeManager.gd:

extends Node

signal time_changed(hour: int, minute: int)
signal day_changed(day: int)
signal season_changed(season: Season)
signal year_changed(year: int)
signal day_phase_changed(phase: DayPhase)

enum Season {
    SPRING,
    SUMMER,
    AUTUMN,
    WINTER
}

enum DayPhase {
    MORNING,
    DAY,
    EVENING,
    NIGHT
}

@export var real_seconds_per_game_minute: float = 1.0

var minute: int = 0
var hour: int = 6
var day: int = 1
var year: int = 1
var season: Season = Season.SPRING

var is_time_paused: bool = false

var _accumulator: float = 0.0
var current_phase: DayPhase = DayPhase.MORNING

func _process(delta):
    if is_time_paused:
        return

    _accumulator += delta

    while _accumulator >= real_seconds_per_game_minute:
        _accumulator -= real_seconds_per_game_minute
        add_game_minutes(1)

这里用 _accumulator 累积真实时间。

比如:

delta 每帧大约 0.016
累积到 1.0
游戏时间 +1 分钟

8. add_game_minutes()

func add_game_minutes(amount: int):
    if amount <= 0:
        return

    for i in range(amount):
        minute += 1

        if minute >= 60:
            minute = 0
            hour += 1

            if hour >= 24:
                hour = 0
                advance_day()

        time_changed.emit(hour, minute)
        update_day_phase()

这里是一分钟一分钟推进。

这样做的好处是:

如果一次加很多分钟
仍然能正确跨小时跨天触发阶段变化

当然,后面如果大量快进时间,可以优化。

第一版这样最清楚。

9. advance_day()

@export var days_per_season: int = 30

func advance_day():
    day += 1
    day_changed.emit(day)

    if day > days_per_season:
        day = 1
        advance_season()

注意这里有个细节:

 day += 1
如果超过本季天数
就进入下一个季节

如果 days_per_season = 30

 1
 2
...
 30
 1

10. advance_season()

func advance_season():
    match season:
        Season.SPRING:
            season = Season.SUMMER
        Season.SUMMER:
            season = Season.AUTUMN
        Season.AUTUMN:
            season = Season.WINTER
        Season.WINTER:
            season = Season.SPRING
            year += 1
            year_changed.emit(year)

    season_changed.emit(season)

这样季节顺序就是:

下一年春

11. update_day_phase()

一天可以分成几个阶段:

早晨06:00 - 11:59
白天12:00 - 16:59
傍晚17:00 - 19:59
夜晚20:00 - 05:59

代码:

func update_day_phase():
    var next_phase := get_day_phase()

    if next_phase == current_phase:
        return

    current_phase = next_phase
    day_phase_changed.emit(current_phase)
func get_day_phase() -> DayPhase:
    if hour >= 6 and hour < 12:
        return DayPhase.MORNING

    if hour >= 12 and hour < 17:
        return DayPhase.DAY

    if hour >= 17 and hour < 20:
        return DayPhase.EVENING

    return DayPhase.NIGHT

这样其他系统可以监听:

TimeManager.day_phase_changed.connect(_on_day_phase_changed)

比如:

夜晚生成怪物
傍晚点亮路灯
早晨刷新 NPC

12. 完整 TimeManager 基础版

extends Node

signal time_changed(hour: int, minute: int)
signal day_changed(day: int)
signal season_changed(season: Season)
signal year_changed(year: int)
signal day_phase_changed(phase: DayPhase)

enum Season {
    SPRING,
    SUMMER,
    AUTUMN,
    WINTER
}

enum DayPhase {
    MORNING,
    DAY,
    EVENING,
    NIGHT
}

@export var real_seconds_per_game_minute: float = 1.0
@export var days_per_season: int = 30

var minute: int = 0
var hour: int = 6
var day: int = 1
var year: int = 1
var season: Season = Season.SPRING

var is_time_paused: bool = false

var _accumulator: float = 0.0
var current_phase: DayPhase = DayPhase.MORNING

func _ready():
    current_phase = get_day_phase()
    emit_time_signals()

func _process(delta):
    if is_time_paused:
        return

    _accumulator += delta

    while _accumulator >= real_seconds_per_game_minute:
        _accumulator -= real_seconds_per_game_minute
        add_game_minutes(1)

func add_game_minutes(amount: int):
    if amount <= 0:
        return

    for i in range(amount):
        minute += 1

        if minute >= 60:
            minute = 0
            hour += 1

            if hour >= 24:
                hour = 0
                advance_day()

        time_changed.emit(hour, minute)
        update_day_phase()

func advance_day():
    day += 1
    day_changed.emit(day)

    if day > days_per_season:
        day = 1
        advance_season()

func advance_season():
    match season:
        Season.SPRING:
            season = Season.SUMMER
        Season.SUMMER:
            season = Season.AUTUMN
        Season.AUTUMN:
            season = Season.WINTER
        Season.WINTER:
            season = Season.SPRING
            year += 1
            year_changed.emit(year)

    season_changed.emit(season)

func get_day_phase() -> DayPhase:
    if hour >= 6 and hour < 12:
        return DayPhase.MORNING

    if hour >= 12 and hour < 17:
        return DayPhase.DAY

    if hour >= 17 and hour < 20:
        return DayPhase.EVENING

    return DayPhase.NIGHT

func update_day_phase():
    var next_phase := get_day_phase()

    if next_phase == current_phase:
        return

    current_phase = next_phase
    day_phase_changed.emit(current_phase)

func emit_time_signals():
    time_changed.emit(hour, minute)
    day_changed.emit(day)
    season_changed.emit(season)
    year_changed.emit(year)
    day_phase_changed.emit(current_phase)

13. 时间 UI

在 UI 的 PlayerHUD 或单独 TimeHUD 里添加:

TimeHUD Control
├── DateLabel Label
├── TimeLabel Label
└── PhaseLabel Label

推荐单独做:

TimeHUD

结构:

UI CanvasLayer
├── PlayerHUD
├── TimeHUD
│   ├── DateLabel
│   ├── TimeLabel
│   └── PhaseLabel
├── InventoryPanel
├── QuestPanel
└── ...

14. TimeHUD.gd

extends Control

@onready var date_label: Label = $DateLabel
@onready var time_label: Label = $TimeLabel
@onready var phase_label: Label = $PhaseLabel

func _ready():
    TimeManager.time_changed.connect(_on_time_changed)
    TimeManager.day_changed.connect(_on_day_changed)
    TimeManager.season_changed.connect(_on_season_changed)
    TimeManager.year_changed.connect(_on_year_changed)
    TimeManager.day_phase_changed.connect(_on_day_phase_changed)

    refresh_all()

func refresh_all():
    _on_time_changed(TimeManager.hour, TimeManager.minute)
    _on_day_changed(TimeManager.day)
    _on_season_changed(TimeManager.season)
    _on_year_changed(TimeManager.year)
    _on_day_phase_changed(TimeManager.current_phase)

func _on_time_changed(hour: int, minute: int):
    time_label.text = "%02d:%02d" % [hour, minute]

func _on_day_changed(_day: int):
    update_date_label()

func _on_season_changed(_season):
    update_date_label()

func _on_year_changed(_year: int):
    update_date_label()

func update_date_label():
    date_label.text = "第 %d 年 %s 第 %d 天" % [
        TimeManager.year,
        TimeManager.get_season_name(),
        TimeManager.day
    ]

func _on_day_phase_changed(phase):
    phase_label.text = TimeManager.get_phase_name()

这里用了:

TimeManager.get_season_name()
TimeManager.get_phase_name()

所以 TimeManager 里补这两个方法。

15. TimeManager 补显示方法

func get_season_name() -> String:
    match season:
        Season.SPRING:
            return "春"
        Season.SUMMER:
            return "夏"
        Season.AUTUMN:
            return "秋"
        Season.WINTER:
            return "冬"

    return "未知"

func get_phase_name() -> String:
    match current_phase:
        DayPhase.MORNING:
            return "早晨"
        DayPhase.DAY:
            return "白天"
        DayPhase.EVENING:
            return "傍晚"
        DayPhase.NIGHT:
            return "夜晚"

    return "未知"

16. 暂停时间

有些情况下时间应该暂停:

打开菜单
打开商店
打开背包
对话中
剧情演出
暂停界面

可以提供:

func pause_time():
    is_time_paused = true

func resume_time():
    is_time_paused = false

然后 ShopPanel 打开时:

TimeManager.pause_time()

关闭时:

TimeManager.resume_time()

DialogueBox 开始时:

TimeManager.pause_time()

结束时:

TimeManager.resume_time()

不过第一版要小心一个问题:

多个 UI 同时打开时一个关闭就 resume_time可能不对

比如:

打开背包pause
又打开装备面板pause
关闭装备面板resume
但背包还开着

所以更稳的方式是用暂停计数。

17. 时间暂停计数

TimeManager:

var pause_count: int = 0

func pause_time():
    pause_count += 1
    is_time_paused = pause_count > 0

func resume_time():
    pause_count = max(pause_count - 1, 0)
    is_time_paused = pause_count > 0

func force_resume_time:
    pause_count = 0
    is_time_paused = false

注意 force_resume_time 要写成函数:

func force_resume_time():
    pause_count = 0
    is_time_paused = false

这样多个系统都可以安全暂停。

比如:

商店打开pause_count = 1
背包打开pause_count = 2
关闭背包pause_count = 1仍然暂停
关闭商店pause_count = 0恢复

这比一个简单 bool 更稳。

18. 睡觉系统

睡觉是时间系统的重要入口。

睡觉通常做几件事:

跳到第二天早晨
恢复玩家生命
触发每日刷新
保存游戏
关闭 UI

比如床 Bed 是一个可交互对象。

结构:

Bed Area2D
└── CollisionShape2D

Bed.gd:

extends Area2D

@export var prompt_text: String = "按 E 睡觉"

func interact(player: Node):
    TimeManager.sleep_to_next_day()

    if player.has_method("heal"):
        player.heal(player.max_hp)

    SaveManager.save_game(player, get_tree().current_scene.get_node("QuestManager"), SceneManager.current_map_id)

这能跑,但 Bed 直接找 QuestManager 不太优雅。

更好是:

Bed 只发出 sleep 请求
Main GameManager 处理睡觉流程

第一版可以先简单做,后面再优化。

19. TimeManager.sleep_to_next_day()

signal before_sleep
signal after_sleep
signal daily_refresh(day: int)

func sleep_to_next_day():
    before_sleep.emit()

    hour = 6
    minute = 0
    advance_day()

    current_phase = get_day_phase()

    time_changed.emit(hour, minute)
    day_phase_changed.emit(current_phase)

    daily_refresh.emit(day)

    after_sleep.emit()

这里做了:

发出 before_sleep
时间跳到 06:00
天数 +1
更新时间阶段
发出 daily_refresh
发出 after_sleep

注意:这里直接调用 advance_day()

也就是说:

睡觉一定进入下一天

20. daily_refresh 是干什么的

每日刷新事件很关键。

很多系统都可以监听它:

采集点刷新
商店库存刷新
怪物刷新
NPC 日程重置
天气重新随机
任务每日进度刷新

比如:

TimeManager.daily_refresh.connect(_on_daily_refresh)

这比让每个系统自己判断日期变化更干净。

21. 采集点每日刷新

上一部分我们做的采集点是永久消失:

采集后永远不出现

现在如果想让草药第二天刷新,可以改成:

采集后当天消失
第二天刷新

这时不能只保存 true。

要保存采集日期。

SaveManager 的 collected_nodes 可以从:

"forest_herb_node_001": true

升级为:

"forest_herb_node_001": {
    "day": 3,
    "season": 0,
    "year": 1
}

不过第一版可以不改存档结构,先做“不保存采集点,切地图刷新”。

如果你想做每日刷新,就要稍微复杂一点。

22. GatherNode 每日刷新版

先让 SaveManager 支持设置复杂状态:

func set_state_data(category: String, persistent_id: String, data: Dictionary):
    if persistent_id == "":
        return

    if not world_state.has(category):
        world_state[category] = {}

    world_state[category][persistent_id] = data

func get_state_data(category: String, persistent_id: String) -> Dictionary:
    if persistent_id == "":
        return {}

    if not world_state.has(category):
        return {}

    return world_state[category].get(persistent_id, {})

GatherNode:

extends Area2D

@export var persistent_id: String = ""
@export var prompt_text: String = "按 E 采集"

@export var item: ItemData
@export var quantity: int = 1
@export var respawn_after_days: int = 1

var collected: bool = false

func _ready():
    apply_saved_state()

func apply_saved_state():
    var data = SaveManager.get_state_data("collected_nodes", persistent_id)

    if data.is_empty():
        collected = false
        visible = true
        monitoring = true
        return

    var collected_day: int = data.get("day", 0)
    var collected_year: int = data.get("year", 1)
    var collected_season: int = data.get("season", TimeManager.Season.SPRING)

    var days_passed = TimeManager.get_total_days() - TimeManager.get_total_days_from(collected_year, collected_season, collected_day)

    if days_passed >= respawn_after_days:
        collected = false
        visible = true
        monitoring = true
    else:
        collected = true
        visible = false
        monitoring = false

func interact(player: Node):
    if collected:
        return

    collected = true

    if player.has_method("pick_up_item"):
        player.pick_up_item(item, quantity)

    SaveManager.set_state_data("collected_nodes", persistent_id, {
        "year": TimeManager.year,
        "season": TimeManager.season,
        "day": TimeManager.day
    })

    visible = false
    monitoring = false

这个版本需要 TimeManager.get_total_days()

23. TimeManager 总天数计算

func get_total_days() -> int:
    return get_total_days_from(year, season, day)

func get_total_days_from(p_year: int, p_season: int, p_day: int) -> int:
    var seasons_per_year := 4

    return ((p_year - 1) * seasons_per_year * days_per_season) + (p_season * days_per_season) + p_day

比如:

 1 1 = 1
 1 2 = 2
 1 1 = 31

这样就能比较两个日期相差多少天。

24. 白天黑夜视觉效果

2D 游戏里做日夜效果,常见简单方案是:

CanvasModulate

在地图或 World 下面加:

CanvasModulate

它会给 2D 场景整体叠加颜色。

比如:

白天Color(1, 1, 1, 1)
傍晚Color(1.0, 0.75, 0.55, 1)
夜晚Color(0.25, 0.3, 0.55, 1)
早晨Color(0.9, 0.85, 0.75, 1)

第一版可以根据 DayPhase 直接切颜色。

更高级可以根据时间平滑渐变。

25. DayNightController

放在 Main 或 UI/World 下都可以。

结构:

Main
├── WorldContainer
├── Player
├── CanvasModulate
└── UI

脚本:

extends CanvasModulate

func _ready():
    TimeManager.day_phase_changed.connect(_on_day_phase_changed)
    apply_phase(TimeManager.current_phase)

func _on_day_phase_changed(phase):
    apply_phase(phase)

func apply_phase(phase):
    match phase:
        TimeManager.DayPhase.MORNING:
            color = Color(0.9, 0.85, 0.75, 1.0)
        TimeManager.DayPhase.DAY:
            color = Color(1.0, 1.0, 1.0, 1.0)
        TimeManager.DayPhase.EVENING:
            color = Color(1.0, 0.72, 0.48, 1.0)
        TimeManager.DayPhase.NIGHT:
            color = Color(0.32, 0.36, 0.58, 1.0)

这会让场景整体变色。

第一版很够用。

26. 平滑过渡

直接切颜色会有点硬。

可以用 Tween 平滑过渡。

Godot 4 里可以用 create_tween() 创建 Tween。

func apply_phase(phase):
    var target_color := get_color_for_phase(phase)

    var tween = create_tween()
    tween.tween_property(self, "color", target_color, 2.0)
func get_color_for_phase(phase) -> Color:
    match phase:
        TimeManager.DayPhase.MORNING:
            return Color(0.9, 0.85, 0.75, 1.0)
        TimeManager.DayPhase.DAY:
            return Color(1.0, 1.0, 1.0, 1.0)
        TimeManager.DayPhase.EVENING:
            return Color(1.0, 0.72, 0.48, 1.0)
        TimeManager.DayPhase.NIGHT:
            return Color(0.32, 0.36, 0.58, 1.0)

    return Color(1, 1, 1, 1)

第一版直接切,第二版再 Tween。

27. Timer 和 SceneTreeTimer 在时间系统里的位置

注意,游戏内时间流逝不一定要用 Timer 节点。

我们现在用 _process(delta) 自己累积时间。

这很适合做:

游戏内时钟
可暂停
可加速
可存档
可跳转

Timer 更适合做:

每隔几秒执行一次
冷却
延迟
一次性等待

Godot 的 Timer 节点会倒计时并在结束时发出 timeout 信号,文档也提醒,想创建不需要节点的一次性计时器可以用 SceneTree.create_timer();Timer 还会受到 Engine.time_scale 影响。(Godot Engine documentation)

SceneTreeTimer 是由 SceneTree 管理的一次性计时器,不需要创建 Timer 节点,常见写法就是 await get_tree().create_timer(1.0).timeout。它很适合临时延迟,不太适合做整个世界的日期系统。(Godot Engine documentation)

所以你可以这样分工:

TimeManager
管理游戏内时间日期季节

Timer / SceneTreeTimer
做冷却延迟动画等待提示消失

28. 时间加速和暂停

调试时可以加几个方法:

func set_time_scale(multiplier: float):
    real_seconds_per_game_minute = 1.0 / max(multiplier, 0.01)

比如:

TimeManager.set_time_scale(10.0)

表示:

游戏时间速度 x10

但注意,这里的 time_scale 是你自己的游戏时间比例,不是 Engine.time_scale

不要一开始就改:

Engine.time_scale

因为它会影响物理、动画、Timer 等很多东西。

我们现在只想让游戏内时钟变快,不想让玩家、怪物、攻击动画全都变快。

所以自己控制 TimeManager 的流速更安全。

29. 夜晚刷怪

以后可以做:

夜晚出现幽灵
白天消失

简单版:

func _ready():
    TimeManager.day_phase_changed.connect(_on_day_phase_changed)
    update_night_spawn()

func _on_day_phase_changed(_phase):
    update_night_spawn()

func update_night_spawn():
    if TimeManager.current_phase == TimeManager.DayPhase.NIGHT:
        spawn_night_enemies()
    else:
        despawn_night_enemies()

第一版可以先不做。

但这个就是时间系统的价值:它能驱动世界变化。

30. 商店每日刷新

商店库存如果有限,可以监听:

TimeManager.daily_refresh.connect(_on_daily_refresh)

ShopData 或 ShopNPC 可以恢复库存。

例如:

func _on_daily_refresh(_day: int):
    restock()
func restock():
    for shop_item in shop_data.items:
        if not shop_item.infinite_stock:
            shop_item.stock = shop_item.default_stock

这需要 ShopItemData 增加:

@export var default_stock: int = 1

第一版商店无限库存,所以可以先不做。

31. 睡觉存档

睡觉通常是最佳存档点。

流程建议:

玩家与床交互
显示确认要睡觉吗
确认后
关闭 UI
淡出
TimeManager.sleep_to_next_day()
恢复玩家生命
每日刷新
保存游戏
淡入

第一版可以简化:

 E 睡觉
直接到第二天 6:00
恢复满血
保存

Bed.gd:

extends Area2D

@export var prompt_text: String = "按 E 睡觉"

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

    TimeManager.sleep_to_next_day()

    if player.has_method("heal"):
        player.heal(player.max_hp)

    var quest_manager = get_tree().current_scene.get_node_or_null("QuestManager")

    if quest_manager != null:
        SaveManager.save_game(player, quest_manager, SceneManager.current_map_id)

等你后面有 GameManager,再把这套流程集中管理。

32. 保存时间

SaveManager 里加时间数据。

保存:

func build_time_data() -> Dictionary:
    return {
        "minute": TimeManager.minute,
        "hour": TimeManager.hour,
        "day": TimeManager.day,
        "season": TimeManager.season,
        "year": TimeManager.year
    }

save_data 里加:

"time": build_time_data()

33. 读取时间

TimeManager 加:

func apply_time_data(data: Dictionary):
    minute = data.get("minute", minute)
    hour = data.get("hour", hour)
    day = data.get("day", day)
    season = data.get("season", season)
    year = data.get("year", year)

    current_phase = get_day_phase()
    emit_time_signals()

SaveManager 读档时:

TimeManager.apply_time_data(save_data.get("time", {}))

注意读档顺序:

读取 save_data
恢复 TimeManager
恢复 world_state
切地图
恢复玩家数据

为什么时间要在地图前恢复?

因为地图对象可能根据时间决定状态。

比如:

采集点是否刷新
夜晚怪物是否出现
NPC 是否在家

所以更稳的顺序是:

1. read_save_data
2. TimeManager.apply_time_data
3. SaveManager.apply_world_state_data
4. SceneManager.change_map
5. apply player/inventory/equipment/quests

34. 新游戏初始化时间

TimeManager 加:

func reset_to_new_game():
    minute = 0
    hour = 6
    day = 1
    year = 1
    season = Season.SPRING
    _accumulator = 0.0
    current_phase = get_day_phase()
    pause_count = 0
    is_time_paused = false

    emit_time_signals()

新游戏时调用:

TimeManager.reset_to_new_game()
SaveManager.clear_world_state()

35. TimeManager 存档相关完整片段

func build_save_data() -> Dictionary:
    return {
        "minute": minute,
        "hour": hour,
        "day": day,
        "season": season,
        "year": year
    }

func apply_time_data(data: Dictionary):
    minute = data.get("minute", minute)
    hour = data.get("hour", hour)
    day = data.get("day", day)
    season = data.get("season", season)
    year = data.get("year", year)

    _accumulator = 0.0
    current_phase = get_day_phase()

    emit_time_signals()

func reset_to_new_game():
    minute = 0
    hour = 6
    day = 1
    year = 1
    season = Season.SPRING
    _accumulator = 0.0
    current_phase = get_day_phase()
    pause_count = 0
    is_time_paused = false

    emit_time_signals()

SaveManager 里:

func build_time_data() -> Dictionary:
    return TimeManager.build_save_data()

save_data:

"time": build_time_data()

load:

TimeManager.apply_time_data(save_data.get("time", {}))

36. 时间和存档顺序总结

保存时:

保存 current_map_id
保存 player
保存 inventory
保存 equipment
保存 quests
保存 world_state
保存 time

读档时:

读取 save_data
恢复 TimeManager
恢复 SaveManager.world_state
切换到存档地图
恢复 player
恢复 inventory
恢复 equipment
恢复 quests
刷新 UI

这个顺序很重要。

尤其是:

时间和 world_state 要先恢复
地图再加载

否则地图对象在 _ready() 里读取到的是旧时间、旧世界状态。

37. 时间系统和暂停菜单

如果你有 PausePanel,打开时:

TimeManager.pause_time()

关闭时:

TimeManager.resume_time()

但是如果你还使用 Godot 的暂停树:

get_tree().paused = true

那要注意:

节点的 process 可能暂停
Timer 也可能受影响
UI 是否还能响应取决于 process_mode

第一版不建议你立刻用 get_tree().paused = true 做所有暂停。

先用:

TimeManager.pause_time()
Player.lock_control_for_menu()

就够了。

等项目复杂后,再统一 GameState 和 pause 模式。

38. TimeManager 和 GameState 的关系

未来你可能会有:

EXPLORING
DIALOGUE
SHOP
INVENTORY
PAUSED
CUTSCENE
BATTLE

不同状态下时间是否流动不同:

EXPLORING时间流动
DIALOGUE时间暂停
SHOP时间暂停
INVENTORY时间暂停
PAUSED时间暂停
CUTSCENE看情况
BATTLE看游戏类型

后面可以做一个 GameStateManager,统一控制:

TimeManager.pause_time()
player.lock_control_for_menu()
ui.open_panel()

现在先不要急。

39. 常见错误 1:时间不走

排查:

TimeManager 是否 Autoload
TimeManager._process 是否运行
is_time_paused 是否为 true
pause_count 是否没有恢复
real_seconds_per_game_minute 是否太大
TimeHUD 是否连接 time_changed

临时打印:

func add_game_minutes(amount: int):
    print("时间推进")

如果不打印,是 _process 或暂停问题。

40. 常见错误 2:UI 时间不刷新

排查:

TimeHUD 是否在场景里
TimeHUD._ready 是否连接 TimeManager.time_changed
TimeManager 是否 emit time_changed
Label 路径是否正确

临时打印:

func _on_time_changed(hour: int, minute: int):
    print("UI 时间:", hour, ":", minute)

41. 常见错误 3:睡觉后日期不对

检查:

sleep_to_next_day 是否调用 advance_day
hour 是否设置为 6
minute 是否设置为 0
advance_day 是否正确处理 season
days_per_season 是否设置正确

如果你在 23:50 睡觉,第二天应该是:

06:00
day + 1

不是:

00:00

除非你设计成半夜醒来,听着就很惨。

42. 常见错误 4:读档后时间恢复了,但地图状态不对

排查读档顺序:

是否先 TimeManager.apply_time_data
是否先 SaveManager.apply_world_state_data
是否再 SceneManager.change_map
地图对象是否在 _ready 里读取状态

时间和世界状态必须早于地图加载。

43. 常见错误 5:对话时游戏时间还在走

解决:

DialogueBox 打开时 TimeManager.pause_time()
DialogueBox 结束时 TimeManager.resume_time()

如果 pause_count 越来越大,说明某些地方 pause 了但没 resume。

可以调试:

print("pause_count: ", pause_count)

44. 常见错误 6:夜晚颜色太黑

CanvasModulate 夜晚颜色不要一上来太暗。

可以先用:

Color(0.45, 0.48, 0.7, 1)

比:

Color(0.1, 0.1, 0.25, 1)

更适合调试。

不然你会陷入“我的游戏太真实了,真实到啥也看不见”的尴尬场面。

45. 常见错误 7:采集点每日刷新不稳定

如果你做了每日刷新版,排查:

采集时是否保存 year/season/day
get_total_days 是否计算正确
读档时 TimeManager 是否先恢复
GatherNode.apply_saved_state 是否根据 total days 判断

如果你暂时不想处理这些,就先保持永久采集状态。

别强行一步做完。

46. 当前推荐结构

Autoload:

SceneManager
SaveManager
ItemDatabase
QuestDatabase
TimeManager

Main:

Main
├── QuestManager
├── WorldContainer
├── Player
├── CanvasModulate DayNightController
└── UI
    ├── PlayerHUD
    ├── TimeHUD
    ├── InventoryPanel
    ├── EquipmentPanel
    ├── QuestPanel
    ├── ShopPanel
    ├── DialogueBox
    └── DeathPanel

TimeManager 负责:

分钟
小时
天数
季节
年份
时间推进
睡觉
每日刷新
存档恢复

DayNightController 负责:

根据时间阶段改变画面颜色

TimeHUD 负责:

显示当前日期和时间

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

游戏时间不要直接依赖系统时间
TimeManager 适合做 Autoload
 _process(delta) 累积真实秒数转换成游戏分钟
时间变化用 signal 通知 UI 和系统
白天黑夜可以先用 DayPhase
睡觉可以跳到第二天早上
daily_refresh 用来驱动每日刷新
存档要保存 minute/hour/day/season/year
读档时要先恢复时间再加载地图
CanvasModulate 可以做简单 2D 日夜色调
暂停时间最好用 pause_count而不是单个 bool

48. 你现在的小练习

按这个顺序做:

1. 创建 TimeManager.gd
2. 设置为 AutoloadTimeManager
3. 添加 minute / hour / day / season / year
4. 添加 real_seconds_per_game_minute
5. _process(delta) 推进游戏时间
6. 实现 add_game_minutes()
7. 实现 advance_day()
8. 实现 advance_season()
9. 实现 DayPhase
10. 实现 day_phase_changed 信号
11. UI 添加 TimeHUD
12. TimeHUD 显示 06:00
13. TimeHUD 显示 1 1
14. 等待时间流逝确认 UI 更新时间
15. 添加 CanvasModulate
16. 创建 DayNightController
17. 不同 DayPhase 改变画面颜色
18. 创建 Bed
19. Bed interact 后调用 sleep_to_next_day()
20. 睡觉后时间变成第二天 06:00
21. 睡觉后恢复玩家生命
22. 睡觉后触发 daily_refresh
23. SaveManager 保存 time
24. SaveManager 读档恢复 time
25. 测试在夜晚保存重启读档后仍然是夜晚

完成这一部分后,你的 RPG 就开始拥有“世界在流动”的感觉了。

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

分享文章

相关文章

更多文章 →
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 官方文档也把节点和场景放在一起讲:多个节点组成树状结构后...
学习

评论

请登录后发表评论

去登录
加载评论中...