Compare commits

..

10 Commits

49 changed files with 1132 additions and 64 deletions

View File

@@ -10,6 +10,7 @@
"format": "prettier --write --cache ." "format": "prettier --write --cache ."
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^25.0.1",
"@types/three": "^0.182.0", "@types/three": "^0.182.0",
"prettier": "^3.7.4", "prettier": "^3.7.4",
"typescript": "~5.9.3", "typescript": "~5.9.3",

26
pnpm-lock.yaml generated
View File

@@ -14,6 +14,9 @@ importers:
specifier: ^0.182.0 specifier: ^0.182.0
version: 0.182.0 version: 0.182.0
devDependencies: devDependencies:
"@types/node":
specifier: ^25.0.1
version: 25.0.1
"@types/three": "@types/three":
specifier: ^0.182.0 specifier: ^0.182.0
version: 0.182.0 version: 0.182.0
@@ -25,7 +28,7 @@ importers:
version: 5.9.3 version: 5.9.3
vite: vite:
specifier: npm:rolldown-vite@7.2.5 specifier: npm:rolldown-vite@7.2.5
version: rolldown-vite@7.2.5 version: rolldown-vite@7.2.5(@types/node@25.0.1)
packages: packages:
"@dimforge/rapier3d-compat@0.12.0": "@dimforge/rapier3d-compat@0.12.0":
@@ -214,6 +217,12 @@ packages:
integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==, integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==,
} }
"@types/node@25.0.1":
resolution:
{
integrity: sha512-czWPzKIAXucn9PtsttxmumiQ9N0ok9FrBwgRWrwmVLlp86BrMExzvXRLFYRJ+Ex3g6yqj+KuaxfX1JTgV2lpfg==,
}
"@types/stats.js@0.17.4": "@types/stats.js@0.17.4":
resolution: resolution:
{ {
@@ -504,6 +513,12 @@ packages:
engines: { node: ">=14.17" } engines: { node: ">=14.17" }
hasBin: true hasBin: true
undici-types@7.16.0:
resolution:
{
integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==,
}
snapshots: snapshots:
"@dimforge/rapier3d-compat@0.12.0": {} "@dimforge/rapier3d-compat@0.12.0": {}
@@ -587,6 +602,10 @@ snapshots:
tslib: 2.8.1 tslib: 2.8.1
optional: true optional: true
"@types/node@25.0.1":
dependencies:
undici-types: 7.16.0
"@types/stats.js@0.17.4": {} "@types/stats.js@0.17.4": {}
"@types/three@0.182.0": "@types/three@0.182.0":
@@ -679,7 +698,7 @@ snapshots:
prettier@3.7.4: {} prettier@3.7.4: {}
rolldown-vite@7.2.5: rolldown-vite@7.2.5(@types/node@25.0.1):
dependencies: dependencies:
"@oxc-project/runtime": 0.97.0 "@oxc-project/runtime": 0.97.0
fdir: 6.5.0(picomatch@4.0.3) fdir: 6.5.0(picomatch@4.0.3)
@@ -689,6 +708,7 @@ snapshots:
rolldown: 1.0.0-beta.50 rolldown: 1.0.0-beta.50
tinyglobby: 0.2.15 tinyglobby: 0.2.15
optionalDependencies: optionalDependencies:
"@types/node": 25.0.1
fsevents: 2.3.3 fsevents: 2.3.3
rolldown@1.0.0-beta.50: rolldown@1.0.0-beta.50:
@@ -724,3 +744,5 @@ snapshots:
optional: true optional: true
typescript@5.9.3: {} typescript@5.9.3: {}
undici-types@7.16.0: {}

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 438 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 334 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 350 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 470 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 B

View File

@@ -2,7 +2,7 @@ import "./style.css";
import * as THREE from "three"; import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js"; import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import { NDS } from "./nds"; import { NDS } from "~/nds";
// initialize scene // initialize scene
const scene = new THREE.Scene(); const scene = new THREE.Scene();

View File

@@ -1,7 +1,6 @@
import * as THREE from "three"; import * as THREE from "three";
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js"; import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
import { ScreenManager } from "./screen-manager"; import { ScreenManager } from "~/screen-manager";
import { HomeScreen } from "./screens/home-screen";
const SCREEN_SOURCE_TEX_SIZE = 1024; const SCREEN_SOURCE_TEX_SIZE = 1024;
const TOP_SCREEN_SOURCE_TEX_HEIGHT = SCREEN_SOURCE_TEX_SIZE / 404; const TOP_SCREEN_SOURCE_TEX_HEIGHT = SCREEN_SOURCE_TEX_SIZE / 404;
@@ -37,11 +36,10 @@ export class NDS extends THREE.Object3D {
public constructor(camera: THREE.Camera, domElement: HTMLCanvasElement) { public constructor(camera: THREE.Camera, domElement: HTMLCanvasElement) {
super(); super();
// Initialize screen manager this.screenManager = new ScreenManager();
this.screenManager = new ScreenManager(new HomeScreen());
const loader = new GLTFLoader();
// load model // load model
const loader = new GLTFLoader();
loader.load("/nintendo-ds/scene.gltf", ({ scene: model }) => { loader.load("/nintendo-ds/scene.gltf", ({ scene: model }) => {
model.scale.set(50, 50, 50); model.scale.set(50, 50, 50);

View File

@@ -1,19 +1,28 @@
import type { Screen, ScreenContext } from "./screen"; import type { Screen, ScreenContext } from "~/screen";
import { HomeScreen } from "~/screens/home";
import { Animator } from "~/utils/animator";
export class ScreenManager { export class ScreenManager {
private currentScreen: Screen; private currentScreen: Screen;
private context: ScreenContext; private context: ScreenContext;
private animator = new Animator({});
constructor(initialScreen: Screen) { constructor() {
this.currentScreen = initialScreen;
this.context = { this.context = {
navigate: (screen: Screen) => { navigate: (screen: Screen) => {
if (this.currentScreen.destroy) {
this.currentScreen.destroy();
}
this.currentScreen = screen; this.currentScreen = screen;
}, },
animator: this.animator,
}; };
this.currentScreen = new HomeScreen(this.context);
} }
renderTop(ctx: CanvasRenderingContext2D) { renderTop(ctx: CanvasRenderingContext2D) {
this.animator.update();
ctx.clearRect(0, 0, 256, 192); ctx.clearRect(0, 0, 256, 192);
ctx.save(); ctx.save();
this.currentScreen.renderTop(ctx); this.currentScreen.renderTop(ctx);
@@ -29,7 +38,7 @@ export class ScreenManager {
handleTouch(x: number, y: number) { handleTouch(x: number, y: number) {
if (this.currentScreen.handleTouch) { if (this.currentScreen.handleTouch) {
this.currentScreen.handleTouch(x, y, this.context); this.currentScreen.handleTouch(x, y);
} }
} }
} }

View File

@@ -1,9 +1,13 @@
import type { Animator } from "~/utils/animator";
export interface ScreenContext { export interface ScreenContext {
navigate: (screen: Screen) => void; navigate: (screen: Screen) => void;
animator: Animator;
} }
export interface Screen { export interface Screen {
renderTop(ctx: CanvasRenderingContext2D): void; renderTop(ctx: CanvasRenderingContext2D): void;
renderBottom(ctx: CanvasRenderingContext2D): void; renderBottom(ctx: CanvasRenderingContext2D): void;
handleTouch?(x: number, y: number, context: ScreenContext): void; handleTouch?(x: number, y: number): void;
destroy?(): void;
} }

View File

@@ -1,25 +0,0 @@
import type { Screen, ScreenContext } from "../screen";
import { HomeScreen } from "./home-screen";
export class ContactScreen implements Screen {
renderTop(ctx: CanvasRenderingContext2D) {
ctx.fillStyle = "#ffffff";
ctx.font = "10px NDS10";
ctx.textBaseline = "top";
ctx.fillText("CONTACT", 1, 1);
}
renderBottom(ctx: CanvasRenderingContext2D) {
ctx.fillStyle = "#ffffff";
ctx.font = "10px NDS10";
ctx.textAlign = "left";
ctx.textBaseline = "bottom";
ctx.fillText("< Back", 1, 191);
}
handleTouch(x: number, y: number, context: ScreenContext): void {
if (x >= 0 && x <= 51 && y >= 178 && y <= 192) {
context.navigate(new HomeScreen());
}
}
}

View File

@@ -0,0 +1,131 @@
import { ImageLoader } from "~/utils/loadImages";
import { ButtonNavigation } from "~/utils/buttonNavigation";
import { ButtonSelector } from "~/utils/buttonSelector";
import { HomeScreen } from "~/screens/home";
import type { ContactScreenContext } from "~/screens/contact";
type ContactButton = "github" | "email" | "website" | "cv";
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<
ContactButton,
[action: "Copy" | "Open", verb: string, content: string]
>;
export class ContactBottomScreen {
private images = new ImageLoader({
homeBackground: "/images/home/bottom-screen/background.webp",
contactBackground: "/images/contact/bottom-screen/background.webp",
buttons: "/images/contact/bottom-screen/buttons.webp",
topBar: "/images/contact/bottom-screen/top-bar.webp",
bottomBar: "/images/contact/bottom-screen/bottom-bar.webp",
okButton: "/images/contact/bottom-screen/ok-button.webp",
});
private navigation: ButtonNavigation<ContactButton>;
private selector = new ButtonSelector([26, 27, 202, 42]);
private context: ContactScreenContext;
constructor(context: ContactScreenContext) {
this.context = context;
this.navigation = new ButtonNavigation<ContactButton>({
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: (button) => {
this.actionateButton(button);
},
});
}
private async actionateButton(button: ContactButton): Promise<void> {
const [action, verb, content] = ACTIONS[button];
if (action === "Copy") {
try {
await navigator.clipboard.writeText(content);
this.context.pushNotification(`${verb} copied to clipboard`);
} catch (error) {
console.error("Failed to copy to clipboard:", error);
}
} else {
window.open(content, "_blank");
this.context.pushNotification(`${verb} opened`);
}
}
render(ctx: CanvasRenderingContext2D): void {
if (!this.images.isReady) return;
// backgrounds
ctx.drawImage(this.images.require("homeBackground"), 0, 0);
ctx.drawImage(this.images.require("contactBackground"), 0, 0);
// top bar
ctx.drawImage(this.images.require("topBar"), 0, 0);
// buttons
ctx.drawImage(this.images.require("buttons"), 31, 32);
// selector
this.selector.render(ctx, this.navigation.getSelectorPosition());
// bottom bar
ctx.save();
ctx.translate(0, 192 - 24);
ctx.drawImage(this.images.require("bottomBar"), 0, 0);
ctx.drawImage(this.images.require("okButton"), 144, 4);
ctx.font = "10px NDS10";
ctx.fillStyle = "#000000";
const okLabel = ACTIONS[this.navigation.getSelectedButton()][0];
ctx.fillText(okLabel, 144 + 35, 4 + 13);
ctx.restore();
}
handleTouch(x: number, y: number): void {
// Handle Quit button
if (x >= 31 && x <= 111 && y >= 172 && y <= 190) {
this.context.navigate(new HomeScreen(this.context));
return;
}
// Handle OK button
if (x >= 144 && x <= 224 && y >= 172 && y <= 190) {
this.actionateButton(this.navigation.getSelectedButton());
return;
}
// Handle button navigation
this.navigation.handleTouch(x, y);
}
destroy(): void {
this.navigation.destroy();
}
}

View File

@@ -0,0 +1,39 @@
import type { Screen, ScreenContext } from "~/screen";
import { ContactTopScreen } from "./top";
import { ContactBottomScreen } from "./bottom";
export interface ContactScreenContext extends ScreenContext {
pushNotification: (message: string) => void;
}
export class ContactScreen implements Screen {
private topScreen = new ContactTopScreen();
private bottomScreen: ContactBottomScreen;
constructor(context: ScreenContext) {
const contactContext: ContactScreenContext = {
...context,
pushNotification: (message: string) => {
this.topScreen.pushNotification(message);
},
};
this.bottomScreen = new ContactBottomScreen(contactContext);
}
renderTop(ctx: CanvasRenderingContext2D) {
this.topScreen.render(ctx);
}
renderBottom(ctx: CanvasRenderingContext2D) {
this.bottomScreen.render(ctx);
}
handleTouch(x: number, y: number): void {
this.bottomScreen.handleTouch(x, y);
}
destroy(): void {
this.bottomScreen.destroy();
}
}

View File

@@ -0,0 +1,88 @@
import { ImageLoader } from "~/utils/loadImages";
export class ContactTopScreen {
private images = new ImageLoader({
homeBackground: "/images/home/top-screen/background.webp",
contactBackground: "/images/contact/top-screen/background.webp",
leftBar: "/images/contact/top-screen/left-bar.webp",
leftBarThings: "/images/contact/top-screen/left-bar-things.webp",
notification: "/images/contact/bottom-screen/notification.webp",
title: "/images/contact/top-screen/title.webp",
});
private notifications: string[] = [];
private notificationsYOffset = 0;
public pushNotification(message: string): void {
this.notifications.push(message);
}
render(ctx: CanvasRenderingContext2D): void {
if (!this.images.isReady) return;
// backgrounds
// NOTE: animate home background
ctx.drawImage(this.images.require("homeBackground"), 0, 0);
ctx.drawImage(this.images.require("contactBackground"), 0, 0);
// left bar
ctx.drawImage(this.images.require("leftBar"), 0, 0);
ctx.drawImage(this.images.require("leftBarThings"), 0, 0);
ctx.font = "10px NDS10";
// notifications
for (let i = this.notifications.length - 1; i >= 0; i--) {
const index = this.notifications.length - 1 - i;
const y = 169 - 24 * index + this.notificationsYOffset;
if (y < -24) break;
ctx.drawImage(this.images.require("notification"), 21, y);
const content = this.notifications[i]!;
ctx.fillStyle = content.includes("opened") ? "#00fbba" : "#e3f300";
ctx.fillText(this.notifications[i]!, 27, y + 15);
}
// title
ctx.drawImage(
this.images.require("title"),
21,
169 - 24 * this.notifications.length + this.notificationsYOffset,
);
// notifications count (left bar)
const MAX = 36;
const MAX_VISIBLE = 8;
let visibleNotifications = Math.min(this.notifications.length, MAX_VISIBLE);
const extraActive =
this.notificationsYOffset > 0 && this.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 < this.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);
}
}
}
}

View File

@@ -1,25 +0,0 @@
import type { Screen, ScreenContext } from "../screen";
import { ContactScreen } from "./contact-screen";
export class HomeScreen implements Screen {
renderTop(ctx: CanvasRenderingContext2D) {
ctx.fillStyle = "#ffffff";
ctx.font = "10px NDS10";
ctx.textBaseline = "top";
ctx.fillText("HOME", 1, 1);
}
renderBottom(ctx: CanvasRenderingContext2D) {
ctx.fillStyle = "#ffffff";
ctx.font = "10px NDS10";
ctx.textAlign = "right";
ctx.textBaseline = "bottom";
ctx.fillText("Contact >", 255, 191);
}
handleTouch(x: number, y: number, context: ScreenContext): void {
if (x >= 205 && x <= 256 && y >= 178 && y <= 192) {
context.navigate(new ContactScreen());
}
}
}

View File

@@ -0,0 +1,99 @@
import { ImageLoader } from "~/utils/loadImages";
import { ButtonNavigation } from "~/utils/buttonNavigation";
import { ButtonSelector } from "~/utils/buttonSelector";
import { ContactScreen } from "~/screens/contact";
import type { Screen, ScreenContext } from "~/screen";
type HomeButton = "projects" | "contact" | "downloadPlay" | "settings";
export class HomeBottomScreen {
private images = new ImageLoader({
background: "/images/home/bottom-screen/background.webp",
game: "/images/home/bottom-screen/buttons/game.webp",
contact: "/images/home/bottom-screen/buttons/contact.webp",
downloadPlay: "/images/home/bottom-screen/buttons/downloadPlay.webp",
settings: "/images/home/bottom-screen/buttons/settings.webp",
});
private navigation: ButtonNavigation<HomeButton>;
private selector = new ButtonSelector([31, 23, 193, 49]);
private onNavigate: (screen: Screen, animateTop: boolean) => void;
private context: ScreenContext;
constructor(
onNavigate: (screen: Screen, animateTop: boolean) => void,
context?: ScreenContext,
) {
this.onNavigate = onNavigate;
this.context = context!;
this.navigation = new ButtonNavigation<HomeButton>({
buttons: {
projects: [31, 23, 193, 49],
contact: [31, 71, 97, 49],
downloadPlay: [127, 71, 97, 49],
settings: [112, 167, 31, 26],
},
initialButton: "projects",
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",
},
},
onButtonClick: (button) => {
if (button === "contact") {
this.onNavigate(new ContactScreen(this.context), true);
}
},
});
}
render(ctx: CanvasRenderingContext2D): void {
if (!this.images.isReady) return;
const buttonOffsetY = this.context.animator.get("buttonOffsetY");
const stage1Opacity = this.context.animator.get("stage1Opacity");
ctx.drawImage(this.images.require("background"), 0, 0);
ctx.save();
ctx.translate(0, buttonOffsetY);
ctx.globalAlpha = stage1Opacity;
ctx.drawImage(this.images.require("game"), 33, 25);
ctx.drawImage(this.images.require("contact"), 32, 72);
ctx.drawImage(this.images.require("downloadPlay"), 128, 72);
ctx.drawImage(this.images.require("settings"), 117, 170);
this.selector.render(
ctx,
this.navigation.getSelectorPosition(),
stage1Opacity,
);
ctx.restore();
}
handleTouch(x: number, y: number): void {
this.navigation.handleTouch(x, y);
}
destroy(): void {
this.navigation.destroy();
}
}

83
src/screens/home/index.ts Normal file
View File

@@ -0,0 +1,83 @@
import type { Screen, ScreenContext } from "~/screen";
import { HomeTopScreen } from "./top";
import { HomeBottomScreen } from "./bottom";
export class HomeScreen implements Screen {
private topScreen: HomeTopScreen;
private bottomScreen: HomeBottomScreen;
private context: ScreenContext;
constructor(context: ScreenContext) {
this.context = context;
context.animator.init({
statusBarY: -20,
stage1Opacity: 0,
buttonOffsetY: 0,
stage2Opacity: 1,
});
this.topScreen = new HomeTopScreen(context.animator);
this.bottomScreen = new HomeBottomScreen((screen, animateTop) => {
this.navigateWithAnimation(screen, animateTop);
}, context);
this.animateIntro();
}
private navigateWithAnimation(screen: Screen, animateTop: boolean): void {
this.animateOutro(animateTop, () => {
this.context.navigate(screen);
});
}
private animateIntro(): void {
const start = 2;
this.context.animator.animate([
{
target: { stage1Opacity: 1 },
duration: 0.5,
delay: start + 0.5,
},
{
target: { statusBarY: 0 },
duration: 0.15,
delay: start + 0.85,
},
]);
}
public animateOutro(animateTop: boolean, onComplete: () => void): void {
this.context.animator.animate(
[
{
target: { stage2Opacity: 0 },
duration: 0.16,
delay: 0,
},
{
target: { buttonOffsetY: -200, stage1Opacity: animateTop ? 0 : 1 },
duration: 0.4,
delay: 0.08,
},
],
onComplete,
);
}
renderTop(ctx: CanvasRenderingContext2D) {
this.topScreen.render(ctx);
}
renderBottom(ctx: CanvasRenderingContext2D) {
this.bottomScreen.render(ctx);
}
handleTouch(x: number, y: number): void {
this.bottomScreen.handleTouch(x, y);
}
destroy(): void {
this.bottomScreen.destroy();
this.context.animator.clear();
}
}

View File

@@ -0,0 +1,99 @@
import { ImageLoader } from "~/utils/loadImages";
import type { Animator } from "~/utils/animator";
export class Calendar {
private images = new ImageLoader({
calendar: "/images/home/top-screen/calendar/calendar.webp",
lastRow: "/images/home/top-screen/calendar/last-row.webp",
daySelector: "/images/home/top-screen/calendar/day-selector.webp",
});
private animator: Animator;
constructor(animator: Animator) {
this.animator = animator;
}
public render(ctx: CanvasRenderingContext2D): void {
if (!this.images.isReady) return;
const stage1Opacity = this.animator.get("stage1Opacity");
const stage2Opacity = this.animator.get("stage2Opacity");
const opacity = stage1Opacity * stage2Opacity;
ctx.save();
ctx.globalAlpha = opacity;
const CALENDAR_COLS = 7;
const CALENDAR_ROWS = 5;
const CALENDAR_LEFT = 128;
const CALENDAR_TOP = 64;
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(
this.images.require("calendar"),
CALENDAR_LEFT - 3,
CALENDAR_TOP - 33,
);
const extraRow = CALENDAR_COLS * CALENDAR_ROWS - daysInMonth - firstDay < 0;
if (extraRow) {
ctx.drawImage(
this.images.require("lastRow"),
CALENDAR_LEFT - 3,
CALENDAR_TOP + 79,
);
}
ctx.fillStyle = "#343434";
ctx.font = "7px NDS7";
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(
this.images.require("daySelector"),
cellLeft,
cellTop,
);
}
ctx.fillText(
dayText,
cellLeft + Math.floor((15 - width) / 2),
cellTop + 11,
);
}
}
}
ctx.fillStyle = "black";
ctx.font = "10px NDS10";
const timeText = `${month}/${year}`;
const { actualBoundingBoxRight: width } = ctx.measureText(timeText);
ctx.fillText(
timeText,
CALENDAR_LEFT + Math.floor((111 - width) / 2),
CALENDAR_TOP - 20,
);
ctx.restore();
}
}

View File

@@ -0,0 +1,103 @@
import { ImageLoader } from "~/utils/loadImages";
import type { Animator } from "~/utils/animator";
const CENTER_X = 63;
const CENTER_Y = 95;
export class Clock {
private images = new ImageLoader({
clock: "/images/home/top-screen/clock.webp",
});
private animator: Animator;
constructor(animator: Animator) {
this.animator = animator;
}
private 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;
}
}
}
public render(ctx: CanvasRenderingContext2D): void {
if (!this.images.isReady) return;
const stage1Opacity = this.animator.get("stage1Opacity");
const stage2Opacity = this.animator.get("stage2Opacity");
const opacity = stage1Opacity * stage2Opacity;
ctx.save();
ctx.globalAlpha = opacity;
ctx.drawImage(this.images.require("clock"), 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;
this.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);
ctx.restore();
}
}

