316 lines
8.6 KiB
Vue
316 lines
8.6 KiB
Vue
<script setup lang="ts">
|
|
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
|
import { useLoop, useTresContext } from "@tresjs/core";
|
|
import * as THREE from "three";
|
|
|
|
const props = defineProps<{
|
|
topScreenCanvas: HTMLCanvasElement | null;
|
|
bottomScreenCanvas: HTMLCanvasElement | null;
|
|
}>();
|
|
|
|
const { state: model } = useLoader(
|
|
GLTFLoader,
|
|
"/models/nintendo-ds/scene.gltf",
|
|
);
|
|
|
|
const scene = computed(() => model.value?.scene);
|
|
|
|
let topScreenTexture: THREE.CanvasTexture | null = null;
|
|
let bottomScreenTexture: THREE.CanvasTexture | null = null;
|
|
|
|
/// meshes ///
|
|
// screens
|
|
const TOP_SCREEN = "Object_9";
|
|
const BOTTOM_SCREEN = "Object_28";
|
|
|
|
// buttons
|
|
const CROSS_BUTTON = "Object_21";
|
|
const X_BUTTON = "Object_6";
|
|
const A_BUTTON = "Object_32";
|
|
const Y_BUTTON = "Object_4";
|
|
const B_BUTTON = "Object_30";
|
|
const SELECT_BUTTON = "Object_17";
|
|
const START_BUTTON = "Object_11";
|
|
|
|
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();
|
|
|
|
watch(
|
|
() => [props.topScreenCanvas, props.bottomScreenCanvas],
|
|
() => {
|
|
if (!props.topScreenCanvas || !props.bottomScreenCanvas) return;
|
|
|
|
topScreenTexture = new THREE.CanvasTexture(props.topScreenCanvas);
|
|
topScreenTexture.minFilter = THREE.NearestFilter;
|
|
topScreenTexture.magFilter = THREE.NearestFilter;
|
|
topScreenTexture.flipY = false;
|
|
topScreenTexture.repeat.set(1, 1024 / 404);
|
|
topScreenTexture.offset.set(0, -4 / 1024);
|
|
|
|
bottomScreenTexture = new THREE.CanvasTexture(props.bottomScreenCanvas);
|
|
bottomScreenTexture.minFilter = THREE.NearestFilter;
|
|
bottomScreenTexture.magFilter = THREE.NearestFilter;
|
|
bottomScreenTexture.flipY = false;
|
|
bottomScreenTexture.repeat.set(1, 1024 / 532);
|
|
bottomScreenTexture.offset.set(0, -1024 / 532 + 1);
|
|
},
|
|
{ immediate: true },
|
|
);
|
|
|
|
watch(scene, () => {
|
|
if (!scene.value) return;
|
|
|
|
meshes.clear();
|
|
|
|
scene.value.scale.set(100, 100, 100);
|
|
|
|
scene.value.traverse((child) => {
|
|
if (child instanceof THREE.Mesh) {
|
|
meshes.set(child.name, child);
|
|
}
|
|
});
|
|
|
|
if (!topScreenTexture || !bottomScreenTexture)
|
|
throw new Error(
|
|
"topScreenTexture and bottomScreenTexture should be initialized",
|
|
);
|
|
|
|
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,
|
|
});
|
|
});
|
|
|
|
const { onRender } = useLoop();
|
|
|
|
const physicalButtonsDown = new Set<string>();
|
|
let mousePressedButton: string | null = null;
|
|
|
|
const keyToButton: Record<string, string> = {
|
|
ArrowUp: "UP",
|
|
ArrowDown: "DOWN",
|
|
ArrowLeft: "LEFT",
|
|
ArrowRight: "RIGHT",
|
|
d: "A",
|
|
s: "B",
|
|
w: "X",
|
|
a: "Y",
|
|
" ": "SELECT",
|
|
Enter: "START",
|
|
};
|
|
|
|
useKeyDown((key) => {
|
|
const button = keyToButton[key];
|
|
if (button) {
|
|
physicalButtonsDown.add(button);
|
|
window.dispatchEvent(
|
|
new KeyboardEvent("keydown", { key: `NDS_${button}` }),
|
|
);
|
|
}
|
|
});
|
|
|
|
useKeyUp((key) => {
|
|
const button = keyToButton[key];
|
|
if (button) {
|
|
physicalButtonsDown.delete(button);
|
|
window.dispatchEvent(new KeyboardEvent("keyup", { key: `NDS_${button}` }));
|
|
}
|
|
});
|
|
|
|
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 handleClick = (event: MouseEvent) => {
|
|
if (!scene.value) return;
|
|
|
|
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,
|
|
),
|
|
camera.activeCamera.value,
|
|
);
|
|
|
|
const intersects = raycaster.intersectObjects(scene.value.children, true);
|
|
const intersection = intersects[0];
|
|
if (!intersection?.uv) return;
|
|
|
|
switch (intersection.object.name) {
|
|
case TOP_SCREEN:
|
|
case BOTTOM_SCREEN: {
|
|
const canvas =
|
|
intersection.object.name === TOP_SCREEN
|
|
? props.topScreenCanvas
|
|
: props.bottomScreenCanvas;
|
|
|
|
if (!canvas) break;
|
|
|
|
const x = Math.floor(intersection.uv.x * 256);
|
|
let y: number;
|
|
|
|
if (intersection.object.name === TOP_SCREEN) {
|
|
y = Math.floor(intersection.uv.y * (1024 / 404) * 192);
|
|
} else {
|
|
y = Math.floor(192 - (1 - intersection.uv.y) * (1024 / 532) * 192);
|
|
}
|
|
|
|
const rect = canvas.getBoundingClientRect();
|
|
canvas.dispatchEvent(
|
|
new MouseEvent("click", {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
clientX: (x / 256) * rect.width + rect.left,
|
|
clientY: (y / 192) * rect.height + rect.top,
|
|
}),
|
|
);
|
|
|
|
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;
|
|
}
|
|
|
|
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];
|
|
if (button) pressButton(button);
|
|
break;
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleMouseUp = () => {
|
|
if (mousePressedButton) {
|
|
physicalButtonsDown.delete(mousePressedButton);
|
|
|
|
window.dispatchEvent(
|
|
new KeyboardEvent("keyup", { key: `NDS_${mousePressedButton}` }),
|
|
);
|
|
|
|
mousePressedButton = null;
|
|
}
|
|
};
|
|
|
|
onMounted(() => {
|
|
if (renderer) {
|
|
renderer.instance.domElement.addEventListener("mousedown", handleClick);
|
|
renderer.instance.domElement.addEventListener("mouseup", handleMouseUp);
|
|
}
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
if (renderer) {
|
|
renderer.instance.domElement.removeEventListener("mousedown", handleClick);
|
|
renderer.instance.domElement.removeEventListener("mouseup", handleMouseUp);
|
|
}
|
|
topScreenTexture?.dispose();
|
|
bottomScreenTexture?.dispose();
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<primitive v-if="scene" :object="scene" />
|
|
</template>
|