diff --git a/packages/framework/src/index.ts b/packages/framework/src/index.ts index 5a3360f..04aadec 100644 --- a/packages/framework/src/index.ts +++ b/packages/framework/src/index.ts @@ -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'; diff --git a/packages/framework/src/scenes/FadeScene.ts b/packages/framework/src/scenes/FadeScene.ts new file mode 100644 index 0000000..4f567b3 --- /dev/null +++ b/packages/framework/src/scenes/FadeScene.ts @@ -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 { + 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 { + return this.fadeTo(0, duration); + } + + /** + * 淡出(从透明到黑色) + * @param duration 动画时长(毫秒) + */ + fadeOut(duration = 300): Promise { + return this.fadeTo(1, duration); + } + + /** + * 淡入淡出到指定透明度 + */ + private fadeTo(targetAlpha: number, duration: number): Promise { + if (this.isFading) { + console.warn('FadeScene: 正在进行过渡动画'); + } + + this.isFading = true; + this.overlay.setAlpha(targetAlpha === 1 ? 0 : 1); + + return new Promise((resolve) => { + this.tweens.add({ + targets: this.overlay, + alpha: targetAlpha, + duration, + ease: 'Linear', + onComplete: () => { + this.isFading = false; + resolve(); + }, + }); + }); + } +} + +// 导出常量供 PhaserGame 使用 +export const FADE_SCENE_KEY = '__fade__'; diff --git a/packages/framework/src/scenes/ReactiveScene.ts b/packages/framework/src/scenes/ReactiveScene.ts index 03d34b1..ac6ca01 100644 --- a/packages/framework/src/scenes/ReactiveScene.ts +++ b/packages/framework/src/scenes/ReactiveScene.ts @@ -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): Promise; + currentScene: ReadonlySignal; + isTransitioning: ReadonlySignal; } export interface ReactiveScenePhaserData { - phaserGame: PhaserGameContext; + phaserGame: ReadonlySignal<{ game: Phaser.Game }>; + sceneController: SceneController; } export interface ReactiveSceneOptions = {}> { @@ -18,7 +22,7 @@ export interface ReactiveSceneOptions = {} /** * 通用的响应式 Scene 基类 - * @typeparam TData - 通过 init(data) 接收的数据类型(必须包含 phaserGame) + * @typeparam TData - 通过 init(data) 接收的数据类型(必须包含 phaserGame 和 sceneController) */ export abstract class ReactiveScene = {}> extends Phaser.Scene @@ -38,10 +42,17 @@ export abstract class ReactiveScene = {}> /** * 获取 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); } diff --git a/packages/framework/src/scenes/index.ts b/packages/framework/src/scenes/index.ts index 25e229d..c7e004f 100644 --- a/packages/framework/src/scenes/index.ts +++ b/packages/framework/src/scenes/index.ts @@ -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'; diff --git a/packages/framework/src/ui/PhaserBridge.tsx b/packages/framework/src/ui/PhaserBridge.tsx index 0166564..079cd93 100644 --- a/packages/framework/src/ui/PhaserBridge.tsx +++ b/packages/framework/src/ui/PhaserBridge.tsx @@ -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): Promise; + /** 当前活跃场景 key */ + currentScene: ReadonlySignal; + /** 是否正在过渡 */ + isTransitioning: ReadonlySignal; +} export interface PhaserGameContext { game: Phaser.Game; + sceneController: SceneController; } export const phaserContext = createContext | null>(null); @@ -26,7 +37,8 @@ export interface PhaserGameProps { } export function PhaserGame(props: PhaserGameProps) { - const gameSignal = useSignal({ game: undefined! }); + const gameSignal = useSignal({ game: undefined!, sceneController: undefined! }); + const registeredScenes = useSignal>(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(null); + const isTransitioning = signal(false); + + const sceneController: SceneController = { + async launch(sceneKey: string, data?: Record) { + 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 = {}> { sceneKey: string; scene: ReactiveScene; - autoStart: boolean; data?: TData; children?: any; } @@ -62,7 +113,7 @@ export interface PhaserSceneProps = {}> { export const phaserSceneContext = createContext | null>(null); export function PhaserScene = {}>(props: PhaserSceneProps) { const phaserGameSignal = useContext(phaserContext); - const sceneSignal = useSignal(); + const sceneSignal = useSignal>(); useSignalEffect(() => { if (!phaserGameSignal) return; @@ -72,14 +123,19 @@ export function PhaserScene = {}>(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 管理生命周期 }; }); diff --git a/packages/framework/src/ui/index.ts b/packages/framework/src/ui/index.ts index 8329404..258b9ed 100644 --- a/packages/framework/src/ui/index.ts +++ b/packages/framework/src/ui/index.ts @@ -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'; diff --git a/packages/sample-game/src/scenes/GameScene.ts b/packages/sample-game/src/scenes/GameScene.ts index 39cb614..d9e351c 100644 --- a/packages/sample-game/src/scenes/GameScene.ts +++ b/packages/sample-game/src/scenes/GameScene.ts @@ -13,6 +13,9 @@ export class GameScene extends GameHostScene { private gridGraphics!: Phaser.GameObjects.Graphics; private turnText!: Phaser.GameObjects.Text; private winnerOverlay?: Phaser.GameObjects.Container; + private menuButton!: Phaser.GameObjects.Container; + private menuButtonText!: Phaser.GameObjects.Text; + private menuButtonBg!: Phaser.GameObjects.Rectangle; constructor() { super('GameScene'); @@ -24,7 +27,8 @@ export class GameScene extends GameHostScene { this.boardContainer = this.add.container(0, 0); this.gridGraphics = this.add.graphics(); this.drawGrid(); - + this.createMenuButton(); + this.disposables.add(spawnEffect(new TicTacToePartSpawner(this))); this.addEffect(() => { @@ -49,6 +53,43 @@ export class GameScene extends GameHostScene { return !!this.state.board.partMap[`${row},${col}`]; } + private createMenuButton(): void { + const buttonX = this.game.scale.width - 80; + const buttonY = 30; + + this.menuButtonBg = this.add.rectangle(buttonX, buttonY, 120, 40, 0x6b7280) + .setInteractive({ useHandCursor: true }); + + this.menuButtonText = this.add.text(buttonX, buttonY, 'Menu', { + fontSize: '18px', + fontFamily: 'Arial', + color: '#ffffff', + }).setOrigin(0.5); + + this.menuButton = this.add.container(buttonX, buttonY, [ + this.menuButtonBg, + this.menuButtonText, + ]); + + // 按钮交互 + this.menuButtonBg.on('pointerover', () => { + this.menuButtonBg.setFillStyle(0x4b5563); + }); + + this.menuButtonBg.on('pointerout', () => { + this.menuButtonBg.setFillStyle(0x6b7280); + }); + + this.menuButtonBg.on('pointerdown', () => { + this.goToMenu(); + }); + } + + private async goToMenu(): Promise { + const data = this.initData as unknown as Record; + await this.sceneController.launch('MenuScene', data); + } + private setupInput(): void { for (let row = 0; row < BOARD_SIZE; row++) { for (let col = 0; col < BOARD_SIZE; col++) { diff --git a/packages/sample-game/src/scenes/MenuScene.ts b/packages/sample-game/src/scenes/MenuScene.ts new file mode 100644 index 0000000..524f5c2 --- /dev/null +++ b/packages/sample-game/src/scenes/MenuScene.ts @@ -0,0 +1,85 @@ +import { ReactiveScene } from 'boardgame-phaser'; + +export class MenuScene extends ReactiveScene { + private titleText!: Phaser.GameObjects.Text; + private startButton!: Phaser.GameObjects.Container; + private startButtonText!: Phaser.GameObjects.Text; + private startButtonBg!: Phaser.GameObjects.Rectangle; + + constructor() { + super('MenuScene'); + } + + create(): void { + super.create(); + + const centerX = this.game.scale.width / 2; + const centerY = this.game.scale.height / 2; + + // 标题 + this.titleText = this.add.text(centerX, centerY - 100, 'Tic-Tac-Toe', { + fontSize: '48px', + fontFamily: 'Arial', + color: '#1f2937', + }).setOrigin(0.5); + + // 添加标题动画 + this.titleText.setScale(0); + this.tweens.add({ + targets: this.titleText, + scale: 1, + duration: 600, + ease: 'Back.easeOut', + }); + + // 开始按钮 + this.startButtonBg = this.add.rectangle(centerX, centerY + 40, 200, 60, 0x3b82f6) + .setInteractive({ useHandCursor: true }); + + this.startButtonText = this.add.text(centerX, centerY + 40, 'Start Game', { + fontSize: '24px', + fontFamily: 'Arial', + color: '#ffffff', + }).setOrigin(0.5); + + this.startButton = this.add.container(centerX, centerY + 40, [ + this.startButtonBg, + this.startButtonText, + ]); + + // 按钮交互 + this.startButtonBg.on('pointerover', () => { + this.startButtonBg.setFillStyle(0x2563eb); + this.tweens.add({ + targets: this.startButton, + scale: 1.05, + duration: 100, + }); + }); + + this.startButtonBg.on('pointerout', () => { + this.startButtonBg.setFillStyle(0x3b82f6); + this.tweens.add({ + targets: this.startButton, + scale: 1, + duration: 100, + }); + }); + + this.startButtonBg.on('pointerdown', () => { + this.startGame(); + }); + + // 副标题 + this.add.text(centerX, centerY + 140, 'Click to start playing', { + fontSize: '16px', + fontFamily: 'Arial', + color: '#6b7280', + }).setOrigin(0.5); + } + + private async startGame(): Promise { + const data = this.initData as unknown as Record; + await this.sceneController.launch('GameScene', data); + } +} diff --git a/packages/sample-game/src/ui/App.tsx b/packages/sample-game/src/ui/App.tsx index 01dc23d..6c22027 100644 --- a/packages/sample-game/src/ui/App.tsx +++ b/packages/sample-game/src/ui/App.tsx @@ -1,7 +1,9 @@ -import {useComputed} from '@preact/signals'; +import {useComputed, useSignalEffect} from '@preact/signals'; import { createGameHost, type GameModule } from 'boardgame-core'; import { h } from 'preact'; -import {PhaserGame, PhaserScene, ReactiveScene} from 'boardgame-phaser'; +import {PhaserGame, PhaserScene, ReactiveScene, phaserContext} from 'boardgame-phaser'; +import {useContext} from 'preact/hooks'; +import {MenuScene} from "@/scenes/MenuScene"; export default function App>(props: { gameModule: GameModule, gameScene: { new(): ReactiveScene } }) { @@ -10,26 +12,24 @@ export default function App>(props: { gam return { gameHost }; }); - const scene = useComputed(() => new props.gameScene()); + const gameScene = useComputed(() => new props.gameScene()); + const menuScene = useComputed(() => new MenuScene()); - const handleReset = () => { - gameHost.value.gameHost.start(); - }; - const label = useComputed(() => gameHost.value.gameHost.status.value === 'running' ? 'Restart' : 'Start'); + // 自动启动菜单场景 + const phaserSignal = useContext(phaserContext); + useSignalEffect(() => { + const ctx = phaserSignal?.value; + if (ctx?.sceneController) { + ctx.sceneController.launch('MenuScene', { gameHost: gameHost.value }); + } + }); return (
-
- -
- + +