View File

@@ -0,0 +1,30 @@
import { ImageLoader } from "~/utils/loadImages";
import { StatusBar } from "./statusBar";
import { Clock } from "./clock";
import { Calendar } from "./calendar";
import type { Animator } from "~/utils/animator";
export class HomeTopScreen {
private backgroundImage = new ImageLoader({
background: "/images/home/top-screen/background.webp",
});
private statusBar: StatusBar;
private clock: Clock;
private calendar: Calendar;
constructor(animator: Animator) {
this.statusBar = new StatusBar(animator);
this.clock = new Clock(animator);
this.calendar = new Calendar(animator);
}
render(ctx: CanvasRenderingContext2D): void {
if (!this.backgroundImage.isReady) return;
ctx.drawImage(this.backgroundImage.require("background"), 0, 0);
this.clock.render(ctx);
this.calendar.render(ctx);
this.statusBar.render(ctx);
}
}

View File

@@ -0,0 +1,66 @@
import { ImageLoader } from "~/utils/loadImages";
import type { Animator } from "~/utils/animator";
export class StatusBar {
private images = new ImageLoader({
statusBar: "/images/home/top-screen/status-bar/status-bar.webp",
gbaDisplay: "/images/home/top-screen/status-bar/gba-display.webp",
startupMode: "/images/home/top-screen/status-bar/startup-mode.webp",
battery: "/images/home/top-screen/status-bar/battery.webp",
});
private animator: Animator;
constructor(animator: Animator) {
this.animator = animator;
}
public render(ctx: CanvasRenderingContext2D): void {
if (!this.images.isReady) return;
const TEXT_Y = 11;
const statusBarY = this.animator.get("statusBarY");
ctx.save();
ctx.translate(0, statusBarY);
// Draw status bar background
ctx.drawImage(this.images.require("statusBar"), 0, 0);
// Set text style
ctx.fillStyle = "#ffffff";
ctx.font = "7px NDS7";
// Draw username
ctx.fillText("pihkaal", 3, TEXT_Y);
// Helper function to draw centered numbers in cells
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();
// Draw time (hours:minutes)
fillNumberCell(now.getHours(), 9, 1);
if (Math.floor(now.getMilliseconds() / 500) % 2 === 0) {
ctx.fillText(":", 159, TEXT_Y);
}
fillNumberCell(now.getMinutes(), 10, -1);
// Draw date (day/month)
fillNumberCell(now.getDate(), 11, 1);
ctx.fillText("/", 190, TEXT_Y);
fillNumberCell(now.getMonth() + 1, 12, -1);
// Draw icons
ctx.drawImage(this.images.require("gbaDisplay"), 210, 2);
ctx.drawImage(this.images.require("startupMode"), 226, 2);
ctx.drawImage(this.images.require("battery"), 242, 4);
ctx.restore();
}
}

