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 ."
|
"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
@@ -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: {}
|
||||||
|
|||||||
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 * 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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
"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
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import { resolve } from "path";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"~": resolve(__dirname, "./src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||