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:
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