Compare commits

...

20 Commits

Author SHA1 Message Date
54bef8629c feat: animation system + test for home screen 2025-12-13 20:37:22 +01:00
68fd923d2e refactor: absolute paths 2025-12-13 20:07:10 +01:00
3b801c97ff refactor(home): use new context system 2025-12-13 19:56:57 +01:00
3db8f850f0 feat(contact): implement contact screen 2025-12-13 19:55:26 +01:00
b70d0f3347 feat: improve screen context system 2025-12-13 19:52:31 +01:00
80fbab446b feat(home): buttons navigation 2025-12-13 19:18:15 +01:00
720bbeedd7 feat(utils): add button navigation and selector 2025-12-13 19:17:28 +01:00
f2e869e283 feat(home): implement background, clock and calendar 2025-12-13 19:02:52 +01:00
1f859c6578 feat(home): status bar images 2025-12-13 18:56:21 +01:00
b29fbe8f7b feat: screens component system 2025-12-13 18:55:39 +01:00
108ee082d8 feat(utils): image loader 2025-12-13 18:55:18 +01:00
92f5c83e36 feat: screen manager 2025-12-13 18:28:53 +01:00
33f918995b feat: add fonts 2025-12-13 18:27:26 +01:00
88beb5f421 feat: render screens 2025-12-13 17:54:38 +01:00
1a82f3c8d0 refactor: nds as a 3d object 2025-12-13 17:28:23 +01:00
8d45b76944 feat: compute mouse position for each screen 2025-12-13 17:00:42 +01:00
285de91dd0 fix: webgl feedback loop error 2025-12-13 16:02:24 +01:00
69abfd8aca feat: load 3d nds model 2025-12-13 15:55:38 +01:00
a5dba4b652 BREAKING CHANGE: vanilla ts three scene setup 2025-12-13 15:47:30 +01:00
1631e3206a feat(projects): add s3pweb pokemon 2025-12-12 19:42:11 +01:00
190 changed files with 3718 additions and 14233 deletions

View File

@@ -1,34 +0,0 @@
name: CI
on:
push:
branches: [main, 3d-nds]
pull_request:
branches: [main, 3d-nds]
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: pnpm setup
uses: pnpm/action-setup@v4
with:
version: 10
- name: node setup
uses: actions/setup-node@v4
with:
node-version: 25
cache: "pnpm"
- name: install
run: pnpm install --frozen-lockfile
- name: lint
run: pnpm lint
- name: build
run: pnpm build

47
.gitignore vendored
View File

@@ -1,33 +1,26 @@
# generated
public/images/projects
# temporary
__old
# ESlint
.eslintcache
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist
# Node dependencies
node_modules
OLD
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Misc
.DS_Store
.fleet
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
# Local env files
.env
.env.*
!.env.example
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

2
.prettierignore Normal file
View File

@@ -0,0 +1,2 @@
OLD
.test

View File

@@ -1,75 +0,0 @@
# Nuxt Minimal Starter
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
## Setup
Make sure to install dependencies:
```bash
# npm
npm install
# pnpm
pnpm install
# yarn
yarn install
# bun
bun install
```
## Development Server
Start the development server on `http://localhost:3000`:
```bash
# npm
npm run dev
# pnpm
pnpm dev
# yarn
yarn dev
# bun
bun run dev
```
## Production
Build the application for production:
```bash
# npm
npm run build
# pnpm
pnpm build
# yarn
yarn build
# bun
bun run build
```
Locally preview production build:
```bash
# npm
npm run preview
# pnpm
pnpm preview
# yarn
yarn preview
# bun
bun run preview
```
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.

View File

@@ -1,3 +0,0 @@
<template>
<NuxtPage />
</template>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1010 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 438 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 322 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 336 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 318 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 362 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 334 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 374 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 322 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 266 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 220 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 254 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 342 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 302 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 388 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 302 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 344 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 306 B

View File

@@ -1,69 +0,0 @@
<script setup lang="ts">
import CORNER_IMAGE from "/assets/images/home/bottom-screen/buttons/corner.webp";
const props = withDefaults(
defineProps<{
rect: [x: number, y: number, width: number, height: number];
opacity?: number;
}>(),
{
opacity: 1,
},
);
const [cornerImage] = useImages(CORNER_IMAGE);
const ANIMATION_SPEED = 0.25;
let [currentX, currentY, currentWidth, currentHeight] = props.rect;
useRender((ctx) => {
const [targetX, targetY, targetWidth, targetHeight] = props.rect;
const dx = targetX - currentX;
const dy = targetY - currentY;
const dw = targetWidth - currentWidth;
const dh = targetHeight - currentHeight;
if (
Math.abs(dx) < 0.5 &&
Math.abs(dy) < 0.5 &&
Math.abs(dw) < 0.5 &&
Math.abs(dh) < 0.5
) {
[currentX, currentY, currentWidth, currentHeight] = props.rect;
} else {
currentX += dx * ANIMATION_SPEED;
currentY += dy * ANIMATION_SPEED;
currentWidth += dw * ANIMATION_SPEED;
currentHeight += dh * ANIMATION_SPEED;
}
const x = Math.floor(currentX);
const y = Math.floor(currentY);
const w = Math.floor(currentWidth);
const h = Math.floor(currentHeight);
ctx.globalAlpha = props.opacity;
ctx.drawImage(cornerImage!, x, y);
ctx.save();
ctx.scale(-1, 1);
ctx.drawImage(cornerImage!, -(x + w), y);
ctx.restore();
ctx.save();
ctx.scale(1, -1);
ctx.drawImage(cornerImage!, x, -(y + h));
ctx.restore();
ctx.save();
ctx.scale(-1, -1);
ctx.drawImage(cornerImage!, -(x + w), -(y + h));
ctx.restore();
});
defineOptions({
render: () => null,
});
</script>

View File

@@ -1,24 +0,0 @@
<script setup lang="ts">
import HOME_BACKGROUND_IMAGE from "/assets/images/home/bottom-screen/background.webp";
import CONTACT_BACKGROUND_IMAGE from "/assets/images/contact/bottom-screen/background.webp";
const store = useContactStore();
const [homeBackgroundImage, contactBackgroundImage] = useImages(
HOME_BACKGROUND_IMAGE,
CONTACT_BACKGROUND_IMAGE,
);
useRender((ctx) => {
ctx.drawImage(homeBackgroundImage!, 0, 0);
ctx.globalAlpha = store.isIntro
? store.intro.stage2Opacity
: store.outro.stage3Opacity;
ctx.drawImage(contactBackgroundImage!, 0, 0);
});
defineOptions({
render: () => null,
});
</script>

View File

@@ -1,39 +0,0 @@
<script setup lang="ts">
import TOP_BAR_IMAGE from "/assets/images/contact/bottom-screen/top-bar.webp";
import BOTTOM_BAR_IMAGE from "/assets/images/contact/bottom-screen/bottom-bar.webp";
import BOTTOM_BAR_OK_IMAGE from "/assets/images/contact/bottom-screen/ok-button.webp";
const props = defineProps<{
okLabel: "Copy" | "Open";
}>();
const store = useContactStore();
const [topBarImage, bottomBarImage, bottomBarOkImage] = useImages(
TOP_BAR_IMAGE,
BOTTOM_BAR_IMAGE,
BOTTOM_BAR_OK_IMAGE,
);
useRender((ctx) => {
ctx.globalAlpha = store.isIntro
? store.intro.stage3Opacity
: store.outro.stage2Opacity;
// top bar
ctx.drawImage(topBarImage!, 0, store.isIntro ? store.intro.topBarY : 0);
// bottom bar
ctx.translate(0, store.isIntro ? store.intro.bottomBarY : SCREEN_HEIGHT - 24);
ctx.drawImage(bottomBarImage!, 0, 0);
ctx.drawImage(bottomBarOkImage!, 144, 4);
ctx.font = "10px NDS10";
ctx.fillStyle = "#000000";
ctx.fillText(props.okLabel, 144 + 35, 4 + 13);
});
defineOptions({
render: () => null,
});
</script>

View File

@@ -1,87 +0,0 @@
<script setup lang="ts">
import Background from "./Background.vue";
import Buttons from "./Buttons.vue";
import ButtonSelector from "~/components/Common/ButtonSelector.vue";
import Bars from "./Bars.vue";
const store = useContactStore();
const ACTIONS = {
github: ["Open", "Github profile", "https://github.com/pihkaal"],
email: ["Copy", "Email", "hello@pihkaal.me"],
website: ["Copy", "Website link", "https://pihkaal.me"],
cv: ["Open", "CV", "https://pihkaal.me/cv"],
} as const satisfies Record<
string,
[action: "Copy" | "Open", verb: string, content: string]
>;
const { selectedButton, selectorPosition } = useButtonNavigation({
buttons: {
github: [26, 27, 202, 42],
email: [26, 59, 202, 42],
website: [26, 91, 202, 42],
cv: [26, 123, 202, 42],
},
navigation: {
github: {
down: "email",
},
email: {
up: "github",
down: "website",
},
website: {
up: "email",
down: "cv",
},
cv: {
up: "website",
},
},
initialButton: "github",
onButtonClick: async (button) => {
actionateButton(button);
},
});
const actionateButton = async (button: (typeof selectedButton)["value"]) => {
const [action, verb, content] = ACTIONS[button];
if (action === "Copy") {
try {
await navigator.clipboard.writeText(content);
store.pushNotification(`${verb} copied to clipboard`);
} catch (error) {
console.error("Failed to copy to clipboard:", error);
}
} else {
await navigateTo(content, { open: { target: "_blank " } });
store.pushNotification(`${verb} opened`);
}
};
const QUIT_BUTTON: Rect = [31, 172, 80, 18];
const OK_BUTTON: Rect = [144, 172, 80, 18];
useScreenClick((x, y) => {
if (rectContains(QUIT_BUTTON, [x, y])) {
store.animateOutro();
} else if (rectContains(OK_BUTTON, [x, y])) {
actionateButton(selectedButton.value);
}
});
</script>
<template>
<Background />
<Buttons />
<ButtonSelector
:rect="selectorPosition"
:opacity="
store.isIntro ? store.intro.stage3Opacity : store.outro.stage1Opacity
"
/>
<Bars :ok-label="ACTIONS[selectedButton][0]" />
</template>

View File

@@ -1,18 +0,0 @@
<script setup lang="ts">
import BUTTONS_IMAGE from "/assets/images/contact/bottom-screen/buttons.webp";
const store = useContactStore();
const [buttonsImage] = useImages(BUTTONS_IMAGE);
useRender((ctx) => {
ctx.globalAlpha = store.isIntro
? store.intro.stage3Opacity
: store.outro.stage1Opacity;
ctx.drawImage(buttonsImage!, 31, 32);
});
defineOptions({
render: () => null,
});
</script>

