Compare commits
10 Commits
108ee082d8
...
54bef8629c
| Author | SHA1 | Date | |
|---|---|---|---|
| 54bef8629c | |||
| 68fd923d2e | |||
| 3b801c97ff | |||
| 3db8f850f0 | |||
| b70d0f3347 | |||
| 80fbab446b | |||
| 720bbeedd7 | |||
| f2e869e283 | |||
| 1f859c6578 | |||
| b29fbe8f7b |
@@ -10,6 +10,7 @@
|
||||
"format": "prettier --write --cache ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.0.1",
|
||||
"@types/three": "^0.182.0",
|
||||
"prettier": "^3.7.4",
|
||||
"typescript": "~5.9.3",
|
||||
|
||||
26
pnpm-lock.yaml
generated
@@ -14,6 +14,9 @@ importers:
|
||||
specifier: ^0.182.0
|
||||
version: 0.182.0
|
||||
devDependencies:
|
||||
"@types/node":
|
||||
specifier: ^25.0.1
|
||||
version: 25.0.1
|
||||
"@types/three":
|
||||
specifier: ^0.182.0
|
||||
version: 0.182.0
|
||||
@@ -25,7 +28,7 @@ importers:
|
||||
version: 5.9.3
|
||||
vite:
|
||||
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:
|
||||
"@dimforge/rapier3d-compat@0.12.0":
|
||||
@@ -214,6 +217,12 @@ packages:
|
||||
integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==,
|
||||
}
|
||||
|
||||
"@types/node@25.0.1":
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-czWPzKIAXucn9PtsttxmumiQ9N0ok9FrBwgRWrwmVLlp86BrMExzvXRLFYRJ+Ex3g6yqj+KuaxfX1JTgV2lpfg==,
|
||||
}
|
||||
|
||||
"@types/stats.js@0.17.4":
|
||||
resolution:
|
||||
{
|
||||
@@ -504,6 +513,12 @@ packages:
|
||||
engines: { node: ">=14.17" }
|
||||
hasBin: true
|
||||
|
||||
undici-types@7.16.0:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==,
|
||||
}
|
||||
|
||||
snapshots:
|
||||
"@dimforge/rapier3d-compat@0.12.0": {}
|
||||
|
||||
@@ -587,6 +602,10 @@ snapshots:
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
|
||||
"@types/node@25.0.1":
|
||||
dependencies:
|
||||
undici-types: 7.16.0
|
||||
|
||||
"@types/stats.js@0.17.4": {}
|
||||
|
||||
"@types/three@0.182.0":
|
||||
@@ -679,7 +698,7 @@ snapshots:
|
||||
|
||||
prettier@3.7.4: {}
|
||||
|
||||
rolldown-vite@7.2.5:
|
||||
rolldown-vite@7.2.5(@types/node@25.0.1):
|
||||
dependencies:
|
||||
"@oxc-project/runtime": 0.97.0
|
||||
fdir: 6.5.0(picomatch@4.0.3)
|
||||
@@ -689,6 +708,7 @@ snapshots:
|
||||
rolldown: 1.0.0-beta.50
|
||||
tinyglobby: 0.2.15
|
||||
optionalDependencies:
|
||||
"@types/node": 25.0.1
|
||||
fsevents: 2.3.3
|
||||
|
||||
rolldown@1.0.0-beta.50:
|
||||
@@ -724,3 +744,5 @@ snapshots:
|
||||
optional: true
|
||||
|
||||
typescript@5.9.3: {}
|
||||
|
||||
undici-types@7.16.0: {}
|
||||
|
||||
BIN
public/images/contact/bottom-screen/background.webp
Normal file
|
After Width: | Height: | Size: 102 B |
BIN
public/images/contact/bottom-screen/bottom-bar.webp
Normal file
|
After Width: | Height: | Size: 438 B |
BIN
public/images/contact/bottom-screen/buttons.webp
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
public/images/contact/bottom-screen/notification.webp
Normal file
|
After Width: | Height: | Size: 110 B |
BIN
public/images/contact/bottom-screen/ok-button.webp
Normal file
|
After Width: | Height: | Size: 236 B |
BIN
public/images/contact/bottom-screen/top-bar.webp
Normal file
|
After Width: | Height: | Size: 334 B |
BIN
public/images/contact/top-screen/background.webp
Normal file
|
After Width: | Height: | Size: 60 B |
BIN
public/images/contact/top-screen/left-bar-things.webp
Normal file
|
After Width: | Height: | Size: 176 B |
BIN
public/images/contact/top-screen/left-bar.webp
Normal file
|
After Width: | Height: | Size: 44 B |
BIN
public/images/contact/top-screen/title.webp
Normal file
|
After Width: | Height: | Size: 180 B |
BIN
public/images/home/bottom-screen/background.webp
Normal file
|
After Width: | Height: | Size: 132 B |
BIN
public/images/home/bottom-screen/buttons/alarm.webp
Normal file
|
After Width: | Height: | Size: 66 B |
BIN
public/images/home/bottom-screen/buttons/contact.webp
Normal file
|
After Width: | Height: | Size: 350 B |
BIN
public/images/home/bottom-screen/buttons/downloadPlay.webp
Normal file
|
After Width: | Height: | Size: 470 B |
BIN
public/images/home/bottom-screen/buttons/game.webp
Normal file
|
After Width: | Height: | Size: 296 B |
BIN
public/images/home/bottom-screen/buttons/settings.webp
Normal file
|
After Width: | Height: | Size: 108 B |
BIN
public/images/home/bottom-screen/buttons/theme.webp
Normal file
|
After Width: | Height: | Size: 68 B |
BIN
public/images/home/top-screen/background.webp
Normal file
|
After Width: | Height: | Size: 98 B |
BIN
public/images/home/top-screen/calendar/calendar.webp
Normal file
|
After Width: | Height: | Size: 302 B |
BIN
public/images/home/top-screen/calendar/day-selector.webp
Normal file
|
After Width: | Height: | Size: 90 B |
BIN
public/images/home/top-screen/calendar/last-row.webp
Normal file
|
After Width: | Height: | Size: 122 B |
BIN
public/images/home/top-screen/clock.webp
Normal file
|
After Width: | Height: | Size: 398 B |
BIN
public/images/home/top-screen/status-bar/battery.webp
Normal file
|
After Width: | Height: | Size: 104 B |
BIN
public/images/home/top-screen/status-bar/gba-display.webp
Normal file
|
After Width: | Height: | Size: 84 B |
BIN
public/images/home/top-screen/status-bar/startup-mode.webp
Normal file
|
After Width: | Height: | Size: 88 B |
BIN
public/images/home/top-screen/status-bar/status-bar.webp
Normal file
|
After Width: | Height: | Size: 160 B |
BIN
public/images/utils/selector-corner.webp
Normal file
|
After Width: | Height: | Size: 80 B |
@@ -2,7 +2,7 @@ import "./style.css";
|
||||
|
||||
import * as THREE from "three";
|
||||
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
|
||||
import { NDS } from "./nds";
|
||||
import { NDS } from "~/nds";
|
||||
|
||||
// initialize scene
|
||||
const scene = new THREE.Scene();
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import * as THREE from "three";
|
||||
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
|
||||
import { ScreenManager } from "./screen-manager";
|
||||
import { HomeScreen } from "./screens/home-screen";
|
||||
import { ScreenManager } from "~/screen-manager";
|
||||
|
||||
const SCREEN_SOURCE_TEX_SIZE = 1024;
|
||||
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) {
|
||||
super();
|
||||
|
||||
// Initialize screen manager
|
||||
this.screenManager = new ScreenManager(new HomeScreen());
|
||||
this.screenManager = new ScreenManager();
|
||||
|
||||
const loader = new GLTFLoader();
|
||||
// load model
|
||||
const loader = new GLTFLoader();
|
||||
loader.load("/nintendo-ds/scene.gltf", ({ scene: model }) => {
|
||||
model.scale.set(50, 50, 50);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
private currentScreen: Screen;
|
||||
private context: ScreenContext;
|
||||
private animator = new Animator({});
|
||||
|
||||
constructor(initialScreen: Screen) {
|
||||
this.currentScreen = initialScreen;
|
||||
constructor() {
|
||||
this.context = {
|
||||
navigate: (screen: Screen) => {
|
||||
if (this.currentScreen.destroy) {
|
||||
this.currentScreen.destroy();
|
||||
}
|
||||
this.currentScreen = screen;
|
||||
},
|
||||
animator: this.animator,
|
||||
};
|
||||
|
||||
this.currentScreen = new HomeScreen(this.context);
|
||||
}
|
||||
|
||||
renderTop(ctx: CanvasRenderingContext2D) {
|
||||
this.animator.update();
|
||||
ctx.clearRect(0, 0, 256, 192);
|
||||
ctx.save();
|
||||
this.currentScreen.renderTop(ctx);
|
||||
@@ -29,7 +38,7 @@ export class ScreenManager {
|
||||
|
||||
handleTouch(x: number, y: number) {
|
||||
if (this.currentScreen.handleTouch) {
|
||||
this.currentScreen.handleTouch(x, y, this.context);
|
||||
this.currentScreen.handleTouch(x, y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import type { Animator } from "~/utils/animator";
|
||||
|
||||
export interface ScreenContext {
|
||||
navigate: (screen: Screen) => void;
|
||||
animator: Animator;
|
||||
}
|
||||
|
||||
export interface Screen {
|
||||
renderTop(ctx: CanvasRenderingContext2D): void;
|
||||
renderBottom(ctx: CanvasRenderingContext2D): void;
|
||||
handleTouch?(x: number, y: number, context: ScreenContext): void;
|
||||
handleTouch?(x: number, y: number): void;
|
||||
destroy?(): void;
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
131
src/screens/contact/bottom/index.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
39
src/screens/contact/index.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
88
src/screens/contact/top/index.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
99
src/screens/home/bottom/index.ts
Normal 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
@@ -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();
|
||||
}
|
||||
}
|
||||
99
src/screens/home/top/calendar.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
103
src/screens/home/top/clock.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
30
src/screens/home/top/index.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
66
src/screens/home/top/statusBar.ts
Normal 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
@@ -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;
|
||||
}
|
||||
}
|
||||
145
src/utils/buttonNavigation.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
79
src/utils/buttonSelector.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,13 @@
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
"noUncheckedSideEffectImports": true,
|
||||
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"~/*": ["./src/*"],
|
||||
"~/": ["./src/index.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
10
vite.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from "vite";
|
||||
import { resolve } from "path";
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
"~": resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
});
|
||||