上岸的鱼

心中有光,便可使整个世界升起太阳

一、本章核心目标

本章的核心目标是构建一个功能完备的玩家快捷栏系统,并实现首个可装备的工具——斧头,以此为基础引入资源采集的核心玩法循环。这一过程不仅实现了玩家可见的功能,也为后续更复杂的装备和交互系统奠定了坚实的技术架构。

  • 显性目标 (Player-Facing Goals):

    1. UI实现:在游戏界面底部创建一个可视化的快捷栏UI,允许玩家放置物品。

    2. 装备功能:玩家可以将斧头等工具放入快捷栏,并通过按下对应快捷键(1-8)来装备或卸下该工具。

    3. 资源采集:玩家装备斧头后,可以对场景中的树木进行砍伐,并成功获取木材资源。

    4. 交互优化:实现快捷栏与背包之间的物品拖拽功能,方便玩家管理物品。

  • 隐性/战略目标 (Technical & Strategic Goals):

    1. 模块化设计:建立一个独立的快捷栏组件 (BPC_PlayerHotbar),使其与角色逻辑解耦,便于维护和未来扩展。

    2. 可扩展的装备系统:创建一个可装备物品的基类 (BP_Equippable_Master),为未来所有武器和工具(如镐、弓箭)的快速开发提供统一的框架和接口。

    3. 可扩展的采集系统:建立可收获资源的基类 (BP_Harvesting_Master) 和配套的数据资产,形成一套可扩展的资源采集框架,为后续加入矿石、草药等不同类型的资源点做好准备。

    4. 动画状态驱动:构建一个由装备状态驱动的动画系统,通过枚举 (E_EquippableState) 控制角色在不同装备下的姿态和动作,为后续多样化的武器动画打下基础。

  • 目标关系图:

    graph TD
        A[快捷栏与斧头装备] --> B{显性目标};
        A --> C{隐性/战略目标};
    
        B --> B1[UI与快捷键功能];
        B --> B2[装备/卸下斧头];
        B --> B3[砍树获取资源];
        B --> B4[与背包拖拽交互];
    
        C --> C1[组件化快捷栏系统];
        C --> C2[可扩展的装备框架];
        C --> C3[可扩展的采集框架];
        C --> C4[装备驱动的动画状态机];
    
        subgraph 核心玩法循环
            B2 --> B3;
        end
    
        subgraph 系统架构
            C1 & C2 & C3 & C4;
        end

二、系统与功能实现

本章实现了多个相互关联的系统,共同构成了快捷栏、装备与初级采集的完整功能。

  • 已实现系统/功能列表:

    • 快捷栏UI系统 (W_Hotbar):

      • 描述: 一个独立的UI控件,包含了8个物品槽位 (W_ItemContainerGrid) 和对应的快捷键图标 (InputActionWidget)。 该控件被添加到主HUD布局中,固定在屏幕底部。

      • 实现流程: SizeBox -> Border -> Overlay -> 多个 HorizontalBox 分层布局(上层放物品槽,下层放快捷键图标),确保结构清晰。

    • 快捷栏组件 (BPC_PlayerHotbar):

      • 描述: 继承自通用的 BPC_ItemContainer_Master,专门用于管理快捷栏的8个物品槽位的数据。 它被添加为玩家角色的一个组件,负责存储物品信息并处理逻辑。

      • 与前期系统交互:

        • 依赖: 继承并复用了前期设计的物品容器基类 (BPC_ItemContainer_Master) 的核心数据结构和功能。

        • 交互: 通过覆写 HandleSlotDrop 等函数,与背包组件 (BPC_PlayerInventory) 实现了物品转移(拖拽)的逻辑。

    • 可装备物品系统:

      • 描述: 建立了一套清晰的继承体系 (BP_Item_Master -> BP_Equippable_Master -> BP_Hatchet_Master -> BP_StoneHatchet)。 该系统负责在玩家装备物品时,在服务器上生成对应的实体Actor,并将其附加到角色骨骼的指定插槽 (Socket) 上。

      • 与前期系统交互: 依赖前期建立的数据资产系统 (PDA_ItemInfo) 来获取物品的基础信息,如模型、类型等。

    • 可收获物系统:

      • 描述: 实现了 BP_Harvesting_Master 作为所有可采集资源的基类,并创建了棕榈树 (BP_Palm_02 等) 作为其子类。 系统包含生命值、受击反馈、资源产出和被摧毁后的表现(生成可破坏物理实体)。

      • 交互: 该系统与“可装备物品系统”直接交互。当斧头的攻击范围(通过射线检测实现)命中可收获物时,触发其生命值扣减和资源产出逻辑。

  • 系统交互依赖图:

    flowchart LR
        subgraph PlayerCharacter
            A[BPC_PlayerHotbar]
            B[BPC_PlayerInventory]
            C[输入处理]
            D[动画蓝图]
        end
    
        subgraph UI
            E[W_Hotbar]
            F[W_Inventory]
        end
    
        subgraph World
            G[BP_StoneHatchet Actor]
            H[BP_PalmTree Actor]
        end
    
        C -- 触发装备 --> A
        A -- 更新数据 --> E
        B -- 更新数据 --> F
        A <--> B
        E <--> F
    
        A -- 装备/卸下 --> G
        G -- 设置动画状态 --> D
        C -- 触发攻击 --> G
        G -- 射线检测 --> H
        H -- 给予资源 --> B

三、关键设计思想

本章的设计遵循了现代游戏开发的多个核心原则,确保了系统的可维护性和扩展性。

设计思想 应用实例 优势分析
数据驱动设计 (Data-Driven) 使用 PrimaryDataAsset 定义物品 (DA_StoneHatchet) 和可收获资源 (DA_PalmTreeResource)。 物品的属性(伤害、图标、模型)、资源产出(种类、数量)全部外置于数据表中。 高效率与解耦:策划和设计师可以不接触蓝图代码,直接通过修改数据资产来创建新物品、调整平衡,极大地提升了开发效率和迭代速度。
继承与多态 (Inheritance & Polymorphism) 1. 物品: Equippable_Master 作为所有可装备物品的父类,定义了通用接口和变量。
2. 资源: Harvesting_Master 作为所有可收获物的父类,其子类如 Tree_Master 进一步派生出具体的棕榈树。
遵循开闭原则:未来添加新武器(如镐)或新资源(如矿石),只需继承相应基类并实现其特定逻辑,无需修改现有核心代码,系统扩展性强。
组件模式 (Component Pattern) 快捷栏功能被封装在 BPC_PlayerHotbar 组件中,独立于玩家角色蓝图的其他部分。 高内聚,低耦合:快捷栏的所有数据和主要逻辑都集中在一个地方,使得代码更易于理解和维护。如果不需要快捷栏,可以直接移除该组件,对角色影响最小。
接口驱动通信 (Interface-Driven) 1. BPI_EquippableItem:用于从任何装备上获取信息(如插槽名、动画状态)或调用其方法(如UseItem)。
2. BPI_SurvivalCharacter:用于从外部(如物品Actor)安全地调用角色蓝图的功能(如PlayThirdPersonMontage)。
消除硬引用:避免了物品蓝图和角色蓝图之间的直接引用依赖,降低了耦合度。这使得系统更加灵活,例如,任何实现了 BPI_EquippableItem 接口的Actor都可以被系统识别为可装备物品。