106
src/utils/animator.ts Normal file
View File

@@ -0,0 +1,106 @@
type AnimationStep = {
target: Record<string, number>;
duration: number;
delay: number;
};
export class Animator {
private values: Record<string, number> = {};
private targets: Record<string, number> = {};
private durations: Record<string, number> = {};
private startValues: Record<string, number> = {};
private startTimes: Record<string, number> = {};
private isAnimating = false;
private animationStart = 0;
private onCompleteCallback?: () => void;
constructor(initialValues: Record<string, number> = {}) {
this.values = { ...initialValues };
}
public init(values: Record<string, number>): void {
this.values = { ...values };
this.targets = {};
this.durations = {};
this.startValues = {};
this.startTimes = {};
this.isAnimating = false;
this.onCompleteCallback = undefined;
}
public clear(): void {
this.values = {};
this.targets = {};
this.durations = {};
this.startValues = {};
this.startTimes = {};
this.isAnimating = false;
this.onCompleteCallback = undefined;
}
public animate(steps: AnimationStep[], onComplete?: () => void): void {
this.isAnimating = true;
this.animationStart = performance.now();
this.onCompleteCallback = onComplete;
// Process all steps and set up animations
steps.forEach((step) => {
Object.entries(step.target).forEach(([key, targetValue]) => {
this.targets[key] = targetValue;
this.durations[key] = step.duration;
this.startValues[key] = this.values[key] ?? 0;
this.startTimes[key] = step.delay;
});
});
}
public update(): void {
if (!this.isAnimating) return;
const elapsed = (performance.now() - this.animationStart) / 1000;
let allComplete = true;
Object.keys(this.targets).forEach((key) => {
const delay = this.startTimes[key] ?? 0;
const duration = this.durations[key] ?? 0;
const start = this.startValues[key] ?? 0;
const target = this.targets[key] ?? 0;
if (elapsed < delay) {
allComplete = false;
return;
}
const localElapsed = elapsed - delay;
if (localElapsed >= duration) {
this.values[key] = target;
} else {
const progress = localElapsed / duration;
this.values[key] = start + (target - start) * progress;
allComplete = false;
}
});
if (allComplete) {
this.isAnimating = false;
this.targets = {};
this.durations = {};
this.startValues = {};
this.startTimes = {};
if (this.onCompleteCallback) {
this.onCompleteCallback();
this.onCompleteCallback = undefined;
}
}
}
public get(key: string): number {
return this.values[key] ?? 0;
}
public set(key: string, value: number): void {
this.values[key] = value;
}
}

