From 63d04008da748216f88b172b29de3fd86c382b23 Mon Sep 17 00:00:00 2001 From: Pihkaal Date: Thu, 12 Feb 2026 22:31:13 +0100 Subject: [PATCH] feat(nds): 3d touch support --- app/components/NDS.vue | 169 +++++++++++++++++++++++++++++--------- app/components/Screen.vue | 4 +- 2 files changed, 131 insertions(+), 42 deletions(-) diff --git a/app/components/NDS.vue b/app/components/NDS.vue index 0d9d1c1..8d262bf 100644 --- a/app/components/NDS.vue +++ b/app/components/NDS.vue @@ -274,15 +274,15 @@ const pressButton = (button: string) => { window.dispatchEvent(new KeyboardEvent("keydown", { key: `NDS_${button}` })); }; -const raycast = (event: MouseEvent) => { +const raycast = (clientX: number, clientY: number) => { const domElement = renderer.instance.domElement; const rect = domElement.getBoundingClientRect(); const raycaster = new THREE.Raycaster(); raycaster.setFromCamera( new THREE.Vector2( - ((event.clientX - rect.left) / rect.width) * 2 - 1, - -((event.clientY - rect.top) / rect.height) * 2 + 1, + ((clientX - rect.left) / rect.width) * 2 - 1, + -((clientY - rect.top) / rect.height) * 2 + 1, ), camera.activeCamera.value, ); @@ -297,41 +297,80 @@ const getScreenCanvas = (name: string) => { return null; }; -const dispatchScreenEvent = ( - type: string, +const toScreenCoords = ( canvas: HTMLCanvasElement, intersection: THREE.Intersection, ) => { - if (!intersection.uv) return; + if (!intersection.uv) return null; const logicalX = (1 - intersection.uv.x) * LOGICAL_WIDTH; const logicalY = (1 - intersection.uv.y) * LOGICAL_HEIGHT; const rect = canvas.getBoundingClientRect(); + return { + clientX: (logicalX / LOGICAL_WIDTH) * rect.width + rect.left, + clientY: (logicalY / LOGICAL_HEIGHT) * rect.height + rect.top, + }; +}; + +const dispatchScreenEvent = ( + type: string, + canvas: HTMLCanvasElement, + intersection: THREE.Intersection, +) => { + const coords = toScreenCoords(canvas, intersection); + if (!coords) return; + canvas.dispatchEvent( - new MouseEvent(type, { + new MouseEvent(type, { bubbles: true, cancelable: true, ...coords }), + ); +}; + +const dispatchScreenTouchEvent = ( + type: string, + canvas: HTMLCanvasElement, + intersection: THREE.Intersection, +) => { + const coords = toScreenCoords(canvas, intersection); + if (!coords) return; + + const touch = new Touch({ identifier: 0, target: canvas, ...coords }); + + canvas.dispatchEvent( + new TouchEvent(type, { bubbles: true, cancelable: true, - clientX: (logicalX / LOGICAL_WIDTH) * rect.width + rect.left, - clientY: (logicalY / LOGICAL_HEIGHT) * rect.height + rect.top, + touches: type === "touchend" ? [] : [touch], + changedTouches: [touch], }), ); }; -const handleMouseDown = (event: MouseEvent) => { - if (!hasAnimated.value) { - animateIntro(); - return; - } +const BUTTON_MAP = { + [X_BUTTON]: "X", + [A_BUTTON]: "A", + [Y_BUTTON]: "Y", + [B_BUTTON]: "B", + [SELECT_BUTTON]: "SELECT", + [START_BUTTON]: "START", +} as const; - const intersection = raycast(event); +const handleInteraction = ( + clientX: number, + clientY: number, + onScreen: ( + canvas: HTMLCanvasElement, + intersection: THREE.Intersection, + ) => void, +) => { + const intersection = raycast(clientX, clientY); if (!intersection?.uv) return; switch (intersection.object.name) { case TOP_SCREEN: case BOTTOM_SCREEN: { const canvas = getScreenCanvas(intersection.object.name); - if (canvas) dispatchScreenEvent("mousedown", canvas, intersection); + if (canvas) onScreen(canvas, intersection); break; } @@ -352,47 +391,84 @@ const handleMouseDown = (event: MouseEvent) => { break; } - case X_BUTTON: - case A_BUTTON: - case Y_BUTTON: - case B_BUTTON: - case SELECT_BUTTON: - case START_BUTTON: { - const BUTTON_MAP = { - [X_BUTTON]: "X", - [A_BUTTON]: "A", - [Y_BUTTON]: "Y", - [B_BUTTON]: "B", - [SELECT_BUTTON]: "SELECT", - [START_BUTTON]: "START", - } as const; - - const button = BUTTON_MAP[intersection.object.name]; + default: { + const button = + BUTTON_MAP[intersection.object.name as keyof typeof BUTTON_MAP]; if (button) pressButton(button); break; } } }; +const releaseButton = () => { + if (!mousePressedButton) return; + + physicalButtonsDown.delete(mousePressedButton); + window.dispatchEvent( + new KeyboardEvent("keyup", { key: `NDS_${mousePressedButton}` }), + ); + mousePressedButton = null; +}; + +const handleMouseDown = (event: MouseEvent) => { + if (!hasAnimated.value) { + animateIntro(); + return; + } + + handleInteraction(event.clientX, event.clientY, (canvas, intersection) => { + dispatchScreenEvent("mousedown", canvas, intersection); + }); +}; + const handleClick = (event: MouseEvent) => { if (!hasAnimated.value) return; - const intersection = raycast(event); + const intersection = raycast(event.clientX, event.clientY); if (!intersection?.uv) return; const canvas = getScreenCanvas(intersection.object.name); if (canvas) dispatchScreenEvent("click", canvas, intersection); }; -const handleMouseUp = () => { - if (mousePressedButton) { - physicalButtonsDown.delete(mousePressedButton); +const handleMouseUp = (event: MouseEvent) => { + releaseButton(); - window.dispatchEvent( - new KeyboardEvent("keyup", { key: `NDS_${mousePressedButton}` }), - ); + const intersection = raycast(event.clientX, event.clientY); + if (!intersection?.uv) return; - mousePressedButton = null; + const canvas = getScreenCanvas(intersection.object.name); + if (canvas) dispatchScreenEvent("mouseup", canvas, intersection); +}; + +const handleTouchStart = (event: TouchEvent) => { + const touch = event.touches[0]; + if (!touch) return; + + if (!hasAnimated.value) { + animateIntro(); + return; + } + + handleInteraction(touch.clientX, touch.clientY, (canvas, intersection) => { + dispatchScreenEvent("mousedown", canvas, intersection); + dispatchScreenTouchEvent("touchstart", canvas, intersection); + }); +}; + +const handleTouchEnd = (event: TouchEvent) => { + releaseButton(); + + const touch = event.changedTouches[0]; + if (!touch) return; + + const intersection = raycast(touch.clientX, touch.clientY); + if (!intersection?.uv) return; + + const canvas = getScreenCanvas(intersection.object.name); + if (canvas) { + dispatchScreenEvent("click", canvas, intersection); + dispatchScreenTouchEvent("touchend", canvas, intersection); } }; @@ -403,6 +479,11 @@ onMounted(() => { renderer.instance.domElement.addEventListener("mousedown", handleMouseDown); renderer.instance.domElement.addEventListener("click", handleClick); renderer.instance.domElement.addEventListener("mouseup", handleMouseUp); + renderer.instance.domElement.addEventListener( + "touchstart", + handleTouchStart, + ); + renderer.instance.domElement.addEventListener("touchend", handleTouchEnd); } }); @@ -414,6 +495,14 @@ onUnmounted(() => { ); renderer.instance.domElement.removeEventListener("click", handleClick); renderer.instance.domElement.removeEventListener("mouseup", handleMouseUp); + renderer.instance.domElement.removeEventListener( + "touchstart", + handleTouchStart, + ); + renderer.instance.domElement.removeEventListener( + "touchend", + handleTouchEnd, + ); } topScreenTexture?.dispose(); bottomScreenTexture?.dispose(); diff --git a/app/components/Screen.vue b/app/components/Screen.vue index f67cc9e..1c60e6d 100644 --- a/app/components/Screen.vue +++ b/app/components/Screen.vue @@ -178,7 +178,7 @@ onMounted(() => { }); canvas.value.addEventListener("touchend", handleTouchEnd, { passive: true }); canvas.value.addEventListener("mousedown", handleSwipeMouseDown); - document.addEventListener("mouseup", handleSwipeMouseUp); + canvas.value.addEventListener("mouseup", handleSwipeMouseUp); animationFrameId = requestAnimationFrame(renderFrame); }); @@ -195,8 +195,8 @@ onUnmounted(() => { canvas.value.removeEventListener("touchstart", handleTouchStart); canvas.value.removeEventListener("touchend", handleTouchEnd); canvas.value.removeEventListener("mousedown", handleSwipeMouseDown); + canvas.value.removeEventListener("mouseup", handleSwipeMouseUp); } - document.removeEventListener("mouseup", handleSwipeMouseUp); }); defineExpose({