View File

@@ -1,24 +0,0 @@
<script setup lang="ts">
import HOME_BACKGROUND_IMAGE from "/assets/images/home/top-screen/background.webp";
import CONTACT_BACKGROUND_IMAGE from "/assets/images/contact/top-screen/background.webp";
const store = useContactStore();
const [homeBackgroundImage, contactBackgroundImage] = useImages(
HOME_BACKGROUND_IMAGE,
CONTACT_BACKGROUND_IMAGE,
);
useRender((ctx) => {
ctx.drawImage(homeBackgroundImage!, 0, 0);
ctx.globalAlpha = store.isIntro
? store.intro.stage2Opacity
: store.outro.stage3Opacity;
ctx.drawImage(contactBackgroundImage!, 0, 0);
});
defineOptions({
render: () => null,
});
</script>

View File

@@ -1,27 +0,0 @@
<script setup lang="ts">
import BACKGROUND_IMAGE from "/assets/images/contact/top-screen/left-bar.webp";
import THINGS_IMAGE from "/assets/images/contact/top-screen/left-bar-things.webp";
const store = useContactStore();
const [backgroundImage, thingsImage] = useImages(
BACKGROUND_IMAGE,
THINGS_IMAGE,
);
useRender((ctx) => {
ctx.globalAlpha = store.isIntro
? store.intro.stage1Opacity
: store.outro.stage2Opacity;
ctx.drawImage(backgroundImage!, 0, 0);
ctx.globalAlpha = store.isIntro
? store.intro.stage3Opacity
: store.outro.stage1Opacity;
ctx.drawImage(thingsImage!, 0, 0);
});
defineOptions({
render: () => null,
});
</script>

View File

@@ -1,80 +0,0 @@
<script setup lang="ts">
import NOTIFICATION_IMAGE from "/assets/images/contact/bottom-screen/notification.webp";
import TITLE_IMAGE from "/assets/images/contact/top-screen/title.webp";
// text color:
const store = useContactStore();
const [notificationImage, titleImage] = useImages(
NOTIFICATION_IMAGE,
TITLE_IMAGE,
);
useRender((ctx) => {
ctx.globalAlpha = store.outro.stage2Opacity;
ctx.font = "10px NDS10";
// notifications
for (let i = store.notifications.length - 1; i >= 0; i--) {
const index = store.notifications.length - 1 - i;
const y = 169 - 24 * index + store.notificationsYOffset;
if (y < -24) break;
ctx.drawImage(notificationImage!, 21, y);
const content = store.notifications[i]!;
ctx.fillStyle = content.includes("opened") ? "#00fbba" : "#e3f300";
ctx.fillText(store.notifications[i]!, 27, y + 15);
}
// title
ctx.globalAlpha = store.isIntro
? store.intro.stage1Opacity
: store.outro.stage2Opacity;
ctx.drawImage(
titleImage!,
21,
store.isIntro
? store.intro.titleY
: 169 - 24 * store.notifications.length + store.notificationsYOffset,
);
// notifications count (left bar)
const MAX = 36;
const MAX_VISIBLE = 8;
let visibleNotifications = Math.min(store.notifications.length, MAX_VISIBLE);
const extraActive =
store.notificationsYOffset > 0 && store.notifications.length > MAX_VISIBLE;
if (extraActive) {
visibleNotifications += 1;
}
ctx.fillStyle = "#415969";
for (let i = 0; i < visibleNotifications; i++) {
ctx.fillRect(3, 161 - i * 4, 12, 2);
}
ctx.fillStyle = "#b2c3db";
const startY = 161 - visibleNotifications * 4;
const top = MAX - MAX_VISIBLE - (extraActive ? 1 : 0);
for (
let i = 0;
i < store.notifications.length - visibleNotifications && i < top;
i++
) {
if (i === top - 1) {
ctx.fillRect(7, startY - i * 4, 4, 2);
} else if (i === top - 2) {
ctx.fillRect(5, startY - i * 4, 8, 2);
} else {
ctx.fillRect(3, startY - i * 4, 12, 2);
}
}
});
defineOptions({
render: () => null,
});
</script>

View File

@@ -1,20 +0,0 @@
<script setup lang="ts">
import Background from "./Background.vue";
import LeftBar from "./LeftBar.vue";
import Notifications from "./Notifications.vue";
const store = useContactStore();
onMounted(() => {
store.$reset();
store.animateIntro();
});
</script>
<template>
<Background />
<LeftBar />
<Notifications />
</template>

View File

@@ -1,17 +0,0 @@
<script setup lang="ts">
import BACKGROUND_IMAGE from "/assets/images/home/bottom-screen/background.webp";
const store = useHomeStore();
const app = useAppStore();
const [backgroundImage] = useImages(BACKGROUND_IMAGE);
useRender((ctx) => {
ctx.globalAlpha = app.booted ? 1 : store.intro.stage1Opacity;
ctx.drawImage(backgroundImage!, 0, 0);
});
defineOptions({
render: () => null,
});
</script>

View File

@@ -1,10 +0,0 @@
<script setup lang="ts">
import Background from "./Background.vue";
import Buttons from "./Buttons/Buttons.vue";
</script>
<template>
<Background />
<Buttons />
</template>

View File

@@ -1,82 +0,0 @@
<script setup lang="ts">
import GameButton from "./GameButton.vue";
import ContactButton from "./ContactButton.vue";
import DownloadPlayButton from "./DownloadPlayButton.vue";
import SettingsButton from "./SettingsButton.vue";
import Selector from "~/components/Common/ButtonSelector.vue";
const store = useHomeStore();
const { selectedButton, selectorPosition } = useButtonNavigation({
buttons: {
projects: [31, 23, 193, 49],
contact: [31, 71, 97, 49],
downloadPlay: [127, 71, 97, 49],
settings: [112, 167, 31, 26],
},
initialButton: "projects",
onButtonClick: (button) => {
if (button === "downloadPlay") throw new Error("Not implemented");
store.animateOutro(button);
},
navigation: {
projects: {
down: "last",
left: "contact",
right: "downloadPlay",
horizontalMode: "preview",
},
contact: {
up: "projects",
right: "downloadPlay",
down: "settings",
},
downloadPlay: {
up: "projects",
left: "contact",
down: "settings",
},
settings: {
up: "last",
},
},
});
const getButtonOffset = (button: (typeof selectedButton)["value"]) => {
if (selectedButton.value === button) return store.outro.buttonOffsetY;
return 0;
};
const getOpacity = (button?: (typeof selectedButton)["value"]) => {
if (store.isIntro) return store.intro.stage1Opacity;
if (selectedButton.value === button) return 1;
if (store.isOutro) return store.outro.stage1Opacity;
return 1;
};
</script>
<template>
<GameButton
:x="33"
:y="25 + getButtonOffset('projects')"
:opacity="getOpacity('projects')"
/>
<DownloadPlayButton
:x="128"
:y="72 + getButtonOffset('downloadPlay')"
:opacity="getOpacity('downloadPlay')"
/>
<ContactButton
:x="32"
:y="72 + getButtonOffset('contact')"
:opacity="getOpacity('contact')"
/>
<SettingsButton
:x="117"
:y="170 + getButtonOffset('settings')"
:opacity="getOpacity('settings')"
/>
<Selector :rect="selectorPosition" :opacity="getOpacity()" />
</template>

View File

@@ -1,20 +0,0 @@
<script setup lang="ts">
import BUTTON_IMAGE from "/assets/images/home/bottom-screen/buttons/contact.webp";
const props = defineProps<{
x: number;
y: number;
opacity: number;
}>();
const [buttonImage] = useImages(BUTTON_IMAGE);
useRender((ctx) => {
ctx.globalAlpha = props.opacity;
ctx.drawImage(buttonImage!, props.x, props.y);
});
defineOptions({
render: () => null,
});
</script>

View File

@@ -1,20 +0,0 @@
<script setup lang="ts">
import BUTTON_IMAGE from "/assets/images/home/bottom-screen/buttons/downloadPlay.webp";
const props = defineProps<{
x: number;
y: number;
opacity: number;
}>();
const [buttonImage] = useImages(BUTTON_IMAGE);
useRender((ctx) => {
ctx.globalAlpha = props.opacity;
ctx.drawImage(buttonImage!, props.x, props.y);
});
defineOptions({
render: () => null,
});
</script>

View File

@@ -1,20 +0,0 @@
<script setup lang="ts">
import BUTTON_IMAGE from "/assets/images/home/bottom-screen/buttons/game.webp";
const props = defineProps<{
x: number;
y: number;
opacity: number;
}>();
const [buttonImage] = useImages(BUTTON_IMAGE);
useRender((ctx) => {
ctx.globalAlpha = props.opacity;
ctx.drawImage(buttonImage!, props.x, props.y);
});
defineOptions({
render: () => null,
});
</script>

View File

@@ -1,20 +0,0 @@
<script setup lang="ts">
import BUTTON_IMAGE from "/assets/images/home/bottom-screen/buttons/settings.webp";
const props = defineProps<{
x: number;
y: number;
opacity: number;
}>();
const [buttonImage] = useImages(BUTTON_IMAGE);
useRender((ctx) => {
ctx.globalAlpha = props.opacity;
ctx.drawImage(buttonImage!, props.x, props.y);
});
defineOptions({
render: () => null,
});
</script>

View File

@@ -1,17 +0,0 @@
<script setup lang="ts">
import BACKGROUND_IMAGE from "/assets/images/home/top-screen/background.webp";
const store = useHomeStore();
const app = useAppStore();
const [backgroundImage] = useImages(BACKGROUND_IMAGE);
useRender((ctx) => {
ctx.globalAlpha = app.booted ? 1 : store.intro.stage1Opacity;
ctx.drawImage(backgroundImage!, 0, 0);
});
defineOptions({
render: () => null,
});
</script>

View File

