Compare commits
75
Commits
cc80cbad06
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad2c808482 | ||
|
|
f893ac9ff7 | ||
|
|
dfbdaa3499 | ||
|
|
c29b9a43b3 | ||
|
|
447a5cfbd0 | ||
|
|
dda290bf9c | ||
|
|
5d84c42b78 | ||
|
|
a6ddafb802 | ||
|
|
a181932fb3 | ||
|
|
887da22baa | ||
|
|
5355afb23e | ||
|
|
3f2ba84a06 | ||
|
|
9637312b7c | ||
|
|
82c026628a | ||
|
|
a92b889dfa | ||
|
|
c97c2c4150 | ||
|
|
422ddde200 | ||
|
|
093eadbefd | ||
|
|
eff62bc0ed | ||
|
|
fe0621cedf | ||
|
|
30e2d9ac36 | ||
|
|
c9f573ef86 | ||
|
|
cc796d1e11 | ||
|
|
fd347e7a62 | ||
|
|
19dfafeb30 | ||
|
|
b2862e34d9 | ||
|
|
fd41db2a89 | ||
|
|
f03e6c5a7b | ||
|
|
7039938a72 | ||
|
|
45844ea800 | ||
|
|
29b516c371 | ||
|
|
5884e74d8d | ||
|
|
b06876ccc0 | ||
|
|
368d9942d2 | ||
|
|
22817945cc | ||
|
|
85bc4f5400 | ||
|
|
033a8e4a40 | ||
|
|
1d803dd219 | ||
|
|
2d412eedb5 | ||
|
|
f32aca2543 | ||
|
|
34a7cff964 | ||
|
|
82df3f2a2f | ||
|
|
4d34f9fa78 | ||
|
|
c25759d147 | ||
|
|
3568d99e6e | ||
|
|
b28da1cad3 | ||
|
|
c7ef992082 | ||
|
|
3afe78f54f | ||
|
|
762ed1a63a | ||
|
|
b0e74a5257 | ||
|
|
648e801dad | ||
|
|
ddc9d057fd | ||
|
|
e14e41461f | ||
|
|
5af7140958 | ||
|
|
a7095c37fc | ||
|
|
88d0c5bf55 | ||
|
|
d993d55576 | ||
|
|
eefcef861a | ||
|
|
23575dd516 | ||
|
|
0ecef16a4a | ||
|
|
28a2623bd1 | ||
|
|
f7a6154c68 | ||
|
|
706de4e33a | ||
|
|
5342f1c09d | ||
|
|
3ca2a16e29 | ||
|
|
7fb9edcbf0 | ||
|
|
65684e6cf5 | ||
|
|
c9acb7f228 | ||
|
|
c639218b53 | ||
|
|
e91e8d2ab7 | ||
|
|
02d2f45da2 | ||
|
|
e5b53c6332 | ||
|
|
ecfa89e383 | ||
|
|
8bd790db67 | ||
|
|
93587541ce |
@@ -0,0 +1,114 @@
|
||||
# Game Architecture Patterns & Practices
|
||||
|
||||
> Reference guide for implementing board games using `boardgame-phaser` framework.
|
||||
> Explore `packages/onitama-game/` and `packages/sample-game/` for concrete implementations.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
Games follow a layered architecture separating logic, presentation, and UI state:
|
||||
|
||||
```
|
||||
packages/my-game/
|
||||
├── src/
|
||||
│ ├── game/ # Pure game logic (re-exported from boardgame-core)
|
||||
│ ├── scenes/ # Phaser scenes (MenuScene, GameScene)
|
||||
│ ├── spawners/ # Data-driven object lifecycle
|
||||
│ ├── gameobjects/ # Phaser GameObject or Container extensions
|
||||
│ ├── state/ # UI-only reactive state
|
||||
│ ├── config.ts # Centralized layout & style constants
|
||||
│ └── ui/App.tsx # Preact root component
|
||||
```
|
||||
|
||||
| Layer | Responsibility |
|
||||
|-------|----------------|
|
||||
| `game/` | State, commands, prompts, validation (pure logic) |
|
||||
| `scenes/` | Phaser lifecycle, input handling, visual composition |
|
||||
| `spawners/` | Reactive game object spawn/update/despawn |
|
||||
| `gameobjects/` | Phaser GameObjects or containers |
|
||||
| `state/` | UI-only reactive state (selection, hover, etc.) |
|
||||
| `ui/` | Preact components bridging Phaser and DOM |
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### 1. ReactiveScene / GameHostScene
|
||||
Extend `ReactiveScene`(`packages\framework\src\scenes\ReactiveScene.ts`) to use reactive integration features.
|
||||
- Use `this.disposables` for auto-cleanup on shutdown.
|
||||
- Use `initData` for context data passed from the `<PhaserScene/>` preact component.
|
||||
|
||||
### 2. Spawner Pattern
|
||||
Implement `Spawner<TData, TObj>` for data-driven objects.
|
||||
- `*getData()`: Yield objects that should exist.
|
||||
- `getKey()`: Unique identifier for diffing.
|
||||
- `onSpawn()`: Create Phaser objects.
|
||||
- `onUpdate()`: Handle data changes (animate if needed).
|
||||
- `onDespawn()`: Clean up with optional animation.
|
||||
- See: `packages/onitama-game/src/spawners/`
|
||||
|
||||
### 3. Reactive UI State
|
||||
Use `MutableSignal` for UI-only state, separate from game state.
|
||||
- Mutate via `.produce()`.
|
||||
- React via `effect()` from `@preact/signals-core`.
|
||||
- Clean up effects on object `'destroy'` to prevent leaks.
|
||||
- See: `packages/onitama-game/src/state/ui.ts`
|
||||
|
||||
### 4. Custom GameObjects / Containers
|
||||
Extend `Phaser.GameObjects.Container` to encapsulate visuals and state.
|
||||
- Use signals from `states/`, avoid relying on complex interal state.
|
||||
- Use `effect()` for reactive highlights/selections.
|
||||
|
||||
### 5. Tween Interruption
|
||||
Always register state-related tweens: `this.scene.addTweenInterruption(tween)`.
|
||||
Prevents visual glitches when game state changes mid-animation.
|
||||
DO NOT add interruptions for looped tweens.
|
||||
Otherwise the game logic will hang.
|
||||
|
||||
### 6. Scene Navigation
|
||||
Use `await this.sceneController.launch('SceneKey')`.
|
||||
Register scenes in `App.tsx` via `<PhaserScene>`. Pass data via `data` prop.
|
||||
|
||||
### 7. Central Config
|
||||
Use `src/config/layout.ts` for shared constants like `CELL_SIZE`, `BOARD_OFFSET`, etc.
|
||||
Use `src/config/colors.ts` for shared colors.
|
||||
Use `src/config/text.ts` for text styles.
|
||||
|
||||
|
||||
## Best Practices
|
||||
|
||||
- **Centralize Config**: Keep `CELL_SIZE`, `BOARD_OFFSET`, colors, etc., in `src/config.ts` with `as const`. Avoid magic numbers.
|
||||
- **Type Imports**: Use `import type { Foo } from 'bar'` for type-only imports.
|
||||
- **Input Handling**: Use `this.add.zone()` for grid/cell-based input zones.
|
||||
- **Cleanup**: Always dispose `effect()` on `'destroy'`. Use `this.disposables.add()` for scene-level resources.
|
||||
- *Keep files short and focused**: Each file should have a single responsibility, and not exceed 200 lines.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
| Pitfall | Solution |
|
||||
|---------|----------|
|
||||
| Duplicate constants across files | Export from single `config.ts` or shared spawner |
|
||||
| Missing tween interruptions | Always call `addTweenInterruption()` |
|
||||
| Effect memory leaks | `this.on('destroy', () => dispose())` |
|
||||
| Hardcoded magic numbers | Extract to `src/config.ts` |
|
||||
| UI state staleness | Clear related selections on state change |
|
||||
|
||||
## Testing
|
||||
Add Vitest for UI state transitions, coordinate conversions, and spawner logic.
|
||||
See `packages/framework/` for test setup examples.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Pattern | Purpose |
|
||||
|---------|---------|
|
||||
| `GameHostScene` | Connect Phaser to game host |
|
||||
| `Spawner<T, TObj>` | Reactive object lifecycle |
|
||||
| `MutableSignal` | UI-only reactive state |
|
||||
| `effect()` | React to signal changes in Phaser objects |
|
||||
| `addTweenInterruption()` | Prevent animation race conditions |
|
||||
| `sceneController.launch()` | Navigate between scenes |
|
||||
| `spawnEffect()` | Register spawners |
|
||||
| `this.disposables.add()` | Auto-cleanup resources |
|
||||
|
||||
## Related Documents
|
||||
- `AGENTS.md` — Project overview, commands, and code style
|
||||
- `docs/GameModule.md` — GameModule implementation guide
|
||||
- `packages/framework/src/` — `boardgame-phaser` source code
|
||||
- `packages/onitama-game/src/` — Complete game implementation reference
|
||||
@@ -0,0 +1,83 @@
|
||||
import * as js from "@eslint";
|
||||
import tseslint from "typescript-eslint";
|
||||
import importPlugin from "eslint-plugin-import";
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: [
|
||||
"**/dist/**",
|
||||
"**/node_modules/**",
|
||||
"**/*.d.ts",
|
||||
"**/coverage/**",
|
||||
],
|
||||
},
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
importPlugin.flatConfigs.recommended,
|
||||
importPlugin.flatConfigs.typescript,
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
ecmaVersion: 2020,
|
||||
sourceType: "module",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["**/*.ts", "**/*.tsx"],
|
||||
rules: {
|
||||
// --- Project Conventions ---
|
||||
quotes: ["error", "double", { avoidEscape: true }],
|
||||
"comma-dangle": ["error", "always-multiline"],
|
||||
indent: ["error", 2, { SwitchCase: 1 }],
|
||||
|
||||
// --- TypeScript ---
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
"@typescript-eslint/explicit-function-return-type": "off",
|
||||
"@typescript-eslint/explicit-module-boundary-types": "off",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
|
||||
],
|
||||
"@typescript-eslint/consistent-type-imports": [
|
||||
"error",
|
||||
{ prefer: "type-imports" },
|
||||
],
|
||||
"@typescript-eslint/no-non-null-assertion": "warn",
|
||||
|
||||
// --- Import Ordering ---
|
||||
"import/order": [
|
||||
"error",
|
||||
{
|
||||
groups: [
|
||||
"builtin",
|
||||
"external",
|
||||
"internal",
|
||||
"parent",
|
||||
"sibling",
|
||||
"index",
|
||||
"object",
|
||||
"type",
|
||||
],
|
||||
"newlines-between": "always",
|
||||
alphabetize: { order: "asc", caseInsensitive: true },
|
||||
},
|
||||
],
|
||||
"import/no-unresolved": "off",
|
||||
"import/no-duplicates": "error",
|
||||
|
||||
// --- General ---
|
||||
"prefer-const": "error",
|
||||
"no-var": "error",
|
||||
eqeqeq: ["error", "always"],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["**/*.test.ts", "**/*.spec.ts"],
|
||||
rules: {
|
||||
"no-console": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
},
|
||||
},
|
||||
);
|
||||
+7
-1
@@ -6,10 +6,16 @@
|
||||
"dev": "pnpm --filter sample-game dev",
|
||||
"build": "pnpm --filter boardgame-phaser build && pnpm --filter sample-game build",
|
||||
"build:framework": "pnpm --filter boardgame-phaser build",
|
||||
"preview": "pnpm --filter sample-game preview"
|
||||
"preview": "pnpm --filter sample-game preview",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.17.0",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-plugin-import": "^2.31.0",
|
||||
"typescript": "^5.3.3",
|
||||
"typescript-eslint": "^8.18.0",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"pnpm": {
|
||||
|
||||
@@ -1 +1,17 @@
|
||||
export * from 'boardgame-core/samples/boop';
|
||||
/**
|
||||
* Re-export boop game module from boardgame-core
|
||||
* This provides a convenient import path within boop-game
|
||||
*/
|
||||
export {
|
||||
registry,
|
||||
prompts,
|
||||
start,
|
||||
createInitialState,
|
||||
type BoopState,
|
||||
type BoopPart,
|
||||
type BoopGame,
|
||||
type PlayerType,
|
||||
type PieceType,
|
||||
type WinnerType,
|
||||
type RegionType,
|
||||
} from "boardgame-core/samples/boop";
|
||||
@@ -1,13 +1,11 @@
|
||||
import { h } from 'preact';
|
||||
import { GameUI } from 'boardgame-phaser';
|
||||
import * as boop from './game';
|
||||
import './style.css';
|
||||
import App from "@/ui/App";
|
||||
import {GameScene} from "@/scenes/GameScene";
|
||||
|
||||
const ui = new GameUI({
|
||||
container: document.getElementById('ui-root')!,
|
||||
root: <App gameModule={boop} gameScene={GameScene}/>,
|
||||
root: <App/>,
|
||||
});
|
||||
|
||||
ui.mount();
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { BoopState, PlayerType, BoopPart } from '@/game';
|
||||
|
||||
const BOARD_SIZE = 6;
|
||||
const CELL_SIZE = 80;
|
||||
const BOARD_OFFSET = { x: 80, y: 100 };
|
||||
const BOARD_OFFSET = { x: 80, y: 80 };
|
||||
|
||||
export { BOARD_SIZE, CELL_SIZE, BOARD_OFFSET };
|
||||
|
||||
@@ -20,7 +20,7 @@ export class BoardRenderer {
|
||||
|
||||
this.turnText = this.scene.add.text(
|
||||
BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2,
|
||||
BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE + 30,
|
||||
BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE + 40,
|
||||
'',
|
||||
{
|
||||
fontSize: '22px',
|
||||
@@ -31,7 +31,7 @@ export class BoardRenderer {
|
||||
|
||||
this.infoText = this.scene.add.text(
|
||||
BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2,
|
||||
BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE + 60,
|
||||
BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE + 80,
|
||||
'点击空白处放置小猫,三个大猫连线获胜!',
|
||||
{
|
||||
fontSize: '16px',
|
||||
@@ -62,7 +62,7 @@ export class BoardRenderer {
|
||||
|
||||
g.strokePath();
|
||||
|
||||
this.scene.add.text(BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2, BOARD_OFFSET.y - 50, 'Boop Game', {
|
||||
this.scene.add.text(BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2, BOARD_OFFSET.y - 40, 'Boop Game', {
|
||||
fontSize: '32px',
|
||||
fontFamily: 'Arial',
|
||||
color: '#1f2937',
|
||||
|
||||
@@ -6,7 +6,6 @@ import { createPieceSpawner } from './PieceSpawner';
|
||||
import { SupplyUI } from './SupplyUI';
|
||||
import { PieceTypeSelector } from './PieceTypeSelector';
|
||||
import { WinnerOverlay } from './WinnerOverlay';
|
||||
import { StartOverlay } from './StartOverlay';
|
||||
import { ErrorOverlay } from './ErrorOverlay';
|
||||
|
||||
export class GameScene extends GameHostScene<BoopState> {
|
||||
@@ -14,7 +13,6 @@ export class GameScene extends GameHostScene<BoopState> {
|
||||
private supplyUI!: SupplyUI;
|
||||
private pieceTypeSelector!: PieceTypeSelector;
|
||||
private winnerOverlay!: WinnerOverlay;
|
||||
private startOverlay!: StartOverlay;
|
||||
private errorOverlay!: ErrorOverlay;
|
||||
|
||||
constructor() {
|
||||
@@ -28,8 +26,7 @@ export class GameScene extends GameHostScene<BoopState> {
|
||||
this.boardRenderer = new BoardRenderer(this);
|
||||
this.supplyUI = new SupplyUI(this);
|
||||
this.pieceTypeSelector = new PieceTypeSelector(this);
|
||||
this.winnerOverlay = new WinnerOverlay(this, () => this.restartGame());
|
||||
this.startOverlay = new StartOverlay(this, () => this.startGame());
|
||||
this.winnerOverlay = new WinnerOverlay(this, () => this.gameHost.start());
|
||||
this.errorOverlay = new ErrorOverlay(this);
|
||||
|
||||
// 设置棋子生成器
|
||||
@@ -43,16 +40,6 @@ export class GameScene extends GameHostScene<BoopState> {
|
||||
() => this.gameHost.status.value !== 'running' || !!this.state.winner
|
||||
);
|
||||
|
||||
// 监听游戏状态变化
|
||||
this.addEffect(() => {
|
||||
const status = this.gameHost.status.value;
|
||||
if (status === 'running') {
|
||||
this.startOverlay.hide();
|
||||
} else if (status === 'created') {
|
||||
this.startOverlay.show();
|
||||
}
|
||||
});
|
||||
|
||||
// 监听胜负状态
|
||||
this.addEffect(() => {
|
||||
const winner = this.state.winner;
|
||||
@@ -69,6 +56,9 @@ export class GameScene extends GameHostScene<BoopState> {
|
||||
this.supplyUI.update(this.state);
|
||||
this.pieceTypeSelector.update(this.state);
|
||||
});
|
||||
|
||||
// 启动游戏
|
||||
this.gameHost.start();
|
||||
}
|
||||
|
||||
private handleCellClick(row: number, col: number): void {
|
||||
@@ -81,17 +71,14 @@ export class GameScene extends GameHostScene<BoopState> {
|
||||
|
||||
private handlePieceClick(row: number, col: number): void {
|
||||
// 棋盘满时,点击棋子触发升级
|
||||
const error = this.gameHost.tryAnswerPrompt(prompts.graduate, this.state.currentPlayer, row, col);
|
||||
const error = this.gameHost.tryAnswerPrompt(prompts.choose, this.state.currentPlayer, row, col);
|
||||
if (error) {
|
||||
this.errorOverlay.show(error);
|
||||
}
|
||||
}
|
||||
|
||||
private startGame(): void {
|
||||
this.gameHost.start();
|
||||
}
|
||||
|
||||
private restartGame(): void {
|
||||
this.gameHost.start();
|
||||
/** 跳转到菜单场景 */
|
||||
private async goToMenu(): Promise<void> {
|
||||
await this.sceneController.launch('MenuScene');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { ReactiveScene } from 'boardgame-phaser';
|
||||
import Phaser from 'phaser';
|
||||
|
||||
/** 菜单场景配置 */
|
||||
const MENU_CONFIG = {
|
||||
colors: {
|
||||
title: '#fbbf24',
|
||||
buttonText: '#1f2937',
|
||||
buttonBg: 0xfbbf24,
|
||||
buttonBgHover: 0xf59e0b,
|
||||
subtitle: '#6b7280',
|
||||
},
|
||||
fontSize: {
|
||||
title: '48px',
|
||||
button: '24px',
|
||||
subtitle: '16px',
|
||||
},
|
||||
button: {
|
||||
width: 200,
|
||||
height: 80,
|
||||
},
|
||||
positions: {
|
||||
titleY: -120,
|
||||
buttonY: 40,
|
||||
subtitleY: 160,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export class MenuScene extends ReactiveScene {
|
||||
private titleText!: Phaser.GameObjects.Text;
|
||||
private startButtonContainer!: Phaser.GameObjects.Container;
|
||||
private startButtonBg!: Phaser.GameObjects.Rectangle;
|
||||
private startButtonText!: Phaser.GameObjects.Text;
|
||||
|
||||
constructor() {
|
||||
super('MenuScene');
|
||||
}
|
||||
|
||||
create(): void {
|
||||
super.create();
|
||||
|
||||
const center = this.getCenterPosition();
|
||||
|
||||
this.createTitle(center);
|
||||
this.createStartButton(center);
|
||||
this.createSubtitle(center);
|
||||
}
|
||||
|
||||
/** 获取屏幕中心位置 */
|
||||
private getCenterPosition(): { x: number; y: number } {
|
||||
return {
|
||||
x: this.game.scale.width / 2,
|
||||
y: this.game.scale.height / 2,
|
||||
};
|
||||
}
|
||||
|
||||
/** 创建标题文本 */
|
||||
private createTitle(center: { x: number; y: number }): void {
|
||||
this.titleText = this.add.text(
|
||||
center.x,
|
||||
center.y + MENU_CONFIG.positions.titleY,
|
||||
'🐱 BOOP 🐾',
|
||||
{
|
||||
fontSize: MENU_CONFIG.fontSize.title,
|
||||
fontFamily: 'Arial',
|
||||
color: MENU_CONFIG.colors.title,
|
||||
fontStyle: 'bold',
|
||||
}
|
||||
).setOrigin(0.5);
|
||||
|
||||
// 标题入场动画
|
||||
this.titleText.setScale(0);
|
||||
this.tweens.add({
|
||||
targets: this.titleText,
|
||||
scale: 1,
|
||||
duration: 600,
|
||||
ease: 'Back.easeOut',
|
||||
});
|
||||
|
||||
// 标题呼吸动画
|
||||
this.tweens.add({
|
||||
targets: this.titleText,
|
||||
scale: 1.05,
|
||||
duration: 1000,
|
||||
yoyo: true,
|
||||
repeat: -1,
|
||||
ease: 'Sine.easeInOut',
|
||||
});
|
||||
}
|
||||
|
||||
/** 创建开始按钮 */
|
||||
private createStartButton(center: { x: number; y: number }): void {
|
||||
const { button, colors } = MENU_CONFIG;
|
||||
|
||||
this.startButtonBg = this.add.rectangle(
|
||||
0,
|
||||
0,
|
||||
button.width,
|
||||
button.height,
|
||||
colors.buttonBg
|
||||
).setOrigin(0.5).setInteractive({ useHandCursor: true });
|
||||
|
||||
this.startButtonText = this.add.text(
|
||||
0,
|
||||
0,
|
||||
'Start Game',
|
||||
{
|
||||
fontSize: MENU_CONFIG.fontSize.button,
|
||||
fontFamily: 'Arial',
|
||||
color: colors.buttonText,
|
||||
fontStyle: 'bold',
|
||||
}
|
||||
).setOrigin(0.5);
|
||||
|
||||
this.startButtonContainer = this.add.container(
|
||||
center.x,
|
||||
center.y + MENU_CONFIG.positions.buttonY,
|
||||
[this.startButtonBg, this.startButtonText]
|
||||
);
|
||||
|
||||
// 按钮交互
|
||||
this.setupButtonInteraction();
|
||||
}
|
||||
|
||||
/** 设置按钮交互效果 */
|
||||
private setupButtonInteraction(): void {
|
||||
this.startButtonBg.on('pointerover', () => {
|
||||
this.startButtonBg.setFillStyle(MENU_CONFIG.colors.buttonBgHover);
|
||||
this.tweens.add({
|
||||
targets: this.startButtonContainer,
|
||||
scale: 1.05,
|
||||
duration: 100,
|
||||
});
|
||||
});
|
||||
|
||||
this.startButtonBg.on('pointerout', () => {
|
||||
this.startButtonBg.setFillStyle(MENU_CONFIG.colors.buttonBg);
|
||||
this.tweens.add({
|
||||
targets: this.startButtonContainer,
|
||||
scale: 1,
|
||||
duration: 100,
|
||||
});
|
||||
});
|
||||
|
||||
this.startButtonBg.on('pointerdown', () => {
|
||||
this.startGame();
|
||||
});
|
||||
}
|
||||
|
||||
/** 创建副标题 */
|
||||
private createSubtitle(center: { x: number; y: number }): void {
|
||||
this.add.text(
|
||||
center.x,
|
||||
center.y + MENU_CONFIG.positions.subtitleY,
|
||||
'Click to start playing',
|
||||
{
|
||||
fontSize: MENU_CONFIG.fontSize.subtitle,
|
||||
fontFamily: 'Arial',
|
||||
color: MENU_CONFIG.colors.subtitle,
|
||||
}
|
||||
).setOrigin(0.5);
|
||||
}
|
||||
|
||||
/** 开始游戏 */
|
||||
private async startGame(): Promise<void> {
|
||||
await this.sceneController.launch('GameScene');
|
||||
}
|
||||
}
|
||||
@@ -11,18 +11,18 @@ export class PieceTypeSelector {
|
||||
|
||||
constructor(private scene: Phaser.Scene) {
|
||||
const boardCenterX = BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2;
|
||||
const selectorY = BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE + 100;
|
||||
const selectorY = BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE + 120;
|
||||
|
||||
this.container = this.scene.add.container(boardCenterX, selectorY);
|
||||
|
||||
const label = this.scene.add.text(-160, 0, '放置:', {
|
||||
fontSize: '18px',
|
||||
fontSize: '16px',
|
||||
fontFamily: 'Arial',
|
||||
color: '#4b5563',
|
||||
}).setOrigin(0.5, 0.5);
|
||||
|
||||
this.kittenButton = this.createPieceButton('kitten', '🐾 小猫', -50);
|
||||
this.catButton = this.createPieceButton('cat', '🐱 大猫', 70);
|
||||
this.kittenButton = this.createPieceButton('kitten', '🐾 小猫', -40);
|
||||
this.catButton = this.createPieceButton('cat', '🐱 大猫', 80);
|
||||
|
||||
this.container.add([label, this.kittenButton, this.catButton]);
|
||||
}
|
||||
@@ -30,7 +30,7 @@ export class PieceTypeSelector {
|
||||
private createPieceButton(type: PieceType, text: string, xOffset: number): Phaser.GameObjects.Container {
|
||||
const container = this.scene.add.container(xOffset, 0);
|
||||
|
||||
const bg = this.scene.add.rectangle(0, 0, 100, 40, 0xe5e7eb)
|
||||
const bg = this.scene.add.rectangle(0, 0, 120, 40, 0xe5e7eb)
|
||||
.setStrokeStyle(2, 0x9ca3af);
|
||||
|
||||
const textObj = this.scene.add.text(0, 0, text, {
|
||||
@@ -88,10 +88,10 @@ export class PieceTypeSelector {
|
||||
|
||||
update(state: BoopState): void {
|
||||
const currentPlayer = state.currentPlayer;
|
||||
const { kittensInSupply, catsInSupply, catsOnBoard } = this.countPieces(state, currentPlayer);
|
||||
const { kittensInSupply, catsInSupply } = this.countPieces(state, currentPlayer);
|
||||
|
||||
const kittenAvailable = kittensInSupply > 0;
|
||||
const catsAvailable = catsInSupply + catsOnBoard > 0;
|
||||
const catsAvailable = catsInSupply > 0;
|
||||
|
||||
this.updateButton(
|
||||
this.kittenButton,
|
||||
@@ -104,7 +104,7 @@ export class PieceTypeSelector {
|
||||
this.catButton,
|
||||
catsAvailable,
|
||||
this.selectedType === 'cat',
|
||||
`🐱 大猫 (${catsInSupply + catsOnBoard})`
|
||||
`🐱 大猫 (${catsInSupply})`
|
||||
);
|
||||
|
||||
// 自动切换到可用类型
|
||||
|
||||
@@ -12,13 +12,13 @@ export class SupplyUI {
|
||||
|
||||
constructor(private scene: Phaser.Scene) {
|
||||
const boardCenterX = BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2;
|
||||
const uiY = BOARD_OFFSET.y - 20;
|
||||
const uiY = BOARD_OFFSET.y - 40;
|
||||
|
||||
// 白色玩家容器
|
||||
this.whiteContainer = this.scene.add.container(boardCenterX - 150, uiY);
|
||||
this.whiteBg = this.scene.add.rectangle(0, 0, 120, 50, 0x000000);
|
||||
this.whiteContainer = this.scene.add.container(boardCenterX - 160, uiY);
|
||||
this.whiteBg = this.scene.add.rectangle(0, 0, 120, 40, 0x000000);
|
||||
this.whiteText = this.scene.add.text(0, 0, '', {
|
||||
fontSize: '16px',
|
||||
fontSize: '12px',
|
||||
fontFamily: 'Arial',
|
||||
color: '#ffffff',
|
||||
align: 'center',
|
||||
@@ -27,10 +27,10 @@ export class SupplyUI {
|
||||
this.whiteContainer.setDepth(100);
|
||||
|
||||
// 黑色玩家容器
|
||||
this.blackContainer = this.scene.add.container(boardCenterX + 150, uiY);
|
||||
this.blackBg = this.scene.add.rectangle(0, 0, 120, 50, 0x333333);
|
||||
this.blackContainer = this.scene.add.container(boardCenterX + 160, uiY);
|
||||
this.blackBg = this.scene.add.rectangle(0, 0, 120, 40, 0x333333);
|
||||
this.blackText = this.scene.add.text(0, 0, '', {
|
||||
fontSize: '16px',
|
||||
fontSize: '12px',
|
||||
fontFamily: 'Arial',
|
||||
color: '#ffffff',
|
||||
align: 'center',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export { GameScene } from './GameScene';
|
||||
export { MenuScene } from './MenuScene';
|
||||
export { BoardRenderer, BOARD_SIZE, CELL_SIZE, BOARD_OFFSET } from './BoardRenderer';
|
||||
export { createPieceSpawner } from './PieceSpawner';
|
||||
export { SupplyUI } from './SupplyUI';
|
||||
|
||||
@@ -1,38 +1,25 @@
|
||||
import {useComputed} from '@preact/signals';
|
||||
import { createGameHost, type GameModule } from 'boardgame-core';
|
||||
import Phaser from 'phaser';
|
||||
import { h } from 'preact';
|
||||
import { PhaserGame, PhaserScene } from 'boardgame-phaser';
|
||||
import {PhaserGame, PhaserScene } from 'boardgame-phaser';
|
||||
import {MenuScene} from "@/scenes/MenuScene";
|
||||
import {useMemo} from "preact/hooks";
|
||||
import * as gameModule from '../game';
|
||||
import {GameScene} from "@/scenes/GameScene";
|
||||
import {createGameHost, type GameModule} from "boardgame-core";
|
||||
import type {BoopState} from "@/game";
|
||||
|
||||
export default function App<TState extends Record<string, unknown>>(props: { gameModule: GameModule<TState>, gameScene: { new(): Phaser.Scene } }) {
|
||||
|
||||
const gameHost = useComputed(() => {
|
||||
const gameHost = createGameHost(props.gameModule);
|
||||
return { gameHost };
|
||||
});
|
||||
|
||||
const scene = useComputed(() => new props.gameScene());
|
||||
|
||||
const handleReset = async () => {
|
||||
gameHost.value.gameHost.start();
|
||||
};
|
||||
const label = useComputed(() => gameHost.value.gameHost.status.value === 'running' ? 'Restart' : 'Start');
|
||||
export default function App() {
|
||||
const gameHost = useMemo(() => createGameHost(gameModule as unknown as GameModule<BoopState>), []);
|
||||
const gameScene = useMemo(() => new GameScene(), []);
|
||||
const menuScene = useMemo(() => new MenuScene(), []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen">
|
||||
<div className="flex-1 flex relative justify-center items-center">
|
||||
<PhaserGame config={{ width: 640, height: 750 }}>
|
||||
<PhaserScene sceneKey="GameScene" scene={scene.value} autoStart data={gameHost.value} />
|
||||
<PhaserGame initialScene="MenuScene" config={{ width: 640, height: 720 }}>
|
||||
<PhaserScene sceneKey="MenuScene" scene={menuScene} />
|
||||
<PhaserScene sceneKey="GameScene" scene={gameScene} data={{gameHost}}/>
|
||||
</PhaserGame>
|
||||
</div>
|
||||
<div className="p-4 bg-gray-100 border-t border-gray-200">
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="px-6 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors font-medium"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,39 @@
|
||||
// Resource management
|
||||
export { DisposableBag } from './utils';
|
||||
export type { IDisposable, DisposableItem } from './utils';
|
||||
export { DisposableBag } from "./utils";
|
||||
export type { IDisposable, DisposableItem } from "./utils";
|
||||
|
||||
// Drag & drop utilities
|
||||
export { dragDropEventEffect, DragDropEventType } from "./utils";
|
||||
export type { DragDropEvent, DragDropCallback } from "./utils";
|
||||
export { hoverEffect } from "./utils";
|
||||
|
||||
// Data-driven object spawning
|
||||
export { spawnEffect } from './spawner';
|
||||
export type { Spawner } from './spawner';
|
||||
export { spawnEffect, createSpawnUpdate } from "./spawner";
|
||||
export type { Spawner, SpawnerCallback } from "./spawner";
|
||||
|
||||
// Scene base classes
|
||||
export { ReactiveScene, GameHostScene, FadeScene, FADE_SCENE_KEY } from './scenes';
|
||||
export type { ReactiveSceneOptions, ReactiveScenePhaserData, SceneController, GameHostSceneOptions, FadeSceneData } 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, type SceneController as PhaserSceneController } from './ui';
|
||||
export type { PhaserGameProps, PhaserSceneProps, GameUIOptions } from './ui';
|
||||
export {
|
||||
PhaserGame,
|
||||
PhaserScene,
|
||||
phaserContext,
|
||||
defaultPhaserConfig,
|
||||
GameUI,
|
||||
type PhaserGameContext,
|
||||
type SceneController as PhaserSceneController,
|
||||
} from "./ui";
|
||||
export type { PhaserGameProps, PhaserSceneProps, GameUIOptions } from "./ui";
|
||||
|
||||
@@ -1,89 +1,83 @@
|
||||
import Phaser from 'phaser';
|
||||
import { ReactiveScene, type ReactiveScenePhaserData } from './ReactiveScene';
|
||||
import { ReactiveScene } from "./ReactiveScene";
|
||||
|
||||
export interface FadeSceneData {
|
||||
[key: string]: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理淡入淡出到黑色的过渡场景
|
||||
*/
|
||||
export class FadeScene extends ReactiveScene<FadeSceneData> {
|
||||
private overlay!: Phaser.GameObjects.Rectangle;
|
||||
private isFading = false;
|
||||
private overlay!: Phaser.GameObjects.Rectangle;
|
||||
private isFading = false;
|
||||
|
||||
constructor() {
|
||||
super(FADE_SCENE_KEY);
|
||||
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> {
|
||||
// 如果 overlay 还未初始化,直接返回 resolved promise
|
||||
if (!this.overlay) {
|
||||
console.warn("FadeScene: overlay 未初始化,跳过过渡动画");
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
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();
|
||||
if (this.isFading) {
|
||||
console.warn("FadeScene: 正在进行过渡动画");
|
||||
}
|
||||
|
||||
/**
|
||||
* 淡入(从黑色到透明)
|
||||
* @param duration 动画时长(毫秒)
|
||||
*/
|
||||
fadeIn(duration = 300): Promise<void> {
|
||||
return this.fadeTo(0, duration);
|
||||
}
|
||||
this.isFading = true;
|
||||
this.overlay.setAlpha(targetAlpha === 1 ? 0 : 1);
|
||||
|
||||
/**
|
||||
* 淡出(从透明到黑色)
|
||||
* @param duration 动画时长(毫秒)
|
||||
*/
|
||||
fadeOut(duration = 300): Promise<void> {
|
||||
return this.fadeTo(1, duration);
|
||||
}
|
||||
|
||||
/**
|
||||
* 淡入淡出到指定透明度
|
||||
*/
|
||||
private fadeTo(targetAlpha: number, duration: number): Promise<void> {
|
||||
// 如果 overlay 还未初始化,直接返回 resolved promise
|
||||
if (!this.overlay) {
|
||||
console.warn('FadeScene: overlay 未初始化,跳过过渡动画');
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
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();
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
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__';
|
||||
export const FADE_SCENE_KEY = "__fade__";
|
||||
|
||||
@@ -1,36 +1,43 @@
|
||||
import type { GameHost } from 'boardgame-core';
|
||||
import { ReactiveScene } from './ReactiveScene';
|
||||
import { ReactiveScene } from "./ReactiveScene";
|
||||
|
||||
export interface GameHostSceneOptions<TState extends Record<string, unknown>> {
|
||||
gameHost: GameHost<TState>;
|
||||
[key: string]: unknown;
|
||||
import type { GameHost, GameModule } from "boardgame-core";
|
||||
|
||||
export interface GameHostSceneOptions<
|
||||
TState extends Record<string, unknown>,
|
||||
TResult,
|
||||
TModule extends GameModule<TState, TResult>,
|
||||
> {
|
||||
gameHost: GameHost<TState, TResult, TModule>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export abstract class GameHostScene<TState extends Record<string, unknown>>
|
||||
extends ReactiveScene<GameHostSceneOptions<TState>>
|
||||
{
|
||||
public get gameHost(): GameHost<TState> {
|
||||
const gameHost = this.initData.gameHost as GameHost<TState>;
|
||||
if (!gameHost) {
|
||||
throw new Error(
|
||||
`GameHostScene (${this.scene.key}): gameHost 未提供。` +
|
||||
`确保在 PhaserScene 组件的 data 属性中传入 gameHost。`
|
||||
);
|
||||
}
|
||||
return gameHost;
|
||||
export abstract class GameHostScene<
|
||||
TState extends Record<string, unknown>,
|
||||
TResult,
|
||||
TModule extends GameModule<TState, TResult>,
|
||||
> extends ReactiveScene<GameHostSceneOptions<TState, TResult, TModule>> {
|
||||
public get gameHost(): GameHost<TState, TResult, TModule> {
|
||||
const gameHost = this.initData.gameHost;
|
||||
if (!gameHost) {
|
||||
throw new Error(
|
||||
`GameHostScene (${this.scene.key}): gameHost 未提供。` +
|
||||
"确保在 PhaserScene 组件的 data 属性中传入 gameHost。",
|
||||
);
|
||||
}
|
||||
return gameHost;
|
||||
}
|
||||
|
||||
public get state(): TState {
|
||||
return this.gameHost?.state.value;
|
||||
}
|
||||
public get state(): TState {
|
||||
return this.gameHost?.state.value;
|
||||
}
|
||||
|
||||
addInterruption(promise: Promise<void>){
|
||||
this.gameHost?.addInterruption(promise);
|
||||
}
|
||||
addInterruption(promise: Promise<void>) {
|
||||
this.gameHost?.addInterruption(promise);
|
||||
}
|
||||
|
||||
addTweenInterruption(tween: Phaser.Tweens.Tween){
|
||||
this.addInterruption(new Promise(
|
||||
resolve => tween.once('complete', resolve)
|
||||
));
|
||||
}
|
||||
addTweenInterruption(tween: Phaser.Tweens.Tween) {
|
||||
this.addInterruption(
|
||||
new Promise((resolve) => tween.once("complete", resolve)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,86 +1,90 @@
|
||||
import Phaser from 'phaser';
|
||||
import { effect, type ReadonlySignal } from '@preact/signals-core';
|
||||
import { DisposableBag, type IDisposable } from '../utils';
|
||||
import { type ReadonlySignal } from "@preact/signals-core";
|
||||
import { Scene } from "phaser";
|
||||
|
||||
import { DisposableBag, type IDisposable } from "../utils";
|
||||
|
||||
import type Phaser from "phaser";
|
||||
|
||||
type CleanupFn = void | (() => void);
|
||||
|
||||
// 前向声明,避免循环导入
|
||||
export interface SceneController {
|
||||
launch(sceneKey: string): Promise<void>;
|
||||
currentScene: ReadonlySignal<string | null>;
|
||||
isTransitioning: ReadonlySignal<boolean>;
|
||||
launch(sceneKey: string): Promise<void>;
|
||||
restart(): Promise<void>;
|
||||
currentScene: ReadonlySignal<string | null>;
|
||||
isTransitioning: ReadonlySignal<boolean>;
|
||||
}
|
||||
|
||||
export interface ReactiveScenePhaserData {
|
||||
phaserGame: ReadonlySignal<{ game: Phaser.Game }>;
|
||||
sceneController: SceneController;
|
||||
phaserGame: ReadonlySignal<{ game: Phaser.Game }>;
|
||||
sceneController: SceneController;
|
||||
}
|
||||
|
||||
export interface ReactiveSceneOptions<TData extends Record<string, unknown> = {}> {
|
||||
key?: string;
|
||||
export interface ReactiveSceneOptions {
|
||||
key?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用的响应式 Scene 基类
|
||||
* @typeparam TData - 通过 init(data) 接收的数据类型(必须包含 phaserGame 和 sceneController)
|
||||
*/
|
||||
export abstract class ReactiveScene<TData extends Record<string, unknown> = {}>
|
||||
extends Phaser.Scene
|
||||
implements IDisposable
|
||||
export abstract class ReactiveScene<TData = object>
|
||||
extends Scene
|
||||
implements IDisposable
|
||||
{
|
||||
protected disposables = new DisposableBag();
|
||||
private _initData?: TData & ReactiveScenePhaserData;
|
||||
protected disposables = new DisposableBag();
|
||||
private _initData?: TData & ReactiveScenePhaserData;
|
||||
|
||||
/**
|
||||
* 获取通过 init() 注入的数据
|
||||
* 在 create() 阶段保证可用
|
||||
*/
|
||||
public get initData(): TData & ReactiveScenePhaserData {
|
||||
if (!this._initData) {
|
||||
throw new Error(
|
||||
`ReactiveScene (${this.scene.key}): initData 尚未初始化。` +
|
||||
`确保场景通过 PhaserScene 组件注册,并在 create() 阶段访问。`
|
||||
);
|
||||
}
|
||||
return this._initData;
|
||||
/**
|
||||
* 获取通过 init() 注入的数据
|
||||
* 在 create() 阶段保证可用
|
||||
*/
|
||||
public get initData(): TData & ReactiveScenePhaserData {
|
||||
if (!this._initData) {
|
||||
throw new Error(
|
||||
`ReactiveScene (${this.scene.key}): initData 尚未初始化。` +
|
||||
"确保场景通过 PhaserScene 组件注册,并在 create() 阶段访问。",
|
||||
);
|
||||
}
|
||||
return this._initData;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Phaser game 实例的响应式信号
|
||||
*/
|
||||
public get phaserGame(): ReadonlySignal<{ game: Phaser.Game }> {
|
||||
return this.initData.phaserGame;
|
||||
}
|
||||
/**
|
||||
* 获取 Phaser game 实例的响应式信号
|
||||
*/
|
||||
public get phaserGame(): ReadonlySignal<{ game: Phaser.Game }> {
|
||||
return this.initData.phaserGame;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取场景控制器
|
||||
*/
|
||||
public get sceneController(): SceneController {
|
||||
return this.initData.sceneController;
|
||||
}
|
||||
/**
|
||||
* 获取场景控制器
|
||||
*/
|
||||
public get sceneController(): SceneController {
|
||||
return this.initData.sceneController;
|
||||
}
|
||||
|
||||
constructor(key?: string) {
|
||||
super(key);
|
||||
}
|
||||
constructor(key?: string) {
|
||||
super(key);
|
||||
}
|
||||
|
||||
init(data: TData & ReactiveScenePhaserData): void {
|
||||
this._initData = data;
|
||||
}
|
||||
init(data: TData & ReactiveScenePhaserData): void {
|
||||
this._initData = data;
|
||||
}
|
||||
|
||||
create(): void {
|
||||
this.events.on('shutdown', this.dispose, this);
|
||||
}
|
||||
create(): void {
|
||||
this.events.on("shutdown", this.dispose, this);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.disposables.dispose();
|
||||
}
|
||||
dispose(): void {
|
||||
this.disposables.dispose();
|
||||
}
|
||||
|
||||
public addDisposable(disposable: IDisposable): void {
|
||||
this.disposables.add(disposable);
|
||||
}
|
||||
public addDisposable(disposable: IDisposable): void {
|
||||
this.disposables.add(disposable);
|
||||
}
|
||||
|
||||
/** 注册响应式监听(场景关闭时自动清理) */
|
||||
public addEffect(fn: () => CleanupFn): void {
|
||||
this.disposables.add(effect(fn));
|
||||
}
|
||||
/** 注册响应式监听(场景关闭时自动清理) */
|
||||
public addEffect(fn: () => CleanupFn): void {
|
||||
this.disposables.addEffect(fn);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
export { ReactiveScene } from './ReactiveScene';
|
||||
export type { ReactiveSceneOptions, ReactiveScenePhaserData, SceneController } from './ReactiveScene';
|
||||
export { ReactiveScene } from "./ReactiveScene";
|
||||
export type {
|
||||
ReactiveSceneOptions,
|
||||
ReactiveScenePhaserData,
|
||||
SceneController,
|
||||
} from "./ReactiveScene";
|
||||
|
||||
export { FadeScene, FADE_SCENE_KEY } from './FadeScene';
|
||||
export type { FadeSceneData } from './FadeScene';
|
||||
export { FadeScene, FADE_SCENE_KEY } from "./FadeScene";
|
||||
export type { FadeSceneData } from "./FadeScene";
|
||||
|
||||
export { GameHostScene } from './GameHostScene';
|
||||
export type { GameHostSceneOptions } from './GameHostScene';
|
||||
export { GameHostScene } from "./GameHostScene";
|
||||
export type { GameHostSceneOptions } from "./GameHostScene";
|
||||
|
||||
@@ -1,54 +1,70 @@
|
||||
import Phaser from 'phaser';
|
||||
import { effect } from '@preact/signals-core';
|
||||
import { effect } from "@preact/signals-core";
|
||||
|
||||
import type Phaser from "phaser";
|
||||
|
||||
type GO = Phaser.GameObjects.GameObject;
|
||||
|
||||
export interface Spawner<TData, TObject extends GO = GO> {
|
||||
/** 数据源迭代器 */
|
||||
getData(): Iterable<TData>;
|
||||
/** 获取数据的唯一键 */
|
||||
getKey(t: TData): string;
|
||||
/** 创建新对象 */
|
||||
onSpawn(t: TData): TObject | null;
|
||||
/** 销毁旧对象 */
|
||||
onDespawn(obj: TObject, t: TData): void;
|
||||
/** 更新已有对象 */
|
||||
onUpdate(t: TData, obj: TObject): void;
|
||||
export interface SpawnerCallback<TData, TObject extends GO = GO> {
|
||||
/** 获取数据的唯一键 */
|
||||
getKey(t: TData): string;
|
||||
/** 创建新对象 */
|
||||
onSpawn(t: TData): TObject | null;
|
||||
/** 销毁旧对象 */
|
||||
onDespawn(obj: TObject, t: TData): void;
|
||||
/** 更新已有对象 */
|
||||
onUpdate(t: TData, obj: TObject): void;
|
||||
}
|
||||
|
||||
export interface Spawner<
|
||||
TData,
|
||||
TObject extends GO = GO,
|
||||
> extends SpawnerCallback<TData, TObject> {
|
||||
/** 数据源迭代器 */
|
||||
getData(): Iterable<TData>;
|
||||
}
|
||||
|
||||
export function spawnEffect<TData, TObject extends GO = GO>(
|
||||
spawner: Spawner<TData, TObject>,
|
||||
): () => void {
|
||||
const objects = new Map<string, TObject>();
|
||||
const spawnData = new Map<string, TData>();
|
||||
spawner: Spawner<TData, TObject>,
|
||||
) {
|
||||
const update = createSpawnUpdate(spawner);
|
||||
|
||||
return effect(() => {
|
||||
const current = new Set<string>();
|
||||
return effect(() => {
|
||||
update(spawner.getData());
|
||||
});
|
||||
}
|
||||
|
||||
for (const t of spawner.getData()) {
|
||||
const key = spawner.getKey(t);
|
||||
current.add(key);
|
||||
export function createSpawnUpdate<TData, TObject extends GO = GO>(
|
||||
spawner: SpawnerCallback<TData, TObject>,
|
||||
) {
|
||||
const entries = new Map<string, { object: TObject; data: TData }>();
|
||||
|
||||
const existing = objects.get(key);
|
||||
if (!existing) {
|
||||
const obj = spawner.onSpawn(t);
|
||||
if (obj) {
|
||||
spawnData.set(key, t);
|
||||
objects.set(key, obj);
|
||||
}
|
||||
} else {
|
||||
if(spawnData.get(key) === t) continue;
|
||||
spawner.onUpdate(t, existing);
|
||||
spawnData.set(key, t);
|
||||
}
|
||||
function update(data: Iterable<TData>) {
|
||||
const current = new Set<string>();
|
||||
|
||||
for (const t of data) {
|
||||
const key = spawner.getKey(t);
|
||||
current.add(key);
|
||||
|
||||
const existing = entries.get(key);
|
||||
if (!existing) {
|
||||
const obj = spawner.onSpawn(t);
|
||||
if (obj) {
|
||||
entries.set(key, { object: obj, data: t });
|
||||
}
|
||||
} else {
|
||||
if (existing.data === t) continue;
|
||||
spawner.onUpdate(t, existing.object);
|
||||
existing.data = t;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, obj] of objects) {
|
||||
if (!current.has(key)) {
|
||||
spawner.onDespawn(obj, spawnData.get(key)!);
|
||||
objects.delete(key);
|
||||
spawnData.delete(key);
|
||||
}
|
||||
}
|
||||
});
|
||||
for (const [key, entry] of entries) {
|
||||
if (!current.has(key)) {
|
||||
spawner.onDespawn(entry.object, entry.data);
|
||||
entries.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return update;
|
||||
}
|
||||
|
||||
@@ -1,25 +1,39 @@
|
||||
import type { JSX } from "preact";
|
||||
|
||||
export interface GameUIOptions {
|
||||
container: HTMLElement;
|
||||
root: any;
|
||||
container: HTMLElement | string;
|
||||
root: JSX.Element;
|
||||
}
|
||||
|
||||
export class GameUI {
|
||||
private container: HTMLElement;
|
||||
private root: any;
|
||||
private root: JSX.Element;
|
||||
|
||||
constructor(options: GameUIOptions) {
|
||||
this.container = options.container;
|
||||
if (typeof options.container === "string") {
|
||||
const existing = document.getElementById(options.container);
|
||||
if (existing) {
|
||||
this.container = existing;
|
||||
} else {
|
||||
const newContainer = document.createElement("div");
|
||||
newContainer.id = options.container;
|
||||
document.body.appendChild(newContainer);
|
||||
this.container = newContainer;
|
||||
}
|
||||
} else {
|
||||
this.container = options.container;
|
||||
}
|
||||
this.root = options.root;
|
||||
}
|
||||
|
||||
mount(): void {
|
||||
import('preact').then(({ render }) => {
|
||||
import("preact").then(({ render }) => {
|
||||
render(this.root, this.container);
|
||||
});
|
||||
}
|
||||
|
||||
unmount(): void {
|
||||
import('preact').then(({ render }) => {
|
||||
import("preact").then(({ render }) => {
|
||||
render(null, this.container);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,184 +1,247 @@
|
||||
import Phaser from 'phaser';
|
||||
import { signal, useSignal, useSignalEffect } from '@preact/signals';
|
||||
import { createContext, h } from 'preact';
|
||||
import { useContext, useEffect, useRef } from 'preact/hooks';
|
||||
import { ReadonlySignal } from "@preact/signals-core";
|
||||
import type { ReactiveScene } from '../scenes';
|
||||
import { FadeScene as FadeSceneClass, FADE_SCENE_KEY } from '../scenes/FadeScene';
|
||||
import { signal, useSignal, useSignalEffect } from "@preact/signals";
|
||||
import Phaser, { AUTO, Scene } from "phaser";
|
||||
import { createContext } from "preact";
|
||||
import { useContext, useEffect, useRef } from "preact/hooks";
|
||||
|
||||
import {
|
||||
FadeScene as FadeSceneClass,
|
||||
FADE_SCENE_KEY,
|
||||
} from "../scenes/FadeScene";
|
||||
|
||||
import type { ReactiveScene } from "../scenes";
|
||||
import type { ReadonlySignal } from "@preact/signals-core";
|
||||
|
||||
export interface SceneController {
|
||||
/** 启动场景(带淡入淡出过渡) */
|
||||
launch(sceneKey: string): Promise<void>;
|
||||
/** 当前活跃场景 key */
|
||||
currentScene: ReadonlySignal<string | null>;
|
||||
/** 是否正在过渡 */
|
||||
isTransitioning: ReadonlySignal<boolean>;
|
||||
/** 启动场景(带淡入淡出过渡) */
|
||||
launch(sceneKey: string): Promise<void>;
|
||||
/** 重新启动当前场景(带淡入淡出过渡) */
|
||||
restart(): Promise<void>;
|
||||
/** 当前活跃场景 key */
|
||||
currentScene: ReadonlySignal<string | null>;
|
||||
/** 是否正在过渡 */
|
||||
isTransitioning: ReadonlySignal<boolean>;
|
||||
}
|
||||
|
||||
export interface PhaserGameContext {
|
||||
game: Phaser.Game;
|
||||
sceneController: SceneController;
|
||||
game: Phaser.Game;
|
||||
sceneController: SceneController;
|
||||
}
|
||||
|
||||
export const phaserContext = createContext<ReadonlySignal<PhaserGameContext> | null>(null);
|
||||
export const phaserContext =
|
||||
createContext<ReadonlySignal<PhaserGameContext> | null>(null);
|
||||
|
||||
export const defaultPhaserConfig: Phaser.Types.Core.GameConfig = {
|
||||
type: Phaser.AUTO,
|
||||
width: 560,
|
||||
height: 560,
|
||||
parent: 'phaser-container',
|
||||
backgroundColor: '#f9fafb',
|
||||
scene: [],
|
||||
type: AUTO,
|
||||
width: 560,
|
||||
height: 560,
|
||||
parent: "phaser-container",
|
||||
backgroundColor: "#f9fafb",
|
||||
scene: [],
|
||||
disableContextMenu: true,
|
||||
};
|
||||
|
||||
export interface PhaserGameProps {
|
||||
config?: Partial<Phaser.Types.Core.GameConfig>;
|
||||
/** 初始启动的场景 key */
|
||||
initialScene?: string;
|
||||
children?: any;
|
||||
config?: Partial<Phaser.Types.Core.GameConfig>;
|
||||
/** 初始启动的场景 key */
|
||||
initialScene?: string;
|
||||
children?: preact.ComponentChildren;
|
||||
}
|
||||
|
||||
export function PhaserGame(props: PhaserGameProps) {
|
||||
const gameSignal = useSignal<PhaserGameContext>({ game: undefined!, sceneController: undefined! });
|
||||
const initialSceneLaunched = useRef(false);
|
||||
const gameSignal = useSignal<PhaserGameContext>({
|
||||
game: undefined!,
|
||||
sceneController: undefined!,
|
||||
});
|
||||
const initialSceneLaunched = useRef(false);
|
||||
|
||||
useSignalEffect(() => {
|
||||
const config: Phaser.Types.Core.GameConfig = {
|
||||
...defaultPhaserConfig,
|
||||
...props.config,
|
||||
};
|
||||
const phaserGame = new Phaser.Game(config);
|
||||
useSignalEffect(() => {
|
||||
const config: Phaser.Types.Core.GameConfig = {
|
||||
...defaultPhaserConfig,
|
||||
...props.config,
|
||||
};
|
||||
const phaserGame = new Phaser.Game(config);
|
||||
|
||||
// 添加 FadeScene 并启动它来初始化 overlay
|
||||
const fadeScene = new FadeSceneClass();
|
||||
phaserGame.scene.add(FADE_SCENE_KEY, fadeScene, true); // 改为 true 以触发 create
|
||||
// 添加 FadeScene 并启动它来初始化 overlay
|
||||
const fadeScene = new FadeSceneClass();
|
||||
phaserGame.scene.add(FADE_SCENE_KEY, fadeScene, true); // 改为 true 以触发 create
|
||||
|
||||
// 创建 SceneController
|
||||
const currentScene = signal<string | null>(null);
|
||||
const isTransitioning = signal(false);
|
||||
// 创建 SceneController
|
||||
const currentScene = signal<string | null>(null);
|
||||
const isTransitioning = signal(false);
|
||||
|
||||
const sceneController: SceneController = {
|
||||
async launch(sceneKey: string) {
|
||||
if (isTransitioning.value) {
|
||||
console.warn('SceneController: 正在进行场景切换');
|
||||
return;
|
||||
}
|
||||
|
||||
// 等待场景注册完成(最多等待 100ms)
|
||||
let retries = 0;
|
||||
while (!phaserGame.scene.getScene(sceneKey) && retries < 10) {
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
retries++;
|
||||
}
|
||||
|
||||
// 验证场景是否已注册
|
||||
if (!phaserGame.scene.getScene(sceneKey)) {
|
||||
console.error(`SceneController: 场景 "${sceneKey}" 未注册`);
|
||||
return;
|
||||
}
|
||||
|
||||
isTransitioning.value = true;
|
||||
const fade = phaserGame.scene.getScene(FADE_SCENE_KEY) as FadeSceneClass;
|
||||
|
||||
// 淡出到黑色
|
||||
phaserGame.scene.bringToTop(FADE_SCENE_KEY);
|
||||
await fade.fadeOut(300);
|
||||
|
||||
// 停止当前场景
|
||||
if (currentScene.value) {
|
||||
phaserGame.scene.stop(currentScene.value);
|
||||
}
|
||||
|
||||
// 确保场景已注册后再启动
|
||||
// (场景应该已经在 PhaserScene 组件中注册)
|
||||
if (!phaserGame.scene.getScene(sceneKey)) {
|
||||
console.error(`SceneController: 场景 "${sceneKey}" 在切换时仍未注册`);
|
||||
isTransitioning.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 启动新场景
|
||||
phaserGame.scene.start(sceneKey);
|
||||
currentScene.value = sceneKey;
|
||||
|
||||
// 淡入
|
||||
await fade.fadeIn(300);
|
||||
isTransitioning.value = false;
|
||||
},
|
||||
currentScene,
|
||||
isTransitioning,
|
||||
};
|
||||
|
||||
gameSignal.value = { game: phaserGame, sceneController };
|
||||
|
||||
return () => {
|
||||
gameSignal.value = { game: undefined!, sceneController: undefined! };
|
||||
initialSceneLaunched.current = false;
|
||||
phaserGame.destroy(true);
|
||||
};
|
||||
});
|
||||
|
||||
// 启动初始场景(仅一次)
|
||||
useEffect(() => {
|
||||
const ctx = gameSignal.value;
|
||||
if (!initialSceneLaunched.current && props.initialScene && ctx?.sceneController) {
|
||||
initialSceneLaunched.current = true;
|
||||
// 使用 microtask 确保所有子组件的场景注册已完成
|
||||
Promise.resolve().then(() => {
|
||||
ctx.sceneController.launch(props.initialScene!);
|
||||
});
|
||||
}
|
||||
}, [gameSignal.value, props.initialScene]);
|
||||
|
||||
return (
|
||||
<div id="phaser-container" className="w-full h-full">
|
||||
<phaserContext.Provider value={gameSignal}>
|
||||
{props.children}
|
||||
</phaserContext.Provider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface PhaserSceneProps<TData extends Record<string, unknown> = {}> {
|
||||
sceneKey: string;
|
||||
scene: ReactiveScene<TData>;
|
||||
data?: TData;
|
||||
children?: any;
|
||||
}
|
||||
|
||||
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<ReactiveScene<TData>>();
|
||||
const registered = useRef(false);
|
||||
|
||||
useSignalEffect(() => {
|
||||
if (!phaserGameSignal) return;
|
||||
const ctx = phaserGameSignal.value;
|
||||
if (!ctx?.game) return;
|
||||
|
||||
const game = ctx.game;
|
||||
|
||||
// 注册场景到 Phaser(但不启动)
|
||||
if (!game.scene.getScene(props.sceneKey)) {
|
||||
const initData = {
|
||||
...props.data,
|
||||
phaserGame: phaserGameSignal,
|
||||
sceneController: ctx.sceneController,
|
||||
};
|
||||
game.scene.add(props.sceneKey, props.scene, false, initData);
|
||||
const sceneController: SceneController = {
|
||||
async launch(sceneKey: string) {
|
||||
if (isTransitioning.value) {
|
||||
console.warn("SceneController: 正在进行场景切换");
|
||||
return;
|
||||
}
|
||||
|
||||
sceneSignal.value = props.scene;
|
||||
registered.current = true;
|
||||
// 等待场景注册完成(最多等待 100ms)
|
||||
let retries = 0;
|
||||
while (!phaserGame.scene.getScene(sceneKey) && retries < 10) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
retries++;
|
||||
}
|
||||
|
||||
return () => {
|
||||
sceneSignal.value = undefined;
|
||||
registered.current = false;
|
||||
// 不在这里移除场景,让 SceneController 管理生命周期
|
||||
};
|
||||
});
|
||||
// 验证场景是否已注册
|
||||
if (!phaserGame.scene.getScene(sceneKey)) {
|
||||
console.error(`SceneController: 场景 "${sceneKey}" 未注册`);
|
||||
return;
|
||||
}
|
||||
|
||||
return <phaserSceneContext.Provider value={sceneSignal as ReadonlySignal<ReactiveScene>}>{props.children}</phaserSceneContext.Provider>;
|
||||
isTransitioning.value = true;
|
||||
const fade = phaserGame.scene.getScene(
|
||||
FADE_SCENE_KEY,
|
||||
) as FadeSceneClass;
|
||||
|
||||
// 淡出到黑色
|
||||
phaserGame.scene.bringToTop(FADE_SCENE_KEY);
|
||||
await fade.fadeOut(300);
|
||||
|
||||
// 停止当前场景
|
||||
if (currentScene.value) {
|
||||
phaserGame.scene.stop(currentScene.value);
|
||||
}
|
||||
|
||||
// 确保场景已注册后再启动
|
||||
// (场景应该已经在 PhaserScene 组件中注册)
|
||||
if (!phaserGame.scene.getScene(sceneKey)) {
|
||||
console.error(`SceneController: 场景 "${sceneKey}" 在切换时仍未注册`);
|
||||
isTransitioning.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 启动新场景
|
||||
phaserGame.scene.start(sceneKey);
|
||||
currentScene.value = sceneKey;
|
||||
|
||||
// 淡入
|
||||
await fade.fadeIn(300);
|
||||
isTransitioning.value = false;
|
||||
},
|
||||
async restart() {
|
||||
if (isTransitioning.value) {
|
||||
console.warn("SceneController: 正在进行场景切换");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentScene.value) {
|
||||
console.warn("SceneController: 没有当前场景,无法 restart");
|
||||
return;
|
||||
}
|
||||
|
||||
const sceneKey = currentScene.value;
|
||||
const scene = phaserGame.scene.getScene(sceneKey);
|
||||
|
||||
if (!scene) {
|
||||
console.error(`SceneController: 场景 "${sceneKey}" 不存在`);
|
||||
return;
|
||||
}
|
||||
|
||||
isTransitioning.value = true;
|
||||
const fade = phaserGame.scene.getScene(
|
||||
FADE_SCENE_KEY,
|
||||
) as FadeSceneClass;
|
||||
|
||||
// 淡出到黑色
|
||||
phaserGame.scene.bringToTop(FADE_SCENE_KEY);
|
||||
await fade.fadeOut(300);
|
||||
|
||||
// 重启当前场景
|
||||
scene.scene.restart();
|
||||
|
||||
// 淡入
|
||||
await fade.fadeIn(300);
|
||||
isTransitioning.value = false;
|
||||
},
|
||||
currentScene,
|
||||
isTransitioning,
|
||||
};
|
||||
|
||||
gameSignal.value = { game: phaserGame, sceneController };
|
||||
|
||||
return () => {
|
||||
gameSignal.value = { game: undefined!, sceneController: undefined! };
|
||||
initialSceneLaunched.current = false;
|
||||
phaserGame.destroy(true);
|
||||
};
|
||||
});
|
||||
|
||||
// 启动初始场景(仅一次)
|
||||
useEffect(() => {
|
||||
const ctx = gameSignal.value;
|
||||
if (
|
||||
!initialSceneLaunched.current &&
|
||||
props.initialScene &&
|
||||
ctx?.sceneController
|
||||
) {
|
||||
initialSceneLaunched.current = true;
|
||||
// 使用 microtask 确保所有子组件的场景注册已完成
|
||||
Promise.resolve().then(() => {
|
||||
ctx.sceneController.launch(props.initialScene!);
|
||||
});
|
||||
}
|
||||
}, [gameSignal.value, props.initialScene]);
|
||||
|
||||
return (
|
||||
<div id="phaser-container" className="w-full h-full">
|
||||
<phaserContext.Provider value={gameSignal}>
|
||||
{props.children}
|
||||
</phaserContext.Provider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface PhaserSceneProps<TData = {}> {
|
||||
sceneKey?: string;
|
||||
scene: ReactiveScene<TData> | { new (): ReactiveScene<TData> };
|
||||
data?: TData;
|
||||
children?: any;
|
||||
}
|
||||
|
||||
export const phaserSceneContext =
|
||||
createContext<ReadonlySignal<ReactiveScene> | null>(null);
|
||||
|
||||
export function PhaserScene<TData = {}>(props: PhaserSceneProps<TData>) {
|
||||
const phaserGameSignal = useContext(phaserContext);
|
||||
const sceneSignal = useSignal<ReactiveScene<TData>>();
|
||||
const registered = useRef(false);
|
||||
|
||||
useSignalEffect(() => {
|
||||
if (!phaserGameSignal) return;
|
||||
const ctx = phaserGameSignal.value;
|
||||
if (!ctx?.game) return;
|
||||
|
||||
const game = ctx.game;
|
||||
|
||||
// 注册场景到 Phaser(但不启动)
|
||||
const scene =
|
||||
props.scene instanceof Scene ? props.scene : new props.scene();
|
||||
const sceneKey = props.sceneKey ?? scene.sys.settings.key;
|
||||
if (!game.scene.getScene(sceneKey)) {
|
||||
const initData = {
|
||||
...props.data,
|
||||
phaserGame: phaserGameSignal,
|
||||
sceneController: ctx.sceneController,
|
||||
};
|
||||
game.scene.add(sceneKey, props.scene, false, initData);
|
||||
}
|
||||
|
||||
sceneSignal.value = scene;
|
||||
registered.current = true;
|
||||
|
||||
return () => {
|
||||
sceneSignal.value = undefined;
|
||||
registered.current = false;
|
||||
// 不在这里移除场景,让 SceneController 管理生命周期
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<phaserSceneContext.Provider
|
||||
value={sceneSignal as ReadonlySignal<ReactiveScene>}
|
||||
>
|
||||
{props.children}
|
||||
</phaserSceneContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
export { GameUI } from './GameUI';
|
||||
export type { GameUIOptions } from './GameUI';
|
||||
export { GameUI } from "./GameUI";
|
||||
export type { GameUIOptions } from "./GameUI";
|
||||
|
||||
export { PhaserGame, PhaserScene, phaserContext, defaultPhaserConfig, type PhaserGameContext, type SceneController } from './PhaserBridge';
|
||||
export type { PhaserGameProps, PhaserSceneProps } from './PhaserBridge';
|
||||
export {
|
||||
PhaserGame,
|
||||
PhaserScene,
|
||||
phaserContext,
|
||||
defaultPhaserConfig,
|
||||
type PhaserGameContext,
|
||||
type SceneController,
|
||||
} from "./PhaserBridge";
|
||||
export type { PhaserGameProps, PhaserSceneProps } from "./PhaserBridge";
|
||||
|
||||
@@ -1,46 +1,52 @@
|
||||
import { effect } from "@preact/signals-core";
|
||||
|
||||
export interface IDisposable {
|
||||
dispose(): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export type DisposableItem = IDisposable | (() => void);
|
||||
|
||||
export class DisposableBag implements IDisposable {
|
||||
private _disposables = new Set<DisposableItem>();
|
||||
private _isDisposed = false;
|
||||
private _disposables = new Set<DisposableItem>();
|
||||
private _isDisposed = false;
|
||||
|
||||
get isDisposed(): boolean {
|
||||
return this._isDisposed;
|
||||
constructor(go?: Phaser.GameObjects.GameObject) {
|
||||
if (go) go.on("shutdown", () => this.dispose());
|
||||
}
|
||||
|
||||
get isDisposed(): boolean {
|
||||
return this._isDisposed;
|
||||
}
|
||||
|
||||
add(item: DisposableItem): void {
|
||||
if (this._isDisposed) {
|
||||
this._execute(item);
|
||||
return;
|
||||
}
|
||||
this._disposables.add(item);
|
||||
}
|
||||
|
||||
addEffect(fn: () => void) {
|
||||
this.add(effect(fn));
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this._isDisposed) return;
|
||||
|
||||
this._isDisposed = true;
|
||||
|
||||
for (const item of this._disposables) {
|
||||
this._execute(item);
|
||||
}
|
||||
|
||||
add(item: DisposableItem): void {
|
||||
if (this._isDisposed) {
|
||||
this._execute(item);
|
||||
return;
|
||||
}
|
||||
this._disposables.add(item);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this._isDisposed) return;
|
||||
|
||||
this._isDisposed = true;
|
||||
|
||||
for (const item of this._disposables) {
|
||||
try {
|
||||
this._execute(item);
|
||||
} catch (error) {
|
||||
console.error('Error during resource disposal:', error);
|
||||
}
|
||||
}
|
||||
|
||||
this._disposables.clear();
|
||||
}
|
||||
|
||||
private _execute(item: DisposableItem): void {
|
||||
if (typeof item === 'function') {
|
||||
item();
|
||||
} else {
|
||||
item.dispose();
|
||||
}
|
||||
this._disposables.clear();
|
||||
}
|
||||
|
||||
private _execute(item: DisposableItem): void {
|
||||
if (typeof item === "function") {
|
||||
item();
|
||||
} else {
|
||||
item.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { DisposableBag } from "./disposable";
|
||||
|
||||
type PointerRecord = {
|
||||
id: number;
|
||||
button: number;
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
export enum DragDropEventType {
|
||||
DOWN,
|
||||
UP,
|
||||
MOVE,
|
||||
ALTBUTTON,
|
||||
}
|
||||
|
||||
export type DragDropEvent = {
|
||||
type: DragDropEventType;
|
||||
deltaX: number;
|
||||
deltaY: number;
|
||||
};
|
||||
|
||||
export type DragDropCallback = (event: DragDropEvent) => void;
|
||||
|
||||
export function dragDropEventEffect(
|
||||
gameObject: Phaser.GameObjects.GameObject,
|
||||
callback: DragDropCallback,
|
||||
disposables?: DisposableBag,
|
||||
): () => void {
|
||||
let isDragging = false;
|
||||
let down: PointerRecord | null = null;
|
||||
|
||||
function onPointerDown(pointer: Phaser.Input.Pointer) {
|
||||
if (down !== null) {
|
||||
if (pointer.button === down.button) return;
|
||||
callback({
|
||||
type: DragDropEventType.ALTBUTTON,
|
||||
deltaX: pointer.x - down.x,
|
||||
deltaY: pointer.y - down.y,
|
||||
});
|
||||
return;
|
||||
}
|
||||
isDragging = true;
|
||||
down = {
|
||||
id: pointer.id,
|
||||
button: pointer.button,
|
||||
x: pointer.x,
|
||||
y: pointer.y,
|
||||
};
|
||||
|
||||
const event: DragDropEvent = {
|
||||
type: DragDropEventType.DOWN,
|
||||
deltaX: 0,
|
||||
deltaY: 0,
|
||||
};
|
||||
callback(event);
|
||||
}
|
||||
|
||||
function onPointerUp(pointer: Phaser.Input.Pointer) {
|
||||
if (
|
||||
!isDragging ||
|
||||
!down ||
|
||||
pointer.id !== down.id ||
|
||||
pointer.button !== down.button
|
||||
)
|
||||
return;
|
||||
|
||||
const deltaX = pointer.x - down.x;
|
||||
const deltaY = pointer.y - down.y;
|
||||
|
||||
isDragging = false;
|
||||
const event: DragDropEvent = {
|
||||
type: DragDropEventType.UP,
|
||||
deltaX,
|
||||
deltaY,
|
||||
};
|
||||
callback(event);
|
||||
down = null;
|
||||
}
|
||||
|
||||
function onPointerMove(pointer: Phaser.Input.Pointer) {
|
||||
if (!isDragging || !down || pointer.id !== down.id) return;
|
||||
|
||||
const deltaX = pointer.x - down.x;
|
||||
const deltaY = pointer.y - down.y;
|
||||
const event: DragDropEvent = {
|
||||
type: DragDropEventType.MOVE,
|
||||
deltaX,
|
||||
deltaY,
|
||||
};
|
||||
callback(event);
|
||||
}
|
||||
|
||||
gameObject.on("pointerdown", onPointerDown);
|
||||
gameObject.scene.input.on("pointerup", onPointerUp);
|
||||
gameObject.scene.input.on("pointermove", onPointerMove);
|
||||
|
||||
const dispose = () => {
|
||||
gameObject.off("pointerdown", onPointerDown);
|
||||
gameObject.scene.input.off("pointerup", onPointerUp);
|
||||
gameObject.scene.input.off("pointermove", onPointerMove);
|
||||
};
|
||||
|
||||
disposables?.add(dispose);
|
||||
return dispose;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
type HoverCallback = (hovering: boolean) => void;
|
||||
export function hoverEffect(
|
||||
gameObject: Phaser.GameObjects.GameObject,
|
||||
callback: HoverCallback,
|
||||
) {
|
||||
let isHovering = false;
|
||||
const onPointerOver = () => {
|
||||
if (isHovering) return;
|
||||
isHovering = true;
|
||||
callback(true);
|
||||
};
|
||||
const onPointerOut = () => {
|
||||
if (!isHovering) return;
|
||||
isHovering = false;
|
||||
callback(false);
|
||||
};
|
||||
|
||||
gameObject.on("pointerover", onPointerOver);
|
||||
gameObject.on("pointerout", onPointerOut);
|
||||
const cleanup = () => {
|
||||
gameObject.off("pointerover", onPointerOver);
|
||||
gameObject.off("pointerout", onPointerOut);
|
||||
gameObject.off("destroy", cleanup);
|
||||
};
|
||||
|
||||
gameObject.once("destroy", cleanup);
|
||||
return cleanup;
|
||||
}
|
||||
@@ -1,2 +1,9 @@
|
||||
export { DisposableBag } from './disposable';
|
||||
export type { IDisposable, DisposableItem } from './disposable';
|
||||
export { DisposableBag } from "./disposable";
|
||||
export type { IDisposable, DisposableItem } from "./disposable";
|
||||
export {
|
||||
dragDropEventEffect,
|
||||
DragDropEventType,
|
||||
type DragDropEvent,
|
||||
type DragDropCallback,
|
||||
} from "./dnd";
|
||||
export * from "./hover";
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* Centralized configuration for Onitama game
|
||||
* All layout, style, and game constants should be defined here
|
||||
*/
|
||||
|
||||
import { Display } from "phaser";
|
||||
|
||||
// Board configuration
|
||||
export const BOARD_SIZE = 5;
|
||||
export const CELL_SIZE = 80;
|
||||
export const BOARD_OFFSET = { x: 240, y: 240 } as const;
|
||||
|
||||
// Card configuration
|
||||
export const CARD_WIDTH = 80;
|
||||
export const CARD_HEIGHT = 120;
|
||||
export const CARD_SPACING = 100;
|
||||
export const CARD_LABEL_OFFSET = 40; // Distance from board edge
|
||||
|
||||
// Menu configuration
|
||||
export const MENU_CONFIG = {
|
||||
colors: {
|
||||
title: "#1f2937",
|
||||
buttonText: "#ffffff",
|
||||
buttonBg: 0x3b82f6,
|
||||
buttonBgHover: 0x2563eb,
|
||||
subtitle: "#6b7280",
|
||||
},
|
||||
fontSize: {
|
||||
title: "48px",
|
||||
button: "24px",
|
||||
subtitle: "16px",
|
||||
},
|
||||
button: {
|
||||
width: 200,
|
||||
height: 80,
|
||||
},
|
||||
positions: {
|
||||
titleY: -120,
|
||||
buttonY: 40,
|
||||
subtitleY: 160,
|
||||
},
|
||||
} as const;
|
||||
|
||||
// UI colors
|
||||
export const COLORS = {
|
||||
red: 0xef4444,
|
||||
black: 0x3b82f6,
|
||||
highlight: 0xfbbf24,
|
||||
highlightStroke: 0xf59e0b,
|
||||
gridLine: 0x6b7280,
|
||||
pawnStroke: 0x1f2937,
|
||||
cardBg: 0xf9fafb,
|
||||
cardStroke: 0x6b7280,
|
||||
cardCenter: 0x3b82f6,
|
||||
cardTarget: 0xef4444,
|
||||
textDark: "#1f2937",
|
||||
textGray: "#6b7280",
|
||||
textInfo: "#4b5563",
|
||||
winnerGold: "#fbbf24",
|
||||
overlayBg: 0x000000,
|
||||
menuButton: 0x6b7280,
|
||||
menuButtonHover: 0x4b5563,
|
||||
} as const;
|
||||
|
||||
// Font configuration
|
||||
export const FONTS = {
|
||||
title: {
|
||||
fontSize: "28px",
|
||||
fontFamily: "Arial",
|
||||
color: COLORS.textDark,
|
||||
},
|
||||
info: {
|
||||
fontSize: "16px",
|
||||
fontFamily: "Arial",
|
||||
color: COLORS.textInfo,
|
||||
},
|
||||
cardTitle: {
|
||||
fontSize: "12px",
|
||||
fontFamily: "Arial",
|
||||
color: COLORS.textDark,
|
||||
},
|
||||
cardPlayer: {
|
||||
fontSize: "10px",
|
||||
fontFamily: "Arial",
|
||||
color: COLORS.textGray,
|
||||
},
|
||||
pawnLabel: {
|
||||
fontSize: "24px",
|
||||
fontFamily: "Arial",
|
||||
color: "#ffffff",
|
||||
},
|
||||
cardLabel: {
|
||||
fontSize: "16px",
|
||||
fontFamily: "Arial",
|
||||
},
|
||||
winner: {
|
||||
fontSize: "36px",
|
||||
fontFamily: "Arial",
|
||||
color: COLORS.winnerGold,
|
||||
},
|
||||
menuButton: {
|
||||
fontSize: "18px",
|
||||
fontFamily: "Arial",
|
||||
color: "#ffffff",
|
||||
},
|
||||
} as const;
|
||||
|
||||
// Menu button configuration
|
||||
export const MENU_BUTTON = {
|
||||
x: 680,
|
||||
y: 40,
|
||||
width: 120,
|
||||
height: 40,
|
||||
} as const;
|
||||
|
||||
// Text positioning
|
||||
export const TEXT_POSITION = {
|
||||
titleX: 40,
|
||||
titleY: 40,
|
||||
infoX: 40,
|
||||
} as const;
|
||||
|
||||
// Animation durations (in ms)
|
||||
export const ANIMATIONS = {
|
||||
pawnSpawn: 300,
|
||||
pawnDespawn: 300,
|
||||
pawnMove: 400,
|
||||
cardSpawn: 300,
|
||||
cardDespawn: 200,
|
||||
cardMove: 600,
|
||||
cardRotate: 400,
|
||||
highlightSpawn: 250,
|
||||
highlightDespawn: 200,
|
||||
selectionFadeIn: 200,
|
||||
selectionFadeOut: 150,
|
||||
selectionPulse: 500,
|
||||
menuTitle: 600,
|
||||
buttonHover: 100,
|
||||
winnerPulse: 500,
|
||||
clickFeedback: 150,
|
||||
} as const;
|
||||
|
||||
// Grid cell size for card move visualization
|
||||
export const CARD_GRID = {
|
||||
cellSize: 14,
|
||||
gridSize: 5,
|
||||
} as const;
|
||||
|
||||
// Visual style constants
|
||||
export const VISUAL = {
|
||||
pawnRadius: CELL_SIZE / 3,
|
||||
pawnStrokeWidth: 2,
|
||||
selectionRingOffset: 5,
|
||||
selectionRingStrokeWidth: 3,
|
||||
highlightOuterRadius: CELL_SIZE / 3,
|
||||
highlightInnerRadius: CELL_SIZE / 4,
|
||||
highlightHitAreaRadius: CELL_SIZE / 3,
|
||||
cardStrokeWidth: 2,
|
||||
cardTitleOffset: 16,
|
||||
cardPlayerOffset: 16,
|
||||
cardDisabledAlpha: 0.8,
|
||||
overlayAlpha: 0.6,
|
||||
cardBackgroundDepth: -1,
|
||||
} as const;
|
||||
|
||||
// Helper function to convert board coordinates to screen coordinates
|
||||
export function boardToScreen(
|
||||
boardX: number,
|
||||
boardY: number,
|
||||
): { x: number; y: number } {
|
||||
return {
|
||||
x: BOARD_OFFSET.x + boardX * CELL_SIZE + CELL_SIZE / 2,
|
||||
y: BOARD_OFFSET.y + (BOARD_SIZE - 1 - boardY) * CELL_SIZE + CELL_SIZE / 2,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper function to get board center
|
||||
export function getBoardCenter(): { x: number; y: number } {
|
||||
return {
|
||||
x: BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2,
|
||||
y: BOARD_OFFSET.y + (BOARD_SIZE * CELL_SIZE) / 2,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper function to get card label position
|
||||
export function getCardLabelPosition(position: "red" | "black"): {
|
||||
x: number;
|
||||
y: number;
|
||||
originX: number;
|
||||
originY: number;
|
||||
} {
|
||||
const boardLeft = BOARD_OFFSET.x;
|
||||
const boardTop = BOARD_OFFSET.y;
|
||||
const boardBottom = BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE;
|
||||
const centerX = boardLeft + (BOARD_SIZE * CELL_SIZE) / 2;
|
||||
|
||||
if (position === "red") {
|
||||
return {
|
||||
x: centerX,
|
||||
y: boardBottom + CARD_LABEL_OFFSET,
|
||||
originX: 0.5,
|
||||
originY: 0,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
x: centerX,
|
||||
y: boardTop - CARD_LABEL_OFFSET,
|
||||
originX: 0.5,
|
||||
originY: 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to get card position
|
||||
export function getCardPosition(
|
||||
position: "red" | "black" | "spare",
|
||||
index: number,
|
||||
): { x: number; y: number } {
|
||||
const boardLeft = BOARD_OFFSET.x;
|
||||
const boardTop = BOARD_OFFSET.y;
|
||||
const boardCenterX = boardLeft + (BOARD_SIZE * CELL_SIZE) / 2;
|
||||
const boardBottom = BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE;
|
||||
const boardCenterY = boardTop + (BOARD_SIZE * CELL_SIZE) / 2;
|
||||
|
||||
if (position === "red") {
|
||||
return {
|
||||
x: boardCenterX - (index - 0.5) * CARD_SPACING,
|
||||
y: boardBottom + CARD_HEIGHT / 2 + CARD_LABEL_OFFSET,
|
||||
};
|
||||
} else if (position === "black") {
|
||||
return {
|
||||
x: boardCenterX - (index - 0.5) * CARD_SPACING,
|
||||
y: boardTop - CARD_HEIGHT / 2 - CARD_LABEL_OFFSET,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
x: boardLeft - CARD_WIDTH / 2 - CARD_LABEL_OFFSET,
|
||||
y: boardCenterY,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function colorToStr(hex: number) {
|
||||
return Display.Color.ValueToColor(hex).toString();
|
||||
}
|
||||
|
||||
// Tween factory functions
|
||||
export {
|
||||
// Card tweens
|
||||
createCardMoveTween,
|
||||
createCardRotateTween,
|
||||
createCardSpawnTween,
|
||||
createCardDespawnTween,
|
||||
// Pawn tweens
|
||||
createPawnMoveTween,
|
||||
createPawnSpawnTween,
|
||||
createPawnDespawnTween,
|
||||
// Highlight tweens
|
||||
createHighlightSpawnTween,
|
||||
createHighlightDespawnTween,
|
||||
createHighlightClickFeedbackTween,
|
||||
createHighlightInnerPulseTween,
|
||||
createHighlightOuterPulseTween,
|
||||
// Selection tweens
|
||||
createSelectionFadeInTween,
|
||||
createSelectionPulseTween,
|
||||
createSelectionFadeOutTween,
|
||||
createSelectionShowTween,
|
||||
createSelectionRingPulseTween,
|
||||
createSelectionHideTween,
|
||||
// Overlay tweens
|
||||
createWinnerPulseTween,
|
||||
// Types
|
||||
type TweenScene,
|
||||
} from "@/config/tweens";
|
||||
@@ -0,0 +1,322 @@
|
||||
import type Phaser from "phaser";
|
||||
|
||||
import { ANIMATIONS } from "@/config";
|
||||
|
||||
export type TweenScene = Phaser.Scene & {
|
||||
addTweenInterruption(tween: Phaser.Tweens.Tween): void;
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Card Tweens
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function createCardMoveTween(
|
||||
scene: TweenScene,
|
||||
target: Phaser.GameObjects.GameObject,
|
||||
x: number,
|
||||
y: number,
|
||||
): Phaser.Tweens.Tween {
|
||||
const tween = scene.tweens.add({
|
||||
targets: target,
|
||||
x,
|
||||
y,
|
||||
duration: ANIMATIONS.cardMove,
|
||||
ease: "Back.easeOut",
|
||||
});
|
||||
scene.addTweenInterruption(tween);
|
||||
return tween;
|
||||
}
|
||||
|
||||
export function createCardRotateTween(
|
||||
scene: TweenScene,
|
||||
target: Phaser.GameObjects.GameObject,
|
||||
fromAngle: number,
|
||||
toAngle: number,
|
||||
): Phaser.Tweens.Tween {
|
||||
const tween = scene.tweens.add({
|
||||
targets: target,
|
||||
angle: { from: fromAngle, to: toAngle },
|
||||
duration: ANIMATIONS.cardRotate,
|
||||
ease: "Back.easeOut",
|
||||
});
|
||||
scene.addTweenInterruption(tween);
|
||||
return tween;
|
||||
}
|
||||
|
||||
export function createCardSpawnTween(
|
||||
scene: TweenScene,
|
||||
target: Phaser.GameObjects.GameObject,
|
||||
): Phaser.Tweens.Tween {
|
||||
const tween = scene.tweens.add({
|
||||
targets: target,
|
||||
alpha: 1,
|
||||
duration: ANIMATIONS.cardSpawn,
|
||||
ease: "Power2",
|
||||
});
|
||||
scene.addTweenInterruption(tween);
|
||||
return tween;
|
||||
}
|
||||
|
||||
export function createCardDespawnTween(
|
||||
scene: TweenScene,
|
||||
target: Phaser.GameObjects.GameObject,
|
||||
onComplete: () => void,
|
||||
): Phaser.Tweens.Tween {
|
||||
const tween = scene.tweens.add({
|
||||
targets: target,
|
||||
alpha: 0,
|
||||
scale: 0.8,
|
||||
duration: ANIMATIONS.cardDespawn,
|
||||
ease: "Power2",
|
||||
onComplete,
|
||||
});
|
||||
scene.addTweenInterruption(tween);
|
||||
return tween;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Pawn Tweens
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function createPawnMoveTween(
|
||||
scene: TweenScene,
|
||||
target: Phaser.GameObjects.GameObject,
|
||||
x: number,
|
||||
y: number,
|
||||
): Phaser.Tweens.Tween {
|
||||
const tween = scene.tweens.add({
|
||||
targets: target,
|
||||
x,
|
||||
y,
|
||||
duration: ANIMATIONS.pawnMove,
|
||||
ease: "Back.easeOut",
|
||||
});
|
||||
scene.addTweenInterruption(tween);
|
||||
return tween;
|
||||
}
|
||||
|
||||
export function createPawnSpawnTween(
|
||||
scene: TweenScene,
|
||||
target: Phaser.GameObjects.GameObject,
|
||||
): Phaser.Tweens.Tween {
|
||||
const tween = scene.tweens.add({
|
||||
targets: target,
|
||||
scale: 1,
|
||||
duration: ANIMATIONS.pawnSpawn,
|
||||
ease: "Back.easeOut",
|
||||
});
|
||||
scene.addTweenInterruption(tween);
|
||||
return tween;
|
||||
}
|
||||
|
||||
export function createPawnDespawnTween(
|
||||
scene: TweenScene,
|
||||
target: Phaser.GameObjects.GameObject,
|
||||
onComplete: () => void,
|
||||
): Phaser.Tweens.Tween {
|
||||
const tween = scene.tweens.add({
|
||||
targets: target,
|
||||
scale: 0,
|
||||
alpha: 0,
|
||||
duration: ANIMATIONS.pawnDespawn,
|
||||
ease: "Back.easeIn",
|
||||
onComplete,
|
||||
});
|
||||
scene.addTweenInterruption(tween);
|
||||
return tween;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Highlight Tweens
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function createHighlightSpawnTween(
|
||||
scene: TweenScene,
|
||||
target: Phaser.GameObjects.GameObject,
|
||||
): Phaser.Tweens.Tween {
|
||||
const tween = scene.tweens.add({
|
||||
targets: target,
|
||||
alpha: 1,
|
||||
scale: 1,
|
||||
duration: ANIMATIONS.highlightSpawn,
|
||||
ease: "Back.easeOut",
|
||||
});
|
||||
scene.addTweenInterruption(tween);
|
||||
return tween;
|
||||
}
|
||||
|
||||
export function createHighlightDespawnTween(
|
||||
scene: TweenScene,
|
||||
target: Phaser.GameObjects.GameObject,
|
||||
onComplete: () => void,
|
||||
): Phaser.Tweens.Tween {
|
||||
const tween = scene.tweens.add({
|
||||
targets: target,
|
||||
scale: 0,
|
||||
alpha: 0,
|
||||
duration: ANIMATIONS.highlightDespawn,
|
||||
ease: "Back.easeIn",
|
||||
onComplete,
|
||||
});
|
||||
scene.addTweenInterruption(tween);
|
||||
return tween;
|
||||
}
|
||||
|
||||
export function createHighlightClickFeedbackTween(
|
||||
scene: TweenScene,
|
||||
target: Phaser.GameObjects.GameObject,
|
||||
): Phaser.Tweens.Tween {
|
||||
return scene.tweens.add({
|
||||
targets: target,
|
||||
scale: 1.5,
|
||||
alpha: 0.8,
|
||||
duration: ANIMATIONS.clickFeedback,
|
||||
ease: "Power2",
|
||||
yoyo: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function createHighlightInnerPulseTween(
|
||||
scene: TweenScene,
|
||||
targets: Phaser.GameObjects.GameObject[],
|
||||
): Phaser.Tweens.Tween {
|
||||
return scene.tweens.add({
|
||||
targets,
|
||||
scale: 1.2,
|
||||
alpha: 0.6,
|
||||
duration: 600,
|
||||
ease: "Sine.easeInOut",
|
||||
yoyo: true,
|
||||
repeat: -1,
|
||||
});
|
||||
}
|
||||
|
||||
export function createHighlightOuterPulseTween(
|
||||
scene: TweenScene,
|
||||
target: Phaser.GameObjects.GameObject,
|
||||
): Phaser.Tweens.Tween {
|
||||
return scene.tweens.add({
|
||||
targets: target,
|
||||
scale: 1.3,
|
||||
alpha: 0.3,
|
||||
duration: 800,
|
||||
ease: "Sine.easeInOut",
|
||||
yoyo: true,
|
||||
repeat: -1,
|
||||
delay: 200,
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Selection Tweens
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function createSelectionFadeInTween(
|
||||
scene: TweenScene,
|
||||
target: Phaser.GameObjects.GameObject,
|
||||
onComplete: () => void,
|
||||
): Phaser.Tweens.Tween {
|
||||
return scene.tweens.add({
|
||||
targets: target,
|
||||
alpha: 1,
|
||||
scale: 1.05,
|
||||
duration: ANIMATIONS.selectionFadeIn,
|
||||
ease: "Power2",
|
||||
onComplete,
|
||||
});
|
||||
}
|
||||
|
||||
export function createSelectionPulseTween(
|
||||
scene: TweenScene,
|
||||
target: Phaser.GameObjects.GameObject,
|
||||
lineWidth: number,
|
||||
): Phaser.Tweens.Tween {
|
||||
return scene.tweens.add({
|
||||
targets: target,
|
||||
alpha: 0.7,
|
||||
lineWidth: lineWidth + 1,
|
||||
duration: ANIMATIONS.selectionPulse,
|
||||
ease: "Sine.easeInOut",
|
||||
yoyo: true,
|
||||
repeat: -1,
|
||||
});
|
||||
}
|
||||
|
||||
export function createSelectionFadeOutTween(
|
||||
scene: TweenScene,
|
||||
target: Phaser.GameObjects.GameObject,
|
||||
onComplete: () => void,
|
||||
): Phaser.Tweens.Tween {
|
||||
return scene.tweens.add({
|
||||
targets: target,
|
||||
alpha: 0,
|
||||
scale: 0.95,
|
||||
duration: ANIMATIONS.selectionFadeOut,
|
||||
ease: "Power2",
|
||||
onComplete,
|
||||
});
|
||||
}
|
||||
|
||||
export function createSelectionShowTween(
|
||||
scene: TweenScene,
|
||||
target: Phaser.GameObjects.GameObject,
|
||||
onCompleteFadeIn: () => void,
|
||||
): Phaser.Tweens.Tween {
|
||||
return scene.tweens.add({
|
||||
targets: target,
|
||||
alpha: 0.8,
|
||||
duration: ANIMATIONS.selectionFadeIn,
|
||||
ease: "Power2",
|
||||
onComplete: onCompleteFadeIn,
|
||||
});
|
||||
}
|
||||
|
||||
export function createSelectionRingPulseTween(
|
||||
scene: TweenScene,
|
||||
target: Phaser.GameObjects.GameObject,
|
||||
): Phaser.Tweens.Tween {
|
||||
return scene.tweens.add({
|
||||
targets: target,
|
||||
scale: 1.15,
|
||||
alpha: 0.6,
|
||||
duration: ANIMATIONS.selectionPulse,
|
||||
ease: "Sine.easeInOut",
|
||||
yoyo: true,
|
||||
repeat: -1,
|
||||
});
|
||||
}
|
||||
|
||||
export function createSelectionHideTween(
|
||||
scene: TweenScene,
|
||||
target: Phaser.GameObjects.GameObject,
|
||||
onComplete: () => void,
|
||||
): Phaser.Tweens.Tween {
|
||||
return scene.tweens.add({
|
||||
targets: target,
|
||||
alpha: 0,
|
||||
scale: 0.9,
|
||||
duration: ANIMATIONS.selectionFadeOut,
|
||||
ease: "Power2",
|
||||
onComplete: () => {
|
||||
target.destroy();
|
||||
onComplete();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Overlay Tweens
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function createWinnerPulseTween(
|
||||
scene: TweenScene,
|
||||
target: Phaser.GameObjects.GameObject,
|
||||
): Phaser.Tweens.Tween {
|
||||
return scene.tweens.add({
|
||||
targets: target,
|
||||
scale: 1.2,
|
||||
duration: ANIMATIONS.winnerPulse,
|
||||
yoyo: true,
|
||||
repeat: 1,
|
||||
});
|
||||
}
|
||||
@@ -1 +1,25 @@
|
||||
export * from "boardgame-core/samples/onitama";
|
||||
/**
|
||||
* Re-export onitama game module from boardgame-core
|
||||
* This provides a convenient import path within onitama-game
|
||||
*/
|
||||
export {
|
||||
registry,
|
||||
prompts,
|
||||
start,
|
||||
createInitialState,
|
||||
initializeCards,
|
||||
getAvailableMoves,
|
||||
isValidMove,
|
||||
getCardMoveCandidates,
|
||||
createRegions,
|
||||
createCards,
|
||||
createPawns,
|
||||
createGameInfo,
|
||||
type OnitamaState,
|
||||
type OnitamaGame,
|
||||
type PlayerType,
|
||||
type Card,
|
||||
type CardData,
|
||||
type Pawn,
|
||||
type RegionType,
|
||||
} from "boardgame-core/samples/onitama";
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { h } from 'preact';
|
||||
import { GameUI } from 'boardgame-phaser';
|
||||
import * as gameModule from './game/onitama';
|
||||
import './style.css';
|
||||
import { GameUI } from "boardgame-phaser";
|
||||
|
||||
import "./style.css";
|
||||
import App from "@/ui/App";
|
||||
import {OnitamaScene} from "@/scenes/OnitamaScene";
|
||||
|
||||
const ui = new GameUI({
|
||||
container: document.getElementById('ui-root')!,
|
||||
root: <App gameModule={gameModule} gameScene={OnitamaScene}/>,
|
||||
container: "ui-root",
|
||||
root: <App />,
|
||||
});
|
||||
|
||||
ui.mount();
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { Card } from "@/game/onitama";
|
||||
import type { OnitamaScene } from "@/scenes/OnitamaScene";
|
||||
|
||||
import {
|
||||
CARD_WIDTH,
|
||||
CARD_HEIGHT,
|
||||
COLORS,
|
||||
FONTS,
|
||||
CARD_GRID,
|
||||
VISUAL,
|
||||
} from "@/config";
|
||||
|
||||
export interface CardRenderOptions {
|
||||
card: Card;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renderer for card game objects
|
||||
* Extracts visual creation logic from CardContainer
|
||||
*/
|
||||
export class CardRenderer {
|
||||
constructor(private readonly scene: OnitamaScene) {}
|
||||
|
||||
/**
|
||||
* Render card visuals into a container
|
||||
* @param container - The container to add visuals to
|
||||
* @param options - Card rendering options
|
||||
*/
|
||||
render(
|
||||
container: Phaser.GameObjects.Container,
|
||||
options: CardRenderOptions,
|
||||
): void {
|
||||
const { card } = options;
|
||||
|
||||
// Create background rectangle
|
||||
const bg = this.scene.add
|
||||
.rectangle(0, 0, CARD_WIDTH, CARD_HEIGHT, COLORS.cardBg, 1)
|
||||
.setStrokeStyle(VISUAL.cardStrokeWidth, COLORS.cardStroke);
|
||||
container.add(bg);
|
||||
|
||||
// Create title text
|
||||
const title = this.scene.add
|
||||
.text(
|
||||
0,
|
||||
-CARD_HEIGHT / 2 + VISUAL.cardTitleOffset,
|
||||
card.id,
|
||||
FONTS.cardTitle,
|
||||
)
|
||||
.setOrigin(0.5);
|
||||
container.add(title);
|
||||
|
||||
// Create move candidate grid
|
||||
this.renderMoveGrid(container, card);
|
||||
|
||||
// Create starting player text
|
||||
const playerText = this.scene.add
|
||||
.text(
|
||||
0,
|
||||
CARD_HEIGHT / 2 - VISUAL.cardPlayerOffset,
|
||||
card.startingPlayer,
|
||||
FONTS.cardPlayer,
|
||||
)
|
||||
.setOrigin(0.5);
|
||||
container.add(playerText);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the 5x5 grid showing move candidates
|
||||
*/
|
||||
private renderMoveGrid(
|
||||
container: Phaser.GameObjects.Container,
|
||||
card: Card,
|
||||
): void {
|
||||
const grid = this.scene.add.graphics();
|
||||
const { cellSize, gridSize } = CARD_GRID;
|
||||
const gridWidth = gridSize * cellSize;
|
||||
const gridHeight = gridSize * cellSize;
|
||||
const gridStartX = -gridWidth / 2;
|
||||
const gridStartY = -gridHeight / 2 + 20;
|
||||
|
||||
for (let row = 0; row < gridSize; row++) {
|
||||
for (let col = 0; col < gridSize; col++) {
|
||||
const x = gridStartX + col * cellSize;
|
||||
const y = gridStartY + row * cellSize;
|
||||
const centerX = x + cellSize / 2;
|
||||
const centerY = y + cellSize / 2;
|
||||
const radius = cellSize / 3;
|
||||
|
||||
// Center position marker
|
||||
if (row === 2 && col === 2) {
|
||||
grid.fillStyle(COLORS.cardCenter, 1);
|
||||
grid.fillCircle(centerX, centerY, radius);
|
||||
} else {
|
||||
// Check if this cell is a move candidate
|
||||
const isTarget = card.moveCandidates.some(
|
||||
(m) => m.dx === col - 2 && m.dy === 2 - row,
|
||||
);
|
||||
if (isTarget) {
|
||||
grid.fillStyle(COLORS.cardTarget, 0.6);
|
||||
grid.fillCircle(centerX, centerY, radius);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
container.add(grid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a standalone card visual at the specified position
|
||||
* Useful for previews or temporary displays
|
||||
*/
|
||||
createStandalone(
|
||||
x: number,
|
||||
y: number,
|
||||
options: CardRenderOptions,
|
||||
): Phaser.GameObjects.Container {
|
||||
const container = this.scene.add.container(x, y);
|
||||
this.render(container, options);
|
||||
return container;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { OnitamaScene } from "@/scenes/OnitamaScene";
|
||||
|
||||
import {
|
||||
COLORS,
|
||||
VISUAL,
|
||||
createHighlightInnerPulseTween,
|
||||
createHighlightOuterPulseTween,
|
||||
} from "@/config";
|
||||
|
||||
export interface HighlightRenderOptions {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renderer for move target highlight visuals
|
||||
* Extracts visual creation logic from HighlightSpawner
|
||||
*/
|
||||
export class HighlightRenderer {
|
||||
constructor(private readonly scene: OnitamaScene) {}
|
||||
|
||||
/**
|
||||
* Render highlight visuals into a container
|
||||
* @param container - The container to add visuals to
|
||||
* @param options - Highlight rendering options
|
||||
*/
|
||||
render(
|
||||
container: Phaser.GameObjects.Container,
|
||||
options: HighlightRenderOptions,
|
||||
): void {
|
||||
const { x, y } = options;
|
||||
|
||||
// Set container position
|
||||
container.setPosition(x, y);
|
||||
|
||||
// Outer circle (animated pulse)
|
||||
const outerCircle = this.scene.add.circle(
|
||||
0,
|
||||
0,
|
||||
VISUAL.highlightOuterRadius,
|
||||
COLORS.black,
|
||||
0.2,
|
||||
);
|
||||
container.add(outerCircle);
|
||||
|
||||
// Inner circle
|
||||
const innerCircle = this.scene.add.circle(
|
||||
0,
|
||||
0,
|
||||
VISUAL.highlightInnerRadius,
|
||||
COLORS.black,
|
||||
0.4,
|
||||
);
|
||||
container.add(innerCircle);
|
||||
|
||||
// Store references for animation
|
||||
container.setData("outerCircle", outerCircle);
|
||||
container.setData("innerCircle", innerCircle);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a standalone highlight visual at the specified position
|
||||
* Useful for previews or temporary displays
|
||||
*/
|
||||
createStandalone(x: number, y: number): Phaser.GameObjects.Container {
|
||||
const container = this.scene.add.container(x, y);
|
||||
this.render(container, { x, y });
|
||||
return container;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup pulse animations for highlight circles
|
||||
* @param container - The highlight container
|
||||
*/
|
||||
setupPulseAnimations(container: Phaser.GameObjects.Container): void {
|
||||
const outerCircle = container.getData("outerCircle") as
|
||||
| Phaser.GameObjects.Arc
|
||||
| undefined;
|
||||
const innerCircle = container.getData("innerCircle") as
|
||||
| Phaser.GameObjects.Arc
|
||||
| undefined;
|
||||
|
||||
if (!outerCircle || !innerCircle) return;
|
||||
|
||||
// Inner circle pulse
|
||||
createHighlightInnerPulseTween(this.scene, [outerCircle, innerCircle]);
|
||||
|
||||
// Outer circle staggered pulse
|
||||
createHighlightOuterPulseTween(this.scene, outerCircle);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { OnitamaScene } from "@/scenes/OnitamaScene";
|
||||
|
||||
import { CELL_SIZE, COLORS, FONTS, VISUAL } from "@/config";
|
||||
|
||||
export type PawnType = "master" | "student";
|
||||
export type PawnOwner = "red" | "black";
|
||||
|
||||
export interface PawnRenderOptions {
|
||||
owner: PawnOwner;
|
||||
type: PawnType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renderer for pawn game objects
|
||||
* Extracts visual creation logic from PawnContainer
|
||||
*/
|
||||
export class PawnRenderer {
|
||||
constructor(private readonly scene: OnitamaScene) {}
|
||||
|
||||
/**
|
||||
* Render pawn visuals into a container
|
||||
* @param container - The container to add visuals to
|
||||
* @param options - Pawn rendering options
|
||||
*/
|
||||
render(
|
||||
container: Phaser.GameObjects.Container,
|
||||
options: PawnRenderOptions,
|
||||
): void {
|
||||
const { owner, type } = options;
|
||||
|
||||
// Create background circle
|
||||
const bgColor = owner === "red" ? COLORS.red : COLORS.black;
|
||||
const circle = this.scene.add
|
||||
.circle(0, 0, VISUAL.pawnRadius, bgColor, 1)
|
||||
.setStrokeStyle(VISUAL.pawnStrokeWidth, COLORS.pawnStroke);
|
||||
container.add(circle);
|
||||
|
||||
// Create label text
|
||||
const label = type === "master" ? "M" : "S";
|
||||
const text = this.scene.add
|
||||
.text(0, 0, label, FONTS.pawnLabel)
|
||||
.setOrigin(0.5);
|
||||
container.add(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a standalone pawn visual (circle + text) at the specified position
|
||||
* Useful for previews or temporary displays
|
||||
*/
|
||||
createStandalone(
|
||||
x: number,
|
||||
y: number,
|
||||
options: PawnRenderOptions,
|
||||
): Phaser.GameObjects.Container {
|
||||
const container = this.scene.add.container(x, y);
|
||||
this.render(container, options);
|
||||
return container;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { GameObjects } from "phaser";
|
||||
|
||||
import type { OnitamaScene } from "@/scenes/OnitamaScene";
|
||||
|
||||
import {
|
||||
CELL_SIZE,
|
||||
COLORS,
|
||||
VISUAL,
|
||||
createSelectionShowTween,
|
||||
createSelectionRingPulseTween,
|
||||
createSelectionHideTween,
|
||||
} from "@/config";
|
||||
|
||||
export interface SelectionRenderOptions {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renderer for pawn selection ring visuals
|
||||
* Extracts selection ring creation and animation logic from PawnContainer
|
||||
*/
|
||||
export class SelectionRenderer {
|
||||
constructor(private readonly scene: OnitamaScene) {}
|
||||
|
||||
/**
|
||||
* Create a selection ring visual
|
||||
* @param parent - The parent container or game object to add the ring to
|
||||
* @returns The selection ring game object
|
||||
*/
|
||||
create(
|
||||
parent: Phaser.GameObjects.Container | Phaser.GameObjects.GameObject,
|
||||
): Phaser.GameObjects.Arc {
|
||||
const ring = this.scene.add
|
||||
.arc(
|
||||
0,
|
||||
0,
|
||||
VISUAL.pawnRadius + VISUAL.selectionRingOffset,
|
||||
0,
|
||||
360,
|
||||
false,
|
||||
COLORS.highlight,
|
||||
0,
|
||||
)
|
||||
.setStrokeStyle(
|
||||
VISUAL.selectionRingStrokeWidth,
|
||||
COLORS.highlightStroke,
|
||||
1,
|
||||
)
|
||||
.setAlpha(0);
|
||||
|
||||
// Add to parent at index 0 (behind other visuals)
|
||||
if (parent instanceof GameObjects.Container) {
|
||||
parent.addAt(ring, 0);
|
||||
}
|
||||
|
||||
return ring;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show selection with fade-in and pulse animation
|
||||
* @param ring - The selection ring to animate
|
||||
* @returns Cleanup function to stop animations
|
||||
*/
|
||||
show(ring: Phaser.GameObjects.Arc): () => void {
|
||||
if (!ring.active) return () => {};
|
||||
|
||||
let pulseTween: Phaser.Tweens.Tween | null = null;
|
||||
const tweens = this.scene.tweens;
|
||||
|
||||
// Fade in animation
|
||||
// DO NOT add interruption here
|
||||
// otherwise the game will stop indefinitely
|
||||
createSelectionShowTween(this.scene, ring, () => {
|
||||
// Start pulse animation after fade-in completes
|
||||
pulseTween = createSelectionRingPulseTween(this.scene, ring);
|
||||
});
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
if (pulseTween) {
|
||||
pulseTween.stop();
|
||||
pulseTween = null;
|
||||
}
|
||||
tweens.killTweensOf(ring);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide selection with fade-out animation
|
||||
* @param ring - The selection ring to animate
|
||||
* @param onComplete - Callback when animation completes
|
||||
*/
|
||||
hide(ring: Phaser.GameObjects.Arc, onComplete?: () => void): void {
|
||||
if (!ring.active) {
|
||||
onComplete?.();
|
||||
return;
|
||||
}
|
||||
|
||||
const tweens = this.scene.tweens;
|
||||
|
||||
// Stop any existing tweens on this ring
|
||||
tweens.killTweensOf(ring);
|
||||
|
||||
// Fade out animation
|
||||
createSelectionHideTween(this.scene, ring, () => onComplete?.());
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a standalone selection ring at the specified position
|
||||
* Useful for previews or temporary displays
|
||||
*/
|
||||
createStandalone(x: number, y: number): Phaser.GameObjects.Container {
|
||||
const container = this.scene.add.container(x, y);
|
||||
this.create(container);
|
||||
return container;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export { PawnRenderer } from "./PawnRenderer";
|
||||
export type { PawnRenderOptions, PawnType, PawnOwner } from "./PawnRenderer";
|
||||
|
||||
export { CardRenderer } from "./CardRenderer";
|
||||
export type { CardRenderOptions } from "./CardRenderer";
|
||||
|
||||
export { HighlightRenderer } from "./HighlightRenderer";
|
||||
export type { HighlightRenderOptions } from "./HighlightRenderer";
|
||||
|
||||
export { SelectionRenderer } from "./SelectionRenderer";
|
||||
export type { SelectionRenderOptions } from "./SelectionRenderer";
|
||||
@@ -0,0 +1,125 @@
|
||||
import { ReactiveScene } from "boardgame-phaser";
|
||||
|
||||
import { MENU_CONFIG, ANIMATIONS } from "@/config";
|
||||
|
||||
export class MenuScene extends ReactiveScene {
|
||||
private titleText!: Phaser.GameObjects.Text;
|
||||
private startButtonContainer!: Phaser.GameObjects.Container;
|
||||
private startButtonBg!: Phaser.GameObjects.Rectangle;
|
||||
private startButtonText!: Phaser.GameObjects.Text;
|
||||
|
||||
constructor() {
|
||||
super("MenuScene");
|
||||
}
|
||||
|
||||
create(): void {
|
||||
super.create();
|
||||
|
||||
const center = this.getCenterPosition();
|
||||
|
||||
this.createTitle(center);
|
||||
this.createStartButton(center);
|
||||
this.createSubtitle(center);
|
||||
}
|
||||
|
||||
/** 获取屏幕中心位置 */
|
||||
private getCenterPosition(): { x: number; y: number } {
|
||||
return {
|
||||
x: this.game.scale.width / 2,
|
||||
y: this.game.scale.height / 2,
|
||||
};
|
||||
}
|
||||
|
||||
/** 创建标题文本 */
|
||||
private createTitle(center: { x: number; y: number }): void {
|
||||
this.titleText = this.add
|
||||
.text(center.x, center.y + MENU_CONFIG.positions.titleY, "Onitama", {
|
||||
fontSize: MENU_CONFIG.fontSize.title,
|
||||
fontFamily: "Arial",
|
||||
color: MENU_CONFIG.colors.title,
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
|
||||
// 标题入场动画
|
||||
this.titleText.setScale(0);
|
||||
this.tweens.add({
|
||||
targets: this.titleText,
|
||||
scale: 1,
|
||||
duration: ANIMATIONS.menuTitle,
|
||||
ease: "Back.easeOut",
|
||||
});
|
||||
}
|
||||
|
||||
/** 创建开始按钮 */
|
||||
private createStartButton(center: { x: number; y: number }): void {
|
||||
const { button, colors } = MENU_CONFIG;
|
||||
|
||||
this.startButtonBg = this.add
|
||||
.rectangle(0, 0, button.width, button.height, colors.buttonBg)
|
||||
.setOrigin(0.5)
|
||||
.setInteractive({ useHandCursor: true });
|
||||
|
||||
this.startButtonText = this.add
|
||||
.text(0, 0, "Start Game", {
|
||||
fontSize: MENU_CONFIG.fontSize.button,
|
||||
fontFamily: "Arial",
|
||||
color: colors.buttonText,
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
|
||||
this.startButtonContainer = this.add.container(
|
||||
center.x,
|
||||
center.y + MENU_CONFIG.positions.buttonY,
|
||||
[this.startButtonBg, this.startButtonText],
|
||||
);
|
||||
|
||||
// 按钮交互
|
||||
this.setupButtonInteraction();
|
||||
}
|
||||
|
||||
/** 设置按钮交互效果 */
|
||||
private setupButtonInteraction(): void {
|
||||
this.startButtonBg.on("pointerover", () => {
|
||||
this.startButtonBg.setFillStyle(MENU_CONFIG.colors.buttonBgHover);
|
||||
this.tweens.add({
|
||||
targets: this.startButtonContainer,
|
||||
scale: 1.05,
|
||||
duration: ANIMATIONS.buttonHover,
|
||||
});
|
||||
});
|
||||
|
||||
this.startButtonBg.on("pointerout", () => {
|
||||
this.startButtonBg.setFillStyle(MENU_CONFIG.colors.buttonBg);
|
||||
this.tweens.add({
|
||||
targets: this.startButtonContainer,
|
||||
scale: 1,
|
||||
duration: ANIMATIONS.buttonHover,
|
||||
});
|
||||
});
|
||||
|
||||
this.startButtonBg.on("pointerdown", () => {
|
||||
this.startGame();
|
||||
});
|
||||
}
|
||||
|
||||
/** 创建副标题 */
|
||||
private createSubtitle(center: { x: number; y: number }): void {
|
||||
this.add
|
||||
.text(
|
||||
center.x,
|
||||
center.y + MENU_CONFIG.positions.subtitleY,
|
||||
"Click to start playing",
|
||||
{
|
||||
fontSize: MENU_CONFIG.fontSize.subtitle,
|
||||
fontFamily: "Arial",
|
||||
color: MENU_CONFIG.colors.subtitle,
|
||||
},
|
||||
)
|
||||
.setOrigin(0.5);
|
||||
}
|
||||
|
||||
/** 开始游戏 */
|
||||
private async startGame(): Promise<void> {
|
||||
await this.sceneController.launch("OnitamaScene");
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,38 @@
|
||||
import Phaser from 'phaser';
|
||||
import type { OnitamaState, Pawn } from '@/game/onitama';
|
||||
import {getAvailableMoves, prompts} from '@/game/onitama';
|
||||
import { GameHostScene } from 'boardgame-phaser';
|
||||
import { spawnEffect } from 'boardgame-phaser';
|
||||
import type { MutableSignal } from 'boardgame-core';
|
||||
import { GameHostScene, spawnEffect } from "boardgame-phaser";
|
||||
|
||||
import type { OnitamaState, Pawn } from "@/game/onitama";
|
||||
import type { HighlightData } from "@/spawners/HighlightSpawner";
|
||||
import type { OnitamaUIState } from "@/state";
|
||||
import type { MutableSignal } from "boardgame-core";
|
||||
import type Phaser from "phaser";
|
||||
|
||||
import {
|
||||
PawnSpawner, CardSpawner, BOARD_OFFSET, CELL_SIZE, CARD_WIDTH, CARD_HEIGHT, boardToScreen, BOARD_SIZE,
|
||||
HighlightSpawner
|
||||
} from '@/spawners';
|
||||
import type { HighlightData } from '@/spawners/HighlightSpawner';
|
||||
import {createUIState, clearSelection, selectPiece, selectCard, createValidMoves} from '@/state';
|
||||
import type { OnitamaUIState, ValidMove } from '@/state';
|
||||
COLORS,
|
||||
FONTS,
|
||||
MENU_BUTTON,
|
||||
TEXT_POSITION,
|
||||
VISUAL,
|
||||
getBoardCenter,
|
||||
getCardLabelPosition,
|
||||
colorToStr,
|
||||
createWinnerPulseTween,
|
||||
} from "@/config";
|
||||
import { prompts } from "@/game/onitama";
|
||||
import {
|
||||
PawnSpawner,
|
||||
CardSpawner,
|
||||
BOARD_OFFSET,
|
||||
CELL_SIZE,
|
||||
BOARD_SIZE,
|
||||
boardToScreen,
|
||||
HighlightSpawner,
|
||||
} from "@/spawners";
|
||||
import {
|
||||
createUIState,
|
||||
clearSelection,
|
||||
selectPiece,
|
||||
selectCard,
|
||||
} from "@/state";
|
||||
|
||||
export class OnitamaScene extends GameHostScene<OnitamaState> {
|
||||
private boardContainer!: Phaser.GameObjects.Container;
|
||||
@@ -18,12 +40,15 @@ export class OnitamaScene extends GameHostScene<OnitamaState> {
|
||||
private infoText!: Phaser.GameObjects.Text;
|
||||
private winnerOverlay?: Phaser.GameObjects.Container;
|
||||
private cardLabelContainers: Map<string, Phaser.GameObjects.Text> = new Map();
|
||||
private menuButtonContainer!: Phaser.GameObjects.Container;
|
||||
private menuButtonBg!: Phaser.GameObjects.Rectangle;
|
||||
private menuButtonText!: Phaser.GameObjects.Text;
|
||||
|
||||
// UI State managed by MutableSignal
|
||||
public uiState!: MutableSignal<OnitamaUIState>;
|
||||
|
||||
constructor() {
|
||||
super('OnitamaScene');
|
||||
super("OnitamaScene");
|
||||
}
|
||||
|
||||
create(): void {
|
||||
@@ -57,14 +82,10 @@ export class OnitamaScene extends GameHostScene<OnitamaState> {
|
||||
|
||||
// Info text
|
||||
this.infoText = this.add.text(
|
||||
20,
|
||||
TEXT_POSITION.infoX,
|
||||
BOARD_OFFSET.y,
|
||||
'',
|
||||
{
|
||||
fontSize: '15px',
|
||||
fontFamily: 'Arial',
|
||||
color: '#4b5563',
|
||||
}
|
||||
"",
|
||||
FONTS.info,
|
||||
);
|
||||
|
||||
// Update info text when UI state changes
|
||||
@@ -74,39 +95,34 @@ export class OnitamaScene extends GameHostScene<OnitamaState> {
|
||||
|
||||
// Input handling
|
||||
this.setupInput();
|
||||
|
||||
// Menu button
|
||||
this.createMenuButton();
|
||||
|
||||
// Start the game
|
||||
this.gameHost.start();
|
||||
}
|
||||
|
||||
private createCardLabels(): void {
|
||||
const boardLeft = BOARD_OFFSET.x;
|
||||
const boardTop = BOARD_OFFSET.y;
|
||||
const boardRight = BOARD_OFFSET.x + BOARD_SIZE * CELL_SIZE;
|
||||
const boardBottom = BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE;
|
||||
|
||||
// Red cards label - 棋盘下方
|
||||
const redLabel = this.add.text(
|
||||
boardLeft + (BOARD_SIZE * CELL_SIZE) / 2,
|
||||
boardBottom + 10,
|
||||
"RED",
|
||||
{
|
||||
fontSize: '16px',
|
||||
fontFamily: 'Arial',
|
||||
color: '#ef4444',
|
||||
}
|
||||
).setOrigin(0.5, 0);
|
||||
this.cardLabelContainers.set('red', redLabel);
|
||||
const redPos = getCardLabelPosition("red");
|
||||
const redLabel = this.add
|
||||
.text(redPos.x, redPos.y, "RED", {
|
||||
...FONTS.cardLabel,
|
||||
color: colorToStr(COLORS.red),
|
||||
})
|
||||
.setOrigin(redPos.originX, redPos.originY);
|
||||
this.cardLabelContainers.set("red", redLabel);
|
||||
|
||||
// Black cards label - 棋盘上方
|
||||
const blackLabel = this.add.text(
|
||||
boardLeft + (BOARD_SIZE * CELL_SIZE) / 2,
|
||||
boardTop - 10,
|
||||
"BLACK",
|
||||
{
|
||||
fontSize: '16px',
|
||||
fontFamily: 'Arial',
|
||||
color: '#3b82f6',
|
||||
}
|
||||
).setOrigin(0.5, 1);
|
||||
this.cardLabelContainers.set('black', blackLabel);
|
||||
const blackPos = getCardLabelPosition("black");
|
||||
const blackLabel = this.add
|
||||
.text(blackPos.x, blackPos.y, "BLACK", {
|
||||
...FONTS.cardLabel,
|
||||
color: colorToStr(COLORS.black),
|
||||
})
|
||||
.setOrigin(blackPos.originX, blackPos.originY);
|
||||
this.cardLabelContainers.set("black", blackLabel);
|
||||
}
|
||||
|
||||
private updateInfoText(): void {
|
||||
@@ -121,34 +137,30 @@ export class OnitamaScene extends GameHostScene<OnitamaState> {
|
||||
|
||||
private drawBoard(): void {
|
||||
const g = this.gridGraphics;
|
||||
g.lineStyle(2, 0x6b7280);
|
||||
g.lineStyle(2, COLORS.gridLine);
|
||||
|
||||
for (let i = 0; i <= BOARD_SIZE; i++) {
|
||||
g.lineBetween(
|
||||
BOARD_OFFSET.x + i * CELL_SIZE,
|
||||
BOARD_OFFSET.y,
|
||||
BOARD_OFFSET.x + i * CELL_SIZE,
|
||||
BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE
|
||||
BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE,
|
||||
);
|
||||
g.lineBetween(
|
||||
BOARD_OFFSET.x,
|
||||
BOARD_OFFSET.y + i * CELL_SIZE,
|
||||
BOARD_OFFSET.x + BOARD_SIZE * CELL_SIZE,
|
||||
BOARD_OFFSET.y + i * CELL_SIZE
|
||||
BOARD_OFFSET.y + i * CELL_SIZE,
|
||||
);
|
||||
}
|
||||
|
||||
g.strokePath();
|
||||
|
||||
this.add.text(
|
||||
20,
|
||||
20,
|
||||
'Onitama',
|
||||
{
|
||||
fontSize: '28px',
|
||||
fontFamily: 'Arial',
|
||||
color: '#1f2937',
|
||||
}
|
||||
TEXT_POSITION.titleX,
|
||||
TEXT_POSITION.titleY,
|
||||
"Onitama",
|
||||
FONTS.title,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -158,9 +170,11 @@ export class OnitamaScene extends GameHostScene<OnitamaState> {
|
||||
for (let col = 0; col < BOARD_SIZE; col++) {
|
||||
const pos = boardToScreen(col, row);
|
||||
|
||||
const zone = this.add.zone(pos.x, pos.y, CELL_SIZE, CELL_SIZE).setInteractive();
|
||||
const zone = this.add
|
||||
.zone(pos.x, pos.y, CELL_SIZE, CELL_SIZE)
|
||||
.setInteractive();
|
||||
|
||||
zone.on('pointerdown', () => {
|
||||
zone.on("pointerdown", () => {
|
||||
if (this.state.winner) return;
|
||||
this.handleCellClick(col, row);
|
||||
});
|
||||
@@ -170,7 +184,7 @@ export class OnitamaScene extends GameHostScene<OnitamaState> {
|
||||
|
||||
private handleCellClick(x: number, y: number): void {
|
||||
const pawn = this.getPawnAtPosition(x, y);
|
||||
if(pawn?.owner !== this.state.currentPlayer){
|
||||
if (pawn?.owner !== this.state.currentPlayer) {
|
||||
return;
|
||||
}
|
||||
selectPiece(this.uiState, x, y);
|
||||
@@ -179,7 +193,8 @@ export class OnitamaScene extends GameHostScene<OnitamaState> {
|
||||
public onCardClick(cardId: string): void {
|
||||
// 只能选择当前玩家的手牌
|
||||
const currentPlayer = this.state.currentPlayer;
|
||||
const playerCards = currentPlayer === 'red' ? this.state.redCards : this.state.blackCards;
|
||||
const playerCards =
|
||||
currentPlayer === "red" ? this.state.redCards : this.state.blackCards;
|
||||
|
||||
if (!playerCards.includes(cardId)) {
|
||||
return;
|
||||
@@ -199,7 +214,13 @@ export class OnitamaScene extends GameHostScene<OnitamaState> {
|
||||
});
|
||||
}
|
||||
|
||||
private executeMove(move: { card: string; fromX: number; fromY: number; toX: number; toY: number }): void {
|
||||
private executeMove(move: {
|
||||
card: string;
|
||||
fromX: number;
|
||||
fromY: number;
|
||||
toX: number;
|
||||
toY: number;
|
||||
}): void {
|
||||
const error = this.gameHost.tryAnswerPrompt(
|
||||
prompts.move,
|
||||
this.state.currentPlayer,
|
||||
@@ -207,10 +228,10 @@ export class OnitamaScene extends GameHostScene<OnitamaState> {
|
||||
move.fromX,
|
||||
move.fromY,
|
||||
move.toX,
|
||||
move.toY
|
||||
move.toY,
|
||||
);
|
||||
if (error) {
|
||||
console.warn('Invalid move:', error);
|
||||
console.warn("Invalid move:", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,6 +241,46 @@ export class OnitamaScene extends GameHostScene<OnitamaState> {
|
||||
return pawnId ? this.state.pawns[pawnId] : null;
|
||||
}
|
||||
|
||||
/** 创建菜单按钮 */
|
||||
private createMenuButton(): void {
|
||||
this.menuButtonBg = this.add
|
||||
.rectangle(
|
||||
MENU_BUTTON.x,
|
||||
MENU_BUTTON.y,
|
||||
MENU_BUTTON.width,
|
||||
MENU_BUTTON.height,
|
||||
COLORS.menuButton,
|
||||
)
|
||||
.setInteractive({ useHandCursor: true });
|
||||
|
||||
this.menuButtonText = this.add
|
||||
.text(MENU_BUTTON.x, MENU_BUTTON.y, "Menu", FONTS.menuButton)
|
||||
.setOrigin(0.5);
|
||||
|
||||
this.menuButtonContainer = this.add.container(
|
||||
MENU_BUTTON.x,
|
||||
MENU_BUTTON.y,
|
||||
[this.menuButtonBg, this.menuButtonText],
|
||||
);
|
||||
|
||||
this.menuButtonBg.on("pointerover", () => {
|
||||
this.menuButtonBg.setFillStyle(COLORS.menuButtonHover);
|
||||
});
|
||||
|
||||
this.menuButtonBg.on("pointerout", () => {
|
||||
this.menuButtonBg.setFillStyle(COLORS.menuButton);
|
||||
});
|
||||
|
||||
this.menuButtonBg.on("pointerdown", () => {
|
||||
this.goToMenu();
|
||||
});
|
||||
}
|
||||
|
||||
/** 跳转到菜单场景 */
|
||||
private async goToMenu(): Promise<void> {
|
||||
await this.sceneController.launch("MenuScene");
|
||||
}
|
||||
|
||||
private showWinner(winner: string): void {
|
||||
if (this.winnerOverlay) {
|
||||
this.winnerOverlay.destroy();
|
||||
@@ -227,42 +288,34 @@ export class OnitamaScene extends GameHostScene<OnitamaState> {
|
||||
|
||||
this.winnerOverlay = this.add.container();
|
||||
|
||||
const text = winner === 'draw' ? "It's a draw!" : `${winner} wins!`;
|
||||
const text = winner === "draw" ? "It's a draw!" : `${winner} wins!`;
|
||||
const center = getBoardCenter();
|
||||
const boardWidth = BOARD_SIZE * CELL_SIZE;
|
||||
const boardHeight = BOARD_SIZE * CELL_SIZE;
|
||||
|
||||
const bg = this.add.rectangle(
|
||||
BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2,
|
||||
BOARD_OFFSET.y + (BOARD_SIZE * CELL_SIZE) / 2,
|
||||
BOARD_SIZE * CELL_SIZE,
|
||||
BOARD_SIZE * CELL_SIZE,
|
||||
0x000000,
|
||||
0.6
|
||||
).setInteractive({ useHandCursor: true });
|
||||
const bg = this.add
|
||||
.rectangle(
|
||||
center.x,
|
||||
center.y,
|
||||
boardWidth,
|
||||
boardHeight,
|
||||
COLORS.overlayBg,
|
||||
VISUAL.overlayAlpha,
|
||||
)
|
||||
.setInteractive({ useHandCursor: true });
|
||||
|
||||
bg.on('pointerdown', () => {
|
||||
bg.on("pointerdown", () => {
|
||||
this.gameHost.start();
|
||||
});
|
||||
|
||||
this.winnerOverlay.add(bg);
|
||||
|
||||
const winText = this.add.text(
|
||||
BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2,
|
||||
BOARD_OFFSET.y + (BOARD_SIZE * CELL_SIZE) / 2,
|
||||
text,
|
||||
{
|
||||
fontSize: '36px',
|
||||
fontFamily: 'Arial',
|
||||
color: '#fbbf24',
|
||||
}
|
||||
).setOrigin(0.5);
|
||||
const winText = this.add
|
||||
.text(center.x, center.y, text, FONTS.winner)
|
||||
.setOrigin(0.5);
|
||||
|
||||
this.winnerOverlay.add(winText);
|
||||
|
||||
this.tweens.add({
|
||||
targets: winText,
|
||||
scale: 1.2,
|
||||
duration: 500,
|
||||
yoyo: true,
|
||||
repeat: 1,
|
||||
});
|
||||
createWinnerPulseTween(this, winText);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export { OnitamaScene } from "./OnitamaScene";
|
||||
export { MenuScene } from "./MenuScene";
|
||||
@@ -1,17 +1,31 @@
|
||||
import Phaser from 'phaser';
|
||||
import type { Card } from '@/game/onitama';
|
||||
import type { Spawner } from 'boardgame-phaser';
|
||||
import type { OnitamaScene } from '@/scenes/OnitamaScene';
|
||||
import { BOARD_OFFSET, CELL_SIZE } from './PawnSpawner';
|
||||
import { effect } from "@preact/signals-core";
|
||||
|
||||
export const CARD_WIDTH = 100;
|
||||
export const CARD_HEIGHT = 140;
|
||||
const BOARD_SIZE = 5;
|
||||
import type { Card } from "@/game/onitama";
|
||||
import type { OnitamaScene } from "@/scenes/OnitamaScene";
|
||||
import type { Spawner } from "boardgame-phaser";
|
||||
|
||||
import {
|
||||
CARD_WIDTH,
|
||||
CARD_HEIGHT,
|
||||
COLORS,
|
||||
VISUAL,
|
||||
getCardPosition,
|
||||
createCardMoveTween,
|
||||
createCardRotateTween,
|
||||
createCardSpawnTween,
|
||||
createCardDespawnTween,
|
||||
createSelectionFadeInTween,
|
||||
createSelectionPulseTween,
|
||||
createSelectionFadeOutTween,
|
||||
} from "@/config";
|
||||
import { CardRenderer } from "@/renderers";
|
||||
|
||||
// Re-export for backward compatibility
|
||||
export { CARD_WIDTH, CARD_HEIGHT } from "@/config";
|
||||
|
||||
export interface CardSpawnData {
|
||||
cardId: string;
|
||||
position: 'red' | 'black' | 'spare';
|
||||
position: "red" | "black" | "spare";
|
||||
index: number;
|
||||
}
|
||||
|
||||
@@ -24,11 +38,18 @@ export class CardContainer extends Phaser.GameObjects.Container {
|
||||
private highlightTween: Phaser.Tweens.Tween | null = null;
|
||||
private _cardId: string;
|
||||
private _rotation: number = 0;
|
||||
private cardRenderer: CardRenderer;
|
||||
|
||||
constructor(scene: OnitamaScene, cardId: string, card: Card, rotation: number = 0) {
|
||||
constructor(
|
||||
scene: OnitamaScene,
|
||||
cardId: string,
|
||||
card: Card,
|
||||
rotation: number = 0,
|
||||
) {
|
||||
super(scene, 0, 0);
|
||||
this._cardId = cardId;
|
||||
this._rotation = rotation;
|
||||
this.cardRenderer = new CardRenderer(scene);
|
||||
|
||||
// 将容器添加到场景
|
||||
scene.add.existing(this);
|
||||
@@ -36,11 +57,16 @@ export class CardContainer extends Phaser.GameObjects.Container {
|
||||
// 应用旋转
|
||||
this.angle = rotation;
|
||||
|
||||
// 创建卡牌视觉
|
||||
this.createCardVisual(card);
|
||||
// 使用 CardRenderer 创建卡牌视觉
|
||||
this.cardRenderer.render(this, { card });
|
||||
|
||||
// 使卡牌可点击
|
||||
const hitArea = new Phaser.Geom.Rectangle(-CARD_WIDTH / 2, -CARD_HEIGHT / 2, CARD_WIDTH, CARD_HEIGHT);
|
||||
const hitArea = new Phaser.Geom.Rectangle(
|
||||
-CARD_WIDTH / 2,
|
||||
-CARD_HEIGHT / 2,
|
||||
CARD_WIDTH,
|
||||
CARD_HEIGHT,
|
||||
);
|
||||
this.setInteractive(hitArea, Phaser.Geom.Rectangle.Contains);
|
||||
|
||||
// 添加场景 effect 监听高亮状态变化
|
||||
@@ -53,13 +79,12 @@ export class CardContainer extends Phaser.GameObjects.Container {
|
||||
setRotation(rotation: number, animated: boolean = false): this {
|
||||
if (animated) {
|
||||
const currentAngle = this.angle;
|
||||
const tween = this.scene.tweens.add({
|
||||
targets: this,
|
||||
angle: { from: currentAngle, to: rotation },
|
||||
duration: 400,
|
||||
ease: 'Back.easeOut',
|
||||
});
|
||||
(this.scene as OnitamaScene).addTweenInterruption(tween);
|
||||
createCardRotateTween(
|
||||
this.scene as OnitamaScene,
|
||||
this,
|
||||
currentAngle,
|
||||
rotation,
|
||||
);
|
||||
} else {
|
||||
this.angle = rotation;
|
||||
}
|
||||
@@ -75,33 +100,22 @@ export class CardContainer extends Phaser.GameObjects.Container {
|
||||
|
||||
if (!this.highlightRect) {
|
||||
// 创建高亮边框(初始透明)
|
||||
this.highlightRect = (this.scene as OnitamaScene).add.rectangle(
|
||||
0, 0, CARD_WIDTH + 8, CARD_HEIGHT + 8, color, 0
|
||||
)
|
||||
const rect = (this.scene as OnitamaScene).add
|
||||
.rectangle(0, 0, CARD_WIDTH + 8, CARD_HEIGHT + 8, color, 0)
|
||||
.setStrokeStyle(lineWidth, color)
|
||||
.setAlpha(0)
|
||||
.setDepth(-1);
|
||||
.setDepth(VISUAL.cardBackgroundDepth);
|
||||
this.highlightRect = rect;
|
||||
this.addAt(this.highlightRect, 0);
|
||||
|
||||
// 淡入动画
|
||||
const fadeIn = this.scene.tweens.add({
|
||||
targets: this.highlightRect,
|
||||
alpha: 1,
|
||||
scale: 1.05,
|
||||
duration: 200,
|
||||
ease: 'Power2',
|
||||
onComplete: () => {
|
||||
// 淡入完成后开始脉冲动画
|
||||
this.highlightTween = this.scene.tweens.add({
|
||||
targets: this.highlightRect,
|
||||
alpha: 0.7,
|
||||
lineWidth: lineWidth + 1,
|
||||
duration: 500,
|
||||
ease: 'Sine.easeInOut',
|
||||
yoyo: true,
|
||||
repeat: -1,
|
||||
});
|
||||
},
|
||||
createSelectionFadeInTween(this.scene as OnitamaScene, rect, () => {
|
||||
// 淡入完成后开始脉冲动画
|
||||
this.highlightTween = createSelectionPulseTween(
|
||||
this.scene as OnitamaScene,
|
||||
rect,
|
||||
lineWidth,
|
||||
);
|
||||
});
|
||||
} else {
|
||||
// 如果已经存在,停止当前动画并重新开始脉冲
|
||||
@@ -109,15 +123,11 @@ export class CardContainer extends Phaser.GameObjects.Container {
|
||||
this.highlightTween.stop();
|
||||
}
|
||||
this.highlightRect.setStrokeStyle(lineWidth, color);
|
||||
this.highlightTween = this.scene.tweens.add({
|
||||
targets: this.highlightRect,
|
||||
alpha: 0.7,
|
||||
lineWidth: lineWidth + 1,
|
||||
duration: 500,
|
||||
ease: 'Sine.easeInOut',
|
||||
yoyo: true,
|
||||
repeat: -1,
|
||||
});
|
||||
this.highlightTween = createSelectionPulseTween(
|
||||
this.scene as OnitamaScene,
|
||||
this.highlightRect,
|
||||
lineWidth,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,18 +144,15 @@ export class CardContainer extends Phaser.GameObjects.Container {
|
||||
this.scene.tweens.killTweensOf(this.highlightRect);
|
||||
|
||||
// 淡出动画
|
||||
this.scene.tweens.add({
|
||||
targets: this.highlightRect,
|
||||
alpha: 0,
|
||||
scale: 0.95,
|
||||
duration: 150,
|
||||
ease: 'Power2',
|
||||
onComplete: () => {
|
||||
createSelectionFadeOutTween(
|
||||
this.scene as OnitamaScene,
|
||||
this.highlightRect,
|
||||
() => {
|
||||
// 淡出完成后销毁矩形
|
||||
this.highlightRect?.destroy();
|
||||
this.highlightRect = null;
|
||||
},
|
||||
});
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,124 +163,50 @@ export class CardContainer extends Phaser.GameObjects.Container {
|
||||
// 创建一个 effect 来持续监听高亮状态变化
|
||||
const dispose = effect(() => {
|
||||
if (scene.uiState.value.selectedCard === this._cardId) {
|
||||
this.highlight(0xfbbf24, 3);
|
||||
this.highlight(COLORS.highlight, VISUAL.selectionRingStrokeWidth);
|
||||
} else {
|
||||
this.unhighlight();
|
||||
}
|
||||
});
|
||||
|
||||
// 在容器销毁时清理 effect
|
||||
this.on('destroy', () => {
|
||||
this.on("destroy", () => {
|
||||
dispose();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建卡牌视觉元素
|
||||
*/
|
||||
private createCardVisual(card: Card): void {
|
||||
const bg = (this.scene as OnitamaScene).add.rectangle(0, 0, CARD_WIDTH, CARD_HEIGHT, 0xf9fafb, 1)
|
||||
.setStrokeStyle(2, 0x6b7280);
|
||||
this.add(bg);
|
||||
|
||||
const title = (this.scene as OnitamaScene).add.text(0, -CARD_HEIGHT / 2 + 15, card.id, {
|
||||
fontSize: '12px',
|
||||
fontFamily: 'Arial',
|
||||
color: '#1f2937',
|
||||
}).setOrigin(0.5);
|
||||
this.add(title);
|
||||
|
||||
const grid = (this.scene as OnitamaScene).add.graphics();
|
||||
const cellSize = 16;
|
||||
const gridWidth = 5 * cellSize;
|
||||
const gridHeight = 5 * cellSize;
|
||||
const gridStartX = -gridWidth / 2;
|
||||
const gridStartY = -gridHeight / 2 + 30;
|
||||
|
||||
for (let row = 0; row < 5; row++) {
|
||||
for (let col = 0; col < 5; col++) {
|
||||
const x = gridStartX + col * cellSize;
|
||||
const y = gridStartY + row * cellSize;
|
||||
|
||||
if (row === 2 && col === 2) {
|
||||
grid.fillStyle(0x3b82f6, 1);
|
||||
grid.fillCircle(x + cellSize / 2, y + cellSize / 2, cellSize / 3);
|
||||
} else {
|
||||
const isTarget = card.moveCandidates.some(m => m.dx === col - 2 && m.dy === 2 - row);
|
||||
if (isTarget) {
|
||||
grid.fillStyle(0xef4444, 0.6);
|
||||
grid.fillCircle(x + cellSize / 2, y + cellSize / 2, cellSize / 3);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
this.add(grid);
|
||||
|
||||
const playerText = (this.scene as OnitamaScene).add.text(0, CARD_HEIGHT / 2 - 15, card.startingPlayer, {
|
||||
fontSize: '10px',
|
||||
fontFamily: 'Arial',
|
||||
color: '#6b7280',
|
||||
}).setOrigin(0.5);
|
||||
this.add(playerText);
|
||||
}
|
||||
}
|
||||
|
||||
export class CardSpawner implements Spawner<CardSpawnData, CardContainer> {
|
||||
private previousData = new Map<string, CardSpawnData>();
|
||||
private cardRenderer: CardRenderer;
|
||||
|
||||
constructor(public readonly scene: OnitamaScene) {}
|
||||
constructor(public readonly scene: OnitamaScene) {
|
||||
this.cardRenderer = new CardRenderer(scene);
|
||||
}
|
||||
|
||||
*getData(): Iterable<CardSpawnData> {
|
||||
const state = this.scene.state;
|
||||
|
||||
// 红方卡牌
|
||||
for (let i = 0; i < state.redCards.length; i++) {
|
||||
yield { cardId: state.redCards[i], position: 'red', index: i };
|
||||
yield { cardId: state.redCards[i], position: "red", index: i };
|
||||
}
|
||||
|
||||
// 黑方卡牌
|
||||
for (let i = 0; i < state.blackCards.length; i++) {
|
||||
yield { cardId: state.blackCards[i], position: 'black', index: i };
|
||||
yield { cardId: state.blackCards[i], position: "black", index: i };
|
||||
}
|
||||
|
||||
// 备用卡牌
|
||||
if(state.spareCard)
|
||||
yield { cardId: state.spareCard, position: 'spare', index: 0 };
|
||||
if (state.spareCard) {
|
||||
yield { cardId: state.spareCard, position: "spare", index: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
getKey(data: CardSpawnData): string {
|
||||
return data.cardId;
|
||||
}
|
||||
|
||||
private getCardPosition(data: CardSpawnData): { x: number, y: number } {
|
||||
const boardLeft = BOARD_OFFSET.x;
|
||||
const boardTop = BOARD_OFFSET.y;
|
||||
const boardRight = BOARD_OFFSET.x + BOARD_SIZE * CELL_SIZE;
|
||||
const boardCenterX = boardLeft + (BOARD_SIZE * CELL_SIZE) / 2;
|
||||
const boardBottom = BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE;
|
||||
|
||||
if (data.position === 'red') {
|
||||
// 红方卡牌在棋盘下方,水平排列
|
||||
return {
|
||||
x: boardLeft + BOARD_SIZE * CELL_SIZE / 2 - (data.index - 0.5) * (CARD_WIDTH + 15),
|
||||
y: boardBottom + CARD_HEIGHT - 30,
|
||||
};
|
||||
} else if (data.position === 'black') {
|
||||
// 黑方卡牌在棋盘上方,水平排列
|
||||
return {
|
||||
x: boardLeft + BOARD_SIZE * CELL_SIZE / 2 - (data.index - 0.5) * (CARD_WIDTH + 15),
|
||||
y: boardTop - CARD_HEIGHT + 30,
|
||||
};
|
||||
} else {
|
||||
// 备用卡牌在棋盘左侧,垂直居中
|
||||
const boardCenterY = boardTop + (BOARD_SIZE * CELL_SIZE) / 2;
|
||||
return {
|
||||
x: boardLeft - CARD_WIDTH,
|
||||
y: boardCenterY,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private hasPositionChanged(data: CardSpawnData): boolean {
|
||||
const prev = this.previousData.get(data.cardId);
|
||||
if (!prev) return true;
|
||||
@@ -281,13 +214,13 @@ export class CardSpawner implements Spawner<CardSpawnData, CardContainer> {
|
||||
}
|
||||
|
||||
onUpdate(data: CardSpawnData, obj: CardContainer): void {
|
||||
const isBlackTurn = this.scene.state.currentPlayer === 'black';
|
||||
const isBlackTurn = this.scene.state.currentPlayer === "black";
|
||||
|
||||
// 检查卡牌是否需要旋转
|
||||
let targetRotation = 0;
|
||||
if (data.position === 'black') {
|
||||
if (data.position === "black") {
|
||||
targetRotation = -180; // 黑方卡牌始终旋转
|
||||
} else if (data.position === 'spare' && isBlackTurn) {
|
||||
} else if (data.position === "spare" && isBlackTurn) {
|
||||
targetRotation = -180; // 备用卡牌在黑方回合旋转
|
||||
}
|
||||
|
||||
@@ -301,18 +234,10 @@ export class CardSpawner implements Spawner<CardSpawnData, CardContainer> {
|
||||
return;
|
||||
}
|
||||
|
||||
const pos = this.getCardPosition(data);
|
||||
const pos = getCardPosition(data.position, data.index);
|
||||
|
||||
// 播放移动动画并添加中断
|
||||
const tween = this.scene.tweens.add({
|
||||
targets: obj,
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
duration: 350,
|
||||
ease: 'Back.easeOut',
|
||||
});
|
||||
|
||||
this.scene.addTweenInterruption(tween);
|
||||
createCardMoveTween(this.scene, obj, pos.x, pos.y);
|
||||
this.previousData.set(data.cardId, { ...data });
|
||||
}
|
||||
|
||||
@@ -322,57 +247,58 @@ export class CardSpawner implements Spawner<CardSpawnData, CardContainer> {
|
||||
this.previousData.set(data.cardId, { ...data });
|
||||
// 返回空容器
|
||||
const emptyContainer = new CardContainer(this.scene, data.cardId, {
|
||||
id: data.cardId, regionId: '', position: [],
|
||||
id: data.cardId,
|
||||
regionId: "",
|
||||
position: [],
|
||||
moveCandidates: [],
|
||||
startingPlayer: 'red'
|
||||
startingPlayer: "red",
|
||||
} as Card);
|
||||
return emptyContainer;
|
||||
}
|
||||
|
||||
// 计算旋转角度 - 初始不旋转,后续由 onUpdate 处理
|
||||
const container = new CardContainer(this.scene, data.cardId, card, 0);
|
||||
const pos = this.getCardPosition(data);
|
||||
// 计算初始旋转角度
|
||||
const isBlackTurn = this.scene.state.currentPlayer === "black";
|
||||
let initialRotation = 0;
|
||||
if (data.position === "black") {
|
||||
initialRotation = -180;
|
||||
} else if (data.position === "spare" && isBlackTurn) {
|
||||
initialRotation = -180;
|
||||
}
|
||||
|
||||
const container = new CardContainer(
|
||||
this.scene,
|
||||
data.cardId,
|
||||
card,
|
||||
initialRotation,
|
||||
);
|
||||
const pos = getCardPosition(data.position, data.index);
|
||||
container.x = pos.x;
|
||||
container.y = pos.y;
|
||||
|
||||
// 设置悬停效果
|
||||
container.on('pointerover', () => {
|
||||
container.on("pointerover", () => {
|
||||
if (this.scene.uiState.value.selectedCard !== data.cardId) {
|
||||
container.setAlpha(0.8);
|
||||
container.setAlpha(VISUAL.cardDisabledAlpha);
|
||||
}
|
||||
});
|
||||
|
||||
container.on('pointerout', () => {
|
||||
container.on("pointerout", () => {
|
||||
container.setAlpha(1);
|
||||
});
|
||||
|
||||
container.on('pointerdown', () => {
|
||||
container.on("pointerdown", () => {
|
||||
this.scene.onCardClick(data.cardId);
|
||||
});
|
||||
|
||||
// 初始状态为透明,然后淡入
|
||||
container.setAlpha(0);
|
||||
const tween = this.scene.tweens.add({
|
||||
targets: container,
|
||||
alpha: 1,
|
||||
duration: 300,
|
||||
ease: 'Power2',
|
||||
});
|
||||
this.scene.addTweenInterruption(tween);
|
||||
createCardSpawnTween(this.scene, container);
|
||||
|
||||
this.previousData.set(data.cardId, { ...data });
|
||||
return container;
|
||||
}
|
||||
|
||||
onDespawn(obj: CardContainer): void {
|
||||
const tween = this.scene.tweens.add({
|
||||
targets: obj,
|
||||
alpha: 0,
|
||||
scale: 0.8,
|
||||
duration: 200,
|
||||
ease: 'Power2',
|
||||
onComplete: () => obj.destroy(),
|
||||
});
|
||||
this.scene.addTweenInterruption(tween);
|
||||
createCardDespawnTween(this.scene, obj, () => obj.destroy());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import Phaser from 'phaser';
|
||||
import type { Spawner } from 'boardgame-phaser';
|
||||
import type { OnitamaScene } from '@/scenes/OnitamaScene';
|
||||
import {getAvailableMoves} from "boardgame-core/samples/onitama";
|
||||
import {boardToScreen, CELL_SIZE} from './PawnSpawner';
|
||||
import { getAvailableMoves } from "boardgame-core/samples/onitama";
|
||||
import { Geom } from "phaser";
|
||||
|
||||
import type { OnitamaScene } from "@/scenes/OnitamaScene";
|
||||
import type { Spawner } from "boardgame-phaser";
|
||||
|
||||
import {
|
||||
boardToScreen,
|
||||
CELL_SIZE,
|
||||
VISUAL,
|
||||
createHighlightSpawnTween,
|
||||
createHighlightDespawnTween,
|
||||
createHighlightClickFeedbackTween,
|
||||
} from "@/config";
|
||||
import { HighlightRenderer } from "@/renderers";
|
||||
|
||||
export interface HighlightData {
|
||||
key: string;
|
||||
@@ -15,8 +25,15 @@ export interface HighlightData {
|
||||
toY: number;
|
||||
}
|
||||
|
||||
export class HighlightSpawner implements Spawner<HighlightData, Phaser.GameObjects.Container> {
|
||||
constructor(public readonly scene: OnitamaScene) {}
|
||||
export class HighlightSpawner implements Spawner<
|
||||
HighlightData,
|
||||
Phaser.GameObjects.Container
|
||||
> {
|
||||
private renderer: HighlightRenderer;
|
||||
|
||||
constructor(public readonly scene: OnitamaScene) {
|
||||
this.renderer = new HighlightRenderer(scene);
|
||||
}
|
||||
|
||||
*getData(): Iterable<HighlightData> {
|
||||
const state = this.scene.state;
|
||||
@@ -31,10 +48,12 @@ export class HighlightSpawner implements Spawner<HighlightData, Phaser.GameObjec
|
||||
const availableMoves = getAvailableMoves(state, currentPlayer);
|
||||
|
||||
// 过滤出符合当前选择的移动
|
||||
for(const move of availableMoves){
|
||||
if(move.fromX === uiState.selectedPiece!.x &&
|
||||
move.fromY === uiState.selectedPiece!.y &&
|
||||
move.card === uiState.selectedCard){
|
||||
for (const move of availableMoves) {
|
||||
if (
|
||||
move.fromX === uiState.selectedPiece.x &&
|
||||
move.fromY === uiState.selectedPiece.y &&
|
||||
move.card === uiState.selectedCard
|
||||
) {
|
||||
const pos = boardToScreen(move.toX, move.toY);
|
||||
yield {
|
||||
key: `${move.fromX}-${move.fromY}-${move.card}-${move.toX}-${move.toY}`,
|
||||
@@ -44,8 +63,8 @@ export class HighlightSpawner implements Spawner<HighlightData, Phaser.GameObjec
|
||||
fromX: move.fromX,
|
||||
fromY: move.fromY,
|
||||
toX: move.toX,
|
||||
toY: move.toY
|
||||
}
|
||||
toY: move.toY,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,77 +80,27 @@ export class HighlightSpawner implements Spawner<HighlightData, Phaser.GameObjec
|
||||
onSpawn(data: HighlightData): Phaser.GameObjects.Container {
|
||||
const container = this.scene.add.container(data.x, data.y);
|
||||
|
||||
// 外圈光环(动画)
|
||||
const outerCircle = this.scene.add.circle(
|
||||
0,
|
||||
0,
|
||||
CELL_SIZE / 3,
|
||||
0x3b82f6,
|
||||
0.2
|
||||
);
|
||||
container.add(outerCircle);
|
||||
// 使用 HighlightRenderer 创建视觉元素
|
||||
this.renderer.render(container, { x: data.x, y: data.y });
|
||||
|
||||
// 内圈
|
||||
const innerCircle = this.scene.add.circle(
|
||||
0,
|
||||
0,
|
||||
CELL_SIZE / 4,
|
||||
0x3b82f6,
|
||||
0.4
|
||||
);
|
||||
container.add(innerCircle);
|
||||
|
||||
const hitArea = new Phaser.Geom.Circle(0, 0, CELL_SIZE / 3);
|
||||
// 设置交互区域
|
||||
const hitArea = new Geom.Circle(0, 0, VISUAL.highlightHitAreaRadius);
|
||||
container.setInteractive(hitArea, Phaser.Geom.Circle.Contains);
|
||||
if (container.input) {
|
||||
container.input.cursor = 'pointer';
|
||||
container.input.cursor = "pointer";
|
||||
}
|
||||
|
||||
// 出现动画:从0缩放和透明淡入
|
||||
container.setAlpha(0);
|
||||
container.setScale(0);
|
||||
const spawnTween = this.scene.tweens.add({
|
||||
targets: container,
|
||||
alpha: 1,
|
||||
scale: 1,
|
||||
duration: 250,
|
||||
ease: 'Back.easeOut',
|
||||
});
|
||||
this.scene.addTweenInterruption(spawnTween);
|
||||
createHighlightSpawnTween(this.scene, container);
|
||||
|
||||
// 脉冲动画
|
||||
this.scene.tweens.add({
|
||||
targets: [outerCircle, innerCircle],
|
||||
scale: 1.2,
|
||||
alpha: 0.6,
|
||||
duration: 600,
|
||||
ease: 'Sine.easeInOut',
|
||||
yoyo: true,
|
||||
repeat: -1,
|
||||
});
|
||||
// 使用 HighlightRenderer 设置脉冲动画
|
||||
this.renderer.setupPulseAnimations(container);
|
||||
|
||||
// 外圈延迟动画,形成错开效果
|
||||
this.scene.tweens.add({
|
||||
targets: outerCircle,
|
||||
scale: 1.3,
|
||||
alpha: 0.3,
|
||||
duration: 800,
|
||||
ease: 'Sine.easeInOut',
|
||||
yoyo: true,
|
||||
repeat: -1,
|
||||
delay: 200,
|
||||
});
|
||||
|
||||
container.on('pointerdown', () => {
|
||||
container.on("pointerdown", () => {
|
||||
// 点击时的反馈动画
|
||||
this.scene.tweens.add({
|
||||
targets: container,
|
||||
scale: 1.5,
|
||||
alpha: 0.8,
|
||||
duration: 150,
|
||||
ease: 'Power2',
|
||||
yoyo: true,
|
||||
});
|
||||
createHighlightClickFeedbackTween(this.scene, container);
|
||||
|
||||
this.scene.onHighlightClick(data);
|
||||
});
|
||||
@@ -141,15 +110,6 @@ export class HighlightSpawner implements Spawner<HighlightData, Phaser.GameObjec
|
||||
|
||||
onDespawn(obj: Phaser.GameObjects.Container): void {
|
||||
// 消失动画:缩小并淡出
|
||||
const despawnTween = this.scene.tweens.add({
|
||||
targets: obj,
|
||||
scale: 0,
|
||||
alpha: 0,
|
||||
duration: 200,
|
||||
ease: 'Back.easeIn',
|
||||
onComplete: () => obj.destroy(),
|
||||
});
|
||||
this.scene.addTweenInterruption(despawnTween);
|
||||
createHighlightDespawnTween(this.scene, obj, () => obj.destroy());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,43 +1,46 @@
|
||||
import Phaser from 'phaser';
|
||||
import type { Pawn } from '@/game/onitama';
|
||||
import type { Spawner } from 'boardgame-phaser';
|
||||
import type { OnitamaScene } from '@/scenes/OnitamaScene';
|
||||
import type { OnitamaUIState } from '@/state';
|
||||
import { effect } from "@preact/signals-core";
|
||||
import { GameObjects } from "phaser";
|
||||
|
||||
export const CELL_SIZE = 80;
|
||||
export const BOARD_OFFSET = { x: 240, y: 200 };
|
||||
export const BOARD_SIZE = 5;
|
||||
import type { Pawn } from "@/game/onitama";
|
||||
import type { OnitamaScene } from "@/scenes/OnitamaScene";
|
||||
import type { Spawner } from "boardgame-phaser";
|
||||
|
||||
export function boardToScreen(boardX: number, boardY: number): { x: number; y: number } {
|
||||
return {
|
||||
x: BOARD_OFFSET.x + boardX * CELL_SIZE + CELL_SIZE / 2,
|
||||
y: BOARD_OFFSET.y + (BOARD_SIZE - 1 - boardY) * CELL_SIZE + CELL_SIZE / 2,
|
||||
};
|
||||
}
|
||||
import {
|
||||
boardToScreen,
|
||||
createPawnMoveTween,
|
||||
createPawnSpawnTween,
|
||||
createPawnDespawnTween,
|
||||
} from "@/config";
|
||||
import { PawnRenderer, SelectionRenderer } from "@/renderers";
|
||||
|
||||
// Re-export for backward compatibility
|
||||
export { CELL_SIZE, BOARD_OFFSET, BOARD_SIZE, boardToScreen } from "@/config";
|
||||
|
||||
/**
|
||||
* 继承自 Phaser.GameObjects.Container 的棋子容器类
|
||||
* 管理棋子视觉元素和选中状态
|
||||
*/
|
||||
export class PawnContainer extends Phaser.GameObjects.Container {
|
||||
export class PawnContainer extends GameObjects.Container {
|
||||
private selectionRing: Phaser.GameObjects.Arc | null = null;
|
||||
private selectionTween: Phaser.Tweens.Tween | null = null;
|
||||
private _position: [number, number];
|
||||
private _owner: 'red' | 'black';
|
||||
private _type: 'master' | 'student';
|
||||
private _owner: "red" | "black";
|
||||
private _type: "master" | "student";
|
||||
private pawnRenderer: PawnRenderer;
|
||||
private selectionRenderer: SelectionRenderer;
|
||||
|
||||
constructor(scene: OnitamaScene, pawn: Pawn) {
|
||||
super(scene, 0, 0);
|
||||
this._owner = pawn.owner;
|
||||
this._type = pawn.type;
|
||||
this._position = pawn.position as [number, number];
|
||||
this.pawnRenderer = new PawnRenderer(scene);
|
||||
this.selectionRenderer = new SelectionRenderer(scene);
|
||||
|
||||
// 将容器添加到场景
|
||||
scene.add.existing(this);
|
||||
|
||||
// 创建棋子视觉
|
||||
this.createPawnVisual();
|
||||
// 使用 PawnRenderer 创建棋子视觉
|
||||
this.pawnRenderer.render(this, { owner: this._owner, type: this._type });
|
||||
|
||||
// 添加选中状态监听
|
||||
this.addSelectionEffect(scene);
|
||||
@@ -50,33 +53,8 @@ export class PawnContainer extends Phaser.GameObjects.Container {
|
||||
if (!this.active) return;
|
||||
|
||||
if (!this.selectionRing) {
|
||||
// 创建选中光环(初始透明)
|
||||
this.selectionRing = (this.scene as OnitamaScene).add.arc(
|
||||
0, 0, CELL_SIZE / 3 + 5, 0, 360, false, 0xfbbf24, 0
|
||||
)
|
||||
.setStrokeStyle(3, 0xf59e0b, 1)
|
||||
.setAlpha(0);
|
||||
this.addAt(this.selectionRing, 0);
|
||||
|
||||
// 淡入动画
|
||||
const fadeIn = this.scene.tweens.add({
|
||||
targets: this.selectionRing,
|
||||
alpha: 0.8,
|
||||
duration: 200,
|
||||
ease: 'Power2',
|
||||
onComplete: () => {
|
||||
// 淡入完成后开始脉冲动画
|
||||
this.selectionTween = this.scene.tweens.add({
|
||||
targets: this.selectionRing,
|
||||
scale: 1.15,
|
||||
alpha: 0.6,
|
||||
duration: 500,
|
||||
ease: 'Sine.easeInOut',
|
||||
yoyo: true,
|
||||
repeat: -1,
|
||||
});
|
||||
},
|
||||
});
|
||||
this.selectionRing = this.selectionRenderer.create(this);
|
||||
this.selectionRenderer.show(this.selectionRing);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,25 +63,8 @@ export class PawnContainer extends Phaser.GameObjects.Container {
|
||||
*/
|
||||
hideSelection(): void {
|
||||
if (this.selectionRing) {
|
||||
// 停止所有动画
|
||||
if (this.selectionTween) {
|
||||
this.selectionTween.stop();
|
||||
this.selectionTween = null;
|
||||
}
|
||||
this.scene.tweens.killTweensOf(this.selectionRing);
|
||||
|
||||
// 淡出动画
|
||||
this.scene.tweens.add({
|
||||
targets: this.selectionRing,
|
||||
alpha: 0,
|
||||
scale: 0.9,
|
||||
duration: 150,
|
||||
ease: 'Power2',
|
||||
onComplete: () => {
|
||||
// 淡出完成后销毁
|
||||
this.selectionRing?.destroy();
|
||||
this.selectionRing = null;
|
||||
},
|
||||
this.selectionRenderer.hide(this.selectionRing, () => {
|
||||
this.selectionRing = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -114,8 +75,9 @@ export class PawnContainer extends Phaser.GameObjects.Container {
|
||||
private addSelectionEffect(scene: OnitamaScene): void {
|
||||
const dispose = effect(() => {
|
||||
const uiState = scene.uiState.value;
|
||||
const isSelected = uiState.selectedPiece?.x === this._position[0] &&
|
||||
uiState.selectedPiece?.y === this._position[1];
|
||||
const isSelected =
|
||||
uiState.selectedPiece?.x === this._position[0] &&
|
||||
uiState.selectedPiece?.y === this._position[1];
|
||||
|
||||
if (isSelected) {
|
||||
this.showSelection();
|
||||
@@ -124,7 +86,7 @@ export class PawnContainer extends Phaser.GameObjects.Container {
|
||||
}
|
||||
});
|
||||
|
||||
this.on('destroy', () => {
|
||||
this.on("destroy", () => {
|
||||
dispose();
|
||||
});
|
||||
}
|
||||
@@ -132,42 +94,25 @@ export class PawnContainer extends Phaser.GameObjects.Container {
|
||||
/**
|
||||
* 更新棋子位置
|
||||
*/
|
||||
updatePosition(newPosition: [number, number], animated: boolean = false): void {
|
||||
updatePosition(
|
||||
newPosition: [number, number],
|
||||
animated: boolean = false,
|
||||
): void {
|
||||
this._position = newPosition;
|
||||
const targetPos = boardToScreen(newPosition[0], newPosition[1]);
|
||||
|
||||
if (animated) {
|
||||
const tween = this.scene.tweens.add({
|
||||
targets: this,
|
||||
x: targetPos.x,
|
||||
y: targetPos.y,
|
||||
duration: 400,
|
||||
ease: 'Back.easeOut',
|
||||
});
|
||||
(this.scene as OnitamaScene).addTweenInterruption(tween);
|
||||
createPawnMoveTween(
|
||||
this.scene as OnitamaScene,
|
||||
this,
|
||||
targetPos.x,
|
||||
targetPos.y,
|
||||
);
|
||||
} else {
|
||||
this.x = targetPos.x;
|
||||
this.y = targetPos.y;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建棋子视觉元素
|
||||
*/
|
||||
private createPawnVisual(): void {
|
||||
const bgColor = this._owner === 'red' ? 0xef4444 : 0x3b82f6;
|
||||
const circle = (this.scene as OnitamaScene).add.circle(0, 0, CELL_SIZE / 3, bgColor, 1)
|
||||
.setStrokeStyle(2, 0x1f2937);
|
||||
this.add(circle);
|
||||
|
||||
const label = this._type === 'master' ? 'M' : 'S';
|
||||
const text = (this.scene as OnitamaScene).add.text(0, 0, label, {
|
||||
fontSize: '24px',
|
||||
fontFamily: 'Arial',
|
||||
color: '#ffffff',
|
||||
}).setOrigin(0.5);
|
||||
this.add(text);
|
||||
}
|
||||
}
|
||||
|
||||
export class PawnSpawner implements Spawner<Pawn, PawnContainer> {
|
||||
@@ -177,7 +122,7 @@ export class PawnSpawner implements Spawner<Pawn, PawnContainer> {
|
||||
|
||||
*getData() {
|
||||
for (const pawn of Object.values(this.scene.state.pawns)) {
|
||||
if (pawn.regionId === 'board') {
|
||||
if (pawn.regionId === "board") {
|
||||
yield pawn;
|
||||
}
|
||||
}
|
||||
@@ -211,28 +156,13 @@ export class PawnSpawner implements Spawner<Pawn, PawnContainer> {
|
||||
|
||||
// 淡入动画
|
||||
container.setScale(0);
|
||||
const tween = this.scene.tweens.add({
|
||||
targets: container,
|
||||
scale: 1,
|
||||
duration: 300,
|
||||
ease: 'Back.easeOut',
|
||||
});
|
||||
this.scene.addTweenInterruption(tween);
|
||||
createPawnSpawnTween(this.scene, container);
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
onDespawn(obj: PawnContainer): void {
|
||||
// 播放消失动画并添加中断
|
||||
const tween = this.scene.tweens.add({
|
||||
targets: obj,
|
||||
scale: 0,
|
||||
alpha: 0,
|
||||
duration: 300,
|
||||
ease: 'Back.easeIn',
|
||||
onComplete: () => obj.destroy(),
|
||||
});
|
||||
|
||||
this.scene.addTweenInterruption(tween);
|
||||
createPawnDespawnTween(this.scene, obj, () => obj.destroy());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
export { PawnSpawner, CELL_SIZE, BOARD_OFFSET, BOARD_SIZE, boardToScreen } from './PawnSpawner';
|
||||
export { CardSpawner, CARD_WIDTH, CARD_HEIGHT, type CardSpawnData } from './CardSpawner';
|
||||
export { HighlightSpawner, type HighlightData } from './HighlightSpawner';
|
||||
export { PawnSpawner } from "./PawnSpawner";
|
||||
export { CardSpawner, type CardSpawnData } from "./CardSpawner";
|
||||
export { HighlightSpawner, type HighlightData } from "./HighlightSpawner";
|
||||
|
||||
// Re-export config constants for backward compatibility
|
||||
// New code should import these directly from '@/config'
|
||||
export {
|
||||
CELL_SIZE,
|
||||
BOARD_OFFSET,
|
||||
BOARD_SIZE,
|
||||
boardToScreen,
|
||||
CARD_WIDTH,
|
||||
CARD_HEIGHT,
|
||||
} from "@/config";
|
||||
|
||||
@@ -1,2 +1,8 @@
|
||||
export { createUIState, clearSelection, selectPiece, selectCard, createValidMoves } from './ui';
|
||||
export type { OnitamaUIState, ValidMove } from './ui';
|
||||
export {
|
||||
createUIState,
|
||||
clearSelection,
|
||||
selectPiece,
|
||||
selectCard,
|
||||
createValidMoves,
|
||||
} from "./ui";
|
||||
export type { OnitamaUIState, ValidMove } from "./ui";
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { MutableSignal, mutableSignal, computed, ReadonlySignal } from 'boardgame-core';
|
||||
import {getAvailableMoves, OnitamaState} from "boardgame-core/samples/onitama";
|
||||
import { mutableSignal, computed } from "boardgame-core";
|
||||
import { getAvailableMoves } from "boardgame-core/samples/onitama";
|
||||
|
||||
import type { MutableSignal, ReadonlySignal } from "boardgame-core";
|
||||
import type { OnitamaState } from "boardgame-core/samples/onitama";
|
||||
|
||||
export interface ValidMove {
|
||||
card: string;
|
||||
@@ -22,18 +25,26 @@ export function createUIState(): MutableSignal<OnitamaUIState> {
|
||||
});
|
||||
}
|
||||
|
||||
export function createValidMoves(state: ReadonlySignal<OnitamaState>, ui: ReadonlySignal<OnitamaUIState>){
|
||||
export function createValidMoves(
|
||||
state: ReadonlySignal<OnitamaState>,
|
||||
ui: ReadonlySignal<OnitamaUIState>,
|
||||
) {
|
||||
return computed(() => {
|
||||
return getAvailableMoves(state.value, state.value.currentPlayer)
|
||||
.filter(move => {
|
||||
const {selectedCard, selectedPiece} = ui.value;
|
||||
return selectedPiece?.x === move.fromX && selectedPiece?.y === move.fromY && selectedCard === move.card;
|
||||
})
|
||||
return getAvailableMoves(state.value, state.value.currentPlayer).filter(
|
||||
(move) => {
|
||||
const { selectedCard, selectedPiece } = ui.value;
|
||||
return (
|
||||
selectedPiece?.x === move.fromX &&
|
||||
selectedPiece?.y === move.fromY &&
|
||||
selectedCard === move.card
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function clearSelection(uiState: MutableSignal<OnitamaUIState>): void {
|
||||
uiState.produce(state => {
|
||||
uiState.produce((state) => {
|
||||
state.selectedPiece = null;
|
||||
state.selectedCard = null;
|
||||
});
|
||||
@@ -42,13 +53,13 @@ export function clearSelection(uiState: MutableSignal<OnitamaUIState>): void {
|
||||
export function selectPiece(
|
||||
uiState: MutableSignal<OnitamaUIState>,
|
||||
x: number,
|
||||
y: number
|
||||
y: number,
|
||||
): void {
|
||||
uiState.produce(state => {
|
||||
uiState.produce((state) => {
|
||||
// 如果点击已选中的棋子,取消选择
|
||||
if(state.selectedPiece?.x === x && state.selectedPiece?.y === y){
|
||||
if (state.selectedPiece?.x === x && state.selectedPiece?.y === y) {
|
||||
state.selectedPiece = null;
|
||||
}else{
|
||||
} else {
|
||||
state.selectedPiece = { x, y };
|
||||
}
|
||||
});
|
||||
@@ -56,9 +67,9 @@ export function selectPiece(
|
||||
|
||||
export function selectCard(
|
||||
uiState: MutableSignal<OnitamaUIState>,
|
||||
card: string
|
||||
card: string,
|
||||
): void {
|
||||
uiState.produce(state => {
|
||||
uiState.produce((state) => {
|
||||
// 如果点击已选中的卡牌,取消选择
|
||||
if (state.selectedCard === card) {
|
||||
state.selectedCard = null;
|
||||
|
||||
@@ -1,43 +1,37 @@
|
||||
import {useComputed} from '@preact/signals';
|
||||
import { createGameHost } from 'boardgame-core';
|
||||
import Phaser from 'phaser';
|
||||
import { h } from 'preact';
|
||||
import { PhaserGame, PhaserScene } from 'boardgame-phaser';
|
||||
import { createGameHost, type GameModule } from "boardgame-core";
|
||||
import { PhaserGame, PhaserScene } from "boardgame-phaser";
|
||||
import { useMemo } from "preact/hooks";
|
||||
|
||||
export default function App(props: { gameModule: any, gameScene: { new(): Phaser.Scene } }) {
|
||||
import * as gameModule from "../game/onitama";
|
||||
|
||||
const gameHost = useComputed(() => {
|
||||
const gameHost = createGameHost(props.gameModule);
|
||||
return { gameHost };
|
||||
});
|
||||
import type { OnitamaState } from "@/game/onitama";
|
||||
|
||||
const scene = useComputed(() => new props.gameScene());
|
||||
import { MenuScene } from "@/scenes/MenuScene";
|
||||
import { OnitamaScene } from "@/scenes/OnitamaScene";
|
||||
|
||||
const handleReset = () => {
|
||||
gameHost.value.gameHost.start();
|
||||
};
|
||||
const label = useComputed(() => gameHost.value.gameHost.status.value === 'running' ? 'Restart' : 'Start');
|
||||
export default function App() {
|
||||
const gameHost = useMemo(
|
||||
() => createGameHost(gameModule as unknown as GameModule<OnitamaState>),
|
||||
[],
|
||||
);
|
||||
const gameScene = useMemo(() => new OnitamaScene(), []);
|
||||
const menuScene = useMemo(() => new MenuScene(), []);
|
||||
|
||||
const phaserConfig: Partial<Phaser.Types.Core.GameConfig> = {
|
||||
width: 700,
|
||||
height: 800,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen">
|
||||
<div className="p-4 bg-gray-100 border-t border-gray-200">
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="px-6 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors font-medium"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 flex relative justify-center items-center">
|
||||
<PhaserGame config={phaserConfig}>
|
||||
<PhaserScene sceneKey="OnitamaScene" scene={scene.value} autoStart data={gameHost.value} />
|
||||
</PhaserGame>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="flex flex-col h-screen">
|
||||
<div className="flex-1 flex relative justify-center items-center">
|
||||
<PhaserGame
|
||||
initialScene="MenuScene"
|
||||
config={{ width: 720, height: 840 }}
|
||||
>
|
||||
<PhaserScene sceneKey="MenuScene" scene={menuScene} />
|
||||
<PhaserScene
|
||||
sceneKey="OnitamaScene"
|
||||
scene={gameScene}
|
||||
data={{ gameHost }}
|
||||
/>
|
||||
</PhaserGame>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>STS-Like Viewer</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="ui-root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "sts-like-viewer",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@preact/signals-core": "^1.5.1",
|
||||
"boardgame-core": "link:../../../boardgame-core",
|
||||
"boardgame-phaser": "workspace:*",
|
||||
"mutative": "^1.3.0",
|
||||
"phaser": "^3.80.1",
|
||||
"preact": "^10.19.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@preact/preset-vite": "^2.8.1",
|
||||
"@preact/signals": "^2.9.0",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@types/node": "^25.6.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Centralized layout and style constants for sts-like-viewer.
|
||||
* All magic numbers should be defined here and imported where needed.
|
||||
*/
|
||||
|
||||
import { PhaserGameProps } from "boardgame-phaser";
|
||||
|
||||
// ── Map Layout ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const MAP_CONFIG = {
|
||||
/** Horizontal spacing between map layers (left-to-right) */
|
||||
LAYER_HEIGHT: 110,
|
||||
/** Vertical spacing between nodes within a layer */
|
||||
NODE_SPACING: 140,
|
||||
/** Radius of each map node circle */
|
||||
NODE_RADIUS: 28,
|
||||
/** Total number of layers in the point-crawl map */
|
||||
TOTAL_LAYERS: 10,
|
||||
/** Maximum nodes in any single layer */
|
||||
MAX_NODES_PER_LAYER: 5,
|
||||
} as const;
|
||||
|
||||
// ── Grid Inventory ──────────────────────────────────────────────────────────
|
||||
|
||||
export const GRID_CONFIG = {
|
||||
/** Default grid width (columns) */
|
||||
WIDTH: 6,
|
||||
/** Default grid height (rows) */
|
||||
HEIGHT: 4,
|
||||
/** Cell size for the standalone grid viewer */
|
||||
VIEWER_CELL_SIZE: 60,
|
||||
/** Cell size for the encounter inventory widget */
|
||||
WIDGET_CELL_SIZE: 80,
|
||||
/** Gap between grid cells (pixels) */
|
||||
GRID_GAP: 2,
|
||||
/** Empty cell background color */
|
||||
CELL_EMPTY_COLOR: 0x222233,
|
||||
/** Occupied cell background color */
|
||||
CELL_OCCUPIED_COLOR: 0x334455,
|
||||
/** Grid line color */
|
||||
GRID_LINE_COLOR: 0x555577,
|
||||
/** Title text style */
|
||||
TITLE_STYLE: {
|
||||
fontSize: "24px",
|
||||
color: "#888",
|
||||
fontStyle: "bold",
|
||||
} as const,
|
||||
/** Subtitle/hint text style */
|
||||
SUBTITLE_STYLE: {
|
||||
fontSize: "14px",
|
||||
color: "#aaaaaa",
|
||||
} as const,
|
||||
/** Item name text style */
|
||||
ITEM_NAME_STYLE: {
|
||||
fontSize: "11px",
|
||||
color: "#888",
|
||||
fontStyle: "bold",
|
||||
} as const,
|
||||
} as const;
|
||||
|
||||
// ── Shape Viewer ────────────────────────────────────────────────────────────
|
||||
|
||||
export const SHAPE_CONFIG = {
|
||||
/** Cell size for rendering item shapes */
|
||||
CELL_SIZE: 40,
|
||||
/** Number of items per row in the shape viewer grid */
|
||||
ITEMS_PER_ROW: 4,
|
||||
/** Maximum items to display at once */
|
||||
MAX_ITEMS: 12,
|
||||
/** Horizontal spacing between shape previews */
|
||||
SPACING_X: 220,
|
||||
/** Vertical spacing between shape previews */
|
||||
SPACING_Y: 140,
|
||||
} as const;
|
||||
|
||||
// ── Combat Unit Widget ──────────────────────────────────────────────────────
|
||||
|
||||
export const COMBAT_WIDGET_CONFIG = {
|
||||
/** Default widget width */
|
||||
WIDTH: 240,
|
||||
/** Default widget height */
|
||||
HEIGHT: 120,
|
||||
/** HP bar width */
|
||||
HP_BAR_WIDTH: 180,
|
||||
/** HP bar height */
|
||||
HP_BAR_HEIGHT: 16,
|
||||
/** Buff/debuff icon size */
|
||||
BUFF_ICON_SIZE: 28,
|
||||
/** Gap between buff icons */
|
||||
BUFF_ICON_GAP: 6,
|
||||
} as const;
|
||||
|
||||
// ── UI / Button ─────────────────────────────────────────────────────────────
|
||||
|
||||
export const UI_CONFIG = {
|
||||
/** Standard button width */
|
||||
BUTTON_WIDTH: 120,
|
||||
/** Standard button height */
|
||||
BUTTON_HEIGHT: 36,
|
||||
/** Large button width */
|
||||
BUTTON_WIDTH_LARGE: 220,
|
||||
/** Large button height */
|
||||
BUTTON_HEIGHT_LARGE: 50,
|
||||
/** Button background color */
|
||||
BUTTON_BG: 0x444466,
|
||||
/** Button hover background color */
|
||||
BUTTON_BG_HOVER: 0x555588,
|
||||
/** Button border color */
|
||||
BUTTON_BORDER: 0x7777aa,
|
||||
/** Button text color */
|
||||
BUTTON_TEXT_COLOR: "#ffffff",
|
||||
/** Button font size */
|
||||
BUTTON_FONT_SIZE: "16px",
|
||||
} as const;
|
||||
|
||||
// ── Colors ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export const NODE_COLORS = {
|
||||
start: 0x44aa44,
|
||||
end: 0xcc8844,
|
||||
minion: 0xcc4444,
|
||||
elite: 0xcc44cc,
|
||||
event: 0xaaaa44,
|
||||
camp: 0x44cccc,
|
||||
shop: 0x4488cc,
|
||||
curio: 0x8844cc,
|
||||
} as const;
|
||||
|
||||
export const NODE_LABELS = {
|
||||
start: "起点",
|
||||
end: "终点",
|
||||
minion: "战斗",
|
||||
elite: "精英",
|
||||
event: "事件",
|
||||
camp: "营地",
|
||||
shop: "商店",
|
||||
curio: "奇遇",
|
||||
} as const;
|
||||
|
||||
export const ITEM_COLORS = [
|
||||
0x4488cc, 0xcc8844, 0x44cc88, 0xcc4488, 0x8844cc, 0x44cccc,
|
||||
] as const;
|
||||
|
||||
// ── Positive/Negative Effects (for buff icons) ──────────────────────────────
|
||||
|
||||
export const POSITIVE_EFFECTS = new Set([
|
||||
"block",
|
||||
"strength",
|
||||
"dexterity",
|
||||
"regen",
|
||||
]);
|
||||
export const NEGATIVE_EFFECTS = new Set([
|
||||
"weak",
|
||||
"vulnerable",
|
||||
"frail",
|
||||
"poison",
|
||||
]);
|
||||
|
||||
export const CARD_CONFIG = {
|
||||
WIDTH: 140,
|
||||
HEIGHT: 200,
|
||||
CORNER_RADIUS: 8,
|
||||
} as const;
|
||||
|
||||
export const GAME_CONFIG: Phaser.Types.Core.GameConfig = {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
};
|
||||
@@ -0,0 +1,152 @@
|
||||
import Phaser from "phaser";
|
||||
|
||||
export type BuffData = {
|
||||
id: string;
|
||||
stacks: number;
|
||||
emoji: string;
|
||||
isPositive: boolean;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
const ICON_SIZE = 28;
|
||||
const ICON_COLOR_POSITIVE = 0x44aa44;
|
||||
const ICON_COLOR_NEGATIVE = 0xaa4444;
|
||||
const TOOLTIP_WIDTH = 200;
|
||||
const TOOLTIP_PADDING = 8;
|
||||
|
||||
export class Buff extends Phaser.GameObjects.Container {
|
||||
private bg!: Phaser.GameObjects.Rectangle;
|
||||
private label!: Phaser.GameObjects.Text;
|
||||
private tooltipContainer!: Phaser.GameObjects.Container;
|
||||
private tooltipBg!: Phaser.GameObjects.Rectangle;
|
||||
private tooltipText!: Phaser.GameObjects.Text;
|
||||
|
||||
private currentData!: BuffData;
|
||||
|
||||
constructor(scene: Phaser.Scene, x: number, y: number, data: BuffData) {
|
||||
super(scene, x, y);
|
||||
scene.add.existing(this);
|
||||
|
||||
this.currentData = data;
|
||||
this.createVisuals();
|
||||
this.createTooltip();
|
||||
this.setupInteraction();
|
||||
this.updateFromData(data);
|
||||
}
|
||||
|
||||
private createVisuals(): void {
|
||||
const color = this.currentData.isPositive
|
||||
? ICON_COLOR_POSITIVE
|
||||
: ICON_COLOR_NEGATIVE;
|
||||
|
||||
this.bg = this.scene.add
|
||||
.rectangle(0, 0, ICON_SIZE, ICON_SIZE, color)
|
||||
.setStrokeStyle(1, 0xffffff);
|
||||
|
||||
this.label = this.scene.add
|
||||
.text(0, 0, "", {
|
||||
fontSize: "12px",
|
||||
color: "#ffffff",
|
||||
fontStyle: "bold",
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
|
||||
this.add([this.bg, this.label]);
|
||||
}
|
||||
|
||||
private createTooltip(): void {
|
||||
this.tooltipContainer = this.scene.add.container(0, 0).setVisible(false);
|
||||
|
||||
this.tooltipBg = this.scene.add
|
||||
.rectangle(0, 0, 0, 0, 0x000000, 0.9)
|
||||
.setStrokeStyle(1, 0xffffff);
|
||||
|
||||
this.tooltipText = this.scene.add
|
||||
.text(0, 0, "", {
|
||||
fontSize: "12px",
|
||||
color: "#ffffff",
|
||||
align: "center",
|
||||
wordWrap: { width: TOOLTIP_WIDTH - TOOLTIP_PADDING * 2 },
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
|
||||
this.tooltipContainer.add([this.tooltipBg, this.tooltipText]);
|
||||
this.tooltipContainer.setDepth(1000);
|
||||
}
|
||||
|
||||
private setupInteraction(): void {
|
||||
this.setInteractive(
|
||||
new Phaser.Geom.Rectangle(
|
||||
-ICON_SIZE / 2,
|
||||
-ICON_SIZE / 2,
|
||||
ICON_SIZE,
|
||||
ICON_SIZE,
|
||||
),
|
||||
Phaser.Geom.Rectangle.Contains,
|
||||
);
|
||||
|
||||
this.on("pointerover", this.onPointerOver, this);
|
||||
this.on("pointerout", this.onPointerOut, this);
|
||||
}
|
||||
|
||||
private onPointerOver(): void {
|
||||
const desc = this.currentData.description ?? "";
|
||||
const content = desc;
|
||||
|
||||
this.tooltipText.setText(content);
|
||||
|
||||
const bounds = this.tooltipText.getBounds();
|
||||
const width = Math.min(bounds.width + TOOLTIP_PADDING * 2, TOOLTIP_WIDTH);
|
||||
const height = bounds.height + TOOLTIP_PADDING * 2;
|
||||
|
||||
this.tooltipBg.setDisplaySize(width, height);
|
||||
|
||||
const worldPos = this.getBounds();
|
||||
this.tooltipContainer.setPosition(
|
||||
worldPos.x + worldPos.width / 2,
|
||||
worldPos.y - height / 2 - 4,
|
||||
);
|
||||
this.tooltipContainer.setDepth(1000);
|
||||
this.scene.children.bringToTop(this.tooltipContainer);
|
||||
this.tooltipContainer.setVisible(true);
|
||||
}
|
||||
|
||||
private onPointerOut(): void {
|
||||
this.tooltipContainer.setVisible(false);
|
||||
}
|
||||
|
||||
updateFromData(data: BuffData): void {
|
||||
this.currentData = data;
|
||||
this.label.setText(`${data.emoji}${data.stacks}`);
|
||||
|
||||
const color = data.isPositive ? ICON_COLOR_POSITIVE : ICON_COLOR_NEGATIVE;
|
||||
this.bg.setFillStyle(color);
|
||||
}
|
||||
|
||||
destroy(fromScene?: boolean): void {
|
||||
this.off("pointerover", this.onPointerOver, this);
|
||||
this.off("pointerout", this.onPointerOut, this);
|
||||
this.tooltipContainer.destroy(fromScene);
|
||||
super.destroy(fromScene);
|
||||
}
|
||||
|
||||
playSpawnTween() {
|
||||
this.setScale(0);
|
||||
this.scene.tweens.add({
|
||||
targets: this,
|
||||
scale: 1,
|
||||
duration: 200,
|
||||
ease: "Back.Out",
|
||||
});
|
||||
}
|
||||
|
||||
playDespawnTween() {
|
||||
this.scene.tweens.add({
|
||||
targets: this,
|
||||
scale: 0,
|
||||
duration: 200,
|
||||
ease: "Back.In",
|
||||
onComplete: () => this.destroy(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { GameHostScene } from "boardgame-phaser";
|
||||
import { BuffData, Buff } from "./Buff";
|
||||
import { SpawnerCallback } from "boardgame-phaser";
|
||||
import { CombatState } from "boardgame-core/samples/slay-the-spire-like";
|
||||
|
||||
export type BuffDataWithIndex = BuffData & { x: number };
|
||||
export class BuffSpawner implements SpawnerCallback<BuffDataWithIndex, Buff> {
|
||||
constructor(
|
||||
public scene: GameHostScene<CombatState>,
|
||||
public buffContainer: Phaser.GameObjects.Container,
|
||||
) {}
|
||||
getKey(t: BuffDataWithIndex) {
|
||||
return t.id;
|
||||
}
|
||||
onSpawn(t: BuffDataWithIndex) {
|
||||
const buff = new Buff(this.scene, t.x, 0, t);
|
||||
this.buffContainer.add(buff);
|
||||
buff.playSpawnTween();
|
||||
this.scene.addInterruption(new Promise((r) => setTimeout(r, 50)));
|
||||
return buff;
|
||||
}
|
||||
onDespawn(obj: Buff) {
|
||||
this.scene.addInterruption(new Promise((r) => setTimeout(r, 50)));
|
||||
obj.playDespawnTween();
|
||||
}
|
||||
onUpdate(t: BuffDataWithIndex, obj: Buff) {
|
||||
obj.setPosition(t.x, 0);
|
||||
obj.updateFromData(t);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import Phaser, { Tweens } from "phaser";
|
||||
import type { GameCard } from "boardgame-core/samples/slay-the-spire-like";
|
||||
import { CARD_CONFIG } from "@/config";
|
||||
import { hoverEffect } from "boardgame-phaser";
|
||||
|
||||
export interface CardContainerOptions {
|
||||
card: GameCard;
|
||||
onClick?: (cardId: string) => void;
|
||||
playable?: boolean;
|
||||
}
|
||||
|
||||
const CARD_WIDTH = CARD_CONFIG.WIDTH;
|
||||
const CARD_HEIGHT = CARD_CONFIG.HEIGHT;
|
||||
const CORNER_RADIUS = CARD_CONFIG.CORNER_RADIUS;
|
||||
|
||||
export class CardContainer extends Phaser.GameObjects.Container {
|
||||
private bg!: Phaser.GameObjects.Rectangle;
|
||||
private nameText!: Phaser.GameObjects.Text;
|
||||
private costBg!: Phaser.GameObjects.Container;
|
||||
private costText!: Phaser.GameObjects.Text;
|
||||
private descText!: Phaser.GameObjects.Text;
|
||||
private highlight!: Phaser.GameObjects.Rectangle | null;
|
||||
private cardId: string;
|
||||
private _selected = false;
|
||||
|
||||
constructor(
|
||||
scene: Phaser.Scene,
|
||||
x: number,
|
||||
y: number,
|
||||
options: CardContainerOptions,
|
||||
) {
|
||||
super(scene, x, y);
|
||||
scene.add.existing(this);
|
||||
|
||||
this.cardId = options.card.id;
|
||||
this.createVisuals(options);
|
||||
this.setupInteractive(options);
|
||||
}
|
||||
|
||||
private createVisuals(options: CardContainerOptions): void {
|
||||
const cardData = options.card.cardData;
|
||||
const isPlayable = options.playable ?? true;
|
||||
|
||||
// Background
|
||||
const bgColor = isPlayable ? 0x2a2a3a : 0x1a1a24;
|
||||
this.bg = this.scene.add
|
||||
.rectangle(0, 0, CARD_WIDTH, CARD_HEIGHT, bgColor)
|
||||
.setStrokeStyle(2, isPlayable ? 0x555577 : 0x333344);
|
||||
this.add(this.bg);
|
||||
|
||||
// Name
|
||||
this.nameText = this.scene.add
|
||||
.text(0, -CARD_HEIGHT / 2 + 24, cardData.name, {
|
||||
fontSize: "13px",
|
||||
color: isPlayable ? "#ffffff" : "#666666",
|
||||
fontStyle: "bold",
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
this.add(this.nameText);
|
||||
|
||||
// Cost badge (top-right)
|
||||
this.costBg = this.scene.add.container(
|
||||
CARD_WIDTH / 2 - 18,
|
||||
-CARD_HEIGHT / 2 + 18,
|
||||
);
|
||||
const costCircle = this.scene.add.circle(0, 0, 14, 0x3b82f6);
|
||||
this.costText = this.scene.add
|
||||
.text(0, 0, `${cardData.costCount}`, {
|
||||
fontSize: "14px",
|
||||
color: "#ffffff",
|
||||
fontStyle: "bold",
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
this.costBg.add([costCircle, this.costText]);
|
||||
this.add(this.costBg);
|
||||
|
||||
// Description
|
||||
this.descText = this.scene.add
|
||||
.text(0, 16, cardData.desc, {
|
||||
fontSize: "10px",
|
||||
color: isPlayable ? "#aaaaaa" : "#444444",
|
||||
align: "center",
|
||||
wordWrap: { width: CARD_WIDTH - 16 },
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
this.add(this.descText);
|
||||
|
||||
// Target indicator
|
||||
const targetLabel = (() => {
|
||||
switch (cardData.targetType) {
|
||||
case "enemy":
|
||||
return "⚔️ Enemy";
|
||||
case "enemies":
|
||||
return "⚔️ Enemies";
|
||||
case "player":
|
||||
return "🛡️ Player";
|
||||
default:
|
||||
return "❓ Unknown";
|
||||
}
|
||||
})();
|
||||
const targetText = this.scene.add
|
||||
.text(0, CARD_HEIGHT / 2 - 20, targetLabel, {
|
||||
fontSize: "10px",
|
||||
color: isPlayable ? "#8888cc" : "#444466",
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
this.add(targetText);
|
||||
}
|
||||
|
||||
private setupInteractive(options: CardContainerOptions): void {
|
||||
const hitArea = new Phaser.Geom.Rectangle(
|
||||
-CARD_WIDTH / 2,
|
||||
-CARD_HEIGHT / 2,
|
||||
CARD_WIDTH,
|
||||
CARD_HEIGHT,
|
||||
);
|
||||
this.setInteractive(hitArea, Phaser.Geom.Rectangle.Contains);
|
||||
if (this.input) {
|
||||
this.input.cursor = options.playable ? "pointer" : "not-allowed";
|
||||
}
|
||||
|
||||
hoverEffect(this, (hovering) => {
|
||||
if (!options.playable) return;
|
||||
this.playHoverTween(hovering);
|
||||
});
|
||||
|
||||
this.on("pointerdown", () => {
|
||||
if (options.playable && options.onClick) {
|
||||
options.onClick(this.cardId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setSelected(selected: boolean): void {
|
||||
this._selected = selected;
|
||||
if (selected) {
|
||||
if (!this.highlight) {
|
||||
this.highlight = this.scene.add
|
||||
.rectangle(0, 0, CARD_WIDTH + 10, CARD_HEIGHT + 10, 0x000000, 0)
|
||||
.setStrokeStyle(4, 0xfbbf24)
|
||||
.setDepth(-1);
|
||||
this.addAt(this.highlight, 0);
|
||||
}
|
||||
this.highlight.setAlpha(1);
|
||||
this.scene.tweens.add({
|
||||
targets: this,
|
||||
scale: 1.05,
|
||||
duration: 100,
|
||||
ease: "Power2",
|
||||
});
|
||||
} else {
|
||||
if (this.highlight) {
|
||||
this.highlight.setAlpha(0);
|
||||
}
|
||||
this.scene.tweens.add({
|
||||
targets: this,
|
||||
scale: 1,
|
||||
duration: 100,
|
||||
ease: "Power2",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
get selected(): boolean {
|
||||
return this._selected;
|
||||
}
|
||||
|
||||
playSpawnTween(delay = 0) {
|
||||
this.setAlpha(0);
|
||||
this.setScale(0.5);
|
||||
return this.scene.tweens.add({
|
||||
targets: this,
|
||||
alpha: 1,
|
||||
scale: 1,
|
||||
duration: 300,
|
||||
delay,
|
||||
ease: "Back.easeOut",
|
||||
});
|
||||
}
|
||||
|
||||
playDespawnTween(onComplete?: () => void) {
|
||||
return this.scene.tweens.add({
|
||||
targets: this,
|
||||
alpha: 0,
|
||||
scale: 0.5,
|
||||
y: this.y - 50,
|
||||
duration: 200,
|
||||
ease: "Back.easeIn",
|
||||
onComplete: () => {
|
||||
if (onComplete) onComplete();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private playHoverTween(isHovering: boolean) {
|
||||
this.scene.tweens.add({
|
||||
targets: this,
|
||||
y: isHovering ? this.y - 10 : this.y + 10,
|
||||
scale: isHovering ? 1.08 : 1,
|
||||
duration: 150,
|
||||
ease: "Power2",
|
||||
});
|
||||
this.bg.setStrokeStyle(
|
||||
isHovering ? 3 : 2,
|
||||
isHovering ? 0x88aaff : 0x555577,
|
||||
);
|
||||
}
|
||||
|
||||
destroy(fromScene?: boolean): void {
|
||||
this.highlight = null;
|
||||
super.destroy(fromScene);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { spawnEffect, type Spawner } from "boardgame-phaser";
|
||||
import type {
|
||||
CombatState,
|
||||
GameCard,
|
||||
} from "boardgame-core/samples/slay-the-spire-like";
|
||||
import { CardContainer } from "./CardContainer";
|
||||
import { GameHostScene } from "boardgame-phaser";
|
||||
|
||||
export interface CardSpawnData {
|
||||
cardId: string;
|
||||
index: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
const HAND_Y = 140;
|
||||
const CARD_SPACING = 160;
|
||||
const HAND_MARGIN = 100;
|
||||
|
||||
export class CardSpawner implements Spawner<CardSpawnData, CardContainer> {
|
||||
constructor(
|
||||
private scene: GameHostScene<CombatState>,
|
||||
private getState: () => CombatState,
|
||||
private onCardClick: (cardId: string) => void,
|
||||
) {}
|
||||
|
||||
*getData(): Iterable<CardSpawnData> {
|
||||
const state = this.getState();
|
||||
const handIds = state.player.deck.regions.hand.childIds;
|
||||
const total = handIds.length;
|
||||
for (let i = 0; i < total; i++) {
|
||||
yield { cardId: handIds[i], index: i, total };
|
||||
}
|
||||
}
|
||||
|
||||
getKey(data: CardSpawnData): string {
|
||||
return data.cardId;
|
||||
}
|
||||
|
||||
onSpawn(data: CardSpawnData): CardContainer {
|
||||
const state = this.getState();
|
||||
const card = state.player.deck.cards[data.cardId];
|
||||
const { x, y } = this.getCardPosition(data.index, data.total);
|
||||
|
||||
const container = new CardContainer(this.scene, x, y, {
|
||||
card: card ?? this.createFallbackCard(data.cardId),
|
||||
onClick: this.onCardClick,
|
||||
playable: true,
|
||||
});
|
||||
|
||||
container.playSpawnTween(data.index * 40);
|
||||
this.scene.addInterruption(new Promise((r) => setTimeout(r, 40)));
|
||||
return container;
|
||||
}
|
||||
|
||||
onUpdate(data: CardSpawnData, obj: CardContainer): void {
|
||||
const { x, y } = this.getCardPosition(data.index, data.total);
|
||||
if (obj.x !== x || obj.y !== y) {
|
||||
this.scene.tweens.add({
|
||||
targets: obj,
|
||||
x,
|
||||
y,
|
||||
duration: 250,
|
||||
ease: "Power2",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onDespawn(obj: CardContainer, data: CardSpawnData): void {
|
||||
obj.playDespawnTween(() => obj.destroy());
|
||||
this.scene.addInterruption(new Promise((r) => setTimeout(r, 40)));
|
||||
}
|
||||
|
||||
private getCardPosition(
|
||||
index: number,
|
||||
total: number,
|
||||
): { x: number; y: number } {
|
||||
const { width, height } = this.scene.scale;
|
||||
const spacing = Math.min(
|
||||
CARD_SPACING,
|
||||
(width - HAND_MARGIN * 2) / Math.max(1, total),
|
||||
);
|
||||
const startX = width / 2 - ((total - 1) * spacing) / 2;
|
||||
return {
|
||||
x: startX + index * spacing,
|
||||
y: height - HAND_Y,
|
||||
};
|
||||
}
|
||||
|
||||
private createFallbackCard(id: string): GameCard {
|
||||
return {
|
||||
id,
|
||||
regionId: "",
|
||||
position: [],
|
||||
cardData: {
|
||||
id,
|
||||
name: "Unknown",
|
||||
desc: "Card data missing",
|
||||
type: "item",
|
||||
costType: "none",
|
||||
costCount: 0,
|
||||
targetType: "player",
|
||||
effects: [],
|
||||
},
|
||||
itemId: "",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function createCardSpawner(
|
||||
scene: GameHostScene<CombatState>,
|
||||
getState: () => CombatState,
|
||||
onCardClick: (cardId: string) => void,
|
||||
) {
|
||||
return spawnEffect(new CardSpawner(scene, getState, onCardClick));
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import Phaser from "phaser";
|
||||
import type {
|
||||
CombatEntity,
|
||||
CombatState,
|
||||
EffectTable,
|
||||
EnemyEntity,
|
||||
} from "boardgame-core/samples/slay-the-spire-like";
|
||||
import { BuffData } from "./Buff";
|
||||
import { createSpawnUpdate } from "boardgame-phaser";
|
||||
import { BuffDataWithIndex, BuffSpawner } from "./BuffSpawner";
|
||||
import { GameHostScene } from "boardgame-phaser";
|
||||
|
||||
export type CombatUnitData = {
|
||||
key: string;
|
||||
entity: CombatEntity;
|
||||
name: string;
|
||||
isPlayer: boolean;
|
||||
};
|
||||
|
||||
const CONTAINER_WIDTH = 200;
|
||||
const CONTAINER_HEIGHT = 260;
|
||||
const HP_BAR_WIDTH = 160;
|
||||
const HP_BAR_HEIGHT = 16;
|
||||
const BUFF_ICON_SIZE = 28;
|
||||
const BUFF_ICON_GAP = 6;
|
||||
|
||||
export class CombatUnitContainer extends Phaser.GameObjects.Container {
|
||||
private bg!: Phaser.GameObjects.Rectangle;
|
||||
private nameText!: Phaser.GameObjects.Text;
|
||||
private hpBarBg!: Phaser.GameObjects.Rectangle;
|
||||
private hpBarFill!: Phaser.GameObjects.Rectangle;
|
||||
private hpText!: Phaser.GameObjects.Text;
|
||||
private buffContainer!: Phaser.GameObjects.Container;
|
||||
private intentText!: Phaser.GameObjects.Text | null;
|
||||
private hostScene: GameHostScene<CombatState>;
|
||||
private updateBuffs!: (buffs: Iterable<BuffData & { x: number }>) => void;
|
||||
|
||||
private currentEntity!: CombatEntity;
|
||||
private currentName: string;
|
||||
private currentIsPlayer: boolean;
|
||||
|
||||
constructor(
|
||||
scene: GameHostScene<CombatState>,
|
||||
x: number,
|
||||
y: number,
|
||||
data: CombatUnitData,
|
||||
) {
|
||||
super(scene, x, y);
|
||||
scene.add.existing(this);
|
||||
this.hostScene = scene;
|
||||
|
||||
this.currentEntity = data.entity;
|
||||
this.currentName = data.name;
|
||||
this.currentIsPlayer = data.isPlayer;
|
||||
|
||||
this.createVisuals();
|
||||
this.updateFromData(data);
|
||||
}
|
||||
|
||||
private createVisuals(): void {
|
||||
const bgColor = this.currentIsPlayer ? 0x224466 : 0x662222;
|
||||
const borderColor = this.currentIsPlayer ? 0x44aaff : 0xff4444;
|
||||
|
||||
this.bg = this.scene.add
|
||||
.rectangle(0, 0, CONTAINER_WIDTH, CONTAINER_HEIGHT, bgColor)
|
||||
.setStrokeStyle(3, borderColor);
|
||||
|
||||
this.nameText = this.scene.add
|
||||
.text(0, -CONTAINER_HEIGHT / 2 + 24, this.currentName, {
|
||||
fontSize: "18px",
|
||||
color: "#ffffff",
|
||||
fontStyle: "bold",
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
|
||||
this.hpBarBg = this.scene.add.rectangle(
|
||||
0,
|
||||
-CONTAINER_HEIGHT / 2 + 60,
|
||||
HP_BAR_WIDTH,
|
||||
HP_BAR_HEIGHT,
|
||||
0x333333,
|
||||
);
|
||||
|
||||
this.hpBarFill = this.scene.add
|
||||
.rectangle(
|
||||
-HP_BAR_WIDTH / 2,
|
||||
-CONTAINER_HEIGHT / 2 + 60,
|
||||
HP_BAR_WIDTH,
|
||||
HP_BAR_HEIGHT,
|
||||
0x22c55e,
|
||||
)
|
||||
.setOrigin(0, 0.5);
|
||||
|
||||
this.hpText = this.scene.add
|
||||
.text(0, -CONTAINER_HEIGHT / 2 + 60, "", {
|
||||
fontSize: "12px",
|
||||
color: "#ffffff",
|
||||
fontStyle: "bold",
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
|
||||
this.buffContainer = this.scene.add.container(0, CONTAINER_HEIGHT / 2 - 40);
|
||||
this.updateBuffs = createSpawnUpdate(
|
||||
new BuffSpawner(this.hostScene, this.buffContainer),
|
||||
);
|
||||
|
||||
if (!this.currentIsPlayer) {
|
||||
this.intentText = this.scene.add
|
||||
.text(0, -CONTAINER_HEIGHT / 2 + 86, "", {
|
||||
fontSize: "12px",
|
||||
color: "#ffcc88",
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
} else {
|
||||
this.intentText = null;
|
||||
}
|
||||
|
||||
this.add([
|
||||
this.bg,
|
||||
this.nameText,
|
||||
this.hpBarBg,
|
||||
this.hpBarFill,
|
||||
this.hpText,
|
||||
this.buffContainer,
|
||||
...(this.intentText ? [this.intentText] : []),
|
||||
]);
|
||||
}
|
||||
|
||||
updateFromData(data: CombatUnitData): void {
|
||||
this.currentEntity = data.entity;
|
||||
this.currentName = data.name;
|
||||
|
||||
this.nameText.setText(this.currentName);
|
||||
|
||||
const hpPercent =
|
||||
this.currentEntity.maxHp > 0
|
||||
? this.currentEntity.hp / this.currentEntity.maxHp
|
||||
: 0;
|
||||
|
||||
const fillWidth = Math.max(0, HP_BAR_WIDTH * hpPercent);
|
||||
this.hpBarFill.setDisplaySize(fillWidth, HP_BAR_HEIGHT);
|
||||
|
||||
const hpColor =
|
||||
hpPercent > 0.5 ? 0x22c55e : hpPercent > 0.25 ? 0xf59e0b : 0xef4444;
|
||||
this.hpBarFill.setFillStyle(hpColor);
|
||||
|
||||
this.hpText.setText(
|
||||
`${this.currentEntity.hp} / ${this.currentEntity.maxHp} HP`,
|
||||
);
|
||||
|
||||
this.updateBuffs(this.getBuffEntries());
|
||||
|
||||
if (!this.currentIsPlayer && this.intentText) {
|
||||
const enemyEntity = data.entity as EnemyEntity;
|
||||
const intent = enemyEntity.currentIntent;
|
||||
const intentName = intent?.id ?? "unknown";
|
||||
this.intentText.setText(`Intent: ${intentName}`);
|
||||
}
|
||||
}
|
||||
|
||||
private *getBuffEntries() {
|
||||
const entries = Object.values(this.currentEntity.effects);
|
||||
const totalWidth =
|
||||
entries.length * BUFF_ICON_SIZE + (entries.length - 1) * BUFF_ICON_GAP;
|
||||
const startX = -totalWidth / 2 + BUFF_ICON_SIZE / 2;
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const buff = entries[i];
|
||||
yield {
|
||||
...buff.data,
|
||||
stacks: buff.stacks,
|
||||
isPositive: true,
|
||||
x: startX + i * BUFF_ICON_SIZE,
|
||||
} as BuffDataWithIndex;
|
||||
}
|
||||
}
|
||||
|
||||
playSpawnEffect(): void {
|
||||
this.setScale(0);
|
||||
this.setAlpha(0);
|
||||
|
||||
this.scene.tweens.add({
|
||||
targets: this,
|
||||
scale: 1,
|
||||
alpha: 1,
|
||||
duration: 400,
|
||||
ease: "Back.easeOut",
|
||||
});
|
||||
}
|
||||
|
||||
playDamageEffect(): void {
|
||||
const flash = this.scene.add
|
||||
.rectangle(
|
||||
this.x,
|
||||
this.y,
|
||||
CONTAINER_WIDTH,
|
||||
CONTAINER_HEIGHT,
|
||||
0xff0000,
|
||||
0.4,
|
||||
)
|
||||
.setDepth(200);
|
||||
|
||||
this.scene.tweens.add({
|
||||
targets: this,
|
||||
x: this.x + 8,
|
||||
duration: 60,
|
||||
yoyo: true,
|
||||
repeat: 3,
|
||||
onComplete: () => {
|
||||
this.scene.tweens.add({
|
||||
targets: flash,
|
||||
alpha: 0,
|
||||
duration: 200,
|
||||
onComplete: () => flash.destroy(),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
playHealEffect(): void {
|
||||
const healText = this.scene.add
|
||||
.text(this.x, this.y - 40, "+HP", {
|
||||
fontSize: "20px",
|
||||
color: "#22c55e",
|
||||
fontStyle: "bold",
|
||||
})
|
||||
.setOrigin(0.5)
|
||||
.setDepth(200);
|
||||
|
||||
this.scene.tweens.add({
|
||||
targets: healText,
|
||||
y: this.y - 80,
|
||||
alpha: 0,
|
||||
duration: 800,
|
||||
ease: "Power2",
|
||||
onComplete: () => healText.destroy(),
|
||||
});
|
||||
}
|
||||
|
||||
destroy(fromScene?: boolean): void {
|
||||
this.buffContainer.removeAll(true);
|
||||
super.destroy(fromScene);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { Spawner } from "boardgame-phaser";
|
||||
import { spawnEffect } from "boardgame-phaser";
|
||||
import type { CombatState } from "boardgame-core/samples/slay-the-spire-like";
|
||||
import {
|
||||
CombatUnitContainer,
|
||||
type CombatUnitData,
|
||||
} from "./CombatUnitContainer";
|
||||
import { GameHostScene } from "boardgame-phaser";
|
||||
|
||||
export class CombatUnitSpawner implements Spawner<
|
||||
CombatUnitData,
|
||||
CombatUnitContainer
|
||||
> {
|
||||
constructor(private scene: GameHostScene<CombatState>) {}
|
||||
|
||||
*getData(): Iterable<CombatUnitData> {
|
||||
const combat = this.getCombatState();
|
||||
if (!combat) return;
|
||||
|
||||
yield {
|
||||
key: "player",
|
||||
entity: combat.player,
|
||||
name: "Player",
|
||||
isPlayer: true,
|
||||
};
|
||||
|
||||
for (let i = 0; i < combat.enemies.length; i++) {
|
||||
const enemy = combat.enemies[i];
|
||||
yield {
|
||||
key: `enemy-${i}`,
|
||||
entity: enemy,
|
||||
name: enemy.enemy.name,
|
||||
isPlayer: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
getKey(t: CombatUnitData): string {
|
||||
return t.key;
|
||||
}
|
||||
|
||||
onSpawn(t: CombatUnitData): CombatUnitContainer | null {
|
||||
const { width, height } = this.scene.scale;
|
||||
const combat = this.getCombatState();
|
||||
if (!combat) return null;
|
||||
|
||||
const totalUnits = 1 + combat.enemies.length;
|
||||
const spacing = 220;
|
||||
const totalWidth = (totalUnits - 1) * spacing;
|
||||
const startX = width / 2 - totalWidth / 2;
|
||||
|
||||
let x = startX;
|
||||
if (t.key.startsWith("enemy-")) {
|
||||
const index = parseInt(t.key.replace("enemy-", ""), 10);
|
||||
x = startX + (index + 1) * spacing;
|
||||
}
|
||||
|
||||
const y = height / 2;
|
||||
|
||||
const container = new CombatUnitContainer(this.scene, x, y, t);
|
||||
container.playSpawnEffect();
|
||||
return container;
|
||||
}
|
||||
|
||||
onUpdate(t: CombatUnitData, obj: CombatUnitContainer): void {
|
||||
obj.updateFromData(t);
|
||||
}
|
||||
|
||||
onDespawn(obj: CombatUnitContainer): void {
|
||||
obj.destroy();
|
||||
}
|
||||
|
||||
private getCombatState(): CombatState | null {
|
||||
const registry = this.scene.registry;
|
||||
return (registry.get("combatState") as CombatState | undefined) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
export function createCombatUnitSpawner(scene: GameHostScene<CombatState>) {
|
||||
return spawnEffect(new CombatUnitSpawner(scene));
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import Phaser from "phaser";
|
||||
import { dragDropEventEffect, DragDropEventType } from "boardgame-phaser";
|
||||
import { GRID_CONFIG } from "@/config";
|
||||
import {
|
||||
IDENTITY_TRANSFORM,
|
||||
ParsedShape,
|
||||
Point2D,
|
||||
Transform2D,
|
||||
transformShape,
|
||||
type GameItem,
|
||||
} from "boardgame-core/samples/slay-the-spire-like";
|
||||
import { DisposableBag } from "../../../framework/dist";
|
||||
import { InventoryItemState } from "@/state/InventoryItemState";
|
||||
import { moveItem } from "@/state/inventory";
|
||||
import {
|
||||
InventorySurface,
|
||||
InventorySurfaceState,
|
||||
inventoryToScene,
|
||||
sceneToInventory,
|
||||
} from "@/state/inventorySurfaceState";
|
||||
|
||||
export class InventoryItemContainer extends Phaser.GameObjects.Container {
|
||||
private itemState: InventoryItemState;
|
||||
private surfaceState: InventorySurfaceState;
|
||||
private surfaces: Iterable<InventorySurface>;
|
||||
private hitArea: Point2D[] = [];
|
||||
|
||||
constructor(
|
||||
scene: Phaser.Scene,
|
||||
item: GameItem,
|
||||
surface: InventorySurface,
|
||||
surfaces: Iterable<InventorySurface>,
|
||||
) {
|
||||
super(scene, surface.gridOffsetX, surface.gridOffsetY);
|
||||
scene.add.existing(this);
|
||||
|
||||
const cellSize = surface.cellSize;
|
||||
const graphics = this.scene.add.graphics();
|
||||
graphics.setPosition(cellSize / 2, cellSize / 2);
|
||||
const label = this.scene.add.text(
|
||||
cellSize / 2,
|
||||
cellSize / 2,
|
||||
"",
|
||||
GRID_CONFIG.ITEM_NAME_STYLE,
|
||||
);
|
||||
this.add([graphics, label]);
|
||||
this.setupInteractive();
|
||||
|
||||
this.itemState = new InventoryItemState(item);
|
||||
this.surfaceState = new InventorySurfaceState(surface);
|
||||
this.surfaces = surfaces;
|
||||
|
||||
const disposables = new DisposableBag(this);
|
||||
|
||||
disposables.addEffect(() => {
|
||||
label.setText(this.itemState.name.value);
|
||||
});
|
||||
|
||||
disposables.add(this.setupDnDEffect());
|
||||
|
||||
disposables.addEffect(() => {
|
||||
graphics.clear();
|
||||
this.renderGraphics(
|
||||
graphics,
|
||||
this.surfaceState.cellSize,
|
||||
this.itemState.shape.value,
|
||||
this.itemState.color.value,
|
||||
);
|
||||
});
|
||||
|
||||
disposables.addEffect(() => {
|
||||
this.scene.tweens.add({
|
||||
targets: graphics,
|
||||
rotation: -(this.itemState.previewRotation.value * Math.PI) / 180,
|
||||
duration: 150,
|
||||
ease: "Power2",
|
||||
});
|
||||
this.hitArea = this.updateHitArea(this.itemState.previewRotation.value);
|
||||
});
|
||||
|
||||
disposables.addEffect(() => {
|
||||
this.snapBack(this.itemState.transform.value);
|
||||
this.itemState.setPreviewRotation(0);
|
||||
});
|
||||
}
|
||||
|
||||
setSurfaceItem(surface: InventorySurface, item: GameItem): void {
|
||||
this.surfaceState.setSurface(surface);
|
||||
this.itemState.setItem(item);
|
||||
}
|
||||
|
||||
private updateHitArea(value: number): Point2D[] {
|
||||
const shape = this.itemState.shape.value;
|
||||
if (!shape) return [];
|
||||
|
||||
const rotation = value;
|
||||
const cells = transformShape(shape, {
|
||||
rotation,
|
||||
offset: { x: 0, y: 0 },
|
||||
flipX: false,
|
||||
flipY: false,
|
||||
});
|
||||
const cellSize = this.surfaceState.cellSize;
|
||||
|
||||
return cells.map((cell) => ({
|
||||
x: (cell.x - cells[0].x) * cellSize,
|
||||
y: (cell.y - cells[0].y) * cellSize,
|
||||
}));
|
||||
}
|
||||
|
||||
private renderGraphics(
|
||||
graphics: Phaser.GameObjects.Graphics,
|
||||
cellSize: number,
|
||||
shape: ParsedShape,
|
||||
itemColor: number,
|
||||
) {
|
||||
const cells = transformShape(shape, IDENTITY_TRANSFORM);
|
||||
|
||||
for (const cell of cells) {
|
||||
const localX = (cell.x - cells[0].x) * cellSize;
|
||||
const localY = (cell.y - cells[0].y) * cellSize;
|
||||
|
||||
graphics.fillStyle(itemColor);
|
||||
graphics.fillRect(
|
||||
localX + 2 - cellSize / 2,
|
||||
localY + 2 - cellSize / 2,
|
||||
cellSize - 4,
|
||||
cellSize - 4,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private setupInteractive() {
|
||||
this.setScrollFactor(0);
|
||||
this.setInteractive({
|
||||
hitArea: this,
|
||||
hitAreaCallback: (
|
||||
hitArea: InventoryItemContainer,
|
||||
x: number,
|
||||
y: number,
|
||||
) =>
|
||||
hitArea.hitArea.some(
|
||||
(cell) =>
|
||||
x >= cell.x &&
|
||||
x < cell.x + this.surfaceState.cellSize &&
|
||||
y >= cell.y &&
|
||||
y < cell.y + this.surfaceState.cellSize,
|
||||
),
|
||||
useHandCursor: true,
|
||||
} as Phaser.Types.Input.InputConfiguration);
|
||||
}
|
||||
|
||||
private setupDnDEffect() {
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
return dragDropEventEffect(this, (event) => {
|
||||
if (event.type === DragDropEventType.DOWN) {
|
||||
startX = this.x;
|
||||
startY = this.y;
|
||||
this.setAlpha(0.7);
|
||||
} else if (event.type === DragDropEventType.MOVE) {
|
||||
this.x = startX + event.deltaX;
|
||||
this.y = startY + event.deltaY;
|
||||
} else if (event.type === DragDropEventType.ALTBUTTON) {
|
||||
this.itemState.addPreviewRotation(90);
|
||||
} else if (event.type === DragDropEventType.UP) {
|
||||
const finalRotation = this.itemState.previewRotation.peek();
|
||||
const finalX = this.x;
|
||||
const finalY = this.y;
|
||||
if (!this.handleDragEnd(finalX, finalY, finalRotation)) {
|
||||
const t = this.itemState.transform.peek();
|
||||
t && this.snapBack(t);
|
||||
}
|
||||
this.setAlpha(1);
|
||||
this.itemState.setPreviewRotation(0);
|
||||
startX = startY = 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private handleDragEnd(
|
||||
finalX: number,
|
||||
finalY: number,
|
||||
finalRotation: number,
|
||||
): boolean {
|
||||
const item = this.itemState.item;
|
||||
if (!item) return false;
|
||||
|
||||
const target = this.findDropSurface(finalX, finalY);
|
||||
if (!target) return false;
|
||||
|
||||
if (
|
||||
this.surfaceState.surface === target.surface &&
|
||||
item.transform.offset.x === target.x &&
|
||||
item.transform.offset.y === target.y &&
|
||||
item.transform.rotation === finalRotation
|
||||
)
|
||||
return false;
|
||||
|
||||
return moveItem(
|
||||
this.surfaceState.invSignal,
|
||||
target.surface.invSignal,
|
||||
item.id,
|
||||
target.x,
|
||||
target.y,
|
||||
finalRotation,
|
||||
);
|
||||
}
|
||||
|
||||
private findDropSurface(x: number, y: number) {
|
||||
for (const surface of this.surfaces) {
|
||||
const target = sceneToInventory(surface, x, y);
|
||||
if (target)
|
||||
return {
|
||||
surface,
|
||||
...target,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private snapBack(transform: Transform2D): void {
|
||||
const { x, y } = transform.offset;
|
||||
const target = inventoryToScene(this.surfaceState, x, y);
|
||||
|
||||
this.scene.tweens.add({
|
||||
targets: this,
|
||||
...target,
|
||||
duration: 150,
|
||||
ease: "Power2",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import Phaser from "phaser";
|
||||
import type { Spawner } from "boardgame-phaser";
|
||||
import type { GameItem } from "boardgame-core/samples/slay-the-spire-like";
|
||||
import { spawnEffect } from "boardgame-phaser";
|
||||
import { InventoryItemContainer } from "./InventoryItemContainer";
|
||||
import { InventorySurface } from "@/state/inventorySurfaceState";
|
||||
|
||||
export class InventoryItemSpawner implements Spawner<
|
||||
[InventorySurface, GameItem],
|
||||
InventoryItemContainer
|
||||
> {
|
||||
constructor(
|
||||
private scene: Phaser.Scene,
|
||||
private surfaces: Iterable<InventorySurface>,
|
||||
) {}
|
||||
|
||||
*getData(): Iterable<[InventorySurface, GameItem]> {
|
||||
for (const surface of this.surfaces) {
|
||||
const inv = surface.invSignal.value;
|
||||
for (const item of inv.items.values()) {
|
||||
yield [surface, item];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getKey(entry: [InventorySurface, GameItem]): string {
|
||||
return entry[1].id;
|
||||
}
|
||||
|
||||
onSpawn(entry: [InventorySurface, GameItem]): InventoryItemContainer | null {
|
||||
const [surface, item] = entry;
|
||||
|
||||
const container = new InventoryItemContainer(
|
||||
this.scene,
|
||||
item,
|
||||
surface,
|
||||
this.surfaces,
|
||||
);
|
||||
|
||||
container.setSurfaceItem(surface, item);
|
||||
return container;
|
||||
}
|
||||
|
||||
onUpdate(
|
||||
entry: [InventorySurface, GameItem],
|
||||
container: InventoryItemContainer,
|
||||
): void {
|
||||
const [surface, item] = entry;
|
||||
|
||||
container.setSurfaceItem(surface, item);
|
||||
}
|
||||
|
||||
onDespawn(container: InventoryItemContainer): void {
|
||||
// TODO: add tween
|
||||
container.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
export function createInventoryItemSpawner(
|
||||
scene: Phaser.Scene,
|
||||
surfaces: Iterable<InventorySurface>,
|
||||
) {
|
||||
return spawnEffect(new InventoryItemSpawner(scene, surfaces));
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { GameUI } from "boardgame-phaser";
|
||||
import "./style.css";
|
||||
import App from "@/ui/App";
|
||||
|
||||
const ui = new GameUI({
|
||||
container: "ui-root",
|
||||
root: <App />,
|
||||
});
|
||||
|
||||
ui.mount();
|
||||
@@ -0,0 +1,286 @@
|
||||
import Phaser from "phaser";
|
||||
import { GameHostScene } from "boardgame-phaser";
|
||||
import { spawnEffect, type Spawner } from "boardgame-phaser";
|
||||
import {
|
||||
CombatResult,
|
||||
type CombatState,
|
||||
prompts,
|
||||
} from "boardgame-core/samples/slay-the-spire-like";
|
||||
import { createButton } from "@/utils/createButton";
|
||||
import { SceneKey } from "./types";
|
||||
import {
|
||||
CombatUnitContainer,
|
||||
type CombatUnitData,
|
||||
} from "@/gameobjects/CombatUnitContainer";
|
||||
import { CardSpawner } from "@/gameobjects/CardSpawner";
|
||||
import { CombatModule } from "@/state/combatState";
|
||||
|
||||
const CARD_SPACING = 160;
|
||||
const HAND_MARGIN = 100;
|
||||
const HAND_Y = 140;
|
||||
|
||||
export class CombatTestScene extends GameHostScene<
|
||||
CombatState,
|
||||
CombatResult | null,
|
||||
CombatModule
|
||||
> {
|
||||
private selectedCardId: string | null = null;
|
||||
private isTargeting = false;
|
||||
private targetingText!: Phaser.GameObjects.Text;
|
||||
private selectionRect!: Phaser.GameObjects.Rectangle;
|
||||
|
||||
constructor() {
|
||||
super("CombatTestScene");
|
||||
}
|
||||
|
||||
create(): void {
|
||||
super.create();
|
||||
|
||||
const { width, height } = this.scale;
|
||||
|
||||
// Title
|
||||
this.add
|
||||
.text(width / 2, 30, "Combat Test — Card Play", {
|
||||
fontSize: "24px",
|
||||
color: "#ffffff",
|
||||
fontStyle: "bold",
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
|
||||
// Info text (reactive)
|
||||
const infoText = this.add
|
||||
.text(width / 2, 60, "", {
|
||||
fontSize: "14px",
|
||||
color: "#aaaaaa",
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
|
||||
this.addEffect(() => {
|
||||
const s = this.state;
|
||||
infoText.setText(
|
||||
`Turn ${s.turnNumber} | Phase: ${s.phase} | Energy: ${s.player.energy}/${s.player.maxEnergy}`,
|
||||
);
|
||||
});
|
||||
|
||||
// Targeting indicator
|
||||
this.targetingText = this.add
|
||||
.text(width / 2, height - 220, "", {
|
||||
fontSize: "18px",
|
||||
color: "#fbbf24",
|
||||
fontStyle: "bold",
|
||||
})
|
||||
.setOrigin(0.5)
|
||||
.setAlpha(0);
|
||||
|
||||
// Selection rectangle overlay for selected card
|
||||
this.selectionRect = this.add
|
||||
.rectangle(0, 0, 150, 210, 0x000000, 0)
|
||||
.setStrokeStyle(4, 0xfbbf24)
|
||||
.setAlpha(0)
|
||||
.setDepth(50);
|
||||
|
||||
// Unit spawner (player + enemies)
|
||||
this.disposables.add(spawnEffect(new UnitSpawner(this)));
|
||||
|
||||
// Card spawner (hand)
|
||||
this.disposables.add(
|
||||
spawnEffect(
|
||||
new CardSpawner(
|
||||
this,
|
||||
() => this.state,
|
||||
(cardId) => this.onCardClick(cardId),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Watch hand changes to clear stale selection
|
||||
this.addEffect(() => {
|
||||
const handIds = this.state.player.deck.regions.hand.childIds;
|
||||
if (this.selectedCardId && !handIds.includes(this.selectedCardId)) {
|
||||
this.clearTargeting();
|
||||
} else {
|
||||
this.updateSelectionRect();
|
||||
}
|
||||
});
|
||||
|
||||
// Controls
|
||||
createButton({
|
||||
scene: this,
|
||||
label: "返回菜单",
|
||||
x: 100,
|
||||
y: 40,
|
||||
onClick: async () => {
|
||||
await this.sceneController.launch(SceneKey.IndexScene);
|
||||
},
|
||||
});
|
||||
|
||||
createButton({
|
||||
scene: this,
|
||||
label: "End Turn",
|
||||
x: width - 100,
|
||||
y: 40,
|
||||
onClick: () => {
|
||||
this.tryPlayCard("end-turn");
|
||||
},
|
||||
});
|
||||
|
||||
// Start the game loop
|
||||
this.gameHost.start();
|
||||
this.gameHost.gameModule.triggers.onEffectApplied.use(async (ctx, next) => {
|
||||
const { effect, entityId, stacks, targetId, cardId } = ctx;
|
||||
console.log(
|
||||
`${effect.name} x${stacks} ${entityId}/${cardId} :${targetId}`,
|
||||
);
|
||||
await next();
|
||||
});
|
||||
}
|
||||
|
||||
private onCardClick(cardId: string): void {
|
||||
const state = this.state;
|
||||
const card = state.player.deck.cards[cardId];
|
||||
if (!card) return;
|
||||
|
||||
const targetType = card.cardData.targetType;
|
||||
|
||||
if (targetType === "enemy") {
|
||||
this.selectedCardId = cardId;
|
||||
this.isTargeting = true;
|
||||
this.targetingText.setText("Select a target!");
|
||||
this.targetingText.setAlpha(1);
|
||||
this.updateSelectionRect();
|
||||
} else {
|
||||
this.tryPlayCard(cardId);
|
||||
}
|
||||
}
|
||||
|
||||
public onEnemyClick(enemyId: string): void {
|
||||
if (!this.isTargeting || !this.selectedCardId) return;
|
||||
this.tryPlayCard(this.selectedCardId, enemyId);
|
||||
}
|
||||
|
||||
public getIsTargeting(): boolean {
|
||||
return this.isTargeting;
|
||||
}
|
||||
|
||||
private tryPlayCard(cardId: string, targetId?: string): void {
|
||||
const error = this.gameHost.prompts.tryCommit(
|
||||
prompts.mainAction,
|
||||
"global",
|
||||
cardId,
|
||||
targetId,
|
||||
);
|
||||
if (!error.ok) {
|
||||
console.warn("Play failed:", error.reason);
|
||||
}
|
||||
this.clearTargeting();
|
||||
}
|
||||
|
||||
private clearTargeting(): void {
|
||||
this.selectedCardId = null;
|
||||
this.isTargeting = false;
|
||||
this.targetingText.setAlpha(0);
|
||||
this.selectionRect.setAlpha(0);
|
||||
}
|
||||
|
||||
private updateSelectionRect(): void {
|
||||
if (!this.selectedCardId) {
|
||||
this.selectionRect.setAlpha(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const state = this.state;
|
||||
const handIds = state.player.deck.regions.hand.childIds;
|
||||
const index = handIds.indexOf(this.selectedCardId);
|
||||
if (index === -1) {
|
||||
this.selectionRect.setAlpha(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const { width, height } = this.scale;
|
||||
const total = handIds.length;
|
||||
const spacing = Math.min(
|
||||
CARD_SPACING,
|
||||
(width - HAND_MARGIN * 2) / Math.max(1, total),
|
||||
);
|
||||
const startX = width / 2 - ((total - 1) * spacing) / 2;
|
||||
const x = startX + index * spacing;
|
||||
const y = height - HAND_Y;
|
||||
|
||||
this.selectionRect.setPosition(x, y);
|
||||
this.selectionRect.setAlpha(1);
|
||||
}
|
||||
}
|
||||
|
||||
class UnitSpawner implements Spawner<CombatUnitData, CombatUnitContainer> {
|
||||
constructor(private scene: CombatTestScene) {}
|
||||
|
||||
*getData(): Iterable<CombatUnitData> {
|
||||
const state = this.scene.state;
|
||||
yield {
|
||||
key: "player",
|
||||
entity: state.player,
|
||||
name: "Player",
|
||||
isPlayer: true,
|
||||
};
|
||||
for (let i = 0; i < state.enemies.length; i++) {
|
||||
const enemy = state.enemies[i];
|
||||
yield {
|
||||
key: `enemy-${i}`,
|
||||
entity: enemy,
|
||||
name: enemy.enemy.name,
|
||||
isPlayer: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
getKey(t: CombatUnitData): string {
|
||||
return t.key;
|
||||
}
|
||||
|
||||
onSpawn(t: CombatUnitData): CombatUnitContainer {
|
||||
const { width, height } = this.scene.scale;
|
||||
const state = this.scene.state;
|
||||
const totalUnits = 1 + state.enemies.length;
|
||||
const spacing = 220;
|
||||
const totalWidth = (totalUnits - 1) * spacing;
|
||||
const startX = width / 2 - totalWidth / 2;
|
||||
|
||||
let x = startX;
|
||||
if (t.key.startsWith("enemy-")) {
|
||||
const index = parseInt(t.key.replace("enemy-", ""), 10);
|
||||
x = startX + (index + 1) * spacing;
|
||||
}
|
||||
|
||||
const container = new CombatUnitContainer(
|
||||
this.scene,
|
||||
x,
|
||||
height / 2 - 80,
|
||||
t,
|
||||
);
|
||||
container.playSpawnEffect();
|
||||
|
||||
// Make enemies clickable when targeting
|
||||
if (!t.isPlayer) {
|
||||
const hitArea = new Phaser.Geom.Rectangle(-100, -130, 200, 260);
|
||||
container.setInteractive(hitArea, Phaser.Geom.Rectangle.Contains);
|
||||
if (container.input) {
|
||||
container.input.cursor = "pointer";
|
||||
}
|
||||
container.on("pointerdown", () => {
|
||||
if (this.scene.getIsTargeting()) {
|
||||
this.scene.onEnemyClick(t.entity.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
onUpdate(t: CombatUnitData, obj: CombatUnitContainer): void {
|
||||
obj.updateFromData(t);
|
||||
}
|
||||
|
||||
onDespawn(obj: CombatUnitContainer): void {
|
||||
obj.destroy();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import { ReactiveScene } from "boardgame-phaser";
|
||||
import { createButton } from "@/utils/createButton";
|
||||
import { GRID_CONFIG, ITEM_COLORS } from "@/config";
|
||||
import {
|
||||
createGridInventory,
|
||||
placeItem,
|
||||
getAdjacentItems,
|
||||
parseShapeString,
|
||||
data,
|
||||
type GridInventory,
|
||||
type InventoryItem,
|
||||
type GameItemMeta,
|
||||
} from "boardgame-core/samples/slay-the-spire-like";
|
||||
import { SceneKey } from "./types";
|
||||
|
||||
export class GridViewerScene extends ReactiveScene {
|
||||
private inventory: GridInventory<GameItemMeta>;
|
||||
private gridOffsetX = 0;
|
||||
private gridOffsetY = 0;
|
||||
|
||||
constructor() {
|
||||
super("GridViewerScene");
|
||||
this.inventory = createGridInventory<GameItemMeta>(
|
||||
GRID_CONFIG.WIDTH,
|
||||
GRID_CONFIG.HEIGHT,
|
||||
);
|
||||
}
|
||||
|
||||
create(): void {
|
||||
super.create();
|
||||
const { width, height } = this.scale;
|
||||
this.gridOffsetX =
|
||||
(width - GRID_CONFIG.WIDTH * GRID_CONFIG.VIEWER_CELL_SIZE) / 2;
|
||||
this.gridOffsetY =
|
||||
(height - GRID_CONFIG.HEIGHT * GRID_CONFIG.VIEWER_CELL_SIZE) / 2 + 20;
|
||||
|
||||
this.placeSampleItems();
|
||||
this.drawGrid();
|
||||
this.drawItems();
|
||||
|
||||
this.add
|
||||
.text(width / 2, 30, "Grid Inventory Viewer (4x6)", {
|
||||
fontSize: "24px",
|
||||
color: "#ffffff",
|
||||
fontStyle: "bold",
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
|
||||
this.createControls();
|
||||
|
||||
this.add
|
||||
.text(width / 2, height - 40, "Hover over cells to see item details", {
|
||||
fontSize: "14px",
|
||||
color: "#aaaaaa",
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
}
|
||||
|
||||
private placeSampleItems(): void {
|
||||
const items = data.desert.getItems();
|
||||
const sampleItems = [
|
||||
{ index: 0, x: 0, y: 0 },
|
||||
{ index: 3, x: 3, y: 0 },
|
||||
{ index: 6, x: 0, y: 2 },
|
||||
{ index: 12, x: 3, y: 2 },
|
||||
];
|
||||
|
||||
for (const { index, x, y } of sampleItems) {
|
||||
if (index >= items.length) continue;
|
||||
const itemData = items[index];
|
||||
const shape = parseShapeString(itemData.shape);
|
||||
const item: InventoryItem<GameItemMeta> = {
|
||||
id: `item-${index}`,
|
||||
shape,
|
||||
transform: {
|
||||
offset: { x, y },
|
||||
rotation: 0,
|
||||
flipX: false,
|
||||
flipY: false,
|
||||
},
|
||||
meta: { itemData, shape },
|
||||
};
|
||||
placeItem(this.inventory, item);
|
||||
}
|
||||
}
|
||||
|
||||
private drawGrid(): void {
|
||||
const graphics = this.add.graphics();
|
||||
|
||||
for (let y = 0; y < GRID_CONFIG.HEIGHT; y++) {
|
||||
for (let x = 0; x < GRID_CONFIG.WIDTH; x++) {
|
||||
const px = this.gridOffsetX + x * GRID_CONFIG.VIEWER_CELL_SIZE;
|
||||
const py = this.gridOffsetY + y * GRID_CONFIG.VIEWER_CELL_SIZE;
|
||||
|
||||
const isOccupied = this.inventory.occupiedCells.has(`${x},${y}`);
|
||||
const color = isOccupied ? 0x334455 : 0x222233;
|
||||
|
||||
graphics.fillStyle(color);
|
||||
graphics.fillRect(
|
||||
px + 1,
|
||||
py + 1,
|
||||
GRID_CONFIG.VIEWER_CELL_SIZE - 2,
|
||||
GRID_CONFIG.VIEWER_CELL_SIZE - 2,
|
||||
);
|
||||
graphics.lineStyle(1, 0x555577);
|
||||
graphics.strokeRect(
|
||||
px,
|
||||
py,
|
||||
GRID_CONFIG.VIEWER_CELL_SIZE,
|
||||
GRID_CONFIG.VIEWER_CELL_SIZE,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private drawItems(): void {
|
||||
for (const [itemId, item] of this.inventory.items) {
|
||||
const shape = item.shape;
|
||||
const transform = item.transform;
|
||||
|
||||
const cells: { x: number; y: number }[] = [];
|
||||
for (let y = 0; y < shape.height; y++) {
|
||||
for (let x = 0; x < shape.width; x++) {
|
||||
if (shape.grid[y]?.[x]) {
|
||||
const finalX = x + transform.offset.x;
|
||||
const finalY = y + transform.offset.y;
|
||||
cells.push({ x: finalX, y: finalY });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const itemColor = this.getItemColor(itemId);
|
||||
const graphics = this.add.graphics();
|
||||
|
||||
for (const cell of cells) {
|
||||
const px = this.gridOffsetX + cell.x * GRID_CONFIG.VIEWER_CELL_SIZE;
|
||||
const py = this.gridOffsetY + cell.y * GRID_CONFIG.VIEWER_CELL_SIZE;
|
||||
|
||||
graphics.fillStyle(itemColor);
|
||||
graphics.fillRect(
|
||||
px + 2,
|
||||
py + 2,
|
||||
GRID_CONFIG.VIEWER_CELL_SIZE - 4,
|
||||
GRID_CONFIG.VIEWER_CELL_SIZE - 4,
|
||||
);
|
||||
}
|
||||
|
||||
if (cells.length > 0) {
|
||||
const firstCell = cells[0];
|
||||
const px =
|
||||
this.gridOffsetX + firstCell.x * GRID_CONFIG.VIEWER_CELL_SIZE;
|
||||
const py =
|
||||
this.gridOffsetY + firstCell.y * GRID_CONFIG.VIEWER_CELL_SIZE;
|
||||
const itemName = item.meta?.itemData.name ?? item.id;
|
||||
|
||||
this.add
|
||||
.text(
|
||||
px + GRID_CONFIG.VIEWER_CELL_SIZE / 2,
|
||||
py + GRID_CONFIG.VIEWER_CELL_SIZE / 2,
|
||||
itemName,
|
||||
{
|
||||
fontSize: "11px",
|
||||
color: "#ffffff",
|
||||
fontStyle: "bold",
|
||||
},
|
||||
)
|
||||
.setOrigin(0.5);
|
||||
}
|
||||
|
||||
const adjacent = getAdjacentItems(this.inventory, itemId);
|
||||
if (adjacent.size > 0) {
|
||||
const adjacentNames = Array.from(adjacent.values())
|
||||
.map((i) => i.meta?.itemData.name ?? i.id)
|
||||
.join(", ");
|
||||
const firstCell = cells[0];
|
||||
const px =
|
||||
this.gridOffsetX + firstCell.x * GRID_CONFIG.VIEWER_CELL_SIZE;
|
||||
const py =
|
||||
this.gridOffsetY + firstCell.y * GRID_CONFIG.VIEWER_CELL_SIZE - 20;
|
||||
|
||||
this.add
|
||||
.text(
|
||||
px + GRID_CONFIG.VIEWER_CELL_SIZE / 2,
|
||||
py,
|
||||
`邻接: ${adjacentNames}`,
|
||||
{
|
||||
fontSize: "10px",
|
||||
color: "#ffff88",
|
||||
},
|
||||
)
|
||||
.setOrigin(0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getItemColor(itemId: string): number {
|
||||
const hash = itemId.split("").reduce((acc, c) => acc + c.charCodeAt(0), 0);
|
||||
return ITEM_COLORS[hash % ITEM_COLORS.length];
|
||||
}
|
||||
|
||||
private createControls(): void {
|
||||
const { width, height } = this.scale;
|
||||
|
||||
createButton({
|
||||
scene: this,
|
||||
label: "返回菜单",
|
||||
x: 100,
|
||||
y: 40,
|
||||
onClick: async () => {
|
||||
await this.sceneController.launch(SceneKey.IndexScene);
|
||||
},
|
||||
});
|
||||
|
||||
createButton({
|
||||
scene: this,
|
||||
label: "清空",
|
||||
x: width - 260,
|
||||
y: 40,
|
||||
onClick: async () => {
|
||||
this.inventory = createGridInventory<GameItemMeta>(
|
||||
GRID_CONFIG.WIDTH,
|
||||
GRID_CONFIG.HEIGHT,
|
||||
);
|
||||
await this.sceneController.restart();
|
||||
},
|
||||
});
|
||||
|
||||
createButton({
|
||||
scene: this,
|
||||
label: "随机填充",
|
||||
x: width - 130,
|
||||
y: 40,
|
||||
onClick: async () => {
|
||||
this.randomFill();
|
||||
await this.sceneController.restart();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private randomFill(): void {
|
||||
this.inventory = createGridInventory<GameItemMeta>(
|
||||
GRID_CONFIG.WIDTH,
|
||||
GRID_CONFIG.HEIGHT,
|
||||
);
|
||||
const items = data.desert.getItems();
|
||||
|
||||
let itemIndex = 0;
|
||||
for (let y = 0; y < GRID_CONFIG.HEIGHT && itemIndex < items.length; y++) {
|
||||
for (let x = 0; x < GRID_CONFIG.WIDTH && itemIndex < items.length; x++) {
|
||||
const itemData = items[itemIndex];
|
||||
const shape = parseShapeString(itemData.shape);
|
||||
|
||||
const occupiedCells = new Set<string>();
|
||||
for (let sy = 0; sy < shape.height; sy++) {
|
||||
for (let sx = 0; sx < shape.width; sx++) {
|
||||
if (shape.grid[sy]?.[sx]) {
|
||||
occupiedCells.add(`${sx + x},${sy + y}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let valid = true;
|
||||
for (const cell of occupiedCells) {
|
||||
const [cx, cy] = cell.split(",").map(Number);
|
||||
if (
|
||||
cx >= GRID_CONFIG.WIDTH ||
|
||||
cy >= GRID_CONFIG.HEIGHT ||
|
||||
this.inventory.occupiedCells.has(cell as `${number},${number}`)
|
||||
) {
|
||||
valid = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (valid) {
|
||||
const item: InventoryItem<GameItemMeta> = {
|
||||
id: `item-${itemIndex}`,
|
||||
shape,
|
||||
transform: {
|
||||
offset: { x, y },
|
||||
rotation: 0,
|
||||
flipX: false,
|
||||
flipY: false,
|
||||
},
|
||||
meta: { itemData, shape },
|
||||
};
|
||||
placeItem(this.inventory, item);
|
||||
itemIndex++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import Phaser from "phaser";
|
||||
import { ReactiveScene } from "boardgame-phaser";
|
||||
import { createButton } from "@/utils/createButton";
|
||||
import { UI_CONFIG } from "@/config";
|
||||
import { SceneKey } from "./types";
|
||||
|
||||
export class IndexScene extends ReactiveScene {
|
||||
constructor() {
|
||||
super("IndexScene");
|
||||
}
|
||||
|
||||
create(): void {
|
||||
super.create();
|
||||
const { width, height } = this.scale;
|
||||
const centerX = width / 2;
|
||||
const centerY = height / 2;
|
||||
|
||||
// Title
|
||||
this.add
|
||||
.text(centerX, centerY - 150, "Slay-the-Spire-Like Viewer", {
|
||||
fontSize: "36px",
|
||||
color: "#ffffff",
|
||||
fontStyle: "bold",
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
|
||||
// Subtitle
|
||||
this.add
|
||||
.text(centerX, centerY - 100, "Choose a viewer to explore:", {
|
||||
fontSize: "18px",
|
||||
color: "#aaaaaa",
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
|
||||
// Buttons
|
||||
const buttons: {
|
||||
label: string;
|
||||
scene: SceneKey;
|
||||
y: number;
|
||||
}[] = [
|
||||
{ label: "Combat Test", scene: SceneKey.CombatTestScene, y: centerY },
|
||||
{ label: "Map Viewer", scene: SceneKey.MapViewerScene, y: centerY + 70 },
|
||||
{
|
||||
label: "Grid Inventory Viewer",
|
||||
scene: SceneKey.GridViewerScene,
|
||||
y: centerY + 140,
|
||||
},
|
||||
{
|
||||
label: "Shape Viewer",
|
||||
scene: SceneKey.ShapeViewerScene,
|
||||
y: centerY + 210,
|
||||
},
|
||||
{
|
||||
label: "Inventory Test",
|
||||
scene: SceneKey.InventoryTestScene,
|
||||
y: centerY + 280,
|
||||
},
|
||||
];
|
||||
|
||||
for (const btn of buttons) {
|
||||
this.createButton(btn.label, btn.scene, centerX, btn.y);
|
||||
}
|
||||
}
|
||||
|
||||
private createButton(
|
||||
label: string,
|
||||
targetScene: SceneKey,
|
||||
x: number,
|
||||
y: number,
|
||||
): void {
|
||||
createButton({
|
||||
scene: this,
|
||||
label,
|
||||
x,
|
||||
y,
|
||||
width: UI_CONFIG.BUTTON_WIDTH_LARGE,
|
||||
height: UI_CONFIG.BUTTON_HEIGHT_LARGE,
|
||||
onClick: async () => {
|
||||
await this.sceneController.launch(targetScene);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { ReactiveScene } from "boardgame-phaser";
|
||||
import { createButton } from "@/utils/createButton";
|
||||
import { GRID_CONFIG } from "@/config";
|
||||
import { createInventorySignal, type InventorySignal } from "@/state/inventory";
|
||||
import { createItemIn, data } from "boardgame-core/samples/slay-the-spire-like";
|
||||
import { createInventoryItemSpawner } from "@/gameobjects/InventoryItemSpawner";
|
||||
import { SceneKey } from "./types";
|
||||
import { InventorySurface } from "@/state/inventorySurfaceState";
|
||||
|
||||
export class InventoryTestScene extends ReactiveScene {
|
||||
private readonly _surfaces: InventorySurface[] = [];
|
||||
private _leftInv!: InventorySignal;
|
||||
private _rightInv!: InventorySignal;
|
||||
private _leftGridOffsetX = 0;
|
||||
private _leftGridOffsetY = 0;
|
||||
private _rightGridOffsetX = 0;
|
||||
private _rightGridOffsetY = 0;
|
||||
|
||||
constructor() {
|
||||
super("InventoryTestScene");
|
||||
}
|
||||
|
||||
create(): void {
|
||||
super.create();
|
||||
|
||||
const { width, height } = this.scale;
|
||||
|
||||
this._leftInv = createInventorySignal();
|
||||
this._rightInv = createInventorySignal();
|
||||
|
||||
const invWidth = 4;
|
||||
const invHeight = 6;
|
||||
const cellSize = GRID_CONFIG.VIEWER_CELL_SIZE;
|
||||
const gap = 80;
|
||||
|
||||
this._leftGridOffsetX =
|
||||
(width / 2 - gap / 2) / 2 - (invWidth * cellSize) / 2;
|
||||
this._leftGridOffsetY = (height - invHeight * cellSize) / 2 + 40;
|
||||
|
||||
this._rightGridOffsetX =
|
||||
width / 2 +
|
||||
gap / 2 +
|
||||
(width / 2 - gap / 2) / 2 -
|
||||
(invWidth * cellSize) / 2;
|
||||
this._rightGridOffsetY = (height - invHeight * cellSize) / 2 + 40;
|
||||
|
||||
this._surfaces.push(
|
||||
{
|
||||
invSignal: this._leftInv,
|
||||
gridOffsetX: this._leftGridOffsetX,
|
||||
gridOffsetY: this._leftGridOffsetY,
|
||||
cellSize,
|
||||
},
|
||||
{
|
||||
invSignal: this._rightInv,
|
||||
gridOffsetX: this._rightGridOffsetX,
|
||||
gridOffsetY: this._rightGridOffsetY,
|
||||
cellSize,
|
||||
},
|
||||
);
|
||||
|
||||
this.drawGrid(
|
||||
this._leftInv,
|
||||
invWidth,
|
||||
invHeight,
|
||||
this._leftGridOffsetX,
|
||||
this._leftGridOffsetY,
|
||||
);
|
||||
this.drawGrid(
|
||||
this._rightInv,
|
||||
invWidth,
|
||||
invHeight,
|
||||
this._rightGridOffsetX,
|
||||
this._rightGridOffsetY,
|
||||
);
|
||||
this.setupItemSpawner();
|
||||
|
||||
this.add
|
||||
.text(
|
||||
width / 2,
|
||||
30,
|
||||
"Inventory Signal Test (4x6 x2)",
|
||||
GRID_CONFIG.TITLE_STYLE,
|
||||
)
|
||||
.setOrigin(0.5);
|
||||
|
||||
this.createControls();
|
||||
|
||||
this.add
|
||||
.text(
|
||||
width / 2,
|
||||
height - 40,
|
||||
"Drag items between inventories",
|
||||
GRID_CONFIG.SUBTITLE_STYLE,
|
||||
)
|
||||
.setOrigin(0.5);
|
||||
}
|
||||
|
||||
private drawGrid(
|
||||
inv: InventorySignal,
|
||||
invWidth: number,
|
||||
invHeight: number,
|
||||
offsetX: number,
|
||||
offsetY: number,
|
||||
): void {
|
||||
const graphics = this.add.graphics();
|
||||
|
||||
this.addEffect(() => {
|
||||
for (let y = 0; y < invHeight; y++) {
|
||||
for (let x = 0; x < invWidth; x++) {
|
||||
const px = offsetX + x * GRID_CONFIG.VIEWER_CELL_SIZE;
|
||||
const py = offsetY + y * GRID_CONFIG.VIEWER_CELL_SIZE;
|
||||
|
||||
const isOccupied = inv.value.occupiedCells.has(`${x},${y}`);
|
||||
graphics.fillStyle(
|
||||
isOccupied
|
||||
? GRID_CONFIG.CELL_OCCUPIED_COLOR
|
||||
: GRID_CONFIG.CELL_EMPTY_COLOR,
|
||||
);
|
||||
graphics.fillRect(
|
||||
px + 1,
|
||||
py + 1,
|
||||
GRID_CONFIG.VIEWER_CELL_SIZE - 2,
|
||||
GRID_CONFIG.VIEWER_CELL_SIZE - 2,
|
||||
);
|
||||
graphics.lineStyle(1, GRID_CONFIG.GRID_LINE_COLOR);
|
||||
graphics.strokeRect(
|
||||
px,
|
||||
py,
|
||||
GRID_CONFIG.VIEWER_CELL_SIZE,
|
||||
GRID_CONFIG.VIEWER_CELL_SIZE,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private setupItemSpawner(): void {
|
||||
const spawner = createInventoryItemSpawner(this, this._surfaces);
|
||||
this.disposables.add(spawner);
|
||||
}
|
||||
|
||||
private createControls(): void {
|
||||
const { width } = this.scale;
|
||||
|
||||
createButton({
|
||||
scene: this,
|
||||
label: "返回菜单",
|
||||
x: 100,
|
||||
y: 40,
|
||||
onClick: async () => {
|
||||
await this.sceneController.launch(SceneKey.IndexScene);
|
||||
},
|
||||
});
|
||||
|
||||
createButton({
|
||||
scene: this,
|
||||
label: "添加道具(左)",
|
||||
x: width - 350,
|
||||
y: 40,
|
||||
onClick: () => {
|
||||
this.addRandomItem(this._leftInv);
|
||||
},
|
||||
});
|
||||
|
||||
createButton({
|
||||
scene: this,
|
||||
label: "添加道具(右)",
|
||||
x: width - 180,
|
||||
y: 40,
|
||||
onClick: () => {
|
||||
this.addRandomItem(this._rightInv);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private addRandomItem(inv: InventorySignal): void {
|
||||
const items = data.desert.getItems();
|
||||
|
||||
inv.produce((inventory) => {
|
||||
const usedIndices = new Set<number>();
|
||||
|
||||
for (const item of inventory.items.values()) {
|
||||
const match = item.id.match(/^item-(\d+)-/);
|
||||
if (match) {
|
||||
usedIndices.add(parseInt(match[1], 10));
|
||||
}
|
||||
}
|
||||
|
||||
let availableIndex = 0;
|
||||
while (usedIndices.has(availableIndex) && availableIndex < items.length) {
|
||||
availableIndex++;
|
||||
}
|
||||
|
||||
if (availableIndex >= items.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const itemData = items[availableIndex];
|
||||
const id = `item-${availableIndex}-${Date.now().toString(16).slice(-4)}`;
|
||||
createItemIn(inventory, id, itemData);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import Phaser from "phaser";
|
||||
import { ReactiveScene } from "boardgame-phaser";
|
||||
import { createButton } from "@/utils/createButton";
|
||||
import { UI_CONFIG, MAP_CONFIG, NODE_COLORS, NODE_LABELS } from "@/config";
|
||||
import { createRNG } from "boardgame-core";
|
||||
import {
|
||||
generatePointCrawlMap,
|
||||
data,
|
||||
type PointCrawlMap,
|
||||
type MapNode,
|
||||
type MapNodeType,
|
||||
} from "boardgame-core/samples/slay-the-spire-like";
|
||||
import { SceneKey } from "./types";
|
||||
|
||||
export class MapViewerScene extends ReactiveScene {
|
||||
private map: PointCrawlMap | null = null;
|
||||
private seed: number = Date.now();
|
||||
|
||||
// Scroll state
|
||||
private mapContainer!: Phaser.GameObjects.Container;
|
||||
private isDragging = false;
|
||||
private dragStartX = 0;
|
||||
private dragStartContainerX = 0;
|
||||
private dragStartY = 0;
|
||||
private dragStartContainerY = 0;
|
||||
|
||||
// Fixed UI (always visible, not scrolled)
|
||||
private titleText!: Phaser.GameObjects.Text;
|
||||
private legendContainer!: Phaser.GameObjects.Container;
|
||||
|
||||
constructor() {
|
||||
super("MapViewerScene");
|
||||
}
|
||||
|
||||
create(): void {
|
||||
super.create();
|
||||
this.drawFixedUI();
|
||||
this.drawMap();
|
||||
}
|
||||
|
||||
private drawFixedUI(): void {
|
||||
const { width, height } = this.scale;
|
||||
|
||||
// Title
|
||||
this.titleText = this.add
|
||||
.text(width / 2, 30, "", {
|
||||
fontSize: "24px",
|
||||
color: "#ffffff",
|
||||
fontStyle: "bold",
|
||||
})
|
||||
.setOrigin(0.5)
|
||||
.setDepth(100);
|
||||
|
||||
// Back button
|
||||
createButton({
|
||||
scene: this,
|
||||
label: "返回菜单",
|
||||
x: 100,
|
||||
y: 40,
|
||||
onClick: async () => {
|
||||
await this.sceneController.launch(SceneKey.IndexScene);
|
||||
},
|
||||
depth: 100,
|
||||
});
|
||||
|
||||
// Regenerate button
|
||||
createButton({
|
||||
scene: this,
|
||||
label: "重新生成",
|
||||
x: width - 120,
|
||||
y: 40,
|
||||
onClick: () => {
|
||||
this.seed = Date.now();
|
||||
this.mapContainer.destroy();
|
||||
this.drawMap();
|
||||
},
|
||||
depth: 100,
|
||||
});
|
||||
|
||||
// Legend (bottom-left, fixed)
|
||||
this.legendContainer = this.add.container(20, height - 180).setDepth(100);
|
||||
const legendBg = this.add.rectangle(75, 80, 150, 160, 0x222222, 0.8);
|
||||
this.legendContainer.add(legendBg);
|
||||
|
||||
this.legendContainer.add(
|
||||
this.add.text(10, 5, "图例:", {
|
||||
fontSize: "14px",
|
||||
color: "#ffffff",
|
||||
fontStyle: "bold",
|
||||
}),
|
||||
);
|
||||
|
||||
let offsetY = 30;
|
||||
for (const [type, color] of Object.entries(NODE_COLORS)) {
|
||||
this.legendContainer.add(this.add.circle(20, offsetY, 8, color));
|
||||
this.legendContainer.add(
|
||||
this.add.text(40, offsetY - 5, NODE_LABELS[type as MapNodeType], {
|
||||
fontSize: "12px",
|
||||
color: "#ffffff",
|
||||
}),
|
||||
);
|
||||
offsetY += 20;
|
||||
}
|
||||
|
||||
// Hint text
|
||||
this.add
|
||||
.text(width / 2, height - 20, "拖拽滚动查看地图 (从左到右)", {
|
||||
fontSize: "14px",
|
||||
color: "#888888",
|
||||
})
|
||||
.setOrigin(0.5)
|
||||
.setDepth(100);
|
||||
}
|
||||
|
||||
private drawMap(): void {
|
||||
const rng = createRNG(this.seed);
|
||||
this.map = generatePointCrawlMap(rng, data.desert.getEncounters());
|
||||
|
||||
const { width, height } = this.scale;
|
||||
const {
|
||||
LAYER_HEIGHT,
|
||||
NODE_SPACING,
|
||||
NODE_RADIUS,
|
||||
TOTAL_LAYERS,
|
||||
MAX_NODES_PER_LAYER,
|
||||
} = MAP_CONFIG;
|
||||
|
||||
// Update title
|
||||
this.titleText.setText(`Map Viewer (Seed: ${this.seed})`);
|
||||
|
||||
// Calculate map bounds
|
||||
const maxLayer = TOTAL_LAYERS - 1;
|
||||
const mapWidth = maxLayer * LAYER_HEIGHT + 200;
|
||||
const mapHeight = (MAX_NODES_PER_LAYER - 1) * NODE_SPACING + 200;
|
||||
|
||||
// Create scrollable container
|
||||
this.mapContainer = this.add.container(width / 2, height / 2);
|
||||
|
||||
// Background panel for the map area
|
||||
const bg = this.add
|
||||
.rectangle(0, 0, mapWidth, mapHeight, 0x111122, 0.5)
|
||||
.setOrigin(0.5);
|
||||
this.mapContainer.add(bg);
|
||||
|
||||
const graphics = this.add.graphics();
|
||||
this.mapContainer.add(graphics);
|
||||
|
||||
// Draw edges
|
||||
graphics.lineStyle(2, 0x666666);
|
||||
for (const [nodeId, node] of this.map.nodes) {
|
||||
const posX = this.getNodeX(node);
|
||||
const posY = this.getNodeY(node);
|
||||
|
||||
for (const childId of node.childIds) {
|
||||
const child = this.map.nodes.get(childId);
|
||||
if (child) {
|
||||
const childX = this.getNodeX(child);
|
||||
const childY = this.getNodeY(child);
|
||||
graphics.lineBetween(posX, posY, childX, childY);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Draw nodes
|
||||
for (const [nodeId, node] of this.map.nodes) {
|
||||
const posX = this.getNodeX(node);
|
||||
const posY = this.getNodeY(node);
|
||||
const color = NODE_COLORS[node.type as MapNodeType] ?? 0x888888;
|
||||
|
||||
// Node circle
|
||||
graphics.fillStyle(color);
|
||||
graphics.fillCircle(posX, posY, NODE_RADIUS);
|
||||
graphics.lineStyle(2, 0xffffff);
|
||||
graphics.strokeCircle(posX, posY, NODE_RADIUS);
|
||||
|
||||
// Node label
|
||||
const label = NODE_LABELS[node.type as MapNodeType] ?? node.type;
|
||||
this.mapContainer.add(
|
||||
this.add
|
||||
.text(posX, posY, label, {
|
||||
fontSize: "12px",
|
||||
color: "#ffffff",
|
||||
})
|
||||
.setOrigin(0.5),
|
||||
);
|
||||
|
||||
// Encounter name (if available)
|
||||
if (node.encounter) {
|
||||
this.mapContainer.add(
|
||||
this.add
|
||||
.text(posX, posY + NODE_RADIUS + 12, node.encounter.name, {
|
||||
fontSize: "10px",
|
||||
color: "#cccccc",
|
||||
})
|
||||
.setOrigin(0.5),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Setup drag-to-scroll with disposables cleanup
|
||||
const onPointerDown = (pointer: Phaser.Input.Pointer) => {
|
||||
this.isDragging = true;
|
||||
this.dragStartX = pointer.x;
|
||||
this.dragStartY = pointer.y;
|
||||
this.dragStartContainerX = this.mapContainer.x;
|
||||
this.dragStartContainerY = this.mapContainer.y;
|
||||
};
|
||||
|
||||
const onPointerMove = (pointer: Phaser.Input.Pointer) => {
|
||||
if (!this.isDragging) return;
|
||||
this.mapContainer.x =
|
||||
this.dragStartContainerX + (pointer.x - this.dragStartX);
|
||||
this.mapContainer.y =
|
||||
this.dragStartContainerY + (pointer.y - this.dragStartY);
|
||||
};
|
||||
|
||||
const onPointerUp = () => {
|
||||
this.isDragging = false;
|
||||
};
|
||||
|
||||
this.input.on("pointerdown", onPointerDown);
|
||||
this.input.on("pointermove", onPointerMove);
|
||||
this.input.on("pointerup", onPointerUp);
|
||||
this.input.on("pointerout", onPointerUp);
|
||||
|
||||
this.disposables.add(() => {
|
||||
this.input.off("pointerdown", onPointerDown);
|
||||
this.input.off("pointermove", onPointerMove);
|
||||
this.input.off("pointerup", onPointerUp);
|
||||
this.input.off("pointerout", onPointerUp);
|
||||
});
|
||||
}
|
||||
|
||||
private getNodeX(node: MapNode): number {
|
||||
return -500 + node.layerIndex * MAP_CONFIG.LAYER_HEIGHT;
|
||||
}
|
||||
|
||||
private getNodeY(node: MapNode): number {
|
||||
const layer = this.map!.layers[node.layerIndex];
|
||||
const nodeIndex = layer.nodeIds.indexOf(node.id);
|
||||
const totalNodes = layer.nodeIds.length;
|
||||
const layerHeight = (totalNodes - 1) * MAP_CONFIG.NODE_SPACING;
|
||||
return -layerHeight / 2 + nodeIndex * MAP_CONFIG.NODE_SPACING;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { ReactiveScene } from "boardgame-phaser";
|
||||
import { createButton } from "@/utils/createButton";
|
||||
import { SHAPE_CONFIG } from "@/config";
|
||||
import {
|
||||
parseShapeString,
|
||||
data,
|
||||
type ParsedShape,
|
||||
} from "boardgame-core/samples/slay-the-spire-like";
|
||||
import { SceneKey } from "./types";
|
||||
|
||||
export class ShapeViewerScene extends ReactiveScene {
|
||||
constructor() {
|
||||
super("ShapeViewerScene");
|
||||
}
|
||||
|
||||
create(): void {
|
||||
super.create();
|
||||
this.drawShapeViewer();
|
||||
this.createControls();
|
||||
this.createBackButton();
|
||||
}
|
||||
|
||||
private createBackButton(): void {
|
||||
createButton({
|
||||
scene: this,
|
||||
label: "← Back",
|
||||
x: 80,
|
||||
y: 30,
|
||||
onClick: async () => {
|
||||
await this.sceneController.launch(SceneKey.IndexScene);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private drawShapeViewer(): void {
|
||||
this.children.removeAll();
|
||||
|
||||
const { width } = this.scale;
|
||||
|
||||
this.add
|
||||
.text(width / 2, 30, "Shape Viewer - Item Shapes", {
|
||||
fontSize: "24px",
|
||||
color: "#ffffff",
|
||||
fontStyle: "bold",
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
|
||||
this.drawAllShapes();
|
||||
}
|
||||
|
||||
private drawAllShapes(): void {
|
||||
const { width } = this.scale;
|
||||
const startY = 80;
|
||||
const { SPACING_X, SPACING_Y, ITEMS_PER_ROW, MAX_ITEMS } = SHAPE_CONFIG;
|
||||
|
||||
const itemsToShow = data.desert.getItems().slice(0, MAX_ITEMS);
|
||||
|
||||
for (let i = 0; i < itemsToShow.length; i++) {
|
||||
const itemData = itemsToShow[i];
|
||||
const shape = parseShapeString(itemData.shape);
|
||||
|
||||
const col = i % ITEMS_PER_ROW;
|
||||
const row = Math.floor(i / ITEMS_PER_ROW);
|
||||
|
||||
const x = 60 + col * SPACING_X;
|
||||
const y = startY + row * SPACING_Y;
|
||||
|
||||
this.drawSingleShape(x, y, itemData, shape);
|
||||
}
|
||||
}
|
||||
|
||||
private drawSingleShape(
|
||||
startX: number,
|
||||
startY: number,
|
||||
itemData: any,
|
||||
shape: ParsedShape,
|
||||
): void {
|
||||
const graphics = this.add.graphics();
|
||||
const { CELL_SIZE } = SHAPE_CONFIG;
|
||||
|
||||
const shapeWidth = shape.width * CELL_SIZE;
|
||||
const shapeHeight = shape.height * CELL_SIZE;
|
||||
|
||||
this.add
|
||||
.text(startX + shapeWidth / 2, startY - 20, itemData.name, {
|
||||
fontSize: "14px",
|
||||
color: "#ffffff",
|
||||
fontStyle: "bold",
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
|
||||
for (let y = 0; y < shape.height; y++) {
|
||||
for (let x = 0; x < shape.width; x++) {
|
||||
if (shape.grid[y]?.[x]) {
|
||||
const px = startX + x * CELL_SIZE;
|
||||
const py = startY + y * CELL_SIZE;
|
||||
|
||||
const isOrigin = x === shape.originX && y === shape.originY;
|
||||
const color = isOrigin ? 0x88cc44 : 0x4488cc;
|
||||
|
||||
graphics.fillStyle(color);
|
||||
graphics.fillRect(px + 1, py + 1, CELL_SIZE - 2, CELL_SIZE - 2);
|
||||
graphics.lineStyle(2, 0xffffff);
|
||||
graphics.strokeRect(px, py, CELL_SIZE, CELL_SIZE);
|
||||
|
||||
if (isOrigin) {
|
||||
this.add
|
||||
.text(px + CELL_SIZE / 2, py + CELL_SIZE / 2, "O", {
|
||||
fontSize: "16px",
|
||||
color: "#ffffff",
|
||||
fontStyle: "bold",
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.add
|
||||
.text(
|
||||
startX + shapeWidth / 2,
|
||||
startY + shapeHeight + 10,
|
||||
`形状: ${itemData.shape}`,
|
||||
{
|
||||
fontSize: "11px",
|
||||
color: "#aaaaaa",
|
||||
},
|
||||
)
|
||||
.setOrigin(0.5);
|
||||
|
||||
this.add
|
||||
.text(
|
||||
startX + shapeWidth / 2,
|
||||
startY + shapeHeight + 28,
|
||||
`类型: ${itemData.type} | 费用: ${itemData.costCount} ${itemData.costType}`,
|
||||
{
|
||||
fontSize: "11px",
|
||||
color: "#cccccc",
|
||||
},
|
||||
)
|
||||
.setOrigin(0.5);
|
||||
|
||||
this.add
|
||||
.text(startX + shapeWidth / 2, startY + shapeHeight + 46, itemData.desc, {
|
||||
fontSize: "10px",
|
||||
color: "#888888",
|
||||
wordWrap: { width: shapeWidth },
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
}
|
||||
|
||||
private createControls(): void {
|
||||
const { width, height } = this.scale;
|
||||
|
||||
createButton({
|
||||
scene: this,
|
||||
label: "返回菜单",
|
||||
x: 100,
|
||||
y: height - 40,
|
||||
onClick: async () => {
|
||||
await this.sceneController.launch(SceneKey.IndexScene);
|
||||
},
|
||||
});
|
||||
|
||||
this.add
|
||||
.text(
|
||||
width / 2,
|
||||
height - 40,
|
||||
`Showing first ${SHAPE_CONFIG.MAX_ITEMS} items | Green = Origin | Blue = Normal`,
|
||||
{
|
||||
fontSize: "14px",
|
||||
color: "#aaaaaa",
|
||||
},
|
||||
)
|
||||
.setOrigin(0.5);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export enum SceneKey {
|
||||
CombatTestScene = "CombatTestScene",
|
||||
GridViewerScene = "GridViewerScene",
|
||||
IndexScene = "IndexScene",
|
||||
InventoryTestScene = "InventoryTestScene",
|
||||
MapViewerScene = "MapViewerScene",
|
||||
ShapeViewerScene = "ShapeViewerScene",
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { computed, ReadonlySignal, signal, Signal } from "@preact/signals-core";
|
||||
import { ITEM_COLORS } from "@/config";
|
||||
import {
|
||||
type GameItem,
|
||||
type ParsedShape,
|
||||
type Transform2D,
|
||||
} from "boardgame-core/samples/slay-the-spire-like";
|
||||
|
||||
export class InventoryItemState {
|
||||
private readonly _item: Signal<GameItem>;
|
||||
private readonly _previewRotation: Signal<number>;
|
||||
|
||||
readonly name: ReadonlySignal<string>;
|
||||
readonly shape: ReadonlySignal<ParsedShape>;
|
||||
readonly color: ReadonlySignal<number>;
|
||||
readonly transform: ReadonlySignal<Transform2D>;
|
||||
readonly previewRotation: ReadonlySignal<number>;
|
||||
|
||||
constructor(initialItem: GameItem) {
|
||||
this._item = signal(initialItem);
|
||||
this._previewRotation = signal(0);
|
||||
|
||||
this.name = computed(() => {
|
||||
const item = this._item.value;
|
||||
return item.meta?.itemData.name ?? item?.id ?? "";
|
||||
});
|
||||
|
||||
this.shape = computed(() => this._item.value.shape);
|
||||
|
||||
this.color = computed(() => this.computeColor(this._item.value.id ?? ""));
|
||||
|
||||
this.transform = computed(() => this._item.value.transform);
|
||||
|
||||
this.previewRotation = computed(() => {
|
||||
const base = this._item.value.transform.rotation ?? 0;
|
||||
return (base + this._previewRotation.value) % 360;
|
||||
});
|
||||
}
|
||||
|
||||
get item(): GameItem {
|
||||
return this._item.value;
|
||||
}
|
||||
|
||||
setItem(item: GameItem): void {
|
||||
this._item.value = item;
|
||||
this._previewRotation.value = 0;
|
||||
}
|
||||
|
||||
setPreviewRotation(rotation: number): void {
|
||||
this._previewRotation.value = rotation;
|
||||
}
|
||||
|
||||
addPreviewRotation(rotation: number): void {
|
||||
this._previewRotation.value += rotation;
|
||||
}
|
||||
|
||||
private computeColor(itemId: string): number {
|
||||
const hash = itemId.split("").reduce((acc, c) => acc + c.charCodeAt(0), 0);
|
||||
return ITEM_COLORS[hash % ITEM_COLORS.length];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
buildCombatState,
|
||||
createRunState,
|
||||
createStart,
|
||||
createTriggers,
|
||||
data,
|
||||
generateDeckFromInventory,
|
||||
getAdjacentItems,
|
||||
getItemEffects,
|
||||
IRunContext,
|
||||
} from "boardgame-core/samples/slay-the-spire-like";
|
||||
import { createInventorySignal } from "./inventory";
|
||||
|
||||
export type CombatModule = ReturnType<typeof createCombatModule>;
|
||||
export function createCombatModule() {
|
||||
const inventory = createInventorySignal(true);
|
||||
|
||||
const deck = generateDeckFromInventory(inventory.value);
|
||||
|
||||
const run = createRunState();
|
||||
|
||||
const encounter = data.desert.getEncounters()[0];
|
||||
|
||||
const effects = getItemEffects(inventory.value);
|
||||
const combat = buildCombatState(
|
||||
{
|
||||
hp: run.currentHp,
|
||||
maxHp: run.maxHp,
|
||||
itemEffects: effects,
|
||||
},
|
||||
deck,
|
||||
encounter,
|
||||
);
|
||||
|
||||
const runContext: IRunContext = {
|
||||
getItemData(id: string) {
|
||||
return inventory.value.items.get(id)?.meta?.itemData || null;
|
||||
},
|
||||
getAdjacentItems(id: string) {
|
||||
return getAdjacentItems(inventory.value, id).keys();
|
||||
},
|
||||
getConsumedUses(id: string) {
|
||||
return inventory.value.items.get(id)?.meta?.consumedUses || 0;
|
||||
},
|
||||
async setConsumedUsesAsync(id: string, uses: number) {
|
||||
await inventory.produceAsync((draft) => {
|
||||
const item = draft.items.get(id);
|
||||
if (!item?.meta) return;
|
||||
item.meta.consumedUses = uses;
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const triggers = createTriggers(runContext);
|
||||
data.desert.addTriggers(triggers, runContext);
|
||||
const start = createStart(triggers, runContext);
|
||||
|
||||
return {
|
||||
start,
|
||||
createInitialState: () => combat,
|
||||
triggers,
|
||||
runContext,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { create } from "mutative";
|
||||
import { mutableSignal } from "boardgame-core";
|
||||
import {
|
||||
createGridInventory,
|
||||
createItemIn,
|
||||
data,
|
||||
GameItemMeta,
|
||||
placeItem,
|
||||
removeItem,
|
||||
Transform2D,
|
||||
validatePlacement,
|
||||
} from "boardgame-core/samples/slay-the-spire-like";
|
||||
import { batch } from "@preact/signals-core";
|
||||
|
||||
function genId() {
|
||||
return Math.random().toString(16).slice(-8);
|
||||
}
|
||||
|
||||
export type InventorySignal = ReturnType<typeof createInventorySignal>;
|
||||
|
||||
export function createInventorySignal(giveStart = false) {
|
||||
const inventory = createGridInventory<GameItemMeta>(4, 6);
|
||||
|
||||
if (giveStart) {
|
||||
const startingItems = data.desert.getStartingItems();
|
||||
for (const d of startingItems) {
|
||||
createItemIn(inventory, `${d.id}-${genId()}`, d);
|
||||
}
|
||||
}
|
||||
|
||||
return mutableSignal(inventory);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move an item to a new position in the inventory.
|
||||
* Returns true if the move was successful, false if the new position is invalid.
|
||||
*/
|
||||
export function moveItem(
|
||||
from: InventorySignal,
|
||||
to: InventorySignal,
|
||||
itemId: string,
|
||||
newX: number,
|
||||
newY: number,
|
||||
newRotation?: number,
|
||||
): boolean {
|
||||
const inventory = from.value;
|
||||
const item = inventory.items.get(itemId);
|
||||
|
||||
if (!item) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const newTransform: Transform2D = {
|
||||
offset: { x: newX, y: newY },
|
||||
rotation: newRotation === undefined ? item.transform.rotation : newRotation,
|
||||
flipX: false,
|
||||
flipY: false,
|
||||
};
|
||||
|
||||
const removed = create(to.value, (inv) => {
|
||||
removeItem(inv, itemId);
|
||||
});
|
||||
const validation = validatePlacement(removed, item.shape, newTransform);
|
||||
if (!validation.valid) return false;
|
||||
|
||||
batch(() => {
|
||||
from.produce((inv) => {
|
||||
removeItem(inv, itemId);
|
||||
});
|
||||
to.produce((inv) => {
|
||||
placeItem(inv, {
|
||||
...item,
|
||||
transform: newTransform,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { InventorySignal } from "./inventory";
|
||||
import { MutableSignal, mutableSignal } from "boardgame-core";
|
||||
|
||||
export type InventorySurface = {
|
||||
invSignal: InventorySignal;
|
||||
gridOffsetX: number;
|
||||
gridOffsetY: number;
|
||||
cellSize: number;
|
||||
};
|
||||
|
||||
export class InventorySurfaceState {
|
||||
private readonly _signal: MutableSignal<InventorySurface>;
|
||||
|
||||
constructor(init: InventorySurface) {
|
||||
this._signal = mutableSignal(init);
|
||||
}
|
||||
|
||||
public get surface() {
|
||||
return this._signal.value;
|
||||
}
|
||||
|
||||
public get invSignal() {
|
||||
return this._signal.value.invSignal;
|
||||
}
|
||||
|
||||
public get gridOffsetX() {
|
||||
return this._signal.value.gridOffsetX;
|
||||
}
|
||||
|
||||
public get gridOffsetY() {
|
||||
return this._signal.value.gridOffsetY;
|
||||
}
|
||||
|
||||
public get cellSize() {
|
||||
return this._signal.value.cellSize;
|
||||
}
|
||||
|
||||
public setSurface(surface: InventorySurface) {
|
||||
this._signal.value = surface;
|
||||
}
|
||||
}
|
||||
|
||||
export function sceneToInventory(
|
||||
surface: InventorySurface | InventorySurfaceState,
|
||||
x: number,
|
||||
y: number,
|
||||
): { x: number; y: number } | null {
|
||||
const invX = Math.round((x - surface.gridOffsetX) / surface.cellSize);
|
||||
const invY = Math.round((y - surface.gridOffsetY) / surface.cellSize);
|
||||
|
||||
const { width, height } = surface.invSignal.peek();
|
||||
if (invX < 0 || invY < 0 || invX >= width || invY >= height) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { x: invX, y: invY };
|
||||
}
|
||||
|
||||
export function inventoryToScene(
|
||||
surface: InventorySurface | InventorySurfaceState,
|
||||
x: number,
|
||||
y: number,
|
||||
): { x: number; y: number } {
|
||||
return {
|
||||
x: x * surface.cellSize + surface.gridOffsetX,
|
||||
y: y * surface.cellSize + surface.gridOffsetY,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { GAME_CONFIG } from "@/config";
|
||||
import { CombatTestScene } from "@/scenes/CombatTestScene";
|
||||
import { GridViewerScene } from "@/scenes/GridViewerScene";
|
||||
import { IndexScene } from "@/scenes/IndexScene";
|
||||
import { InventoryTestScene } from "@/scenes/InventoryTestScene";
|
||||
import { MapViewerScene } from "@/scenes/MapViewerScene";
|
||||
import { ShapeViewerScene } from "@/scenes/ShapeViewerScene";
|
||||
import { CombatModule, createCombatModule } from "@/state/combatState";
|
||||
import { createGameHost } from "boardgame-core";
|
||||
import {
|
||||
CombatResult,
|
||||
CombatState,
|
||||
} from "boardgame-core/samples/slay-the-spire-like";
|
||||
import { PhaserGame, PhaserScene } from "boardgame-phaser";
|
||||
import { useMemo } from "preact/hooks";
|
||||
|
||||
export default function App() {
|
||||
const gameHost = useMemo(
|
||||
() =>
|
||||
createGameHost<CombatState, CombatResult | null, CombatModule>(
|
||||
createCombatModule(),
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen">
|
||||
<div className="flex-1 flex relative justify-center items-center">
|
||||
<PhaserGame initialScene="IndexScene" config={GAME_CONFIG}>
|
||||
<PhaserScene scene={IndexScene} />
|
||||
<PhaserScene scene={CombatTestScene} data={{ gameHost }} />
|
||||
<PhaserScene scene={InventoryTestScene} />
|
||||
<PhaserScene scene={MapViewerScene} />
|
||||
<PhaserScene scene={GridViewerScene} />
|
||||
<PhaserScene scene={ShapeViewerScene} />
|
||||
</PhaserGame>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import Phaser from 'phaser';
|
||||
import { UI_CONFIG } from '@/config';
|
||||
|
||||
export interface ButtonOptions {
|
||||
/** The Phaser scene to create the button in */
|
||||
scene: Phaser.Scene;
|
||||
/** Button label text */
|
||||
label: string;
|
||||
/** X position (center of button) */
|
||||
x: number;
|
||||
/** Y position (center of button) */
|
||||
y: number;
|
||||
/** Button width (defaults to UI_CONFIG.BUTTON_WIDTH) */
|
||||
width?: number;
|
||||
/** Button height (defaults to UI_CONFIG.BUTTON_HEIGHT) */
|
||||
height?: number;
|
||||
/** Click handler */
|
||||
onClick: () => void;
|
||||
/** Phaser depth (defaults to 100) */
|
||||
depth?: number;
|
||||
/** Background color (defaults to UI_CONFIG.BUTTON_BG) */
|
||||
bgColor?: number;
|
||||
/** Hover background color (defaults to UI_CONFIG.BUTTON_BG_HOVER) */
|
||||
hoverBgColor?: number;
|
||||
/** Border color (defaults to UI_CONFIG.BUTTON_BORDER) */
|
||||
borderColor?: number;
|
||||
/** Text color (defaults to UI_CONFIG.BUTTON_TEXT_COLOR) */
|
||||
textColor?: string;
|
||||
/** Font size (defaults to UI_CONFIG.BUTTON_FONT_SIZE) */
|
||||
fontSize?: string;
|
||||
}
|
||||
|
||||
export interface ButtonObjects {
|
||||
/** Background rectangle */
|
||||
bg: Phaser.GameObjects.Rectangle;
|
||||
/** Text label */
|
||||
text: Phaser.GameObjects.Text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a styled interactive button in a Phaser scene.
|
||||
*
|
||||
* Handles hover effects (fill color change + text scale) and click callback.
|
||||
* Returns both the bg and text objects for further customization if needed.
|
||||
*/
|
||||
export function createButton(options: ButtonOptions): ButtonObjects {
|
||||
const {
|
||||
scene,
|
||||
label,
|
||||
x,
|
||||
y,
|
||||
width = UI_CONFIG.BUTTON_WIDTH,
|
||||
height = UI_CONFIG.BUTTON_HEIGHT,
|
||||
onClick,
|
||||
depth = 100,
|
||||
bgColor = UI_CONFIG.BUTTON_BG,
|
||||
hoverBgColor = UI_CONFIG.BUTTON_BG_HOVER,
|
||||
borderColor = UI_CONFIG.BUTTON_BORDER,
|
||||
textColor = UI_CONFIG.BUTTON_TEXT_COLOR,
|
||||
fontSize = UI_CONFIG.BUTTON_FONT_SIZE,
|
||||
} = options;
|
||||
|
||||
const bg = scene.add.rectangle(x, y, width, height, bgColor)
|
||||
.setStrokeStyle(2, borderColor)
|
||||
.setInteractive({ useHandCursor: true })
|
||||
.setDepth(depth);
|
||||
|
||||
const text = scene.add.text(x, y, label, {
|
||||
fontSize,
|
||||
color: textColor,
|
||||
}).setOrigin(0.5).setDepth(depth);
|
||||
|
||||
bg.on('pointerover', () => {
|
||||
bg.setFillStyle(hoverBgColor);
|
||||
text.setScale(1.05);
|
||||
});
|
||||
|
||||
bg.on('pointerout', () => {
|
||||
bg.setFillStyle(bgColor);
|
||||
text.setScale(1);
|
||||
});
|
||||
|
||||
bg.on('pointerdown', onClick);
|
||||
|
||||
return { bg, text };
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"],
|
||||
"boardgame-phaser": ["../framework/src/index.ts"],
|
||||
},
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "preact",
|
||||
"noEmit": true,
|
||||
"declaration": false,
|
||||
"declarationMap": false,
|
||||
"sourceMap": false,
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import preact from '@preact/preset-vite';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [preact(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src'),
|
||||
'boardgame-phaser': path.resolve(__dirname, '../framework/src/index.ts'),
|
||||
},
|
||||
},
|
||||
});
|
||||
Generated
+2143
-34
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user