diff --git a/src/components/Button.ts b/src/components/Button.ts index d4468f8..eadc4b6 100644 --- a/src/components/Button.ts +++ b/src/components/Button.ts @@ -13,10 +13,11 @@ export interface ButtonOptions { bg?: Texture; pressBg?: Texture; position?: () => { x: number; y: number }; - onClick: () => void; + onClick: () => void | Promise; autoUpdate?: boolean; padding?: { x: number; y: number }; fontSize?: number; + clickCooldownMs?: number; } export class Button { @@ -26,12 +27,17 @@ export class Button { private bgRect?: Graphics; private bgNine?: NineSliceSprite; private padding: { x: number; y: number }; + private isPointerDown = false; + private isHovered = false; + private isDisabled = false; + private cooldownUntil = 0; constructor(opts: ButtonOptions) { this.config = { autoUpdate: true, padding: { x: 60, y: 40 }, fontSize: 30, + clickCooldownMs: 250, ...opts, }; this.padding = this.config.padding!; @@ -39,6 +45,7 @@ export class Button { this._container = new Container(); this._container.cursor = "pointer"; this._container.interactive = true; + this._container.eventMode = "static"; if (!this.config.bg) { this.createRectBackground(); @@ -70,6 +77,25 @@ export class Button { 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 { if (this._container.destroyed) return; @@ -130,39 +156,79 @@ export class Button { private setupEvents(): void { const onPointerDown = () => { - if (this.config.pressBg && this.bgNine && this.config.bg) { - this.bgNine.texture = this.config.pressBg; - } else { - this._container.alpha = 0.8; - this._container.scale.set(0.8, 0.8); - } + if (this.isDisabled) return; + this.isPointerDown = true; + this.applyStateVisual("pressed"); }; - const onPointerUp = () => { - if (this.config.pressBg && this.bgNine && this.config.bg) { - this.bgNine.texture = this.config.bg; - } else { - this._container.alpha = 1; - this._container.scale.set(1, 1); + const onPointerUp = async () => { + if (this.isDisabled) return; + const isClick = this.isPointerDown; + this.isPointerDown = false; + this.applyStateVisual(this.isHovered ? "hover" : "normal"); + 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 = () => { - if (this.config.pressBg && this.bgNine && this.config.bg) { - this.bgNine.texture = this.config.bg; - } else { - this._container.alpha = 1; - this._container.scale.set(1, 1); + this.isPointerDown = false; + this.applyStateVisual(this.isHovered ? "hover" : "normal"); + }; + + const onPointerOver = () => { + if (this.isDisabled) return; + this.isHovered = true; + if (!this.isPointerDown) { + this.applyStateVisual("hover"); } }; - this._container.on("mousedown", onPointerDown); - this._container.on("touchstart", onPointerDown); - this._container.on("mouseup", onPointerUp); - this._container.on("touchend", onPointerUp); - this._container.on("mouseupoutside", onPointerUpOutside); - this._container.on("touchendoutside", onPointerUpOutside); + const onPointerOut = () => { + this.isHovered = false; + this.isPointerDown = false; + if (this.isDisabled) return; + this.applyStateVisual("normal"); + }; + + 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; + } } } diff --git a/src/init.ts b/src/init.ts index 8c595fd..3ef07f1 100644 --- a/src/init.ts +++ b/src/init.ts @@ -6,6 +6,7 @@ import { logger } from "./core/Logger"; import { Container } from "pixi.js"; import { appRuntime } from "./kernel/AppRuntime"; import type { RuntimePlugin } from "./kernel/RuntimePlugin"; +import { collectSceneDefinitions } from "./init/sceneDiscovery"; const game = appRuntime.game; const sceneManager = appRuntime.sceneManager; @@ -28,33 +29,17 @@ const runtimeSceneEventsPlugin: RuntimePlugin = { }, }; -type Constructor = new (...args: unknown[]) => T; - export async function initApp(): Promise { appRuntime.use(runtimeSceneEventsPlugin); await assetManager.init(); await game.init(); 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 { - const mod = sceneModules[path]; - 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>; + const { name: fileSceneName, path, SceneCtor } = definition; const scene = new SceneCtor(); if (!scene || typeof scene !== "object" || !("stage" in scene)) { @@ -77,7 +62,7 @@ export async function initApp(): Promise { sceneManager.registerScene(legacy as IBaseScene); } catch (error) { - logger.error(`initApp: failed to load scene file ${path}`, error); + logger.error(`initApp: failed to instantiate scene from ${definition.path}`, error); } } diff --git a/src/init/sceneDiscovery.ts b/src/init/sceneDiscovery.ts new file mode 100644 index 0000000..2a6427f --- /dev/null +++ b/src/init/sceneDiscovery.ts @@ -0,0 +1,44 @@ +export type SceneModuleMap = Record; + +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; +} diff --git a/src/scene/SceneLoadingOverlay.ts b/src/scene/SceneLoadingOverlay.ts index 5bddf8e..4951cc4 100644 --- a/src/scene/SceneLoadingOverlay.ts +++ b/src/scene/SceneLoadingOverlay.ts @@ -3,6 +3,7 @@ import { Container, Graphics, Text, TextStyle } from "pixi.js"; export interface SceneLoadingOverlayConfig { enabled?: boolean; + showDelayMs?: number; minDisplayMs?: number; maskAlpha?: number; safeTop?: number; @@ -22,6 +23,7 @@ export interface SceneLoadingOverlayState { const DEFAULT_CONFIG: Required = { enabled: true, + showDelayMs: 2000, minDisplayMs: 350, maskAlpha: 0.55, safeTop: 24, diff --git a/src/scene/SceneManager.ts b/src/scene/SceneManager.ts index 7ba0140..e77f40f 100644 --- a/src/scene/SceneManager.ts +++ b/src/scene/SceneManager.ts @@ -327,12 +327,25 @@ class SceneManager { const base = scene instanceof BaseScene ? scene : undefined; const config = base?.getLoadingOverlayConfig?.(); 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 | undefined; if (enableOverlay) { - this.loadingOverlay.show(config); - base!._loadingReporter = (state) => { - this.loadingOverlay.update(state); - }; + if (base) { + base._loadingReporter = (state) => { + pendingState = state; + if (overlayVisible) { + this.loadingOverlay.update(state); + } + }; + } + overlayTimer = setTimeout(() => { + overlayVisible = true; + this.loadingOverlay.show(config); + this.loadingOverlay.update(pendingState); + }, showDelayMs); } try { @@ -345,11 +358,19 @@ class SceneManager { scene._layoutDone = true; } await scene.onLoad?.(); - if (enableOverlay) { + if (overlayTimer) { + clearTimeout(overlayTimer); + overlayTimer = undefined; + } + if (enableOverlay && overlayVisible) { await this.loadingOverlay.hide(); } } catch (error) { - if (enableOverlay) { + if (overlayTimer) { + clearTimeout(overlayTimer); + overlayTimer = undefined; + } + if (enableOverlay && overlayVisible) { await this.loadingOverlay.hide(); } throw error; diff --git a/src/stages/page_init.ts b/src/stages/page_init.ts index 695e5a6..a3d03db 100644 --- a/src/stages/page_init.ts +++ b/src/stages/page_init.ts @@ -5,21 +5,16 @@ import { BaseScene } from "@/scene/BaseScene"; import { SceneType } from "@/enums/SceneType"; import position from "@/utils/Position"; import { - AnimatedSprite, Container, - Rectangle, Text, TextStyle, Texture, - Ticker, } from "pixi.js"; -const Z_HERO = 2; const Z_TEXT = 3; const INIT_LAYOUT = { titleYRatio: 0.12, subtitleYRatio: 0.2, - heroYRatio: 0.46, startCtaOffsetY: -110, } as const; @@ -30,7 +25,6 @@ export default class InitScene extends BaseScene { private titleText?: Text; private subtitleText?: Text; - private pixie?: AnimatedSprite; private startBtn?: Button; private readonly onResize = (): void => { @@ -97,37 +91,20 @@ export default class InitScene extends BaseScene { this.subtitleText.zIndex = Z_TEXT; 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({ text: "开始游戏", bg: this.assets["btn-bg"], pressBg: this.assets["btn-bg-press"], fontSize: 28, padding: { x: 72, y: 36 }, + clickCooldownMs: 450, position: () => position.get("center", "bottom", { y: INIT_LAYOUT.startCtaOffsetY, x: 0, }), onClick: () => { - // 当前版本仅保留开始界面,先保留按钮交互占位。 + this.handleStartClick(); }, }); this.startBtn._comp.zIndex = Z_TEXT; @@ -138,17 +115,12 @@ export default class InitScene extends BaseScene { protected onSceneEnter(): void { window.addEventListener("resize", this.onResize); - this.pixie?.gotoAndPlay(0); } protected onSceneExit(): void { window.removeEventListener("resize", this.onResize); } - update(_dt: number, _name: string, ticker: Ticker): void { - this.pixie?.update(ticker); - } - protected getSceneLoadingOverlayConfig() { return { title: "初始化资源中", @@ -173,10 +145,15 @@ export default class InitScene extends BaseScene { ); } - /* 中区:主视觉 */ - if (this.pixie) { - this.pixie.anchor.set(0.5); - this.pixie.position.copyFrom(position.get("center", H * L.heroYRatio)); + } + + private handleStartClick(): void { + if (!this.startBtn) { + return; } + + this.startBtn.setDisabled(true); + this.startBtn.setText("进入中..."); + void this.changeScene("welcome"); } } diff --git a/src/stages/page_welcome.ts b/src/stages/page_welcome.ts new file mode 100644 index 0000000..9080a13 --- /dev/null +++ b/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 { + 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(); + } +} diff --git a/tests/scene-discovery.test.ts b/tests/scene-discovery.test.ts new file mode 100644 index 0000000..c6301d2 --- /dev/null +++ b/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"]); + }); +});