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 { Animator } from "~/utils/animator";
|
||||
|
||||
export class ScreenManager {
|
||||
private currentScreen: Screen;
|
||||
private context: ScreenContext;
|
||||
private animator = new Animator({});
|
||||
|
||||
constructor() {
|
||||
const context = {
|
||||
this.context = {
|
||||
navigate: (screen: Screen) => {
|
||||
if (this.currentScreen.destroy) {
|
||||
this.currentScreen.destroy();
|
||||
}
|
||||
this.currentScreen = screen;
|
||||
},
|
||||
animator: this.animator,
|
||||
};
|
||||
|
||||
this.currentScreen = new HomeScreen(context);
|
||||
this.currentScreen = new HomeScreen(this.context);
|
||||
}
|
||||
|
||||
renderTop(ctx: CanvasRenderingContext2D) {
|
||||
this.animator.update();
|
||||
ctx.clearRect(0, 0, 256, 192);
|
||||
ctx.save();
|
||||
this.currentScreen.renderTop(ctx);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { Animator } from "~/utils/animator";
|
||||
|
||||
export interface ScreenContext {
|
||||
navigate: (screen: Screen) => void;
|
||||
animator: Animator;
|
||||
}
|
||||
|
||||
export interface Screen {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { ImageLoader } from "~/utils/loadImages";
|
||||
import { ButtonNavigation } from "~/utils/buttonNavigation";
|
||||
import { ButtonSelector } from "~/utils/buttonSelector";
|
||||
import type { ScreenContext } from "~/screen";
|
||||
import { ContactScreen } from "~/screens/contact";
|
||||
import type { Screen, ScreenContext } from "~/screen";
|
||||
|
||||
type HomeButton = "projects" | "contact" | "downloadPlay" | "settings";
|
||||
|
||||
@@ -17,8 +17,15 @@ export class HomeBottomScreen {
|
||||
|
||||
private navigation: ButtonNavigation<HomeButton>;
|
||||
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>({
|
||||
buttons: {
|
||||
projects: [31, 23, 193, 49],
|
||||
@@ -50,7 +57,7 @@ export class HomeBottomScreen {
|
||||
},
|
||||
onButtonClick: (button) => {
|
||||
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 {
|
||||
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);
|
||||
|
||||
// buttons
|
||||
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);
|
||||
|
||||
// selector
|
||||
this.selector.render(ctx, this.navigation.getSelectorPosition());
|
||||
this.selector.render(
|
||||
ctx,
|
||||
this.navigation.getSelectorPosition(),
|
||||
stage1Opacity,
|
||||
);
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
handleTouch(x: number, y: number): void {
|
||||
|
||||
@@ -3,11 +3,65 @@ import { HomeTopScreen } from "./top";
|
||||
import { HomeBottomScreen } from "./bottom";
|
||||
|
||||
export class HomeScreen implements Screen {
|
||||
private topScreen = new HomeTopScreen();
|
||||
private topScreen: HomeTopScreen;
|
||||
private bottomScreen: HomeBottomScreen;
|
||||
private 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) {
|
||||
@@ -24,5 +78,6 @@ export class HomeScreen implements Screen {
|
||||
|
||||
destroy(): void {
|
||||
this.bottomScreen.destroy();
|
||||
this.context.animator.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ImageLoader } from "~/utils/loadImages";
|
||||
import type { Animator } from "~/utils/animator";
|
||||
|
||||
export class Calendar {
|
||||
private images = new ImageLoader({
|
||||
@@ -6,10 +7,22 @@ export class Calendar {
|
||||
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;
|
||||
@@ -80,5 +93,7 @@ export class Calendar {
|
||||
CALENDAR_LEFT + Math.floor((111 - width) / 2),
|
||||
CALENDAR_TOP - 20,
|
||||
);
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ImageLoader } from "~/utils/loadImages";
|
||||
import type { Animator } from "~/utils/animator";
|
||||
|
||||
const CENTER_X = 63;
|
||||
const CENTER_Y = 95;
|
||||
@@ -7,6 +8,11 @@ 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,
|
||||
@@ -56,6 +62,13 @@ export class Clock {
|
||||
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();
|
||||
@@ -84,5 +97,7 @@ export class Clock {
|
||||
|
||||
ctx.fillStyle = "#494949";
|
||||
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 { 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 = new StatusBar();
|
||||
private clock = new Clock();
|
||||
private calendar = new Calendar();
|
||||
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;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ImageLoader } from "~/utils/loadImages";
|
||||
import type { Animator } from "~/utils/animator";
|
||||
|
||||
export class StatusBar {
|
||||
private images = new ImageLoader({
|
||||
@@ -7,11 +8,20 @@ export class StatusBar {
|
||||
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);
|
||||
@@ -50,9 +60,7 @@ export class StatusBar {
|
||||
ctx.drawImage(this.images.require("gbaDisplay"), 210, 2);
|
||||
ctx.drawImage(this.images.require("startupMode"), 226, 2);
|
||||
ctx.drawImage(this.images.require("battery"), 242, 4);
|
||||
}
|
||||
|
||||
public update(): void {
|
||||
// Placeholder for future animation updates if needed
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
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