diff --git a/src/screen-manager.ts b/src/screen-manager.ts index 6e59b43..eaf4cab 100644 --- a/src/screen-manager.ts +++ b/src/screen-manager.ts @@ -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); diff --git a/src/screen.ts b/src/screen.ts index d3c5ce3..8491759 100644 --- a/src/screen.ts +++ b/src/screen.ts @@ -1,5 +1,8 @@ +import type { Animator } from "~/utils/animator"; + export interface ScreenContext { navigate: (screen: Screen) => void; + animator: Animator; } export interface Screen { diff --git a/src/screens/home/bottom/index.ts b/src/screens/home/bottom/index.ts index eb9ea0b..a159ac7 100644 --- a/src/screens/home/bottom/index.ts +++ b/src/screens/home/bottom/index.ts @@ -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; 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({ 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 { diff --git a/src/screens/home/index.ts b/src/screens/home/index.ts index d7f5743..2b8b2f0 100644 --- a/src/screens/home/index.ts +++ b/src/screens/home/index.ts @@ -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(); } } diff --git a/src/screens/home/top/calendar.ts b/src/screens/home/top/calendar.ts index eefbe6f..d00164f 100644 --- a/src/screens/home/top/calendar.ts +++ b/src/screens/home/top/calendar.ts @@ -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(); } } diff --git a/src/screens/home/top/clock.ts b/src/screens/home/top/clock.ts index 837e2ca..3decd36 100644 --- a/src/screens/home/top/clock.ts +++ b/src/screens/home/top/clock.ts @@ -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(); } } diff --git a/src/screens/home/top/index.ts b/src/screens/home/top/index.ts index 165eb0c..10c9fce 100644 --- a/src/screens/home/top/index.ts +++ b/src/screens/home/top/index.ts @@ -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; diff --git a/src/screens/home/top/statusBar.ts b/src/screens/home/top/statusBar.ts index cd3bf75..2983f81 100644 --- a/src/screens/home/top/statusBar.ts +++ b/src/screens/home/top/statusBar.ts @@ -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(); } } diff --git a/src/utils/animator.ts b/src/utils/animator.ts new file mode 100644 index 0000000..48ed58c --- /dev/null +++ b/src/utils/animator.ts @@ -0,0 +1,106 @@ +type AnimationStep = { + target: Record; + duration: number; + delay: number; +}; + +export class Animator { + private values: Record = {}; + private targets: Record = {}; + private durations: Record = {}; + private startValues: Record = {}; + private startTimes: Record = {}; + private isAnimating = false; + private animationStart = 0; + private onCompleteCallback?: () => void; + + constructor(initialValues: Record = {}) { + this.values = { ...initialValues }; + } + + public init(values: Record): 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; + } +}