@@ -1,92 +0,0 @@
<script setup lang="ts">
import CALENDAR_IMAGE from "/assets/images/home/top-screen/calendar/calendar.webp";
import LAST_ROW_IMAGE from "/assets/images/home/top-screen/calendar/last-row.webp";
import DAY_SELECTOR_IMAGE from "/assets/images/home/top-screen/calendar/day-selector.webp";
// NOTE: calendar background is handled by TopScreenBackground
const store = useHomeStore();
const [calendarImage, lastRowImage, daySelectorImage] = useImages(
CALENDAR_IMAGE,
LAST_ROW_IMAGE,
DAY_SELECTOR_IMAGE,
);
useRender((ctx) => {
ctx.fillStyle = "black";
ctx.font = "7px NDS7";
const CALENDAR_COLS = 7;
const CALENDAR_ROWS = 5;
const CALENDAR_LEFT = 128;
const CALENDAR_TOP = 64;
ctx.fillStyle = "#343434";
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth();
const firstDay = new Date(year, month, 1).getDay();
const daysInMonth = new Date(year, month + 1, 0).getDate();
ctx.globalAlpha = store.isIntro
? store.intro.stage1Opacity
: store.isOutro && store.outro.animateTop
? store.outro.stage1Opacity
: 1;
ctx.drawImage(calendarImage!, CALENDAR_LEFT - 3, CALENDAR_TOP - 33);
const extraRow = CALENDAR_COLS * CALENDAR_ROWS - daysInMonth - firstDay < 0;
if (extraRow) {
ctx.drawImage(lastRowImage!, CALENDAR_LEFT - 3, CALENDAR_TOP + 79);
}
ctx.globalAlpha = store.isIntro
? store.intro.stage1Opacity
: store.isOutro && store.outro.animateTop
? store.outro.stage2Opacity
: 1;
for (let col = 0; col < CALENDAR_ROWS + (extraRow ? 1 : 0); col += 1) {
for (let row = 0; row < CALENDAR_COLS; row += 1) {
const cellIndex = col * CALENDAR_COLS + row;
const day = cellIndex - firstDay + 1;
if (day > 0 && day <= daysInMonth) {
const dayText = day.toString();
const { actualBoundingBoxRight: width } = ctx.measureText(dayText);
const cellLeft = CALENDAR_LEFT + row * 16;
const cellTop = CALENDAR_TOP + col * 16;
if (now.getDate() === day) {
ctx.drawImage(daySelectorImage!, cellLeft, cellTop);
}
ctx.fillText(
dayText,
cellLeft + Math.floor((15 - width) / 2),
cellTop + 11,
);
}
}
}
ctx.fillStyle = "black";
ctx.font = "10px NDS10";
ctx.letterSpacing = "2px";
const timeText = `${month}/${year}`;
const { actualBoundingBoxRight: width } = ctx.measureText(timeText);
ctx.fillText(
timeText,
CALENDAR_LEFT + Math.floor((111 - width) / 2),
CALENDAR_TOP - 20,
);
});
defineOptions({
render: () => null,
});
</script>

View File

@@ -1,100 +0,0 @@
<script setup lang="ts">
import CLOCK_IMAGE from "/assets/images/home/top-screen/clock.webp";
const CENTER_X = 63;
const CENTER_Y = 95;
const store = useHomeStore();
const [clockImage] = useImages(CLOCK_IMAGE);
function drawLine(
ctx: CanvasRenderingContext2D,
x0: number,
y0: number,
x1: number,
y1: number,
width: number,
) {
const dx = Math.abs(x1 - x0);
const dy = Math.abs(y1 - y0);
const sx = x0 < x1 ? 1 : -1;
const sy = y0 < y1 ? 1 : -1;
let err = dx - dy;
const drawThickPixel = (x: number, y: number) => {
const isVertical = dy > dx;
if (width === 1) {
ctx.fillRect(x, y, 1, 1);
} else if (isVertical) {
const offset = Math.floor((width - 1) / 2);
ctx.fillRect(x - offset, y, width, 1);
} else {
const offset = Math.floor((width - 1) / 2);
ctx.fillRect(x, y - offset, 1, width);
}
};
while (true) {
drawThickPixel(x0, y0);
if (x0 === x1 && y0 === y1) break;
const e2 = 2 * err;
if (e2 > -dy) {
err -= dy;
x0 += sx;
}
if (e2 < dx) {
err += dx;
y0 += sy;
}
}
}
useRender((ctx) => {
ctx.globalAlpha = store.isIntro
? store.intro.stage1Opacity
: store.isOutro && store.outro.animateTop
? store.outro.stage1Opacity
: 1;
ctx.drawImage(clockImage!, 13, 45);
ctx.globalAlpha = store.isIntro
? store.intro.stage1Opacity
: store.isOutro && store.outro.animateTop
? store.outro.stage2Opacity
: 1;
const now = new Date();
const renderHand = (
value: number,
color: string,
length: number,
width: number,
) => {
const angle = value * Math.PI * 2 - Math.PI / 2;
const endX = Math.round(CENTER_X + Math.cos(angle) * length);
const endY = Math.round(CENTER_Y + Math.sin(angle) * length);
ctx.fillStyle = color;
drawLine(ctx, CENTER_X, CENTER_Y, endX, endY, width);
};
renderHand(now.getMinutes() / 60, "#797979", 30, 2);
renderHand(
now.getHours() / 12 + now.getMinutes() / 60 / 12,
"#797979",
23,
2,
);
renderHand(now.getSeconds() / 60, "#49db8a", 35, 2);
ctx.fillStyle = "#494949";
ctx.fillRect(CENTER_X - 2, CENTER_Y - 2, 5, 5);
});
defineOptions({
render: () => null,
});
</script>

View File

@@ -1,62 +0,0 @@
<script setup lang="ts">
import STATUS_BAR_IMAGE from "/assets/images/home/top-screen/status-bar/status-bar.webp";
import GBA_DISPLAY_IMAGE from "/assets/images/home/top-screen/status-bar/gba-display.webp";
import STARTUP_MODE_IMAGE from "/assets/images/home/top-screen/status-bar/startup-mode.webp";
import BATTERY_IMAGE from "/assets/images/home/top-screen/status-bar/battery.webp";
const store = useHomeStore();
const [statusBarImage, gbaDisplayImage, startupModeImage, batteryImage] =
useImages(
STATUS_BAR_IMAGE,
GBA_DISPLAY_IMAGE,
STARTUP_MODE_IMAGE,
BATTERY_IMAGE,
);
useRender((ctx) => {
const TEXT_Y = 11;
ctx.translate(0, store.isIntro ? store.intro.statusBarY : 0);
ctx.globalAlpha =
store.isOutro && store.outro.animateTop ? store.outro.stage2Opacity : 1;
ctx.drawImage(statusBarImage!, 0, 0);
ctx.fillStyle = "#ffffff";
ctx.font = "7px NDS7";
// username
ctx.fillText("pihkaal", 3, TEXT_Y);
// time + date
const fillNumberCell = (value: number, cellX: number, offset: number) => {
const text = value.toFixed().padStart(2, "0");
const { actualBoundingBoxRight: width } = ctx.measureText(text);
const x = cellX * 16;
ctx.fillText(text, Math.floor(x + offset + (16 - width) / 2), TEXT_Y);
};
const now = new Date();
fillNumberCell(now.getHours(), 9, 1);
if (Math.floor(now.getMilliseconds() / 500) % 2 == 0) {
ctx.fillText(":", 159, TEXT_Y);
}
fillNumberCell(now.getMinutes(), 10, -1);
fillNumberCell(now.getDate(), 11, 1);
ctx.fillText("/", 190, TEXT_Y);
fillNumberCell(now.getMonth() + 1, 12, -1);
// icons
ctx.drawImage(gbaDisplayImage!, 210, 2);
ctx.drawImage(startupModeImage!, 226, 2);
ctx.drawImage(batteryImage!, 242, 4);
});
defineOptions({
render: () => null,
});
</script>

View File

@@ -1,20 +0,0 @@
<script setup lang="ts">
import Background from "./Background.vue";
import Calendar from "./Calendar.vue";
import Clock from "./Clock.vue";
import StatusBar from "./StatusBar.vue";
const store = useHomeStore();
onMounted(() => {
store.$reset();
store.animateIntro();
});
</script>
<template>
<Background />
<Calendar />
<Clock />
<StatusBar />
</template>

View File

@@ -1,12 +0,0 @@
<script setup lang="ts">
import BACKGROUND_IMAGE from "~/assets/images/projects/bottom-screen/background.webp";
const [backgroundImage] = useImages(BACKGROUND_IMAGE);
useRender((ctx) => {
ctx.drawImage(backgroundImage!, 0, 0);
});
defineOptions({
render: () => null,
});
</script>

View File

@@ -1,17 +0,0 @@
<script setup lang="ts">
import Background from "./Background.vue";
import Buttons from "./Buttons.vue";
const store = useProjectsStore();
onMounted(async () => {
store.$reset();
await store.loadProjects();
});
</script>
<template>
<Background />
<Buttons v-if="!store.loading" />
</template>

View File

@@ -1,56 +0,0 @@
<script setup lang="ts">
const store = useProjectsStore();
const PREV_BUTTON: Point = [36, 100];
const QUIT_BUTTON: Point = [88, 156];
const LINK_BUTTON: Point = [168, 156];
const NEXT_BUTTON: Point = [220, 100];
const CLICK_RADIUS = 22;
const circleContains = (
[cx, cy]: Point,
[x, y]: Point,
radius: number,
): boolean => Math.sqrt(Math.pow(x - cx, 2) + Math.pow(y - cy, 2)) < radius;
useScreenClick((x, y) => {
const project = store.projects[store.currentProject];
if (circleContains(PREV_BUTTON, [x, y], CLICK_RADIUS)) {
store.scrollProjects("left");
} else if (circleContains(NEXT_BUTTON, [x, y], CLICK_RADIUS)) {
store.scrollProjects("right");
} else if (circleContains(QUIT_BUTTON, [x, y], CLICK_RADIUS)) {
throw new Error("quit");
} else if (
circleContains(LINK_BUTTON, [x, y], CLICK_RADIUS) &&
project?.link
) {
// TODO: show confirmation popup before opening the link, like "you are about to navigate to [...]"
store.visitProject();
}
});
useScreenMouseWheel((dy) => {
if (dy > 0) {
store.scrollProjects("right");
} else if (dy < 0) {
store.scrollProjects("left");
}
});
useKeyDown((key) => {
switch (key) {
case "ArrowLeft":
store.scrollProjects("left");
break;
case "ArrowRight":
store.scrollProjects("right");
break;
}
});
defineOptions({
render: () => null,
});
</script>

View File

@@ -1,13 +0,0 @@
<script setup lang="ts">
import BACKGROUND_IMAGE from "/assets/images/projects/top-screen/background.webp";
const [backgroundImage] = useImages(BACKGROUND_IMAGE);
useRender((ctx) => {
ctx.drawImage(backgroundImage!, 0, 0);
});
defineOptions({
render: () => null,
});
</script>

View File