四、核心技术点与难点

本章涉及了动画、网络和物理等多个技术领域的关键实现。

  • 关键技术点:

    • 动画蓝图集成:

      • 使用 Blend Poses by Enum 节点,根据 E_EquippableState 枚举变量,在动画蓝图的状态机中动态切换角色的基础姿态(例如,从默认站立切换到持斧站立)。

      • 利用 Layered Blend Per Bone 节点,实现了上下半身动画分离。这使得角色在行走或奔跑时,上半身仍能独立播放攻击(挥斧)动画,避免了“滑步”现象。

    • 动画通知 (Anim Notify):

      • 在挥斧动画蒙太奇 (AnimMontage) 的关键帧(斧头挥出的瞬间)上添加了 Anim Notify 事件。

      • 该事件触发一个精准的服务器端射线检测,用于判断攻击是否命中目标,确保了打击判定的时机与视觉表现完美同步。

    • 网络复制 (Replication):

      • 核心逻辑在服务器: 物品的装备 (Equip)、使用 (UseItem) 和伤害计算 (HarvestFoliage) 等核心逻辑均通过 Run On Server 事件在服务器上执行,保证了游戏状态的一致性和安全性。

      • 视觉表现在所有客户端: 物品的附加到手上、动画蒙太奇的播放、树木的倒塌等视觉表现,则通过 Multicast 事件同步给所有客户端,确保所有玩家都能看到一致的画面。

    • 动态Actor生成与附加:

      • 装备物品时,服务器动态生成 (Spawn Actor From Class) 物品的蓝图实例,并通过 Attach Actor to Component 将其附加到角色骨骼的预设插槽 (Socket) 上。
  • 技术难点与解决方案:

难点描述 解决方案 优势
武器/工具与手部精准对齐 在角色骨骼的 hand_r 骨骼上创建了一个名为 HatchetSocket 的插槽。 在骨骼编辑器中,通过预览斧头模型并播放持握动画,精细调整了该插槽的相对位置和旋转,确保斧头被正确地握在手中。 精确可控:插槽提供了一种与模型无关的、稳定的附着点,即使更换角色模型,只要骨骼结构和插槽名相同,武器就能正确附着。
攻击判定与动画不同步 使用了 Anim Notify。在挥斧动画最符合打击逻辑的帧上插入通知事件,该事件链式调用到服务器执行射线检测,实现了视觉与逻辑的精确同步。 所见即所得:打击判定不再依赖于固定的延迟或猜测,而是由动画师在动画资源中直接定义,实现了像素级的精准判定。
树木倒塌物理效果不自然/穿模 创建了一个独立的 BP_Destructible_TreeMaster 类。 它使用多个胶囊体碰撞组件模拟树干和树冠的物理形态,并在生成后施加一个来自玩家方向的力,模拟被砍倒的效果。同时,树木本身的静态网格体被设置为无碰撞。 性能与效果兼顾:使用简化的物理形状(胶囊体)进行物理模拟,比使用复杂的网格体碰撞性能更高。分离出的Actor也使得逻辑更清晰,易于管理。

五、自我批判与重构

对本章开发过程中的问题进行总结和反思,有助于提升未来开发的质量和效率。

类别 问题描述 解决方案 如果重来一次的优化方案
遇到的“坑” 1. UI未更新: 将物品添加到快捷栏组件后,UI界面没有实时刷新。
2. 物理碰撞: 导入的树木模型碰撞体过大,阻挡玩家靠近;装备的斧头与角色碰撞导致抖动。
3. 引擎稳定性: 在动画蓝图使用接口时,直接修改接口会导致引擎崩溃。
1. 修复了控制器中获取UI控件的逻辑,确保能正确找到并更新快捷栏的UI。
2. 手动移除了错误的碰撞体,为树木重新创建了合适的胶囊体碰撞;将斧头模型的碰撞设为NoCollision
3. 采用临时删除再重新添加接口节点的办法规避了崩溃。
1. 建立UI更新广播机制: 任何容器(背包、快捷栏)数据变化时,都应通过一个全局事件调度器广播更新消息,相关的UI控件订阅该消息并自我刷新,而不是依赖控制器去手动查找和调用。
2. 制定资产导入规范: 制定严格的资产导入流程,要求美术在导出模型前就处理好基础碰撞,并为不同类型的物体预设好碰撞通道(如Harvestable, EquippedItem),从源头避免问题。
设计反思 多层继承: 为了区分不同类型的斧头(石头、铁),在 Equippable_MasterStoneHatchet 之间增加了 Hatchet_Master 中间层,用于承载所有斧头共有的逻辑。 这是对前期设计的正确修正,体现了在开发中逐步完善架构的思路。 采用组合优于继承: 可以考虑将“工具类型”(是斧头还是镐)和“材质等级”(是石头还是铁)作为数据组件或标签附加到物品上,而不是通过严格的继承链来定义。这样可以更灵活地组合出“石斧”、“铁镐”等物品,避免继承层次过深。
重构建议 装备状态管理: 当前装备/卸下逻辑分散在角色蓝图中,并与快捷栏输入紧密耦合,导致了拖拽时的状态同步BUG。 N/A 引入装备管理器 (EquipmentManager): 创建一个专门的玩家组件BPC_EquipmentManager,它唯一负责处理EquipItemUnequipItem的请求,并管理当前装备的状态。快捷栏、背包等其他系统只向该管理器发送请求,而不直接操作物品的生成和销毁。这样可以确保状态的单一来源和一致性,彻底解决同步问题。

一、核心摘要

本章详细介绍了在虚幻引擎5中构建一个模块化、可扩展的生存游戏物品栏系统的核心框架。该框架以一个名为 BPC_ItemsContainer_Master 的主Actor组件为中心,它封装了所有通用的物品管理逻辑,例如添加、堆叠、拆分和移动物品。随后,通过面向对象的继承方式,从该主组件派生出三个功能专一的子组件:BPC_PlayerInventory(玩家主背包)、BPC_HotbarComponent(快捷栏)和 BPC_StorageContainer(储物容器),这种设计极大地提高了代码的复用性和系统的可维护性。

课程首先清晰地定义了物品的数据结构,通过创建“主数据资产”(Primary Data Asset)来储存物品的静态定义(如名称、图标、类型、最大堆叠数),并利用一个自定义的结构体(Struct)S_ItemStructure来管理每个物品实例的动态数据(如物品ID、数量、当前耐久度)。在UI方面,教程系统地指导了如何利用Widget蓝图构建完整的用户界面,包括主HUD、游戏内物品栏界面、可复用的物品槽网格,并利用Common UI插件实现了跨平台(键鼠/手柄)的输入提示。最后,课程详细拆解了拖放操作的完整实现逻辑,确保了后端数据与前端UI之间的精确通信与状态同步,为后续开发更复杂的功能(如制作、装备系统)奠定了坚实的基础。

