From 48688544f15e9a9ac37d4aa9a58ed21118b10102 Mon Sep 17 00:00:00 2001 From: Pihkaal Date: Sun, 22 Feb 2026 21:16:13 +0100 Subject: [PATCH] feat(nds): add help button with hints for all physical buttons --- app/components/NDS2D.vue | 116 ++++++------------- app/components/NDS3D.vue | 234 +++++++++++++++++++++++++++++---------- app/pages/index.vue | 160 ++++++++++++++++++++++---- app/stores/app.ts | 20 ++++ app/stores/gallery.ts | 2 + app/stores/intro.ts | 6 +- 6 files changed, 377 insertions(+), 161 deletions(-) diff --git a/app/components/NDS2D.vue b/app/components/NDS2D.vue index e9b5a80..f0a43e8 100644 --- a/app/components/NDS2D.vue +++ b/app/components/NDS2D.vue @@ -1,9 +1,9 @@ @@ -602,25 +569,4 @@ onUnmounted(() => { bottom: 28px; transform: translateX(-100%); } - -.nds2d-help-btn { - position: fixed; - bottom: 16px; - left: 16px; - width: 30px; - height: 30px; - border: none; - border-radius: 50%; - background: rgba(255, 255, 255, 0.1); - color: #666; - font-size: 18px; - cursor: pointer; - opacity: 0.5; - transition: opacity 0.2s; - user-select: none; -} - -.nds2d-help-btn:hover { - opacity: 1; -} diff --git a/app/components/NDS3D.vue b/app/components/NDS3D.vue index 38e8e22..7173368 100644 --- a/app/components/NDS3D.vue +++ b/app/components/NDS3D.vue @@ -2,6 +2,7 @@ import { useLoop, useTresContext } from "@tresjs/core"; import * as THREE from "three"; import gsap from "gsap"; +import { mapNDSToKey } from "~/utils/input"; const INTRO_ANIMATION = { MODEL_SPIN_DURATION: 3, @@ -55,7 +56,7 @@ const requireMesh = (key: string): THREE.Mesh => { return mesh; }; -const { camera, renderer } = useTresContext(); +const { camera, renderer, scene } = useTresContext(); model.scale.set(100, 100, 100); @@ -101,61 +102,57 @@ 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", - }, - "<", - ); + gsap + .timeline() + .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, + ) + .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, + ) + .to( + model.rotation, + { + y: Math.PI * 2, + duration: INTRO_ANIMATION.MODEL_SPIN_DURATION, + ease: "power2.inOut", + }, + 0, + ) + .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, + ) + .to( + topScreenMesh.rotation, + { + x: INTRO_ANIMATION.LID_OPENED_ROTATION, + duration: INTRO_ANIMATION.LID_OPEN_DURATION, + ease: "power2.out", + }, + "<", + ); }; watch( @@ -200,6 +197,131 @@ watch( const { onRender } = useLoop(); +const HINT_SPRITE_SCALE = 2; +const HINT_SPRITE_DPR = 4; + +const makeHintSprite = (text: string): THREE.Sprite => { + const canvas = document.createElement("canvas"); + canvas.width = 256 * HINT_SPRITE_DPR; + canvas.height = 64 * HINT_SPRITE_DPR; + + const ctx = canvas.getContext("2d")!; + + ctx.scale(HINT_SPRITE_DPR, HINT_SPRITE_DPR); + ctx.font = "32px monospace"; + ctx.fillStyle = "#666666"; + + const padding = 12; + const textWidth = ctx.measureText(text).width + padding * 2; + ctx.roundRect((256 - textWidth) / 2, 12, textWidth, 40, 6); + ctx.fill(); + ctx.fillStyle = "#000000"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(text, 128, 32); + + const texture = new THREE.CanvasTexture(canvas); + const material = new THREE.SpriteMaterial({ + map: texture, + transparent: true, + depthTest: false, + }); + const sprite = new THREE.Sprite(material); + sprite.scale.set(HINT_SPRITE_SCALE, HINT_SPRITE_SCALE * 0.25, 1); + + return sprite; +}; + +const HINTS: Record = { + [X_BUTTON]: { + label: mapNDSToKey("X"), + offset: new THREE.Vector3(-0.3, 1.1, 0.0), + }, + [A_BUTTON]: { + label: mapNDSToKey("A"), + offset: new THREE.Vector3(-0.4, 1.2, 0.0), + }, + [Y_BUTTON]: { + label: mapNDSToKey("Y"), + offset: new THREE.Vector3(-0.3, 1.2, 0.0), + }, + [B_BUTTON]: { + label: mapNDSToKey("B"), + offset: new THREE.Vector3(-0.4, 1.3, 0.0), + }, + [SELECT_BUTTON]: { + label: mapNDSToKey("SELECT"), + offset: new THREE.Vector3(-0.7, 0.05, 0.0), + }, + [START_BUTTON]: { + label: mapNDSToKey("START"), + offset: new THREE.Vector3(-0.75, 0.15, 0.0), + }, + [CROSS_BUTTON]: { + label: "Arrows", + offset: new THREE.Vector3(0.75, 2.3, 0.0), + }, +}; + +const helpSprites = new THREE.Group(); +helpSprites.renderOrder = 999; + +const buildHelpSprites = () => { + helpSprites.clear(); + for (const [meshName, { label, offset }] of Object.entries(HINTS)) { + const mesh = meshes.get(meshName); + if (!mesh) continue; + + const sprite = makeHintSprite(label); + const worldPos = new THREE.Vector3(); + mesh.getWorldPosition(worldPos); + sprite.position.copy(worldPos.add(offset)); + + helpSprites.add(sprite); + } +}; + +watch( + () => app.hintsVisible, + (show) => { + if (show) { + buildHelpSprites(); + helpSprites.visible = true; + for (const child of helpSprites.children) { + const sprite = child as THREE.Sprite; + gsap.fromTo( + sprite.material, + { opacity: 0 }, + { opacity: 1, duration: 0.2, ease: "power1.out" }, + ); + } + } else { + for (const child of helpSprites.children) { + const sprite = child as THREE.Sprite; + gsap.to(sprite.material, { + opacity: 0, + duration: 0.2, + ease: "power1.in", + onComplete: () => { + helpSprites.visible = false; + }, + }); + } + } + }, +); + +helpSprites.visible = app.hintsVisible; +watch( + scene, + (s) => { + if (!s) return; + s.add(helpSprites); + if (app.hintsVisible) buildHelpSprites(); + }, + { immediate: true }, +); + const physicalButtonsDown = new Set(); let mousePressedButton: string | null = null; diff --git a/app/pages/index.vue b/app/pages/index.vue index b059305..66ff1eb 100644 --- a/app/pages/index.vue +++ b/app/pages/index.vue @@ -1,14 +1,12 @@ @@ -105,6 +196,15 @@ onMounted(() => { + +