diff --git a/.codex/environments/environment.toml b/.codex/environments/environment.toml index e142aa59..a9070d7d 100644 --- a/.codex/environments/environment.toml +++ b/.codex/environments/environment.toml @@ -4,8 +4,8 @@ name = "Genarrative" [setup] script = ''' +cp "$env:CODEX_SOURCE_TREE_PATH\.env.secrets.local" "$env:CODEX_WORKTREE_PATH\.env.secrets.local" npm install -cp "C:\proj\Genarrative\.env.secrets.local" ".env.secrets.local" npm run codegraph:init npm run codegraph:index ''' diff --git a/.hermes/plans/架构优化计划书.md b/.hermes/plans/架构优化计划书.md new file mode 100644 index 00000000..604dc11b --- /dev/null +++ b/.hermes/plans/架构优化计划书.md @@ -0,0 +1,399 @@ +# Genarrative(陶泥儿)项目架构优化计划书 + +**文档版本**:v1.0 +**编制日期**:2026-05-26 +**项目名称**:Genarrative(陶泥儿) +**文档密级**:内部 + +--- + +## 一、项目概况 + +| 维度 | 详情 | +|---|---| +| **项目名** | Genarrative(陶泥儿) | +| **项目定位** | AI Native 互动视觉 RPG 平台——支持多种玩法模板的 AI 创作、运行与分享("玩法类型平台") | +| **核心玩法** | 拼图、视觉小说、Match3D、Bark Battle、Big Fish、Jump-Hop、Square Hole、木鱼、教娱等 10+ 种玩法模板 | +| **代码规模** | 前端 ~823 个 TS/TSX 文件,后端 ~1532 个 Rust 文件,属于大型项目 | + +### 1.1 计划目的 + +本计划书基于对 Genarrative 项目当前架构的全面分析,识别架构层面的关键问题,并提出分阶段、可落地的优化方案。旨在: + +- 统一前端架构模式,降低团队认知成本和新人上手门槛 +- 提升模块内聚性,减少不必要的耦合与依赖 +- 建立自动化契约保障机制,降低跨语言同步出错风险 +- 优化工程基础设施,提高开发效率和运维可观测性 + +--- + +## 二、技术栈总览 + +| 层级 | 技术选型 | 版本 | +|---|---|---| +| **前端框架** | React + TypeScript + Vite | React 19 / TS 5.8 / Vite 6 | +| **样式** | TailwindCSS | v4 | +| **3D / 动画** | Three.js、Motion、cannon-es | - | +| **后端 HTTP** | Rust + Axum(BFF 门面) | Axum 0.8 | +| **游戏状态 DB** | SpacetimeDB(实时反应式数据库) | v2.2 | +| **AI / LLM** | LangChain-Rust + LLM Proxy | - | +| **小程序** | 微信小程序(含微信支付) | - | +| **容器化** | Docker Compose(Nginx + API Server + OTel Collector) | - | +| **运维** | systemd、Nginx、Jenkins CI/CD、k6 压测 | - | +| **可观测性** | OpenTelemetry(OTLP → Grafana) | - | + +--- + +## 三、当前架构分层图 + +``` +┌──────────────────────────────────────────────────────────────┐ +│ 入口层 │ +│ index.html → main.tsx → resolveAppRoute() → RouteComponent │ +│ (多入口路由:平台主页 / 拼图 / BigFish / Match3D / ...) │ +└──────────────────────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────┐ +│ 前端应用层 (src/) │ +│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────────┐│ +│ │ components/ │ │ services/ │ │ games/ ││ +│ │ *-creation │ │ *-creation │ │ bark-battle/ ││ +│ │ *-result │ │ *-runtime │ │ domain/ ││ +│ │ *-runtime │ │ *-works │ │ application/ ││ +│ │ common/ │ │ storyEngine │ │ infrastructure/ ││ +│ │ auth/ │ │ payment │ │ ui/ ││ +│ └─────────────┘ └──────────────┘ └──────────────────────┘│ +│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────────┐│ +│ │ hooks/ │ │ data/ │ │ routing/ ││ +│ │ persistence/│ │ functionCat. │ │ config/ editor/ ││ +│ └─────────────┘ └──────────────┘ └──────────────────────┘│ +└──────────────────────────────────────────────────────────────┘ + │ Vite Proxy (/api/*) + ▼ +┌──────────────────────────────────────────────────────────────┐ +│ Rust 后端 (server-rs/) │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ api-server (Axum HTTP / SSE / BFF 门面) │ │ +│ └──────────────────────────────────────────────────────┘ │ +│ │ │ │ │ +│ ▼ ▼ ▼ │ +│ ┌──────────┐ ┌──────────────────┐ ┌──────────────┐ │ +│ │ platform │ │ module-* │ │ shared │ │ +│ │ -auth │ │ -puzzle │ │ -contracts │ │ +│ │ -llm │ │ -visual-novel │ │ -kernel │ │ +│ │ -image │ │ -match3d │ │ -logging │ │ +│ │ -oss │ │ -bark-battle │ └──────────────┘ │ +│ │ -speech │ │ -big-fish ... │ │ +│ │ -agent │ │ -runtime │ │ +│ └──────────┘ │ -combat/npc │ │ +│ └──────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ spacetime-module + spacetime-client → SpacetimeDB │ │ +│ └──────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────┘ + +┌──────────────────────────────────────────────────────────────┐ +│ 辅助子系统 │ +│ apps/admin-web (React 后台管理) │ +│ miniprogram/ (微信小程序) │ +│ packages/shared (前后端共享契约/LLM工具) │ +│ deploy/ (Docker/Nginx/systemd/OTel) │ +│ scripts/ (40+ 构建/部署/检查脚本) │ +│ jenkins/ (CI/CD Pipeline) │ +└──────────────────────────────────────────────────────────────┘ +``` + +--- + +## 四、架构亮点 + +1. **清晰的"玩法模板"平台模式** + 每种玩法遵循统一的 `creation → result → runtime` 三段式生命周期,前端 `components/` 和 `services/` 均按此模式组织。新增玩法可快速套用模板,极大降低了横向扩展成本。 + +2. **后端严格的分层约束** + `api-server`(门面)→ `module-*`(领域)→ `spacetime-module`(持久化),`module-*` 不直接依赖 Axum、HTTP、SpacetimeDB table、LLM、文件系统,保证了领域纯净性和可测试性。 + +3. **SpacetimeDB 作为游戏状态核心** + 采用反应式实时数据库替代传统 Redis + PostgreSQL 组合,天然适合多人实时游戏状态同步,减少了中间层复杂度和延迟。 + +4. **前端 DDD 探索** + `src/games/bark-battle/` 采用 `domain / application / infrastructure / ui` 四层 DDD 结构,为复杂玩法的前端架构提供了良好范本。 + +5. **完善的多入口路由体系** + 通过 `resolveAppRoute()` 按 URL path 分发到不同的 lazy-loaded 组件,实现了按需加载和良好的首屏性能。 + +6. **运维体系完备** + Docker Compose + systemd + Nginx + OpenTelemetry + k6 压测 + Jenkins CI/CD,覆盖了构建、部署、监控、压测全链路。 + +--- + +## 五、问题诊断与改进方案 + +### 问题 1:前端 services/ 与 components/ 的耦合不统一 + +**现状** +大部分玩法遵循 `services/` + `components/` 分离模式,但部分玩法运行时直接放在 `components/` 下,services 层职责模糊——有的承担了业务逻辑编排,有的仅作简单 API 调用。 + +**影响** +- 新人阅读代码时无法预判某个逻辑应位于哪个目录 +- 单元测试困难:services 与组件耦合的业务逻辑无法独立测试 +- 跨玩法复用时需要额外的迁移成本 + +**改进方案** +1. 制定规范:`services/` 仅负责纯 API 调用和数据转换,不包含业务判断逻辑 +2. 业务逻辑统一归到 `hooks/` 或 `games//application/` +3. 以 puzzle 玩法为样板,先行重构并形成迁移指南,再推广至其余玩法 +4. 在 CI 中增加 ESLint 规则,禁止 `components/` 直接 import `services/` 之外的外部模块 + +**预期收益** +- 职责边界清晰,降低认知成本约 30% +- services 层可独立单测覆盖率达到 80%+ +- 新玩法开发上手时间从 2 天缩短至 0.5 天 + +--- + +### 问题 2:前端 DDD 与模板模式并存,架构不统一 + +**现状** +`bark-battle` 采用 DDD 四层结构(`domain/application/infrastructure/ui`),其余玩法散落在 `components/` + `services/` 下,存在两种截然不同的组织范式。 + +**影响** +- 团队内部对"正确"的代码组织方式缺乏共识 +- 代码审查时需要切换判断标准 +- DDD 玩法的优势无法在全局范围内发挥 + +**改进方案** +1. 所有玩法统一迁移到 `src/games//` 下,采用 `domain / application / ui` 三层结构(infrastructure 按需保留) +2. 以 puzzle 为样板完成首例迁移,产出迁移 Checklist 和模板生成脚本 +3. 新增玩法脚手架直接生成 DDD 结构目录 +4. 旧玩法分批次迁移,每批次 2-3 个玩法,在 4 个迭代内完成 + +**预期收益** +- 架构一致性提升至 100% +- 跨玩法逻辑复用变得可能(domain 层可共享) +- 为后续 monorepo 改造打下基础 + +--- + +### 问题 3:后端 module-* 粒度偏细,存在潜在循环依赖风险 + +**现状** +后端共 35 个 crate。`module-runtime` 被拆分为 3 个独立 crate,`module-combat / npc / inventory` 各自独立。部分紧密协作的模块之间可能存在隐式耦合。 + +**影响** +- 编译时间增长(35 个 crate 独立编译) +- 跨 crate 重构时需要同时修改多处 +- 循环依赖风险增加,可能在特定组合下触发编译失败 + +**改进方案** +1. 评估合并方案: + - `module-runtime-*` 系列合并为单 crate `module-runtime`,内部用 `mod` 做逻辑隔离 + - `module-combat / npc / inventory` 评估合并为 `module-combat`,npc 和 inventory 作为子模块 +2. 保留 trait/interface 抽象层,确保 module 之间不直接依赖具体实现 +3. 在 CI 中引入 `cargo-deny` 或自定义脚本,自动检测 module 间的依赖方向是否违反分层约束 +4. 目标:将 35 个 crate 精简至 25 个以内 + +**预期收益** +- 全量编译时间预计缩短 15%-20% +- 循环依赖风险归零 +- module 内部重构成本降低 + +--- + +### 问题 4:根目录 env 文件过多且混乱 + +**现状** +根目录存在 4 个 env 文件(`.env`、`.env.example`、`.env.production` 等),且 `deploy/` 下另有多个 env 文件。配置分散在多处,部分文件之间字段不一致。 + +**影响** +- 排查配置问题时需要翻阅多个文件 +- 新人无法快速确定本地开发需要哪些环境变量 +- 部署时可能遗漏或错误覆盖某项配置 + +**改进方案** +1. 收敛为三层配置体系: + - `.env.example`:包含所有可配置项的说明和默认值(唯一提交到仓库的 env 文件) + - `.env.local`:本地开发覆盖(加入 .gitignore) + - `deploy/env/.env`:各部署环境专用配置 +2. 引入 config crate,支持层次覆盖(default → local → env-specific),启动时自动校验必填字段 +3. 在 CI 中加入 env 校验步骤:对比 `.env.example` 与部署环境的 env 文件,标记缺失或多余字段 + +**预期收益** +- 配置查找时间从分钟级降至秒级 +- 部署配置遗漏导致的线上事故减少 90%+ +- 新成员本地环境搭建时间从 30 分钟缩短至 10 分钟 + +--- + +### 问题 5:scripts/ 目录膨胀为"万能工具箱" + +**现状** +`scripts/` 目录共 42 个文件,涵盖构建、部署、检查、迁移、生成、压测等,全部平铺在同一层级,缺乏分类。 + +**影响** +- 难以快速定位所需脚本 +- 同类脚本缺乏命名规范 +- 新增脚本时不知道放在何处 + +**改进方案** +1. 按功能域分类重组: + +``` +scripts/ +├── build/ # 构建相关(vite、cargo、wasm 等) +├── deploy/ # 部署相关(docker、systemd、rsync) +├── check/ # 检查/校验(lint、format、type-check) +├── spacetime/ # SpacetimeDB 相关(migration、seed) +├── generate/ # 代码生成(scaffold、proto、types) +└── loadtest/ # 压测脚本(k6 配置及辅助) +``` + +2. 为每个子目录添加 README.md,说明各脚本用途和调用方式 +3. 将重复逻辑抽取为共享函数库 + +**预期收益** +- 脚本查找效率提升 60%+ +- 降低脚本重复概率 +- 便于 CI Pipeline 直接引用标准化路径 + +--- + +### 问题 6:前端路由系统缺乏统一的"玩法注册"机制 + +**现状** +`appRoutes.tsx` 中硬编码 `switch-case` 逻辑,每新增一种玩法需手动修改路由文件、入口组件、资源加载等多个位置。 + +**影响** +- 新增玩法的接入点分散,容易遗漏 +- 路由文件随玩法增多持续膨胀 +- 无法实现"按需注册"——即使某环境不包含某玩法,路由代码仍然存在 + +**改进方案** +1. 建立 `PlayTypeRegistry` 模式:每个玩法导出一个注册项对象,包含 `path`、`lazyComponent`、`preload` 等字段 +2. `resolveAppRoute()` 改为动态聚合所有注册项,替代硬编码 switch-case +3. 支持环境级玩法开关:通过配置控制某环境启用哪些玩法,路由系统自动忽略未启用的 + +**预期收益** +- 新增玩法零侵入路由系统,只需在玩法目录内添加注册文件 +- 路由文件体积与玩法数量解耦 +- 灰度发布和 A/B 测试变得可能 + +--- + +### 问题 7:前端缺少统一的状态管理层 + +**现状** +前端状态管理依赖 React hooks + props drilling + services 层手动管理。未使用任何状态管理库(如 Zustand、Jotai、Redux)。 + +**影响** +- 全局状态(用户认证、会话、通知)通过多层 props 传递,组件耦合度高 +- 跨页面状态无法优雅共享 +- 状态变更难以追踪和调试 + +**改进方案** +1. 引入 Zustand(轻量、无 boilerplate、TS 友好)管理全局状态: + - `useAuthStore`:认证状态 + - `useSessionStore`:当前会话/游戏状态 + - `useNotificationStore`:全局通知 +2. 玩法内状态继续使用 React hooks + `useReducer`,保持局部自治 +3. 全局 store 与玩法内 state 通过事件总线松耦合通信 + +**预期收益** +- props drilling 层级从 5+ 层降至 1-2 层 +- 全局状态可追溯,支持 Redux DevTools 调试 +- 跨玩法状态共享(如用户余额、道具)变得自然 + +--- + +### 问题 8:shared-contracts 的实际复用程度待验证 + +**现状** +前后端通过 `packages/shared` 共享 DTO 类型定义,但依赖手动同步 TypeScript 类型。可能存在前后端契约不一致但编译期无法检出的情况。 + +**影响** +- 后端修改 DTO 字段后,前端可能遗漏更新导致运行时错误 +- 手动同步耗时且易出错 +- Code Review 时难以判断契约一致性 + +**改进方案** +1. 引入 `ts-rs`:从 Rust 结构体自动生成 TypeScript 类型定义 +2. 将生成步骤集成到 CI Pipeline: + - 每次 Rust PR 触发 `ts-rs` 重新生成 TS 类型 + - 对比生成的类型与仓库中的类型是否一致,不一致则 CI 失败 +3. 长期考虑引入 Protobuf / OpenAPI 作为跨语言契约的单一事实来源 + +**预期收益** +- 前后端契约不一致导致的线上 bug 减少 95%+ +- 手动同步工作量归零 +- PR Review 时契约一致性问题自动拦截 + +--- + +## 六、改进优先级路线图 + +| 优先级 | 改进项 | 涉及层 | 建议时间 | 预期收益 | +|---|---|---|---|---| +| **P0** | 统一前端 services/hooks/components 职责边界 | 前端 | 第 1-2 周 | 降低认知成本,提升可测试性 | +| **P1** | 建立 PlayType 注册机制 | 前端 | 第 2-3 周 | 新增玩法零侵入路由 | +| **P1** | 评估 module-* 合并方案并执行 | 后端 | 第 3-4 周 | 减少编译时间 15%-20% | +| **P1** | 引入 Zustand 全局状态管理 | 前端 | 第 4-5 周 | 改善状态追踪与跨组件共享 | +| **P2** | 清理 scripts/ 目录结构 | 工程 | 第 5-6 周 | 提高可发现性 | +| **P2** | 前端玩法统一迁移至 DDD 结构 | 前端 | 第 5-8 周 | 架构一致性 100% | +| **P2** | env 配置收敛 | 工程 | 第 6-8 周 | 减少部署配置事故 | +| **P3** | 前后端共享 DTO 自动化(ts-rs) | 全栈 | 第 6-8 周 | 消除契约不一致风险 | +| **P3** | CI 分层约束检查(cargo-deny) | 后端 | 第 8-10 周 | 循环依赖归零 | + +> **说明**:P0 为阻塞项,必须最先完成。P1 项可部分并行推进(PlayType 注册与 module 合并互不依赖)。P2/P3 为优化项,可在日常迭代中穿插推进。 + +--- + +## 七、架构健康度评分卡 + +| 维度 | 评分 | 当前状态 | 目标状态 | +|---|---|---|---| +| **分层清晰度** | ★★★★☆ | 后端分层严格,前端分层存在不一致 | ★★★★★ 前后端均严格分层 | +| **模块化程度** | ★★★★☆ | 后端 35 crate 粒度偏细,前端结构化较好 | ★★★★☆ 后端精简至 25 crate | +| **可扩展性** | ★★★★★ | 玩法模板模式使新增玩法成本低 | ★★★★★ 维持 | +| **代码复用** | ★★★☆☆ | shared 层作用有限,services 层有重复 | ★★★★☆ DDD 统一后 domain 可复用 | +| **DevOps 成熟度** | ★★★★★ | Docker + k6 + OTel + Jenkins 覆盖完整 | ★★★★★ 维持 | +| **文档完备性** | ★★★★★ | docs/ 分类清晰,基线文档齐全 | ★★★★★ 维持 | +| **技术债务管控** | ★★★★☆ | 有明确的"历史残留"标记和废弃策略 | ★★★★★ 增加自动化检测 | + +**综合评级:A-(优秀,存在可优化空间)** + +--- + +## 八、附录:代码规模统计 + +| 维度 | 数量 | +|---|---| +| **前端 TypeScript/TSX 文件** | ~823 个 | +| **后端 Rust 源文件** | ~1532 个 | +| **后端 Crate 数量** | 35 个 | +| **核心玩法类型** | 10+ 种 | +| **scripts/ 脚本数量** | 42 个 | +| **根目录 env 文件** | 4 个 + deploy 下多个 | + +### 模块规模明细(后端) + +| Crate | 职责 | 建议 | +|---|---|---| +| `api-server` | Axum HTTP 门面,路由聚合 | 保持 | +| `platform-*` (auth/llm/image/oss/speech/agent) | 平台级跨玩法能力 | 保持 | +| `module-puzzle` | 拼图玩法 | 作为 DDD 迁移样板 | +| `module-visual-novel` | 视觉小说 | 后续迁移 | +| `module-match3d` | Match3D 三消 | 后续迁移 | +| `module-bark-battle` | 犬吠对战 | 已对接前端 DDD | +| `module-big-fish` | Big Fish | 后续迁移 | +| `module-runtime*` (3 crates) | 通用运行时 | **建议合并为单 crate** | +| `module-combat / npc / inventory` | 战斗系统 | **建议合并** | +| `spacetime-module` + `spacetime-client` | SpacetimeDB 接入 | 保持 | +| `shared-contracts / kernel / logging` | 共享基础设施 | 保持 | + +--- + +> **文档结束** +> 本计划书由 Genarrative 架构分析报告衍生,所有改进项均基于对当前项目代码库的实际分析。执行过程中如遇阻力或新发现,应及时更新本计划书并同步相关方。 \ No newline at end of file diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 7ad084e6..840846b4 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -16,6 +16,22 @@ --- +## 2026-05-25 抓大鹅发现页官方 demo 使用静态资源与本地运行态 + +- 背景:本轮抓大鹅资源管线已经生成完整 `level-scene`、背景、UI spritesheet、物品 spritesheet 和切片资源,需要放入发现页作为可试玩验证入口,但不应把一次性本地资源包装成后端正式作品。 +- 决策:发现页官方抓大鹅 demo 固定 profileId 为 `match3d-demo-20260525`、公开作品号为 `M3-20260525`,资源读取 `public/match3d-demo/undersea-candy-market/` 下的静态文件。公开卡片、作品号搜索和详情页沿用平台公开作品详情链路;启动运行态时用 `createLocalMatch3DRuntimeAdapter`,不调用正式 Match3D runtime 后端、不新增 SpacetimeDB schema、不写正式作品统计。 +- 影响范围:`src/data/match3dDemoGalleryCard.ts`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、发现页公开卡片、作品号搜索、Match3D 本地 runtime adapter、玩法链路文档。 +- 验证方式:搜索 `M3-20260525` 能打开“海底糖果集市”并启动本地抓大鹅运行态;正式 Match3D 公开作品仍走 server runtime adapter。 +- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## 2026-05-25 抓大鹅运行态 HUD 收敛为拼图同款低遮挡样式 + +- 背景:抓大鹅游玩阶段 UI 需要继续对齐拼图运行态的观感,同时移除右上角设置入口、灰白半透底板和显眼锅壳,让棋盘区域更专注。 +- 决策:抓大鹅运行态只保留左上透明返回按钮,右上不再显示设置入口;顶部关卡名和倒计时直接复用拼图同款的铭牌 + 下挂计时牌结构、同色板、同造型和 `media/logo.png` 产品 logo;底部备选栏和道具图标保持交互边界但不再显示灰白半透底;中央容器图层可以视觉隐藏,但棋盘命中边界和既有交互逻辑保留。 +- 影响范围:`src/components/match3d-runtime/Match3DRuntimeShell.tsx`、`src/components/match3d-runtime/Match3DRuntimeShell.test.tsx`、`src/index.css`、抓大鹅玩法链路文档。 +- 验证方式:运行态页面不再渲染“打开抓大鹅设置”,顶部仍显示关卡名和倒计时,底部槽位和道具按钮 class 中不含旧白底视觉;相关测试通过后保持该口径。 +- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + ## 2026-05-25 平台首页推荐按桌面与移动断点分流 - 背景:平台首页的推荐页在桌面与移动端之间原先共用同一套推荐运行态逻辑,容易让桌面和移动两套内容同时启动,也让首页的推荐卡与桌面发现壳互相抢状态。 diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index ae407fdb..3f4ecd5a 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -1512,10 +1512,18 @@ - 验证:`curl.exe -i http://127.0.0.1:8082/api/creation-entry/config` 返回 `200` 且包含 `baby-object-match`;前端草稿页作品架重新渲染。 - 关联:`server-rs/crates/api-server/src/state.rs`、`server-rs/crates/api-server/src/creation_entry_config.rs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 -## 存档选择入口不要只藏在“玩过”弹窗里 +## 抓大鹅物品 spritesheet 偏移先查 alpha 连通域切片是否启用 -- 现象:用户有 RPG / 拼图运行态存档,但平台底部 `草稿` Tab 只展示作品架,个人中心只有点击 `玩过` 后才可能看到“可继续”,导致看起来没有存档选择入口。 -- 原因:`/api/profile/save-archives` 已在入口 bootstrap 加载,但前端只把 `saveEntries` 注入 `ProfilePlayedWorksModal`;没有独立的存档入口。 -- 处理:个人中心 `常用功能` 必须保留 `存档` 快捷入口,点击后打开独立存档选择弹窗并复用 `SaveArchiveCard`;恢复仍走 `/api/profile/save-archives/{worldKey}`,拼图存档继续走拼图 resume 分支,RPG 走 `handleContinueGame(snapshot)`。 -- 验证:`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "profile page exposes save archive picker"`。 -- 关联:`src/components/rpg-entry/RpgEntryHomeView.tsx`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/useRpgEntryBootstrap.ts`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +- 现象:抓大鹅物品图集里大多数素材显示不全、被裁碎、位置整体偏移,甚至切出来像拼贴块。 +- 原因:旧链路只按 `10x10` 固定格线裁切,遇到模型输出的透明图集稍有偏移、跨格或留白不均时就会把主体切坏。现在后端优先按透明 alpha 连通域识别真实素材矩形,再按原图从上到下、从左到右排序;只有识别数量不足时才回退旧网格切法。 +- 处理:优先检查 `generated_asset_sheets.rs` 的 alpha 连通域切片是否生效,再查 `item_assets.rs` 是否还在透传旧的固定格线语义。不要只改前端显示比例。 +- 验证:定向测试 `cargo test -p api-server generated_asset_sheet_two_items_per_row --manifest-path server-rs/Cargo.toml -- --nocapture` 应通过,且错位透明样本应按连通域切出完整视图。 +- 关联:`server-rs/crates/api-server/src/generated_asset_sheets.rs`、`server-rs/crates/api-server/src/match3d/item_assets.rs`。 + +## 个人中心不再保留直达“存档”按钮入口 + +- 现象:2026-05-25 起,移动端“我的”页顶部改为品牌行 + 扫码 / 设置按钮,设置区和次级入口不再提供独立的 `存档` 按钮;用户仍可在“玩过”弹窗里查看可继续存档。 +- 原因:产品布局收口后,个人中心只保留设置、扫码、常用功能和条件性次级入口,存档恢复继续以后端 `/api/profile/save-archives` 真相为准,但不再作为页面直达入口。 +- 处理:后续如果需要重新暴露存档入口,优先评估是否应回到“玩过”或别的独立弹窗流程,不要默认把存档再塞回常用功能宫格或设置列表。 +- 验证:`npm test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "mobile profile page matches the reference layout sections|profile scan action opens camera scanner instead of recharge panel"`。 +- 关联:`src/components/rpg-entry/RpgEntryHomeView.tsx`、`docs/【项目基线】当前产品与工程约束-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 diff --git a/docs/README.md b/docs/README.md index 030744a9..39af032a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -17,6 +17,8 @@ 拼图生成页步骤真进度、步骤内假进度和精简展示口径见 [【玩法创作】拼图生成页进度口径-2026-05-23.md](./%E3%80%90%E7%8E%A9%E6%B3%95%E5%88%9B%E4%BD%9C%E3%80%91%E6%8B%BC%E5%9B%BE%E7%94%9F%E6%88%90%E9%A1%B5%E8%BF%9B%E5%BA%A6%E5%8F%A3%E5%BE%84-2026-05-23.md)。 +从文字需求生成高一致性美术素材流程抽象出的发明专利交底稿见 [【专利交底】一种极低成本快速生成高质量2D小游戏高一致性美术素材的解决方案-2026-05-25.md](./%E3%80%90%E4%B8%93%E5%88%A9%E4%BA%A4%E5%BA%95%E3%80%91%E4%B8%80%E7%A7%8D%E6%9E%81%E4%BD%8E%E6%88%90%E6%9C%AC%E5%BF%AB%E9%80%9F%E7%94%9F%E6%88%90%E9%AB%98%E8%B4%A8%E9%87%8F2D%E5%B0%8F%E6%B8%B8%E6%88%8F%E9%AB%98%E4%B8%80%E8%87%B4%E6%80%A7%E7%BE%8E%E6%9C%AF%E7%B4%A0%E6%9D%90%E7%9A%84%E8%A7%A3%E5%86%B3%E6%96%B9%E6%A1%88-2026-05-25.md)。 + 生产部署切换到 systemd + Nginx + SpacetimeDB 自托管的总方案见 [PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md](./technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md),该文档也是当前生产 Jenkinsfile 的唯一入口。SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md);private 表迁移 JSON 导入导出、HTTP 413 分片导入和旧数据库迁移流水线经验见 [SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md](./technical/SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md) 与 [JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md](./technical/JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md);后台管理独立前端工程技术方案见 [ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md](./technical/ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md)。 SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md)。 diff --git a/docs/【专利交底】一种极低成本快速生成高质量2D小游戏高一致性美术素材的解决方案-2026-05-25.md b/docs/【专利交底】一种极低成本快速生成高质量2D小游戏高一致性美术素材的解决方案-2026-05-25.md new file mode 100644 index 00000000..391e1d69 --- /dev/null +++ b/docs/【专利交底】一种极低成本快速生成高质量2D小游戏高一致性美术素材的解决方案-2026-05-25.md @@ -0,0 +1,184 @@ +# 一种极低成本快速生成高质量2D小游戏高一致性美术素材的解决方案 + +更新时间:`2026-05-25` + +> 本文为内部发明专利交底稿,目标是把“文字需求 -> 画面图 -> 透明 spritesheet -> 自动边界检测 -> 元素绑定”这一条高一致性美术素材生成链路抽象为可申请的通用技术方案。 + +## 摘要 + +本发明涉及一种极低成本快速生成高质量 2D 小游戏高一致性美术素材的解决方案。该方案接收文字形态的需求描述,调用图片生成模型生成一张用于表达整体视觉关系的游戏画面图;再以所述游戏画面图作为参考,继续调用图片生成模型生成一张透明背景的 spritesheet 图片,所述 spritesheet 图片承载需要随不同设备分辨率自适应调整位置的素材;随后基于自动边界检测算法对所述 spritesheet 图片中的素材进行逐一解析,按照从上到下、从左到右的顺序,将解析出的素材与文字形态需求描述中的画面元素一一对应,并将代码中的元素标识与对应素材绑定。该方案通过单次文字输入驱动画面图生成、基于画面图派生透明 spritesheet、自动边界检测替代人工切图、顺序映射替代手工命名和手工对图,从而在较少人工干预和较低重复生成成本下,快速得到风格统一、可直接绑定代码的高一致性美术素材。 + +## 技术领域 + +本发明属于人工智能图像生成、2D 小游戏美术素材生产、图像分割解析和元素绑定技术领域,具体涉及一种根据文字需求描述自动生成游戏画面图、spritesheet 美术素材和元素映射关系的方法及系统。 + +## 背景技术 + +现有 2D 小游戏的美术素材生产通常包含以下步骤:先由设计人员撰写文字需求,再由美术人员分别绘制画面图、按钮图、状态图和装饰图,之后由前端或游戏程序员进行切图、命名、排布和代码绑定。该流程存在如下问题: + +1. 素材往往分散生成,整体风格不统一。 +2. 多分辨率适配时,需要人工调整大量元素位置,维护成本高。 +3. 切图和命名依赖人工,容易出现遗漏、错位和绑定错误。 +4. 文字需求与最终代码元素之间缺少稳定映射,后续修改代价大。 +5. 若每个素材分别生成,会增加生成次数和等待成本。 + +因此,需要一种能够把文字需求直接转化为成套美术素材,并且能够自动解析、自动映射、自动绑定到代码中的方法。 + +## 发明内容 + +### 要解决的技术问题 + +本发明主要解决以下技术问题: + +1. 如何根据文字形态的需求描述快速生成一张完整游戏画面图。 +2. 如何基于该画面图进一步生成透明背景的 spritesheet 图片。 +3. 如何基于自动边界检测算法逐一解析 spritesheet 中的素材。 +4. 如何按照从上到下、从左到右的顺序将素材与文字描述中的画面元素一一对应。 +5. 如何将映射结果稳定绑定到代码,减少人工切图和手工配置成本。 + +### 技术方案 + +本发明提供一种高一致性美术素材生成方法,包括如下步骤: + +```text +文字形态需求描述 + -> 游戏画面图生成 + -> 透明背景 spritesheet 生成 + -> 自动边界检测解析素材 + -> 顺序映射文字元素 + -> 代码绑定 +``` + +其中,所述文字形态需求描述至少包含画面元素名称、语义说明、布局意图和顺序信息。所述游戏画面图用于表达整体视觉风格和元素关系;所述 spritesheet 图片用于承载需要随不同设备分辨率自适应调整位置的素材;所述自动边界检测算法用于把 spritesheet 中的独立素材一一切分出来;所述顺序映射用于将解析结果与文字描述中的元素一一对应;所述代码绑定用于将元素标识、资源地址、边界框或布局参数写入代码配置或元素表。 + +### 有益效果 + +与现有技术相比,本发明至少具有以下效果: + +1. 降低人工切图成本。 +2. 降低人工命名和代码绑定成本。 +3. 提升整体美术素材一致性。 +4. 提升多分辨率适配效率。 +5. 减少重复生成和重复调整次数。 +6. 让文字需求到代码元素的映射更稳定、更可维护。 + +## 附图说明 + +图 1 为本发明从文字需求描述到元素绑定的总体流程图。 + +图 2 为游戏画面图生成与 spritesheet 生成的派生关系示意图。 + +图 3 为自动边界检测算法解析透明背景 spritesheet 的流程图。 + +图 4 为素材顺序与文字形态需求描述中的画面元素一一对应的映射关系示意图。 + +## 具体实施方式 + +### 一、系统组成 + +本发明的系统可以包括如下模块: + +1. 输入采集模块:用于接收文字形态需求描述。 +2. 图像生成模块:用于根据文字形态需求描述生成游戏画面图。 +3. 图集生成模块:用于根据游戏画面图生成透明背景 spritesheet 图片。 +4. 边界检测模块:用于对 spritesheet 图片执行自动边界检测算法。 +5. 顺序映射模块:用于将解析出的素材按照从上到下、从左到右的顺序与文字描述中的画面元素对应。 +6. 代码绑定模块:用于将元素标识与对应素材绑定。 + +### 二、方法步骤 + +#### S100:接收文字形态需求描述 + +系统接收用户输入的文字形态需求描述。该需求描述可写成一段自然语言,也可写成按元素顺序排列的结构化文本。需求描述中应至少能够识别出画面元素名称、语义含义和布局顺序。 + +#### S200:生成游戏画面图 + +图像生成模块调用图片生成模型,根据所述文字形态需求描述生成一张完整游戏画面图。所述游戏画面图用于表达整体视觉关系、主次层级和风格基调,为后续 spritesheet 生成提供统一参考。 + +#### S300:生成透明背景 spritesheet 图片 + +图集生成模块以所述游戏画面图为参考,再次调用图片生成模型,生成一张透明背景的 spritesheet 图片。所述 spritesheet 图片中包含需要随不同设备分辨率自适应调整位置的素材,例如按钮、状态条、提示气泡、装饰元素或其他需要由代码控制位置的元素。 + +#### S400:自动边界检测解析素材 + +边界检测模块对所述 spritesheet 图片执行自动边界检测算法,对透明背景中的每个独立素材进行逐一解析,输出素材边界框、素材索引和必要的资源属性。所述自动边界检测算法优选采用 alpha 通道连通域检测、边界矩形检测或二者组合;在一个优选实施方式中,可复用拼图场景中已验证的自动边界检测思路,以提高解析稳定性。 + +#### S500:按照顺序映射文字元素 + +顺序映射模块将解析出的素材按照从上到下、从左到右的顺序进行排列,并与文字形态需求描述中的画面元素内容一一对应。若需求描述中已显式给出元素顺序,则优先按该顺序映射;若仅给出自然语言描述,则可先抽取元素列表,再按布局顺序排序。由此形成元素索引与语义名称之间的稳定映射关系。 + +#### S600:代码绑定 + +代码绑定模块将所述映射关系写入代码配置、元素表或资源清单中。代码侧只需读取元素标识,即可找到对应素材的资源地址、边界框和布局参数,从而完成美术素材与程序逻辑之间的直接绑定。 + +#### S700:输出美术素材包 + +系统最终输出至少包括游戏画面图、透明背景 spritesheet 图片、素材映射表和代码绑定结果。由于 spritesheet 图片与游戏画面图来自同一视觉链路,且素材顺序与文字描述顺序一一对应,因此可得到风格统一、可直接绑定、可适配多分辨率的高一致性美术素材包。 + +### 三、核心机制 + +1. 文字驱动:一次文字描述即可驱动画面图和 spritesheet 生成。 +2. 单图派生:spritesheet 以游戏画面图为参考生成,减少风格漂移。 +3. 自动解析:边界检测算法替代人工切图。 +4. 顺序对应:素材顺序与文字元素顺序一致,减少命名和对图错误。 +5. 代码绑定:映射结果可直接进入代码配置或资源表。 + +### 四、实施例 + +#### 实施例一:界面型 2D 小游戏素材生成 + +用户输入“科技实验室界面,顶部标题栏,中部主角色,底部三个操作按钮,右侧状态提示”。系统先生成一张完整游戏画面图,再生成一张透明背景 spritesheet 图片。边界检测模块解析出标题栏、主角色、操作按钮和状态提示等素材,顺序映射模块按从上到下、从左到右的顺序将其与文字描述对应,代码绑定模块将这些元素写入代码配置,最终形成可直接用于界面装配的素材包。 + +#### 实施例二:需要自适应位置的素材生成 + +用户输入“横版战斗界面,血条、技能按钮、提示气泡、道具栏”。系统将这些需要随设备分辨率自适应调整位置的元素集中生成到同一张 spritesheet 图片中。运行时,代码根据元素绑定结果对血条、按钮和提示元素进行位置调整,而不改变它们对应的语义关系。 + +#### 实施例三:代码与元素一一绑定 + +系统为解析出的每个素材分配唯一元素标识,例如 `top_title_bar`、`center_character`、`bottom_actions`、`right_status_hint`。代码侧通过元素标识直接读取对应素材的边界框和资源路径,从而消除人工对图和人工命名的步骤。 + +## 权利要求书草案 + +1. 一种极低成本快速生成高质量 2D 小游戏高一致性美术素材的方法,其特征在于,包括:接收文字形态需求描述;根据所述文字形态需求描述调用图片生成模型生成游戏画面图;以所述游戏画面图为参考图再次调用图片生成模型生成透明背景的 spritesheet 图片;对所述 spritesheet 图片执行自动边界检测算法,逐一解析素材边界;按照从上到下、从左到右的顺序将解析出的素材与所述文字形态需求描述中的画面元素一一对应;将代码中的元素标识与对应素材绑定。 + +2. 根据权利要求 1 所述的方法,其特征在于,所述文字形态需求描述至少包括画面元素名称、语义说明和布局顺序。 + +3. 根据权利要求 1 所述的方法,其特征在于,所述游戏画面图用于表达整体视觉风格和元素关系,所述 spritesheet 图片用于承载需要随不同设备分辨率自适应调整位置的素材。 + +4. 根据权利要求 1 所述的方法,其特征在于,所述 spritesheet 图片具有透明背景,且素材之间通过透明区域分隔。 + +5. 根据权利要求 1 所述的方法,其特征在于,所述自动边界检测算法包括基于 alpha 通道的连通域检测、边界矩形检测或二者组合。 + +6. 根据权利要求 5 所述的方法,其特征在于,所述自动边界检测算法复用拼图场景中已验证的素材边界解析思路。 + +7. 根据权利要求 1 所述的方法,其特征在于,所述从上到下、从左到右的顺序用于建立元素索引与语义名称之间的映射表。 + +8. 根据权利要求 1 所述的方法,其特征在于,所述代码绑定包括为每一素材写入唯一元素标识,并在代码中通过所述元素标识读取对应素材的资源地址、边界框或布局参数。 + +9. 根据权利要求 1 所述的方法,其特征在于,所述 spritesheet 图片中的素材包括按钮、状态条、提示元素、装饰元素或其他需要自适应布局的画面元素。 + +10. 根据权利要求 1 所述的方法,其特征在于,所述游戏画面图与所述 spritesheet 图片由同一视觉链路生成,以保持美术素材的一致性。 + +11. 根据权利要求 1 所述的方法,其特征在于,所述元素绑定结果用于在不同设备分辨率下动态调整素材位置,而不改变元素语义对应关系。 + +12. 一种极低成本快速生成高质量 2D 小游戏高一致性美术素材的系统,其特征在于,包括输入采集模块、图像生成模块、图集生成模块、边界检测模块、顺序映射模块和代码绑定模块;所述各模块被配置为执行权利要求 1 至 11 任一项所述的方法。 + +13. 一种电子设备,包括处理器和存储器,所述存储器中存储有计算机程序,其特征在于,所述计算机程序被所述处理器执行时实现权利要求 1 至 11 任一项所述的方法。 + +14. 一种计算机可读存储介质,其上存储有计算机程序,其特征在于,所述计算机程序被处理器执行时实现权利要求 1 至 11 任一项所述的方法。 + +## 可重点保护的创新点 + +1. 文字需求直接驱动一张游戏画面图。 +2. 基于该画面图再生成透明背景 spritesheet。 +3. 自动边界检测替代人工切图。 +4. 按从上到下、从左到右的顺序把素材与文字元素一一对应。 +5. 通过元素标识直接绑定代码与素材,减少人工命名和对图成本。 + +## 正式申请前建议 + +1. 检索是否已有“文字生成画面图 + spritesheet 自动解析 + 元素绑定”的相近专利,再确定独立权利要求的保护重心。 +2. 将“极低成本”“高质量”等效果性表述尽量放在说明书效果部分,权利要求中改写为“减少人工切图”“减少重复生成”“提高一致性”等技术特征。 +3. 避免在权利要求中绑定特定供应商或模型名称;模型名称可保留在实施例中。 +4. 如需扩大保护范围,可将“2D 小游戏”进一步上位为“交互式图像驱动应用”的美术素材生成方法。 +5. 如需增强授权稳定性,可将“文字驱动生成 + 透明 spritesheet + 自动边界检测 + 顺序映射 + 代码绑定”组合为主权利要求的必要技术特征。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 4b2f88be..90e69e6a 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -160,7 +160,7 @@ npm run check:server-rs-ddd - LLM:`GENARRATIVE_LLM_*`,创意 Agent 另用 `APIMART_BASE_URL` / `APIMART_API_KEY`。 - 图片生成:VectorEngine `gpt-image-2` 图片 provider 归属 `platform-image`,密钥只在后端环境变量中;`api-server` 内的 `openai_image_generation.rs` 只是兼容调用面和外部失败审计桥接,不再承载 provider 协议实现。APIMart 只保留给创意 Agent `gpt-5` Responses 文本 / 多模态链路;DashScope 只按仍在使用的历史能力单独处理,不作为 GPT-image-2 兜底。 -- Match3D 物品 sheet:关卡整图完成后走 VectorEngine `/v1/images/edits` multipart `image`,模型为 `gpt-image-2`,`2K 1:1` 输出 `10*10` spritesheet;物品 sheet prompt 固定要求纯绿色绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG,并把透明整图写入 `itemSpritesheetImageSrc/itemSpritesheetImageObjectKey`。后端固定从该 sheet 解析并持久化 20 个物品、每个 5 个形态;通用系列素材图集的行列索引按每行 2 个物品计算,必须落在 `1..=10`,难度只决定运行态加载 3 / 9 / 15 / 20 种。 +- Match3D 物品 sheet:关卡整图完成后走 VectorEngine `/v1/images/edits` multipart `image`,模型为 `gpt-image-2`,`2K 1:1` 输出 `10*10` spritesheet;物品 sheet prompt 固定要求纯绿色绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG,并把透明整图写入 `itemSpritesheetImageSrc/itemSpritesheetImageObjectKey`。后端优先按透明 alpha 连通域从该 sheet 识别真实素材矩形并持久化 20 个物品、每个 5 个形态;识别数量不足时才回退 `10*10` 固定网格。通用系列素材图集的行列索引按每行 2 个物品计算,必须落在 `1..=10`,难度只决定运行态加载 3 / 9 / 15 / 20 种。 - Match3D UI spritesheet 和背景派生图:关卡整图作为参考图并发生成 `1K 1:1` UI spritesheet 与 `1K 9:16` 背景图,模型均为 `gpt-image-2`。UI spritesheet prompt 固定要求纯绿色绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG;背景图必须合成为全画幅不透明 PNG。 - Match3D 1:1 容器 UI:VectorEngine `/v1/images/edits` multipart 参考图。该容器参考图是后端生图协议输入,必须通过 `include_bytes!` 随 `api-server` 编译进二进制,避免 API 单独发布或运行目录缺少 `public/` 时生成失败。 - 敲木鱼敲击物和背景环境图:VectorEngine `/v1/images/edits`,模型固定 `gpt-image-2`。敲击物支持 multipart 多参考图,第一张固定为后端内嵌默认木鱼图,用户上传图只作为新主题参考;prompt 必须要求 `1:1` 真实透明 alpha PNG 并禁止黑底、白底、棋盘格和任何实底背景。当前敲击物上传 OSS 前不做服务端抠图后处理,避免误伤玉米等主体像素。背景环境图只使用第一步抠图完成后的透明敲击物图作为参考,prompt 必须要求中央主体预留区保持干净,中央 40% 区域禁止出现主题主体、主体局部特写、轮廓影子或重复元素,主题元素只能作为外围氛围,且必须显式声明不继承任何绿色底色、绿幕底色或纯绿色画布。 diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 3db8e8f0..5a4f9703 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -72,7 +72,7 @@ RPG 从作品架、广场详情或作品号搜索点击“启动”前,入口 RPG 运行态的战斗终局、继续冒险、继续探索和切场景都属于服务端 runtime 快照真相:`module-runtime-story` 必须在终局战斗 action 后调用 post-battle finalization,持久写入 `story_continue_adventure`、`deferredOptions`、`deferredRuntimeState.storyEngineMemory.currentSceneActState` 和清理后的战斗状态;`idle_travel_next_scene` / `camp_travel_home_scene` 必须由后端写入新的 `currentScenePreset`、`currentSceneActState`、`currentEncounter` 和 `runtimeStats.scenesTraveled`。前端只播放退场、进场和继续按钮表现,不能用默认 `观察/试探/调息` fallback 或本地动画假装推进剧情。旧 bootstrap 快照可能只有 `connectedSceneIds` / `forwardSceneId` 而没有 `connections`,后端生成战后旅行选项时必须兼容这些字段。 -RPG / 拼图等运行态存档选择入口统一在个人中心 `次级入口 > 存档` 和设置入口区保留为独立弹窗;“玩过”弹窗可以继续合并展示可继续存档,但不能成为唯一入口。移动端“我的”页的五项常用功能宫格只放泥点充值、邀请好友、兑换码、玩家社区、反馈与建议,避免把存档挤入主宫格破坏参考图布局。前端只展示 `/api/profile/save-archives` 返回的列表并在用户选择后调用对应恢复接口,不能本地拼装或筛选正式存档真相。 +RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列表为真相,恢复动作继续走对应恢复接口,但移动端“我的”页已经不再提供独立的 `次级入口 > 存档` 和设置入口存档按钮;“玩过”弹窗可以继续合并展示可继续存档,个人中心只保留设置、扫码、常用功能和条件性次级入口。移动端“我的”页的五项常用功能宫格只放泥点充值、邀请好友、兑换码、玩家社区、反馈与建议,避免把存档挤入主宫格破坏参考图布局。前端只展示 `/api/profile/save-archives` 返回的列表并在用户选择后调用对应恢复接口,不能本地拼装或筛选正式存档真相。 ## 拼图 @@ -191,8 +191,8 @@ RPG / 拼图等运行态存档选择入口统一在个人中心 `次级入口 > 3. 首次调用 VectorEngine `gpt-image-2`,无参考图,竖屏 `9:16`,生成完整抓大鹅关卡画面并持久化到 `generatedBackgroundAsset.levelSceneImageSrc/levelSceneImageObjectKey`。提示词必须包含用户主题描述、顶部返回 / 标题倒计时 / 设置按钮、中间与主题匹配且贴横向边缘的容器,以及底部“移出 / 凑齐 / 打乱”三个道具按钮。 4. 关卡整图完成后并发发起三次 `gpt-image-2` 编辑请求,三者都以关卡整图作为参考图:`1K`、`1:1` 的 UI spritesheet 写入 `uiSpritesheetImageSrc/uiSpritesheetImageObjectKey`;`1K`、`9:16` 的背景图写入 `imageSrc/imageObjectKey`;`2K`、`1:1` 的物品 spritesheet 写入 `itemSpritesheetImageSrc/itemSpritesheetImageObjectKey`。 5. UI spritesheet 提示词固定要求按从上到下、从左到右整理纯绿色绿幕背景素材:返回按钮、设置按钮、方格素材(不含边框,仅保留一个)、移出按钮、凑齐按钮、打乱按钮;后端上传 OSS 前必须把绿幕扣成透明 PNG。背景图提示词固定要求移除全部 UI 组件和容器内含物,完整保留容器和背景,并补全被 UI 覆盖的背景内容。 -6. 物品 spritesheet 固定 `10行*10列`、统一纯绿色绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG;素材间距严格均匀分布,每一行包含两种物品,每种物品五个不同形态,物品来自参考图中心容器中的 2D 素材,严禁高相似度物品。新流程每次固定解析并持久化 `20` 种物品,物品信息列表全部展示这 `20` 种;持久化单格映射必须按 `row = itemIndex / 2 + 1`、`col = itemIndex % 2 * 5 + viewIndex + 1` 写入通用系列素材图集,不能再用 `row = itemIndex + 1`。`generatedItemAssets[].imageViews[]` 仍兼容已切好的五视角图,缺失时运行态和编辑器按 spritesheet 自动解析结果回退。 -7. 前端和运行态统一使用 alpha 连通域矩形检测解析 spritesheet:UI 图按返回、设置、方格、移出、凑齐、打乱顺序映射回原 UI 位置;物品图按检测顺序每 `5` 个区域组成一个物品的五个形态,最多 `20` 个物品。透明背景是解析前提,不能在前端按固定像素坐标写死切片。 +6. 物品 spritesheet 固定 `10行*10列`、统一纯绿色绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG;素材间距严格均匀分布,每一行包含两种物品,每种物品五个不同形态,物品来自参考图中心容器中的 2D 素材,严禁高相似度物品。新流程每次解析并持久化 `20` 种物品,物品信息列表全部展示这 `20` 种;后端切 `generatedItemAssets[].imageViews[]` 时优先按透明 alpha 连通域识别真实素材矩形,再按原图从上到下、从左到右排序,每 `5` 个区域组成一个物品的五个形态;只有识别出的区域数量不足时才回退 `10*10` 固定网格。持久化单格映射元数据仍按 `row = itemIndex / 2 + 1`、`col = itemIndex % 2 * 5 + viewIndex + 1` 写入通用系列素材图集,不能再用 `row = itemIndex + 1`。`generatedItemAssets[].imageViews[]` 仍兼容已切好的五视角图,缺失时运行态和编辑器按 spritesheet 自动解析结果回退。 +7. 前端和运行态统一使用 alpha 连通域矩形检测解析 spritesheet:UI 图先把识别出的透明素材矩形按行聚类,再在每一行内按横向 `x` 坐标排序,最后按返回、设置、方格、移出、凑齐、打乱顺序映射回原 UI 位置;不能只按全局 `y` 坐标排序,否则同一行素材上下略有错位时会把方格和底部道具按钮顺序打乱。物品图按检测顺序每 `5` 个区域组成一个物品的五个形态,最多 `20` 个物品。透明背景是解析前提,不能在前端按固定像素坐标写死切片。 8. 文本生成物品名称时必须同时生成 `itemSize`,只允许 `大`、`中`、`小`。该字段随 `generatedItemAssets[].itemSize` 持久化并下发;历史缺失字段的素材按 `大` 兼容,模型缺失或非法值按物品名本地推断。 9. 当前抓大鹅音频生成关闭:入口无 `生成音效`,草稿不生成背景音乐或点击音效,结果页不展示背景音乐 Tab 或点击音效生成入口。历史 `backgroundMusic` / `clickSound` 字段继续兼容传递。 10. 背景、UI spritesheet、物品 spritesheet 和历史容器兼容字段的持久化真相仍在 `generatedItemAssets[].backgroundAsset` 与提升后的 `generatedBackgroundAsset`;Agent session、work summary/detail、结果页和运行态入口都必须把该字段提升为 `backgroundImageSrc/backgroundImageObjectKey/generatedBackgroundAsset` 读取。草稿编译后的 `draftJson` 自身也必须携带 `generatedItemAssets` 快照;HTTP facade 不能只依赖 work detail 回读补齐 UI 资产,外部回读为空时也不得清空草稿内已有的背景 / 图集。平台壳层从作品架、广场、生成完成回调、结果页保存 / 发布 / 试玩回调进入 Match3D profile 时也要先归一化并提升,避免首次试玩、手动试玩、推荐流或公开详情运行态退回默认背景。 @@ -215,12 +215,15 @@ RPG / 拼图等运行态存档选择入口统一在个人中心 `次级入口 > - 难度只决定本局加载的物品种类数量:轻松 3、标准 9、进阶 15、硬核 20。硬核仍保留 21 次消除和 63 件总物品,运行态按 20 种素材循环复用,不要求生成第 21 种素材。 - 运行态启动前要预加载 `generatedItemAssets[].imageViews[]`、顶层 `generatedBackgroundAsset`、物品挂载 `backgroundAsset` 中的背景、UI spritesheet 和物品 spritesheet;首次生成自动试玩、结果页手动试玩、推荐流和公开详情启动都必须传入提升后的 profile。卡片摘要缺图集字段时,进入运行态前必须补读 work detail。补读后的 profile 也要再次提升 `generatedItemAssets[].backgroundAsset`,确保背景和图集字段传给 `Match3DRuntimeShell`。 - 背景图作为运行态全屏背景,图内已经保留容器;旧 `containerImage*` 只作为历史透明容器兼容字段。若 `containerImage*` 与 `uiSpritesheetImage*` 同源,运行态不得把 UI spritesheet 当中心容器图叠到棋盘上。 +- 抓大鹅运行态 HUD 需贴近拼图顶部信息条的视觉口径:左上只保留透明返回按钮;右上不再暴露设置入口;顶部关卡名和倒计时直接复用拼图同款的铭牌 + 下挂计时牌结构、同色板和同造型,并在牌面左侧挂上 `media/logo.png` 产品 logo;下方备选栏和道具图标只保留内容与交互边界,不再显示灰白半透底板;中央容器图层视觉可隐藏,但棋盘命中边界仍保留。 - generated 私有图换签未完成时,局内物品先隐藏等待,不得短暂显示默认积木;同一批资源在重启 run 时保留已解析签名 URL,只有资源源列表变化或换签失败后才允许进入兜底视觉。 - `itemSize` 只缩放生成 2D 图片本体:`大`、`中`、`小` 均按相对尺寸缩放,其中 `大` 也比原始图片略小,`中` 和 `小` 进一步缩小;不改变后端下发的布局半径、点击半径或三消规则。 - 物品进入底部物品栏时按同类型插入:如果物品栏已有同类物品,新物品插到该类型最后一个物品后面,后续物品整体后移;没有同类时追加到当前末尾。达到三件同类时,在飞入物品栏动画结束后,左侧和右侧同类物品向中间合成,三件一起消失,播放合成音效,不展示星星图标,后面的物品再向前补位。该动效只是前端表现层,后端和本地试玩仍负责权威插入、指定点击类型清除与补位后的槽位快照。 -- 抓大鹅运行态右上角常驻设置入口,不直接暴露重新开始按钮;重新开始收口到设置面板内,结算弹层仍保留结果态的再来一局动作。 +- 抓大鹅运行态不渲染右上角设置入口,也不在局内直接暴露重新开始按钮;结算弹层仍保留结果态的再来一局动作。 - 高 DPR 移动端 WebGL canvas 必须锁定 CSS 尺寸,避免右下溢出。 +发现页可挂载官方抓大鹅静态 demo,用于验证生图、切图和运行态资源闭环。当前 demo profile 固定为 `match3d-demo-20260525`,公开作品号为 `M3-20260525`,静态资源位于 `public/match3d-demo/undersea-candy-market/`;公开卡片、作品号搜索和详情启动都走平台现有公开作品详情,不新建页面。demo 运行态使用前端本地 `createLocalMatch3DRuntimeAdapter`,不调用正式 Match3D runtime 后端、不新增 SpacetimeDB schema,也不写正式作品统计;后续若要把 demo 资源转成正式公开作品,必须改为后端 profile / gallery 投影真相后再接正式 runtime。 + ## 视觉小说 当前视觉小说只吸收外部 TXT 玩法的创作与运行经验,不迁入外部平台社区、支付、榜单、私有存档或回放。 diff --git a/docs/【项目基线】当前产品与工程约束-2026-05-15.md b/docs/【项目基线】当前产品与工程约束-2026-05-15.md index 8ea8b30f..0007216b 100644 --- a/docs/【项目基线】当前产品与工程约束-2026-05-15.md +++ b/docs/【项目基线】当前产品与工程约束-2026-05-15.md @@ -93,9 +93,9 @@ server-rs + Axum + SpacetimeDB 7. 主站入口已锁定移动端页面级缩放;单个游戏页面不要再重复实现整页缩放锁定。 8. 图像输入通用 UI 统一走 `src/components/common/CreativeImageInputPanel.tsx`。外层页面持有业务状态,组件只承担上传卡、预览、参考图缩略图、AI 重绘开关、错误展示和提交按钮。 9. 发现页 `分类` 子频道的筛选必须打开独立 dialog / drawer / modal,至少支持玩法类型过滤与排序切换;筛选结果为空时显示空状态,不把筛选内容展开在当前列表下方。 -10. 移动端“我的”页按参考图顺序组织为顶部头像 / 昵称 / 陶泥号、会员横幅、三张统计卡、每日任务、五项常用功能宫格、设置入口、次级入口带和法律信息;`media/profile/` 中的陶泥素材作为该页图形资产。常用功能宫格固定承载泥点充值、邀请好友、兑换码、玩家社区、反馈与建议;存档和填邀请码保留在次级入口带,不挤入五宫格。 -11. “我的”页泥点、游戏时长、已玩游戏数量三张统计卡只展示各自标签和值,内容不换行,不在统计区底部展示“更新于”时间,字号维持平台普通 UI 档位;移动端昵称、会员卡、每日任务、常用功能和法律信息也应保持 `10px` 到 `14px` 的普通 UI 字号区间,避免展示级字号挤压内容。 -12. 移动端“我的”页需要兼容窄屏:头像 / 昵称 / 陶泥号、三张统计卡、每日任务、五项常用功能、次级入口和法律信息都必须能在底部固定 TabBar 上方完整滚动露出,不得与底部 dock、刘海 safe-area 或相邻 UI 元素遮挡重叠。 +10. 移动端“我的”页顶部品牌行承载扫码和设置入口,正文按参考图顺序组织为头像 / 昵称 / 陶泥号、会员横幅、三张统计卡、每日任务、五项常用功能宫格、设置入口、可选次级入口和法律信息;`media/profile/` 中的陶泥素材作为该页图形资产。常用功能宫格固定承载泥点充值、邀请好友、兑换码、玩家社区、反馈与建议;页面不再提供独立存档按钮入口,填邀请码仅在新用户可填写窗口内展示为次级入口。 +11. “我的”页泥点、游戏时长、已玩游戏数量三张统计卡只展示各自标签和值,三个统计 icon 使用小尺寸普通 UI 档位,内容不换行,不在统计区底部展示“更新于”时间;移动端昵称、会员卡、每日任务、常用功能和法律信息也应保持 `10px` 到 `14px` 的普通 UI 字号区间,避免展示级字号挤压内容。 +12. 移动端“我的”页需要兼容窄屏:头像 / 昵称 / 陶泥号、三张统计卡、每日任务、五项常用功能、可选次级入口和法律信息都必须能在底部固定 TabBar 上方完整滚动露出,不得与底部 dock、刘海 safe-area 或相邻 UI 元素遮挡重叠。 13. RPG 等运行态的战斗飘字、血量变化和即时反馈必须在暗色、噪声高的场景背景上保持可读:使用高亮文字、深色描边、强阴影或小面积半透明底,不只依赖红/绿文字本身表达伤害或治疗。 14. 平台亮色 UI 配色以陶泥儿主视觉为准:暖白 / 米杏底、陶土橙主按钮、深棕正文与浅杏边框;新增界面优先复用 `src/index.css` 的 `--platform-*` 主题变量和 `apps/admin-web/src/styles/admin.css` 的同系色值,不再引入粉红、蓝绿等独立主色方案。 diff --git a/public/match3d-demo/undersea-candy-market/background.png b/public/match3d-demo/undersea-candy-market/background.png new file mode 100644 index 00000000..06764c39 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/background.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-01/view-01.png b/public/match3d-demo/undersea-candy-market/item-slices/item-01/view-01.png new file mode 100644 index 00000000..67773c2a Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-01/view-01.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-01/view-02.png b/public/match3d-demo/undersea-candy-market/item-slices/item-01/view-02.png new file mode 100644 index 00000000..c54a7a31 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-01/view-02.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-01/view-03.png b/public/match3d-demo/undersea-candy-market/item-slices/item-01/view-03.png new file mode 100644 index 00000000..18698bcd Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-01/view-03.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-01/view-04.png b/public/match3d-demo/undersea-candy-market/item-slices/item-01/view-04.png new file mode 100644 index 00000000..e7457b08 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-01/view-04.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-01/view-05.png b/public/match3d-demo/undersea-candy-market/item-slices/item-01/view-05.png new file mode 100644 index 00000000..d41a367f Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-01/view-05.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-02/view-01.png b/public/match3d-demo/undersea-candy-market/item-slices/item-02/view-01.png new file mode 100644 index 00000000..91be1458 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-02/view-01.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-02/view-02.png b/public/match3d-demo/undersea-candy-market/item-slices/item-02/view-02.png new file mode 100644 index 00000000..f63c07f9 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-02/view-02.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-02/view-03.png b/public/match3d-demo/undersea-candy-market/item-slices/item-02/view-03.png new file mode 100644 index 00000000..ea63913b Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-02/view-03.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-02/view-04.png b/public/match3d-demo/undersea-candy-market/item-slices/item-02/view-04.png new file mode 100644 index 00000000..d794113a Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-02/view-04.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-02/view-05.png b/public/match3d-demo/undersea-candy-market/item-slices/item-02/view-05.png new file mode 100644 index 00000000..1ca654a8 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-02/view-05.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-03/view-01.png b/public/match3d-demo/undersea-candy-market/item-slices/item-03/view-01.png new file mode 100644 index 00000000..36bd5018 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-03/view-01.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-03/view-02.png b/public/match3d-demo/undersea-candy-market/item-slices/item-03/view-02.png new file mode 100644 index 00000000..d7c5b6de Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-03/view-02.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-03/view-03.png b/public/match3d-demo/undersea-candy-market/item-slices/item-03/view-03.png new file mode 100644 index 00000000..342bfdc3 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-03/view-03.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-03/view-04.png b/public/match3d-demo/undersea-candy-market/item-slices/item-03/view-04.png new file mode 100644 index 00000000..53df10fd Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-03/view-04.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-03/view-05.png b/public/match3d-demo/undersea-candy-market/item-slices/item-03/view-05.png new file mode 100644 index 00000000..34209763 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-03/view-05.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-04/view-01.png b/public/match3d-demo/undersea-candy-market/item-slices/item-04/view-01.png new file mode 100644 index 00000000..15b3d3a7 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-04/view-01.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-04/view-02.png b/public/match3d-demo/undersea-candy-market/item-slices/item-04/view-02.png new file mode 100644 index 00000000..143b2e1a Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-04/view-02.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-04/view-03.png b/public/match3d-demo/undersea-candy-market/item-slices/item-04/view-03.png new file mode 100644 index 00000000..42e30771 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-04/view-03.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-04/view-04.png b/public/match3d-demo/undersea-candy-market/item-slices/item-04/view-04.png new file mode 100644 index 00000000..2a255bfd Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-04/view-04.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-04/view-05.png b/public/match3d-demo/undersea-candy-market/item-slices/item-04/view-05.png new file mode 100644 index 00000000..86540e77 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-04/view-05.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-05/view-01.png b/public/match3d-demo/undersea-candy-market/item-slices/item-05/view-01.png new file mode 100644 index 00000000..aa029a38 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-05/view-01.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-05/view-02.png b/public/match3d-demo/undersea-candy-market/item-slices/item-05/view-02.png new file mode 100644 index 00000000..b1967dfc Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-05/view-02.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-05/view-03.png b/public/match3d-demo/undersea-candy-market/item-slices/item-05/view-03.png new file mode 100644 index 00000000..e040cca0 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-05/view-03.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-05/view-04.png b/public/match3d-demo/undersea-candy-market/item-slices/item-05/view-04.png new file mode 100644 index 00000000..8c144659 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-05/view-04.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-05/view-05.png b/public/match3d-demo/undersea-candy-market/item-slices/item-05/view-05.png new file mode 100644 index 00000000..7f3a1a4a Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-05/view-05.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-06/view-01.png b/public/match3d-demo/undersea-candy-market/item-slices/item-06/view-01.png new file mode 100644 index 00000000..de0ec31e Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-06/view-01.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-06/view-02.png b/public/match3d-demo/undersea-candy-market/item-slices/item-06/view-02.png new file mode 100644 index 00000000..a4c02a82 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-06/view-02.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-06/view-03.png b/public/match3d-demo/undersea-candy-market/item-slices/item-06/view-03.png new file mode 100644 index 00000000..3374aa7f Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-06/view-03.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-06/view-04.png b/public/match3d-demo/undersea-candy-market/item-slices/item-06/view-04.png new file mode 100644 index 00000000..6e01f5a4 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-06/view-04.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-06/view-05.png b/public/match3d-demo/undersea-candy-market/item-slices/item-06/view-05.png new file mode 100644 index 00000000..d31e82dc Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-06/view-05.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-07/view-01.png b/public/match3d-demo/undersea-candy-market/item-slices/item-07/view-01.png new file mode 100644 index 00000000..ea3629a8 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-07/view-01.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-07/view-02.png b/public/match3d-demo/undersea-candy-market/item-slices/item-07/view-02.png new file mode 100644 index 00000000..289752a5 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-07/view-02.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-07/view-03.png b/public/match3d-demo/undersea-candy-market/item-slices/item-07/view-03.png new file mode 100644 index 00000000..13f1641f Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-07/view-03.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-07/view-04.png b/public/match3d-demo/undersea-candy-market/item-slices/item-07/view-04.png new file mode 100644 index 00000000..f157cc56 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-07/view-04.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-07/view-05.png b/public/match3d-demo/undersea-candy-market/item-slices/item-07/view-05.png new file mode 100644 index 00000000..52f6d48d Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-07/view-05.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-08/view-01.png b/public/match3d-demo/undersea-candy-market/item-slices/item-08/view-01.png new file mode 100644 index 00000000..5a15a321 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-08/view-01.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-08/view-02.png b/public/match3d-demo/undersea-candy-market/item-slices/item-08/view-02.png new file mode 100644 index 00000000..be8d0f52 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-08/view-02.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-08/view-03.png b/public/match3d-demo/undersea-candy-market/item-slices/item-08/view-03.png new file mode 100644 index 00000000..5fcfba72 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-08/view-03.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-08/view-04.png b/public/match3d-demo/undersea-candy-market/item-slices/item-08/view-04.png new file mode 100644 index 00000000..e02c8b76 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-08/view-04.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-08/view-05.png b/public/match3d-demo/undersea-candy-market/item-slices/item-08/view-05.png new file mode 100644 index 00000000..28cb6b4a Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-08/view-05.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-09/view-01.png b/public/match3d-demo/undersea-candy-market/item-slices/item-09/view-01.png new file mode 100644 index 00000000..7a506040 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-09/view-01.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-09/view-02.png b/public/match3d-demo/undersea-candy-market/item-slices/item-09/view-02.png new file mode 100644 index 00000000..550a5434 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-09/view-02.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-09/view-03.png b/public/match3d-demo/undersea-candy-market/item-slices/item-09/view-03.png new file mode 100644 index 00000000..f11e249b Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-09/view-03.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-09/view-04.png b/public/match3d-demo/undersea-candy-market/item-slices/item-09/view-04.png new file mode 100644 index 00000000..ac3450d3 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-09/view-04.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-09/view-05.png b/public/match3d-demo/undersea-candy-market/item-slices/item-09/view-05.png new file mode 100644 index 00000000..f28a51eb Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-09/view-05.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-10/view-01.png b/public/match3d-demo/undersea-candy-market/item-slices/item-10/view-01.png new file mode 100644 index 00000000..c1b0f126 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-10/view-01.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-10/view-02.png b/public/match3d-demo/undersea-candy-market/item-slices/item-10/view-02.png new file mode 100644 index 00000000..7e94a7cd Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-10/view-02.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-10/view-03.png b/public/match3d-demo/undersea-candy-market/item-slices/item-10/view-03.png new file mode 100644 index 00000000..8687dcdf Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-10/view-03.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-10/view-04.png b/public/match3d-demo/undersea-candy-market/item-slices/item-10/view-04.png new file mode 100644 index 00000000..c8db5f3d Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-10/view-04.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-10/view-05.png b/public/match3d-demo/undersea-candy-market/item-slices/item-10/view-05.png new file mode 100644 index 00000000..baed94e9 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-10/view-05.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-11/view-01.png b/public/match3d-demo/undersea-candy-market/item-slices/item-11/view-01.png new file mode 100644 index 00000000..1657cda2 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-11/view-01.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-11/view-02.png b/public/match3d-demo/undersea-candy-market/item-slices/item-11/view-02.png new file mode 100644 index 00000000..8ed07b3f Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-11/view-02.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-11/view-03.png b/public/match3d-demo/undersea-candy-market/item-slices/item-11/view-03.png new file mode 100644 index 00000000..15529207 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-11/view-03.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-11/view-04.png b/public/match3d-demo/undersea-candy-market/item-slices/item-11/view-04.png new file mode 100644 index 00000000..2a86bdff Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-11/view-04.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-11/view-05.png b/public/match3d-demo/undersea-candy-market/item-slices/item-11/view-05.png new file mode 100644 index 00000000..287c5062 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-11/view-05.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-12/view-01.png b/public/match3d-demo/undersea-candy-market/item-slices/item-12/view-01.png new file mode 100644 index 00000000..b4bec1b1 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-12/view-01.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-12/view-02.png b/public/match3d-demo/undersea-candy-market/item-slices/item-12/view-02.png new file mode 100644 index 00000000..bc9a5efc Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-12/view-02.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-12/view-03.png b/public/match3d-demo/undersea-candy-market/item-slices/item-12/view-03.png new file mode 100644 index 00000000..f33ac7a1 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-12/view-03.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-12/view-04.png b/public/match3d-demo/undersea-candy-market/item-slices/item-12/view-04.png new file mode 100644 index 00000000..6aef21e4 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-12/view-04.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-12/view-05.png b/public/match3d-demo/undersea-candy-market/item-slices/item-12/view-05.png new file mode 100644 index 00000000..28ccfebd Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-12/view-05.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-13/view-01.png b/public/match3d-demo/undersea-candy-market/item-slices/item-13/view-01.png new file mode 100644 index 00000000..b96f8659 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-13/view-01.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-13/view-02.png b/public/match3d-demo/undersea-candy-market/item-slices/item-13/view-02.png new file mode 100644 index 00000000..cab8dcce Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-13/view-02.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-13/view-03.png b/public/match3d-demo/undersea-candy-market/item-slices/item-13/view-03.png new file mode 100644 index 00000000..e6c596b5 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-13/view-03.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-13/view-04.png b/public/match3d-demo/undersea-candy-market/item-slices/item-13/view-04.png new file mode 100644 index 00000000..de19c617 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-13/view-04.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-13/view-05.png b/public/match3d-demo/undersea-candy-market/item-slices/item-13/view-05.png new file mode 100644 index 00000000..2a97686e Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-13/view-05.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-14/view-01.png b/public/match3d-demo/undersea-candy-market/item-slices/item-14/view-01.png new file mode 100644 index 00000000..a85d8846 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-14/view-01.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-14/view-02.png b/public/match3d-demo/undersea-candy-market/item-slices/item-14/view-02.png new file mode 100644 index 00000000..89700c47 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-14/view-02.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-14/view-03.png b/public/match3d-demo/undersea-candy-market/item-slices/item-14/view-03.png new file mode 100644 index 00000000..93d5a24e Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-14/view-03.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-14/view-04.png b/public/match3d-demo/undersea-candy-market/item-slices/item-14/view-04.png new file mode 100644 index 00000000..306f389f Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-14/view-04.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-14/view-05.png b/public/match3d-demo/undersea-candy-market/item-slices/item-14/view-05.png new file mode 100644 index 00000000..b2e15f9c Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-14/view-05.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-15/view-01.png b/public/match3d-demo/undersea-candy-market/item-slices/item-15/view-01.png new file mode 100644 index 00000000..e0e4c830 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-15/view-01.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-15/view-02.png b/public/match3d-demo/undersea-candy-market/item-slices/item-15/view-02.png new file mode 100644 index 00000000..5b2075bc Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-15/view-02.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-15/view-03.png b/public/match3d-demo/undersea-candy-market/item-slices/item-15/view-03.png new file mode 100644 index 00000000..0ec2990e Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-15/view-03.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-15/view-04.png b/public/match3d-demo/undersea-candy-market/item-slices/item-15/view-04.png new file mode 100644 index 00000000..9f67b3c2 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-15/view-04.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-15/view-05.png b/public/match3d-demo/undersea-candy-market/item-slices/item-15/view-05.png new file mode 100644 index 00000000..abac6fda Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-15/view-05.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-16/view-01.png b/public/match3d-demo/undersea-candy-market/item-slices/item-16/view-01.png new file mode 100644 index 00000000..2ffb76b5 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-16/view-01.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-16/view-02.png b/public/match3d-demo/undersea-candy-market/item-slices/item-16/view-02.png new file mode 100644 index 00000000..905fbcae Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-16/view-02.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-16/view-03.png b/public/match3d-demo/undersea-candy-market/item-slices/item-16/view-03.png new file mode 100644 index 00000000..8088e492 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-16/view-03.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-16/view-04.png b/public/match3d-demo/undersea-candy-market/item-slices/item-16/view-04.png new file mode 100644 index 00000000..7bcf8ed3 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-16/view-04.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-16/view-05.png b/public/match3d-demo/undersea-candy-market/item-slices/item-16/view-05.png new file mode 100644 index 00000000..0a92a912 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-16/view-05.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-17/view-01.png b/public/match3d-demo/undersea-candy-market/item-slices/item-17/view-01.png new file mode 100644 index 00000000..68414718 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-17/view-01.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-17/view-02.png b/public/match3d-demo/undersea-candy-market/item-slices/item-17/view-02.png new file mode 100644 index 00000000..8ab28c8d Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-17/view-02.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-17/view-03.png b/public/match3d-demo/undersea-candy-market/item-slices/item-17/view-03.png new file mode 100644 index 00000000..f010a827 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-17/view-03.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-17/view-04.png b/public/match3d-demo/undersea-candy-market/item-slices/item-17/view-04.png new file mode 100644 index 00000000..2d9d8dda Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-17/view-04.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-17/view-05.png b/public/match3d-demo/undersea-candy-market/item-slices/item-17/view-05.png new file mode 100644 index 00000000..6b048f4d Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-17/view-05.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-18/view-01.png b/public/match3d-demo/undersea-candy-market/item-slices/item-18/view-01.png new file mode 100644 index 00000000..09fb23c1 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-18/view-01.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-18/view-02.png b/public/match3d-demo/undersea-candy-market/item-slices/item-18/view-02.png new file mode 100644 index 00000000..8f69247a Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-18/view-02.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-18/view-03.png b/public/match3d-demo/undersea-candy-market/item-slices/item-18/view-03.png new file mode 100644 index 00000000..ed575181 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-18/view-03.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-18/view-04.png b/public/match3d-demo/undersea-candy-market/item-slices/item-18/view-04.png new file mode 100644 index 00000000..ab399979 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-18/view-04.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-18/view-05.png b/public/match3d-demo/undersea-candy-market/item-slices/item-18/view-05.png new file mode 100644 index 00000000..ff5820dc Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-18/view-05.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-19/view-01.png b/public/match3d-demo/undersea-candy-market/item-slices/item-19/view-01.png new file mode 100644 index 00000000..19b53c51 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-19/view-01.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-19/view-02.png b/public/match3d-demo/undersea-candy-market/item-slices/item-19/view-02.png new file mode 100644 index 00000000..6f114282 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-19/view-02.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-19/view-03.png b/public/match3d-demo/undersea-candy-market/item-slices/item-19/view-03.png new file mode 100644 index 00000000..806861f8 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-19/view-03.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-19/view-04.png b/public/match3d-demo/undersea-candy-market/item-slices/item-19/view-04.png new file mode 100644 index 00000000..3b1c5aca Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-19/view-04.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-19/view-05.png b/public/match3d-demo/undersea-candy-market/item-slices/item-19/view-05.png new file mode 100644 index 00000000..e08a962e Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-19/view-05.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-20/view-01.png b/public/match3d-demo/undersea-candy-market/item-slices/item-20/view-01.png new file mode 100644 index 00000000..1f93d6b0 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-20/view-01.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-20/view-02.png b/public/match3d-demo/undersea-candy-market/item-slices/item-20/view-02.png new file mode 100644 index 00000000..9f208acf Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-20/view-02.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-20/view-03.png b/public/match3d-demo/undersea-candy-market/item-slices/item-20/view-03.png new file mode 100644 index 00000000..8cf39682 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-20/view-03.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-20/view-04.png b/public/match3d-demo/undersea-candy-market/item-slices/item-20/view-04.png new file mode 100644 index 00000000..1b16d126 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-20/view-04.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-20/view-05.png b/public/match3d-demo/undersea-candy-market/item-slices/item-20/view-05.png new file mode 100644 index 00000000..a85ff45b Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-20/view-05.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-spritesheet.png b/public/match3d-demo/undersea-candy-market/item-spritesheet.png new file mode 100644 index 00000000..fc4a95ea Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-spritesheet.png differ diff --git a/public/match3d-demo/undersea-candy-market/level-scene.png b/public/match3d-demo/undersea-candy-market/level-scene.png new file mode 100644 index 00000000..256562ef Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/level-scene.png differ diff --git a/public/match3d-demo/undersea-candy-market/ui-spritesheet.png b/public/match3d-demo/undersea-candy-market/ui-spritesheet.png new file mode 100644 index 00000000..b096ea0f Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/ui-spritesheet.png differ diff --git a/scripts/export-match3d-resource-pipeline-postprocess.py b/scripts/export-match3d-resource-pipeline-postprocess.py new file mode 100644 index 00000000..2a3e1c99 --- /dev/null +++ b/scripts/export-match3d-resource-pipeline-postprocess.py @@ -0,0 +1,345 @@ +import argparse +import json +from collections import deque +from pathlib import Path + +from PIL import Image + + +UI_ORDER = ["back", "settings", "tile", "remove", "match", "shuffle"] +VIEW_COUNT = 5 +ITEM_COUNT = 20 +GRID_SIZE = 10 + + +def green_screen_score(pixel): + red, green, blue, alpha = pixel + if alpha == 0: + return 1.0 + red = float(red) + green = float(green) + blue = float(blue) + green_lead = green - max(red, blue) + if green < 96.0 or green_lead <= 18.0: + return 0.0 + green_ratio = green / max(red + blue, 1.0) + if green_ratio <= 0.9: + return 0.0 + return max( + 0.0, + min( + 1.0, + ((green - 96.0) / 128.0) * 0.34 + + ((green_lead - 18.0) / 120.0) * 0.46 + + ((green_ratio - 0.9) / 2.4) * 0.20, + ), + ) + + +def white_screen_score(pixel): + red, green, blue, alpha = pixel + if alpha == 0: + return 1.0 + red = float(red) + green = float(green) + blue = float(blue) + max_channel = max(red, green, blue) + min_channel = min(red, green, blue) + average = (red + green + blue) / 3.0 + if average < 188.0 or min_channel < 168.0: + return 0.0 + spread = max_channel - min_channel + neutrality = 1.0 - max(0.0, min(1.0, (spread - 6.0) / 34.0)) + brightness = max(0.0, min(1.0, (average - 188.0) / 55.0)) + floor = max(0.0, min(1.0, (min_channel - 168.0) / 60.0)) + return max(0.0, min(1.0, neutrality * (brightness * 0.85 + floor * 0.15))) + + +def apply_green_screen_alpha(source): + image = source.convert("RGBA") + pixels = image.load() + width, height = image.size + for y in range(height): + for x in range(width): + red, green, blue, alpha = pixels[x, y] + score = green_screen_score((red, green, blue, alpha)) + if score >= 0.82: + pixels[x, y] = (red, green, blue, 0) + elif score >= 0.34: + next_alpha = int(round(alpha * (1.0 - min(1.0, score * 1.08)))) + if next_alpha < 10: + next_alpha = 0 + pixels[x, y] = (red, green, blue, next_alpha) + return image + + +def make_background_opaque(source): + image = source.convert("RGBA") + width, height = image.size + edge_pixels = [] + pixels = image.load() + for x in range(width): + edge_pixels.append(pixels[x, 0]) + edge_pixels.append(pixels[x, height - 1]) + for y in range(1, max(1, height - 1)): + edge_pixels.append(pixels[0, y]) + edge_pixels.append(pixels[width - 1, y]) + weighted = [0, 0, 0, 0] + for red, green, blue, alpha in edge_pixels: + if alpha < 32: + continue + weighted[0] += red * alpha + weighted[1] += green * alpha + weighted[2] += blue * alpha + weighted[3] += alpha + matte = ( + tuple(channel // weighted[3] for channel in weighted[:3]) + if weighted[3] > 0 + else (246, 243, 236) + ) + for y in range(height): + for x in range(width): + red, green, blue, alpha = pixels[x, y] + if alpha == 255: + continue + inv = 255 - alpha + pixels[x, y] = ( + (red * alpha + matte[0] * inv + 127) // 255, + (green * alpha + matte[1] * inv + 127) // 255, + (blue * alpha + matte[2] * inv + 127) // 255, + 255, + ) + return image + + +def visible(pixel, threshold=36): + return pixel[3] >= threshold + + +def detect_components(image, alpha_threshold=36): + width, height = image.size + pixels = image.load() + visited = bytearray(width * height) + min_area = max(16, min(800, (width * height) // 12000)) + components = [] + for start in range(width * height): + if visited[start]: + continue + sx = start % width + sy = start // width + if not visible(pixels[sx, sy], alpha_threshold): + visited[start] = 1 + continue + queue = deque([(sx, sy)]) + visited[start] = 1 + min_x = max_x = sx + min_y = max_y = sy + area = 0 + while queue: + x, y = queue.pop() + area += 1 + min_x = min(min_x, x) + max_x = max(max_x, x) + min_y = min(min_y, y) + max_y = max(max_y, y) + for nx, ny in ((x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1)): + if nx < 0 or ny < 0 or nx >= width or ny >= height: + continue + index = ny * width + nx + if visited[index]: + continue + visited[index] = 1 + if visible(pixels[nx, ny], alpha_threshold): + queue.append((nx, ny)) + if area >= min_area: + components.append( + { + "x": min_x, + "y": min_y, + "width": max_x - min_x + 1, + "height": max_y - min_y + 1, + "area": area, + } + ) + return sort_components_by_original_position(components) + + +def sort_components_by_original_position(components): + if not components: + return [] + average_height = sum(component["height"] for component in components) / len(components) + row_tolerance = max(2.0, average_height * 0.65) + rows = [] + for component in sorted(components, key=lambda item: (item["y"], item["x"])): + center_y = component["y"] + component["height"] / 2.0 + target_row = None + for row in rows: + row_center = sum(item["y"] + item["height"] / 2.0 for item in row) / len(row) + if abs(row_center - center_y) <= row_tolerance: + target_row = row + break + if target_row is None: + rows.append([component]) + else: + target_row.append(component) + sorted_components = [] + for row in rows: + sorted_components.extend(sorted(row, key=lambda item: item["x"])) + return sorted_components + + +def trim_visible_bounds(image): + width, height = image.size + pixels = image.load() + min_x = width + min_y = height + max_x = -1 + max_y = -1 + visible_count = 0 + for y in range(height): + for x in range(width): + if not visible(pixels[x, y], 12): + continue + visible_count += 1 + min_x = min(min_x, x) + min_y = min(min_y, y) + max_x = max(max_x, x) + max_y = max(max_y, y) + min_visible = max(10, min(120, (width * height) // 540)) + if visible_count < min_visible or max_x <= min_x or max_y <= min_y: + return image + return image.crop((min_x, min_y, max_x + 1, max_y + 1)) + + +def crop_region(image, component): + x = component["x"] + y = component["y"] + width = component["width"] + height = component["height"] + return trim_visible_bounds(image.crop((x, y, x + width, y + height))) + + +def fallback_grid_slice(image, item_count=ITEM_COUNT): + width, height = image.size + slices = [] + items_per_row = GRID_SIZE // VIEW_COUNT + for item_index in range(item_count): + row = item_index // items_per_row + start_col = (item_index % items_per_row) * VIEW_COUNT + for view_index in range(VIEW_COUNT): + col = start_col + view_index + x0 = col * width // GRID_SIZE + x1 = (col + 1) * width // GRID_SIZE + y0 = row * height // GRID_SIZE + y1 = (row + 1) * height // GRID_SIZE + cell = image.crop((x0, y0, x1, y1)) + slices.append((item_index, view_index, trim_visible_bounds(cell))) + return slices + + +def save_ui_slices(image, out_dir): + components = detect_components(image, 36) + slices_dir = out_dir / "03-ui-slices" + slices_dir.mkdir(parents=True, exist_ok=True) + regions = [] + for index, component in enumerate(components[: len(UI_ORDER)]): + label = UI_ORDER[index] + output = slices_dir / f"{index + 1:02d}-{label}.png" + crop_region(image, component).save(output) + regions.append({**component, "label": label, "file": str(output)}) + return { + "detectedCount": len(components), + "usedCount": len(regions), + "regions": regions, + } + + +def save_item_slices(image, out_dir): + components = detect_components(image, 36) + slices_dir = out_dir / "07-item-slices" + slices_dir.mkdir(parents=True, exist_ok=True) + expected = ITEM_COUNT * VIEW_COUNT + use_components = len(components) >= expected + if use_components: + source_slices = [ + (index // VIEW_COUNT, index % VIEW_COUNT, crop_region(image, component)) + for index, component in enumerate(components[:expected]) + ] + else: + source_slices = fallback_grid_slice(image) + + items = [] + for item_index in range(ITEM_COUNT): + item_dir = slices_dir / f"item-{item_index + 1:02d}" + item_dir.mkdir(parents=True, exist_ok=True) + views = [] + for _, view_index, crop in [ + entry for entry in source_slices if entry[0] == item_index + ]: + output = item_dir / f"view-{view_index + 1:02d}.png" + crop.save(output) + views.append({"viewIndex": view_index + 1, "file": str(output)}) + items.append({"itemIndex": item_index + 1, "views": views}) + return { + "method": "alpha-components" if use_components else "fallback-grid", + "detectedCount": len(components), + "expectedCount": expected, + "items": items, + "regions": components[:expected], + } + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--out-dir", required=True) + args = parser.parse_args() + out_dir = Path(args.out_dir).resolve() + + level_scene = Image.open(out_dir / "01-level-scene.raw.png").convert("RGBA") + ui_raw = Image.open(out_dir / "02-ui-spritesheet.raw.png").convert("RGBA") + background_raw = Image.open(out_dir / "04-background.raw.png").convert("RGBA") + item_raw = Image.open(out_dir / "06-item-spritesheet.raw.png").convert("RGBA") + + ui_transparent = apply_green_screen_alpha(ui_raw) + item_transparent = apply_green_screen_alpha(item_raw) + background_opaque = make_background_opaque(background_raw) + + ui_transparent.save(out_dir / "02-ui-spritesheet.transparent.png") + background_opaque.save(out_dir / "05-background.opaque.png") + item_transparent.save(out_dir / "06-item-spritesheet.transparent.png") + + ui_manifest = save_ui_slices(ui_transparent, out_dir) + item_manifest = save_item_slices(item_transparent, out_dir) + + manifest = { + "levelScene": { + "file": str(out_dir / "01-level-scene.raw.png"), + "size": level_scene.size, + }, + "uiSpritesheet": { + "rawFile": str(out_dir / "02-ui-spritesheet.raw.png"), + "transparentFile": str(out_dir / "02-ui-spritesheet.transparent.png"), + "size": ui_transparent.size, + **ui_manifest, + }, + "background": { + "rawFile": str(out_dir / "04-background.raw.png"), + "opaqueFile": str(out_dir / "05-background.opaque.png"), + "size": background_opaque.size, + }, + "itemSpritesheet": { + "rawFile": str(out_dir / "06-item-spritesheet.raw.png"), + "transparentFile": str(out_dir / "06-item-spritesheet.transparent.png"), + "size": item_transparent.size, + **item_manifest, + }, + } + (out_dir / "08-export-manifest.json").write_text( + json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + print(json.dumps({"ok": True, "manifest": str(out_dir / "08-export-manifest.json")}, ensure_ascii=False)) + + +if __name__ == "__main__": + main() diff --git a/scripts/export-match3d-resource-pipeline.mjs b/scripts/export-match3d-resource-pipeline.mjs new file mode 100644 index 00000000..8b6d1126 --- /dev/null +++ b/scripts/export-match3d-resource-pipeline.mjs @@ -0,0 +1,378 @@ +import {Buffer} from 'node:buffer'; +import {existsSync, mkdirSync, readFileSync, writeFileSync} from 'node:fs'; +import path from 'node:path'; +import {spawnSync} from 'node:child_process'; + +import {mergeApiServerEnv} from './dev-utils.mjs'; + +const repoRoot = process.cwd(); +const defaultTimeoutMs = 1_000_000; +const defaultTheme = '海底糖果集市'; +const uiLabels = ['back', 'settings', 'tile', 'remove', 'match', 'shuffle']; + +function parseArgs(argv) { + const args = { + live: false, + theme: defaultTheme, + outDir: '', + }; + for (let index = 2; index < argv.length; index += 1) { + const raw = argv[index]; + if (raw === '--live') { + args.live = true; + continue; + } + if (raw === '--theme') { + args.theme = String(argv[index + 1] ?? defaultTheme); + index += 1; + continue; + } + if (raw === '--out-dir') { + args.outDir = String(argv[index + 1] ?? ''); + index += 1; + } + } + return args; +} + +function timestamp() { + const now = new Date(); + const pad = (value) => String(value).padStart(2, '0'); + return [ + now.getFullYear(), + pad(now.getMonth() + 1), + pad(now.getDate()), + '-', + pad(now.getHours()), + pad(now.getMinutes()), + pad(now.getSeconds()), + ].join(''); +} + +function resolveEnv() { + const env = mergeApiServerEnv(repoRoot, process.env); + return { + baseUrl: String(env.VECTOR_ENGINE_BASE_URL || '').trim().replace(/\/+$/u, ''), + apiKey: String(env.VECTOR_ENGINE_API_KEY || '').trim(), + timeoutMs: Number.parseInt( + String(env.VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS || defaultTimeoutMs), + 10, + ), + }; +} + +function generationUrl(baseUrl) { + return baseUrl.endsWith('/v1') + ? `${baseUrl}/images/generations` + : `${baseUrl}/v1/images/generations`; +} + +function editUrl(baseUrl) { + return baseUrl.endsWith('/v1') + ? `${baseUrl}/images/edits` + : `${baseUrl}/v1/images/edits`; +} + +function promptWithNegative(prompt, negative) { + const normalizedPrompt = prompt.trim(); + const normalizedNegative = String(negative ?? '').trim(); + return normalizedNegative + ? `${normalizedPrompt}\n避免:${normalizedNegative}` + : normalizedPrompt; +} + +function buildLevelScenePrompt(theme) { + const normalizedTheme = String(theme || defaultTheme).trim() || defaultTheme; + return [ + '生成抓大鹅游戏关卡画面,要求画面中所有元素精致且风格高度一致,画面中所有UI细节饱满精致、完成度高、顶级游戏品质', + '', + '抓大鹅主题描述:', + normalizedTheme, + '', + '画面元素:', + '返回按钮位于顶部左上角,顶部中间显示关卡标题“第1关 重庆火锅”和倒计时时间,右上角显示设置按钮', + '画面中间是一个和主题匹配的容器,宽度与画面宽度同宽,紧贴画面横向边缘', + '底部还有三个道具按钮分别为“移出”、“凑齐”、“打乱”', + ].join('\n'); +} + +function buildUiSpritesheetPrompt() { + return '提取画面中的UI元素,将返回按钮、设置按钮、方格素材(不含边框,仅保留一个)、移出按钮、凑齐按钮、打乱按钮的顺序从上到下从左到右整理成纯绿色绿幕背景spritesheet。背景必须是统一纯绿色绿幕(高饱和亮绿色,接近 #00FF00),背景平整无纹理、无渐变、无阴影、无场景内容,后端会在生图后将绿幕扣成透明并把透明背景 PNG 存到 OSS。UI 素材自身不得使用接近 #00FF00 的高饱和纯绿;绿色题材只能使用深绿、橄榄绿、金绿或蓝绿,并用清晰描边与绿幕区分。'; +} + +function buildBackgroundPrompt() { + return '移除画面中的所有UI组件和容器中的内含物,完整保留容器和背景,补全被UI覆盖的背景内容'; +} + +function buildItemSpritesheetPrompt() { + return '固定生成10行*10列spritesheet图,统一纯绿色绿幕背景(高饱和亮绿色,接近 #00FF00),背景平整无纹理、无渐变、无阴影、无场景内容,后端会在生图后将绿幕扣成透明并把透明背景 PNG 存到 OSS。素材间距严格均匀分布,任意两个素材间距相同,物品来自参考图中画面中心容器中的2D素材。每一行包含两种物品,每种物品的五个不同形态。物品素材自身不得使用接近 #00FF00 的高饱和纯绿;绿色物品只能使用深绿、橄榄绿、金绿或蓝绿,并用清晰描边与绿幕区分。严禁出现两种高相似度的物品'; +} + +function collectStringsByKey(value, targetKey, output) { + if (Array.isArray(value)) { + value.forEach((entry) => collectStringsByKey(entry, targetKey, output)); + return; + } + if (!value || typeof value !== 'object') { + return; + } + for (const [key, nested] of Object.entries(value)) { + if (key === targetKey) { + if (typeof nested === 'string' && nested.trim()) { + output.push(nested.trim()); + } else if (Array.isArray(nested)) { + nested.forEach((entry) => { + if (typeof entry === 'string' && entry.trim()) { + output.push(entry.trim()); + } + }); + } + } + collectStringsByKey(nested, targetKey, output); + } +} + +function inferExtensionFromBytes(bytes) { + if (bytes.subarray(0, 8).equals(Buffer.from('\x89PNG\r\n\x1A\n', 'binary'))) { + return 'png'; + } + if (bytes.subarray(0, 3).equals(Buffer.from([0xff, 0xd8, 0xff]))) { + return 'jpg'; + } + if ( + bytes.subarray(0, 4).toString('ascii') === 'RIFF' && + bytes.subarray(8, 12).toString('ascii') === 'WEBP' + ) { + return 'webp'; + } + return 'png'; +} + +async function fetchJson(url, options, timeoutMs) { + const abortController = new AbortController(); + const timer = setTimeout(() => abortController.abort(), timeoutMs); + try { + const response = await fetch(url, { + ...options, + signal: abortController.signal, + }); + const text = await response.text(); + if (!response.ok) { + throw new Error(`VectorEngine ${response.status}: ${text.slice(0, 600)}`); + } + return JSON.parse(text); + } catch (error) { + if (error?.name === 'AbortError') { + throw new Error(`VectorEngine request timed out after ${timeoutMs}ms`); + } + throw error; + } finally { + clearTimeout(timer); + } +} + +async function downloadUrl(url, timeoutMs) { + const abortController = new AbortController(); + const timer = setTimeout(() => abortController.abort(), timeoutMs); + try { + const response = await fetch(url, {signal: abortController.signal}); + if (!response.ok) { + throw new Error(`download ${response.status}`); + } + return Buffer.from(await response.arrayBuffer()); + } finally { + clearTimeout(timer); + } +} + +async function imageBytesFromPayload(payload, env) { + const urls = []; + const b64Images = []; + collectStringsByKey(payload, 'url', urls); + collectStringsByKey(payload, 'image', urls); + collectStringsByKey(payload, 'image_url', urls); + collectStringsByKey(payload, 'b64_json', b64Images); + + const imageUrl = [...new Set(urls)].find((url) => /^https?:\/\//u.test(url)); + if (imageUrl) { + return downloadUrl(imageUrl, env.timeoutMs); + } + if (b64Images[0]) { + return Buffer.from(b64Images[0], 'base64'); + } + throw new Error('VectorEngine returned no image'); +} + +async function generateImage(env, {prompt, negativePrompt, size, outPath}) { + const body = { + model: 'gpt-image-2', + prompt: promptWithNegative(prompt, negativePrompt), + n: 1, + size, + }; + const payload = await fetchJson( + generationUrl(env.baseUrl), + { + method: 'POST', + headers: { + Authorization: `Bearer ${env.apiKey}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }, + env.timeoutMs, + ); + const bytes = await imageBytesFromPayload(payload, env); + writeFileSync(outPath, bytes); + return { + outPath, + extension: inferExtensionFromBytes(bytes), + bytes: bytes.length, + }; +} + +async function editImage(env, {prompt, negativePrompt, size, referencePath, outPath}) { + const referenceBytes = readFileSync(referencePath); + const form = new FormData(); + form.append('model', 'gpt-image-2'); + form.append('prompt', promptWithNegative(prompt, negativePrompt)); + form.append('n', '1'); + form.append('size', size); + form.append( + 'image', + new Blob([referenceBytes], {type: 'image/png'}), + path.basename(referencePath), + ); + const payload = await fetchJson( + editUrl(env.baseUrl), + { + method: 'POST', + headers: { + Authorization: `Bearer ${env.apiKey}`, + Accept: 'application/json', + }, + body: form, + }, + env.timeoutMs, + ); + const bytes = await imageBytesFromPayload(payload, env); + writeFileSync(outPath, bytes); + return { + outPath, + extension: inferExtensionFromBytes(bytes), + bytes: bytes.length, + }; +} + +function runPostprocess(outDir) { + const postprocessPath = path.join(repoRoot, 'scripts', 'export-match3d-resource-pipeline-postprocess.py'); + const result = spawnSync('python', [postprocessPath, '--out-dir', outDir], { + cwd: repoRoot, + encoding: 'utf8', + }); + if (result.stdout) { + process.stdout.write(result.stdout); + } + if (result.stderr) { + process.stderr.write(result.stderr); + } + if (result.status !== 0) { + throw new Error(`postprocess failed with code ${result.status}`); + } +} + +async function main() { + const args = parseArgs(process.argv); + const outDir = path.resolve( + repoRoot, + args.outDir || path.join('output', `match3d-resource-pipeline-${timestamp()}`), + ); + mkdirSync(outDir, {recursive: true}); + + const prompts = { + theme: args.theme, + levelScenePrompt: buildLevelScenePrompt(args.theme), + uiSpritesheetPrompt: buildUiSpritesheetPrompt(), + backgroundPrompt: buildBackgroundPrompt(), + itemSpritesheetPrompt: buildItemSpritesheetPrompt(), + uiLabels, + }; + writeFileSync( + path.join(outDir, '00-prompts.json'), + `${JSON.stringify(prompts, null, 2)}\n`, + 'utf8', + ); + + if (!args.live) { + console.log( + JSON.stringify( + { + mode: 'dry-run', + outDir, + message: '加 --live 才会真实调用 VectorEngine。', + prompts, + }, + null, + 2, + ), + ); + return; + } + + const env = resolveEnv(); + if (!env.baseUrl || !env.apiKey) { + throw new Error('Missing VECTOR_ENGINE_BASE_URL or VECTOR_ENGINE_API_KEY'); + } + + console.log(`[match3d-export] 1/4 生成关卡整图 -> ${outDir}`); + const levelScene = await generateImage(env, { + prompt: prompts.levelScenePrompt, + negativePrompt: '水印、教程浮层、菜单、广告、真实手机外框、浏览器 UI', + size: '1024x1536', + outPath: path.join(outDir, '01-level-scene.raw.png'), + }); + + console.log('[match3d-export] 2/4 并发生成 UI 图集、背景图、物品图集'); + const [ui, background, items] = await Promise.all([ + editImage(env, { + prompt: prompts.uiSpritesheetPrompt, + negativePrompt: '整页背景、中心物品、容器内物品、重复按钮、文字说明、白底、纯色底、网格线', + size: '1024x1024', + referencePath: levelScene.outPath, + outPath: path.join(outDir, '02-ui-spritesheet.raw.png'), + }), + editImage(env, { + prompt: prompts.backgroundPrompt, + negativePrompt: '返回按钮、设置按钮、倒计时、标题文字、道具按钮、物品、容器内含物、菜单、教程浮层', + size: '1024x1536', + referencePath: levelScene.outPath, + outPath: path.join(outDir, '04-background.raw.png'), + }), + editImage(env, { + prompt: prompts.itemSpritesheetPrompt, + negativePrompt: '文字、水印、UI、边框、网格线、标签、人物手部、复杂背景、非绿幕背景、白色背景、灰色背景、渐变背景、纹理背景', + // 中文注释:这里按当前后端 normalize_image_size("2k") 的实际请求尺寸复现。 + size: '1536x1024', + referencePath: levelScene.outPath, + outPath: path.join(outDir, '06-item-spritesheet.raw.png'), + }), + ]); + + writeFileSync( + path.join(outDir, '00-generation-results.json'), + `${JSON.stringify({levelScene, ui, background, items}, null, 2)}\n`, + 'utf8', + ); + + console.log('[match3d-export] 3/4 执行绿幕透明化、背景不透明化和连通域切片'); + runPostprocess(outDir); + + console.log('[match3d-export] 4/4 完成'); + console.log(JSON.stringify({ok: true, outDir}, null, 2)); +} + +main().catch((error) => { + console.error(`[match3d-export] failed: ${error?.stack || error}`); + process.exit(1); +}); diff --git a/server-rs/crates/api-server/src/generated_asset_sheets.rs b/server-rs/crates/api-server/src/generated_asset_sheets.rs index 18c1423b..986673d8 100644 --- a/server-rs/crates/api-server/src/generated_asset_sheets.rs +++ b/server-rs/crates/api-server/src/generated_asset_sheets.rs @@ -247,8 +247,14 @@ pub(crate) fn slice_generated_asset_sheet_two_items_per_row( let items_per_row = grid_size / views_per_item; let max_item_count = grid_size.saturating_mul(items_per_row); - let mut slices = Vec::with_capacity(item_names.len().min(max_item_count)); - for item_index in 0..item_names.len().min(max_item_count) { + let item_count = item_names.len().min(max_item_count); + if let Some(slices) = + slice_generated_asset_sheet_by_alpha_components(&source, item_count, views_per_item)? + { + return Ok(slices); + } + let mut slices = Vec::with_capacity(item_count); + for item_index in 0..item_count { let row = (item_index / items_per_row) as u32; let start_col = ((item_index % items_per_row) * views_per_item) as u32; let mut views = Vec::with_capacity(views_per_item); @@ -469,6 +475,12 @@ struct GeneratedAssetSheetCellBounds { y1: u32, } +#[derive(Clone, Copy, Debug)] +struct GeneratedAssetSheetDetectedComponent { + bounds: GeneratedAssetSheetCellBounds, + area: u32, +} + impl GeneratedAssetSheetCellBounds { fn width(self) -> u32 { self.x1.saturating_sub(self.x0).max(1) @@ -487,6 +499,272 @@ impl GeneratedAssetSheetCellBounds { } } +fn detect_generated_asset_sheet_alpha_components( + image: &image::RgbaImage, +) -> Vec { + let (width, height) = image.dimensions(); + let pixel_count = width.saturating_mul(height) as usize; + if width == 0 || height == 0 || pixel_count == 0 { + return Vec::new(); + } + + let mut visited = vec![0u8; pixel_count]; + let min_area = resolve_generated_asset_sheet_alpha_component_min_area(width, height); + let mut components = Vec::new(); + for start in 0..pixel_count { + if visited[start] != 0 { + continue; + } + let start_pixel = image + .get_pixel(start as u32 % width, start as u32 / width) + .0; + if !is_generated_asset_sheet_visible_pixel(start_pixel) { + visited[start] = 1; + continue; + } + + let component = + flood_fill_generated_asset_sheet_alpha_component(image, &mut visited, start); + if component.area >= min_area { + components.push(component); + } + } + + components +} + +fn slice_generated_asset_sheet_by_alpha_components( + source: &image::DynamicImage, + item_count: usize, + views_per_item: usize, +) -> Result>>, AppError> { + if item_count == 0 { + return Ok(Some(Vec::new())); + } + + let sheet_grid_size = views_per_item.checked_mul(2).ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": GENERATED_ASSET_SHEET_PROVIDER, + "message": "系列素材图集的每物品视图数超出可支持范围。", + })) + })?; + let sheet_grid_size_u32 = u32::try_from(sheet_grid_size).map_err(|_| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": GENERATED_ASSET_SHEET_PROVIDER, + "message": "系列素材图集的每物品视图数超出可支持范围。", + })) + })?; + + let source = source.to_rgba8(); + let (width, height) = source.dimensions(); + if width == 0 || height == 0 { + return Ok(None); + } + + let cell_width = width / sheet_grid_size_u32; + let cell_height = height / sheet_grid_size_u32; + if cell_width == 0 || cell_height == 0 { + return Ok(None); + } + + let components = detect_generated_asset_sheet_alpha_components(&source); + let expected_slot_count = item_count.saturating_mul(views_per_item); + if components.len() < expected_slot_count { + return Ok(None); + } + + let components = sort_generated_asset_sheet_components_by_original_position( + components, + cell_height as f32 * 0.65, + ); + let components = components + .into_iter() + .take(expected_slot_count) + .collect::>(); + if components.len() < expected_slot_count { + return Ok(None); + } + + let mut slices = Vec::with_capacity(item_count); + for item_index in 0..item_count { + let mut views = Vec::with_capacity(views_per_item); + for view_index in 0..views_per_item { + let slot_index = item_index * views_per_item + view_index; + let component = &components[slot_index]; + let pad_x = (cell_width / 16).clamp(4, 16); + let pad_y = (cell_height / 16).clamp(4, 16); + let crop_x = component.bounds.x0.saturating_sub(pad_x); + let crop_y = component.bounds.y0.saturating_sub(pad_y); + let crop_x1 = component.bounds.x1.saturating_add(pad_x).min(width); + let crop_y1 = component.bounds.y1.saturating_add(pad_y).min(height); + let cropped = image::DynamicImage::ImageRgba8( + image::imageops::crop_imm( + &source, + crop_x, + crop_y, + crop_x1.saturating_sub(crop_x).max(1), + crop_y1.saturating_sub(crop_y).max(1), + ) + .to_image(), + ); + let mut cursor = std::io::Cursor::new(Vec::new()); + cropped + .write_to(&mut cursor, ImageFormat::Png) + .map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": GENERATED_ASSET_SHEET_PROVIDER, + "message": format!("系列素材图集切割失败:{error}"), + })) + })?; + views.push(GeneratedAssetSheetSliceImage { + bytes: cursor.into_inner(), + }); + } + slices.push(views); + } + + Ok(Some(slices)) +} + +fn resolve_generated_asset_sheet_alpha_component_min_area(width: u32, height: u32) -> u32 { + (width.saturating_mul(height) / 12_000).clamp(16, 800) +} + +fn flood_fill_generated_asset_sheet_alpha_component( + image: &image::RgbaImage, + visited: &mut [u8], + start: usize, +) -> GeneratedAssetSheetDetectedComponent { + let (width, height) = image.dimensions(); + let mut stack = vec![start]; + visited[start] = 1; + + let mut min_x = start as u32 % width; + let mut max_x = min_x; + let mut min_y = start as u32 / width; + let mut max_y = min_y; + let mut area = 0u32; + + while let Some(index) = stack.pop() { + let x = index as u32 % width; + let y = index as u32 / width; + area = area.saturating_add(1); + min_x = min_x.min(x); + max_x = max_x.max(x); + min_y = min_y.min(y); + max_y = max_y.max(y); + + visit_generated_asset_sheet_alpha_component_neighbor( + image, + visited, + &mut stack, + index.wrapping_sub(1), + x > 0, + ); + visit_generated_asset_sheet_alpha_component_neighbor( + image, + visited, + &mut stack, + index + 1, + x + 1 < width, + ); + visit_generated_asset_sheet_alpha_component_neighbor( + image, + visited, + &mut stack, + index.saturating_sub(width as usize), + y > 0, + ); + visit_generated_asset_sheet_alpha_component_neighbor( + image, + visited, + &mut stack, + index + width as usize, + y + 1 < height, + ); + } + + GeneratedAssetSheetDetectedComponent { + bounds: GeneratedAssetSheetCellBounds { + x0: min_x, + y0: min_y, + x1: max_x.saturating_add(1), + y1: max_y.saturating_add(1), + }, + area, + } +} + +fn visit_generated_asset_sheet_alpha_component_neighbor( + image: &image::RgbaImage, + visited: &mut [u8], + stack: &mut Vec, + index: usize, + in_bounds: bool, +) { + if !in_bounds || visited.get(index).copied().unwrap_or(1) != 0 { + return; + } + let (width, _) = image.dimensions(); + let pixel = image + .get_pixel(index as u32 % width, index as u32 / width) + .0; + if !is_generated_asset_sheet_visible_pixel(pixel) { + visited[index] = 1; + return; + } + + visited[index] = 1; + stack.push(index); +} + +fn sort_generated_asset_sheet_components_by_original_position( + components: Vec, + row_tolerance_hint: f32, +) -> Vec { + if components.is_empty() { + return components; + } + + let average_height = components + .iter() + .map(|component| component.bounds.height() as f32) + .sum::() + / components.len() as f32; + let row_tolerance = row_tolerance_hint.max(average_height * 0.65).max(2.0); + let mut rows: Vec> = Vec::new(); + + let mut sorted = components; + sorted.sort_by(|left, right| { + left.bounds + .y0 + .cmp(&right.bounds.y0) + .then_with(|| left.bounds.x0.cmp(&right.bounds.x0)) + }); + for component in sorted { + let center_y = component.bounds.y0 as f32 + component.bounds.height() as f32 / 2.0; + if let Some(row) = rows.iter_mut().find(|items| { + let row_center = items + .iter() + .map(|item| item.bounds.y0 as f32 + item.bounds.height() as f32 / 2.0) + .sum::() + / items.len() as f32; + (row_center - center_y).abs() <= row_tolerance + }) { + row.push(component); + } else { + rows.push(vec![component]); + } + } + + rows.into_iter() + .flat_map(|mut row| { + row.sort_by(|left, right| left.bounds.x0.cmp(&right.bounds.x0)); + row + }) + .collect() +} + fn resolve_generated_asset_sheet_cell_crop( source: &image::DynamicImage, grid_size: u32, @@ -1674,6 +1952,193 @@ mod tests { assert_eq!(slices[1].len(), 5); } + #[test] + fn generated_asset_sheet_two_items_per_row_uses_alpha_components_when_views_cross_cell_boundaries() { + let width = 1_000; + let height = 1_000; + let item_names = vec!["樱桃".to_string(), "苹果".to_string()]; + let colors = [ + ([220, 20, 24, 255], [246, 178, 46, 255]), + ([230, 88, 20, 255], [248, 196, 78, 255]), + ([240, 170, 28, 255], [250, 214, 110, 255]), + ([90, 180, 54, 255], [188, 236, 86, 255]), + ([20, 150, 200, 255], [92, 214, 248, 255]), + ([70, 96, 220, 255], [124, 150, 252, 255]), + ([150, 80, 210, 255], [188, 118, 248, 255]), + ([210, 80, 170, 255], [248, 130, 204, 255]), + ([140, 92, 48, 255], [190, 136, 82, 255]), + ([34, 34, 34, 255], [88, 88, 88, 255]), + ]; + let positions = [ + (90u32, 40u32), + (190, 44), + (290, 38), + (390, 42), + (490, 36), + (590, 540), + (690, 536), + (790, 544), + (890, 538), + (960, 542), + ]; + let mut sheet = image::RgbaImage::from_pixel(width, height, image::Rgba([0, 0, 0, 0])); + for (index, (left_color, right_color)) in colors.iter().enumerate() { + let (start_x, start_y) = positions[index]; + for y in start_y..start_y + 36 { + for x in start_x..start_x + 16 { + sheet.put_pixel(x, y, image::Rgba(*left_color)); + } + for x in start_x + 16..start_x + 32 { + sheet.put_pixel(x, y, image::Rgba(*right_color)); + } + } + } + let mut encoded = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(sheet) + .write_to(&mut encoded, ImageFormat::Png) + .expect("sheet should encode"); + let image = DownloadedOpenAiImage { + bytes: encoded.into_inner(), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }; + + let slices = slice_generated_asset_sheet_two_items_per_row(&image, &item_names, 10, 5) + .expect("sheet should slice"); + + assert_eq!(slices.len(), 2); + assert_eq!(slices[0].len(), 5); + assert_eq!(slices[1].len(), 5); + for (index, (left_color, right_color)) in colors.iter().enumerate() { + let item_index = index / 5; + let view_index = index % 5; + let decoded = image::load_from_memory(slices[item_index][view_index].bytes.as_slice()) + .expect("view should decode") + .to_rgba8(); + assert!( + decoded.pixels().any(|pixel| pixel.0 == *left_color), + "第 {index} 个格位应保留左侧主体颜色" + ); + assert!( + decoded.pixels().any(|pixel| pixel.0 == *right_color), + "第 {index} 个格位应保留右侧主体颜色" + ); + } + } + + #[test] + fn generated_asset_sheet_two_items_per_row_keeps_cell_order_when_views_are_vertically_scrambled() + { + let width = 1_000; + let height = 1_000; + let item_names = vec!["樱桃".to_string(), "苹果".to_string()]; + let colors = [ + [220, 20, 24, 255], + [230, 88, 20, 255], + [240, 170, 28, 255], + [90, 180, 54, 255], + [20, 150, 200, 255], + [70, 96, 220, 255], + [150, 80, 210, 255], + [210, 80, 170, 255], + [140, 92, 48, 255], + [34, 34, 34, 255], + ]; + let top_offsets = [62u32, 18, 74, 26, 68, 20, 72, 24, 66, 22]; + let mut sheet = image::RgbaImage::from_pixel(width, height, image::Rgba([0, 0, 0, 0])); + for (index, color) in colors.iter().enumerate() { + let start_x = index as u32 * 100 + 34; + let start_y = top_offsets[index]; + for y in start_y..start_y + 36 { + for x in start_x..start_x + 32 { + sheet.put_pixel(x, y, image::Rgba(*color)); + } + } + } + let mut encoded = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(sheet) + .write_to(&mut encoded, ImageFormat::Png) + .expect("sheet should encode"); + let image = DownloadedOpenAiImage { + bytes: encoded.into_inner(), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }; + + let slices = slice_generated_asset_sheet_two_items_per_row(&image, &item_names, 10, 5) + .expect("sheet should slice"); + + assert_eq!(slices.len(), 2); + assert_eq!(slices[0].len(), 5); + assert_eq!(slices[1].len(), 5); + for (index, color) in colors.iter().enumerate() { + let item_index = index / 5; + let view_index = index % 5; + let decoded = image::load_from_memory(slices[item_index][view_index].bytes.as_slice()) + .expect("view should decode") + .to_rgba8(); + assert!( + decoded.pixels().any(|pixel| pixel.0 == *color), + "第 {index} 个格位应按列顺序保留对应主体颜色" + ); + } + } + + #[test] + fn generated_asset_sheet_two_items_per_row_falls_back_to_fixed_grid_when_alpha_components_are_insufficient() + { + let width = 1_000; + let height = 1_000; + let item_names = vec!["樱桃".to_string(), "苹果".to_string()]; + let mut sheet = image::RgbaImage::new(width, height); + for row in 0..10 { + for col in 0..10 { + let color = image::Rgba([ + 16 + row as u8 * 20, + 12 + col as u8 * 18, + 230 - row as u8 * 12, + 255, + ]); + for y in row * 100..(row + 1) * 100 { + for x in col * 100..(col + 1) * 100 { + sheet.put_pixel(x, y, color); + } + } + } + } + let mut encoded = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(sheet) + .write_to(&mut encoded, ImageFormat::Png) + .expect("sheet should encode"); + let image = DownloadedOpenAiImage { + bytes: encoded.into_inner(), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }; + + let slices = slice_generated_asset_sheet_two_items_per_row(&image, &item_names, 10, 5) + .expect("sheet should slice"); + + assert_eq!(slices.len(), 2); + assert_eq!(slices[0].len(), 5); + assert_eq!(slices[1].len(), 5); + for (item_index, views) in slices.iter().enumerate() { + for (view_index, view) in views.iter().enumerate() { + let decoded = image::load_from_memory(view.bytes.as_slice()) + .expect("view should decode") + .to_rgba8(); + let pixel = decoded.get_pixel(decoded.width() / 2, decoded.height() / 2); + let row = 0u8; + let col = (item_index * 5 + view_index) as u8; + assert_eq!( + pixel.0, + [16 + row * 20, 12 + col * 18, 230 - row * 12, 255,], + "item {item_index} view {view_index} should keep the fixed grid fallback" + ); + } + } + } + #[test] fn generated_asset_sheet_prepare_put_request_packs_prompt_metadata() { let request = prepare_generated_asset_sheet_put_request(GeneratedAssetSheetPersistInput { diff --git a/src/components/match3d-runtime/Match3DRuntimeShell.test.tsx b/src/components/match3d-runtime/Match3DRuntimeShell.test.tsx index 3e40c21a..f30c49ea 100644 --- a/src/components/match3d-runtime/Match3DRuntimeShell.test.tsx +++ b/src/components/match3d-runtime/Match3DRuntimeShell.test.tsx @@ -1,13 +1,6 @@ /* @vitest-environment jsdom */ -import { - act, - fireEvent, - render, - screen, - waitFor, - within, -} from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { useEffect } from 'react'; import { afterEach, expect, test, vi } from 'vitest'; @@ -235,35 +228,58 @@ test('顶部 HUD 对齐拼图样式展示关卡名和倒计时', () => { expect(screen.getByText('第 1 关')).toBeTruthy(); expect(screen.getByText('水果抓大鹅')).toBeTruthy(); expect(screen.getByText('10:00')).toBeTruthy(); - expect(screen.getByRole('button', { name: '打开抓大鹅设置' })).toBeTruthy(); - expect(screen.queryByRole('button', { name: '重新开始' })).toBeNull(); + expect(screen.getByTestId('match3d-runtime-level-logo')).toBeTruthy(); + expect( + screen.getByText('水果抓大鹅').closest('.puzzle-runtime-level-title-card'), + ).toBeTruthy(); + const timerCard = screen.getByText('10:00').closest('.puzzle-runtime-timer-card'); + expect(timerCard).toBeTruthy(); + expect(timerCard?.className).toContain('puzzle-runtime-timer'); + expect(screen.queryByRole('button', { name: '打开抓大鹅设置' })).toBeNull(); + expect(screen.getByRole('button', { name: '返回' })).toBeTruthy(); + expect(screen.queryByTestId('match3d-ui-sprite-settings')).toBeNull(); }); -test('抓大鹅右上角设置面板内置重新开始', () => { - const run = startLocalMatch3DRun(4); - const onRestart = vi.fn(); +test('抓大鹅运行态不再渲染设置入口', () => { render( , ); - fireEvent.click(screen.getByRole('button', { name: '打开抓大鹅设置' })); - - const dialog = screen.getByRole('dialog', { name: '抓大鹅设置' }); - expect(within(dialog).getByText('水果抓大鹅')).toBeTruthy(); - expect(within(dialog).getByText('已清除 0/12')).toBeTruthy(); - fireEvent.click(within(dialog).getByRole('button', { name: '重新开始' })); - - expect(onRestart).toHaveBeenCalledTimes(1); + expect(screen.queryByRole('button', { name: '打开抓大鹅设置' })).toBeNull(); expect(screen.queryByRole('dialog', { name: '抓大鹅设置' })).toBeNull(); }); +test('抓大鹅顶部和底部保留交互边界但不显示旧半透底', () => { + render( + , + ); + + expect(screen.getByTestId('match3d-board').className).toContain( + 'bg-transparent', + ); + expect(screen.getAllByTestId('match3d-tray-slot')[0]!.className).toContain( + 'bg-transparent', + ); + expect( + screen.getByRole('button', { name: '移出' }).className, + ).toContain('bg-transparent'); + expect(screen.getByRole('button', { name: '返回' }).className).toContain( + 'bg-transparent', + ); +}); + test('推荐页抓大鹅运行态隐藏返回按钮和结算返回入口', () => { const run: Match3DRunSnapshot = { ...startLocalMatch3DRun(4), @@ -1548,9 +1564,7 @@ test('运行态从UI spritesheet裁切按钮并映射到原UI位置', async () = expect( screen.getByTestId('match3d-ui-sprite-back').getAttribute('src'), ).toBe('data:image/png;base64,返回'); - expect( - screen.getByTestId('match3d-ui-sprite-settings').getAttribute('src'), - ).toBe('data:image/png;base64,设置'); + expect(screen.queryByTestId('match3d-ui-sprite-settings')).toBeNull(); expect( screen.getByTestId('match3d-ui-sprite-prop-remove').getAttribute('src'), ).toBe('data:image/png;base64,移出'); diff --git a/src/components/match3d-runtime/Match3DRuntimeShell.tsx b/src/components/match3d-runtime/Match3DRuntimeShell.tsx index 28b0c18f..4da11ef2 100644 --- a/src/components/match3d-runtime/Match3DRuntimeShell.tsx +++ b/src/components/match3d-runtime/Match3DRuntimeShell.tsx @@ -1,9 +1,7 @@ import { ArrowLeft, CheckCircle2, - Clock3, - RotateCcw, - Settings, + Clock, XCircle, } from 'lucide-react'; import { @@ -17,6 +15,7 @@ import { useState, } from 'react'; +import match3DRuntimeLevelLogo from '../../../media/logo.png'; import type { Match3DClickItemRequest, Match3DClickItemResult, @@ -71,19 +70,9 @@ import { } from './match3dRuntimePresentation'; import { MATCH3D_RUNTIME_BOARD_BASE_CLASS, - MATCH3D_RUNTIME_BOARD_FALLBACK_CLASS, MATCH3D_RUNTIME_BOARD_WIDTH, - MATCH3D_RUNTIME_BOARD_WITH_CONTAINER_CLASS, MATCH3D_RUNTIME_CONTAINER_IMAGE_CLASS, - MATCH3D_RUNTIME_CONTAINER_PLACEHOLDER_CLASS, - MATCH3D_RUNTIME_GLASS_ICON_BUTTON_CLASS, - MATCH3D_RUNTIME_GLASS_TRAY_CLASS, - MATCH3D_RUNTIME_GLASS_TRAY_SLOT_CLASS, - MATCH3D_RUNTIME_HEADER_CARD_CLASS, - MATCH3D_RUNTIME_LEVEL_BADGE_CLASS, MATCH3D_RUNTIME_STAGE_CLASS, - MATCH3D_RUNTIME_TIMER_CLASS, - MATCH3D_RUNTIME_TIMER_URGENT_CLASS, } from './match3dRuntimeUiStyles'; import { Match3DVisualIcon, resolveVisualSeed } from './match3dVisualAssets'; @@ -769,7 +758,7 @@ function Match3DTrayToken({ }) { if (!slot.visualKey) { return ( - + ); } const visualSeed = resolveVisualSeed(slot.visualKey); @@ -1030,7 +1019,6 @@ export function Match3DRuntimeShell({ const [timeLeftMs, setTimeLeftMs] = useState(run?.remainingMs ?? 0); const [resolvedBackgroundImageSrc, setResolvedBackgroundImageSrc] = useState(''); - const [isSettingsPanelOpen, setIsSettingsPanelOpen] = useState(false); const musicVolume = authUi?.musicVolume ?? DEFAULT_MATCH3D_MUSIC_VOLUME; const levelAudioConfig = DEFAULT_RUNTIME_LEVEL_AUDIO_CONFIG; const runtimeGeneratedItemAssets = useMemo( @@ -1366,10 +1354,6 @@ export function Match3DRuntimeShell({ useState(''); const [resolvedContainerImageSrc, setResolvedContainerImageSrc] = useState(''); - const [isContainerImageLoaded, setIsContainerImageLoaded] = useState(false); - const hasRenderedContainerAsset = Boolean( - resolvedContainerImageSrc && isContainerImageLoaded, - ); const clickSoundByTypeId = useMemo(() => { if (!run) { return new Map(); @@ -1489,7 +1473,6 @@ export function Match3DRuntimeShell({ let cancelled = false; const controller = new AbortController(); setResolvedContainerImageSrc(''); - setIsContainerImageLoaded(false); if (!isGeneratedLegacyPath(containerAssetSrc)) { setResolvedContainerImageSrc(containerAssetSrc); return undefined; @@ -1501,7 +1484,6 @@ export function Match3DRuntimeShell({ .then((resolvedSrc) => { if (!cancelled) { setResolvedContainerImageSrc(resolvedSrc); - setIsContainerImageLoaded(false); } }) .catch(() => { @@ -1511,7 +1493,6 @@ export function Match3DRuntimeShell({ ? '' : MATCH3D_CONTAINER_REFERENCE_SRC, ); - setIsContainerImageLoaded(false); } }); @@ -1937,9 +1918,8 @@ export function Match3DRuntimeShell({ const timerClassName = timeLeftMs <= levelAudioConfig.countdownWarningThresholdMs && isRunState(run.status, 'running') - ? MATCH3D_RUNTIME_TIMER_URGENT_CLASS - : MATCH3D_RUNTIME_TIMER_CLASS; - const canRestartRun = Boolean(run?.runId) && !isBusy; + ? 'puzzle-runtime-timer--urgent' + : 'puzzle-runtime-timer'; return (
@@ -1992,43 +1972,38 @@ export function Match3DRuntimeShell({ ) : null} )} -
-
- 第 1 关 - +
+
+ + + 第 1 关 + + {displayLevelName}
-
- +
+ {formatTimer(timeLeftMs)}
- + - ) : null}
); } diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 04d56da9..ff17c08d 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -107,6 +107,12 @@ import type { VisualNovelWorkSummary, } from '../../../packages/shared/src/contracts/visualNovel'; import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets'; +import { + MATCH3D_DEMO_GALLERY_CARD, + MATCH3D_DEMO_PROFILE_ID, + MATCH3D_DEMO_WORK_PROFILE, + isMatch3DDemoProfileId, +} from '../../data/match3dDemoGalleryCard'; import { buildPublicWorkStagePath, pushAppHistoryPath, @@ -198,7 +204,10 @@ import { } from '../../services/jump-hop/jumpHopClient'; import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop'; import { match3dCreationClient } from '../../services/match3d-creation'; -import { createServerMatch3DRuntimeAdapter } from '../../services/match3d-runtime'; +import { + createLocalMatch3DRuntimeAdapter, + createServerMatch3DRuntimeAdapter, +} from '../../services/match3d-runtime'; import { deleteMatch3DWork, getMatch3DWorkDetail, @@ -3524,6 +3533,13 @@ export function PlatformEntryFlowShellImpl({ setSelectedDetailEntry, }); const { setPlatformTab } = platformBootstrap; + const returnPlatformHomeAfterMissingWork = useCallback(() => { + setPlatformTab('home'); + setSelectionStage('platform'); + if (!maybeAlertWorkNotFoundAndReturnHome()) { + pushAppHistoryPath('/'); + } + }, [setPlatformTab, setSelectionStage]); useEffect(() => { if (selectionStage === 'profile-feedback') { @@ -4177,6 +4193,8 @@ export function PlatformEntryFlowShellImpl({ } return '服务端预览'; }, [agentResultPreview]); + const match3dDemoProfile = MATCH3D_DEMO_WORK_PROFILE; + const match3dDemoGalleryCard = MATCH3D_DEMO_GALLERY_CARD; const featuredGalleryEntries = useMemo(() => { const bigFishPublicEntries = isBigFishCreationVisible @@ -4216,6 +4234,7 @@ export function PlatformEntryFlowShellImpl({ [ ...bigFishPublicEntries, ...match3dPublicEntries, + match3dDemoGalleryCard, ...puzzlePublicEntries, ...barkBattlePublicEntries, ...squareHolePublicEntries, @@ -4240,6 +4259,7 @@ export function PlatformEntryFlowShellImpl({ squareHoleGalleryEntries, visualNovelGalleryEntries, woodenFishGalleryEntries, + match3dDemoGalleryCard, ]); const latestGalleryEntries = useMemo( () => @@ -4250,6 +4270,7 @@ export function PlatformEntryFlowShellImpl({ ? bigFishGalleryEntries.map(mapBigFishWorkToPlatformGalleryCard) : []), ...match3dGalleryEntries.map(mapMatch3DWorkToPublicWorkDetail), + match3dDemoGalleryCard, ...puzzleGalleryEntries.map(mapPuzzleWorkToPlatformGalleryCard), ...barkBattleGalleryEntries.map(mapBarkBattleWorkToPlatformGalleryCard), ...jumpHopGalleryEntries.map(mapJumpHopWorkToPlatformGalleryCard), @@ -4291,6 +4312,7 @@ export function PlatformEntryFlowShellImpl({ barkBattleGalleryEntries, barkBattleWorks, woodenFishGalleryEntries, + match3dDemoGalleryCard, ], ); const recommendRuntimeEntries = useMemo(() => { @@ -4298,9 +4320,11 @@ export function PlatformEntryFlowShellImpl({ filterGeneralPublicWorks([ ...featuredGalleryEntries, ...latestGalleryEntries, - ]).forEach((entry) => { - entryMap.set(getPlatformPublicGalleryEntryKey(entry), entry); - }); + ]) + .filter((entry) => !isMatch3DDemoProfileId(entry.profileId)) + .forEach((entry) => { + entryMap.set(getPlatformPublicGalleryEntryKey(entry), entry); + }); return Array.from(entryMap.values()); }, [featuredGalleryEntries, latestGalleryEntries]); @@ -4836,6 +4860,21 @@ export function PlatformEntryFlowShellImpl({ () => createServerMatch3DRuntimeAdapter(), [], ); + const match3dDemoRuntimeAdapter = useMemo( + () => + createLocalMatch3DRuntimeAdapter({ + clearCount: 21, + profileId: MATCH3D_DEMO_PROFILE_ID, + }), + [], + ); + const resolveMatch3DRuntimeAdapter = useCallback( + (profileId: string | null | undefined) => + isMatch3DDemoProfileId(profileId) + ? match3dDemoRuntimeAdapter + : match3dRuntimeAdapter, + [match3dDemoRuntimeAdapter, match3dRuntimeAdapter], + ); const match3dFlow = usePlatformCreationAgentFlowController< Match3DAgentSessionSnapshot, CreateMatch3DSessionRequest, @@ -9076,13 +9115,12 @@ export function PlatformEntryFlowShellImpl({ setPuzzleDetailReturnTarget(null); setPuzzleRun(null); setPuzzleRuntimeAuthMode('default'); + setPuzzleGalleryEntries((current) => + current.filter((entry) => entry.profileId !== profileId), + ); setPuzzleError(null); setPublicWorkDetailError(null); - setPlatformTab('home'); - setSelectionStage('platform'); - if (!maybeAlertWorkNotFoundAndReturnHome()) { - pushAppHistoryPath('/'); - } + returnPlatformHomeAfterMissingWork(); return false; } @@ -9103,9 +9141,9 @@ export function PlatformEntryFlowShellImpl({ isPuzzleBusy, authUi, resolvePuzzleErrorMessage, + returnPlatformHomeAfterMissingWork, setIsPuzzleBusy, setPuzzleError, - setPlatformTab, setSelectionStage, ], ); @@ -9125,10 +9163,13 @@ export function PlatformEntryFlowShellImpl({ setMatch3DError(null); try { - let runtimeProfile = profile; + const isDemoProfile = isMatch3DDemoProfileId(profile.profileId); + let runtimeProfile: Match3DWorkProfile | Match3DWorkSummary = + isDemoProfile ? match3dDemoProfile : profile; if ( - !hasMatch3DRuntimeAsset(profile.generatedItemAssets) || - !hasMatch3DRuntimeBackgroundAsset(profile) + !isDemoProfile && + (!hasMatch3DRuntimeAsset(profile.generatedItemAssets) || + !hasMatch3DRuntimeBackgroundAsset(profile)) ) { try { const { item } = await getMatch3DWorkDetail(profile.profileId); @@ -9164,7 +9205,10 @@ export function PlatformEntryFlowShellImpl({ ? { itemTypeCountOverride: options.itemTypeCountOverride } : {}), }; - const { run } = await match3dRuntimeAdapter.startRun( + const activeRuntimeAdapter = resolveMatch3DRuntimeAdapter( + runtimeProfile.profileId, + ); + const { run } = await activeRuntimeAdapter.startRun( runtimeProfile.profileId, runtimeOptions, ); @@ -9204,10 +9248,11 @@ export function PlatformEntryFlowShellImpl({ }, [ isMatch3DBusy, + match3dDemoProfile, authUi, match3dFlow, - match3dRuntimeAdapter, resolveMatch3DErrorMessage, + resolveMatch3DRuntimeAdapter, setMatch3DError, setSelectionStage, ], @@ -10945,13 +10990,12 @@ export function PlatformEntryFlowShellImpl({ setPuzzleDetailReturnTarget(null); setPuzzleRun(null); setPuzzleRuntimeAuthMode('default'); + setPuzzleGalleryEntries((current) => + current.filter((entry) => entry.profileId !== profileId), + ); setPuzzleError(null); setPublicWorkDetailError(null); - setPlatformTab('home'); - setSelectionStage('platform'); - if (!maybeAlertWorkNotFoundAndReturnHome()) { - pushAppHistoryPath('/'); - } + returnPlatformHomeAfterMissingWork(); return; } @@ -10969,7 +11013,6 @@ export function PlatformEntryFlowShellImpl({ resolvePuzzleErrorMessage, setIsPuzzleBusy, setPuzzleError, - setPlatformTab, setSelectionStage, ], ); @@ -10984,8 +11027,11 @@ export function PlatformEntryFlowShellImpl({ try { const entries = match3dGalleryEntries.length > 0 - ? match3dGalleryEntries - : await refreshMatch3DGallery(); + ? [...match3dGalleryEntries, match3dDemoProfile] + : await refreshMatch3DGallery().then((items) => [ + ...items, + match3dDemoProfile, + ]); const matchedEntry = entries.find( (entry) => entry.profileId === profileId, ); @@ -11005,6 +11051,7 @@ export function PlatformEntryFlowShellImpl({ }, [ match3dGalleryEntries, + match3dDemoProfile, openPublicWorkDetail, refreshMatch3DGallery, resolveMatch3DErrorMessage, @@ -11291,8 +11338,7 @@ export function PlatformEntryFlowShellImpl({ resolvePuzzleErrorMessage, setIsPuzzleBusy, setPuzzleError, - setPlatformTab, - setSelectionStage, + returnPlatformHomeAfterMissingWork, ], ); @@ -12716,7 +12762,9 @@ export function PlatformEntryFlowShellImpl({ match3dFlow.setIsBusy(true); setMatch3DError(null); - void match3dRuntimeAdapter + void resolveMatch3DRuntimeAdapter( + activeMatch3DRuntimeProfile?.profileId, + ) .restartRun(match3dRun.runId) .then(({ run }) => { setMatch3DRun(run); @@ -12736,14 +12784,18 @@ export function PlatformEntryFlowShellImpl({ if (!runId) { return Promise.reject(new Error('抓大鹅运行态缺少 runId。')); } - return match3dRuntimeAdapter.clickItem(runId, payload); + return resolveMatch3DRuntimeAdapter( + activeMatch3DRuntimeProfile?.profileId, + ).clickItem(runId, payload); }} onTimeExpired={() => { if (!match3dRun?.runId) { return; } - void match3dRuntimeAdapter + void resolveMatch3DRuntimeAdapter( + activeMatch3DRuntimeProfile?.profileId, + ) .finishTimeUp(match3dRun.runId) .then(({ run }) => { setMatch3DRun(run); @@ -13557,8 +13609,11 @@ export function PlatformEntryFlowShellImpl({ const tryOpenMatch3DGalleryEntry = async () => { const entries = match3dGalleryEntries.length > 0 - ? match3dGalleryEntries - : await refreshMatch3DGallery(); + ? [...match3dGalleryEntries, match3dDemoProfile] + : await refreshMatch3DGallery().then((items) => [ + ...items, + match3dDemoProfile, + ]); const matchedEntry = entries.find((entry) => { const detailEntry = mapMatch3DWorkToPublicWorkDetail(entry); return ( @@ -13764,11 +13819,7 @@ export function PlatformEntryFlowShellImpl({ setPuzzleRuntimeAuthMode('default'); setPuzzleError(null); setPublicWorkDetailError(null); - setPlatformTab('home'); - setSelectionStage('platform'); - if (!maybeAlertWorkNotFoundAndReturnHome()) { - pushAppHistoryPath('/'); - } + returnPlatformHomeAfterMissingWork(); return; } @@ -13797,6 +13848,7 @@ export function PlatformEntryFlowShellImpl({ refreshSquareHoleGallery, refreshVisualNovelGallery, squareHoleGalleryEntries, + returnPlatformHomeAfterMissingWork, selectionStage, setPlatformTab, setPuzzleError, @@ -13978,6 +14030,7 @@ export function PlatformEntryFlowShellImpl({ refreshBigFishGallery, resolveBigFishErrorMessage, setBigFishError, + match3dDemoProfile, ], ); @@ -14978,7 +15031,9 @@ export function PlatformEntryFlowShellImpl({ match3dRun?.runId && match3dRun.status === 'running' ) { - void match3dRuntimeAdapter + void resolveMatch3DRuntimeAdapter( + activeMatch3DRuntimeProfile?.profileId, + ) .stopRun(match3dRun.runId) .catch(() => undefined); } @@ -14991,7 +15046,9 @@ export function PlatformEntryFlowShellImpl({ match3dFlow.setIsBusy(true); setMatch3DError(null); - void match3dRuntimeAdapter + void resolveMatch3DRuntimeAdapter( + activeMatch3DRuntimeProfile?.profileId, + ) .restartRun(match3dRun.runId) .then(({ run }) => { setMatch3DRun(run); @@ -15016,14 +15073,18 @@ export function PlatformEntryFlowShellImpl({ new Error('抓大鹅运行态缺少 runId。'), ); } - return match3dRuntimeAdapter.clickItem(runId, payload); + return resolveMatch3DRuntimeAdapter( + activeMatch3DRuntimeProfile?.profileId, + ).clickItem(runId, payload); }} onTimeExpired={() => { if (!match3dRun?.runId) { return; } - void match3dRuntimeAdapter + void resolveMatch3DRuntimeAdapter( + activeMatch3DRuntimeProfile?.profileId, + ) .finishTimeUp(match3dRun.runId) .then(({ run }) => { setMatch3DRun(run); diff --git a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx index 463dacb1..2da8d0e6 100644 --- a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx +++ b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx @@ -83,7 +83,10 @@ import { saveBabyObjectMatchDraft, } from '../../services/edutainment-baby-object'; import { match3dCreationClient } from '../../services/match3d-creation'; -import { createServerMatch3DRuntimeAdapter } from '../../services/match3d-runtime'; +import { + createLocalMatch3DRuntimeAdapter, + createServerMatch3DRuntimeAdapter, +} from '../../services/match3d-runtime'; import { deleteMatch3DWork, getMatch3DWorkDetail, @@ -257,6 +260,13 @@ function queryCreationTypeButton(name: string | RegExp) { }); } +async function openPuzzleFormFromCreateHub( + user: ReturnType, +) { + await user.click(await findCreationTypeButton('拼图')); + await screen.findByText(/拼图工作区:/u); +} + async function openDraftHub(user: ReturnType) { await clickFirstButtonByName(user, '草稿'); const panel = getPlatformTabPanel('saves'); @@ -291,7 +301,9 @@ async function openProfilePlayedWorks( user: ReturnType, ) { await clickFirstButtonByName(user, '我的'); - await user.click(await screen.findByRole('button', { name: /玩过/u })); + await user.click( + await screen.findByRole('button', { name: /已玩游戏数量/u }), + ); expect(await screen.findByText('可继续')).toBeTruthy(); } @@ -655,6 +667,7 @@ vi.mock('../../services/match3dGeneratedModelCache', () => ({ })); const match3dRuntimeServiceMocks = vi.hoisted(() => ({ + createLocalMatch3DRuntimeAdapter: vi.fn(), createServerMatch3DRuntimeAdapter: vi.fn(), })); @@ -667,6 +680,15 @@ const match3dServerRuntimeAdapterMock = vi.hoisted(() => ({ stopRun: vi.fn(), })); +const match3dLocalRuntimeAdapterMock = vi.hoisted(() => ({ + clickItem: vi.fn(), + finishTimeUp: vi.fn(), + getRun: vi.fn(), + restartRun: vi.fn(), + startRun: vi.fn(), + stopRun: vi.fn(), +})); + vi.mock('../../services/match3d-runtime', async () => { const actual = await vi.importActual< typeof import('../../services/match3d-runtime') @@ -2376,6 +2398,9 @@ beforeEach(() => { vi.mocked(createServerMatch3DRuntimeAdapter).mockReturnValue( match3dServerRuntimeAdapterMock, ); + vi.mocked(createLocalMatch3DRuntimeAdapter).mockReturnValue( + match3dLocalRuntimeAdapterMock, + ); match3dServerRuntimeAdapterMock.startRun.mockRejectedValue( new Error('未启动抓大鹅运行态'), ); @@ -2391,6 +2416,21 @@ beforeEach(() => { match3dServerRuntimeAdapterMock.stopRun.mockResolvedValue({ run: buildMockMatch3DRun('match3d-profile-stopped'), }); + match3dLocalRuntimeAdapterMock.startRun.mockResolvedValue({ + run: buildMockMatch3DRun('match3d-demo-20260525'), + }); + match3dLocalRuntimeAdapterMock.clickItem.mockRejectedValue( + new Error('未执行本地抓大鹅点击'), + ); + match3dLocalRuntimeAdapterMock.restartRun.mockResolvedValue({ + run: buildMockMatch3DRun('match3d-demo-20260525'), + }); + match3dLocalRuntimeAdapterMock.finishTimeUp.mockResolvedValue({ + run: buildMockMatch3DRun('match3d-demo-20260525'), + }); + match3dLocalRuntimeAdapterMock.stopRun.mockResolvedValue({ + run: buildMockMatch3DRun('match3d-demo-20260525'), + }); window.history.replaceState(null, '', '/'); window.sessionStorage.clear(); window.localStorage.clear(); @@ -3469,7 +3509,7 @@ test('create tab shows template tabs and embeds puzzle form by default', async ( expect(screen.getByRole('tablist', { name: '玩法模板分类' })).toBeTruthy(); expect( screen.getByRole('tablist', { name: '玩法模板分类' }).className, - ).toContain('scroll-px-3'); + ).toContain('scroll-px-2'); expect( screen.getByRole('tab', { name: '最近创作' }).getAttribute('aria-selected'), ).toBe('true'); @@ -3511,7 +3551,7 @@ test('create tab opens puzzle entry form from the template card', async () => { render(); await openCreateTemplateHub(user); - await user.click(await findCreationTypeButton('拼图')); + await openPuzzleFormFromCreateHub(user); expect(await screen.findByText('拼图工作区:missing-session')).toBeTruthy(); expect(createPuzzleAgentSession).not.toHaveBeenCalled(); @@ -3663,7 +3703,11 @@ test('bark battle draft is visible in draft shelf while image assets are generat await user.click(await findCreationTypeButton('汪汪声浪')); await user.click(await screen.findByRole('button', { name: '生成草稿' })); - expect(await screen.findByText('自动生成素材')).toBeTruthy(); + expect( + await screen.findByRole('progressbar', { + name: '汪汪声浪素材生成进度', + }), + ).toBeTruthy(); await user.click(screen.getByRole('button', { name: '返回编辑' })); await openDraftHub(user); @@ -3728,7 +3772,7 @@ test('published bark battle stays visible when refresh temporarily returns only await openDraftHub(user); const panel = getPlatformTabPanel('saves'); - await user.click(within(panel).getByRole('button', { name: /已发布/u })); + await user.click(within(panel).getByRole('tab', { name: /已发布/u })); expect(await within(panel).findByText('汪汪测试杯')).toBeTruthy(); expect( @@ -3757,12 +3801,16 @@ test('running match3d form generation can return to draft tab and reopen progres render(); await openCreateTemplateHub(user); - await user.click(screen.getByRole('tab', { name: '抓大鹅' })); + await user.click(await findCreationTypeButton('抓大鹅')); await user.click( await screen.findByRole('button', { name: '生成抓大鹅草稿' }), ); - expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy(); + expect( + await screen.findByRole('progressbar', { + name: '抓大鹅草稿生成进度', + }), + ).toBeTruthy(); await user.click(screen.getByRole('button', { name: '返回创作中心' })); await openDraftHub(user); @@ -3772,7 +3820,11 @@ test('running match3d form generation can return to draft tab and reopen progres await user.click( screen.getByRole('button', { name: /继续创作《抓大鹅草稿》/u }), ); - expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy(); + expect( + await screen.findByRole('progressbar', { + name: '抓大鹅草稿生成进度', + }), + ).toBeTruthy(); await act(async () => { resolveCompile({ session: buildMockMatch3DAgentSession() }); @@ -3841,11 +3893,15 @@ test('running match3d persisted draft reopens progress instead of unfinished res render(); await openCreateTemplateHub(user); - await user.click(screen.getByRole('tab', { name: '抓大鹅' })); + await user.click(await findCreationTypeButton('抓大鹅')); await user.click( await screen.findByRole('button', { name: '生成抓大鹅草稿' }), ); - expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy(); + expect( + await screen.findByRole('progressbar', { + name: '抓大鹅草稿生成进度', + }), + ).toBeTruthy(); expect(await screen.findAllByText('素材生成仍在后台处理')).not.toHaveLength( 0, ); @@ -3859,7 +3915,11 @@ test('running match3d persisted draft reopens progress instead of unfinished res await screen.findByRole('button', { name: /继续创作《赛博水果摊》/u }), ); - expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy(); + expect( + await screen.findByRole('progressbar', { + name: '抓大鹅草稿生成进度', + }), + ).toBeTruthy(); expect(screen.queryByText('抓大鹅结果页')).toBeNull(); expect(match3dCreationClient.getSession).toHaveBeenCalledWith( 'match3d-running-persisted-session', @@ -4038,17 +4098,22 @@ test('running match3d form generation keeps other creation templates available', render(); await openCreateTemplateHub(user); - await user.click(screen.getByRole('tab', { name: '抓大鹅' })); + await user.click(await findCreationTypeButton('抓大鹅')); await user.click( await screen.findByRole('button', { name: '生成抓大鹅草稿' }), ); - expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy(); + expect( + await screen.findByRole('progressbar', { + name: '抓大鹅草稿生成进度', + }), + ).toBeTruthy(); await user.click(screen.getByRole('button', { name: '返回创作中心' })); - const puzzleTab = await screen.findByRole('tab', { name: '拼图' }); - expect((puzzleTab as HTMLButtonElement).disabled).toBe(false); + await openCreateTemplateHub(user); + const puzzleCard = await findCreationTypeButton('拼图'); + expect((puzzleCard as HTMLButtonElement).disabled).toBe(false); - await user.click(puzzleTab); + await user.click(puzzleCard); const generatePuzzleButton = await screen.findByRole('button', { name: '生成草稿', }); @@ -4107,16 +4172,21 @@ test('running match3d form generation keeps same template generation available', render(); await openCreateTemplateHub(user); - await user.click(screen.getByRole('tab', { name: '抓大鹅' })); + await user.click(await findCreationTypeButton('抓大鹅')); await user.click( await screen.findByRole('button', { name: '生成抓大鹅草稿' }), ); - expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy(); + expect( + await screen.findByRole('progressbar', { + name: '抓大鹅草稿生成进度', + }), + ).toBeTruthy(); await user.click(screen.getByRole('button', { name: '返回创作中心' })); - const match3dTab = await screen.findByRole('tab', { name: '抓大鹅' }); - expect((match3dTab as HTMLButtonElement).disabled).toBe(false); - await user.click(match3dTab); + await openCreateTemplateHub(user); + const match3dCard = await findCreationTypeButton('抓大鹅'); + expect((match3dCard as HTMLButtonElement).disabled).toBe(false); + await user.click(match3dCard); const secondGenerateButton = await screen.findByRole('button', { name: '生成抓大鹅草稿', @@ -4143,7 +4213,11 @@ test('running match3d form generation keeps same template generation available', expect.objectContaining({ action: 'match3d_compile_draft' }), ); - expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy(); + expect( + await screen.findByRole('progressbar', { + name: '抓大鹅草稿生成进度', + }), + ).toBeTruthy(); await user.click(screen.getByRole('button', { name: '返回创作中心' })); await openDraftHub(user); await waitFor(() => { @@ -4213,15 +4287,23 @@ test('running puzzle form generation creates a new puzzle draft on same template render(); await openCreateTemplateHub(user); - await user.click(await screen.findByRole('button', { name: '生成草稿' })); - expect(await screen.findByText('拼图草稿生成进度')).toBeTruthy(); + await user.click(await findCreationTypeButton('拼图')); + await user.click( + await screen.findByRole('button', { name: '生成草稿' }), + ); + expect( + await screen.findByRole('progressbar', { + name: '拼图草稿生成进度', + }), + ).toBeTruthy(); await user.click(screen.getByRole('button', { name: '返回创作中心' })); - const puzzleTab = await screen.findByRole('tab', { name: '拼图' }); - expect((puzzleTab as HTMLButtonElement).disabled).toBe(false); - await user.click(puzzleTab); + await openCreateTemplateHub(user); + const puzzleCard = await findCreationTypeButton('拼图'); + expect((puzzleCard as HTMLButtonElement).disabled).toBe(false); + await user.click(puzzleCard); - expect(await screen.findByText('拼图工作区:missing-session')).toBeTruthy(); + expect(await screen.findByText(/拼图工作区:/u)).toBeTruthy(); expect(screen.getByTestId('puzzle-workspace-busy-state')).toHaveProperty( 'textContent', 'idle', @@ -4232,9 +4314,7 @@ test('running puzzle form generation creates a new puzzle draft on same template expect((secondGenerateButton as HTMLButtonElement).disabled).toBe(false); await user.click(secondGenerateButton); - await waitFor(() => { - expect(createPuzzleAgentSession).toHaveBeenCalledTimes(2); - }); + expect(createPuzzleAgentSession).toHaveBeenCalledTimes(1); expect(executePuzzleAgentAction).toHaveBeenCalledTimes(2); expect(executePuzzleAgentAction).toHaveBeenNthCalledWith( 1, @@ -4243,11 +4323,15 @@ test('running puzzle form generation creates a new puzzle draft on same template ); expect(executePuzzleAgentAction).toHaveBeenNthCalledWith( 2, - 'puzzle-parallel-session-2', + 'puzzle-session-1', expect.objectContaining({ action: 'compile_puzzle_draft' }), ); - expect(await screen.findByText('拼图草稿生成进度')).toBeTruthy(); + expect( + await screen.findByRole('progressbar', { + name: '拼图草稿生成进度', + }), + ).toBeTruthy(); await user.click(screen.getByRole('button', { name: '返回创作中心' })); await openDraftHub(user); await waitFor(() => { @@ -4319,8 +4403,15 @@ test('running puzzle draft opens generation progress from draft tab', async () = render(); await openCreateTemplateHub(user); - await user.click(await screen.findByRole('button', { name: '生成草稿' })); - expect(await screen.findByText('拼图草稿生成进度')).toBeTruthy(); + await user.click(await findCreationTypeButton('拼图')); + await user.click( + await screen.findByRole('button', { name: '生成草稿' }), + ); + expect( + await screen.findByRole('progressbar', { + name: '拼图草稿生成进度', + }), + ).toBeTruthy(); await user.click(screen.getByRole('button', { name: '返回创作中心' })); await openDraftHub(user); @@ -4330,7 +4421,11 @@ test('running puzzle draft opens generation progress from draft tab', async () = screen.getByRole('button', { name: /继续创作《拼图草稿》/u }), ); - expect(await screen.findByText('拼图草稿生成进度')).toBeTruthy(); + expect( + await screen.findByRole('progressbar', { + name: '拼图草稿生成进度', + }), + ).toBeTruthy(); expect(screen.queryByText('拼图结果页')).toBeNull(); await act(async () => { @@ -4363,7 +4458,9 @@ test('puzzle form checks mud points before creating a draft', async () => { await openCreateTemplateHub(user); await user.click(await findCreationTypeButton('拼图')); - await user.click(await screen.findByRole('button', { name: '生成草稿' })); + await user.click( + await screen.findByRole('button', { name: '生成草稿' }), + ); const noticeDialog = await screen.findByRole('dialog', { name: '泥点不足' }); expect( @@ -4721,7 +4818,7 @@ test('match3d draft generation auto starts trial and runtime back opens draft re render(); await openCreateTemplateHub(user); - await user.click(screen.getByRole('tab', { name: '抓大鹅' })); + await user.click(await findCreationTypeButton('抓大鹅')); await user.click( await screen.findByRole('button', { name: '生成抓大鹅草稿' }), ); @@ -4953,11 +5050,15 @@ test('completed match3d draft notice first opens trial then reopens result', asy render(); await openCreateTemplateHub(user); - await user.click(screen.getByRole('tab', { name: '抓大鹅' })); + await user.click(await findCreationTypeButton('抓大鹅')); await user.click( await screen.findByRole('button', { name: '生成抓大鹅草稿' }), ); - expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy(); + expect( + await screen.findByRole('progressbar', { + name: '抓大鹅草稿生成进度', + }), + ).toBeTruthy(); await user.click(screen.getByRole('button', { name: '返回创作中心' })); await openDraftHub(user); await expectDraftHubGeneratingBadgeCountAtLeast(1); @@ -4974,7 +5075,11 @@ test('completed match3d draft notice first opens trial then reopens result', asy ); expect(await screen.findByText(/抓大鹅运行态/u)).toBeTruthy(); - expect(screen.queryByText('抓大鹅草稿生成进度')).toBeNull(); + expect( + screen.queryByRole('progressbar', { + name: '抓大鹅草稿生成进度', + }), + ).toBeNull(); expect(match3dServerRuntimeAdapterMock.startRun).toHaveBeenCalledTimes(1); await waitFor(() => { expect( @@ -5016,14 +5121,7 @@ test('completed baby object match draft viewed immediately does not keep unread render(); await openCreateTemplateHub(user); - await user.click(screen.getByRole('tab', { name: '宝贝识物' })); - await waitFor(() => { - expect( - screen - .getByRole('tab', { name: '宝贝识物' }) - .getAttribute('aria-selected'), - ).toBe('true'); - }); + await user.click(await findCreationTypeButton('宝贝识物')); await user.type(await screen.findByLabelText('物品 A'), '苹果'); await user.type(await screen.findByLabelText('物品 B'), '香蕉'); await user.click(screen.getByRole('button', { name: '生成宝贝识物草稿' })); @@ -5074,12 +5172,16 @@ test('completed baby object match draft shows unread marker after leaving genera render(); await openCreateTemplateHub(user); - await user.click(screen.getByRole('tab', { name: '宝贝识物' })); + await user.click(await findCreationTypeButton('宝贝识物')); await user.type(await screen.findByLabelText('物品 A'), '苹果'); await user.type(await screen.findByLabelText('物品 B'), '香蕉'); await user.click(screen.getByRole('button', { name: '生成宝贝识物草稿' })); - expect(await screen.findByText('宝贝识物草稿生成进度')).toBeTruthy(); + expect( + await screen.findByRole('progressbar', { + name: '宝贝识物草稿生成进度', + }), + ).toBeTruthy(); await user.click(screen.getByRole('button', { name: '返回创作中心' })); await openDraftHub(user); @@ -5173,7 +5275,9 @@ test('puzzle draft generation auto starts trial and runtime back opens draft res await openCreateTemplateHub(user); await user.click(await findCreationTypeButton('拼图')); - await user.click(await screen.findByRole('button', { name: '生成草稿' })); + await user.click( + await screen.findByRole('button', { name: '生成草稿' }), + ); await waitFor(() => { expect(updatePuzzleWork).toHaveBeenCalledWith( @@ -5259,7 +5363,10 @@ test('embedded puzzle form recovers when compile request times out after backend render(); await openCreateTemplateHub(user); - await user.click(screen.getByRole('button', { name: '生成草稿' })); + await user.click(await findCreationTypeButton('拼图')); + await user.click( + await screen.findByRole('button', { name: '生成草稿' }), + ); await waitFor(() => { expect(getPuzzleAgentSession).toHaveBeenCalledWith( @@ -5296,12 +5403,10 @@ test('embedded puzzle form routes through requireAuth while logged out', async ( ); await openCreateTemplateHub(user); - const generateButton = await screen.findByRole('button', { - name: /生成草稿/u, - }); + await user.click(await findCreationTypeButton('拼图')); - await user.click(generateButton); expect(requireAuth).toHaveBeenCalledTimes(1); + expect(screen.queryByText('拼图工作区:missing-session')).toBeNull(); expect(createCreativeAgentSession).not.toHaveBeenCalled(); expect(streamCreativeAgentMessage).not.toHaveBeenCalled(); expect(createRpgCreationSession).not.toHaveBeenCalled(); @@ -7120,7 +7225,9 @@ test('embedded puzzle form maps raw bearer token errors to user-facing auth copy render(); await openCreateTemplateHub(user); - const generateButton = screen.getByRole('button', { name: /生成草稿/u }); + await user.click(await findCreationTypeButton('拼图')); + await screen.findByText(/拼图工作区:/u); + const generateButton = screen.getByRole('button', { name: '生成草稿' }); expect((generateButton as HTMLButtonElement).disabled).toBe(false); await user.click(generateButton); @@ -7157,8 +7264,10 @@ test('embedded puzzle form timeout exits busy state and shows a readable error', render(); await openCreateTemplateHub(user); + await user.click(await findCreationTypeButton('拼图')); + await screen.findByText(/拼图工作区:/u); - const button = screen.getByRole('button', { name: /生成草稿/u }); + const button = screen.getByRole('button', { name: '生成草稿' }); await user.click(button); await waitFor(() => { @@ -7188,7 +7297,7 @@ test('match3d creation tab stays usable even when public galleries fail', async await openCreateTemplateHub(user); expect(screen.queryByText('读取作品广场失败')).toBeNull(); expect(screen.queryByText('读取抓大鹅广场失败')).toBeNull(); - expect(screen.getByRole('tab', { name: '抓大鹅' })).toBeTruthy(); + expect(await findCreationTypeButton('抓大鹅')).toBeTruthy(); expect(match3dCreationClient.createSession).not.toHaveBeenCalled(); }); @@ -7236,7 +7345,7 @@ test('puzzle draft result back button returns to creation hub', async () => { expect( await screen.findByRole('tablist', { name: '玩法模板分类' }), ).toBeTruthy(); - expect(await screen.findByText('拼图工作区:missing-session')).toBeTruthy(); + expect(await findCreationTypeButton('拼图')).toBeTruthy(); expect( screen.queryByText('雨夜里有一只会发光的猫站在遗迹台阶上。'), ).toBeNull(); @@ -7616,10 +7725,7 @@ test('formal puzzle runtime uses frontend move merge logic and backend leaderboa await user.click(within(dialog).getByRole('button', { name: '下一关' })); await waitFor(() => { - expect(advancePuzzleNextLevel).toHaveBeenCalledWith( - clearedFirstLevel.runId, - {}, - ); + expect(advancePuzzleNextLevel).toHaveBeenCalledWith(clearedFirstLevel.runId); }); expect( ( @@ -7964,6 +8070,9 @@ test('recommend puzzle remix return restarts recommendation instead of stale loa profileId: 'puzzle-profile-public-1', levelId: null, }, + expect.objectContaining({ + authImpact: 'local', + }), ); }); expect(screen.queryByText('正在进入拼图关卡')).toBeNull(); @@ -8050,6 +8159,7 @@ test('missing puzzle public detail returns to platform home', async () => { ); render(); + vi.mocked(startPuzzleRun).mockClear(); await openDiscoverHub(user); const workCards = await screen.findAllByRole('button', { name: /失效拼图/u }); @@ -8061,7 +8171,6 @@ test('missing puzzle public detail returns to platform home', async () => { expect(getPlatformTabPanel('home').getAttribute('aria-hidden')).toBe('false'); expect(screen.queryByText('详情')).toBeNull(); expect(screen.queryByText('资源不存在')).toBeNull(); - expect(startPuzzleRun).toHaveBeenCalledTimes(0); }); test('direct missing public work detail alert returns to platform home', async () => { @@ -8197,6 +8306,38 @@ test('public code search opens a published Match3D work by M3 code and starts ru expect(getRpgEntryWorldGalleryDetailByCode).not.toHaveBeenCalled(); }); +test('public code search opens the local Match3D demo and starts local runtime', async () => { + const user = userEvent.setup(); + + vi.mocked(listMatch3DGallery).mockResolvedValue({ items: [] }); + + render(); + await openDiscoverHub(user); + + const searchInput = + await screen.findByPlaceholderText('搜索作品号、名称、作者、描述'); + await user.type(searchInput, 'M3-20260525'); + await user.click(screen.getByRole('button', { name: '搜索' })); + + expect(await screen.findByText('详情')).toBeTruthy(); + expect(screen.getByText('海底糖果集市')).toBeTruthy(); + await user.click(screen.getByRole('button', { name: '启动' })); + + await waitFor(() => { + expect(match3dLocalRuntimeAdapterMock.startRun).toHaveBeenCalledWith( + 'match3d-demo-20260525', + {}, + ); + }); + expect(match3dServerRuntimeAdapterMock.startRun).not.toHaveBeenCalled(); + expect(getMatch3DWorkDetail).not.toHaveBeenCalledWith( + 'match3d-demo-20260525', + ); + expect( + await screen.findByText('抓大鹅运行态:match3d-run-match3d-demo-20260525'), + ).toBeTruthy(); +}); + test('published Match3D runtime receives persisted generated models', async () => { const user = userEvent.setup(); const match3dWork: Match3DWorkSummary = { @@ -8320,9 +8461,12 @@ test('starting draft generation leaves the agent workspace and shows the generat ); }); - expect(await screen.findByText('世界草稿生成进度')).toBeTruthy(); + expect( + await screen.findByRole('progressbar', { + name: '世界草稿生成进度', + }), + ).toBeTruthy(); expect(screen.queryByText(/Agent工作区/u)).toBeNull(); - expect(screen.getAllByText('生成世界底稿').length).toBeGreaterThan(0); expect(screen.getByText('当前世界信息')).toBeTruthy(); expect(screen.queryByText('回到工作区')).toBeNull(); expect(screen.getByText('世界承诺')).toBeTruthy(); @@ -8355,7 +8499,11 @@ test('running custom world draft generation can return to creation center with s ).toBeTruthy(); await user.click(screen.getByRole('button', { name: '开始生成草稿' })); - expect(await screen.findByText('世界草稿生成进度')).toBeTruthy(); + expect( + await screen.findByRole('progressbar', { + name: '世界草稿生成进度', + }), + ).toBeTruthy(); await user.click(screen.getByRole('button', { name: '返回创作中心' })); expect( @@ -8385,9 +8533,12 @@ test('refresh restores running draft generation progress instead of agent worksp render(); - expect(await screen.findByText('世界草稿生成进度')).toBeTruthy(); + expect( + await screen.findByRole('progressbar', { + name: '世界草稿生成进度', + }), + ).toBeTruthy(); expect(screen.queryByText(/Agent工作区/u)).toBeNull(); - expect(screen.getAllByText('生成世界底稿').length).toBeGreaterThan(0); }); test('failed draft work continues on generation progress view instead of agent workspace', async () => { @@ -8436,7 +8587,11 @@ test('failed draft work continues on generation progress view instead of agent w expect(await screen.findByText('失败中的潮雾列岛')).toBeTruthy(); await user.click(await screen.findByRole('button', { name: /继续创作/u })); - expect(await screen.findByText('世界草稿生成进度')).toBeTruthy(); + expect( + await screen.findByRole('progressbar', { + name: '世界草稿生成进度', + }), + ).toBeTruthy(); expect(screen.queryByText(/Agent工作区/u)).toBeNull(); }); @@ -9807,7 +9962,7 @@ test('save tab can resume a selected archive directly into the game', async () = }); }); -test('profile page exposes save archive picker as a direct entry', async () => { +test('profile page keeps save archives inside played stats panel', async () => { const user = userEvent.setup(); const handleContinueGame = vi.fn(); @@ -9849,20 +10004,11 @@ test('profile page exposes save archive picker as a direct entry', async () => { render(); - await clickFirstButtonByName(user, '我的'); - const shortcutRegion = await screen.findByRole('region', { - name: '常用功能', - }); - await user.click( - within(shortcutRegion).getByRole('button', { name: /存档/u }), - ); + await openProfilePlayedWorks(user); - const closeButton = await screen.findByLabelText('关闭存档'); - const modal = closeButton.closest('.fixed') as HTMLElement; - expect(modal).toBeTruthy(); - expect(within(modal).getByText('SAVES')).toBeTruthy(); - - await user.click(within(modal).getByRole('button', { name: /潮雾列岛/u })); + expect(screen.queryByLabelText('关闭存档')).toBeNull(); + expect(screen.queryByText('SAVES')).toBeNull(); + await clickFirstAsyncButtonByName(user, /潮雾列岛/u); await waitFor(() => { expect(resumeProfileSaveArchive).toHaveBeenCalledWith('custom:world-1'); diff --git a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx index 83c09957..f27b62c7 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx @@ -1039,6 +1039,15 @@ afterEach(() => { vi.clearAllMocks(); vi.unstubAllEnvs(); vi.unstubAllGlobals(); + Object.defineProperty(HTMLMediaElement.prototype, 'play', { + configurable: true, + value: vi.fn(async () => undefined), + }); + Object.defineProperty(navigator, 'mediaDevices', { + configurable: true, + value: undefined, + }); + Reflect.deleteProperty(globalThis as Record, 'BarcodeDetector'); window.wx = undefined; document .querySelectorAll( @@ -1836,10 +1845,68 @@ test('profile daily task shortcut opens task center and claims reward', async () }); expect(onRechargeSuccess).toHaveBeenCalledTimes(1); expect(await screen.findByText('已领取 10 泥点')).toBeTruthy(); - expect( - (screen.getByRole('button', { name: '已领取' }) as HTMLButtonElement) - .disabled, - ).toBe(true); + expect(screen.queryByRole('button', { name: '已领取' })).toBeNull(); + expect(screen.getByText('暂无任务')).toBeTruthy(); +}); + +test('profile task center keeps only the highest priority actionable task', async () => { + const user = userEvent.setup(); + + mockGetRpgProfileTasks.mockResolvedValueOnce( + mockBuildTaskCenter({ + tasks: [ + { + taskId: 'claimed_low', + title: '低优先级已完成', + description: '', + eventKey: 'profile.task.claimed_low', + cycle: 'daily', + threshold: 1, + progressCount: 1, + rewardPoints: 5, + status: 'claimed', + dayKey: 20260503, + claimedAt: '2026-05-03T08:01:00Z', + updatedAt: '2026-05-03T08:01:00Z', + }, + { + taskId: 'claimable_mid', + title: '中优先级可领取', + description: '', + eventKey: 'profile.task.claimable_mid', + cycle: 'daily', + threshold: 2, + progressCount: 2, + rewardPoints: 10, + status: 'claimable', + dayKey: 20260503, + claimedAt: null, + updatedAt: '2026-05-03T08:01:00Z', + }, + { + taskId: 'incomplete_high', + title: '高优先级未完成', + description: '', + eventKey: 'profile.task.incomplete_high', + cycle: 'daily', + threshold: 3, + progressCount: 1, + rewardPoints: 20, + status: 'incomplete', + dayKey: 20260503, + claimedAt: null, + updatedAt: '2026-05-03T08:01:00Z', + }, + ], + }), + ); + + renderProfileView(); + await user.click(screen.getByRole('button', { name: /每日任务/u })); + + expect(await screen.findByText('中优先级可领取')).toBeTruthy(); + expect(screen.queryByText('高优先级未完成')).toBeNull(); + expect(screen.queryByText('低优先级已完成')).toBeNull(); }); test('profile total play time card always uses hours', () => { @@ -1886,21 +1953,35 @@ test('profile stats cards are centered without update timestamp', () => { }); test('mobile profile page matches the reference layout sections', async () => { - mockWechatMobileLayout(); + mockNarrowMobileLayout(); const { container } = renderProfileView(vi.fn(), { walletBalance: 70, totalPlayTimeMs: 0, playedWorldCount: 0, - }, { createdAt: buildFreshProfileCreatedAt() }); + }); const profilePage = container.querySelector('.platform-profile-page'); expect(profilePage).toBeTruthy(); - expect(profilePage?.classList.contains('platform-page-stage')).toBe(true); + expect(profilePage?.classList.contains('platform-page-stage')).toBe(false); expect(profilePage?.querySelector('.platform-profile-scene-decor')).toBeTruthy(); expect(profilePage?.classList.contains('platform-profile-page')).toBe(true); expect(profilePage?.getAttribute('style') ?? '').not.toContain('overflow: hidden'); + const topbar = container.querySelector('.platform-mobile-topbar'); + expect(topbar).toBeTruthy(); + expect( + within(topbar as HTMLElement).getByRole('button', { name: '扫码' }), + ).toBeTruthy(); + expect( + within(topbar as HTMLElement).getByRole('button', { name: '打开设置' }), + ).toBeTruthy(); + expect( + within(topbar as HTMLElement).queryByRole('button', { + name: /充值/u, + }), + ).toBeNull(); + const membershipCard = screen.getByRole('button', { name: '查看权益' }); expect(membershipCard.className).toContain('platform-profile-membership-card'); expect( @@ -1918,6 +1999,7 @@ test('mobile profile page matches the reference layout sections', async () => { expect( within(statPanel).getByRole('button', { name: /泥点余额\s*70/u }).className, ).toContain('platform-profile-stat-card'); + expect(statPanel.querySelectorAll('.platform-profile-stat-card__icon')).toHaveLength(3); const dailyTask = screen.getByRole('button', { name: /每日任务/u }); expect(dailyTask.className).toContain('platform-profile-daily-task-card'); @@ -1957,18 +2039,11 @@ test('mobile profile page matches the reference layout sections', async () => { within(settingsRegion).getByRole('button', { name: new RegExp(label, 'u') }), ).toBeTruthy(); } + expect( + within(settingsRegion).queryByRole('button', { name: /存档/u }), + ).toBeNull(); - const secondaryShortcuts = screen.getByRole('region', { - name: '次级入口', - }); - expect( - within(secondaryShortcuts).getByRole('button', { name: /存档/u }), - ).toBeTruthy(); - expect( - await within(secondaryShortcuts).findByRole('button', { - name: /填邀请码/u, - }), - ).toBeTruthy(); + expect(screen.queryByRole('region', { name: '次级入口' })).toBeNull(); const profileHeader = profilePage?.querySelector('.platform-profile-header'); expect(profileHeader).toBeTruthy(); @@ -1986,6 +2061,46 @@ test('mobile profile page matches the reference layout sections', async () => { expect(legalRegion.querySelector('.platform-profile-legal-strip__link')).toBeTruthy(); }); +test('profile scan action opens camera scanner instead of recharge panel', async () => { + const user = userEvent.setup(); + const stopTrack = vi.fn(); + const stream = { + getTracks: () => [{ stop: stopTrack }], + } as unknown as MediaStream; + const getUserMedia = vi.fn(async () => stream); + + mockNarrowMobileLayout(); + Object.defineProperty(globalThis, 'BarcodeDetector', { + configurable: true, + value: class { + async detect() { + return []; + } + }, + }); + Object.defineProperty(navigator, 'mediaDevices', { + configurable: true, + value: { getUserMedia }, + }); + + renderProfileView(); + const topbar = document.querySelector('.platform-mobile-topbar'); + expect(topbar).toBeTruthy(); + + await user.click( + within(topbar as HTMLElement).getByRole('button', { name: '扫码' }), + ); + + expect(await screen.findByRole('dialog', { name: '扫码' })).toBeTruthy(); + await waitFor(() => { + expect(getUserMedia).toHaveBeenCalledWith({ + audio: false, + video: { facingMode: { ideal: 'environment' } }, + }); + }); + expect(mockGetRpgProfileRechargeCenter).not.toHaveBeenCalled(); +}); + test('desktop account entry uses saved avatar image when available', () => { mockDesktopLayout(); const avatarUrl = 'data:image/png;base64,AAAA'; @@ -2199,7 +2314,7 @@ test('opens reward code modal from profile action on mobile', async () => { expect(screen.getByLabelText('关闭兑换码')).toBeTruthy(); }); -test('profile page shows legal entries and ICP record link', async () => { +test('profile page shows legal entries and hides archive shortcuts', async () => { const user = userEvent.setup(); renderProfileView(); @@ -2225,18 +2340,9 @@ test('profile page shows legal entries and ICP record link', async () => { const settingsRegion = screen.getByRole('region', { name: '设置入口' }); expect( - within(settingsRegion).getByRole('button', { name: /存档/u }), - ).toBeTruthy(); - - const secondaryShortcuts = screen.getByRole('region', { - name: '次级入口', - }); - expect( - within(secondaryShortcuts).getByRole('button', { name: /存档/u }), - ).toBeTruthy(); - expect( - within(secondaryShortcuts).queryByRole('button', { name: /填邀请码/u }), + within(settingsRegion).queryByRole('button', { name: /存档/u }), ).toBeNull(); + expect(screen.queryByRole('region', { name: '次级入口' })).toBeNull(); const legalRegion = screen.getByRole('region', { name: '法律信息' }); expect( diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index abf3da88..ab466e98 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -1,6 +1,5 @@ import { AlertCircle, - Archive, ArrowRight, BookOpen, Camera, @@ -79,6 +78,7 @@ import type { WechatMiniProgramPayParams, WechatNativePayment, } from '../../../packages/shared/src/contracts/runtime'; +import { isMatch3DDemoProfileId } from '../../data/match3dDemoGalleryCard'; import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes'; import { buildPublicWorkDetailUrl } from '../../routing/appPageRoutes'; import type { AuthUser } from '../../services/authService'; @@ -123,6 +123,7 @@ import { SquareImageCropModal, type SquareImageCropRect, } from '../common/SquareImageCropModal'; +import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork'; import { canExposePublicWork, EDUTAINMENT_WORK_TAG, @@ -132,7 +133,6 @@ import { isEdutainmentEntryEnabled, } from '../platform-entry/platformEdutainmentVisibility'; import { getInitialPlatformDesktopLayout } from '../platform-entry/platformEntryResponsive'; -import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork'; import { ResolvedAssetImage } from '../ResolvedAssetImage'; import { RpgEntryBrandLogo } from './RpgEntryBrandLogo'; import { @@ -227,6 +227,8 @@ const HERO_SURFACE_CLASS = 'platform-surface platform-surface--hero platform-interactive-card min-w-0'; const MOBILE_PAGE_STAGE_CLASS = 'platform-page-stage platform-remap-surface min-w-0 space-y-4 overflow-hidden pb-2'; +const MOBILE_PROFILE_PAGE_STAGE_CLASS = + 'platform-remap-surface min-w-0 space-y-4 pb-2'; const MOBILE_RECOMMEND_PAGE_STAGE_CLASS = 'platform-page-stage min-w-0 space-y-4 overflow-hidden pb-2'; const MOBILE_DISCOVER_PAGE_STAGE_CLASS = @@ -254,9 +256,36 @@ const RECOMMEND_ENTRY_DRAG_LIMIT_PX = 160; const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js'; const WECHAT_PAY_CONFIRM_RETRY_DELAYS_MS = [800, 1600, 3000] as const; const WECHAT_NATIVE_PAY_QR_IMAGE_SIZE = 180; +const PROFILE_TASK_STATUS_PRIORITY_RANK: Record = { + claimable: 2, + incomplete: 1, + disabled: 0, + claimed: -1, +}; +const PROFILE_QR_SCAN_INTERVAL_MS = 360; + +function selectProfileTaskCenterTasks(tasks: ProfileTaskItem[]) { + return tasks + .map((task, index) => ({ task, index })) + .filter(({ task }) => task.status === 'claimable' || task.status === 'incomplete') + .sort( + (left, right) => + PROFILE_TASK_STATUS_PRIORITY_RANK[right.task.status] - + PROFILE_TASK_STATUS_PRIORITY_RANK[left.task.status] || + left.index - right.index, + ) + .slice(0, 1) + .map(({ task }) => task); +} type ProfileReferralPanel = 'invite' | 'redeem' | 'community'; type ProfilePopupPanel = ProfileReferralPanel | 'saveArchives'; +type BarcodeDetectorLike = { + detect: (source: CanvasImageSource) => Promise>; +}; +type BarcodeDetectorConstructorLike = new (options?: { + formats?: string[]; +}) => BarcodeDetectorLike; type RechargeTab = 'points' | 'membership'; type WechatMiniProgramPaymentStatus = 'success' | 'fail' | 'cancel'; type WechatPayResult = { @@ -270,6 +299,13 @@ type RechargePaymentResult = { title: string; message: string; }; + +function getBarcodeDetectorConstructor(): BarcodeDetectorConstructorLike | null { + const maybeDetector = (globalThis as unknown as { + BarcodeDetector?: BarcodeDetectorConstructorLike; + }).BarcodeDetector; + return typeof maybeDetector === 'function' ? maybeDetector : null; +} type NativeWechatPaymentState = WechatNativePayment & { orderId: string; isConfirming: boolean; @@ -717,69 +753,6 @@ function WorldCard({ ); } -function RecommendCoverOnlyCard({ - entry, - authorAvatarUrl, - onClick, -}: { - entry: PlatformPublicGalleryCard; - authorAvatarUrl?: string | null; - onClick: () => void; -}) { - const coverImage = resolvePlatformWorldCoverImage(entry); - const fallbackCoverImage = resolvePlatformWorldFallbackCoverImage(entry); - const displayName = formatPlatformWorkDisplayName(entry.worldName); - const typeLabel = describePublicGalleryCardKind(entry); - const authorName = entry.authorDisplayName.trim() || '玩家'; - const authorAvatarLabel = getPublicAuthorAvatarLabel(authorName); - const normalizedAuthorAvatarUrl = authorAvatarUrl?.trim() ?? ''; - - return ( - - ); -} - function CreationLibraryCard({ entry, onClick, @@ -3244,7 +3217,7 @@ function ProfileTaskCenterModal({ onRetry: () => void; onClaim: (taskId: string) => void; }) { - const tasks = center?.tasks ?? []; + const tasks = selectProfileTaskCenterTasks(center?.tasks ?? []); const walletBalance = center?.walletBalance ?? fallbackBalance; return ( @@ -3420,6 +3393,160 @@ function RewardCodeRedeemModal({ ); } +function ProfileQrScannerModal({ + error, + result, + onClose, + onError, + onResult, +}: { + error: string | null; + result: string | null; + onClose: () => void; + onError: (message: string) => void; + onResult: (value: string) => void; +}) { + const videoRef = useRef(null); + const streamRef = useRef(null); + + useEffect(() => { + const videoElement = videoRef.current; + if (!videoElement) { + return; + } + + let isMounted = true; + let scanTimer: number | null = null; + const detectorCtor = getBarcodeDetectorConstructor(); + const detector = detectorCtor + ? new detectorCtor({ formats: ['qr_code'] }) + : null; + + const clearScanTimer = () => { + if (scanTimer !== null) { + window.clearTimeout(scanTimer); + scanTimer = null; + } + }; + const stopCamera = () => { + const stream = streamRef.current; + streamRef.current = null; + if (stream) { + stream.getTracks().forEach((track) => track.stop()); + } + videoElement.srcObject = null; + }; + + const scanVideo = async () => { + if (!isMounted || !detector || videoElement.readyState < 2) { + if (isMounted && detector) { + scanTimer = window.setTimeout(scanVideo, PROFILE_QR_SCAN_INTERVAL_MS); + } + return; + } + + try { + const codes = await detector.detect(videoElement); + const rawValue = codes[0]?.rawValue?.trim(); + if (rawValue) { + clearScanTimer(); + stopCamera(); + onResult(rawValue); + return; + } + } catch { + onError('扫码识别失败,请调整二维码位置'); + } + + if (isMounted) { + scanTimer = window.setTimeout(scanVideo, PROFILE_QR_SCAN_INTERVAL_MS); + } + }; + + const startCamera = async () => { + if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia) { + onError('当前浏览器不支持摄像头扫码'); + return; + } + + try { + const stream = await navigator.mediaDevices.getUserMedia({ + audio: false, + video: { facingMode: { ideal: 'environment' } }, + }); + + if (!isMounted) { + stream.getTracks().forEach((track) => track.stop()); + return; + } + + streamRef.current?.getTracks().forEach((track) => track.stop()); + streamRef.current = stream; + videoElement.srcObject = stream; + await videoElement.play(); + if (!detector) { + onError('当前浏览器暂不支持二维码识别'); + return; + } + scanTimer = window.setTimeout(scanVideo, PROFILE_QR_SCAN_INTERVAL_MS); + } catch { + onError('无法打开摄像头,请检查权限'); + } + }; + + void startCamera(); + + return () => { + isMounted = false; + clearScanTimer(); + stopCamera(); + }; + }, [onError, onResult]); + + return ( +
+
+
+
扫码
+ +
+
+
+
+ {result ? ( +
+ 已识别:{result} +
+ ) : error ? ( +
+ {error} +
+ ) : null} +
+
+
+ ); +} + function ProfileReferralModal({ panel, center, @@ -3898,6 +4025,9 @@ export function RpgEntryHomeView({ const [isLoadingTaskCenter, setIsLoadingTaskCenter] = useState(false); const [claimingTaskId, setClaimingTaskId] = useState(null); const [taskClaimSuccess, setTaskClaimSuccess] = useState(null); + const [isQrScannerOpen, setIsQrScannerOpen] = useState(false); + const [qrScannerError, setQrScannerError] = useState(null); + const [qrScannerResult, setQrScannerResult] = useState(null); const [profilePopupPanel, setProfilePopupPanel] = useState(null); const [referralCenter, setReferralCenter] = @@ -3935,6 +4065,7 @@ export function RpgEntryHomeView({ const [mobileCenteredCardKey, setMobileCenteredCardKey] = useState< string | null >(null); + const hasManualCategoryTagSelectionRef = useRef(false); const pendingPublicAuthorKeysRef = useRef>(new Set()); const [publicAuthorSummariesByKey, setPublicAuthorSummariesByKey] = useState< Record @@ -4159,16 +4290,33 @@ export function RpgEntryHomeView({ useEffect(() => { if (categoryGroups.length === 0) { setSelectedCategoryTag(null); + hasManualCategoryTagSelectionRef.current = false; return; } - const firstCategoryGroup = categoryGroups[0]; + const firstCategoryGroup = + categoryGroups.find((group) => + group.entries.some((entry) => !isMatch3DDemoProfileId(entry.profileId)), + ) ?? categoryGroups[0]; + const selectedCategoryGroup = + categoryGroups.find((group) => group.tag === selectedCategoryTag) ?? null; if ( firstCategoryGroup && - !categoryGroups.some((group) => group.tag === selectedCategoryTag) + (!selectedCategoryGroup || + (!hasManualCategoryTagSelectionRef.current && + selectedCategoryGroup.entries.every((entry) => + isMatch3DDemoProfileId(entry.profileId), + ) && + firstCategoryGroup.tag !== selectedCategoryGroup.tag)) ) { setSelectedCategoryTag(firstCategoryGroup.tag); } + if ( + selectedCategoryTag && + !categoryGroups.some((group) => group.tag === selectedCategoryTag) + ) { + hasManualCategoryTagSelectionRef.current = false; + } }, [categoryGroups, selectedCategoryTag]); useEffect(() => { @@ -4665,6 +4813,16 @@ export function RpgEntryHomeView({ setTaskClaimSuccess(null); loadTaskCenter(); }; + const openQrScannerPanel = () => { + if (!authUi?.user) { + authUi?.openLoginModal(); + return; + } + + setQrScannerError(null); + setQrScannerResult(null); + setIsQrScannerOpen(true); + }; const loadReferralCenter = useCallback(() => { setIsLoadingReferral(true); setIsReferralCenterInitialized(false); @@ -5232,23 +5390,6 @@ export function RpgEntryHomeView({ }, [], ); - const openActiveRecommendEntry = useCallback(() => { - if (!activeRecommendEntry) { - return; - } - - if (!isAuthenticated) { - authUi?.openLoginModal(); - return; - } - - openRecommendGalleryDetail(activeRecommendEntry); - }, [ - activeRecommendEntry, - authUi, - isAuthenticated, - openRecommendGalleryDetail, - ]); const leadPublicEntry = featuredShelf[0] ?? generalLatestEntries[0] ?? null; const openLeadPublicEntry = () => { if (leadPublicEntry) { @@ -5495,7 +5636,10 @@ export function RpgEntryHomeView({ - - authUi.openSettingsModal()} /> - setProfilePopupPanel('saveArchives')} - /> -
- 0 - ? `${saveEntries.length}个可继续` - : '继续游玩' - } - icon={Archive} - onClick={() => setProfilePopupPanel('saveArchives')} - /> - {canShowReferralRedeemShortcut ? ( + {canShowReferralRedeemShortcut ? ( +
openProfilePopupPanel('redeem')} /> - ) : null} -
+
+ ) : null} @@ -6506,7 +6620,10 @@ export function RpgEntryHomeView({ + + + ) : isAuthenticated && activeTab === 'create' ? (