feat: animation system + test for home screen

This commit is contained in:
2025-12-13 20:37:22 +01:00
parent 68fd923d2e
commit 54bef8629c
9 changed files with 249 additions and 18 deletions

View File

@@ -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);

View File

@@ -1,5 +1,8 @@
import type { Animator } from "~/utils/animator";
export interface ScreenContext {
navigate: (screen: Screen) => void;
animator: Animator;
}
export interface Screen {

View File

@@ -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 {

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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;

View File

@@ -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
View File

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