9. 生存计时与难度递增
2026/4/13大约 9 分钟
生存计时与难度递增
时间是最终的敌人
在割草游戏中,你面对的真正敌人不是那些僵尸和蝙蝠——而是时间。不管你多强,时间到了你就得面对Boss;不管你装备多好,最终你一定会死。时间推着游戏向前走,让你从"轻松愉快"逐渐变成"手忙脚乱"。
这一章我们要实现两个紧密相关的系统:生存计时器(告诉玩家你活了多久)和难度递增(让游戏越来越难)。
生存计时器
计时器的位置和格式
计时器通常显示在屏幕正上方,使用大字体,让玩家一眼就能看到:
┌─────────────────────────────────────┐
│ ⏱ 05:23 │
│ │
│ ❤ [========----] 60/100 │
│ │
│ │
│ 🧙 (玩家) │
│ 💀💀 💀 │
│ 💀 🗡️ 💀💀 │
│ 💀💀 💀 │
└─────────────────────────────────────┘计时器格式说明
| 显示格式 | 示例 | 适用场景 |
|---|---|---|
| MM:SS | 05:23 | 游戏中显示(简洁) |
| HH:MM:SS | 00:05:23 | 超过60分钟时 |
| 整数秒 | 323 | 结算画面(精确) |
计时器的心理作用
大字体显示存活时间有两个作用:
- 让玩家随时知道自己的"成绩",产生"打破记录"的动力
- 时间数字持续增长,给玩家一种"我在进步"的满足感
计时器实现
C
// SurvivalTimer.cs
// 生存计时器:记录玩家存活时间
using Godot;
public partial class SurvivalTimer : Node
{
// === 计时状态 ===
public float ElapsedTime { get; private set; } = 0f;
public bool IsRunning { get; private set; } = false;
// 格式化时间字符串
public string FormattedTime
{
get
{
int totalSeconds = Mathf.FloorToInt(ElapsedTime);
int minutes = totalSeconds / 60;
int seconds = totalSeconds % 60;
return $"{minutes:D2}:{seconds:D2}";
}
}
// 统计数据
public int KillCount { get; set; } = 0;
public int TotalExperienceCollected { get; set; } = 0;
public int WeaponsCollected { get; set; } = 0;
// 信号
[Signal] public delegate void TimeUpdatedEventHandler(
string formattedTime);
[Signal] public delegate void MilestoneReachedEventHandler(
int minute);
// 已触发的里程碑
private HashSet<int> _triggeredMilestones = new();
// 里程碑(每5分钟一个)
private int[] _milestones = { 5, 10, 15, 20, 25, 30 };
public override void _Process(double delta)
{
if (!IsRunning) return;
ElapsedTime += (float)delta;
// 发射时间更新信号(更新UI)
EmitSignal(SignalName.TimeUpdated, FormattedTime);
// 检查里程碑
CheckMilestones();
}
/// <summary>
/// 开始计时
/// </summary>
public void Start()
{
IsRunning = true;
ElapsedTime = 0f;
KillCount = 0;
TotalExperienceCollected = 0;
WeaponsCollected = 0;
_triggeredMilestones.Clear();
}
/// <summary>
/// 暂停计时
/// </summary>
public void Pause()
{
IsRunning = false;
}
/// <summary>
/// 恢复计时
/// </summary>
public void Resume()
{
IsRunning = true;
}
/// <summary>
/// 停止计时(游戏结束)
/// </summary>
public void Stop()
{
IsRunning = false;
}
/// <summary>
/// 增加击杀数
/// </summary>
public void AddKill()
{
KillCount++;
}
/// <summary>
/// 增加收集的经验值
/// </summary>
public void AddExperienceCollected(float amount)
{
TotalExperienceCollected += Mathf.FloorToInt(amount);
}
/// <summary>
/// 检查里程碑事件
/// </summary>
private void CheckMilestones()
{
int currentMinute = Mathf.FloorToInt(ElapsedTime / 60f);
foreach (int milestone in _milestones)
{
if (currentMinute >= milestone
&& !_triggeredMilestones.Contains(milestone))
{
_triggeredMilestones.Add(milestone);
EmitSignal(SignalName.MilestoneReached, milestone);
GD.Print($"里程碑!已存活 {milestone} 分钟");
}
}
}
/// <summary>
/// 获取结算数据
/// </summary>
public GameResult GetResult()
{
return new GameResult
{
SurvivalTime = ElapsedTime,
FormattedTime = FormattedTime,
KillCount = KillCount,
TotalExperience = TotalExperienceCollected,
WeaponsCount = WeaponsCollected
};
}
}
// 结算数据
public struct GameResult
{
public float SurvivalTime;
public string FormattedTime;
public int KillCount;
public int TotalExperience;
public int WeaponsCount;
}GDScript
# survival_timer.gd
# 生存计时器:记录玩家存活时间
extends Node
# === 计时状态 ===
var elapsed_time: float = 0.0
var is_running: bool = false
# 格式化时间字符串
var formatted_time: String:
get:
var total_seconds = floori(elapsed_time)
var minutes = total_seconds / 60
var seconds = total_seconds % 60
return "%02d:%02d" % [minutes, seconds]
# 统计数据
var kill_count: int = 0
var total_experience_collected: int = 0
var weapons_collected: int = 0
# 信号
signal time_updated(formatted_time: String)
signal milestone_reached(minute: int)
# 已触发的里程碑
var _triggered_milestones = []
# 里程碑(每5分钟一个)
var _milestones = [5, 10, 15, 20, 25, 30]
func _process(delta):
if not is_running:
return
elapsed_time += delta
# 发射时间更新信号(更新UI)
time_updated.emit(formatted_time)
# 检查里程碑
check_milestones()
## 开始计时
func start():
is_running = true
elapsed_time = 0.0
kill_count = 0
total_experience_collected = 0
weapons_collected = 0
_triggered_milestones.clear()
## 暂停计时
func pause():
is_running = false
## 恢复计时
func resume():
is_running = true
## 停止计时(游戏结束)
func stop():
is_running = false
## 增加击杀数
func add_kill():
kill_count += 1
## 增加收集的经验值
func add_experience_collected(amount: float):
total_experience_collected += floori(amount)
## 检查里程碑事件
func check_milestones():
var current_minute = floori(elapsed_time / 60.0)
for milestone in _milestones:
if current_minute >= milestone \
and milestone not in _triggered_milestones:
_triggered_milestones.append(milestone)
milestone_reached.emit(milestone)
print("里程碑!已存活 %d 分钟" % milestone)
## 获取结算数据
func get_result() -> Dictionary:
return {
"survival_time": elapsed_time,
"formatted_time": formatted_time,
"kill_count": kill_count,
"total_experience": total_experience_collected,
"weapons_count": weapons_collected
}难度递增系统
难度曲线设计
难度不能直线上升(太早太难玩家会放弃),也不能一直很平(太无聊)。好的难度曲线应该是"阶梯式"的——大部分时间在积累压力,关键时刻突然释放一波更强的敌人。
难度
↑
│ ╱╲
│ ╱ ╲ ╱╲
│ ╱╲ ╱ ╲ ╱ ╲
│ ╱ ╲╱ ╲ ╱ ╲
│ ╱ ╲ ╱ ╲
│ ╱ ╲╱ ╲
│ ╱
│ ╱
└──────────────────────────────→ 时间
0 5 10 15 20 25 30
↑ ↑ ↑
Boss1 Boss2 Boss3难度参数表
| 时间段 | 敌人数量倍率 | 敌人血量倍率 | 新增敌人类型 | 特殊事件 |
|---|---|---|---|---|
| 0~3分钟 | 1.0x | 1.0x | 普通僵尸 | 无 |
| 3~5分钟 | 1.5x | 1.2x | 蝙蝠 | 无 |
| 5分钟 | 2.0x | 1.5x | - | Boss 1 |
| 5~10分钟 | 2.0x | 1.5x | 骷髅战士 | 无 |
| 10分钟 | 2.5x | 2.0x | - | Boss 2 |
| 10~15分钟 | 3.0x | 2.0x | 幽灵 | 无 |
| 15分钟 | 3.5x | 2.5x | - | Boss 3(最终) |
难度管理器
C
// DifficultyManager.cs
// 管理游戏难度随时间递增
using Godot;
public partial class DifficultyManager : Node
{
// === 难度参数 ===
// 基础难度(游戏开始时)
[Export] public float BaseDifficulty = 1.0f;
// 每分钟难度增长
[Export] public float DifficultyPerMinute = 0.15f;
// 难度上限
[Export] public float MaxDifficulty = 5.0f;
// === 运行时状态 ===
public float CurrentDifficulty { get; private set; }
public int CurrentWave { get; private set; } = 1;
// 引用
private SurvivalTimer _timer;
private EnemySpawner _spawner;
// Boss触发时间(秒)
private int[] _bossTimes = { 300, 600, 900 };
public override void _Ready()
{
_timer = GetParent()
.GetNode<SurvivalTimer>("SurvivalTimer");
_spawner = GetParent()
.GetNode<EnemySpawner>("EnemySpawner");
CurrentDifficulty = BaseDifficulty;
// 连接里程碑信号
_timer.MilestoneReached += OnMilestoneReached;
}
public override void _Process(double delta)
{
if (!_timer.IsRunning) return;
// 根据游戏时间计算当前难度
float minutes = _timer.ElapsedTime / 60f;
CurrentDifficulty = Mathf.Min(
BaseDifficulty + minutes * DifficultyPerMinute,
MaxDifficulty
);
// 更新生成器的难度倍率
if (_spawner != null)
{
_spawner.DifficultyMultiplier = CurrentDifficulty;
}
}
/// <summary>
/// 里程碑事件处理
/// </summary>
private void OnMilestoneReached(int minute)
{
CurrentWave++;
GD.Print($"=== 第 {CurrentWave} 波 ===");
GD.Print($"难度倍率:{CurrentDifficulty:F2}x");
GD.Print($"已存活:{minute} 分钟");
// 每5分钟触发Boss
if (minute % 5 == 0)
{
TriggerBossEvent(minute);
}
}
/// <summary>
/// 触发Boss事件
/// </summary>
private void TriggerBossEvent(int minute)
{
GD.Print($"!!! {minute}分钟Boss事件 !!!");
// 暂停普通敌人生成
if (_spawner != null)
{
_spawner.IsBossActive = true;
}
// 生成Boss(通过BossEvent节点)
var bossEvents = GetParent()
.GetNodeOrNull<BossEvent>("BossEvent");
if (bossEvents != null && bossEvents.HasMethod("ForceTrigger"))
{
bossEvents.Call("ForceTrigger");
}
}
/// <summary>
/// Boss被击败后调用
/// </summary>
public void OnBossDefeated()
{
// 恢复普通敌人生成
if (_spawner != null)
{
_spawner.IsBossActive = false;
}
// 额外增加难度
CurrentDifficulty += 0.5f;
GD.Print("Boss被击败!难度增加!");
}
/// <summary>
/// 获取当前难度描述
/// </summary>
public string GetDifficultyDescription()
{
if (CurrentDifficulty < 1.5f)
return "轻松";
if (CurrentDifficulty < 2.5f)
return "普通";
if (CurrentDifficulty < 3.5f)
return "困难";
return "地狱";
}
}GDScript
# difficulty_manager.gd
# 管理游戏难度随时间递增
extends Node
# === 难度参数 ===
# 基础难度(游戏开始时)
@export var base_difficulty: float = 1.0
# 每分钟难度增长
@export var difficulty_per_minute: float = 0.15
# 难度上限
@export var max_difficulty: float = 5.0
# === 运行时状态 ===
var current_difficulty: float
var current_wave: int = 1
# 引用
var _timer: Node
var _spawner: Node
func _ready():
_timer = get_parent().get_node("SurvivalTimer")
_spawner = get_parent().get_node("EnemySpawner")
current_difficulty = base_difficulty
# 连接里程碑信号
_timer.milestone_reached.connect(_on_milestone_reached)
func _process(delta):
if not _timer.is_running:
return
# 根据游戏时间计算当前难度
var minutes = _timer.elapsed_time / 60.0
current_difficulty = minf(
base_difficulty + minutes * difficulty_per_minute,
max_difficulty
)
# 更新生成器的难度倍率
if _spawner:
_spawner.difficulty_multiplier = current_difficulty
## 里程碑事件处理
func _on_milestone_reached(minute: int):
current_wave += 1
print("=== 第 %d 波 ===" % current_wave)
print("难度倍率:%.2fx" % current_difficulty)
print("已存活:%d 分钟" % minute)
# 每5分钟触发Boss
if minute % 5 == 0:
trigger_boss_event(minute)
## 触发Boss事件
func trigger_boss_event(minute: int):
print("!!! %d分钟Boss事件 !!!" % minute)
# 暂停普通敌人生成
if _spawner:
_spawner.is_boss_active = true
# 生成Boss
var boss_events = get_parent().get_node_or_null("BossEvent")
if boss_events and boss_events.has_method("force_trigger"):
boss_events.force_trigger()
## Boss被击败后调用
func on_boss_defeated():
# 恢复普通敌人生成
if _spawner:
_spawner.is_boss_active = false
# 额外增加难度
current_difficulty += 0.5
print("Boss被击败!难度增加!")
## 获取当前难度描述
func get_difficulty_description() -> String:
if current_difficulty < 1.5:
return "轻松"
elif current_difficulty < 2.5:
return "普通"
elif current_difficulty < 3.5:
return "困难"
else:
return "地狱"死亡结算画面
当玩家血量归零时,游戏结束,显示结算画面。这是玩家的"成绩单"。
结算画面内容
| 显示项 | 说明 | 数据来源 |
|---|---|---|
| 存活时间 | 本次存活了多久 | SurvivalTimer |
| 击杀数 | 消灭了多少敌人 | SurvivalTimer |
| 收集经验 | 总共收集了多少经验 | SurvivalTimer |
| 武器数量 | 最终装备了几把武器 | WeaponManager |
| 最高等级 | 达到了多少级 | ExperienceManager |
| 击败Boss | 击败了几个Boss | DifficultyManager |
结算画面脚本
C
// GameOverScreen.cs
// 游戏结束结算画面
using Godot;
public partial class GameOverScreen : Control
{
// UI 节点引用
private Label _timeLabel;
private Label _killLabel;
private Label _expLabel;
private Label _levelLabel;
private Label _bossLabel;
private Button _restartButton;
private Button _quitButton;
public override void _Ready()
{
// 获取UI节点引用
_timeLabel = GetNode<Label>("VBox/TimeValue");
_killLabel = GetNode<Label>("VBox/KillValue");
_expLabel = GetNode<Label>("VBox/ExpValue");
_levelLabel = GetNode<Label>("VBox/LevelValue");
_bossLabel = GetNode<Label>("VBox/BossValue");
_restartButton = GetNode<Button>("VBox/RestartButton");
_quitButton = GetNode<Button>("VBox/QuitButton");
// 连接按钮信号
_restartButton.Pressed += OnRestartPressed;
_quitButton.Pressed += OnQuitPressed;
// 默认隐藏
Visible = false;
}
/// <summary>
/// 显示结算画面
/// </summary>
public void Show(GameResult result)
{
_timeLabel.Text = result.FormattedTime;
_killLabel.Text = result.KillCount.ToString();
_expLabel.Text = result.TotalExperience.ToString();
_levelLabel.Text = "Lv." + GetPlayerLevel().ToString();
_bossLabel.Text = GetDefeatedBossCount().ToString();
Visible = true;
// 解除游戏暂停(让UI可以交互)
GetTree().Paused = false;
}
private int GetPlayerLevel()
{
var expManager = GetTree().CurrentScene
.GetNodeOrNull("ExperienceManager");
return expManager?.Get("current_level") as int? ?? 1;
}
private int GetDefeatedBossCount()
{
var diffManager = GetTree().CurrentScene
.GetNodeOrNull("DifficultyManager");
return diffManager?.Get("current_wave") as int? ?? 0;
}
private void OnRestartPressed()
{
// 重新加载当前场景
GetTree().ReloadCurrentScene();
}
private void OnQuitPressed()
{
// 返回主菜单
GetTree().ChangeSceneToFile(
"res://scenes/ui/main_menu.tscn");
}
}GDScript
# game_over_screen.gd
# 游戏结束结算画面
extends Control
# UI 节点引用
var _time_label: Label
var _kill_label: Label
var _exp_label: Label
var _level_label: Label
var _boss_label: Label
var _restart_button: Button
var _quit_button: Button
func _ready():
# 获取UI节点引用
_time_label = get_node("VBox/TimeValue")
_kill_label = get_node("VBox/KillValue")
_exp_label = get_node("VBox/ExpValue")
_level_label = get_node("VBox/LevelValue")
_boss_label = get_node("VBox/BossValue")
_restart_button = get_node("VBox/RestartButton")
_quit_button = get_node("VBox/QuitButton")
# 连接按钮信号
_restart_button.pressed.connect(_on_restart_pressed)
_quit_button.pressed.connect(_on_quit_pressed)
# 默认隐藏
visible = false
## 显示结算画面
func show_result(result: Dictionary):
_time_label.text = result.formatted_time
_kill_label.text = str(result.kill_count)
_exp_label.text = str(result.total_experience)
_level_label.text = "Lv." + str(get_player_level())
_boss_label.text = str(get_defeated_boss_count())
visible = true
# 解除游戏暂停(让UI可以交互)
get_tree().paused = false
func get_player_level() -> int:
var exp_manager = get_tree().current_scene \
.get_node_or_null("ExperienceManager")
return exp_manager.current_level if exp_manager else 1
func get_defeated_boss_count() -> int:
var diff_manager = get_tree().current_scene \
.get_node_or_null("DifficultyManager")
return diff_manager.current_wave if diff_manager else 0
func _on_restart_pressed():
# 重新加载当前场景
get_tree().reload_current_scene()
func _on_quit_pressed():
# 返回主菜单
get_tree().change_scene_to_file(
"res://scenes/ui/main_menu.tscn")永久升级系统
虽然每一局都是重新开始,但玩家可以通过积累的"金币"(每局结束后根据表现获得)在主菜单购买永久升级。
永久升级项
| 升级项 | 效果 | 最大等级 | 每级费用 |
|---|---|---|---|
| 额外生命 | 起始生命+10 | 5级 | 100金币 |
| 额外伤害 | 起始伤害+5% | 5级 | 150金币 |
| 额外移速 | 起始移速+5% | 5级 | 100金币 |
| 额外拾取 | 起始拾取范围+10% | 5级 | 100金币 |
| 额外武器 | 起始自带1把武器 | 1级 | 500金币 |
永久升级的意义
永久升级让玩家觉得"即使死了也不是白玩"。每次游戏结束后获得金币,攒够就能在下一局获得优势。这给了玩家持续游玩的动力,也是" Rogue-lite "(轻度Rogue)的核心设计理念。
总结
本章我们实现了:
- 生存计时器 — 精确记录存活时间,格式化显示
- 难度递增 — 随时间增加敌人数量、血量和种类
- Boss事件 — 每5分钟触发Boss战
- 死亡结算 — 显示详细的游戏成绩
- 永久升级 — 用金币购买跨局永久增益
现在整个游戏的核心玩法已经完整了。最后一章,我们要对游戏进行打磨和优化,让它变得可以发布。
