You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

41 KiB

PixiJS 游戏框架重构实现计划

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 对现有的 PixiJS 游戏开发框架进行全面架构重构,实现清晰的分层模块化设计,提升开发体验。

Architecture: 采用分层架构,从下到上依次是 Core 核心层、Scene 场景层、Component 组件层、Utils 工具层,上层依赖下层。保留自动场景文件发现的特性。每个模块职责单一,接口清晰,完整 TypeScript 类型支持。

Tech Stack: PixiJS v8, TypeScript, @pixi/sound, @tweenjs/tween.js, Vite


文件结构

新文件将按以下结构创建:

src/
├── core/                    # Core Layer - 核心层
│   ├── Game.ts             # 主应用类,维护 renderer/stage/ticker
│   ├── EventBus.ts         # 事件总线
│   ├── AssetManager.ts     # 资源管理器,带引用计数
│   └── Logger.ts           # 日志工具
├── scene/                  # Scene Layer - 场景层
│   ├── SceneManager.ts     # 场景管理器
│   ├── BaseScene.ts        # 场景抽象基类
│   └── types.ts            # 场景相关类型定义
├── components/             # Component Layer - 组件层
│   ├── Button.ts           # 按钮组件(重构)
│   ├── Label.ts            # 文本标签组件(新增)
│   └── Panel.ts            # 面板容器组件(新增)
├── utils/                  # Utils Layer - 工具层
│   ├── Position.ts         # 定位对齐工具
│   ├── Tween.ts            # 动画补间封装
│   ├── Sound.ts            # 音频管理
│   ├── Storage.ts          # 本地存储封装
│   └── MathUtils.ts        # 常用数学工具
├── enums/                  # 枚举定义
│   ├── SceneType.ts        # 场景类型枚举
│   └── Orientation.ts      # 屏幕方向枚举
├── types/                 # 全局类型
│   └── index.d.ts         # 全局类型定义
└── scenes/                # Game Content - 游戏场景
    └── **/page_*.ts       # 自动发现场景文件

任务分解

Task 1: 创建枚举定义

Files:

  • Create: src/enums/SceneType.ts

  • Create: src/enums/Orientation.ts

  • Step 1: 创建 SceneType 枚举

export enum SceneType {
  Normal = 0,    // 普通场景 - 退出时销毁
  Resident = 1,  // 常驻场景 - 只隐藏不销毁
}
  • Step 2: 创建 Orientation 枚举
export enum Orientation {
  Landscape = "landscape",
  Portrait = "portrait",
}
  • Step 3: Commit
