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",
|
"dev": "pnpm --filter sample-game dev",
|
||||||
"build": "pnpm --filter boardgame-phaser build && pnpm --filter sample-game build",
|
"build": "pnpm --filter boardgame-phaser build && pnpm --filter sample-game build",
|
||||||
"build:framework": "pnpm --filter boardgame-phaser 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": {
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.17.0",
|
||||||
|
"eslint": "^9.17.0",
|
||||||
|
"eslint-plugin-import": "^2.31.0",
|
||||||
"typescript": "^5.3.3",
|
"typescript": "^5.3.3",
|
||||||
|
"typescript-eslint": "^8.18.0",
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^3.2.4"
|
||||||
},
|
},
|
||||||
"pnpm": {
|
"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 { h } from 'preact';
|
||||||
import { GameUI } from 'boardgame-phaser';
|
import { GameUI } from 'boardgame-phaser';
|
||||||
import * as boop from './game';
|
|
||||||
import './style.css';
|
import './style.css';
|
||||||
import App from "@/ui/App";
|
import App from "@/ui/App";
|
||||||
import {GameScene} from "@/scenes/GameScene";
|
|
||||||
|
|
||||||
const ui = new GameUI({
|
const ui = new GameUI({
|
||||||
container: document.getElementById('ui-root')!,
|
container: document.getElementById('ui-root')!,
|
||||||
root: <App gameModule={boop} gameScene={GameScene}/>,
|
root: <App/>,
|
||||||
});
|
});
|
||||||
|
|
||||||
ui.mount();
|
ui.mount();
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { BoopState, PlayerType, BoopPart } from '@/game';
|
|||||||
|
|
||||||
const BOARD_SIZE = 6;
|
const BOARD_SIZE = 6;
|
||||||
const CELL_SIZE = 80;
|
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 };
|
export { BOARD_SIZE, CELL_SIZE, BOARD_OFFSET };
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ export class BoardRenderer {
|
|||||||
|
|
||||||
this.turnText = this.scene.add.text(
|
this.turnText = this.scene.add.text(
|
||||||
BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2,
|
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',
|
fontSize: '22px',
|
||||||
@@ -31,7 +31,7 @@ export class BoardRenderer {
|
|||||||
|
|
||||||
this.infoText = this.scene.add.text(
|
this.infoText = this.scene.add.text(
|
||||||
BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2,
|
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',
|
fontSize: '16px',
|
||||||
@@ -62,7 +62,7 @@ export class BoardRenderer {
|
|||||||
|
|
||||||
g.strokePath();
|
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',
|
fontSize: '32px',
|
||||||
fontFamily: 'Arial',
|
fontFamily: 'Arial',
|
||||||
color: '#1f2937',
|
color: '#1f2937',
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { createPieceSpawner } from './PieceSpawner';
|
|||||||
import { SupplyUI } from './SupplyUI';
|
import { SupplyUI } from './SupplyUI';
|
||||||
import { PieceTypeSelector } from './PieceTypeSelector';
|
import { PieceTypeSelector } from './PieceTypeSelector';
|
||||||
import { WinnerOverlay } from './WinnerOverlay';
|
import { WinnerOverlay } from './WinnerOverlay';
|
||||||
import { StartOverlay } from './StartOverlay';
|
|
||||||
import { ErrorOverlay } from './ErrorOverlay';
|
import { ErrorOverlay } from './ErrorOverlay';
|
||||||
|
|
||||||
export class GameScene extends GameHostScene<BoopState> {
|
export class GameScene extends GameHostScene<BoopState> {
|
||||||
@@ -14,7 +13,6 @@ export class GameScene extends GameHostScene<BoopState> {
|
|||||||
private supplyUI!: SupplyUI;
|
private supplyUI!: SupplyUI;
|
||||||
private pieceTypeSelector!: PieceTypeSelector;
|
private pieceTypeSelector!: PieceTypeSelector;
|
||||||
private winnerOverlay!: WinnerOverlay;
|
private winnerOverlay!: WinnerOverlay;
|
||||||
private startOverlay!: StartOverlay;
|
|
||||||
private errorOverlay!: ErrorOverlay;
|
private errorOverlay!: ErrorOverlay;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -28,8 +26,7 @@ export class GameScene extends GameHostScene<BoopState> {
|
|||||||
this.boardRenderer = new BoardRenderer(this);
|
this.boardRenderer = new BoardRenderer(this);
|
||||||
this.supplyUI = new SupplyUI(this);
|
this.supplyUI = new SupplyUI(this);
|
||||||
this.pieceTypeSelector = new PieceTypeSelector(this);
|
this.pieceTypeSelector = new PieceTypeSelector(this);
|
||||||
this.winnerOverlay = new WinnerOverlay(this, () => this.restartGame());
|
this.winnerOverlay = new WinnerOverlay(this, () => this.gameHost.start());
|
||||||
this.startOverlay = new StartOverlay(this, () => this.startGame());
|
|
||||||
this.errorOverlay = new ErrorOverlay(this);
|
this.errorOverlay = new ErrorOverlay(this);
|
||||||
|
|
||||||
// 设置棋子生成器
|
// 设置棋子生成器
|
||||||
@@ -43,16 +40,6 @@ export class GameScene extends GameHostScene<BoopState> {
|
|||||||
() => this.gameHost.status.value !== 'running' || !!this.state.winner
|
() => 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(() => {
|
this.addEffect(() => {
|
||||||
const winner = this.state.winner;
|
const winner = this.state.winner;
|
||||||
@@ -69,6 +56,9 @@ export class GameScene extends GameHostScene<BoopState> {
|
|||||||
this.supplyUI.update(this.state);
|
this.supplyUI.update(this.state);
|
||||||
this.pieceTypeSelector.update(this.state);
|
this.pieceTypeSelector.update(this.state);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 启动游戏
|
||||||
|
this.gameHost.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleCellClick(row: number, col: number): void {
|
private handleCellClick(row: number, col: number): void {
|
||||||
@@ -81,17 +71,14 @@ export class GameScene extends GameHostScene<BoopState> {
|
|||||||
|
|
||||||
private handlePieceClick(row: number, col: number): void {
|
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) {
|
if (error) {
|
||||||
this.errorOverlay.show(error);
|
this.errorOverlay.show(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private startGame(): void {
|
/** 跳转到菜单场景 */
|
||||||
this.gameHost.start();
|
private async goToMenu(): Promise<void> {
|
||||||
}
|
await this.sceneController.launch('MenuScene');
|
||||||
|
|
||||||
private restartGame(): void {
|
|
||||||
this.gameHost.start();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
constructor(private scene: Phaser.Scene) {
|
||||||
const boardCenterX = BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2;
|
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);
|
this.container = this.scene.add.container(boardCenterX, selectorY);
|
||||||
|
|
||||||
const label = this.scene.add.text(-160, 0, '放置:', {
|
const label = this.scene.add.text(-160, 0, '放置:', {
|
||||||
fontSize: '18px',
|
fontSize: '16px',
|
||||||
fontFamily: 'Arial',
|
fontFamily: 'Arial',
|
||||||
color: '#4b5563',
|
color: '#4b5563',
|
||||||
}).setOrigin(0.5, 0.5);
|
}).setOrigin(0.5, 0.5);
|
||||||
|
|
||||||
this.kittenButton = this.createPieceButton('kitten', '🐾 小猫', -50);
|
this.kittenButton = this.createPieceButton('kitten', '🐾 小猫', -40);
|
||||||
this.catButton = this.createPieceButton('cat', '🐱 大猫', 70);
|
this.catButton = this.createPieceButton('cat', '🐱 大猫', 80);
|
||||||
|
|
||||||
this.container.add([label, this.kittenButton, this.catButton]);
|
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 {
|
private createPieceButton(type: PieceType, text: string, xOffset: number): Phaser.GameObjects.Container {
|
||||||
const container = this.scene.add.container(xOffset, 0);
|
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);
|
.setStrokeStyle(2, 0x9ca3af);
|
||||||
|
|
||||||
const textObj = this.scene.add.text(0, 0, text, {
|
const textObj = this.scene.add.text(0, 0, text, {
|
||||||
@@ -88,10 +88,10 @@ export class PieceTypeSelector {
|
|||||||
|
|
||||||
update(state: BoopState): void {
|
update(state: BoopState): void {
|
||||||
const currentPlayer = state.currentPlayer;
|
const currentPlayer = state.currentPlayer;
|
||||||
const { kittensInSupply, catsInSupply, catsOnBoard } = this.countPieces(state, currentPlayer);
|
const { kittensInSupply, catsInSupply } = this.countPieces(state, currentPlayer);
|
||||||
|
|
||||||
const kittenAvailable = kittensInSupply > 0;
|
const kittenAvailable = kittensInSupply > 0;
|
||||||
const catsAvailable = catsInSupply + catsOnBoard > 0;
|
const catsAvailable = catsInSupply > 0;
|
||||||
|
|
||||||
this.updateButton(
|
this.updateButton(
|
||||||
this.kittenButton,
|
this.kittenButton,
|
||||||
@@ -104,7 +104,7 @@ export class PieceTypeSelector {
|
|||||||
this.catButton,
|
this.catButton,
|
||||||
catsAvailable,
|
catsAvailable,
|
||||||
this.selectedType === 'cat',
|
this.selectedType === 'cat',
|
||||||
`🐱 大猫 (${catsInSupply + catsOnBoard})`
|
`🐱 大猫 (${catsInSupply})`
|
||||||
);
|
);
|
||||||
|
|
||||||
// 自动切换到可用类型
|
// 自动切换到可用类型
|
||||||
|
|||||||
@@ -12,13 +12,13 @@ export class SupplyUI {
|
|||||||
|
|
||||||
constructor(private scene: Phaser.Scene) {
|
constructor(private scene: Phaser.Scene) {
|
||||||
const boardCenterX = BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2;
|
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.whiteContainer = this.scene.add.container(boardCenterX - 160, uiY);
|
||||||
this.whiteBg = this.scene.add.rectangle(0, 0, 120, 50, 0x000000);
|
this.whiteBg = this.scene.add.rectangle(0, 0, 120, 40, 0x000000);
|
||||||
this.whiteText = this.scene.add.text(0, 0, '', {
|
this.whiteText = this.scene.add.text(0, 0, '', {
|
||||||
fontSize: '16px',
|
fontSize: '12px',
|
||||||
fontFamily: 'Arial',
|
fontFamily: 'Arial',
|
||||||
color: '#ffffff',
|
color: '#ffffff',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
@@ -27,10 +27,10 @@ export class SupplyUI {
|
|||||||
this.whiteContainer.setDepth(100);
|
this.whiteContainer.setDepth(100);
|
||||||
|
|
||||||
// 黑色玩家容器
|
// 黑色玩家容器
|
||||||
this.blackContainer = this.scene.add.container(boardCenterX + 150, uiY);
|
this.blackContainer = this.scene.add.container(boardCenterX + 160, uiY);
|
||||||
this.blackBg = this.scene.add.rectangle(0, 0, 120, 50, 0x333333);
|
this.blackBg = this.scene.add.rectangle(0, 0, 120, 40, 0x333333);
|
||||||
this.blackText = this.scene.add.text(0, 0, '', {
|
this.blackText = this.scene.add.text(0, 0, '', {
|
||||||
fontSize: '16px',
|
fontSize: '12px',
|
||||||
fontFamily: 'Arial',
|
fontFamily: 'Arial',
|
||||||
color: '#ffffff',
|
color: '#ffffff',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export { GameScene } from './GameScene';
|
export { GameScene } from './GameScene';
|
||||||
|
export { MenuScene } from './MenuScene';
|
||||||
export { BoardRenderer, BOARD_SIZE, CELL_SIZE, BOARD_OFFSET } from './BoardRenderer';
|
export { BoardRenderer, BOARD_SIZE, CELL_SIZE, BOARD_OFFSET } from './BoardRenderer';
|
||||||
export { createPieceSpawner } from './PieceSpawner';
|
export { createPieceSpawner } from './PieceSpawner';
|
||||||
export { SupplyUI } from './SupplyUI';
|
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 { 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 } }) {
|
export default function App() {
|
||||||
|
const gameHost = useMemo(() => createGameHost(gameModule as unknown as GameModule<BoopState>), []);
|
||||||
const gameHost = useComputed(() => {
|
const gameScene = useMemo(() => new GameScene(), []);
|
||||||
const gameHost = createGameHost(props.gameModule);
|
const menuScene = useMemo(() => new MenuScene(), []);
|
||||||
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');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-screen">
|
<div className="flex flex-col h-screen">
|
||||||
<div className="flex-1 flex relative justify-center items-center">
|
<div className="flex-1 flex relative justify-center items-center">
|
||||||
<PhaserGame config={{ width: 640, height: 750 }}>
|
<PhaserGame initialScene="MenuScene" config={{ width: 640, height: 720 }}>
|
||||||
<PhaserScene sceneKey="GameScene" scene={scene.value} autoStart data={gameHost.value} />
|
<PhaserScene sceneKey="MenuScene" scene={menuScene} />
|
||||||
|
<PhaserScene sceneKey="GameScene" scene={gameScene} data={{gameHost}}/>
|
||||||
</PhaserGame>
|
</PhaserGame>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,39 @@
|
|||||||
// Resource management
|
// Resource management
|
||||||
export { DisposableBag } from './utils';
|
export { DisposableBag } from "./utils";
|
||||||
export type { IDisposable, DisposableItem } 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
|
// Data-driven object spawning
|
||||||
export { spawnEffect } from './spawner';
|
export { spawnEffect, createSpawnUpdate } from "./spawner";
|
||||||
export type { Spawner } from './spawner';
|
export type { Spawner, SpawnerCallback } from "./spawner";
|
||||||
|
|
||||||
// Scene base classes
|
// Scene base classes
|
||||||
export { ReactiveScene, GameHostScene, FadeScene, FADE_SCENE_KEY } from './scenes';
|
export {
|
||||||
export type { ReactiveSceneOptions, ReactiveScenePhaserData, SceneController, GameHostSceneOptions, FadeSceneData } from './scenes';
|
ReactiveScene,
|
||||||
|
GameHostScene,
|
||||||
|
FadeScene,
|
||||||
|
FADE_SCENE_KEY,
|
||||||
|
} from "./scenes";
|
||||||
|
export type {
|
||||||
|
ReactiveSceneOptions,
|
||||||
|
ReactiveScenePhaserData,
|
||||||
|
SceneController,
|
||||||
|
GameHostSceneOptions,
|
||||||
|
FadeSceneData,
|
||||||
|
} from "./scenes";
|
||||||
|
|
||||||
// React ↔ Phaser bridge
|
// React ↔ Phaser bridge
|
||||||
export { PhaserGame, PhaserScene, phaserContext, defaultPhaserConfig, GameUI, type PhaserGameContext, type SceneController as PhaserSceneController } from './ui';
|
export {
|
||||||
export type { PhaserGameProps, PhaserSceneProps, GameUIOptions } from './ui';
|
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 } from "./ReactiveScene";
|
||||||
import { ReactiveScene, type ReactiveScenePhaserData } from './ReactiveScene';
|
|
||||||
|
|
||||||
export interface FadeSceneData {
|
export interface FadeSceneData {
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理淡入淡出到黑色的过渡场景
|
* 处理淡入淡出到黑色的过渡场景
|
||||||
*/
|
*/
|
||||||
export class FadeScene extends ReactiveScene<FadeSceneData> {
|
export class FadeScene extends ReactiveScene<FadeSceneData> {
|
||||||
private overlay!: Phaser.GameObjects.Rectangle;
|
private overlay!: Phaser.GameObjects.Rectangle;
|
||||||
private isFading = false;
|
private isFading = false;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super(FADE_SCENE_KEY);
|
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 {
|
if (this.isFading) {
|
||||||
super.create();
|
console.warn("FadeScene: 正在进行过渡动画");
|
||||||
|
|
||||||
// 创建黑色遮罩层,覆盖整个游戏区域
|
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
this.isFading = true;
|
||||||
* 淡入(从黑色到透明)
|
this.overlay.setAlpha(targetAlpha === 1 ? 0 : 1);
|
||||||
* @param duration 动画时长(毫秒)
|
|
||||||
*/
|
|
||||||
fadeIn(duration = 300): Promise<void> {
|
|
||||||
return this.fadeTo(0, duration);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
return new Promise<void>((resolve) => {
|
||||||
* 淡出(从透明到黑色)
|
this.tweens.add({
|
||||||
* @param duration 动画时长(毫秒)
|
targets: this.overlay,
|
||||||
*/
|
alpha: targetAlpha,
|
||||||
fadeOut(duration = 300): Promise<void> {
|
duration,
|
||||||
return this.fadeTo(1, duration);
|
ease: "Linear",
|
||||||
}
|
onComplete: () => {
|
||||||
|
this.isFading = false;
|
||||||
/**
|
resolve();
|
||||||
* 淡入淡出到指定透明度
|
},
|
||||||
*/
|
});
|
||||||
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();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 导出常量供 PhaserGame 使用
|
// 导出常量供 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>> {
|
import type { GameHost, GameModule } from "boardgame-core";
|
||||||
gameHost: GameHost<TState>;
|
|
||||||
[key: string]: unknown;
|
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>>
|
export abstract class GameHostScene<
|
||||||
extends ReactiveScene<GameHostSceneOptions<TState>>
|
TState extends Record<string, unknown>,
|
||||||
{
|
TResult,
|
||||||
public get gameHost(): GameHost<TState> {
|
TModule extends GameModule<TState, TResult>,
|
||||||
const gameHost = this.initData.gameHost as GameHost<TState>;
|
> extends ReactiveScene<GameHostSceneOptions<TState, TResult, TModule>> {
|
||||||
if (!gameHost) {
|
public get gameHost(): GameHost<TState, TResult, TModule> {
|
||||||
throw new Error(
|
const gameHost = this.initData.gameHost;
|
||||||
`GameHostScene (${this.scene.key}): gameHost 未提供。` +
|
if (!gameHost) {
|
||||||
`确保在 PhaserScene 组件的 data 属性中传入 gameHost。`
|
throw new Error(
|
||||||
);
|
`GameHostScene (${this.scene.key}): gameHost 未提供。` +
|
||||||
}
|
"确保在 PhaserScene 组件的 data 属性中传入 gameHost。",
|
||||||
return gameHost;
|
);
|
||||||
}
|
}
|
||||||
|
return gameHost;
|
||||||
|
}
|
||||||
|
|
||||||
public get state(): TState {
|
public get state(): TState {
|
||||||
return this.gameHost?.state.value;
|
return this.gameHost?.state.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
addInterruption(promise: Promise<void>){
|
addInterruption(promise: Promise<void>) {
|
||||||
this.gameHost?.addInterruption(promise);
|
this.gameHost?.addInterruption(promise);
|
||||||
}
|
}
|
||||||
|
|
||||||
addTweenInterruption(tween: Phaser.Tweens.Tween){
|
addTweenInterruption(tween: Phaser.Tweens.Tween) {
|
||||||
this.addInterruption(new Promise(
|
this.addInterruption(
|
||||||
resolve => tween.once('complete', resolve)
|
new Promise((resolve) => tween.once("complete", resolve)),
|
||||||
));
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,86 +1,90 @@
|
|||||||
import Phaser from 'phaser';
|
import { type ReadonlySignal } from "@preact/signals-core";
|
||||||
import { effect, type ReadonlySignal } from '@preact/signals-core';
|
import { Scene } from "phaser";
|
||||||
import { DisposableBag, type IDisposable } from '../utils';
|
|
||||||
|
import { DisposableBag, type IDisposable } from "../utils";
|
||||||
|
|
||||||
|
import type Phaser from "phaser";
|
||||||
|
|
||||||
type CleanupFn = void | (() => void);
|
type CleanupFn = void | (() => void);
|
||||||
|
|
||||||
// 前向声明,避免循环导入
|
// 前向声明,避免循环导入
|
||||||
export interface SceneController {
|
export interface SceneController {
|
||||||
launch(sceneKey: string): Promise<void>;
|
launch(sceneKey: string): Promise<void>;
|
||||||
currentScene: ReadonlySignal<string | null>;
|
restart(): Promise<void>;
|
||||||
isTransitioning: ReadonlySignal<boolean>;
|
currentScene: ReadonlySignal<string | null>;
|
||||||
|
isTransitioning: ReadonlySignal<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReactiveScenePhaserData {
|
export interface ReactiveScenePhaserData {
|
||||||
phaserGame: ReadonlySignal<{ game: Phaser.Game }>;
|
phaserGame: ReadonlySignal<{ game: Phaser.Game }>;
|
||||||
sceneController: SceneController;
|
sceneController: SceneController;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReactiveSceneOptions<TData extends Record<string, unknown> = {}> {
|
export interface ReactiveSceneOptions {
|
||||||
key?: string;
|
key?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 通用的响应式 Scene 基类
|
* 通用的响应式 Scene 基类
|
||||||
* @typeparam TData - 通过 init(data) 接收的数据类型(必须包含 phaserGame 和 sceneController)
|
* @typeparam TData - 通过 init(data) 接收的数据类型(必须包含 phaserGame 和 sceneController)
|
||||||
*/
|
*/
|
||||||
export abstract class ReactiveScene<TData extends Record<string, unknown> = {}>
|
export abstract class ReactiveScene<TData = object>
|
||||||
extends Phaser.Scene
|
extends Scene
|
||||||
implements IDisposable
|
implements IDisposable
|
||||||
{
|
{
|
||||||
protected disposables = new DisposableBag();
|
protected disposables = new DisposableBag();
|
||||||
private _initData?: TData & ReactiveScenePhaserData;
|
private _initData?: TData & ReactiveScenePhaserData;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取通过 init() 注入的数据
|
* 获取通过 init() 注入的数据
|
||||||
* 在 create() 阶段保证可用
|
* 在 create() 阶段保证可用
|
||||||
*/
|
*/
|
||||||
public get initData(): TData & ReactiveScenePhaserData {
|
public get initData(): TData & ReactiveScenePhaserData {
|
||||||
if (!this._initData) {
|
if (!this._initData) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`ReactiveScene (${this.scene.key}): initData 尚未初始化。` +
|
`ReactiveScene (${this.scene.key}): initData 尚未初始化。` +
|
||||||
`确保场景通过 PhaserScene 组件注册,并在 create() 阶段访问。`
|
"确保场景通过 PhaserScene 组件注册,并在 create() 阶段访问。",
|
||||||
);
|
);
|
||||||
}
|
|
||||||
return this._initData;
|
|
||||||
}
|
}
|
||||||
|
return this._initData;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取 Phaser game 实例的响应式信号
|
* 获取 Phaser game 实例的响应式信号
|
||||||
*/
|
*/
|
||||||
public get phaserGame(): ReadonlySignal<{ game: Phaser.Game }> {
|
public get phaserGame(): ReadonlySignal<{ game: Phaser.Game }> {
|
||||||
return this.initData.phaserGame;
|
return this.initData.phaserGame;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取场景控制器
|
* 获取场景控制器
|
||||||
*/
|
*/
|
||||||
public get sceneController(): SceneController {
|
public get sceneController(): SceneController {
|
||||||
return this.initData.sceneController;
|
return this.initData.sceneController;
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(key?: string) {
|
constructor(key?: string) {
|
||||||
super(key);
|
super(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
init(data: TData & ReactiveScenePhaserData): void {
|
init(data: TData & ReactiveScenePhaserData): void {
|
||||||
this._initData = data;
|
this._initData = data;
|
||||||
}
|
}
|
||||||
|
|
||||||
create(): void {
|
create(): void {
|
||||||
this.events.on('shutdown', this.dispose, this);
|
this.events.on("shutdown", this.dispose, this);
|
||||||
}
|
}
|
||||||
|
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
this.disposables.dispose();
|
this.disposables.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
public addDisposable(disposable: IDisposable): void {
|
public addDisposable(disposable: IDisposable): void {
|
||||||
this.disposables.add(disposable);
|
this.disposables.add(disposable);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 注册响应式监听(场景关闭时自动清理) */
|
/** 注册响应式监听(场景关闭时自动清理) */
|
||||||
public addEffect(fn: () => CleanupFn): void {
|
public addEffect(fn: () => CleanupFn): void {
|
||||||
this.disposables.add(effect(fn));
|
this.disposables.addEffect(fn);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
export { ReactiveScene } from './ReactiveScene';
|
export { ReactiveScene } from "./ReactiveScene";
|
||||||
export type { ReactiveSceneOptions, ReactiveScenePhaserData, SceneController } from './ReactiveScene';
|
export type {
|
||||||
|
ReactiveSceneOptions,
|
||||||
|
ReactiveScenePhaserData,
|
||||||
|
SceneController,
|
||||||
|
} from "./ReactiveScene";
|
||||||
|
|
||||||
export { FadeScene, FADE_SCENE_KEY } from './FadeScene';
|
export { FadeScene, FADE_SCENE_KEY } from "./FadeScene";
|
||||||
export type { FadeSceneData } from './FadeScene';
|
export type { FadeSceneData } from "./FadeScene";
|
||||||
|
|
||||||
export { GameHostScene } from './GameHostScene';
|
export { GameHostScene } from "./GameHostScene";
|
||||||
export type { GameHostSceneOptions } 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;
|
type GO = Phaser.GameObjects.GameObject;
|
||||||
|
|
||||||
export interface Spawner<TData, TObject extends GO = GO> {
|
export interface SpawnerCallback<TData, TObject extends GO = GO> {
|
||||||
/** 数据源迭代器 */
|
/** 获取数据的唯一键 */
|
||||||
getData(): Iterable<TData>;
|
getKey(t: TData): string;
|
||||||
/** 获取数据的唯一键 */
|
/** 创建新对象 */
|
||||||
getKey(t: TData): string;
|
onSpawn(t: TData): TObject | null;
|
||||||
/** 创建新对象 */
|
/** 销毁旧对象 */
|
||||||
onSpawn(t: TData): TObject | null;
|
onDespawn(obj: TObject, t: TData): void;
|
||||||
/** 销毁旧对象 */
|
/** 更新已有对象 */
|
||||||
onDespawn(obj: TObject, t: TData): void;
|
onUpdate(t: TData, obj: TObject): 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>(
|
export function spawnEffect<TData, TObject extends GO = GO>(
|
||||||
spawner: Spawner<TData, TObject>,
|
spawner: Spawner<TData, TObject>,
|
||||||
): () => void {
|
) {
|
||||||
const objects = new Map<string, TObject>();
|
const update = createSpawnUpdate(spawner);
|
||||||
const spawnData = new Map<string, TData>();
|
|
||||||
|
|
||||||
return effect(() => {
|
return effect(() => {
|
||||||
const current = new Set<string>();
|
update(spawner.getData());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
for (const t of spawner.getData()) {
|
export function createSpawnUpdate<TData, TObject extends GO = GO>(
|
||||||
const key = spawner.getKey(t);
|
spawner: SpawnerCallback<TData, TObject>,
|
||||||
current.add(key);
|
) {
|
||||||
|
const entries = new Map<string, { object: TObject; data: TData }>();
|
||||||
|
|
||||||
const existing = objects.get(key);
|
function update(data: Iterable<TData>) {
|
||||||
if (!existing) {
|
const current = new Set<string>();
|
||||||
const obj = spawner.onSpawn(t);
|
|
||||||
if (obj) {
|
for (const t of data) {
|
||||||
spawnData.set(key, t);
|
const key = spawner.getKey(t);
|
||||||
objects.set(key, obj);
|
current.add(key);
|
||||||
}
|
|
||||||
} else {
|
const existing = entries.get(key);
|
||||||
if(spawnData.get(key) === t) continue;
|
if (!existing) {
|
||||||
spawner.onUpdate(t, existing);
|
const obj = spawner.onSpawn(t);
|
||||||
spawnData.set(key, 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) {
|
for (const [key, entry] of entries) {
|
||||||
if (!current.has(key)) {
|
if (!current.has(key)) {
|
||||||
spawner.onDespawn(obj, spawnData.get(key)!);
|
spawner.onDespawn(entry.object, entry.data);
|
||||||
objects.delete(key);
|
entries.delete(key);
|
||||||
spawnData.delete(key);
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
return update;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,39 @@
|
|||||||
|
import type { JSX } from "preact";
|
||||||
|
|
||||||
export interface GameUIOptions {
|
export interface GameUIOptions {
|
||||||
container: HTMLElement;
|
container: HTMLElement | string;
|
||||||
root: any;
|
root: JSX.Element;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class GameUI {
|
export class GameUI {
|
||||||
private container: HTMLElement;
|
private container: HTMLElement;
|
||||||
private root: any;
|
private root: JSX.Element;
|
||||||
|
|
||||||
constructor(options: GameUIOptions) {
|
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;
|
this.root = options.root;
|
||||||
}
|
}
|
||||||
|
|
||||||
mount(): void {
|
mount(): void {
|
||||||
import('preact').then(({ render }) => {
|
import("preact").then(({ render }) => {
|
||||||
render(this.root, this.container);
|
render(this.root, this.container);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
unmount(): void {
|
unmount(): void {
|
||||||
import('preact').then(({ render }) => {
|
import("preact").then(({ render }) => {
|
||||||
render(null, this.container);
|
render(null, this.container);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,184 +1,247 @@
|
|||||||
import Phaser from 'phaser';
|
import { signal, useSignal, useSignalEffect } from "@preact/signals";
|
||||||
import { signal, useSignal, useSignalEffect } from '@preact/signals';
|
import Phaser, { AUTO, Scene } from "phaser";
|
||||||
import { createContext, h } from 'preact';
|
import { createContext } from "preact";
|
||||||
import { useContext, useEffect, useRef } from 'preact/hooks';
|
import { useContext, useEffect, useRef } from "preact/hooks";
|
||||||
import { ReadonlySignal } from "@preact/signals-core";
|
|
||||||
import type { ReactiveScene } from '../scenes';
|
import {
|
||||||
import { FadeScene as FadeSceneClass, FADE_SCENE_KEY } from '../scenes/FadeScene';
|
FadeScene as FadeSceneClass,
|
||||||
|
FADE_SCENE_KEY,
|
||||||
|
} from "../scenes/FadeScene";
|
||||||
|
|
||||||
|
import type { ReactiveScene } from "../scenes";
|
||||||
|
import type { ReadonlySignal } from "@preact/signals-core";
|
||||||
|
|
||||||
export interface SceneController {
|
export interface SceneController {
|
||||||
/** 启动场景(带淡入淡出过渡) */
|
/** 启动场景(带淡入淡出过渡) */
|
||||||
launch(sceneKey: string): Promise<void>;
|
launch(sceneKey: string): Promise<void>;
|
||||||
/** 当前活跃场景 key */
|
/** 重新启动当前场景(带淡入淡出过渡) */
|
||||||
currentScene: ReadonlySignal<string | null>;
|
restart(): Promise<void>;
|
||||||
/** 是否正在过渡 */
|
/** 当前活跃场景 key */
|
||||||
isTransitioning: ReadonlySignal<boolean>;
|
currentScene: ReadonlySignal<string | null>;
|
||||||
|
/** 是否正在过渡 */
|
||||||
|
isTransitioning: ReadonlySignal<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PhaserGameContext {
|
export interface PhaserGameContext {
|
||||||
game: Phaser.Game;
|
game: Phaser.Game;
|
||||||
sceneController: SceneController;
|
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 = {
|
export const defaultPhaserConfig: Phaser.Types.Core.GameConfig = {
|
||||||
type: Phaser.AUTO,
|
type: AUTO,
|
||||||
width: 560,
|
width: 560,
|
||||||
height: 560,
|
height: 560,
|
||||||
parent: 'phaser-container',
|
parent: "phaser-container",
|
||||||
backgroundColor: '#f9fafb',
|
backgroundColor: "#f9fafb",
|
||||||
scene: [],
|
scene: [],
|
||||||
|
disableContextMenu: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface PhaserGameProps {
|
export interface PhaserGameProps {
|
||||||
config?: Partial<Phaser.Types.Core.GameConfig>;
|
config?: Partial<Phaser.Types.Core.GameConfig>;
|
||||||
/** 初始启动的场景 key */
|
/** 初始启动的场景 key */
|
||||||
initialScene?: string;
|
initialScene?: string;
|
||||||
children?: any;
|
children?: preact.ComponentChildren;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PhaserGame(props: PhaserGameProps) {
|
export function PhaserGame(props: PhaserGameProps) {
|
||||||
const gameSignal = useSignal<PhaserGameContext>({ game: undefined!, sceneController: undefined! });
|
const gameSignal = useSignal<PhaserGameContext>({
|
||||||
const initialSceneLaunched = useRef(false);
|
game: undefined!,
|
||||||
|
sceneController: undefined!,
|
||||||
|
});
|
||||||
|
const initialSceneLaunched = useRef(false);
|
||||||
|
|
||||||
useSignalEffect(() => {
|
useSignalEffect(() => {
|
||||||
const config: Phaser.Types.Core.GameConfig = {
|
const config: Phaser.Types.Core.GameConfig = {
|
||||||
...defaultPhaserConfig,
|
...defaultPhaserConfig,
|
||||||
...props.config,
|
...props.config,
|
||||||
};
|
};
|
||||||
const phaserGame = new Phaser.Game(config);
|
const phaserGame = new Phaser.Game(config);
|
||||||
|
|
||||||
// 添加 FadeScene 并启动它来初始化 overlay
|
// 添加 FadeScene 并启动它来初始化 overlay
|
||||||
const fadeScene = new FadeSceneClass();
|
const fadeScene = new FadeSceneClass();
|
||||||
phaserGame.scene.add(FADE_SCENE_KEY, fadeScene, true); // 改为 true 以触发 create
|
phaserGame.scene.add(FADE_SCENE_KEY, fadeScene, true); // 改为 true 以触发 create
|
||||||
|
|
||||||
// 创建 SceneController
|
// 创建 SceneController
|
||||||
const currentScene = signal<string | null>(null);
|
const currentScene = signal<string | null>(null);
|
||||||
const isTransitioning = signal(false);
|
const isTransitioning = signal(false);
|
||||||
|
|
||||||
const sceneController: SceneController = {
|
const sceneController: SceneController = {
|
||||||
async launch(sceneKey: string) {
|
async launch(sceneKey: string) {
|
||||||
if (isTransitioning.value) {
|
if (isTransitioning.value) {
|
||||||
console.warn('SceneController: 正在进行场景切换');
|
console.warn("SceneController: 正在进行场景切换");
|
||||||
return;
|
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sceneSignal.value = props.scene;
|
// 等待场景注册完成(最多等待 100ms)
|
||||||
registered.current = true;
|
let retries = 0;
|
||||||
|
while (!phaserGame.scene.getScene(sceneKey) && retries < 10) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
retries++;
|
||||||
|
}
|
||||||
|
|
||||||
return () => {
|
// 验证场景是否已注册
|
||||||
sceneSignal.value = undefined;
|
if (!phaserGame.scene.getScene(sceneKey)) {
|
||||||
registered.current = false;
|
console.error(`SceneController: 场景 "${sceneKey}" 未注册`);
|
||||||
// 不在这里移除场景,让 SceneController 管理生命周期
|
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 { GameUI } from "./GameUI";
|
||||||
export type { GameUIOptions } from './GameUI';
|
export type { GameUIOptions } from "./GameUI";
|
||||||
|
|
||||||
export { PhaserGame, PhaserScene, phaserContext, defaultPhaserConfig, type PhaserGameContext, type SceneController } from './PhaserBridge';
|
export {
|
||||||
export type { PhaserGameProps, PhaserSceneProps } from './PhaserBridge';
|
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 {
|
export interface IDisposable {
|
||||||
dispose(): void;
|
dispose(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DisposableItem = IDisposable | (() => void);
|
export type DisposableItem = IDisposable | (() => void);
|
||||||
|
|
||||||
export class DisposableBag implements IDisposable {
|
export class DisposableBag implements IDisposable {
|
||||||
private _disposables = new Set<DisposableItem>();
|
private _disposables = new Set<DisposableItem>();
|
||||||
private _isDisposed = false;
|
private _isDisposed = false;
|
||||||
|
|
||||||
get isDisposed(): boolean {
|
constructor(go?: Phaser.GameObjects.GameObject) {
|
||||||
return this._isDisposed;
|
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 {
|
this._disposables.clear();
|
||||||
if (this._isDisposed) {
|
}
|
||||||
this._execute(item);
|
|
||||||
return;
|
private _execute(item: DisposableItem): void {
|
||||||
}
|
if (typeof item === "function") {
|
||||||
this._disposables.add(item);
|
item();
|
||||||
}
|
} else {
|
||||||
|
item.dispose();
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { DisposableBag } from "./disposable";
|
||||||
export type { IDisposable, DisposableItem } 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 { GameUI } from 'boardgame-phaser';
|
|
||||||
import * as gameModule from './game/onitama';
|
import "./style.css";
|
||||||
import './style.css';
|
|
||||||
import App from "@/ui/App";
|
import App from "@/ui/App";
|
||||||
import {OnitamaScene} from "@/scenes/OnitamaScene";
|
|
||||||
|
|
||||||
const ui = new GameUI({
|
const ui = new GameUI({
|
||||||
container: document.getElementById('ui-root')!,
|
container: "ui-root",
|
||||||
root: <App gameModule={gameModule} gameScene={OnitamaScene}/>,
|
root: <App />,
|
||||||
});
|
});
|
||||||
|
|
||||||
ui.mount();
|
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 { GameHostScene, spawnEffect } from "boardgame-phaser";
|
||||||
import type { OnitamaState, Pawn } from '@/game/onitama';
|
|
||||||
import {getAvailableMoves, prompts} from '@/game/onitama';
|
import type { OnitamaState, Pawn } from "@/game/onitama";
|
||||||
import { GameHostScene } from 'boardgame-phaser';
|
import type { HighlightData } from "@/spawners/HighlightSpawner";
|
||||||
import { spawnEffect } from 'boardgame-phaser';
|
import type { OnitamaUIState } from "@/state";
|
||||||
import type { MutableSignal } from 'boardgame-core';
|
import type { MutableSignal } from "boardgame-core";
|
||||||
|
import type Phaser from "phaser";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
PawnSpawner, CardSpawner, BOARD_OFFSET, CELL_SIZE, CARD_WIDTH, CARD_HEIGHT, boardToScreen, BOARD_SIZE,
|
COLORS,
|
||||||
HighlightSpawner
|
FONTS,
|
||||||
} from '@/spawners';
|
MENU_BUTTON,
|
||||||
import type { HighlightData } from '@/spawners/HighlightSpawner';
|
TEXT_POSITION,
|
||||||
import {createUIState, clearSelection, selectPiece, selectCard, createValidMoves} from '@/state';
|
VISUAL,
|
||||||
import type { OnitamaUIState, ValidMove } from '@/state';
|
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> {
|
export class OnitamaScene extends GameHostScene<OnitamaState> {
|
||||||
private boardContainer!: Phaser.GameObjects.Container;
|
private boardContainer!: Phaser.GameObjects.Container;
|
||||||
@@ -18,12 +40,15 @@ export class OnitamaScene extends GameHostScene<OnitamaState> {
|
|||||||
private infoText!: Phaser.GameObjects.Text;
|
private infoText!: Phaser.GameObjects.Text;
|
||||||
private winnerOverlay?: Phaser.GameObjects.Container;
|
private winnerOverlay?: Phaser.GameObjects.Container;
|
||||||
private cardLabelContainers: Map<string, Phaser.GameObjects.Text> = new Map();
|
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
|
// UI State managed by MutableSignal
|
||||||
public uiState!: MutableSignal<OnitamaUIState>;
|
public uiState!: MutableSignal<OnitamaUIState>;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super('OnitamaScene');
|
super("OnitamaScene");
|
||||||
}
|
}
|
||||||
|
|
||||||
create(): void {
|
create(): void {
|
||||||
@@ -57,14 +82,10 @@ export class OnitamaScene extends GameHostScene<OnitamaState> {
|
|||||||
|
|
||||||
// Info text
|
// Info text
|
||||||
this.infoText = this.add.text(
|
this.infoText = this.add.text(
|
||||||
20,
|
TEXT_POSITION.infoX,
|
||||||
BOARD_OFFSET.y,
|
BOARD_OFFSET.y,
|
||||||
'',
|
"",
|
||||||
{
|
FONTS.info,
|
||||||
fontSize: '15px',
|
|
||||||
fontFamily: 'Arial',
|
|
||||||
color: '#4b5563',
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update info text when UI state changes
|
// Update info text when UI state changes
|
||||||
@@ -74,39 +95,34 @@ export class OnitamaScene extends GameHostScene<OnitamaState> {
|
|||||||
|
|
||||||
// Input handling
|
// Input handling
|
||||||
this.setupInput();
|
this.setupInput();
|
||||||
|
|
||||||
|
// Menu button
|
||||||
|
this.createMenuButton();
|
||||||
|
|
||||||
|
// Start the game
|
||||||
|
this.gameHost.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
private createCardLabels(): void {
|
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 - 棋盘下方
|
// Red cards label - 棋盘下方
|
||||||
const redLabel = this.add.text(
|
const redPos = getCardLabelPosition("red");
|
||||||
boardLeft + (BOARD_SIZE * CELL_SIZE) / 2,
|
const redLabel = this.add
|
||||||
boardBottom + 10,
|
.text(redPos.x, redPos.y, "RED", {
|
||||||
"RED",
|
...FONTS.cardLabel,
|
||||||
{
|
color: colorToStr(COLORS.red),
|
||||||
fontSize: '16px',
|
})
|
||||||
fontFamily: 'Arial',
|
.setOrigin(redPos.originX, redPos.originY);
|
||||||
color: '#ef4444',
|
this.cardLabelContainers.set("red", redLabel);
|
||||||
}
|
|
||||||
).setOrigin(0.5, 0);
|
|
||||||
this.cardLabelContainers.set('red', redLabel);
|
|
||||||
|
|
||||||
// Black cards label - 棋盘上方
|
// Black cards label - 棋盘上方
|
||||||
const blackLabel = this.add.text(
|
const blackPos = getCardLabelPosition("black");
|
||||||
boardLeft + (BOARD_SIZE * CELL_SIZE) / 2,
|
const blackLabel = this.add
|
||||||
boardTop - 10,
|
.text(blackPos.x, blackPos.y, "BLACK", {
|
||||||
"BLACK",
|
...FONTS.cardLabel,
|
||||||
{
|
color: colorToStr(COLORS.black),
|
||||||
fontSize: '16px',
|
})
|
||||||
fontFamily: 'Arial',
|
.setOrigin(blackPos.originX, blackPos.originY);
|
||||||
color: '#3b82f6',
|
this.cardLabelContainers.set("black", blackLabel);
|
||||||
}
|
|
||||||
).setOrigin(0.5, 1);
|
|
||||||
this.cardLabelContainers.set('black', blackLabel);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateInfoText(): void {
|
private updateInfoText(): void {
|
||||||
@@ -121,34 +137,30 @@ export class OnitamaScene extends GameHostScene<OnitamaState> {
|
|||||||
|
|
||||||
private drawBoard(): void {
|
private drawBoard(): void {
|
||||||
const g = this.gridGraphics;
|
const g = this.gridGraphics;
|
||||||
g.lineStyle(2, 0x6b7280);
|
g.lineStyle(2, COLORS.gridLine);
|
||||||
|
|
||||||
for (let i = 0; i <= BOARD_SIZE; i++) {
|
for (let i = 0; i <= BOARD_SIZE; i++) {
|
||||||
g.lineBetween(
|
g.lineBetween(
|
||||||
BOARD_OFFSET.x + i * CELL_SIZE,
|
BOARD_OFFSET.x + i * CELL_SIZE,
|
||||||
BOARD_OFFSET.y,
|
BOARD_OFFSET.y,
|
||||||
BOARD_OFFSET.x + i * CELL_SIZE,
|
BOARD_OFFSET.x + i * CELL_SIZE,
|
||||||
BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE
|
BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE,
|
||||||
);
|
);
|
||||||
g.lineBetween(
|
g.lineBetween(
|
||||||
BOARD_OFFSET.x,
|
BOARD_OFFSET.x,
|
||||||
BOARD_OFFSET.y + i * CELL_SIZE,
|
BOARD_OFFSET.y + i * CELL_SIZE,
|
||||||
BOARD_OFFSET.x + BOARD_SIZE * CELL_SIZE,
|
BOARD_OFFSET.x + BOARD_SIZE * CELL_SIZE,
|
||||||
BOARD_OFFSET.y + i * CELL_SIZE
|
BOARD_OFFSET.y + i * CELL_SIZE,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
g.strokePath();
|
g.strokePath();
|
||||||
|
|
||||||
this.add.text(
|
this.add.text(
|
||||||
20,
|
TEXT_POSITION.titleX,
|
||||||
20,
|
TEXT_POSITION.titleY,
|
||||||
'Onitama',
|
"Onitama",
|
||||||
{
|
FONTS.title,
|
||||||
fontSize: '28px',
|
|
||||||
fontFamily: 'Arial',
|
|
||||||
color: '#1f2937',
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,9 +170,11 @@ export class OnitamaScene extends GameHostScene<OnitamaState> {
|
|||||||
for (let col = 0; col < BOARD_SIZE; col++) {
|
for (let col = 0; col < BOARD_SIZE; col++) {
|
||||||
const pos = boardToScreen(col, row);
|
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;
|
if (this.state.winner) return;
|
||||||
this.handleCellClick(col, row);
|
this.handleCellClick(col, row);
|
||||||
});
|
});
|
||||||
@@ -170,7 +184,7 @@ export class OnitamaScene extends GameHostScene<OnitamaState> {
|
|||||||
|
|
||||||
private handleCellClick(x: number, y: number): void {
|
private handleCellClick(x: number, y: number): void {
|
||||||
const pawn = this.getPawnAtPosition(x, y);
|
const pawn = this.getPawnAtPosition(x, y);
|
||||||
if(pawn?.owner !== this.state.currentPlayer){
|
if (pawn?.owner !== this.state.currentPlayer) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
selectPiece(this.uiState, x, y);
|
selectPiece(this.uiState, x, y);
|
||||||
@@ -179,7 +193,8 @@ export class OnitamaScene extends GameHostScene<OnitamaState> {
|
|||||||
public onCardClick(cardId: string): void {
|
public onCardClick(cardId: string): void {
|
||||||
// 只能选择当前玩家的手牌
|
// 只能选择当前玩家的手牌
|
||||||
const currentPlayer = this.state.currentPlayer;
|
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)) {
|
if (!playerCards.includes(cardId)) {
|
||||||
return;
|
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(
|
const error = this.gameHost.tryAnswerPrompt(
|
||||||
prompts.move,
|
prompts.move,
|
||||||
this.state.currentPlayer,
|
this.state.currentPlayer,
|
||||||
@@ -207,10 +228,10 @@ export class OnitamaScene extends GameHostScene<OnitamaState> {
|
|||||||
move.fromX,
|
move.fromX,
|
||||||
move.fromY,
|
move.fromY,
|
||||||
move.toX,
|
move.toX,
|
||||||
move.toY
|
move.toY,
|
||||||
);
|
);
|
||||||
if (error) {
|
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;
|
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 {
|
private showWinner(winner: string): void {
|
||||||
if (this.winnerOverlay) {
|
if (this.winnerOverlay) {
|
||||||
this.winnerOverlay.destroy();
|
this.winnerOverlay.destroy();
|
||||||
@@ -227,42 +288,34 @@ export class OnitamaScene extends GameHostScene<OnitamaState> {
|
|||||||
|
|
||||||
this.winnerOverlay = this.add.container();
|
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(
|
const bg = this.add
|
||||||
BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2,
|
.rectangle(
|
||||||
BOARD_OFFSET.y + (BOARD_SIZE * CELL_SIZE) / 2,
|
center.x,
|
||||||
BOARD_SIZE * CELL_SIZE,
|
center.y,
|
||||||
BOARD_SIZE * CELL_SIZE,
|
boardWidth,
|
||||||
0x000000,
|
boardHeight,
|
||||||
0.6
|
COLORS.overlayBg,
|
||||||
).setInteractive({ useHandCursor: true });
|
VISUAL.overlayAlpha,
|
||||||
|
)
|
||||||
|
.setInteractive({ useHandCursor: true });
|
||||||
|
|
||||||
bg.on('pointerdown', () => {
|
bg.on("pointerdown", () => {
|
||||||
this.gameHost.start();
|
this.gameHost.start();
|
||||||
});
|
});
|
||||||
|
|
||||||
this.winnerOverlay.add(bg);
|
this.winnerOverlay.add(bg);
|
||||||
|
|
||||||
const winText = this.add.text(
|
const winText = this.add
|
||||||
BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2,
|
.text(center.x, center.y, text, FONTS.winner)
|
||||||
BOARD_OFFSET.y + (BOARD_SIZE * CELL_SIZE) / 2,
|
.setOrigin(0.5);
|
||||||
text,
|
|
||||||
{
|
|
||||||
fontSize: '36px',
|
|
||||||
fontFamily: 'Arial',
|
|
||||||
color: '#fbbf24',
|
|
||||||
}
|
|
||||||
).setOrigin(0.5);
|
|
||||||
|
|
||||||
this.winnerOverlay.add(winText);
|
this.winnerOverlay.add(winText);
|
||||||
|
|
||||||
this.tweens.add({
|
createWinnerPulseTween(this, winText);
|
||||||
targets: winText,
|
|
||||||
scale: 1.2,
|
|
||||||
duration: 500,
|
|
||||||
yoyo: true,
|
|
||||||
repeat: 1,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
import { effect } from "@preact/signals-core";
|
||||||
|
|
||||||
export const CARD_WIDTH = 100;
|
import type { Card } from "@/game/onitama";
|
||||||
export const CARD_HEIGHT = 140;
|
import type { OnitamaScene } from "@/scenes/OnitamaScene";
|
||||||
const BOARD_SIZE = 5;
|
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 {
|
export interface CardSpawnData {
|
||||||
cardId: string;
|
cardId: string;
|
||||||
position: 'red' | 'black' | 'spare';
|
position: "red" | "black" | "spare";
|
||||||
index: number;
|
index: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,11 +38,18 @@ export class CardContainer extends Phaser.GameObjects.Container {
|
|||||||
private highlightTween: Phaser.Tweens.Tween | null = null;
|
private highlightTween: Phaser.Tweens.Tween | null = null;
|
||||||
private _cardId: string;
|
private _cardId: string;
|
||||||
private _rotation: number = 0;
|
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);
|
super(scene, 0, 0);
|
||||||
this._cardId = cardId;
|
this._cardId = cardId;
|
||||||
this._rotation = rotation;
|
this._rotation = rotation;
|
||||||
|
this.cardRenderer = new CardRenderer(scene);
|
||||||
|
|
||||||
// 将容器添加到场景
|
// 将容器添加到场景
|
||||||
scene.add.existing(this);
|
scene.add.existing(this);
|
||||||
@@ -36,11 +57,16 @@ export class CardContainer extends Phaser.GameObjects.Container {
|
|||||||
// 应用旋转
|
// 应用旋转
|
||||||
this.angle = rotation;
|
this.angle = rotation;
|
||||||
|
|
||||||
// 创建卡牌视觉
|
// 使用 CardRenderer 创建卡牌视觉
|
||||||
this.createCardVisual(card);
|
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);
|
this.setInteractive(hitArea, Phaser.Geom.Rectangle.Contains);
|
||||||
|
|
||||||
// 添加场景 effect 监听高亮状态变化
|
// 添加场景 effect 监听高亮状态变化
|
||||||
@@ -53,13 +79,12 @@ export class CardContainer extends Phaser.GameObjects.Container {
|
|||||||
setRotation(rotation: number, animated: boolean = false): this {
|
setRotation(rotation: number, animated: boolean = false): this {
|
||||||
if (animated) {
|
if (animated) {
|
||||||
const currentAngle = this.angle;
|
const currentAngle = this.angle;
|
||||||
const tween = this.scene.tweens.add({
|
createCardRotateTween(
|
||||||
targets: this,
|
this.scene as OnitamaScene,
|
||||||
angle: { from: currentAngle, to: rotation },
|
this,
|
||||||
duration: 400,
|
currentAngle,
|
||||||
ease: 'Back.easeOut',
|
rotation,
|
||||||
});
|
);
|
||||||
(this.scene as OnitamaScene).addTweenInterruption(tween);
|
|
||||||
} else {
|
} else {
|
||||||
this.angle = rotation;
|
this.angle = rotation;
|
||||||
}
|
}
|
||||||
@@ -75,33 +100,22 @@ export class CardContainer extends Phaser.GameObjects.Container {
|
|||||||
|
|
||||||
if (!this.highlightRect) {
|
if (!this.highlightRect) {
|
||||||
// 创建高亮边框(初始透明)
|
// 创建高亮边框(初始透明)
|
||||||
this.highlightRect = (this.scene as OnitamaScene).add.rectangle(
|
const rect = (this.scene as OnitamaScene).add
|
||||||
0, 0, CARD_WIDTH + 8, CARD_HEIGHT + 8, color, 0
|
.rectangle(0, 0, CARD_WIDTH + 8, CARD_HEIGHT + 8, color, 0)
|
||||||
)
|
|
||||||
.setStrokeStyle(lineWidth, color)
|
.setStrokeStyle(lineWidth, color)
|
||||||
.setAlpha(0)
|
.setAlpha(0)
|
||||||
.setDepth(-1);
|
.setDepth(VISUAL.cardBackgroundDepth);
|
||||||
|
this.highlightRect = rect;
|
||||||
this.addAt(this.highlightRect, 0);
|
this.addAt(this.highlightRect, 0);
|
||||||
|
|
||||||
// 淡入动画
|
// 淡入动画
|
||||||
const fadeIn = this.scene.tweens.add({
|
createSelectionFadeInTween(this.scene as OnitamaScene, rect, () => {
|
||||||
targets: this.highlightRect,
|
// 淡入完成后开始脉冲动画
|
||||||
alpha: 1,
|
this.highlightTween = createSelectionPulseTween(
|
||||||
scale: 1.05,
|
this.scene as OnitamaScene,
|
||||||
duration: 200,
|
rect,
|
||||||
ease: 'Power2',
|
lineWidth,
|
||||||
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,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// 如果已经存在,停止当前动画并重新开始脉冲
|
// 如果已经存在,停止当前动画并重新开始脉冲
|
||||||
@@ -109,15 +123,11 @@ export class CardContainer extends Phaser.GameObjects.Container {
|
|||||||
this.highlightTween.stop();
|
this.highlightTween.stop();
|
||||||
}
|
}
|
||||||
this.highlightRect.setStrokeStyle(lineWidth, color);
|
this.highlightRect.setStrokeStyle(lineWidth, color);
|
||||||
this.highlightTween = this.scene.tweens.add({
|
this.highlightTween = createSelectionPulseTween(
|
||||||
targets: this.highlightRect,
|
this.scene as OnitamaScene,
|
||||||
alpha: 0.7,
|
this.highlightRect,
|
||||||
lineWidth: lineWidth + 1,
|
lineWidth,
|
||||||
duration: 500,
|
);
|
||||||
ease: 'Sine.easeInOut',
|
|
||||||
yoyo: true,
|
|
||||||
repeat: -1,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,18 +144,15 @@ export class CardContainer extends Phaser.GameObjects.Container {
|
|||||||
this.scene.tweens.killTweensOf(this.highlightRect);
|
this.scene.tweens.killTweensOf(this.highlightRect);
|
||||||
|
|
||||||
// 淡出动画
|
// 淡出动画
|
||||||
this.scene.tweens.add({
|
createSelectionFadeOutTween(
|
||||||
targets: this.highlightRect,
|
this.scene as OnitamaScene,
|
||||||
alpha: 0,
|
this.highlightRect,
|
||||||
scale: 0.95,
|
() => {
|
||||||
duration: 150,
|
|
||||||
ease: 'Power2',
|
|
||||||
onComplete: () => {
|
|
||||||
// 淡出完成后销毁矩形
|
// 淡出完成后销毁矩形
|
||||||
this.highlightRect?.destroy();
|
this.highlightRect?.destroy();
|
||||||
this.highlightRect = null;
|
this.highlightRect = null;
|
||||||
},
|
},
|
||||||
});
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,124 +163,50 @@ export class CardContainer extends Phaser.GameObjects.Container {
|
|||||||
// 创建一个 effect 来持续监听高亮状态变化
|
// 创建一个 effect 来持续监听高亮状态变化
|
||||||
const dispose = effect(() => {
|
const dispose = effect(() => {
|
||||||
if (scene.uiState.value.selectedCard === this._cardId) {
|
if (scene.uiState.value.selectedCard === this._cardId) {
|
||||||
this.highlight(0xfbbf24, 3);
|
this.highlight(COLORS.highlight, VISUAL.selectionRingStrokeWidth);
|
||||||
} else {
|
} else {
|
||||||
this.unhighlight();
|
this.unhighlight();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 在容器销毁时清理 effect
|
// 在容器销毁时清理 effect
|
||||||
this.on('destroy', () => {
|
this.on("destroy", () => {
|
||||||
dispose();
|
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> {
|
export class CardSpawner implements Spawner<CardSpawnData, CardContainer> {
|
||||||
private previousData = new Map<string, CardSpawnData>();
|
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> {
|
*getData(): Iterable<CardSpawnData> {
|
||||||
const state = this.scene.state;
|
const state = this.scene.state;
|
||||||
|
|
||||||
// 红方卡牌
|
// 红方卡牌
|
||||||
for (let i = 0; i < state.redCards.length; i++) {
|
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++) {
|
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)
|
if (state.spareCard) {
|
||||||
yield { cardId: state.spareCard, position: 'spare', index: 0 };
|
yield { cardId: state.spareCard, position: "spare", index: 0 };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getKey(data: CardSpawnData): string {
|
getKey(data: CardSpawnData): string {
|
||||||
return data.cardId;
|
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 {
|
private hasPositionChanged(data: CardSpawnData): boolean {
|
||||||
const prev = this.previousData.get(data.cardId);
|
const prev = this.previousData.get(data.cardId);
|
||||||
if (!prev) return true;
|
if (!prev) return true;
|
||||||
@@ -281,13 +214,13 @@ export class CardSpawner implements Spawner<CardSpawnData, CardContainer> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onUpdate(data: CardSpawnData, obj: CardContainer): void {
|
onUpdate(data: CardSpawnData, obj: CardContainer): void {
|
||||||
const isBlackTurn = this.scene.state.currentPlayer === 'black';
|
const isBlackTurn = this.scene.state.currentPlayer === "black";
|
||||||
|
|
||||||
// 检查卡牌是否需要旋转
|
// 检查卡牌是否需要旋转
|
||||||
let targetRotation = 0;
|
let targetRotation = 0;
|
||||||
if (data.position === 'black') {
|
if (data.position === "black") {
|
||||||
targetRotation = -180; // 黑方卡牌始终旋转
|
targetRotation = -180; // 黑方卡牌始终旋转
|
||||||
} else if (data.position === 'spare' && isBlackTurn) {
|
} else if (data.position === "spare" && isBlackTurn) {
|
||||||
targetRotation = -180; // 备用卡牌在黑方回合旋转
|
targetRotation = -180; // 备用卡牌在黑方回合旋转
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,18 +234,10 @@ export class CardSpawner implements Spawner<CardSpawnData, CardContainer> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pos = this.getCardPosition(data);
|
const pos = getCardPosition(data.position, data.index);
|
||||||
|
|
||||||
// 播放移动动画并添加中断
|
// 播放移动动画并添加中断
|
||||||
const tween = this.scene.tweens.add({
|
createCardMoveTween(this.scene, obj, pos.x, pos.y);
|
||||||
targets: obj,
|
|
||||||
x: pos.x,
|
|
||||||
y: pos.y,
|
|
||||||
duration: 350,
|
|
||||||
ease: 'Back.easeOut',
|
|
||||||
});
|
|
||||||
|
|
||||||
this.scene.addTweenInterruption(tween);
|
|
||||||
this.previousData.set(data.cardId, { ...data });
|
this.previousData.set(data.cardId, { ...data });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,57 +247,58 @@ export class CardSpawner implements Spawner<CardSpawnData, CardContainer> {
|
|||||||
this.previousData.set(data.cardId, { ...data });
|
this.previousData.set(data.cardId, { ...data });
|
||||||
// 返回空容器
|
// 返回空容器
|
||||||
const emptyContainer = new CardContainer(this.scene, data.cardId, {
|
const emptyContainer = new CardContainer(this.scene, data.cardId, {
|
||||||
id: data.cardId, regionId: '', position: [],
|
id: data.cardId,
|
||||||
|
regionId: "",
|
||||||
|
position: [],
|
||||||
moveCandidates: [],
|
moveCandidates: [],
|
||||||
startingPlayer: 'red'
|
startingPlayer: "red",
|
||||||
} as Card);
|
} as Card);
|
||||||
return emptyContainer;
|
return emptyContainer;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算旋转角度 - 初始不旋转,后续由 onUpdate 处理
|
// 计算初始旋转角度
|
||||||
const container = new CardContainer(this.scene, data.cardId, card, 0);
|
const isBlackTurn = this.scene.state.currentPlayer === "black";
|
||||||
const pos = this.getCardPosition(data);
|
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.x = pos.x;
|
||||||
container.y = pos.y;
|
container.y = pos.y;
|
||||||
|
|
||||||
// 设置悬停效果
|
// 设置悬停效果
|
||||||
container.on('pointerover', () => {
|
container.on("pointerover", () => {
|
||||||
if (this.scene.uiState.value.selectedCard !== data.cardId) {
|
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.setAlpha(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
container.on('pointerdown', () => {
|
container.on("pointerdown", () => {
|
||||||
this.scene.onCardClick(data.cardId);
|
this.scene.onCardClick(data.cardId);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 初始状态为透明,然后淡入
|
// 初始状态为透明,然后淡入
|
||||||
container.setAlpha(0);
|
container.setAlpha(0);
|
||||||
const tween = this.scene.tweens.add({
|
createCardSpawnTween(this.scene, container);
|
||||||
targets: container,
|
|
||||||
alpha: 1,
|
|
||||||
duration: 300,
|
|
||||||
ease: 'Power2',
|
|
||||||
});
|
|
||||||
this.scene.addTweenInterruption(tween);
|
|
||||||
|
|
||||||
this.previousData.set(data.cardId, { ...data });
|
this.previousData.set(data.cardId, { ...data });
|
||||||
return container;
|
return container;
|
||||||
}
|
}
|
||||||
|
|
||||||
onDespawn(obj: CardContainer): void {
|
onDespawn(obj: CardContainer): void {
|
||||||
const tween = this.scene.tweens.add({
|
createCardDespawnTween(this.scene, obj, () => obj.destroy());
|
||||||
targets: obj,
|
|
||||||
alpha: 0,
|
|
||||||
scale: 0.8,
|
|
||||||
duration: 200,
|
|
||||||
ease: 'Power2',
|
|
||||||
onComplete: () => obj.destroy(),
|
|
||||||
});
|
|
||||||
this.scene.addTweenInterruption(tween);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,18 @@
|
|||||||
import Phaser from 'phaser';
|
import { getAvailableMoves } from "boardgame-core/samples/onitama";
|
||||||
import type { Spawner } from 'boardgame-phaser';
|
import { Geom } from "phaser";
|
||||||
import type { OnitamaScene } from '@/scenes/OnitamaScene';
|
|
||||||
import {getAvailableMoves} from "boardgame-core/samples/onitama";
|
import type { OnitamaScene } from "@/scenes/OnitamaScene";
|
||||||
import {boardToScreen, CELL_SIZE} from './PawnSpawner';
|
import type { Spawner } from "boardgame-phaser";
|
||||||
|
|
||||||
|
import {
|
||||||
|
boardToScreen,
|
||||||
|
CELL_SIZE,
|
||||||
|
VISUAL,
|
||||||
|
createHighlightSpawnTween,
|
||||||
|
createHighlightDespawnTween,
|
||||||
|
createHighlightClickFeedbackTween,
|
||||||
|
} from "@/config";
|
||||||
|
import { HighlightRenderer } from "@/renderers";
|
||||||
|
|
||||||
export interface HighlightData {
|
export interface HighlightData {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -15,8 +25,15 @@ export interface HighlightData {
|
|||||||
toY: number;
|
toY: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class HighlightSpawner implements Spawner<HighlightData, Phaser.GameObjects.Container> {
|
export class HighlightSpawner implements Spawner<
|
||||||
constructor(public readonly scene: OnitamaScene) {}
|
HighlightData,
|
||||||
|
Phaser.GameObjects.Container
|
||||||
|
> {
|
||||||
|
private renderer: HighlightRenderer;
|
||||||
|
|
||||||
|
constructor(public readonly scene: OnitamaScene) {
|
||||||
|
this.renderer = new HighlightRenderer(scene);
|
||||||
|
}
|
||||||
|
|
||||||
*getData(): Iterable<HighlightData> {
|
*getData(): Iterable<HighlightData> {
|
||||||
const state = this.scene.state;
|
const state = this.scene.state;
|
||||||
@@ -31,10 +48,12 @@ export class HighlightSpawner implements Spawner<HighlightData, Phaser.GameObjec
|
|||||||
const availableMoves = getAvailableMoves(state, currentPlayer);
|
const availableMoves = getAvailableMoves(state, currentPlayer);
|
||||||
|
|
||||||
// 过滤出符合当前选择的移动
|
// 过滤出符合当前选择的移动
|
||||||
for(const move of availableMoves){
|
for (const move of availableMoves) {
|
||||||
if(move.fromX === uiState.selectedPiece!.x &&
|
if (
|
||||||
move.fromY === uiState.selectedPiece!.y &&
|
move.fromX === uiState.selectedPiece.x &&
|
||||||
move.card === uiState.selectedCard){
|
move.fromY === uiState.selectedPiece.y &&
|
||||||
|
move.card === uiState.selectedCard
|
||||||
|
) {
|
||||||
const pos = boardToScreen(move.toX, move.toY);
|
const pos = boardToScreen(move.toX, move.toY);
|
||||||
yield {
|
yield {
|
||||||
key: `${move.fromX}-${move.fromY}-${move.card}-${move.toX}-${move.toY}`,
|
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,
|
fromX: move.fromX,
|
||||||
fromY: move.fromY,
|
fromY: move.fromY,
|
||||||
toX: move.toX,
|
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 {
|
onSpawn(data: HighlightData): Phaser.GameObjects.Container {
|
||||||
const container = this.scene.add.container(data.x, data.y);
|
const container = this.scene.add.container(data.x, data.y);
|
||||||
|
|
||||||
// 外圈光环(动画)
|
// 使用 HighlightRenderer 创建视觉元素
|
||||||
const outerCircle = this.scene.add.circle(
|
this.renderer.render(container, { x: data.x, y: data.y });
|
||||||
0,
|
|
||||||
0,
|
|
||||||
CELL_SIZE / 3,
|
|
||||||
0x3b82f6,
|
|
||||||
0.2
|
|
||||||
);
|
|
||||||
container.add(outerCircle);
|
|
||||||
|
|
||||||
// 内圈
|
// 设置交互区域
|
||||||
const innerCircle = this.scene.add.circle(
|
const hitArea = new Geom.Circle(0, 0, VISUAL.highlightHitAreaRadius);
|
||||||
0,
|
|
||||||
0,
|
|
||||||
CELL_SIZE / 4,
|
|
||||||
0x3b82f6,
|
|
||||||
0.4
|
|
||||||
);
|
|
||||||
container.add(innerCircle);
|
|
||||||
|
|
||||||
const hitArea = new Phaser.Geom.Circle(0, 0, CELL_SIZE / 3);
|
|
||||||
container.setInteractive(hitArea, Phaser.Geom.Circle.Contains);
|
container.setInteractive(hitArea, Phaser.Geom.Circle.Contains);
|
||||||
if (container.input) {
|
if (container.input) {
|
||||||
container.input.cursor = 'pointer';
|
container.input.cursor = "pointer";
|
||||||
}
|
}
|
||||||
|
|
||||||
// 出现动画:从0缩放和透明淡入
|
// 出现动画:从0缩放和透明淡入
|
||||||
container.setAlpha(0);
|
container.setAlpha(0);
|
||||||
container.setScale(0);
|
container.setScale(0);
|
||||||
const spawnTween = this.scene.tweens.add({
|
createHighlightSpawnTween(this.scene, container);
|
||||||
targets: container,
|
|
||||||
alpha: 1,
|
|
||||||
scale: 1,
|
|
||||||
duration: 250,
|
|
||||||
ease: 'Back.easeOut',
|
|
||||||
});
|
|
||||||
this.scene.addTweenInterruption(spawnTween);
|
|
||||||
|
|
||||||
// 脉冲动画
|
// 使用 HighlightRenderer 设置脉冲动画
|
||||||
this.scene.tweens.add({
|
this.renderer.setupPulseAnimations(container);
|
||||||
targets: [outerCircle, innerCircle],
|
|
||||||
scale: 1.2,
|
|
||||||
alpha: 0.6,
|
|
||||||
duration: 600,
|
|
||||||
ease: 'Sine.easeInOut',
|
|
||||||
yoyo: true,
|
|
||||||
repeat: -1,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 外圈延迟动画,形成错开效果
|
container.on("pointerdown", () => {
|
||||||
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', () => {
|
|
||||||
// 点击时的反馈动画
|
// 点击时的反馈动画
|
||||||
this.scene.tweens.add({
|
createHighlightClickFeedbackTween(this.scene, container);
|
||||||
targets: container,
|
|
||||||
scale: 1.5,
|
|
||||||
alpha: 0.8,
|
|
||||||
duration: 150,
|
|
||||||
ease: 'Power2',
|
|
||||||
yoyo: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.scene.onHighlightClick(data);
|
this.scene.onHighlightClick(data);
|
||||||
});
|
});
|
||||||
@@ -141,15 +110,6 @@ export class HighlightSpawner implements Spawner<HighlightData, Phaser.GameObjec
|
|||||||
|
|
||||||
onDespawn(obj: Phaser.GameObjects.Container): void {
|
onDespawn(obj: Phaser.GameObjects.Container): void {
|
||||||
// 消失动画:缩小并淡出
|
// 消失动画:缩小并淡出
|
||||||
const despawnTween = this.scene.tweens.add({
|
createHighlightDespawnTween(this.scene, obj, () => obj.destroy());
|
||||||
targets: obj,
|
|
||||||
scale: 0,
|
|
||||||
alpha: 0,
|
|
||||||
duration: 200,
|
|
||||||
ease: 'Back.easeIn',
|
|
||||||
onComplete: () => obj.destroy(),
|
|
||||||
});
|
|
||||||
this.scene.addTweenInterruption(despawnTween);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 { effect } from "@preact/signals-core";
|
||||||
|
import { GameObjects } from "phaser";
|
||||||
|
|
||||||
export const CELL_SIZE = 80;
|
import type { Pawn } from "@/game/onitama";
|
||||||
export const BOARD_OFFSET = { x: 240, y: 200 };
|
import type { OnitamaScene } from "@/scenes/OnitamaScene";
|
||||||
export const BOARD_SIZE = 5;
|
import type { Spawner } from "boardgame-phaser";
|
||||||
|
|
||||||
export function boardToScreen(boardX: number, boardY: number): { x: number; y: number } {
|
import {
|
||||||
return {
|
boardToScreen,
|
||||||
x: BOARD_OFFSET.x + boardX * CELL_SIZE + CELL_SIZE / 2,
|
createPawnMoveTween,
|
||||||
y: BOARD_OFFSET.y + (BOARD_SIZE - 1 - boardY) * CELL_SIZE + CELL_SIZE / 2,
|
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 的棋子容器类
|
* 继承自 Phaser.GameObjects.Container 的棋子容器类
|
||||||
* 管理棋子视觉元素和选中状态
|
* 管理棋子视觉元素和选中状态
|
||||||
*/
|
*/
|
||||||
export class PawnContainer extends Phaser.GameObjects.Container {
|
export class PawnContainer extends GameObjects.Container {
|
||||||
private selectionRing: Phaser.GameObjects.Arc | null = null;
|
private selectionRing: Phaser.GameObjects.Arc | null = null;
|
||||||
private selectionTween: Phaser.Tweens.Tween | null = null;
|
|
||||||
private _position: [number, number];
|
private _position: [number, number];
|
||||||
private _owner: 'red' | 'black';
|
private _owner: "red" | "black";
|
||||||
private _type: 'master' | 'student';
|
private _type: "master" | "student";
|
||||||
|
private pawnRenderer: PawnRenderer;
|
||||||
|
private selectionRenderer: SelectionRenderer;
|
||||||
|
|
||||||
constructor(scene: OnitamaScene, pawn: Pawn) {
|
constructor(scene: OnitamaScene, pawn: Pawn) {
|
||||||
super(scene, 0, 0);
|
super(scene, 0, 0);
|
||||||
this._owner = pawn.owner;
|
this._owner = pawn.owner;
|
||||||
this._type = pawn.type;
|
this._type = pawn.type;
|
||||||
this._position = pawn.position as [number, number];
|
this._position = pawn.position as [number, number];
|
||||||
|
this.pawnRenderer = new PawnRenderer(scene);
|
||||||
|
this.selectionRenderer = new SelectionRenderer(scene);
|
||||||
|
|
||||||
// 将容器添加到场景
|
// 将容器添加到场景
|
||||||
scene.add.existing(this);
|
scene.add.existing(this);
|
||||||
|
|
||||||
// 创建棋子视觉
|
// 使用 PawnRenderer 创建棋子视觉
|
||||||
this.createPawnVisual();
|
this.pawnRenderer.render(this, { owner: this._owner, type: this._type });
|
||||||
|
|
||||||
// 添加选中状态监听
|
// 添加选中状态监听
|
||||||
this.addSelectionEffect(scene);
|
this.addSelectionEffect(scene);
|
||||||
@@ -50,33 +53,8 @@ export class PawnContainer extends Phaser.GameObjects.Container {
|
|||||||
if (!this.active) return;
|
if (!this.active) return;
|
||||||
|
|
||||||
if (!this.selectionRing) {
|
if (!this.selectionRing) {
|
||||||
// 创建选中光环(初始透明)
|
this.selectionRing = this.selectionRenderer.create(this);
|
||||||
this.selectionRing = (this.scene as OnitamaScene).add.arc(
|
this.selectionRenderer.show(this.selectionRing);
|
||||||
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,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,25 +63,8 @@ export class PawnContainer extends Phaser.GameObjects.Container {
|
|||||||
*/
|
*/
|
||||||
hideSelection(): void {
|
hideSelection(): void {
|
||||||
if (this.selectionRing) {
|
if (this.selectionRing) {
|
||||||
// 停止所有动画
|
this.selectionRenderer.hide(this.selectionRing, () => {
|
||||||
if (this.selectionTween) {
|
this.selectionRing = null;
|
||||||
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;
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -114,8 +75,9 @@ export class PawnContainer extends Phaser.GameObjects.Container {
|
|||||||
private addSelectionEffect(scene: OnitamaScene): void {
|
private addSelectionEffect(scene: OnitamaScene): void {
|
||||||
const dispose = effect(() => {
|
const dispose = effect(() => {
|
||||||
const uiState = scene.uiState.value;
|
const uiState = scene.uiState.value;
|
||||||
const isSelected = uiState.selectedPiece?.x === this._position[0] &&
|
const isSelected =
|
||||||
uiState.selectedPiece?.y === this._position[1];
|
uiState.selectedPiece?.x === this._position[0] &&
|
||||||
|
uiState.selectedPiece?.y === this._position[1];
|
||||||
|
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
this.showSelection();
|
this.showSelection();
|
||||||
@@ -124,7 +86,7 @@ export class PawnContainer extends Phaser.GameObjects.Container {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.on('destroy', () => {
|
this.on("destroy", () => {
|
||||||
dispose();
|
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;
|
this._position = newPosition;
|
||||||
const targetPos = boardToScreen(newPosition[0], newPosition[1]);
|
const targetPos = boardToScreen(newPosition[0], newPosition[1]);
|
||||||
|
|
||||||
if (animated) {
|
if (animated) {
|
||||||
const tween = this.scene.tweens.add({
|
createPawnMoveTween(
|
||||||
targets: this,
|
this.scene as OnitamaScene,
|
||||||
x: targetPos.x,
|
this,
|
||||||
y: targetPos.y,
|
targetPos.x,
|
||||||
duration: 400,
|
targetPos.y,
|
||||||
ease: 'Back.easeOut',
|
);
|
||||||
});
|
|
||||||
(this.scene as OnitamaScene).addTweenInterruption(tween);
|
|
||||||
} else {
|
} else {
|
||||||
this.x = targetPos.x;
|
this.x = targetPos.x;
|
||||||
this.y = targetPos.y;
|
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> {
|
export class PawnSpawner implements Spawner<Pawn, PawnContainer> {
|
||||||
@@ -177,7 +122,7 @@ export class PawnSpawner implements Spawner<Pawn, PawnContainer> {
|
|||||||
|
|
||||||
*getData() {
|
*getData() {
|
||||||
for (const pawn of Object.values(this.scene.state.pawns)) {
|
for (const pawn of Object.values(this.scene.state.pawns)) {
|
||||||
if (pawn.regionId === 'board') {
|
if (pawn.regionId === "board") {
|
||||||
yield pawn;
|
yield pawn;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -211,28 +156,13 @@ export class PawnSpawner implements Spawner<Pawn, PawnContainer> {
|
|||||||
|
|
||||||
// 淡入动画
|
// 淡入动画
|
||||||
container.setScale(0);
|
container.setScale(0);
|
||||||
const tween = this.scene.tweens.add({
|
createPawnSpawnTween(this.scene, container);
|
||||||
targets: container,
|
|
||||||
scale: 1,
|
|
||||||
duration: 300,
|
|
||||||
ease: 'Back.easeOut',
|
|
||||||
});
|
|
||||||
this.scene.addTweenInterruption(tween);
|
|
||||||
|
|
||||||
return container;
|
return container;
|
||||||
}
|
}
|
||||||
|
|
||||||
onDespawn(obj: PawnContainer): void {
|
onDespawn(obj: PawnContainer): void {
|
||||||
// 播放消失动画并添加中断
|
// 播放消失动画并添加中断
|
||||||
const tween = this.scene.tweens.add({
|
createPawnDespawnTween(this.scene, obj, () => obj.destroy());
|
||||||
targets: obj,
|
|
||||||
scale: 0,
|
|
||||||
alpha: 0,
|
|
||||||
duration: 300,
|
|
||||||
ease: 'Back.easeIn',
|
|
||||||
onComplete: () => obj.destroy(),
|
|
||||||
});
|
|
||||||
|
|
||||||
this.scene.addTweenInterruption(tween);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,14 @@
|
|||||||
export { PawnSpawner, CELL_SIZE, BOARD_OFFSET, BOARD_SIZE, boardToScreen } from './PawnSpawner';
|
export { PawnSpawner } from "./PawnSpawner";
|
||||||
export { CardSpawner, CARD_WIDTH, CARD_HEIGHT, type CardSpawnData } from './CardSpawner';
|
export { CardSpawner, type CardSpawnData } from "./CardSpawner";
|
||||||
export { HighlightSpawner, type HighlightData } from './HighlightSpawner';
|
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 {
|
||||||
export type { OnitamaUIState, ValidMove } from './ui';
|
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 { mutableSignal, computed } from "boardgame-core";
|
||||||
import {getAvailableMoves, OnitamaState} from "boardgame-core/samples/onitama";
|
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 {
|
export interface ValidMove {
|
||||||
card: string;
|
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 computed(() => {
|
||||||
return getAvailableMoves(state.value, state.value.currentPlayer)
|
return getAvailableMoves(state.value, state.value.currentPlayer).filter(
|
||||||
.filter(move => {
|
(move) => {
|
||||||
const {selectedCard, selectedPiece} = ui.value;
|
const { selectedCard, selectedPiece } = ui.value;
|
||||||
return selectedPiece?.x === move.fromX && selectedPiece?.y === move.fromY && selectedCard === move.card;
|
return (
|
||||||
})
|
selectedPiece?.x === move.fromX &&
|
||||||
|
selectedPiece?.y === move.fromY &&
|
||||||
|
selectedCard === move.card
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearSelection(uiState: MutableSignal<OnitamaUIState>): void {
|
export function clearSelection(uiState: MutableSignal<OnitamaUIState>): void {
|
||||||
uiState.produce(state => {
|
uiState.produce((state) => {
|
||||||
state.selectedPiece = null;
|
state.selectedPiece = null;
|
||||||
state.selectedCard = null;
|
state.selectedCard = null;
|
||||||
});
|
});
|
||||||
@@ -42,13 +53,13 @@ export function clearSelection(uiState: MutableSignal<OnitamaUIState>): void {
|
|||||||
export function selectPiece(
|
export function selectPiece(
|
||||||
uiState: MutableSignal<OnitamaUIState>,
|
uiState: MutableSignal<OnitamaUIState>,
|
||||||
x: number,
|
x: number,
|
||||||
y: number
|
y: number,
|
||||||
): void {
|
): 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;
|
state.selectedPiece = null;
|
||||||
}else{
|
} else {
|
||||||
state.selectedPiece = { x, y };
|
state.selectedPiece = { x, y };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -56,9 +67,9 @@ export function selectPiece(
|
|||||||
|
|
||||||
export function selectCard(
|
export function selectCard(
|
||||||
uiState: MutableSignal<OnitamaUIState>,
|
uiState: MutableSignal<OnitamaUIState>,
|
||||||
card: string
|
card: string,
|
||||||
): void {
|
): void {
|
||||||
uiState.produce(state => {
|
uiState.produce((state) => {
|
||||||
// 如果点击已选中的卡牌,取消选择
|
// 如果点击已选中的卡牌,取消选择
|
||||||
if (state.selectedCard === card) {
|
if (state.selectedCard === card) {
|
||||||
state.selectedCard = null;
|
state.selectedCard = null;
|
||||||
|
|||||||
@@ -1,43 +1,37 @@
|
|||||||
import {useComputed} from '@preact/signals';
|
import { createGameHost, type GameModule } from "boardgame-core";
|
||||||
import { createGameHost } from 'boardgame-core';
|
import { PhaserGame, PhaserScene } from "boardgame-phaser";
|
||||||
import Phaser from 'phaser';
|
import { useMemo } from "preact/hooks";
|
||||||
import { h } from 'preact';
|
|
||||||
import { PhaserGame, PhaserScene } from 'boardgame-phaser';
|
|
||||||
|
|
||||||
export default function App(props: { gameModule: any, gameScene: { new(): Phaser.Scene } }) {
|
import * as gameModule from "../game/onitama";
|
||||||
|
|
||||||
const gameHost = useComputed(() => {
|
import type { OnitamaState } from "@/game/onitama";
|
||||||
const gameHost = createGameHost(props.gameModule);
|
|
||||||
return { gameHost };
|
|
||||||
});
|
|
||||||
|
|
||||||
const scene = useComputed(() => new props.gameScene());
|
import { MenuScene } from "@/scenes/MenuScene";
|
||||||
|
import { OnitamaScene } from "@/scenes/OnitamaScene";
|
||||||
|
|
||||||
const handleReset = () => {
|
export default function App() {
|
||||||
gameHost.value.gameHost.start();
|
const gameHost = useMemo(
|
||||||
};
|
() => createGameHost(gameModule as unknown as GameModule<OnitamaState>),
|
||||||
const label = useComputed(() => gameHost.value.gameHost.status.value === 'running' ? 'Restart' : 'Start');
|
[],
|
||||||
|
);
|
||||||
|
const gameScene = useMemo(() => new OnitamaScene(), []);
|
||||||
|
const menuScene = useMemo(() => new MenuScene(), []);
|
||||||
|
|
||||||
const phaserConfig: Partial<Phaser.Types.Core.GameConfig> = {
|
return (
|
||||||
width: 700,
|
<div className="flex flex-col h-screen">
|
||||||
height: 800,
|
<div className="flex-1 flex relative justify-center items-center">
|
||||||
};
|
<PhaserGame
|
||||||
|
initialScene="MenuScene"
|
||||||
return (
|
config={{ width: 720, height: 840 }}
|
||||||
<div className="flex flex-col h-screen">
|
>
|
||||||
<div className="p-4 bg-gray-100 border-t border-gray-200">
|
<PhaserScene sceneKey="MenuScene" scene={menuScene} />
|
||||||
<button
|
<PhaserScene
|
||||||
onClick={handleReset}
|
sceneKey="OnitamaScene"
|
||||||
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"
|
scene={gameScene}
|
||||||
>
|
data={{ gameHost }}
|
||||||
{label}
|
/>
|
||||||
</button>
|
</PhaserGame>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 flex relative justify-center items-center">
|
</div>
|
||||||
<PhaserGame config={phaserConfig}>
|
);
|
||||||
<PhaserScene sceneKey="OnitamaScene" scene={scene.value} autoStart data={gameHost.value} />
|
|
||||||
</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