@@ -1,134 +0,0 @@
<script setup lang="ts">
import BACKGROUND_IMAGE from "~/assets/images/projects/top-screen/background.webp";
const store = useProjectsStore();
const [backgroundImage, ...projectImages] = useImages(
BACKGROUND_IMAGE,
...store.projects.map((project) => `/images/projects/${project.id}.webp`),
);
const drawTextWithShadow = (
ctx: CanvasRenderingContext2D,
color: "white" | "black",
text: string,
x: number,
y: number,
) => {
ctx.fillStyle = color === "white" ? "#505050" : "#a8b8b8";
ctx.fillText(text, x + 1, y + 0);
ctx.fillText(text, x + 1, y + 1);
ctx.fillText(text, x + 0, y + 1);
ctx.fillStyle = color === "white" ? "#f8f8f8" : "#101820";
ctx.fillText(text, x, y);
};
const drawTextWithShadow2Lines = (
ctx: CanvasRenderingContext2D,
text: string,
x: number,
y: number,
maxWidth: number,
line1Color: "white" | "black",
line2Color: "white" | "black",
) => {
const { actualBoundingBoxRight: textWidth } = ctx.measureText(text);
if (textWidth <= maxWidth) {
drawTextWithShadow(ctx, line1Color, text, x, y);
return;
}
const words = text.split(" ");
let firstLine = "";
let secondLine = "";
for (let i = 0; i < words.length; i++) {
const testLine = firstLine + (firstLine ? " " : "") + words[i];
const { actualBoundingBoxRight: testWidth } = ctx.measureText(testLine);
if (testWidth > maxWidth && firstLine) {
secondLine = words.slice(i).join(" ");
break;
}
firstLine = testLine;
}
drawTextWithShadow(ctx, line1Color, firstLine, x, y);
drawTextWithShadow(ctx, line2Color, secondLine, x, y + 16);
};
useRender((ctx) => {
ctx.drawImage(backgroundImage!, 0, 0);
ctx.textBaseline = "hanging";
ctx.font = "16px Pokemon DP Pro";
const project = store.projects[store.currentProject];
if (!project) return;
// image
const projectImage = projectImages[store.currentProject]!;
ctx.drawImage(
projectImage,
Math.floor(52 - projectImage.width / 2),
Math.floor(104 - projectImage.height / 2),
);
// text
drawTextWithShadow(ctx, "white", project.title.toUpperCase(), 23, 25);
drawTextWithShadow(ctx, "black", project.scope, 12, 41);
drawTextWithShadow2Lines(
ctx,
project.description,
8,
161,
90,
"white",
"black",
);
const { actualBoundingBoxRight: textWidth } = ctx.measureText(
project.summary,
);
drawTextWithShadow(
ctx,
"black",
project.summary,
Math.floor(185 - textWidth / 2),
19,
);
let textY = 35;
for (let i = 0; i < project.tasks.length; i += 1) {
const lines = project.tasks[i]!.split("\\n");
ctx.fillStyle = i % 2 === 0 ? "#6870d8" : "#8890f8";
ctx.fillRect(106, textY - 1, 150, lines.length * 16);
ctx.fillStyle = i % 2 === 0 ? "#8890f8" : "#b0b8d0";
ctx.fillRect(105, textY - 1, 1, lines.length * 16);
for (let j = 0; j < lines.length; j += 1) {
drawTextWithShadow(ctx, "white", lines[j]!, 118, textY);
textY += 16;
}
}
drawTextWithShadow2Lines(
ctx,
project.technologies.join(", "),
111,
161,
145,
"black",
"black",
);
});
defineOptions({
render: () => null,
});
</script>

View File

@@ -1,12 +0,0 @@
<script setup lang="ts">
import Background from "./Background.vue";
import Project from "./Project.vue";
const store = useProjectsStore();
</script>
<template>
<Background />
<Project v-if="!store.loading" />
</template>

View File

@@ -1,127 +0,0 @@
<script lang="ts" setup>
const canvas = useTemplateRef("canvas");
const updateCallbacks = new Set<UpdateCallback>();
const renderCallbacks = new Set<RenderCallback>();
const screenClickCallbacks = new Set<ScreenClickCallback>();
const screenMouseWheelCallbacks = new Set<ScreenMouseWheelCallback>();
let ctx: CanvasRenderingContext2D | null = null;
let animationFrameId: number | null = null;
let lastFrameTime = 0;
let lastRealFrameTime = 0;
const registerUpdateCallback = (callback: UpdateCallback) => {
updateCallbacks.add(callback);
return () => updateCallbacks.delete(callback);
};
const registerRenderCallback = (callback: RenderCallback) => {
renderCallbacks.add(callback);
return () => renderCallbacks.delete(callback);
};
const registerScreenClickCallback = (callback: ScreenClickCallback) => {
screenClickCallbacks.add(callback);
return () => screenClickCallbacks.delete(callback);
};
const registerScreenMouseWheelCallback = (
callback: ScreenMouseWheelCallback,
) => {
screenMouseWheelCallbacks.add(callback);
return () => screenMouseWheelCallbacks.delete(callback);
};
const handleCanvasClick = (event: MouseEvent) => {
if (!canvas.value) return;
const rect = canvas.value.getBoundingClientRect();
const scaleX = SCREEN_WIDTH / rect.width;
const scaleY = SCREEN_HEIGHT / rect.height;
const x = (event.clientX - rect.left) * scaleX;
const y = (event.clientY - rect.top) * scaleY;
for (const callback of screenClickCallbacks) {
callback(x, y);
}
};
const handleCanvasWheel = (event: WheelEvent) => {
for (const callback of screenMouseWheelCallbacks) {
callback(event.deltaY, event.deltaX);
}
};
const renderFrame = (timestamp: number) => {
if (!ctx) return;
const deltaTime = timestamp - lastFrameTime;
lastFrameTime = timestamp;
const start = Date.now();
// update
for (const callback of updateCallbacks) {
callback(deltaTime, lastRealFrameTime);
}
// render
ctx.clearRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
for (const callback of renderCallbacks) {
ctx.save();
callback(ctx);
ctx.restore();
}
lastRealFrameTime = Date.now() - start;
animationFrameId = requestAnimationFrame(renderFrame);
};
onMounted(() => {
if (!canvas.value) throw new Error("Missing canvas");
ctx = canvas.value.getContext("2d");
if (!ctx) throw new Error("Missing 2d context");
provide("registerUpdateCallback", registerUpdateCallback);
provide("registerRenderCallback", registerRenderCallback);
provide("registerScreenClickCallback", registerScreenClickCallback);
provide("registerScreenMouseWheelCallback", registerScreenMouseWheelCallback);
canvas.value.addEventListener("click", handleCanvasClick);
canvas.value.addEventListener("wheel", handleCanvasWheel, { passive: true });
animationFrameId = requestAnimationFrame(renderFrame);
});
onUnmounted(() => {
if (animationFrameId !== null) {
cancelAnimationFrame(animationFrameId);
}
if (canvas.value) {
canvas.value.removeEventListener("click", handleCanvasClick);
canvas.value.removeEventListener("wheel", handleCanvasWheel);
}
});
</script>
<template>
<canvas
ref="canvas"
:width="SCREEN_WIDTH"
:height="SCREEN_HEIGHT"
:style="{
margin: '0',
border: '1px solid red',
}"
/>
<slot v-if="canvas" />
</template>

View File

@@ -1,21 +0,0 @@
<script setup lang="ts">
import BACKGROUND_IMAGE from "/assets/images/home/bottom-screen/background.webp";
import TOP_BAR_IMAGE from "/assets/images/settings/bottom-screen/top-bar.webp";
import BOTTOM_BAR_IMAGE from "/assets/images/settings/bottom-screen/bottom-bar.webp";
const [backgroundImage, topBarImage, bottomBarImage] = useImages(
BACKGROUND_IMAGE,
TOP_BAR_IMAGE,
BOTTOM_BAR_IMAGE,
);
useRender((ctx) => {
ctx.drawImage(backgroundImage!, 0, 0);
ctx.drawImage(topBarImage!, 0, 0);
ctx.drawImage(bottomBarImage!, 0, 168);
});
defineOptions({
render: () => null,
});
</script>

View File

@@ -1,9 +0,0 @@
<script setup lang="ts">
import Background from "./Background.vue";
import Menus from "./Menus/Menus.vue";
</script>
<template>
<Background />
<Menus />
</template>

View File

@@ -1,66 +0,0 @@
<script setup lang="ts">
import MENU_IMAGE from "/assets/images/settings/top-screen/clock/clock.webp";
import MENU_ACTIVE_IMAGE from "/assets/images/settings/top-screen/clock/clock-active.webp";
import MENU_DISABLED_IMAGE from "/assets/images/settings/top-screen/clock/clock-disabled.png";
import ALARM_IMAGE from "/assets/images/settings/top-screen/clock/alarm.webp";
import TIME_IMAGE from "/assets/images/settings/top-screen/clock/time.webp";
import DATE_IMAGE from "/assets/images/settings/top-screen/clock/date.webp";
const props = defineProps<{
x: number;
y: number;
}>();
const settingsStore = useSettingsStore();
const [
menuImage,
menuActiveImage,
menuDisabledImage,
alarmImage,
timeImage,
dateImage,
] = useImages(
MENU_IMAGE,
MENU_ACTIVE_IMAGE,
MENU_DISABLED_IMAGE,
ALARM_IMAGE,
TIME_IMAGE,
DATE_IMAGE,
);
const isOpen = computed(() => settingsStore.isMenuOpen("clock"));
const isAnyOtherMenuOpen = computed(() =>
settingsStore.isAnyOtherMenuOpen("clock"),
);
const animation = useMenuAnimation("clock", isOpen);
useRender((ctx) => {
ctx.translate(props.x, props.y);
if (isOpen.value || animation.playing) {
ctx.drawImage(
timeImage!,
48 - animation.stage2Offset,
-48 + animation.stage1Offset,
);
ctx.drawImage(
dateImage!,
0,
-96 + animation.stage2Offset + animation.stage1Offset,
);
ctx.drawImage(alarmImage!, 0, -48 + animation.stage1Offset);
ctx.drawImage(menuActiveImage!, 0, 0);
} else if (isAnyOtherMenuOpen.value) {
ctx.drawImage(menuDisabledImage!, 0, 0);
} else {
ctx.drawImage(menuImage!, 0, 0);
}
});
defineOptions({
render: () => null,
});
</script>

View File