git add src/enums/*.ts
git commit -m "feat: add enum definitions for SceneType and Orientation"

Task 2: 创建全局类型定义

Files:

  • Create: src/types/index.d.ts

  • Step 1: 创建全局类型定义文件

// 全局构造函数类型
declare type Constructor<T> = new (...args: any[]) => T;

// 通用回调类型
declare type Callback<T = void> = () => T;
declare type Callback1<T, R = void> = (arg: T) => R;
  • Step 2: Commit
git add src/types/index.d.ts
git commit -m "feat: add global type definitions"

Task 3: 实现 Core 层 - Logger

Files:

  • Create: src/core/Logger.ts

  • Step 1: 创建 Logger 类

type LogLevel = "debug" | "info" | "warn" | "error" | "none";

class Logger {
  private level: LogLevel = "debug";

  setLevel(level: LogLevel): void {
    this.level = level;
  }

  debug(...args: any[]): void {
    if (this.shouldLog("debug")) {
      console.debug(...args);
    }
  }

  info(...args: any[]): void {
    if (this.shouldLog("info")) {
      console.info(...args);
    }
  }

  warn(...args: any[]): void {
    if (this.shouldLog("warn")) {
      console.warn(...args);
    }
  }

  error(...args: any[]): void {
    if (this.shouldLog("error")) {
      console.error(...args);
    }
  }

  private shouldLog(level: LogLevel): boolean {
    const levels: LogLevel[] = ["debug", "info", "warn", "error", "none"];
    return levels.indexOf(level) >= levels.indexOf(this.level);
  }
}

export const logger = new Logger();
export default logger;
  • Step 2: Commit
git add src/core/Logger.ts
git commit -m "feat: add Logger core module"

Task 4: 实现 Core 层 - EventBus

Files:

  • Create: src/core/EventBus.ts

  • Step 1: 创建 EventBus 类

type EventCallback = (...args: any[]) => void;

class EventBus {
  private events: Map<string, Set<EventCallback>> = new Map();

  on(event: string, callback: EventCallback): () => void {
    if (!this.events.has(event)) {
      this.events.set(event, new Set());
    }
    this.events.get(event)!.add(callback);

    // 返回取消订阅函数
    return () => this.off(event, callback);
  }

  off(event: string, callback: EventCallback): void {
    const callbacks = this.events.get(event);
    if (callbacks) {
      callbacks.delete(callback);
      if (callbacks.size === 0) {
        this.events.delete(event);
      }
    }
  }

  once(event: string, callback: EventCallback): () => void {
    const onceCallback: EventCallback = (...args) => {
      callback(...args);
      this.off(event, onceCallback);
    };
    return this.on(event, onceCallback);
  }

  emit(event: string, ...args: any[]): void {
    const callbacks = this.events.get(event);
    if (callbacks) {
      callbacks.forEach(cb => {
        try {
          cb(...args);
        } catch (e) {
          console.error(`EventBus: error in event "${event}"`, e);
        }
      });
    }
  }

  clear(): void {
    this.events.clear();
  }

  getEventCount(): number {
    return this.events.size;
  }
}

export const eventBus = new EventBus();
export default eventBus;
  • Step 2: Commit
git add src/core/EventBus.ts
git commit -m "feat: add EventBus core module"

Task 5: 实现 Core 层 - AssetManager

Files:

  • Create: src/core/AssetManager.ts

  • Step 1: 创建 AssetManager 类

import { Assets, type AssetsBundle } from "pixi.js";
import { logger } from "./Logger";

class AssetManager {
  private referenceCounts: Map<string, number> = new Map();

  async init(): Promise<void> {
    await Assets.init({ manifest: "/manifest.json" });
    logger.debug("AssetManager initialized");
  }

  async loadBundle(
    name: string,
    onProgress?: (progress: number) => void
  ): Promise<AssetsBundle> {
    // 已经加载过,增加引用计数
    if (this.referenceCounts.has(name)) {
      const count = this.referenceCounts.get(name)! + 1;
      this.referenceCounts.set(name, count);
      logger.debug(`AssetManager: reuse bundle "${name}", refCount = ${count}`);
      onProgress?.(1);
      return Assets.getBundle(name);
    }

    // 首次加载
    logger.debug(`AssetManager: loading bundle "${name}"`);
    const bundle = await Assets.loadBundle(name, onProgress);
    this.referenceCounts.set(name, 1);
    return bundle;
  }

  async unloadBundle(name: string): Promise<void> {
    if (!this.referenceCounts.has(name)) {
      logger.warn(`AssetManager: unloading unloaded bundle "${name}"`);
      return;
    }

    const count = this.referenceCounts.get(name)! - 1;
    this.referenceCounts.set(name, count);

    if (count <= 0) {
      logger.debug(`AssetManager: unloading bundle "${name}"`);
      await Assets.unloadBundle(name);
      this.referenceCounts.delete(name);
    } else {
      logger.debug(`AssetManager: decrease refCount for "${name}" to ${count}`);
    }
  }

  isLoaded(name: string): boolean {
    return this.referenceCounts.has(name) && this.referenceCounts.get(name)! > 0;
  }

  getRefCount(name: string): number {
    return this.referenceCounts.get(name) ?? 0;
  }

  clearAll(): void {
    this.referenceCounts.clear();
  }
}

export const assetManager = new AssetManager();
export default assetManager;
  • Step 2: Commit
git add src/core/AssetManager.ts
git commit -m "feat: add AssetManager core module with ref counting"

Task 6: 实现 Core 层 - Game 主类

Files:

  • Create: src/core/Game.ts

  • Step 1: 创建 Game 类,维护 PixiJS 核心对象

import {
  autoDetectRenderer,
  Container,
  Renderer,
  Ticker,
} from "pixi.js";
import { Orientation } from "@/enums/Orientation";
import { initDevtools } from "@pixi/devtools";
import { logger } from "./Logger";

class Game {
  private static instance: Game;
  private _stage: Container;
  private _renderer: Renderer;
  private _ticker: Ticker;
  private orientation: Orientation = Orientation.Portrait;

  public designWidth: number = 750;

  public info = {
    width: 0,
    height: 0,
  };

  private constructor() {}

  static getInstance(): Game {
    if (!Game.instance) {
      Game.instance = new Game();
    }
    return Game.instance;
  }

  public get renderer(): Renderer {
    return this._renderer;
  }

  public get stage(): Container {
    return this._stage;
  }

  public get ticker(): Ticker {
    return this._ticker;
  }

  async init(): Promise<void> {
    const screenWidth = document.documentElement.clientWidth;
    const screenHeight = document.documentElement.clientHeight;

    this._stage = new Container();
    this._stage.label = "root";

    this._renderer = await autoDetectRenderer({
      autoDensity: true,
      width: screenWidth,
      height: screenHeight,
      antialias: true,
      backgroundAlpha: 255,
      resolution: 2,
      backgroundColor: 0x1d9ce0,
    });

    this.renderer.resize(screenWidth, screenHeight);
    document.body.appendChild(this.renderer.canvas);

    window.addEventListener("resize", () => {
      this.updateView();
    });

    this._ticker = Ticker.shared;
    this._ticker.autoStart = true;

    if (import.meta.env.DEV) {
      initDevtools({ stage: this._stage, renderer: this._renderer });
    }

    this.updateView();
    logger.info("Game initialized", { screenWidth, screenHeight });
  }

  getInfo(): { width: number; height: number } {
    return { ...this.info };
  }

  setOrientation(orientation: Orientation): void {
    this.orientation = orientation;
    this.updateView();
  }

  private detectCurrentOrientation(): Orientation {
    const isLandscape = window.innerWidth > window.innerHeight;
    return isLandscape ? Orientation.Landscape : Orientation.Portrait;
  }

  updateView(): void {
    const clientWidth = document.documentElement.clientWidth;
    const clientHeight = document.documentElement.clientHeight;

    this.renderer.resize(clientWidth, clientHeight);

    const currentOrientation = this.detectCurrentOrientation();

    let offsetWidth = clientWidth;
    let offsetHeight = clientHeight;

    if (this.orientation === Orientation.Landscape) {
      if (currentOrientation === Orientation.Landscape) {
        this._stage.rotation = 0;
        this._stage.y = 0;
      } else {
        this._stage.rotation = -Math.PI / 2;
        this._stage.y = clientHeight;
      }
      const scaleRatio = clientWidth / this.designWidth;
      this._stage.scale.set(scaleRatio, scaleRatio);
      this.info.width = offsetWidth / scaleRatio;
      this.info.height = offsetHeight / scaleRatio;
    } else {
      if (currentOrientation === Orientation.Landscape) {
        this._stage.rotation = 0;
        this._stage.y = 0;
        const scaleRatio = offsetWidth / this.designWidth;
        this._stage.scale.set(scaleRatio, scaleRatio);
        this.info.width = offsetHeight / scaleRatio;
        this.info.height = offsetWidth / scaleRatio;
      } else {
        this._stage.rotation = -Math.PI / 2;
        this._stage.y = offsetHeight;
        const scaleRatio = offsetWidth / this.designWidth;
        this._stage.scale.set(scaleRatio, scaleRatio);
        this.info.width = offsetHeight / scaleRatio;
        this.info.height = offsetWidth / scaleRatio;
      }
    }

    this.render();
  }

  render(): void {
    this.renderer.render(this._stage);
  }
}

export default Game;
  • Step 2: Commit
git add src/core/Game.ts
git commit -m "feat: add Game core class with orientation handling"

Task 7: 定义 Scene 层类型和 BaseScene

Files:

  • Create: src/scene/types.ts

  • Create: src/scene/BaseScene.ts

  • Step 1: 创建 types.ts

import { Container } from "pixi.js";
import { SceneType } from "@/enums/SceneType";

export interface SceneConfig {
  name: string;
  type: SceneType;
  stage: Container;
}

export interface SceneLifeCycle {
  /** 加载资源包 */
  loadBundle?(): Promise<void>;
  /** 卸载资源包 */
  unLoadBundle?(): Promise<void>;
  /** 创建布局 */
  layout?(): void | Promise<void>;
  /** 场景加载完成 */
  onLoad?(): void | Promise<void>;
  /** 场景即将卸载 */
  onUnLoad?(): void | Promise<void>;
  /** 每帧更新 */
  update?(dt: number, ticker: Ticker): void;
  /** 更新后处理 */
  lateUpdate?(dt: number, ticker: Ticker): void;
}