二、关键要点

  • 组件化架构: 整个物品栏系统的核心是一个名为 BPC_ItemsContainer_Master 的主Actor组件,它处理所有通用的物品操作逻辑,如添加、堆叠和拆分物品。

  • 专用子组件: 从主组件派生出三个子组件:BPC_PlayerInventory(玩家主背包)、BPC_HotbarComponent(快捷栏)和 BPC_StorageContainer(储物箱、熔炉等),每个子组件负责其特定领域的逻辑。

  • 数据驱动设计: 采用 Primary Data Asset 来定义每种物品的静态属性,这使得物品管理和扩展变得简单高效,无需修改代码即可添加新物品。

  • 动静数据分离: 使用结构体 S_ItemStructure 来存储每个物品实例的动态数据(如数量、当前耐久度),而物品的静态信息(如名称、图标)则存储在数据资产中,实现了数据的清晰分离。

  • UI与逻辑解耦: 课程利用Common UI插件构建了独立的用户界面层,通过接口(Interface)和事件驱动的方式实现UI与后端物品栏组件之间的通信,确保了系统的模块化和可维护性。

  • 拖放功能实现: 详细实现了完整的物品拖放逻辑,包括在同一个容器内移动物品、在不同容器间转移物品以及处理堆叠逻辑。

  • 跨平台输入兼容: 通过Common UI插件的输入操作(Input Action)和相关设置,实现了UI能够根据玩家使用的设备(键盘鼠标或手柄)自动显示对应的按键提示图标。

  • 动态UI生成: 物品栏和储物箱的UI格子(Slot)是动态生成的,其数量由其对应的物品容器组件(Items Container)中的变量(NumberOfSlots)决定,实现了UI与数据源的同步。

三、核心术语

  • Primary Data Asset (主数据资产): 虚幻引擎中一种用于存储静态数据的资产类型。在本课程中,它被用来定义物品的固有属性,如图标、名称、描述、是否可堆叠等。

  • Actor Component (Actor组件): 一种可附加到任何Actor上的可复用功能模块。本章中的物品栏系统就是作为一个组件(BPC_ItemsContainer_Master)创建的,以便能轻松地添加到玩家角色或储物箱等Actor上。

  • Struct (结构体): 一种自定义的数据类型,用于将多个相关的变量组合成一个单一的单元。本章使用 S_ItemStructure 结构体来封装一个物品实例的所有动态信息,如ID、数量和当前耐久度。

  • Common UI (通用UI插件): 虚幻引擎的一个官方插件,旨在帮助开发者创建更健壮、更具平台兼容性的用户界面。本章利用它来处理跨平台(如PC和手柄)的输入提示。

  • Drag and Drop Operation (拖放操作): UI编程中的一个概念,允许用户通过鼠标(或手柄模拟)“抓起”一个UI元素并将其“放置”到另一个位置。本章用于在物品栏格子之间移动物品。

四、详细实现流程

物品数据结构设计

  1. 创建主数据资产 DA_ItemMaster: 定义所有物品共有的静态属性,例如 ItemName(物品名称)、ItemIcon(物品图标)、bIsStackable(是否可堆叠)、MaxStackSize(最大堆叠数量)等。

  2. 创建物品结构体 S_ItemStructure: 定义物品实例的动态数据,包含 ItemID(一个 FName,用于关联到对应的主数据资产)和 Quantity(数量)。

  3. 配置物品容器: 在 BPC_ItemsContainer_Master 组件中,创建一个名为 Items 的数组(Array),其元素类型为 S_ItemStructure。这个数组将是存储所有物品实例的核心数据结构。数组的大小由一个整数变量 NumberOfSlots 控制。

UI构建与绑定

  1. 创建物品槽 WBP_ItemSlot: 这是显示单个物品的最小UI单元。它包含一个图标(Image)和一个数量文本(Text Block)。它还需要持有两个关键变量:ItemStructure(用于存储它所代表的物品数据)和 SlotIndex(它在容器中的索引)。

  2. 创建物品网格 WBP_ItemsGrid: 该控件负责动态生成并排列所有的 WBP_ItemSlot。它包含一个 Uniform Grid Panel,并在“构建时”(OnConstruct)事件中,根据传入的物品容器组件引用,循环创建相应数量的 WBP_ItemSlot 并添加到网格中。

  3. 更新UI: BPC_ItemsContainer_Master 组件中创建一个名为 OnInventoryUpdated 的事件分发器。每当 Items 数组发生变化(如添加、移除物品)时,就调用此事件。WBP_ItemsGrid 则绑定该事件,一旦接收到信号,就刷新所有 WBP_ItemSlot 的显示内容。

核心物品操作逻辑:“添加物品”

“添加物品”(AddItem)是系统中最核心的函数之一,其执行逻辑非常严谨,确保了物品能够被正确地堆叠或放置到空格子中。

  • 第一步:查找可堆叠的槽位。函数首先会遍历整个 Items 数组,查找是否已存在相同 ItemID 且未达到最大堆叠数量的物品槽。

  • 第二步:执行堆叠。如果找到,则计算可堆叠的数量,将新物品添加到该槽位,并更新数量。如果新物品数量超出该槽位的堆叠上限,则将该槽填满后,用剩余的物品继续执行“添加物品”流程(递归或循环调用)。

  • 第三步:查找空槽位。如果遍历完所有槽位都无法完成堆叠(即没有可堆叠的槽或所有物品都已堆满),函数则开始查找第一个空的槽位(ItemID 为 None 的槽)。

  • 第四步:放入空槽位。如果找到空槽位,则将新物品直接放入该槽位。

  • 第五步:处理无法添加的情况。如果遍历完既无法堆叠,也找不到任何空槽位,则意味着物品栏已满,添加失败。函数返回一个布尔值(Boolean)来表示操作是否成功。

核心物品操作逻辑:“拖放与移动物品”

拖放是实现物品移动、交换、合并的基础,其逻辑在UI层面(Widget)和数据层面(Component)协同完成。

  • 发起拖动 (OnDragDetected): 在 WBP_ItemSlot 控件中,当鼠标在一个非空的槽位上按下并移动时触发。

    1. 创建一个 Drag and Drop Operation 对象。

    2. 源物品槽的信息(如物品数据、源容器引用、源索引)作为“Payload”(有效载荷)存入该对象。

    3. 创建一个 WBP_DragVisual 控件(通常仅显示物品图标),作为拖动时跟随鼠标的视觉元素,并将其存入操作对象。

  • 处理放置 (OnDrop): 在目标 WBP_ItemSlot 控件上释放鼠标时触发。这是逻辑最复杂的部分。

    1. 从传入的 Drag and Drop Operation 对象中获取源物品的信息。

    2. 情况一:目标槽位为空。直接将源物品数据移动到目标槽位,并清空源槽位的数据。

    3. 情况二:目标槽位物品与源物品ID相同且可堆叠。将源物品堆叠到目标物品上,处理可能超出堆叠上限的情况,并清空源槽位。

    4. 情况三:目标槽位物品与源物品ID不同(或不可堆叠)。交换源槽位与目标槽位中的物品数据。

    5. 所有操作都在后端的物品容器组件(BPC_ItemsContainer_Master)中执行,完成后调用 OnInventoryUpdated 事件来刷新UI。

核心物品操作逻辑:“拆分堆叠”

