Browse Source

feat(scene): enhance scene initialization and button interactions

- Introduced scene discovery utility to streamline scene loading by automatically collecting scene definitions.
- Refactored initApp function to utilize the new scene discovery method, improving clarity and maintainability.
- Updated Button component to support click cooldowns and state management, enhancing user interaction experience.
- Added WelcomeScene to guide users through gameplay instructions with interactive elements.

Made-with: Cursor
master
npmrun 2 weeks ago
parent
commit
18e94df315
  1. 116
      src/components/Button.ts
  2. 25
      src/init.ts
  3. 44
      src/init/sceneDiscovery.ts
  4. 2
      src/scene/SceneLoadingOverlay.ts
  5. 33
      src/scene/SceneManager.ts
  6. 45
      src/stages/page_init.ts
  7. 166
      src/stages/page_welcome.ts
  8. 36
      tests/scene-discovery.test.ts

116
src/components/Button.ts

@ -13,10 +13,11 @@ export interface ButtonOptions {
bg?: Texture; bg?: Texture;
pressBg?: Texture; pressBg?: Texture;
position?: () => { x: number; y: number }; position?: () => { x: number; y: number };
onClick: () => void; onClick: () => void | Promise<void>;
autoUpdate?: boolean; autoUpdate?: boolean;
padding?: { x: number; y: number }; padding?: { x: number; y: number };
fontSize?: number; fontSize?: number;
clickCooldownMs?: number;
} }
export class Button { export class Button {
@ -26,12 +27,17 @@ export class Button {
private bgRect?: Graphics; private bgRect?: Graphics;
private bgNine?: NineSliceSprite; private bgNine?: NineSliceSprite;
private padding: { x: number; y: number }; private padding: { x: number; y: number };
private isPointerDown = false;
private isHovered = false;
private isDisabled = false;
private cooldownUntil = 0;
constructor(opts: ButtonOptions) { constructor(opts: ButtonOptions) {
this.config = { this.config = {
autoUpdate: true, autoUpdate: true,
padding: { x: 60, y: 40 }, padding: { x: 60, y: 40 },
fontSize: 30, fontSize: 30,
clickCooldownMs: 250,
...opts, ...opts,
}; };
this.padding = this.config.padding!; this.padding = this.config.padding!;
@ -39,6 +45,7 @@ export class Button {
this._container = new Container(); this._container = new Container();
this._container.cursor = "pointer"; this._container.cursor = "pointer";
this._container.interactive = true; this._container.interactive = true;
this._container.eventMode = "static";
if (!this.config.bg) { if (!this.config.bg) {
this.createRectBackground(); this.createRectBackground();
@ -70,6 +77,25 @@ export class Button {
return this._container; return this._container;
} }
setDisabled(disabled: boolean): void {
this.isDisabled = disabled;
this._container.interactive = !disabled;
this._container.cursor = disabled ? "default" : "pointer";
if (disabled) {
this.isPointerDown = false;
this.applyStateVisual("normal");
this._container.alpha = 0.72;
return;
}
this._container.alpha = 1;
this.applyStateVisual("normal");
}
setText(text: string): void {
this.textObj.text = text;
this.updateView();
}
updateView(): void { updateView(): void {
if (this._container.destroyed) return; if (this._container.destroyed) return;
@ -130,39 +156,79 @@ export class Button {
private setupEvents(): void { private setupEvents(): void {
const onPointerDown = () => { const onPointerDown = () => {
if (this.config.pressBg && this.bgNine && this.config.bg) { if (this.isDisabled) return;
this.bgNine.texture = this.config.pressBg; this.isPointerDown = true;
} else { this.applyStateVisual("pressed");
this._container.alpha = 0.8;
this._container.scale.set(0.8, 0.8);
}
}; };
const onPointerUp = () => { const onPointerUp = async () => {
if (this.config.pressBg && this.bgNine && this.config.bg) { if (this.isDisabled) return;
this.bgNine.texture = this.config.bg; const isClick = this.isPointerDown;
} else { this.isPointerDown = false;
this._container.alpha = 1; this.applyStateVisual(this.isHovered ? "hover" : "normal");
this._container.scale.set(1, 1); if (!isClick) return;
const now = Date.now();
if (now < this.cooldownUntil) {
return;
} }
this.config.onClick();
this.cooldownUntil = now + (this.config.clickCooldownMs ?? 250);
await this.config.onClick();
}; };
const onPointerUpOutside = () => { const onPointerUpOutside = () => {
if (this.config.pressBg && this.bgNine && this.config.bg) { this.isPointerDown = false;
this.bgNine.texture = this.config.bg; this.applyStateVisual(this.isHovered ? "hover" : "normal");
} else { };
this._container.alpha = 1;
this._container.scale.set(1, 1); const onPointerOver = () => {
if (this.isDisabled) return;
this.isHovered = true;
if (!this.isPointerDown) {
this.applyStateVisual("hover");
} }
}; };
this._container.on("mousedown", onPointerDown); const onPointerOut = () => {
this._container.on("touchstart", onPointerDown); this.isHovered = false;
this._container.on("mouseup", onPointerUp); this.isPointerDown = false;
this._container.on("touchend", onPointerUp); if (this.isDisabled) return;
this._container.on("mouseupoutside", onPointerUpOutside); this.applyStateVisual("normal");
this._container.on("touchendoutside", onPointerUpOutside); };
this._container.on("pointerdown", onPointerDown);
this._container.on("pointerup", onPointerUp);
this._container.on("pointerupoutside", onPointerUpOutside);
this._container.on("pointerover", onPointerOver);
this._container.on("pointerout", onPointerOut);
}
private applyStateVisual(state: "normal" | "hover" | "pressed"): void {
if (this.config.pressBg && this.bgNine && this.config.bg) {
this.bgNine.texture = state === "pressed" ? this.config.pressBg : this.config.bg;
}
if (state === "pressed") {
this._container.scale.set(0.95, 0.95);
if (!this.config.pressBg) {
this._container.alpha = 0.86;
}
return;
}
if (state === "hover") {
this._container.scale.set(1.03, 1.03);
if (!this.config.pressBg) {
this._container.alpha = 1;
}
return;
}
this._container.scale.set(1, 1);
if (!this.config.pressBg) {
this._container.alpha = 1;
}
} }
} }

25
src/init.ts

@ -6,6 +6,7 @@ import { logger } from "./core/Logger";
import { Container } from "pixi.js"; import { Container } from "pixi.js";
import { appRuntime } from "./kernel/AppRuntime"; import { appRuntime } from "./kernel/AppRuntime";
import type { RuntimePlugin } from "./kernel/RuntimePlugin"; import type { RuntimePlugin } from "./kernel/RuntimePlugin";
import { collectSceneDefinitions } from "./init/sceneDiscovery";
const game = appRuntime.game; const game = appRuntime.game;
const sceneManager = appRuntime.sceneManager; const sceneManager = appRuntime.sceneManager;
@ -28,33 +29,17 @@ const runtimeSceneEventsPlugin: RuntimePlugin = {
}, },
}; };
type Constructor<T> = new (...args: unknown[]) => T;
export async function initApp(): Promise<void> { export async function initApp(): Promise<void> {
appRuntime.use(runtimeSceneEventsPlugin); appRuntime.use(runtimeSceneEventsPlugin);
await assetManager.init(); await assetManager.init();
await game.init(); await game.init();
const sceneModules = import.meta.glob("./stages/**/page_*.ts", { eager: true }); const sceneModules = import.meta.glob("./stages/**/page_*.ts", { eager: true });
const enabledSceneNames = new Set(["init", "00_global"]); const discoveredScenes = collectSceneDefinitions(sceneModules);
for (const path in sceneModules) { for (const definition of discoveredScenes) {
try { try {
const mod = sceneModules[path]; const { name: fileSceneName, path, SceneCtor } = definition;
const match = path.match(/page_(.*?)\.ts$/);
if (!match) continue;
const fileSceneName = match[1];
if (!enabledSceneNames.has(fileSceneName)) {
continue;
}
const raw = (mod as { default: unknown }).default;
if (typeof raw !== "function") {
logger.warn(`initApp: invalid scene file ${path}, expected default class export, skipping`);
continue;
}
const SceneCtor = raw as Constructor<IBaseScene & Record<string, unknown>>;
const scene = new SceneCtor(); const scene = new SceneCtor();
if (!scene || typeof scene !== "object" || !("stage" in scene)) { if (!scene || typeof scene !== "object" || !("stage" in scene)) {
@ -77,7 +62,7 @@ export async function initApp(): Promise<void> {
sceneManager.registerScene(legacy as IBaseScene); sceneManager.registerScene(legacy as IBaseScene);
} catch (error) { } catch (error) {
logger.error(`initApp: failed to load scene file ${path}`, error); logger.error(`initApp: failed to instantiate scene from ${definition.path}`, error);
} }
} }

44
src/init/sceneDiscovery.ts

@ -0,0 +1,44 @@
export type SceneModuleMap = Record<string, unknown>;
export type SceneDefinition = {
name: string;
path: string;
SceneCtor: new (...args: unknown[]) => object;
};
type CollectSceneDefinitionsOptions = {
exclude?: (sceneName: string, modulePath: string) => boolean;
};
export function collectSceneDefinitions(
modules: SceneModuleMap,
options: CollectSceneDefinitionsOptions = {}
): SceneDefinition[] {
const definitions: SceneDefinition[] = [];
const { exclude } = options;
for (const modulePath in modules) {
const match = modulePath.match(/page_(.*?)\.ts$/);
if (!match) {
continue;
}
const sceneName = match[1];
if (exclude?.(sceneName, modulePath)) {
continue;
}
const raw = (modules[modulePath] as { default?: unknown }).default;
if (typeof raw !== "function") {
continue;
}
definitions.push({
name: sceneName,
path: modulePath,
SceneCtor: raw as new (...args: unknown[]) => object,
});
}
return definitions;
}

2
src/scene/SceneLoadingOverlay.ts

@ -3,6 +3,7 @@ import { Container, Graphics, Text, TextStyle } from "pixi.js";
export interface SceneLoadingOverlayConfig { export interface SceneLoadingOverlayConfig {
enabled?: boolean; enabled?: boolean;
showDelayMs?: number;
minDisplayMs?: number; minDisplayMs?: number;
maskAlpha?: number; maskAlpha?: number;
safeTop?: number; safeTop?: number;
@ -22,6 +23,7 @@ export interface SceneLoadingOverlayState {
const DEFAULT_CONFIG: Required<SceneLoadingOverlayConfig> = { const DEFAULT_CONFIG: Required<SceneLoadingOverlayConfig> = {
enabled: true, enabled: true,
showDelayMs: 2000,
minDisplayMs: 350, minDisplayMs: 350,
maskAlpha: 0.55, maskAlpha: 0.55,
safeTop: 24, safeTop: 24,

33
src/scene/SceneManager.ts

@ -327,12 +327,25 @@ class SceneManager {
const base = scene instanceof BaseScene ? scene : undefined; const base = scene instanceof BaseScene ? scene : undefined;
const config = base?.getLoadingOverlayConfig?.(); const config = base?.getLoadingOverlayConfig?.();
const enableOverlay = config?.enabled ?? true; const enableOverlay = config?.enabled ?? true;
const showDelayMs = Math.max(0, config?.showDelayMs ?? 2000);
let overlayVisible = false;
let pendingState: { progress?: number; text?: string } | undefined;
let overlayTimer: ReturnType<typeof setTimeout> | undefined;
if (enableOverlay) { if (enableOverlay) {
this.loadingOverlay.show(config); if (base) {
base!._loadingReporter = (state) => { base._loadingReporter = (state) => {
this.loadingOverlay.update(state); pendingState = state;
}; if (overlayVisible) {
this.loadingOverlay.update(state);
}
};
}
overlayTimer = setTimeout(() => {
overlayVisible = true;
this.loadingOverlay.show(config);
this.loadingOverlay.update(pendingState);
}, showDelayMs);
} }
try { try {
@ -345,11 +358,19 @@ class SceneManager {
scene._layoutDone = true; scene._layoutDone = true;
} }
await scene.onLoad?.(); await scene.onLoad?.();
if (enableOverlay) { if (overlayTimer) {
clearTimeout(overlayTimer);
overlayTimer = undefined;
}
if (enableOverlay && overlayVisible) {
await this.loadingOverlay.hide(); await this.loadingOverlay.hide();
} }
} catch (error) { } catch (error) {
if (enableOverlay) { if (overlayTimer) {
clearTimeout(overlayTimer);
overlayTimer = undefined;
}
if (enableOverlay && overlayVisible) {
await this.loadingOverlay.hide(); await this.loadingOverlay.hide();
} }
throw error; throw error;

45
src/stages/page_init.ts

@ -5,21 +5,16 @@ import { BaseScene } from "@/scene/BaseScene";
import { SceneType } from "@/enums/SceneType"; import { SceneType } from "@/enums/SceneType";
import position from "@/utils/Position"; import position from "@/utils/Position";
import { import {
AnimatedSprite,
Container, Container,
Rectangle,
Text, Text,
TextStyle, TextStyle,
Texture, Texture,
Ticker,
} from "pixi.js"; } from "pixi.js";
const Z_HERO = 2;
const Z_TEXT = 3; const Z_TEXT = 3;
const INIT_LAYOUT = { const INIT_LAYOUT = {
titleYRatio: 0.12, titleYRatio: 0.12,
subtitleYRatio: 0.2, subtitleYRatio: 0.2,
heroYRatio: 0.46,
startCtaOffsetY: -110, startCtaOffsetY: -110,
} as const; } as const;
@ -30,7 +25,6 @@ export default class InitScene extends BaseScene {
private titleText?: Text; private titleText?: Text;
private subtitleText?: Text; private subtitleText?: Text;
private pixie?: AnimatedSprite;
private startBtn?: Button; private startBtn?: Button;
private readonly onResize = (): void => { private readonly onResize = (): void => {
@ -97,37 +91,20 @@ export default class InitScene extends BaseScene {
this.subtitleText.zIndex = Z_TEXT; this.subtitleText.zIndex = Z_TEXT;
this.stage.addChild(this.subtitleText); this.stage.addChild(this.subtitleText);
const base = this.assets["dnf"];
if (base) {
const src = base as unknown as Texture["source"];
const textures = [0, 80, 160, 240].map(
(x) =>
new Texture({
source: src,
frame: new Rectangle(x, 0, 80, 143),
})
);
this.pixie = new AnimatedSprite(textures, false);
this.pixie.animationSpeed = 0.12;
this.pixie.loop = true;
this.pixie.scale.set(1.35);
this.pixie.zIndex = Z_HERO;
this.stage.addChild(this.pixie);
}
this.startBtn = new Button({ this.startBtn = new Button({
text: "开始游戏", text: "开始游戏",
bg: this.assets["btn-bg"], bg: this.assets["btn-bg"],
pressBg: this.assets["btn-bg-press"], pressBg: this.assets["btn-bg-press"],
fontSize: 28, fontSize: 28,
padding: { x: 72, y: 36 }, padding: { x: 72, y: 36 },
clickCooldownMs: 450,
position: () => position: () =>
position.get("center", "bottom", { position.get("center", "bottom", {
y: INIT_LAYOUT.startCtaOffsetY, y: INIT_LAYOUT.startCtaOffsetY,
x: 0, x: 0,
}), }),
onClick: () => { onClick: () => {
// 当前版本仅保留开始界面,先保留按钮交互占位。 this.handleStartClick();
}, },
}); });
this.startBtn._comp.zIndex = Z_TEXT; this.startBtn._comp.zIndex = Z_TEXT;
@ -138,17 +115,12 @@ export default class InitScene extends BaseScene {
protected onSceneEnter(): void { protected onSceneEnter(): void {
window.addEventListener("resize", this.onResize); window.addEventListener("resize", this.onResize);
this.pixie?.gotoAndPlay(0);
} }
protected onSceneExit(): void { protected onSceneExit(): void {
window.removeEventListener("resize", this.onResize); window.removeEventListener("resize", this.onResize);
} }
update(_dt: number, _name: string, ticker: Ticker): void {
this.pixie?.update(ticker);
}
protected getSceneLoadingOverlayConfig() { protected getSceneLoadingOverlayConfig() {
return { return {
title: "初始化资源中", title: "初始化资源中",
@ -173,10 +145,15 @@ export default class InitScene extends BaseScene {
); );
} }
/* 中区:主视觉 */ }
if (this.pixie) {
this.pixie.anchor.set(0.5); private handleStartClick(): void {
this.pixie.position.copyFrom(position.get("center", H * L.heroYRatio)); if (!this.startBtn) {
return;
} }
this.startBtn.setDisabled(true);
this.startBtn.setText("进入中...");
void this.changeScene("welcome");
} }
} }

166
src/stages/page_welcome.ts

@ -0,0 +1,166 @@
import { Button } from "@/components/Button";
import Game from "@/core/Game";
import { SceneType } from "@/enums/SceneType";
import { BaseScene } from "@/scene/BaseScene";
import position from "@/utils/Position";
import { Container, Graphics, Text, TextStyle } from "pixi.js";
type GuideItem = {
title: string;
hint: string;
};
const GUIDE_ITEMS: GuideItem[] = [
{ title: "1. 宝箱记忆", hint: "翻牌配对,考验短时记忆" },
{ title: "2. 节奏连击", hint: "跟随节拍点击,连击越高分越高" },
{ title: "3. 躲避训练", hint: "滑动角色闪避障碍,坚持更久" },
];
export default class WelcomeScene extends BaseScene {
stage = new Container();
private game = Game.getInstance();
private titleText?: Text;
private descText?: Text;
private detailText?: Text;
private backBtn?: Button;
private itemRows: Container[] = [];
private readonly onResize = (): void => {
this.relayout();
};
constructor() {
super("welcome", SceneType.Normal);
}
protected async onSceneLayout(): Promise<void> {
this.stage.sortableChildren = true;
this.stage.eventMode = "passive";
this.titleText = new Text({
text: "子游戏引导",
style: new TextStyle({
fontFamily: "'Microsoft YaHei', 'PingFang SC', system-ui, sans-serif",
fontSize: 42,
fontWeight: "700",
fill: 0xffffff,
}),
});
this.titleText.anchor.set(0.5);
this.stage.addChild(this.titleText);
this.descText = new Text({
text: "选择一项查看玩法说明",
style: new TextStyle({
fontFamily: "'Microsoft YaHei', 'PingFang SC', system-ui, sans-serif",
fontSize: 20,
fill: 0xdbeafe,
}),
});
this.descText.anchor.set(0.5);
this.stage.addChild(this.descText);
this.detailText = new Text({
text: GUIDE_ITEMS[0].hint,
style: new TextStyle({
fontFamily: "'Microsoft YaHei', 'PingFang SC', system-ui, sans-serif",
fontSize: 18,
fill: 0xf8fafc,
}),
});
this.detailText.anchor.set(0.5);
this.stage.addChild(this.detailText);
this.itemRows = GUIDE_ITEMS.map((item, index) => this.createGuideRow(item, index));
for (const row of this.itemRows) {
this.stage.addChild(row);
}
this.highlightRow(0);
this.backBtn = new Button({
text: "返回开始",
fontSize: 24,
padding: { x: 64, y: 26 },
position: () => position.get("center", "bottom", { y: -56, x: 0 }),
onClick: () => {
this.backBtn?.setDisabled(true);
this.backBtn?.setText("返回中...");
void this.changeScene("init");
},
});
this.stage.addChild(this.backBtn.getView());
this.relayout();
}
protected onSceneEnter(): void {
window.addEventListener("resize", this.onResize);
}
protected onSceneExit(): void {
window.removeEventListener("resize", this.onResize);
}
private createGuideRow(item: GuideItem, index: number): Container {
const row = new Container();
row.eventMode = "static";
row.cursor = "pointer";
const bg = new Graphics();
bg.roundRect(0, 0, 560, 68, 14);
bg.fill({ color: 0x10223f, alpha: 0.92 });
row.addChild(bg);
const title = new Text({
text: item.title,
style: new TextStyle({
fontFamily: "'Microsoft YaHei', 'PingFang SC', system-ui, sans-serif",
fontSize: 24,
fill: 0xffffff,
}),
});
title.anchor.set(0.5);
title.position.set(280, 34);
row.addChild(title);
row.on("pointerdown", () => this.highlightRow(index));
return row;
}
private highlightRow(targetIndex: number): void {
this.itemRows.forEach((row, index) => {
const bg = row.children[0] as Graphics | undefined;
if (!bg) {
return;
}
bg.clear();
if (index === targetIndex) {
bg.roundRect(0, 0, 560, 68, 14);
bg.fill({ color: 0x1d4ed8, alpha: 0.95 });
} else {
bg.roundRect(0, 0, 560, 68, 14);
bg.fill({ color: 0x10223f, alpha: 0.92 });
}
});
if (this.detailText) {
this.detailText.text = GUIDE_ITEMS[targetIndex].hint;
}
}
private relayout(): void {
const { height: H } = this.game.getInfo();
this.titleText?.position.copyFrom(position.get("center", H * 0.12));
this.descText?.position.copyFrom(position.get("center", H * 0.2));
this.itemRows.forEach((row, index) => {
row.position.copyFrom(position.get("center", H * 0.32 + index * 90));
row.pivot.set(280, 34);
});
this.detailText?.position.copyFrom(position.get("center", H * 0.66));
this.backBtn?.updateView();
}
}

36
tests/scene-discovery.test.ts

@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { collectSceneDefinitions } from "../src/init/sceneDiscovery";
import { SceneType } from "../src/enums/SceneType";
class DummyScene {
name = "dummy";
type = SceneType.Normal;
stage = {};
}
describe("collectSceneDefinitions", () => {
it("collects every page_*.ts scene without manual whitelist", () => {
const modules = {
"./stages/page_init.ts": { default: DummyScene },
"./stages/_global/page_00_global.ts": { default: DummyScene },
"./stages/welcome/page_welcome.ts": { default: DummyScene },
};
const scenes = collectSceneDefinitions(modules);
expect(scenes.map((item) => item.name)).toEqual(["init", "00_global", "welcome"]);
});
it("skips files excluded by predicate", () => {
const modules = {
"./stages/page_init.ts": { default: DummyScene },
"./stages/_global/page_00_global.ts": { default: DummyScene },
"./stages/welcome/page_welcome.ts": { default: DummyScene },
};
const scenes = collectSceneDefinitions(modules, {
exclude: (name) => name.startsWith("00_"),
});
expect(scenes.map((item) => item.name)).toEqual(["init", "welcome"]);
});
});
Loading…
Cancel
Save