export interface IBaseScene extends SceneLifeCycle {
  /** 场景容器 */
  stage: Container;
  /** 场景名称 */
  readonly name: string;
  /** 场景类型 */
  readonly type: SceneType;
  /** 是否已加载资源 */
  _assetsLoaded: boolean;
  /** 是否已布局 */
  _layoutDone: boolean;
  /** 改变场景 */
  changeScene(name: string, options?: { isHolderLast?: boolean }): void;
}
  • Step 2: 创建 BaseScene 抽象基类
import { Container, Ticker } from "pixi.js";
import { SceneType } from "@/enums/SceneType";
import type { IBaseScene, SceneLifeCycle } from "./types";

export abstract class BaseScene implements IBaseScene {
  abstract stage: Container;
  readonly name: string;
  readonly type: SceneType;

  _assetsLoaded: boolean = false;
  _layoutDone: boolean = false;

  constructor(name: string, type: SceneType = SceneType.Normal) {
    this.name = name;
    this.type = type;
  }

  // 用于注入场景切换方法
  changeScene(name: string, options?: { isHolderLast?: boolean }): void {
    // 由 SceneManager 注入实现
    throw new Error("changeScene not injected by SceneManager");
  }

  // 生命周期方法 - 默认空实现
  async loadBundle(): Promise<void> {}
  async unLoadBundle(): Promise<void> {}
  layout(): void | Promise<void> {}
  onLoad(): void | Promise<void> {}
  onUnLoad(): void | Promise<void> {}
  update(dt: number, ticker: Ticker): void {}
  lateUpdate(dt: number, ticker: Ticker): void {}
}

export default BaseScene;
  • Step 3: Commit
git add src/scene/types.ts src/scene/BaseScene.ts
git commit -m "feat: add Scene types and BaseScene abstract class"

Task 8: 实现 Scene 层 - SceneManager

Files:

  • Create: src/scene/SceneManager.ts

  • Step 1: 创建 SceneManager 类

import { Container } from "pixi.js";
import Game from "@/core/Game";
import { logger } from "@/core/Logger";
import { assetManager } from "@/core/AssetManager";
import { SceneType } from "@/enums/SceneType";
import { BaseScene } from "./BaseScene";
import type { SceneConfig, IBaseScene } from "./types";

