diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index c2e3f02e..733dea4c 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -175,6 +175,14 @@ - 验证:`http://127.0.0.1:3000/api/auth/login-options` 返回至少 `{"availableLoginMethods":["phone","password"]}` 后,登录弹窗会恢复短信登录页签和“获取验证码”按钮。 - 关联:`scripts/api-server-dev.mjs`、`scripts/api-server-maincloud.mjs`、`scripts/dev-rust-stack.sh`、`scripts/dev-web-rust.mjs`、`docs/technical/AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md`。 +## 本地短信收不到验证码先查 provider + +- 现象:登录弹窗可以进入短信页签,但点击“获取验证码”后,手机没有收到短信。 +- 原因:本地 `.env.local` 里如果是 `SMS_AUTH_PROVIDER="mock"`,后端不会发真实短信,只会返回固定 mock 验证码;另外 `npm run api-server` 过去曾让 `.env` 覆盖 `.env.local`,导致本地真实短信配置被错误压回默认值。 +- 处理:真实短信联调时把 `.env.local` 的 `SMS_AUTH_PROVIDER` 显式设为 `aliyun`,然后重启 `api-server`;如果只想验证 UI 和账号链路,则保留 `mock` 并使用 `SMS_AUTH_MOCK_VERIFY_CODE`。 +- 验证:`GET /api/auth/login-options` 返回 `["phone","password"]`,`api-server` 日志里 `provider=aliyun` 才说明真实短信链路已生效。 +- 关联:`scripts/api-server-dev.mjs`、`docs/technical/AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md`、`docs/technical/PHONE_SMS_REAL_PROVIDER_MANUAL_VERIFICATION_RUNBOOK_2026-04-23.md`。 + ## 手机验证码登录 500 先查短信 provider 语义 - 现象:登录弹窗手机号验证码登录失败,浏览器看到 `POST /api/auth/phone/login 500`,后端日志里同时出现阿里云短信 `UNKNOWN`、`biz.FREQUENCY` 或 `check frequency failed`。 diff --git a/docs/design/CHILD_MOTION_EDUTAINMENT_DISCOVER_ENTRY_2026-05-09.md b/docs/design/CHILD_MOTION_EDUTAINMENT_DISCOVER_ENTRY_2026-05-09.md index 03b5e114..cd2befcb 100644 --- a/docs/design/CHILD_MOTION_EDUTAINMENT_DISCOVER_ENTRY_2026-05-09.md +++ b/docs/design/CHILD_MOTION_EDUTAINMENT_DISCOVER_ENTRY_2026-05-09.md @@ -16,6 +16,8 @@ 后续生产的该内容线模板和游戏关卡,都放置在“寓教于乐”独立标签下。 +该内容线当前只覆盖儿童动作识别 Demo 内容。后续创作环节需要继续对该板块内容做区分和独立管理,不把普通公开作品仅凭近似教育题材自动归入本板块。 + ## 2. 展示边界 寓教于乐内容不直接展示在以下位置: @@ -29,6 +31,8 @@ 寓教于乐内容只在“发现 / 寓教于乐”标签下展示。 +“寓教于乐”标签在发现页频道列表中放在最后,桌面端和移动端都显示。移动端访问该内容线的动作识别 Demo 时,需要提示横屏体验。 + ## 3. 开关规则 该入口需要支持灵活开关。 @@ -43,13 +47,14 @@ 1. 发现页隐藏“寓教于乐”标签; 2. 隐藏“寓教于乐”标签下内容; -3. 该内容线内容不进入推荐、今日、分类、排行和搜索结果。 +3. 该内容线内容不进入推荐、今日、分类、排行和搜索结果; +4. 该内容线内容完全不可见,公开作品搜索、作品号搜索直达、公开详情深链、浏览历史入口等平台公开入口都不能打开该内容。 ## 4. 内容识别规则 临时阶段使用作品标签识别寓教于乐内容。 -当公开作品标签中包含: +当公开作品标签中存在一个精确等于以下文本的标签: ```text 寓教于乐 @@ -57,6 +62,10 @@ 则该作品归入寓教于乐内容线。 +识别规则为精确匹配,不做包含匹配,不兼容空格、大小写变体或同义标签,例如“教育”“儿童教育”“动作教育”都不视为寓教于乐内容。 + +关闭开关时,即使作品具备精确的“寓教于乐”标签,也不允许通过任何平台公开展示入口或搜索入口访问。 + ## 5. 技术落地边界 本次只做前端入口和前端展示过滤,不新增后端接口。 @@ -87,3 +96,22 @@ no 3. 带有“寓教于乐”标签的公开作品不进入推荐页。 4. 带有“寓教于乐”标签的公开作品不进入发现页推荐、今日、分类、排行和搜索结果。 5. 带有“寓教于乐”标签的公开作品只在“发现 / 寓教于乐”标签下展示。 +6. “寓教于乐”标签位于发现页频道列表最后,桌面端和移动端均可见。 +7. 开关关闭后,带有“寓教于乐”标签的公开作品不可通过作品号搜索、公开详情深链或浏览历史入口打开。 +8. 标签识别只接受精确等于“寓教于乐”的作品标签,近似标签不归入该内容线。 + +## 7. 待补充事项 + +“寓教于乐”标签下暂无内容时的空状态文案待定。落地时可先复用平台现有空状态组件,但不新增功能说明类长文案。 + +## 8. 第 1-2 项工程落地状态 + +第 1 项“发现页入口与过滤”和第 2 项“搜索 / 深链 / 历史入口拦截”已进入前端落地阶段,当前实现口径如下: + +1. 入口开关由 `VITE_ENABLE_EDUTAINMENT_ENTRY` 控制,默认开启,显式配置 `false`、`0`、`off`、`no` 时关闭。 +2. 内容识别集中在 `src/components/platform-entry/platformEdutainmentVisibility.ts`,只读取公开作品原始 `themeTags`,且只接受精确等于“寓教于乐”的标签。 +3. `src/components/rpg-entry/RpgEntryHomeView.tsx` 已在发现页频道末尾追加“寓教于乐”频道,并将该类作品从推荐、今日、分类、排行、搜索、本地搜索兜底和桌面推荐模块中过滤。 +4. `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx` 已复用同一过滤 helper,避免推荐运行态自动启动寓教于乐作品,并在公开详情、作品号直达和公开详情深链等公开入口保留不可见保护。 +5. 浏览历史入口会优先按当前公开作品集合匹配作品标签;匹配到“寓教于乐”作品且开关关闭时不再展示历史入口。 +6. `/child-motion-demo` 本地动作 Demo 直达路由也复用同一开关;开关关闭时不匹配独立 Demo 应用,回落到主站入口。 +7. 定向回归覆盖在 `src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`、`src/components/platform-entry/platformEdutainmentVisibility.test.ts` 和 `src/routing/appRoutes.test.ts`,包含频道顺序、开关关闭、普通列表过滤、搜索过滤、作品号直达拦截、Demo 直达路由拦截和精确标签识别。 diff --git a/docs/technical/AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md b/docs/technical/AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md index 0995f68d..9048c8f2 100644 --- a/docs/technical/AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md +++ b/docs/technical/AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md @@ -137,3 +137,19 @@ 4. `npm run dev` / `npm run dev:rust` 完整栈默认由脚本计算 API 端口;加载 `.env.local` 给后端使用后,脚本必须重新固定 `RUST_SERVER_TARGET`,避免 `.env.local` 中的旧代理目标覆盖本次启动的实际 API 端口。 5. `npm run dev:web` 只启动前端,不会自动拉起 Rust API;如果 `.env.local` / 当前环境已经显式声明 `GENARRATIVE_RUNTIME_SERVER_TARGET`、`RUST_SERVER_TARGET`、`GENARRATIVE_API_TARGET` 或 `GENARRATIVE_API_PORT`,脚本必须固定使用该目标。目标当下不可用时只打印警告,不自动切到另一个端口,避免前端进程长时间绑定到随后会停掉的临时 API。 6. 如果 `3000` 仍然返回 `500`,先确认浏览器是不是还开着旧的前端进程。当前脚本如果因为端口占用漂移到 `3001` / `3002`,应直接关掉旧进程后重启,而不是继续用旧的 3000 页面判断登录入口状态。 + +## 11. 2026-05-10 `npm run api-server` 环境加载与短信 provider 排查记录 + +本地单独启动 `api-server` 时,环境变量合并顺序固定为: + +```text +外层 shell > .env > .env.local > .env.secrets.local +``` + +这保证 `.env.local` 能覆盖 `.env.example` 派生出的默认值,`.env.secrets.local` 能继续覆盖本地私密密钥配置。`scripts/api-server-dev.mjs` 不得让 `.env` 后加载并覆盖 `.env.local`,否则 `SMS_AUTH_ENABLED` 或 `SMS_AUTH_PROVIDER` 可能被压回错误值。 + +排查“点击获取验证码但手机收不到短信”时,除了确认 `availableLoginMethods` 包含 `phone`,还必须确认当前进程实际使用的 provider: + +1. `SMS_AUTH_PROVIDER="mock"` 只用于本地 UI / 账号链路联调,不会向手机发送真实短信;此时应使用 `SMS_AUTH_MOCK_VERIFY_CODE`,默认 `123456`。 +2. 真实短信链路必须使用 `SMS_AUTH_PROVIDER="aliyun"`,并在修改 `.env.local` 后重启 `api-server`,运行中的进程不会自动切换 provider。 +3. 真实 provider 是否被使用,以 `api-server` 日志中的 `provider=aliyun`、`provider_request_id` 和 `provider_out_id` 为准。 diff --git a/docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md b/docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md index 82213996..6f10423e 100644 --- a/docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md +++ b/docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md @@ -21,6 +21,8 @@ 9. 后续关卡使用热身记录的边界进行安全提醒和暂停恢复。 10. 热身结束后进入关卡选择。 +当前阶段先落浏览器本地 Demo。浏览器摄像头视频流已接入舞台背景;摄像头硬件动作识别 SDK、正式动作识别接口和正式语音播报接口继续预留适配层,不阻塞前端热身流程、调试输入和页面表现骨架落地。 + ## 2. 非目标范围 热身关当前不包含以下内容: @@ -33,6 +35,7 @@ 6. 不做特定用户识别。 7. 不跨会话保存左右空间边界、手臂挥动空间和跳跃空间。 8. 不对手部细节进行识别,只对肢体进行区分。 +9. 本阶段不处理无硬件、拒绝摄像头、多人入镜、识别丢失等异常流程;这些问题记录为待决策事项,后续硬件与摄像头方案稳定后再重新设计。 ## 3. 运行入口与流向 @@ -44,6 +47,8 @@ 用户完成热身关所有步骤后,进入关卡选择。 +当前后续游戏仍在设计中。热身结束后可先展示“开始游戏”按钮作为关卡选择占位,用户点击后进入下一关占位界面。 + ### 3.3 固定流程顺序 热身关必须按照以下顺序执行: @@ -166,6 +171,20 @@ 4. 动作类状态没有最长等待时间。 5. 动作类状态等待 3 秒后可以播放对应引导动画。 +### 6.3 开发者调试输入 + +本地 Demo 需要支持开发者调试模式,用于无摄像头和自动化验证场景。 + +调试映射如下: + +1. `A` 键映射用户向左移动。 +2. `D` 键映射用户向右移动。 +3. 鼠标左键按下并拖动映射左手轨迹。 +4. 鼠标右键按下并拖动映射右手轨迹。 +5. 空格键映射原地跳跃。 + +调试输入只作为本地 Demo 与测试辅助,不代表正式动作识别硬件口径。正式摄像头接入后,位置、手势和跳跃判断需要按摄像头硬件调教结果重新校准。 + ## 7. 分步骤开发规格 ### 7.1 进入热身关 @@ -438,6 +457,12 @@ 4. 右手挥动空间。 5. 跳跃空间。 +当前 Demo 体验会话数据需要满足: + +1. 用户刷新产品或退出产品后失效。 +2. 用户只关闭当前游戏关卡并重新进入时,可以直接来到开始游戏界面,不强制重复热身。 +3. 首版可使用前端运行时内存或同等生命周期容器保存;不得跨产品刷新持久化保存。 + ### 8.2 当前 Demo 体验会话定义 “当前 Demo 体验会话”指用户本次打开并体验 Demo 的过程。 @@ -523,10 +548,14 @@ 18. 关卡暂停时屏幕中央地面绿色圆圈。 19. 关卡暂停提示文案。 +角色剪影、绿色圆环、虚影提醒、圆圈消失特效、手势引导动画和热身结束特效的正式视觉资源将通过 gpt-image-2 设计和生成。本地 Demo 阶段可以先使用 CSS、Canvas 或临时占位资源实现相同交互位置与状态,不把占位资源写死为正式资产。 + ## 12. 固定文案与语音清单 以下文案需要作为屏幕中上方浮现文字,并同步语音播报。 +正式语音播报后续接入语音播报功能接口。本地 Demo 阶段保留播报适配层与调用点,可先只展示文字,不强制生成或播放正式语音资产。 + ```text 欢迎你,小朋友,见到你真开心 请你来到圆圈这里和我打个招呼吧 @@ -593,10 +622,49 @@ 当前需求已明确本文所需的热身关开发规格。 -以下内容未在当前文档中强行定义,后续如进入工程实现阶段,可再补充对应技术细节: +以下内容作为待决策事项保留,后续硬件、摄像头和正式关卡设计稳定后再补充: -1. 具体接入的动作识别 SDK 或硬件接口。 -2. 角色剪影、圆环、虚影提醒、特效、手势引导动画的具体资源文件命名。 -3. 当前 Demo 体验会话数据在前端状态、运行时上下文或其他容器中的具体存放位置。 -4. 绿色圆环、角色剪影、安全边界在线性空间或屏幕坐标中的具体计算公式。 -5. 关卡选择页的具体页面结构。 +1. 具体接入的动作识别 SDK、硬件接口和摄像头接口。 +2. 无硬件、摄像头拒绝授权、多人入镜、识别不到用户、跟踪丢失等异常流程。 +3. 角色剪影、圆环、虚影提醒、特效、手势引导动画的正式资源文件命名。 +4. 绿色圆环、角色剪影、安全边界在线性空间或屏幕坐标中的正式计算公式。 +5. 正式关卡选择页与后续游戏关卡的具体页面结构。 + +## 15. 第 3 项本地 Demo 落地记录 + +本地浏览器 Demo 入口已落在: + +```text +/child-motion-demo +``` + +当前实现范围: + +1. `src/ChildMotionDemoApp.tsx` 挂载独立 Demo 应用壳。 +2. `src/components/child-motion-demo/childMotionWarmupModel.ts` 维护热身步骤、圆环目标、2 秒保持判定、热身校准记录和当前运行时会话完成标记。 +3. `src/components/child-motion-demo/ChildMotionWarmupDemo.tsx` 实现横屏舞台、背景虚化占位层、角色剪影、绿色圆环、手势引导、热身记录面板、热身完成后的“开始游戏”按钮和下一关占位界面。 +4. `src/services/child-motion-demo/childMotionDebugInput.ts` 保留开发者调试输入适配层,后续可被正式动作识别 SDK 适配层替换或并行接入。 +5. `src/routing/appRoutes.tsx` 新增 `/child-motion-demo` 独立路由,并复用 `VITE_ENABLE_EDUTAINMENT_ENTRY` 开关;开关关闭时不允许通过该直达路径进入 Demo。 + +当前调试输入: + +1. `A` 键映射用户向左移动,松开后回到中心。 +2. `D` 键映射用户向右移动,松开后回到中心。 +3. 鼠标左键按下并拖动映射左手轨迹。 +4. 鼠标右键按下并拖动映射右手轨迹。 +5. 空格键映射原地跳跃。 + +当前未接入但已保留边界: + +1. 浏览器摄像头视频流已接入;硬件动作识别 SDK 和正式动作识别接口暂不接入,后续通过动作输入适配层替换或并行接入调试输入。 +2. 正式语音播报接口暂不接入,当前先展示热身文案。 +3. 正式 gpt-image-2 视觉资源暂不接入,当前使用 CSS 占位表达相同位置和状态。 +4. 后续关卡安全边界暂停逻辑暂未落地,当前只完成热身记录和下一关按钮占位。 + +已执行的定向验证命令: + +```bash +npx eslint src/components/child-motion-demo/ChildMotionWarmupDemo.tsx src/components/child-motion-demo/childMotionWarmupModel.ts src/components/child-motion-demo/ChildMotionWarmupDemo.test.tsx src/components/child-motion-demo/childMotionWarmupModel.test.ts src/services/child-motion-demo/childMotionDebugInput.ts src/services/child-motion-demo/childMotionDebugInput.test.ts src/services/child-motion-demo/index.ts src/ChildMotionDemoApp.tsx src/routing/appRoutes.tsx src/routing/appRoutes.test.ts --ext .ts,.tsx --max-warnings 0 +npx vitest run src/components/child-motion-demo/childMotionWarmupModel.test.ts src/components/child-motion-demo/ChildMotionWarmupDemo.test.tsx src/services/child-motion-demo/childMotionDebugInput.test.ts src/routing/appRoutes.test.ts +npm run check:encoding +``` diff --git a/scripts/api-server-dev.mjs b/scripts/api-server-dev.mjs index 95b60010..ba1adc92 100644 --- a/scripts/api-server-dev.mjs +++ b/scripts/api-server-dev.mjs @@ -38,9 +38,10 @@ function loadEnvFile(path, target) { } const mergedEnv = { ...process.env }; -loadEnvFile(resolve(repoRoot, '.env.secrets.local'), mergedEnv); -loadEnvFile(resolve(repoRoot, '.env.local'), mergedEnv); +// 保持与 dev-web-rust.mjs / dev-rust-stack.sh 一致:shell > .env > .env.local > .env.secrets.local。 loadEnvFile(resolve(repoRoot, '.env'), mergedEnv); +loadEnvFile(resolve(repoRoot, '.env.local'), mergedEnv); +loadEnvFile(resolve(repoRoot, '.env.secrets.local'), mergedEnv); mergedEnv.GENARRATIVE_API_HOST = mergedEnv.GENARRATIVE_API_HOST || '127.0.0.1'; mergedEnv.GENARRATIVE_API_PORT = mergedEnv.GENARRATIVE_API_PORT || '3100'; diff --git a/src/ChildMotionDemoApp.tsx b/src/ChildMotionDemoApp.tsx new file mode 100644 index 00000000..fb181b1b --- /dev/null +++ b/src/ChildMotionDemoApp.tsx @@ -0,0 +1,5 @@ +import { ChildMotionWarmupDemo } from './components/child-motion-demo/ChildMotionWarmupDemo'; + +export default function ChildMotionDemoApp() { + return ; +} diff --git a/src/components/child-motion-demo/ChildMotionWarmupDemo.test.tsx b/src/components/child-motion-demo/ChildMotionWarmupDemo.test.tsx new file mode 100644 index 00000000..e1912ba5 --- /dev/null +++ b/src/components/child-motion-demo/ChildMotionWarmupDemo.test.tsx @@ -0,0 +1,96 @@ +/* @vitest-environment jsdom */ + +import { fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, expect, test, vi } from 'vitest'; + +import { ChildMotionWarmupDemo } from './ChildMotionWarmupDemo'; +import { + markChildMotionWarmupCompletedInRuntime, + resetChildMotionWarmupRuntimeSession, +} from './childMotionWarmupModel'; + +beforeEach(() => { + resetChildMotionWarmupRuntimeSession(); + vi.restoreAllMocks(); + Object.defineProperty(navigator, 'mediaDevices', { + configurable: true, + value: undefined, + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +test('renders the warmup stage and starts with the center ring step', () => { + render(); + + expect(screen.getByTestId('child-motion-demo')).toBeTruthy(); + expect(screen.getByText('来到圆圈这里')).toBeTruthy(); + expect(screen.getByLabelText('绿色圆环')).toBeTruthy(); + expect(screen.getByText('请横屏体验')).toBeTruthy(); +}); + +test('re-entering within the same runtime session opens the start button', () => { + markChildMotionWarmupCompletedInRuntime(); + + render(); + + expect(screen.getByRole('button', { name: '开始游戏' })).toBeTruthy(); +}); + +test('developer keyboard input moves the avatar and triggers jump state', () => { + render(); + + const avatar = screen.getByTestId('child-motion-avatar'); + + fireEvent.keyDown(window, { key: 'a', code: 'KeyA' }); + expect(avatar.getAttribute('style')).toContain('left: 34%'); + + fireEvent.keyDown(window, { key: 'd', code: 'KeyD' }); + expect(avatar.getAttribute('style')).toContain('left: 66%'); + + fireEvent.keyUp(window, { key: 'd', code: 'KeyD' }); + expect(avatar.getAttribute('style')).toContain('left: 50%'); + + fireEvent.keyDown(window, { key: ' ', code: 'Space' }); + expect(avatar.className).toContain('child-motion-avatar--jumping'); +}); + +test('connects camera stream and releases it on unmount', async () => { + const stopTrack = vi.fn(); + const stream = { + getTracks: () => [ + { + stop: stopTrack, + }, + ], + } as unknown as MediaStream; + const getUserMedia = vi.fn().mockResolvedValue(stream); + const play = vi + .spyOn(HTMLMediaElement.prototype, 'play') + .mockResolvedValue(undefined); + Object.defineProperty(navigator, 'mediaDevices', { + configurable: true, + value: { + getUserMedia, + }, + }); + + const { unmount } = render(); + + expect(await screen.findByText('正在连接摄像头')).toBeTruthy(); + await vi.waitFor(() => { + expect(getUserMedia).toHaveBeenCalledWith({ + audio: false, + video: { + facingMode: 'user', + }, + }); + expect(play).toHaveBeenCalled(); + }); + + unmount(); + + expect(stopTrack).toHaveBeenCalled(); +}); diff --git a/src/components/child-motion-demo/ChildMotionWarmupDemo.tsx b/src/components/child-motion-demo/ChildMotionWarmupDemo.tsx new file mode 100644 index 00000000..a68a2dfc --- /dev/null +++ b/src/components/child-motion-demo/ChildMotionWarmupDemo.tsx @@ -0,0 +1,582 @@ +import type { + CSSProperties, + PointerEvent as ReactPointerEvent, +} from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { + applyChildMotionWarmupCompletion, + CHILD_MOTION_CENTER_X, + CHILD_MOTION_FINISH_DURATION_MS, + CHILD_MOTION_HOLD_DURATION_MS, + CHILD_MOTION_NARRATION_DURATION_MS, + type ChildMotionPoint, + type ChildMotionWarmupCalibration, + type ChildMotionWarmupStepId, + createEmptyChildMotionCalibration, + getChildMotionTargetX, + getChildMotionWarmupStep, + hasCompletedChildMotionWarmupInRuntime, + isAvatarOnWarmupTarget, + markChildMotionWarmupCompletedInRuntime, + resolveNextChildMotionWarmupStep, +} from './childMotionWarmupModel'; + +type DragHand = 'left' | 'right'; +type CameraAccessState = 'idle' | 'requesting' | 'ready' | 'blocked'; + +function clampMotionUnit(value: number) { + return Math.max(0, Math.min(1, value)); +} + +function normalizePointerPoint( + event: ReactPointerEvent, + element: HTMLElement, +): ChildMotionPoint { + const rect = element.getBoundingClientRect(); + const width = rect.width || 1; + const height = rect.height || 1; + return { + x: clampMotionUnit((event.clientX - rect.left) / width), + y: clampMotionUnit((event.clientY - rect.top) / height), + }; +} + +function formatPercent(value: number | null) { + if (value === null) { + return '--'; + } + + return `${Math.round(value * 100)}%`; +} + +function getHoldProgress( + stepId: ChildMotionWarmupStepId, + avatarX: number, + holdStartedAt: number | null, + nowMs: number, +) { + const step = getChildMotionWarmupStep(stepId); + if (!isAvatarOnWarmupTarget(step, avatarX) || holdStartedAt === null) { + return 0; + } + + return Math.min(1, (nowMs - holdStartedAt) / CHILD_MOTION_HOLD_DURATION_MS); +} + +function getStepIndex(stepId: ChildMotionWarmupStepId) { + const order: ChildMotionWarmupStepId[] = [ + 'center_arrive', + 'wave_greeting', + 'warmup_intro', + 'move_left', + 'return_center_1', + 'move_right', + 'return_center_2', + 'wave_left_hand', + 'wave_right_hand', + 'jump_once', + 'warmup_finish', + 'level_select', + 'play_placeholder', + ]; + return Math.max(0, order.indexOf(stepId)); +} + +function ChildMotionAvatar({ + avatarX, + isJumping, +}: { + avatarX: number; + isJumping: boolean; +}) { + return ( +
+ + + + + + +
+ ); +} + +function ChildMotionRing({ + targetX, + progress, +}: { + targetX: number; + progress: number; +}) { + return ( +
0 ? 'child-motion-ring--active' : ''}`} + data-testid="child-motion-ring" + style={{ + left: `${targetX * 100}%`, + '--child-motion-ring-progress': `${Math.round(progress * 360)}deg`, + } as CSSProperties} + aria-label="绿色圆环" + > + +
+ ); +} + +function ChildMotionGestureGuide({ + stepId, + leftHandPath, + rightHandPath, +}: { + stepId: ChildMotionWarmupStepId; + leftHandPath: ChildMotionPoint[]; + rightHandPath: ChildMotionPoint[]; +}) { + const isLeft = stepId === 'wave_left_hand'; + const isRight = stepId === 'wave_right_hand'; + const isGreeting = stepId === 'wave_greeting'; + const isJump = stepId === 'jump_once'; + const activePath = isLeft ? leftHandPath : isRight ? rightHandPath : []; + + return ( + + ); +} + +function ChildMotionCalibrationPanel({ + calibration, +}: { + calibration: ChildMotionWarmupCalibration; +}) { + return ( +
+
+ 左边界 + {formatPercent(calibration.leftBoundary)} +
+
+ 右边界 + {formatPercent(calibration.rightBoundary)} +
+
+ 左手 + {calibration.leftHandPath.length} +
+
+ 右手 + {calibration.rightHandPath.length} +
+
+ 跳跃 + {formatPercent(calibration.jumpSpace)} +
+
+ ); +} + +export function ChildMotionWarmupDemo() { + const [stepId, setStepId] = useState(() => + hasCompletedChildMotionWarmupInRuntime() ? 'level_select' : 'center_arrive', + ); + const [avatarX, setAvatarX] = useState(CHILD_MOTION_CENTER_X); + const [calibration, setCalibration] = useState( + createEmptyChildMotionCalibration, + ); + const [holdStartedAt, setHoldStartedAt] = useState(null); + const [nowMs, setNowMs] = useState(() => Date.now()); + const [leftHandPath, setLeftHandPath] = useState([]); + const [rightHandPath, setRightHandPath] = useState([]); + const [activeHand, setActiveHand] = useState(null); + const [isJumping, setIsJumping] = useState(false); + const [justCompletedText, setJustCompletedText] = useState(null); + const [cameraAccessState, setCameraAccessState] = + useState(() => + typeof navigator === 'undefined' || + !navigator.mediaDevices?.getUserMedia + ? 'blocked' + : 'idle', + ); + const holdCompletionRef = useRef(false); + const cameraVideoRef = useRef(null); + const cameraStreamRef = useRef(null); + + const step = getChildMotionWarmupStep(stepId); + const stepIndex = getStepIndex(stepId); + const progressPercent = Math.round((stepIndex / 12) * 100); + const holdProgress = getHoldProgress(stepId, avatarX, holdStartedAt, nowMs); + const targetX = step.target ? getChildMotionTargetX(step.target) : null; + + const completeStep = useCallback( + (completion: Parameters[2]) => { + setCalibration((current) => + applyChildMotionWarmupCompletion(stepId, current, completion), + ); + + const nextStep = resolveNextChildMotionWarmupStep(stepId); + if (stepId === 'jump_once') { + markChildMotionWarmupCompletedInRuntime(); + } + + setJustCompletedText( + stepId === 'warmup_finish' || stepId === 'jump_once' ? null : '真棒', + ); + window.setTimeout(() => setJustCompletedText(null), 720); + setStepId(nextStep); + setHoldStartedAt(null); + holdCompletionRef.current = false; + }, + [stepId], + ); + + useEffect(() => { + const timer = window.setInterval(() => setNowMs(Date.now()), 120); + return () => window.clearInterval(timer); + }, []); + + useEffect(() => { + const videoElement = cameraVideoRef.current; + if ( + typeof navigator === 'undefined' || + !navigator.mediaDevices?.getUserMedia || + !videoElement + ) { + return; + } + + let isMounted = true; + const startCamera = async () => { + if (!navigator.mediaDevices?.getUserMedia) { + if (isMounted) { + setCameraAccessState('blocked'); + } + return; + } + + try { + setCameraAccessState('requesting'); + const stream = await navigator.mediaDevices.getUserMedia({ + audio: false, + video: { + facingMode: 'user', + }, + }); + + if (!isMounted) { + stream.getTracks().forEach((track) => track.stop()); + return; + } + + cameraStreamRef.current?.getTracks().forEach((track) => track.stop()); + cameraStreamRef.current = stream; + videoElement.srcObject = stream; + await videoElement.play(); + setCameraAccessState('ready'); + } catch { + cameraStreamRef.current?.getTracks().forEach((track) => track.stop()); + cameraStreamRef.current = null; + videoElement.srcObject = null; + if (isMounted) { + setCameraAccessState('blocked'); + } + } + }; + + void startCamera(); + + return () => { + isMounted = false; + const stream = cameraStreamRef.current; + cameraStreamRef.current = null; + if (stream) { + stream.getTracks().forEach((track) => track.stop()); + } + videoElement.srcObject = null; + }; + }, []); + + useEffect(() => { + const stream = cameraStreamRef.current; + const videoElement = cameraVideoRef.current; + if (stream && videoElement && videoElement.srcObject !== stream) { + videoElement.srcObject = stream; + } + }, [cameraAccessState]); + + useEffect(() => { + holdCompletionRef.current = false; + setHoldStartedAt(null); + setLeftHandPath([]); + setRightHandPath([]); + }, [stepId]); + + useEffect(() => { + if (step.kind !== 'position') { + return; + } + + if (!isAvatarOnWarmupTarget(step, avatarX)) { + setHoldStartedAt(null); + holdCompletionRef.current = false; + return; + } + + setHoldStartedAt((current) => current ?? Date.now()); + }, [avatarX, step]); + + useEffect(() => { + if ( + step.kind !== 'position' || + holdStartedAt === null || + holdCompletionRef.current || + nowMs - holdStartedAt < CHILD_MOTION_HOLD_DURATION_MS + ) { + return; + } + + holdCompletionRef.current = true; + completeStep({ type: 'position', avatarX }); + }, [avatarX, completeStep, holdStartedAt, nowMs, step.kind]); + + useEffect(() => { + if (step.kind !== 'narration' && step.kind !== 'finish') { + return; + } + + const timeout = window.setTimeout( + () => completeStep({ type: 'narration' }), + step.kind === 'finish' + ? CHILD_MOTION_FINISH_DURATION_MS + : CHILD_MOTION_NARRATION_DURATION_MS, + ); + return () => window.clearTimeout(timeout); + }, [completeStep, step.kind]); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.repeat) { + return; + } + + const key = event.key.toLowerCase(); + if (key === 'a') { + setAvatarX(0.34); + return; + } + + if (key === 'd') { + setAvatarX(0.66); + return; + } + + if (event.code === 'Space') { + event.preventDefault(); + setIsJumping(true); + window.setTimeout(() => setIsJumping(false), 360); + if (stepId === 'jump_once') { + completeStep({ type: 'jump', jumpSpace: 0.14 }); + } + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [completeStep, stepId]); + + useEffect(() => { + const handleKeyUp = (event: KeyboardEvent) => { + const key = event.key.toLowerCase(); + if (key === 'a' || key === 'd' || event.code === 'KeyA' || event.code === 'KeyD') { + setAvatarX(CHILD_MOTION_CENTER_X); + } + }; + + window.addEventListener('keyup', handleKeyUp); + return () => window.removeEventListener('keyup', handleKeyUp); + }, []); + + const handleStagePointerDown = (event: ReactPointerEvent) => { + if (event.button !== 0 && event.button !== 2) { + return; + } + + event.preventDefault(); + const nextHand: DragHand = event.button === 2 ? 'right' : 'left'; + setActiveHand(nextHand); + const point = normalizePointerPoint(event, event.currentTarget); + if (nextHand === 'left') { + setLeftHandPath([point]); + } else { + setRightHandPath([point]); + } + event.currentTarget.setPointerCapture(event.pointerId); + }; + + const handleStagePointerMove = (event: ReactPointerEvent) => { + if (!activeHand) { + return; + } + + const point = normalizePointerPoint(event, event.currentTarget); + const appendPoint = (points: ChildMotionPoint[]) => + [...points, point].slice(-16); + if (activeHand === 'left') { + setLeftHandPath(appendPoint); + } else { + setRightHandPath(appendPoint); + } + }; + + const handleStagePointerUp = (event: ReactPointerEvent) => { + if (!activeHand) { + return; + } + + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + const hand = activeHand; + const point = normalizePointerPoint(event, event.currentTarget); + const completedPath = + hand === 'left' + ? [...leftHandPath, point].slice(-16) + : [...rightHandPath, point].slice(-16); + setActiveHand(null); + + if (stepId === 'wave_greeting') { + completeStep({ type: 'left-hand', path: completedPath }); + return; + } + + if (stepId === 'wave_left_hand' && hand === 'left') { + completeStep({ type: 'left-hand', path: completedPath }); + return; + } + + if (stepId === 'wave_right_hand' && hand === 'right') { + completeStep({ type: 'right-hand', path: completedPath }); + } + }; + + const handleStartPlaceholderLevel = () => { + setStepId('play_placeholder'); + }; + + const handleReturnToStart = () => { + setStepId('level_select'); + }; + + const lineText = useMemo(() => step.spokenLines.join(','), [step.spokenLines]); + + return ( +
+
+ 请横屏体验 +
+ +
event.preventDefault()} + > +
+
+ ); +} + +export default ChildMotionWarmupDemo; diff --git a/src/components/child-motion-demo/childMotionWarmupModel.test.ts b/src/components/child-motion-demo/childMotionWarmupModel.test.ts new file mode 100644 index 00000000..16c04b07 --- /dev/null +++ b/src/components/child-motion-demo/childMotionWarmupModel.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest'; + +import { + applyChildMotionWarmupCompletion, + CHILD_MOTION_CENTER_X, + CHILD_MOTION_WARMUP_STEPS, + createEmptyChildMotionCalibration, + getChildMotionWarmupStep, + isAvatarOnWarmupTarget, + resolveNextChildMotionWarmupStep, +} from './childMotionWarmupModel'; + +describe('childMotionWarmupModel', () => { + it('keeps the confirmed warmup order as a strict state chain', () => { + expect(CHILD_MOTION_WARMUP_STEPS.map((step) => step.id)).toEqual([ + 'center_arrive', + 'wave_greeting', + 'warmup_intro', + 'move_left', + 'return_center_1', + 'move_right', + 'return_center_2', + 'wave_left_hand', + 'wave_right_hand', + 'jump_once', + 'warmup_finish', + 'level_select', + 'play_placeholder', + ]); + expect(resolveNextChildMotionWarmupStep('center_arrive')).toBe( + 'wave_greeting', + ); + expect(resolveNextChildMotionWarmupStep('level_select')).toBe( + 'play_placeholder', + ); + }); + + it('checks position completion against the active green ring target', () => { + expect( + isAvatarOnWarmupTarget( + getChildMotionWarmupStep('center_arrive'), + CHILD_MOTION_CENTER_X, + ), + ).toBe(true); + expect( + isAvatarOnWarmupTarget(getChildMotionWarmupStep('move_left'), 0.66), + ).toBe(false); + }); + + it('records session-only calibration values from completed steps', () => { + const empty = createEmptyChildMotionCalibration(); + const withLeft = applyChildMotionWarmupCompletion('move_left', empty, { + type: 'position', + avatarX: 0.34, + }); + const withRight = applyChildMotionWarmupCompletion('move_right', withLeft, { + type: 'position', + avatarX: 0.66, + }); + const withLeftHand = applyChildMotionWarmupCompletion( + 'wave_left_hand', + withRight, + { + type: 'left-hand', + path: [ + { x: 0.3, y: 0.4 }, + { x: 0.34, y: 0.32 }, + ], + }, + ); + const completed = applyChildMotionWarmupCompletion( + 'jump_once', + withLeftHand, + { + type: 'jump', + jumpSpace: 0.14, + }, + ); + + expect(completed.leftBoundary).toBeCloseTo(0.16); + expect(completed.rightBoundary).toBeCloseTo(0.16); + expect(completed.leftHandPath).toHaveLength(2); + expect(completed.jumpSpace).toBe(0.14); + }); +}); diff --git a/src/components/child-motion-demo/childMotionWarmupModel.ts b/src/components/child-motion-demo/childMotionWarmupModel.ts new file mode 100644 index 00000000..f009fe7a --- /dev/null +++ b/src/components/child-motion-demo/childMotionWarmupModel.ts @@ -0,0 +1,274 @@ +export type ChildMotionWarmupStepId = + | 'center_arrive' + | 'wave_greeting' + | 'warmup_intro' + | 'move_left' + | 'return_center_1' + | 'move_right' + | 'return_center_2' + | 'wave_left_hand' + | 'wave_right_hand' + | 'jump_once' + | 'warmup_finish' + | 'level_select' + | 'play_placeholder'; + +export type ChildMotionWarmupTarget = 'center' | 'left' | 'right'; + +export type ChildMotionWarmupStepKind = + | 'position' + | 'gesture' + | 'narration' + | 'finish' + | 'levelSelect' + | 'placeholder'; + +export type ChildMotionWarmupStep = { + id: ChildMotionWarmupStepId; + kind: ChildMotionWarmupStepKind; + title: string; + spokenLines: string[]; + target?: ChildMotionWarmupTarget; +}; + +export type ChildMotionPoint = { + x: number; + y: number; +}; + +export type ChildMotionWarmupCalibration = { + leftBoundary: number | null; + rightBoundary: number | null; + leftHandPath: ChildMotionPoint[]; + rightHandPath: ChildMotionPoint[]; + jumpSpace: number | null; +}; + +export type ChildMotionWarmupCompletion = + | { + type: 'position'; + avatarX: number; + } + | { + type: 'left-hand'; + path: ChildMotionPoint[]; + } + | { + type: 'right-hand'; + path: ChildMotionPoint[]; + } + | { + type: 'jump'; + jumpSpace: number; + } + | { + type: 'narration'; + }; + +export const CHILD_MOTION_CENTER_X = 0.5; +export const CHILD_MOTION_LEFT_X = 0.34; +export const CHILD_MOTION_RIGHT_X = 0.66; +export const CHILD_MOTION_POSITION_EPSILON = 0.045; +export const CHILD_MOTION_HOLD_DURATION_MS = 2000; +export const CHILD_MOTION_NARRATION_DURATION_MS = 900; +export const CHILD_MOTION_FINISH_DURATION_MS = 1200; + +export const CHILD_MOTION_WARMUP_STEPS: ChildMotionWarmupStep[] = [ + { + id: 'center_arrive', + kind: 'position', + title: '来到圆圈这里', + spokenLines: ['欢迎你,小朋友,见到你真开心', '请你来到圆圈这里和我打个招呼吧'], + target: 'center', + }, + { + id: 'wave_greeting', + kind: 'gesture', + title: '打个招呼', + spokenLines: ['请你来到圆圈这里和我打个招呼吧'], + }, + { + id: 'warmup_intro', + kind: 'narration', + title: '准备热身', + spokenLines: ['你好呀小朋友,为了你玩的安全和开心,先来和我一起热个身吧'], + }, + { + id: 'move_left', + kind: 'position', + title: '向左一步', + spokenLines: ['向左一步'], + target: 'left', + }, + { + id: 'return_center_1', + kind: 'position', + title: '回到中间来', + spokenLines: ['回到中间来'], + target: 'center', + }, + { + id: 'move_right', + kind: 'position', + title: '向右一步', + spokenLines: ['向右一步'], + target: 'right', + }, + { + id: 'return_center_2', + kind: 'position', + title: '回到中间来', + spokenLines: ['回到中间来'], + target: 'center', + }, + { + id: 'wave_left_hand', + kind: 'gesture', + title: '挥动左手', + spokenLines: ['挥动左手'], + }, + { + id: 'wave_right_hand', + kind: 'gesture', + title: '挥动右手', + spokenLines: ['挥动右手'], + }, + { + id: 'jump_once', + kind: 'gesture', + title: '原地跳一下', + spokenLines: ['原地跳一下'], + }, + { + id: 'warmup_finish', + kind: 'finish', + title: '热身完成', + spokenLines: ['真厉害,你是我见过最聪明的小朋友', '别走开,现在开始我们的游戏吧'], + }, + { + id: 'level_select', + kind: 'levelSelect', + title: '准备开始', + spokenLines: ['现在开始我们的游戏吧'], + }, + { + id: 'play_placeholder', + kind: 'placeholder', + title: '下一关', + spokenLines: ['游戏关卡正在准备中'], + }, +]; + +const STEP_BY_ID = new Map( + CHILD_MOTION_WARMUP_STEPS.map((step) => [step.id, step]), +); + +const NEXT_STEP_BY_ID = new Map( + CHILD_MOTION_WARMUP_STEPS.slice(0, -1).map((step, index) => [ + step.id, + CHILD_MOTION_WARMUP_STEPS[index + 1]!.id, + ]), +); + +let childMotionWarmupCompletedInRuntime = false; + +export function getChildMotionWarmupStep(id: ChildMotionWarmupStepId) { + return STEP_BY_ID.get(id) ?? CHILD_MOTION_WARMUP_STEPS[0]!; +} + +export function getChildMotionTargetX(target: ChildMotionWarmupTarget) { + if (target === 'left') { + return CHILD_MOTION_LEFT_X; + } + + if (target === 'right') { + return CHILD_MOTION_RIGHT_X; + } + + return CHILD_MOTION_CENTER_X; +} + +export function isAvatarOnWarmupTarget( + step: ChildMotionWarmupStep, + avatarX: number, +) { + if (step.kind !== 'position' || !step.target) { + return false; + } + + return ( + Math.abs(avatarX - getChildMotionTargetX(step.target)) <= + CHILD_MOTION_POSITION_EPSILON + ); +} + +export function resolveNextChildMotionWarmupStep( + stepId: ChildMotionWarmupStepId, +) { + return NEXT_STEP_BY_ID.get(stepId) ?? stepId; +} + +export function createEmptyChildMotionCalibration(): ChildMotionWarmupCalibration { + return { + leftBoundary: null, + rightBoundary: null, + leftHandPath: [], + rightHandPath: [], + jumpSpace: null, + }; +} + +export function applyChildMotionWarmupCompletion( + stepId: ChildMotionWarmupStepId, + calibration: ChildMotionWarmupCalibration, + completion: ChildMotionWarmupCompletion, +): ChildMotionWarmupCalibration { + if (stepId === 'move_left' && completion.type === 'position') { + return { + ...calibration, + leftBoundary: Math.max(0, CHILD_MOTION_CENTER_X - completion.avatarX), + }; + } + + if (stepId === 'move_right' && completion.type === 'position') { + return { + ...calibration, + rightBoundary: Math.max(0, completion.avatarX - CHILD_MOTION_CENTER_X), + }; + } + + if (stepId === 'wave_left_hand' && completion.type === 'left-hand') { + return { + ...calibration, + leftHandPath: completion.path, + }; + } + + if (stepId === 'wave_right_hand' && completion.type === 'right-hand') { + return { + ...calibration, + rightHandPath: completion.path, + }; + } + + if (stepId === 'jump_once' && completion.type === 'jump') { + return { + ...calibration, + jumpSpace: completion.jumpSpace, + }; + } + + return calibration; +} + +export function hasCompletedChildMotionWarmupInRuntime() { + return childMotionWarmupCompletedInRuntime; +} + +export function markChildMotionWarmupCompletedInRuntime() { + childMotionWarmupCompletedInRuntime = true; +} + +export function resetChildMotionWarmupRuntimeSession() { + childMotionWarmupCompletedInRuntime = false; +} diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index d2aac4e1..b82b8c4a 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -285,8 +285,12 @@ import { useRpgCreationEnterWorld } from '../rpg-entry/useRpgCreationEnterWorld' import { useRpgCreationResultAutosave } from '../rpg-entry/useRpgCreationResultAutosave'; import { useRpgCreationSessionController } from '../rpg-entry/useRpgCreationSessionController'; import { createMockVisualNovelRunFromDraft } from '../visual-novel-runtime/visualNovelMockData'; +import { + canExposePublicWork, + EDUTAINMENT_HIDDEN_MESSAGE, + filterGeneralPublicWorks, +} from './platformEdutainmentVisibility'; import { PlatformEntryCreationTypeModal } from './PlatformEntryCreationTypeModal'; -import { PlatformFeedbackView } from './PlatformFeedbackView'; import type { PlatformCreationTypeId } from './platformEntryCreationTypes'; import { getVisiblePlatformCreationTypes, @@ -302,6 +306,7 @@ import { } from './platformEntryShared'; import type { PlatformEntryFlowShellProps } from './platformEntryTypes'; import { PlatformEntryWorldDetailView } from './PlatformEntryWorldDetailView'; +import { PlatformFeedbackView } from './PlatformFeedbackView'; import { PlatformWorkDetailView } from './PlatformWorkDetailView'; import { usePlatformCreationAgentFlowController } from './usePlatformCreationAgentFlowController'; import { usePlatformEntryBootstrap } from './usePlatformEntryBootstrap'; @@ -2158,7 +2163,10 @@ export function PlatformEntryFlowShellImpl({ const recommendRuntimeEntries = useMemo( () => { const entryMap = new Map(); - [...featuredGalleryEntries, ...latestGalleryEntries].forEach((entry) => { + filterGeneralPublicWorks([ + ...featuredGalleryEntries, + ...latestGalleryEntries, + ]).forEach((entry) => { entryMap.set(getPlatformPublicGalleryEntryKey(entry), entry); }); return Array.from(entryMap.values()); @@ -5263,6 +5271,13 @@ export function PlatformEntryFlowShellImpl({ const openPublicWorkDetail = useCallback( (entry: PlatformPublicGalleryCard) => { + if (!canExposePublicWork(entry)) { + setSelectedPublicWorkDetail(null); + setPublicWorkDetailError(EDUTAINMENT_HIDDEN_MESSAGE); + setSelectionStage('platform'); + return; + } + setSelectedPublicWorkDetail(entry); setPublicWorkDetailError(null); setSelectionStage('work-detail'); @@ -5490,6 +5505,13 @@ export function PlatformEntryFlowShellImpl({ const openRpgPublicWorkDetail = useCallback( async (entry: CustomWorldGalleryCard) => { + if (!canExposePublicWork(entry)) { + setSelectedPublicWorkDetail(null); + setPublicWorkDetailError(EDUTAINMENT_HIDDEN_MESSAGE); + setSelectionStage('platform'); + return; + } + setIsPublicWorkDetailBusy(true); setPublicWorkDetailError(null); clearSelectedPublicWorkAuthor(); @@ -5501,6 +5523,14 @@ export function PlatformEntryFlowShellImpl({ await detailNavigation.loadGalleryDetailEntry(entry); setSelectedDetailEntry(detailEntry); const detailCard = mapRpgGalleryCardToPublicWorkDetail(detailEntry); + if (!canExposePublicWork(detailCard)) { + setSelectedDetailEntry(null); + setSelectedPublicWorkDetail(null); + setPublicWorkDetailError(EDUTAINMENT_HIDDEN_MESSAGE); + setSelectionStage('platform'); + return; + } + setSelectedPublicWorkDetail(detailCard); if (detailEntry.publicWorkCode?.trim()) { pushAppHistoryPath( @@ -5539,9 +5569,17 @@ export function PlatformEntryFlowShellImpl({ try { const { item } = await getPuzzleGalleryDetail(profileId); + const detailEntry = mapPuzzleWorkToPublicWorkDetail(item); + if (!canExposePublicWork(detailEntry)) { + setSelectedPuzzleDetail(null); + setPublicWorkDetailError(EDUTAINMENT_HIDDEN_MESSAGE); + setSelectionStage('platform'); + return; + } + setSelectedPuzzleDetail(item); setPuzzleDetailReturnTarget(returnTarget); - openPublicWorkDetail(mapPuzzleWorkToPublicWorkDetail(item)); + openPublicWorkDetail(detailEntry); } catch (error) { if (isMissingPuzzleWorkError(error)) { setSelectedPuzzleDetail(null); @@ -6577,11 +6615,14 @@ export function PlatformEntryFlowShellImpl({ match3dError, match3dFlow, match3dRun, + platformBootstrap.platformTab, platformThemeClass, puzzleError, puzzleRun, recommendRuntimeEntries, remodelCurrentPuzzleRuntimeWork, + resolveMatch3DErrorMessage, + resolveSquareHoleErrorMessage, reportBigFishObservedPlayTime, restartBigFishRun, selectedPuzzleDetail, @@ -6930,6 +6971,10 @@ export function PlatformEntryFlowShellImpl({ remixCount: entry.remixCount ?? 0, likeCount: entry.likeCount ?? 0, } satisfies CustomWorldGalleryCard; + if (!canExposePublicWork(card)) { + throw new Error(EDUTAINMENT_HIDDEN_MESSAGE); + } + setSelectedDetailEntry(entry); openPublicWorkDetail(card); }; @@ -6938,9 +6983,12 @@ export function PlatformEntryFlowShellImpl({ puzzleGalleryEntries.length > 0 ? puzzleGalleryEntries : await refreshPuzzleGallery(); - const matchedEntry = entries.find((entry) => - isSamePuzzlePublicWorkCode(normalizedKeyword, entry.profileId), - ); + const matchedEntry = entries + .map(mapPuzzleWorkToPublicWorkDetail) + .filter(canExposePublicWork) + .find((entry) => + isSamePuzzlePublicWorkCode(normalizedKeyword, entry.profileId), + ); if (!matchedEntry) { throw new Error('未找到拼图作品。'); @@ -6955,9 +7003,13 @@ export function PlatformEntryFlowShellImpl({ bigFishGalleryEntries.length > 0 ? bigFishGalleryEntries : await refreshBigFishGallery(); - const matchedEntry = entries.find((entry) => - isSameBigFishPublicWorkCode(normalizedKeyword, entry.sourceSessionId), - ); + const matchedEntry = entries.find((entry) => { + const detailEntry = mapBigFishWorkToPublicWorkDetail(entry); + return ( + canExposePublicWork(detailEntry) && + isSameBigFishPublicWorkCode(normalizedKeyword, entry.sourceSessionId) + ); + }); if (!matchedEntry) { throw new Error('未找到大鱼吃小鱼作品。'); @@ -6970,9 +7022,13 @@ export function PlatformEntryFlowShellImpl({ match3dGalleryEntries.length > 0 ? match3dGalleryEntries : await refreshMatch3DGallery(); - const matchedEntry = entries.find((entry) => - isSameMatch3DPublicWorkCode(normalizedKeyword, entry.profileId), - ); + const matchedEntry = entries.find((entry) => { + const detailEntry = mapMatch3DWorkToPublicWorkDetail(entry); + return ( + canExposePublicWork(detailEntry) && + isSameMatch3DPublicWorkCode(normalizedKeyword, entry.profileId) + ); + }); if (!matchedEntry) { throw new Error('未找到抓大鹅作品。'); @@ -6985,9 +7041,13 @@ export function PlatformEntryFlowShellImpl({ squareHoleGalleryEntries.length > 0 ? squareHoleGalleryEntries : await refreshSquareHoleGallery(); - const matchedEntry = entries.find((entry) => - isSameSquareHolePublicWorkCode(normalizedKeyword, entry.profileId), - ); + const matchedEntry = entries.find((entry) => { + const detailEntry = mapSquareHoleWorkToPublicWorkDetail(entry); + return ( + canExposePublicWork(detailEntry) && + isSameSquareHolePublicWorkCode(normalizedKeyword, entry.profileId) + ); + }); if (!matchedEntry) { throw new Error('未找到方洞挑战作品。'); @@ -7000,9 +7060,13 @@ export function PlatformEntryFlowShellImpl({ visualNovelGalleryEntries.length > 0 ? visualNovelGalleryEntries : await refreshVisualNovelGallery(); - const matchedEntry = entries.find((entry) => - isSameVisualNovelPublicWorkCode(normalizedKeyword, entry.profileId), - ); + const matchedEntry = entries.find((entry) => { + const detailEntry = mapVisualNovelWorkToPublicWorkDetail(entry); + return ( + canExposePublicWork(detailEntry) && + isSameVisualNovelPublicWorkCode(normalizedKeyword, entry.profileId) + ); + }); if (!matchedEntry) { throw new Error('未找到视觉小说作品。'); diff --git a/src/components/platform-entry/platformEdutainmentVisibility.test.ts b/src/components/platform-entry/platformEdutainmentVisibility.test.ts new file mode 100644 index 00000000..df71edde --- /dev/null +++ b/src/components/platform-entry/platformEdutainmentVisibility.test.ts @@ -0,0 +1,59 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; + +import type { PlatformPublicGalleryCard } from '../rpg-entry/rpgEntryWorldPresentation'; +import { + canExposePublicWork, + filterEdutainmentPublicWorks, + filterGeneralPublicWorks, + isEdutainmentEntryEnabled, + isEdutainmentPublicWork, +} from './platformEdutainmentVisibility'; + +function buildPuzzleCard(themeTags: string[]): PlatformPublicGalleryCard { + return { + sourceType: 'puzzle', + workId: 'puzzle-work-education-demo', + profileId: 'puzzle-profile-education-demo', + publicWorkCode: 'PZ-EDUDEMO', + ownerUserId: 'user-education', + authorDisplayName: '动作 Demo 作者', + worldName: '儿童动作热身 Demo', + subtitle: '拼图关卡', + summaryText: '本地动作 Demo。', + coverImageSrc: null, + themeTags, + visibility: 'published', + publishedAt: '2026-05-09T10:00:00.000Z', + updatedAt: '2026-05-09T10:00:00.000Z', + }; +} + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe('platformEdutainmentVisibility', () => { + test('matches only the exact edutainment tag from full work tags', () => { + const exact = buildPuzzleCard(['运动', '安全', '拼图', '寓教于乐']); + const fuzzy = buildPuzzleCard(['儿童教育', '寓教于乐 ']); + + expect(isEdutainmentPublicWork(exact)).toBe(true); + expect(isEdutainmentPublicWork(fuzzy)).toBe(false); + expect(filterEdutainmentPublicWorks([exact, fuzzy])).toEqual([exact]); + expect(filterGeneralPublicWorks([exact, fuzzy])).toEqual([fuzzy]); + }); + + test('defaults to enabled and blocks exact edutainment works only when disabled', () => { + const exact = buildPuzzleCard(['寓教于乐']); + const general = buildPuzzleCard(['儿童教育']); + + expect(isEdutainmentEntryEnabled()).toBe(true); + expect(canExposePublicWork(exact)).toBe(true); + + vi.stubEnv('VITE_ENABLE_EDUTAINMENT_ENTRY', 'false'); + + expect(isEdutainmentEntryEnabled()).toBe(false); + expect(canExposePublicWork(exact)).toBe(false); + expect(canExposePublicWork(general)).toBe(true); + }); +}); diff --git a/src/components/platform-entry/platformEdutainmentVisibility.ts b/src/components/platform-entry/platformEdutainmentVisibility.ts new file mode 100644 index 00000000..f3ab5615 --- /dev/null +++ b/src/components/platform-entry/platformEdutainmentVisibility.ts @@ -0,0 +1,58 @@ +import type { PlatformBrowseHistoryEntry } from '../../../packages/shared/src/contracts/runtime'; +import type { PlatformPublicGalleryCard } from '../rpg-entry/rpgEntryWorldPresentation'; + +export const EDUTAINMENT_WORK_TAG = '寓教于乐'; +export const EDUTAINMENT_HIDDEN_MESSAGE = '该内容暂不可见。'; + +const EDUTAINMENT_ENTRY_DISABLED_VALUES = new Set(['false', '0', 'off', 'no']); + +// 中文注释:入口默认开启;只有明确写入关闭值时才完全隐藏寓教于乐内容。 +export function isEdutainmentEntryEnabled( + rawValue = import.meta.env.VITE_ENABLE_EDUTAINMENT_ENTRY, +) { + const normalized = (rawValue ?? '').trim().toLowerCase(); + return !EDUTAINMENT_ENTRY_DISABLED_VALUES.has(normalized); +} + +function getPlatformPublicWorkTags(entry: PlatformPublicGalleryCard) { + if ('themeTags' in entry) { + return entry.themeTags; + } + + return []; +} + +export function isEdutainmentPublicWork(entry: PlatformPublicGalleryCard) { + return getPlatformPublicWorkTags(entry).some( + (tag) => tag === EDUTAINMENT_WORK_TAG, + ); +} + +export function canExposePublicWork(entry: PlatformPublicGalleryCard) { + return isEdutainmentEntryEnabled() || !isEdutainmentPublicWork(entry); +} + +export function filterGeneralPublicWorks(entries: PlatformPublicGalleryCard[]) { + return entries.filter((entry) => !isEdutainmentPublicWork(entry)); +} + +export function filterEdutainmentPublicWorks( + entries: PlatformPublicGalleryCard[], +) { + return entries.filter(isEdutainmentPublicWork); +} + +export function filterVisiblePublicWorks(entries: PlatformPublicGalleryCard[]) { + return entries.filter(canExposePublicWork); +} + +export function findPublicWorkForHistoryEntry( + historyEntry: PlatformBrowseHistoryEntry, + entries: PlatformPublicGalleryCard[], +) { + return entries.find( + (entry) => + entry.ownerUserId === historyEntry.ownerUserId && + entry.profileId === historyEntry.profileId, + ); +} diff --git a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx index 2edf0d55..eb353f41 100644 --- a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx +++ b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx @@ -3,7 +3,7 @@ import { render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { useState } from 'react'; -import { beforeEach, expect, test, vi } from 'vitest'; +import { afterEach, beforeEach, expect, test, vi } from 'vitest'; import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary'; import type { CreativeAgentSessionSnapshot } from '../../../packages/shared/src/contracts/creativeAgent'; @@ -2332,6 +2332,10 @@ beforeEach(() => { ); }); +afterEach(() => { + vi.unstubAllEnvs(); +}); + test('create tab shows template tabs and embeds puzzle form by default', async () => { const user = userEvent.setup(); @@ -2364,7 +2368,7 @@ test('create tab shows template tabs and embeds puzzle form by default', async ( expect(screen.queryByRole('button', { name: /智能创作/u })).toBeNull(); expect(screen.queryByPlaceholderText('问一问百梦')).toBeNull(); expect(screen.queryByRole('button', { name: /角色扮演/u })).toBeNull(); - expect(screen.queryByRole('tab', { name: /抓大鹅/u })).toBeNull(); + expect(screen.getByRole('tab', { name: /抓大鹅/u })).toBeTruthy(); expect(createRpgCreationSession).not.toHaveBeenCalled(); expect(match3dCreationClient.createSession).not.toHaveBeenCalled(); expect(createPuzzleAgentSession).not.toHaveBeenCalled(); @@ -2879,6 +2883,50 @@ test('logged out public detail gates big fish start before local runtime', async expect(recordBigFishPlay).not.toHaveBeenCalled(); }); +test('public code search blocks edutainment work when entry switch is disabled', async () => { + vi.stubEnv('VITE_ENABLE_EDUTAINMENT_ENTRY', 'false'); + const user = userEvent.setup(); + const edutainmentPuzzleWork: PuzzleWorkSummary = { + workId: 'puzzle-work-edutainment-1', + profileId: 'puzzle-profile-edutainment-1', + ownerUserId: 'user-2', + sourceSessionId: 'puzzle-session-edutainment-1', + authorDisplayName: '动作 Demo 作者', + levelName: '儿童动作热身 Demo', + summary: '寓教于乐专属动作 Demo。', + themeTags: ['运动', '安全', '拼图', '寓教于乐'], + coverImageSrc: null, + coverAssetId: null, + publicationStatus: 'published', + updatedAt: '2026-05-09T10:00:00.000Z', + publishedAt: '2026-05-09T10:00:00.000Z', + playCount: 3, + remixCount: 0, + likeCount: 0, + publishReady: true, + }; + + vi.mocked(listPuzzleGallery).mockResolvedValue({ + items: [edutainmentPuzzleWork], + }); + vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({ + item: edutainmentPuzzleWork, + }); + + render(); + await openDiscoverHub(user); + + const searchInput = await screen.findByPlaceholderText( + '搜索作品号、名称、作者、描述', + ); + await user.type(searchInput, 'PZ-TMENT1'); + await user.click(screen.getByRole('button', { name: '搜索' })); + + expect(await screen.findByText('未找到结果')).toBeTruthy(); + expect(screen.queryByText('儿童动作热身 Demo')).toBeNull(); + expect(getPuzzleGalleryDetail).not.toHaveBeenCalled(); +}); + test('creation hub clears all private work shelves immediately after logout state', async () => { const user = userEvent.setup(); const loggedInAuth = createAuthValue(); @@ -3027,7 +3075,6 @@ test('published puzzle works appear on home and mobile game category channel', a }); test('home recommendation starts embedded puzzle without global auth reset on local failure', async () => { - const user = userEvent.setup(); const publishedPuzzleWork = { workId: 'puzzle-work-public-1', profileId: 'puzzle-profile-public-1', diff --git a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx index c4c7d118..4b2e9323 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx @@ -25,7 +25,10 @@ import { RpgEntryHomeView, type RpgEntryHomeViewProps, } from './RpgEntryHomeView'; -import type { PlatformPublicGalleryCard } from './rpgEntryWorldPresentation'; +import type { + PlatformPublicGalleryCard, + PlatformPuzzleGalleryCard, +} from './rpgEntryWorldPresentation'; const { mockBuildReferralCenter, @@ -425,6 +428,23 @@ const longTextRankEntry = { updatedAt: '2026-04-29T10:00:00.000Z', } satisfies PlatformPublicGalleryCard; +function buildTaggedPuzzleEntry( + id: string, + worldName: string, + themeTags: string[], + overrides: Partial = {}, +) { + return { + ...puzzlePublicEntry, + workId: `puzzle-work-${id}`, + profileId: `puzzle-profile-${id}`, + publicWorkCode: `PZ-${id.toUpperCase()}`, + worldName, + themeTags, + ...overrides, + } satisfies PlatformPuzzleGalleryCard; +} + function mockDesktopLayout() { Object.defineProperty(window, 'matchMedia', { configurable: true, @@ -688,6 +708,7 @@ function renderStatefulLoggedOutHomeView( afterEach(() => { vi.useRealTimers(); vi.clearAllMocks(); + vi.unstubAllEnvs(); mockGetRpgProfileReferralInviteCenter.mockResolvedValue( mockBuildReferralCenter(), ); @@ -1097,6 +1118,108 @@ test('discover search fuzzy matches public work id, name, author and description expect(onOpenGalleryDetail).toHaveBeenCalledWith(entries[1]); }); +test('mobile discover keeps edutainment works in the last dedicated channel only', async () => { + const user = userEvent.setup(); + const onSearchPublicCode = vi.fn(); + const generalEntry = buildTaggedPuzzleEntry('normal01', '普通拼图作品', [ + '儿童教育', + ]); + const edutainmentEntry = buildTaggedPuzzleEntry( + 'edu001', + '儿童动作热身 Demo', + ['运动', '安全', '拼图', '寓教于乐'], + { + playCount: 99, + remixCount: 30, + likeCount: 50, + recentPlayCount7d: 88, + publishedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + summaryText: '寓教于乐专属内容', + }, + ); + + renderStatefulLoggedOutHomeView({ + latestEntries: [edutainmentEntry, generalEntry], + onSearchPublicCode, + }); + await user.click(screen.getByRole('button', { name: '发现' })); + const discoverPanel = document.getElementById('platform-tab-panel-category'); + if (!discoverPanel) { + throw new Error('缺少发现面板'); + } + + const channels = Array.from( + discoverPanel.querySelectorAll('.platform-mobile-home-channel'), + ).map((button) => button.textContent); + expect(channels).toEqual(['推荐', '今日', '分类', '排行', '寓教于乐']); + expect(within(discoverPanel).getByText('普通拼图作品')).toBeTruthy(); + expect(within(discoverPanel).queryByText('儿童动作热身 Demo')).toBeNull(); + + await user.click(screen.getByRole('button', { name: '今日' })); + expect(within(discoverPanel).queryByText('儿童动作热身 Demo')).toBeNull(); + + await user.click(screen.getByRole('button', { name: '分类' })); + expect(screen.getByRole('button', { name: '儿童教育' })).toBeTruthy(); + expect(screen.queryByRole('button', { name: '寓教于乐' })).toBeTruthy(); + expect(within(discoverPanel).queryByText('儿童动作热身 Demo')).toBeNull(); + + await user.click(screen.getByRole('button', { name: '排行' })); + expect(within(discoverPanel).queryByText('儿童动作热身 Demo')).toBeNull(); + + await user.click(screen.getByRole('button', { name: '寓教于乐' })); + expect( + within(discoverPanel).getByRole('button', { + name: /儿童动作热身 Demo/u, + }), + ).toBeTruthy(); + expect(within(discoverPanel).queryByText('普通拼图作品')).toBeNull(); + + const searchInput = screen.getByPlaceholderText('搜索作品号、名称、作者、描述'); + await user.type(searchInput, '儿童动作热身{enter}'); + expect(await within(discoverPanel).findByText('搜索结果')).toBeTruthy(); + expect(within(discoverPanel).queryByText('儿童动作热身 Demo')).toBeNull(); + expect(onSearchPublicCode).not.toHaveBeenCalled(); +}); + +test('mobile discover hides edutainment channel and work when switch is disabled', async () => { + vi.stubEnv('VITE_ENABLE_EDUTAINMENT_ENTRY', 'false'); + const user = userEvent.setup(); + const onSearchPublicCode = vi.fn(); + const edutainmentEntry = buildTaggedPuzzleEntry( + 'eduoff1', + '关闭后隐藏的热身 Demo', + ['寓教于乐'], + { + summaryText: '关闭后不可见', + publishedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + ); + + renderStatefulLoggedOutHomeView({ + latestEntries: [edutainmentEntry], + onSearchPublicCode, + }); + await user.click(screen.getByRole('button', { name: '发现' })); + const discoverPanel = document.getElementById('platform-tab-panel-category'); + if (!discoverPanel) { + throw new Error('缺少发现面板'); + } + + const channels = Array.from( + discoverPanel.querySelectorAll('.platform-mobile-home-channel'), + ).map((button) => button.textContent); + expect(channels).toEqual(['推荐', '今日', '分类', '排行']); + expect(within(discoverPanel).queryByText('关闭后隐藏的热身 Demo')).toBeNull(); + + const searchInput = screen.getByPlaceholderText('搜索作品号、名称、作者、描述'); + await user.type(searchInput, 'PZ-EDUOFF1{enter}'); + expect(await within(discoverPanel).findByText('搜索结果')).toBeTruthy(); + expect(within(discoverPanel).queryByText('关闭后隐藏的热身 Demo')).toBeNull(); + expect(onSearchPublicCode).not.toHaveBeenCalled(); +}); + test('discover search keeps public code fallback when local works do not match', async () => { const user = userEvent.setup(); const onSearchPublicCode = vi.fn(); diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 92f55331..93e0df70 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -71,6 +71,14 @@ import { } from '../../services/rpg-entry/rpgProfileClient'; import type { CustomWorldProfile } from '../../types'; import { useAuthUi } from '../auth/AuthUiContext'; +import { + canExposePublicWork, + EDUTAINMENT_WORK_TAG, + filterEdutainmentPublicWorks, + filterGeneralPublicWorks, + findPublicWorkForHistoryEntry, + isEdutainmentEntryEnabled, +} from '../platform-entry/platformEdutainmentVisibility'; import { ResolvedAssetImage } from '../ResolvedAssetImage'; import { RpgEntryBrandLogo } from './RpgEntryBrandLogo'; import { @@ -176,7 +184,12 @@ const RECOMMEND_ENTRY_COMMIT_ANIMATION_MS = 180; const RECOMMEND_ENTRY_DRAG_LIMIT_PX = 160; type ProfilePopupPanel = 'invite' | 'redeem' | 'community'; -type DiscoverChannel = 'recommend' | 'today' | 'category' | 'ranking'; +type DiscoverChannel = + | 'recommend' + | 'today' + | 'category' + | 'ranking' + | 'edutainment'; type PlatformRankingTab = 'hot' | 'remix' | 'new' | 'like'; const COMMUNITY_QR_CODES = [ @@ -201,6 +214,10 @@ const DISCOVER_CHANNELS: Array<{ { id: 'category', label: '分类' }, { id: 'ranking', label: '排行' }, ]; +const EDUTAINMENT_DISCOVER_CHANNEL = { + id: 'edutainment', + label: EDUTAINMENT_WORK_TAG, +} as const; const PLATFORM_RANKING_TABS: Array<{ id: PlatformRankingTab; @@ -1246,9 +1263,11 @@ function buildPublicCategoryGroups( ) { const publicEntryMap = new Map(); - [...featuredEntries, ...latestEntries].forEach((entry) => { - publicEntryMap.set(buildPublicGalleryCardKey(entry), entry); - }); + filterGeneralPublicWorks([...featuredEntries, ...latestEntries]).forEach( + (entry) => { + publicEntryMap.set(buildPublicGalleryCardKey(entry), entry); + }, + ); const categoryMap = new Map(); Array.from(publicEntryMap.values()).forEach((entry) => { @@ -1279,6 +1298,21 @@ function getPlatformPublicEntries( ) { const entryMap = new Map(); + filterGeneralPublicWorks([...featuredEntries, ...latestEntries]).forEach( + (entry) => { + entryMap.set(buildPublicGalleryCardKey(entry), entry); + }, + ); + + return Array.from(entryMap.values()); +} + +function getAllPlatformPublicEntries( + featuredEntries: PlatformPublicGalleryCard[], + latestEntries: PlatformPublicGalleryCard[], +) { + const entryMap = new Map(); + [...featuredEntries, ...latestEntries].forEach((entry) => { entryMap.set(buildPublicGalleryCardKey(entry), entry); }); @@ -3079,21 +3113,62 @@ export function RpgEntryHomeView({ const [avatarError, setAvatarError] = useState(null); const [isSavingAvatar, setIsSavingAvatar] = useState(false); const isAuthenticated = Boolean(authUi?.user); + const edutainmentEntryEnabled = isEdutainmentEntryEnabled(); const isDesktopLayout = usePlatformDesktopLayout(); const openRecommendGalleryDetail = onOpenRecommendGalleryDetail ?? onOpenGalleryDetail; - const featuredShelf = useMemo( - () => featuredEntries.slice(0, 6), + const generalFeaturedEntries = useMemo( + () => filterGeneralPublicWorks(featuredEntries), [featuredEntries], ); - const categoryGroups = useMemo( - () => buildPublicCategoryGroups(featuredEntries, latestEntries), + const featuredShelf = useMemo( + () => generalFeaturedEntries.slice(0, 6), + [generalFeaturedEntries], + ); + const generalLatestEntries = useMemo( + () => filterGeneralPublicWorks(latestEntries), + [latestEntries], + ); + const allEdutainmentEntries = useMemo( + () => filterEdutainmentPublicWorks([...featuredEntries, ...latestEntries]), [featuredEntries, latestEntries], ); + const edutainmentEntries = useMemo( + () => (edutainmentEntryEnabled ? allEdutainmentEntries : []), + [allEdutainmentEntries, edutainmentEntryEnabled], + ); + const visibleDiscoverChannels = useMemo( + () => + edutainmentEntryEnabled + ? [...DISCOVER_CHANNELS, EDUTAINMENT_DISCOVER_CHANNEL] + : DISCOVER_CHANNELS, + [edutainmentEntryEnabled], + ); + const categoryGroups = useMemo( + () => + buildPublicCategoryGroups(generalFeaturedEntries, generalLatestEntries), + [generalFeaturedEntries, generalLatestEntries], + ); const publicEntries = useMemo( - () => getPlatformPublicEntries(featuredEntries, latestEntries), + () => + getPlatformPublicEntries(generalFeaturedEntries, generalLatestEntries), + [generalFeaturedEntries, generalLatestEntries], + ); + const allPublicEntries = useMemo( + () => getAllPlatformPublicEntries(featuredEntries, latestEntries), [featuredEntries, latestEntries], ); + const visibleHistoryEntries = useMemo( + () => + historyEntries.filter((entry) => { + const matchedPublicWork = findPublicWorkForHistoryEntry( + entry, + allPublicEntries, + ); + return !matchedPublicWork || canExposePublicWork(matchedPublicWork); + }), + [allPublicEntries, historyEntries], + ); const workSearchResults = useMemo( () => filterPlatformWorkSearchResults(publicEntries, activeWorkSearchKeyword), @@ -3188,6 +3263,12 @@ export function RpgEntryHomeView({ } }, [activeTab, isAuthenticated, onTabChange, visibleTabs]); + useEffect(() => { + if (!visibleDiscoverChannels.some((channel) => channel.id === discoverChannel)) { + setDiscoverChannel('recommend'); + } + }, [discoverChannel, visibleDiscoverChannels]); + useEffect(() => { setVisitedTabs((currentTabs) => { if (currentTabs.has(activeTab)) { @@ -3670,6 +3751,10 @@ export function RpgEntryHomeView({ publicEntries, trimmedKeyword, ); + const hiddenEdutainmentMatches = filterPlatformWorkSearchResults( + allEdutainmentEntries, + trimmedKeyword, + ); if ( matchedEntries.length > 0 && isExactPublicWorkCodeSearch(matchedEntries, trimmedKeyword) && @@ -3686,6 +3771,11 @@ export function RpgEntryHomeView({ return; } + if (hiddenEdutainmentMatches.length > 0) { + setActiveWorkSearchKeyword(trimmedKeyword); + return; + } + setActiveWorkSearchKeyword(''); if (!onSearchPublicCode || isSearchingPublicCode) { return; @@ -3700,50 +3790,58 @@ export function RpgEntryHomeView({ submitWorkSearch(mobileSearchKeyword); }; const desktopHeroEntry = - featuredShelf[0] ?? latestEntries[0] ?? myEntries[0] ?? null; + featuredShelf[0] ?? generalLatestEntries[0] ?? myEntries[0] ?? null; const desktopHeroCover = desktopHeroEntry ? resolvePlatformWorldCoverImage(desktopHeroEntry) : null; const desktopHeroStripEntries = ( - featuredShelf.length > 0 ? featuredShelf : latestEntries + featuredShelf.length > 0 ? featuredShelf : generalLatestEntries ).slice(0, 5); // 网页端保留原有宽屏布局,只把模块数据同步到移动端首页频道语义。 const desktopRecommendEntries = useMemo(() => { const entryMap = new Map(); - [...featuredShelf, ...latestEntries].forEach((entry) => { + [...featuredShelf, ...generalLatestEntries].forEach((entry) => { entryMap.set(buildPublicGalleryCardKey(entry), entry); }); return Array.from(entryMap.values()); - }, [featuredShelf, latestEntries]); + }, [featuredShelf, generalLatestEntries]); const desktopTodayEntries = useMemo( - () => filterTodayPublishedEntries(latestEntries), - [latestEntries], + () => filterTodayPublishedEntries(generalLatestEntries), + [generalLatestEntries], ); const desktopFeaturedGrid = desktopRecommendEntries.slice(0, 4); const desktopCategoryGrid = activeCategoryEntries.slice(0, 6); const desktopLibraryPreview = myEntries.slice(0, 2); const recommendedFeedEntries = useMemo(() => { const entryMap = new Map(); - [...featuredShelf, ...latestEntries].forEach((entry) => { + [...featuredShelf, ...generalLatestEntries].forEach((entry) => { entryMap.set(buildPublicGalleryCardKey(entry), entry); }); return Array.from(entryMap.values()); - }, [featuredShelf, latestEntries]); + }, [featuredShelf, generalLatestEntries]); const discoverFeedEntries = useMemo(() => { const entryMap = new Map(); const sourceEntries = discoverChannel === 'recommend' ? recommendedFeedEntries - : filterTodayPublishedEntries(latestEntries); + : filterTodayPublishedEntries(generalLatestEntries); sourceEntries.forEach((entry) => { entryMap.set(buildPublicGalleryCardKey(entry), entry); }); return Array.from(entryMap.values()); - }, [discoverChannel, latestEntries, recommendedFeedEntries]); + }, [discoverChannel, generalLatestEntries, recommendedFeedEntries]); + const edutainmentFeedEntries = useMemo(() => { + const entryMap = new Map(); + edutainmentEntries.forEach((entry) => { + entryMap.set(buildPublicGalleryCardKey(entry), entry); + }); + + return Array.from(entryMap.values()); + }, [edutainmentEntries]); const mobileFeedCarouselEnabled = !isDesktopLayout && activeTab === 'category' && @@ -4022,7 +4120,7 @@ export function RpgEntryHomeView({ isAuthenticated, openRecommendGalleryDetail, ]); - const leadPublicEntry = featuredShelf[0] ?? latestEntries[0] ?? null; + const leadPublicEntry = featuredShelf[0] ?? generalLatestEntries[0] ?? null; const openLeadPublicEntry = () => { if (leadPublicEntry) { openRecommendGalleryDetail(leadPublicEntry); @@ -4217,7 +4315,7 @@ export function RpgEntryHomeView({ ) : ( <>
- {DISCOVER_CHANNELS.map((channel) => { + {visibleDiscoverChannels.map((channel) => { const active = discoverChannel === channel.id; return ( + ); + })} +
+ + {platformError ? ( +
+ {platformError} +
+ ) : null} + + {discoverChannel === 'ranking' ? ( + mobileRankingPanel + ) : discoverChannel === 'category' ? ( +
+ + {isLoadingPlatform ? ( + + ) : activeCategoryGroup && desktopCategoryGrid.length > 0 ? ( + <> +
+ {categoryGroups.map((group) => { + const active = group.tag === activeCategoryGroup.tag; + + return ( + + ); + })} +
+
+ {desktopCategoryGrid.map((entry) => ( + openRecommendGalleryDetail(entry)} + className="w-full min-w-0" + authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)} + /> + ))} +
+ + ) : ( + + )} +
+ ) : discoverChannel === 'edutainment' ? ( +
+ + {isLoadingPlatform ? ( + + ) : edutainmentFeedEntries.length > 0 ? ( +
+ {edutainmentFeedEntries.map((entry) => ( + openRecommendGalleryDetail(entry)} + className="w-full min-w-0" + authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)} + /> + ))} +
+ ) : ( + + )} +
+ ) : ( +
+ + {isLoadingPlatform ? ( + + ) : discoverFeedEntries.length > 0 ? ( +
+ {discoverFeedEntries.map((entry) => ( + openRecommendGalleryDetail(entry)} + className="w-full min-w-0" + authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)} + /> + ))} +
+ ) : ( + + )} +
+ )} + + ); const categoryContent: ReactNode = isDesktopLayout ? ( -
{mobileRankingPanel}
+ desktopDiscoverContent ) : ( mobileDiscoverContent ); @@ -4773,7 +5010,7 @@ export function RpgEntryHomeView({
0 || historyEntries.length > 0 ? '2xl:grid-cols-[minmax(0,1.2fr)_minmax(22rem,0.8fr)]' : ''}`} + className={`grid gap-5 ${desktopLibraryPreview.length > 0 || visibleHistoryEntries.length > 0 ? '2xl:grid-cols-[minmax(0,1.2fr)_minmax(22rem,0.8fr)]' : ''}`} >
@@ -4796,7 +5033,7 @@ export function RpgEntryHomeView({ )}
- {desktopLibraryPreview.length > 0 || historyEntries.length > 0 ? ( + {desktopLibraryPreview.length > 0 || visibleHistoryEntries.length > 0 ? (
0 ? '最近作品' : '最近浏览'} @@ -4841,7 +5078,7 @@ export function RpgEntryHomeView({
) : (
- {historyEntries.slice(0, 2).map((entry) => { + {visibleHistoryEntries.slice(0, 2).map((entry) => { const displayName = formatPlatformWorkDisplayName( entry.worldName, ); diff --git a/src/index.css b/src/index.css index 5f9eccee..fffb999c 100644 --- a/src/index.css +++ b/src/index.css @@ -5623,6 +5623,473 @@ button { color: rgba(255, 255, 255, 0.9) !important; } +.child-motion-demo { + --child-motion-bg: #07151c; + --child-motion-panel: rgba(6, 24, 30, 0.64); + --child-motion-panel-border: rgba(178, 239, 220, 0.25); + --child-motion-text: #eefcf7; + --child-motion-soft: rgba(238, 252, 247, 0.7); + --child-motion-green: #5ff08f; + --child-motion-sky: #8fd8ff; + display: grid; + width: 100%; + min-width: 0; + height: 100vh; + min-height: 100vh; + place-items: center; + overflow: hidden; + background: + radial-gradient(circle at 18% 14%, rgba(143, 216, 255, 0.24), transparent 32%), + radial-gradient(circle at 82% 22%, rgba(95, 240, 143, 0.18), transparent 30%), + linear-gradient(180deg, #092433 0%, var(--child-motion-bg) 54%, #0a1f18 100%); + color: var(--child-motion-text); + font-family: Inter, ui-sans-serif, system-ui, sans-serif; +} + +@supports (height: 100dvh) { + .child-motion-demo { + height: 100dvh; + min-height: 100dvh; + } +} + +.child-motion-stage { + position: relative; + width: min(100vw, calc(100vh * 16 / 9)); + height: min(100vh, calc(100vw * 9 / 16)); + overflow: hidden; + background: + linear-gradient(180deg, rgba(16, 64, 86, 0.86), rgba(9, 42, 39, 0.9)), + var(--child-motion-bg); + box-shadow: 0 30px 100px rgba(0, 0, 0, 0.38); + touch-action: none; + user-select: none; +} + +@supports (height: 100dvh) { + .child-motion-stage { + width: min(100vw, calc(100dvh * 16 / 9)); + height: min(100dvh, calc(100vw * 9 / 16)); + } +} + +.child-motion-camera-layer { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + background: + radial-gradient(circle at 50% 33%, rgba(255, 255, 255, 0.12), transparent 28%), + linear-gradient(110deg, rgba(255, 255, 255, 0.06) 0 12%, transparent 12% 20%, rgba(255, 255, 255, 0.04) 20% 31%, transparent 31% 100%); + filter: blur(7px) saturate(0.8); + opacity: 0.62; + transform: scale(1.05); +} + +.child-motion-camera-state { + position: absolute; + top: 18%; + left: 50%; + z-index: 7; + transform: translateX(-50%); + border: 1px solid rgba(238, 252, 247, 0.2); + border-radius: 999px; + background: rgba(6, 24, 30, 0.52); + color: rgba(238, 252, 247, 0.82); + padding: 0.45rem 0.9rem; + font-size: clamp(0.68rem, 1.35vw, 0.84rem); + font-weight: 800; + backdrop-filter: blur(12px); +} + +.child-motion-floor { + position: absolute; + right: -8%; + bottom: -19%; + left: -8%; + height: 47%; + border-radius: 50% 50% 0 0; + background: + radial-gradient(ellipse at 50% 8%, rgba(190, 255, 220, 0.22), transparent 36%), + linear-gradient(180deg, rgba(24, 86, 67, 0.84), rgba(7, 43, 34, 0.96)); + box-shadow: inset 0 22px 70px rgba(255, 255, 255, 0.07); +} + +.child-motion-hud { + position: absolute; + z-index: 8; + display: flex; + align-items: center; + gap: clamp(0.6rem, 1.8vw, 1rem); + border: 1px solid var(--child-motion-panel-border); + border-radius: clamp(0.75rem, 2vw, 1.25rem); + background: var(--child-motion-panel); + box-shadow: 0 18px 48px rgba(0, 0, 0, 0.2); + backdrop-filter: blur(14px); +} + +.child-motion-hud--top { + top: 4.2%; + left: 50%; + width: min(72%, 48rem); + min-height: clamp(4.2rem, 11vh, 6.25rem); + transform: translateX(-50%); + padding: clamp(0.65rem, 1.8vw, 1rem) clamp(0.8rem, 2.2vw, 1.25rem); +} + +.child-motion-hud h1 { + margin: 0; + color: var(--child-motion-text); + font-size: clamp(1.2rem, 3.2vw, 2rem); + font-weight: 900; + line-height: 1.08; +} + +.child-motion-hud p { + margin: 0.28rem 0 0; + color: var(--child-motion-soft); + font-size: clamp(0.72rem, 1.45vw, 0.98rem); + font-weight: 700; + line-height: 1.45; +} + +.child-motion-step-count, +.child-motion-progress { + display: inline-flex; + width: clamp(2.7rem, 7vw, 4rem); + height: clamp(2.7rem, 7vw, 4rem); + flex: 0 0 auto; + align-items: center; + justify-content: center; + border: 1px solid rgba(238, 252, 247, 0.2); + border-radius: 999px; + background: rgba(255, 255, 255, 0.1); + color: var(--child-motion-text); + font-size: clamp(0.72rem, 1.45vw, 0.95rem); + font-weight: 900; +} + +.child-motion-ring { + position: absolute; + bottom: 20.5%; + z-index: 3; + width: clamp(5.8rem, 13vw, 9rem); + aspect-ratio: 1; + transform: translateX(-50%) rotateX(62deg); + border-radius: 999px; + background: + conic-gradient( + from -90deg, + rgba(255, 255, 255, 0.95) 0 var(--child-motion-ring-progress), + rgba(95, 240, 143, 0.18) var(--child-motion-ring-progress) 360deg + ); + box-shadow: + 0 0 28px rgba(95, 240, 143, 0.42), + inset 0 0 26px rgba(255, 255, 255, 0.18); +} + +.child-motion-ring::before { + position: absolute; + inset: 14%; + border-radius: inherit; + background: rgba(8, 44, 36, 0.94); + content: ''; +} + +.child-motion-ring__core { + position: absolute; + inset: 34%; + border-radius: 999px; + background: var(--child-motion-green); + opacity: 0.28; +} + +.child-motion-ring--active { + animation: child-motion-ring-pulse 0.78s ease-in-out infinite alternate; +} + +@keyframes child-motion-ring-pulse { + from { + filter: brightness(1); + } + + to { + filter: brightness(1.25); + } +} + +.child-motion-avatar { + position: absolute; + bottom: 24%; + z-index: 5; + width: clamp(3.4rem, 7vw, 5.6rem); + height: clamp(6rem, 13vw, 10rem); + transform: translateX(-50%); + transition: left 260ms ease, transform 220ms ease; +} + +.child-motion-avatar--jumping { + transform: translate(-50%, -14%); +} + +.child-motion-avatar__head, +.child-motion-avatar__body, +.child-motion-avatar__arm, +.child-motion-avatar__leg { + position: absolute; + display: block; + background: rgba(7, 18, 24, 0.82); + box-shadow: 0 0 24px rgba(143, 216, 255, 0.18); +} + +.child-motion-avatar__head { + top: 0; + left: 50%; + width: 34%; + aspect-ratio: 1; + transform: translateX(-50%); + border-radius: 999px; +} + +.child-motion-avatar__body { + top: 27%; + left: 50%; + width: 42%; + height: 36%; + transform: translateX(-50%); + border-radius: 999px 999px 45% 45%; +} + +.child-motion-avatar__arm { + top: 33%; + width: 15%; + height: 34%; + border-radius: 999px; +} + +.child-motion-avatar__arm--left { + left: 17%; + transform: rotate(18deg); +} + +.child-motion-avatar__arm--right { + right: 17%; + transform: rotate(-18deg); +} + +.child-motion-avatar__leg { + bottom: 0; + width: 15%; + height: 34%; + border-radius: 999px; +} + +.child-motion-avatar__leg--left { + left: 36%; + transform: rotate(7deg); +} + +.child-motion-avatar__leg--right { + right: 36%; + transform: rotate(-7deg); +} + +.child-motion-gesture-guide { + position: absolute; + inset: 20% 22% 19%; + z-index: 4; + pointer-events: none; +} + +.child-motion-gesture-guide__wave, +.child-motion-gesture-guide__jump { + position: absolute; + left: 50%; + top: 38%; + display: inline-flex; + width: clamp(4.5rem, 11vw, 8rem); + aspect-ratio: 1; + transform: translate(-50%, -50%); + align-items: center; + justify-content: center; + border: 2px solid rgba(95, 240, 143, 0.64); + border-radius: 999px; + background: rgba(95, 240, 143, 0.1); + color: var(--child-motion-text); + font-size: clamp(1rem, 2.4vw, 1.55rem); + font-weight: 900; +} + +.child-motion-gesture-guide__hand { + position: absolute; + top: 28%; + width: clamp(4rem, 9vw, 7rem); + aspect-ratio: 1; + border: 2px dashed rgba(95, 240, 143, 0.58); + border-radius: 999px; + animation: child-motion-hand-guide 1.1s ease-in-out infinite alternate; +} + +.child-motion-gesture-guide__hand--left { + left: 22%; +} + +.child-motion-gesture-guide__hand--right { + right: 22%; +} + +@keyframes child-motion-hand-guide { + from { + transform: translateY(0); + } + + to { + transform: translateY(-10%); + } +} + +.child-motion-gesture-guide__trail { + position: absolute; + width: 0.8rem; + height: 0.8rem; + transform: translate(-50%, -50%); + border-radius: 999px; + background: #b9ffd0; + box-shadow: 0 0 16px rgba(95, 240, 143, 0.56); +} + +.child-motion-floating-reward { + position: absolute; + left: 50%; + top: 34%; + z-index: 9; + transform: translateX(-50%); + color: #ffffff; + font-size: clamp(1.4rem, 4vw, 2.4rem); + font-weight: 900; + text-shadow: 0 4px 26px rgba(0, 0, 0, 0.42); + animation: child-motion-reward-rise 0.72s ease-out forwards; +} + +@keyframes child-motion-reward-rise { + from { + opacity: 0; + transform: translate(-50%, 22%); + } + + to { + opacity: 1; + transform: translate(-50%, -18%); + } +} + +.child-motion-calibration { + position: absolute; + right: 3.2%; + bottom: 4%; + z-index: 8; + display: grid; + grid-template-columns: repeat(5, minmax(0, auto)); + gap: 0.45rem; + max-width: 82%; + border: 1px solid var(--child-motion-panel-border); + border-radius: 999px; + background: var(--child-motion-panel); + padding: 0.45rem; + backdrop-filter: blur(14px); +} + +.child-motion-calibration div { + display: grid; + min-width: clamp(3.2rem, 7vw, 4.8rem); + gap: 0.08rem; + justify-items: center; + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); + padding: 0.36rem 0.55rem; +} + +.child-motion-calibration span { + color: var(--child-motion-soft); + font-size: clamp(0.55rem, 1.2vw, 0.72rem); + font-weight: 800; +} + +.child-motion-calibration strong { + color: var(--child-motion-text); + font-size: clamp(0.72rem, 1.5vw, 0.95rem); + font-weight: 900; +} + +.child-motion-start-panel { + position: absolute; + left: 50%; + top: 53%; + z-index: 10; + display: flex; + transform: translate(-50%, -50%); + align-items: center; + gap: 0.85rem; + border: 1px solid rgba(178, 239, 220, 0.32); + border-radius: 1.4rem; + background: rgba(6, 24, 30, 0.7); + padding: clamp(0.85rem, 2vw, 1.15rem); + box-shadow: 0 24px 70px rgba(0, 0, 0, 0.28); + backdrop-filter: blur(14px); +} + +.child-motion-start-panel button { + min-width: clamp(8rem, 18vw, 12rem); + min-height: clamp(3rem, 7vw, 4.2rem); + border: 0; + border-radius: 999px; + background: linear-gradient(135deg, #5ff08f, #8fd8ff); + color: #062018; + font-size: clamp(1rem, 2.5vw, 1.4rem); + font-weight: 950; + cursor: pointer; + box-shadow: 0 16px 44px rgba(95, 240, 143, 0.28); +} + +.child-motion-start-panel span { + color: var(--child-motion-text); + font-size: clamp(1rem, 2vw, 1.25rem); + font-weight: 900; +} + +.child-motion-orientation-tip { + position: fixed; + inset: 0; + z-index: 30; + display: none; + place-items: center; + background: #07151c; + color: var(--child-motion-text); + font-size: 1.25rem; + font-weight: 900; +} + +@media (orientation: portrait) and (max-width: 920px) { + .child-motion-orientation-tip { + display: grid; + } +} + +@media (max-width: 760px) { + .child-motion-hud--top { + width: 88%; + } + + .child-motion-calibration { + left: 50%; + right: auto; + grid-template-columns: repeat(5, minmax(0, 1fr)); + width: min(92%, 35rem); + transform: translateX(-50%); + } +} + @media (min-width: 768px) { .platform-work-detail { border-radius: 1.2rem; diff --git a/src/routing/appRoutes.test.ts b/src/routing/appRoutes.test.ts index ef440ecf..c5a4556a 100644 --- a/src/routing/appRoutes.test.ts +++ b/src/routing/appRoutes.test.ts @@ -1,7 +1,11 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { matchAppRoute } from './appRoutes'; +afterEach(() => { + vi.unstubAllEnvs(); +}); + describe('matchAppRoute', () => { it('routes the main app by default', () => { expect(matchAppRoute('/')).toEqual({ @@ -27,6 +31,20 @@ describe('matchAppRoute', () => { }); }); + it('routes child motion demo path to the standalone warmup demo', () => { + expect(matchAppRoute('/CHILD-MOTION-DEMO/')).toEqual({ + kind: 'child-motion-demo', + }); + }); + + it('blocks direct child motion demo path when edutainment entry is disabled', () => { + vi.stubEnv('VITE_ENABLE_EDUTAINMENT_ENTRY', 'false'); + + expect(matchAppRoute('/child-motion-demo')).toEqual({ + kind: 'game', + }); + }); + it('routes former standalone editor paths back to the main game', () => { expect(matchAppRoute('/item-editor/tools')).toEqual({ kind: 'game', diff --git a/src/routing/appRoutes.tsx b/src/routing/appRoutes.tsx index 27c22631..d80cf62e 100644 --- a/src/routing/appRoutes.tsx +++ b/src/routing/appRoutes.tsx @@ -2,6 +2,7 @@ import { type ComponentType, lazy, type LazyExoticComponent } from 'react'; +import { isEdutainmentEntryEnabled } from '../components/platform-entry/platformEdutainmentVisibility'; import { normalizeAppPath } from './appPageRoutes'; type AppRouteComponent = LazyExoticComponent< @@ -18,6 +19,9 @@ export type AppRouteMatch = | { kind: 'match3d-playground'; } + | { + kind: 'child-motion-demo'; + } | { kind: 'game'; }; @@ -34,6 +38,7 @@ const GameApp = lazy(() => import('../AuthenticatedApp')) as AppRouteComponent; const BigFishPlaygroundApp = lazy(() => import('../BigFishPlaygroundApp')) as AppRouteComponent; const Match3DPlaygroundApp = lazy(() => import('../Match3DPlaygroundApp')) as AppRouteComponent; const PuzzlePlaygroundApp = lazy(() => import('../PuzzlePlaygroundApp')) as AppRouteComponent; +const ChildMotionDemoApp = lazy(() => import('../ChildMotionDemoApp')) as AppRouteComponent; function normalizeRoutePath(pathname: string) { return normalizeAppPath(pathname); @@ -60,6 +65,15 @@ export function matchAppRoute(pathname: string): AppRouteMatch { }; } + if ( + normalizedPath === '/child-motion-demo' && + isEdutainmentEntryEnabled() + ) { + return { + kind: 'child-motion-demo', + }; + } + return { kind: 'game', }; @@ -95,6 +109,15 @@ export function resolveAppRoute(pathname: string): ResolvedAppRoute { }; } + if (matchedRoute.kind === 'child-motion-demo') { + return { + kind: 'child-motion-demo', + loadingEyebrow: '正在载入热身关', + loadingText: '正在进入寓教于乐 Demo...', + Component: ChildMotionDemoApp, + }; + } + return { kind: 'game', loadingEyebrow: '正在载入游戏', diff --git a/src/services/child-motion-demo/childMotionDebugInput.test.ts b/src/services/child-motion-demo/childMotionDebugInput.test.ts new file mode 100644 index 00000000..fe24e4a4 --- /dev/null +++ b/src/services/child-motion-demo/childMotionDebugInput.test.ts @@ -0,0 +1,263 @@ +// @vitest-environment jsdom + +import { afterEach, describe, expect, test, vi } from 'vitest'; + +import { + type ChildMotionDebugAction, + createChildMotionDebugInputController, + resolveKeyboardDebugAction, +} from './childMotionDebugInput'; + +let mountedTargets: HTMLElement[] = []; + +afterEach(() => { + mountedTargets.forEach((target) => target.remove()); + mountedTargets = []; +}); + +function createTarget() { + const target = document.createElement('div'); + document.body.appendChild(target); + mountedTargets.push(target); + return target; +} + +function dispatchKeyboard( + target: HTMLElement, + options: { key: string; code?: string; repeat?: boolean }, +) { + const event = new KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + key: options.key, + code: options.code ?? '', + repeat: options.repeat ?? false, + }); + target.dispatchEvent(event); + return event; +} + +function dispatchPointer( + target: HTMLElement, + type: string, + options: { + button?: number; + clientX: number; + clientY: number; + pointerId?: number; + }, +) { + const event = new MouseEvent(type, { + bubbles: true, + cancelable: true, + button: options.button ?? 0, + clientX: options.clientX, + clientY: options.clientY, + }); + Object.assign(event, { pointerId: options.pointerId ?? 1 }); + target.dispatchEvent(event); + return event; +} + +describe('childMotionDebugInput', () => { + test('maps A, D and Space keys to movement and jump actions', () => { + const target = createTarget(); + const actions: ChildMotionDebugAction[] = []; + const controller = createChildMotionDebugInputController({ + target, + onAction: (action) => actions.push(action), + now: () => 120, + }); + + const leftEvent = dispatchKeyboard(target, { key: 'a', code: 'KeyA' }); + dispatchKeyboard(target, { key: 'D', code: 'KeyD' }); + dispatchKeyboard(target, { key: ' ', code: 'Space' }); + + expect(leftEvent.defaultPrevented).toBe(true); + expect(actions).toEqual([ + { + kind: 'move', + direction: 'left', + source: 'keyboard', + occurredAtMs: 120, + }, + { + kind: 'move', + direction: 'right', + source: 'keyboard', + occurredAtMs: 120, + }, + { + kind: 'jump', + source: 'keyboard', + occurredAtMs: 120, + }, + ]); + + controller.dispose(); + }); + + test('ignores repeated or unrelated keyboard events', () => { + const unrelatedEvent = new KeyboardEvent('keydown', { + key: 'x', + code: 'KeyX', + }); + const repeatEvent = new KeyboardEvent('keydown', { + key: 'a', + code: 'KeyA', + repeat: true, + }); + + expect(resolveKeyboardDebugAction(unrelatedEvent)).toBeNull(); + expect(resolveKeyboardDebugAction(repeatEvent)).toBeNull(); + }); + + test('maps left mouse drag to a left hand trajectory', () => { + const target = createTarget(); + const actions: ChildMotionDebugAction[] = []; + const controller = createChildMotionDebugInputController({ + target, + onAction: (action) => actions.push(action), + now: () => 240, + }); + + dispatchPointer(target, 'pointerdown', { + button: 0, + clientX: 10, + clientY: 20, + pointerId: 7, + }); + dispatchPointer(target, 'pointermove', { + clientX: 18, + clientY: 24, + pointerId: 7, + }); + dispatchPointer(target, 'pointerup', { + clientX: 22, + clientY: 28, + pointerId: 7, + }); + + expect(actions).toEqual([ + { + kind: 'hand_trace', + hand: 'left', + phase: 'start', + pointerId: 7, + point: { x: 10, y: 20 }, + path: [{ x: 10, y: 20 }], + source: 'pointer', + occurredAtMs: 240, + }, + { + kind: 'hand_trace', + hand: 'left', + phase: 'move', + pointerId: 7, + point: { x: 18, y: 24 }, + path: [ + { x: 10, y: 20 }, + { x: 18, y: 24 }, + ], + source: 'pointer', + occurredAtMs: 240, + }, + { + kind: 'hand_trace', + hand: 'left', + phase: 'end', + pointerId: 7, + point: { x: 22, y: 28 }, + path: [ + { x: 10, y: 20 }, + { x: 18, y: 24 }, + { x: 22, y: 28 }, + ], + source: 'pointer', + occurredAtMs: 240, + }, + ]); + + controller.dispose(); + }); + + test('maps right mouse drag to a right hand trajectory and prevents context menu', () => { + const target = createTarget(); + const actions: ChildMotionDebugAction[] = []; + const controller = createChildMotionDebugInputController({ + target, + onAction: (action) => actions.push(action), + now: () => 360, + }); + + const pointerDown = dispatchPointer(target, 'pointerdown', { + button: 2, + clientX: 30, + clientY: 40, + pointerId: 9, + }); + dispatchPointer(target, 'pointermove', { + clientX: 44, + clientY: 48, + pointerId: 9, + }); + dispatchPointer(target, 'pointercancel', { + clientX: 48, + clientY: 52, + pointerId: 9, + }); + const contextMenuEvent = new MouseEvent('contextmenu', { + bubbles: true, + cancelable: true, + button: 2, + }); + target.dispatchEvent(contextMenuEvent); + + expect(pointerDown.defaultPrevented).toBe(true); + expect(contextMenuEvent.defaultPrevented).toBe(true); + expect(actions.map((action) => action.kind)).toEqual([ + 'hand_trace', + 'hand_trace', + 'hand_trace', + ]); + expect(actions[0]).toMatchObject({ + hand: 'right', + phase: 'start', + point: { x: 30, y: 40 }, + }); + expect(actions[2]).toMatchObject({ + hand: 'right', + phase: 'cancel', + point: { x: 48, y: 52 }, + }); + + controller.dispose(); + }); + + test('can be disabled or disposed without emitting debug actions', () => { + const target = createTarget(); + const onAction = vi.fn(); + const controller = createChildMotionDebugInputController({ + target, + onAction, + }); + + controller.setEnabled(false); + expect(controller.isEnabled()).toBe(false); + dispatchKeyboard(target, { key: 'a', code: 'KeyA' }); + dispatchPointer(target, 'pointerdown', { + button: 0, + clientX: 10, + clientY: 20, + }); + expect(onAction).not.toHaveBeenCalled(); + + controller.setEnabled(true); + dispatchKeyboard(target, { key: 'd', code: 'KeyD' }); + expect(onAction).toHaveBeenCalledTimes(1); + + controller.dispose(); + dispatchKeyboard(target, { key: ' ', code: 'Space' }); + expect(onAction).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/services/child-motion-demo/childMotionDebugInput.ts b/src/services/child-motion-demo/childMotionDebugInput.ts new file mode 100644 index 00000000..c6ce3b45 --- /dev/null +++ b/src/services/child-motion-demo/childMotionDebugInput.ts @@ -0,0 +1,287 @@ +export type ChildMotionDebugMoveDirection = 'left' | 'right'; +export type ChildMotionDebugHand = 'left' | 'right'; +export type ChildMotionDebugHandTracePhase = 'start' | 'move' | 'end' | 'cancel'; + +export type ChildMotionDebugPoint = { + x: number; + y: number; +}; + +export type ChildMotionDebugMoveAction = { + kind: 'move'; + direction: ChildMotionDebugMoveDirection; + source: 'keyboard'; + occurredAtMs: number; +}; + +export type ChildMotionDebugJumpAction = { + kind: 'jump'; + source: 'keyboard'; + occurredAtMs: number; +}; + +export type ChildMotionDebugHandTraceAction = { + kind: 'hand_trace'; + hand: ChildMotionDebugHand; + phase: ChildMotionDebugHandTracePhase; + pointerId: number; + point: ChildMotionDebugPoint; + path: ChildMotionDebugPoint[]; + source: 'pointer'; + occurredAtMs: number; +}; + +export type ChildMotionDebugAction = + | ChildMotionDebugMoveAction + | ChildMotionDebugJumpAction + | ChildMotionDebugHandTraceAction; + +type ChildMotionDebugActionPayload = + | Omit + | Omit + | Omit; + +export type ChildMotionDebugInputTarget = Pick< + EventTarget, + 'addEventListener' | 'removeEventListener' +>; + +export type ChildMotionDebugInputOptions = { + target: ChildMotionDebugInputTarget; + onAction: (action: ChildMotionDebugAction) => void; + enabled?: boolean; + now?: () => number; + preventContextMenu?: boolean; +}; + +export type ChildMotionDebugInputController = { + dispose: () => void; + isEnabled: () => boolean; + setEnabled: (enabled: boolean) => void; +}; + +type ActiveHandTrace = { + hand: ChildMotionDebugHand; + path: ChildMotionDebugPoint[]; +}; + +const DEFAULT_POINTER_ID = 1; + +export function createChildMotionDebugInputController( + options: ChildMotionDebugInputOptions, +): ChildMotionDebugInputController { + const { target, onAction, now = () => Date.now() } = options; + const preventContextMenu = options.preventContextMenu ?? true; + const activeHandTraces = new Map(); + let enabled = options.enabled ?? true; + + const emit = (action: ChildMotionDebugActionPayload) => { + onAction({ + ...action, + occurredAtMs: now(), + }); + }; + + const handleKeyDown = (event: Event) => { + if (!enabled) { + return; + } + + const action = resolveKeyboardDebugAction(event); + if (!action) { + return; + } + + event.preventDefault(); + emit(action); + }; + + const handlePointerDown = (event: Event) => { + if (!enabled) { + return; + } + + const hand = resolvePointerHand(event); + if (!hand) { + return; + } + + event.preventDefault(); + const pointerId = readPointerId(event); + const point = readPointerPoint(event); + const trace: ActiveHandTrace = { + hand, + path: [point], + }; + activeHandTraces.set(pointerId, trace); + emit({ + kind: 'hand_trace', + hand, + phase: 'start', + pointerId, + point, + path: trace.path, + source: 'pointer', + }); + }; + + const handlePointerMove = (event: Event) => { + if (!enabled) { + return; + } + + const pointerId = readPointerId(event); + const trace = activeHandTraces.get(pointerId); + if (!trace) { + return; + } + + event.preventDefault(); + const point = readPointerPoint(event); + trace.path = [...trace.path, point]; + activeHandTraces.set(pointerId, trace); + emit({ + kind: 'hand_trace', + hand: trace.hand, + phase: 'move', + pointerId, + point, + path: trace.path, + source: 'pointer', + }); + }; + + const finishPointerTrace = ( + event: Event, + phase: Extract, + ) => { + if (!enabled) { + return; + } + + const pointerId = readPointerId(event); + const trace = activeHandTraces.get(pointerId); + if (!trace) { + return; + } + + event.preventDefault(); + const point = readPointerPoint(event); + const path = [...trace.path, point]; + activeHandTraces.delete(pointerId); + emit({ + kind: 'hand_trace', + hand: trace.hand, + phase, + pointerId, + point, + path, + source: 'pointer', + }); + }; + + const handlePointerUp = (event: Event) => finishPointerTrace(event, 'end'); + const handlePointerCancel = (event: Event) => + finishPointerTrace(event, 'cancel'); + const handleContextMenu = (event: Event) => { + if (enabled && preventContextMenu) { + event.preventDefault(); + } + }; + + target.addEventListener('keydown', handleKeyDown); + target.addEventListener('pointerdown', handlePointerDown); + target.addEventListener('pointermove', handlePointerMove); + target.addEventListener('pointerup', handlePointerUp); + target.addEventListener('pointercancel', handlePointerCancel); + target.addEventListener('contextmenu', handleContextMenu); + + return { + dispose: () => { + activeHandTraces.clear(); + target.removeEventListener('keydown', handleKeyDown); + target.removeEventListener('pointerdown', handlePointerDown); + target.removeEventListener('pointermove', handlePointerMove); + target.removeEventListener('pointerup', handlePointerUp); + target.removeEventListener('pointercancel', handlePointerCancel); + target.removeEventListener('contextmenu', handleContextMenu); + }, + isEnabled: () => enabled, + setEnabled: (nextEnabled: boolean) => { + enabled = nextEnabled; + if (!enabled) { + activeHandTraces.clear(); + } + }, + }; +} + +export function resolveKeyboardDebugAction( + event: Event, +): + | Omit + | Omit + | null { + const keyboardEvent = event as KeyboardEvent; + if (keyboardEvent.repeat) { + return null; + } + + const normalizedKey = keyboardEvent.key?.toLocaleLowerCase('en-US') ?? ''; + const normalizedCode = keyboardEvent.code ?? ''; + + if (normalizedKey === 'a' || normalizedCode === 'KeyA') { + return { + kind: 'move', + direction: 'left', + source: 'keyboard', + }; + } + + if (normalizedKey === 'd' || normalizedCode === 'KeyD') { + return { + kind: 'move', + direction: 'right', + source: 'keyboard', + }; + } + + if ( + keyboardEvent.key === ' ' || + keyboardEvent.key === 'Spacebar' || + normalizedCode === 'Space' + ) { + return { + kind: 'jump', + source: 'keyboard', + }; + } + + return null; +} + +function resolvePointerHand(event: Event): ChildMotionDebugHand | null { + const button = (event as MouseEvent).button; + if (button === 0) { + return 'left'; + } + + if (button === 2) { + return 'right'; + } + + return null; +} + +function readPointerId(event: Event) { + const pointerId = (event as PointerEvent).pointerId; + return typeof pointerId === 'number' ? pointerId : DEFAULT_POINTER_ID; +} + +function readPointerPoint(event: Event): ChildMotionDebugPoint { + const mouseEvent = event as MouseEvent; + return { + x: mouseEvent.clientX, + y: mouseEvent.clientY, + }; +} diff --git a/src/services/child-motion-demo/index.ts b/src/services/child-motion-demo/index.ts new file mode 100644 index 00000000..3d9cfd0d --- /dev/null +++ b/src/services/child-motion-demo/index.ts @@ -0,0 +1 @@ +export * from './childMotionDebugInput';