feat: add Minecraft launch transition animation (cover/logo phases)

This commit is contained in:
Xiao-no-love
2026-09-15 22:33:18 +08:00
parent bd09fed0ed
commit 776f621436
8 changed files with 1301 additions and 406 deletions

308
README.md
View File

@ -1,4 +1,3 @@
<div align="center"> <div align="center">
<img src="./apps/app/icons/128x128.png" width="128" height="128" alt="Starlight Launcher Logo" /> <img src="./apps/app/icons/128x128.png" width="128" height="128" alt="Starlight Launcher Logo" />
<h1>Starlight Launcher</h1> <h1>Starlight Launcher</h1>
@ -73,3 +72,310 @@ Starlight Launcher 的进步离不开社区的反馈与贡献。
参与社区和贡献代码前,也请先阅读[行为准则 (CODE_OF_CONDUCT.md)](CODE_OF_CONDUCT.md)。 参与社区和贡献代码前,也请先阅读[行为准则 (CODE_OF_CONDUCT.md)](CODE_OF_CONDUCT.md)。
--- ---
# 🌟 启动过渡动画(本次改动)
> 本节记录「游戏启动时窗口平滑过渡」功能的完整实现,供后续维护、移植与继续开发使用。
> **生效平台:仅 Windows**(其他平台为空实现,行为与改动前一致)。
## 一、功能目标
消除「点击启动 → MC 游戏窗口突然弹出 → 把启动器挤走」的割裂感,用一套完整的窗口动画把这段过渡变得顺滑。
启动一个实例后,会经历以下动画序列:
```
点击「启动实例」
① 纯色遮罩出现(启动器主题色,无图)—— 立刻盖住后续所有抖动
② 启动器放大到全屏0.4s 动画,全程置顶 + 聚焦)
③ 全屏后,遮罩上渐显 Mojang SVG logo0.5s
④ 等待 MC 游戏窗口出现(最多 60 秒,期间遮罩一直盖着)
⑤ 缩小前:遮罩背景色 → Mojang 红 (#db1f29) + 渐隐 logo0.5s
⑥ 启动器缩放到 MC 窗口的位置和大小0.4s
⑦ 等待 1 秒(让游戏稳定,遮罩仍在)
⑧ 整体淡出透明0.5s)→ 聚焦游戏窗口 → 启动器最小化
进入轻量模式(隐藏到系统托盘)
```
**设计要点**
- **遮罩提前**:纯色遮罩在放大**之前**就出现,所以放大和缩小时的窗口抖动都被盖住,用户看不到。
- **缩放与淡出串行**:先缩放完成,再淡出,不再同步进行(早期版本两者同步,观感差)。
- **动画放慢**:缩放 0.4s(早期 0.2s 太快),淡出 0.5s。
- **修复重开透明**:最小化后重置窗口透明度,并且恢复时用轻量模式**重建全新窗口**,彻底杜绝「重新打开启动器一片透明、需要点击/拖动才恢复」的问题。
- **轻量模式接管**:过渡动画结束后,统一进入轻量模式(隐藏到托盘),不再分别判断用户的「轻量模式 / 启动后隐藏」设置,避免冲突。
## 二、改动文件总览
| 文件 | 改动类型 | 说明 |
|---|---|---|
| `apps/app/src/mc_transition.rs` | **新建** | 核心模块(约 470 行),负责窗口查找、四段动画、遮罩/淡出协调 |
| `apps/app/src/main.rs` | 修改 | 声明模块 + 注册 4 个 Tauri 命令 |
| `apps/app/src/lightweight_mode.rs` | 修改 | `launched` 分支接入过渡,并统一进轻量模式 |
| `apps/app/Cargo.toml` | 修改 | windows crate 增加 `Win32_Graphics_Gdi` feature |
| `apps/app-frontend/src/App.vue` | 修改 | 遮罩三阶段、淡出、透明度重置等前端逻辑 |
| `apps/app-frontend/public/mojang-logo.svg` | 新建 | 过渡遮罩上显示的 Mojang logo |
| `packages/app-lib/.env` | 新建 | 从 `.env.prod` 复制,提供编译期环境变量(非功能改动) |
## 三、各文件详细说明
### 1. `apps/app/src/mc_transition.rs`(新建 · 核心)
> 搜索定位关键词:`mc_transition`
**关键常量**
| 常量 | 值 | 含义 |
|---|---|---|
| `FIND_RETRIES` | 120 | 等待 MC 窗口的最大轮询次数 |
| `FIND_INTERVAL_MS` | 500 | 每次轮询间隔120 × 500ms = 60 秒) |
| `ANIM_DURATION_MS` | 400 | 放大/缩小动画时长0.4s |
| `ANIM_STEPS` | 50 | 动画帧数400ms / 50 = 8ms 一帧) |
| `FADE_TIMEOUT_MS` | 700 | 等待前端淡出的超时兜底 |
**静态变量(跨 await 传信号)**
- `FADE_DONE` — 等前端淡出完成
- `COVER_DONE` — 等纯色遮罩出现
- `LOGO_IN_DONE` — 等 logo 渐显完成
- `LOGO_OUT_DONE` — 等 logo 渐隐 + 变红完成
**关键函数**
| 函数 | 关键词 | 作用 |
|---|---|---|
| `pub async fn run(app, pid, maximize)` | `run() invoked` | 主入口,串联全部 8 步 |
| `async fn animate_resize(...)` | `animate_resize` | 分帧移动 + 缩放窗口 |
| `fn ease_in_out(t)` | `ease_in_out` | 缓入缓出曲线 |
| `win_impl::find_window(pid)` | `find_cb` / `FOUND_HWND` | 按 pid 枚举顶层窗口 |
| `win_impl::covers_monitor(raw)` | `covers_monitor` | 判断窗口是否铺满显示器 |
| `win_impl::get_rect(raw)` | `get_rect` | 取窗口矩形 |
| `win_impl::focus(raw)` | `SetForegroundWindow` | 把焦点交给游戏 |
| `win_impl::dump_pid_windows(pid)` | `dump_pid_windows` | **调试用**:列出 pid 下所有窗口 |
| `#[tauri::command] mc_transition_fade_done` | — | 前端淡出完成回调 |
| `#[tauri::command] mc_transition_cover_ready` | — | 遮罩出现完成回调 |
| `#[tauri::command] mc_transition_logo_in_done` | — | logo 渐显完成回调 |
| `#[tauri::command] mc_transition_logo_out_done` | — | logo 渐隐完成回调 |
**⚠️ 三个关键设计约束(改代码前必读)**
1. **HWND 不能跨 await 持有**
`HWND` 是裸指针,非 `Send`,一旦跨 `await` 持有async 块就不是 `Send`,无法被 `tauri::async_runtime::spawn`
→ 因此全程用 `usize` 保存窗口句柄(变量 `mc_raw`),只在同步的 win32 调用里通过 `to_hwnd()` 临时转回。
搜索关键词:`to_hwnd``FOUND_HWND`
2. **`BOOL` 的导入路径**。
`windows` crate 0.61 中,`BOOL` 位于 `windows::core::BOOL`**不在** `Win32::Foundation`
搜索关键词:`use windows::core::BOOL`
3. **窗口尺寸过滤阈值**
`find_cb` 里只认宽高都 > 50 的可见窗口,用来排除工具窗口 / 0 尺寸窗口。
**⚠️ 调试代码仍在(上线前需清理)**
- `fn dbg(msg)` —— 把日志直接写入 `%USERPROFILE%\mc_transition_debug.log`,绕过 tracing 配置
- 全文件多处 `dbg(...)` 调用
- 等待窗口期间每 2 秒调用一次 `dump_pid_windows`
- 搜索关键词:`fn dbg(``mc_transition_debug.log``dump_pid_windows`
### 2. `apps/app/src/main.rs`
- 第 25 行附近:`mod mc_transition;`
- `invoke_handler``generate_handler!` 宏内注册 4 个命令:
```rust
mc_transition::mc_transition_fade_done,
mc_transition::mc_transition_cover_ready,
mc_transition::mc_transition_logo_in_done,
mc_transition::mc_transition_logo_out_done,
```
- 搜索关键词:`mod mc_transition``mc_transition_fade_done`
### 3. `apps/app/src/lightweight_mode.rs`
`process_event``None if payload.event == "launched"` 分支(约 250 行起):
- 新增调用:`crate::mc_transition::run(&app, payload.pid, payload.maximize_window).await;`
- 过渡完成后**统一**进入轻量模式:`state.enter(&app)`
- **删除**了原来分别判断 `settings.enter_lightweight_mode_on_game_launch``settings.hide_on_process_start` 的逻辑
- 搜索关键词:`crate::mc_transition::run``launched``enter lightweight mode after transition`
**执行顺序**
```
maximize_minecraft_window若用户勾选最大化
→ mc_transition::run完整过渡动画
→ state.enter()(隐藏到托盘)
```
### 4. `apps/app/Cargo.toml`
`[target."cfg(windows)".dependencies.windows]` 的 features 中新增:
```toml
"Win32_Graphics_Gdi",
```
> 原因:`GetMonitorInfoW` / `MonitorFromWindow` / `MONITORINFO` 属于 GDI 模块,不加会编译失败。
- 搜索关键词:`Win32_Graphics_Gdi`
### 5. `apps/app-frontend/src/App.vue`
**变量声明**(约 376~381 行):
```ts
let unlistenMcTransitionFadeout: (() => void) | undefined
let unlistenMcTransitionFocus: (() => void) | undefined
let unlistenMcTransitionCover: (() => void) | undefined
let unlistenMcTransitionLogoIn: (() => void) | undefined
let unlistenMcTransitionLogoOut: (() => void) | undefined
let unlistenMcTransitionReset: (() => void) | undefined
```
**`onMounted` 内注册的监听**(约 500 行起):
| 监听事件 | 作用 |
|---|---|
| `mc-transition-fadeout` | `#app``opacity: 0` + 500ms 过渡510ms 后回调 `mc_transition_fade_done` |
| `mc-transition-cover-show` | 动态创建遮罩 div主题色背景 + 隐藏的 logo渐显后回调 `mc_transition_cover_ready` |
| `mc-transition-logo-in` | logo 渐显opacity 0→1520ms 后回调 `mc_transition_logo_in_done` |
| `mc-transition-logo-out` | 遮罩背景 → `#db1f29` + logo 渐隐520ms 后回调 `mc_transition_logo_out_done` |
| `mc-transition-reset-opacity` | 重置 `#app` 透明度 + 移除遮罩 |
**透明度重置(修复重开透明)**——三重兜底:
- `window``focus` 事件
- `document``visibilitychange` 事件
- Tauri 的 `getCurrentWindow().onFocusChanged`
任一触发即调用 `resetLauncherOpacity()`,把 `#app` 的 opacity 清空。
**`onUnmounted` 内清理**(约 674 行起6 个 `unlistenMcTransition*.?.()`
- 搜索关键词:`unlistenMcTransitionFadeout``mc-transition-fadeout``mc_transition_fade_done``mcCoverEl``resetLauncherOpacity`
**遮罩实现细节**
遮罩是**运行时动态创建**的 DOM不是 Vue template避免改动庞大的 template 结构:
```ts
mcCoverEl = document.createElement('div') // 固定定位、铺满、主题色背景、最高 z-index
mcCoverImg = document.createElement('img') // /mojang-logo.svg初始 opacity 0
```
- 遮罩 id`mc-transition-cover`
- 背景色:`var(--color-brand, #db1f29)`(跟随启动器主题色)
- logo 宽度42%,最大 640px
### 6. `apps/app-frontend/public/mojang-logo.svg`(新建)
过渡遮罩上显示的 Mojang 官方 logo矢量图
## 四、实测记录
调试日志 `%USERPROFILE%\mc_transition_debug.log` 中的一次完整流程实录:
```
run() invoked pid=3088 maximize=false
try#0 ... try#38 candidates: [] <- MC 窗口约 20 秒后才出现
FOUND hwnd=0x3b03da try#40 covers=false
...
```
**关键发现**
- **MC 窗口需要约 20 秒才出现**(带 mod 的实例更慢),所以等待轮询必须足够长(当前 60s
- 早期版本只等 5 秒,导致「启动器毫无变化」的假象——实际是超时静默放弃了。
## 五、编译与运行Windows
### 前置准备
```bat
:: 1. 子模块(本项目不是 git 仓库,需手动 clone
git clone https://github.com/Cubitect/cubiomes.git apps/app/vendor/cubiomes
git clone https://github.com/Axolotl-Launcher/blockbench-skin-standalone.git third-party/blockbench
:: 2. 环境变量(或把 .env.prod 复制为 packages/app-lib/.env
set "PATH=%USERPROFILE%\.cargo\bin;%PATH%"
set "MODRINTH_URL=https://modrinth.com/"
set "MODRINTH_API_BASE_URL=https://api.modrinth.com/"
set "MODRINTH_ARCHON_BASE_URL=https://archon.modrinth.com/"
set "MODRINTH_API_URL=https://api.modrinth.com/v2/"
set "MODRINTH_API_URL_V3=https://api.modrinth.com/v3/"
set "MODRINTH_SOCKET_URL=wss://api.modrinth.com/"
set "MODRINTH_LAUNCHER_META_URL=https://launcher-meta.modrinth.com/"
```
### 构建
```bat
:: 前端
pnpm install
pnpm --filter @modrinth/app-frontend run build
:: 后端 debug产物 target/debug/theseus_gui.exe约 155 MB
cargo build -p theseus_gui
:: 后端 release产物 target/release/theseus_gui.exe约 75 MB
cargo build -p theseus_gui --release
```
### 已知坑
| 坑 | 现象 | 解决 |
|---|---|---|
| 子模块缺失 | `build.rs` panic`Blockbench skin editor submodule is missing` | 手动 clone 两个子模块 |
| 资源路径校验 | `resource path resources\blockbench-skin doesn't exist` | 构建会自动生成;若报错手动建空目录 |
| 环境变量缺失 | `environment variable MODRINTH_API_URL not defined` | 见上方环境变量,或复制 `.env` |
| 数据库迁移冲突 | `migration ... was previously applied but is missing` | 删除 `%APPDATA%\cool.starlight.launcher\release\app.db` 后重启 |
| exe 被占用 | `failed to remove file ...theseus_gui.exe`(拒绝访问) | `taskkill /F /IM theseus_gui.exe` 后重编 |
| `pnpm app:build` 失败 | 找不到 cargo | 那会走完整 `tauri build` 打包;只构建前端请用 `--filter @modrinth/app-frontend` |
## 六、待办 / TODO
| # | 事项 | 优先级 | 关键词 |
|---|---|---|---|
| 1 | **清理调试代码**:删 `fn dbg` + 所有 `dbg(...)` + `dump_pid_windows` 调用 | 高 | `fn dbg(``mc_transition_debug.log` |
| 2 | 等待窗口超时60s可做成可配置 / 事件驱动 | 中 | `FIND_RETRIES` |
| 3 | 多显示器场景未充分测试(已按窗口所在屏取显示器) | 中 | `monitor_rect` |
| 4 | 前端淡出若卡顿靠 700ms 兜底 | 低 | `FADE_TIMEOUT_MS` |
| 5 | `SetForegroundWindow` 可能被系统拒绝,未做失败重试 | 低 | `focus` |
| 6 | 未来若做 macOS / Linux 支持:需 `CGWindowListCopyWindowInfo` / X11Wayland 基本不可行) | 低 | `#[cfg(target_os]` |
## 七、快速搜索索引
| 想找什么 | 搜这个关键词 |
|---|---|
| 核心模块 | `mc_transition` |
| 过渡主流程 | `pub async fn run(app` |
| 八步流程标记 | `step 1` ~ `step 8` |
| 找 MC 窗口 | `find_window``find_cb` |
| 全屏判定 | `covers_monitor` |
| 缩放动画 | `animate_resize``ANIM_DURATION_MS` |
| 缓动曲线 | `ease_in_out` |
| 纯色遮罩事件 | `mc-transition-cover-show` |
| logo 渐显事件 | `mc-transition-logo-in` |
| logo 渐隐/变红事件 | `mc-transition-logo-out` |
| 淡出事件 | `mc-transition-fadeout` |
| 透明度重置事件 | `mc-transition-reset-opacity` |
| 前端回调命令 | `mc_transition_cover_ready``mc_transition_logo_in_done``mc_transition_logo_out_done``mc_transition_fade_done` |
| 接入点 | `crate::mc_transition::run` |
| 透明度重置函数 | `resetLauncherOpacity` |
| 遮罩 DOM | `mc-transition-cover``mcCoverEl` |
| 调试日志(待删) | `fn dbg(``mc_transition_debug.log` |
| 调试日志文件 | `%USERPROFILE%\mc_transition_debug.log` |
| windows feature | `Win32_Graphics_Gdi` |
| 数据库冲突 | `previously applied but is missing` |
---
_文档结束。功能已跑通上线前记得清理调试代码喵。_

View File

@ -0,0 +1 @@
<svg height="388" width="2500" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 375.103901073386 57.671"><path d="M57.355.316H16.519C7.396.316 0 7.713 0 16.834V57.671h40.836c9.124 0 16.519-7.396 16.519-16.518V7.38z" fill="#db1f29"/><path d="M13.772 48.104c-4.892 0-4.892-4.892-4.892-4.892V18.746c0-4.893 4.892-4.893 4.892-4.893h9.786c4.894 0 9.786 4.893 9.786 4.893s4.894 0 0 0c0-14.251 4.501 5.285 9.786 0 3.46-3.459 4.893 17.125 4.893 17.125l-4.893-2.444s0-4.894-7.339-9.786c-9.104-6.071-19.572-2.071-19.572 7.338 0 19.196 31.804 12.233 31.804 12.233s0 4.892-4.893 4.892H13.772z" fill="#fff"/><path d="M38.238 13.854c0-1.35 1.095-7.34 2.445-7.34 1.352 0 2.446 5.99 2.446 7.34a2.445 2.445 0 1 1-4.891 0z" fill="#fff"/><path d="M105.714 56.675V21.379L93.342 39.77H87.85L75.479 21.379v35.296h-9.85V.996h7.899l17.109 25.186L107.747.996h7.745v55.679zM149.727 57.671c-7.744 0-14.943-3.021-20.271-8.507-5.375-5.545-8.333-12.763-8.333-20.325 0-7.57 2.959-14.788 8.333-20.325C134.783 3.101 142.169 0 149.727 0c7.636 0 14.858 3.02 20.337 8.502 5.384 5.549 8.343 12.767 8.343 20.336 0 7.562-2.958 14.781-8.332 20.325-5.412 5.489-12.636 8.508-20.348 8.508zm0-47.666c-4.919 0-9.567 1.979-13.088 5.574-3.56 3.484-5.512 8.188-5.512 13.26 0 5.069 1.952 9.77 5.496 13.237 3.531 3.61 8.179 5.59 13.104 5.59 4.947 0 9.774-2.031 13.242-5.573 3.506-3.51 5.433-8.215 5.433-13.254 0-5.042-1.927-9.75-5.426-13.252-3.48-3.55-8.307-5.582-13.249-5.582zM180.215 56.675v-9.396h1.062c16.254 0 19.52-5.682 19.52-14.848V10.236h-20.582V1.071h30.432v31.36c0 16.993-8.783 24.243-29.369 24.243h-1.063zM253.239 56.675l-5.128-13.543h-20.26l-5.204 13.543h-10.633L234.354.312h7.106l22.34 56.363zm-8.762-22.931l-6.491-17.066-6.491 17.066zM301.564 56.675l-24.237-35.068v35.068h-9.853V.996h7.588l24.467 35.376V.996h9.776v55.679zM343.234 57.671c-7.744 0-14.943-3.021-20.271-8.506-5.375-5.546-8.334-12.764-8.334-20.326 0-7.57 2.959-14.788 8.332-20.325C328.29 3.101 335.677 0 343.234 0c7.634 0 14.857 3.02 20.337 8.502a29.986 29.986 0 0 1 4.891 6.671l.842 1.566h-11.782l-.501-.563a12.058 12.058 0 0 0-.539-.591c-3.479-3.549-8.305-5.58-13.247-5.58-4.919 0-9.567 1.979-13.089 5.574-3.559 3.484-5.511 8.188-5.511 13.26 0 5.069 1.952 9.77 5.496 13.237 3.53 3.61 8.179 5.59 13.104 5.59 4.947 0 9.773-2.031 13.24-5.573a18.397 18.397 0 0 0 4.85-8.598h-20.991V23.49h31.095l.14.899c.229 1.469.347 2.965.347 4.45 0 7.562-2.959 14.78-8.331 20.325-5.415 5.488-12.639 8.507-20.351 8.507z"/></svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@ -373,6 +373,12 @@ let allowWindowClose = false
let unlistenCloseRequested: (() => void) | undefined let unlistenCloseRequested: (() => void) | undefined
let unlistenLightweightModeError: (() => void) | undefined let unlistenLightweightModeError: (() => void) | undefined
let unlistenSystemAccentColor: (() => void) | undefined let unlistenSystemAccentColor: (() => void) | undefined
let unlistenMcTransitionFadeout: (() => void) | undefined
let unlistenMcTransitionFocus: (() => void) | undefined
let unlistenMcTransitionCover: (() => void) | undefined
let unlistenMcTransitionLogoIn: (() => void) | undefined
let unlistenMcTransitionLogoOut: (() => void) | undefined
let unlistenMcTransitionReset: (() => void) | undefined
let maximizedStateTimer: ReturnType<typeof setTimeout> | undefined let maximizedStateTimer: ReturnType<typeof setTimeout> | undefined
let unlistenWindowResize: (() => void) | undefined let unlistenWindowResize: (() => void) | undefined
const minecraftCrashModal = ref() const minecraftCrashModal = ref()
@ -491,6 +497,104 @@ onMounted(async () => {
checkUpdates() checkUpdates()
void warnIfRunningElevated() void warnIfRunningElevated()
startDirectLinkSync() startDirectLinkSync()
unlistenMcTransitionFadeout = await listen('mc-transition-fadeout', async () => {
// 游戏窗口已就位、启动器已缩放到同尺寸。这里做 0.5s CSS 淡出,
// 完成后回调 Rust由后端执行聚焦游戏 + 最小化启动器。
const root = document.getElementById('app')
if (root) {
root.style.transition = 'opacity 500ms ease-out'
root.style.opacity = '0'
}
window.setTimeout(() => {
void invoke('mc_transition_fade_done').catch(() => {})
}, 510)
})
// 窗口恢复可见时,清掉残留的淡出透明度,避免再次打开时全透明。
const resetLauncherOpacity = () => {
const root = document.getElementById('app')
if (root) {
root.style.transition = 'none'
root.style.opacity = ''
void root.offsetHeight
}
}
window.addEventListener('focus', resetLauncherOpacity)
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') resetLauncherOpacity()
})
unlistenMcTransitionFocus = await getCurrentWindow().onFocusChanged(({ payload: focused }) => {
if (focused) resetLauncherOpacity()
})
// ============ 启动过渡遮罩(三阶段) ============
// 阶段1纯色遮罩主题色无图—— 盖住放大抖动
// 阶段2全屏后渐显 SVG logo
// 阶段3缩小前背景变 Mojang 红 + 渐隐 logo
let mcCoverEl = null
let mcCoverImg = null
unlistenMcTransitionCover = await listen('mc-transition-cover-show', () => {
const app = document.getElementById('app')
if (!app) return
if (!mcCoverEl) {
mcCoverEl = document.createElement('div')
mcCoverEl.id = 'mc-transition-cover'
mcCoverEl.style.cssText = [
'position: fixed',
'inset: 0',
'z-index: 999999',
'display: flex',
'align-items: center',
'justify-content: center',
'background: var(--color-brand, #db1f29)',
'transition: background-color 500ms ease, opacity 300ms ease-out',
'opacity: 0',
'pointer-events: none',
].join(';')
mcCoverImg = document.createElement('img')
mcCoverImg.src = '/mojang-logo.svg'
mcCoverImg.alt = ''
mcCoverImg.style.cssText =
'width: 42%; max-width: 640px; height: auto; opacity: 0; transition: opacity 500ms ease-out;'
mcCoverEl.appendChild(mcCoverImg)
app.appendChild(mcCoverEl)
}
requestAnimationFrame(() => {
mcCoverEl.style.opacity = '1'
})
window.setTimeout(() => {
void invoke('mc_transition_cover_ready').catch(() => {})
}, 320)
})
unlistenMcTransitionLogoIn = await listen('mc-transition-logo-in', () => {
if (mcCoverImg) mcCoverImg.style.opacity = '1'
window.setTimeout(() => {
void invoke('mc_transition_logo_in_done').catch(() => {})
}, 520)
})
unlistenMcTransitionLogoOut = await listen('mc-transition-logo-out', () => {
if (mcCoverEl) mcCoverEl.style.backgroundColor = '#db1f29'
if (mcCoverImg) mcCoverImg.style.opacity = '0'
window.setTimeout(() => {
void invoke('mc_transition_logo_out_done').catch(() => {})
}, 520)
})
unlistenMcTransitionReset = await listen('mc-transition-reset-opacity', () => {
const app = document.getElementById('app')
if (app) {
app.style.transition = 'none'
app.style.opacity = ''
}
if (mcCoverEl) {
mcCoverEl.remove()
mcCoverEl = null
mcCoverImg = null
}
})
}) })
let directLinkSync: (() => Promise<void>) | undefined let directLinkSync: (() => Promise<void>) | undefined
@ -567,6 +671,12 @@ onUnmounted(async () => {
window.removeEventListener('keydown', handleGlobalKeydown, true) window.removeEventListener('keydown', handleGlobalKeydown, true)
unlistenCloseRequested?.() unlistenCloseRequested?.()
unlistenLightweightModeError?.() unlistenLightweightModeError?.()
unlistenMcTransitionFadeout?.()
unlistenMcTransitionFocus?.()
unlistenMcTransitionCover?.()
unlistenMcTransitionLogoIn?.()
unlistenMcTransitionLogoOut?.()
unlistenMcTransitionReset?.()
unlistenSystemAccentColor?.() unlistenSystemAccentColor?.()
document.querySelector('body').removeEventListener('click', handleClick) document.querySelector('body').removeEventListener('click', handleClick)
document.querySelector('body').removeEventListener('auxclick', handleAuxClick) document.querySelector('body').removeEventListener('auxclick', handleAuxClick)

View File

@ -92,6 +92,7 @@ features = [
"Foundation", "Foundation",
"UI_ViewManagement", "UI_ViewManagement",
"Win32_Graphics_Dwm", "Win32_Graphics_Dwm",
"Win32_Graphics_Gdi",
"Win32_System_Com", "Win32_System_Com",
"Win32_UI_Shell", "Win32_UI_Shell",
"Win32_UI_WindowsAndMessaging", "Win32_UI_WindowsAndMessaging",

View File

@ -253,29 +253,22 @@ impl LightweightMode {
if payload.maximize_window { if payload.maximize_window {
maximize_minecraft_window(payload.pid).await; maximize_minecraft_window(payload.pid).await;
} }
let settings = match theseus::settings::get().await { // 窗口过渡:把启动器全屏遮罩 → 缩放 → 淡出 → 最小化
Ok(settings) => settings, crate::mc_transition::run(
Err(error) => { &app,
tracing::warn!( payload.pid,
"Failed to read lightweight mode setting: {error}" payload.maximize_window,
); )
return; .await;
} // 过渡动画结束后,统一进入轻量模式(隐藏到托盘)。
}; // 由过渡流程接管,不再各自判断用户的「轻量模式 /
if settings.enter_lightweight_mode_on_game_launch { // 启动后隐藏」设置,避免与动画收尾冲突,也顺带修掉
let state = app.state::<LightweightMode>(); // 「重开时窗口全透明」的问题(轻量模式恢复时是重建
if let Err(error) = state.enter(&app) { // 全新窗口,不残留旧的 opacity
tracing::warn!( let state = app.state::<LightweightMode>();
"Failed to enter lightweight mode: {error}" if let Err(error) = state.enter(&app) {
);
}
} else if settings.hide_on_process_start
&& let Some(window) =
app.get_webview_window(MAIN_WINDOW_LABEL)
&& let Err(error) = window.minimize()
{
tracing::warn!( tracing::warn!(
"Failed to minimize launcher after Minecraft started: {error}" "Failed to enter lightweight mode after transition: {error}"
); );
} }
}); });

