From 5a6e68b6dc7aa7c780398cfcda5613ea446392e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=89=A9?= <253518756@qq.com> Date: Mon, 25 May 2026 22:57:14 +0800 Subject: [PATCH] 1 --- .hermes/shared-memory/pitfalls.md | 12 +- docs/README.md | 2 + ...Ÿæˆé«˜è´¨é‡2Då°æ¸¸æˆé«˜ä¸€è‡´æ€§ç¾Žæœ¯ç´ æçš„解决方案-2026-05-25.md | 184 +++++++++ ...玩法创作】平å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md | 2 +- ...项目基线】当å‰äº§å“与工程约æŸ-2026-05-15.md | 6 +- .../RpgEntryHomeView.recharge.test.tsx | 185 +++++++-- src/components/rpg-entry/RpgEntryHomeView.tsx | 375 ++++++++++++------ src/index.css | 63 +-- 8 files changed, 627 insertions(+), 202 deletions(-) create mode 100644 docs/ã€ä¸“åˆ©äº¤åº•ã€‘ä¸€ç§æžä½Žæˆæœ¬å¿«é€Ÿç”Ÿæˆé«˜è´¨é‡2Då°æ¸¸æˆé«˜ä¸€è‡´æ€§ç¾Žæœ¯ç´ æçš„解决方案-2026-05-25.md diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 682af630..1259be9d 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -1456,10 +1456,10 @@ - 验è¯ï¼š`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`。 -## 存档选择入å£ä¸è¦åªè—在“玩过â€å¼¹çª—里 +## 个人中心ä¸å†ä¿ç•™ç›´è¾¾â€œå­˜æ¡£â€æŒ‰é’®å…¥å£ -- 现象:用户有 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`。 +- 现象: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/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md b/docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md index a538870c..04728f45 100644 --- a/docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md +++ b/docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md @@ -66,7 +66,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` 返回的列表并在用户选择åŽè°ƒç”¨å¯¹åº”æ¢å¤æŽ¥å£ï¼Œä¸èƒ½æœ¬åœ°æ‹¼è£…或筛选正å¼å­˜æ¡£çœŸç›¸ã€‚ ## 拼图 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/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx index a7c4e5a4..103781a9 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx @@ -1035,6 +1035,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( @@ -1832,10 +1841,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', () => { @@ -1882,21 +1949,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( @@ -1914,6 +1995,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'); @@ -1953,18 +2035,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(); @@ -1982,6 +2057,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'; @@ -2195,7 +2310,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(); @@ -2221,18 +2336,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( @@ -2697,7 +2803,7 @@ test('logged out mobile shell defaults to discover tab', () => { ).toBeNull(); }); -test('logged out recommend tab opens login modal and shows cover only', async () => { +test('logged out recommend tab opens recommend runtime directly', async () => { const user = userEvent.setup(); const { container, openLoginModal } = renderStatefulLoggedOutHomeView({ latestEntries: [puzzlePublicEntry], @@ -2715,17 +2821,17 @@ test('logged out recommend tab opens login modal and shows cover only', async () expect(openLoginModal).toHaveBeenCalledTimes(1); expect( container.querySelector('.platform-recommend-cover-only'), - ).toBeTruthy(); + ).toBeNull(); expect(container.querySelector('.platform-mobile-topbar')).toBeNull(); expect( container.querySelector('.platform-mobile-entry-shell--recommend'), ).toBeTruthy(); - expect(screen.queryByTestId('recommend-runtime')).toBeNull(); - expect(screen.queryByLabelText('奇幻拼图 作å“ä¿¡æ¯')).toBeNull(); + expect(screen.getByTestId('recommend-runtime')).toBeTruthy(); + expect(screen.getByLabelText('奇幻拼图 作å“ä¿¡æ¯')).toBeTruthy(); expect(screen.getAllByText('奇幻拼图').length).toBeGreaterThan(0); }); -test('logged out recommend cover opens login modal again', async () => { +test('logged out recommend meta keeps gallery detail gated', async () => { const user = userEvent.setup(); const onOpenGalleryDetail = vi.fn(); const { openLoginModal } = renderStatefulLoggedOutHomeView({ @@ -2741,12 +2847,9 @@ test('logged out recommend cover opens login modal again', async () => { await user.click( within(bottomNav as HTMLElement).getByRole('button', { name: '推è' }), ); - await user.click( - screen.getByRole('button', { name: /ç™»å½•åŽæ¸¸çŽ© 奇幻拼图/u }), - ); + await user.click(screen.getByLabelText('奇幻拼图 作å“ä¿¡æ¯')); - expect(openLoginModal).toHaveBeenCalledTimes(2); - expect(openLoginModal).toHaveBeenLastCalledWith(); + expect(openLoginModal).toHaveBeenCalledTimes(1); expect(onOpenGalleryDetail).not.toHaveBeenCalled(); }); @@ -3082,7 +3185,7 @@ test('mobile recommend meta loads real author avatar from public user summary', await waitFor(() => { expect( document - .querySelector('.platform-recommend-cover-only__author img') + .querySelector('.platform-recommend-work-meta__avatar img') ?.getAttribute('src'), ).toBe('data:image/png;base64,AUTHOR'); }); diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 30017806..ba98c461 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, @@ -123,6 +122,7 @@ import { SquareImageCropModal, type SquareImageCropRect, } from '../common/SquareImageCropModal'; +import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork'; import { canExposePublicWork, EDUTAINMENT_WORK_TAG, @@ -131,7 +131,6 @@ import { findPublicWorkForHistoryEntry, isEdutainmentEntryEnabled, } from '../platform-entry/platformEdutainmentVisibility'; -import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork'; import { ResolvedAssetImage } from '../ResolvedAssetImage'; import { RpgEntryBrandLogo } from './RpgEntryBrandLogo'; import { @@ -225,6 +224,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 = @@ -253,9 +254,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 = { @@ -269,6 +297,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; @@ -756,69 +791,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, @@ -3283,7 +3255,7 @@ function ProfileTaskCenterModal({ onRetry: () => void; onClaim: (taskId: string) => void; }) { - const tasks = center?.tasks ?? []; + const tasks = selectProfileTaskCenterTasks(center?.tasks ?? []); const walletBalance = center?.walletBalance ?? fallbackBalance; return ( @@ -3459,6 +3431,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, @@ -3936,6 +4062,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] = @@ -4702,6 +4831,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); @@ -5264,23 +5403,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) { @@ -5924,28 +6046,10 @@ export function RpgEntryHomeView({ const savesContent: ReactNode = draftTabContent ?? fallbackDraftContent; const profileContent: ReactNode = ( -
+
{authUi?.user ? ( <>
-
- - -
authUi.openSettingsModal()} /> - setProfilePopupPanel('saveArchives')} - />
-
- 0 - ? `${saveEntries.length}个å¯ç»§ç»­` - : '继续游玩' - } - icon={Archive} - onClick={() => setProfilePopupPanel('saveArchives')} - /> - {canShowReferralRedeemShortcut ? ( + {canShowReferralRedeemShortcut ? ( +
openProfilePopupPanel('redeem')} /> - ) : null} -
+
+ ) : null} @@ -6695,6 +6784,22 @@ export function RpgEntryHomeView({ onClose={() => setIsCategoryFilterPanelOpen(false)} /> ) : null; + const qrScannerModal: ReactNode = isQrScannerOpen ? ( + { + setIsQrScannerOpen(false); + setQrScannerError(null); + setQrScannerResult(null); + }} + onError={setQrScannerError} + onResult={(value) => { + setQrScannerError(null); + setQrScannerResult(value); + }} + /> + ) : null; if (!isDesktopLayout) { const isMobileRecommendTab = activeTab === 'home'; @@ -6706,7 +6811,26 @@ export function RpgEntryHomeView({ {!isMobileRecommendTab ? (
- {isAuthenticated && activeTab === 'create' ? ( + {isAuthenticated && activeTab === 'profile' ? ( +
+ + +
+ ) : isAuthenticated && activeTab === 'create' ? (