上岸的鱼

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

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。即使是在多屏使用场景,也能做到。

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

代码如下:

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

#!/bin/bash

# ====== 配置路径 ======
source_folder="$HOME/Library/Obsidian/1-blog"
destination_folder="$HOME/blog/source/_posts"
image_folder="$HOME/Library/blog/source/images"
hexo_root="$HOME/Library/blog"
log_file="$hexo_root/deploy.log"

# ====== 参数处理 ======
PREVIEW=false
BACKUP=false
for arg in "$@"; do
case $arg in
--preview) PREVIEW=true ;;
--backup) BACKUP=true ;;
esac
done

# ====== 日志函数 ======
log() {
echo "[$(date +'%Y-%m-%d %H:%M:%S')] $*" | tee -a "$log_file"
}

# ====== 检查 SSH 连接 ======
log "🔐 检查 GitHub SSH 连接..."
if ! ssh -T git@github.com 2>&1 | grep -q "successfully authenticated"; then
log "❌ SSH 未配置或连接失败!请先配置 GitHub SSH key:https://github.com/settings/keys"
exit 1
fi
log "✅ SSH 连接正常"

# ====== 可选备份 ======
if $BACKUP; then
backup_zip="$HOME/Desktop/1-blog-backup_$(date +'%Y%m%d-%H%M%S').zip"
log "🗃️ 正在备份 Obsidian 博客目录..."
zip -r "$backup_zip" "$source_folder" >/dev/null
log "✅ 已备份到桌面:$backup_zip"
fi

# ====== 确保目录存在 ======
mkdir -p "$destination_folder"
mkdir -p "$image_folder"

# ====== 增量同步 Markdown 和图片文件 ======
log "📥 增量同步 Markdown 和图片..."
rsync -av --include="*/" \
--include="*.md" \
--include="*.png" \
--include="*.jpg" \
--include="*.jpeg" \
--include="*.gif" \
--include="*.svg" \
--include="*.webp" \
--exclude="*" \
"$source_folder"/ "$destination_folder"/

# ====== 替换图片语法 & 拷贝图片 ======
log "🛠 替换 Obsidian 图片语法,并复制图片..."

find "$destination_folder" -name "*.md" | while read -r mdfile; do
rel_md_path="${mdfile#$destination_folder/}"
original_md="$source_folder/$rel_md_path"
original_md_dir=$(dirname "$original_md")

# 查找 Obsidian 图片语法
perl -nle 'print $1 while /\!\[\[\s*(.*?)\s*\]\]/g' "$mdfile" | while read -r relimg; do
imgname=$(basename "$relimg")
src_img="$original_md_dir/$relimg"
dest_img="$image_folder/$imgname"

if {% post_link " -f "$src_img" " %}; then
# 增量复制
if {% post_link " ! -f "$dest_img" " %}; then
cp "$src_img" "$dest_img"
log "🖼 已复制图片: $src_img -> $dest_img"
fi
else
log "⚠️ 图片未找到: $src_img"
fi
done

# 替换 Obsidian 语法为 Hexo 路径
perl -i -pe 's/\!\[\[\s*(.*?)\s*\]\]/![]\(\/images\/\1\)/g' "$mdfile"
perl -i -pe 's/\/images\/.*\/([^\/]+\.(png|jpg|jpeg|gif|svg|webp))/\/images\/$1/g' "$mdfile"
done

log "✅ 图片路径修复完成"

# ====== Hexo 构建或预览 ======
cd "$hexo_root" || { log "❌ Hexo 根目录不存在"; exit 1; }

log "🧹 Hexo 清理 & 生成中..."
hexo clean && hexo generate

if $PREVIEW; then
log "🌐 启动本地预览..."
hexo server &
sleep 2
open "http://localhost:4000"
log "✅ 浏览器已打开本地预览"
else
log "🚀 正在部署 Hexo 博客..."
hexo deploy && log "✅ 部署完成"
fi


Unreal Engine 5:Nanite 与 Fracture 不兼容问题总结

在使用 Chaos Fracture 进行物体破坏时,如果出现以下问题:

  • 看不到碎片(Exploded View 无效)
  • 模拟或运行时无法破碎
  • 碎片无法参与物理模拟

很可能是因为你的模型启用了 Nanite

❗ 问题原因

Nanite 不支持运行时的网格拓扑变形,和 Chaos Fracture 系统不兼容。> “Nanite meshes cannot be fractured as the data representation is not compatible with Chaos destruction.” —— 官方论坛讨论原帖

✅ 解决方法

  1. 找到原始 Static Mesh
  2. 右键关闭 Nanite
  3. 保存并重新创建 Geometry Collection

📌 提示

  • 启用了 Nanite 的模型在资源图标上有绿色闪电标志 ⚡
  • 关闭 Nanite 后必须重新创建 GC,旧的无效

🧠 总结表

项目 是否支持
Nanite 支持高效渲染 ✅ 支持
Nanite Mesh 支持 Fracture ❌ 不支持
正确做法:关闭 Nanite 再破碎 ✅ 推荐

记住一句话:要做破碎,先关 Nanite

0%