diff --git a/src/__tests__/live2d/Cubism2PointerCoordinates.spec.ts b/src/__tests__/live2d/Cubism2PointerCoordinates.spec.ts index 02b9134..38f2651 100644 --- a/src/__tests__/live2d/Cubism2PointerCoordinates.spec.ts +++ b/src/__tests__/live2d/Cubism2PointerCoordinates.spec.ts @@ -1,67 +1,77 @@ import { describe, expect, it } from 'vitest' -import { toCubism2ViewPoint } from '../../components/blog/live2d/runtime/cubism2PointerCoordinates' +import { + toCubism2ModelPoint, + toCubism2PageTarget, +} from '../../components/blog/live2d/runtime/cubism2PointerCoordinates' describe('Cubism2 pointer coordinates', () => { - it('preserves source coordinates for points inside the canvas', () => { - const canvas = { - getBoundingClientRect: () => ({ - bottom: 270, - height: 250, - left: 10, - right: 290, - top: 20, - width: 280, - x: 10, - y: 20, - toJSON: () => ({}), - }), - height: 500, - width: 560, - } + const canvas = { + getBoundingClientRect: () => ({ + bottom: 720, + height: 250, + left: 0, + right: 280, + top: 470, + width: 280, + x: 0, + y: 470, + toJSON: () => ({}), + }), + height: 500, + width: 560, + } - expect(toCubism2ViewPoint(canvas, { clientX: 10, clientY: 20 })).toEqual({ + it('preserves source canvas-local coordinates for model hit testing', () => { + expect(toCubism2ModelPoint(canvas, { clientX: 0, clientY: 470 })).toEqual({ x: -1, y: 500 / 560, }) - expect(toCubism2ViewPoint(canvas, { clientX: 290, clientY: 270 })).toEqual({ + expect(toCubism2ModelPoint(canvas, { clientX: 280, clientY: 720 })).toEqual({ x: 1, y: -500 / 560, }) - expect(toCubism2ViewPoint(canvas, { clientX: 150, clientY: 145 })).toEqual({ + expect(toCubism2ModelPoint(canvas, { clientX: 140, clientY: 595 })).toEqual({ x: 0, y: 0, }) }) - it('projects page-level pointers onto the canvas boundary around the model center', () => { - const canvas = { - getBoundingClientRect: () => ({ - bottom: 270, - height: 250, - left: 10, - right: 290, - top: 20, - width: 280, - x: 10, - y: 20, - toJSON: () => ({}), - }), - height: 500, - width: 560, - } + it('uses the whole viewport as a continuous page-level look-at range', () => { + const viewport = { innerHeight: 720, innerWidth: 1280 } - expect(toCubism2ViewPoint(canvas, { clientX: 1_000, clientY: 145 })).toEqual({ + expect(toCubism2PageTarget(canvas, viewport, { clientX: 140, clientY: 595 })).toEqual({ + x: 0, + y: 0, + }) + expect(toCubism2PageTarget(canvas, viewport, { clientX: 0, clientY: 595 })).toEqual({ + x: -1, + y: 0, + }) + expect(toCubism2PageTarget(canvas, viewport, { clientX: 1280, clientY: 595 })).toEqual({ x: 1, y: 0, }) - expect(toCubism2ViewPoint(canvas, { clientX: 150, clientY: -1_000 })).toEqual({ + expect(toCubism2PageTarget(canvas, viewport, { clientX: 140, clientY: 0 })).toEqual({ x: 0, y: 500 / 560, }) - expect(toCubism2ViewPoint(canvas, { clientX: 710, clientY: -355 })).toEqual({ - x: 1, - y: 500 / 560, + expect(toCubism2PageTarget(canvas, viewport, { clientX: 140, clientY: 720 })).toEqual({ + x: 0, + y: -500 / 560, }) }) + + it('retains page-level distance after the pointer leaves the canvas', () => { + const viewport = { innerHeight: 720, innerWidth: 1280 } + const near = toCubism2PageTarget(canvas, viewport, { clientX: 300, clientY: 595 }) + const middle = toCubism2PageTarget(canvas, viewport, { clientX: 640, clientY: 595 }) + const far = toCubism2PageTarget(canvas, viewport, { clientX: 1200, clientY: 595 }) + + expect(near.x).toBeCloseTo(160 / 1140) + expect(middle.x).toBeCloseTo(500 / 1140) + expect(far.x).toBeCloseTo(1060 / 1140) + expect(near.x).toBeLessThan(middle.x) + expect(middle.x).toBeLessThan(far.x) + }) }) diff --git a/src/components/blog/live2d/runtime/cubism2PointerCoordinates.ts b/src/components/blog/live2d/runtime/cubism2PointerCoordinates.ts index da31153..5d0b0e0 100644 --- a/src/components/blog/live2d/runtime/cubism2PointerCoordinates.ts +++ b/src/components/blog/live2d/runtime/cubism2PointerCoordinates.ts @@ -4,16 +4,41 @@ export interface Cubism2ViewPoint { } type Cubism2PointerCanvas = Pick; type Cubism2ClientPoint = Pick; +type Cubism2PointerViewport = Pick; /** - * Converts a page-level browser pointer into the bounded WordPress Cubism2 view range. - * @param canvas Canvas whose bounds and drawing-buffer dimensions define the view range. + * Converts a canvas interaction into the source Cubism2 model-view coordinates used for hit testing. + * @param canvas Canvas whose bounds and drawing-buffer dimensions define the model-view transform. * @param point Browser client coordinates from a mouse or touch event. - * @returns View point projected from the model center to the canvas boundary when needed. + * @returns Source view point where both axes use the canvas width as the scale denominator. */ -export function toCubism2ViewPoint( +export function toCubism2ModelPoint( canvas: Cubism2PointerCanvas, point: Cubism2ClientPoint, +): Cubism2ViewPoint { + const bounds = canvas.getBoundingClientRect(); + const cssWidth = Math.max(bounds.width, 1); + const cssHeight = Math.max(bounds.height, 1); + const deviceX = (point.clientX - bounds.left) * canvas.width / cssWidth; + const deviceY = (point.clientY - bounds.top) * canvas.height / cssHeight; + const coordinateScale = Math.max(canvas.width, 1); + return { + x: (deviceX - canvas.width / 2) * 2 / coordinateScale, + y: (canvas.height / 2 - deviceY) * 2 / coordinateScale, + }; +} + +/** + * Maps page-wide pointer movement continuously into the Cubism2 look-at range around the model center. + * @param canvas Canvas whose center is the neutral look-at origin and whose aspect ratio limits vertical motion. + * @param viewport Browser viewport whose edges represent the maximum look-at targets in each direction. + * @param point Browser client coordinates from a page-level pointer event. + * @returns Bounded look-at target that retains distance across the whole visible page. + */ +export function toCubism2PageTarget( + canvas: Cubism2PointerCanvas, + viewport: Cubism2PointerViewport, + point: Cubism2ClientPoint, ): Cubism2ViewPoint { const bounds = canvas.getBoundingClientRect(); const cssWidth = Math.max(bounds.width, 1); @@ -22,20 +47,20 @@ export function toCubism2ViewPoint( const centerY = bounds.top + cssHeight / 2; const deltaX = point.clientX - centerX; const deltaY = point.clientY - centerY; - const horizontalProjection = deltaX === 0 - ? Number.POSITIVE_INFINITY - : cssWidth / 2 / Math.abs(deltaX); - const verticalProjection = deltaY === 0 - ? Number.POSITIVE_INFINITY - : cssHeight / 2 / Math.abs(deltaY); - const projectionScale = Math.min(1, horizontalProjection, verticalProjection); - const projectedX = centerX + deltaX * projectionScale; - const projectedY = centerY + deltaY * projectionScale; - const deviceX = (projectedX - bounds.left) * canvas.width / cssWidth; - const deviceY = (projectedY - bounds.top) * canvas.height / cssHeight; - const coordinateScale = Math.max(canvas.width, 1); + const horizontalDistanceToViewportEdge = Math.max( + deltaX < 0 ? centerX : viewport.innerWidth - centerX, + 1, + ); + const verticalDistanceToViewportEdge = Math.max( + deltaY < 0 ? centerY : viewport.innerHeight - centerY, + 1, + ); + const verticalRange = canvas.height / Math.max(canvas.width, 1); + const normalizedY = deltaY === 0 + ? 0 + : -deltaY / verticalDistanceToViewportEdge * verticalRange; return { - x: (deviceX - canvas.width / 2) * 2 / coordinateScale, - y: (canvas.height / 2 - deviceY) * 2 / coordinateScale, + x: Math.max(-1, Math.min(1, deltaX / horizontalDistanceToViewportEdge)), + y: Math.max(-verticalRange, Math.min(verticalRange, normalizedY)), }; } diff --git a/src/components/blog/live2d/runtime/webglLive2DRenderer.ts b/src/components/blog/live2d/runtime/webglLive2DRenderer.ts index bb80420..f904a09 100644 --- a/src/components/blog/live2d/runtime/webglLive2DRenderer.ts +++ b/src/components/blog/live2d/runtime/webglLive2DRenderer.ts @@ -4,7 +4,10 @@ import { type Cubism2ModelAnimator, } from './cubism2ModelAnimator'; import { configureCubism2ModelProjection } from './cubism2ModelProjection'; -import { toCubism2ViewPoint } from './cubism2PointerCoordinates'; +import { + toCubism2ModelPoint, + toCubism2PageTarget, +} from './cubism2PointerCoordinates'; import type { Live2DCoreModel, Live2DRendererAdapter, Live2DResolvedState } from './live2dRuntimeTypes'; import { installCubism2WebGLTextureReleaseHook } from '../vendor/cubism2Core/compatibility/webglTextureRelease'; @@ -104,8 +107,8 @@ export function createWebGLLive2DRenderer(canvas: HTMLCanvasElement): Live2DRend * Updates the smoothed look-at target from a page-level pointer. * @param event Mouse or touch coordinates in the browser client space. */ - const updatePointerTarget = (event: Pick) => { - const point = toCubism2ViewPoint(canvas, event); + const updatePagePointerTarget = (event: Pick) => { + const point = toCubism2PageTarget(canvas, window, event); animator?.setPointerTarget(point.x, point.y); }; @@ -123,8 +126,7 @@ export function createWebGLLive2DRenderer(canvas: HTMLCanvasElement): Live2DRend return; } event.preventDefault(); - const point = toCubism2ViewPoint(canvas, event); - animator?.setPointerTarget(point.x, point.y); + const point = toCubism2ModelPoint(canvas, event); void animator?.startMotionForPoint(point.x, point.y).catch((error: unknown) => { console.warn('[KT Blog] Live2D interaction motion failed.', error); }); @@ -135,7 +137,7 @@ export function createWebGLLive2DRenderer(canvas: HTMLCanvasElement): Live2DRend * @param event Page-level mouse-move event. */ const handleMouseMove = (event: MouseEvent) => { - updatePointerTarget(event); + updatePagePointerTarget(event); }; /** @@ -150,7 +152,7 @@ export function createWebGLLive2DRenderer(canvas: HTMLCanvasElement): Live2DRend const touch = event.touches[0]; if (touch) { touchDragging = true; - const point = toCubism2ViewPoint(canvas, touch); + const point = toCubism2ModelPoint(canvas, touch); animator?.setPointerTarget(point.x, point.y); void animator?.startMotionForPoint(point.x, point.y).catch((error: unknown) => { console.warn('[KT Blog] Live2D interaction motion failed.', error); @@ -171,7 +173,8 @@ export function createWebGLLive2DRenderer(canvas: HTMLCanvasElement): Live2DRend return; } event.preventDefault(); - updatePointerTarget(touch); + const point = toCubism2ModelPoint(canvas, touch); + animator?.setPointerTarget(point.x, point.y); }; /**