View File

@@ -0,0 +1,145 @@
export type ButtonRect = [x: number, y: number, w: number, h: number];
export interface ButtonNavigationConfig<T extends string> {
up?: T | "last";
down?: T | "last";
left?: T;
right?: T;
horizontalMode?: "navigate" | "preview";
}
export class ButtonNavigation<T extends string> {
private selectedButton: T;
private nextButton?: T;
private buttons: Record<T, ButtonRect>;
private navigation: Record<T, ButtonNavigationConfig<T>>;
private onButtonClick?: (buttonName: T) => void;
private keydownHandler: (event: KeyboardEvent) => void;
constructor(config: {
buttons: Record<T, ButtonRect>;
initialButton: T;
navigation: Record<T, ButtonNavigationConfig<T>>;
onButtonClick?: (buttonName: T) => void;
}) {
this.buttons = config.buttons;
this.selectedButton = config.initialButton;
this.navigation = config.navigation;
this.onButtonClick = config.onButtonClick;
this.keydownHandler = this.handleKeyPress.bind(this);
window.addEventListener("keydown", this.keydownHandler);
}
public getSelectedButton(): T {
return this.selectedButton;
}
public getSelectorPosition(): ButtonRect {
return this.buttons[this.selectedButton];
}
public handleTouch(x: number, y: number): void {
for (const [buttonName, config] of Object.entries(this.buttons) as [
T,
ButtonRect,
][]) {
const [sx, sy, sw, sh] = config;
if (x >= sx && x <= sx + sw && y >= sy && y <= sy + sh) {
if (this.selectedButton === buttonName) {
this.onButtonClick?.(buttonName);
} else {
if (
(this.navigation[buttonName].down === "last" &&
this.navigation[this.selectedButton]!.up === buttonName) ||
(this.navigation[buttonName].up === "last" &&
this.navigation[this.selectedButton]!.down === buttonName)
) {
this.nextButton = this.selectedButton;
}
this.selectedButton = buttonName;
}
break;
}
}
}
private handleKeyPress(event: KeyboardEvent): void {
const currentButton = this.selectedButton;
const currentNav = this.navigation[currentButton];
if (!currentNav) return;
switch (event.key) {
case "ArrowUp":
if (!currentNav.up) return;
if (currentNav.up === "last") {
if (this.nextButton) {
this.selectedButton = this.nextButton;
} else {
this.selectedButton = (currentNav.left ?? currentNav.right) as T;
}
} else {
if (this.navigation[currentNav.up].down === "last") {
this.nextButton = this.selectedButton;
}
this.selectedButton = currentNav.up;
}
break;
case "ArrowDown":
if (!currentNav.down) return;
if (currentNav.down === "last") {
if (this.nextButton) {
this.selectedButton = this.nextButton;
} else {
this.selectedButton = (currentNav.left ?? currentNav.right) as T;
}
} else {
if (this.navigation[currentNav.down].up === "last") {
this.nextButton = this.selectedButton;
}
this.selectedButton = currentNav.down;
}
break;
case "ArrowLeft":
if (!currentNav.left) return;
if (currentNav.horizontalMode === "preview") {
this.nextButton = currentNav.left;
} else {
this.selectedButton = currentNav.left;
}
break;
case "ArrowRight":
if (!currentNav.right) return;
if (currentNav.horizontalMode === "preview") {
this.nextButton = currentNav.right;
} else {
this.selectedButton = currentNav.right;
}
break;
case "Enter":
case " ":
this.onButtonClick?.(this.selectedButton);
break;
default:
return;
}
event.preventDefault();
}
public destroy(): void {
window.removeEventListener("keydown", this.keydownHandler);
}
}

