();
+ edutainmentEntries.forEach((entry) => {
+ entryMap.set(buildPublicGalleryCardKey(entry), entry);
+ });
+
+ return Array.from(entryMap.values());
+ }, [edutainmentEntries]);
const mobileFeedCarouselEnabled =
!isDesktopLayout &&
activeTab === 'category' &&
@@ -4022,7 +4120,7 @@ export function RpgEntryHomeView({
isAuthenticated,
openRecommendGalleryDetail,
]);
- const leadPublicEntry = featuredShelf[0] ?? latestEntries[0] ?? null;
+ const leadPublicEntry = featuredShelf[0] ?? generalLatestEntries[0] ?? null;
const openLeadPublicEntry = () => {
if (leadPublicEntry) {
openRecommendGalleryDetail(leadPublicEntry);
@@ -4217,7 +4315,7 @@ export function RpgEntryHomeView({
) : (
<>
- {DISCOVER_CHANNELS.map((channel) => {
+ {visibleDiscoverChannels.map((channel) => {
const active = discoverChannel === channel.id;
return (
)}
+ ) : discoverChannel === 'edutainment' ? (
+
+ {isLoadingPlatform ? (
+
+ ) : edutainmentFeedEntries.length > 0 ? (
+
+ {edutainmentFeedEntries.map((entry) => {
+ const cardKey = buildPublicGalleryCardKey(entry);
+
+ return (
+ onOpenGalleryDetail(entry)}
+ className="w-full"
+ authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)}
+ feedCardKey={cardKey}
+ />
+ );
+ })}
+
+ ) : (
+
+ )}
+
) : (
);
+ const desktopDiscoverContent: ReactNode = (
+
+
+ {visibleDiscoverChannels.map((channel) => {
+ const active = discoverChannel === channel.id;
+
+ return (
+
+ );
+ })}
+
+
+ {platformError ? (
+
+ {platformError}
+
+ ) : null}
+
+ {discoverChannel === 'ranking' ? (
+ mobileRankingPanel
+ ) : discoverChannel === 'category' ? (
+
+
+ {isLoadingPlatform ? (
+
+ ) : activeCategoryGroup && desktopCategoryGrid.length > 0 ? (
+ <>
+
+ {categoryGroups.map((group) => {
+ const active = group.tag === activeCategoryGroup.tag;
+
+ return (
+
+ );
+ })}
+
+
+ {desktopCategoryGrid.map((entry) => (
+ openRecommendGalleryDetail(entry)}
+ className="w-full min-w-0"
+ authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)}
+ />
+ ))}
+
+ >
+ ) : (
+
+ )}
+
+ ) : discoverChannel === 'edutainment' ? (
+
+
+ {isLoadingPlatform ? (
+
+ ) : edutainmentFeedEntries.length > 0 ? (
+
+ {edutainmentFeedEntries.map((entry) => (
+ openRecommendGalleryDetail(entry)}
+ className="w-full min-w-0"
+ authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)}
+ />
+ ))}
+
+ ) : (
+
+ )}
+
+ ) : (
+
+
+ {isLoadingPlatform ? (
+
+ ) : discoverFeedEntries.length > 0 ? (
+
+ {discoverFeedEntries.map((entry) => (
+ openRecommendGalleryDetail(entry)}
+ className="w-full min-w-0"
+ authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)}
+ />
+ ))}
+
+ ) : (
+
+ )}
+
+ )}
+
+ );
const categoryContent: ReactNode = isDesktopLayout ? (
- {mobileRankingPanel}
+ desktopDiscoverContent
) : (
mobileDiscoverContent
);
@@ -4773,7 +5010,7 @@ export function RpgEntryHomeView({
0 || historyEntries.length > 0 ? '2xl:grid-cols-[minmax(0,1.2fr)_minmax(22rem,0.8fr)]' : ''}`}
+ className={`grid gap-5 ${desktopLibraryPreview.length > 0 || visibleHistoryEntries.length > 0 ? '2xl:grid-cols-[minmax(0,1.2fr)_minmax(22rem,0.8fr)]' : ''}`}
>
@@ -4796,7 +5033,7 @@ export function RpgEntryHomeView({
)}
- {desktopLibraryPreview.length > 0 || historyEntries.length > 0 ? (
+ {desktopLibraryPreview.length > 0 || visibleHistoryEntries.length > 0 ? (
0 ? '最近作品' : '最近浏览'}
@@ -4841,7 +5078,7 @@ export function RpgEntryHomeView({
) : (
- {historyEntries.slice(0, 2).map((entry) => {
+ {visibleHistoryEntries.slice(0, 2).map((entry) => {
const displayName = formatPlatformWorkDisplayName(
entry.worldName,
);
diff --git a/src/index.css b/src/index.css
index 5f9eccee..fffb999c 100644
--- a/src/index.css
+++ b/src/index.css
@@ -5623,6 +5623,473 @@ button {
color: rgba(255, 255, 255, 0.9) !important;
}
+.child-motion-demo {
+ --child-motion-bg: #07151c;
+ --child-motion-panel: rgba(6, 24, 30, 0.64);
+ --child-motion-panel-border: rgba(178, 239, 220, 0.25);
+ --child-motion-text: #eefcf7;
+ --child-motion-soft: rgba(238, 252, 247, 0.7);
+ --child-motion-green: #5ff08f;
+ --child-motion-sky: #8fd8ff;
+ display: grid;
+ width: 100%;
+ min-width: 0;
+ height: 100vh;
+ min-height: 100vh;
+ place-items: center;
+ overflow: hidden;
+ background:
+ radial-gradient(circle at 18% 14%, rgba(143, 216, 255, 0.24), transparent 32%),
+ radial-gradient(circle at 82% 22%, rgba(95, 240, 143, 0.18), transparent 30%),
+ linear-gradient(180deg, #092433 0%, var(--child-motion-bg) 54%, #0a1f18 100%);
+ color: var(--child-motion-text);
+ font-family: Inter, ui-sans-serif, system-ui, sans-serif;
+}
+
+@supports (height: 100dvh) {
+ .child-motion-demo {
+ height: 100dvh;
+ min-height: 100dvh;
+ }
+}
+
+.child-motion-stage {
+ position: relative;
+ width: min(100vw, calc(100vh * 16 / 9));
+ height: min(100vh, calc(100vw * 9 / 16));
+ overflow: hidden;
+ background:
+ linear-gradient(180deg, rgba(16, 64, 86, 0.86), rgba(9, 42, 39, 0.9)),
+ var(--child-motion-bg);
+ box-shadow: 0 30px 100px rgba(0, 0, 0, 0.38);
+ touch-action: none;
+ user-select: none;
+}
+
+@supports (height: 100dvh) {
+ .child-motion-stage {
+ width: min(100vw, calc(100dvh * 16 / 9));
+ height: min(100dvh, calc(100vw * 9 / 16));
+ }
+}
+
+.child-motion-camera-layer {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ background:
+ radial-gradient(circle at 50% 33%, rgba(255, 255, 255, 0.12), transparent 28%),
+ linear-gradient(110deg, rgba(255, 255, 255, 0.06) 0 12%, transparent 12% 20%, rgba(255, 255, 255, 0.04) 20% 31%, transparent 31% 100%);
+ filter: blur(7px) saturate(0.8);
+ opacity: 0.62;
+ transform: scale(1.05);
+}
+
+.child-motion-camera-state {
+ position: absolute;
+ top: 18%;
+ left: 50%;
+ z-index: 7;
+ transform: translateX(-50%);
+ border: 1px solid rgba(238, 252, 247, 0.2);
+ border-radius: 999px;
+ background: rgba(6, 24, 30, 0.52);
+ color: rgba(238, 252, 247, 0.82);
+ padding: 0.45rem 0.9rem;
+ font-size: clamp(0.68rem, 1.35vw, 0.84rem);
+ font-weight: 800;
+ backdrop-filter: blur(12px);
+}
+
+.child-motion-floor {
+ position: absolute;
+ right: -8%;
+ bottom: -19%;
+ left: -8%;
+ height: 47%;
+ border-radius: 50% 50% 0 0;
+ background:
+ radial-gradient(ellipse at 50% 8%, rgba(190, 255, 220, 0.22), transparent 36%),
+ linear-gradient(180deg, rgba(24, 86, 67, 0.84), rgba(7, 43, 34, 0.96));
+ box-shadow: inset 0 22px 70px rgba(255, 255, 255, 0.07);
+}
+
+.child-motion-hud {
+ position: absolute;
+ z-index: 8;
+ display: flex;
+ align-items: center;
+ gap: clamp(0.6rem, 1.8vw, 1rem);
+ border: 1px solid var(--child-motion-panel-border);
+ border-radius: clamp(0.75rem, 2vw, 1.25rem);
+ background: var(--child-motion-panel);
+ box-shadow: 0 18px 48px rgba(0, 0, 0, 0.2);
+ backdrop-filter: blur(14px);
+}
+
+.child-motion-hud--top {
+ top: 4.2%;
+ left: 50%;
+ width: min(72%, 48rem);
+ min-height: clamp(4.2rem, 11vh, 6.25rem);
+ transform: translateX(-50%);
+ padding: clamp(0.65rem, 1.8vw, 1rem) clamp(0.8rem, 2.2vw, 1.25rem);
+}
+
+.child-motion-hud h1 {
+ margin: 0;
+ color: var(--child-motion-text);
+ font-size: clamp(1.2rem, 3.2vw, 2rem);
+ font-weight: 900;
+ line-height: 1.08;
+}
+
+.child-motion-hud p {
+ margin: 0.28rem 0 0;
+ color: var(--child-motion-soft);
+ font-size: clamp(0.72rem, 1.45vw, 0.98rem);
+ font-weight: 700;
+ line-height: 1.45;
+}
+
+.child-motion-step-count,
+.child-motion-progress {
+ display: inline-flex;
+ width: clamp(2.7rem, 7vw, 4rem);
+ height: clamp(2.7rem, 7vw, 4rem);
+ flex: 0 0 auto;
+ align-items: center;
+ justify-content: center;
+ border: 1px solid rgba(238, 252, 247, 0.2);
+ border-radius: 999px;
+ background: rgba(255, 255, 255, 0.1);
+ color: var(--child-motion-text);
+ font-size: clamp(0.72rem, 1.45vw, 0.95rem);
+ font-weight: 900;
+}
+
+.child-motion-ring {
+ position: absolute;
+ bottom: 20.5%;
+ z-index: 3;
+ width: clamp(5.8rem, 13vw, 9rem);
+ aspect-ratio: 1;
+ transform: translateX(-50%) rotateX(62deg);
+ border-radius: 999px;
+ background:
+ conic-gradient(
+ from -90deg,
+ rgba(255, 255, 255, 0.95) 0 var(--child-motion-ring-progress),
+ rgba(95, 240, 143, 0.18) var(--child-motion-ring-progress) 360deg
+ );
+ box-shadow:
+ 0 0 28px rgba(95, 240, 143, 0.42),
+ inset 0 0 26px rgba(255, 255, 255, 0.18);
+}
+
+.child-motion-ring::before {
+ position: absolute;
+ inset: 14%;
+ border-radius: inherit;
+ background: rgba(8, 44, 36, 0.94);
+ content: '';
+}
+
+.child-motion-ring__core {
+ position: absolute;
+ inset: 34%;
+ border-radius: 999px;
+ background: var(--child-motion-green);
+ opacity: 0.28;
+}
+
+.child-motion-ring--active {
+ animation: child-motion-ring-pulse 0.78s ease-in-out infinite alternate;
+}
+
+@keyframes child-motion-ring-pulse {
+ from {
+ filter: brightness(1);
+ }
+
+ to {
+ filter: brightness(1.25);
+ }
+}
+
+.child-motion-avatar {
+ position: absolute;
+ bottom: 24%;
+ z-index: 5;
+ width: clamp(3.4rem, 7vw, 5.6rem);
+ height: clamp(6rem, 13vw, 10rem);
+ transform: translateX(-50%);
+ transition: left 260ms ease, transform 220ms ease;
+}
+
+.child-motion-avatar--jumping {
+ transform: translate(-50%, -14%);
+}
+
+.child-motion-avatar__head,
+.child-motion-avatar__body,
+.child-motion-avatar__arm,
+.child-motion-avatar__leg {
+ position: absolute;
+ display: block;
+ background: rgba(7, 18, 24, 0.82);
+ box-shadow: 0 0 24px rgba(143, 216, 255, 0.18);
+}
+
+.child-motion-avatar__head {
+ top: 0;
+ left: 50%;
+ width: 34%;
+ aspect-ratio: 1;
+ transform: translateX(-50%);
+ border-radius: 999px;
+}
+
+.child-motion-avatar__body {
+ top: 27%;
+ left: 50%;
+ width: 42%;
+ height: 36%;
+ transform: translateX(-50%);
+ border-radius: 999px 999px 45% 45%;
+}
+
+.child-motion-avatar__arm {
+ top: 33%;
+ width: 15%;
+ height: 34%;
+ border-radius: 999px;
+}
+
+.child-motion-avatar__arm--left {
+ left: 17%;
+ transform: rotate(18deg);
+}
+
+.child-motion-avatar__arm--right {
+ right: 17%;
+ transform: rotate(-18deg);
+}
+
+.child-motion-avatar__leg {
+ bottom: 0;
+ width: 15%;
+ height: 34%;
+ border-radius: 999px;
+}
+
+.child-motion-avatar__leg--left {
+ left: 36%;
+ transform: rotate(7deg);
+}
+
+.child-motion-avatar__leg--right {
+ right: 36%;
+ transform: rotate(-7deg);
+}
+
+.child-motion-gesture-guide {
+ position: absolute;
+ inset: 20% 22% 19%;
+ z-index: 4;
+ pointer-events: none;
+}
+
+.child-motion-gesture-guide__wave,
+.child-motion-gesture-guide__jump {
+ position: absolute;
+ left: 50%;
+ top: 38%;
+ display: inline-flex;
+ width: clamp(4.5rem, 11vw, 8rem);
+ aspect-ratio: 1;
+ transform: translate(-50%, -50%);
+ align-items: center;
+ justify-content: center;
+ border: 2px solid rgba(95, 240, 143, 0.64);
+ border-radius: 999px;
+ background: rgba(95, 240, 143, 0.1);
+ color: var(--child-motion-text);
+ font-size: clamp(1rem, 2.4vw, 1.55rem);
+ font-weight: 900;
+}
+
+.child-motion-gesture-guide__hand {
+ position: absolute;
+ top: 28%;
+ width: clamp(4rem, 9vw, 7rem);
+ aspect-ratio: 1;
+ border: 2px dashed rgba(95, 240, 143, 0.58);
+ border-radius: 999px;
+ animation: child-motion-hand-guide 1.1s ease-in-out infinite alternate;
+}
+
+.child-motion-gesture-guide__hand--left {
+ left: 22%;
+}
+
+.child-motion-gesture-guide__hand--right {
+ right: 22%;
+}
+
+@keyframes child-motion-hand-guide {
+ from {
+ transform: translateY(0);
+ }
+
+ to {
+ transform: translateY(-10%);
+ }
+}
+
+.child-motion-gesture-guide__trail {
+ position: absolute;
+ width: 0.8rem;
+ height: 0.8rem;
+ transform: translate(-50%, -50%);
+ border-radius: 999px;
+ background: #b9ffd0;
+ box-shadow: 0 0 16px rgba(95, 240, 143, 0.56);
+}
+
+.child-motion-floating-reward {
+ position: absolute;
+ left: 50%;
+ top: 34%;
+ z-index: 9;
+ transform: translateX(-50%);
+ color: #ffffff;
+ font-size: clamp(1.4rem, 4vw, 2.4rem);
+ font-weight: 900;
+ text-shadow: 0 4px 26px rgba(0, 0, 0, 0.42);
+ animation: child-motion-reward-rise 0.72s ease-out forwards;
+}
+
+@keyframes child-motion-reward-rise {
+ from {
+ opacity: 0;
+ transform: translate(-50%, 22%);
+ }
+
+ to {
+ opacity: 1;
+ transform: translate(-50%, -18%);
+ }
+}
+
+.child-motion-calibration {
+ position: absolute;
+ right: 3.2%;
+ bottom: 4%;
+ z-index: 8;
+ display: grid;
+ grid-template-columns: repeat(5, minmax(0, auto));
+ gap: 0.45rem;
+ max-width: 82%;
+ border: 1px solid var(--child-motion-panel-border);
+ border-radius: 999px;
+ background: var(--child-motion-panel);
+ padding: 0.45rem;
+ backdrop-filter: blur(14px);
+}
+
+.child-motion-calibration div {
+ display: grid;
+ min-width: clamp(3.2rem, 7vw, 4.8rem);
+ gap: 0.08rem;
+ justify-items: center;
+ border-radius: 999px;
+ background: rgba(255, 255, 255, 0.08);
+ padding: 0.36rem 0.55rem;
+}
+
+.child-motion-calibration span {
+ color: var(--child-motion-soft);
+ font-size: clamp(0.55rem, 1.2vw, 0.72rem);
+ font-weight: 800;
+}
+
+.child-motion-calibration strong {
+ color: var(--child-motion-text);
+ font-size: clamp(0.72rem, 1.5vw, 0.95rem);
+ font-weight: 900;
+}
+
+.child-motion-start-panel {
+ position: absolute;
+ left: 50%;
+ top: 53%;
+ z-index: 10;
+ display: flex;
+ transform: translate(-50%, -50%);
+ align-items: center;
+ gap: 0.85rem;
+ border: 1px solid rgba(178, 239, 220, 0.32);
+ border-radius: 1.4rem;
+ background: rgba(6, 24, 30, 0.7);
+ padding: clamp(0.85rem, 2vw, 1.15rem);
+ box-shadow: 0 24px 70px rgba(0, 0, 0, 0.28);
+ backdrop-filter: blur(14px);
+}
+
+.child-motion-start-panel button {
+ min-width: clamp(8rem, 18vw, 12rem);
+ min-height: clamp(3rem, 7vw, 4.2rem);
+ border: 0;
+ border-radius: 999px;
+ background: linear-gradient(135deg, #5ff08f, #8fd8ff);
+ color: #062018;
+ font-size: clamp(1rem, 2.5vw, 1.4rem);
+ font-weight: 950;
+ cursor: pointer;
+ box-shadow: 0 16px 44px rgba(95, 240, 143, 0.28);
+}
+
+.child-motion-start-panel span {
+ color: var(--child-motion-text);
+ font-size: clamp(1rem, 2vw, 1.25rem);
+ font-weight: 900;
+}
+
+.child-motion-orientation-tip {
+ position: fixed;
+ inset: 0;
+ z-index: 30;
+ display: none;
+ place-items: center;
+ background: #07151c;
+ color: var(--child-motion-text);
+ font-size: 1.25rem;
+ font-weight: 900;
+}
+
+@media (orientation: portrait) and (max-width: 920px) {
+ .child-motion-orientation-tip {
+ display: grid;
+ }
+}
+
+@media (max-width: 760px) {
+ .child-motion-hud--top {
+ width: 88%;
+ }
+
+ .child-motion-calibration {
+ left: 50%;
+ right: auto;
+ grid-template-columns: repeat(5, minmax(0, 1fr));
+ width: min(92%, 35rem);
+ transform: translateX(-50%);
+ }
+}
+
@media (min-width: 768px) {
.platform-work-detail {
border-radius: 1.2rem;
diff --git a/src/routing/appRoutes.test.ts b/src/routing/appRoutes.test.ts
index ef440ecf..c5a4556a 100644
--- a/src/routing/appRoutes.test.ts
+++ b/src/routing/appRoutes.test.ts
@@ -1,7 +1,11 @@
-import { describe, expect, it } from 'vitest';
+import { afterEach, describe, expect, it, vi } from 'vitest';
import { matchAppRoute } from './appRoutes';
+afterEach(() => {
+ vi.unstubAllEnvs();
+});
+
describe('matchAppRoute', () => {
it('routes the main app by default', () => {
expect(matchAppRoute('/')).toEqual({
@@ -27,6 +31,20 @@ describe('matchAppRoute', () => {
});
});
+ it('routes child motion demo path to the standalone warmup demo', () => {
+ expect(matchAppRoute('/CHILD-MOTION-DEMO/')).toEqual({
+ kind: 'child-motion-demo',
+ });
+ });
+
+ it('blocks direct child motion demo path when edutainment entry is disabled', () => {
+ vi.stubEnv('VITE_ENABLE_EDUTAINMENT_ENTRY', 'false');
+
+ expect(matchAppRoute('/child-motion-demo')).toEqual({
+ kind: 'game',
+ });
+ });
+
it('routes former standalone editor paths back to the main game', () => {
expect(matchAppRoute('/item-editor/tools')).toEqual({
kind: 'game',
diff --git a/src/routing/appRoutes.tsx b/src/routing/appRoutes.tsx
index 27c22631..d80cf62e 100644
--- a/src/routing/appRoutes.tsx
+++ b/src/routing/appRoutes.tsx
@@ -2,6 +2,7 @@
import { type ComponentType, lazy, type LazyExoticComponent } from 'react';
+import { isEdutainmentEntryEnabled } from '../components/platform-entry/platformEdutainmentVisibility';
import { normalizeAppPath } from './appPageRoutes';
type AppRouteComponent = LazyExoticComponent<
@@ -18,6 +19,9 @@ export type AppRouteMatch =
| {
kind: 'match3d-playground';
}
+ | {
+ kind: 'child-motion-demo';
+ }
| {
kind: 'game';
};
@@ -34,6 +38,7 @@ const GameApp = lazy(() => import('../AuthenticatedApp')) as AppRouteComponent;
const BigFishPlaygroundApp = lazy(() => import('../BigFishPlaygroundApp')) as AppRouteComponent;
const Match3DPlaygroundApp = lazy(() => import('../Match3DPlaygroundApp')) as AppRouteComponent;
const PuzzlePlaygroundApp = lazy(() => import('../PuzzlePlaygroundApp')) as AppRouteComponent;
+const ChildMotionDemoApp = lazy(() => import('../ChildMotionDemoApp')) as AppRouteComponent;
function normalizeRoutePath(pathname: string) {
return normalizeAppPath(pathname);
@@ -60,6 +65,15 @@ export function matchAppRoute(pathname: string): AppRouteMatch {
};
}
+ if (
+ normalizedPath === '/child-motion-demo' &&
+ isEdutainmentEntryEnabled()
+ ) {
+ return {
+ kind: 'child-motion-demo',
+ };
+ }
+
return {
kind: 'game',
};
@@ -95,6 +109,15 @@ export function resolveAppRoute(pathname: string): ResolvedAppRoute {
};
}
+ if (matchedRoute.kind === 'child-motion-demo') {
+ return {
+ kind: 'child-motion-demo',
+ loadingEyebrow: '正在载入热身关',
+ loadingText: '正在进入寓教于乐 Demo...',
+ Component: ChildMotionDemoApp,
+ };
+ }
+
return {
kind: 'game',
loadingEyebrow: '正在载入游戏',
diff --git a/src/services/child-motion-demo/childMotionDebugInput.test.ts b/src/services/child-motion-demo/childMotionDebugInput.test.ts
new file mode 100644
index 00000000..fe24e4a4
--- /dev/null
+++ b/src/services/child-motion-demo/childMotionDebugInput.test.ts
@@ -0,0 +1,263 @@
+// @vitest-environment jsdom
+
+import { afterEach, describe, expect, test, vi } from 'vitest';
+
+import {
+ type ChildMotionDebugAction,
+ createChildMotionDebugInputController,
+ resolveKeyboardDebugAction,
+} from './childMotionDebugInput';
+
+let mountedTargets: HTMLElement[] = [];
+
+afterEach(() => {
+ mountedTargets.forEach((target) => target.remove());
+ mountedTargets = [];
+});
+
+function createTarget() {
+ const target = document.createElement('div');
+ document.body.appendChild(target);
+ mountedTargets.push(target);
+ return target;
+}
+
+function dispatchKeyboard(
+ target: HTMLElement,
+ options: { key: string; code?: string; repeat?: boolean },
+) {
+ const event = new KeyboardEvent('keydown', {
+ bubbles: true,
+ cancelable: true,
+ key: options.key,
+ code: options.code ?? '',
+ repeat: options.repeat ?? false,
+ });
+ target.dispatchEvent(event);
+ return event;
+}
+
+function dispatchPointer(
+ target: HTMLElement,
+ type: string,
+ options: {
+ button?: number;
+ clientX: number;
+ clientY: number;
+ pointerId?: number;
+ },
+) {
+ const event = new MouseEvent(type, {
+ bubbles: true,
+ cancelable: true,
+ button: options.button ?? 0,
+ clientX: options.clientX,
+ clientY: options.clientY,
+ });
+ Object.assign(event, { pointerId: options.pointerId ?? 1 });
+ target.dispatchEvent(event);
+ return event;
+}
+
+describe('childMotionDebugInput', () => {
+ test('maps A, D and Space keys to movement and jump actions', () => {
+ const target = createTarget();
+ const actions: ChildMotionDebugAction[] = [];
+ const controller = createChildMotionDebugInputController({
+ target,
+ onAction: (action) => actions.push(action),
+ now: () => 120,
+ });
+
+ const leftEvent = dispatchKeyboard(target, { key: 'a', code: 'KeyA' });
+ dispatchKeyboard(target, { key: 'D', code: 'KeyD' });
+ dispatchKeyboard(target, { key: ' ', code: 'Space' });
+
+ expect(leftEvent.defaultPrevented).toBe(true);
+ expect(actions).toEqual([
+ {
+ kind: 'move',
+ direction: 'left',
+ source: 'keyboard',
+ occurredAtMs: 120,
+ },
+ {
+ kind: 'move',
+ direction: 'right',
+ source: 'keyboard',
+ occurredAtMs: 120,
+ },
+ {
+ kind: 'jump',
+ source: 'keyboard',
+ occurredAtMs: 120,
+ },
+ ]);
+
+ controller.dispose();
+ });
+
+ test('ignores repeated or unrelated keyboard events', () => {
+ const unrelatedEvent = new KeyboardEvent('keydown', {
+ key: 'x',
+ code: 'KeyX',
+ });
+ const repeatEvent = new KeyboardEvent('keydown', {
+ key: 'a',
+ code: 'KeyA',
+ repeat: true,
+ });
+
+ expect(resolveKeyboardDebugAction(unrelatedEvent)).toBeNull();
+ expect(resolveKeyboardDebugAction(repeatEvent)).toBeNull();
+ });
+
+ test('maps left mouse drag to a left hand trajectory', () => {
+ const target = createTarget();
+ const actions: ChildMotionDebugAction[] = [];
+ const controller = createChildMotionDebugInputController({
+ target,
+ onAction: (action) => actions.push(action),
+ now: () => 240,
+ });
+
+ dispatchPointer(target, 'pointerdown', {
+ button: 0,
+ clientX: 10,
+ clientY: 20,
+ pointerId: 7,
+ });
+ dispatchPointer(target, 'pointermove', {
+ clientX: 18,
+ clientY: 24,
+ pointerId: 7,
+ });
+ dispatchPointer(target, 'pointerup', {
+ clientX: 22,
+ clientY: 28,
+ pointerId: 7,
+ });
+
+ expect(actions).toEqual([
+ {
+ kind: 'hand_trace',
+ hand: 'left',
+ phase: 'start',
+ pointerId: 7,
+ point: { x: 10, y: 20 },
+ path: [{ x: 10, y: 20 }],
+ source: 'pointer',
+ occurredAtMs: 240,
+ },
+ {
+ kind: 'hand_trace',
+ hand: 'left',
+ phase: 'move',
+ pointerId: 7,
+ point: { x: 18, y: 24 },
+ path: [
+ { x: 10, y: 20 },
+ { x: 18, y: 24 },
+ ],
+ source: 'pointer',
+ occurredAtMs: 240,
+ },
+ {
+ kind: 'hand_trace',
+ hand: 'left',
+ phase: 'end',
+ pointerId: 7,
+ point: { x: 22, y: 28 },
+ path: [
+ { x: 10, y: 20 },
+ { x: 18, y: 24 },
+ { x: 22, y: 28 },
+ ],
+ source: 'pointer',
+ occurredAtMs: 240,
+ },
+ ]);
+
+ controller.dispose();
+ });
+
+ test('maps right mouse drag to a right hand trajectory and prevents context menu', () => {
+ const target = createTarget();
+ const actions: ChildMotionDebugAction[] = [];
+ const controller = createChildMotionDebugInputController({
+ target,
+ onAction: (action) => actions.push(action),
+ now: () => 360,
+ });
+
+ const pointerDown = dispatchPointer(target, 'pointerdown', {
+ button: 2,
+ clientX: 30,
+ clientY: 40,
+ pointerId: 9,
+ });
+ dispatchPointer(target, 'pointermove', {
+ clientX: 44,
+ clientY: 48,
+ pointerId: 9,
+ });
+ dispatchPointer(target, 'pointercancel', {
+ clientX: 48,
+ clientY: 52,
+ pointerId: 9,
+ });
+ const contextMenuEvent = new MouseEvent('contextmenu', {
+ bubbles: true,
+ cancelable: true,
+ button: 2,
+ });
+ target.dispatchEvent(contextMenuEvent);
+
+ expect(pointerDown.defaultPrevented).toBe(true);
+ expect(contextMenuEvent.defaultPrevented).toBe(true);
+ expect(actions.map((action) => action.kind)).toEqual([
+ 'hand_trace',
+ 'hand_trace',
+ 'hand_trace',
+ ]);
+ expect(actions[0]).toMatchObject({
+ hand: 'right',
+ phase: 'start',
+ point: { x: 30, y: 40 },
+ });
+ expect(actions[2]).toMatchObject({
+ hand: 'right',
+ phase: 'cancel',
+ point: { x: 48, y: 52 },
+ });
+
+ controller.dispose();
+ });
+
+ test('can be disabled or disposed without emitting debug actions', () => {
+ const target = createTarget();
+ const onAction = vi.fn();
+ const controller = createChildMotionDebugInputController({
+ target,
+ onAction,
+ });
+
+ controller.setEnabled(false);
+ expect(controller.isEnabled()).toBe(false);
+ dispatchKeyboard(target, { key: 'a', code: 'KeyA' });
+ dispatchPointer(target, 'pointerdown', {
+ button: 0,
+ clientX: 10,
+ clientY: 20,
+ });
+ expect(onAction).not.toHaveBeenCalled();
+
+ controller.setEnabled(true);
+ dispatchKeyboard(target, { key: 'd', code: 'KeyD' });
+ expect(onAction).toHaveBeenCalledTimes(1);
+
+ controller.dispose();
+ dispatchKeyboard(target, { key: ' ', code: 'Space' });
+ expect(onAction).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/services/child-motion-demo/childMotionDebugInput.ts b/src/services/child-motion-demo/childMotionDebugInput.ts
new file mode 100644
index 00000000..c6ce3b45
--- /dev/null
+++ b/src/services/child-motion-demo/childMotionDebugInput.ts
@@ -0,0 +1,287 @@
+export type ChildMotionDebugMoveDirection = 'left' | 'right';
+export type ChildMotionDebugHand = 'left' | 'right';
+export type ChildMotionDebugHandTracePhase = 'start' | 'move' | 'end' | 'cancel';
+
+export type ChildMotionDebugPoint = {
+ x: number;
+ y: number;
+};
+
+export type ChildMotionDebugMoveAction = {
+ kind: 'move';
+ direction: ChildMotionDebugMoveDirection;
+ source: 'keyboard';
+ occurredAtMs: number;
+};
+
+export type ChildMotionDebugJumpAction = {
+ kind: 'jump';
+ source: 'keyboard';
+ occurredAtMs: number;
+};
+
+export type ChildMotionDebugHandTraceAction = {
+ kind: 'hand_trace';
+ hand: ChildMotionDebugHand;
+ phase: ChildMotionDebugHandTracePhase;
+ pointerId: number;
+ point: ChildMotionDebugPoint;
+ path: ChildMotionDebugPoint[];
+ source: 'pointer';
+ occurredAtMs: number;
+};
+
+export type ChildMotionDebugAction =
+ | ChildMotionDebugMoveAction
+ | ChildMotionDebugJumpAction
+ | ChildMotionDebugHandTraceAction;
+
+type ChildMotionDebugActionPayload =
+ | Omit
+ | Omit
+ | Omit;
+
+export type ChildMotionDebugInputTarget = Pick<
+ EventTarget,
+ 'addEventListener' | 'removeEventListener'
+>;
+
+export type ChildMotionDebugInputOptions = {
+ target: ChildMotionDebugInputTarget;
+ onAction: (action: ChildMotionDebugAction) => void;
+ enabled?: boolean;
+ now?: () => number;
+ preventContextMenu?: boolean;
+};
+
+export type ChildMotionDebugInputController = {
+ dispose: () => void;
+ isEnabled: () => boolean;
+ setEnabled: (enabled: boolean) => void;
+};
+
+type ActiveHandTrace = {
+ hand: ChildMotionDebugHand;
+ path: ChildMotionDebugPoint[];
+};
+
+const DEFAULT_POINTER_ID = 1;
+
+export function createChildMotionDebugInputController(
+ options: ChildMotionDebugInputOptions,
+): ChildMotionDebugInputController {
+ const { target, onAction, now = () => Date.now() } = options;
+ const preventContextMenu = options.preventContextMenu ?? true;
+ const activeHandTraces = new Map();
+ let enabled = options.enabled ?? true;
+
+ const emit = (action: ChildMotionDebugActionPayload) => {
+ onAction({
+ ...action,
+ occurredAtMs: now(),
+ });
+ };
+
+ const handleKeyDown = (event: Event) => {
+ if (!enabled) {
+ return;
+ }
+
+ const action = resolveKeyboardDebugAction(event);
+ if (!action) {
+ return;
+ }
+
+ event.preventDefault();
+ emit(action);
+ };
+
+ const handlePointerDown = (event: Event) => {
+ if (!enabled) {
+ return;
+ }
+
+ const hand = resolvePointerHand(event);
+ if (!hand) {
+ return;
+ }
+
+ event.preventDefault();
+ const pointerId = readPointerId(event);
+ const point = readPointerPoint(event);
+ const trace: ActiveHandTrace = {
+ hand,
+ path: [point],
+ };
+ activeHandTraces.set(pointerId, trace);
+ emit({
+ kind: 'hand_trace',
+ hand,
+ phase: 'start',
+ pointerId,
+ point,
+ path: trace.path,
+ source: 'pointer',
+ });
+ };
+
+ const handlePointerMove = (event: Event) => {
+ if (!enabled) {
+ return;
+ }
+
+ const pointerId = readPointerId(event);
+ const trace = activeHandTraces.get(pointerId);
+ if (!trace) {
+ return;
+ }
+
+ event.preventDefault();
+ const point = readPointerPoint(event);
+ trace.path = [...trace.path, point];
+ activeHandTraces.set(pointerId, trace);
+ emit({
+ kind: 'hand_trace',
+ hand: trace.hand,
+ phase: 'move',
+ pointerId,
+ point,
+ path: trace.path,
+ source: 'pointer',
+ });
+ };
+
+ const finishPointerTrace = (
+ event: Event,
+ phase: Extract,
+ ) => {
+ if (!enabled) {
+ return;
+ }
+
+ const pointerId = readPointerId(event);
+ const trace = activeHandTraces.get(pointerId);
+ if (!trace) {
+ return;
+ }
+
+ event.preventDefault();
+ const point = readPointerPoint(event);
+ const path = [...trace.path, point];
+ activeHandTraces.delete(pointerId);
+ emit({
+ kind: 'hand_trace',
+ hand: trace.hand,
+ phase,
+ pointerId,
+ point,
+ path,
+ source: 'pointer',
+ });
+ };
+
+ const handlePointerUp = (event: Event) => finishPointerTrace(event, 'end');
+ const handlePointerCancel = (event: Event) =>
+ finishPointerTrace(event, 'cancel');
+ const handleContextMenu = (event: Event) => {
+ if (enabled && preventContextMenu) {
+ event.preventDefault();
+ }
+ };
+
+ target.addEventListener('keydown', handleKeyDown);
+ target.addEventListener('pointerdown', handlePointerDown);
+ target.addEventListener('pointermove', handlePointerMove);
+ target.addEventListener('pointerup', handlePointerUp);
+ target.addEventListener('pointercancel', handlePointerCancel);
+ target.addEventListener('contextmenu', handleContextMenu);
+
+ return {
+ dispose: () => {
+ activeHandTraces.clear();
+ target.removeEventListener('keydown', handleKeyDown);
+ target.removeEventListener('pointerdown', handlePointerDown);
+ target.removeEventListener('pointermove', handlePointerMove);
+ target.removeEventListener('pointerup', handlePointerUp);
+ target.removeEventListener('pointercancel', handlePointerCancel);
+ target.removeEventListener('contextmenu', handleContextMenu);
+ },
+ isEnabled: () => enabled,
+ setEnabled: (nextEnabled: boolean) => {
+ enabled = nextEnabled;
+ if (!enabled) {
+ activeHandTraces.clear();
+ }
+ },
+ };
+}
+
+export function resolveKeyboardDebugAction(
+ event: Event,
+):
+ | Omit
+ | Omit
+ | null {
+ const keyboardEvent = event as KeyboardEvent;
+ if (keyboardEvent.repeat) {
+ return null;
+ }
+
+ const normalizedKey = keyboardEvent.key?.toLocaleLowerCase('en-US') ?? '';
+ const normalizedCode = keyboardEvent.code ?? '';
+
+ if (normalizedKey === 'a' || normalizedCode === 'KeyA') {
+ return {
+ kind: 'move',
+ direction: 'left',
+ source: 'keyboard',
+ };
+ }
+
+ if (normalizedKey === 'd' || normalizedCode === 'KeyD') {
+ return {
+ kind: 'move',
+ direction: 'right',
+ source: 'keyboard',
+ };
+ }
+
+ if (
+ keyboardEvent.key === ' ' ||
+ keyboardEvent.key === 'Spacebar' ||
+ normalizedCode === 'Space'
+ ) {
+ return {
+ kind: 'jump',
+ source: 'keyboard',
+ };
+ }
+
+ return null;
+}
+
+function resolvePointerHand(event: Event): ChildMotionDebugHand | null {
+ const button = (event as MouseEvent).button;
+ if (button === 0) {
+ return 'left';
+ }
+
+ if (button === 2) {
+ return 'right';
+ }
+
+ return null;
+}
+
+function readPointerId(event: Event) {
+ const pointerId = (event as PointerEvent).pointerId;
+ return typeof pointerId === 'number' ? pointerId : DEFAULT_POINTER_ID;
+}
+
+function readPointerPoint(event: Event): ChildMotionDebugPoint {
+ const mouseEvent = event as MouseEvent;
+ return {
+ x: mouseEvent.clientX,
+ y: mouseEvent.clientY,
+ };
+}
diff --git a/src/services/child-motion-demo/index.ts b/src/services/child-motion-demo/index.ts
new file mode 100644
index 00000000..3d9cfd0d
--- /dev/null
+++ b/src/services/child-motion-demo/index.ts
@@ -0,0 +1 @@
+export * from './childMotionDebugInput';