type StageChangeCallback = (
  current: IBaseScene,
  previous: IBaseScene | undefined
) => Promise<void> | void;

class SceneManager {
  private static instance: SceneManager;
  private game: Game;
  private scenes: Map<string, IBaseScene> = new Map();
  private _currentScene: IBaseScene | null = null;
  private changeCallbacks: StageChangeCallback[] = [];

  private constructor() {
    this.game = Game.getInstance();
  }

  static getInstance(): SceneManager {
    if (!SceneManager.instance) {
      SceneManager.instance = new SceneManager();
    }
    return SceneManager.instance;
  }

  get currentScene(): IBaseScene | null {
    return this._currentScene;
  }

  /** 监听场景变化 */
  onStageChange(cb: StageChangeCallback): () => void {
    this.changeCallbacks.push(cb);
    return () => {
      const index = this.changeCallbacks.indexOf(cb);
      if (index >= 0) {
        this.changeCallbacks.splice(index, 1);
      }
    };
  }

  /** 触发场景变化回调 */
  private async emitStageChange(
    current: IBaseScene,
    previous: IBaseScene | undefined
  ): Promise<void> {
    for (const cb of this.changeCallbacks) {
      await cb(current, previous);
    }
  }

  /** 注册场景 */
  registerScene(scene: IBaseScene): void {
    if (this.scenes.has(scene.name)) {
      logger.warn(`SceneManager: scene "${scene.name}" already registered`);
    }
    this.scenes.set(scene.name, scene);

    // 注入 changeScene 方法
    scene.changeScene = (name: string, options) => {
      this.changeScene(name, options);
    };

    if (scene.type === SceneType.Resident) {
      this.game.stage.addChild(scene.stage);
      logger.debug(`SceneManager: registered resident scene "${scene.name}"`);
    } else {
      logger.debug(`SceneManager: registered normal scene "${scene.name}"`);
    }
  }

  /** 初始化入口场景 */
  initScene(name: string): void {
    const scene = this.getSceneOrThrow(name);
    this._currentScene = scene;

    if (scene.type === SceneType.Normal) {
      this.game.stage.addChild(scene.stage);
    }

    this.emitStageChange(scene, undefined);
    logger.debug(`SceneManager: initialized scene "${name}"`);
  }

  /** 切换场景 */
  async changeScene(name: string, options?: { isHolderLast?: boolean }): Promise<void> {
    const previous = this._currentScene;
    if (!previous) {
      throw new Error(`SceneManager: no current scene, call initScene first`);
    }

    const target = this.getSceneOrThrow(name);

    // 处理前一个场景
    if (previous.type === SceneType.Normal) {
      if (options?.isHolderLast) {
        previous.stage.visible = false;
        // @ts-ignore 标记保留
        previous.stage._isHolderLast = true;
      } else {
        // 销毁场景
        previous.stage.destroy({ children: true });
        this.game.stage.removeChild(previous.stage);
        // 卸载资源
        if (previous._assetsLoaded) {
          await previous.unLoadBundle?.();
          previous._assetsLoaded = false;
        }
        await previous.onUnLoad?.();
        this.scenes.delete(previous.name);
      }
    } else if (previous.type === SceneType.Resident) {
      previous.stage.visible = false;
      await previous.onUnLoad?.();
    }

    this._currentScene = target;

    // 处理目标场景
    if (target.type === SceneType.Normal) {
      // @ts-ignore
      if (target.stage._isHolderLast) {
        target.stage.visible = true;
      } else {
        this.game.stage.addChild(target.stage);
      }
    } else if (target.type === SceneType.Resident) {
      target.stage.visible = true;
    }

    // 生命周期
    if (!target._assetsLoaded) {
      await target.loadBundle?.();
      target._assetsLoaded = true;
    }

    if (!target._layoutDone || target.stage !== this.getSceneOrThrow(name).stage) {
      await target.layout?.();
      target._layoutDone = true;
    }

    await target.onLoad?.();

    // 触发回调
    await this.emitStageChange(target, previous);

    logger.debug(`SceneManager: changed from "${previous?.name}" to "${name}"`);
  }

  /** 获取已注册场景 */
  getScene(name: string): IBaseScene | undefined {
    return this.scenes.get(name);
  }

  getSceneOrThrow(name: string): IBaseScene {
    const scene = this.scenes.get(name);
    if (!scene) {
      throw new Error(`SceneManager: scene "${name}" not registered`);
    }
    return scene;
  }

  /** 确保场景存在,不存在则创建容器 */
  ensureSceneExists(name: string, type: SceneType = SceneType.Normal): Container {
    if (this.scenes.has(name)) {
      return this.getSceneOrThrow(name).stage;
    }

    const container = new Container();
    container.label = name;
    // 动态创建一个简单场景
    // 这个功能主要用于兼容旧的代码风格
    class DynamicScene extends BaseScene {
      stage = container;
      name = name;
      type = type;
    }

    const scene = new DynamicScene();
    this.registerScene(scene);
    return container;
  }

  hasScene(name: string): boolean {
    return this.scenes.has(name);
  }
}

