Files
pihkaal-me/app/components/NDS3D.vue

532 lines
14 KiB
Vue

<script setup lang="ts">
import { useLoop, useTresContext } from "@tresjs/core";
import * as THREE from "three";
import gsap from "gsap";
const INTRO_ANIMATION = {
MODEL_SPIN_DURATION: 3,
LID_OPEN_DURATION: 2,
LID_OPEN_OVERLAP: 1.5,
CAMERA_START_POSITION: new THREE.Vector3(0, 15, 2.6),
CAMERA_START_ROTATION: new THREE.Euler(THREE.MathUtils.degToRad(-90), 0, 0),
CAMERA_END_POSITION: new THREE.Vector3(0, 14, 10),
CAMERA_END_ROTATION: new THREE.Euler(THREE.MathUtils.degToRad(-55), 0, 0),
CAMERA_DURATION: 3,
LID_CLOSED_ROTATION: THREE.MathUtils.degToRad(120),
LID_OPENED_ROTATION: THREE.MathUtils.degToRad(-30),
};
const props = defineProps<{
topScreenCanvas: HTMLCanvasElement | null;
bottomScreenCanvas: HTMLCanvasElement | null;
}>();
const { assets } = useAssets();
const app = useAppStore();
const model = assets.models.nitendoDs.model.clone(true);
let topScreenTexture: THREE.CanvasTexture | null = null;
let bottomScreenTexture: THREE.CanvasTexture | null = null;
/// meshes ///
// screens
const TOP_SCREEN = "top_screen";
const BOTTOM_SCREEN = "bottom_screen";
const LID = "lid";
// buttons
const CROSS_BUTTON = "button_pad";
const X_BUTTON = "button_x";
const A_BUTTON = "button_a";
const Y_BUTTON = "button_y";
const B_BUTTON = "button_b";
const SELECT_BUTTON = "button_select";
const START_BUTTON = "button_start";
const meshes = new Map<string, THREE.Mesh>();
const requireMesh = (key: string): THREE.Mesh => {
const mesh = meshes.get(key);
if (!mesh) throw new Error(`Missing mesh '${key}'`);
return mesh;
};
const { camera, renderer } = useTresContext();
model.scale.set(100, 100, 100);
watch(
() => camera.activeCamera.value,
(cam) => {
if (cam) {
app.setCamera(cam);
if (app.booted) {
cam.position.copy(INTRO_ANIMATION.CAMERA_END_POSITION);
cam.rotation.copy(INTRO_ANIMATION.CAMERA_END_ROTATION);
} else {
cam.position.copy(INTRO_ANIMATION.CAMERA_START_POSITION);
cam.rotation.copy(INTRO_ANIMATION.CAMERA_START_ROTATION);
}
}
},
{ immediate: true },
);
meshes.clear();
model.traverse((child) => {
if (child instanceof THREE.Mesh) {
meshes.set(child.name, child);
}
});
const lidMesh = requireMesh(LID);
const topScreenMesh = requireMesh(TOP_SCREEN);
if (app.booted) {
lidMesh.rotation.x = INTRO_ANIMATION.LID_OPENED_ROTATION;
topScreenMesh.rotation.x = INTRO_ANIMATION.LID_OPENED_ROTATION;
} else {
lidMesh.rotation.x = INTRO_ANIMATION.LID_CLOSED_ROTATION;
topScreenMesh.rotation.x = INTRO_ANIMATION.LID_CLOSED_ROTATION;
}
const hasAnimated = ref(app.booted);
const animateIntro = () => {
if (hasAnimated.value) return;
hasAnimated.value = true;
const introTimeline = gsap.timeline();
introTimeline.to(
camera.activeCamera.value.position,
{
x: INTRO_ANIMATION.CAMERA_END_POSITION.x,
y: INTRO_ANIMATION.CAMERA_END_POSITION.y,
z: INTRO_ANIMATION.CAMERA_END_POSITION.z,
duration: INTRO_ANIMATION.CAMERA_DURATION,
ease: "power2.inOut",
},
0,
);
introTimeline.to(
camera.activeCamera.value.rotation,
{
x: INTRO_ANIMATION.CAMERA_END_ROTATION.x,
y: INTRO_ANIMATION.CAMERA_END_ROTATION.y,
z: INTRO_ANIMATION.CAMERA_END_ROTATION.z,
duration: INTRO_ANIMATION.CAMERA_DURATION,
ease: "power2.inOut",
},
0,
);
introTimeline.to(
model.rotation,
{
y: Math.PI * 2,
duration: INTRO_ANIMATION.MODEL_SPIN_DURATION,
ease: "power2.inOut",
},
0,
);
introTimeline.to(
lidMesh.rotation,
{
x: INTRO_ANIMATION.LID_OPENED_ROTATION,
duration: INTRO_ANIMATION.LID_OPEN_DURATION,
ease: "power2.out",
},
INTRO_ANIMATION.MODEL_SPIN_DURATION - INTRO_ANIMATION.LID_OPEN_OVERLAP,
);
introTimeline.to(
topScreenMesh.rotation,
{
x: INTRO_ANIMATION.LID_OPENED_ROTATION,
duration: INTRO_ANIMATION.LID_OPEN_DURATION,
ease: "power2.out",
},
"<",
);
};
watch(
() => [props.topScreenCanvas, props.bottomScreenCanvas],
() => {
if (!props.topScreenCanvas || !props.bottomScreenCanvas) return;
const webglRenderer = renderer.instance;
if (!(webglRenderer instanceof THREE.WebGLRenderer)) return;
const createScreenTexture = (
canvas: HTMLCanvasElement,
): THREE.CanvasTexture => {
const texture = new THREE.CanvasTexture(canvas);
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.colorSpace = THREE.SRGBColorSpace;
texture.wrapS = THREE.RepeatWrapping;
texture.repeat.x = -1;
texture.anisotropy = webglRenderer.capabilities.getMaxAnisotropy();
texture.generateMipmaps = false;
return texture;
};
topScreenTexture = createScreenTexture(props.topScreenCanvas);
bottomScreenTexture = createScreenTexture(props.bottomScreenCanvas);
requireMesh(TOP_SCREEN).material = new THREE.MeshStandardMaterial({
map: topScreenTexture,
emissive: new THREE.Color(0x222222),
emissiveIntensity: 0.5,
});
requireMesh(BOTTOM_SCREEN).material = new THREE.MeshStandardMaterial({
map: bottomScreenTexture,
emissive: new THREE.Color(0x222222),
emissiveIntensity: 0.5,
});
},
{ immediate: true },
);
const { onRender } = useLoop();
const physicalButtonsDown = new Set<string>();
let mousePressedButton: string | null = null;
useKeyDown(({ ndsButton }) => {
if (ndsButton) {
physicalButtonsDown.add(ndsButton);
}
});
useKeyUp(({ ndsButton }) => {
if (ndsButton) {
physicalButtonsDown.delete(ndsButton);
}
});
onRender(({ delta }) => {
if (topScreenTexture) topScreenTexture.needsUpdate = true;
if (bottomScreenTexture) bottomScreenTexture.needsUpdate = true;
// cross
const crossButton = meshes.get(CROSS_BUTTON);
if (crossButton) {
const PRESS_ANGLE = Math.PI / 28;
const PRESS_SPEED = 150;
const targetRotation = new THREE.Vector3(0);
if (physicalButtonsDown.has("UP")) targetRotation.setX(-PRESS_ANGLE);
if (physicalButtonsDown.has("RIGHT")) targetRotation.setZ(-PRESS_ANGLE);
if (physicalButtonsDown.has("DOWN")) targetRotation.setX(PRESS_ANGLE);
if (physicalButtonsDown.has("LEFT")) targetRotation.setZ(PRESS_ANGLE);
const lerpFactor = Math.min(delta * PRESS_SPEED, 1);
const currentRotation = new THREE.Vector3().setFromEuler(
crossButton.rotation,
);
currentRotation.lerp(targetRotation, lerpFactor);
crossButton.rotation.setFromVector3(currentRotation);
}
// action buttons
const PRESS_SPEED = 100;
const ACTION_BUTTONS = [
{ mesh: meshes.get(X_BUTTON), name: "X", offset: -0.0012 },
{ mesh: meshes.get(A_BUTTON), name: "A", offset: -0.0012 },
{ mesh: meshes.get(Y_BUTTON), name: "Y", offset: -0.0012 },
{ mesh: meshes.get(B_BUTTON), name: "B", offset: -0.0012 },
{ mesh: meshes.get(SELECT_BUTTON), name: "SELECT", offset: -0.0007 },
{ mesh: meshes.get(START_BUTTON), name: "START", offset: -0.0007 },
] as const;
for (const { mesh, name, offset } of ACTION_BUTTONS) {
if (!mesh) continue;
const targetY = physicalButtonsDown.has(name) ? offset : 0;
const lerpFactor = Math.min(delta * PRESS_SPEED, 1);
mesh.position.y += (targetY - mesh.position.y) * lerpFactor;
}
});
const pressButton = (button: string) => {
if (mousePressedButton) {
physicalButtonsDown.delete(mousePressedButton);
window.dispatchEvent(
new KeyboardEvent("keyup", { key: `NDS_${mousePressedButton}` }),
);
}
physicalButtonsDown.add(button);
mousePressedButton = button;
window.dispatchEvent(new KeyboardEvent("keydown", { key: `NDS_${button}` }));
};
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(
((clientX - rect.left) / rect.width) * 2 - 1,
-((clientY - rect.top) / rect.height) * 2 + 1,
),
camera.activeCamera.value,
);
const intersects = raycaster.intersectObjects(model.children, true);
return intersects[0];
};
const getScreenCanvas = (name: string) => {
if (name === TOP_SCREEN) return props.topScreenCanvas;
if (name === BOTTOM_SCREEN) return props.bottomScreenCanvas;
return null;
};
const toScreenCoords = (
canvas: HTMLCanvasElement,
intersection: THREE.Intersection,
) => {
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, { bubbles: true, cancelable: true, ...coords }),
);
};
const BUTTON_MAP = {
[X_BUTTON]: "X",
[A_BUTTON]: "A",
[Y_BUTTON]: "Y",
[B_BUTTON]: "B",
[SELECT_BUTTON]: "SELECT",
[START_BUTTON]: "START",
} as const;
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) onScreen(canvas, intersection);
break;
}
case CROSS_BUTTON: {
const localPos = intersection.point
.clone()
.sub(intersection.object.getWorldPosition(new THREE.Vector3()));
const MIN_DIST = 0.45;
let button: string | null = null;
if (localPos.z <= -MIN_DIST) button = "UP";
else if (localPos.x >= MIN_DIST) button = "RIGHT";
else if (localPos.z >= MIN_DIST) button = "DOWN";
else if (localPos.x <= -MIN_DIST) button = "LEFT";
if (button) pressButton(button);
break;
}
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.clientX, event.clientY);
if (!intersection?.uv) return;
const canvas = getScreenCanvas(intersection.object.name);
if (canvas) dispatchScreenEvent("click", canvas, intersection);
};
const handleMouseUp = (event: MouseEvent) => {
releaseButton();
const intersection = raycast(event.clientX, event.clientY);
if (!intersection?.uv) return;
const canvas = getScreenCanvas(intersection.object.name);
if (canvas) dispatchScreenEvent("mouseup", canvas, intersection);
};
const SWIPE_THRESHOLD = 30;
let swipeStartX = 0;
let swipeStartY = 0;
const dispatchSwipe = (endX: number, endY: number) => {
const deltaX = endX - swipeStartX;
const deltaY = endY - swipeStartY;
const absDeltaX = Math.abs(deltaX);
const absDeltaY = Math.abs(deltaY);
if (Math.max(absDeltaX, absDeltaY) < SWIPE_THRESHOLD) return;
const direction =
absDeltaX > absDeltaY
? deltaX > 0
? "RIGHT"
: "LEFT"
: deltaY > 0
? "DOWN"
: "UP";
window.dispatchEvent(
new KeyboardEvent("keydown", { key: `NDS_SWIPE_${direction}` }),
);
};
const handleTouchStart = (event: TouchEvent) => {
const touch = event.touches[0];
if (!touch) return;
event.preventDefault();
swipeStartX = touch.clientX;
swipeStartY = touch.clientY;
if (!hasAnimated.value) {
animateIntro();
return;
}
handleInteraction(touch.clientX, touch.clientY, (canvas, intersection) => {
dispatchScreenEvent("mousedown", canvas, intersection);
});
};
const handleTouchEnd = (event: TouchEvent) => {
releaseButton();
const touch = event.changedTouches[0];
if (!touch) return;
dispatchSwipe(touch.clientX, touch.clientY);
const intersection = raycast(touch.clientX, touch.clientY);
if (!intersection?.uv) return;
const canvas = getScreenCanvas(intersection.object.name);
if (canvas) {
dispatchScreenEvent("click", canvas, intersection);
}
document.dispatchEvent(
new MouseEvent("mouseup", {
clientX: touch.clientX,
clientY: touch.clientY,
}),
);
};
onMounted(() => {
app.ready = true;
if (renderer) {
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);
}
});
onUnmounted(() => {
if (renderer) {
renderer.instance.domElement.removeEventListener(
"mousedown",
handleMouseDown,
);
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();
});
</script>
<template>
<primitive :object="model" />
</template>