拆分通常通过特定输入(如右键拖拽)来触发。

  • 发起拆分: 用户在可堆叠的物品槽上按下特定按键(如右键)并拖动。

  • 显示拆分窗口: 弹出一个UI窗口(Widget),包含一个滑块(Slider)和一个确认按钮,让玩家选择要拆分的数量(1到堆叠总数-1)。

  • 执行拆分:

    1. 玩家确认数量后,在源物品容器中调用一个“拆分堆叠”函数。

    2. 该函数从源槽位的物品数量中减去指定的拆分数量。

    3. 同时,函数创建一个新的、仅用于拖拽的临时物品数据(包含物品ID和被拆分出的数量)。

    4. 将这个临时物品数据打包进 Drag and Drop Operation 对象,并启动标准的拖放流程。这样,玩家鼠标上“拿”着的就是被拆分出来的新堆叠。

“添加物品”函数逻辑流程图

这个流程图详细展示了向物品栏容器中添加一个物品时,系统内部的判断和执行顺序。

graph TD

A[开始 调用 AddItem ItemID Quantity] --> B{物品可堆叠}
B -- 是 --> C{遍历所有槽 查找相同 ItemID 且未满的槽}
C -- 找到 --> D[计算可堆叠数量]
D --> E{新物品能完全放入}
E -- 是 --> F[更新该槽数量 结束]
E -- 否 --> G[填满当前槽]
G --> H[计算剩余物品数量]
H --> B[用剩余物品继续流程]
C -- 未找到 --> I{遍历所有槽 查找空槽}
B -- 否 --> I
I -- 找到 --> J[将物品放入该空槽 结束]
I -- 未找到 --> K[物品栏已满 添加失败 结束]

“处理放置(OnDrop)”核心逻辑流程图

这个流程图展示了当一个物品被拖动并放置到另一个物品槽上时,系统如何根据目标槽位的状态来决定执行哪种操作。

graph TD
    subgraph OnDrop
        Start[开始: 物品A在槽B上释放] --> GetInfo[获取A的源信息与B的目标信息];
        GetInfo --> IsTargetSlotEmpty{目标槽B为空?};
        IsTargetSlotEmpty -- 是 --> Move[移动: 将A放入B, 清空A的源槽. 结束];
        IsTargetSlotEmpty -- 否 --> IsSameItem{A与B是同种物品且可堆叠?};
        IsSameItem -- 是 --> Stack[堆叠: 将A合并到B, 清空A源槽. 结束];
        IsSameItem -- 否 --> Swap[交换: 将A与B的数据互换. 结束];
    end

Hexo 404 及 Mermaid 图表渲染问题排查记录

作为 Hexo 用户,我最近也遇到了不少恼人的问题,特别是 Mermaid 图表无法渲染以及部署后出现 404 错误。这里记录一下整个排查和解决过程,希望能给遇到类似问题的朋友一点参考。


一、Mermaid 图表渲染问题与项目重建

最初的问题是博客中的 Mermaid 图表无法正常显示。在尝试了各种插件配置和主题调整后,我决定采取最彻底的方式:重建整个 Hexo 项目

重建过程概览:

  1. 备份: 在动手前,备份了 source (文章)、_config.yml (站点配置)、_config.next.yml (Next 主题配置) 和 package.json (插件列表)。

  2. 删除旧项目: 在终端中进入 blog 目录的上一级,执行 rm -rf blog

  3. 初始化新项目: 运行 hexo init blog,然后 cd blog

  4. 安装插件和主题: 重新安装了 hexo-filter-mermaid-diagramshexo-deployer-git 等必要插件,并克隆安装了 Next 主题。

  5. 恢复配置和文章: 将备份的配置文件覆盖新生成的文件,把文章复制回 source 目录。

  6. 测试: 运行 hexo clean && hexo g && hexo s

重建后,Mermaid 图表问题依旧存在,这让我意识到问题可能不只在 Hexo 本身。


二、浏览器端排查

既然 Hexo 配置看起来没问题,我将注意力转向了浏览器。

浏览器排查步骤:

  1. 无痕模式测试: 在 Chrome 或 Safari 中打开无痕窗口,访问 http://localhost:4000/。如果图表在此模式下正常显示,那问题多半是某个浏览器扩展程序(比如广告拦截插件)造成的。

  2. 开发者工具控制台: 如果无痕模式也无效,那就打开开发者工具(F12 或右键“检查”),切换到“控制台”(Console)选项卡,刷新页面。仔细查看是否有红色错误信息。这些信息是前端脚本加载或执行失败的直接线索。


三、版本兼容性问题

经过反复尝试,我开始思考是否是 Hexo 及其组件的版本兼容性问题。Hexo 的生态更新较快,老版本可能无法完美支持新插件或主题的特性。最终,通过彻底重建并确保所有组件都更新到最新、相互兼容的版本,之前遇到的许多奇怪问题才得以解决。这表明,统一组件版本对于 Hexo 博客的稳定性非常重要。


四、404 错误与 new_post_name 配置

在解决了渲染问题后,部署到线上又遇到了新的 404 错误。排查发现,一个不显眼的配置项是罪魁祸首:_config.yml 中的 new_post_name

问题根源:

  • 如果 new_post_name 设置为 :category/title,Hexo 生成文章时会创建没有 .md 后缀的文件(例如 public/tech/my-article)。

  • Hexo 本地服务器可以正确处理这类文件,但大多数标准 Web 服务器(如 Nginx、GitHub Pages)在收到 http://yourdomain.com/tech/my-article 这样的请求时,会因为找不到明确的 .html 文件而返回 404

解决方案:

_config.yml 中的 new_post_name 修改为:

YAML

1
new_post_name: :category/:title.md

修改后,务必执行 hexo clean && hexo g 清理并重新生成所有文件,然后重新部署。 这样 Hexo 就会生成带有 .html 扩展名的文件(例如 public/tech/my-article/index.html),Web 服务器就能正常识别并提供内容了。


总结

这次 Hexo 排查经历让我学到不少:

  1. 彻底重建是解决复杂环境问题的有效手段。

  2. 浏览器端排查是定位前端渲染问题的关键。

  3. 版本兼容性对 Hexo 博客至关重要,尽量保持各组件版本统一。

  4. new_post_name 这样的小细节也能导致部署后出现大面积 404。

希望这篇记录能帮到同样在使用 Hexo 的你。

一、核心摘要

本章作为整个课程的基石,其核心任务是从零开始,利用UE 5.4的第三人称模板,搭建一个功能完备、支持多人游戏基础架构的可玩角色。内容覆盖了从项目创建、角色与摄像机设置,到实现一个包含跑、跳、蹲伏和俯卧的完整移动动画系统。同时,本章还引入了UE5最新的增强输入系统,并最终以专业的项目备份方法收尾,为后续所有复杂系统的开发奠定坚实的技术与工程化基础。

二、 本章模块详解