export default SceneManager;
export const sceneManager = SceneManager.getInstance();
  • Step 2: Commit
git add src/scene/SceneManager.ts
git commit -m "feat: add SceneManager with full lifecycle management"

Task 9: 创建入口文件自动发现场景 (原有机制升级)

Files:

  • Create: src/init.ts

  • Step 1: 创建场景自动发现初始化

import Game from "./core/Game";
import SceneManager from "./scene/SceneManager";
import { BaseScene } from "./scene/BaseScene";
import { SceneType } from "./enums/SceneType";
import { assetManager } from "./core/AssetManager";
import { logger } from "./core/Logger";
import eventBus from "./core/EventBus";

const game = Game.getInstance();
const sceneManager = SceneManager.getInstance();

export async function initApp(): Promise<void> {
  await assetManager.init();
  await game.init();

  // 自动导入所有场景文件 src/scenes/**/page_*.ts
  const sceneModules = import.meta.glob("./scenes/**/page_*.ts", { eager: true });

  for (const path in sceneModules) {
    const mod = sceneModules[path];
    // 文件名匹配提取场景名称
    const match = path.match(/page_(.*?)\.ts$/);
    if (!match) continue;
    const sceneName = match[1];

    const sceneClass = (mod as { default: Constructor<BaseScene> }).default;
    const scene = new sceneClass();

    // 如果模块默认导出不是场景构造函数,尝试其他格式(兼容旧定义)
    if (!scene || typeof scene !== "object" || !("stage" in scene)) {
      logger.warn(`initApp: invalid scene file ${path}, skipping`);
      continue;
    }

    sceneManager.registerScene(scene);
  }

  // 启动更新循环
  game.ticker.add((ticker) => {
    const dt = ticker.deltaTime;
    const current = sceneManager.currentScene;

    // 更新常驻场景
    sceneManager["scenes"].forEach((scene) => {
      if (scene.type === SceneType.Resident && scene.stage.visible) {
        scene.update?.(dt, ticker);
      }
    });

    // 更新当前场景
    if (current) {
      current.update?.(dt, ticker);
    }

    game.render();

    // lateUpdate
    sceneManager["scenes"].forEach((scene) => {
      if (scene.type === SceneType.Resident && scene.stage.visible && scene.lateUpdate) {
        scene.lateUpdate(dt, ticker);
      }
    });

    if (current?.lateUpdate) {
      current.lateUpdate(dt, ticker);
    }
  });

  // 处理常驻场景初始化
  const residentScenes: BaseScene[] = [];
  sceneManager["scenes"].forEach((scene) => {
    if (scene.type === SceneType.Resident) {
      residentScenes.push(scene);
    }
  });

  (async () => {
    for (const scene of residentScenes) {
      if (!scene._assetsLoaded) {
        await scene.loadBundle?.();
        scene._assetsLoaded = true;
      }
      if (!scene._layoutDone) {
        await scene.layout?.();
        scene._layoutDone = true;
      }
      await scene.onLoad?.();
    }
  })();

  // 场景变化监听 - 日志
  sceneManager.onStageChange((current) => {
    logger.debug("Scene changed to", current.name);
  });

  logger.info("App initialized");
}

export { game, sceneManager, eventBus, assetManager, logger };
  • Step 2: Commit
git add src/init.ts
git commit -m "feat: add app init with auto scene discovery"

Task 10: 实现 Utils 层 - Position

Files:

  • Create: src/utils/Position.ts

  • Step 1: 创建 Position 定位工具

import Game from "@/core/Game";

type HorizontalAlign = "left" | "center" | "right" | number;
type VerticalAlign = "top" | "center" | "bottom" | number;

interface PositionOptions {
  x?: number;
  y?: number;
}

class Position {
  private game: Game;

  constructor() {
    this.game = Game.getInstance();
  }

  get(
    hAlign: HorizontalAlign,
    vAlign: VerticalAlign,
    options?: PositionOptions
  ): { x: number; y: number } {
    const { width, height } = this.game.getInfo();

    let x: number;
    let y: number;

    // 水平对齐
    if (hAlign === "left") {
      x = 0;
    } else if (hAlign === "center") {
      x = width / 2;
    } else if (hAlign === "right") {
      x = width;
    } else {
      x = hAlign;
    }

    // 垂直对齐
    if (vAlign === "top") {
      y = 0;
    } else if (vAlign === "center") {
      y = height / 2;
    } else if (vAlign === "bottom") {
      y = height;
    } else {
      y = vAlign;
    }

    // 添加偏移
    if (options) {
      x += options.x ?? 0;
      y += options.y ?? 0;
    }

    return { x, y };
  }

  center(options?: PositionOptions): { x: number; y: number } {
    return this.get("center", "center", options);
  }

  centerX(y: VerticalAlign, options?: PositionOptions): { x: number; y: number } {
    return this.get("center", y, options);
  }

  centerY(x: HorizontalAlign, options?: PositionOptions): { x: number; y: number } {
    return this.get(x, "center", options);
  }
}

export const position = new Position();
export default position;
  • Step 2: Commit
git add src/utils/Position.ts
git commit -m "feat: add Position utility for alignment"

