AlfFarmTale 项目专属的「小抄本」。AI Agent / 新人 / 未来的你进项目时,先读这一份。
最后更新:2026-06-21(第二轮整理:补全 npc4001~4003、修正 ID 段、补 MapCapture 路径、扩充 building/hold_effect 列表、修正 wood bug 说明)
0. 项目速览
- 引擎:Godot 4.5 Forward Plus(Web 端切到 GL Compatibility)
- viewport:480×270 像素风,window 1440×810,
stretch/mode = canvas_items
- 主场景:
res://scenes/main/main.tscn(标题/存档菜单)→ 点 NewGame / Continue / LoadGame 切到 Game(scenes/game/game.tscn)
- 导出平台:仅 Web(
export_presets.cfg 一条 web preset)
- 关键 Addons:Dialogic 2(对话)、PhantomCamera 2D(相机)、LimboAI(NPC 行为树)、imgui-godot(编辑器调试,已废弃)、awesome_custom_cursor、inventory_editor
Inventory ID 体系(按 Inventory.InventoryType enum 索引)
注意:enum 注释与实际 .tres 数据有出入,实际数据以下表「已用」列为准。
| Enum 索引 | 千位 ID 段 | 注释 | 实际 .tres 数据 | .tres 数 |
|---|
| Weapon (0) | 1000 | 武器 | 1001 sword | 1 |
| Armor (1) | 2000 | 防具 | 无 | 0 |
| Material (2) | 3000 | 材料 | id 段有,但只有 medicine/wood.tres 占位(见 §13 bug) | 0 |
| Seed (3) | 4000 | 种子 | 4001~4007 | 7 |
| Food (4) | 5000 | 食物 | 5000~5007 | 8 |
| Medicine (5) | 6000 | 药品 | 只有 wood.tres(id=3001,路径与 type 都不一致,见 §13 bug) | 1(错位) |
| Tool (6) | 7000 | 工具 | 7001 hoe / 7002 sprinkler / 7003 axe | 3 |
| Crop (7) | 8000 | 作物 | enum 注释说”作物 8000″,但实际所有 id=8001~8095 的物品 .tres 都是 type=8 (Building) | 0 |
| Building (8) | 9000 | 建筑 | 实际用 8001~8095 段(type=8),共 92 个 .tres(id 8061/8062/8063 缺号) | 92 |
设计上的不一致:inventory.gd 注释说”作物 8000 / 建筑 9000″,但 .tres 实际全用 8000 段(type=8 = Building)。改任何涉及 building 的代码前,先确认 id 段是 8000 不是 9000。详见 §13 BuildingResource.BuildingType enum 已废弃说明。
季节
TimeRecord.Season { Spring, Summer, Fall, Winter }
- 月份 → 季节映射写在
time_record.gd 的 SEASONS 常量里
NPC 类型(NPC.NpcType enum)
| 值 | 用途 | 位置 | BT 树 |
|---|
wuning | 剧情 NPC(剧情内引用,无脚本类) | — | — |
npc1001 | 船夫(silver_mist ↔ village_01 转移) | compoents/npc/npc1001/ | ai/trees/npc1001.tres |
npc2001 | 店主(village_01) | compoents/npc/npc2001/ | ai/trees/npc2001.tres |
npc4001 | 家具店主(silver_mist) | compoents/npc/npc4001/ | ai/trees/npc4001.tres |
npc4002 | 食材种子店主(silver_mist) | compoents/npc/npc4002/ | ai/trees/npc4002.tres |
npc4003 | 武器杂货店主(silver_mist) | compoents/npc/npc4003/ | ai/trees/npc4003.tres |
⚠️ NPC.NPC_TYPE 字典(character.gd 的 to_shop 走 NPC.NPC_TYPE.get(npc))必须含每个 NpcType 值,否则 to_shop 会 push_error。新增 NPC 务必同步。
1. 体力系统(energy)
体力分两种来源,只能追回其中一种:
| 来源 | 行为 | 持久化字段 |
|---|
| 跑步(按住 Shift) | 消耗可自然回复 | energy_current 直接操作 |
| 工具操作(锄地/浇水/砍树/播种/建造) | 消耗永久扣除,不可自然回复 | energy_floor 累加 |
CharacterResource.energy_max = 40、energy_current = 40.0、energy_floor = 0.0
- 跑步回复封顶:
min(energy_max - energy_floor, current + RECOVER_RATE)
- 跑步机制详细参数见
compoents/character/character.gd:9-18(RUN_COST_PER_SEC 等常量)
- 喘气期:体力归零后 2 秒内 Shift 无效(
_panting_timer)
体力归零拦截(2026-06)
energy_current <= 0.0 时禁止所有消耗体力的工具操作(锄地/浇水/砍树),并取消 range 显示。 触发”累瘫”对话提示玩家吃东西/睡觉。
- 判定阈值
<= 0.0:与 character.gd:99 触发喘气期的边界对齐,与 consume_food 满体力守卫风格一致
- 单点拦截:
range_prompt.deal_hold_action() 在调 play_effect 前守卫一次即可,7001/7002/7003 一行不用改
HoldEffectResource.consumes_energy() -> bool:新虚方法,默认 false;锄地(7001)/浇水(7002)/砍树(7003)override 返回 true。种子/建造/剑/食物默认 false,不被拦截
- 未来加新消耗工具(如 hammer / pickaxe):只需 override
consumes_energy() -> true,不用改 range_prompt
range_prompt.update_visible() 加分支:体力 <= 0 且 current_effect.consumes_energy() → visible = false
range_prompt.deal_hold_action() 加守卫:体力 <= 0 且 current_effect.consumes_energy() → return(不播动画、不 add_farmland / to_watering / to_tool / breakable.hit)
- 抛光(hold.gd:update_effect):切工具时若
current_effect.consumes_energy() && current <= 0,不调 show_range,避免切到工具瞬间闪一帧 range
statusinfo._on_consume_energy* 的 if current > 0 守卫保留:作为 last-line defense 防 bug,不依赖它做主拦截(它只能挡”扣能量”,挡不住”动作”)
- 喘气期 vs 工具拦截独立:喘气期只挡 Shift 跑步(
character.gd:138),工具拦截是另一条独立通道
累瘫对话(Alf_stamina_deplete,2026-06)
- timeline:
res://dialogic/timeline/Alf_stamina_deplete.dtl(bubble 风格,两行提示:四肢软软的 + 肚子空空的)
- 触发点:
statusinfo.gd 的 _process 里检测 _last_energy > 0.0 && energy_current <= 0.0 边沿
- 触发模式:从正数跌到 <= 0 那一帧触发一次;之后即使 current 仍 <= 0 也不再触发;要等 current 重新 > 0 后再次跌到 0 才再触发(避免喘气期反复弹)
- 首次帧跳过:
_is_first_frame 守卫避免加载一个 current=0 的存档时误弹
- 调用模式:跟 wuninghouse_cutscene 等剧情脚本一致 —
Dialogic.start() + character.on_dialogue_timeline_started()(设 is_dialogue=true)+ await Dialogic.timeline_ended + character.on_dialogue_timeline_ended()
- 对话期间不能操作:因
is_dialogue=true,character.gd:138 的 direction 被清空(sprint 失效)、distribute_input 的 right_mouse/left_mouse 不触发;玩家看完对话再恢复
能量条 UI(2026-06)
- 旧版:体力 <= 0 时
EnergyInner.visible = false(外框在、内框整个隐藏),玩家看不到”累瘫”状态
- 新版:始终显示
EnergyInner,width 按 current/max 撑开;<= 0 时 modulate = Color(0.55, 0.35, 0.35)(暗红色)提示玩家没力气了
- 仍在
compoents/character/children/attribute/children/statusinfo/statusinfo.gd 里维护
character_resource.gd:energy_floor 默认值坑(2026-06 修复)
历史 bug:structs/character_resource.gd 的 energy_floor 原本默认值是 1(不是 0.0),同时 resources/character_resource.tres 加载时未声明 energy 三个字段 → 全部走默认 → 开局就被 update_stamina 1 秒后 clamp 到 ceiling = max - floor = 40 - 1 = 39。UI 用 current / max = 39/40 = 0.975 显示,玩家看到能量条差一点点不满,以为是 bug。
修复(2026-06):
energy_floor 默认值改成 0.0
resources/character_resource.tres 显式声明 energy_max = 40 / energy_current = 40.0 / energy_floor = 0.0(之前三个字段全靠代码默认,加载顺序敏感)
为什么是 floor 模型设计缺陷:energy_floor 表达”已永久扣除的体力”(AGENTS.md §1),开局应该是 0(玩家没做过任何消耗动作)。默认 1 意味着玩家凭空被扣了 1 点体力——没游戏逻辑支撑。
Web 模式额外注意:WebSaveAdapter.SEED_RUNTIME_PATHS:29 把 character_resource.tres cold-start 拷到 user://runtime_state/ 做镜像,只 seed 一次。改源 .tres 后旧镜像仍是 floor=1,必须调 WebSaveAdapter.reset_runtime_state() 清掉镜像才能让改动生效。
未来加「食物/睡觉恢复体力」时
- 不要直接 +
energy_current
- 调
energy_floor = max(0, energy_floor - N),让「自然回复上限」自动上移
- 这样与现有封顶逻辑一致,UI 不会跳值
- 完整范式(
character.gd:consume_food,2026-06 实现):
energy_floor = max(0, energy_floor - restore_value)
ceiling = energy_max - energy_floor(新封顶)
energy_current = min(ceiling, energy_current + restore_value)(立刻拉到新封顶,给玩家即时反馈)
- 扣 1 个物品,count → 0 时
slot.current.clear() + slot.update_display()
- 不变量:
energy_current ≤ energy_max - energy_floor(即 current ≤ ceiling)。floor 模型任何操作都维持这个
- 满体力判定:
current >= ceiling(不是 current == max)。floor > 0 时 current 永远到不了 max
- 满体力守卫(
consume_food:411-419,2026-06 修复):旧版用 if current >= ceiling: return 在 floor > 0 / current = ceiling 时错误 return(玩家视觉看到能量条 12.5% 觉得”快没电”,右键 pumpkin 却被守卫拦掉,物品也不扣,体验上是”吃东西没反应”)。新版改为预测算法:吃完不能涨 current 就不吃。计算 future_floor / future_ceiling / future_current = min(future_ceiling, current + restore),if future_current <= current: return。涵盖:
floor=0 / current=ceiling=max:吃完 future_current == current → return → 不吃(满 max,没收益)
floor=35 / current=5 / ceiling=5:吃完 future_floor=25 / future_ceiling=15 / future_current=15 > 5 → 不 return → 吃 → current 涨到 15 ✓
floor=40 / current=0 / ceiling=0(累瘫对话期间):吃完 future_floor=30 / future_ceiling=10 / future_current=10 > 0 → 不 return → 吃 → current 涨到 10 ✓
- 注意:对话期间
is_dialogue=true,character.gd:144 的 right_mouse 入口被 !is_dialogue 守卫拦掉,对话期间不能吃东西——必须等 Dialogic.timeline_ended 后才能右键
- 辅助方法:
character.get_energy_ceiling() -> float — 返回当前封顶 max - floor
character.clamp_energy_to_ceiling() — 把 current 强制 ≤ ceiling(防 stale current 溢出)
- 未来加 max 变化时(提升/降低 max 的 buff 或升级):先调
clamp_energy_to_ceiling() 再改 max,否则 max 降后 current 可能 > ceiling 溢出
- 恢复量写在 Inventory 上:
@export var restore_value: int = 0,跟 terrain_id 同款(AGENTS.md §6)。Food/Medicine 类型自己填值,其他类型保持 0
- 入口:手持 Food 类物品按鼠标右键(
right_mouse action),character.gd:distribute_input 调 consume_food()。门禁同 walk:is_interfact_state() && !is_dialogue && !LoadingManager.is_loading && !UtilsManager.is_shop
- food 现状(
resources/inventory/food/*.tres,已填 restore_value):
- apple 5 / carrot 5 / tomato 6 / eggplant 6 / cabbage 8 / wheat 4 / pumpkin 10 / cucumber 7
- 价格 2~4,恢复量 4~10,与价格正相关
各信号能量消耗(statusinfo.gd:_on_consume_energy*)
| 信号 | 触发动作 | 扣 energy | 加 floor |
|---|
hoe_target | 锄地 / 收获 | 1 | 1 |
watering_target | 浇水 | 1 | 1 |
axe_target | 砍树 / 砍建筑 | 1 | 1 |
seed_target | 播种 | 0.5 | 0.5 |
building_target | 建造 | 0.5 | 0.5 |
floor 累加用 min(energy_max, energy_floor + N) 封顶,避免异常突破上限。
2. 剧情状态机:cutscene_trigger 边界
game_resource.cutscene_trigger 是核心剧情状态,目前已知边界:
| trigger | 含义 | 影响 |
|---|
| 0 | 初始/未进入剧情 | 禁用 Shift(见 §3) |
| 1~4 | 剧情引导期 | 禁用 Shift、雨声播放、wuning BGM (Silent Rain Narrative) |
| 5 | incident 视频播放 | 视频中 BGM + 雨声硬停(pause_bgm_immediately / pause_rain_immediately) |
| 6 | leave 对话(玩家在 wuning 出口附近) | 看完对话 → trigger=7 |
| 7 | 玩家出门后 | 进入 SilverMist 时 resume BGM(silver_mist 自己的音乐) |
| >7 | 自由探索 | Shift 启用、正常 BGM |
各 trigger 在哪里被改写(owner map)
| 文件 | 改写 | 时机 |
|---|
wuninghouse_alf.gd:emit_cutscene_trigger() | → 2 | cs_pr_pro00_alfroom AnimationPlayer call_method track 调 |
wuninghouse_cutscene.gd:_on_character_speak01_entered | 2 → 3 | wuning_house_pro00_02 对话结束后 |
wuninghouse_cutscene.gd:_finalize_video_playback | 3 → 4 | incident 视频结束/被 Skip |
wuninghouse_cutscene.gd | 4 → 5 | wuning_house_pro00_04(chest key 在 wuning_e 区域)+ Sunny 动画 + 发布任务 101 |
wuninghouse_alf.gd _process | 5 → 6 | sleep area body_entered + Input.is_action_just_pressed("pick") + 「Sleep」动画播完 + 日期+1/7:00 + load_level(WuningHouseCorridor) |
wuninghouse_cutscene.gd | 6 → 7 | wuning_house_pro00_06(leave area)+ pause_bgm_immediately() |
silver_mist_cutscene.gd:_process | 7 → 8 | 进入 over_area,显示 %Over 结局面板 |
WuningHouseAlf sleep area 流程细节(trigger 5→6)
wuninghouse_alf.gd 有 var in_sleep_area: bool = false
_on_sleep_area_2d_body_entered 仅在 cutscene_trigger == 5 时设为 true + %Sleep_f.visible = true
_process 里 in_sleep_area && cutscene_trigger == 5 && Input.is_action_just_pressed("pick") 触发整段:
character.is_dialogue = true
animation_player.play("Sleep") + await animation_finished
cutscene_trigger = 6
- 日期 +1(month wrap / 12 月 → Winter 重置)
hour = 7 / minute = 0
load_level(WuningHouseCorridor) + 恢复 UI(statusinfo / menu / time_record)
character.is_dialogue = false
⚠️ 改 trigger 边界前必须先查完整剧情流程,不要随便改数字。
3. 角色输入限制
Sprint(Shift 奔跑)
- 文件:
compoents/character/character.gd:97
- 判断:
is_run = Input.is_action_pressed('run') and CutsceneManager.can_sprint() and _panting_timer <= 0.0
- 业务规则集中:
CutsceneManager.can_sprint() → cutscene_trigger > 6
- 改 sprint 启用条件时不要直接在 character.gd 加 if,加到 CutsceneManager
喘气期
- 体力归零 →
_panting_timer = 2.0,期间 Shift 无效
- 喘气期过了就允许再按(不会因为体力低就死锁,因为
is_run=true 后会继续扣体力再触发)
输入动作清单(project.godot 的 [input])
| Action | 物理键 | 用途 |
|---|
| walk_up/down/left/right | W/S/A/D | 八向行走 |
| run | Shift | 奔跑 |
| bag_switch | Tab/B | 开关背包面板(Menu 互斥) |
| select_1..9 | 数字键 1-9 | 手持栏选格 |
| pick | F | 拾起 / 拾取对话 / 睡眠触发 |
| left_mouse | 鼠标左键 | 主操作 |
| right_mouse | 鼠标右键 | 吃手持 Food 类物品(consume_food) |
| dialogue / chest | E | 推进对话 / 开关宝箱 |
| task_switch | X | 开关任务面板 |
| accept | Space | 接受 |
| discard | Q | 丢弃 |
| page_up / page_down | 鼠标侧键 4/5 | 手持栏翻页(带 50ms 冷却) |
| shift_click_transfer | Shift + 鼠标左键 | 背包↔手持↔宝箱快速转移 |
| esc | Esc | 暂停菜单 |
鼠标焦点(MouseFocus)
- 每帧更新:indoor /
InterfaceNode.above / 对话中时隐藏;否则 UtilsManager.transform_position_tile(get_global_mouse_position()) 网格吸附
- 跑 + 点击放树(
is_placing_tree)的 ImGui 入口已废弃(见 §12)
4. 音频管理约定
雨声(CutsceneManager)
_rain_active + _manually_paused 两个状态字段
- 公开方法:
stop_rain() — 10 秒渐变停(自然)
pause_rain_immediately() — 立刻硬停 + 置 _manually_paused=true(不会被 _process 拉回去重启)
resume_rain() — 复位 _manually_paused + 渐入
fade_stop_rain(duration) — 自定义时长渐停
_process 早返:if _manually_paused: return(line 20-21),必须保留
BGM(SoundManager)
- 状态字段:
_bgm_paused(手动暂停标志)、_current_level_name(当前关卡名)、_bgm_session_id(自增 session id)
- 公开方法:
pause_bgm_immediately() — 停 BGM + 置 _bgm_paused=true + 自增 _bgm_session_id(让旧协程退出)
resume_bgm() — 复位 _bgm_paused + 调 play_level_audio_by_name(_current_level_name) 重新播放
play_level_audio_by_name 早返:if _bgm_paused: ... 早返前必须记录 _current_level_name,否则 resume 会播错关卡
- 特殊 BGM:
wuninghouse / wuninghouse_alf / wuninghouse_corridor 三个 level_name 走 _play_silent_rain_narrative()(加载 assets/sound/bgm/Silent Rain Narrative.mp3)
BGM 分发规则(按 level_name)
back_level_audio_list 配置在 managers/sound_manager/sound_manager.tscn,结构:{ audios: [{audio, volume}, ...], level_name: String }
| 场景类型 | BGM 来源 |
|---|
birth | 列表里注册了 birth,播 4 首专属 BGM |
village_01 | 列表里注册了 village_01,播 2 首专属 BGM |
wuninghouse / wuninghouse_alf / wuninghouse_corridor | 走 _play_silent_rain_narrative() 循环 |
| 其他未注册场景(SilverMist / OldPostRd / AdventurerGuildRoom_01 等) | fallback 到 _play_mixed_outdoor_audio_loop():从 village_01 + birth 的 audios 里合并随机选播(6 首) |
- 当前
back_level_audio_list 只配了 birth + village_01 两条 — 大部分场景走 fallback 混合池
- 新增场景专属 BGM:在
.tscn 里照 birth 那条格式加一行 level_name: "<场景名>" + audios 数组即可,无需改 .gd
- 核心循环已抽到
_play_audio_pool_loop(audio_pool: Array, session_id),play_level_audio_loop / _play_mixed_outdoor_audio_loop 都委托给它
BGM 协程生命周期(session id 防 race condition,2026-06)
- bug 场景:旧设计用
is_back_audio_running 全局标志 + pending_level_name 接力机制。协程在 await timer(10-30 秒)期间,新协程启动 → is_back_audio_running 被新协程重设 True → 旧协程醒来不退出 → 两个协程抢同一个 back_audio_player → BGM 一首没播完就停或诡异切换
- fix:每次
play_level_audio_by_name / pause_bgm_immediately / stop_level_audio 自增 _bgm_session_id。协程启动时记录 my_session = _bgm_session_id,每次 await 后检查 _bgm_session_id != session_id 则 return 退出
AudioStreamPlayer.stop() 不 emit finished(Godot 4 已知行为),所以 pause_bgm_immediately 必须自增 session id——否则协程卡在 await back_audio_player.finished 永远不会被唤醒,下次别的协程让 player emit finished 时旧协程也醒来 → race condition
pending_level_name 字段已删除:原本”等当前首播完再切换”的设计已被 session id 机制取代——切关直接走,新协程的淡入淡出会处理切换平滑度
触发点(game.gd load_level 异步加载完成时)
level_loaded.emit() 之后按 LevelType 分发:
LevelType.Birth → task.try_complete_by_signal(&"Reach_Birth")
LevelType.SilverMist && cutscene_trigger == 7 → SoundManager.resume_bgm()
LevelType.WuningHouseAlf && cutscene_trigger == 1 → 播 cs_pr_pro00_alfroom 开场过场
随机音效池
| 动作 | 音效 |
|---|
| 锄地 | hoe_01 ~ hoe_03(3 档随机) |
| 浇水 | watering_01 ~ watering_04(4 档随机) |
| 砍树 | axe_01 ~ axe_04(4 档随机)+ camera.shake |
| 走路(草地) | walk_at_grass_01 ~ walk_at_grass_07(7 档随机,volume -16) |
| 走路(石头/路) | walk_at_stone_01 ~ walk_at_stone_05(5 档随机,volume -10) |
| 宝箱开 | chest_open_01 ~ chest_open_03(3 档随机) |
| 宝箱关 | chest_close_01 ~ chest_close_03(3 档随机) |
5. 任务系统扩展
场景触发完成任务
TaskResource.done_signal: StringName — 任务自己填信号名(如 &"Reach_Birth")
TaskResource.complete() — 外部调,把任务标 done + emit done_triggered
Task.try_complete_by_signal(sig_name) — 遍历任务列表找匹配的,标完成 + 自动 over(调 to_over_task:发奖励 + 移到 over_list)。设计意图:done_signal 触发的任务没有 NPC 交接环节,场景到达 = 完成 + 立即发奖励。不要手动再调一次 to_over_task。
- 新增「场景到达完成任务」的标准做法:
- 在
game.gd level_loaded.emit() 之后加 if level == X: task.try_complete_by_signal(&"Y")
- 在对应
TaskResource.tres 加 done_signal = &"Y"
- 不要写在
_process 里(每帧触发会出问题)
- 如果未来需要「场景到达标完成,但奖励由 NPC 后续交接才发」:那是另一种任务模型,不要走
done_signal,应该用 CollectTaskDetailResource + NPC dialogic signal over_task
Task UI 的两个 signal
Task.detail_closed — Detail 面板(右侧详情)关闭
Task.task_closed — 整个 Task 主面板关闭(包括 Detail + 列表)
- 剧情脚本里要「展示任务给玩家看」就
await task.task_closed(不要 await detail_closed)
触发顺序坑
Task.menu.on_click_item("Task") 调 switch() 是带 0.15s 动画的,不会 await
- 紧接着调
on_click_task_item 选任务时,主面板还没完全显示,要加 await get_tree().create_timer(0.5).timeout 兜底
TaskState enum
enum TaskState { Un_Get, Un_Done, Done, Finish }
Un_Get:未接取(get_task_from_id 返回 null)
Un_Done:已接取,未完成(task.done == false)
Done:完成未交接(task.done == true)
Finish:完成并交接(over_list 里有 id)
任务刷新触发点
attribute.task.refresh_task_state(npc) 在对话开始/结束时调(character.gd:on_dialogue_timeline_started/ended),重新检测 CollectTaskDetailResource 的 has/count
6. 项目结构性坑(已踩过的)
通用
_process 里放「持续条件触发的对话」是反模式 → 玩家在区域里一帧触发一次(前面 leave 对话踩过)。信号回调(_on_*_body_entered)才是「边沿触发」的正确写法
.tres 里 StringName 字段的标准写法:done_signal = &"Name"
- Godot 4 用 UID 跟踪资源,重命名资源文件 UID 不变,引用方零改动
- 找资源引用时必须覆盖 3 种形式:文件路径、UID、字符串/参数里的 ID(DTL 对话文件也得搜)
CanvasLayer 父节点 modulate 不影响子节点
Task.to_add_task(id) 不去重(task.gd:201-204 直接 push_back),重复 add 会让任务列表里出现两个一样的任务。涉及「发完奖再启动 tips」这种二次触发的剧情脚本,必须用 if not get_task_from_id(id): to_add_task(id) 守卫
NPC
- 新建/复制 NPC 子场景必须显式绑
interact = NodePath("Interact"):基类 compoents/npc/npc.tscn 把 interact: Control 声明为 @export,子场景(npc<id>.tscn)的根节点必须带 node_paths=PackedStringArray("interact") + interact = NodePath("Interact") 赋值。漏了之后 BT 首帧跑「不在范围内」序列 → ai/tasks/set_interace_visible.gd:6 执行 npc.interact.visible = visible → interact 是 null → 抛 “Invalid get/set on a null instance”,表现是「一进 level 立刻崩」。schedule_list_resource 是可选的(npc.gd:39 有 if !schedule_list_resource: return 守卫,NPC 站桩不移动时不挂也不会崩)。复制 NPC 时检查 4 件套:scene 根节点 interact 绑了、BT 的 dialogue_name 指向自己的 dtl、dtl 里 [style] 和 npc<id>_speaker: 都换成新 NPC、npc.name 在 silver_mist 实例化时如非 npc<id> 不影响(speaker style 不按 name 注册 character,只有 bubble style 用 npc.name.to_lower() 拼 dch)
NPC.NPC_TYPE 字典必须含每个 NpcType 值:character.gd:to_shop 走 NPC.NPC_TYPE.get(npc),缺失会 push_error。加新 NpcType 时务必同步加映射
Input / 异步
Input.is_action_just_pressed 边沿会被「动画 finished 回调里的 await」接住误触发。例如 page_gift 关闭流程:玩家点 left_mouse 关闭 → tween 150ms → finished 回调里 await 监听输入 → 刚才那次的 left_mouse.just_pressed 状态还没被消费(_process 已跑过那一帧),await 第一帧就命中 → 立刻关掉后续 tips。修法:在新的 await 循环前 await get_tree().process_frame 等一帧,让那一帧的 just_pressed 边沿在 _process 阶段被消费掉
Inventory
InventoryNode.holding_item 是 static dict:跨所有 bag/hold/chest 槽共享当前手持物品;MAX_STACK_SIZE = 64,half-split 是右击一半;DOUBLE_CLICK_INTERVAL = 0.3s
- 背包工具提示延迟 1.2s:太快划过不闪面板,但用了
cancel timer 防 race
- 合并规则:merge same-id stacks → drop 到首个空槽 → 否则 swap 回
holding_item
Typo(保留以兼容旧代码)
BuildingResource 拼写是 colums(不是 columns),ChestResource 也是 colums: int
SeedResource.groth_time_state 拼写是 groth(不是 growth),数组长度 = 帧数 – 1
Level.TileMapLayerContianer 拼写是 Contianer(不是 Container),Level.set_character_postion 也是 typo(不是 position)
Level.load_soild_cell() 拼写是 soild(不是 solid)
FellableBase.highlight_are 拼写是 are(不是 area)
Chest.animtion_player 拼写是 animtion(不是 animation)
Chest.ToOpen / ToClose 大小写不规范(不是 to_open)
ai/tasks/set_interace_visible.gd 拼写是 interace(不是 interface)
wuninghouse_corridor_wuningromm.gd 文件名拼写是 wuningromm(不是 wuningroom)
get_astar_path_to(start, end, callback) 的 callback 参数当前是 dead code,函数体没用
- FellableBase 的 occluder 字段名:
occluder_normal / occluder_stump / occluder_seasonal,季节贴图字段是 collapse_altas_*(typo:altas 不是 atlas)
Inventory / Building
- Building 放置的 terrain id 必须在
Inventory.terrain_id 里手动声明,不要靠 id - 8001 算(levels/level.gd:485-489)。TileSet terrain_set_0 顺序是美术按家具分类硬排的(fence/chest/bed/bonsai/book 在 0~8,book03/bowl/candle/chair/foods/paintings/plates/sofa/toilet/tv 段是另起 id),跟 inventory id 完全脱钩。如果用 id - 8001 算:fence/chest/bed 凑巧对上(0~2),但 paintings07 (id 8066) 会算成 65 → 命中 paintings10 的图;paintings10 (id 8069) → 68 → plates01。每个 building .tres 自己声明 terrain_id = N(对应 used/tileset/building/building.tres 的 terrain_set_0/terrain_N),新增 building 时必须查 TileSet 拿正确的 terrain id 填进去,不能复制 id - 8001
Inventory.terrain_id 缺省值是 -1,只有 add_building 才会用(且只在 < 0 时才兜底回 id - 8001),其他 InventoryType(weapon/material/seed/food/tool/crop)不需要填。Inventory 字段顺序:structs/inventory.gd 里新字段加在最后
Inventory.restore_value 缺省值是 0:只 Food/Medicine 生效,其他类型保持 0;走 floor -= N 模型(详见 §1)
- BuildingResource.BuildingType enum 已废弃(Fence/Chest/Bed 三项),
add_building 实际按 terrain_id 索引而非 enum 引用。新代码不写这个 enum——保留仅为旧逻辑兼容
- silver_mist 场景有 Building TileMapLayer(
silver_mist.tscn:112 [node name="Building" parent="TileMapLayerContianer" index="7"])。Level.building_layer 不为 null,可以正常 add_building / 砍建筑
- 可破坏建筑走 Breakable 通用入口(2026-06 重构):斧子 / 未来其他工具都走
Breakable.hit(tool, at),不要在 Level 上加新的特例方法。每个 level 实例维护 var breakables: Array[Breakable];add_building 时挂 Breakable,load_buildings 时按 cell_to_inventory_id 重建。配置表集中在 Level 顶部两个 const(BREAKABLE_SCRIPT_BY_INVENTORY_ID / BREAKABLE_HITS_NEEDED_BY_INVENTORY_ID)——加新特例改这两张表,不动 inventory 资源。fence_axe_count 字段已废弃,新代码不写(保留仅为旧 .tres 兼容)
- 加新 building 物品务必同步登记
_INVENTORY_ID_TO_FILENAME(levels/level.gd):这是 load_buildings 存档重建 Breakable 的 cell_to_inventory_id → .tres 文件名 反查表。漏登记时 load_buildings 会 push_warning + skip 该 cell——后果是新放能砍、存档重载后就砍不动了(因为没挂 Breakable 子节点,get_breakable_at 找不到)。新 building .tres 加完后务必同步加一行(目前实际 92 项,缺号 8061/8062/8063;后续按需补。这是设计上的 hard-coded 映射——目前没有自动扫目录机制)
tellable 可破坏装饰 tile(2026-06 新增,2026-06 改为持久化):场景中 TileMapLayer 加 metadata/tellable = true(如 Afforest 层),Level._ready 扫描该层所有 tile 挂 BreakableTellable,锄头 1 下擦 tile。已存档(BuildingResource.tellable_broken_cells 按 layer_key 分组存被擦 cell)——reload 时按存档列表反向 erase_cell 还原擦掉状态。只走 Breakable.hit() 通用入口,但只有锄头(_tool == &"hoe")能命中——BreakableTellable.hit() 自身做工具守卫,斧头走 _tool != &"hoe" silent skip
- 美术辅助层约定(visible=false 的 TileMapLayer)(2026-06 修砍完树后不能耕地 bug):silver_mist / birth / village_01 / old_post_rd 四个 outdoor 场景都有一个 Tree2(或 Tree)TileMapLayer 当关卡设计的定位参考层,运行时
visible = false 不渲染。但 tile 数据仍在 —— 之前会阻挡锄地判定(get_current_tile_can_hoe 自顶向下遍历时先命中顶层 tree tile → 非 farmland → return false;表现是「砍完树后那块地不能耕地」)。修复在 levels/level.gd:_get_topdown_layers():遍历时 if not c.visible: continue,所有走这函数的查询(锄地判定、terrain_type 等)都自动跳过美术辅助层。新增 TileMapLayer 时务必遵守:是定位参考 / 装饰 / 调试用的 → 设 visible = false;是真正的游戏内逻辑层 → 留 visible = true。不要给美术辅助层设 visible = true —— 会破坏锄地 / 地形判定
get_breakable_at 不查 tellable(2026-06 重构):原本 get_breakable_at 把 tellable 也算作 breakable 返回,导致斧头在 Afforest 上能 range 出来打掉。重构成 get_breakable_at 跳过 BreakableTellable(保持”building”语义),新增 get_tellable_at(at_position) -> BreakableTellable 给锄头专用。共享扫描逻辑抽到私有 _find_breakable_at(at_position, skip_tellable)
tellable cell = 永远可锄(2026-06,get_current_tile_can_hoe):tellable 元数据的 cell 不管 tile 在不在、TileSet 下层是什么 tile 类型——永远可锄(除非被农田/axe/building 占用)。由 Level.tellable_broken_cells: Dictionary<layer_key, Array<Vector2i>> 记录被擦的 cell(与 BuildingResource.tellable_broken_cells 同构——内存视图 + 持久化共享结构)。layer_key 格式 "<scene_file_path>:<layer_name>",玩家站在这些 cell 上即使 tile 已擦也始终算可锄。设计意图:Afforest 等装饰 tile 擦掉就解锁一块可耕地,绕开 TileSet 配置限制
- .tres 字段缺失陷阱:
sword.tres 和 plates03.tres 没显式声明 type 字段,加载后 type 是 enum 默认 0(Weapon)——这是个潜在 bug,新增 .tres 时务必显式声明 type 字段,不要依赖 enum 默认
Resource / Character
character_resource.gd:energy_floor 默认值必须是 0.0:floor 表达”已永久扣除的体力”,开局应该是 0。不要改回 1 或任何非零值——update_stamina 1 秒后会把 current clamp 到 ceiling = max - floor,非零默认值会让能量条一开局就显示不满。详见 AGENTS.md §1 “character_resource.gd:energy_floor 默认值坑”
.tres 里 Resource 子字段要显式声明,不要全靠代码默认:resources/character_resource.tres 历史上完全没写 energy_max/current/floor 三个字段,加载时全用 character_resource.gd 里的默认。如果默认值改了(比如这次 floor 从 1 改 0),旧 .tres 没有”声明了什么 / 用了默认”的概念,加载顺序敏感容易踩坑。新建/改 .tres 时显式声明关键字段,避免隐性依赖代码默认值
- Web 模式
.tres 改动不会自动同步到 user:// 镜像:WebSaveAdapter.SEED_RUNTIME_PATHS 在 cold-start 把源 .tres 拷贝到 user://runtime_state/,只 seed 一次。改源 .tres 后浏览器里旧的镜像仍存在(值不变)。清镜像:WebSaveAdapter.reset_runtime_state() 或 _seed_runtime_state_if_needed() 检查机制。改 character_resource.gd 字段默认值时尤其注意——浏览器用户跑的就是镜像版
- floor 模型下
current 永远等于 ceiling(除非初始或吃食物让 ceiling 上升的瞬间):每次工具/建造/收获时 current -= N; floor += N,所以 ceiling = max - floor 也同步降 N。任何代码写 if current >= ceiling: return 都会被 floor > 0 时错误触发——视觉上玩家看到 12.5% 觉得”快没电”,守卫却当作”已满 ceiling”拦掉操作。正确守卫:预测 future_current = min(new_ceiling, current + delta),if future_current <= current: return。详见 AGENTS.md §1 “满体力守卫”和”完整范式”。这条是 2026-06 修 consume_food 时挖出来的 floor 模型设计陷阱
7. 关键文件索引
| 主题 | 文件 |
|---|
| 任务资源定义 | structs/task_resource.gd |
| 任务 UI | compoents/character/children/attribute/children/task/task.gd |
| 体力资源 | structs/character_resource.gd |
| 体力 UI / 工具消耗 / 累瘫对话触发 | compoents/character/children/attribute/children/statusinfo/statusinfo.gd |
| 角色输入 + 跑步机制 + 吃食物 | compoents/character/character.gd |
| 雨声 | managers/cutscene_manager/cutscene_manager.gd |
| BGM | managers/sound_manager/sound_manager.gd |
| 剧情 trigger 分发 | scenes/game/game.gd(load_level 内 level_loaded.emit() 之后) |
| wuninghouse cutscene | levels/wuninghouse/wuninghouse_cutscene.gd |
| wuninghouse 灯光闪烁 | levels/wuninghouse/wuninghouse.gd |
| wuninghouse_alf 灯光闪烁 + sleep area | levels/wuninghouse_alf/wuninghouse_alf.gd |
| wuninghouse_corridor 灯光闪烁 + TransferArea 偏移 | levels/wuninghouse_corridor/wuninghouse_corridor.gd |
| 走廊 → wuningroom 单次对话门 | levels/wuninghouse_corridor/wuninghouse_corridor_wuningromm.gd |
| silver_mist 结局面板(trigger 7→8) | levels/silver_mist/silver_mist_cutscene.gd |
| 任务资源文件 | resources/task/*.tres(101 / 1001) |
| 商店资源 | resources/shop/npc{2001,4001,4002,4003}_shop_resource.tres |
| 范围提示(工具 range grid 显示 + 体力归零拦截) | compoents/range_prompt/range_prompt.gd |
工具效果基类(含 consumes_energy() 抽象) | structs/hold_effect_resource.gd |
锄地/浇水/砍树效果(override consumes_energy()=true) | compoents/character/hold_effect/7001.gd / 7002.gd / 7003.gd |
建造效果基类(BuildingHoldEffectResourceBase) | compoents/character/hold_effect/8001.gd |
种子效果基类(SeedHoldEffectResourceBase) | compoents/character/hold_effect/4001.gd |
| 剑攻击效果 | compoents/character/hold_effect/1001.gd |
| 手持栏(含切工具抛光) | compoents/character/children/attribute/children/hold/hold.gd |
| 累瘫对话(体力归零触发) | dialogic/timeline/Alf_stamina_deplete.dtl |
| NPC 1001(船夫)对话 | dialogic/timeline/npc1001_prompt.dtl |
| NPC 2001(店主)对话 | dialogic/timeline/npc2001_prompt.dtl |
| NPC 4001/4002/4003(silver_mist 三店主)对话 | dialogic/timeline/npc{4001,4002,4003}_prompt.dtl |
| WuningHouse 主剧情对话 | dialogic/timeline/wuning_house_pro00_*.dtl |
@tool 编辑器 TileMap 导出工具 | levels/MapCapture.gd |
| 跳官网临时按钮 | scenes/game/canvas_layer.gd |
模块 / 资源 / API 速查(补全)
下面这张表是给新人和 Agent 用的「地图」:每个 autoload、struct、component、level 都能在这里定位。
8. Autoload 总览
注册顺序(project.godot 的 [autoload],按引擎实例化顺序):
| # | 名称 | 来源 | 职责 |
|---|
| 1 | PhantomCameraManager | addons/phantom_camera | 2D 相机管理(addon) |
| 2 | Dialogic | addons/dialogic | 对话系统(addon) |
| 3 | Cursor | addons/awesome_custom_cursor | 自定义光标(addon) |
| 4 | CutsceneManager | managers/cutscene_manager | 剧情状态 / 雨声 / sprint 门禁 |
| 5 | GameManager | managers/game_manager | 全局游戏总控 / quit / save 流程 |
| 6 | SceneManager | managers/scene_manager | Loading 过渡切场景 |
| 7 | PromptManager | managers/prompt_manager | 任务奖励弹窗 start_celebrate |
| 8 | SoundManager | managers/sound_manager | BGM + 动作音效池 |
| 9 | UtilsManager | managers/utils_manager | 坐标/朝向/drop_pickable/start_shop |
| 10 | LoadingManager | managers/loading_manager | ColorRect+AnimationPlayer 加载过渡 |
| 11 | ResourceManager | managers/resource_manager | Resource 加载/保存统一入口 |
| 12 | WebSaveAdapter | managers/web_save_adapter | Web/桌面存档路径适配 + swap_to_runtime_deep |
| 13 | ImGuiRoot | addons/imgui-godot | 编辑器 ImGui 调试(addon) |
9. Manager API 速查
CutsceneManager(剧情状态 + 雨声)
- 信号:无
- 常量:
RAIN_DB=-8.0、RAIN_FADE_OUT_SEC=10.0、RAIN_FADE_IN_SEC=1.0、RAIN_SILENCE_DB=-25.0
- 公开方法:
_process(_delta) — 根据 cutscene_trigger 自动播/停雨声(1~4 播放;5 淡出)
stop_rain() — 主动 10s 渐停
pause_rain_immediately() — 硬停 + _manually_paused=true(incident 视频用)
resume_rain() — 复位 _manually_paused + 渐入
fade_stop_rain(duration = 10.0) — 自定义时长渐停,可 await
can_sprint() -> bool — cutscene_trigger > 6(角色 sprint 门禁)
GameManager(全局总控)
- 信号:
quit
- 导出:
game: Game、setting_resource: SettingResource、archive_resource_list: Array[String]
- 公开方法:
on_quit() — emit quit;save_setting;Web 走 WebSaveAdapter._persist_all,桌面走 _persist_runtime_state + get_tree().quit()
save_to_archives() — 兼容 pause_menu/main 的旧入口,内部走 _persist_runtime_state
get_archive_resource_list() -> Array[String] — 给 _persist_all 用
pause() / resume() — get_tree().paused = true/false
SceneManager(场景切换)
- 信号:
scene_changed(scene: Scene)
- 枚举:
Scene { Main, Game, Prologue }
- 常量:
SCENE_NAME = { Main, Game, Prologue } → 路径
- 公开方法:
switch_scene(scene, center = Vector2(0.5,0.5)) — 内部走 LoadingManager.enter → 异步加载 → add_child → LoadingManager.leave,整个链条 await 在 autoload 内不被截断
PromptManager(奖励弹窗)
- 字段:
is_celebrate: bool(任务弹窗显示期间 true)
- 公开方法:
start_celebrate(label, inventory_list: Array[Dictionary]) — 先 character.add_inventory(item['inventory'], item['count']) 发奖励,再 instantiate Celebrate 节点 → await celebrate.finish → queue_free
SoundManager(BGM + 音效池)
- 枚举:
AudioType(walk 草/石多档、chest 开/关多档、axe/hoe 各档、page_turner、unlocking、rain)、AudioGrade(None + 各 walk/chest 子集)
- 导出:
audio_List: Array[AudioStream]、pool_size: int = 10、back_level_audio_list: Array[Dictionary](按 Game.LevelType)
- 公开方法:
fill_pool() — 初始化 player pool
player_audio(audio_tpye, volume=0.0, from=0.0, grade=None) — 拿空闲 player 播放
stop_grade(grade) — 停掉该 grade 的所有 player
get_free_player() — pool 取空闲,无则 new 一个
play_level_audio(level) — 随机选首 → 旧 1s 淡出 → 新 2s 淡入
stop_level_audio() — CutsceneManager.fade_stop_rain(2.0) + 停 back_audio
pause_bgm_immediately() / resume_bgm() — 见 §4
play_level_audio_loop(level) / play_level_audio_by_name(level_name) — 协程循环播 + 按名特化(wuninghouse 三个走 Silent Rain Narrative)
UtilsManager(杂项)
- 内部类:
ScreenPositionResult { position, canvas_position, is_on_screen }
- 公开方法:
get_screen_position(node) -> ScreenPositionResult — 屏幕归一化坐标(offset -12px)
get_render_size() -> Vector2
transform_position_tile(at) -> Vector2i — 16px 网格吸附
get_direction_from_position(at, target) — 返回 Front/Back/Left/Right
drop_pickable(inventory, count, at_position) — 世界掉落物
start_shop(shop_resource) — await shop.finish 后 queue_free
LoadingManager(加载过渡)
- 公开方法:
enter_force() / leave_force() — 直接设 shader_parameter/progress
enter(center, invent=false, callback) — 播 enter 动画 → await → callback
leave(center, callback) — 播 leave 动画 → await → is_loading=false → callback
ResourceManager(资源加载/保存)
- 公开方法:
load_resource(path) -> Resource — Web 优先查 WebSaveAdapter.runtime_path(path)
load_resource_async(path, callback, process) — 异步 + 进度回调
save_resource(resource, custom_path="") — 递归把 Resource 子属性一并 save
save_to_archive(resource, archive_name, prop_name) — 走 WebSaveAdapter.archive_file_path
save_runtime(resource) — 转发到 WebSaveAdapter.save_runtime
WebSaveAdapter(Web/桌面存档路径适配)
- 常量:
WEB_RUNTIME_ROOT = "user://runtime_state"、DESKTOP_ARCHIVE_ROOT = "res://archives"、SEED_RUNTIME_PATHS(cold-start 镜像清单,31 个 .tres:5 个全局 + 3 个 character 内层 + 8 个关卡外层 + 15 个关卡内层 farmland/fellable_tree/building)
- 字段:
is_web: bool(_ready 时根据 OS.has_feature("web") 决定)
- 公开方法:
runtime_path(original_path) — Web 端 res://xxx.tres → user://runtime_state/<slot>/<basename>
save_runtime(resource) -> Error / load_runtime(path) -> Resource
has_runtime_state() -> bool — 桌面恒 true,Web 看 list_archive_names()
reset_runtime_state() / reset_slot_runtime_state(slot) — Web 端删运行时镜像
archive_root() / archive_dir_path(name) / archive_file_path(name, prop) / open_archive_root()
make_archive_dir(name) -> Error
list_archive_names() -> PackedStringArray
swap_to_runtime(original) -> Resource — 浅 swap(只换外层)
swap_to_runtime_deep(original) -> Resource — 深 swap(递归把 Resource 子属性也切到镜像版)
_persist_all() — quit 时遍历 archive_resource_list 调 save_resource
- Web 端触发器:
_beforeunload / pagehide / visibilitychange / 30s autosave_timer
10. Structs(持久化数据模型)
| 类名 | 关键字段 | 备注 |
|---|
GameResource | level: Game.LevelType、season、month/day/hour/minute、cutscene_trigger | 全局游戏状态(剧情 trigger 主存档) |
CharacterResource | bag: BagResource、hold: HoldResource、current_hold_select、health_max/current、energy_max/current/floor、money | 体力 floor 永久扣除见 §1 |
BagResource | bag_inventory: Array[Dictionary] = [30 个空] | {inventory, count} |
HoldResource | hold_inventory: Array[Dictionary] = [9 个空] | 同 bag 格式 |
Inventory | id, texture, highlight_texture, name, description, type: InventoryType, price, stack, lift, terrain_id=-1, restore_value=0 | InventoryType 千位 ID 见 §0。terrain_id 仅 building 用,restore_value 仅 food/medicine 用 |
TaskResource | id, promupgator: NPC.NpcType, summary, describe, done, detail: Resource, detail_text, reward, done_signal: StringName | 方法 deal_done/pay/complete,信号 done_triggered |
TaskListResource | list: Array[TaskResource]、over_list: Array[int] | 全局任务列表 |
CollectTaskDetailResource | list: Array[{inventory, count, has}] | 方法 deal_done/pay |
SeedResource | sequence_frame: AtlasTexture, groth_time_state: Array[int], fruit: Inventory | 生长帧 + 阶段时长(typo: groth) |
FarmlandExistResource | lands: Array[{inventory, moisture, drown_time, cell, position}] | 持久化农田 |
FellableTreeResource | tree_type, whole/stump/collapse_altas_*(4 季贴图), times, destroy_times, wood | get_whole/stump/collapse_atlas(season) |
FellableTreeExistResource | trees: Array[{fellable_tree, tool_count, position}] | 持久化可砍对象。兼容旧字段 axe_count(fellable_base.gd:_ready 迁移到 tool_count) |
BuildingResource | buildings: Dict<terrain_id, Array[Vector2i]>, chests: Dict<Vector2i, dict>, cell_to_inventory_id: Dict<Vector2i, int>, fence_axe_count: Dictionary, tellable_broken_cells: Dict<layer_key, Array<Vector2i>> | BuildingType enum 已废弃。cell_to_inventory_id 存 Vector2i→inventory.id 用于重建 Breakable;fence_axe_count 已废弃保留兼容;tellable_broken_cells 持久化锄头擦掉的 tellable cell(layer_key = <scene_file_path>:<layer_name>),reload 时按存档反向 erase_cell 还原擦掉状态 |
ChestResource | colums: int = 4(typo)、chest: Array[Dict] | 宝箱物品 |
ShopResource | inventory_list: Array[Inventory], columns = 5 | 商店配置 |
ScheduleResource | type: ScheduleType.Day, start_place, target_place, level: Game.LevelType, hour, end_hour | 子类重写 update_day_schedule/finish |
ScheduleListResource | list: Array[ScheduleResource] | NPC 日程集合 |
HoldEffectResource | false_color, true_color + get_range/update_texture_limit/get_cell_limit/play_effect/has_range_effect/has_oblique_angle/has_origin/consumes_energy | 工具交互范围基类。consumes_energy 默认 false |
InteractKeyboardResource | keyboard_list: Array[{keyboard, label}] | UI 按键提示 |
LevelResource | last_position, farmland_exist_resource, fellable_tree_exist_resource, building_resource | 关卡存档 |
SettingResource | current_existing_index, volume = 0.5 | 全局设置(slot + 音量) |
11. Component API 速查(按目录分组)
compoents/character/(玩家核心)
character.gd — Character extends CharacterBody2D
- 信号:
hoe_target、watering_target、sickle_target、axe_target、seed_target、building_target
- 常量:
RUN_COST_PER_SEC=2.0、RUN_RECOVER_PER_SEC=1.0、RECOVER_DELAY_SEC=1.0、PANTING_DURATION_SEC=2.0、MAX_STACK_SIZE=64
- 枚举:
ActionState{Default, Lift, OneShot}、MovementState{Idle=-1, Walk, Run}、FaceDirection{Front=-1, Back, Left}、OneShotState{Collect, Hoe, Watering, Axe, Place, Sword}
- 关键方法:
is_current_level_indoor()、is_interfact_state()、get_input_values()、distribute_input()、movement()、set_action_state()、set_face_direction()、set_movement_state()、update_face_direction(p1)、update_lift_visible()、update_lights_visibility()、update_stamina(delta)、emit_*_target()、on_pickable_enter/leave(p)、on_pick() / pick_pickable()、add_inventory(inv, count)、find_exist_item_with_space/find_exist_item/find_can_use_item()、on_dialogue_timeline_started/ended、on_dialogue_signal_event(dict)、on_npc1001_dialogue()、to_shop(npc)、to_transfer(level)、to_buy_inventory(inv, count)、selling_inventory(target, count)、consume_food()、get_energy_ceiling()、clamp_energy_to_ceiling()、play_walk_sound() / play_walkatgrass()
_relink_inventory_after_swap():Web 模式 deep swap 后把 BagItem/HoldItem.current 重新指向新 bag/hold 的字典,避免玩家改 current 写到旧 bag
children/interact/interact.gd — Interact extends Control — 拾取提示 KeyboardType {None=-1, Pickable}
children/animation_state/animation_state.gd — AnimationState extends Node2D — 写 BlendTree 参数 + start_one_shot(state) await animation_finished
children/attribute/attribute.gd — Attribute extends CanvasLayer — UI 总枢纽:get_inventory_node(target)、alluishow/alluihide
children/attribute/children/task/task.gd — Task extends InterfaceNode
- 信号:
detail_closed、task_closed
- 枚举:
TaskState{Un_Get, Un_Done, Done, Finish}
- 方法:
save()、draw_list()、switch()、reset()、on_click_task_item(task)、over_task_from_id/get_task_from_id/close_detail/get_task_state/to_add_task/refresh_task_state/to_over_task/try_complete_by_signal
children/attribute/children/statusinfo/statusinfo.gd — StatusInfo extends InterfaceNode
- 监听:
hoe_target/watering_target/axe_target/seed_target/building_target(详见 §1 末尾表)
_process 维护 GoldCount / HealthBar / EnergyBar + 边沿触发累瘫对话
children/attribute/children/bag/bag.gd — Bag extends InterfaceNode
switch()、handle_holding_item_on_close()(drop-back 逻辑:原槽 → 首个空槽 → spawn_pickable)、spawn_pickable(inv, count)、reset()、set_current_inventory(inv)
children/attribute/children/hold/hold.gd — Hold extends InterfaceNode
- 字段:
current_effect: HoldEffectResource、allow_click_select: bool、allow_scroll: bool、_scroll_cooldown、SCROLL_COOLDOWN_MS=50
update_current_select(index)、update_lift()(lift_sprite 缩放到 12px)、update_effect()(instantiate HoldEffectResource,含切工具抛光)、get_current_select()、on_input(event)(1-9 / page_up/down 带 50ms 冷却)
children/attribute/children/closet/closet.gd — Closet extends InterfaceNode — switch() 与 Bag 互斥
children/attribute/children/menu/menu.gd — Menu extends InterfaceNode
on_click_item(item) — Bag/Task 互斥(带 0.15s 延迟),Shop.is_opened 时 bail
children/attribute/children/task/children/task_item/task_item.gd — TaskItem extends InterfaceNode — update_state()、on_left_click() → task.on_click_task_item
children/attribute/children/task/children/collect_item/collect_item.gd — CollectItem extends InventoryNode — 显示 has/count
children/attribute/children/bag/children/bag_item/bag_item.gd — BagItem extends InventoryNode — hover → bag.set_current_inventory
children/attribute/children/hold/children/hold_item/hold_item.gd — HoldItem extends InventoryNode — on_left_click 禁用,on_inventory_click/right_click 受 hold.allow_click_select 控制
children/attribute/children/menu/children/menu_item/menu_item.gd — MenuItem extends InterfaceNode — hover 切换 outline_color
hold_effect/ 完整列表(每个 = 一个 HoldEffectResource 子类)
| ID 段 | 文件 | class_name / extends | 行为 |
|---|
| 1001 | 1001.gd | extends HoldEffectResource | 剑攻击(无 range gate,任意位置 OneShotState.Sword) |
| 4001 | 4001.gd | class_name SeedHoldEffectResourceBase | 种子基类(emit_seed_target) |
| 4002~4007 | 4002.gd ~ 4007.gd | extends SeedHoldEffectResourceBase | 7 个种子物品全部复用 4001 基类 |
| 7001 | 7001.gd | extends HoldEffectResource | 锄头(开垦/收割成熟作物/擦 Afforest tellable)→ emit_hoe_target,consumes_energy()=true |
| 7002 | 7002.gd | extends HoldEffectResource | 洒水壶 → emit_watering_target,consumes_energy()=true |
| 7003 | 7003.gd | extends HoldEffectResource | 斧头(砍树/砍栅栏)→ emit_axe_target + camera.shake,consumes_energy()=true |
| 8001 | 8001.gd | class_name BuildingHoldEffectResourceBase | 建造基类(emit_building_target,call add_building) |
| 8002~8024 | 8002.gd ~ 8024.gd | extends BuildingHoldEffectResourceBase | 23 个 building 物品全部复用 8001 基类 |
| 8025~8055 | 不存在 | — | id 段空缺,没有对应 .tres 也没有 hold_effect 脚本 |
| 8056~8095 | 8056.gd ~ 8095.gd | extends BuildingHoldEffectResourceBase | 40 个 building 物品全部复用 8001 基类 |
全部 94 个 extends BuildingHoldEffectResourceBase 的脚本只是单行 extends(39 bytes),所有行为继承自 8001.gd。新加 building .tres 时不需要新写 hold_effect,系统会按 id 自动 instantiate。
compoents/npc/(NPC 行为树宿主)
npc.gd — NPC extends Node2D
- 信号:
move_finish
- 枚举:
NpcType { wuning, npc1001, npc2001, npc4001, npc4002, npc4003 }
- 常量:
NPC_TYPE = { npc1001, npc2001, npc4001, npc4002, npc4003 }(必须包含每个 NpcType 值,否则 to_shop push_error)
- 导出:
machine: BTPlayer、graphics: Node2D、animation_sprite2d: AnimatedSprite2D、interact: Control、schedule_list_resource: ScheduleListResource(可选)
- 方法:
save()(保存 schedule)、to_update_schedule()、_on_character_entered/exited
- Web 模式:
_ready 把 schedule_list_resource 走 deep swap
- 6 个子类(全部空壳
extends NPC):
npc/npc1001/npc1001.gd — 船夫
npc/npc2001/npc2001.gd — village_01 店主
npc/npc4001/npc4001.gd — silver_mist 家具店主
npc/npc4002/npc4002.gd — silver_mist 食材种子店主
npc/npc4003/npc4003.gd — silver_mist 武器杂货店主
- 每个 NPC 子场景(
.tscn)四件套:.gd + _spriteframes.tres + 主场景 npc.tscn 引用 + 自己的 BT tree
compoents/farmland/farmland.gd
Farmland extends Node2D
- 关键方法:
on_time_update()(moisture -= 0.01 + 仅当 moisture > 0.5 时 growth_time++,与 wet 视觉阈值对齐)、to_saw(inventory_node)(播种)、set_sf()(按 groth_time_state 切帧)、get_frame_from_sequence(at)、to_watering()、set_moisture()(wet.visible = moisture > 0.5)、to_sickle()(收获:成熟 2x seed + 3x fruit,半熟 1x seed,<50% 1x seed)
- 生长-浇水联动(2026-06):
moisture > 0.5 才让 growth_time++,所见即所得——wet 视觉对应「在长」。不浇水 ~50 分钟后(moisture 1.0→0.5)生长暂停,再浇水恢复
compoents/fellable_tree/
fellable_base.gd — FellableBase extends Node2D
- 方法:
_on_level_loaded()、_on_time_update()(季节变更刷帧)、set_frame_from_sequence(season=null)(whole ↔ stump)、update_occluder(season=null)、update_highlight_area()(stump 后 modulate.a=1.0 并 queue_free HighlightArea)、to_tool()(”rock” 动画 + 累加 tool_count + 到 times 掉 base_drop_count wood,到 destroy_times 掉 1 wood 并 remove_fellable_tree)
- 枚举:
FellableType { FELLABLE_TREE, FELLABLE_MINE }
- 字段兼容:
_ready 里 if current.has("axe_count") and not current.has("tool_count"): 迁移到 tool_count
fellable_tree_small.gd — base_drop_count = 1,to_axe() → to_tool()(to_axe() 是 deprecated alias,7003.gd 现在调 to_tool(),对齐 Breakable.hit() 抽象)
fellable_tree_medium.gd — base_drop_count = 2
fellable_tree_large.gd — base_drop_count = 3
compoents/fellable_house/
house.gd — House extends Node2D(空壳)
adventurer_guild/adventurer_guild_house_01.gd — AdventurerGuildHouse01 extends House(空壳)
compoents/chest/
chest.gd — Chest extends Node2D
- 方法:
save()(is_dynamic 时跳过)、to_get_resource()、to_load_items()、ToOpen()(layer 可见 + 随机 SFX + tween fade-in + 动画 + hold.allow_click_select=true)、ToClose()(对称)、_on_character_entered/exited(退出自动关)
- 字段:
current_cell: Vector2i(2026-06 新增,add_chest / load_buildings 时写入;Level 监听 BreakableChest.broken 时按 cell 找对应 Chest 子节点 free)
children/chest_item/chest_item.gd — ChestItem extends InventoryNode(空壳)
compoents/breakable/(2026-06 新增:可破坏建筑通用抽象)
breakable.gd — Breakable extends Node2D 通用基类
- 字段:
current: Dictionary(含 cell / layer / inventory / hits_needed / tool_count / drops / drops_count)
- 信号:
broken(彻底破了)、progress_changed(tool_count 变,可挂进度条 UI)
- 方法:
hit(_tool: StringName, drop_position: Vector2) — 通用入口;累加 tool_count → 达 hits_needed 时 _on_pre_destroy() + drop_pickable + emit broken + queue_free
- 默认行为:
hits_needed=1、drops=自身 inventory、drops_count=1、_on_pre_destroy 空操作
breakable_fence.gd — BreakableFence extends Breakable(空子类占位,未来 fence 加特殊逻辑时 override _on_pre_destroy)
breakable_chest.gd — BreakableChest extends Breakable
- 字段:
chest_resource_ref: Dictionary(引用 level_resource.building_resource.chests[cell])
- override
_on_pre_destroy():先把内部物品全部掉到地上
breakable_bed.gd — BreakableBed extends Breakable(空子类占位)
breakable_tellable.gd — BreakableTellable extends Breakable(2026-06 新增)
- 用于带
metadata/tellable = true 的 TileMapLayer(当前是 Afforest)的 tile
- override 整个 hit() 而非 _on_pre_destroy——基类 hit() 会在 hits_needed 达成时调
UtilsManager.drop_pickable(inventory, ...),tellable 没 inventory 必崩
- 工具守卫:
hit(_tool, _drop) 第一行 if _tool != &"hoe": return——只有锄头能擦。其他工具 silent skip
- 命中后只
layer.erase_cell(cell),不 drop 任何物品、不写存档
- 临时机制,玩家 reload 后 tile 从场景恢复(Level._ready 时重新扫描挂回)
- 95 个其他 inventory 都走默认基类——不要为每个 inventory 新建 .gd
- 配置表集中在
Level 顶部 const:BREAKABLE_SCRIPT_BY_INVENTORY_ID(inventory.id → 子类脚本路径)、BREAKABLE_HITS_NEEDED_BY_INVENTORY_ID(inventory.id → 几下碎,默认 1)、_INVENTORY_ID_TO_FILENAME(inventory.id → .tres 文件名,92 项 仅 load_buildings 用)
- 新工具接入(如未来加 hammer):override
Breakable.hit() 里 _tool 分支,或 override can_break_with(tool) -> bool。7003.gd 不用动
compoents/camera/camera.gd
Camera extends Node2D
- 常量:
ZOOM_INDOOR = 2.0、ZOOM_OUTDOOR = 1.8
- 方法:
set_limit()(从 current_level_instance.get_bound())、set_follow_target(character)、shake(amplitude=2.0, frequency=3.0, duration=0.1)、_check_indoor_and_set_zoom()
compoents/mouse_focus/mouse_focus.gd
MouseFocus extends Node2D
- 每帧:indoor /
InterfaceNode.above / 对话中时隐藏;否则 UtilsManager.transform_position_tile(get_global_mouse_position()) 网格吸附
compoents/range_prompt/range_prompt.gd
RangePrompt extends Node2D
- 方法:
update_visible()(OneShot / 对话 / 无 current_effect 时隐藏;体力<=0 且 consumes_energy() 也隐藏)、distribute_input()、on_left_click() / deal_hold_action()(按 has_range_effect() 分支,含体力守卫)、show_range(range_count)(2n+1 网格)、set_shader_texture()、update_position(force=false)
compoents/highlight_area/highlight_area.gd
HighlightArea extends Area2D(实际类名带 typo highlight_are 字段在 fellable_base 里引用)
_on_character_entered/exited → tween 父 modulate.a 0.3/1.0 + 玩家 sprite shader outline_color
compoents/transfer_area/transfer_area.gd
TransferArea extends Area2D
- 字段:
target_level: Game.LevelType、is_enter: bool、has_finish: bool、timer: Timer
- 方法:
_on_character_entered/exited(退出停 timer + 清 has_finish)、_process(delta)(player inside + 非零方向才 tick)、_on_timer_timeout() → GameManager.game.load_level(target_level)
compoents/pickable/pickable.gd
Pickable extends Area2D
- 方法:
set_size()(12px tile)、play_animation()(0.4s 弹跳 + 垂直 sine arc)、_on_character_entered/exited(维护 character.pickable_list)
compoents/global_light/global_light.gd
GlobalLight extends Node2D
- 节点:
canvas_modulate: CanvasModulate、directional_light: DirectionalLight2D、shadow_polygon: Polygon2D
- 常量(时间带):
DAWN_START=4, DAWN_END=7, MORNING_START=7, MORNING_END=9, DAY_START=9, DAY_END=16, SUNSET_START=16, SUNSET_END=19, DUSK_START=19, DUSK_END=21, NIGHT_START=21, NIGHT_END=4
- 角度常量:
MORNING_3AM_ROTATION=-90°、NOON_ROTATION=-180°、EVENING_7PM_ROTATION=-270°、MIDNIGHT_ROTATION=-360°
- 颜色/能量 export:
COLOR_DAWN/DAY/SUNSET/NIGHT (HSV)、ENERGY_DAWN=0.1/DAY=0.2/SUNSET=0.2/NIGHT=0.0
- 方法:
_physics_process(跟随角色)、_on_time_update()(非 indoor 才更新)、_check_indoor_status()(hide canvas_modulate + directional_light + shadow_polygon)、update_light_color(hour)(分段线性插值 + max_dist 写 shadow shader)、ease_in_out/is_between/interpolate_color
compoents/pause_menu/pause_menu.gd
PauseMenu extends CanvasLayer
- 方法:
on_click_pause()(淡入 + GameManager.pause())、_on_back_to_game_pressed()(淡出 + resume)、_on_save_game_pressed()(quit.emit() + save_to_archives())、_on_setting_pressed()(占位)、_on_back_to_main_menu_pressed()(resume + 停 level audio + quit + save + SceneManager.switch_scene(Main))
compoents/inventory_node/inventory_node.gd
InventoryNode extends InterfaceNode(所有 bag/hold/chest 槽的基类)
- 常量:
MAX_STACK_SIZE = 64、DOUBLE_CLICK_INTERVAL = 0.3
- 静态字段:
holding_item: Dictionary(mode = "left_click"|"right_split"、source、center)、_drag_placed_slots、_last_click_time、_last_click_slot
- 方法:
static drop_holding_item()、_gui_input(event)(处理 Shift+左键转移 / 单击 / 右击 / 双击)、on_shift_click_transfer()、find_open_chest()、transfer_from_bag_to_chest/from_hold_to_chest/from_bag_to_hold/from_hold_to_bag、on_inventory_click(mouse_pos)、merge_all_same_items()、on_inventory_double_click()、on_inventory_right_click()(半切)、on_inventory_drag_over()、update_display()、on_mouse_enter/exit/move、get_popup_position()、place_item()、static create_preview_from_data/close_preview/update_preview()
- 合并规则:merge same-id stacks → drop 到首个空槽 → 否则 swap 回
holding_item
compoents/inventory_preview/inventory_preview.gd
InventoryPreview extends CanvasLayer — 跟随鼠标的浮动槽,每帧更新 inventory_node.center
compoents/inventory_info_popup/inventory_info_popup.gd
InventoryInfoPopup extends CanvasLayer
- 方法:
set_current_inventory(target)、show_popup()(1.2s 延迟 + 淡入)、hide_popup()(取消 timer + 淡出)、update_popup(at)(clamp 到 viewport)
compoents/time_recorder/time_record.gd
TimeRecord extends Node2D
- 信号:
new_day、new_year、time_update、day(06:00)、night(21:00)
- 枚举:
Season { Spring, Summer, Fall, Winter }
- 常量:
MONTH_DAYS(2 月=29 天)、SEASONS(月→季节)
- 方法:
handle_time()(level load 时同步 season)、_on_timer_update()(minute++ + 级联)、update_pointer_rotation()(写 label + 边界 emit)、reset_to_january_first()(年 wrap emit new_year)、uishow()/uihide()
12. Levels / Scenes API 速查
levels/level.gd(关卡基类,所有 Level 子类继承)
Level extends Node2D
- 枚举:
LevelLocation { outdoor, indoor }
- 导出:
level_resource: LevelResource、farmland_layer: TileMapLayer、location = outdoor、building_layer: TileMapLayer
- 状态变量:
farmlands: Array[Farmland]、fellable_trees: Array[Node2D]、breakables: Array[Breakable]、tellable_broken_cells: Dictionary<layer_key, Array<Vector2i>>、astar := AStarGrid2D.new()
- 方法:
save() — last_position + ResourceManager.save_resource
load_astar() / load_soild_cell()(typo) / mark_static_bodies_as_solid() / mark_circle_area_as_solid / mark_rectangle_area_as_solid
get_used_rect()(排除 meta("bound_exclude"))、get_all_tile_map_layers()(遍历 TileMapLayerContianer)、get_bound()(合并 layers 的 world-pixel Rect2i)
set_character_postion()(typo)— 放在 last_position 或 $OriginPoint
- tile 查询:
get_current_tile_can_hoe / can_sickle / can_axe / can_sow / can_building / has_building、get_current_has_farmland、get_tellable_at(at)
- 可破坏建筑(2026-06 新机制):
get_breakable_at(at) -> Breakable / damage_breakable_at(at, tool) —— axe 和未来所有工具走 Breakable.hit(tool, at) 统一入口。axe_fence / get_current_tile_can_axe_fence 已废弃(保留签名只防”unknown method”误报)
get_terrain_type(at) → “grass”/”stone”/”road”/”dirt”/”unknown”
- 农田:
add_farmland / load_exist_farmland / get_farmland_from_position / get_all_farmland_cells
- 可砍对象:
load_exist_fellable_tree(按 tree_type instantiate)/ get_fellable_tree_from_position / remove_fellable_tree
- 寻路:
get_astar_path_to(start, end, callback) 返回 Array[Vector2] cell path(callback 当前是 dead code)
- 建造:
add_building / add_chest / create_empty_chest / load_buildings(add_building 同步挂 Breakable;load_buildings 遍历 cell_to_inventory_id 重建)
- tellable 持久化(2026-06):
load_tellables / _attach_tellable / _on_tellable_broken——扫描带 metadata/tellable 的 TileMapLayer 挂 BreakableTellable;锄头 1 下擦 tile + 写存档(BuildingResource.tellable_broken_cells[layer_key])。reload 时按存档反向 erase_cell 还原擦掉状态
tellable_broken_cells: Dictionary<layer_key, Array<Vector2i>>(2026-06):被锄头擦掉的 tellable cell 列表,按 layer 标识(<scene_file_path>:<layer_name>)分组;Level 内存视图与 BuildingResource 持久化字段同构。get_current_tile_can_hoe 按当前 layer 查对应 list 判断「曾被擦的 tellable cell」——玩家站在这些 cell 上永远可锄,绕开 TileSet 配置限制。Web 旧镜像兼容:缺失此字段 → 加载为 {} → 行为 = “没擦过任何 tile”(用户已擦的 tile 首次 reload 会复活一次,再擦就持久化生效)
- 锄地 + tellable 联动(2026-06,
get_current_tile_can_hoe):tellable cell 永远可锄——第一优先遍历所有 metadata/tellable = true 的 TileMapLayer:
- 玩家位置的 cell 上 tellable tile 还在 → 可锄
- 玩家位置的 cell 上 tellable tile 已被砍、且在
tellable_broken_cells 列表里 → 可锄
- 否则走原始 farmland 类型判断(TileSet custom_data
farmland = true)
- 设计意图:Afforest 等装饰 tile 砍掉就解锁一块可耕地,不依赖 TileSet 下层 tile 是 farmland
- 可破坏查询:
get_breakable_at(at_position) 走 Breakable(不假设 building_layer,按 current.layer + current.cell 反查,跳过 BreakableTellable——tellable 是锄头专用通道,由 get_tellable_at 独立查);跳过 tile 已被 erase 的 breakable(直接查 layer.get_cell_source_id(cell) == -1)——比 is_queued_for_deletion() 更精确(queue_free 窗口期 + tile 状态是真相之源)
- Breakable 私有:
_attach_breakable / _create_breakable_for / _on_breakable_broken / _on_breakable_tree_exiting / _on_tellable_broken
- 常量:
BREAKABLE_SCRIPT_BY_INVENTORY_ID(inventory.id → .gd 路径,4 项特例:8001 fence / 8002 chest / 8003-8004 bed)、BREAKABLE_HITS_NEEDED_BY_INVENTORY_ID(inventory.id → 几下碎,3 项特例:8002=3 / 8003-8004=2)、_INVENTORY_ID_TO_FILENAME(inventory.id → .tres 文件名,92 项 仅 load_buildings 重建用)
levels/ 子类一览
| 类 | 文件 | 关键扩展 |
|---|
WuningHouse | wuninghouse/wuninghouse.gd | PointLight2D flicker 系统(sin + jitter + 偶发 spike),TransferArea 偏移(trigger 1~5 时 y=30),$VideoStreamPlayer/$VideoStreamPlayer2 显隐 |
WuningHouseAlf | wuninghouse_alf/wuninghouse_alf.gd | 同款 flicker;Sleep 区域按 pick 推进一日 + trigger 5→6 + load_level(WuningHouseCorridor)(流程见 §2);AnimationPlayer cs_pr_pro00_alfroom 触发开场过场;CutsceneUiShow/Hide、playalfspeak、emit_cutscene_trigger |
WuningHouseCorridor | wuninghouse_corridor/wuninghouse_corridor.gd | 同款 flicker;trigger 5 时 $TransferArea.x = 30 |
WuningHouseCorridorWuningroom | wuninghouse_corridor/wuninghouse_corridor_wuningromm.gd | extends TransferArea,把传送替换成单次对话门(wuninghouse_corridor_wuningroom.dtl) |
SilverMist | silver_mist/silver_mist.gd | 空壳。场景实例化 npc4001/4002/4003(silver_mist 三店主) |
SilverMistCutscene | silver_mist/silver_mist_cutscene.gd | over_area → 显示 %Over 结局面板 → trigger 7→8 |
Village01 | village_01/village_01.gd | 空壳。场景实例化 npc1001(船夫)+ npc2001(店主) |
OldPostRd | old_post_rd/old_post_rd.gd | 空壳 |
AdventurerGuildRoom01 | adventurer_guild_room_01/adventurer_guild_room_01.gd | 空壳 |
Birth | birth/birth.gd | 空壳 |
WuningHouseCutscene | wuninghouse/wuninghouse_cutscene.gd | 主剧情驱动:trigger 2→3→4→5、6→7、page 拾取、incident 视频、_populate_gift_container/_grant_gift_rewards、_await_any_action |
WuningHouseAlf.OpenCloset | wuninghouse_alf/opencloset.gd | 衣柜触发器(按 chest 键开关) |
GiftResource | wuninghouse/gift_resource.gd | Page_gift 奖励 bundle(reward: Array[Dict]) |
scenes/main/main.gd(主菜单)
Main extends Node2D
- 枚举:
MainType { NewGame, Continue, LoadGame, Setting, About, Quit }
- 导出:
video: Node2D(背景视差)、mouse_sensitivity=0.15、max_offset_x=20、max_offset_y=12、follow_speed=0.01、ExistingList: Control、continue_button、load_game_button、existing_buttons: Array[MainButton]
- 方法:
_process(delta)(视差)、_ready()(无存档时禁用 Continue/LoadGame)、_generate_archive_name()(<seq>_MMDD_HHMM)、_on_new_game_pressed()(生成 slot + desktop 拷贝原始 .tres 到 archive)、_on_continue_pressed()(选最新 slot)、_on_load_game_pressed()(显示存档列表)、_on_back_pressed()(GameMain↔ExistingList)、to_load_existing(index, _new_game=false)、_on_quit_pressed()
- 调用的 autoload:
GameManager、SceneManager、ResourceManager、WebSaveAdapter
scenes/game/game.gd(主游戏循环)
Game extends Node2D
- 枚举:
LevelType { Birth, Village_01, AdventurerGuildRoom_01, SilverMist, OldPostRd, WuningHouse, WuningHouseAlf, WuningHouseCorridor }
- 常量:
LEVEL_TYPE: Dict[LevelType → "<folder>/<name>"](键是小写字符串,如 "wuninghouse_alf",不是 "WuningHouseAlf")
- 导出:
camera: Camera、character: Character、inventory_info_popup、time_record、game_resource: GameResource、global_light、mouse_focus、range_prompt、pause_menu
- 信号:
level_loaded(emit 时机:level add_child + character position + camera set_limit/set_follow + audio start + 一帧 await 之后)
- 状态变量:
is_placing_tree、current_tree_type、current_level_instance: Level
- 核心方法:
load_level(level: LevelType)、switch_season(season, use_loading=true)(更新 game_resource + 切 meta(“seasonal”)=true 的 TileMapLayer tile_set + set_frame_from_sequence)
- ImGui 调试面板已废弃(2026-06 全部代码被
# 注释):包括 Season switch / Pickable spawner / fellable_tree placement / Level Transfer / Hour setter / Cutscene trigger / dialogue tester。_input 的 WantCaptureMouse 守卫 + is_placing_tree 放置树逻辑也都在注释块里。不要恢复这些功能,除非有真实需求
load_level 内 LevelType 分发(在 level_loaded.emit() 之后):
LevelType.Birth → task.try_complete_by_signal(&"Reach_Birth")
LevelType.SilverMist && cutscene_trigger == 7 → SoundManager.resume_bgm()
LevelType.WuningHouseAlf && cutscene_trigger == 1 → animation_player.play("cs_pr_pro00_alfroom")(从 Cutscene/AnimationPlayer 节点取)
scenes/game/canvas_layer.gd(临时按钮)
extends CanvasLayer
const URL = "https://alffarmtale.top/"
- 子节点
TextureRect(Ver 按钮)点击 → 跳官网
- Web 用
JavaScriptBridge.eval("window.open(...)"),桌面用 OS.shell_open()
- 同时被 game.tscn 和 main.tscn 引用:两个场景的 CanvasLayer 节点都挂这个脚本,子节点 TextureRect(main 里位置/贴图一样,但用户加了不同 outline shader)。脚本是通用的,不需要每个场景各自复制
- 未来删除时把 game.tscn + main.tscn 里 CanvasLayer 下的 Ver/TextureRect 子树和这个脚本一起删掉(脚本里有注释提醒)
levels/MapCapture.gd(@tool 编辑器工具)
@tool extends ReferenceRect
- 导出 TileMapLayer 整层为 PNG/JPG(保持原始像素)
- 工具按钮:
1. Setup Capture Area / 2. Export Original Pixels
- 不是关卡类型——是辅助美术的编辑器工具,不在 LEVEL_TYPE / LevelType enum 里
13. 资源目录速查(Catalog)
物品(按 InventoryType 分组,以实际 .tres 数据为准)
Weapon 1000(1 个)
Material 3000(实际无 .tres)
- 该 ID 段实际没有 inventory 数据
- wood.tres 在
medicine/ 目录,type=5(Medicine),id=3001(详见下条 bug)
⚠️ resources/inventory/medicine/wood.tres 数据不一致 bug
- 文件路径:
medicine/(与 type 不一致)
- id:
3001(Material 段,与 type 不一致)
- type:
5(Medicine = 6000 段)
- name:木材
- description:原木原料,制作必备
- price:3 / stack:true
- 修复建议:把文件 mv 到
medicine/ 保留(路径对),改 id 为 6001 + name 不变。所有引用方用 ResourceManager.load_resource("res://resources/inventory/medicine/wood.tres") 走路径,路径不变 id 怎么改都能加载——这是 Uid/路径解耦的好处。task 1001 的 detail list 里用 &"res://resources/inventory/medicine/wood.tres" 引用所以路径改不得。
Seed 4000(7 个,生长阶段 [10,20,30] 秒)
| Seed ID | 名称 | 果实 ID |
|---|
| 4001 | 胡萝卜种子 | 5001 carrot |
| 4002 | 番茄种子 | 5002 tomato |
| 4003 | 茄子种子 | 5003 eggplant |
| 4004 | 卷心菜种子 | 5004 cabbage |
| 4005 | 小麦种子 | 5005 wheat |
| 4006 | 南瓜种子 | 5006 pumpkin |
| 4007 | 小黄瓜种子 | 5007 cucumber |
Food 5000(8 个)
| ID | 名称 | 价格 | restore_value |
|---|
| 5000 | apple(苹果) | 2 | 5 |
| 5001 | carrot(胡萝卜) | 3 | 5 |
| 5002 | tomato(番茄) | 4 | 6 |
| 5003 | eggplant(茄子) | 4 | 6 |
| 5004 | cabbage(卷心菜) | 3 | 8 |
| 5005 | wheat(小麦) | 2 | 4 |
| 5006 | pumpkin(南瓜) | 4 | 10 |
| 5007 | cucumber(小黄瓜) | 4 | 7 |
价格 2~4,恢复量 4~10,与价格正相关。
Medicine 6000(实际只有 1 个错位的 wood)
Tool 7000(3 个)
| ID | 名称 | 价格 | stack | lift |
|---|
| 7001 | hoe(锄头) | 35 | false | false |
| 7002 | sprinkler(洒水壶) | 30 | false | false |
| 7003 | axe(斧头) | 60 | false | false |
Crop 8000(实际无 .tres)
- enum 注释说”作物 8000″,但实际所有 id=8001~8095 的 .tres 都是 type=8 (Building)
- Crop 类型当前没有任何物品
Building 8000 段(实际 92 个 .tres,type=8)
按子类别分组(不全完整,仅列主要):
| 子类别 | id 段 | 数量 |
|---|
| fence / chest | 8001~8002 | 2 |
| bed | 8003~8004 | 2 |
| bonsai | 8005~8007 | 3 |
| book | 8008~8011 | 4 |
| bowl / candle / plate / pork / knife / clock / chinese_cabbage | 8012~8013, 8071, 8075, 8056, 8020, 8019 | 7 |
| chair | 8014~8018 | 5 |
| closet | 8021~8023 | 3 |
| foods(盘子装食物) | 8024~8055 | 32 |
| paintings | 8057~8060, 8064~8070 | 11(8061~8063 缺号) |
| plates | 8072~8074 | 3 |
| seasoner(调味罐) | 8076~8082 | 7 |
| sofa | 8083~8088 | 6 |
| table | 8089 | 1 |
| toilet | 8090~8093 | 4 |
| tv | 8094~8095 | 2 |
总计 92 项(缺号 8061/8062/8063)。Building .tres 的 terrain_id 字段必须手动查 used/tileset/building/building.tres 填正确值,不要复制 id - 8001(详见 §6)。
任务(TaskResource)
| ID | summary | 触发方式 | 奖励 |
|---|
| 101 | 海鸥岛旧农场代管 | done_signal = &"Reach_Birth"(到达 Birth 关触发) | 10× cabbage_seed |
| 1001 | 船夫的请求 | 对话内信号 over_task(收集 1 axe + 20 wood) | 4× carrot_seed + 4× tomato_seed |
可砍对象(FellableTreeResource)
| 大小 | times(树桩)/ destroy_times(消失) | 每次掉落 |
|---|
| small | 3 / 4 | 1 wood |
| medium | 4 / 6 | 2 wood |
| large | 6 / 9 | 3 wood |
商店(ShopResource)— 共 4 个,按店主分类
| 文件 | 店主 NPC | 内容 | 备注 |
|---|
npc2001_shop_resource.tres | npc2001(village_01) | axe, hoe, sprinkler, sword, carrot, carrot_seed | 通用工具店。columns=5 |
npc4001_shop_resource.tres | npc4001(silver_mist) | 全部 92 个 building 物品 | 家具店。columns=5 |
npc4002_shop_resource.tres | npc4002(silver_mist) | 8 Food + 7 Seed | 食材种子店。columns=5 |
npc4003_shop_resource.tres | npc4003(silver_mist) | sword + wood + axe/hoe/sprinkler | 武器杂货店。columns=5 |
卖价统一 = max(int(price * 0.8), 1)(shop_item 卖出计算)
NPC 日程(ScheduleResource)
npc2001_work.tres:9~18 时,rest → work
npc2001_rest.tres:18~9 时,work → rest(覆盖到次日)
npc2001_schedule_list_resource.tres:当前激活的日程(动态追加 rest)
- npc4001/4002/4003 暂无 schedule(silver_mist 场景里不移动,schedule_list_resource 可不挂)
对话(Dialogic timeline)
| 文件 | 角色 / 风格 | 内容 |
|---|
Alf_stamina_deplete.dtl | Alf / bubble | 体力归零提示(详见 §1) |
npc1001_prompt.dtl | NPC 1001 / bubble | 船夫接任务 + 关卡转移(birth ↔ village_01) |
npc2001_prompt.dtl | NPC 2001 / speaker | 商店对话(”我要买点东西”开 Shop) |
npc4001_prompt.dtl | NPC 4001 / speaker | silver_mist 家具店主(”我要买点东西”→ [signal {npc:"3", type:"shop"}]) |
npc4002_prompt.dtl | NPC 4002 / speaker | silver_mist 食材种子店主(→ [signal {npc:"4", type:"shop"}]) |
npc4003_prompt.dtl | NPC 4003 / speaker | silver_mist 武器杂货店主(→ [signal {npc:"5", type:"shop"}]) |
wuning_house_pro00_01.dtl | Alf / bubble | 开头独白:窗开着,文件会被雨淋 |
wuning_house_pro00_02.dtl | Alf / bubble | 收拾房间的誓言 |
wuning_house_pro00_02_1.dtl | Alf / bubble | “她还没准备好” |
wuning_house_pro00_03.dtl | Alf / bubble | 打碎盘子惊慌 |
wuning_house_pro00_04.dtl | Wuning / speaker | 海鸥岛失败开垦,委托 Alf 代管 |
wuning_house_pro00_05.dtl | Wuning / speaker | 雨停,明早 7 点东门出发 |
wuning_house_pro00_06.dtl | Alf / speaker | 离家不舍 |
wuninghouse_corridor_wuningroom.dtl | Alf / bubble | 走廊回忆被训斥的反思 |
Dialogic character(.dch)
character_bubble.dch / character_speaker.dch — Alf
npc1001_bubble.dch — NPC 1001 船夫
npc2001_speaker.dch — NPC 2001 店主
wuning_bubble.dch / wuning_speaker.dch — Wuning
- (npc4001/4002/4003 暂无独立 .dch,使用 speaker style 直接以
[signal] 触发商店)
14. AI / LimboAI 速查
行为树(ai/trees/)
| BT | NPC | Selector |
|---|
npc1001.tres | 船夫 | 不在范围:idle(back) + 隐藏 interact → 在范围:face → idle facing player → 显示 interact → await dialogue → start_bubble_dialogue("npc1001_prompt") |
npc2001.tres | village_01 店主 | 寻路(move=true 隐藏 interact + move_to_destination(60, contrary=true)) → 不在范围:idle(back) + 隐藏 interact → 在范围:face → idle facing player → 显示 interact → await dialogue → start_speaker_dialogue("npc2001_prompt") |
npc4001.tres | silver_mist 家具店主 | 同上但 start_speaker_dialogue("npc4001_prompt") |
npc4002.tres | silver_mist 食材种子店主 | 同上但 start_speaker_dialogue("npc4002_prompt") |
npc4003.tres | silver_mist 武器杂货店主 | 同上但 start_speaker_dialogue("npc4003_prompt") |
自定义 BTAction(ai/tasks/)
| 文件 | 行为 |
|---|
await_input.gd | SUCCESS if Input.is_action_just_pressed(input_key) else FAILURE |
calculation_range_face_direction.gd | 算玩家↔NPC delta → 翻 npc.graphics.scale.x,支持 contrary |
move_to_destination.gd | current_level_instance.get_astar_path_to() + 走 waypoints + 播 walk_<dir> + 到点 set move=false + emit npc.move_finish |
play_direction_animation.gd | 播 <animation>_<face_direction>(黑板 face_direction 或 export) |
set_interace_visible.gd | toggle npc.interact.visible(typo: interace) |
start_bubble_dialogue.gd | 切 bubble style + 注册 npc.name.to_lower()+"_bubble.dch" + 启 timeline + await timeline_ended |
start_speaker_dialogue.gd | 同上但 speaker 风格,不注册 character |
15. Shop 流程细节(utils_manager/children/shop/)
shop.gd — Shop extends CanvasLayer
- 信号:
finish
- 静态字段:
is_opened: bool
- 字段:
current_select: ShopItem、current_page=1、total_pages=1、is_sell_mode=false、sell_items: Array[Dict]、items_per_page=20
- 方法:
load_items()(按 shop_resource.columns/%List 创建 ShopItem)、load_sell_items()(收集 bag + hold_item_list 非空项)、clear_items()、close()(0.15s 同时 modulate.a→0 + scale→0.9 → await finished → is_opened=false → 恢复 hold.allow_scroll=true → emit finish)、update_price()(sell 时 max(int(price*0.8), 1))、update_current_select(item)、set_current_inventory(inventory)、_on_buylabel_button_pressed() / _on_selllabel_button_pressed() / _on_buy_pressed() → character.to_buy_inventory(inv, count) / _on_sell_pressed() → character.selling_inventory(target, count)、_on_page_up_pressed / _on_page_down_pressed、update_pages_text()、update_items_visibility()、calculate_total_pages()
shop_item.gd — ShopItem extends InventoryNode — update_display()、reset()/set_current()、on_left_click() 调 super + shop.update_current_select(self)、on_inventory_click 空实现(不能被拿起)
16. 存档系统流程(Web vs 桌面)
桌面(Desktop)
- 存档根目录:
res://archives/<slot>/
- 路径:
res://archives/<slot>/<game_resource|bag_resource|hold_resource|...>.tres
ResourceManager.save_resource(resource) → 直接 ResourceSaver.save(resource.resource_path)
GameManager.on_quit() → _persist_runtime_state → get_tree().quit()
Web(Web)
- 存档根目录:
user://runtime_state/<slot>/
- cold-start:
SEED_RUNTIME_PATHS 把 31 个核心 .tres 镜像到 user://
- 运行时:
WebSaveAdapter.swap_to_runtime_deep(original) 递归把 Resource 子属性切到镜像版
- 触发保存:
_beforeunload / pagehide / visibilitychange / 30s autosave_timer
GameManager.on_quit() → WebSaveAdapter._persist_all() → 遍历 archive_resource_list 调 save_resource
存档 slot 格式
<seq>_MMDD_HHMM(seq 从 archive_root 子目录数 + 1,MMDD_HHMM 是当前月日时分)
SettingResource.current_existing_index 保存当前 slot 名
Main 菜单存档操作
- NewGame:生成 slot → desktop 创建 archive 目录 + 拷贝原始 .tres →
to_load_existing
- Continue:选最大
seq slot → to_load_existing
- LoadGame:列出所有 slot,按 seq 倒序,点了再
to_load_existing
- 无存档时 Continue/LoadGame 按钮自动 disable
17. 加载流程时序图(文字版)
SceneManager.switch_scene(Game) →
LoadingManager.enter(center, false, _start_scene_load.bind(Game)) — 播放 enter 动画
- await
animation_finished → callback
_start_scene_load(Game) →
ResourceManager.load_resource_async("res://scenes/game/game.tscn", callback, process)
- callback:
instantiate → add_child(Game) → GameManager.game = self → WebSaveAdapter.swap_to_runtime_deep(game_resource) → connect GameManager.quit → save_runtime(game_resource)
- Game 节点
_ready → 首次 load_level(initial_level) →
- 首次走
LoadingManager.enter_force()
ResourceManager.load_resource_async(level.tscn, ...) →
level_instance.instantiate() → if current_level_instance: save + queue_free → add_child(level_instance) → set_character_postion → camera.set_limit → camera.set_follow_target → SoundManager.play_level_audio_by_name → await process_frame → level_loaded.emit()
- LevelType 分发(Birth / SilverMist+7 / WuningHouseAlf+1)
- callback:
await process_frame → camera.phantom_camera2D.teleport_position → await process_frame → LoadingManager.leave(center)
- 后续
load_level(other):走 enter 动画版(不带 force)