模块一:项目设置与管理 (Project Setup & Management)

  • 本章实现:

    1. 创建项目:
      打开UE5.4,在项目浏览器中,分类选择“游戏”,然后选择“第三人称”(Third Person)模板。

    2. 配置设置:
      确保项目类型为“蓝图”(Blueprint),为项目命名并选择存储路径(建议使用英文路径)。

    3. 整理结构:
      进入编辑器后,立即在“内容浏览器”中创建 Blueprints, Animations, Maps 等文件夹,并将模板自带的文件分类归档。

    4. 备份项目:
      在备份时,先删除项目根目录下的 IntermediateSaved 文件夹(但需保留 Saved/Config 文件夹),再对整个项目进行压缩。

    提示: 手动压缩备份是项目初期的好习惯,但在专业开发流程和团队协作中,普遍使用 版本控制系统,如 GitSVN

    • 与每次都完整复制项目不同,它们主要追踪每个文件的修改历史,可以随时回溯到任意版本,极大节省存储空间。
    • 正如手动备份时需要删除 IntermediateSaved 等文件夹,使用 Git (通过 .gitignore 文件) 或 SVN (通过 ignore 属性) 时,也需要设置忽略这些自动生成的文件夹,只对核心资产进行版本管理。
  • 正确的项目初始化可以利用模板加速开发,而规范的备份流程则是保障项目资产安全、规避风险的关键措施。

模块二:角色蓝图与摄像机 (Character Blueprint & Camera)

  • 本章实现:

    1. 定位核心蓝图,后续所有角色的逻辑开发都在 BP_ThirdPersonCharacter 这个蓝图中完成。
    2. 设置视角,通过调整角色蓝图内部的 SpringArm (弹簧臂) 和 Camera (摄像机) 这两个组件的参数,来实现第三人称视角的远近和高低。
  • 角色蓝图 本身是一个容器,其具体功能都是由独立的 组件“拼装” 而成,这使得功能的添加、修改或移除都非常灵活。它作为角色的核心控制中心,负责管理所有这些组件以及角色的状态数据。

模块三:输入系统 (Input System)

  • 本章实现:

    1. 创建输入动作(Action),创建一个名为 IA_Crouch 的资产来代表“蹲伏/俯卧”的意图。
    2. 映射物理按键(Mapping),在 IMC_Default 文件中,为 IA_Crouch 绑定键盘的 X键,并为手柄的右摇杆按下 也进行绑定。
    3. 使用触发器(Trigger),在手柄的绑定上添加“长按”(Hold)触发器,用以区分“轻按蹲伏”和“长按俯卧”。
    4. 处理输入并同步状态,在角色蓝图中,当接收到输入事件后,会修改 isProne 等状态变量。为了让这个状态变化在多人游戏中对其他玩家可见,这个 isProne 变量必须设置为“已复制”(Replicated)
  • 该模块展示了输入是功能逻辑链的起点。它不仅负责将物理操作翻译成游戏可以理解的抽象命令,更重要的是,由输入触发的状态改变(如 isProne 变量),直接引出了为了实现网络同步而必须进行的变量复制设置。

模块四:动画与移动系统 (Animation & Locomotion System)

  • 本章实现:

    1. 定义状态变量,在角色蓝图中创建布尔变量,如 isCrouchingisProne,作为控制动画的“开关”。
    2. 创建动画素材,导入俯卧等动画,并创建 Blendspace (混合空间) 资产来平滑地融合走、跑等动作。
    3. 构建状态机,在动画蓝图中打开“状态机”,添加“Crouch”、“Prone”等新状态,并使用第一步创建的布尔变量作为连线上的“切换规则”。
  • 动画蓝图负责角色所有的视觉表现。它不进行任何逻辑判断,而是持续地从角色蓝图读取状态数据(如 isCrouching 是否为真),并根据这些数据来播放对应的动画。

模块五:多人游戏基础 (Multiplayer Foundation)

  • 本章实现: 在编辑器顶部“播放”按钮旁的下拉菜单中,将“玩家数量”设置为2或更多,并将“网络模式”选择为“作为监听服务器运行”(Play as Listen Server)。

  • 模块作用: 提供一个内置的、无需打包即可模拟网络环境的测试平台。这使得开发者可以从项目初期就方便地测试功能在服务器和客户端上的表现。

三、核心工作流

下图清晰地展示了本章中各个核心模块之间标准的数据与指令流转过程。
其中,从“角色蓝图”到“动画蓝图”的状态数据传递 是关键一步。其实现方式是:在动画蓝图的事件图表中,通过 Try Get Pawn Owner 节点获取其拥有者(即角色本身),然后将其 类型转换(Cast To) 为我们自己的角色蓝图(如 BP_ThirdPersonCharacter),成功后就可以直接访问并读取其中的 isCrouching 等变量了。

graph LR
    subgraph A [输入系统]
        A1(物理按键被按下<br>如玩家按下C键);
    end
    subgraph B [角色蓝图]
        B1(接收输入动作<br>如“执行蹲伏”) --> B2(更新内部状态数据<br>设置 isCrouching = true);
    end
    subgraph C [动画蓝图]
        C1(读取状态数据<br>检测到 isCrouching 为 true) --> C2(切换动画状态机) --> C3(播放对应的蹲伏动画);
    end
    A1 -- 转化为输入动作 --> B1;
    B2 -- 状态数据传递 --> C1;

四、核心技术点与难点

本章涉及了构建现代多人游戏角色的多个关键技术,并解决了一些典型问题。

  • 关键技术点:

    • 增强输入系统 (Enhanced Input System): 全面使用了该系统,包括创建输入动作、映射上下文,并利用触发器(Triggers)实现了复杂的输入行为(如长按和短按的区别)。

    • 动画蓝图与状态机: 深入应用了动画蓝图的状态机,通过变量绑定和转换规则来驱动复杂的 locomotion 状态切换。

    • 蓝图接口通信: 掌握了使用蓝图接口在两个独立的蓝图类之间进行安全、解耦的数据通信方法。

    • 基础网络复制: 实践了客户端到服务器的RPC调用 (Run on Server) 和复制变量 (Replicated) 的基本工作流程,这是所有多人游戏功能的基础。

  • 难点与解决方案:

    • 难点1: 客户端擅自修改速度导致服务器校正“拉扯”

      • 问题: 在未实现RPC时,客户端单独修改行走速度,但服务器仍然维持旧速度,导致服务器强制将客户端位置拉回,产生视觉上的抖动和“拉扯感”。

      • 解决方案: 创建了一个 Run on Server 的RPC (CrouchOnServer)。当客户端需要改变姿态时,它会先在本地预测性地执行,然后通过RPC通知服务器。服务器接收到请求后,权威地改变该玩家的速度和状态,并将这个状态通过复制变量同步给所有其他客户端。

    • 难点2: 引擎在修改已使用的蓝图接口时崩溃

      • 问题: 当尝试为一个已在动画蓝图中被调用的蓝图接口函数添加新的输出参数时,引擎会发生崩溃。

      • 解决方案: 采用了一个有效的规避方法:首先在动画蓝图的事件图中,暂时断开对该接口函数的调用节点,编译并保存。然后回到蓝图接口,安全地添加新的输出参数,再次编译保存。最后,再回到动画蓝图,重新连接接口调用节点并处理新的输出引脚。

    • 难点3: 有限手柄按键的复用

      • 问题: 手柄按键有限,如何用一个按键实现“短按蹲伏,长按卧倒”?

      • 解决方案: 巧妙利用了增强输入系统的触发器功能。在输入映射中,为“蹲伏”绑定了默认的 Pressed 触发器,为“卧倒”绑定了 Hold and Release 触发器,并设置了1秒的持有时间阈值,从而在同一个物理按键上实现了两种不同的逻辑响应。