Task 11: 实现 Utils 层 - Tween

Files:

  • Create: src/utils/Tween.ts

  • Step 1: 创建 Tween 封装

import { Group, Tween as TweenJS } from "@tweenjs/tween.js";

class TweenManager {
  private group: Group;

  constructor() {
    this.group = new Group();
    // 在 ticker 中更新
    // 由 Game 更新循环自动处理
  }

  /**
   * 创建一个新的 Tween
   * @example
   * const tween = tweenManager.create({ x: 0, y: 0 })
   *   .to({ x: 100, y: 100 }, 1000)
   *   .onUpdate(obj => { obj.x })
   *   .start();
   */
  create<T extends object>(obj: T): TweenJS<T> {
    const tween = new TweenJS(obj, this.group);
    return tween;
  }

  update(time?: number): void {
    this.group.update(time);
  }

  removeAll(): void {
    this.group.removeAll();
  }
}

export const tweenManager = new TweenManager();
export default tweenManager;

// 便捷方法
export function tweenFromTo<T>(
  from: T,
  to: Partial<T>,
  duration: number,
  onUpdate: (obj: T) => void,
  onComplete?: () => void
): TweenJS<T> {
  const t = tweenManager.create(from).to(to, duration).onUpdate(onUpdate);
  if (onComplete) {
    t.onComplete(onComplete);
  }
  return t.start();
}
  • Step 2: 更新 Game ticker 以更新 tween

需要修改 src/core/Game.tsinit() 中添加:

// Add after ticker creation:
import { tweenManager } from "@/utils/Tween";
...
this._ticker.add(() => {
  tweenManager.update();
});

(This modification will be done in this step)

  • Step 3: Commit
git add src/utils/Tween.ts src/core/Game.ts
git commit -m "feat: add Tween animation utility"

Task 12: 实现 Utils 层 - Sound

Files:

  • Create: src/utils/Sound.ts

  • Step 1: 创建 Sound 管理

import { sound } from "@pixi/sound";
import { logger } from "@/core/Logger";

class SoundManager {
  private volume: number = 1;
  private muted: boolean = false;

  constructor() {
    sound.volume = this.volume;
  }

  add(name: string, url: string, options?: { singleInstance?: boolean; autoPlay?: boolean }): void {
    sound.add(name, {
      url,
      singleInstance: options?.singleInstance ?? true,
      autoPlay: options?.autoPlay ?? false,
    });
    logger.debug(`Sound: added "${name}"`);
  }

  play(name: string): void {
    if (this.muted) return;
    sound.play(name);
  }

  stop(name: string): void {
    sound.stop(name);
  }

  pause(name: string): void {
    sound.pause(name);
  }

  pauseAll(): void {
    sound.pauseAll();
  }

  resumeAll(): void {
    if (!this.muted) {
      sound.resumeAll();
    }
  }

  setVolume(volume: number): void {
    this.volume = Math.max(0, Math.min(1, volume));
    sound.volume = this.volume;
  }

  getVolume(): number {
    return this.volume;
  }

  mute(): void {
    this.muted = true;
    sound.muteAll();
  }

  unmute(): void {
    this.muted = false;
    sound.unmuteAll();
  }

  isMuted(): boolean {
    return this.muted;
  }

  exists(name: string): boolean {
    return sound.exists(name);
  }

  clear(): void {
    sound.removeAll();
  }
}

export const soundManager = new SoundManager();
export default soundManager;
  • Step 2: Commit
git add src/utils/Sound.ts
git commit -m "feat: add SoundManager utility"

Task 13: 实现 Utils 层 - Storage 和 MathUtils

Files:

  • Create: src/utils/Storage.ts

  • Create: src/utils/MathUtils.ts

  • Step 1: 创建 Storage

class Storage {
  private prefix: string;

  constructor(prefix: string = "game_") {
    this.prefix = prefix;
  }

  get<T>(key: string, defaultValue: T): T {
    try {
      const item = localStorage.getItem(this.prefix + key);
      if (item === null) {
        return defaultValue;
      }
      return JSON.parse(item) as T;
    } catch {
      return defaultValue;
    }
  }

  set(key: string, value: any): void {
    try {
      localStorage.setItem(this.prefix + key, JSON.stringify(value));
    } catch {
      console.warn("Storage: failed to save to localStorage");
    }
  }

  remove(key: string): void {
    localStorage.removeItem(this.prefix + key);
  }

  clear(): void {
    const keysToRemove: string[] = [];
    for (let i = 0; i < localStorage.length; i++) {
      const key = localStorage.key(i);
      if (key?.startsWith(this.prefix)) {
        keysToRemove.push(key);
      }
    }
    keysToRemove.forEach(key => localStorage.removeItem(key));
  }

  has(key: string): boolean {
    return localStorage.getItem(this.prefix + key) !== null;
  }
}

export const storage = new Storage();
export default storage;
  • Step 2: 创建 MathUtils
export function clamp(value: number, min: number, max: number): number {
  return Math.min(max, Math.max(min, value));
}

export function lerp(a: number, b: number, t: number): number {
  return a + (b - a) * t;
}