@@ -1,184 +0,0 @@
<script setup lang="ts">
import OptionsMenu from "./Options/Menu.vue";
import OptionsStartUp from "./Options/StartUp.vue";
import OptionsLanguage from "./Options/Language.vue";
import OptionsGbaMode from "./Options/GbaMode.vue";
import ClockMenu from "./Clock/Menu.vue";
import UserMenu from "./User/Menu.vue";
import TouchScreenMenu from "./TouchScreen/Menu.vue";
import Selector from "~/components/Common/ButtonSelector.vue";
const settingsStore = useSettingsStore();
const { selectedButton: selected, selectorPosition } = useButtonNavigation({
buttons: {
options: [31, 119, 49, 49],
optionsLanguage: [31, 71, 49, 49],
optionsGbaMode: [79, 71, 49, 49],
optionsStartUp: [31, 23, 49, 49],
clock: [79, 119, 49, 49],
clockAlarm: [79, 71, 49, 49],
clockTime: [127, 71, 49, 49],
clockDate: [79, 23, 49, 49],
user: [127, 119, 49, 49],
userBirthday: [79, 71, 49, 49],
userName: [127, 71, 49, 49],
userMessage: [175, 71, 49, 49],
userColor: [127, 23, 49, 49],
touchScreen: [175, 119, 49, 49],
},
initialButton: "options",
onButtonClick: (buttonName: string) => {
if (isSubmenu(buttonName)) {
router.push({
query: {
menu: buttonName,
},
});
}
},
navigation: {
options: {
right: "clock",
up: "optionsLanguage",
},
optionsLanguage: {
down: "options",
up: "optionsStartUp",
right: "optionsGbaMode",
},
optionsGbaMode: {
down: "options",
left: "optionsLanguage",
up: "optionsStartUp",
},
optionsStartUp: {
right: "optionsGbaMode",
down: "optionsLanguage",
},
clock: {
left: "options",
right: "user",
up: "clockAlarm",
},
clockAlarm: {
down: "clock",
up: "clockDate",
right: "clockTime",
},
clockTime: {
down: "clock",
left: "clockAlarm",
up: "clockDate",
},
clockDate: {
right: "clockTime",
down: "clockAlarm",
},
user: {
left: "clock",
right: "touchScreen",
up: "userName",
},
userBirthday: {
down: "user",
up: "userColor",
right: "userName",
},
userName: {
down: "user",
left: "userBirthday",
right: "userMessage",
up: "userColor",
},
userMessage: {
down: "user",
left: "userName",
up: "userColor",
},
userColor: {
left: "userBirthday",
right: "userMessage",
down: "userName",
},
touchScreen: {
left: "user",
},
},
});
const isSubmenu = (buttonName: string) => {
return (
/^(options|clock|user|touchScreen)[A-Z]/.test(buttonName) &&
!["options", "clock", "user", "touchScreen"].includes(buttonName)
);
};
const router = useRouter();
onBeforeRouteUpdate((to, from) => {
const fromMenu = from.query.menu?.toString();
const toMenu = to.query.menu?.toString();
if (!fromMenu && toMenu) {
settingsStore.setActiveMenu(selected.value);
} else if (fromMenu && !toMenu) {
if (fromMenu === "options" || fromMenu === "clock" || fromMenu === "user") {
selected.value = fromMenu;
settingsStore.setActiveMenu(null);
} else {
throw new Error("Unreachable");
}
} else if (fromMenu && toMenu) {
if (toMenu === "options" || toMenu === "clock" || toMenu === "user") {
settingsStore.setCurrentSubMenu(null);
settingsStore.setActiveMenu(selected.value);
} else {
settingsStore.setCurrentSubMenu(toMenu);
}
}
});
watch(
selected,
(newSelected) => {
if (settingsStore.currentSubMenu === null) {
if (isSubmenu(newSelected)) {
router.push({
query: {
menu: newSelected.split(/[A-Z]/)[0],
},
});
} else {
router.push({ query: { menu: undefined } });
}
}
},
{ immediate: true },
);
const viewComponents: Record<string, Component> = {
optionsStartUp: OptionsStartUp,
optionsLanguage: OptionsLanguage,
optionsGbaMode: OptionsGbaMode,
};
</script>
<template>
<template v-if="!settingsStore.currentSubMenu">
<OptionsMenu :x="33" :y="121" />
<ClockMenu :x="81" :y="121" />
<UserMenu :x="129" :y="121" />
<TouchScreenMenu :x="177" :y="121" :opacity="1" />
<Selector :rect="selectorPosition" :opacity="1" />
</template>
<component :is="viewComponents[settingsStore.currentSubMenu]" v-else />
</template>

View File

@@ -1,11 +0,0 @@
<script setup lang="ts">
useRender((ctx) => {
ctx.font = "10px NDS10";
ctx.fillStyle = "#000000";
ctx.fillText("GBA Mode", 10, 20);
});
defineOptions({
render: () => null,
});
</script>

View File

@@ -1,11 +0,0 @@
<script setup lang="ts">
useRender((ctx) => {
ctx.font = "10px NDS10";
ctx.fillStyle = "#000000";
ctx.fillText("Language", 10, 20);
});
defineOptions({
render: () => null,
});
</script>

View File

@@ -1,66 +0,0 @@
<script setup lang="ts">
import MENU_IMAGE from "/assets/images/settings/top-screen/options/options.webp";
import MENU_ACTIVE_IMAGE from "/assets/images/settings/top-screen/options/options-active.png";
import MENU_DISABLED_IMAGE from "/assets/images/settings/top-screen/options/options-disabled.png";
import GBA_MODE_IMAGE from "/assets/images/settings/top-screen/options/gba-mode.webp";
import LANGUAGE_IMAGE from "/assets/images/settings/top-screen/options/language.webp";
import START_UP_IMAGE from "/assets/images/settings/top-screen/options/start-up.webp";
const props = defineProps<{
x: number;
y: number;
}>();
const settingsStore = useSettingsStore();
const [
menuImage,
menuActiveImage,
menuDisabledImage,
gbaModeImage,
languageImage,
startUpImage,
] = useImages(
MENU_IMAGE,
MENU_ACTIVE_IMAGE,
MENU_DISABLED_IMAGE,
GBA_MODE_IMAGE,
LANGUAGE_IMAGE,
START_UP_IMAGE,
);
const isOpen = computed(() => settingsStore.isMenuOpen("options"));
const isAnyOtherMenuOpen = computed(() =>
settingsStore.isAnyOtherMenuOpen("options"),
);
const animation = useMenuAnimation("options", isOpen);
useRender((ctx) => {
ctx.translate(props.x, props.y);
if (isOpen.value || animation.playing) {
ctx.drawImage(languageImage!, 0, -48 + animation.stage1Offset);
ctx.drawImage(
gbaModeImage!,
48 - animation.stage2Offset,
-48 + animation.stage1Offset,
);
ctx.drawImage(
startUpImage!,
0,
-96 + animation.stage2Offset + animation.stage1Offset,
);
ctx.drawImage(menuActiveImage!, 0, 0);
} else if (isAnyOtherMenuOpen.value) {
ctx.drawImage(menuDisabledImage!, 0, 0);
} else {
ctx.drawImage(menuImage!, 0, 0);
}
});
defineOptions({
render: () => null,
});
</script>

View File

@@ -1,11 +0,0 @@
<script setup lang="ts">
useRender((ctx) => {
ctx.font = "10px NDS10";
ctx.fillStyle = "#000000";
ctx.fillText("Startup", 10, 20);
});
defineOptions({
render: () => null,
});
</script>

View File

@@ -1,32 +0,0 @@
<script setup lang="ts">
import MENU_IMAGE from "/assets/images/settings/top-screen/touch_screen/touch-screen.webp";
import MENU_DISABLED_IMAGE from "/assets/images/settings/top-screen/touch_screen/touch-screen-disabled.png";
const props = defineProps<{
x: number;
y: number;
}>();
const settingsStore = useSettingsStore();
const [menuImage, menuDisabledImage] = useImages(
MENU_IMAGE,
MENU_DISABLED_IMAGE,
);
const isAnyOtherMenuOpen = computed(() =>
settingsStore.isAnyOtherMenuOpen("touchScreen"),
);
useRender((ctx) => {
if (isAnyOtherMenuOpen.value) {
ctx.drawImage(menuDisabledImage!, props.x, props.y);
} else {
ctx.drawImage(menuImage!, props.x, props.y);
}
});
defineOptions({
render: () => null,
});
</script>

View File

@@ -1,74 +0,0 @@
<script setup lang="ts">
import MENU_IMAGE from "/assets/images/settings/top-screen/user/user.webp";
import MENU_ACTIVE_IMAGE from "/assets/images/settings/top-screen/user/user-active.webp";
import MENU_DISABLED_IMAGE from "/assets/images/settings/top-screen/user/user-disabled.png";
import BIRTHDAY_IMAGE from "/assets/images/settings/top-screen/user/birthday.webp";
import COLOR_IMAGE from "/assets/images/settings/top-screen/user/color.webp";
import MESSAGE_IMAGE from "/assets/images/settings/top-screen/user/message.webp";
import USER_NAME_IMAGE from "/assets/images/settings/top-screen/user/user-name.webp";
const props = defineProps<{
x: number;
y: number;
}>();
const settingsStore = useSettingsStore();
const [
menuImage,
menuActiveImage,
menuDisabledImage,
birthdayImage,
colorImage,
messageImage,
userNameImage,
] = useImages(
MENU_IMAGE,
MENU_ACTIVE_IMAGE,
MENU_DISABLED_IMAGE,
BIRTHDAY_IMAGE,
COLOR_IMAGE,
MESSAGE_IMAGE,
USER_NAME_IMAGE,
);
const isOpen = computed(() => settingsStore.isMenuOpen("user"));
const isAnyOtherMenuOpen = computed(() =>
settingsStore.isAnyOtherMenuOpen("user"),
);
const animation = useMenuAnimation("user", isOpen);
useRender((ctx) => {
ctx.translate(props.x, props.y);
if (isOpen.value || animation.playing) {
ctx.drawImage(
birthdayImage!,
-48 + animation.stage2Offset,
-48 + animation.stage1Offset,
);
ctx.drawImage(userNameImage!, 0, -48 + animation.stage1Offset);
ctx.drawImage(
messageImage!,
48 - animation.stage2Offset,
-48 + animation.stage1Offset,
);
ctx.drawImage(
colorImage!,
0,
-96 + animation.stage2Offset + animation.stage1Offset,
);
ctx.drawImage(menuActiveImage!, 0, 0);
} else if (isAnyOtherMenuOpen.value) {
ctx.drawImage(menuDisabledImage!, 0, 0);
} else {
ctx.drawImage(menuImage!, 0, 0);
}
});
defineOptions({
render: () => null,
});
</script>

View File

@@ -1,13 +0,0 @@
<script setup lang="ts">
import BACKGROUND_IMAGE from "/assets/images/home/top-screen/background.webp";
const [backgroundImage] = useImages(BACKGROUND_IMAGE);
useRender((ctx) => {
ctx.drawImage(backgroundImage!, 0, 0);
});
defineOptions({
render: () => null,
});
</script>

View File

