feat: add child motion entry and fix auth env
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
This commit is contained in:
@@ -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`。
|
||||
|
||||
@@ -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 直达路由拦截和精确标签识别。
|
||||
|
||||
@@ -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` 为准。
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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';
|
||||
|
||||
5
src/ChildMotionDemoApp.tsx
Normal file
5
src/ChildMotionDemoApp.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { ChildMotionWarmupDemo } from './components/child-motion-demo/ChildMotionWarmupDemo';
|
||||
|
||||
export default function ChildMotionDemoApp() {
|
||||
return <ChildMotionWarmupDemo />;
|
||||
}
|
||||
@@ -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(<ChildMotionWarmupDemo />);
|
||||
|
||||
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(<ChildMotionWarmupDemo />);
|
||||
|
||||
expect(screen.getByRole('button', { name: '开始游戏' })).toBeTruthy();
|
||||
});
|
||||
|
||||
test('developer keyboard input moves the avatar and triggers jump state', () => {
|
||||
render(<ChildMotionWarmupDemo />);
|
||||
|
||||
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(<ChildMotionWarmupDemo />);
|
||||
|
||||
expect(await screen.findByText('正在连接摄像头')).toBeTruthy();
|
||||
await vi.waitFor(() => {
|
||||
expect(getUserMedia).toHaveBeenCalledWith({
|
||||
audio: false,
|
||||
video: {
|
||||
facingMode: 'user',
|
||||
},
|
||||
});
|
||||
expect(play).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
unmount();
|
||||
|
||||
expect(stopTrack).toHaveBeenCalled();
|
||||
});
|
||||
582
src/components/child-motion-demo/ChildMotionWarmupDemo.tsx
Normal file
582
src/components/child-motion-demo/ChildMotionWarmupDemo.tsx
Normal file
@@ -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<HTMLElement>,
|
||||
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 (
|
||||
<div
|
||||
className={`child-motion-avatar ${isJumping ? 'child-motion-avatar--jumping' : ''}`}
|
||||
data-testid="child-motion-avatar"
|
||||
style={{
|
||||
left: `${avatarX * 100}%`,
|
||||
}}
|
||||
aria-label="用户角色剪影"
|
||||
>
|
||||
<span className="child-motion-avatar__head" />
|
||||
<span className="child-motion-avatar__body" />
|
||||
<span className="child-motion-avatar__arm child-motion-avatar__arm--left" />
|
||||
<span className="child-motion-avatar__arm child-motion-avatar__arm--right" />
|
||||
<span className="child-motion-avatar__leg child-motion-avatar__leg--left" />
|
||||
<span className="child-motion-avatar__leg child-motion-avatar__leg--right" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChildMotionRing({
|
||||
targetX,
|
||||
progress,
|
||||
}: {
|
||||
targetX: number;
|
||||
progress: number;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`child-motion-ring ${progress > 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="绿色圆环"
|
||||
>
|
||||
<span className="child-motion-ring__core" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="child-motion-gesture-guide" aria-hidden="true">
|
||||
{isGreeting ? (
|
||||
<span className="child-motion-gesture-guide__wave">挥手</span>
|
||||
) : null}
|
||||
{isLeft || isRight ? (
|
||||
<>
|
||||
<span
|
||||
className={`child-motion-gesture-guide__hand child-motion-gesture-guide__hand--${isLeft ? 'left' : 'right'}`}
|
||||
/>
|
||||
{activePath.map((point, index) => (
|
||||
<span
|
||||
key={`${isLeft ? 'left' : 'right'}-${index}`}
|
||||
className="child-motion-gesture-guide__trail"
|
||||
style={{
|
||||
left: `${point.x * 100}%`,
|
||||
top: `${point.y * 100}%`,
|
||||
opacity: 0.22 + (index / Math.max(1, activePath.length)) * 0.58,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
{isJump ? <span className="child-motion-gesture-guide__jump">跳</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChildMotionCalibrationPanel({
|
||||
calibration,
|
||||
}: {
|
||||
calibration: ChildMotionWarmupCalibration;
|
||||
}) {
|
||||
return (
|
||||
<div className="child-motion-calibration" aria-label="热身记录">
|
||||
<div>
|
||||
<span>左边界</span>
|
||||
<strong>{formatPercent(calibration.leftBoundary)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>右边界</span>
|
||||
<strong>{formatPercent(calibration.rightBoundary)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>左手</span>
|
||||
<strong>{calibration.leftHandPath.length}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>右手</span>
|
||||
<strong>{calibration.rightHandPath.length}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>跳跃</span>
|
||||
<strong>{formatPercent(calibration.jumpSpace)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChildMotionWarmupDemo() {
|
||||
const [stepId, setStepId] = useState<ChildMotionWarmupStepId>(() =>
|
||||
hasCompletedChildMotionWarmupInRuntime() ? 'level_select' : 'center_arrive',
|
||||
);
|
||||
const [avatarX, setAvatarX] = useState(CHILD_MOTION_CENTER_X);
|
||||
const [calibration, setCalibration] = useState(
|
||||
createEmptyChildMotionCalibration,
|
||||
);
|
||||
const [holdStartedAt, setHoldStartedAt] = useState<number | null>(null);
|
||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||
const [leftHandPath, setLeftHandPath] = useState<ChildMotionPoint[]>([]);
|
||||
const [rightHandPath, setRightHandPath] = useState<ChildMotionPoint[]>([]);
|
||||
const [activeHand, setActiveHand] = useState<DragHand | null>(null);
|
||||
const [isJumping, setIsJumping] = useState(false);
|
||||
const [justCompletedText, setJustCompletedText] = useState<string | null>(null);
|
||||
const [cameraAccessState, setCameraAccessState] =
|
||||
useState<CameraAccessState>(() =>
|
||||
typeof navigator === 'undefined' ||
|
||||
!navigator.mediaDevices?.getUserMedia
|
||||
? 'blocked'
|
||||
: 'idle',
|
||||
);
|
||||
const holdCompletionRef = useRef(false);
|
||||
const cameraVideoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const cameraStreamRef = useRef<MediaStream | null>(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<typeof applyChildMotionWarmupCompletion>[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<HTMLElement>) => {
|
||||
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<HTMLElement>) => {
|
||||
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<HTMLElement>) => {
|
||||
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 (
|
||||
<main className="child-motion-demo" data-testid="child-motion-demo">
|
||||
<div className="child-motion-orientation-tip" role="status">
|
||||
请横屏体验
|
||||
</div>
|
||||
|
||||
<section
|
||||
className="child-motion-stage"
|
||||
data-testid="child-motion-stage"
|
||||
onPointerDown={handleStagePointerDown}
|
||||
onPointerMove={handleStagePointerMove}
|
||||
onPointerUp={handleStagePointerUp}
|
||||
onPointerCancel={handleStagePointerUp}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
>
|
||||
<video
|
||||
ref={cameraVideoRef}
|
||||
className="child-motion-camera-layer"
|
||||
aria-hidden="true"
|
||||
autoPlay
|
||||
muted
|
||||
playsInline
|
||||
/>
|
||||
{cameraAccessState === 'requesting' ? (
|
||||
<div className="child-motion-camera-state" aria-live="polite">
|
||||
正在连接摄像头
|
||||
</div>
|
||||
) : null}
|
||||
{cameraAccessState === 'blocked' ? (
|
||||
<div className="child-motion-camera-state" aria-live="polite">
|
||||
摄像头暂不可用,已切换到本地演示
|
||||
</div>
|
||||
) : null}
|
||||
<div className="child-motion-floor" aria-hidden="true" />
|
||||
{targetX !== null && step.kind === 'position' ? (
|
||||
<ChildMotionRing targetX={targetX} progress={holdProgress} />
|
||||
) : null}
|
||||
{step.kind === 'gesture' ? (
|
||||
<ChildMotionGestureGuide
|
||||
stepId={stepId}
|
||||
leftHandPath={leftHandPath}
|
||||
rightHandPath={rightHandPath}
|
||||
/>
|
||||
) : null}
|
||||
<ChildMotionAvatar avatarX={avatarX} isJumping={isJumping} />
|
||||
{justCompletedText ? (
|
||||
<div className="child-motion-floating-reward">{justCompletedText}</div>
|
||||
) : null}
|
||||
|
||||
<div className="child-motion-hud child-motion-hud--top">
|
||||
<span className="child-motion-step-count">{`${Math.min(stepIndex + 1, 12)}/12`}</span>
|
||||
<div>
|
||||
<h1>{step.title}</h1>
|
||||
<p>{lineText}</p>
|
||||
</div>
|
||||
<span className="child-motion-progress">{progressPercent}%</span>
|
||||
</div>
|
||||
|
||||
{step.kind === 'levelSelect' ? (
|
||||
<div className="child-motion-start-panel">
|
||||
<button type="button" onClick={handleStartPlaceholderLevel}>
|
||||
开始游戏
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step.kind === 'placeholder' ? (
|
||||
<div className="child-motion-start-panel">
|
||||
<span>下一关正在设计中</span>
|
||||
<button type="button" onClick={handleReturnToStart}>
|
||||
回到开始
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ChildMotionCalibrationPanel calibration={calibration} />
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChildMotionWarmupDemo;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
274
src/components/child-motion-demo/childMotionWarmupModel.ts
Normal file
274
src/components/child-motion-demo/childMotionWarmupModel.ts
Normal file
@@ -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<ChildMotionWarmupStepId, ChildMotionWarmupStepId>(
|
||||
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;
|
||||
}
|
||||
@@ -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<string, PlatformPublicGalleryCard>();
|
||||
[...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('未找到视觉小说作品。');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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(<TestWrapper withAuth />);
|
||||
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',
|
||||
|
||||
@@ -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<PlatformPuzzleGalleryCard> = {},
|
||||
) {
|
||||
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();
|
||||
|
||||
@@ -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<string, PlatformPublicGalleryCard>();
|
||||
|
||||
[...featuredEntries, ...latestEntries].forEach((entry) => {
|
||||
publicEntryMap.set(buildPublicGalleryCardKey(entry), entry);
|
||||
});
|
||||
filterGeneralPublicWorks([...featuredEntries, ...latestEntries]).forEach(
|
||||
(entry) => {
|
||||
publicEntryMap.set(buildPublicGalleryCardKey(entry), entry);
|
||||
},
|
||||
);
|
||||
|
||||
const categoryMap = new Map<string, PlatformPublicGalleryCard[]>();
|
||||
Array.from(publicEntryMap.values()).forEach((entry) => {
|
||||
@@ -1279,6 +1298,21 @@ function getPlatformPublicEntries(
|
||||
) {
|
||||
const entryMap = new Map<string, PlatformPublicGalleryCard>();
|
||||
|
||||
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<string, PlatformPublicGalleryCard>();
|
||||
|
||||
[...featuredEntries, ...latestEntries].forEach((entry) => {
|
||||
entryMap.set(buildPublicGalleryCardKey(entry), entry);
|
||||
});
|
||||
@@ -3079,21 +3113,62 @@ export function RpgEntryHomeView({
|
||||
const [avatarError, setAvatarError] = useState<string | null>(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<string, PlatformPublicGalleryCard>();
|
||||
[...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<string, PlatformPublicGalleryCard>();
|
||||
[...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<string, PlatformPublicGalleryCard>();
|
||||
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<string, PlatformPublicGalleryCard>();
|
||||
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({
|
||||
) : (
|
||||
<>
|
||||
<div className="platform-mobile-home-channelbar flex min-w-0 gap-4 overflow-x-auto pb-1 scrollbar-hide">
|
||||
{DISCOVER_CHANNELS.map((channel) => {
|
||||
{visibleDiscoverChannels.map((channel) => {
|
||||
const active = discoverChannel === channel.id;
|
||||
return (
|
||||
<button
|
||||
@@ -4296,6 +4394,31 @@ export function RpgEntryHomeView({
|
||||
<EmptyShelf text="公开广场暂时还没有可分类的作品。" />
|
||||
)}
|
||||
</section>
|
||||
) : discoverChannel === 'edutainment' ? (
|
||||
<section className="platform-mobile-home-feed">
|
||||
{isLoadingPlatform ? (
|
||||
<EmptyShelf text="正在读取公开作品..." />
|
||||
) : edutainmentFeedEntries.length > 0 ? (
|
||||
<div className="grid min-w-0 gap-3">
|
||||
{edutainmentFeedEntries.map((entry) => {
|
||||
const cardKey = buildPublicGalleryCardKey(entry);
|
||||
|
||||
return (
|
||||
<WorldCard
|
||||
key={`${cardKey}:mobile-edutainment`}
|
||||
entry={entry}
|
||||
onClick={() => onOpenGalleryDetail(entry)}
|
||||
className="w-full"
|
||||
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)}
|
||||
feedCardKey={cardKey}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyShelf text="暂时还没有可展示的作品。" />
|
||||
)}
|
||||
</section>
|
||||
) : (
|
||||
<section
|
||||
ref={mobileDiscoverFeedRef}
|
||||
@@ -4332,8 +4455,122 @@ export function RpgEntryHomeView({
|
||||
</div>
|
||||
);
|
||||
|
||||
const desktopDiscoverContent: ReactNode = (
|
||||
<div className={DESKTOP_PAGE_STAGE_CLASS}>
|
||||
<div className="platform-mobile-home-channelbar flex min-w-0 gap-4 overflow-x-auto pb-1 scrollbar-hide">
|
||||
{visibleDiscoverChannels.map((channel) => {
|
||||
const active = discoverChannel === channel.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`desktop-${channel.id}`}
|
||||
type="button"
|
||||
onClick={() => setDiscoverChannel(channel.id)}
|
||||
className={`platform-mobile-home-channel shrink-0 ${active ? 'platform-mobile-home-channel--active' : ''}`}
|
||||
>
|
||||
{channel.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{platformError ? (
|
||||
<div className="rounded-[1.5rem] border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-700">
|
||||
{platformError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{discoverChannel === 'ranking' ? (
|
||||
mobileRankingPanel
|
||||
) : discoverChannel === 'category' ? (
|
||||
<section className="platform-desktop-panel px-5 py-5">
|
||||
<SectionHeader title="作品分类" detail="GAME CATEGORY" />
|
||||
{isLoadingPlatform ? (
|
||||
<EmptyShelf text="正在读取作品分类..." />
|
||||
) : activeCategoryGroup && desktopCategoryGrid.length > 0 ? (
|
||||
<>
|
||||
<div className="mb-4 flex min-w-0 items-center gap-2 overflow-x-auto pb-1 scrollbar-hide">
|
||||
{categoryGroups.map((group) => {
|
||||
const active = group.tag === activeCategoryGroup.tag;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${group.tag}:desktop-discover-category`}
|
||||
type="button"
|
||||
onClick={() => setSelectedCategoryTag(group.tag)}
|
||||
className={`platform-category-chip shrink-0 ${active ? 'platform-category-chip--active' : ''}`}
|
||||
>
|
||||
{group.tag}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
{desktopCategoryGrid.map((entry) => (
|
||||
<WorldCard
|
||||
key={`${buildPublicGalleryCardKey(entry)}:desktop-discover-category:${activeCategoryGroup.tag}`}
|
||||
entry={entry}
|
||||
onClick={() => openRecommendGalleryDetail(entry)}
|
||||
className="w-full min-w-0"
|
||||
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<EmptyShelf text="暂时还没有可分类的作品。" />
|
||||
)}
|
||||
</section>
|
||||
) : discoverChannel === 'edutainment' ? (
|
||||
<section className="platform-desktop-panel px-5 py-5">
|
||||
<SectionHeader title={EDUTAINMENT_WORK_TAG} detail="EDUTAINMENT" />
|
||||
{isLoadingPlatform ? (
|
||||
<EmptyShelf text="正在读取公开作品..." />
|
||||
) : edutainmentFeedEntries.length > 0 ? (
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
{edutainmentFeedEntries.map((entry) => (
|
||||
<WorldCard
|
||||
key={`${buildPublicGalleryCardKey(entry)}:desktop-edutainment`}
|
||||
entry={entry}
|
||||
onClick={() => openRecommendGalleryDetail(entry)}
|
||||
className="w-full min-w-0"
|
||||
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyShelf text="暂时还没有可展示的作品。" />
|
||||
)}
|
||||
</section>
|
||||
) : (
|
||||
<section className="platform-desktop-panel px-5 py-5">
|
||||
<SectionHeader
|
||||
title={discoverChannel === 'today' ? '今日游戏' : '推荐'}
|
||||
detail={discoverChannel === 'today' ? 'TODAY GAMES' : 'RECOMMENDED'}
|
||||
/>
|
||||
{isLoadingPlatform ? (
|
||||
<EmptyShelf text="正在读取公开作品..." />
|
||||
) : discoverFeedEntries.length > 0 ? (
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
{discoverFeedEntries.map((entry) => (
|
||||
<WorldCard
|
||||
key={`${buildPublicGalleryCardKey(entry)}:desktop-discover-feed:${discoverChannel}`}
|
||||
entry={entry}
|
||||
onClick={() => openRecommendGalleryDetail(entry)}
|
||||
className="w-full min-w-0"
|
||||
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyShelf text="公开广场暂时还没有可展示的作品。" />
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
const categoryContent: ReactNode = isDesktopLayout ? (
|
||||
<div className={DESKTOP_PAGE_STAGE_CLASS}>{mobileRankingPanel}</div>
|
||||
desktopDiscoverContent
|
||||
) : (
|
||||
mobileDiscoverContent
|
||||
);
|
||||
@@ -4773,7 +5010,7 @@ export function RpgEntryHomeView({
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`grid gap-5 ${desktopLibraryPreview.length > 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)]' : ''}`}
|
||||
>
|
||||
<section className="platform-desktop-panel px-5 py-5">
|
||||
<SectionHeader title="推荐" detail="RECOMMENDED" />
|
||||
@@ -4796,7 +5033,7 @@ export function RpgEntryHomeView({
|
||||
)}
|
||||
</section>
|
||||
|
||||
{desktopLibraryPreview.length > 0 || historyEntries.length > 0 ? (
|
||||
{desktopLibraryPreview.length > 0 || visibleHistoryEntries.length > 0 ? (
|
||||
<section className="platform-desktop-panel px-5 py-5">
|
||||
<SectionHeader
|
||||
title={desktopLibraryPreview.length > 0 ? '最近作品' : '最近浏览'}
|
||||
@@ -4841,7 +5078,7 @@ export function RpgEntryHomeView({
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3 space-y-3">
|
||||
{historyEntries.slice(0, 2).map((entry) => {
|
||||
{visibleHistoryEntries.slice(0, 2).map((entry) => {
|
||||
const displayName = formatPlatformWorkDisplayName(
|
||||
entry.worldName,
|
||||
);
|
||||
|
||||
467
src/index.css
467
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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '正在载入游戏',
|
||||
|
||||
263
src/services/child-motion-demo/childMotionDebugInput.test.ts
Normal file
263
src/services/child-motion-demo/childMotionDebugInput.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
287
src/services/child-motion-demo/childMotionDebugInput.ts
Normal file
287
src/services/child-motion-demo/childMotionDebugInput.ts
Normal file
@@ -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<ChildMotionDebugMoveAction, 'occurredAtMs'>
|
||||
| Omit<ChildMotionDebugJumpAction, 'occurredAtMs'>
|
||||
| Omit<ChildMotionDebugHandTraceAction, 'occurredAtMs'>;
|
||||
|
||||
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<number, ActiveHandTrace>();
|
||||
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<ChildMotionDebugHandTracePhase, 'end' | 'cancel'>,
|
||||
) => {
|
||||
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<ChildMotionDebugMoveAction, 'occurredAtMs'>
|
||||
| Omit<ChildMotionDebugJumpAction, 'occurredAtMs'>
|
||||
| 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,
|
||||
};
|
||||
}
|
||||
1
src/services/child-motion-demo/index.ts
Normal file
1
src/services/child-motion-demo/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './childMotionDebugInput';
|
||||
Reference in New Issue
Block a user