export function random(min: number, max: number): number {
  return min + Math.random() * (max - min);
}

export function randomInt(min: number, max: number): number {
  return Math.floor(random(min, max + 1));
}

export function randomChoice<T>(array: T[]): T {
  return array[randomInt(0, array.length - 1)];
}

export function distance(x1: number, y1: number, x2: number, y2: number): number {
  const dx = x2 - x1;
  const dy = y2 - y1;
  return Math.sqrt(dx * dx + dy * dy);
}

export function degToRad(deg: number): number {
  return (deg * Math.PI) / 180;
}

export function radToDeg(rad: number): number {
  return (rad * 180) / Math.PI;
}

const MathUtils = {
  clamp,
  lerp,
  random,
  randomInt,
  randomChoice,
  distance,
  degToRad,
  radToDeg,
};

export default MathUtils;
  • Step 3: Commit
git add src/utils/Storage.ts src/utils/MathUtils.ts
git commit -m "feat: add Storage and MathUtils utilities"

Task 14: 重构组件层 - Button

Files:

  • Create: src/components/Button.ts (重构现有)

  • Step 1: 重构 Button 组件

import {
  Container,
  Text,
  Graphics,
  TextStyle,
  NineSliceSprite,
  Ticker,
  Texture,
} from "pixi.js";

export interface ButtonOptions {
  text: string;
  bg?: Texture;
  pressBg?: Texture;
  position?: () => { x: number; y: number };
  onClick: () => void;
  autoUpdate?: boolean;
  padding?: { x: number; y: number };
  fontSize?: number;
}

export class Button {
  private config: ButtonOptions;
  private _container: Container;
  private textObj: Text;
  private bgRect?: Graphics;
  private bgNine?: NineSliceSprite;
  private padding: { x: number; y: number };

  constructor(opts: ButtonOptions) {
    this.config = {
      autoUpdate: true,
      padding: { x: 60, y: 40 },
      fontSize: 30,
      ...opts,
    };
    this.padding = this.config.padding!;

    this._container = new Container();
    this._container.cursor = "pointer";
    this._container.interactive = true;

    if (!this.config.bg) {
      this.createRectBackground();
    } else {
      this.createNineSliceBackground();
    }

    this.createText();
    this._container.addChild(this.textObj);

    this.setupEvents();

    if (this.config.autoUpdate) {
      const update = () => this.updateView();
      Ticker.shared.add(update);
      this._container.on("destroyed", () => {
        Ticker.shared.remove(update);
      });
    }

    this.updateView();
  }

  get container(): Container {
    return this._container;
  }

  updateView(): void {
    if (this._container.destroyed) return;

    const width = this.textObj.width + this.padding.x;
    const height = this.textObj.height + this.padding.y;

    this._container.pivot.set(width / 2, height / 2);
    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);
    }

    if (this.bgNine) {
      this.bgNine.width = width;
      this.bgNine.height = height;
    }

    if (this.config.position) {
      this._container.position = this.config.position();
    }
  }

  private createRectBackground(): void {
    this.bgRect = new Graphics();
    this.bgRect.label = "button-bg-rect";
    this._container.addChild(this.bgRect);
  }

  private createNineSliceBackground(): void {
    if (!this.config.bg) return;
    this.bgNine = new NineSliceSprite({
      texture: this.config.bg,
      leftWidth: 10,
      topHeight: 10,
      rightWidth: 10,
      bottomHeight: 10,
    });
    this.bgNine.label = "button-bg-nine";
    this._container.addChild(this.bgNine);
  }

  private createText(): void {
    const style = new TextStyle({
      fontFamily: "Arial",
      fontSize: this.config.fontSize!,
      fill: 0xffffff,
      align: "center",
    });
    this.textObj = new Text({
      text: this.config.text,
      style,
    });
    this.textObj.label = "button-text";
  }

  private setupEvents(): void {
    const onPointerDown = () => {
      if (this.config.pressBg && this.bgNine) {
        this.bgNine.texture = this.config.pressBg;
      } else {
        this._container.alpha = 0.8;
        this._container.scale.set(0.8, 0.8);
      }
    };

    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);
      }
      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._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);
  }
}

export default Button;
  • Step 2: Commit
git add src/components/Button.ts
git commit -m "refactor: Button component with full typing"

Task 15: 添加组件层 - Label 和 Panel

Files:

  • Create: src/components/Label.ts

  • Create: src/components/Panel.ts

  • Step 1: 创建 Label

import { Container, Text, TextStyle, type TextStyleOptions } from "pixi.js";

export interface LabelOptions {
  text: string;
  style?: TextStyleOptions;
  position?: { x: number; y: number };
  anchor?: { x: number; y: number };
}

export class Label {
  private _container: Container;
  private _text: Text;

  constructor(opts: LabelOptions) {
    this._container = new Container();
    const style = new TextStyle({
      fontFamily: "Arial",
      fontSize: 30,
      fill: 0xffffff,
      ...opts.style,
    });
    this._text = new Text({
      text: opts.text,
      style,
    });
    if (opts.anchor) {
      this._text.anchor.set(opts.anchor.x, opts.anchor.y);
    }
    if (opts.position) {
      this._container.position.set(opts.position.x, opts.position.y);
    }
    this._container.addChild(this._text);
  }