一、核心摘要

本笔记提炼了“UE5.4多人生存游戏开发教程”的完整学习路径与核心知识点。该教程(共22章,272讲)旨在引导开发者从一个空白项目开始,直至成功打包并发布一个功能完备的多人在线生存游戏。本笔记将按照课程的原始章节顺序,详尽地拆分每一个章节的核心学习节点,通过本文档可以快速掌握整个课程的脉络。

二、关键要点

  • 贯穿始终的多人网络核心:教程从第一章便引入多人模式,所有后续系统(如部落、建筑、社交)的开发都围绕网络同步展开,并最终以专用服务器的打包和云端托管(AWS)作为终点。

  • “建筑系统”是重中之重:课程对建筑系统(Chapter 9)的投入达到了惊人的30个讲座,深度涵盖了从基础放置、网格吸附到高级结构力学(如级联销毁)的全部细节,是整个教程最核心、最复杂的模块。

  • 掌握数据驱动的开发思想:教程清晰地演示了如何通过创建数据资产(Data Asset)来管理物品、配方和技能信息,实现了逻辑与数据的有效分离,这是一种专业且高效的工作流程。

  • 完整的“产品化”流程:与其他只侧重功能实现的教程不同,本课程的最后阶段(Chapter 20-21)完整地覆盖了从主菜单、游戏设置、Steam会话到最终打包、部署的全过程,为学习者提供了宝贵的上线经验。

  • 实用技术栈的综合应用:课程中运用了UE5的诸多现代技术,例如使用程序化内容生成工具(PCG)来高效创建广袤的植被环境(Chapter 15),以及集成高级会话插件来简化Steam联机功能的实现(Chapter 12)。

三、教程学习路线图

  • Chapter 1: 项目入门 (Getting Started)

    • 创建UE5.4项目与设置

    • 设置角色蓝图与第三人称摄像机

    • 搭建动画蓝图与混合空间 (Blendspace)

    • 实现跳跃、蹲伏、卧倒等角色动作

    • 添加手柄输入支持

    • 介绍多人游戏测试模式

    • 学习项目备份方法

  • Chapter 2: 构建物品系统构架 (Inventory System Framework)

    • 创建物品信息的数据资产 (Item Info Data Asset)

    • 编写库存组件 (Inventory Component)

    • 搭建库存UI界面 (Widgets)

    • 实现完整的拖拽交互逻辑 (Drag and Drop)

  • Chapter 3: 快捷栏与斧头装备 (Player Hotbar & Hatchet)

    • 创建热键栏UI与组件

    • 创建可装备物品的基类

    • 实现斧头的装备、挥砍动画与逻辑

    • 创建可采集的资源基类 (如树木)

    • 实现采集逻辑与UI提示

  • Chapter 4: 采集系统 (Harvesting System)

    • 添加镐子、岩石等更多工具和可采集物

    • 实现地面物品的交互拾取

    • 为库存系统添加物品堆叠功能 (Item Stacking)

    • 为库存界面添加手柄导航

  • Chapter 5: 制作系统 (Crafting System)

    • 创建制作配方的数据资产

    • 搭建制作界面UI,包括物品需求提示

    • 实现物品制作逻辑与进度条

  • Chapter 6: 玩家属性 (Player Stats)

    • 创建玩家状态的UI显示 (HUD)

    • 实现生命、饥饿、口渴、耐力的消耗与恢复逻辑

    • 创建消耗品并实现使用功能

    • 搭建经验值与技能点系统

  • Chapter 7: 印痕系统 (Endgram System)

    • 创建印痕(蓝图)的数据资产

    • 搭建印痕树UI界面

    • 实现印痕的解锁逻辑,并与制作系统关联

  • Chapter 8: 护甲装备 (Armor Equipables)

    • 创建护甲槽位UI与护甲数据

    • 实现护甲的穿戴与脱下逻辑

    • 在UI中实现3D角色实时预览窗口

    • 为护甲添加伤害减免与耐久度属性

  • Chapter 9: 建筑系统 (Building System)

    • 创建建筑模块的数据资产与建造预览

    • 实现网格吸附、重叠检测、结构支撑检测等核心放置逻辑

    • 制作多种建筑模块 (地基、墙、门、窗、屋顶、楼梯等)

    • 实现建筑物的伤害与级联销毁 (Collateral Damage)

    • 实现建筑物的拆除功能

  • Chapter 10: 储物容器(Storage Containers)

    • 创建储物箱UI与交互逻辑

    • 制作高级制作设施 (工作台、熔炉、烹饪锅)

    • 实现设施被摧毁后掉落物品包的功能

    • 为库存添加拆分、交换等高级功能

  • Chapter 11: 物品制作 (All Items / Weapons)

    • 实现远程武器:步枪、火箭筒、弓箭

    • 实现多功能近战武器:长矛 (刺击与投掷)

  • Chapter 12: 部落/氏族系统 (Tribes/Clans System)

    • 搭建部落管理UI界面

    • 实现部落的创建、邀请、加入、管理等全套逻辑

    • 将建筑权限与部落系统关联

  • Chapter 13: 社交系统 (Social System)

    • 实现文字聊天框 (全局与部落频道)

    • 实现玩家头顶名称标签

    • 实现近距离语音聊天功能

  • Chapter 14: 开放世界地图 (Open World Map)

    • 创建大型地编关卡与主地形材质

    • 通过导入高度图来创建地形

    • 为世界添加海洋、湖泊与河流

  • Chapter 15: 程序化植被 (Procedural Foliage)

    • 学习UE5的程序化内容生成工具 (PCG) 基础

    • 为不同生态区(森林、草原等)创建PCG图表

    • 实现草地的程序化生成

  • Chapter 16: AI系统 (AI System)

    • 创建被动型AI (鹿)

    • 创建攻击型AI (狼)

    • 实现AI的死亡与尸体采集逻辑

  • Chapter 17: 程序化刷新区 (Procedural Zones)

    • 创建AI刷新区域蓝图

    • 将可采集的PCG植被与采集系统关联

    • 实现植被的定时重生逻辑

  • Chapter 18: 小地图系统 (Player Minimaps)

    • 创建游戏中的小地图与全屏大地图

    • 制作玩家死亡与重生界面

    • 实现多点重生逻辑 (在随机区域或床上重生)

  • Chapter 19: 存档/读档系统 (Save/Load System)

    • 实现玩家核心数据(库存、技能等)的存档与读档

    • 实现世界中的建筑与部落信息的存档

  • Chapter 20: 网络会话与前端UI (Frontend Widgets & Steam Sessions)

    • 创建主菜单、设置菜单(画质、音量、键位)

    • 实现单人游戏模式

    • 通过Steam实现游戏大厅的创建、查找与加入

  • Chapter 21: 专用服务器设置与托管 (Dedicated Server Setup & Hosting)

    • 打包游戏客户端与服务器版本

    • 学习端口转发等网络基础知识

    • 使用源码版引擎进行项目编译

    • 在云服务器(AWS)上部署并运行专用服务器

  • Chapter 22: 附加内容 (Bonus Content)

    • 实现游泳系统

    • 实现基于不同表面的脚步声系统

    • 实现种植系统 (Crop Plot)

