feat(nds): add help button with hints for all physical buttons

This commit is contained in:
2026-02-22 21:16:13 +01:00
parent af001f1d97
commit 8ac9911746
6 changed files with 377 additions and 161 deletions

View File

@@ -1,9 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import gsap from "gsap"; import gsap from "gsap";
const app = useAppStore();
const ndsScale = ref(1); const ndsScale = ref(1);
const showHelp = ref(false); const hintsContainer = useTemplateRef<HTMLElement>("hintsContainer");
const helpTimeline = ref<gsap.core.Timeline | null>(null);
const updateScale = () => { const updateScale = () => {
const scaleX = (window.innerWidth - 40) / 235; const scaleX = (window.innerWidth - 40) / 235;
@@ -11,72 +11,37 @@ const updateScale = () => {
ndsScale.value = Math.min(scaleX, scaleY); ndsScale.value = Math.min(scaleX, scaleY);
}; };
const animateHelpLabels = async (yoyo = false) => { onMounted(() => {
if (showHelp.value) return;
showHelp.value = true;
helpTimeline.value?.kill();
await nextTick();
const timeline = gsap
.timeline()
.fromTo(
".nds2d-hints-container",
{ opacity: 0 },
{ opacity: 1, duration: 0.2, ease: "power1.out" },
)
.to(
".nds2d-help-btn",
{ color: "#ffffff", opacity: 1, duration: 0.2, ease: "power1.out" },
"<",
)
.to(".nds2d-hints-container", {
opacity: 0,
duration: 0.2,
ease: "power1.in",
delay: 3,
})
.to(
".nds2d-help-btn",
{ color: "#666666", opacity: 0.5, duration: 0.2, ease: "power1.in" },
"<",
)
.call(() => {
showHelp.value = false;
});
if (yoyo) {
timeline.to(".nds2d-help-btn", {
color: "#ffffff",
opacity: 1,
duration: 0.3,
repeat: 5,
yoyo: true,
delay: 0.3,
});
}
helpTimeline.value = timeline;
};
onMounted(async () => {
updateScale(); updateScale();
window.addEventListener("resize", updateScale); window.addEventListener("resize", updateScale);
if (ndsScale.value >= 1) {
await animateHelpLabels(true);
}
});
useKeyDown(async ({ key }) => {
if (key.toLocaleLowerCase() === "h") {
await animateHelpLabels();
}
}); });
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener("resize", updateScale); window.removeEventListener("resize", updateScale);
}); });
watch(
() => app.hintsVisible,
async (show) => {
await nextTick();
if (!hintsContainer.value) return;
if (show) {
gsap.fromTo(
hintsContainer.value,
{ opacity: 0 },
{ opacity: 1, duration: 0.2, ease: "power1.out" },
);
} else {
gsap.to(hintsContainer.value, {
opacity: 0,
duration: 0.2,
ease: "power1.in",
});
}
},
);
defineExpose({ ndsScale });
</script> </script>
<template> <template>
@@ -122,7 +87,11 @@ onUnmounted(() => {
<div class="nds2d-small-button nds2d-start"></div> <div class="nds2d-small-button nds2d-start"></div>
<div class="nds2d-small-button nds2d-select"></div> <div class="nds2d-small-button nds2d-select"></div>
<div v-if="showHelp" class="nds2d-hints-container"> <div
ref="hintsContainer"
class="nds2d-hints-container"
style="opacity: 0"
>
<div class="nds2d-hint nds2d-hint-dpad">Arrows</div> <div class="nds2d-hint nds2d-hint-dpad">Arrows</div>
<div class="nds2d-hint nds2d-hint-x">{{ mapNDSToKey("X") }}</div> <div class="nds2d-hint nds2d-hint-x">{{ mapNDSToKey("X") }}</div>
<div class="nds2d-hint nds2d-hint-a">{{ mapNDSToKey("A") }}</div> <div class="nds2d-hint nds2d-hint-a">{{ mapNDSToKey("A") }}</div>
@@ -137,8 +106,6 @@ onUnmounted(() => {
</div> </div>
</div> </div>
</div> </div>
<button class="nds2d-help-btn" @click="animateHelpLabels()">?</button>
</div> </div>
</template> </template>
@@ -602,25 +569,4 @@ onUnmounted(() => {
bottom: 28px; bottom: 28px;
transform: translateX(-100%); 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;
}
</style> </style>

View File

@@ -2,6 +2,7 @@
import { useLoop, useTresContext } from "@tresjs/core"; import { useLoop, useTresContext } from "@tresjs/core";
import * as THREE from "three"; import * as THREE from "three";
import gsap from "gsap"; import gsap from "gsap";
import { mapNDSToKey } from "~/utils/input";
const INTRO_ANIMATION = { const INTRO_ANIMATION = {
MODEL_SPIN_DURATION: 3, MODEL_SPIN_DURATION: 3,
@@ -55,7 +56,7 @@ const requireMesh = (key: string): THREE.Mesh => {
return mesh; return mesh;
}; };
const { camera, renderer } = useTresContext(); const { camera, renderer, scene } = useTresContext();
model.scale.set(100, 100, 100); model.scale.set(100, 100, 100);
@@ -101,61 +102,57 @@ const animateIntro = () => {
if (hasAnimated.value) return; if (hasAnimated.value) return;
hasAnimated.value = true; hasAnimated.value = true;
const introTimeline = gsap.timeline(); gsap
.timeline()
introTimeline.to( .to(
camera.activeCamera.value.position, camera.activeCamera.value.position,
{ {
x: INTRO_ANIMATION.CAMERA_END_POSITION.x, x: INTRO_ANIMATION.CAMERA_END_POSITION.x,
y: INTRO_ANIMATION.CAMERA_END_POSITION.y, y: INTRO_ANIMATION.CAMERA_END_POSITION.y,
z: INTRO_ANIMATION.CAMERA_END_POSITION.z, z: INTRO_ANIMATION.CAMERA_END_POSITION.z,
duration: INTRO_ANIMATION.CAMERA_DURATION, duration: INTRO_ANIMATION.CAMERA_DURATION,
ease: "power2.inOut", ease: "power2.inOut",
}, },
0, 0,
); )
.to(
introTimeline.to( camera.activeCamera.value.rotation,
camera.activeCamera.value.rotation, {
{ x: INTRO_ANIMATION.CAMERA_END_ROTATION.x,
x: INTRO_ANIMATION.CAMERA_END_ROTATION.x, y: INTRO_ANIMATION.CAMERA_END_ROTATION.y,
y: INTRO_ANIMATION.CAMERA_END_ROTATION.y, z: INTRO_ANIMATION.CAMERA_END_ROTATION.z,
z: INTRO_ANIMATION.CAMERA_END_ROTATION.z, duration: INTRO_ANIMATION.CAMERA_DURATION,
duration: INTRO_ANIMATION.CAMERA_DURATION, ease: "power2.inOut",
ease: "power2.inOut", },
}, 0,
0, )
); .to(
model.rotation,
introTimeline.to( {
model.rotation, y: Math.PI * 2,
{ duration: INTRO_ANIMATION.MODEL_SPIN_DURATION,
y: Math.PI * 2, ease: "power2.inOut",
duration: INTRO_ANIMATION.MODEL_SPIN_DURATION, },
ease: "power2.inOut", 0,
}, )
0, .to(
); lidMesh.rotation,
{
introTimeline.to( x: INTRO_ANIMATION.LID_OPENED_ROTATION,
lidMesh.rotation, duration: INTRO_ANIMATION.LID_OPEN_DURATION,
{ ease: "power2.out",
x: INTRO_ANIMATION.LID_OPENED_ROTATION, },
duration: INTRO_ANIMATION.LID_OPEN_DURATION, INTRO_ANIMATION.MODEL_SPIN_DURATION - INTRO_ANIMATION.LID_OPEN_OVERLAP,
ease: "power2.out", )
}, .to(
INTRO_ANIMATION.MODEL_SPIN_DURATION - INTRO_ANIMATION.LID_OPEN_OVERLAP, topScreenMesh.rotation,
); {
x: INTRO_ANIMATION.LID_OPENED_ROTATION,
introTimeline.to( duration: INTRO_ANIMATION.LID_OPEN_DURATION,
topScreenMesh.rotation, ease: "power2.out",
{ },
x: INTRO_ANIMATION.LID_OPENED_ROTATION, "<",
duration: INTRO_ANIMATION.LID_OPEN_DURATION, );
ease: "power2.out",
},
"<",
);
}; };
watch( watch(
@@ -200,6 +197,131 @@ watch(
const { onRender } = useLoop(); 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<string, { label: string; offset: THREE.Vector3 }> = {
[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<string>(); const physicalButtonsDown = new Set<string>();
let mousePressedButton: string | null = null; let mousePressedButton: string | null = null;

View File

@@ -1,14 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Screen as NDSScreen } from "#components"; import gsap from "gsap";
type ScreenInstance = InstanceType<typeof NDSScreen>;
const { isReady } = useAssets(); const { isReady } = useAssets();
const app = useAppStore(); const app = useAppStore();
const topScreen = useTemplateRef<ScreenInstance>("topScreen"); const topScreen = useTemplateRef("topScreen");
const bottomScreen = useTemplateRef<ScreenInstance>("bottomScreen"); const bottomScreen = useTemplateRef("bottomScreen");
const topScreenCanvas = computed(() => topScreen.value?.canvas ?? null); const topScreenCanvas = computed(() => topScreen.value?.canvas ?? null);
const bottomScreenCanvas = computed(() => bottomScreen.value?.canvas ?? null); const bottomScreenCanvas = computed(() => bottomScreen.value?.canvas ?? null);
@@ -27,27 +25,120 @@ const toggleFullscreen = () => {
} }
}; };
onMounted(() => { const helpButton = useTemplateRef("helpButton");
let helpAnimation: gsap.core.Timeline | null = null;
const showHelpLabels = async (yoyo = false) => {
if (!app.hintsAllowed) return;
helpAnimation?.kill();
app.hintsVisible = true;
await nextTick();
helpAnimation = gsap
.timeline({
onComplete: () => {
helpAnimation = null;
},
})
.fromTo(
helpButton.value,
{ color: "#666666", opacity: 0.5 },
{ color: "#ffffff", opacity: 1, duration: 0.2, ease: "power1.out" },
)
.to(helpButton.value, {
color: "#666666",
opacity: 0.5,
duration: 0.2,
ease: "power1.in",
delay: 3,
})
.call(() => {
app.hintsVisible = false;
});
if (yoyo) {
helpAnimation.to(helpButton.value, {
color: "#ffffff",
opacity: 1,
duration: 0.3,
repeat: 5,
yoyo: true,
delay: 0.3,
});
}
};
const hideHelpLabels = () => {
if (!app.hintsVisible) return;
helpAnimation?.kill();
helpAnimation = null;
app.hintsVisible = false;
gsap.to(helpButton.value, {
color: "#666666",
opacity: 0.5,
duration: 0.2,
ease: "power1.in",
});
};
watch(
() => app.hintsAllowed,
(allowed) => {
if (!allowed) hideHelpLabels();
},
);
onMounted(async () => {
if (isIOS()) { if (isIOS()) {
showFullscreenBtn.value = false; showFullscreenBtn.value = false;
return;
} }
if (!isTouchDevice()) return; if (isTouchDevice()) {
const landscape = window.matchMedia("(orientation: landscape)");
const landscape = window.matchMedia("(orientation: landscape)"); const onOrientationChange = (e: MediaQueryListEvent | MediaQueryList) => {
if (e.matches && !document.fullscreenElement) {
document.documentElement.requestFullscreen().catch(() => {});
}
};
const onOrientationChange = (e: MediaQueryListEvent | MediaQueryList) => { landscape.addEventListener("change", onOrientationChange);
if (e.matches && !document.fullscreenElement) {
document.documentElement.requestFullscreen().catch(() => {}); onUnmounted(() => {
landscape.removeEventListener("change", onOrientationChange);
});
}
const scaleX = (window.innerWidth - 40) / 235;
const scaleY = (window.innerHeight - 40) / 431;
const scale = Math.min(scaleX, scaleY);
if (app.settings.renderingMode === "2d" && scale < 1) return;
if (!app.booted) {
watch(
() => app.hintsAllowed,
async (allowed) => {
if (allowed) await showHelpLabels(true);
},
{ once: true },
);
}
});
useKeyDown(async ({ key, repeated }) => {
if (!repeated && key.toLocaleLowerCase() === "h") {
if (app.hintsVisible) {
hideHelpLabels();
} else {
await showHelpLabels();
} }
}; }
landscape.addEventListener("change", onOrientationChange);
onUnmounted(() => {
landscape.removeEventListener("change", onOrientationChange);
});
}); });
</script> </script>
@@ -105,6 +196,15 @@ onMounted(() => {
</template> </template>
</NDS2D> </NDS2D>
<button
v-if="app.hintsAllowed"
ref="helpButton"
class="help-btn"
@click="app.hintsVisible ? hideHelpLabels() : showHelpLabels()"
>
?
</button>
<button <button
v-if="showFullscreenBtn" v-if="showFullscreenBtn"
class="fullscreen-btn" class="fullscreen-btn"
@@ -125,6 +225,28 @@ onMounted(() => {
</template> </template>
<style scoped> <style scoped>
.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;
z-index: 100;
}
.help-btn:hover {
opacity: 1;
}
.fullscreen-btn { .fullscreen-btn {
position: fixed; position: fixed;
bottom: 16px; bottom: 16px;

View File

@@ -51,6 +51,8 @@ export const useAppStore = defineStore("app", {
screen: "home" as AppScreen, screen: "home" as AppScreen,
visitedGallery: false, visitedGallery: false,
camera: null as THREE.Camera | null, camera: null as THREE.Camera | null,
hintsVisible: false,
hintsAllowed: false,
}; };
}, },
@@ -86,6 +88,24 @@ export const useAppStore = defineStore("app", {
} }
}, },
allowHints() {
this.hintsAllowed = true;
},
disallowHints() {
this.hintsAllowed = false;
this.hintsVisible = false;
},
showHints() {
if (!this.hintsAllowed) return;
this.hintsVisible = true;
},
hideHints() {
this.hintsVisible = false;
},
setCamera(camera: THREE.Camera) { setCamera(camera: THREE.Camera) {
this.camera = camera; this.camera = camera;
}, },

View File

@@ -35,6 +35,7 @@ export const useGalleryStore = defineStore("gallery", {
this.isOutro = false; this.isOutro = false;
const app = useAppStore(); const app = useAppStore();
app.disallowHints();
// Intro: Fade starts first (at 0), camera starts after with overlap // Intro: Fade starts first (at 0), camera starts after with overlap
const cameraDelay = const cameraDelay =
@@ -157,6 +158,7 @@ export const useGalleryStore = defineStore("gallery", {
setTimeout(() => { setTimeout(() => {
this.isOutro = false; this.isOutro = false;
app.navigateTo("home"); app.navigateTo("home");
app.allowHints();
}, ANIMATION.NAVIGATE_DELAY); }, ANIMATION.NAVIGATE_DELAY);
}, },
}, },

View File

@@ -47,7 +47,11 @@ export const useIntroStore = defineStore("intro", {
ease: "steps(" + (totalFrames - 1) + ")", ease: "steps(" + (totalFrames - 1) + ")",
}, },
4.1, 4.1,
); )
.call(() => {
const app = useAppStore();
app.allowHints();
});
}, },
animateOutro() { animateOutro() {