@@ -1,81 +0,0 @@
<script setup lang="ts">
import CALENDAR_IMAGE from "/assets/images/home/top-screen/calendar/calendar.webp";
import LAST_ROW_IMAGE from "/assets/images/home/top-screen/calendar/last-row.webp";
import DAY_SELECTOR_IMAGE from "/assets/images/home/top-screen/calendar/day-selector.webp";
const [calendarImage, lastRowImage, daySelectorImage] = useImages(
CALENDAR_IMAGE,
LAST_ROW_IMAGE,
DAY_SELECTOR_IMAGE,
);
useRender((ctx) => {
ctx.fillStyle = "black";
ctx.font = "7px NDS7";
ctx.translate(0, -16);
const CALENDAR_COLS = 7;
const CALENDAR_ROWS = 5;
const CALENDAR_LEFT = 128;
const CALENDAR_TOP = 64;
ctx.fillStyle = "#343434";
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth();
const firstDay = new Date(year, month, 1).getDay();
const daysInMonth = new Date(year, month + 1, 0).getDate();
ctx.drawImage(calendarImage!, CALENDAR_LEFT - 3, CALENDAR_TOP - 33);
const extraRow = CALENDAR_COLS * CALENDAR_ROWS - daysInMonth - firstDay < 0;
if (extraRow) {
ctx.drawImage(lastRowImage!, CALENDAR_LEFT - 3, CALENDAR_TOP + 79);
}
for (let col = 0; col < CALENDAR_ROWS + (extraRow ? 1 : 0); col += 1) {
for (let row = 0; row < CALENDAR_COLS; row += 1) {
const cellIndex = col * CALENDAR_COLS + row;
const day = cellIndex - firstDay + 1;
if (day > 0 && day <= daysInMonth) {
const dayText = day.toString();
const { actualBoundingBoxRight: width } = ctx.measureText(dayText);
const cellLeft = CALENDAR_LEFT + row * 16;
const cellTop = CALENDAR_TOP + col * 16;
if (now.getDate() === day) {
ctx.drawImage(daySelectorImage!, cellLeft, cellTop);
}
ctx.fillText(
dayText,
cellLeft + Math.floor((15 - width) / 2),
cellTop + 11,
);
}
}
}
ctx.fillStyle = "black";
ctx.font = "10px NDS10";
ctx.letterSpacing = "2px";
const timeText = `${month}/${year}`;
const { actualBoundingBoxRight: width } = ctx.measureText(timeText);
ctx.fillText(
timeText,
CALENDAR_LEFT + Math.floor((111 - width) / 2),
CALENDAR_TOP - 20,
);
});
defineOptions({
render: () => null,
});
</script>

View File

@@ -1,90 +0,0 @@
<script setup lang="ts">
import CLOCK_IMAGE from "/assets/images/home/top-screen/clock.webp";
const CENTER_X = 63;
const CENTER_Y = 95;
const [clockImage] = useImages(CLOCK_IMAGE);
function drawLine(
ctx: CanvasRenderingContext2D,
x0: number,
y0: number,
x1: number,
y1: number,
width: number,
) {
const dx = Math.abs(x1 - x0);
const dy = Math.abs(y1 - y0);
const sx = x0 < x1 ? 1 : -1;
const sy = y0 < y1 ? 1 : -1;
let err = dx - dy;
const drawThickPixel = (x: number, y: number) => {
const isVertical = dy > dx;
if (width === 1) {
ctx.fillRect(x, y, 1, 1);
} else if (isVertical) {
const offset = Math.floor((width - 1) / 2);
ctx.fillRect(x - offset, y, width, 1);
} else {
const offset = Math.floor((width - 1) / 2);
ctx.fillRect(x, y - offset, 1, width);
}
};
while (true) {
drawThickPixel(x0, y0);
if (x0 === x1 && y0 === y1) break;
const e2 = 2 * err;
if (e2 > -dy) {
err -= dy;
x0 += sx;
}
if (e2 < dx) {
err += dx;
y0 += sy;
}
}
}
useRender((ctx) => {
ctx.translate(0, -16);
ctx.drawImage(clockImage!, 13, 45);
const now = new Date();
const renderHand = (
value: number,
color: string,
length: number,
width: number,
) => {
const angle = value * Math.PI * 2 - Math.PI / 2;
const endX = Math.round(CENTER_X + Math.cos(angle) * length);
const endY = Math.round(CENTER_Y + Math.sin(angle) * length);
ctx.fillStyle = color;
drawLine(ctx, CENTER_X, CENTER_Y, endX, endY, width);
};
renderHand(now.getMinutes() / 60, "#797979", 30, 2);
renderHand(
now.getHours() / 12 + now.getMinutes() / 60 / 12,
"#797979",
23,
2,
);
renderHand(now.getSeconds() / 60, "#49db8a", 35, 2);
ctx.fillStyle = "#494949";
ctx.fillRect(CENTER_X - 2, CENTER_Y - 2, 5, 5);
});
defineOptions({
render: () => null,
});
</script>

View File

@@ -1,157 +0,0 @@
<script setup lang="ts">
import NOTIFICATION_IMAGE from "/assets/images/settings/top-screen/notification.webp";
import SETTINGS_IMAGE from "/assets/images/settings/top-screen/settings.webp";
import OPTIONS_IMAGE from "/assets/images/settings/top-screen/options/options.webp";
import CLOCK_IMAGE from "/assets/images/settings/top-screen/clock/clock.webp";
import USER_IMAGE from "/assets/images/settings/top-screen/user/user.webp";
import TOUCH_SCREEN_IMAGE from "/assets/images/settings/top-screen/touch_screen/touch-screen.webp";
import START_UP_IMAGE from "/assets/images/settings/top-screen/options/start-up.webp";
import LANGUAGE_IMAGE from "/assets/images/settings/top-screen/options/language.webp";
import GBA_MODE_IMAGE from "/assets/images/settings/top-screen/options/gba-mode.webp";
const store = useSettingsStore();
const [
notificationImage,
settingsImage,
optionsImage,
clockImage,
userImage,
touchScreenImage,
startUpImage,
languageImage,
gbaModeImage,
] = useImages(
NOTIFICATION_IMAGE,
SETTINGS_IMAGE,
OPTIONS_IMAGE,
CLOCK_IMAGE,
USER_IMAGE,
TOUCH_SCREEN_IMAGE,
START_UP_IMAGE,
LANGUAGE_IMAGE,
GBA_MODE_IMAGE,
);
const renderNotification = (
ctx: CanvasRenderingContext2D,
image: HTMLImageElement,
title: string,
description: string,
) => {
ctx.drawImage(notificationImage!, 0, 0);
ctx.drawImage(image, 2, 2);
ctx.font = "10px NDS10";
ctx.fillStyle = "#282828";
ctx.fillText(title, 52, 13);
ctx.fillStyle = "#fbfbfb";
const lines = description.split("\n");
const textY = lines.length === 1 ? 36 : 28;
for (let i = 0; i < lines.length; i += 1) {
ctx.fillText(lines[i]!, 52, textY + 14 * i);
}
};
const mainNotification = computed(() => ({
image: settingsImage!,
title: $t("settings.title"),
description: $t("settings.description"),
}));
const menuNotification = computed(() => {
if (!store.currentMenu) return null;
let image: HTMLImageElement | null = null;
let id = "";
if (/^options[A-Z]/.test(store.currentMenu)) {
image = optionsImage!;
id = "options";
} else if (/^clock[A-Z]/.test(store.currentMenu)) {
image = clockImage!;
id = "clock";
} else if (/^user[A-Z]/.test(store.currentMenu)) {
image = userImage!;
id = "user";
} else if (/^touchScreen[A-Z]/.test(store.currentMenu)) {
image = touchScreenImage!;
id = "touchScreen";
}
if (!image) return null;
return {
image,
title: $t(`settings.${id}.title`),
description: $t(`settings.${id}.description`),
};
});
const IMAGES_MAP: Record<string, HTMLImageElement> = {
optionsStartUp: startUpImage!,
optionsLanguage: languageImage!,
optionsGbaMode: gbaModeImage!,
};
const submenuNotification = computed(() => {
if (!store.currentSubMenu) return null;
const image = IMAGES_MAP[store.currentSubMenu];
if (!image) return null;
const menuMatch = store.currentSubMenu.match(
/^(options|clock|user|touchScreen)(.+)$/,
);
if (!menuMatch) return null;
const [, menu, submenu] = menuMatch;
const submenuKey = submenu!.charAt(0).toLowerCase() + submenu!.slice(1);
return {
image,
title: $t(`settings.${menu}.${submenuKey}.title`),
description: $t(`settings.${menu}.${submenuKey}.description`),
};
});
useRender((ctx) => {
let count = 1;
if (menuNotification.value) count++;
if (submenuNotification.value) count++;
ctx.translate(0, 144 - (count - 1) * 16);
renderNotification(
ctx,
mainNotification.value.image,
mainNotification.value.title,
mainNotification.value.description,
);
if (menuNotification.value) {
ctx.translate(0, 16);
renderNotification(
ctx,
menuNotification.value.image,
menuNotification.value.title,
menuNotification.value.description,
);
}
if (submenuNotification.value) {
ctx.translate(0, 16);
renderNotification(
ctx,
submenuNotification.value.image,
submenuNotification.value.title,
submenuNotification.value.description,
);
}
});
defineOptions({
render: () => null,
});
</script>

View File

@@ -1,56 +0,0 @@
<script setup lang="ts">
import STATUS_BAR_IMAGE from "/assets/images/home/top-screen/status-bar/status-bar.webp";
import GBA_DISPLAY_IMAGE from "/assets/images/home/top-screen/status-bar/gba-display.webp";
import STARTUP_MODE_IMAGE from "/assets/images/home/top-screen/status-bar/startup-mode.webp";
import BATTERY_IMAGE from "/assets/images/home/top-screen/status-bar/battery.webp";
const [statusBarImage, gbaDisplayImage, startupModeImage, batteryImage] =
useImages(
STATUS_BAR_IMAGE,
GBA_DISPLAY_IMAGE,
STARTUP_MODE_IMAGE,
BATTERY_IMAGE,
);
useRender((ctx) => {
const TEXT_Y = 11;
ctx.drawImage(statusBarImage!, 0, 0);
ctx.fillStyle = "#ffffff";
ctx.font = "7px NDS7";
// username
ctx.fillText("pihkaal", 3, TEXT_Y);
// time + date
const fillNumberCell = (value: number, cellX: number, offset: number) => {
const text = value.toFixed().padStart(2, "0");
const { actualBoundingBoxRight: width } = ctx.measureText(text);
const x = cellX * 16;
ctx.fillText(text, Math.floor(x + offset + (16 - width) / 2), TEXT_Y);
};
const now = new Date();
fillNumberCell(now.getHours(), 9, 1);
if (Math.floor(now.getMilliseconds() / 500) % 2 == 0) {
ctx.fillText(":", 159, TEXT_Y);
}
fillNumberCell(now.getMinutes(), 10, -1);
fillNumberCell(now.getDate(), 11, 1);
ctx.fillText("/", 190, TEXT_Y);
fillNumberCell(now.getMonth() + 1, 12, -1);
// icons
ctx.drawImage(gbaDisplayImage!, 210, 2);
ctx.drawImage(startupModeImage!, 226, 2);
ctx.drawImage(batteryImage!, 242, 4);
});
defineOptions({
render: () => null,
});
</script>