四、 对比表格:课程内容复杂度分析

此表格基于课程大纲文件中的讲座数量统计,清晰地揭示了不同模块的复杂度和学习重点。

学习模块 相关章节 总讲座数 复杂度分析
建筑系统 Chapter 9 30 最高。是整个课程中内容最深、最复杂的模块,涵盖从基础放置到高级物理的全部内容。
网络与发布 Chapter 20, 21 23 。技术性强,是课程的收尾核心,涉及大量多人游戏和部署的专业知识。
武器道具 Chapter 11 22 。包含了步枪、火箭筒、弓箭、长矛等多种武器的完整实现,逻辑复杂。
部落社交 Chapter 12 20 。涉及复杂的多人状态同步、权限管理和UI交互。
库存系统 Chapter 2 19 。作为游戏的基础,包含了大量底层的UI和数据交互逻辑。
AI 系统 Chapter 16 7 中等。实现了基础的被动型和攻击型AI,为后续扩展打下基础。
社交系统 Chapter 13 6 。主要实现了聊天框和语音等基础功能,复杂度不高。

最近在使用夸克看教程,需要频繁地暂停并跟着视频做。当前夸克是不支持通过媒体按键控制播放和暂停的,只能使用空格控制,并只有获取到焦点时才能生效。

现在需要无需切换光标或窗口,就能对另一个屏幕上的程序发送控制命令(如播放/暂停)

以 macOS 为例,使用 Hammerspoon 实现以下自动化流程:

  • 聚焦某个程序(如 Quark 网盘)
  • 向其发送空格键(控制播放/暂停)
  • 然后自动恢复原程序焦点与鼠标位置

🛠️ 工具准备

💡 实现逻辑

  1. 使用快捷键(如 Option + 空格)触发脚本
  2. 记录当前前台应用与鼠标位置
  3. 激活目标程序(Quark 网盘)并发送空格键
  4. 智能判断鼠标是否移动,决定是否恢复位置
  5. 切回原程序

📜 脚本代码

将以下代码添加到你的 ~/.hammerspoon/init.lua 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71

hs.hotkey.bind({"alt"}, "space", function()

    local quarkAppName = "Quark"

    local frontApp = hs.application.frontmostApplication()

    local originalMousePos = hs.mouse.getAbsolutePosition()



    local quarkApp = hs.application.find(quarkAppName)

    if quarkApp then

        local win = quarkApp:mainWindow()

        if win then

            quarkApp:activate(true)

            win:focus()



            hs.timer.doAfter(0.2, function()

                hs.eventtap.event.newKeyEvent({}, "space", true):post()

                hs.eventtap.event.newKeyEvent({}, "space", false):post()



                hs.timer.doAfter(0.3, function()

                    if frontApp then frontApp:activate(true) end



                    -- 检测鼠标是否移动,如果没有再恢复位置

                    local currentMousePos = hs.mouse.getAbsolutePosition()

                    local dx = math.abs(currentMousePos.x - originalMousePos.x)

                    local dy = math.abs(currentMousePos.y - originalMousePos.y)

                    if dx < 5 and dy < 5 then

                        hs.mouse.setAbsolutePosition(originalMousePos)

                    end

                end)

            end)

        else

            hs.alert("未找到 Quark 窗口")

        end

    else

        hs.alert("未找到 Quark 应用")

    end

end)

更新版本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
-- 监听系统的播放键(Play/Pause)
local quarkPlayKeyTap = hs.eventtap.new({hs.eventtap.event.types.systemDefined}, function(event)
local eventData = event:systemKey()

-- 只处理按下的 Play 键
if not eventData or eventData.key ~= "PLAY" or not eventData.down then
return false
end

local quarkAppName = "夸克网盘"
local quarkApp = hs.application.find(quarkAppName)

-- 如果夸克没运行,让系统继续处理播放键
if not quarkApp then
return false
end

local frontApp = hs.application.frontmostApplication()
local originalMousePos = hs.mouse.getAbsolutePosition()
local win = quarkApp:mainWindow()

-- 如果没有窗口,也放行
if not win then
return false
end

-- 激活夸克并发空格
quarkApp:activate(true)
win:focus()

hs.timer.doAfter(0.2, function()
hs.eventtap.keyStroke({}, "space")

hs.timer.doAfter(0.3, function()
if frontApp then frontApp:activate(true) end
local currentMousePos = hs.mouse.getAbsolutePosition()
if math.abs(currentMousePos.x - originalMousePos.x) < 5 and math.abs(currentMousePos.y - originalMousePos.y) < 5 then
hs.mouse.setAbsolutePosition(originalMousePos)
end
end)
end)

return true -- 阻止默认行为(避免 Music.app 被激活)
end)

quarkPlayKeyTap:start()

最终版?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
local log = hs.logger.new("🔊 播放键", "info")
local quarkAppName = "夸克网盘"

-- 全局变量,防止 GC
tap = nil
local lastTriggered = 0

local function isFrontmostAppQuark()
local frontApp = hs.application.frontmostApplication()
if not frontApp then return false end
local name = frontApp:name()
if not name then return false end
return name == quarkAppName
end

local lastFocusedWindow = nil
local lastMousePos = nil

local function sendSpaceToQuark()
local now = hs.timer.secondsSinceEpoch()
if now - lastTriggered < 1.5 then
log.i("⚠️ 操作过于频繁,忽略本次触发")
return
end
lastTriggered = now

local frontApp = hs.application.frontmostApplication()
log.i("当前前台应用: " .. (frontApp and frontApp:name() or "未知"))

lastMousePos = hs.mouse.absolutePosition()
lastFocusedWindow = hs.window.frontmostWindow()

