feat: animation system + test for home screen
This commit is contained in:
@@ -1,23 +1,28 @@
|
|||||||
import type { Screen } from "~/screen";
|
import type { Screen, ScreenContext } from "~/screen";
|
||||||
import { HomeScreen } from "~/screens/home";
|
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 animator = new Animator({});
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
const context = {
|
this.context = {
|
||||||
navigate: (screen: Screen) => {
|
navigate: (screen: Screen) => {
|
||||||
if (this.currentScreen.destroy) {
|
if (this.currentScreen.destroy) {
|
||||||
this.currentScreen.destroy();
|
this.currentScreen.destroy();
|
||||||
}
|
}
|
||||||
this.currentScreen = screen;
|
this.currentScreen = screen;
|
||||||
},
|
},
|
||||||
|
animator: this.animator,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.currentScreen = new HomeScreen(context);
|
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);
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
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 {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { ImageLoader } from "~/utils/loadImages";
|
import { ImageLoader } from "~/utils/loadImages";
|
||||||
import { ButtonNavigation } from "~/utils/buttonNavigation";
|
import { ButtonNavigation } from "~/utils/buttonNavigation";
|
||||||
import { ButtonSelector } from "~/utils/buttonSelector";
|
import { ButtonSelector } from "~/utils/buttonSelector";
|
||||||
import type { ScreenContext } from "~/screen";
|
|
||||||
import { ContactScreen } from "~/screens/contact";
|
import { ContactScreen } from "~/screens/contact";
|
||||||
|
import type { Screen, ScreenContext } from "~/screen";
|
||||||
|
|
||||||
type HomeButton = "projects" | "contact" | "downloadPlay" | "settings";
|
type HomeButton = "projects" | "contact" | "downloadPlay" | "settings";
|
||||||
|
|
||||||
@@ -17,8 +17,15 @@ export class HomeBottomScreen {
|
|||||||
|
|
||||||
private navigation: ButtonNavigation<HomeButton>;
|
private navigation: ButtonNavigation<HomeButton>;
|
||||||
private selector = new ButtonSelector([31, 23, 193, 49]);
|
private selector = new ButtonSelector([31, 23, 193, 49]);
|
||||||
|
private onNavigate: (screen: Screen, animateTop: boolean) => void;
|
||||||
|
private context: ScreenContext;
|
||||||
|
|
||||||
constructor(context: ScreenContext) {
|
constructor(
|
||||||
|
onNavigate: (screen: Screen, animateTop: boolean) => void,
|
||||||
|
context?: ScreenContext,
|
||||||
|
) {
|
||||||
|
this.onNavigate = onNavigate;
|
||||||
|
this.context = context!;
|
||||||
this.navigation = new ButtonNavigation<HomeButton>({
|
this.navigation = new ButtonNavigation<HomeButton>({
|
||||||
buttons: {
|
buttons: {
|
||||||
projects: [31, 23, 193, 49],
|
projects: [31, 23, 193, 49],
|
||||||
@@ -50,7 +57,7 @@ export class HomeBottomScreen {
|
|||||||
},
|
},
|
||||||
onButtonClick: (button) => {
|
onButtonClick: (button) => {
|
||||||
if (button === "contact") {
|
if (button === "contact") {
|
||||||
context.navigate(new ContactScreen(context));
|
this.onNavigate(new ContactScreen(this.context), true);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -59,17 +66,27 @@ export class HomeBottomScreen {
|
|||||||
render(ctx: CanvasRenderingContext2D): void {
|
render(ctx: CanvasRenderingContext2D): void {
|
||||||
if (!this.images.isReady) return;
|
if (!this.images.isReady) return;
|
||||||
|
|
||||||
// background
|
const buttonOffsetY = this.context.animator.get("buttonOffsetY");
|
||||||
|
const stage1Opacity = this.context.animator.get("stage1Opacity");
|
||||||
|
|
||||||
ctx.drawImage(this.images.require("background"), 0, 0);
|
ctx.drawImage(this.images.require("background"), 0, 0);
|
||||||
|
|
||||||
// buttons
|
ctx.save();
|
||||||
|
ctx.translate(0, buttonOffsetY);
|
||||||
|
ctx.globalAlpha = stage1Opacity;
|
||||||
|
|
||||||
ctx.drawImage(this.images.require("game"), 33, 25);
|
ctx.drawImage(this.images.require("game"), 33, 25);
|
||||||
ctx.drawImage(this.images.require("contact"), 32, 72);
|
ctx.drawImage(this.images.require("contact"), 32, 72);
|
||||||
ctx.drawImage(this.images.require("downloadPlay"), 128, 72);
|
ctx.drawImage(this.images.require("downloadPlay"), 128, 72);
|
||||||
ctx.drawImage(this.images.require("settings"), 117, 170);
|
ctx.drawImage(this.images.require("settings"), 117, 170);
|
||||||
|
|
||||||
// selector
|
this.selector.render(
|
||||||
this.selector.render(ctx, this.navigation.getSelectorPosition());
|
ctx,
|
||||||
|
this.navigation.getSelectorPosition(),
|
||||||
|
stage1Opacity,
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
handleTouch(x: number, y: number): void {
|
handleTouch(x: number, y: number): void {
|
||||||
|
|||||||
@@ -3,11 +3,65 @@ import { HomeTopScreen } from "./top";
|
|||||||
import { HomeBottomScreen } from "./bottom";
|
import { HomeBottomScreen } from "./bottom";
|
||||||
|
|
||||||
export class HomeScreen implements Screen {
|
export class HomeScreen implements Screen {
|
||||||
private topScreen = new HomeTopScreen();
|
private topScreen: HomeTopScreen;
|
||||||
private bottomScreen: HomeBottomScreen;
|
private bottomScreen: HomeBottomScreen;
|
||||||
|
private context: ScreenContext;
|
||||||
|
|
||||||
constructor(context: ScreenContext) {
|
constructor(context: ScreenContext) {
|
||||||
this.bottomScreen = new HomeBottomScreen(context);
|
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) {
|
renderTop(ctx: CanvasRenderingContext2D) {
|
||||||
@@ -24,5 +78,6 @@ export class HomeScreen implements Screen {
|
|||||||
|
|
||||||
destroy(): void {
|
destroy(): void {
|
||||||
this.bottomScreen.destroy();
|
this.bottomScreen.destroy();
|
||||||
|
this.context.animator.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ImageLoader } from "~/utils/loadImages";
|
import { ImageLoader } from "~/utils/loadImages";
|
||||||
|
import type { Animator } from "~/utils/animator";
|
||||||
|
|
||||||
export class Calendar {
|
export class Calendar {
|
||||||
private images = new ImageLoader({
|
private images = new ImageLoader({
|
||||||
@@ -6,10 +7,22 @@ export class Calendar {
|
|||||||
lastRow: "/images/home/top-screen/calendar/last-row.webp",
|
lastRow: "/images/home/top-screen/calendar/last-row.webp",
|
||||||
daySelector: "/images/home/top-screen/calendar/day-selector.webp",
|
daySelector: "/images/home/top-screen/calendar/day-selector.webp",
|
||||||
});
|
});
|
||||||
|
private animator: Animator;
|
||||||
|
|
||||||
|
constructor(animator: Animator) {
|
||||||
|
this.animator = animator;
|
||||||
|
}
|
||||||
|
|
||||||
public render(ctx: CanvasRenderingContext2D): void {
|
public render(ctx: CanvasRenderingContext2D): void {
|
||||||
if (!this.images.isReady) return;
|
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_COLS = 7;
|
||||||
const CALENDAR_ROWS = 5;
|
const CALENDAR_ROWS = 5;
|
||||||
const CALENDAR_LEFT = 128;
|
const CALENDAR_LEFT = 128;
|
||||||
@@ -80,5 +93,7 @@ export class Calendar {
|
|||||||
CALENDAR_LEFT + Math.floor((111 - width) / 2),
|
CALENDAR_LEFT + Math.floor((111 - width) / 2),
|
||||||
CALENDAR_TOP - 20,
|
CALENDAR_TOP - 20,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ImageLoader } from "~/utils/loadImages";
|
import { ImageLoader } from "~/utils/loadImages";
|
||||||
|
import type { Animator } from "~/utils/animator";
|
||||||
|
|
||||||
const CENTER_X = 63;
|
const CENTER_X = 63;
|
||||||
const CENTER_Y = 95;
|
const CENTER_Y = 95;
|
||||||
@@ -7,6 +8,11 @@ export class Clock {
|
|||||||
private images = new ImageLoader({
|
private images = new ImageLoader({
|
||||||
clock: "/images/home/top-screen/clock.webp",
|
clock: "/images/home/top-screen/clock.webp",
|
||||||
});
|
});
|
||||||
|
private animator: Animator;
|
||||||
|
|
||||||
|
constructor(animator: Animator) {
|
||||||
|
this.animator = animator;
|
||||||
|
}
|
||||||
|
|
||||||
private drawLine(
|
private drawLine(
|
||||||
ctx: CanvasRenderingContext2D,
|
ctx: CanvasRenderingContext2D,
|
||||||
@@ -56,6 +62,13 @@ export class Clock {
|
|||||||
public render(ctx: CanvasRenderingContext2D): void {
|
public render(ctx: CanvasRenderingContext2D): void {
|
||||||
if (!this.images.isReady) return;
|
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);
|
ctx.drawImage(this.images.require("clock"), 13, 45);
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -84,5 +97,7 @@ export class Clock {
|
|||||||
|
|
||||||
ctx.fillStyle = "#494949";
|
ctx.fillStyle = "#494949";
|
||||||
ctx.fillRect(CENTER_X - 2, CENTER_Y - 2, 5, 5);
|
ctx.fillRect(CENTER_X - 2, CENTER_Y - 2, 5, 5);
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,14 +2,21 @@ import { ImageLoader } from "~/utils/loadImages";
|
|||||||
import { StatusBar } from "./statusBar";
|
import { StatusBar } from "./statusBar";
|
||||||
import { Clock } from "./clock";
|
import { Clock } from "./clock";
|
||||||
import { Calendar } from "./calendar";
|
import { Calendar } from "./calendar";
|
||||||
|
import type { Animator } from "~/utils/animator";
|
||||||
|
|
||||||
export class HomeTopScreen {
|
export class HomeTopScreen {
|
||||||
private backgroundImage = new ImageLoader({
|
private backgroundImage = new ImageLoader({
|
||||||
background: "/images/home/top-screen/background.webp",
|
background: "/images/home/top-screen/background.webp",
|
||||||
});
|
});
|
||||||
private statusBar = new StatusBar();
|
private statusBar: StatusBar;
|
||||||
private clock = new Clock();
|
private clock: Clock;
|
||||||
private calendar = new Calendar();
|
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 {
|
render(ctx: CanvasRenderingContext2D): void {
|
||||||
if (!this.backgroundImage.isReady) return;
|
if (!this.backgroundImage.isReady) return;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ImageLoader } from "~/utils/loadImages";
|
import { ImageLoader } from "~/utils/loadImages";
|
||||||
|
import type { Animator } from "~/utils/animator";
|
||||||
|
|
||||||
export class StatusBar {
|
export class StatusBar {
|
||||||
private images = new ImageLoader({
|
private images = new ImageLoader({
|
||||||
@@ -7,11 +8,20 @@ export class StatusBar {
|
|||||||
startupMode: "/images/home/top-screen/status-bar/startup-mode.webp",
|
startupMode: "/images/home/top-screen/status-bar/startup-mode.webp",
|
||||||
battery: "/images/home/top-screen/status-bar/battery.webp",
|
battery: "/images/home/top-screen/status-bar/battery.webp",
|
||||||
});
|
});
|
||||||
|
private animator: Animator;
|
||||||
|
|
||||||
|
constructor(animator: Animator) {
|
||||||
|
this.animator = animator;
|
||||||
|
}
|
||||||
|
|
||||||
public render(ctx: CanvasRenderingContext2D): void {
|
public render(ctx: CanvasRenderingContext2D): void {
|
||||||
if (!this.images.isReady) return;
|
if (!this.images.isReady) return;
|
||||||
|
|
||||||
const TEXT_Y = 11;
|
const TEXT_Y = 11;
|
||||||
|
const statusBarY = this.animator.get("statusBarY");
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(0, statusBarY);
|
||||||
|
|
||||||
// Draw status bar background
|
// Draw status bar background
|
||||||
ctx.drawImage(this.images.require("statusBar"), 0, 0);
|
ctx.drawImage(this.images.require("statusBar"), 0, 0);
|
||||||
@@ -50,9 +60,7 @@ export class StatusBar {
|
|||||||
ctx.drawImage(this.images.require("gbaDisplay"), 210, 2);
|
ctx.drawImage(this.images.require("gbaDisplay"), 210, 2);
|
||||||
ctx.drawImage(this.images.require("startupMode"), 226, 2);
|
ctx.drawImage(this.images.require("startupMode"), 226, 2);
|
||||||
ctx.drawImage(this.images.require("battery"), 242, 4);
|
ctx.drawImage(this.images.require("battery"), 242, 4);
|
||||||
}
|
|
||||||
|
|
||||||
public update(): void {
|
ctx.restore();
|
||||||
// Placeholder for future animation updates if needed
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
106
src/utils/animator.ts
Normal file
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user