Skip to content

feat(tools): add song sliding puzzle solver replay tool - #104

Merged
Exmeaning merged 3 commits into
mainfrom
feat/song-puzzle-tool
Nov 9, 2026
Merged

Exmeaning merged 3 commits into
mainfrom
feat/song-puzzle-tool

Conversation

@luoxiadesu

@luoxiadesu luoxiadesu commented Nov 8, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Add a dedicated Song Sliding Puzzle tool at /tools/song-puzzle to showcase optimal and near-optimal solution replays for the in-game song cover sliding puzzle minigame across all supported grid dimensions (3x3 to 8x8).

Features & Implementation

  • Mathematical coordinates & slice mapping (src/lib/song-puzzle/math.ts):
    • Implements inverse mapping from bottom-left origin indexing to screen-order image coordinates.
    • Dynamically calculates CSS background-position and background-size directly from original cover artwork without requiring pre-cut image assets.
    • Exposes intuitive 1-indexed screen reading numbers (getTileDisplayNumber) and physical movement vectors (getTileDirection).
  • High-performance animation & visual effects (PuzzleStage.tsx):
    • Uses persistent GPU-composited DOM tiles via CSS translate3d to eliminate DOM reconstruction during high-frequency playback (60/120fps).
    • Material Design 3 emphasized deceleration easing (cubic-bezier(0.05, 0.7, 0.1, 1.0)) with adaptive duration for variable speeds (0.5x, 1x, 2x, 4x) and motion-reduce:transition-none respect.
    • Depth elevation shadows for moving tiles, recessed empty slot styling with subtle slot target indices, and victory completion ceremonies (gap closure, missing tile fade-in, and CSS shimmer sweep).
  • Interactive controls & text step list (PuzzleControls.tsx, PuzzleStepList.tsx):
    • Complete playback controls: play/pause, step backward/forward, reset, and scrubbable MUI Slider.
    • Three display modes: Cover Only, Numbered (with primary home-position indicator), and Numbers Only.
    • Scrollable text step list with internal container auto-scroll sync, click-to-seek, and one-click step sequence copying.
    • Solution quality badges (optimal, near-optimal, heuristic) and solver algorithm indicators.
  • Routing, Assets & i18n:
    • Registered route under tools navigation group with a unique hand-drawn SVG icon in RouteGlyph.tsx.
    • 1:1 translation alignment across all 5 core locales (zh-CN, zh-TW, ja-JP, en-US, ko-KR).
    • Production dataset hosted on EdgeOne storage bucket (https://storage.bdon.moe/moenotes/slidepuzzle/dist) with local development and upstream GitHub raw fallbacks.

Review Fixes

  • Data storage & accessibility: Hosted all 505 solution files directly under https://storage.bdon.moe/moenotes/slidepuzzle/dist/ (EdgeOne CDN cached); eliminated 404 cascade in production; added clear network error banner and retry button.
  • Runtime safety: Skipped replaceUrl updates during active autoplay to avoid browser replaceState safety rate limit exceptions on WebKit/Safari.
  • Spoiler handling: Filtered default selected songs by spoiler setting so users never land on a locked screen; kept the top song picker and selector dialog accessible even when inspecting unreleased songs.
  • Mobile scrolling: Confined step list auto-scrolling to listContainerRef.scrollTop to prevent hijacking window/page scroll on mobile viewports.
  • Intuitive tile numbering: Normalized tile display numbers and slot targets to standard screen reading order (1 to N*N).
  • Design system alignment: Swapped hardcoded colors for MD3 semantic tokens (bg-primary-container, .md3-surface, etc.), added aria-pressed states, and wired useSongTitle for user title language preferences.
  • i18n & cleanup: Corrected typos in Traditional Chinese and Japanese translations; removed unused imports; shared direction calculation logic with test suites.

Verification

  • bun test tests/song-puzzle-math.test.ts tests/song-puzzle-steplist.test.ts tests/md3-shell.test.tsx (all pass)
  • bun run lint:i18n (5 core locales strictly verified)
  • bun run lint:routes (all 69 routes verified)
  • bun run lint:arch (architecture rules pass)
  • bun run lint:text (no hardcoded UI text)
  • bun run check (TypeScript typecheck passes with 0 errors)
  • bun run check:search-seo (search and SEO routes pass)

Add a dedicated Song Sliding Puzzle replay tool under /tools/song-puzzle
to display optimal and near-optimal solution animations and text steps
for the in-game song cover sliding puzzle minigame across dimensions
from 3x3 to 8x8.

Copy link
Copy Markdown
Member

感谢贡献这个很有意思且完成度很高的工具!路由注册、5 语种 i18n 对齐、MUI 按路径按需引入、复用 MusicSelectDialog 统一筛选器,以及用 Unity 左下角坐标反算原图 background-position + translate3d 持久节点做流畅动画的思路都非常干净,CI 也已全绿通过。

完整审阅代码与上游数据包后,整理出以下几个建议在合并前处理的关键问题与细节优化:


1. 【阻塞 / 架构与国内可用性】静态解法数据源与 raw.githubusercontent.com 回退(src/config/assets.ts:84-87、.gitignore:25-26、src/lib/song-puzzle/client.ts:12-30)

  • 线上固定触发 404 瀑布流 + 国内不可达:/public/slidepuzzle/ 被加入了 .gitignore,且 Dockerfile 构建镜像时并未生成或拷入该目录。这意味着线上环境每次加载索引和切换题目都会先对 /slidepuzzle/... 发一次必 404 的请求,再回退请求 https://raw.githubusercontent.com/luoxiadesu/moenotes_slidepuzzle/master/dist/...。
  • 大陆网络访问与无错误提示:raw.githubusercontent.com 在中国大陆网络环境下经常因 DNS 污染/连接重置无法访问;而 SongPuzzleTool.tsx:72-75 在 fetchPuzzleIndex() 抛错时直接静默吞掉错误,用户只会看到空白的「请选择一首歌曲以查看华容道解法」且选歌弹窗为空,无法得知是网络加载失败。
  • 许可证与后续新歌维护:
    1. 注意到上游仓库 luoxiadesu/moenotes_slidepuzzle 的 README.md 目前标注为 No LICENSE file,建议补充明确的开源许可证(如 MIT / Apache-2.0)。
    2. 建议将导出产物同步托管到项目统一的 R2 存储桶(如 https://storage.bdon.moe/moenotes/slidepuzzle,与 storySite / musicDataSite 一致,并在 Dockerfile / scripts/generate-runtime-config.ts 暴露 PUBLIC_SLIDE_PUZZLE_SITE),避免回退个人 GitHub Raw 源。
    3. 进阶建议:既然游戏内盘面是由 .NET System.Random(musicId) 走 11 * N * N 步合法滑动确定性生成的,如果在前端顺带内置这几十行 .NET Random 生成器,当未来游戏更新新歌、离线最优解尚未跑完上传时,也能零延迟展示准确的初始盘面并提供逆序回退解兜底。

2. 【功能 Bug】运行时边界与移动端交互问题

  1. 自动播放触发浏览器 history.replaceState 频率限制导致崩溃(src/components/tools/SongPuzzleTool.tsx:157-168)

    • 当前 useEffect 监听了 [restoredFromUrl, selectedMusicId, selectedGrid, currentStep],每走一步都会同步调用 replaceUrl(...)(底层直接调用 window.history.replaceState)。
    • 在 4x 倍速(80ms/步,即每秒 12.5 次)播放 7×7 / 8×8 长解法(数百步)时,约 8 秒(第 100 步) 就会撞上 Safari / iOS WebKit 的安全频率上限(SecurityError: Attempt to use history.replaceState() more than 100 times per 30 seconds),直接抛出未捕获异常导致整个组件树白屏。
    • 建议:在 isPlaying === true 自动播放期间跳过 URL step 同步(仅在暂停、手动单步/拖拽进度条或切歌/切规格时更新 URL)。
  2. 首次进入页面的剧透锁死陷阱(src/components/tools/SongPuzzleTool.tsx:85-106, 271-277)

    • getBuildMusic 返回的歌曲默认按发布时间倒序(最新在前),而 availableSongs(L85)未按 showSpoiler 过滤未公开曲目;无 ?music= 参数进入页面时会直接选中 availableSongs[0](L104)。
    • 一旦最新一首歌属于尚未到达 startAt 的剧透曲目,且用户处于默认的关闭剧透状态(showSpoiler === false),L271-277 的 if (!showSpoiler && isUnreleased) return <SpoilerNotice /> 会把顶部的「换一首歌」按钮和 MusicSelectDialog 一并卸载,导致普通用户一打开 /tools/song-puzzle 就被锁死在剧透提示页,无法切换到其他已发布歌曲。
    • 建议:默认选曲时排除 !showSpoiler && isSpoiler(s.startAt, now) 的曲目(或者像 MusicRankingTool.tsx 一样保留顶部选歌栏 / 空状态选歌入口)。
  3. 移动端播放时 scrollIntoView 劫持整页视口滚动(src/components/song-puzzle/PuzzleStepList.tsx:37-44)

    • activeItemRef.current.scrollIntoView({ block: "nearest", behavior: "smooth" }) 会同时滚动所有可滚动祖先(包括 window)。在窄屏/手机端单列布局下,棋盘在上方、步骤列表在下方折叠区,点击「播放」后每走一步浏览器视口都会被强行拉到底部步骤列表,导致用户看不到上方的拼图棋盘;同时 80ms/步下连续触发 smooth 滚动会产生动画队列堆积。
    • 建议:改为仅计算并设置 listContainerRef.current.scrollTop,不触发外层页面滚动;自动播放时使用即时定位或与倍速匹配的滚动策略。
  4. 切歌或 URL 指定规格不存在时未回退有效 grid(src/components/tools/SongPuzzleTool.tsx:126-139)

    • 若某首曲目在 index.jsonl 中仅包含部分规格(或 URL 传入的 grid 不在 availableGridsForSelected 中),切换歌曲后 selectedGrid 不会自动回退到 availableGridsForSelected[0],会导致一直停留在空状态。

3. 【交互逻辑与 MD3 设计规范】

  1. 「纯数字 / 原图+序号」模式下的块号直觉问题(PuzzleStage.tsx:121、PuzzleStepList.tsx:50, 110)

    • 由于底层 val 采用左下角原点(y 向上递增),3×3 终局 goal 为 [[6,7,8],[3,4,5],[0,1,-1]]。当前直接用 displayNumber = val + 1 显示,在**「纯数字」**模式下(隐藏封面后),终局从上到下显示为 #7 #8 #9 / #4 #5 #6 / #1 #2 [空],与常规数字华容道从左上角 #1 开始递增的直觉相反,且底盘格子没有目标编号提示,看纯数字很难判断 #1 应该归位到左下角。
    • 建议:要么在展示层与步骤文案层统一映射为屏幕阅读序(screenNumber = goalRow * grid + goalCol + 1,左上角为 #1),要么在底盘背景槽位(slot-${r}-${c})上也标出对应的浅色目标序号。
  2. 失效的动画类名与未定义的 @keyframes(src/components/song-puzzle/PuzzleStage.tsx:177, 203)

    • L177 的 animate-in fade-in zoom-in-95 依赖 tailwindcss-animate 插件,但本项目仅使用原生 Tailwind v4,这几个类不会生效;
    • L203 的 animate-[shimmer_1.2s_ease-out_forwards] 引用了 @keyframes shimmer,但 src/styles/ 中并未定义 shimmer 关键帧,通关扫光层会一直停在 -translate-x-full 屏幕外。
    • 建议补充真实生效的 CSS 过渡/关键帧,并为棋子滑动加上 motion-reduce:transition-none 以尊重系统减少动态效果设置。
  3. 对齐项目 Material Design 3 原语与无障碍属性(PuzzleMetaCard.tsx、PuzzleControls.tsx、SongPuzzleTool.tsx)

    • PuzzleMetaCard.tsx:31-36 与 PuzzleStage.tsx:140, 156, 187 硬编码了 emerald-* / cyan-* 调色板,建议改用项目动态 M3 语义色(如 bg-primary-container text-on-primary-container、bg-tertiary-container text-on-tertiary-container、bg-secondary-container text-on-secondary-container),以便跟随用户选择的乐队/角色主题色平滑适配;
    • 倍速切换、展示模式切换、棋盘规格按钮建议直接使用 src/styles/md3/primitives.css 中的 .md3-segmented + .md3-segment 或 .md3-filter-chip,并为选中态补上 aria-pressed={isSelected};卡片容器可统一使用 .md3-surface;
    • SongPuzzleTool.tsx:296 标题展示建议接入 useSongTitle(selectedSong, locale)(与 MusicSelectDialog 一致,遵循用户的日文原名/译名偏好设置),标题字体可使用 font-[var(--md-ref-typeface-brand)]。

4. 【文案笔误与代码清理】

  • 繁体中文笔误(src/i18n/messages/zh-TW.ts:2771):loading: "正在載理解法..." → 应为 "正在載入解法..."。
  • 日文用词建议(src/i18n/messages/ja-JP.ts:2764):optimalSteps: "最短手目" → 日语里「〜手目」指「第几步」,总步数建议改为更自然的 "最短手数"。
  • 小清理项:
    • 未使用的导入:SongPuzzleTool.tsx:3 的 useId、PuzzleStepList.tsx:4 的 IconButton、PuzzleStage.tsx:7 的 getTileImageCoords;
    • PuzzleStage.tsx:146, 190 的 url(${jacketUrl}) 建议加引号 url("${jacketUrl}");missingTileBgPos(L66-71)可直接复用 getTileBackgroundPosition;
    • PuzzleStepList.tsx:52-55 的 navigator.clipboard.writeText(...) 建议补 .catch(() => {}) 并在卸载时清理 setTimeout;
    • tests/song-puzzle-steplist.test.ts:4-12 在测试文件内复制了一份 getTileDirection,建议将该函数抽到 src/lib/song-puzzle/math.ts 导出后由组件和单测共同引用,确保测试真实覆盖业务实现。

- Use R2 storage with local and GitHub fallbacks to ensure domestic accessibility
- Prevent replaceState rate limit crash during autoplay
- Guard spoiler song auto-selection and retain header picker in spoiler state
- Keep step list scrolling contained within container to avoid page jumps
- Use screen reading numbers for tile display and add subtle slot targets
- Replace broken animation classes with self-contained CSS keyframes
- Align with Material Design 3 semantic colors and aria attributes
- Correct i18n typos in Traditional Chinese and Japanese
@luoxiadesu

Copy link
Copy Markdown
Contributor Author

非常感谢 @Exmeaning 如此详尽细致且切中要害的 Review!提出的每个问题都非常关键,尤其是在 Safari 速率限制、移动端滚动和剧透边界处理上。

针对以上建议,已完成全量整改并通过验证,具体处理如下:


1. 静态解法数据源与国内可用性

  • 全量数据已部署上线:已将全量 504 盘 + index.jsonl 同步托管至项目的 SeaweedFS / EdgeOne 存储桶:
    https://storage.bdon.moe/moenotes/slidepuzzle/dist/
    经真机实测 HTTP/2 200 验证通过,享受腾讯云 EdgeOne 边缘缓存与国内高速直连。
  • 配置与容灾分层:
    在 src/config/assets.ts 中默认指向 https://storage.bdon.moe/moenotes/slidepuzzle/dist,本地开发环境优先读取 /slidepuzzle 静态目录,若均失败则自动回退至 GitHub upstream raw,彻底根除了线上的 404 瀑布流;
  • 错误状态与重试:当索引或解法网络加载失败时,界面展示明确的错误提示信息与「重试」按钮,不再静默吞错。

2. 运行时边界与移动端交互修复

  • 防 replaceState 崩溃:在 isPlaying === true 自动播放期间跳过 URL step 覆写,只在用户暂停、手动单步、拖拽进度条或切歌时同步 URL,彻底规避了 Safari/iOS WebKit 在 4x 高倍速连续播放时触发的安全频率上限异常;
  • 消除剧透锁死陷阱:选曲默认排除未到达 startAt 的曲目;即便当前选中的曲目处于剧透保护期,依然保留顶部曲目横栏与「换一首歌」按钮,用户可随时换歌而不会被锁死在提示页;
  • 移动端滚动防劫持:将步骤列表的高亮居中滚动严格限制在 listContainerRef 容器内(仅更新内部 scrollTop),不再触发 window 页面视口跳转,且自动播放时采用即时滚动避免动画队列堆积;
  • 网格回退兜底:切歌时若上一首歌曲的 selectedGrid 不在当前曲目的可用规格列表中,自动回退到 availableGridsForSelected[0]。

3. 交互逻辑与 MD3 规范对齐

  • 人类直觉屏幕阅读序号:将切片序号与步骤文案统一计算为从左上角 #1 到右下角 #N*N 的直觉屏幕阅读序(getTileDisplayNumber),同时在底盘背景槽位上增加了浅色目标序号印记;
  • CSS 动效原生化:移除未定义的外部动画类,改用自包含的标准 CSS @keyframes puzzle-shimmer;为滑块平移加上 motion-reduce:transition-none 尊重系统减少动效偏好;
  • MD3 语义色适配:移除硬编码颜色,全面改用 bg-primary-container、bg-tertiary-container、.md3-surface 等项目 M3 语义令牌,并补齐了各按钮的 aria-pressed 状态;
  • 多语言曲目标题规范:接入 useSongTitle 遵循用户的日文原名/译名偏好设置,标题字体应用 font-[var(--md-ref-typeface-brand)]。

4. 文案笔误与代码清理

  • 修正繁体中文 loading: "正在載入解法...";
  • 修正日文 optimalSteps: "最短手数";
  • 清理未使用导入,将共享方向计算提取到 src/lib/song-puzzle/math.ts 并补全了单测覆盖。

相关提交:d05f94a、3ecd51e。目前全部 CI(lint:i18n, lint:routes, lint:arch, lint:text, check, bun test)均持续绿灯通过,欢迎再次 review!

Copy link
Copy Markdown
Member

动作好快!复审了 d05f94a 和 3ecd51e:R2 / EdgeOne 数据源切换、自动播放跳过 replaceUrl 防 Safari 崩溃、剧透默认曲目过滤与保留顶部换歌栏、屏幕阅读序 1..N² 映射 + 槽位浅色底标、原生 @keyframes puzzle-shimmer、MD3 动态语义色以及繁中/日文文案都已经改得非常到位了 👍

二审走读新代码时发现 1 个因 offsetParent 引起的列表滚动错位 Bug 和 2 处很小的收尾细节,修完基本就可以直接合入了:


1. 【功能 Bug】PuzzleStepList 滚动容器缺少 relative,导致 item.offsetTop 参照系错位(src/components/song-puzzle/PuzzleStepList.tsx:36-46, 107)

  • 新逻辑用 const itemTop = item.offsetTop 与 container.scrollTop 比较,但 L107 的 listContainerRef 容器(以及 L79 的外层卡片)都是默认的 position: static,不是 item 的 offsetParent。
  • 结果 item.offsetTop 实际返回的是相对于页面上层定位祖先 / <body> 的纵坐标(通常在 600px+),导致初始处于 #0 步时 itemBottom > containerBottom(208px)恒成立,一挂载就会把步骤列表直接往下滚走十几行,反而把 #0 滚出可视区。
  • 修复:给 L107 listContainerRef 的 className 加上 relative(或者用 item.offsetTop - container.offsetTop 计算相对偏移)。

2. 【小细节】单盘加载失败的「重试」按钮未重置 solutionError(src/components/tools/SongPuzzleTool.tsx:383-391)

  • 当单盘解法触发 solutionError 后,点击 L383 的重试按钮没有先调用 setSolutionError(null),且 .catch(() => {}) 吞掉了新错误(若再次失败或返回 null 状态不会更新)。建议把单盘拉取逻辑也像 loadIndex 一样抽成 useCallback 复用,重试前清空 solutionError。

3. 【配置打通】将 PUBLIC_SLIDE_PUZZLE_SITE 加入客户端环境变量白名单(src/config/public-env.ts)

  • 本项目客户端 Bundle 仅内联 src/config/public-env.ts 中 PUBLIC_ENV_KEYS 列出的 PUBLIC_* 变量(否则在 "use client" 组件里 process.env.PUBLIC_SLIDE_PUZZLE_SITE 恒为 undefined,目前靠后面的硬编码默认值正常工作,但外部环境变量覆盖不会生效)。
  • 建议顺手在 src/config/public-env.ts 的 PUBLIC_ENV_KEYS(以及 src/env.d.ts、Dockerfile 的 ARG)里补上 PUBLIC_SLIDE_PUZZLE_SITE。

4. 【顺手微调(可选)】

  • 首屏加载态(SongPuzzleTool.tsx:375):初次挂载、!indexLoaded 尚未返回前,loadingSolution 为 false,左侧画布会短暂闪一下 emptySelection(「请选择一首歌曲...」)且顶部还没有选歌栏;将 L375 判断改为 !indexLoaded || loadingSolution 直接展示 loading 会更连贯。
  • 顺带小提醒:上游 luoxiadesu/moenotes_slidepuzzle 仓库目前还没有 LICENSE 文件(不阻塞本 PR,后续有空在那边补一个 MIT / Apache-2.0 即可)。

@Exmeaning
Exmeaning merged commit 58e1990 into main Nov 9, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants