Browse Source

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
master
npmrun 2 weeks ago
parent
commit
1e24364f93
  1. 78
      src/components/Button.ts
  2. 2
      src/init.ts
  3. 12
      src/kernel/AppRuntime.ts
  4. 41
      src/kernel/NodeRegistry.ts
  5. 4
      src/kernel/RuntimePlugin.ts
  6. 102
      src/stages/page_init.ts
  7. 5
      src/stages/page_welcome.ts
  8. 31
      tests/kernel/node-registry.test.ts
  9. 2
      tests/kernel/runtime-events.test.ts

78
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<void>;
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;
}

2
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",

12
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<string>();
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,
};
}

41
src/kernel/NodeRegistry.ts

@ -0,0 +1,41 @@
import type { Container } from "pixi.js";
export class NodeRegistry {
private readonly nodes = new Map<string, Container>();
set(key: string, node: Container): void {
if (!key) {
throw new Error("NodeRegistry: key cannot be empty");
}
this.nodes.set(key, node);
}
get<T extends Container = Container>(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;
}
}

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

102
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<string, Texture> = {} as Record<string, Texture>;
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<void> {
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<string, Texture>;
}
}
protected async onSceneUnloadBundle(): Promise<void> {
await assetManager.unloadBundle("load-screen");
await this.assetManager.unloadBundle("load-screen");
}
protected async onSceneLayout(): Promise<void> {
@ -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 {

5
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 }),

31
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);
});
});

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

Loading…
Cancel
Save