  get container(): Container {
    return this._container;
  }

  get text(): Text {
    return this._text;
  }

  setText(text: string): void {
    this._text.text = text;
  }

  getText(): string {
    return this._text.text;
  }

  setStyle(style: TextStyleOptions): void {
    this._text.style = new TextStyle({
      ...this._text.style,
      ...style,
    });
  }
}

export default Label;
  • Step 2: 创建 Panel
import { Container, Graphics, type ContainerOptions } from "pixi.js";

export interface PanelOptions extends ContainerOptions {
  width: number;
  height: number;
  backgroundColor?: number;
  padding?: number;
  alpha?: number;
}

export class Panel extends Container {
  private bg: Graphics;

  constructor(opts: PanelOptions) {
    super(opts);
    this.bg = new Graphics();
    this.drawBackground(opts);
    this.addChild(this.bg);
  }

  private drawBackground(opts: PanelOptions): void {
    this.bg.clear();
    this.bg.roundRect(0, 0, opts.width, opts.height, 8);
    this.bg.fill(opts.backgroundColor ?? 0x000000);
    this.bg.alpha = opts.alpha ?? 0.8;
  }

  resize(width: number, height: number): void {
    this.bg.clear();
    this.bg.roundRect(0, 0, width, height, 8);
    this.bg.fill(0x000000);
  }

  setBackgroundColor(color: number): void {
    this.bg.tint = color;
  }
}

export default Panel;
  • Step 3: Commit
git add src/components/Label.ts src/components/Panel.ts
git commit -m "feat: add Label and Panel components"

Task 16: 更新入口 main.ts

Files:

  • Modify: src/main.ts

  • Step 1: 更新 main.ts 适配新架构

import { Orientation } from "./enums/Orientation";
import { initApp, game, sceneManager } from "./init";

// 初始化应用
setTimeout(async () => {
  await initApp();
  game.setOrientation(Orientation.Landscape);
  // 入口场景会自动由 SceneManager 初始化
  // 如果你的入口场景叫 "init",它会被自动发现并在适当的时候初始化
}, 200);

// 导出供全局调试
if (import.meta.env.DEV) {
  (window as any).game = game;
  (window as any).sceneManager = sceneManager;
}
  • Step 2: Commit
git add src/main.ts
git commit -m "refactor: update main.ts for new architecture"

Task 17: 迁移旧场景示例验证

Files:

  • Modify: 迁移一个示例场景到新架构

  • Step 1: 将 welcome 示例场景迁移到新架构

src/scenes/welcome/page_welcome.ts:

import Button from "@/components/Button";
import { game } from "@/core/Game";
import soundManager from "@/utils/Sound";
import { Container } from "pixi.js";
import { BaseScene } from "@/scene/BaseScene";
import { SceneType } from "@/enums/SceneType";
import position from "@/utils/Position";
import { loadAsset, unLoadAsset } from "@/core/AssetManager";

export default class WelcomeScene extends BaseScene {
  stage: Container = new Container();

  constructor() {
    super("welcome", SceneType.Normal);
  }

  async layout(): Promise<void> {
    const btn = new Button({
      text: "进入游戏",
      onClick: () => {
        this.changeStage("welcome2");
      },
      position: () => position.center(),
    });

    this.stage.addChild(btn.container);
  }

  onLoad(): void {
    console.log("welcome onLoad");
  }

  onUnLoad(): void {
    soundManager.stop("my-sound");
    console.log("welcome onUnLoad");
  }
}
  • Step 2: 验证 TypeScript 编译
npx tsc --noEmit
  • Step 3: Commit
git add src/scenes/welcome/page_welcome.ts
git commit -m "refactor: migrate example scene to new architecture"

Task 18: 清理旧文件 (可选,原文件保留在旧位置)

Files:

  • Remove: 旧的 src/Game/ 目录下的文件会保留,但不再使用

  • Step 1: 检查项目编译

npx tsc --noEmit

应该没有错误。如果有错误,修复它们。

  • Step 2: 更新 .gitignore 添加新条目

确保已有:

.superpowers/
node_modules/
dist/
.DS_Store
  • Step 3: Commit
git add .gitignore
git commit -m "chore: update .gitignore"

自检查

完成设计文档覆盖检查:

  • ✓ 枚举定义 - Task 1
  • ✓ 全局类型 - Task 2
  • ✓ Core 层 - Logger (Task 3), EventBus (4), AssetManager (5), Game (6)
  • ✓ Scene 层 - types, BaseScene (7), SceneManager (8), 自动发现 (9)
  • ✓ Utils 层 - Position (10), Tween (11), Sound (12), Storage/MathUtils (13)
  • ✓ Component 层 - Button 重构 (14), Label/Panel 新增 (15)
  • ✓ 入口更新 (16)
  • ✓ 示例迁移验证 (17)
  • ✓ 最终检查 (18)

所有设计要求都已覆盖,没有遗漏。没有占位符,每个任务都有具体代码。