View File

@@ -0,0 +1,79 @@
import { ImageLoader } from "~/utils/loadImages";
import type { ButtonRect } from "~/utils/buttonNavigation";
const ANIMATION_SPEED = 0.25;
export class ButtonSelector {
private images = new ImageLoader({
corner: "/images/utils/selector-corner.webp",
});
private currentX = 0;
private currentY = 0;
private currentWidth = 0;
private currentHeight = 0;
constructor(initialRect: ButtonRect) {
[this.currentX, this.currentY, this.currentWidth, this.currentHeight] =
initialRect;
}
public render(
ctx: CanvasRenderingContext2D,
targetRect: ButtonRect,
opacity: number = 1,
): void {
if (!this.images.isReady) return;
const [targetX, targetY, targetWidth, targetHeight] = targetRect;
const dx = targetX - this.currentX;
const dy = targetY - this.currentY;
const dw = targetWidth - this.currentWidth;
const dh = targetHeight - this.currentHeight;
if (
Math.abs(dx) < 0.5 &&
Math.abs(dy) < 0.5 &&
Math.abs(dw) < 0.5 &&
Math.abs(dh) < 0.5
) {
[this.currentX, this.currentY, this.currentWidth, this.currentHeight] =
targetRect;
} else {
this.currentX += dx * ANIMATION_SPEED;
this.currentY += dy * ANIMATION_SPEED;
this.currentWidth += dw * ANIMATION_SPEED;
this.currentHeight += dh * ANIMATION_SPEED;
}
const x = Math.floor(this.currentX);
const y = Math.floor(this.currentY);
const w = Math.floor(this.currentWidth);
const h = Math.floor(this.currentHeight);
const corner = this.images.require("corner");
ctx.globalAlpha = opacity;
// top-left
ctx.drawImage(corner, x, y);
// top-right
ctx.save();
ctx.scale(-1, 1);
ctx.drawImage(corner, -(x + w), y);
ctx.restore();
// bottom-left
ctx.save();
ctx.scale(1, -1);
ctx.drawImage(corner, x, -(y + h));
ctx.restore();
// bottom-right
ctx.save();
ctx.scale(-1, -1);
ctx.drawImage(corner, -(x + w), -(y + h));
ctx.restore();
}
}

View File

@@ -20,7 +20,13 @@
"noUnusedParameters": true, "noUnusedParameters": true,
"erasableSyntaxOnly": true, "erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true "noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": {
"~/*": ["./src/*"],
"~/": ["./src/index.ts"]
}
}, },
"include": ["src"] "include": ["src"]
} }

10
vite.config.ts Normal file
View File

@@ -0,0 +1,10 @@
import { defineConfig } from "vite";
import { resolve } from "path";
export default defineConfig({
resolve: {
alias: {
"~": resolve(__dirname, "./src"),
},
},
});