From 1e24364f931fc1cd9ae36615702dfa4bdafa27ca Mon Sep 17 00:00:00 2001 From: npmrun <1549469775@qq.com> Date: Sun, 26 Apr 2026 22:50:00 +0800 Subject: [PATCH] feat(init): integrate asset manager into runtime and enhance button functionality - Refactored AppRuntime to include assetManager and NodeRegistry, improving resource management. - Updated Button component to support visual states and variants, enhancing user interaction. - Modified InitScene and WelcomeScene to utilize appRuntime for asset management and improved layout. - Added energy effect animations to InitScene for a more dynamic user experience. Made-with: Cursor --- src/components/Button.ts | 78 ++++++++++++++++++++++++--- src/init.ts | 2 +- src/kernel/AppRuntime.ts | 12 ++++- src/kernel/NodeRegistry.ts | 41 +++++++++++++++ src/kernel/RuntimePlugin.ts | 4 ++ src/stages/page_init.ts | 102 +++++++++++++++++++++++++++--------- src/stages/page_welcome.ts | 5 +- tests/kernel/node-registry.test.ts | 31 +++++++++++ tests/kernel/runtime-events.test.ts | 2 + 9 files changed, 243 insertions(+), 34 deletions(-) create mode 100644 src/kernel/NodeRegistry.ts create mode 100644 tests/kernel/node-registry.test.ts diff --git a/src/components/Button.ts b/src/components/Button.ts index eadc4b6..b1ad2da 100644 --- a/src/components/Button.ts +++ b/src/components/Button.ts @@ -12,6 +12,7 @@ export interface ButtonOptions { text: string; bg?: Texture; pressBg?: Texture; + variant?: "primary" | "secondary" | "ghost"; position?: () => { x: number; y: number }; onClick: () => void | Promise; autoUpdate?: boolean; @@ -31,6 +32,7 @@ export class Button { private isHovered = false; private isDisabled = false; private cooldownUntil = 0; + private visualState: "normal" | "hover" | "pressed" = "normal"; constructor(opts: ButtonOptions) { this.config = { @@ -38,6 +40,7 @@ export class Button { padding: { x: 60, y: 40 }, fontSize: 30, clickCooldownMs: 250, + variant: "primary", ...opts, }; this.padding = this.config.padding!; @@ -106,9 +109,7 @@ export class Button { this.textObj.position.set(this.padding.x / 2, this.padding.y / 2 - 2); if (this.bgRect) { - this.bgRect.clear(); - this.bgRect.rect(0, 0, width, height); - this.bgRect.fill(0xff0000); + this.drawRectBackground(width, height, this.visualState); } if (this.bgNine) { @@ -127,6 +128,62 @@ export class Button { this._container.addChild(this.bgRect); } + private drawRectBackground( + width: number, + height: number, + state: "normal" | "hover" | "pressed" + ): void { + if (!this.bgRect) return; + + const variant = this.config.variant ?? "primary"; + let fillColor = 0x1d4ed8; + let borderColor = 0x93c5fd; + let textColor = 0xf8fafc; + let fillAlpha = 0.96; + + if (variant === "secondary") { + fillColor = 0x0f2748; + borderColor = 0x60a5fa; + } else if (variant === "ghost") { + fillColor = 0x0b1f37; + borderColor = 0x93c5fd; + fillAlpha = 0.58; + } + + if (state === "hover") { + if (variant === "primary") { + fillColor = 0x2563eb; + borderColor = 0xbfdbfe; + } else if (variant === "secondary") { + fillColor = 0x13315b; + borderColor = 0x93c5fd; + } else { + fillColor = 0x10325e; + borderColor = 0xbfdbfe; + fillAlpha = 0.68; + } + } else if (state === "pressed") { + if (variant === "primary") { + fillColor = 0x1e40af; + borderColor = 0x93c5fd; + } else if (variant === "secondary") { + fillColor = 0x0b1f37; + borderColor = 0x60a5fa; + } else { + fillColor = 0x0b2748; + borderColor = 0x93c5fd; + fillAlpha = 0.74; + } + textColor = 0xe2e8f0; + } + + this.bgRect.clear(); + this.bgRect.roundRect(0, 0, width, height, 14); + this.bgRect.fill({ color: fillColor, alpha: fillAlpha }); + this.bgRect.stroke({ width: 2, color: borderColor, alpha: 0.95 }); + this.textObj.style.fill = textColor; + } + private createNineSliceBackground(): void { if (!this.config.bg) return; this.bgNine = new NineSliceSprite({ @@ -142,9 +199,10 @@ export class Button { private createText(): void { const style = new TextStyle({ - fontFamily: "Arial", + fontFamily: "'Microsoft YaHei', 'PingFang SC', system-ui, sans-serif", fontSize: this.config.fontSize!, - fill: 0xffffff, + fill: 0xf8fafc, + fontWeight: "700", align: "center", }); this.textObj = new Text({ @@ -205,14 +263,22 @@ export class Button { } private applyStateVisual(state: "normal" | "hover" | "pressed"): void { + this.visualState = state; + if (this.config.pressBg && this.bgNine && this.config.bg) { this.bgNine.texture = state === "pressed" ? this.config.pressBg : this.config.bg; } + if (this.bgRect) { + const width = this.textObj.width + this.padding.x; + const height = this.textObj.height + this.padding.y; + this.drawRectBackground(width, height, state); + } + if (state === "pressed") { this._container.scale.set(0.95, 0.95); if (!this.config.pressBg) { - this._container.alpha = 0.86; + this._container.alpha = 0.92; } return; } diff --git a/src/init.ts b/src/init.ts index 3ef07f1..bbd8b0a 100644 --- a/src/init.ts +++ b/src/init.ts @@ -1,7 +1,6 @@ import { BaseScene } from "./scene/BaseScene"; import type { IBaseScene } from "./scene/types"; import { SceneType } from "./enums/SceneType"; -import { assetManager } from "./core/AssetManager"; import { logger } from "./core/Logger"; import { Container } from "pixi.js"; import { appRuntime } from "./kernel/AppRuntime"; @@ -11,6 +10,7 @@ import { collectSceneDefinitions } from "./init/sceneDiscovery"; const game = appRuntime.game; const sceneManager = appRuntime.sceneManager; const eventBus = appRuntime.events; +const assetManager = appRuntime.assetManager; const runtimeSceneEventsPlugin: RuntimePlugin = { name: "runtime-scene-events", diff --git a/src/kernel/AppRuntime.ts b/src/kernel/AppRuntime.ts index b783301..769d1cf 100644 --- a/src/kernel/AppRuntime.ts +++ b/src/kernel/AppRuntime.ts @@ -1,5 +1,7 @@ import Game from "@/core/Game"; +import assetManager, { type AssetManager } from "@/core/AssetManager"; import SceneManager from "@/scene/SceneManager"; +import { NodeRegistry } from "./NodeRegistry"; import { RuntimeEvents } from "./RuntimeEvents"; import type { RuntimePlugin, RuntimePluginContext } from "./RuntimePlugin"; @@ -7,20 +9,26 @@ export class AppRuntime { private static instance: AppRuntime; public readonly game: Game; + public readonly assetManager: AssetManager; public readonly sceneManager: SceneManager; public readonly events: RuntimeEvents; + public readonly nodes: NodeRegistry; private readonly installedPlugins = new Set(); private startedAt = 0; constructor( game: Game = Game.getInstance(), + assetManagerInstance: AssetManager = assetManager, sceneManager: SceneManager = SceneManager.getInstance(), - events: RuntimeEvents = new RuntimeEvents() + events: RuntimeEvents = new RuntimeEvents(), + nodes: NodeRegistry = new NodeRegistry() ) { this.game = game; + this.assetManager = assetManagerInstance; this.sceneManager = sceneManager; this.events = events; + this.nodes = nodes; } static getInstance(): AppRuntime { @@ -33,8 +41,10 @@ export class AppRuntime { get pluginContext(): RuntimePluginContext { return { game: this.game, + assetManager: this.assetManager, sceneManager: this.sceneManager, events: this.events, + nodes: this.nodes, }; } diff --git a/src/kernel/NodeRegistry.ts b/src/kernel/NodeRegistry.ts new file mode 100644 index 0000000..796a3bf --- /dev/null +++ b/src/kernel/NodeRegistry.ts @@ -0,0 +1,41 @@ +import type { Container } from "pixi.js"; + +export class NodeRegistry { + private readonly nodes = new Map(); + + set(key: string, node: Container): void { + if (!key) { + throw new Error("NodeRegistry: key cannot be empty"); + } + this.nodes.set(key, node); + } + + get(key: string): T | undefined { + if (!key) { + return undefined; + } + return this.nodes.get(key) as T | undefined; + } + + has(key: string): boolean { + if (!key) { + return false; + } + return this.nodes.has(key); + } + + delete(key: string): boolean { + if (!key) { + return false; + } + return this.nodes.delete(key); + } + + clear(): void { + this.nodes.clear(); + } + + size(): number { + return this.nodes.size; + } +} diff --git a/src/kernel/RuntimePlugin.ts b/src/kernel/RuntimePlugin.ts index 137da1d..1a83220 100644 --- a/src/kernel/RuntimePlugin.ts +++ b/src/kernel/RuntimePlugin.ts @@ -1,11 +1,15 @@ import type Game from "@/core/Game"; +import type { AssetManager } from "@/core/AssetManager"; import type SceneManager from "@/scene/SceneManager"; +import type { NodeRegistry } from "./NodeRegistry"; import type { RuntimeEvents } from "./RuntimeEvents"; export interface RuntimePluginContext { game: Game; + assetManager: AssetManager; sceneManager: SceneManager; events: RuntimeEvents; + nodes: NodeRegistry; } export interface RuntimePlugin { diff --git a/src/stages/page_init.ts b/src/stages/page_init.ts index a3d03db..2df0fff 100644 --- a/src/stages/page_init.ts +++ b/src/stages/page_init.ts @@ -1,31 +1,33 @@ import { Button } from "@/components/Button"; -import { assetManager } from "@/core/AssetManager"; -import Game from "@/core/Game"; import { BaseScene } from "@/scene/BaseScene"; import { SceneType } from "@/enums/SceneType"; +import { appRuntime } from "@/kernel/AppRuntime"; import position from "@/utils/Position"; -import { - Container, - Text, - TextStyle, - Texture, -} from "pixi.js"; +import { Container, Graphics, Text, TextStyle, type Ticker } from "pixi.js"; +const Z_EFFECT = 1; const Z_TEXT = 3; const INIT_LAYOUT = { titleYRatio: 0.12, subtitleYRatio: 0.2, + effectYRatio: 0.48, startCtaOffsetY: -110, } as const; export default class InitScene extends BaseScene { stage = new Container(); - private game = Game.getInstance(); - private assets: Record = {} as Record; + private readonly game = appRuntime.game; + private readonly assetManager = appRuntime.assetManager; private titleText?: Text; private subtitleText?: Text; private startBtn?: Button; + private effectLayer?: Container; + private coreGlow?: Graphics; + private ringOuter?: Graphics; + private ringInner?: Graphics; + private pulseHalo?: Graphics; + private animElapsed = 0; private readonly onResize = (): void => { this.placeHeroElements(); @@ -36,17 +38,13 @@ export default class InitScene extends BaseScene { } protected async onSceneLoadBundle(): Promise { - const bundle = await assetManager.loadBundle("load-screen", (progress: number) => { + await this.assetManager.loadBundle("load-screen", (progress: number) => { this.reportSceneLoading({ progress }); }); - - if (bundle) { - this.assets = bundle as unknown as Record; - } } protected async onSceneUnloadBundle(): Promise { - await assetManager.unloadBundle("load-screen"); + await this.assetManager.unloadBundle("load-screen"); } protected async onSceneLayout(): Promise { @@ -55,7 +53,7 @@ export default class InitScene extends BaseScene { const titleStyle = new TextStyle({ fontFamily: "'Microsoft YaHei', 'PingFang SC', system-ui, sans-serif", - fontSize: 44, + fontSize: 54, fontWeight: "700", fill: 0xffffff, align: "center", @@ -68,7 +66,7 @@ export default class InitScene extends BaseScene { }, }); this.titleText = new Text({ - text: "像素世界", + text: "游戏世界", style: titleStyle, }); this.titleText.anchor.set(0.5); @@ -77,13 +75,13 @@ export default class InitScene extends BaseScene { const subStyle = new TextStyle({ fontFamily: "'Microsoft YaHei', 'PingFang SC', system-ui, sans-serif", - fontSize: 20, + fontSize: 30, fill: 0xe8f4ff, align: "center", letterSpacing: 1, }); this.subtitleText = new Text({ - text: "点击下方按钮,开始你的旅程", + text: "点击下方按钮,开始你的游戏", style: subStyle, }); this.subtitleText.anchor.set(0.5); @@ -91,12 +89,14 @@ export default class InitScene extends BaseScene { this.subtitleText.zIndex = Z_TEXT; this.stage.addChild(this.subtitleText); + this.effectLayer = this.createEnergyEffect(); + this.effectLayer.zIndex = Z_EFFECT; + this.stage.addChild(this.effectLayer); + this.startBtn = new Button({ text: "开始游戏", - bg: this.assets["btn-bg"], - pressBg: this.assets["btn-bg-press"], - fontSize: 28, - padding: { x: 72, y: 36 }, + fontSize: 30, + padding: { x: 92, y: 40 }, clickCooldownMs: 450, position: () => position.get("center", "bottom", { @@ -121,6 +121,26 @@ export default class InitScene extends BaseScene { window.removeEventListener("resize", this.onResize); } + update(dt: number, _name: string, _ticker: Ticker): void { + if (!this.effectLayer || !this.ringOuter || !this.ringInner || !this.pulseHalo || !this.coreGlow) { + return; + } + + this.animElapsed += dt * 0.06; + const t = this.animElapsed; + + this.ringOuter.rotation += 0.0038 * dt; + this.ringInner.rotation -= 0.0026 * dt; + + const breathe = 1 + Math.sin(t) * 0.03; + this.effectLayer.scale.set(breathe, breathe); + + const pulse = (Math.sin(t * 1.8) + 1) * 0.5; + this.coreGlow.alpha = 0.38 + pulse * 0.24; + this.pulseHalo.alpha = 0.12 + pulse * 0.22; + this.pulseHalo.scale.set(0.92 + pulse * 0.18, 0.92 + pulse * 0.18); + } + protected getSceneLoadingOverlayConfig() { return { title: "初始化资源中", @@ -144,7 +164,41 @@ export default class InitScene extends BaseScene { position.get("center", H * L.subtitleYRatio) ); } + if (this.effectLayer) { + this.effectLayer.position.copyFrom(position.get("center", H * L.effectYRatio)); + } + + } + private createEnergyEffect(): Container { + const layer = new Container(); + layer.eventMode = "none"; + + const coreGlow = new Graphics(); + coreGlow.circle(0, 0, 34); + coreGlow.fill({ color: 0xa5f3fc, alpha: 0.46 }); + layer.addChild(coreGlow); + this.coreGlow = coreGlow; + + const ringOuter = new Graphics(); + ringOuter.circle(0, 0, 112); + ringOuter.stroke({ width: 5, color: 0x93c5fd, alpha: 0.35 }); + layer.addChild(ringOuter); + this.ringOuter = ringOuter; + + const ringInner = new Graphics(); + ringInner.circle(0, 0, 76); + ringInner.stroke({ width: 4, color: 0xbfdbfe, alpha: 0.3 }); + layer.addChild(ringInner); + this.ringInner = ringInner; + + const pulseHalo = new Graphics(); + pulseHalo.circle(0, 0, 128); + pulseHalo.stroke({ width: 3, color: 0xe0f2fe, alpha: 0.22 }); + layer.addChild(pulseHalo); + this.pulseHalo = pulseHalo; + + return layer; } private handleStartClick(): void { diff --git a/src/stages/page_welcome.ts b/src/stages/page_welcome.ts index 9080a13..c1cd7a8 100644 --- a/src/stages/page_welcome.ts +++ b/src/stages/page_welcome.ts @@ -1,6 +1,6 @@ import { Button } from "@/components/Button"; -import Game from "@/core/Game"; import { SceneType } from "@/enums/SceneType"; +import { appRuntime } from "@/kernel/AppRuntime"; import { BaseScene } from "@/scene/BaseScene"; import position from "@/utils/Position"; import { Container, Graphics, Text, TextStyle } from "pixi.js"; @@ -18,7 +18,7 @@ const GUIDE_ITEMS: GuideItem[] = [ export default class WelcomeScene extends BaseScene { stage = new Container(); - private game = Game.getInstance(); + private readonly game = appRuntime.game; private titleText?: Text; private descText?: Text; @@ -80,6 +80,7 @@ export default class WelcomeScene extends BaseScene { this.backBtn = new Button({ text: "返回开始", + variant: "secondary", fontSize: 24, padding: { x: 64, y: 26 }, position: () => position.get("center", "bottom", { y: -56, x: 0 }), diff --git a/tests/kernel/node-registry.test.ts b/tests/kernel/node-registry.test.ts new file mode 100644 index 0000000..9ecd0a6 --- /dev/null +++ b/tests/kernel/node-registry.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { Container } from "pixi.js"; +import { NodeRegistry } from "@/kernel/NodeRegistry"; + +describe("NodeRegistry", () => { + it("stores and retrieves node references by key", () => { + const registry = new NodeRegistry(); + const node = new Container(); + + registry.set("hud:score", node); + + expect(registry.get("hud:score")).toBe(node); + expect(registry.has("hud:score")).toBe(true); + expect(registry.size()).toBe(1); + }); + + it("removes and clears registered nodes", () => { + const registry = new NodeRegistry(); + const a = new Container(); + const b = new Container(); + + registry.set("overlay:a", a); + registry.set("overlay:b", b); + expect(registry.delete("overlay:a")).toBe(true); + expect(registry.has("overlay:a")).toBe(false); + + registry.clear(); + expect(registry.has("overlay:b")).toBe(false); + expect(registry.size()).toBe(0); + }); +}); diff --git a/tests/kernel/runtime-events.test.ts b/tests/kernel/runtime-events.test.ts index bbdb85a..13e997e 100644 --- a/tests/kernel/runtime-events.test.ts +++ b/tests/kernel/runtime-events.test.ts @@ -125,5 +125,7 @@ describe("AppRuntime plugin lifecycle", () => { expect(runtime.game).toBeDefined(); expect(runtime.sceneManager).toBeDefined(); expect(runtime.events).toBeDefined(); + expect(runtime.assetManager).toBeDefined(); + expect(runtime.nodes).toBeDefined(); }); });