View File

@@ -1,15 +0,0 @@
<script setup lang="ts">
import Background from "./Background.vue";
import Calendar from "./Calendar.vue";
import Clock from "./Clock.vue";
import StatusBar from "./StatusBar.vue";
import Notifications from "./Notifications.vue";
</script>
<template>
<Background />
<Calendar />
<Clock />
<StatusBar />
<Notifications />
</template>

View File

@@ -1,65 +0,0 @@
<script lang="ts" setup>
const props = withDefaults(defineProps<{ x?: number; y?: number }>(), {
x: 0,
y: 0,
});
const SAMPLES = 60;
let average = { deltaTime: 0, realDeltaTime: 0 };
const lastFrames: (typeof average)[] = [];
useUpdate((deltaTime, realDeltaTime) => {
lastFrames.push({ deltaTime, realDeltaTime });
if (lastFrames.length > SAMPLES) {
lastFrames.shift();
}
if (lastFrames.length > 0) {
average = {
deltaTime:
lastFrames.reduce((acc, v) => acc + v.deltaTime, 0) / lastFrames.length,
realDeltaTime:
lastFrames.reduce((acc, v) => acc + v.realDeltaTime, 0) /
lastFrames.length,
};
}
});
useRender((ctx) => {
const LINE_COUNT = 5;
const LINE_HEIGHT = 12;
ctx.fillStyle = "red";
ctx.fillRect(props.x - 2, props.y, 120, LINE_COUNT * LINE_HEIGHT + 3);
let textY = props.y;
ctx.fillStyle = "black";
ctx.fillText(`[avg on ${SAMPLES} frames]`, props.x, (textY += LINE_HEIGHT));
ctx.fillText(
`fps=${(1000 / average.deltaTime).toFixed()}`,
props.x,
(textY += LINE_HEIGHT),
);
ctx.fillText(
`frame_time=${average.deltaTime.toFixed(2)}ms`,
props.x,
(textY += LINE_HEIGHT),
);
ctx.fillText(
`real_fps=${(1000 / average.realDeltaTime).toFixed()}`,
props.x,
(textY += LINE_HEIGHT),
);
ctx.fillText(
`real_frame_time=${average.realDeltaTime.toFixed(2)}ms`,
props.x,
(textY += LINE_HEIGHT),
);
});
defineOptions({
render: () => null,
});
</script>

View File

@@ -1,140 +0,0 @@
export type ButtonConfig = [x: number, y: number, w: number, h: number];
export const useButtonNavigation = <T extends Record<string, ButtonConfig>>({
buttons,
initialButton,
onButtonClick,
navigation,
}: {
buttons: T;
initialButton: keyof T;
onButtonClick?: (buttonName: keyof T) => void;
navigation: Record<
keyof T,
{
up?: keyof T | "last";
down?: keyof T | "last";
left?: keyof T;
right?: keyof T;
horizontalMode?: "navigate" | "preview";
}
>;
}) => {
const selectedButton = ref(initialButton);
const selectorPosition = computed(() => buttons[selectedButton.value]!);
const nextButton = ref<keyof T | undefined>();
useScreenClick((x: number, y: number) => {
for (const [buttonName, config] of Object.entries(buttons) as [
keyof T,
ButtonConfig,
][]) {
const [sx, sy, sw, sh] = config;
if (x >= sx && x <= sx + sw && y >= sy && y <= sy + sh) {
if (selectedButton.value === buttonName) {
onButtonClick?.(buttonName);
} else {
if (
(navigation[buttonName].down === "last" &&
navigation[selectedButton.value]!.up === buttonName) ||
(navigation[buttonName].up === "last" &&
navigation[selectedButton.value]!.down === buttonName)
) {
nextButton.value = selectedButton.value;
}
selectedButton.value = buttonName;
}
break;
}
}
});
const handleKeyPress = (event: KeyboardEvent) => {
const currentButton = selectedButton.value as keyof T;
const currentNav = navigation[currentButton];
if (!currentNav) return;
switch (event.key) {
case "ArrowUp":
if (!currentNav.up) return;
if (currentNav.up === "last") {
if (nextButton.value) {
selectedButton.value = nextButton.value;
} else {
selectedButton.value = currentNav.left ?? currentNav.right;
}
} else {
if (navigation[currentNav.up].down === "last") {
nextButton.value = selectedButton.value as keyof T;
}
selectedButton.value = currentNav.up;
}
break;
case "ArrowDown":
if (!currentNav.down) return;
if (currentNav.down === "last") {
if (nextButton.value) {
selectedButton.value = nextButton.value;
} else {
selectedButton.value = currentNav.left ?? currentNav.right;
}
} else {
if (navigation[currentNav.down].up === "last") {
nextButton.value = selectedButton.value as keyof T;
}
selectedButton.value = currentNav.down;
}
break;
case "ArrowLeft":
if (!currentNav.left) return;
if (currentNav.horizontalMode === "preview") {
nextButton.value = currentNav.left;
} else {
selectedButton.value = currentNav.left;
}
break;
case "ArrowRight":
if (!currentNav.right) return;
if (currentNav.horizontalMode === "preview") {
nextButton.value = currentNav.right;
} else {
selectedButton.value = currentNav.right;
}
break;
case "Enter":
case " ":
onButtonClick?.(selectedButton.value);
break;
default:
return;
}
event.preventDefault();
};
onMounted(() => {
window.addEventListener("keydown", handleKeyPress);
});
onUnmounted(() => {
window.removeEventListener("keydown", handleKeyPress);
});
return {
selectedButton,
selectorPosition,
};
};

View File

@@ -1,17 +0,0 @@
const imageCache = new Map<string, HTMLImageElement>();
export const useImages = (...paths: string[]) => {
const images = paths.map((path) => {
if (imageCache.has(path)) {
return imageCache.get(path)!;
}
const img = document.createElement("img");
img.src = path;
imageCache.set(path, img);
return img;
});
return images;
};

View File

@@ -1,15 +0,0 @@
export type KeyDownCallback = (key: string) => void;
export const useKeyDown = (callback: KeyDownCallback) => {
const handleKeyDown = (event: KeyboardEvent) => {
callback(event.key);
};
onMounted(() => {
window.addEventListener("keydown", handleKeyDown);
});
onUnmounted(() => {
window.removeEventListener("keydown", handleKeyDown);
});
};

View File

@@ -1,49 +0,0 @@
import gsap from "gsap";
export const useMenuAnimation = (key: string, isOpen: Ref<boolean>) => {
const animation = useState(`animation-${key}`, () => ({
playing: false,
stage1Offset: 48,
stage2Offset: 48,
}));
watch(isOpen, (current, previous) => {
const duration = 0.1;
const timeline = gsap.timeline({
onStart: () => {
animation.value.playing = true;
},
onComplete: () => {
animation.value.playing = false;
},
});
if (current === true && previous === false) {
timeline
.fromTo(
animation.value,
{ stage1Offset: 48 },
{ stage1Offset: 0, duration },
)
.fromTo(
animation.value,
{ stage2Offset: 48 },
{ stage2Offset: 0, duration },
);
} else if (current === false && previous === true) {
timeline
.fromTo(
animation.value,
{ stage2Offset: 0 },
{ stage2Offset: 48, duration },
)
.fromTo(
animation.value,
{ stage1Offset: 0 },
{ stage1Offset: 48, duration },
);
}
});
return animation.value;
};

View File

@@ -1,18 +0,0 @@
export type RenderCallback = (ctx: CanvasRenderingContext2D) => void;
export const useRender = (callback: RenderCallback) => {
const registerRenderCallback = inject<
(callback: RenderCallback) => () => void
>("registerRenderCallback");
onMounted(() => {
if (!registerRenderCallback) {
throw new Error(
"Missing registerRenderCallback - useRender must be used within a Screen component",
);
}
const unregister = registerRenderCallback(callback);
onUnmounted(unregister);
});
};

View File

@@ -1,18 +0,0 @@
export type ScreenClickCallback = (x: number, y: number) => void;
export const useScreenClick = (callback: ScreenClickCallback) => {
const registerScreenClickCallback = inject<
(callback: ScreenClickCallback) => () => void
>("registerScreenClickCallback");
onMounted(() => {
if (!registerScreenClickCallback) {
throw new Error(
"Missing registerScreenClickCallback - useScreenClick must be used within a Screen component",
);
}
const unregister = registerScreenClickCallback(callback);
onUnmounted(unregister);
});
};

View File

@@ -1,18 +0,0 @@
export type ScreenMouseWheelCallback = (deltaY: number, deltaX: number) => void;
export const useScreenMouseWheel = (callback: ScreenMouseWheelCallback) => {
const registerScreenMouseWheelCallback = inject<
(callback: ScreenMouseWheelCallback) => () => void
>("registerScreenMouseWheelCallback");
onMounted(() => {
if (!registerScreenMouseWheelCallback) {
throw new Error(
"Missing registerScreenMouseWheelCallback - useScreenMouseWheel must be used within a Screen component",
);
}
const unregister = registerScreenMouseWheelCallback(callback);
onUnmounted(unregister);
});
};

View File

@@ -1,18 +0,0 @@
export type UpdateCallback = (deltaTime: number, realFrameTime: number) => void;
export const useUpdate = (callback: UpdateCallback) => {
const registerUpdateCallback = inject<
(callback: UpdateCallback) => () => void
>("registerUpdateCallback");
onMounted(() => {
if (!registerUpdateCallback) {
throw new Error(
"Missing registerUpdateCallback - useUpdate must be used within a Screen component",
);
}
const unregister = registerUpdateCallback(callback);
onUnmounted(unregister);
});
};

View File

@@ -1,35 +0,0 @@
<script setup lang="ts">
const showStats = useState("showStats", () => false);
</script>
<template>
<div
:style="{
display: 'flex',
flexDirection: 'column',
gap: '4px',
}"
>
<div :style="{ display: 'flex', alignItems: 'center', gap: '4px' }">
<input id="statsCheckbox" v-model="showStats" type="checkbox" />
<label for="statsCheckbox">Stats</label>
</div>
<div>
<Screen>
<slot name="top">
<slot />
</slot>
<Stats v-if="showStats" />
</Screen>
</div>
<div>
<Screen>
<slot name="bottom">
<slot />
</slot>
<Stats v-if="showStats" />
</Screen>
</div>
</div>
</template>

