import * as THREE from "three"; import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js"; const SCREEN_SOURCE_TEX_SIZE = 1024; const TOP_SCREEN_SOURCE_TEX_HEIGHT = SCREEN_SOURCE_TEX_SIZE / 404; const BOT_SCREEN_SOURCE_TEX_HEIGHT = SCREEN_SOURCE_TEX_SIZE / 532; const createScreenCanvas = () => { const canvas = document.createElement("canvas"); canvas.width = 256; canvas.height = 192; const ctx = canvas.getContext("2d"); if (!ctx) throw new Error( "No 2d rendering context, are you visiting this website on a potato?", ); ctx.imageSmoothingEnabled = false; return canvas; }; type Screen = { mesh: THREE.Mesh; canvas: HTMLCanvasElement; texture: THREE.CanvasTexture; }; export class NDS extends THREE.Object3D { private botScreen: Screen | null = null; private topScreen: Screen | null = null; public constructor(camera: THREE.Camera, domElement: HTMLCanvasElement) { super(); const loader = new GLTFLoader(); // load model loader.load("/nintendo-ds/scene.gltf", ({ scene: model }) => { model.scale.set(50, 50, 50); let topScreenMesh: THREE.Mesh | null = null; let botScreenMesh: THREE.Mesh | null = null; // find top and bottom screens const queue: THREE.Object3D[] = [model]; for (let i = 0; i < queue.length; i++) { const child = queue[i]; if (child instanceof THREE.Mesh) { const material = child.material as THREE.Material; if (material.name?.includes("screen_up")) { topScreenMesh = child; } else if (material.name?.includes("screen_down")) { botScreenMesh = child; } } for (let j = 0; j < child.children.length; j++) { queue.push(child.children[j]); } } if (!topScreenMesh) throw new Error(`Missing top screen mesh`); if (!botScreenMesh) throw new Error(`Missing bottom screen mesh`); // top screen { const canvas = createScreenCanvas(); const texture = new THREE.CanvasTexture(canvas); texture.minFilter = THREE.NearestFilter; texture.magFilter = THREE.NearestFilter; texture.flipY = false; texture.repeat.set(1, TOP_SCREEN_SOURCE_TEX_HEIGHT); texture.offset.set(0, -4 / 1024); topScreenMesh.material = new THREE.MeshStandardMaterial({ map: texture, emissive: new THREE.Color(0x222222), emissiveIntensity: 0.5, }); this.topScreen = { mesh: topScreenMesh, canvas, texture }; } // bottom screen { const canvas = createScreenCanvas(); const texture = new THREE.CanvasTexture(canvas); texture.minFilter = THREE.NearestFilter; texture.magFilter = THREE.NearestFilter; texture.flipY = false; texture.repeat.set(1, BOT_SCREEN_SOURCE_TEX_HEIGHT); texture.offset.set(0, -BOT_SCREEN_SOURCE_TEX_HEIGHT + 1); botScreenMesh.material = new THREE.MeshStandardMaterial({ map: texture, emissive: new THREE.Color(0x222222), emissiveIntensity: 0.5, }); this.botScreen = { mesh: topScreenMesh, canvas, texture }; } domElement.addEventListener("mousemove", (event) => { 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, ); const intersects = raycaster.intersectObjects([ topScreenMesh, botScreenMesh, ]); if (intersects.length > 0) { const intersection = intersects[0]; const mesh = intersection.object as THREE.Mesh; const uv = intersection.uv; if (uv) { const x = Math.floor(uv.x * 256); const y = Math.floor( mesh === topScreenMesh ? uv.y * TOP_SCREEN_SOURCE_TEX_HEIGHT * 192 : // invert coords only for bottom screen 192 - (1 - uv.y) * BOT_SCREEN_SOURCE_TEX_HEIGHT * 192, ); x; y; } } }); super.add(model); }); } public update(): void { if (this.topScreen) { this.topScreen.texture.needsUpdate = true; } if (this.botScreen) { this.botScreen.texture.needsUpdate = true; } } }