View File

@ -22,6 +22,7 @@ use theseus::prelude::*;
mod api; mod api;
mod error; mod error;
mod lightweight_mode; mod lightweight_mode;
mod mc_transition;
mod mod_translation; mod mod_translation;
mod portable; mod portable;
mod seed_map; mod seed_map;
@ -867,6 +868,10 @@ fn main() {
lightweight_mode::lightweight_mode_frontend_ready, lightweight_mode::lightweight_mode_frontend_ready,
lightweight_mode::lightweight_mode_set_route, lightweight_mode::lightweight_mode_set_route,
lightweight_mode::lightweight_mode_enter, lightweight_mode::lightweight_mode_enter,
mc_transition::mc_transition_fade_done,
mc_transition::mc_transition_cover_ready,
mc_transition::mc_transition_logo_in_done,
mc_transition::mc_transition_logo_out_done,
]); ]);
tracing::info!("Initializing app..."); tracing::info!("Initializing app...");

View File

@ -0,0 +1,470 @@
//! Minecraft 启动时的窗口过渡动画(仅 Windows 生效)。
//!
//! 流程(四阶段):
//! 阶段A启动器放大到全屏1s 动画),全程置顶 + 聚焦;
//! 阶段B等待 MC 窗口出现(最多 60s期间启动器盖住游戏
//! 阶段C获取 MC 窗口位置/大小1s 动画缩放到相同大小;
//! 阶段D大小一致后0.5s 淡出(不与缩放同步);
//! 阶段E聚焦游戏窗口 → 启动器最小化。
//!
//! 过渡结束后返回,由调用方继续执行用户的轻量模式 / 隐藏设置。
//!
//! 注意HWND 是裸指针,非 Send不能跨 await 持有,因此一律用 `usize`
//! 保存窗口句柄,只在同步的 win32 调用里临时转回 HWND。
#![cfg_attr(not(target_os = "windows"), allow(dead_code, unused_imports))]
use tauri::{AppHandle, Emitter, Manager, WebviewWindow};
/// 主窗口 label与 lightweight_mode.rs 保持一致。
pub const MAIN_WINDOW_LABEL: &str = "main";
/// 等待 MC 窗口出现的轮询次数与间隔120 × 500ms = 60s
const FIND_RETRIES: u32 = 120;
const FIND_INTERVAL_MS: u64 = 500;
/// 缩放动画总时长与帧数1000ms / 50 帧 = 20ms 一帧)。
const ANIM_DURATION_MS: u64 = 400;
const ANIM_STEPS: u32 = 50;
/// 等待前端完成 CSS 淡出的超时兜底(毫秒)。淡出 500ms留足余量。
const FADE_TIMEOUT_MS: u64 = 700;
#[cfg(target_os = "windows")]
static FADE_DONE: std::sync::Mutex<
Option<tokio::sync::oneshot::Sender<()>>,
> = std::sync::Mutex::new(None);
#[cfg(target_os = "windows")]
static COVER_DONE: std::sync::Mutex<
Option<tokio::sync::oneshot::Sender<()>>,
> = std::sync::Mutex::new(None);
#[cfg(target_os = "windows")]
static LOGO_IN_DONE: std::sync::Mutex<
Option<tokio::sync::oneshot::Sender<()>>,
> = std::sync::Mutex::new(None);
#[cfg(target_os = "windows")]
static LOGO_OUT_DONE: std::sync::Mutex<
Option<tokio::sync::oneshot::Sender<()>>,
> = std::sync::Mutex::new(None);
/// 调试日志:直接追加到用户目录下固定文件,绕过 tracing 配置。
/// TODO(上线前清理):连同所有 `dbg(...)` 调用一起删除。
fn dbg(msg: &str) {
use std::io::Write;
let base =
std::env::var("USERPROFILE").unwrap_or_else(|_| "C:\\".to_string());
let path = format!("{base}\\mc_transition_debug.log");
if let Ok(mut f) =
std::fs::OpenOptions::new().create(true).append(true).open(path)
{
let _ = writeln!(f, "{msg}");
}
}
/// 前端完成淡出动画后的回调命令。
#[tauri::command]
pub fn mc_transition_fade_done() {
#[cfg(target_os = "windows")]
{
dbg("fade_done callback");
if let Ok(mut guard) = FADE_DONE.lock()
&& let Some(tx) = guard.take()
{
let _ = tx.send(());
}
}
}
/// 前端覆盖层渐显完成后的回调命令。
#[tauri::command]
pub fn mc_transition_cover_ready() {
#[cfg(target_os = "windows")]
{
dbg("cover_ready callback");
if let Ok(mut guard) = COVER_DONE.lock()
&& let Some(tx) = guard.take()
{
let _ = tx.send(());
}
}
}
/// 前端 SVG logo 渐显完成后的回调命令。
#[tauri::command]
pub fn mc_transition_logo_in_done() {
#[cfg(target_os = "windows")]
{
dbg("logo_in_done callback");
if let Ok(mut guard) = LOGO_IN_DONE.lock()
&& let Some(tx) = guard.take()
{
let _ = tx.send(());
}
}
}
/// 前端 SVG logo 渐隐 + 背景变红完成后的回调命令。
#[tauri::command]
pub fn mc_transition_logo_out_done() {
#[cfg(target_os = "windows")]
{
dbg("logo_out_done callback");
if let Ok(mut guard) = LOGO_OUT_DONE.lock()
&& let Some(tx) = guard.take()
{
let _ = tx.send(());
}
}
}
/// 缓入缓出曲线,比纯 ease-out 更柔和自然。
#[cfg(target_os = "windows")]
fn ease_in_out(t: f64) -> f64 {
if t < 0.5 {
4.0 * t * t * t
} else {
1.0 - (-2.0 * t + 2.0).powi(3) / 2.0
}
}
/// 分帧移动 + 缩放启动器窗口。`from`/`to` 为 (x, y, w, h)。
/// 若起止相同则直接返回;结束时补一帧精确坐标,避免累积误差。
#[cfg(target_os = "windows")]
async fn animate_resize(
window: &WebviewWindow,
from: (i32, i32, u32, u32),
to: (i32, i32, u32, u32),
duration_ms: u64,
) {
use tauri::{PhysicalPosition, PhysicalSize};
if from == to {
return;
}
let step_ms = (duration_ms / ANIM_STEPS as u64).max(1);
for step in 1..=ANIM_STEPS {
let t = step as f64 / ANIM_STEPS as f64;
let e = ease_in_out(t);
let x = from.0 as f64 + (to.0 - from.0) as f64 * e;
let y = from.1 as f64 + (to.1 - from.1) as f64 * e;
let w = from.2 as f64 + (to.2 as i64 - from.2 as i64) as f64 * e;
let h = from.3 as f64 + (to.3 as i64 - from.3 as i64) as f64 * e;
let _ =
window.set_position(PhysicalPosition::new(x as i32, y as i32));
let _ = window.set_size(PhysicalSize::new(
w.max(1.0) as u32,
h.max(1.0) as u32,
));
tokio::time::sleep(std::time::Duration::from_millis(step_ms)).await;
}
// 补最后一帧精确值
let _ = window.set_position(PhysicalPosition::new(to.0, to.1));
let _ = window.set_size(PhysicalSize::new(to.2.max(1), to.3.max(1)));
}
pub async fn run(app: &AppHandle, pid: u32, _maximize: bool) {
if pid == 0 {
dbg("run() aborted: pid=0");
return;
}
let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) else {
dbg("run() aborted: main window not found");
return;
};
dbg(&format!("run() invoked pid={pid}"));
// 记录启动器当前矩形作为起点
let start = {
let p = window.outer_position().map(|p| (p.x, p.y)).unwrap_or((0, 0));
let s =
window.outer_size().map(|s| (s.width, s.height)).unwrap_or((1280, 800));
(p.0, p.1, s.0, s.1)
};
dbg(&format!("launcher start rect = {start:?}"));
// 全屏目标 = 启动器当前所在显示器的完整矩形
let fullscreen = match window
.current_monitor()
.ok()
.flatten()
.or_else(|| window.primary_monitor().ok().flatten())
{
Some(m) => {
(m.position().x, m.position().y, m.size().width, m.size().height)
}
None => {
dbg("no monitor info, abort");
return;
}
};
dbg(&format!("fullscreen target = {fullscreen:?}"));
// 全程置顶 + 聚焦
let _ = window.set_always_on_top(true);
let _ = window.set_focus();
// ---------- 第①步:显示纯色遮罩(无图,主题色) ----------
dbg("step 1: show plain cover");
let (cover_tx, cover_rx) = tokio::sync::oneshot::channel::<()>();
if let Ok(mut guard) = COVER_DONE.lock() {
*guard = Some(cover_tx);
}
let _ = app.emit("mc-transition-cover-show", ());
let covered = tokio::time::timeout(
std::time::Duration::from_millis(1000),
cover_rx,
)
.await;
dbg(&format!("step 1: cover ready ok={}", covered.is_ok()));
// ---------- 第②步放大到全屏1s ----------
dbg("step 2: expand to fullscreen");
animate_resize(&window, start, fullscreen, ANIM_DURATION_MS).await;
dbg("step 2 done");
// ---------- 第③步:全屏后,渐显 SVG logo ----------
dbg("step 3: fade in logo");
let (lin_tx, lin_rx) = tokio::sync::oneshot::channel::<()>();
if let Ok(mut guard) = LOGO_IN_DONE.lock() {
*guard = Some(lin_tx);
}
let _ = app.emit("mc-transition-logo-in", ());
let logo_in = tokio::time::timeout(
std::time::Duration::from_millis(1500),
lin_rx,
)
.await;
dbg(&format!("step 3: logo in done ok={}", logo_in.is_ok()));
// ---------- 等待 MC 窗口出现(最多 60s ----------
dbg("waiting for MC window");
let mut mc_raw: usize = 0;
for i in 0..FIND_RETRIES {
if let Some(raw) = win_impl::find_window(pid) {
mc_raw = raw;
dbg(&format!("MC window found at try#{i} hwnd={raw:#x}"));
break;
}
if i % 4 == 0 {
dbg(&format!(
"try#{i} pid={pid} candidates: {:?}",
win_impl::dump_pid_windows(pid)
));
}
tokio::time::sleep(std::time::Duration::from_millis(FIND_INTERVAL_MS))
.await;
}
if mc_raw == 0 {
dbg("MC window NOT FOUND -> restore launcher");
let _ = window.set_always_on_top(false);
animate_resize(&window, fullscreen, start, 300).await;
let _ = app.emit("mc-transition-reset-opacity", ());
return;
}
// ---------- 第④步:缩小前,背景变 Mojang 红 + 渐隐 logo ----------
dbg("step 4: turn red + fade out logo");
let (lout_tx, lout_rx) = tokio::sync::oneshot::channel::<()>();
if let Ok(mut guard) = LOGO_OUT_DONE.lock() {
*guard = Some(lout_tx);
}
let _ = app.emit("mc-transition-logo-out", ());
let logo_out = tokio::time::timeout(
std::time::Duration::from_millis(1500),
lout_rx,
)
.await;
dbg(&format!("step 4: logo out done ok={}", logo_out.is_ok()));
// ---------- 第⑤步:缩小到 MC 窗口矩形1s ----------
let mc_rect = win_impl::get_rect(mc_raw).unwrap_or((
fullscreen.0,
fullscreen.1,
fullscreen.2 as i32,
fullscreen.3 as i32,
));
let covers = win_impl::covers_monitor(mc_raw);
dbg(&format!("step 5: MC rect={mc_rect:?} covers_monitor={covers}"));
let target = (
mc_rect.0,
mc_rect.1,
mc_rect.2.max(1) as u32,
mc_rect.3.max(1) as u32,
);
let _ = window.set_always_on_top(true);
let _ = window.set_focus();
animate_resize(&window, fullscreen, target, ANIM_DURATION_MS).await;
dbg("step 5 done");
// ---------- 第⑥步:等待 1s游戏稳定遮罩仍在 ----------
dbg("step 6: settle 1s");
tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
dbg("step 6 done");
// ---------- 第⑦步淡出0.5s,含遮罩一起) ----------
dbg("step 7: fade out, waiting frontend");
let (done_tx, done_rx) = tokio::sync::oneshot::channel::<()>();
if let Ok(mut guard) = FADE_DONE.lock() {
*guard = Some(done_tx);
}
let _ = app.emit("mc-transition-fadeout", ());
let faded = tokio::time::timeout(
std::time::Duration::from_millis(FADE_TIMEOUT_MS),
done_rx,
)
.await;
dbg(&format!("step 7: fade wait ok={}", faded.is_ok()));
// ---------- 第⑧步:聚焦游戏 + 最小化启动器 ----------
dbg("step 8: focus game + minimize launcher");
let _ = window.set_always_on_top(false);
win_impl::focus(mc_raw);
tokio::time::sleep(std::time::Duration::from_millis(80)).await;
let _ = window.minimize();
let _ = app.emit("mc-transition-reset-opacity", ());
dbg("step 8 done (launcher minimized)");
}
#[cfg(not(target_os = "windows"))]
pub async fn run(_app: &AppHandle, _pid: u32, _maximize: bool) {}
#[cfg(target_os = "windows")]
mod win_impl {
use std::sync::{
Mutex,
atomic::{AtomicU32, AtomicUsize, Ordering},
};
use windows::Win32::Foundation::{HWND, LPARAM, RECT};
use windows::Win32::Graphics::Gdi::{
GetMonitorInfoW, MONITOR_DEFAULTTONEAREST, MONITORINFO, MonitorFromWindow,
};
use windows::Win32::UI::WindowsAndMessaging::{
EnumWindows, GetWindowRect, GetWindowThreadProcessId, IsWindowVisible,
SetForegroundWindow,
};
use windows::core::BOOL;
/// 目标进程 pid枚举回调通过静态变量传递
static TARGET_PID: AtomicU32 = AtomicU32::new(0);
/// 命中的窗口句柄,以 usize 存储HWND 非 Send/Sync
static FOUND_HWND: AtomicUsize = AtomicUsize::new(0);
/// 串行化枚举,避免并发查找互相覆盖静态变量。
static ENUM_LOCK: Mutex<()> = Mutex::new(());
/// usize → HWND。调用方保证数值来自真实句柄。
fn to_hwnd(raw: usize) -> HWND {
HWND(raw as *mut core::ffi::c_void)
}
unsafe extern "system" fn find_cb(hwnd: HWND, _: LPARAM) -> BOOL {
let mut pid = 0u32;
unsafe { GetWindowThreadProcessId(hwnd, Some(&mut pid)) };
if pid != TARGET_PID.load(Ordering::Relaxed)
|| !unsafe { IsWindowVisible(hwnd).as_bool() }
{
return BOOL(1);
}
let mut rect = RECT::default();
if unsafe { GetWindowRect(hwnd, &mut rect) }.is_ok() {
let w = rect.right - rect.left;
let h = rect.bottom - rect.top;
// 过滤掉工具窗口 / 0 尺寸窗口,只认真正的游戏主窗口
if w > 50 && h > 50 {
FOUND_HWND.store(hwnd.0 as usize, Ordering::Relaxed);
return BOOL(0); // 找到即停止枚举
}
}
BOOL(1)
}
/// 诊断用:列出属于 `pid` 的所有顶层窗口(含不可见/小窗口)。
/// 返回 (hwnd, 宽, 高, 是否可见)。
pub fn dump_pid_windows(pid: u32) -> Vec<(usize, i32, i32, bool)> {
use windows::core::BOOL as B;
static DUMP_PID: AtomicU32 = AtomicU32::new(0);
static DUMP: Mutex<Vec<(usize, i32, i32, bool)>> =
Mutex::new(Vec::new());
unsafe extern "system" fn cb(hwnd: HWND, _: LPARAM) -> B {
let mut p = 0u32;
unsafe { GetWindowThreadProcessId(hwnd, Some(&mut p)) };
if p != DUMP_PID.load(Ordering::Relaxed) {
return B(1);
}
let mut r = RECT::default();
let _ = unsafe { GetWindowRect(hwnd, &mut r) };
let vis = unsafe { IsWindowVisible(hwnd).as_bool() };
if let Ok(mut v) = DUMP.lock() {
v.push((hwnd.0 as usize, r.right - r.left, r.bottom - r.top, vis));
}
B(1)
}
let _g = ENUM_LOCK.lock();
let _g = match _g {
Ok(g) => g,
Err(_) => return Vec::new(),
};
DUMP_PID.store(pid, Ordering::Relaxed);
if let Ok(mut v) = DUMP.lock() {
v.clear();
}
unsafe {
let _ = EnumWindows(Some(cb), LPARAM(0));
}
DUMP.lock().map(|v| v.clone()).unwrap_or_default()
}
/// 查找属于 `pid` 的可见顶层窗口返回句柄原始值0 表示未找到)。
pub fn find_window(pid: u32) -> Option<usize> {
let _guard = ENUM_LOCK.lock().ok()?;
TARGET_PID.store(pid, Ordering::Relaxed);
FOUND_HWND.store(0, Ordering::Relaxed);
unsafe {
let _ = EnumWindows(Some(find_cb), LPARAM(0));
}
let raw = FOUND_HWND.load(Ordering::Relaxed);
(raw != 0).then_some(raw)
}
/// 取窗口矩形,返回 (x, y, width, height)。
pub fn get_rect(raw: usize) -> Option<(i32, i32, i32, i32)> {
let mut r = RECT::default();
unsafe { GetWindowRect(to_hwnd(raw), &mut r).ok()? };
Some((r.left, r.top, r.right - r.left, r.bottom - r.top))
}
/// 取窗口所在显示器的完整矩形,返回 (x, y, width, height)。
pub fn monitor_rect(raw: usize) -> Option<(i32, i32, i32, i32)> {
let mon =
unsafe { MonitorFromWindow(to_hwnd(raw), MONITOR_DEFAULTTONEAREST) };
let mut mi = MONITORINFO {
cbSize: std::mem::size_of::<MONITORINFO>() as u32,
rcMonitor: RECT::default(),
rcWork: RECT::default(),
dwFlags: 0,
};
if !unsafe { GetMonitorInfoW(mon, &mut mi) }.as_bool() {
return None;
}
let r = mi.rcMonitor;
Some((r.left, r.top, r.right - r.left, r.bottom - r.top))
}
/// 判断窗口是否铺满其所在显示器(全屏 / 无边框全屏 / 最大化)。
pub fn covers_monitor(raw: usize) -> bool {
let (Some((_, _, w, h)), Some((_, _, mw, mh))) =
(get_rect(raw), monitor_rect(raw))
else {
return false;
};
w >= mw && h >= mh
}
/// 把前台焦点交给游戏窗口。
pub fn focus(raw: usize) {
unsafe {
let _ = SetForegroundWindow(to_hwnd(raw));
}
}
}

775
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff