酒魂-虚幻中寻真实，像素里见江湖 - 开发笔记
代码片段、美术资源、踩坑日志，记录《酒魂》从零到一的每一步。
【酒魂开发手记】第七章 投壶战斗——蓄力、抛物线、命中判定
第7章 投壶战斗：蓄力射箭与福缘奖励 「射者，仁之道也。射求正诸己，己正而后发，发而不中，则不怨胜己者，反求诸己而已矣。」——《礼记·射义》 本章我们将开发《酒魂》核心玩法——投壶战斗。实现蓄力射箭、抛物线飞行、动态酒壶、醉意准星、命中评分、福缘奖励全套功能，让游戏拥有完整的休闲战斗体系。 本章核心目标 理解《酒魂》投壶核心设计：顺势而为、可控预判、随机惊喜的国风微醺玩法逻辑； 实现动态交互组件：纵向钟摆摇摆酒壶、随醉意漂移的跟随式准星； 完成投壶核心操作：主角区域内左右走位、鼠标蓄力投掷、箭矢抛物线飞行； 掌握 Godot 信号机制：实现箭矢命中、落地判定，搭建评分+福缘奖励战斗循环； 让福缘奖励真正"看得见"：连珠三箭触发火/冰属性追踪箭，自动追踪命中当前酒壶并额外加分； 完成场景集成：将投壶战斗接入主场景，实现探索场景与战斗场景切换。 7.1 投壶玩法设计 在编写投壶玩法代码前，我们先梳理玩法核心设计逻辑，理解设计思路与底层理念，明确代码开发的核心目标，避免盲目编码。 7.1.1 核心理念：顺势而为 《酒魂》投壶玩法深度贴合「酒与微醺」的核心主题，主打动态预判、顺势而为的游玩体验，完美还原醉酒状态下「不完全可控，但充满意外惊喜」的独特质感。动态摇摆的酒壶、随机漂移的准星，让每一次投掷都无法完全复刻，杜绝机械化瞄准操作。玩法核心不在于对抗不确定性、强行追求精准命中，而是顺应动态规律、借力而为。玩家需要通过走位调整最佳投掷站位，观察酒壶摆动节奏，在壶身摆至端点、速度归零的滞空窗口期出手，将「顺势而为」的东方哲学深度融入游戏核心机制，贴合整体国风醉酒玩法基调。 玩法结构拆解：投壶玩法可拆解为「输入层 → 决策层 → 反馈层」三层结构。输入层负责接收玩家的走位与蓄力操作；决策层由动态酒壶、醉意准星共同构成"不确定性来源"，迫使玩家在观察与等待中做出判断；反馈层则通过命中评分、福缘奖励、视觉特效形成完整的情绪闭环。三层相互独立又彼此联动，任何一层的改动都会影响整体手感，这也是后续代码模块划分的依据。 维度 《酒魂》投壶（动态预判） 靶子 像钟摆一样纵向（前后）摇摆，模拟绳牵壶身忽近忽远的动态效果 准星 随醉意产生布朗运动随机漂移，模拟醉酒手抖状态 投掷位置 主角可在投掷线内自由左右走位，灵活调整投掷角度 操作逻辑 站对位置、卡准时机，等待准星飘过壶口的瞬间松手投掷 核心体验 主打走位博弈、节奏把控，营造顺势而为的沉浸式心流 与醉意联动 醉意数值越高，准星漂移幅度、速度越强，操作不确定性越高 7.1.2 四大核心机制 投壶玩法包含四大专属机制，相互联动形成独特的游玩体验。 动态随机酒壶（纵向钟摆运动 + 随机刷新）：酒壶模拟绳牵物理效果，沿屏幕纵深前后往复摆动，呈现忽近忽远的视觉效果；每一轮游戏都会随机刷新场地位置，不固定单点。酒壶摆动至两端端点时会短暂滞空静止，是玩家投掷的最佳时机。 主角自由走位投掷：玩家可在指定投掷区域内左右移动，箭矢始终从主角实时站位发射，站位不同，箭矢飞行角度与轨迹也会变化，丰富操作策略。 醉意动态准星（跟随式布朗运动）：准星锚点始终跟随主角移动，无法被玩家手动控制，呈现无规律的随机漂移效果，模拟醉酒状态。游戏醉意值越高，准星漂移幅度、速度越大，操作难度越高。 福缘规则奖励（机制级增益）：精准预判投掷可触发随机福缘奖励，判定标准为：酒壶接近静止时出手、箭矢落点贴近壶心。奖励并非单纯积分加成，而是改变本局游戏规则、且视觉上真正看得见的实质性增益，包含连珠三箭（火/冰追踪箭）、积分翻倍等等效果。 7.2 全局醉意值与走位输入 7.2.1 全局醉意值 投壶玩法的核心变量 准星漂移强度 与玩家醉意深度强绑定：醉意越高，准星布朗运动越剧烈，投掷不确定性越强，完美贴合「微醺失控」的游戏主题。 本项目全局数值统一由全局单例 GameManager 管理（第四章已完成全局自动加载配置），我们只需校验并保留对应醉意变量即可。打开项目脚本文件：scripts/game_manager.gd，确认文件内存在如下全局醉意变量： var player_drunk_level: float = 0.0 # 范围 0.0 ~ 1.0 变量说明： 数值区间固定为 0.0 ~ 1.0，0 为完全清醒、无漂移，1.0 为满醉、漂移最大化； 该变量由后续第十章酿酒、饮酒系统动态修改，本章仅做读取使用； 为方便开发调试投壶漂移效果，可临时将默认值设置为 0.3，保证初始有轻微醉意、可直观看到准星漂移效果。 7.2.2 左右走位 投壶玩法支持玩家在投掷区域内左右自由走位，以此调整投掷起点与射击角度。为统一全游戏操作手感、减少冗余配置，投壶走位逻辑完全复用第五章已配置的方向输入，无需新增任何输入映射。 第五章 5.3.2 节已全局配置完成四向输入动作：left、right、up、down，并可通过 Input.get_vector() 统一读取方向输入。 投壶专属规则： 投壶战斗模式下，仅启用 left、right 左右走位，屏蔽上下移动； 整套按键与大世界探索完全一致，玩家无需适应新操作； 玩家角色脚本中会增加「投壶模式状态判断」，进入投壶场景自动切换走位规则，隔离探索移动与投壶移动逻辑（见 7.3.7 节）。 7.3 搭建投壶战斗场景 本节分三个阶段完成投壶战斗场景的全部搭建： 第一阶段（7.3.1–7.3.3）核心实体：创建酒壶和箭矢两个可复用的独立场景，理解简谐运动与抛物线物理； 第二阶段（7.3.4–7.3.6）场景与交互组件：搭建 BowlGame 主场景，添加摄像机、地面、醉意准星和蓄力 UI； 第三阶段（7.3.7–7.3.9）逻辑串联：实例化主角并追加投壶模式，编写 BowlGameManager 把所有零件串成完整战斗循环，最后在检查器里完成节点赋值。 第一阶段：核心实体 7.3.1 创建动态酒壶 TargetPot Ctrl+N 新建场景，根节点选 Area2D，重命名 TargetPot。 添加子节点 Sprite2D，加载 assets/sprites/obj/pot.png。 添加子节点 CollisionShape2D，Shape 新建 CircleShape2D，半径根据酒壶图片适当调整。 Ctrl+S 保存为 scenes/object/target_pot.tscn。 附加脚本 target_pot.gd，保存到 scripts/： class_name TargetPot extends Area2D signal hit(sender:Node2D,arrow:Node2D) #自定义信号 "hit"，当靶子被击中时发射 @export var amplitude:float =60.0 #幅度 @export var frequency:float = 1.2 #摆动频率 var center_y:float=0.0 #初始位置的 Y 轴坐标，作为上下摆动的中心点 var elapsed_time: float = 0.0 # 累计从游戏开始（或节点进入场景）经过的总时间，用于配合三角函数计算摆动位置 var current_swing_velocity: float = 0.0 # 存储当前帧摆动速度的瞬时值（带符号） var _already_hit:bool = false #命中锁 func _ready() -> void: center_y = global_position.y #获取并记录当前全局坐标系下的 Y 轴位置，作为摆动中心 add_to_group("target") # 将该节点添加到名为 "target" 的组中，方便箭矢识别命中目标 func _physics_process(delta: float) -> void: elapsed_time +=delta # 简谐运动核心公式：位置 = 中心 + 幅度 × sin(频率 × 时间) var offset_y = amplitude * sin(frequency*elapsed_time) global_position.y = center_y + offset_y # 瞬时速度是位移对时间的导数：v = amplitude * frequency * cos(frequency * elapsed_time) current_swing_velocity = amplitude * frequency * cos(frequency * elapsed_time) # 获取当前摆动速度的绝对值（只取大小，忽略方向），供管理器算命中评分用 func get_current_velocity() -> float: return abs(current_swing_velocity) # 被箭矢命中时调用（由箭矢的碰撞检测触发） func on_hit(arrow:Node2D) -> void: if _already_hit: return _already_hit = true set_deferred("monitoring", false) hit.emit(self,arrow) queue_free() 7.3.2 深度解析：摆动背后的数学与物理 1. 核心变量：振幅与频率 振幅（Amplitude）：决定了物体离开平衡位置的最大距离。代码中 amplitude = 60 意味着酒壶最高偏离中心 60 像素。它控制了摆动的"强弱"。 频率（Frequency）：决定了单位时间内摆动的次数。frequency = 1.2 意味着摆动的节奏。它控制了摆动的"急缓"。 2. 位置公式：为什么是 sin(频率 × 时间)？ 代码中的核心公式是： 位置 = 中心 + 振幅 × sin(频率 × 时间) 为什么是正弦（sin）？ 因为 sin 的值在 -1 到 1 之间平滑循环。这使得靶子在两端（最高/最低点）自然减速至静止，在中间时加速通过。这就是物理学中的简谐运动，完美模拟了"荡秋千"或"弹簧"的质感，避免了机械生硬的直线弹跳。 为什么是"频率 × 时间"？ sin 函数默认周期约为 6.28 秒。通过乘以频率系数，我们可以自由控制摆动的速度。频率越大，sin 值变化越快，酒壶摆动得越急促。 3. 速度公式：为什么出现余弦（cos）？ 代码中还有一个关键变量 current_swing_velocity，它通过余弦函数计算： 速度 = 振幅 × 频率 × cos(频率 × 时间) 为什么要算速度？ 这用于命中评分。当酒壶在两端（速度 = 0）时最容易射中；在中间（速度最大）时射中则说明技术更高。 为什么是余弦（cos）？ 这就引出了微积分中的核心概念——导数。 导数就是"变化率"。例如：位置的导数 = 速度（位置变化的快慢）。 在数学上，sin 函数的导数恰好就是 cos 函数。位移（sin）对时间求导，自然得到速度（cos）。 直观理解：画一条正弦波。在波峰（最高点）时波形是平的（斜率为 0，速度为 0），此时 sin=±1 而 cos=0；在中间点（零点）时波形最陡（斜率最大，速度最快），此时 sin=0 而 cos=±1。完美对应！ 4. 代码逻辑串联 初始化：记录中心点 Y 坐标，并将自身加入"靶子组"。 每帧更新： 累计时间 → 用 sin 算偏移量 → 更新位置。 用 cos 算瞬时速度 → 存储备用。 命中响应：发射信号，销毁自身。 概念 物理意义 代码体现 数学本质 振幅 摆动幅度（强弱） amplitude 波峰高度 频率 摆动快慢（急缓） frequency 波形疏密 正弦（sin） 计算当前位置 sin(freq * time) 输出 -1 到 1 的循环值 余弦（cos） 计算当前速度 cos(freq * time) 位置（sin）的导数 导数 瞬间变化率 位置求导得速度 曲线在一点的斜率 记住：sin 管你在哪，cos 管你多快；而导数，就是连接"位置"与"速度"的那座数学桥梁。理解了这些，你就彻底掌握了动态酒壶的运动逻辑。 7.3.3 创建箭矢 Arrow Ctrl+N 新建场景，根节点选 Area2D，重命名 Arrow。 添加子节点 Sprite2D（保持默认名 Sprite2D），加载 assets/sprites/arrow.png。 添加子节点 CollisionShape2D，Shape 新建 CircleShape2D，大小根据箭矢的大小调整。 再添加一个子节点 Sprite2D，重命名 Shadow，加载 assets/sprites/arrow_shader.png，箭矢的影子。 在 Sprite2D 添加子节点 GPUParticles2D，重命名 Trail（将其作为Sprite2D的子节点，这样特效会跟随箭矢移动而移动），对应设置其属性： Amount = 16，Lifetime = 0.3； Process Material → 新建 ParticleProcessMaterial：Gravity = (0,0,0)，Initial Velocity → Min/Max 设为 2040，Scale → Min/Max 设为 0.51.0； 6. Ctrl+S 保存为 scenes/object/arrow.tscn。 7. 附加脚本 arrow.gd，保存到 scripts/： class_name Arrow extends Area2D # ========================= # 节点引用 # ========================= @onready var sprite_2d: Sprite2D = $Sprite2D @onready var shadow: Sprite2D = $Shadow @onready var gpu_particles_2d: GPUParticles2D = $Sprite2D/GPUParticles2D var is_bonus_arrow: bool = false # 默认普通箭矢 # ========================= # 信号 # ========================= signal arrow_landed signal bonus_landed(value: int) # ========================= # 基础物理 # ========================= @export var arrow_gravity: float = 800.0 @export var is_homing_missile: bool = false # ========================= # 导弹跟踪参数 # ========================= @export var homing_turn_speed: float = 6.0 @export var snake_amplitude: float = 60.0 @export var snake_frequency: float = 1.5 @export var target_lost_timeout: float = 2.0 # ========================= # 状态变量 # ========================= var velocity: Vector2 = Vector2.ZERO var height: float = 0.0 var vertical_speed: float = 0.0 var is_moving: bool = false var basespeed: float = 500.0 var target: Node2D = null var homing_time: float = 0.0 var base_speed_mag: float = 0.0 var viewport_size: Vector2 = Vector2.ZERO var lost_target_timer: float = 0.0 var already_destroy: bool = false var target_lock_cooldown: float = 0.0 var snake_phase_offset: float = 0.0 # 随机相位（仅导弹使用） var bonus_score_value: int = 0 # 追踪箭的额外加分值 # ========================= # 常量：销毁边界缓冲（仅普通箭矢） # ========================= const DESTROY_MARGIN: float = 100.0 # ========================= # 初始化 # ========================= func _ready() -> void: add_to_group("arrow") area_entered.connect(_on_area_entered) viewport_size = get_viewport().get_visible_rect().size rotation = 0.0 sprite_2d.position = Vector2.ZERO shadow.position = Vector2.ZERO+Vector2(10,15) lost_target_timer = 0.0 target_lock_cooldown = 0.0 already_destroy = false # ========================= # 物理主循环 # ========================= func _physics_process(delta: float) -> void: if not is_instance_valid(self) or not is_moving or already_destroy: return if is_homing_missile: _update_missile(delta) else: _update_physics(delta) global_position += velocity * delta # 边界销毁：仅对普通箭矢生效 if not is_homing_missile and _is_out_of_bounds(): _destroy_arrow() # ========================= # 检查箭矢是否完全离开屏幕（含缓冲区域） # ========================= func _is_out_of_bounds() -> bool: var pos = global_position return (pos.x < -DESTROY_MARGIN or pos.x > viewport_size.x + DESTROY_MARGIN or pos.y < -DESTROY_MARGIN or pos.y > viewport_size.y + DESTROY_MARGIN) # ========================= # 销毁箭矢（统一销毁方法） # ========================= func _destroy_arrow() -> void: if already_destroy: return already_destroy = true # 1. 停止并释放粒子系统 if gpu_particles_2d: gpu_particles_2d.emitting = false gpu_particles_2d.queue_free() # 2. 断开所有信号连接 if area_entered.is_connected(_on_area_entered): area_entered.disconnect(_on_area_entered) # 3. 从组中移除 remove_from_group("arrow") emit_signal("arrow_landed") queue_free() # ========================= # 发射接口 # ========================= func launch(power: float, direction: Vector2, ismissile: bool = false) -> void: is_homing_missile = ismissile is_bonus_arrow = ismissile var init_speed = basespeed + power * basespeed velocity = direction.normalized() * init_speed base_speed_mag = velocity.length() vertical_speed = 120.0 + power * 260.0 height = 0.0 homing_time = 0.0 lost_target_timer = 0.0 target_lock_cooldown = 0.0 target = null # --- 仅当为导弹时生成随机相位，普通箭矢置0 --- if is_homing_missile: snake_phase_offset = randf_range(0.0, TAU) var is_fire = randf() > 0.5 if is_fire: gpu_particles_2d.modulate = Color(1.0, 0.4, 0.0) # 火焰橙红 else: gpu_particles_2d.modulate = Color(0.2, 0.6, 1.0) # 冰蓝 else: snake_phase_offset = 0.0 sprite_2d.rotation = velocity.angle() is_moving = true # ========================= # 设置追踪箭（福缘奖励调用） # ========================= func setup_bonus(p_target: Node2D, p_bonus_score: int, p_element: String) -> void: target = p_target bonus_score_value = p_bonus_score is_homing_missile = true is_bonus_arrow = true snake_phase_offset = randf_range(0.0, TAU) # 染色 + 打开粒子拖尾 if gpu_particles_2d: gpu_particles_2d.emitting = true if p_element == "fire": gpu_particles_2d.modulate = Color(1.0, 0.4, 0.0) # 火焰橙红 else: gpu_particles_2d.modulate = Color(0.2, 0.6, 1.0) # 冰霜蓝 # ========================= # 搜寻最近目标 # ========================= func _find_closest_target(delta: float) -> void: target_lock_cooldown -= delta if target_lock_cooldown > 0.0: return target_lock_cooldown = 0.1 var candidates = get_tree().get_nodes_in_group("target") if candidates.is_empty(): target = null return var closest_dist = INF var closest_node = null for node in candidates: if not is_instance_valid(node): continue var dist = global_position.distance_squared_to(node.global_position) if dist < closest_dist: closest_dist = dist closest_node = node target = closest_node if target != null: lost_target_timer = 0.0 # ========================= # 普通箭矢重力弹道（含落地判断） # ========================= func _update_physics(delta: float) -> void: if not is_instance_valid(self) or already_destroy: return height += vertical_speed * delta vertical_speed -= arrow_gravity * delta # 落地判断 if height <= 0.0 and vertical_speed < 0.0: height = 0.0 vertical_speed = 0.0 _destroy_arrow() return var visual_offset = -height * 0.15 sprite_2d.position.y = visual_offset var shrink = clamp(1.0 - height / 260.0, 0.35, 1.0) shadow.scale = Vector2.ONE * shrink shadow.modulate.a = shrink var shadow_ground_offset = Vector2(1.0, 1.0).normalized() * height * 0.5 shadow.position = Vector2(10, 15) + shadow_ground_offset shadow.rotation = sprite_2d.rotation # ========================= # 导弹跟踪物理（蛇形Z跟踪） # ========================= func _update_missile(delta: float) -> void: # 1. 目标锁定与丢失计时 if not is_instance_valid(target): _find_closest_target(delta) if not is_instance_valid(target): lost_target_timer += delta # 超时后不销毁，继续直线飞行 else: lost_target_timer = 0.0 else: lost_target_timer = 0.0 if lost_target_timer > target_lost_timeout + 1.0: _destroy_arrow() return # 销毁后不再执行后续移动逻辑 # 2. 计算朝向目标的方向 var current_dir = velocity.normalized() if is_instance_valid(target) and lost_target_timer <= target_lost_timeout: var to_target = (target.global_position - global_position).normalized() var angle_diff = current_dir.angle_to(to_target) var max_turn = homing_turn_speed * delta var turn_angle = clamp(angle_diff, -max_turn, max_turn) current_dir = current_dir.rotated(turn_angle) # 3. 叠加蛇形摆动（使用随机相位） var perpendicular = current_dir.orthogonal() var time = Time.get_ticks_msec() / 1000.0 var snake_offset = sin(time * snake_frequency * TAU + snake_phase_offset) * snake_amplitude var new_velocity = (current_dir * base_speed_mag) + (perpendicular * snake_offset) velocity = new_velocity.limit_length(base_speed_mag) # 4. 更新箭头和阴影旋转 sprite_2d.rotation = velocity.angle() shadow.rotation = sprite_2d.rotation # 5. 导弹无重力，重置高度 height = 0.0 vertical_speed = 0.0 sprite_2d.position.y = 0.0 gpu_particles_2d.position.y = 0.0 shadow.scale = Vector2.ONE shadow.modulate.a = 1.0 # ========================= # 碰撞检测 # ========================= func _on_area_entered(area: Area2D) -> void: if not is_moving or not is_instance_valid(self) or already_destroy: return if area.is_in_group("target"): if area.has_method("on_hit"): area.on_hit(self) if is_homing_missile: emit_signal("bonus_landed", bonus_score_value) _destroy_arrow() func _exit_tree() -> void: # 确保粒子系统被释放 if gpu_particles_2d and is_instance_valid(gpu_particles_2d): gpu_particles_2d.emitting = false gpu_particles_2d.queue_free() # 断开所有信号 if area_entered.is_connected(_on_area_entered): area_entered.disconnect(_on_area_entered) 第二阶段：场景与交互组件 7.3.4 创建场景、摄像机与草地地面 新建场景：Ctrl+N，根节点选 Node2D，重命名为 BowlGame，Ctrl+S 保存为 scenes/levels/bowl_game.tscn。 添加摄像机：选中 BowlGame，添加子节点 Camera2D，位置设为场地几何中心 (577, 325)。如果运行后酒壶或主角有一部分在视野外，微调摄像机位置或缩放。 添加草地图层：添加子节点 TileMapLayer，命名 GroundLayer，Tile Set 加载 resources/jiangnan_tileset.tres，用绘制模式铺满一片草地和石板路，作为投壶场地地面。 7.3.5 添加醉意准星 AimCursor 在 BowlGame 场景中。 添加子节点 Sprite2D，重命名 AimCursor，加载 assets/sprites/cursor.png，Z Index 设为 10。 附加脚本 aim_cursor.gd，保存到 scripts/： class_name AimCursor extends Node2D @export var base_drift: float = 20.0 # 基础漂移幅度（准星在锚点周围随机晃动的基准范围，单位：像素） @export var max_drift: float = 180.0 # 最大漂移幅度（醉意值拉满时能达到的最大晃动范围，单位：像素） @export var return_strength: float = 2.0 # 回归力度（准星偏离锚点后被拉回的速度系数，值越大回正越快） @export var smoothness: float = 6.0 # 平滑度（准星实际位置向目标位置移动时的插值速度，值越大跟随越灵敏） @export var follow_offset: Vector2 = Vector2(0, -100) # 相对于主角的锚点偏移量（向上偏移，朝向酒壶所在的区域） var target_offset: Vector2 = Vector2.ZERO # 目标偏移量（期望准星到达的位置，由随机漂移计算得出，相对于锚点的偏移） var current_offset: Vector2 = Vector2.ZERO # 当前实际偏移量（准星当前相对于锚点的位置，会通过插值逐渐接近 target_offset） @onready var player: CharacterBody2D = get_parent().get_node("Player") func _process(delta: float) -> void: var anchor_position = player.global_position + follow_offset var drunk = 0.3 if GameManager: drunk = GameManager.player_drunk_level var current_max = base_drift + (max_drift - base_drift) * drunk var random_angle = randf() * TAU var random_step = randf() * current_max * delta * smoothness target_offset += Vector2(cos(random_angle), sin(random_angle)) * random_step target_offset = target_offset.lerp(Vector2.ZERO, return_strength * delta) if target_offset.length() > current_max * 1.5: target_offset = target_offset.normalized() * current_max * 1.5 current_offset = current_offset.lerp(target_offset, 1.0 - exp(-smoothness * delta)) global_position = anchor_position + current_offset 7.3.6 添加蓄力 UI PowerUI 在 BowlGame 场景中。 添加子节点 CanvasLayer，重命名 PowerUI；在它下面添加子节点 ProgressBar，命名 PowerBar，Min Value=0、Max Value=1、Step=0.01，size=(200, 27)，用 Layout → Bottom Wide 固定在屏幕底部。 附加脚本 power_ui.gd，保存到 scripts/： class_name PowerUI extends CanvasLayer signal arrow_launched(power: float) #当玩家松开鼠标发射箭矢时发出，参数 power 为蓄力大小（0~1） @export var charge_speed: float = 1.5 var current_power: float = 0.0 var is_charging: bool = false var is_ready: bool = true @onready var power_bar: ProgressBar = $PowerBar func _input(event: InputEvent) -> void: if not is_ready: return if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT: if event.pressed and not is_charging: is_charging = true current_power = 0.0 elif not event.pressed and is_charging: is_charging = false if current_power > 0.1: arrow_launched.emit(current_power) current_power = 0.0 power_bar.value = 0.0 func _process(delta: float) -> void: if not is_charging: return current_power += delta * charge_speed if current_power > 1.0: current_power = 1.0 power_bar.value = current_power func reset_power() -> void: current_power = 0.0 power_bar.value = 0.0 is_charging = false func set_ready(state: bool) -> void: is_ready = state if not state: is_charging = false 第三阶段：逻辑串联 7.3.7 实例化 Player，追加投壶模式 回到 bowl_game.tscn，选中 BowlGame，实例化 Player.tscn，保持默认名 Player，位置设为 (400, 520)。 打开 player.gd（第五章写好的主角脚本），探索模式原有的移动/动画代码不用动，追加下面这些内容，让 Player 能在探索地图和投壶场景里复用同一份脚本，目前脚本代码修改如下： extends CharacterBody2D @onready var player_ghost: AnimatedSprite2D = $PlayerGhost @export var speed: float = 200.0 # 记录角色最后移动的方向向量，默认为向下（0, 1） var last_direction:Vector2 =Vector2.DOWN # ============================================================ # 🏹 投壶模式相关状态（新增） # 探索地图不会用到这些变量，只有进入投壶场景后才会被设置 # ============================================================ var bowl_mode: bool = false # 是否处于投壶模式（只左右移动，不碰撞） var bowl_min_x: float = 0.0 var bowl_max_x: float = 0.0 var bowl_power_ui: PowerUI = null # 投壶场景的蓄力 UI，由 BowlGameManager 传入 var bowl_is_shooting: bool = false # 射箭动画播放期间，暂时不切换其他动画 @onready var animated: AnimatedSprite2D = $AnimatedSprite2D #当节点首次进入场景树时自动调用 func _ready() -> void: player_ghost.visible=false update_idle_animation()# 一开始就播放默认方向的待机动画 func _physics_process(delta: float) -> void: if bowl_mode: _physics_process_bowl_mode(delta) else: _physics_process_explore_mode(delta) func _physics_process_explore_mode(delta: float) -> void: # 获取八方向输入（自动归一化，斜向速度不超标） var direction = Input.get_vector("left", "right", "up", "down") velocity = direction * speed move_and_slide() if direction.length() > 0: last_direction = direction play_walk_animation(direction) else: update_idle_animation() func _physics_process_bowl_mode(delta: float) -> void: var input_dir := Input.get_axis("left", "right") position.x += input_dir * speed * delta position.x = clamp(position.x, bowl_min_x, bowl_max_x) if bowl_is_shooting: return if bowl_power_ui and bowl_power_ui.is_charging: _update_bowl_aim_frame(bowl_power_ui.current_power) elif input_dir != 0.0: $AnimatedSprite2D.play("walk_up") # 面朝酒壶方向走位，复用已有的八方向动画之一 else: $AnimatedSprite2D.play("idle_up") # 蓄力拉弓：不自动播放，直接按蓄力比例"点播"到对应的那一帧 func _update_bowl_aim_frame(charge_ratio: float) -> void: var anim: AnimatedSprite2D = $AnimatedSprite2D if not anim.sprite_frames or not anim.sprite_frames.has_animation("aim"): return if anim.animation != "aim": anim.animation = "aim" anim.stop() var frame_count = anim.sprite_frames.get_frame_count("aim") anim.frame = clampi(int(charge_ratio * (frame_count - 1)), 0, frame_count - 1) # 由 BowlGameManager 在 _ready() 里调用，进入投壶模式 func enter_bowl_mode(min_x: float, max_x: float, power_ui: PowerUI) -> void: bowl_mode = true bowl_min_x = min_x bowl_max_x = max_x bowl_power_ui = power_ui bowl_power_ui.arrow_launched.connect(_on_bowl_arrow_launched) $AnimatedSprite2D.animation_finished.connect(_on_bowl_animation_finished) func _on_bowl_arrow_launched(_power: float) -> void: bowl_is_shooting = true if $AnimatedSprite2D.sprite_frames.has_animation("shoot"): $AnimatedSprite2D.play("shoot") func _on_bowl_animation_finished() -> void: if $AnimatedSprite2D.animation == "shoot": bowl_is_shooting = false func exit_bowl_mode() -> void: bowl_mode = false # 根据 last_direction 更新待机动画 func update_idle_animation(): var anim_name = get_animation_name(last_direction, "idle") animated.play(anim_name) func play_walk_animation(dir: Vector2): var anim_name = get_animation_name(dir, "walk") animated.play(anim_name) func get_animation_name(dir:Vector2,action:String)->String: var x = dir.x var y = dir.y var suffix:String if x==0 and y==0: suffix = "right" else: if abs(x)>abs(y): suffix = "right" if x>0 else "left" elif abs(y)>abs(x): suffix = "down" if y>0 else "up" else: if x>0 and y<0: suffix ="up_right" elif x>0 and y>0: suffix = "down_right" elif x < 0 and y < 0: suffix = "up_left" else: suffix = "down_left" return action+"_"+suffix bowl_mode 默认 false，不影响探索地图： _physics_process 里第一行判断就会走 _physics_process_explore_mode() 这个分支——探索地图上的 Player 实例永远不会调用 enter_bowl_mode()，这些新增变量和函数对它完全没有影响。 aim/shoot 这两个动画怎么办： 这两个是投壶专属的新动画，需要在 SpriteFrames 资源里补充；aim 只需要"由松到紧"的几张渐进关键帧（4~6 帧足够），因为最终显示第几帧由蓄力比例决定。如果暂时没做，_update_bowl_aim_frame() 和 _on_bowl_arrow_launched() 都做了存在性检查，不会报错，只是拉弓、射箭时不会有额外动作。 7.3.8 创建游戏管理器 BowlGameManager 这是把所有零件串起来的一节，同时实现两件事：酒壶在场地里随机位置动态生成，以及**"连珠三箭"福缘奖励真正射出三支带火/冰特效的追踪箭，命中额外加分**，而不再是单纯打印文字。 选中根节点 BowlGame，附加脚本 bowl_game_manager.gd： class_name BowlGameManager extends Node2D #对外变量 @export var arrow_scene: PackedScene = preload("res://scenes/object/arrow.tscn") #箭矢场景 @export var pot_scene: PackedScene = preload("res://scenes/object/target_pot.tscn") #酒壶场景 @export var player: CharacterBody2D #主角 @export var aim_cursor: AimCursor #瞄准星 @export var power_ui: PowerUI #力量控制 #角色移动范围 @export var player_min_x: float = 80.0 @export var player_max_x: float = 1200.0 #酒壶出现范围 @export var pot_spawn_min_x: float = 100.0 @export var pot_spawn_max_x: float = 1100.0 @export var pot_spawn_min_y: float = 80.0 @export var pot_spawn_max_y: float = 220.0 @export var round_time_limit: float = 30.0 #游戏时间限制 @export var score_multiplier:float = 1.0 # 福缘奖励"连珠三箭"相关参数 @export var bonus_arrow_score: int = 3 # 每支追踪箭命中额外加多少分 @export var bonus_arrow_delay: float = 0.15 # 三支箭之间的发射间隔（秒） var pot_num:int =52 var current_pot_num:int =0 #场景中酒壶数量 var score: int = 0 #分数 var hits: int = 0 #销毁酒壶数量 var elapsed_round_time: float = 0.0 #共玩了多长时间 var round_active:int=0 #开局状态，0表示未开局，1表示正在开局 2表示结束 var arrowCount:int =0 var is_barrage_active: bool = false # 防止连珠三箭重复触发 var _barrage_should_stop: bool = false #强制中断连发协程 func _ready() -> void: power_ui.arrow_launched.connect(_on_arrow_launched) player.enter_bowl_mode(player_min_x, player_max_x, power_ui) start_round() func _process(delta: float) -> void: elapsed_round_time += delta if elapsed_round_time >= round_time_limit or current_pot_num<=0: if get_tree().get_nodes_in_group("arrow").size()<=0: _end_round() # ============================================================ # 🚀 开局 / 生成酒壶 # ============================================================ func start_round() -> void: score = 0 hits = 0 elapsed_round_time = 0.0 round_active = 1 power_ui.set_ready(true) for i in range(pot_num):#生成酒钢数量 _spawn_pot() update_ui() print("--- 新的一局！", "限时 " + str(round_time_limit) + " 秒", " ---") #------酒壶生成函数---------- func _spawn_pot() -> void: current_pot_num += 1 var pot: TargetPot = pot_scene.instantiate() pot.position = Vector2(randf_range(pot_spawn_min_x, pot_spawn_max_x),randf_range(pot_spawn_min_y, pot_spawn_max_y)) pot.amplitude = randf_range(60,100) pot.frequency = randf_range(1.1,2.5) add_child(pot) pot.hit.connect(_on_target_hit) # ============================================================ # 🏹 发射 # ============================================================ func _on_arrow_launched(power: float,is_bost:bool=false) -> void: var direction = (aim_cursor.global_position - player.global_position).normalized() var arrow = arrow_scene.instantiate() add_child(arrow) arrow.global_position = player.global_position+Vector2(0,-30) arrow.launch(power, direction,is_bost) arrow.arrow_landed.connect(_on_arrow_landed) arrowCount +=1 #发射键的数量 update_ui() #箭矢销毁 func _on_arrow_landed() -> void: _advance_pot() #酒壶被射中 func _on_target_hit(current_pot:Node2D,arrow:Node2D) -> void: # 增加有效性检查，防止回调时酒壶已被销毁 if not is_instance_valid(current_pot) or current_pot.is_queued_for_deletion(): return hits += 1 current_pot_num -=1 #计算分值 var swing_velocity = 0.0 var max_speed = 1.0 swing_velocity = current_pot.get_current_velocity() #获取当前摆动速度的绝对值 max_speed = current_pot.amplitude * current_pot.frequency #幅度*摆动频率 var timing_score = 1.0 - clamp(swing_velocity / max(max_speed, 0.01), 0.0, 1.0) var total_score = 0.6 + timing_score * 0.4 var final_score = total_score * score_multiplier score_multiplier =1.0 if final_score > 0.8 and not is_barrage_active and not arrow.is_bonus_arrow: call_deferred("_fire_bonus_arrows") score += int(final_score*10) if not arrow.is_bonus_arrow: score += int(final_score*10)*3 _advance_pot() func _advance_pot() -> void: if not round_active: return update_ui() #_spawn_next_pot() # ============================================================ # 📊 结算与 UI # ============================================================ func _end_round() -> void: if round_active==2: return round_active = 2 _barrage_should_stop = true #强制停止连发协程 is_barrage_active = false # 防止继续触发 power_ui.set_ready(false) #禁止再发射新箭 # ---------- 统计额外加分 ---------- # 1. 剩余时间奖励：每0.1秒 +5 分（取整秒数） var remaining_time = max(0.0, round_time_limit - elapsed_round_time) var time_bonus = int(remaining_time * 10) * 5 # 向下取整到0.1秒，每0.1秒5分 # 2. 命中率奖励 var hit_rate = 0.0 if arrowCount > 0: hit_rate = float(hits) / float(arrowCount) var hit_bonus = 0 if hit_rate >= 1.0: hit_bonus = 100 # 100% 命中加 100 分 else: hit_bonus = int(hit_rate * 100) # 命中率百分之多少就加多少分 # 累加到总分 score += time_bonus + hit_bonus # 打印最终统计信息 update_ui() print("=== 本局结束 ===","得分",score) print("剩余时间: %.1f 秒, 时间奖励: +%d 分" % [remaining_time, time_bonus]) print("命中率: %.1f%%, 命中奖励: +%d 分" % [hit_rate * 100, hit_bonus]) func update_ui() -> void: print("酒壶: ",current_pot_num,"/",pot_num, " 得分: ", score, " 命中: ", hits, "/", arrowCount) func _fire_bonus_arrows() -> void: if is_barrage_active: return is_barrage_active = true _barrage_should_stop = false print("🍶 触发福缘【连珠三箭】！") for i in range(3): # 每次循环前检查是否需要提前终止 if _barrage_should_stop or round_active != 1 or not is_instance_valid(self): break # 每支箭的力量随机在 0.1 ~ 0.3 之间 var power = randf_range(0.1, 0.3) # 发射追踪箭（is_bost = true） _on_arrow_launched(power, true) if i < 2: var elapsed = 0.0 while elapsed < bonus_arrow_delay: if _barrage_should_stop or round_active != 1 or not is_instance_valid(self): break await get_tree().process_frame elapsed += get_process_delta_time() # 如果是因为停止条件退出的，则不再继续发射后续箭矢 if _barrage_should_stop or round_active != 1 or not is_instance_valid(self): break is_barrage_active = false print("🍶 连珠三箭发射完毕！") 7.3.9 检查器赋值确认表 场景搭建完成后，在 BowlGameManager 节点的检查器中确认以下属性已正确赋值： 属性名 应该拖入/填入的内容 Arrow Scene / Pot Scene 已通过 preload 自动赋值，不用管 Player 场景树中的 Player 实例 Aim Cursor 场景树中的 AimCursor 节点 Power UI 场景树中的 PowerUI 节点 Player Min X / Player Max X 默认 220/580，对应 7.3.4 节的草地范围 Pot Spawn Min X / Max X / Y 默认 260/540/180，需要落在摄像机可见范围内 End Mode 下拉框选择"按数量结束"或"按时间结束" Total Pots 按数量结束时生效，默认 5 Round Time Limit 按时间结束时生效，默认 30 秒 Bonus Arrow Score / Bonus Arrow Delay 默认 3 分 / 0.15 秒，可按手感调整 7.4 集成到主游戏 投壶场景开发完成后，需要把它接入主游戏 main.tscn。我们在第六章已经预留了 BattleArena 容器。 7.4.1 将投壶场景添加到 BattleArena 打开 main.tscn。 选中 BattleArena 节点（位于根节点 Main 下）。 右键 → 实例化子场景，选择 bowl_game.tscn。 将实例化的 BowlGame 节点的 Visible 属性取消勾选（默认隐藏，只有进入战斗时才显示）。 7.4.2 添加触发区域（进入战斗） 在小镇地图上放置一个触发区域，当玩家走进时自动进入投壶战斗： 打开 level_town.tscn。 在根节点 TownRoot 下，添加子节点 Area2D，命名为 BattleTrigger。 添加子节点 CollisionShape2D，形状选 RectangleShape2D，覆盖小镇广场的一块区域。 添加sprite2D，命名为potlist，添加酒壶素材 添加脚本 battle_trigger.gd： extends Area2D func _ready(): body_entered.connect(_on_body_entered) func _on_body_entered(body: Node2D): if body.is_in_group("player"): GameManager.start_battle() 修改game_manager.gd代码如下 extends Node # ---- 游戏状态枚举 ---- 自由移动\参与战斗\特定于某种机制，可能指制作药水、炼金术、混合原料、烹饪或酿造\与NPC对话\游戏完全暂停 enum GameState { EXPLORE, BATTLE, BREWING, DIALOGUE, PAUSED } # ---- 核心变量 ---- var current_state: GameState = GameState.EXPLORE var player_drunk_level: float = 0.3 # 全局醉意值 # ---- 地图切换相关 ---- @export var town_scene: PackedScene = preload("res://scenes/levels/level_town.tscn") var active_level: Node2D = null @onready var level_container: Node = get_node("/root/Main/World/CurrentLevel") @onready var battle_container: Node = get_node("/root/Main/BattleArena") # ---- 战斗动态加载（使用路径，避免编译时预加载占用内存） ---- @export var battle_scene_path: String = "res://scenes/levels/bowl_game.tscn" var current_battle_instance: Node = null # 用来记录当前战斗实例 # ---- 初始化 ---- func _ready() -> void: print("[GameManager] 游戏启动，当前状态: ", GameState.keys()[current_state]) battle_container.visible = false # 加载默认地图（江南小镇） switch_level(town_scene, Vector2(600, 150)) # ---- 地图切换核心函数 ---- func switch_level(target_scene: PackedScene, spawn_pos: Vector2) -> void: if active_level != null: active_level.queue_free() active_level = null await get_tree().process_frame # ★★★ 关键：等一帧，让 Godot 彻底处理完销毁 ★★★ active_level = target_scene.instantiate() level_container.add_child(active_level) # 假设地图中有 Player 和 GroundLayer var player = active_level.get_node("YSortWorld/Player") if player: player.position = spawn_pos var camera = player.get_node("Camera2D") var ground = active_level.get_node("GroundLayer") if camera and ground and camera.has_method("setup_limits_from_tilemap"): camera.setup_limits_from_tilemap(ground) else: push_warning("[GameManager] 地图中没有找到 Player 节点，位置未设置") # ---- 状态切换（保留原逻辑，并添加针对地图切换的处理） ---- func change_state(new_state: GameState) -> void: if current_state == new_state: return print("[GameManager] 状态变更: ", GameState.keys()[current_state], " -> ", GameState.keys()[new_state]) current_state = new_state match new_state: GameState.EXPLORE: get_tree().paused = false GameState.PAUSED: get_tree().paused = true GameState.BREWING, GameState.DIALOGUE: get_tree().paused = true # UI 弹出时暂停世界，但 UI 层需设为不受暂停影响 GameState.BATTLE: # ARPG 战斗不暂停世界 pass func start_battle() -> void: # 防止重复进入战斗 if current_battle_instance != null: push_warning("战斗已存在，请勿重复触发") return await get_tree().physics_frame print("[GameManager] 开始加载战斗场景...") var player = active_level.get_node("YSortWorld/Player") if player: var player_camera = player.get_node("Camera2D") if player_camera and player_camera is Camera2D: # 必须使用 set_deferred，防止物理冲突报错 player_camera.set_deferred("current", false) print("[GameManager] 已强制关闭玩家相机 (current = false)") # 1. 同步加载场景资源（如果场景 50MB 以上觉得卡，看下面进阶异步方案） var battle_scene = load(battle_scene_path) if battle_scene == null: push_error("战斗场景加载失败，请检查路径: ", battle_scene_path) return # 2. 实例化并放入容器 current_battle_instance = battle_scene.instantiate() battle_container.add_child(current_battle_instance) # 3. 显示容器，隐藏世界（因为是平级，互不干扰） battle_container.set_deferred("visible", true) level_container.set_deferred("visible", false) # 4. 切换游戏状态 change_state(GameState.BATTLE) print("[GameManager] 战斗场景已加载并显示") # ---------- 🏕️ 结束战斗（核心优化点：用完即焚） ---------- func end_battle() -> void: if current_battle_instance == null: push_warning("没有正在进行的战斗") return print("[GameManager] 正在销毁战斗场景，释放内存...") # 1. 从树上移除并彻底销毁（引用归零，Godot 自动卸载纹理/音效） current_battle_instance.queue_free() current_battle_instance = null # 2. 隐藏容器，显示世界 battle_container.set_deferred("visible", false) level_container.set_deferred("visible", true) # 2.1 重新启用小镇 player 相机（战斗开始时被禁用了） var player = active_level.get_node("YSortWorld/Player") if player: var player_camera = player.get_node("Camera2D") if player_camera and player_camera is Camera2D: player_camera.set_deferred("current", true) # 3. 切回探索状态 change_state(GameState.EXPLORE) print("[GameManager] 战斗内存已释放") 7.5 本章总结 7.5.1 本章完整交付物 文件/成果 路径 说明 bowl_game.tscn（含 Camera2D、GroundLayer 草地瓦片、Player.tscn 实例） scenes/object/ 投壶战斗主场景 target_pot.tscn scenes/object/ 动态酒壶场景（纵向摇摆，位置由管理器动态指定） arrow.tscn（含 Shadow 影子精灵、Trail 火/冰粒子拖尾） scenes/object/ 箭矢场景，修复了发射速度换算与弧线落地判定 player.gd（新增"投壶模式"） scripts/ 第五章的主角脚本追加 enter_bowl_mode() 等函数，探索/投壶两种模式共用同一份脚本 target_pot.gd scripts/ 酒壶纵向钟摆摇摆脚本 arrow.gd scripts/ 箭矢俯视角飞行脚本，含修复后的命中判定与追踪箭逻辑 aim_cursor.gd scripts/ 醉意准星漂移脚本，锚点每帧跟随主角位置 power_ui.gd scripts/ 蓄力UI控制脚本 bowl_game_manager.gd scripts/ 游戏管理器：动态生成酒壶、按数量/限时双模式结算、真正生效且带特效的福缘奖励 battle_trigger.gd scripts/ 战斗触发脚本 7.5.2 核心知识点总结 知识点 应用场景 章节中的体现 蓄力比例与实际速度分离 修复"箭矢几乎不动"问题 launch() 内部把 0~1 的蓄力比例换算成 200~400 的像素速度，而不是直接拿比例当速度用 抛体运动的符号约定 修复弧线落地判定错误 约定 height 正值=离地、vertical_speed 正值=上升，重力持续做减法，落地条件是"高度归零且正在下降" Area2D 之间的命中判定 命中/落地互斥 箭矢监听自己的 area_entered 信号，命中后立即 queue_free()，不会和落地判定冲突 转向速度限制的追踪逻辑 连珠三箭的自动追踪 每帧用 angle_to() 算出偏差角度，clamp 到最大转向速度内再旋转速度向量，而不是瞬间对准目标 GPUParticles2D 拖尾特效 福缘奖励的视觉表现 追踪箭染色 + 打开粒子发射，让"连珠三箭"从一句打印文字变成真正看得见的战斗表现 奖励系统防止自我叠加 避免箭矢刷屏 barrage_active 标记位，连珠三箭进行中不会再次触发同一个奖励 动态实例化 + 随机位置 酒壶不固定出现在一个点 BowlGameManager 用 pot_scene.instantiate() 在指定范围内随机生成酒壶 按数量/按时间的双结束模式 适配不同的关卡节奏需求 @export_enum 做一个下拉选择，两种模式共用同一套命中/落地事件处理 准星锚点跟随移动目标 让漂移效果和角色位置联动 AimCursor 每帧用 player.global_position + follow_offset 重新计算锚点 简谐运动（正弦波） 物体来回摆动 酒壶的纵向钟摆摇摆：y = center + amplitude * sin(frequency * time) 同一角色的多模式切换 复用同一份场景/脚本 player.gd 用 bowl_mode 开关区分探索移动和投壶走位 蓄力比例驱动动画帧 拉弓等需要"进度感"的动作 关掉自动播放，直接用 charge_ratio 换算 anim.frame 信号机制 节点间解耦通信 arrow_launched、hit、arrow_landed、bonus_landed 信号 launch 与 setup_bonus 职责分离 避免追踪箭初始化重复染色 launch() 只管物理初速度，setup_bonus() 管追踪模式+特效染色，二者不重叠 7.5.3 玩法设计心得 这一版投壶最大的两处修复：一是让箭矢真正"看得见地飞出去"——蓄力比例和飞行速度是两个不同量纲的数字，混用会导致"看起来什么都没发生"；二是让福缘奖励从纯粹的数字变化，变成真正影响画面的战斗表现——"连珠三箭"不再只是控制台里一行加分记录，而是三支带着火焰或寒冰拖尾、会自动追踪的箭矢真的飞了出去。这个思路会贯穿后续章节：数值上生效只是及格线，能被玩家亲眼看到、感受到，才是真正完整的游戏反馈。 7.6 下一章预告与延伸阅读 下一章预告 第8章《战斗升级：状态机与攻击动画》将把本章的远程投壶与近战攻击结合，引入 Hitbox/Hurtbox 体系，并开始构建主角的战斗状态机（待机/移动/攻击/受击/死亡）。届时，投壶将作为主角的"远程技能"融入完整的战斗系统中。 延伸阅读参考 Godot 官方文档 - 物理介绍：Physics introduction — 理解 Area2D 之间 area_entered 信号的触发条件； Godot 官方文档 - 信号与事件系统：Using signals — 巩固本章大量使用的信号驱动通信方式； Godot 官方文档 - GPUParticles2D 类参考：了解粒子发射器的 Amount、Lifetime、ProcessMaterial 等核心属性； Godot 官方文档 - Camera2D 类参考：了解 Zoom、Position 等属性如何影响可视范围； 简谐运动可视化：搜索 "pendulum simulation" 理解正弦波运动；抛体运动物理原理（中文百科词条"抛体运动"）：巩固水平匀速、竖直匀加速的基础公式推导；布朗运动：随机游走模型在游戏AI和特效中的广泛应用。
【酒魂开发手记】第六章 画出江南小镇——TileMap地图与摄像机
引言：有无相生，难易相成，高下相倾 ——《道德经》 上一章我们完成可操控主角，但角色仅存在空白虚空场景。本章先重构清理冗余main.tscn，搭建标准化全局常驻容器；再独立拆分江南小镇关卡，采用多层TileMapLayer制作国风瓦片地图、规范地形物理碰撞；搭配Camera2D实现带边界限制的平滑跟随镜头；完善全局管理器GameManager，实现关卡动态加载、切换自动释放美术资源，从根源解决多地图叠加造成的内存堆积问题；最后解决 2D 层级遮挡、人物被墙体遮挡后完全消失的体验缺陷，完整搭建可自由探索的江南小镇场景。 本章核心目标 完整清理、重构main.tscn主场景，搭建无冗余标准化全局层级结构； 新建独立关卡资源目录，实现地图、角色与主场景完全解耦； 制作、裁切通用国风TileSet瓦片资源，统一规范 2D 物理碰撞分层； 分层绘制江南小镇地图，提前预留战斗、NPC、传送交互点位； 为主角配置带地图边界锁定的平滑跟随摄像机； 迭代GameManager全局管理脚本，实现关卡动态实例化、切换自动释放贴图资源； 实现 Y 轴动态排序，解决 2D 物体前后遮挡错乱 BUG； 开发人物遮挡透视轮廓 Shader，角色被墙体 / 树木遮挡时显示半透明残影，提升探索手感。 6.1 重构主场景 main.tscn（全局永久根场景） 统一规范主场景节点层级，区分全局常驻模块与动态可销毁地图容器，所有探索关卡仅临时挂载，切换时完整释放。 6.1.1 标准主场景树形结构 Main (Node2D，游戏唯一启动根节点，全程不销毁) ├─ World (Node2D 大世界探索总容器) │ └─ CurrentLevel (空Node2D，仅运行时挂载动态地图，编辑器空白无资源、无脚本) ├─ BattleArena (Node2D 独立战斗空间，全局常驻不销毁) └─ UI (CanvasLayer 全局HUD、对话、酿造界面，永久顶层渲染) 6.1.2 分步搭建规范操作 选中父节点World，右键添加子节点Node2D，重命名CurrentLevel； 清空CurrentLevel所有子节点、贴图与附加脚本，保持空白容器状态； 保留原有BattleArena、UI节点，二者属于全局常驻功能，不受地图切换影响； 6.1.3 层级功能约束说明 World：仅承载城镇、室内等探索类地图，所有关卡运行时挂载至子节点CurrentLevel； BattleArena：独立隔离战斗逻辑，投壶、对战系统与探索地图完全互不干扰； UI：依托CanvasLayer天然顶层渲染特性，玩家状态栏、对话弹窗、酿造面板永久可见。 6.2 创建独立关卡目录与小镇关卡场景 6.2.1 新建关卡专用资源文件夹 编辑器左侧文件面板右键scenes文件夹，新建文件夹命名levels； 作用：所有城镇、室内关卡场景统一归档，与主场景、角色预制件分类管理，项目结构清晰。 6.2.2 创建江南小镇独立关卡场景 快捷键Ctrl+N新建空白场景，根节点选择Node2D，重命名TownRoot； Ctrl+S保存路径：res://scenes/levels/level_town.tscn； 在TownRoot下创建全套地图专用节点，瓦片、交互标记、主角全部隔离在本关卡内： TownRoot (Node2D 小镇关卡独立根节点，切换时整体销毁) ├─ GroundLayer (TileMapLayer 地面底层) ├─ DecoLayer (TileMapLayer 花草装饰中层) ├─ WallLayer (TileMapLayer 墙体障碍顶层) ├─ NPCs (Node2D NPC出生坐标标记容器) └─ Player (实例第五章完成的Player.tscn主角预制件) 6.3 瓦片地图核心原理与分层设计规范 6.3.1 瓦片地图基础概念 常规 RPG 地图存在大量重复视觉单元（地砖、墙体、草木），若每一块地面单独使用独立图片，会造成海量内存占用。 瓦片地图（TileMap）核心思路：将所有地图视觉单元整合至一张图集（Atlas），通过网格坐标记录瓦片摆放位置。如同批量地砖，复用同一套素材拼接出完整地图。 三大核心优势： 内存极低：单张图集承载数百种瓦片，无论地图尺寸多大，贴图资源仅加载一次； 编辑高效：编辑器画笔、填充工具可视化绘制地图； 碰撞易管理：可单独为每一类瓦片配置专属碰撞形状，整张地图碰撞批量生效。 6.3.2 TileMapLayer 分层机制 Godot4.0 起废弃旧版单节点TileMap，改用独立TileMapLayer图层节点，每层可单独控制渲染层级、可见性、碰撞逻辑。 《酒魂》固定三层瓦片分层规范，渲染顺序自下而上：底层→中层→顶层： 图层节点名 层级作用 是否启用物理碰撞 GroundLayer 底层：青石板、泥土、河道水面 否 DecoLayer 中层：草丛、落叶、地面花纹、低矮灌木 否 WallLayer 顶层：院墙、房屋墙体、大树、巨石、栅栏 是 分层核心价值：碰撞逻辑分离，不会出现装饰草丛阻挡玩家移动、地板误触发碰撞的问题，后续修改单一图层不影响整张地图。 6.3.3 TileSet 瓦片集资源作用 TileMapLayer仅记录瓦片摆放坐标，不存储图片素材；视觉资源、碰撞属性全部存放在独立TileSet资源中，核心存储内容： 纹理源：瓦片图集 PNG 原图； 裁切规格：单块瓦片像素尺寸（本项目统一 32×32 基础单元）； 瓦片元数据：碰撞外形、自定义标签、水波循环动画； 资源复用：一套TileSet可供给多个关卡、多层TileMapLayer共用，减少贴图重复加载。 6.4 国风瓦片素材导入与 TileSet 标准化制作 6.4.1 全局像素渲染统一配置 顶部菜单：项目 → 项目设置 → 渲染 → 纹理 将「默认纹理过滤」修改为最近邻 (Nearest)，全局统一生效； 优化原理：线性过滤会平滑像素边缘，造成国风像素瓦片模糊；最近邻缩放保留锐利像素边界，适配像素美术风格。 6.4.2 导入江南瓦片图集 本地文件管理器找到配套资源tileset_jiangnan.png； 直接拖拽至编辑器文件面板assets/tilesets/目录，引擎自动完成图片导入。 6.4.3 创建全局共用 TileSet 资源 右键resources文件夹 → 新建资源 → 选择TileSet，命名jiangnan_tileset.tres并保存； 双击打开 TileSet 编辑器，右侧检查器设置基础瓦片尺寸：Tile Size = (32, 32)； 编辑器左下角「图块源」点击+，导入tileset_jiangnan.png图集； 弹窗提示「自动生成瓦片」时选择否：图集包含 32/64/128px 多尺寸建筑、树木素材，自动裁切会拆分完整模型； 手动框选拆分瓦片规范： 32px 小花、碎石：单格框选，右键生成瓦片； 64px 石板、矮墙：按住 Shift 框选 2×2 网格； 128px 大树、房屋主体：Shift 拖拽 4×4 网格区域； 全部瓦片裁切完成，Ctrl+S保存 TileSet 资源。 6.4.4 统一全局 2D 物理碰撞分层规范 进入菜单：项目 → 项目设置 → 常规 → 层名称 → 2D 物理，固定分层命名，全项目统一标准： 层号 层名称 适用实体对象 1 world 地图墙体、树木、静态障碍物瓦片 2 player 玩家主角本体 3 enemy 敌方怪物实体 4 hitbox 攻击伤害判定区域 5 item 可拾取酒坛、道具交互体 6.4.5 TileSet 瓦片绑定物理层 双击打开jiangnan_tileset.tres进入 TileSet 编辑器； 右侧检查器找到Physics Layers，点击Add Element新增物理配置； 物理层参数统一配置： Layer（自身所属层）：勾选world(层1)； Mask（可碰撞对象）：留空； 逻辑说明：地图静态障碍物仅被动阻挡玩家、敌人，无需主动检测其他实体，遮罩保持空白节省性能。 6.4.6 单瓦片碰撞形状绘制流程 TileSet 编辑器左侧瓦片列表，选中需要添加碰撞的目标瓦片； 底部切换至Paint绘制标签页，下拉框选中刚创建的world物理层；! 预览面板工具栏点击矩形碰撞快捷键F，自动生成贴合瓦片尺寸的矩形碰撞体； 微调绿色碰撞框范围，避开镂空空隙（如窗户、门洞），避免玩家被门框误阻挡； 其余障碍物瓦片重复以上流程统一配置。 6.5 town.tscn 江南小镇分层绘制流程 6.5.1 三层 TileMapLayer 绑定共用 TileSet 打开level_town.tscn，依次选中GroundLayer、DecoLayer、WallLayer，检查器加载资源resources/jiangnan_tileset.tres； 三层共用同一套瓦片图集，仅加载一次贴图，杜绝重复加载造成显存浪费。 6.5.2 TileMap 编辑器绘制工具 在场景树中选中 GroundLayer 节点。 此时编辑器底部的 TileMap 编辑器会自动激活，你会看到所有可用的瓦片排列在面板里。 同时，场景视口中会出现淡黄色的网格线——这就是我们的绘制画布。 TileMap 编辑器工具栏中，最常用的几个工具（从左到右）： 工具图标 工具名称 快捷键 使用场景 铅笔 单格绘制 B 点缀零散装饰、修正局部瓦片 矩形填充 批量铺砖 R 绘制大片地面、墙体轮廓 油漆桶 泛洪填充 G 一键替换连通区域地面（河道、草坪） 橡皮擦 擦除瓦片 E 删除多余瓦片、清理门洞通路 随机绘制 随机铺放 无快捷键 地砖、草丛自然化，消除重复单调感 6.5.3 《酒魂》起始小镇的地图设计参考 6.5.3 小镇地图整体规划 首张探索地图：回忆巷江南小镇，尺寸20×15 瓦片格（1280×960 像素），区域布局示意： ┌─────────────────────────────────────────────┐ │ ███████████████ 北侧院墙 ███████████████ │ │ █ [草坪] █ 酒坊主建筑 █ [古井地标] █ │ │ █ █ 【主角初始出生点】 █ │ │ ███ [石桥] ███ 河道水域 ████ [柳树] ███ │ │ █ [主石板街道] 广场空地【战斗预留区】 [摊贩NPC点位] █ │ │ █ [茶肆] ██████████ [竹林景观] █ │ │ ███████████████ 南侧院墙 ███████████████ │ └─────────────────────────────────────────────┘ 设计提示： 对于第一张地图，"够玩"比"够美"更重要。只要有清晰的可行走区域、可碰撞的障碍物，以及一两个有辨识度的地标（酒坊门口、古井、小桥），就已经足够进入第七章的战斗开发了。地图的丰富和美化可以在所有系统开发完成后再回来打磨。 6.5.4 分层绘制标准流程（自下而上，不可颠倒） 第一步：GroundLayer 底层地面绘制 选中GroundLayer，矩形填充工具铺满整张地图青石板基础地面； 泛洪填充工具将河道区域替换水面瓦片； 建筑周边使用随机绘制，混合 2-3 种相似地砖，弱化大面积纯色单调感。 第二步：DecoLayer 中层装饰绘制 草坪区域零散绘制草丛、落叶； 河道边缘放置柳树根部装饰瓦片； 石板道路随机散落落叶，贴合秋季江南美术设定； 低矮灌木仅做视觉点缀，不添加碰撞。 第三步：WallLayer 顶层障碍墙体绘制 地图四周边缘绘制院墙，锁定地图行走边界； 绘制酒坊、茶肆建筑墙体，预留门口通行空位； 河道两侧放置太湖石、木桩障碍物； 地图角落绘制大树景观，全部自带碰撞阻挡玩家穿行。 绘制完成后按F5基础测试：玩家可自由行走地面，墙体、树木完全阻挡角色，碰撞功能生效。遗留问题：角色走到屏幕边缘无镜头限制，画面露出黑色空白，6.6 节摄像机模块解决。 6.6 Camera2D 平滑跟随摄像机（带地图边界锁定） 6.6.1 Camera2D 底层逻辑 Godot2D 坐标系为无限平面，Camera2D相当于屏幕可视窗口，仅渲染摄像机范围内的游戏画面。 将摄像机作为主角子节点可实现自动跟随，但直接父子绑定会出现镜头抖动；因此配置两大核心优化属性： Position Smoothing位置平滑：摄像机延迟缓慢追上角色，消除移动抖动，提升画面丝滑度； Limit边界限制：锁定摄像机移动最大范围，镜头不会移出地图露出黑边。 平滑速度参数适配表（主角移动速度 200px/s） Speed 数值 镜头表现 适用场景 3.0 ~ 5.0 跟随延迟极高，镜头大幅滞后角色 剧情特写、镜头慢拉特效（非日常探索） 6.0 ~ 10.0 轻微滞后，流畅无眩晕 城镇日常探索（项目标准推荐值 8.0） 15.0 以上 几乎同步跟随，延迟极小 高速战斗、追逐场景 调试规范：默认设置Speed=8.0，运行游戏沿地图四边行走测试，拖拽感过重上调数值，跟随生硬下调数值。 6.6.2 Camera2D 节点创建与基础配置 摄像机挂载至主角预制件，保证所有关卡复用统一镜头逻辑： 打开Player.tscn主角场景； 选中根节点CharacterBody2D，添加子节点Camera2D； 最终主角场景树形结构： Player (CharacterBody2D) ├─ CollisionShape2D ├─ AnimatedSprite2D └─ Camera2D 摄像机基础参数配置： 属性路径 参数值 Position Smoothing → Enabled 勾选开启平滑 Position Smoothing → Speed 8.0 6.6.3 地图边界 Limit 两种实现方案 方案一：固定数值手动配置（本章小镇专用） 当前小镇尺寸：20×15 瓦片，单格 64px，总像素尺寸宽 = 20×640=1280，高 = 15×64=960 摄像机 Limit 固定参数： Limit Left = 0 Limit Top = 0 Limit Right = 640 Limit Bottom = 480 设置完 Limit 之后，无论玩家跑向哪里，摄像机都会乖乖停在地图范围内，永远不会让玩家看到地图边缘的黑边。 方案二：代码动态自适应边界（预留扩展接口，第 21 章数据驱动使用） 适配尺寸不统一的多关卡、程序化地图，通过读取 TileMap 实际宽高自动更新摄像机边界，本章仅预留函数，暂不启用。 6.6.4 主角物理碰撞层匹配校验 保证玩家可与地图障碍物正常碰撞，核对Player根节点物理参数： Collision Layer（自身归属层）：仅勾选player(层2)； Collision Mask（可碰撞检测层）：仅勾选world(层1)； 碰撞层的工作逻辑： Collision Layer 是"我是什么"，Collision Mask 是"我能感知到什么"。两个物体之间发生碰撞，需要至少一方的 Mask 包含另一方的 Layer。这种设计让你可以精细控制"哪些东西之间会发生碰撞"，比如子弹只和敌人碰撞、不和友军碰撞——这在第7章开始编写战斗系统时会非常重要。 6.7 GameManager 全局管理器：关卡动态加载与内存释放 打开第四章自动加载脚本scripts/game_manager.gd，新增完整关卡切换逻辑，游戏启动自动加载江南小镇；切换关卡时调用queue_free()销毁旧场景，瓦片、精灵贴图完整释放显存，全程仅单张地图资源驻留内存。 extends Node # ---- 游戏状态枚举 ---- enum GameState { EXPLORE, BATTLE, BREWING, DIALOGUE, PAUSED } # ---- 全局状态变量 ---- var current_state: GameState = GameState.EXPLORE var player_drunk_level: float = 0.0 # 全局统一醉意数值 # ---- 关卡加载相关资源与容器 ---- @export var town_scene: PackedScene = preload("res://scenes/levels/level_town.tscn") var active_level: Node2D = null @onready var level_container: Node = get_node("/root/Main/World/CurrentLevel") # ---- 游戏初始化 ---- func _ready() -> void: print("[GameManager] 游戏启动，当前状态: ", GameState.keys()[current_state]) # 启动默认加载江南小镇，主角出生坐标 switch_level(town_scene, Vector2(400, 450)) # ---- 关卡切换核心函数（内存优化核心） ---- func switch_level(target_scene: PackedScene, spawn_pos: Vector2) -> void: # 销毁正在运行的旧关卡，释放全部贴图资源 if active_level != null: active_level.queue_free() active_level = null # 实例化新关卡挂载至空白容器 active_level = target_scene.instantiate() level_container.add_child(active_level) # 重置主角出生位置 var player = active_level.get_node_or_null("Player") if player: player.position = spawn_pos var camera = player.get_node_or_null("Camera2D") var ground_tilemap = active_level.get_node_or_null("GroundLayer") # 动态边界函数预留（多关卡适配） if camera and ground_tilemap and camera.has_method("setup_limits_from_tilemap"): camera.setup_limits_from_tilemap(ground_tilemap) else: push_warning("[GameManager] 当前关卡未找到Player节点，出生坐标未赋值") # ---- 全局状态切换统一入口 ---- func change_state(new_state: GameState) -> void: if current_state == new_state: return print("[GameManager] 状态切换：", GameState.keys()[current_state], " → ", GameState.keys()[new_state]) current_state = new_state match new_state: GameState.EXPLORE: get_tree().paused = false GameState.PAUSED: get_tree().paused = true GameState.BREWING, GameState.DIALOGUE: get_tree().paused = true # UI弹窗时暂停世界，UI节点需设置Ignore Pause GameState.BATTLE: pass # ARPG战斗模式不暂停探索世界 加载内存逻辑说明 _ready()游戏启动自动调用切换函数，小镇场景实例挂载至CurrentLevel空白容器； 切换其他关卡时，旧关卡整体执行queue_free()，瓦片图集、角色精灵全部从内存、显存卸载； 游戏运行全程仅保留当前活动关卡资源，杜绝多地图叠加占用内存。 6.8 运行验收清单与常见故障排查 按下F5运行游戏，逐项核对验收标准，全部通过代表本章功能完成： 主角可在地面、石板路、河道边缘自由行走，无隐形阻挡； 院墙、房屋、大树、石桥可正常阻挡角色，无穿墙 bug； 角色移动时摄像机平滑跟随，无画面抖动、瞬移； 角色走到地图四角，镜头锁定边界，不会出现外侧黑边； 角色移动播放行走动画，静止切换待机动画（第五章预制件逻辑复用正常）； 常见故障原因与修复方案 故障现象 根因 解决方式 角色直接穿墙 WallLayer 瓦片未配置 world 碰撞，或碰撞框缺失 返回 TileSet 编辑器给墙体瓦片添加矩形碰撞，绑定物理层 1 摄像机完全不跟随角色 Camera2D 未作为 Player 子节点 打开 Player.tscn，确认摄像机层级归属 地图边缘出现大片黑边 Limit 左右上下像素数值计算错误 重新计算瓦片总像素：格数 ×32，更新摄像机边界参数 瓦片缝隙出现白色细线 图片导入启用线性过滤、未开启像素捕捉 项目设置纹理过滤改为最近邻；图集重新导入关闭抗锯齿 草丛、落叶挡住玩家移动 DecoLayer 装饰瓦片误添加碰撞 TileSet 编辑器删除低矮装饰瓦片的碰撞外形 6.9 地图预留交互点位（适配后续全章节开发） 遵循「先占位、后开发」的工程规范，绘制地图时提前预留交互节点，后续章节无需重构地图： 第 7 章投壶战斗预留：小镇中央广场预留 10×8 格无障碍物空地，作为投壶对战、野外遭遇战场地； 第 18 章 NPC 系统预留：在TownRoot/NPCs容器下创建空 Node2D 标记点位（SpawnPoint_LiBai），固定 NPC 生成坐标； 第 13 章酿造系统预留：酒坊门口添加Area2D触发区域TriggerZone_Brewery，矩形碰撞框覆盖门口，后续绑定进出触发酿造 UI 逻辑。 优势：所有交互点位坐标固化，后续新增功能仅补充脚本信号，无需重新调整地图瓦片布局，大幅减少重构工作量。 6.10 修复2D场景深度遮挡BUG 6.10.1 BUG成因——固定渲染顺序导致层级错乱 按 F5 运行第六章的小镇场景，操纵主角走到大树、房屋的北侧——也就是从俯视角度看，角色应该被树冠、屋顶挡住一部分的位置——你会发现：无论主角站在树的哪一侧，角色贴图永远完整地叠在树木/建筑之上，从来不会被遮住哪怕一点点，纵深感完全消失。 根本原因：Godot 2D 默认按照场景树节点顺序渲染画面，后挂载的节点会覆盖先挂载的节点，和物体实际坐标、前后位置无关。第六章关卡节点结构如下： TownRoot (Node2D) ├─ GroundLayer (TileMapLayer) ├─ DecoLayer (TileMapLayer) ├─ WallLayer (TileMapLayer) ├─ NPCs (Node2D) └─ Player ← 排在最后，画布上后绘制的节点会盖住先绘制的，永远覆盖 WallLayer Player在场景树中排在WallLayer之后，而 Godot 的规则是后绘制的节点会盖住先绘制的节点，所以不管角色站在树的哪一侧，角色贴图永远盖在WallLayer里的每一棵树、每一面墙之上——这跟"应该被挡住的时候就该挡住"的直觉完全相反。 6.10.2 核心解决方案——Y-Sort 动态Y轴排序 摒弃固定场景树渲染顺序，改用Y轴坐标动态排序：物体脚底Y坐标越大（越靠下），渲染层级越靠上。 简单理解：角色站在树下方（Y更大），遮挡树根；角色站在树后方（Y更小），被树冠遮挡，完全贴合真实透视逻辑。 Godot 4.6 内置 y_sort_enabled 属性（所有 Node2D、TileMapLayer 节点通用），开启后节点子物体将根据世界Y坐标实时排序，而非固定场景树顺序。 进阶适配：TileMap 瓦片支持单独设置Y排序原点，可精准定义树木、房屋的「落地基准点」，避免以贴图边缘排序导致的层级错误。 6.10.3 动手：重构关卡结构，接入 YSortWorld 容器 打开level_town.tscn，在TownRoot下新建子节点Node2D，重命名YSortWorld； 检查器勾选Node2D → Ordering → Y Sort Enabled； 把WallLayer、Player、NPCs三个节点整体拖拽，移动为YSortWorld的子节点；GroundLayer和DecoLayer都保持在YSortWorld外部——按 6.3.2 节的分层表，DecoLayer装的是草丛、落叶、地面花纹、低矮灌木这类贴地不挡人的纯装饰，跟地面一样不需要参与排序，硬塞进去反而白白增加每帧的排序开销； 重构后的关卡树形结构： TownRoot (Node2D) ├─ GroundLayer (TileMapLayer，平面地面，不参与排序) ├─ DecoLayer (TileMapLayer，草丛/花纹等贴地装饰，不挡人，不参与排序) └─ YSortWorld (Node2D，Y Sort Enabled = 勾选) ├─ WallLayer (TileMapLayer，Y Sort Enabled = 勾选，树木/建筑主体) ├─ NPCs (Node2D) └─ Player (CharacterBody2D) 这样修改以后，有个细节需要修改，就是将game_manager.gd代码中的“var player = active_level.get_node("Player")”，修改为“var player = active_level.get_node("YSortWorld/Player")” 选中WallLayer，检查器同样勾选它自己的Y Sort Enabled——只有父容器和子图层同时开启，瓦片才会作为独立个体参与和Player的排序比较，而不是整层瓦片打包成一个绘制单位。 如果你的DecoLayer里也混了会挡人的东西： 6.3.2 节的分层表里DecoLayer最后一列标的是"否"（不阻挡），说明设计初衷就是纯装饰；但如果后续往里加了个头比较高、你希望能挡住角色的灌木丛，处理思路跟 7.1.9 节迁移大树的做法一样——把这一小撮瓦片单独拿出来，按需要要么并入WallLayer交给 Y-Sort，要么做成独立的Occluder实例，而不是把整个DecoLayer都拖进YSortWorld。 6.10.4 校准瓦片的 Y 排序原点 双击打开jiangnan_tileset.tres进入 TileSet 编辑器； 选中大树、房屋主体等高大瓦片（128px 类），右侧检查器找到Y Sort Origin； 将数值设置为该瓦片贴图高度减去"落地点"到底部的距离——通俗地说：把数值下移到树干/门口与地面接触的那一圈像素，而不是贴图整体的最底部（树冠顶部同样会被误当作"很靠前"）； 矮墙、石桥、碎石等贴地小型瓦片，Y Sort Origin保持默认 0 即可，它们本身不高，遮挡问题不明显； Ctrl+S保存，F5 运行，操纵主角绕大树走一圈——此时角色应能正确穿插在树干前后，而不再是永远被整棵树盖住。 排序原理小结： Y Sort Enabled解决的是"谁该画在谁前面"的数据层面问题，本质上比较的是两者的落地点坐标，和贴图大小、美术风格无关。它对绝大多数矮墙、石桥、灌木类障碍物已经完全够用。 6.11 修复遮挡轮廓残影问题 Y-Sort 解决了"前后顺序对不对"的问题，但又出现了个新问题需要解决，就是角色被树木、房屋、高墙遮挡后完全看不见，会严重破坏玩家操作体验。本节将实现自动遮挡轮廓透视效果：角色被遮挡时自动显示半透明轮廓，未被遮挡时完全正常显示。 6.11.1 核心实现原理 本次效果核心依赖 Godot 2D 专属能力：Canvas Shader + hint_screen_texture 屏幕纹理采样，核心逻辑是「像素颜色自动比对」。 核心设计思路 新建一个和玩家本体动作、位置完全同步的「残影轮廓节点」，并设置为最顶层绘制，保证它是屏幕最后渲染的内容； Shader 会读取「绘制轮廓前的屏幕成品颜色」（已渲染的地面、遮挡物、角色本体）； 将屏幕颜色与角色本体本该显示的贴图颜色做比对； 颜色一致 = 角色未被遮挡，轮廓透明不显示；颜色不一致 = 角色被遮挡，自动渲染半透明轮廓。 方案核心优势 遮挡物无需挂载任何脚本、无需设置感应区域，零配置适配所有遮挡场景； 全自动判断遮挡状态，进入遮挡区自动显形，离开自动消失； 兼容Y轴排序，保留角色与场景的前后穿插视觉效果，不破坏原有画面层级。 6.11.2 创建 PlayerGhost 轮廓节点 打开Player.tscn——角色本体贴图是第五章配置好的AnimatedSprite2D节点，在它的同级，添加子节点AnimatedSprite2D（而不是Sprite2D，因为轮廓要能跟着本体一起播放行走/待机动画），重命名为PlayerGhost； 选中PlayerGhost，检查器里手动设置：Z Index = 100、Z As Relative = 关闭、Ordering → Y Sort Enabled = 关闭——这样它的绘制顺序不再受 Y-Sort 影响，永远盖在最上面，保证它是"最后一个画的东西"； 6.11.3 搭建 Shader 材质资源 一、 新建 Shader 专用文件夹 统一管理项目着色器文件，规范项目结构：编辑器左下角 文件系统，右键项目根目录 res://； 选择 新建 → 文件夹，命名为 shaders；最终目录：res://shaders/，与 scenes、scripts 文件夹同级。 二、为 PlayerGhost 创建 ShaderMaterial 材质 选中场景中的 PlayerGhost 节点；右侧检查器找到 CanvasItem → Material 属性（默认空）；点击属性右侧下拉框，选择 新建 ShaderMaterial；点击 Material 左侧小箭头，展开材质详细配置项。 三、 创建 Shader 代码文件 展开 ShaderMaterial 后，找到下方空的 Shader 属性；下拉选择 新建 Shader；弹窗配置：Shader Type 保持默认 CanvasItem（2D专用）； 路径修改为：res://shaders/occluder_reveal.gdshader；点击创建，自动打开Shader编辑面板。 四、 编写遮挡透视 Shader 代码 删除编辑器默认生成的所有模板代码，粘贴以下完整代码，按下 Ctrl+S 保存。文件路径：res://shaders/occluder_reveal.gdshader shader_type canvas_item; // 读取屏幕已渲染画面，用于像素颜色比对 uniform sampler2D screen_texture : hint_screen_texture; // 遮挡后显示的轮廓颜色（可在检查器自定义颜色、透明度） uniform vec4 silhouette_color : source_color = vec4(0.0, 0.0, 0.0, 0.5); void fragment() { // 获取当前像素位置、渲染轮廓前的屏幕画面颜色 vec4 screen_color = texture(screen_texture, SCREEN_UV); // 获取角色贴图当前像素的原始颜色 vec4 sprite_color = texture(TEXTURE, UV); // 比对颜色：微小色差忽略，避免误判 if (all(lessThan(abs(sprite_color - screen_color), vec4(0.01)))) { // 颜色一致：无遮挡，显示原始角色颜色，轮廓透明 COLOR = sprite_color; } else { // 颜色不一致：被遮挡，渲染半透明轮廓 // 保留贴图透明通道，避免出现方块黑影 COLOR = vec4(silhouette_color.rgb, silhouette_color.a * sprite_color.a); } } 五、新手关键解惑 & 常见报错修复 核心概念区分 **ShaderMaterial（材质）：**外层资源容器，用于挂载Shader代码、存储颜色参数，赋值给节点使用； **Shader（着色器代码）：**核心逻辑文件，定义像素渲染规则。 效果异常必解方案（高频坑点） 如果运行后无轮廓、轮廓一直显示、效果失效：点击顶部菜单栏 项目 → 项目设置 → 渲染，将 渲染方法 切换为 Compatibility（兼容模式）。hint_screen_texture 仅在兼容模式下完全稳定，Forward+、Mobile 渲染器会存在兼容问题。 6.11.4 编写残影同步脚本（player_ghost.gd） 脚本仅负责：让残影和玩家本体位置、动画、朝向完全同步，遮挡判断全程由Shader自动完成，无需复杂逻辑。 新建脚本文件：res://scripts/player_ghost.gd，绑定到 PlayerGhost 节点，粘贴以下代码： # scripts/player_ghost.gd extends AnimatedSprite2D ## 玩家"透视残影"节点：始终绘制在最上层 ## 具体"该不该显形"完全交给 Shader 通过比对屏幕颜色自动判断，脚本只负责同步动作 @onready var player_anim: AnimatedSprite2D = get_parent().get_node("AnimatedSprite2D") # 指向主角本体的 AnimatedSprite2D func _ready() -> void: z_index = 100 z_as_relative = false y_sort_enabled = false # 直接复用主角的动画资源，不用另外拷贝一份 SpriteFrames sprite_frames = player_anim.sprite_frames func _process(_delta: float) -> void: # 每帧同步主角当前播放的动画、帧号、朝向、位置，保证残影和本体完全重合、动作一致 if animation != player_anim.animation: animation = player_anim.animation frame = player_anim.frame flip_h = player_anim.flip_h global_position = player_anim.global_position 为什么不直接把本体的AnimatedSprite2D挪出来当残影用，而要建一个新的？ 因为本体需要留在原地参与Y Sort（它是角色真实的视觉位置，负责跟树木、建筑比前后），而残影必须脱离排序、永远画在最上层——两者的Ordering设置是互斥的，所以必须是两个独立节点，靠脚本每帧同步动画帧和位置来保持"看起来是同一个人"。 最终效果如下图，看见角色的轮廓了吧。 6.12.1 本章完整交付产物 标准化通用jiangnan_tileset.tres瓦片资源，配套分层碰撞配置； 三层隔离TileMapLayer江南小镇完整可运行地图； 带平滑跟随、边界锁定的复用型Camera2D主角摄像机； 解耦式全局main.tscn场景结构，区分常驻容器与动态关卡； 迭代完成GameManager关卡动态加载销毁脚本，解决内存溢出问题； 预占 NPC、战斗、酿造交互点位的完整关卡场景； Y 轴动态排序体系，修复 2D 物体前后遮挡错乱 BUG； 遮挡透视 Shader + 残影同步脚本，人物被墙体遮挡自动显示半透明轮廓。 6.12.2 核心知识点总结 学习内容 项目开发价值 TileMapLayer 三层分层架构 地面、装饰、障碍视觉与碰撞逻辑分离，易维护、可扩展 TileSet 统一瓦片与批量碰撞 资源全局复用，一次性配置全地图碰撞，提升开发效率 2D 物理分层规范 精细化控制实体碰撞关系，为战斗、拾取交互打下底层基础 平滑边界摄像机 提升探索沉浸感，避免镜头溢出破坏世界完整性 动态关卡加载释放机制 控制内存占用，支持多地图无缝切换，防止游戏卡顿闪退 提前预留交互点位开发习惯 降低后期多系统整合的重构成本，规范大型项目开发流程 Y-Sort 动态排序 解决 2D 俯视游戏物体前后穿插错乱，打造真实空间纵深感 CanvasItem 屏幕采样 Shader 低成本实现人物遮挡透视效果，大幅优化探索操作手感 知之愈明，则行之愈笃；行之愈笃，则知之益明。—— 朱熹 本章核心跃迁：从单一角色虚空移动，搭建出分层、可扩展、内存可控、视觉完整的游戏世界。分层设计思维不仅适用于瓦片地图，后续 UI 分层、渲染批次优化、战斗模块隔离均沿用同一开发思路。江南小镇探索舞台搭建完毕，下一章开启核心战斗玩法开发。 下一章预告 第 7 章《投壶战斗：蓄力、抛物线、命中判定》依托本章统一配置的 world/player/enemy 物理分层，实现蓄力投壶弹道物理、碰撞命中判定，正式接入游戏核心战斗模块。 延伸阅读参考 Godot 官方瓦片地图教程：https://docs.godotengine.org/zh_CN/stable/tutorials/2d/using_tilemaps.html 拓展学习地形自动拼接、瓦片动画等进阶 TileSet 功能； Godot Camera2D 官方文档：https://docs.godotengine.org/zh_CN/stable/classes/class_camera2d.html 镜头震动、偏移、淡入淡出特效底层参数查阅； 《Pixel Art for Game Developers》像素美术开发书籍：从零制作适配游戏项目的自定义瓦片图集。
【酒魂开发手记】第五章：让主角动起来——玩家角色创建
「行是知之始，知是行之成。」 —— 陶行知 在第四章，我们搭建了游戏的主框架。这一章，我们创建可由玩家控制的主角，实现八方向移动并播放对应的行走动画。 本章目标：创建 Player.tscn 场景，配置八方向待机和行走动画，实现键盘控制的八方向移动。 5.1 给主角一个“身体”：CharacterBody2D Godot 提供了三种 2D 物理体节点，它们各有分工： 节点 特点 典型用途 StaticBody2D 完全静止，不会移动 地面、墙壁、障碍物 RigidBody2D 受物理引擎自动控制（重力、碰撞反弹等） 弹球、推箱子、物理模拟物体 CharacterBody2D 由你写代码控制移动，引擎只负责碰撞检测 玩家角色、敌人、NPC 5.1.1 为什么玩家角色必须用 CharacterBody2D？ StaticBody2D 不行：它不能移动。主角需要走来走去，所以排除。 RigidBody2D 不合适：它会像真实物体一样有惯性——按右键角色加速，松开后还会滑行一段距离才停下。这种“溜冰”手感会让玩家觉得角色“不跟手”，体验很差。除非你做的是物理平台游戏（如《小小盗贼》），否则不适合主角。 CharacterBody2D 恰到好处： 精准控制：你通过代码直接设置 velocity（速度），每帧告诉引擎“往哪走、走多快”。按下键立刻动，松开立刻停，手感干净利落。 碰撞自动处理：调用 move_and_slide() 后，引擎会帮你处理与墙壁、地面的碰撞，角色不会穿墙。 不受重力影响（除非你手动添加），适合俯视角或平台跳跃游戏。 一句话总结：CharacterBody2D 就是为“由代码精确控制移动的角色”而生的。 创建玩家场景 点击菜单 场景 → 新建场景，或按 Ctrl+N。 在弹出的“创建根节点”窗口中，选择 CharacterBody2D，点击“创建”。 在场景面板中，双击新节点，将其重命名为 Player（回车确认）。 确保选中 Player 节点，点击场景面板上方的“+”图标（添加子节点）。 搜索 CollisionShape2D 并添加。 在右侧检查器中，找到 Shape 属性，点击下拉菜单 → 新建 CapsuleShape2D。 调整胶囊形状的大小：展开 Shape，修改 Height 和 Radius，使其大致覆盖角色身体（例如 Height=48，Radius=16，具体根据素材微调）。 按 Ctrl+S 保存场景，路径为 scenes/characters/Player.tscn。 此时场景结构： Player (CharacterBody2D) └── CollisionShape2D 5.2 添加动画：先从待机开始 现在我们的 Player 场景还是一个只有碰撞形状的“隐形人”。接下来，我们用 AnimatedSprite2D 节点让角色真正显示出来，并播放待机动画（即站立不动的动画，比如轻微呼吸、眨眼等）。 5.2.1 添加 AnimatedSprite2D 节点 首先，在场景面板中选中 Player 节点（确保它是蓝色的高亮状态）。然后点击场景面板上方的 “+” 按钮（添加子节点）。在弹出的搜索框中输入 AnimatedSprite2D，找到它并点击“创建”。这样，AnimatedSprite2D 就成了 Player 的子节点，它将负责展示角色的图片和动画。 创建后，请确保 AnimatedSprite2D 节点仍处于选中状态（在场景面板中它应该是高亮的），因为我们马上要配置它的动画资源。 5.2.2 创建 SpriteFrames 资源并打开动画面板 AnimatedSprite2D 本身并不知道要显示哪些图片、按什么顺序播放——这需要给它一个 SpriteFrames 资源。SpriteFrames 就是“动画帧的容器”，里面存放了所有动画及其对应的图片序列。 在右侧的检查器面板中，找到 SpriteFrames 属性（它位于“Animation”分类下）。此时它显示为“空”。点击它右侧的下拉箭头，在弹出的菜单中选择 “新建 SpriteFrames”。这时你会发现属性框里多了一个 [SpriteFrames] 字样，表示资源已创建。 接下来，双击这个新建的 SpriteFrames 资源（或者点击它右侧的小图标）。Godot 会在编辑器底部自动打开一个专门的 SpriteFrames 面板。如果你不小心关掉了底部面板，可以手动点击编辑器底部的 “SpriteFrames” 标签来重新打开它。 现在，你已经站在了配置动画的核心工作区。 5.2.3 添加八个待机动画 我们的角色需要面向八个方向站立时的待机动画。素材已经准备好了，放在 assets/sprites/player文件夹里，每个动作一个素材，比如idle.png、walk.png。我们要在 SpriteFrames 面板中创建下面这八个动画： 需要创建以下八个待机动画： 动画名称 方向 idle_down 下 idle_up 上 idle_left 左 idle_right 右 idle_down_right 右下 idle_down_left 左下 idle_up_right 右上 idle_up_left 左上 第一步：重命名默认动画 SpriteFrames 面板左侧默认有一个名为 default 的动画。双击它，输入 idle_down，回车确认。 第二步：创建其余七个动画 点击 SpriteFrames 面板左侧的 “添加动画” 按钮（一个带“+”号的纸张图标，位于动画列表上方）。在弹出的对话框中输入 idle_up，点击“确定”。重复此操作，依次创建 idle_left、idle_right、idle_down_right、idle_down_left、idle_up_right、idle_up_left。 第三步：为每个动画添加图片帧 以 idle_down 为例： 在左侧列表中单击 idle_down 选中它。 在面板右侧的“动画帧”区域，点击 “从精灵表中添加帧” 按钮。 在弹出的文件对话框中，导航到 assets/sprites/player/ 文件夹。 框选该文件夹内的idle图片，然后点击“打开”。 在弹出选择帧框中，水平设置8，垂直设置8，然后依次选择第三行1到8帧，最后选择添加8帧，这样就会在场景中显示选择的8帧的第一张图片了，单击动画面板中的按钮，主场景就能预览播放动画了。 对剩下的七个动画重复上述步骤：选中动画 → 添加帧 → 选择对应文件夹 → 打开。 第四步：设置动画速度（FPS） 确保当前选中的是 idle_down 动画。 在 SpriteFrames 面板的右上方找到 FPS 输入框（默认值为 5）。 将 FPS 改为 5（待机动画慢速循环），按回车确认。 依次选中其他七个动画，同样将 FPS 改为 5。 什么是 FPS？ FPS（Frames Per Second，每秒帧数）表示动画每秒钟播放多少张图片。数值越大动画越快。待机动画通常慢一些（5 FPS），行走动画快一些（8 FPS）。 第五步：确认循环播放 在 FPS 右侧有一个 循环 图标（两个带箭头的圆圈）。默认是开启状态（高亮显示）。请确保它是开启的，这样动画才会无限循环。 5.2.4 什么是向量？什么是弧度？ 向量（Vector2）：由 x（水平）和 y（垂直）两个分量组成的有向线段。例如向右为 (1,0)，向下为 (0,1)，右下方为 (0.707, 0.707)。游戏中使用向量表示位置、方向、速度等。 弧度（radian）：度量角度的一种单位。它的定义是“弧长等于半径时所对应的圆心角”。整个圆周的弧长是 2πr，因此一周 = 2π 弧度 ≈ 6.283，半周 = π ≈ 3.1416，直角 = π/2 ≈ 1.57。 Godot 中所有角度函数（如 angle()、sin()、cos()）都使用弧度。你不需要手动换算，直接使用 PI、PI/2、TAU 等常量即可。 为什么用弧度而不用度？ 因为在数学和编程中，弧度能使公式更简洁（例如导数 sin' = cos 无需额外系数），且与向量、圆周运动等计算天然结合。 八个方向的角度范围（弧度）： 5.2.5 编写待机动画代码 为 Player 附加脚本：选中 Player 节点，点击检查器上方的“附加脚本”按钮（卷轴图标），文件名 player.gd，保存到 scripts/ 文件夹。 写入以下代码，让角色播放待机动画（默认向下）： extends CharacterBody2D # 继承自 CharacterBody2D，使角色具备移动和碰撞能力 # 记录角色最后移动的方向向量，默认为向下（0, 1） var last_direction: Vector2 = Vector2.DOWN # 当节点首次进入场景树时自动调用 func _ready(): update_idle_animation() # 一开始就播放默认方向的待机动画 # 根据 last_direction 更新待机动画 func update_idle_animation(): # 获取 last_direction 向量的弧度角度（范围 -PI 到 PI） var angle = last_direction.angle() # 准备存放动画名称的变量 var anim_name: String # 根据角度范围决定播放哪个方向的待机动画 if angle < -3 * PI / 4 or angle >= 3 * PI / 4: anim_name = "idle_left" # 左 elif angle < -PI / 2: anim_name = "idle_up_left" # 左上 elif angle < -PI / 4: anim_name = "idle_up" # 上 elif angle < 0: anim_name = "idle_up_right" # 右上 elif angle < PI / 4: anim_name = "idle_right" # 右 elif angle < PI / 2: anim_name = "idle_down_right" # 右下 elif angle < 3 * PI / 4: anim_name = "idle_down" # 下 else: anim_name = "idle_down_left" # 左下 # 播放选定的待机动画 $AnimatedSprite2D.play(anim_name) _ready()：节点第一次进入场景树时自动调用，适合做初始化工作。这里我们播放待机动画。 测试运行： 将 Player.tscn 拖入主场景的 CurrentLevel 节点下（第四章已创建 Main 和 CurrentLevel）。 按 F5 运行游戏。应看到角色面向下方播放待机动画（呼吸、眨眼等）。 5.3 添加行走动画和移动控制 5.3.1 添加行走动画到 SpriteFrames 行走动画的创建步骤与待机动画完全一致，区别在于动画名称和素材文件夹。 需要添加的行走动画名称（8个）： 动画名称 方向 walk_down 下 walk_up 上 walk_left 左 walk_right 右 walk_down_right 右下 walk_down_left 左下 walk_up_right 右上 walk_up_left 左上 操作步骤： 在 SpriteFrames 面板（仍保持打开）左侧，点击 “添加动画” 按钮。 输入 walk_down，确定。继续添加其余七个动画。 选中 walk_down，点击 “添加帧”，导航到 assets/sprites/player/，框选walk.png图片，打开。 将 walk_down 的 FPS 设为 8（行走动画稍快）。 对其他七个行走动画重复步骤 3-4。 确保所有行走动画的 循环 开关开启。 5.3.2 配置输入映射 需要将键盘按键与游戏动作关联。 点击菜单 项目 → 项目设置。 切换到 输入映射 选项卡。 在顶部 “添加新动作” 输入框中，输入 left，点击 添加。 同样添加 right、up、down 三个动作。 为每个动作绑定物理按键： 点击 left 旁边的 “+” 按钮 → 选择 “键” → 按下键盘上的 A → 确定。再次点击 left 的 “+” → 添加 左箭头（可选，支持两种按键）。 right：绑定 D 和 右箭头。 up：绑定 W 和 上箭头。 down：绑定 S 和 下箭头。 注意：动作名称 left、right、up、down 必须与代码中的 Input.get_vector() 参数一致。 5.3.3 理解 _physics_process 和 delta _physics_process：Godot 的物理处理回调，默认每秒调用 60 次（与物理帧率同步，独立于渲染帧率）。适合处理移动、碰撞等需要稳定时间步长的逻辑。 delta：自上一帧以来经过的时间（秒）。例如 60 FPS 时 delta ≈ 0.0167 秒。使用 delta 可以使速度与帧率解耦。但注意：CharacterBody2D 的 move_and_slide() 内部已经使用 delta 处理，所以我们直接设置 velocity 即可，无需手动乘以 delta。 5.3.4 完整移动与动画代码 在 player.gd 中加入移动逻辑和行走动画播放。完整代码如下： extends CharacterBody2D @export var speed: float = 200.0 var last_direction: Vector2 = Vector2.DOWN func _ready(): update_idle_animation() func _physics_process(delta): # 获取八方向输入（自动归一化，斜向速度不超标） var direction = Input.get_vector("left", "right", "up", "down") # 应用速度并移动 velocity = direction * speed move_and_slide() # 动画控制 if direction.length() > 0: last_direction = direction play_walk_animation(direction) else: update_idle_animation() func play_walk_animation(dir: Vector2): var angle = dir.angle() var anim_name: String if angle < -3 * PI / 4 or angle >= 3 * PI / 4: anim_name = "walk_left" elif angle < -PI / 2: anim_name = "walk_up_left" elif angle < -PI / 4: anim_name = "walk_up" elif angle < 0: anim_name = "walk_up_right" elif angle < PI / 4: anim_name = "walk_right" elif angle < PI / 2: anim_name = "walk_down_right" elif angle < 3 * PI / 4: anim_name = "walk_down" else: anim_name = "walk_down_left" $AnimatedSprite2D.play(anim_name) func update_idle_animation(): var angle = last_direction.angle() var anim_name: String if angle < -3 * PI / 4 or angle >= 3 * PI / 4: anim_name = "idle_left" elif angle < -PI / 2: anim_name = "idle_up_left" elif angle < -PI / 4: anim_name = "idle_up" elif angle < 0: anim_name = "idle_up_right" elif angle < PI / 4: anim_name = "idle_right" elif angle < PI / 2: anim_name = "idle_down_right" elif angle < 3 * PI / 4: anim_name = "idle_down" else: anim_name = "idle_down_left" $AnimatedSprite2D.play(anim_name) 为什么移动前不需要 direction = direction.normalized()？ Input.get_vector() 返回的向量已经归一化（长度为1）。斜向时两个分量均为 ≈0.707，保证了八方向移动速度一致。 move_and_slide() 的作用：使用 velocity 移动角色，自动检测并响应碰撞（如撞墙会停下或滑动）。这是 CharacterBody2D 的核心方法。 5.3.5 测试运行 确保 Player.tscn 已放入主场景的 CurrentLevel 下。 按 F5 运行。 使用 WASD 或方向键移动： 按单方向 → 角色轴向移动，播放对应行走动画。 同时按两个方向（例如右+下）→ 角色斜向移动，播放对角线行走动画（如 walk_down_right）。 松开所有按键 → 角色停止，播放最后移动方向对应的待机动画。 5.3.6 常见问题排查 现象 原因 解决方法 角色不动，控制台无输出 输入映射未配置或动作名错误 检查项目设置的输入映射，确保存在 left、right、up、down 并绑定了按键。 角色不动，控制台报错 脚本中 Input.get_vector 参数名与输入映射不一致 确认动作名完全一致（大小写敏感）。 动画不显示 AnimatedSprite2D 的 SpriteFrames 中缺少对应动画 核对动画名称是否与代码中的字符串一致（例如 walk_down 不能写成 walkDown）。 斜向移动时动画方向不对（如向右上却播放向右） 角度判断阈值写错 在 play_walk_animation 开头加 print(angle)，观察数值对照 5.2.4 的角度图调整条件。 停止后面向错误 last_direction 未正确更新 确认移动分支中执行了 last_direction = direction。 斜向移动速度比轴向快 手动累加方向时未归一化 当前代码使用 Input.get_vector() 自动归一化，不会出现此问题。若手动构建方向，需调用 direction.normalized()。 经过多次测试我发现，按下up键人物并没有播放向上walk_up动画，而是播放的walk_up_left。遇到的问题是由于浮点数精度导致的边界判断误差。当方向向量为 (0, -1) 时，理论上 angle() 应该精确等于 -PI/2，但在实际浮点运算中，其结果可能略小于 -PI/2（例如 -1.57079632679 的微小偏差）。代码中第二个条件判断是 angle < -PI/2，由于这个微小的误差，本应等于的情况变成了“小于”，从而错误地进入了 walk_up_left 分支。 我将代码修改成了如下，这个问题就解决了，同时因为idle和walk都需要获取动画名称，所以我写了个get_animation_name（）函数，最终代码如下： extends CharacterBody2D @export var speed: float = 200.0 # 记录角色最后移动的方向向量，默认为向下（0, 1） var last_direction:Vector2 =Vector2.RIGHT @onready var animated: AnimatedSprite2D = $AnimatedSprite2D #当节点首次进入场景树时自动调用 func _ready() -> void: update_idle_animation()# 一开始就播放默认方向的待机动画 func _physics_process(delta: float) -> void: # 获取八方向输入（自动归一化，斜向速度不超标） var direction = Input.get_vector("left","right","up","down") # 应用速度并移动 velocity = direction*speed move_and_slide() # 动画控制 if direction.length()>0: last_direction = direction play_walk_animation(direction) else: update_idle_animation() # 根据 last_direction 更新待机动画 func update_idle_animation(): var anim_name = get_animation_name(last_direction, "idle") animated.play(anim_name) func play_walk_animation(dir: Vector2): var anim_name = get_animation_name(dir, "walk") animated.play(anim_name) func get_animation_name(dir:Vector2,action:String)->String: var x = dir.x var y = dir.y var suffix:String if x==0 and y==0: suffix = "right" else: if abs(x)>abs(y): suffix = "right" if x>0 else "left" elif abs(y)>abs(x): suffix = "down" if y>0 else "up" else: if x>0 and y<0: suffix ="up_right" elif x>0 and y>0: suffix = "down_right" elif x < 0 and y < 0: suffix = "up_left" else: suffix = "down_left" return action+"_"+suffix 我重点解释一下get_animation_name函数的代码， func get_animation_name(dir: Vector2, action: String) -> String: **参数 dir：**移动方向向量，例如 (0, -1) 表示向上，(0.707, -0.707) 表示右上（45°）。 **参数 action：**动画类型前缀，如 "walk"（行走）或 "idle"（待机）。 函数会返回完整的动画名称，比如 "walk_up_right"。 1. 提取坐标并初始化后缀变量 var x = dir.x var y = dir.y var suffix: String x：水平分量（正→右，负→左） y：垂直分量（正→下，负→上） 注意：Godot 中屏幕 Y 轴向下为正。 suffix：用来存储方向后缀，例如 "up_right"、"left" 等。 2. 处理零向量（防错） if x == 0 and y == 0: suffix = "right" 如果方向向量为零（理论上不应发生，因为调用前已判断 direction.length()>0，但作为安全保护），默认使用 "right" 作为后缀。 3. 非零向量的方向判断 else: if abs(x) > abs(y): suffix = "right" if x > 0 else "left" 绝对值比较：abs(x) > abs(y) 表示水平移动的幅度大于垂直移动的幅度 → 以左右方向为主。 若 x > 0 → "right" 若 x < 0 → "left" 此时忽略垂直分量，因为玩家主要意图是水平移动（即使是斜向按键，也会优先视为水平）。 elif abs(y) > abs(x): suffix = "down" if y > 0 else "up" 垂直为主：abs(y) > abs(x) 表示垂直移动幅度更大 → 优先上下方向。 y > 0 → "down"（向下） y < 0 → "up"（向上） else: # abs(x) == abs(y) 斜向 if x > 0 and y < 0: suffix = "up_right" elif x > 0 and y > 0: suffix = "down_right" elif x < 0 and y < 0: suffix = "up_left" else: # x < 0 and y > 0 suffix = "down_left" 精确斜向：当 abs(x) == abs(y) 时，表示两个方向幅度完全相等（即 45° 斜向移动，例如同时按下 → 和 ↑ 得到 (0.707, -0.707)）。 需要区分四个斜角：up_right、down_right、up_left、down_left。 通过 x 和 y 的正负符号组合确定具体方向。 4. 拼接最终动画名 return action + "_" + suffix 例如：action = "walk", suffix = "up_right" → "walk_up_right" 为什么这样设计？ 避免浮点精度问题 如果使用 angle() 计算角度再划分区间（如 -PI/2 附近），可能因微小误差导致 (0, -1) 被误判为 "up_left"。而直接比较 abs(x) 和 abs(y) 不会出现这种边界错误。 优先主方向，斜角仅在完全相等时触发 大多数游戏引擎中，玩家按下斜向键（如右+上）得到的向量是两个分量绝对值相等的归一化向量 (≈0.707, ≈-0.707)，此时刚好进入 else 分支，播放精确的斜角动画。 如果玩家摇杆不是完美的 45°，比如 (0.8, -0.6)，则 abs(x) > abs(y)，只会播放 "right" 动画。这符合直观：轻微偏右的向上移动，看起来主要还是向右走。 代码可读性好 不需要记忆角度阈值，逻辑一目了然。 最终效果如下： 5.4 小结 本章完成了： 使用 CharacterBody2D 作为角色根节点，添加胶囊碰撞体。 通过 AnimatedSprite2D 和 SpriteFrames 配置八个待机动画和八个行走动画，包括创建动画、添加帧、设置 FPS 和循环。 理解了向量、弧度、_physics_process、delta、move_and_slide() 等基础概念。 使用 Input.get_vector() 获取八方向输入并实现平滑移动。 根据方向向量的弧度，通过角度分支选择对应的行走或待机动画。 利用 last_direction 变量在停止时播放正确方向的待机动画。 下一章，我们将使用 TileMap 绘制游戏地图，并实现摄像机跟随。 延伸阅读： Godot 官方文档：CharacterBody2D Godot 官方文档：使用 Input.get_vector Godot 官方文档：AnimatedSprite2D Godot 官方文档：向量数学
【酒魂开发手记】第四章：创建《酒魂》项目与场景结构
「君子务本，本立而道生。」 ——《论语·学而》 这一章的核心任务只有一个：为《酒魂》搭建一个稳固、干净、可扩展的项目骨架。 这就像盖房子打地基、铺管道，虽然看不见摸不着，但决定了整栋房子能盖多高、住起来是否舒适。很多新手项目做到一半推倒重来，都是因为一开始的项目结构混乱，导致后期难以管理。 4.1 创建项目，规划文件夹结构 4.1.1 从图纸到工地：创建《酒魂》项目 首先，让我们打开Godot引擎，把《酒魂》项目的“工地”搭建起来。 打开Godot 4.6：双击运行我们在第一章安装好的Godot引擎。 新建项目：在欢迎界面（项目管理器）点击右侧的“新建项目”按钮。 填写项目信息： 项目名称：输入 WineSoul（《酒魂》的英文名，建议统一用这个，避免路径问题）。 项目路径：选择一个你容易找到的目录，例如 D:\GodotProjects\WineSoul。 渲染器：选择默认的 Forward+（兼容性好，功能足够）。 版本控制：选择 Git。如果你已经按照第1章的指引安装了Git，这里会自动识别。勾选后，Godot会为你生成一个 .gitignore 文件，自动忽略那些不需要备份的临时文件，这是个非常好的习惯。 创建：点击“创建并编辑”按钮。 图4-1-1：在Godot中创建“WineSoul”新项目，别忘了选择Git版本控制。 现在，你已经进入了一个空荡荡的Godot编辑器界面。这就是《酒魂》诞生的起点。 4.1.2 项目文件夹规划：对杂乱无章说不 一个清晰的目录结构，是项目长期维护的基础。想象一下，三个月后，你有一个上百个文件的工程，如果所有文件都堆在一个文件夹里，找到你需要的东西将是一场噩梦。 我们遵循业界通用的分类方式，为《酒魂》建立一套规范的文件夹体系。请在Godot编辑器的“文件系统”面板（左侧下方）中，完成以下操作： 右键点击 res:// 根目录，选择“新建 → 文件夹”。 依次创建以下文件夹（名称区分大小写，建议全部小写）： 文件夹名称 用途说明 类比生活中的 assets 存放所有外部导入的原始资源。 建材市场买回来的原料。 ├─ sprites 2D图片、精灵图、序列帧。 油漆、壁纸、瓷砖。 ├─ tilesets 地图用的瓦片集。 铺地板的木料。 ├─ fonts 字体文件（.ttf, .otf）。 装饰用的字画。 ├─ sounds 音效、音乐文件。 背景音乐播放器。 scenes 存放构成游戏的各个场景（.tscn文件）。 房子的各个房间（卧室、客厅）。 ├─ characters 玩家、敌人、NPC的场景。 房间里的人。 ├─ levels 游戏关卡/地图场景。 整栋房子的户型图。 ui 存放所有用户界面（UI）相关的场景和脚本。 房子的电路开关面板、水管阀门。 scripts 存放所有GDScript脚本（.gd文件）。 各种功能的控制电路和管道系统。 resources 存放自定义的Godot资源文件（如配方数据、物品数据）。 定制的智能家居配置文件。 addons 存放从外部导入的Godot插件。 额外的智能家电。 关键认知： 把场景、脚本、资源分开放，而不是按功能（比如“酿造系统”一个文件夹），好处是当项目变大时，你和引擎都能更快地定位和加载文件。 动手创建： 在你的 WineSoul 项目中，按照表格结构，在 res:// 下创建所有这些文件夹。创建完成后，你的“文件系统”面板应该看起来像这样： res:// ├─ assets/ │ ├─ sprites/ │ ├─ tilesets/ │ ├─ fonts/ │ └─ sounds/ ├─ scenes/ │ ├─ characters/ │ └─ levels/ ├─ ui/ ├─ scripts/ ├─ resources/ └─ addons/ 这就是《酒魂》项目的“地基”。一个干净、分门别类的文件夹，能让你在后续数百个文件中心平气和，而不是在文件堆里内耗。 4.2 《酒魂》的场景树设计 如果说文件夹结构是项目的静态骨架，那么场景树（Scene Tree）就是项目的动态骨架。它决定了游戏运行时，各个部分（如玩家、地图、UI）是如何组织和协作的。 4.2.1 理解“一切都是节点”与场景的层次 Godot的核心哲学之一是“一切皆节点”。一个场景是由一棵节点树组成的，每个节点都是一个独立的功能模块。 Node：所有东西的基础。可以把它想象成一个“看不见的积木块”。 Node2D：所有2D东西的基础，有位置、旋转、缩放。 Control：所有UI的基础，有锚点、边距等用于界面布局。 CharacterBody2D：一个专门的2D物理体，适合做玩家和敌人。 场景（.tscn）是节点的集合，它保存了一棵带有特定配置的节点树。场景可以实例化到另一个场景中，形成父子关系。例如，你的“玩家”场景，可以被实例化到“主游戏”场景中作为子节点。 4.2.2 设计《酒魂》的主场景 《酒魂》是一款ARPG游戏，核心体验是“探索中遇敌，拔刀就砍”的无缝动作感。因此，我们绝不能把“探索”和“战斗”拆成两个独立切换的场景。正确的做法是：探索即战斗，按需动态加载地图，UI覆盖交互。 我们的根场景将作为游戏主世界的容器，它负责管理和切换不同的关卡，同时处理所有的界面交互。让我们画出主场景的节点结构图： Main (Node2D) 主场景根节点 ├── CurrentLevel (Node2D) 当前活跃关卡容器（动态替换） │ ├── TileMapLayer 地图瓦片与碰撞（来自第6章） │ ├── Player (CharacterBody2D) 我们的主角（来自第5章，独立场景实例化） │ ├── Enemies (Node2D) 本地敌人容器 │ │ ├── Slime (CharacterBody2D) │ │ └── Archer (CharacterBody2D) │ └── NPCs (Node2D) 本地NPC容器 │ └── LiBai (CharacterBody2D) └── UI (CanvasLayer) 全局用户界面层（永远在最上方） ├── MainHUD (Control) 主界面HUD（血条、醉意条等，常驻） ├── PauseMenu (Control) 暂停菜单（初始隐藏） ├── BreweryPanel (Control) 酿造面板（初始隐藏，按F弹出） └── DialoguePanel (Control) 对话面板（初始隐藏，交互时弹出） 设计意图解析： Main 作为根：一个简单的 Node2D，它是整个游戏世界坐标系的原点。 CurrentLevel 动态关卡容器：这是ARPG的灵魂。玩家在地图上走动，遇敌直接在这里开打，没有“切入战斗”的过程。当玩家走到地图边缘（如进入酒坊或下一张地图）时，我们只需卸载当前关卡，将新关卡场景实例化到这个节点下，即可实现无缝换图。 UI 层独立：使用 CanvasLayer 节点，UI将永远显示在游戏世界的上方，不受摄像机移动影响。 酿造与对话是UI，不是场景：打开酿造界面，不是切换到独立的 brewery.tscn，而是弹出 UI 节点下的 BreweryPanel，同时暂停游戏世界进程。这样玩家关闭面板后立刻恢复动作，丝滑无违和感。 4.2.3 细分场景：为特殊玩法预留空间 根据第三章的设计，第7章我们要做“投壶战斗”。它属于独立规则的小游戏，不适合和主线ARPG战斗混在一起。正确的做法是叠加场景： 当玩家与NPC对话触发投壶时： GameManager 暂停 CurrentLevel（process_mode = PROCESS_MODE_DISABLED）。 动态实例化 res://scenes/mini_games/pot_throw.tscn，作为 Main 的子节点叠加在画面上。 投壶结束，移除小游戏实例，恢复 CurrentLevel。 这样既不破坏原本的地图状态，又实现了独立玩法的切入。 4.3 全局自动加载：游戏的“中央指挥部” 在游戏中，总有一些数据或功能是全局的，无论在哪个场景、哪个角落都需要被访问。比如：游戏设置、玩家基本状态、全局事件总线、音乐管理器等。 Godot 提供了“自动加载”机制来完美解决这个问题。任何被添加到自动加载列表中的脚本或场景，都会在游戏启动时最先加载，并在整个游戏生命周期中作为单例存在，可以被任何脚本通过其名字直接访问。 4.3.1 配置《酒魂》的“中央指挥部” 对于《酒魂》，我们至少需要两个全局单例： GameManager (脚本)：游戏流程的指挥中心。负责切换游戏状态（探索、战斗、酿造）、触发全局事件（如“游戏暂停”、“游戏结束”）。 EventBus (脚本)：全局事件总线。负责在各系统间传递消息，实现解耦。例如，“玩家获得物品”这个事件，背包UI需要更新，任务系统也需要检查，但玩家系统不需要知道背包和任务的存在，只需通过 EventBus 发出信号即可。 动手配置： 创建全局脚本： 在 scripts 文件夹下，右键“新建 → 脚本”。 新建一个名为 game_manager.gd 的脚本。模板选择 Node。 再新建一个名为 event_bus.gd 的脚本。模板也选择 Node。 添加为自动加载： 点击顶部菜单栏的“项目 → 项目设置”。 在弹出的窗口中，切换到顶部的“全局”标签页，自动加载标签。 在“路径”一栏，选择你刚刚创建的 scripts/game_manager.gd。 节点名称会自动填充为 GameManager（区分大小写，我们保持默认）。点击“添加”。 重复此步骤，将 scripts/event_bus.gd 也添加进去，节点名为 EventBus。 点击“关闭”按钮。 图4-3-1：在“项目设置”的“自动加载”页签中添加全局脚本，这样它们就会在游戏启动时最先加载并成为单例。 现在，无论在哪个脚本里，你都可以通过 GameManager.xxx 或 EventBus.xxx 来调用它们的方法和信号了。它们是整个《酒魂》的“中央指挥部”和“广播站”。 4.4 动手实操：创建《酒魂》项目根场景 “纸上得来终觉浅，绝知此事要躬行。”现在，让我们把上面的设计实现，亲手搭建出《酒魂》的根场景骨架。这是本章最重要的动手环节。 4.4.1 创建主场景和基本节点 新建主场景： 点击“文件系统”面板上方的“新建场景”按钮（或使用快捷键 Ctrl+N）。 选中 Node2D 作为根节点，点击“创建”。 将这个根节点重命名为 Main（在左侧“场景”面板中双击节点名即可修改）。 保存主场景： 按 Ctrl+S 保存。 将场景名保存为 main.tscn，存放在 scenes/ 文件夹下。这是我们的游戏入口。 搭建场景骨架： 在 Main 节点下，创建以下子节点（确保在Main选中时点击“+”号添加）： 一个 Node2D 节点，命名 CurrentLevel。这是当前关卡的容器。 一个 CanvasLayer 节点，命名 UI。这是全局UI层。 选中 UI 节点，在其下创建一个 Control 节点，命名 MainHUD。 此时，你的场景树应该如下图： Main (Node2D) ├── CurrentLevel (Node2D) └── UI (CanvasLayer) └── MainHUD (Control) 设置游戏入口： 点击顶部菜单“项目 → 项目设置”。 在“通用”标签页下，找到左侧“应用 → 运行”。 在右侧的“主场景”设置中，点击文件夹图标，选择我们刚刚创建的 scenes/main.tscn。 关闭窗口。这样，每次你按下F5运行游戏，都会从这个场景开始。 图4-4-1：将main.tscn设置为项目的主场景，这是游戏运行的起点。 4.4.2 编写 GameManager 与 EventBus 脚本 在之前创建的 game_manager.gd 和 event_bus.gd 中，我们填入适配的基础代码。 event_bus.gd (全局事件总线) extends Node # 这是一个全局信号总线，所有系统间的通信都通过这里广播 # 例如：玩家获得物品，背包UI和任务系统都需要知道，但它们不需要互相认识 signal player_drunk_changed(drunk_level: float) # 玩家醉意值变化 signal item_obtained(item_data: Resource) # 玩家获得物品 signal battle_started # 战斗开始 signal battle_ended(is_victory: bool) # 战斗结束 game_manager.gd (游戏管理器) extends Node # 《酒魂》的游戏流程指挥中心 # 定义游戏可能的状态枚举：探索、战斗、酿酒、对话、暂停 enum GameState { EXPLORE, BATTLE, BREWING, DIALOGUE, PAUSED } # 当前游戏状态，初始为探索状态 var current_state: GameState = GameState.EXPLORE var player_drunk_level: float = 0.0 # 全局缓存一份醉意值，方便其他系统查询 func _ready() -> void: print("[GameManager] 游戏启动，当前状态: ", GameState.keys()[current_state]) # 切换游戏状态的方法 func change_state(new_state: GameState) -> void: if current_state == new_state: return # 避免重复切换 print("[GameManager] 状态变更: ", GameState.keys()[current_state], " -> ", GameState.keys()[new_state]) current_state = new_state match new_state: GameState.EXPLORE: get_tree().paused = false # 恢复游戏 GameState.PAUSED: get_tree().paused = true # 暂停游戏 GameState.BREWING, GameState.DIALOGUE: # 弹出UI时暂停游戏世界，但UI层(CanvasLayer)需要设置不受暂停影响 get_tree().paused = true GameState.BATTLE: # ARPG中战斗无需暂停世界，敌人与玩家同场 pass 关键认知： 我们这里的 GameState 枚举和 change_state 函数，就是后续所有系统状态同步的核心。当你需要判断“玩家在对话中能否打开背包？”时，答案就是： if GameManager.current_state != GameManager.GameState.DIALOGUE。提前定义好状态，能避免大量的逻辑冲突。 动手练习4.1：让UI层不受暂停影响 当使用 get_tree().paused = true 暂停游戏来打开酿造或暂停菜单时，UI也会被冻结，导致按钮点不动。 在场景树中选中 UI (CanvasLayer) 节点。 在右侧检查器中找到 Process -> Mode 属性。 将其从 Inherit 修改为 When Paused（暂停时仍可处理）。 这样，即使游戏世界暂停了，你的UI面板依然可以点击关闭。 4.5 本章产出与小结：一个稳固的起点 现已经成功完成了《酒魂》项目的奠基工作。不再是一张白纸，而是拥有了一个结构清晰、配置完整的工程骨架。 4.5.1 本章为你产出了什么？ 回顾一下，你在Godot中完成了以下实实在在的成果： 一个干净的 WineSoul 项目，并配置了Git进行版本控制。 一套规范、可扩展的文件夹结构，所有文件今后都各得其所。 一张专为ARPG设计的场景树蓝图，采用 CurrentLevel 动态加载架构，并创建了 main.tscn 作为游戏入口。 两个全局自动加载单例：GameManager（指挥中心）和 EventBus（广播站），并编写了适配ARPG状态的基础代码。 一个可以运行的空壳项目，没有报错，随时可以进入第5章的开发。 4.5.2 本章小结 我们学了什么 为什么它很重要 项目文件夹规划 从源头避免混乱，让项目像图书馆一样井井有条。 场景树分层设计 探索即战斗，动态加载关卡，UI覆盖交互，保证了动作游戏的流畅性。 CanvasLayer的用途 确保UI永远在游戏世界之上，且可以独立于游戏暂停状态运行。 自动加载 创建全局唯一的“管理器”和“通讯器”，实现跨系统的解耦和通信。 游戏状态枚举 用清晰的状态管理游戏流程，避免逻辑混乱的灵魂Bug。 “千里之行，始于足下。” ——老子《道德经》 这一章，就是我们《酒魂》千里之行的坚实第一步。一个稳固的基础，比一百个炫酷但摇摇欲坠的功能都重要。你现在拥有的不仅是一个空项目，更是一个可以承载你所有设计蓝图的专业工程。 下一步： 框架已经搭好，是时候让它活起来了。在下一章，我们将深入 Player 场景，创造出属于《酒魂》的主角，并让她在我们的江南小镇上自由行走。 延伸阅读 Godot官方文档《场景与节点》：https://docs.godotengine.org/zh_CN/4.x/tutorials/scripting/scenes_and_nodes.html。必读。这是理解Godot核心哲学的基础。 Godot官方文档《单例（自动加载）》：https://docs.godotengine.org/zh_CN/4.x/tutorials/scripting/singletons_autoload.html。深入了解自动加载的最佳实践和注意事项。
一、设计核心理念 《酒魂》以“诗酒江湖”为核心体验锚点，将东方酒文化、唐诗文化与2D动作经营玩法结合，通过“醉意”数值作为核心体验杠杆，在“可控的失控感”“文化共鸣的成就感”“有温度的失败反馈”三个维度，打造差异化的国风游戏体验。所有体验设计均围绕“酒入豪肠，七分酿成月光，三分啸成剑气”的核心意境展开。 二、关键体验节点 游戏时刻 目标体验 技术实现（Godot 2D 落地细节） 体验补充说明 饮酒后攻击力暴涨 醉拳豪迈感、力量宣泄感 1. 屏幕后处理：使用ColorRect叠加红色滤镜（透明度随醉意值0.3-1.0线性提升）+ FastBlur节点实现动态模糊；2. 粒子系统：为武器节点挂载Trail2D（颜色偏酒红，宽度随攻击力提升）；3. 音效：播放低沉的饮酒声+拳风强化音效（音量提升20%）。 攻击力暴涨的数值反馈与视觉/听觉反馈同步，强化“醉后无畏”的爽感，模糊效果控制在不影响操作的范围内。 吟诗清屏 诗意爆发、文化爽感 1. 文字动画：通过Label节点实现诗句逐字飞散（Tween动画控制位置+透明度），文字方向向全屏扩散；2. 伤害反馈：高伤害数字使用艺术字体，缩放动画（从0.5倍放大至1.5倍再恢复）+ 渐隐；3. 特效：清屏瞬间触发全屏淡白闪效，配合古琴+剑气混合音效。 诗句内容与当前场景/敌人类型匹配（如打群敌时触发“十步杀一人，千里不留行”），强化文化沉浸。 酿造出第一壶药酒 成就感、创造感 1. 动画：酒壶揭盖使用AnimationPlayer实现壶盖抬起+旋转动画；2. 粒子：壶口挂载Steam2D粒子（淡金色，区别于普通酒的白色蒸汽）；3. 音效：专属“开坛”音效+轻快的奖励提示音；4. UI：弹出“新配方解锁”提示框，附带药酒效果说明。 首次酿造触发特写镜头（Camera2D聚焦酒壶），延长动画时长至2秒，强化“里程碑”式的成就感。 诗魂战士首次暴击 文化震撼、身份认同 1. 文字显示：在敌人碰撞体位置生成Label节点，显示“十步杀一人”等诗句（毛笔字体，渐显渐隐）；2. 特效：诗句浮现时触发敌人周身红色剑气粒子；3. 震动：Camera2D小幅度震动（强度0.5，时长0.8秒）。 首次暴击触发新手引导提示，说明“诗魂共鸣”机制，让玩家理解文化元素与战斗的关联。 醉意达到1.0 濒临失控紧张、压迫感 1. 音效：循环播放心跳声（音量随时间逐步提升），屏蔽部分环境音效；2. 镜头：Camera2D使用Tween实现随机小幅度晃动（偏移量±5px）；3. 角色：角色移动动画添加“醉步”偏移（移动方向随机小幅度偏差，幅度≤10%）；4. UI：醉意条变红并闪烁，附带“醉意盈满！”文字提示。 紧张感的同时保留操作可控性，让玩家感受到“失控边缘”的刺激，而非纯粹的操作阻碍。 对诗成功 智识满足、互动愉悦 1. 动画：毛笔书写效果通过TextureProgress节点模拟笔触渐显，配合墨点粒子；2. 语音：播放“妙哉！”“此乃佳句！”等国风语音（多段随机触发）；3. 反馈：诗魂数值条快速上涨的动画（绿色高亮），附带“诗魂+XX”提示。 对诗成功的反馈节奏紧凑（≤1秒），避免玩家等待，强化“答对即爽”的即时满足感。 三、有趣的失败设计（容错+正向反馈） 核心设计原则：失败不只是惩罚，而是“带有补偿的成长契机”，避免玩家因失败产生挫败感，同时强化“酒文化”的“容错”“豁达”内核。 失败场景 传统惩罚 本设计做法（Godot 2D 落地细节） 设计意图 醉意超过1.0 眩晕、纯负面效果 1. 状态触发：通过AnimationPlayer播放角色“醉倒”动画（瘫坐地面，时长10秒），禁用移动/攻击输入；2. BUFF生成：醉倒结束后，为角色添加“宿醉”buff（通过Node2D挂载状态节点，UI显示酒红色图标），攻击+50%持续30秒；3. 提示：弹出“醉卧沙场君莫笑”文字提示，弱化失败感。 用“短期失控换长期增益”，契合“酒后更勇”的酒文化，让玩家不惧怕“醉倒”，甚至主动尝试可控的醉倒策略。 药膳使用过度 无法回血、纯负面 1. 数值反馈：下一次药膳回血从基础值（如50血）降至5血（通过变量修改回血公式）；2. 召唤机制：在角色周围随机位置生成毒虫节点（带独立AI的2D敌人），击杀后掉落“药引”（可用于强化药膳）；3. 提示：UI显示“药毒缠身！”，附带“击杀毒虫可解”的引导文字。 将“惩罚”转化为“小型战斗事件”，既提醒玩家合理用药，又补充资源获取渠道，避免纯数值惩罚的枯燥。 对诗失败 失去机会、无反馈 1. 对话反馈：NPC通过DialogueBox节点说出“回去读读《唐诗三百首》”“此句不妥，再思之”等趣味台词；2. 道具奖励：自动获得“唐诗残卷”（道具节点，存入背包），使用后下次对诗答题时间延长2秒；3. 引导：残卷道具附带“残卷可集齐合成完整诗集，解锁更多诗句”的提示。 用轻松的台词降低失败挫败感，“残卷”补偿让失败有长期价值，引导玩家后续探索诗文化内容。 酒灵死亡 白白损失资源、无补偿 1. 掉落机制：酒灵死亡时在位置生成“酒糟”道具（带拾取碰撞体），通过Tween实现掉落动画；2. 兑换机制：集齐3个酒糟可在酒坊NPC处兑换“酒灵召唤符”（免费召唤1个酒灵）；3. 提示：酒灵死亡时弹出“酒灵消散，掉落酒糟”，说明兑换规则。 降低酒灵死亡的资源损失焦虑，让玩家即使失误也有“止损”的机会，同时鼓励玩家回收掉落物。 英雄被击杀 读档、重复流程 1. 复活机制：直接在酒坊场景复活（通过场景切换+角色位置重置），无需读档；2. 资源惩罚：扣除20%粮食和药引（通过变量计算，UI显示扣除提示），保留诗魂数值（核心成长资源）；3. 成就触发：首次复活解锁“醉生梦死”成就（Achievement节点，解锁后显示成就描述“醉里挑灯看剑，梦回吹角连营”）。 避免重复读档的体验割裂，惩罚聚焦非核心资源，成就系统让“死亡”也成为成长里程碑，强化国风主题共鸣。 四、MVP优先实现的体验（低成本、高体验收益） 核心优先级（按落地难度+体验影响排序） 饮酒后攻击力提升的视觉反馈 简化实现：使用ColorRect叠加半透明红色滤镜（仅控制透明度，不做复杂模糊）+ 攻击力数字弹窗（Label节点，显示“攻击+100%”缩放动画）； 核心目标：让玩家直观感知“饮酒=变强”，无需复杂特效，优先保证数值与视觉反馈同步。 建造第一个自动酒田的“资源自给”喜悦 简化实现：酒田建造完成后触发Camera2D聚焦酒田+ 1秒淡入淡出的“酒田生效”提示； 反馈补充：UI显示“自动酒田已解锁！每10秒产出1份粮食”，配合轻快的建造完成音效； 核心目标：强化“经营闭环”的初始成就感，让玩家感知到“自动化”带来的资源解放。 对诗小游戏的正反馈 简化实现：对诗成功后，用Tween实现“妙哉！”文字从屏幕中央向上飞散（仅位置+透明度动画）+ 诗魂数值条绿色高亮（修改Modulate颜色）； 核心目标：快速传递“答对正确”的反馈，无需复杂毛笔动画，优先保证反馈的即时性。 MVP阶段暂不做（降本聚焦核心） 醉意1.0的复杂镜头晃动（先保留心跳声+UI闪烁即可）； 诗魂战士暴击的诗句定制化（先固定“十步杀一人”一句）； 失败设计中的“毒虫AI”（先简化为静态可拾取道具）。 五、体验调优原则 节奏适配：核心体验节点的反馈时长控制在0.5-2秒，避免过长动画打断游戏流程； 文化统一：所有视觉/音效/文字反馈均贴合“盛唐酒文化”，避免现代元素出戏； 容错友好：失败设计的补偿收益≥惩罚损失，让玩家敢于尝试不同策略； 可感知性：所有核心机制（如醉意、诗魂）均有对应的UI/音效/动画反馈，避免“隐形数值”。 六、后续迭代方向 体验节点拓展：增加“酒坊升级”“诗会夺冠”“跨服斗酒”等高阶体验节点； 失败设计深化：为不同失败场景增加个性化动画（如醉倒的不同姿势、药毒的不同特效）； 文化沉浸强化：联动更多唐诗/酒文化典故，让体验节点与文化内容深度绑定。
主线 你继承破酒坊后，发现每次喝醉都会被前世人格夺舍。在老酒鬼、药王、李白三位英灵的引导下，你历经人间、草木、诗酒三重试炼，最终酿出“清魂”——喝下它洗净前世做个凡人，或拒绝它跳入醴泉与前世融合，永镇浊气。 三境界叙事框架 叙事分三层境界，逐境递进——不是割裂的章节，而是同一世界逐步展开的三重认知维度：从“活下去”，到“为何活”，再到“如何才算活过”。 境界 核心命题 情感基调 主导英灵 你的身份 人间·忘忧境 我与小镇——我是谁，真相为何被隐瞒 悬疑、暗流涌动的日常 老酒鬼 被蒙在鼓里的探寻者 草木·渡厄境 我与他——前世留下的债，今生如何清偿 悲悯、负重、两难抉择 药王孙思邈 继承因果的还债者 诗酒·归真境 我与我——洗净前尘，还是拥抱完整的自己 壮阔、释然、诗意的告别 李白 手握终局的抉择者 剧情与玩法绑定 每个剧情事件均由系统数值触发。不玩到那一步，就看不到那层真相。 触发条件（玩法） 剧情 解锁 境界 开场 你是被酒坛碎裂声叫醒的。不是失手砸的——是它自己裂了。酒液渗过桌缝，在地上淌出一个字，像“归”，又像“悔”。低头间，手背已多了一道陌生的符文。门外有人脚步顿了顿，没进来。这个镇子早已习惯你的酒坛在同一个时辰自裂。只有你才刚发现这件事。 酒坊主基地、醉意系统、手背符文（前世觉醒度的视觉载体） 人间·忘忧境 首次酿造成功 酒是清的，倒在碗里却照不出你的脸。酒汽氤氲中凝出一位老者的半身——老酒鬼。他先嗅了嗅你的手背，而后长久地盯着那道符文，像在读一封写给自己的信。“这手艺是他教的。但他教过的人，不该还活着。”他没解释，只是把酿酒的口诀教得格外仔细，像在赶时间。 基础单位“酒灵”、酿造系统基础配方 人间·忘忧境 药引累计达20 二十株药引攒在掌心时，草叶上的露水忽然悬在半空，聚成雾。雾中走出一位青衣老者虚影——药王孙思邈。他把了很久的脉，然后说：“你身上有另一个人的脉搏。跳得很慢，很沉，像在井底敲钟。答应老夫，不要轻易回应它。”交付药童训育之法时，他又补了一句：“药能渡人，也能证因果。你好自为之。” 建筑“药圃”、远程治疗单位“药童” 草木·渡厄境 对诗成功3次 石碑炸裂，飞出来的是光，还有笑声。李白的魂破石而出，衣袂翻飞，第一眼就认出了你魂里藏着的东西。“好酒！可惜闷在井底太久，馊了一半。”他笑得恣意，被老酒鬼沉声喝住。夜半，他独坐屋脊，把酒葫芦倒过来——一线酒瀑在半空凝成一首诗，停在第三句。“最后一句在你手里。酿好那坛酒，再来给我看。” 建筑“诗碑亭”、高爆发单位“诗魂战士” 草木·渡厄境 醉意首次达0.8 醉意冲破阈值的刹那，一切声音退去。你站在一口古井前，青苔湿冷，井壁刻满你手背上的那种符文。井边有人——穿着你的衣服，长着你的脸。他回头，没有开口，但那个神情比任何话语都清楚：我等你很久了，你怎么才来。天地倒旋，你跌回现实，浑身冷汗。三魂站在你面前。李白把酒葫芦塞进你手里，难得没说笑：“喝了。不要问他长什么样。” 限时buff“前世余响”、隐藏数值“前世觉醒度”、手背符文首次发光 草木·渡厄境 酿造出B级以上“渡厄” 开坛时药香不散，直直向西飘——飘向醴泉的方向。药王的脸色终于变了。他试过教你避开的，可酒性自己认出了前世的去向。“浊气已缠结心脉，药只能缓，不能断。你要的根治之法，在井底——那个你前世选择不回来的地方。去不去——”他停顿，“你酿这坛渡厄，其实就已经选了。” 专属配方“渡厄”、醴泉区域入口线索 草木·渡厄境 三建筑建成且前世觉醒度达3 那一夜没有风，三魂却同时现身。老酒鬼终于开口：“浊气当年的宿主，是你前世。他主动饮下浊酒，以身为印，沉入醴泉。我们守在这里，不是等你复兴酒坊——是等你记起来。”良久沉默。李白接话：“现在你有两条路。酿‘清魂’，喝了，前世尽忘，我三人另寻封印之法。或者——”他晃了晃酒葫芦，“你下去，把他和你自己，一起捞上来。”药王低声补了句：“不管选哪条，都有一个人会被留在井底。这是‘渡厄’的代价。” 新地图“浊气深渊”、终极配方“清魂” 诗酒·归真境 首次酿造出A级以上“清魂” 杯中酒液澄澈。你没有喝，只是低头看。酒面如镜，镜中人是你——但他没有跟着你眨眼。他用你的声音开口，平静得像井水：“你酿此酒，是要渡我，还是弑我？渡我者，身魂合一，永镇醴泉；弑我者，干干净净，一世凡人。不必急着答。我在井底等了那么久，不差这几口酒的时间。”抬头时，镜面已散。窗外天色未亮，酒还温着。 装置“醴泉之眼”、结局选择激活 诗酒·归真境 击败最终Boss“浊气之形” Boss溃散。浊气没有炸开——它缩成一团，凝成一个人形：你少年时教你采药引的身影。他开口，浊气凝成的声音：“爹只是替你封了一道。我不下去，也会是你下去。”他站在井口，没劝你选哪条路。他只抛来一样东西——一枚和你手背符文一模一样的酒坛碎片。“不管你选哪条路，把酒壶拼完整。缺着口，不像样子。”浊气散尽，井口幽幽。你身后有人站了很久。你没有回头。 结局选择触发，全剧情通关 诗酒·归真境 MVP剧情流程 第一境：人间·忘忧境 1. 开场：酒坛自裂 被坛裂之声惊醒，酒液渗地成字。手背首次出现不明符文。小镇寂静如常——它已替你习惯了许多事。悬念种下：裂坛、符文、被隐瞒的真相。 2. 首次酿造与老酒鬼现身 新手引导完成首酿。老酒鬼从酒汽中凝出——“这手艺是他教的，但他教过的人，不该还活着。”传授酒灵与酿造基础，留下第一道裂口：那个“他”是谁。 第二境：草木·渡厄境 3. 邂逅药王孙思邈 药引累计达20，触发药王登场。号出“另一人的脉搏”，揭示前世的债。解锁药圃与药童。情感基调由悬疑转向悲悯：被赋予的因果，能不能不接。 4. 邂逅李白 完成对诗3次，李白破碑而出。识出主角魂魄中封着的前世，留下一首停在第三句的诗。解锁诗碑亭与诗魂战士。悬念持续收紧：所有人知道得比你多。 5. 首次醉意觉醒 醉意首次达0.8，触发前世井边幻境——看见“他”，被看见。获得“前世余响”buff，符文第一次发光。三魂的反应令玩家明确：醉意不是战斗资源，是前世的反噬计时器。 6. 酿造渡厄与三魂交底 酿出B级以上“渡厄”，药香引路，醴泉方位暴露。三魂全盘托出前世封印的真相，以及两条路的代价。玩家获知终极目标：酿造“清魂”。 第三境：诗酒·归真境 7. 清魂酿成，结局分岔 酿造出A级“清魂”，触发与前世倒影的对话。击败浊气Boss后，在醴泉之眼前做最终抉择： 喝下清魂 → 前世尽忘，归于人间，一世凡人 与前世融合 → 身魂合一，永镇醴泉，成新的守护者 两条路皆非圆满。都有一个人会被留在井底。 关键意象速查 意象 含义 首次出现 手背符文 前世觉醒度的外化标记 开场 裂坛 封印松动、前世召唤的具象 开场 老酒鬼的沉默 对旧友的愧疚与不忍告知 首酿 药王的脉诊 前世存在性的物理证据 20药引 李白未完成的诗 主角未走完的路 对诗3次 古井 醴泉封印入口 醉意首次0.8 渡厄药香向西 前世对主角的引力 B级渡厄 清魂倒影 前世人格最后一次对话 A级清魂 酒坛碎片 完整自我认同的隐喻 终局Boss战
世界观与美学 在名为“酒魂大陆”的东方幻想之地，天地间充盈着三种令万物欣悦的本源之力：酒之醺、药之苏与诗之鸣。酒神、药王、诗仙三脉自古守护着这片大陆，人们以酿酒为歌、以采药为养、以赋诗为风，生活处处皆是敞亮的笑意与缤纷的色彩。 然而，一种名为“沉晦”的灰色薄雾悄然蔓延——它不伤人、不害命，只是蒙住了万物本来的光彩。被沉晦沾染的酒液寡淡如水，药草不再散发光华，诗碑上的字迹灰暗难辨。更糟糕的是，沉晦催生出三类迷失的造物：浊酒怪（近战，由变质的酒液凝聚而成）、药毒虫（远程喷吐毒雾，由枯萎的草药异化）和失意书生（吟诵残缺的诗句伤人，由蒙尘的诗碑幻化）。笑声渐渐被沉默取代，大陆陷入了漫长而温吞的乏味。 你，是偶然间被古旧酒葫芦中淌出的第一滴琥珀光选中的少年，被称作“酒魂传人”。你将重开荒废的酒坊，辟出阶梯状的酒田与发光的药圃，走进爬满藤蔓的诗碑亭，与那些憨态可掬的酒灵、身背药篓的药童、持笔为剑的诗魂战士结伴同行。每当你与石碑或游荡的文人墨客对上一联好诗，便能获得“诗魂墨韵”这种灵感能量；采集五谷与百草，则可酿造驱散沉晦的琼浆，制作治愈大地的药膳。 你的旅途，不是暴力征服，而是用酒去欢醉沉晦、用药去复苏大地、用诗去唤醒真心——这是一场以醺然、生意与吟咏重绘人间颜色的温柔修复之旅。 视觉风格 主色调：采用明亮欢愉的“酿春”色系——以琥珀金（醇酒）为光、新芽绿（药草）为生、浅绯红（诗笺桃瓣）为笑，三色在宣纸纹理上以水彩晕染手法铺展，处处是柔和通透的暖光，仿佛阳光刚穿过半透明的酒坛，连影子都带着微甜的暖意。 场景： 酒坊（主基地）：原木梁柱披着午后暖阳，靛蓝酒旗在微风里轻扬。陶坛堆叠成小山，坛口凝着亮晶晶的酒露。光影里浮动着曲精化作的金色微粒，偶有胖乎乎的酒灵抱着酒杓打盹。这里也是“封坛养器”之所，你的装备在此以酿酒之法温养而成。 酒田：依山坡而辟的阶梯黍米田，成熟的黍穗垂着琥珀色浆光。田埂间暖黄流萤飞舞，草叶簌簌如低语。英雄靠近即可采收五谷——黍米、高粱、稻糯皆备。 药圃：灵芝舒展如云，人参娃娃头顶红珠，当归叶片泛着银绒般的柔光。每一株草药都被淡绿光晕笼罩，药童轻挥药锄时，星点孢子飞扬，百草——人参、灵芝、何首乌等皆可由此采集。 诗碑亭：古旧石亭被花树环抱，石碑上诗句流转着金、青、绯三色微光。在此可以触发“对诗”小游戏：碑文浮现上联，你需在三个下联选项中选出正确的那一个。成功时文字粒子如萤火般升起，获得诗魂墨韵与临时增益；即使答错，石碑也会温和地现出正确答案，不叫英雄难堪。 酒窖：半地下，坛堆如山，微光鼾声特效萦绕，可在此“诚邀”酒灵与你同行。 药庐：庐顶飘出淡绿药香烟缕，可在此邀出药童。后续还可在此进行“药斗”小游戏，对症下药获取丰厚百草回报。 诗社：文人墨客的虚影在此吟哦漫步，社内有飞花令小景，可在此邀出诗魂战士。 角色： 英雄（酒魂传人）：融汇历代之美的国风装束，腰间酒葫芦随步伐轻轻晃响。主手装备“酒壶”（武器），副手持有“素笺卷”，身着青衫，系丝绦，悬琼瑶佩，戴清音戒。所有装备品质分为五等：粗陶（白）→ 良酝（蓝）→ 陈坛（黄）→ 谪仙（橙）→ 药王沁（绿）。 酒灵：半透明的坛形小生灵，坛身绘有简笔憨笑。近战伙伴，生命120，意气15。被击倒时散为“醺香涟漪”，为周围友方增加醺然值，一如它们天真的笑脸，倒下了也要让你醉一醉。 药童：总角小童背着几乎比他还高的药篓，手持小巧药锄。远程伙伴，生命80，意气8。投掷发光药包治疗友方，给敌人挂上“苏麻”状态——绿藤缠绕使其短暂迟滞，周身环绕药香凝成的翠绿光蝶。 诗魂战士：历代文人墨客的虚影，或持毛笔如剑，或展书卷为屏。生命100，意气20。攻击伴随吟哦之声，高概率打出“对诗”效果——敌方被一段文字环锁降速，同时掉落诗魂墨韵。吟咏之时，墨字绕身绽开，触物即生出小花、蔓藤，风度潇洒且爱笑。 文化元素清单 类别 具体内容（跨越朝代，无限制） 酒 酿材：黍米、高粱、葡萄、稻糯。工艺：酒曲、窖池、蒸馏、陈化、勾调、封坛。名饮：屠苏酒、菊花酒、竹叶青、状元红、寒潭香、新丰酒。传说与雅事：杜康始酿、仪狄作醪、刘伶醉；曲水流觞、酒令对答、碰杯成礼。 药 草木：人参、灵芝、何首乌、当归、甘草、枸杞、金银花、艾草、茯苓。方用：药酒、药膳、药浴、香囊、凉茶、蜜丸。医典与人物：《神农本草经》《伤寒杂病论》《本草纲目》；扁鹊、华佗、张仲景、孙思邈、李时珍。 诗 体裁：诗经、楚辞、汉赋、唐诗、宋词、元曲。雅事：联句、题壁、飞花令、诗社雅集、对诗。名句意象：举杯邀月、采菊东篱、对酒当歌、大江东去、稻花香里说丰年。代表人物：屈原、陶渊明、李白、杜甫、王维、苏轼、李清照等。 绝对禁止元素 欧洲中世纪风格（包括城堡、骑士、哥特式建筑等） 枪炮、机械、蒸汽工业及任何现代火器装置 非中文体系的诗词歌赋（谨限中华历代经典诗词与典故） 采矿、伐木、采石等带有工业感的资源开采行为（资源获取仅来自种植、采集、酿造、文思与自然馈赠）
核心玩法：英雄战斗 操作：WASD移动,鼠标左键普攻，右键药膳，Q吟诗，E饮酒，R大招 系统关联： 普攻积攒“诗意”（每秒+2，命中敌人+5） 饮酒增加醉意，醉意≥0.5时攻击力+30%但闪避-20%，醉意≥0.8可放大招 药膳可紧急回血，但连续使用产生“药毒”（下次效果减半） 决策点：保持低醉意稳定输出，还是高醉意冒险放大招？ 核心玩法：资源采集与建造 资源点： 荒废酒田：采集获得粮食（每点5-10粮食） 草药丛：采集获得药引（每点3-8药引） 残破石碑：读碑诗小游戏（限时对出下联），成功获得诗魂（10-20） 建造：按B键打开建造菜单，选择建筑（需要资源和空间） 自动生产建筑：酒田、药圃、诗碑亭（类似RTS的资源建筑） 决策点：优先发展哪种资源？粮食用来频繁喝酒，药引用来保命，诗魂用来召唤强力单位？ 核心玩法：单位生产与控制 酒窖→酒灵（肉盾，死后爆炸醉意） 药庐→药童（远程治疗） 诗社→诗魂战士（高爆发） - 控制：队友跟随英雄（F键集合），可框选，简单AI自动攻击 - 决策点：不同敌人类型需要不同单位组合（例如亡灵怕诗魂战士，毒怪需要药童净化） 核心玩法：文化小游戏（诗、药、酒） 对诗系统：遇到NPC或石碑，出现上联，玩家在限定时间内从3个选项中选下联；成功增加诗魂并获得临时buff（攻击+20%，持续30秒） 酿酒小游戏：选择材料（粮食种类+药引种类）→ 调整火候（滑块）→ 获得品质，不同配方解锁不同效果（如“屠苏酒”全队抗毒） 药斗（可选）：类似配药小游戏，对症下药，成功获得药引回报 MVP版本优先实现“对诗”小游戏（简单三选一），酿酒小游戏后续。 MVP版本范围 战斗系统 英雄普攻+吟诗(Q)+饮酒(E)+大招(R) 醉意系统（影响伤害和移速） 诗意系统（吟诗消耗） 3种敌人：浊酒怪（近战）、药毒虫（远程中毒）、失意书生（吟诗攻击） 资源与建造 粮食、药引、诗魂资源UI 采集资源点：酒田、药丛、石碑 建筑：酒坊（主基地）、酒田、药圃、诗碑亭、酒窖、药庐、诗社 单位：酒灵、药童、诗魂战士（各最多3个） 装备系统（简化） 酒壶（武器）、袍服 两个槽位 掉落白色/蓝色品质，带酒/药/诗词缀 简单背包和装备替换 文化元素 对诗小游戏（3次触发，奖励诗魂） 酿酒界面第一版（固定配方产出）
1. 英雄属性 属性 范围 说明 生命值 50 ~ 500 受“体魄”属性影响 攻击力 10 ~ 100 受武器和“醉意”加成 醉意值 0.0 ~ 1.0 喝酒增加，提升攻击但降低精准 诗意值 0 ~ 100 吟诗或触发诗魂技消耗 药性值 0 ~ 100 用药酒/草药恢复生命，过量中毒 2. 资源系统 资源 范围 获取方式 用途 粮食（黍米/高粱） 0 ~ 999 农田种植（需建造“酒田”） 酿造基础酒液 药引（草药） 0 ~ 999 采集野外药草 酿造药酒、恢复道具 诗魂（文化灵感） 0 ~ 100 击败敌人、阅读石碑、对诗NPC 召唤诗魂单位、解锁技能 资源采集方式：英雄靠近资源点，按E键采集（进度条）。建筑可自动生产部分资源。 3. 装备系统 品质：普通(白) → 良酿(蓝) → 陈酿(黄) → 诗仙(橙) → 药王(绿) 词缀池（与酒、药、诗相关）： 酒系：+醉意上限、+醉意伤害加成、+喝酒冷却缩减 药系：+生命恢复、+中毒抗性、+药酒效果 诗系：+诗意回复速度、+诗魂技伤害、+对诗胜利奖励 装备槽位：酒壶（武器）、诗卷（副手）、袍服、腰带、玉佩、戒指×2 4. 建筑数据 建筑 粮食 药引 诗魂 功能 酒坊（初始） 0 0 0 基础酿造，恢复点 酒田 50 0 0 自动生产粮食（+1/秒） 药圃 20 30 0 自动生产药引（+1/秒） 诗碑亭 0 0 40 自动生产诗魂（+1/3秒） 酒窖 30 20 10 训练“酒灵”单位 药庐 20 30 10 训练“药童”单位 诗社 10 10 30 训练“诗魂战士”单位 5. 单位数据 单位 粮食 药引 诗魂 生命 攻击 特点 酒灵 30 0 10 120 15 普通近战，死亡溅射醉意伤害 药童 10 30 0 80 8 远程投药，治疗英雄+单位 诗魂战士 10 10 30 100 20 高暴击，攻击带“对诗”状态（敌人减速） 6. 英雄技能（与酒药诗绑定） 技能 消耗 效果 决策点 饮酒（E键） 1份粮食（自动消耗库存） 醉意+0.2，生命+20 醉意过高会失控 吟诗（Q键） 诗意≥20 诗意-20，对周围敌人造成“醉诗”伤害（无视护甲） 积攒诗意还是立即释放 药膳（右键） 1份药引 恢复30生命，移除中毒/流血 消耗宝贵的药引 醉拳（R键大招） 醉意≥0.8，消耗全部醉意 大范围AOE，伤害=基础×（1+醉意×2） 快速清场或打Boss
【酒魂开发手记】第三章：五维设计框架——让《酒魂》的设计有章可循
「凡事预则立，不预则废。」 ——《礼记·中庸》 在正式开始之前，有一个问题必须先想清楚：你到底在做一个什么东西？这个问题看似显而易见，但大多数半途而废的游戏，恰恰是因为没想清楚就动了手。第2章提到的「范围蔓延」，本质上就是设计层面缺乏明确方向，导致开发中不断加功能、改逻辑，最终陷入混乱。而「MVP思维」的前提也正是这个核心设计——连自己在做什么都没定，最小可行产品就成了无的放矢，做了三个月才发现不好玩或系统之间打架，为时已晚。 本章将介绍《酒魂》所采用的五维设计框架——不是晦涩的学术理论，而是一套实用的设计检查清单。读完本章，你会完成一份《酒魂》的五维设计文档，这份文档就是后续所有章节的开发依据：第7章做战斗时查“醉意阈值”，第15章做酿造时查“品质分几级”。它不是练习，而是施工图纸。后续开发中遇到任何困惑，你都可以回到这五个维度重新审视。 3.1 游戏的五个构成维度 一款游戏，可以从五个相互独立又彼此咬合的维度来理解和设计：数据、规则、设定、剧情、体验。 这五个维度不是开发顺序，而是任何阶段都必须同时兼顾的五个视角——无论是前期设计、中期开发还是后期优化，都要确保它们协同一致。一个好的游戏，是五维协调的结果；一个崩坏的游戏，往往是某一维度与其他四个脱节。就像第2章提到的「完美主义陷阱」：过度追求设定精美或剧情华丽，却忽略了规则的可玩性和数据的合理性，最终做出来的游戏「好看不好玩」。 3.1.1 📊 数据——游戏世界里所有「量」的定义 数据包括： 属性、数值、资源、概率、范围、增长曲线……一切可以量化的东西。 《酒魂》的数据示例： 玩家生命值范围 1-200 醉意值 0.0-1.0 酒品品质分级 F/D/C/B/A/S 词缀权重表（决定酒品特效的触发概率） 设计数据时问自己： 这个数值的范围合理吗？它的增长曲线让玩家感受到成长，还是感受到无聊？ 为什么数据维度很重要？ 因为数据是游戏里最容易被忽视、但出问题最麻烦的东西。如果你在写代码的时候才临时决定“醉意值先写个0.5吧”，后面会发现： 醉意值衰减太快，玩家永远放不出大招 醉意值衰减太慢，玩家全程满醉意，没有决策空间 不同酒品的醉意增加值没有统一标准，平衡性完全靠猜 好的数据设计，应该在写第一行代码之前就确定下来。 在《酒魂》项目中创建数据设计文档 在《酒魂》项目根目录下，创建一个 docs/ 文件夹。在里面新建一个文件叫 01_data_design.md（Markdown格式，用记事本或VS Code都可以打开）。 在这个文件中，写下以下内容（括号里的文字替换成你的设计）： # 《酒魂》数据设计文档 ## 玩家属性 - 生命值：范围 （最小值） ~ （最大值） - 攻击力：范围 （最小值） ~ （最大值） - 醉意值：范围 0.0 ~ 1.0 ## 酒品品质 分级：F / D / C / B / A / S（共6级） ## 货币 - 铜钱：无上限（普通货币） - 功德值：范围 0 ~ 999（稀有货币） ## NPC好感度 范围：0 ~ 100（每20级解锁新内容） 如果你暂时不确定具体数值，可以先写“待定”。后续章节会回来填充。 3.1.2 📏 规则——游戏世界里所有「动词」的定义 规则包括： 什么能做，什么不能做，做了会怎样……一切关于“操作”和“反馈”的定义。 《酒魂》的规则示例： 投壶命中目标造成伤害（第7章） 醉意超过0.8才能释放酒魂技（第10章） 陈化30天，品质每天提升0.5%（第17章） 设计规则时问自己： 这条规则让游戏更有趣还是更繁琐？它是否给玩家留出了足够的决策空间？ 为什么规则维度很重要？ 因为规则决定了玩家“玩什么”。如果你的规则只是“点一下屏幕，数字+1”，玩家3分钟就腻了。如果你的规则是“点一下屏幕，数字+1，但数字越大，下次点屏幕的风险越高”，玩家就会开始纠结——要不要继续点？ 纠结，就是乐趣的来源。 在《酒魂》项目中创建规则设计文档 在 docs/ 文件夹下，新建 02_rules_design.md，写下以下内容： # 《酒魂》规则设计文档 ## 核心玩法：投壶战斗 - 操作：按住鼠标蓄力，松开投掷 - 效果：箭矢沿抛物线飞行，命中敌人造成伤害 - 决策点：蓄力时间越长，射程越远，但瞄准越难 ## 核心玩法：醉意系统 - 操作：按E键喝酒 - 效果：醉意值+0.2，生命值+20 - 决策点：现在喝（提升战力但有风险）还是留到关键时刻喝？ ## 核心玩法：酒魂技 - 触发条件：醉意值 ≥ 0.8，按R键 - 效果：高伤害大招，屏幕震动，诗词浮现 - 决策点：放完大招后醉意值会下降，后续战斗怎么办？ ## 核心玩法：酿造 - 操作：在酒坊按F键打开酿造界面 → 选择配方 → 添加材料 → 点击酿造 - 效果：产出酒品，品质取决于材料和工艺匹配度 - 决策点：用稀有材料赌S级，还是用普通材料求稳？ 3.1.3 🌄 设定——游戏世界的时代背景、地理环境、世界观和美学风格 设定包括： 发生在什么时代？什么地点？什么画风？什么音乐？……一切关于“世界是什么样”的定义。 《酒魂》的设定示例： 唐代江南，酒香弥漫的小镇 水墨与工笔并存的视觉风格 古诗词贯穿始终 设计设定时问自己： 这个设定是否给规则和剧情提供了一个自洽的容器？玩家相信这个世界吗？ 为什么设定维度很重要？ 因为设定决定了“玩家信不信”。如果你的游戏设定是唐代，但NPC掏出手机发微信，玩家立刻出戏。出戏之后，再精妙的规则、再感人的剧情，都很难让玩家沉浸。 设定是所有其他维度的“容器”——它决定了什么东西在这个世界里是合理的。 在《酒魂》项目中创建设定文档 在 docs/ 文件夹下，新建 03_setting_design.md，写下以下内容： # 《酒魂》设定设计文档 ## 时代与地点 - 时代：唐代（618-907年） - 地点：江南某小镇，靠近运河 ## 视觉风格 - 主风格：水墨+浅绛彩（参考《绘真·妙笔千山》） - 建筑：唐代风格酒肆、民居 - 角色：唐代服饰 ## 可出现的元素 - 历史人物：李白、杜甫、王维、贺知章等唐代诗人 - 物品：铜钱、酒坛、竹简、毛笔 - 场景：酒坊、河边、诗会 ## 绝对禁止出现的元素 - 任何现代科技（手机、汽车、电灯） - 唐代以后的历史人物（苏轼、李清照等） - 非中国元素（和服、西式建筑） 3.1.4 📖 剧情——发生在游戏世界里的事件序列 剧情包括： 主线、支线、角色弧光、情感节拍……一切关于“发生了什么故事”的定义。 《酒魂》的剧情示例： 主角继承破败的酒坊 邂逅历史上真实存在的酒仙诗人 通过对饮解锁他们的记忆碎片 设计剧情时问自己： 玩家有没有理由在乎这些角色？剧情的节奏是否和游戏进度相互支撑？ 为什么剧情维度很重要？ 因为剧情给了玩家“为什么要做这些事”的答案。如果只有规则和数据，游戏就像一个精密的机器，能运转，但没有温度。剧情让玩家在乎这个机器里发生的事情。 但要注意： 剧情不应该独立于系统之外。如果玩家可以不玩酿造就解锁剧情，那剧情就成了“额外的负担”，而不是“玩法的奖励”。 在《酒魂》项目中创建剧情设计文档 在 docs/ 文件夹下，新建 04_story_design.md，写下以下内容： # 《酒魂》剧情设计文档 ## 主线（30字版） 你继承了一座破酒坊，通过酿酒结识唐代诗人，找回失传的《酒魂谱》，复兴酒坊。 ## 剧情与玩法的绑定 | 触发条件（玩法） | 触发的剧情 | 解锁内容 | |:---|:---|:---| | 首次酿造成功 | 镇上的人开始知道你的酒坊 | 售卖功能解锁 | | 酒坊等级达到2级 | 李白路过，被酒香吸引 | 李白NPC出现 | | 李白好感度达到60 | 李白讲述《将进酒》的故事 | 解锁“诗剑酒魂技” | | 酿造出A级以上酒品 | 消息传到杜甫耳中 | 杜甫NPC出现 | | 收集3个记忆碎片 | 拼凑出《酒魂谱》的线索 | 开放新地图“藏经阁” | ## MVP版本的剧情范围 只实现：开场 → 首次酿造 → 邂逅李白 → 李白好感度60 → 解锁第一个酒魂技 3.1.5 ✨ 体验——玩家实际感受到的情绪弧线 体验包括： 紧张、成就感、好奇、代入感、遗憾……一切关于“玩家感觉到什么”的定义。 《酒魂》的体验示例： 第一次酿出S级药酒时的惊喜 在醉意大爆发时释放诗号技的痛快 陈酒开坛时的仪式感 设计体验时问自己： 我希望玩家在这个时刻感受到什么？我的设计是否在制造那种感受？ 为什么体验维度很重要？ 因为体验是游戏的最终交付物。玩家花钱买游戏，买的不是代码、不是美术、不是音效——买的是感觉。 数据、规则、设定、剧情，最终都是为了制造某种体验。 在《酒魂》项目中创建体验设计文档 在 docs/ 文件夹下，新建 05_experience_design.md，写下以下内容： # 《酒魂》体验设计文档 ## 关键体验节点 | 游戏时刻 | 目标体验 | 实现手段（技术） | |:---|:---|:---| | 第一次酿出酒 | 成就感 | 成功音效 + 品质弹窗动画 + 酒坛发光特效 | | 醉意达到0.8 | 紧张+期待 | 屏幕边缘泛红 + 心跳音效 + UI闪烁 | | 释放酒魂技 | 痛快 | 屏幕震动 + 全屏粒子特效 + 诗词浮现 + 高伤害数字 | | 陈酒开坛 | 仪式感 | 缓慢揭盖动画 + 蒸汽粒子 + 古筝音效 | | 酿造失败 | “再试一次” | 产出“意外变体”（不是纯损失）+ 神秘音效 | ## MVP版本优先实现的体验 - 第一次酿出酒的成就感（最容易实现，用音效+弹窗） - 释放酒魂技的痛快（最核心的爽点） - 酿造失败的“意外变体”（让失败不沮丧） 3.2 五维之间的咬合关系 五个维度不是平行的，它们之间有方向性的影响关系。理解这些关系，能让你在设计时少走弯路。 3.2.1 设定是容器，其他四维在里面生长 设定决定了什么东西在这个游戏世界里是合理的，是所有设计的基础。 《酒魂》的设定是唐代中国，这意味着： 数据： 货币是铜钱，不是金币；属性名叫「功德」而非「荣誉值」 规则： 酿酒用的是古法工艺，不是现代蒸馏；对话用古典诗词 剧情： 出现的历史人物必须和唐代相关，不能让李白和苏轼同场 体验： 视觉风格服务于「水墨江南·诗酒人生」的整体氛围 检查《酒魂》的设定一致性 打开你刚才创建的 03_setting_design.md，在末尾添加一个“一致性检查清单”： ## 一致性检查清单 在设计任何新功能之前，先检查： - [ ] 数据命名是否符合唐代背景？（用“铜钱”不用“金币”，用“功德”不用“荣誉”） - [ ] 规则是否贴合唐代工艺？（酿酒用古法，不用现代蒸馏） - [ ] 剧情人物是否生活在唐代？（李白可以，苏轼不行） - [ ] 视觉音效是否符合水墨江南风格？ 后续每新增一个功能，都先过一遍这个清单。 3.2.2 规则决定体验，数据调节体验 这是五维框架里最重要的一对关系。 以《酒魂》的醉意系统为例： 规则设计 产生的体验 数据调节 醉意值越高，攻击力越强，但控制变差 高风险高回报的心跳感 醉意≥0.8，攻击力×1.5；醉意≥0.9，控制延迟+0.3秒 醉意超过阈值才能释放酒魂技 给玩家“积累醉意”的目标 阈值0.8，冷却10秒 数据的作用是：在规则确定之后，把体验调节到「刚好好玩」的程度。 在《酒魂》项目中记录数据调节参数 打开 01_data_design.md，在末尾添加“醉意系统参数”： ## 醉意系统参数（MVP版本） | 参数 | 值 | 设计理由 | |:---|:---|:---| | 初始醉意值 | 0.0 | 战斗开始时清醒 | | 每口酒增加醉意 | 0.2 | 喝4口达到0.8，节奏刚好 | | 醉意自然消退速度 | 0.02/秒 | 从0.8降到0需要40秒，够打完一场战斗 | | 酒魂技触发阈值 | 0.8 | 需要喝4口才能放，有明确的积累过程 | | 酒魂技冷却时间 | 10秒 | 不能连续放，让玩家计算时机 | | 醉意≥0.8攻击加成 | 1.5倍 | 足够诱人，值得冒险 | | 醉意≥0.8移速惩罚 | 0.7倍 | 让玩家真正感到“控制变差” | 这些参数会在第10章（醉意系统）用到。 3.2.3 剧情借设定生根，借规则推进 《酒魂》的剧情不是独立于系统之外的「看板」，而是通过游戏规则自然流露的。 《酒魂》的剧情推进方式： 玩家酿出特定酒品（酿造规则）→ 触发对应古人的好感度（数据）→ 解锁对饮事件（剧情） 玩家在投壶小游戏中获胜（战斗规则）→ 获得古人赠送的酿酒配方（数据+规则）→ 解锁新的剧情对话（剧情） 在《酒魂》项目中记录剧情触发条件 打开 04_story_design.md，补充“剧情触发条件”表格： ## 剧情触发条件（代码层面的判断） | 剧情事件 | 触发条件（代码判断） | 后续影响 | |:---|:---|:---| | 邂逅李白 | `brew_count >= 1`（酿过一次酒）且 `winery_level >= 2` | 李白NPC出现在酒坊门口 | | 李白好感度60 | `li_bai_favorability >= 60` | 解锁“诗剑酒魂技”技能书 | | 第一次释放酒魂技 | `drunk_level >= 0.8` 且 `skill_cast_count == 0` | 触发李白对话“好酒量！” | 这些条件会在第18-20章（剧情与任务系统）用到。 3.3 设计 vs 执行：从创意到落地的哲学 3.3.1 创意在纸上总是完美的 在你想象《酒魂》的时候，一切都那么美好。但一旦开始实现，你就会发现：创意的完美，需要大量的执行来支撑。 具体来说： 「精妙的酿酒系统」需要数十张数据表、复杂的概率算法和大量的UI开发 「鲜活的历史人物」需要大量的对话文本、动画、语音 「充满诗意的战斗」需要流畅的碰撞检测、敌人AI、技能系统，和几百次调参 3.3.2 「好的设计」和「能做出来的设计」 对于独立开发者，最重要的设计原则不是「最好的设计」，而是「在当前资源约束下，能做出来的最好设计」。 以下是《酒魂》的务实决策： 理想设计 务实替代方案 节约的工作量 全语音配音的历史人物对话 文字对话 + 环境音效 数十小时的录音和后期 手绘逐帧动画的角色 少量关键帧 + 粒子效果 数百张手绘图 几十种各有特色的敌人 5-8种行为模式，用数值变体扩展 大量的AI调试时间 开放世界地图 精心设计的线性或半开放地图 数倍的地图绘制时间 在《酒魂》项目中记录MVP范围 打开 02_rules_design.md，在末尾添加“MVP版本范围”： ## MVP版本范围（第一版必须完成） ### 战斗系统 - [x] 投壶核心玩法（蓄力+抛物线+命中） - [x] 3种敌人类型（近战、远程、巡逻） - [x] 醉意系统基础版（0~1数值，影响攻击力和移速） - [x] 1种酒魂技（醉意≥0.8时释放） ### 酿造系统 - [x] 3种基础配方 - [x] 品质计算（F~S级） - [x] 随机词缀（简单版：5个词缀随机抽取） ### 剧情 - [x] 开场（继承酒坊） - [x] 邂逅李白 - [x] 李白好感度达到60的剧情 ### 暂不实现（放到后续版本） - [ ] 全语音对话 - [ ] 复杂君臣佐使配伍系统 - [ ] 程序化地图生成 - [ ] 陈化系统 - [ ] 多语言支持 3.4 从设计到代码：一个完整的翻译案例 这是本章最重要的一节。它演示了“设计文档里的文字”如何变成“Godot里的代码”。 案例：醉意值影响攻击力 步骤1：设计语言（你写在文档里的） “玩家喝酒越多，醉意值越高，攻击力越强。但醉意太高时，移动会变得不稳定（打滑）。” 步骤2：翻译成规则（你补充在文档里的） 醉意值范围 0.0 ~ 1.0 按E键喝酒，醉意值+0.2，上限1.0 醉意值每秒自然消退0.02 醉意值 ≥ 0.5 时，攻击力 × 1.2 醉意值 ≥ 0.8 时，攻击力 × 1.5，且移速 × 0.7，移动时增加“打滑”惯性 步骤3：翻译成代码（你在Godot里写的） # 挂在玩家身上的脚本 extends CharacterBody2D # 数据设计（对应 01_data_design.md） var drunk_level: float = 0.0 # 规则设计（对应 02_rules_design.md） func drink(): drunk_level = min(1.0, drunk_level + 0.2) func get_attack_multiplier() -> float: if drunk_level >= 0.8: return 1.5 elif drunk_level >= 0.5: return 1.2 return 1.0 func get_speed_multiplier() -> float: return 0.7 if drunk_level >= 0.8 else 1.0 func _process(delta): # 醉意自然消退 drunk_level = max(0.0, drunk_level - 0.02 * delta) 在《酒魂》项目中创建这个脚本 打开《酒魂》Godot项目 在玩家角色场景（Player.tscn）上，添加一个新的脚本，叫 player_drunk.gd 把上面的代码复制进去 按F5运行，按E键测试喝酒功能（暂时还没有UI显示醉意值，第12章会做） 这个脚本是你《酒魂》项目的第一段醉意系统代码。虽然没有UI，但它已经在后台运行了——你可以用 print(drunk_level) 在调试面板看到数值变化。 3.5 三条设计法则（附《酒魂》检查清单） 法则一：有意义的选择 检查方法： 你设计的每一个选择，闭上眼睛选A。你会后悔没选B吗？不会？那这个选择删掉。 《酒魂》的选择设计： 场景 选项A 选项B 玩家会纠结吗？ 战斗前 喝酒（攻击+50%，移速-30%） 不喝（正常属性） ✅ 会 酿造时 用稀有材料（可能出S级） 用普通材料（保底C级） ✅ 会 与李白对话 说实话（好感+10） 说俏皮话（好感+5，获得一壶酒） ✅ 会 为《酒魂》添加一个“有意义的选择” 在 02_rules_design.md 末尾，写下你为《酒魂》设计的一个新选择。要求：两个选项各有优劣，玩家会纠结。 示例：战斗中是先打败小兵（积累醉意）还是直接打Boss（醉意不足，伤害不够）？ 法则二：可读性优先 检查方法： 把你设计的系统，用一句话说给朋友听。他点点头，你就过了。他皱眉头，你回去改。 《酒魂》各系统的一句话版： 酿造：选配方→加材料→按按钮，出酒 醉意：喝越多越厉害，但越不稳 好感度：送酒给古人，他喜欢你就会教你新东西 动手练习3.12：用一句话描述《酒魂》的醉意系统 写下来。如果超过20个字，简化它。 法则三：失败也要有趣 检查方法： 玩家失败后，是沮丧地说“烦死了”，还是兴奋地说“有意思，再来一次”？ 《酒魂》的失败设计： 失败场景 传统做法 《酒魂》做法 酿造失败 材料消失，什么都没得到 产出“意外变体”，效果奇特，可能是惊喜 战斗死亡 读档重来，进度丢失 敌人记住你的打法，下次有变化 对话选错 对话结束，无法继续 触发特殊台词，“下次带XX再来找我” 为《酒魂》设计一个“有趣的失败” 在 05_experience_design.md 末尾，写下你对“酿造失败”的具体设计。要求：失败后玩家不是空手而归，而是得到一个“意外变体”。 示例：想酿“竹叶青”，失败后得到“苦竹酒”——效果是回血少，但增加暴击率。 3.6 本章小结：《酒魂》的设计蓝图 本章你完成了以下文件（都在 docs/ 文件夹下）： 文件 内容 后续哪一章用到 01_data_design.md 所有数值的范围和参数 第11章（数据驱动） 02_rules_design.md 核心规则和MVP范围 第7-10章（战斗） 03_setting_design.md 世界观和一致性清单 贯穿全书 04_story_design.md 剧情主线和触发条件 第18-20章（剧情） 05_experience_design.md 体验节点和失败设计 第12-14章（UI）、第24章（优化） player_drunk.gd 醉意系统的第一段代码 第10章（醉意系统） 这些文件就是《酒魂》的施工图纸。 后续每一章开始前，你都可以回到这些文档，确认“这个功能的设计依据是什么”。 最终检查： 打开你的《酒魂》Godot项目，确认： docs/ 文件夹存在，里面有5个.md文件 player_drunk.gd 脚本已添加到玩家角色上 按F5运行，按E键能在调试面板看到 drunk_level 的变化 下一章： 第4章 创建《酒魂》项目——动手之前，先把工程搭好 延伸阅读 《游戏设计艺术》（Jesse Schell）——五维框架的灵感来源，重点阅读第1-5章 《游戏编程模式》（Robert Nystrom）——理解“设计模式是解决什么问题”的