local quarkApp = hs.application.find(quarkAppName)
if not quarkApp then
log.e("❌ 未找到 '" .. quarkAppName .. "' 应用")
return
end
log.i("'" .. quarkAppName .. "' 窗口数量: " .. tostring(#quarkApp:allWindows()))

local tryCount = 0
local maxTries = 10
local tryInterval = 0.3

local function tryActivate()
tryCount = tryCount + 1
log.i("尝试切换焦点到 '" .. quarkAppName .. "',次数:" .. tryCount)
quarkApp:activate(true)

hs.timer.doAfter(tryInterval, function()
local quarkWindows = quarkApp:visibleWindows()
if #quarkWindows > 0 then
log.i("聚焦 '" .. quarkAppName .. "' 可见窗口")
quarkWindows[1]:focus()
else
log.w("⚠️ '" .. quarkAppName .. "' 无可见窗口")
end

if isFrontmostAppQuark() then
log.i("✅ 成功切换焦点到 '" .. quarkAppName .. "',发送空格键")
hs.eventtap.keyStroke({}, "space")
hs.timer.doAfter(0.05, function()
hs.eventtap.keyStroke({}, "space")
log.i("⬇️ 空格键发送 x2")
hs.timer.doAfter(0.2, function()
if lastFocusedWindow and lastFocusedWindow:application() then
log.i("🔄 尝试恢复焦点到原应用")
local newMousePos = hs.mouse.absolutePosition()
if newMousePos.x == lastMousePos.x and newMousePos.y == lastMousePos.y then
local app = lastFocusedWindow:application()
app:activate(true)
hs.timer.doAfter(0.1, function()
if lastFocusedWindow:isVisible() then
lastFocusedWindow:focus()
log.i("✅ 焦点已恢复")
else
log.w("⚠️ 原窗口不可见")
end
end)
else
log.i("🖱️ 鼠标移动过,未恢复焦点")
end
else
log.w("⚠️ 无原焦点窗口")
end
end)
end)
else
if tryCount < maxTries then
log.w("⚠️ 焦点切换失败,第 " .. tryCount .. " 次," .. tryInterval .. " 秒后重试")
hs.timer.doAfter(tryInterval, tryActivate)
else
log.e("❌ 多次尝试切换焦点失败,放弃")
end
end
end)
end

tryActivate()
end

local function buildTap()
return hs.eventtap.new({ hs.eventtap.event.types.systemDefined }, function(event)
local data = event:systemKey()
if data then
log.i("检测系统键: key=" .. tostring(data.key) .. ", down=" .. tostring(data.down))
if data.key == "PLAY" then
log.i("🎬 捕获播放键")
sendSpaceToQuark()
return true
end
end
return false
end, { allowBubbles = true })
end

tap = buildTap()
tap:start()

-- 每分钟检查 tap 是否正常运行,否则重建
hs.timer.doEvery(60, function()
if not tap:isRunning() then
log.w("⚠️ tap 未运行,尝试重建")
if tap then tap:stop() end
tap = buildTap()
tap:start()
log.i("🔄 已重建 tap 监听器")
end
end)


最最终版

加上多窗口的处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
local log = hs.logger.new("🔊 播放键", "info")
local quarkAppName = "夸克网盘"

-- 全局变量,防止 GC
tap = nil
local lastTriggered = 0

local function isFrontmostAppQuark()
local frontApp = hs.application.frontmostApplication()
if not frontApp then return false end
local name = frontApp:name()
if not name then return false end
return name == quarkAppName
end

local lastFocusedWindow = nil
local lastMousePos = nil

local function sendSpaceToQuark()
local now = hs.timer.secondsSinceEpoch()
if now - lastTriggered < 1.5 then
log.i("⚠️ 操作过于频繁,忽略本次触发")
return
end
lastTriggered = now

local frontApp = hs.application.frontmostApplication()
log.i("当前前台应用: " .. (frontApp and frontApp:name() or "未知"))

lastMousePos = hs.mouse.absolutePosition()
lastFocusedWindow = hs.window.frontmostWindow()

local quarkApp = hs.application.find(quarkAppName)
if not quarkApp then
log.e("❌ 未找到 '" .. quarkAppName .. "' 应用")
return
end
log.i("'" .. quarkAppName .. "' 窗口数量: " .. tostring(#quarkApp:allWindows()))

local tryCount = 0
local maxTries = 10
local tryInterval = 0.3

local function tryActivate()
tryCount = tryCount + 1
log.i("尝试切换焦点到 '" .. quarkAppName .. "',次数:" .. tryCount)
quarkApp:activate(true)

hs.timer.doAfter(tryInterval, function()
local quarkWindows = quarkApp:visibleWindows()
if #quarkWindows > 0 then
-- 优先寻找标题包含“视频”字样的窗口
local targetWindow = nil
for _, win in ipairs(quarkWindows) do
local title = win:title() or ""
log.i("检查窗口标题: " .. title)
if title:match("视频") or title:match("播放") or title:match("%.mp4") then
targetWindow = win
break
end
end
if targetWindow then
log.i("🎯 选择播放窗口: " .. (targetWindow:title() or "无标题"))
targetWindow:focus()
else
log.w("⚠️ 未找到播放窗口,聚焦第一个窗口")
quarkWindows[1]:focus()
end
else
log.w("⚠️ '" .. quarkAppName .. "' 无可见窗口")
end

if isFrontmostAppQuark() then
log.i("✅ 成功切换焦点到 '" .. quarkAppName .. "',发送空格键")
hs.eventtap.keyStroke({}, "space")
hs.timer.doAfter(0.05, function()
hs.eventtap.keyStroke({}, "space")
log.i("⬇️ 空格键发送 x2")
hs.timer.doAfter(0.2, function()
if lastFocusedWindow and lastFocusedWindow:application() then
log.i("🔄 尝试恢复焦点到原应用")
local newMousePos = hs.mouse.absolutePosition()
if newMousePos.x == lastMousePos.x and newMousePos.y == lastMousePos.y then
local app = lastFocusedWindow:application()
app:activate(true)
hs.timer.doAfter(0.1, function()
if lastFocusedWindow:isVisible() then
lastFocusedWindow:focus()
log.i("✅ 焦点已恢复")
else
log.w("⚠️ 原窗口不可见")
end
end)
else
log.i("🖱️ 鼠标移动过,未恢复焦点")
end
else
log.w("⚠️ 无原焦点窗口")
end
end)
end)
else
if tryCount < maxTries then
log.w("⚠️ 焦点切换失败,第 " .. tryCount .. " 次," .. tryInterval .. " 秒后重试")
hs.timer.doAfter(tryInterval, tryActivate)
else
log.e("❌ 多次尝试切换焦点失败,放弃")
end
end
end)
end

tryActivate()
end

local function buildTap()
return hs.eventtap.new({ hs.eventtap.event.types.systemDefined }, function(event)
local data = event:systemKey()
if data then
log.i("检测系统键: key=" .. tostring(data.key) .. ", down=" .. tostring(data.down))
if data.key == "PLAY" then
log.i("🎬 捕获播放键")
sendSpaceToQuark()
return true
end
end
return false
end, { allowBubbles = true })
end

tap = buildTap()
tap:start()

-- 每分钟检查 tap 是否正常运行,否则重建
hs.timer.doEvery(60, function()
if not tap:isRunning() then
log.w("⚠️ tap 未运行,尝试重建")
if tap then tap:stop() end
tap = buildTap()
tap:start()
log.i("🔄 已重建 tap 监听器")
end
end)

✅ 使用说明

  1. 启动 Quark 网盘并加载播放界面
  2. 回到其他应用继续工作(如浏览器、VS Code)
  3. 按下 Option + 空格
       - 自动切换至 Quark
       - 空格键触发播放/暂停
       - 稍后自动回到原应用
       - 如果鼠标没动,还原到原本的鼠标位置

🔧 技术细节优化

  • 使用 hs.eventtap.event.newKeyEvent(),更底层模拟空格键,更兼容 Electron 应用(如 Quark 网盘)
  • app:activate(true) + win:focus() 保证窗口成为第一响应者
  • 鼠标恢复前做位置差判断,避免“跳回”造成干扰

🧾 总结

通过 Hammerspoon 脚本,可以在 macOS 上无感控制任意 App。即使是在多屏使用场景,也能做到。

不打断当前任务,一键遥控另一个程序行为。
自动化提升的不只是效率,更是专注力。

0%