feat: scene manager and transitions

This commit is contained in:
hyper
2026-04-12 16:52:53 +08:00
parent f8a19653ba
commit 50531446c2
9 changed files with 318 additions and 39 deletions
+3 -3
View File
@@ -7,9 +7,9 @@ export { spawnEffect } from './spawner';
export type { Spawner } from './spawner';
// Scene base classes
export { ReactiveScene, GameHostScene } from './scenes';
export type { ReactiveSceneOptions, ReactiveScenePhaserData, GameHostSceneOptions } from './scenes';
export { ReactiveScene, GameHostScene, FadeScene, FADE_SCENE_KEY } from './scenes';
export type { ReactiveSceneOptions, ReactiveScenePhaserData, SceneController, GameHostSceneOptions, FadeSceneData } from './scenes';
// React ↔ Phaser bridge
export { PhaserGame, PhaserScene, phaserContext, defaultPhaserConfig, GameUI, type PhaserGameContext } from './ui';
export { PhaserGame, PhaserScene, phaserContext, defaultPhaserConfig, GameUI, type PhaserGameContext, type SceneController as PhaserSceneController } from './ui';
export type { PhaserGameProps, PhaserSceneProps, GameUIOptions } from './ui';
@@ -0,0 +1,83 @@
import Phaser from 'phaser';
import { ReactiveScene, type ReactiveScenePhaserData } from './ReactiveScene';
export interface FadeSceneData {
[key: string]: unknown;
}
/**
* 处理淡入淡出到黑色的过渡场景
*/
export class FadeScene extends ReactiveScene<FadeSceneData> {
private overlay!: Phaser.GameObjects.Rectangle;
private isFading = false;
constructor() {
super(FADE_SCENE_KEY);
}
create(): void {
super.create();
// 创建黑色遮罩层,覆盖整个游戏区域
const game = this.game;
this.overlay = this.add.rectangle(
0,
0,
game.scale.width,
game.scale.height,
0x000000,
1
).setOrigin(0)
.setAlpha(1)
.setDepth(999999)
.setInteractive({ useHandCursor: false });
// 防止遮罩阻挡输入
this.overlay.disableInteractive();
}
/**
* 淡入(从黑色到透明)
* @param duration 动画时长(毫秒)
*/
fadeIn(duration = 300): Promise<void> {
return this.fadeTo(0, duration);
}
/**
* 淡出(从透明到黑色)
* @param duration 动画时长(毫秒)
*/
fadeOut(duration = 300): Promise<void> {
return this.fadeTo(1, duration);
}
/**
* 淡入淡出到指定透明度
*/
private fadeTo(targetAlpha: number, duration: number): Promise<void> {
if (this.isFading) {
console.warn('FadeScene: 正在进行过渡动画');
}
this.isFading = true;
this.overlay.setAlpha(targetAlpha === 1 ? 0 : 1);
return new Promise<void>((resolve) => {
this.tweens.add({
targets: this.overlay,
alpha: targetAlpha,
duration,
ease: 'Linear',
onComplete: () => {
this.isFading = false;
resolve();
},
});
});
}
}
// 导出常量供 PhaserGame 使用
export const FADE_SCENE_KEY = '__fade__';
+17 -6
View File
@@ -1,15 +1,19 @@
import Phaser from 'phaser';
import { effect } from '@preact/signals-core';
import { effect, type ReadonlySignal } from '@preact/signals-core';
import { DisposableBag, type IDisposable } from '../utils';
type CleanupFn = void | (() => void);
export interface PhaserGameContext {
game: Phaser.Game;
// 前向声明,避免循环导入
export interface SceneController {
launch(sceneKey: string, data?: Record<string, unknown>): Promise<void>;
currentScene: ReadonlySignal<string | null>;
isTransitioning: ReadonlySignal<boolean>;
}
export interface ReactiveScenePhaserData {
phaserGame: PhaserGameContext;
phaserGame: ReadonlySignal<{ game: Phaser.Game }>;
sceneController: SceneController;
}
export interface ReactiveSceneOptions<TData extends Record<string, unknown> = {}> {
@@ -18,7 +22,7 @@ export interface ReactiveSceneOptions<TData extends Record<string, unknown> = {}
/**
* 通用的响应式 Scene 基类
* @typeparam TData - 通过 init(data) 接收的数据类型(必须包含 phaserGame
* @typeparam TData - 通过 init(data) 接收的数据类型(必须包含 phaserGame 和 sceneController
*/
export abstract class ReactiveScene<TData extends Record<string, unknown> = {}>
extends Phaser.Scene
@@ -38,10 +42,17 @@ export abstract class ReactiveScene<TData extends Record<string, unknown> = {}>
/**
* 获取 Phaser game 实例的响应式信号
*/
public get phaserGame(): PhaserGameContext {
public get phaserGame(): ReadonlySignal<{ game: Phaser.Game }> {
return this._initData.phaserGame;
}
/**
* 获取场景控制器
*/
public get sceneController(): SceneController {
return this._initData.sceneController;
}
constructor(key?: string) {
super(key);
}
+4 -1
View File
@@ -1,5 +1,8 @@
export { ReactiveScene } from './ReactiveScene';
export type { ReactiveSceneOptions, ReactiveScenePhaserData } from './ReactiveScene';
export type { ReactiveSceneOptions, ReactiveScenePhaserData, SceneController } from './ReactiveScene';
export { FadeScene, FADE_SCENE_KEY } from './FadeScene';
export type { FadeSceneData } from './FadeScene';
export { GameHostScene } from './GameHostScene';
export type { GameHostSceneOptions } from './GameHostScene';
+67 -11
View File
@@ -1,12 +1,23 @@
import Phaser from 'phaser';
import { signal, useSignal, useSignalEffect } from '@preact/signals';
import { computed, signal, useSignal, useSignalEffect } from '@preact/signals';
import { createContext, h } from 'preact';
import { useContext } from 'preact/hooks';
import {ReadonlySignal} from "@preact/signals-core";
import type { ReactiveScene, ReactiveScenePhaserData } from '../scenes';
import { FadeScene as FadeSceneClass, FADE_SCENE_KEY } from '../scenes/FadeScene';
export interface SceneController {
/** 启动场景(带淡入淡出过渡) */
launch(sceneKey: string, data?: Record<string, unknown>): Promise<void>;
/** 当前活跃场景 key */
currentScene: ReadonlySignal<string | null>;
/** 是否正在过渡 */
isTransitioning: ReadonlySignal<boolean>;
}
export interface PhaserGameContext {
game: Phaser.Game;
sceneController: SceneController;
}
export const phaserContext = createContext<ReadonlySignal<PhaserGameContext> | null>(null);
@@ -26,7 +37,8 @@ export interface PhaserGameProps {
}
export function PhaserGame(props: PhaserGameProps) {
const gameSignal = useSignal<PhaserGameContext>({ game: undefined! });
const gameSignal = useSignal<PhaserGameContext>({ game: undefined!, sceneController: undefined! });
const registeredScenes = useSignal<Map<string, ReactiveScene>>(new Map());
useSignalEffect(() => {
const config: Phaser.Types.Core.GameConfig = {
@@ -34,10 +46,50 @@ export function PhaserGame(props: PhaserGameProps) {
...props.config,
};
const phaserGame = new Phaser.Game(config);
gameSignal.value = { game: phaserGame };
// 添加 FadeScene
const fadeScene = new FadeSceneClass();
phaserGame.scene.add(FADE_SCENE_KEY, fadeScene, false);
// 创建 SceneController
const currentScene = signal<string | null>(null);
const isTransitioning = signal(false);
const sceneController: SceneController = {
async launch(sceneKey: string, data?: Record<string, unknown>) {
if (isTransitioning.value) {
console.warn('SceneController: 正在进行场景切换');
return;
}
isTransitioning.value = true;
const fade = phaserGame.scene.getScene(FADE_SCENE_KEY) as FadeSceneClass;
// 淡出到黑色
await fade.fadeOut(300);
// 停止当前场景
if (currentScene.value) {
phaserGame.scene.stop(currentScene.value);
}
// 启动新场景
phaserGame.scene.start(sceneKey, data);
currentScene.value = sceneKey;
// 淡入
await fade.fadeIn(300);
isTransitioning.value = false;
},
currentScene,
isTransitioning,
};
gameSignal.value = { game: phaserGame, sceneController };
return () => {
gameSignal.value = { game: undefined! };
gameSignal.value = { game: undefined!, sceneController: undefined! };
registeredScenes.value.clear();
phaserGame.destroy(true);
};
});
@@ -54,7 +106,6 @@ export function PhaserGame(props: PhaserGameProps) {
export interface PhaserSceneProps<TData extends Record<string, unknown> = {}> {
sceneKey: string;
scene: ReactiveScene<TData>;
autoStart: boolean;
data?: TData;
children?: any;
}
@@ -62,7 +113,7 @@ export interface PhaserSceneProps<TData extends Record<string, unknown> = {}> {
export const phaserSceneContext = createContext<ReadonlySignal<ReactiveScene> | null>(null);
export function PhaserScene<TData extends Record<string, unknown> = {}>(props: PhaserSceneProps<TData>) {
const phaserGameSignal = useContext(phaserContext);
const sceneSignal = useSignal<Phaser.Scene>();
const sceneSignal = useSignal<ReactiveScene<TData>>();
useSignalEffect(() => {
if (!phaserGameSignal) return;
@@ -72,14 +123,19 @@ export function PhaserScene<TData extends Record<string, unknown> = {}>(props: P
const game = ctx.game;
const initData = {
...props.data,
phaserGame: phaserGameSignal.value,
} as TData & ReactiveScenePhaserData;
phaserGame: phaserGameSignal,
sceneController: ctx.sceneController,
};
game.scene.add(props.sceneKey, props.scene, props.autoStart, initData);
sceneSignal.value = game.scene.getScene(props.sceneKey);
// 注册场景但不启动
if (!game.scene.getScene(props.sceneKey)) {
game.scene.add(props.sceneKey, props.scene, false, initData);
}
sceneSignal.value = props.scene;
return () => {
sceneSignal.value = undefined;
game.scene.remove(props.sceneKey);
// 不在这里移除场景,让 SceneController 管理生命周期
};
});
+1 -1
View File
@@ -1,5 +1,5 @@
export { GameUI } from './GameUI';
export type { GameUIOptions } from './GameUI';
export { PhaserGame, PhaserScene, phaserContext, defaultPhaserConfig, type PhaserGameContext } from './PhaserBridge';
export { PhaserGame, PhaserScene, phaserContext, defaultPhaserConfig, type PhaserGameContext, type SceneController } from './PhaserBridge';
export type { PhaserGameProps, PhaserSceneProps } from './PhaserBridge';