feat(credits): add in both nds and repo

This commit is contained in:
2026-02-24 14:38:15 +01:00
parent 98890f699d
commit 806fd11dd1
16 changed files with 400 additions and 11 deletions

19
CREDITS.md Normal file
View File

@@ -0,0 +1,19 @@
# Credits
## 3D Model
**"Nintendo DS Lite"** by Cianon
https://skfb.ly/6ZDvQ
Licensed under [Creative Commons Attribution 4.0](http://creativecommons.org/licenses/by/4.0/)
Modified from the original.
## 2D Nintendo DS CSS
Based on **"CSS Nintendo DS"** by Alexandra Radevich (@aradevich)
https://codepen.io/aradevich/pen/mdRYzyJ
## UI Design & Sound Effects
**Nintendo DS** — UI design and sound effects are property of Nintendo.
https://nintendo.com
© Nintendo

View File

@@ -0,0 +1,99 @@
<script setup lang="ts">
const { onRender, onClick, onMouseDown } = useScreen();
const store = useCreditsStore();
const { assets } = useAssets((a) => a.images.credits);
const QUIT_SIZE = assets.quit.rect.width;
const QUIT_X = Math.floor(LOGICAL_WIDTH / 2 - QUIT_SIZE / 2);
const QUIT_Y = 135;
const quitPressed = ref(false);
const handleMouseUp = () => {
quitPressed.value = false;
};
onMounted(() => document.addEventListener("mouseup", handleMouseUp));
onUnmounted(() => document.removeEventListener("mouseup", handleMouseUp));
onMouseDown((x, y) => {
if (store.isIntro || store.isOutro) return;
if (rectContains([QUIT_X, QUIT_Y, QUIT_SIZE, QUIT_SIZE], [x, y])) {
quitPressed.value = true;
}
});
useKeyDown(({ key, repeated }) => {
if (store.isIntro || store.isOutro || repeated) return;
switch (key) {
case "NDS_B":
case "NDS_A":
case "NDS_START": {
store.animateOutro();
}
}
});
onClick((x, y) => {
if (store.isIntro || store.isOutro) return;
if (rectContains([QUIT_X, QUIT_Y, QUIT_SIZE, QUIT_SIZE], [x, y])) {
store.animateOutro();
}
});
onRender((ctx) => {
ctx.fillStyle = "#000000";
ctx.fillRect(0, 0, LOGICAL_WIDTH, LOGICAL_HEIGHT);
// credits list (bottom screen entries)
ctx.globalAlpha = store.isIntro
? store.intro.stage1Opacity
: store.isOutro
? store.outro.stage1Opacity
: 1;
ctx.textBaseline = "top";
for (let i = CREDITS.length - 1; i >= CREDITS_TOP_SCREEN_COUNT; i--) {
const id = CREDITS[i]!;
const offset = store.getItemOffset(i);
if (offset === -CREDITS_LINE_HEIGHT) continue;
const baseY =
CREDITS_BOTTOM_START_Y +
(i - CREDITS_TOP_SCREEN_COUNT) * CREDITS_ENTRY_HEIGHT;
const y = baseY + offset;
ctx.fillStyle = "#000000";
ctx.fillRect(0, y, LOGICAL_WIDTH, CREDITS_LINE_HEIGHT * 3);
ctx.fillStyle = "#aaaaaa";
ctx.font = "7px NDS7";
fillTextHCentered(ctx, $t(`creditsScreen.${id}.label`), 0, y, LOGICAL_WIDTH);
ctx.fillStyle = "#ffffff";
ctx.font = "10px NDS10";
fillTextHCentered(ctx, $t(`creditsScreen.${id}.author`), 0, y + CREDITS_LINE_HEIGHT, LOGICAL_WIDTH);
ctx.fillStyle = "#888888";
ctx.font = "7px NDS7";
fillTextHCentered(ctx, $t(`creditsScreen.${id}.url`), 0, y + CREDITS_LINE_HEIGHT * 2, LOGICAL_WIDTH);
}
ctx.globalAlpha = store.isIntro
? store.intro.stage1Opacity
: store.isOutro
? store.outro.stage2Opacity
: 1;
(quitPressed.value ? assets.quitPressed : assets.quit).draw(
ctx,
QUIT_X,
QUIT_Y,
);
});
defineOptions({
render: () => null,
});
</script>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
const { onRender } = useScreen();
const store = useCreditsStore();
onRender((ctx) => {
if (!store.fadeToBlack.active) return;
if (store.fadeToBlack.isOutro && store.fadeToBlack.opacity === 0) return;
ctx.globalAlpha = store.fadeToBlack.opacity;
ctx.fillStyle = "#000000";
ctx.fillRect(0, 0, LOGICAL_WIDTH, LOGICAL_HEIGHT);
}, 9999);
defineOptions({
render: () => null,
});
</script>

View File

@@ -0,0 +1,67 @@
<script setup lang="ts">
const { onRender } = useScreen();
const store = useCreditsStore();
onMounted(() => {
store.$reset();
store.animateIntro(CREDITS.length);
});
onRender((ctx) => {
ctx.fillStyle = "#000000";
ctx.fillRect(0, 0, LOGICAL_WIDTH, LOGICAL_HEIGHT);
ctx.globalAlpha = store.isIntro
? store.intro.stage1Opacity
: store.isOutro
? store.outro.stage2Opacity
: 1;
ctx.fillStyle = "#ffffff";
ctx.textBaseline = "top";
// title
ctx.font = "10px NDS10";
fillTextHCentered(
ctx,
$t("creditsScreen.title"),
0,
CREDITS_HEADER_Y,
LOGICAL_WIDTH,
);
// credits list (top screen entries only)
ctx.globalAlpha = store.isIntro
? store.intro.stage1Opacity
: store.isOutro
? store.outro.stage1Opacity
: 1;
for (let i = CREDITS_TOP_SCREEN_COUNT - 1; i >= 0; i--) {
const id = CREDITS[i]!;
const offset = store.getItemOffset(i);
if (offset === -CREDITS_LINE_HEIGHT) continue;
const baseY = CREDITS_LIST_START_Y + i * CREDITS_ENTRY_HEIGHT;
const y = baseY + offset;
ctx.fillStyle = "#000000";
ctx.fillRect(0, y, LOGICAL_WIDTH, CREDITS_LINE_HEIGHT * 3);
ctx.fillStyle = "#aaaaaa";
ctx.font = "7px NDS7";
fillTextHCentered(ctx, $t(`creditsScreen.${id}.label`), 0, y, LOGICAL_WIDTH);
ctx.fillStyle = "#ffffff";
ctx.font = "10px NDS10";
fillTextHCentered(ctx, $t(`creditsScreen.${id}.author`), 0, y + CREDITS_LINE_HEIGHT, LOGICAL_WIDTH);
ctx.fillStyle = "#888888";
ctx.font = "7px NDS7";
fillTextHCentered(ctx, $t(`creditsScreen.${id}.url`), 0, y + CREDITS_LINE_HEIGHT * 2, LOGICAL_WIDTH);
}
});
defineOptions({
render: () => null,
});
</script>

View File

@@ -7,4 +7,5 @@ import Buttons from "./Buttons.vue";
<Background />
<Buttons />
<AchievementsFadeToBlack />
<CreditsFadeToBlack />
</template>

View File

@@ -13,13 +13,12 @@ const { selected, pressed, selectorPosition } = useButtonNavigation({
contact: [31, 71, 97, 49],
gallery: [127, 71, 97, 49],
theme: [0, 167, 31, 26],
credits: [0, 167, 31, 26],
settings: [112, 167, 31, 26],
achievements: [225, 167, 31, 26],
},
initialButton: store.selectedButton,
onActivate: (button) => {
if (button === "theme") throw new Error(`Not implemented: ${button}`);
store.animateOutro(button);
},
navigation: {
@@ -39,11 +38,11 @@ const { selected, pressed, selectorPosition } = useButtonNavigation({
left: "contact",
down: "settings",
},
theme: {
credits: {
right: "settings",
},
settings: {
left: "theme",
left: "credits",
up: "last",
right: "achievements",
},
@@ -161,9 +160,9 @@ onRender((ctx) => {
<Button
:x="10"
:y="175 + getButtonOffset('theme')"
:opacity="getOpacity('theme')"
:image="getButtonImage('theme')"
:y="175 + getButtonOffset('credits')"
:opacity="getOpacity('credits')"
:image="getButtonImage('credits')"
/>
<Button
:x="117"

View File

@@ -17,4 +17,5 @@ onMounted(() => {
<Clock />
<StatusBar />
<AchievementsFadeToBlack />
<CreditsFadeToBlack />
</template>

View File

@@ -187,6 +187,7 @@ useKeyDown(async ({ key, repeated }) => {
<SettingsTopScreen v-else-if="app.screen === 'settings'" />
<GalleryTopScreen v-else-if="app.screen === 'gallery'" />
<AchievementsTopScreen v-else-if="app.screen === 'achievements'" />
<CreditsTopScreen v-else-if="app.screen === 'credits'" />
<AchievementsNotification />
<CommonConfetti screen="top" />
@@ -201,6 +202,7 @@ useKeyDown(async ({ key, repeated }) => {
<SettingsBottomScreen v-else-if="app.screen === 'settings'" />
<GalleryBottomScreen v-else-if="app.screen === 'gallery'" />
<AchievementsBottomScreen v-else-if="app.screen === 'achievements'" />
<CreditsBottomScreen v-else-if="app.screen === 'credits'" />
<CommonConfetti screen="bottom" />
</Screen>

156
app/stores/credits.ts Normal file
View File

@@ -0,0 +1,156 @@
import gsap from "gsap";
export const CREDITS = ["model3d", "css2d", "nintendo"] as const;
export type CreditId = (typeof CREDITS)[number];
export const useCreditsStore = defineStore("credits", {
state: () => ({
fadeToBlack: {
opacity: 0,
active: false,
isOutro: false,
},
intro: {
stage1Opacity: 0,
itemOffsets: {} as Record<number, number>,
},
outro: {
stage1Opacity: 1,
stage2Opacity: 1,
},
isIntro: true,
isOutro: false,
}),
actions: {
animateFadeToBlackIntro() {
this.fadeToBlack.active = true;
this.fadeToBlack.isOutro = false;
gsap
.timeline({
onComplete: () => {
const app = useAppStore();
app.navigateTo("credits");
},
})
.fromTo(
this.fadeToBlack,
{ opacity: 0 },
{
opacity: 1,
duration: 0.4,
ease: "none",
},
);
},
animateFadeToBlackOutro() {
this.fadeToBlack.active = true;
this.fadeToBlack.isOutro = true;
this.fadeToBlack.opacity = 1;
gsap
.timeline({
onComplete: () => {
this.fadeToBlack.active = false;
this.isOutro = false;
},
})
.fromTo(
this.fadeToBlack,
{ opacity: 1 },
{
opacity: 0,
duration: 0.4,
ease: "none",
},
);
},
animateIntro(itemCount: number) {
this.isIntro = true;
this.isOutro = false;
for (let i = 0; i < itemCount; i++) {
this.intro.itemOffsets[i] = -CREDITS_LINE_HEIGHT;
}
const tl = gsap.timeline({
onComplete: () => {
this.isIntro = false;
},
});
tl.fromTo(
this.intro,
{ stage1Opacity: 0 },
{
stage1Opacity: 1,
duration: 0.5,
ease: "none",
},
0.5,
);
for (let i = 0; i < itemCount; i++) {
tl.to(
this.intro.itemOffsets,
{
[i]: 0,
duration: 0.4,
ease: "power2.out",
},
0.75 + i * 0.05,
);
}
},
animateOutro() {
this.isIntro = false;
this.isOutro = true;
gsap
.timeline()
.fromTo(
this.outro,
{ stage1Opacity: 1 },
{
stage1Opacity: 0,
duration: 0.3,
},
)
.fromTo(
this.outro,
{ stage2Opacity: 1 },
{
stage2Opacity: 0,
duration: 0.3,
},
"-=0.15",
)
.call(() => {
const app = useAppStore();
app.navigateTo(app.previousScreen);
this.animateFadeToBlackOutro();
});
},
getItemOffset(index: number): number {
return this.intro.itemOffsets[index] ?? 0;
},
},
});
export const CREDITS_LINE_HEIGHT = 14;
export const CREDITS_ENTRY_GAP = 12;
export const CREDITS_HEADER_Y = 30;
export const CREDITS_LIST_START_Y = CREDITS_HEADER_Y + 35;
export const CREDITS_ENTRY_HEIGHT = CREDITS_LINE_HEIGHT * 3 + CREDITS_ENTRY_GAP;
export const CREDITS_TOP_SCREEN_COUNT = Math.floor(
(LOGICAL_HEIGHT - CREDITS_LIST_START_Y) / CREDITS_ENTRY_HEIGHT,
);
export const CREDITS_BOTTOM_START_Y = 30;

View File

@@ -4,9 +4,9 @@ export type HomeButton =
| "projects"
| "contact"
| "gallery"
| "theme"
| "settings"
| "achievements";
| "achievements"
| "credits";
export const useHomeStore = defineStore("home", {
state: () => ({
@@ -32,7 +32,10 @@ export const useHomeStore = defineStore("home", {
actions: {
reset() {
const app = useAppStore();
if (app.previousScreen === "achievements") {
if (
app.previousScreen === "achievements" ||
app.previousScreen === "credits"
) {
return;
}
@@ -105,6 +108,12 @@ export const useHomeStore = defineStore("home", {
return;
}
if (to === "credits") {
const creditsScreen = useCreditsStore();
creditsScreen.animateFadeToBlackIntro();
return;
}
this.isOutro = true;
this.outro.animateTop = to !== "settings";

3
app/types/app.d.ts vendored
View File

@@ -4,4 +4,5 @@ type AppScreen =
| "projects"
| "settings"
| "gallery"
| "achievements";
| "achievements"
| "credits";

View File

@@ -14,6 +14,24 @@
"achievementsScreen": {
"title": "Achievements"
},
"creditsScreen": {
"title": "Credits",
"model3d": {
"label": "3D Model",
"author": "Nintendo DS Lite by Cianon",
"url": "skfb.ly/6ZDvQ - CC BY 4.0"
},
"css2d": {
"label": "2D CSS",
"author": "CSS Nintendo DS by A. Radevich",
"url": "codepen.io/aradevich/pen/mdRYzyJ"
},
"nintendo": {
"label": "UI Design & Sound Effects",
"author": "Nintendo DS",
"url": "nintendo.com - © Nintendo"
}
},
"achievements": {
"boot": "Boot up the system",
"projects_visit": "Visit the projects\nsection",

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 B