View File

@@ -1,17 +0,0 @@
<script setup lang="ts">
definePageMeta({
layout: false,
});
</script>
<template>
<NuxtLayout name="default">
<template #top>
<SettingsTopScreen />
</template>
<template #bottom>
<SettingsBottomScreen />
</template>
</NuxtLayout>
</template>

View File

@@ -1,17 +0,0 @@
<script setup lang="ts">
definePageMeta({
layout: false,
});
</script>
<template>
<NuxtLayout name="default">
<template #top>
<ContactTopScreen />
</template>
<template #bottom>
<ContactBottomScreen />
</template>
</NuxtLayout>
</template>

View File

@@ -1,17 +0,0 @@
<script setup lang="ts">
definePageMeta({
layout: false,
});
</script>
<template>
<NuxtLayout name="default">
<template #top>
<HomeTopScreen />
</template>
<template #bottom>
<HomeBottomScreen />
</template>
</NuxtLayout>
</template>

View File

@@ -1,17 +0,0 @@
<script setup lang="ts">
definePageMeta({
layout: false,
});
</script>
<template>
<NuxtLayout name="default">
<template #top>
<ProjectsTopScreen />
</template>
<template #bottom>
<ProjectsBottomScreen />
</template>
</NuxtLayout>
</template>

View File

@@ -1,5 +0,0 @@
export const useAppStore = defineStore("app", {
state: () => ({
booted: false,
}),
});

View File

@@ -1,137 +0,0 @@
import gsap from "gsap";
export const useContactStore = defineStore("contact", {
state: () => ({
intro: {
stage1Opacity: 0,
stage2Opacity: 0,
stage3Opacity: 0,
titleY: SCREEN_HEIGHT,
topBarY: -20,
bottomBarY: SCREEN_HEIGHT + 20,
},
outro: {
stage1Opacity: 1,
stage2Opacity: 1,
stage3Opacity: 1,
},
isIntro: true,
isOutro: true,
notifications: [] as string[],
notificationsYOffset: 0,
}),
actions: {
animateIntro() {
this.isIntro = true;
const timeline = gsap.timeline({
onComplete: () => {
this.isIntro = false;
},
});
timeline
.fromTo(
this.intro,
{
stage1Opacity: 0,
titleY: SCREEN_HEIGHT,
},
{
stage1Opacity: 1,
titleY: SCREEN_HEIGHT - 23,
duration: 0.1,
ease: "none",
},
2,
)
.fromTo(
this.intro,
{ stage2Opacity: 0 },
{
stage2Opacity: 1,
duration: 0.1,
ease: "none",
},
2.15,
)
.fromTo(
this.intro,
{
stage3Opacity: 0,
topBarY: -20,
bottomBarY: SCREEN_HEIGHT - 4,
},
{
stage3Opacity: 1,
topBarY: 0,
bottomBarY: SCREEN_HEIGHT - 24,
duration: 0.1,
ease: "none",
},
2.3,
);
},
pushNotification(content: string) {
this.notifications.push(content);
gsap.fromTo(
this,
{ notificationsYOffset: 20 },
{ notificationsYOffset: 0, duration: 0.075 },
);
},
animateOutro() {
this.isOutro = true;
const timeline = gsap.timeline({
onComplete: () => {
setTimeout(() => {
this.isOutro = false;
navigateTo("/");
}, 2000);
},
});
timeline
.fromTo(
this.outro,
{ stage1Opacity: 1 },
{
stage1Opacity: 0,
duration: 0.2,
ease: "none",
},
0,
)
.fromTo(
this.outro,
{ stage2Opacity: 1 },
{
stage2Opacity: 0,
duration: 0.25,
ease: "none",
},
0.25,
)
.fromTo(
this.outro,
{ stage3Opacity: 1 },
{
stage3Opacity: 0,
duration: 0.3,
ease: "none",
},
0.5,
);
},
},
});

View File

@@ -1,95 +0,0 @@
import gsap from "gsap";
export const useHomeStore = defineStore("home", {
state: () => ({
intro: {
statusBarY: -20,
stage1Opacity: 0,
},
outro: {
buttonOffsetY: 0,
stage1Opacity: 1,
stage2Opacity: 1,
animateTop: false,
},
isIntro: true,
isOutro: false,
}),
actions: {
animateIntro() {
const appStore = useAppStore();
this.isIntro = true;
const timeline = gsap.timeline({
onComplete: () => {
this.isIntro = false;
if (!appStore.booted) appStore.booted = true;
},
});
timeline
.fromTo(
this.intro,
{ stage1Opacity: 0 },
{
stage1Opacity: 1,
duration: 0.5,
ease: "none",
},
0.5,
)
.fromTo(
this.intro,
{ statusBarY: -20 },
{
statusBarY: 0,
duration: 0.15,
ease: "none",
},
0.85,
);
},
animateOutro(to: "contact" | "projects" | "settings") {
this.isOutro = true;
this.outro.animateTop = to !== "settings";
const timeline = gsap.timeline({
onComplete: () => {
this.isOutro = true;
navigateTo(`/${to}`);
},
});
timeline
.fromTo(
this.outro,
{ stage2Opacity: 1 },
{
stage2Opacity: 0,
duration: 0.16,
ease: "none",
},
0,
)
.fromTo(
this.outro,
{
buttonOffsetY: 0,
stage1Opacity: 1,
},
{
buttonOffsetY: -200,
stage1Opacity: 0,
duration: 0.4,
ease: "none",
},
0.08,
);
},
},
});

View File

@@ -1,99 +0,0 @@
import type {
DataCollectionItemBase,
ProjectsCollectionItem,
} from "@nuxt/content";
import gsap from "gsap";
export const useProjectsStore = defineStore("projects", {
state: () => ({
projects: [] as (Omit<
ProjectsCollectionItem,
keyof DataCollectionItemBase
> & { id: string })[],
currentProject: 0,
loading: true,
offsetX: 0,
}),
actions: {
async loadProjects() {
this.loading = true;
const { data: projects } = await useAsyncData("projects", () =>
queryCollection("projects")
.order("order", "ASC")
.select(
"id",
"order",
"scope",
"title",
"link",
"description",
"summary",
"technologies",
"tasks",
)
.all(),
);
if (!projects.value) throw "Cannot load projects";
this.projects = projects.value.map((project) => ({
...project,
id: project.id.split("/")[2]!,
}));
console.log(this.projects);
this.loading = false;
},
visitProject() {
const link = this.projects[this.currentProject]?.link;
if (link) navigateTo(link, { open: { target: "_blank" } });
},
scrollProjects(direction: "left" | "right") {
let offset = 0;
if (
direction === "right" &&
this.currentProject < this.projects.length - 1
) {
this.currentProject += 1;
offset = 69;
} else if (direction === "left" && this.currentProject > 0) {
this.currentProject -= 1;
offset = -69;
}
if (offset !== 0) {
gsap.fromTo(
this,
{ offsetX: offset },
{
offsetX: 0,
duration: 0.1,
ease: "none",
},
);
}
},
// TODO: not used anymore
scrollToProject(index: number) {
if (index === this.currentProject) return;
const offset = (index - this.currentProject) * 69;
this.currentProject = index;
gsap.fromTo(
this,
{ offsetX: offset },
{
offsetX: 0,
duration: 0.1,
ease: "none",
},
);
},
},
});

View File

@@ -1,28 +0,0 @@
export const useSettingsStore = defineStore("settings", {
state: () => ({
currentMenu: null as string | null,
currentSubMenu: null as string | null,
}),
getters: {
isMenuOpen: (state) => (menu: string) => {
if (!state.currentMenu) return false;
return new RegExp(`^${menu}[A-Z]`).test(state.currentMenu);
},
isAnyOtherMenuOpen: (state) => (excludeMenu: string) => {
if (!state.currentMenu) return false;
return ["options", "clock", "user", "touchScreen"]
.filter((m) => m !== excludeMenu)
.some((m) => new RegExp(`^${m}[A-Z]`).test(state.currentMenu!));
},
},
actions: {
setActiveMenu(menu: string | null) {
this.currentMenu = menu;
},
setCurrentSubMenu(submenu: string | null) {
this.currentSubMenu = submenu;
},
},
});

View File

@@ -1,57 +0,0 @@
export const fillTextCentered = (
ctx: CanvasRenderingContext2D,
text: string,
x: number,
y: number,
width: number,
): number => {
const measure = ctx.measureText(text);
const textX = Math.floor(x + width / 2 - measure.actualBoundingBoxRight / 2);
const textY =
measure.actualBoundingBoxAscent + measure.actualBoundingBoxDescent;
ctx.fillText(text, textX, y + textY);
return measure.actualBoundingBoxAscent + measure.actualBoundingBoxDescent + 1;
};
export const fillTextWordWrapped = (
ctx: CanvasRenderingContext2D,
text: string,
x: number,
y: number,
width: number,
lineHeight?: number,
): number => {
const words = text.split(" ");
let line = "";
let currentY = y;
let lineCount = 0;
const height =
lineHeight ||
ctx.measureText("M").actualBoundingBoxAscent +
ctx.measureText("M").actualBoundingBoxDescent;
currentY += height;
for (let i = 0; i < words.length; i++) {
const testLine = line + (line ? " " : "") + words[i];
const metrics = ctx.measureText(testLine);
const testWidth = metrics.width;
if (testWidth > width && line) {
ctx.fillText(line, x, currentY);
line = words[i]!;
currentY += height;
lineCount++;
} else {
line = testLine;
}
}
if (line) {
ctx.fillText(line, x, currentY);
lineCount++;
}
return lineCount * height;
};

View File

@@ -1,8 +0,0 @@
export type Point = [x: number, y: number];
export type Rect = [x: number, y: number, width: number, height: number];
export function rectContains(rect: Rect, point: Point): boolean {
const [x, y, width, height] = rect;
const [px, py] = point;
return px >= x && px <= x + width && py >= y && py <= y + height;
}

View File

@@ -1,2 +0,0 @@
export const SCREEN_WIDTH = 256;
export const SCREEN_HEIGHT = 192;

View File

@@ -1,21 +0,0 @@
import { defineContentConfig, defineCollection } from "@nuxt/content";
import { z } from "zod";
export default defineContentConfig({
collections: {
projects: defineCollection({
type: "data",
source: "projects/**/index.yml",
schema: z.object({
order: z.number(),
scope: z.enum(["hobby", "work"]),
title: z.string(),
link: z.url().nullable(),
description: z.string(),
summary: z.string(),
technologies: z.array(z.string()),
tasks: z.array(z.string()),
}),
}),
},
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 B

Some files were not shown because too many files have changed in this diff Show More