Compare commits
No commits in common. "c25759d1472662948c95e8f024995221a20556d1" and "b0e74a5257163aef347e24a40d5a44db80c249bd" have entirely different histories.
c25759d147
...
b0e74a5257
|
|
@ -59,8 +59,6 @@ Extend `Phaser.GameObjects.Container` to encapsulate visuals and state.
|
||||||
### 5. Tween Interruption
|
### 5. Tween Interruption
|
||||||
Always register state-related tweens: `this.scene.addTweenInterruption(tween)`.
|
Always register state-related tweens: `this.scene.addTweenInterruption(tween)`.
|
||||||
Prevents visual glitches when game state changes mid-animation.
|
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
|
### 6. Scene Navigation
|
||||||
Use `await this.sceneController.launch('SceneKey')`.
|
Use `await this.sceneController.launch('SceneKey')`.
|
||||||
|
|
@ -72,7 +70,6 @@ Register scenes in `App.tsx` via `<PhaserScene>`. Pass data via `data` prop.
|
||||||
- **Type Imports**: Use `import type { Foo } from 'bar'` for type-only imports.
|
- **Type Imports**: Use `import type { Foo } from 'bar'` for type-only imports.
|
||||||
- **Input Handling**: Use `this.add.zone()` for grid/cell-based input zones.
|
- **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.
|
- **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
|
## Common Pitfalls
|
||||||
|
|
||||||
|
|
@ -105,4 +102,4 @@ See `packages/framework/` for test setup examples.
|
||||||
- `AGENTS.md` — Project overview, commands, and code style
|
- `AGENTS.md` — Project overview, commands, and code style
|
||||||
- `docs/GameModule.md` — GameModule implementation guide
|
- `docs/GameModule.md` — GameModule implementation guide
|
||||||
- `packages/framework/src/` — `boardgame-phaser` source code
|
- `packages/framework/src/` — `boardgame-phaser` source code
|
||||||
- `packages/onitama-game/src/` — Complete game implementation reference
|
- `packages/onitama-game/src/` — Complete game implementation reference
|
||||||
|
|
@ -1,28 +1,14 @@
|
||||||
import type { JSX } from "preact";
|
|
||||||
|
|
||||||
export interface GameUIOptions {
|
export interface GameUIOptions {
|
||||||
container: HTMLElement | string;
|
container: HTMLElement;
|
||||||
root: JSX.Element;
|
root: HTMLElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class GameUI {
|
export class GameUI {
|
||||||
private container: HTMLElement;
|
private container: HTMLElement;
|
||||||
private root: JSX.Element;
|
private root: HTMLElement;
|
||||||
|
|
||||||
constructor(options: GameUIOptions) {
|
constructor(options: GameUIOptions) {
|
||||||
if (typeof options.container === "string") {
|
this.container = options.container;
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,251 +0,0 @@
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
|
|
||||||
// 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";
|
|
||||||
|
|
@ -1,322 +0,0 @@
|
||||||
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,11 +1,11 @@
|
||||||
import { GameUI } from "boardgame-phaser";
|
import { h } from 'preact';
|
||||||
|
import { GameUI } from 'boardgame-phaser';
|
||||||
import "./style.css";
|
import './style.css';
|
||||||
import App from "@/ui/App";
|
import App from "@/ui/App";
|
||||||
|
|
||||||
const ui = new GameUI({
|
const ui = new GameUI({
|
||||||
container: "ui-root",
|
container: document.getElementById('ui-root')!,
|
||||||
root: <App />,
|
root: <App/>,
|
||||||
});
|
});
|
||||||
|
|
||||||
ui.mount();
|
ui.mount();
|
||||||
|
|
|
||||||
|
|
@ -1,104 +0,0 @@
|
||||||
import type { Card } from "@/game/onitama";
|
|
||||||
import type { OnitamaScene } from "@/scenes/OnitamaScene";
|
|
||||||
|
|
||||||
import { CARD_WIDTH, CARD_HEIGHT, COLORS, FONTS, CARD_GRID } 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(2, COLORS.cardStroke);
|
|
||||||
container.add(bg);
|
|
||||||
|
|
||||||
// Create title text
|
|
||||||
const title = this.scene.add
|
|
||||||
.text(0, -CARD_HEIGHT / 2 + 16, 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 - 16, 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
import type { OnitamaScene } from "@/scenes/OnitamaScene";
|
|
||||||
|
|
||||||
import {
|
|
||||||
CELL_SIZE,
|
|
||||||
COLORS,
|
|
||||||
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,
|
|
||||||
CELL_SIZE / 3,
|
|
||||||
COLORS.black,
|
|
||||||
0.2,
|
|
||||||
);
|
|
||||||
container.add(outerCircle);
|
|
||||||
|
|
||||||
// Inner circle
|
|
||||||
const innerCircle = this.scene.add.circle(
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
CELL_SIZE / 4,
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
import type { OnitamaScene } from "@/scenes/OnitamaScene";
|
|
||||||
|
|
||||||
import { CELL_SIZE, COLORS, FONTS } 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, CELL_SIZE / 3, bgColor, 1)
|
|
||||||
.setStrokeStyle(2, 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,104 +0,0 @@
|
||||||
import { GameObjects } from "phaser";
|
|
||||||
|
|
||||||
import type { OnitamaScene } from "@/scenes/OnitamaScene";
|
|
||||||
|
|
||||||
import {
|
|
||||||
CELL_SIZE,
|
|
||||||
COLORS,
|
|
||||||
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, CELL_SIZE / 3 + 5, 0, 360, false, COLORS.highlight, 0)
|
|
||||||
.setStrokeStyle(3, 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
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";
|
|
||||||
|
|
@ -1,6 +1,30 @@
|
||||||
import { ReactiveScene } from "boardgame-phaser";
|
import { ReactiveScene } from 'boardgame-phaser';
|
||||||
|
import Phaser from 'phaser';
|
||||||
|
|
||||||
import { MENU_CONFIG, ANIMATIONS } from "@/config";
|
/** 菜单场景配置 */
|
||||||
|
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;
|
||||||
|
|
||||||
export class MenuScene extends ReactiveScene {
|
export class MenuScene extends ReactiveScene {
|
||||||
private titleText!: Phaser.GameObjects.Text;
|
private titleText!: Phaser.GameObjects.Text;
|
||||||
|
|
@ -9,7 +33,7 @@ export class MenuScene extends ReactiveScene {
|
||||||
private startButtonText!: Phaser.GameObjects.Text;
|
private startButtonText!: Phaser.GameObjects.Text;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super("MenuScene");
|
super('MenuScene');
|
||||||
}
|
}
|
||||||
|
|
||||||
create(): void {
|
create(): void {
|
||||||
|
|
@ -32,21 +56,24 @@ export class MenuScene extends ReactiveScene {
|
||||||
|
|
||||||
/** 创建标题文本 */
|
/** 创建标题文本 */
|
||||||
private createTitle(center: { x: number; y: number }): void {
|
private createTitle(center: { x: number; y: number }): void {
|
||||||
this.titleText = this.add
|
this.titleText = this.add.text(
|
||||||
.text(center.x, center.y + MENU_CONFIG.positions.titleY, "Onitama", {
|
center.x,
|
||||||
|
center.y + MENU_CONFIG.positions.titleY,
|
||||||
|
'Onitama',
|
||||||
|
{
|
||||||
fontSize: MENU_CONFIG.fontSize.title,
|
fontSize: MENU_CONFIG.fontSize.title,
|
||||||
fontFamily: "Arial",
|
fontFamily: 'Arial',
|
||||||
color: MENU_CONFIG.colors.title,
|
color: MENU_CONFIG.colors.title,
|
||||||
})
|
}
|
||||||
.setOrigin(0.5);
|
).setOrigin(0.5);
|
||||||
|
|
||||||
// 标题入场动画
|
// 标题入场动画
|
||||||
this.titleText.setScale(0);
|
this.titleText.setScale(0);
|
||||||
this.tweens.add({
|
this.tweens.add({
|
||||||
targets: this.titleText,
|
targets: this.titleText,
|
||||||
scale: 1,
|
scale: 1,
|
||||||
duration: ANIMATIONS.menuTitle,
|
duration: 600,
|
||||||
ease: "Back.easeOut",
|
ease: 'Back.easeOut',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -54,23 +81,29 @@ export class MenuScene extends ReactiveScene {
|
||||||
private createStartButton(center: { x: number; y: number }): void {
|
private createStartButton(center: { x: number; y: number }): void {
|
||||||
const { button, colors } = MENU_CONFIG;
|
const { button, colors } = MENU_CONFIG;
|
||||||
|
|
||||||
this.startButtonBg = this.add
|
this.startButtonBg = this.add.rectangle(
|
||||||
.rectangle(0, 0, button.width, button.height, colors.buttonBg)
|
0,
|
||||||
.setOrigin(0.5)
|
0,
|
||||||
.setInteractive({ useHandCursor: true });
|
button.width,
|
||||||
|
button.height,
|
||||||
|
colors.buttonBg
|
||||||
|
).setOrigin(0.5).setInteractive({ useHandCursor: true });
|
||||||
|
|
||||||
this.startButtonText = this.add
|
this.startButtonText = this.add.text(
|
||||||
.text(0, 0, "Start Game", {
|
0,
|
||||||
|
0,
|
||||||
|
'Start Game',
|
||||||
|
{
|
||||||
fontSize: MENU_CONFIG.fontSize.button,
|
fontSize: MENU_CONFIG.fontSize.button,
|
||||||
fontFamily: "Arial",
|
fontFamily: 'Arial',
|
||||||
color: colors.buttonText,
|
color: colors.buttonText,
|
||||||
})
|
}
|
||||||
.setOrigin(0.5);
|
).setOrigin(0.5);
|
||||||
|
|
||||||
this.startButtonContainer = this.add.container(
|
this.startButtonContainer = this.add.container(
|
||||||
center.x,
|
center.x,
|
||||||
center.y + MENU_CONFIG.positions.buttonY,
|
center.y + MENU_CONFIG.positions.buttonY,
|
||||||
[this.startButtonBg, this.startButtonText],
|
[this.startButtonBg, this.startButtonText]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 按钮交互
|
// 按钮交互
|
||||||
|
|
@ -79,47 +112,45 @@ export class MenuScene extends ReactiveScene {
|
||||||
|
|
||||||
/** 设置按钮交互效果 */
|
/** 设置按钮交互效果 */
|
||||||
private setupButtonInteraction(): void {
|
private setupButtonInteraction(): void {
|
||||||
this.startButtonBg.on("pointerover", () => {
|
this.startButtonBg.on('pointerover', () => {
|
||||||
this.startButtonBg.setFillStyle(MENU_CONFIG.colors.buttonBgHover);
|
this.startButtonBg.setFillStyle(MENU_CONFIG.colors.buttonBgHover);
|
||||||
this.tweens.add({
|
this.tweens.add({
|
||||||
targets: this.startButtonContainer,
|
targets: this.startButtonContainer,
|
||||||
scale: 1.05,
|
scale: 1.05,
|
||||||
duration: ANIMATIONS.buttonHover,
|
duration: 100,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
this.startButtonBg.on("pointerout", () => {
|
this.startButtonBg.on('pointerout', () => {
|
||||||
this.startButtonBg.setFillStyle(MENU_CONFIG.colors.buttonBg);
|
this.startButtonBg.setFillStyle(MENU_CONFIG.colors.buttonBg);
|
||||||
this.tweens.add({
|
this.tweens.add({
|
||||||
targets: this.startButtonContainer,
|
targets: this.startButtonContainer,
|
||||||
scale: 1,
|
scale: 1,
|
||||||
duration: ANIMATIONS.buttonHover,
|
duration: 100,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
this.startButtonBg.on("pointerdown", () => {
|
this.startButtonBg.on('pointerdown', () => {
|
||||||
this.startGame();
|
this.startGame();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 创建副标题 */
|
/** 创建副标题 */
|
||||||
private createSubtitle(center: { x: number; y: number }): void {
|
private createSubtitle(center: { x: number; y: number }): void {
|
||||||
this.add
|
this.add.text(
|
||||||
.text(
|
center.x,
|
||||||
center.x,
|
center.y + MENU_CONFIG.positions.subtitleY,
|
||||||
center.y + MENU_CONFIG.positions.subtitleY,
|
'Click to start playing',
|
||||||
"Click to start playing",
|
{
|
||||||
{
|
fontSize: MENU_CONFIG.fontSize.subtitle,
|
||||||
fontSize: MENU_CONFIG.fontSize.subtitle,
|
fontFamily: 'Arial',
|
||||||
fontFamily: "Arial",
|
color: MENU_CONFIG.colors.subtitle,
|
||||||
color: MENU_CONFIG.colors.subtitle,
|
}
|
||||||
},
|
).setOrigin(0.5);
|
||||||
)
|
|
||||||
.setOrigin(0.5);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 开始游戏 */
|
/** 开始游戏 */
|
||||||
private async startGame(): Promise<void> {
|
private async startGame(): Promise<void> {
|
||||||
await this.sceneController.launch("OnitamaScene");
|
await this.sceneController.launch('OnitamaScene');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,16 @@
|
||||||
import { GameHostScene, spawnEffect } from "boardgame-phaser";
|
import Phaser from 'phaser';
|
||||||
|
import type { OnitamaState, Pawn } from '@/game/onitama';
|
||||||
import type { OnitamaState, Pawn } from "@/game/onitama";
|
import {getAvailableMoves, prompts} from '@/game/onitama';
|
||||||
import type { HighlightData } from "@/spawners/HighlightSpawner";
|
import { GameHostScene } from 'boardgame-phaser';
|
||||||
import type { OnitamaUIState } from "@/state";
|
import { spawnEffect } from 'boardgame-phaser';
|
||||||
import type { MutableSignal } from "boardgame-core";
|
import type { MutableSignal } from 'boardgame-core';
|
||||||
import type Phaser from "phaser";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
COLORS,
|
PawnSpawner, CardSpawner, BOARD_OFFSET, CELL_SIZE, CARD_WIDTH, CARD_HEIGHT, boardToScreen, BOARD_SIZE,
|
||||||
FONTS,
|
HighlightSpawner
|
||||||
MENU_BUTTON,
|
} from '@/spawners';
|
||||||
getBoardCenter,
|
import type { HighlightData } from '@/spawners/HighlightSpawner';
|
||||||
getCardLabelPosition,
|
import {createUIState, clearSelection, selectPiece, selectCard, createValidMoves} from '@/state';
|
||||||
colorToStr,
|
import type { OnitamaUIState, ValidMove } from '@/state';
|
||||||
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;
|
||||||
|
|
@ -46,7 +26,7 @@ export class OnitamaScene extends GameHostScene<OnitamaState> {
|
||||||
public uiState!: MutableSignal<OnitamaUIState>;
|
public uiState!: MutableSignal<OnitamaUIState>;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super("OnitamaScene");
|
super('OnitamaScene');
|
||||||
}
|
}
|
||||||
|
|
||||||
create(): void {
|
create(): void {
|
||||||
|
|
@ -79,7 +59,16 @@ export class OnitamaScene extends GameHostScene<OnitamaState> {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Info text
|
// Info text
|
||||||
this.infoText = this.add.text(40, BOARD_OFFSET.y, "", FONTS.info);
|
this.infoText = this.add.text(
|
||||||
|
40,
|
||||||
|
BOARD_OFFSET.y,
|
||||||
|
'',
|
||||||
|
{
|
||||||
|
fontSize: '16px',
|
||||||
|
fontFamily: 'Arial',
|
||||||
|
color: '#4b5563',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// Update info text when UI state changes
|
// Update info text when UI state changes
|
||||||
this.addEffect(() => {
|
this.addEffect(() => {
|
||||||
|
|
@ -97,30 +86,41 @@ export class OnitamaScene extends GameHostScene<OnitamaState> {
|
||||||
}
|
}
|
||||||
|
|
||||||
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 redPos = getCardLabelPosition("red");
|
const redLabel = this.add.text(
|
||||||
const redLabel = this.add
|
boardLeft + (BOARD_SIZE * CELL_SIZE) / 2,
|
||||||
.text(redPos.x, redPos.y, "RED", {
|
boardBottom + 40,
|
||||||
...FONTS.cardLabel,
|
"RED",
|
||||||
color: colorToStr(COLORS.red),
|
{
|
||||||
})
|
fontSize: '16px',
|
||||||
.setOrigin(redPos.originX, redPos.originY);
|
fontFamily: 'Arial',
|
||||||
this.cardLabelContainers.set("red", redLabel);
|
color: '#ef4444',
|
||||||
|
}
|
||||||
|
).setOrigin(0.5, 0);
|
||||||
|
this.cardLabelContainers.set('red', redLabel);
|
||||||
|
|
||||||
// Black cards label - 棋盘上方
|
// Black cards label - 棋盘上方
|
||||||
const blackPos = getCardLabelPosition("black");
|
const blackLabel = this.add.text(
|
||||||
const blackLabel = this.add
|
boardLeft + (BOARD_SIZE * CELL_SIZE) / 2,
|
||||||
.text(blackPos.x, blackPos.y, "BLACK", {
|
boardTop - 40,
|
||||||
...FONTS.cardLabel,
|
"BLACK",
|
||||||
color: colorToStr(COLORS.black),
|
{
|
||||||
})
|
fontSize: '16px',
|
||||||
.setOrigin(blackPos.originX, blackPos.originY);
|
fontFamily: 'Arial',
|
||||||
this.cardLabelContainers.set("black", blackLabel);
|
color: '#3b82f6',
|
||||||
|
}
|
||||||
|
).setOrigin(0.5, 1);
|
||||||
|
this.cardLabelContainers.set('black', blackLabel);
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateInfoText(): void {
|
private updateInfoText(): void {
|
||||||
const currentPlayer = this.state.currentPlayer;
|
const currentPlayer = this.state.currentPlayer;
|
||||||
|
|
||||||
if (this.state.winner) {
|
if (this.state.winner) {
|
||||||
this.infoText.setText(`${this.state.winner} wins!`);
|
this.infoText.setText(`${this.state.winner} wins!`);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -130,26 +130,35 @@ export class OnitamaScene extends GameHostScene<OnitamaState> {
|
||||||
|
|
||||||
private drawBoard(): void {
|
private drawBoard(): void {
|
||||||
const g = this.gridGraphics;
|
const g = this.gridGraphics;
|
||||||
g.lineStyle(2, COLORS.gridLine);
|
g.lineStyle(2, 0x6b7280);
|
||||||
|
|
||||||
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(40, 40, "Onitama", FONTS.title);
|
this.add.text(
|
||||||
|
40,
|
||||||
|
40,
|
||||||
|
'Onitama',
|
||||||
|
{
|
||||||
|
fontSize: '28px',
|
||||||
|
fontFamily: 'Arial',
|
||||||
|
color: '#1f2937',
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private setupInput(): void {
|
private setupInput(): void {
|
||||||
|
|
@ -158,11 +167,9 @@ 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
|
const zone = this.add.zone(pos.x, pos.y, CELL_SIZE, CELL_SIZE).setInteractive();
|
||||||
.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);
|
||||||
});
|
});
|
||||||
|
|
@ -172,7 +179,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);
|
||||||
|
|
@ -181,9 +188,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 =
|
const playerCards = currentPlayer === 'red' ? this.state.redCards : this.state.blackCards;
|
||||||
currentPlayer === "red" ? this.state.redCards : this.state.blackCards;
|
|
||||||
|
|
||||||
if (!playerCards.includes(cardId)) {
|
if (!playerCards.includes(cardId)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -202,13 +208,7 @@ export class OnitamaScene extends GameHostScene<OnitamaState> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private executeMove(move: {
|
private executeMove(move: { card: string; fromX: number; fromY: number; toX: number; toY: number }): void {
|
||||||
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,
|
||||||
|
|
@ -216,10 +216,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -231,42 +231,39 @@ export class OnitamaScene extends GameHostScene<OnitamaState> {
|
||||||
|
|
||||||
/** 创建菜单按钮 */
|
/** 创建菜单按钮 */
|
||||||
private createMenuButton(): void {
|
private createMenuButton(): void {
|
||||||
this.menuButtonBg = this.add
|
const buttonX = 680;
|
||||||
.rectangle(
|
const buttonY = 40;
|
||||||
MENU_BUTTON.x,
|
|
||||||
MENU_BUTTON.y,
|
this.menuButtonBg = this.add.rectangle(buttonX, buttonY, 120, 40, 0x6b7280)
|
||||||
MENU_BUTTON.width,
|
|
||||||
MENU_BUTTON.height,
|
|
||||||
COLORS.menuButton,
|
|
||||||
)
|
|
||||||
.setInteractive({ useHandCursor: true });
|
.setInteractive({ useHandCursor: true });
|
||||||
|
|
||||||
this.menuButtonText = this.add
|
this.menuButtonText = this.add.text(buttonX, buttonY, 'Menu', {
|
||||||
.text(MENU_BUTTON.x, MENU_BUTTON.y, "Menu", FONTS.menuButton)
|
fontSize: '18px',
|
||||||
.setOrigin(0.5);
|
fontFamily: 'Arial',
|
||||||
|
color: '#ffffff',
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
|
||||||
this.menuButtonContainer = this.add.container(
|
this.menuButtonContainer = this.add.container(buttonX, buttonY, [
|
||||||
MENU_BUTTON.x,
|
this.menuButtonBg,
|
||||||
MENU_BUTTON.y,
|
this.menuButtonText,
|
||||||
[this.menuButtonBg, this.menuButtonText],
|
]);
|
||||||
);
|
|
||||||
|
|
||||||
this.menuButtonBg.on("pointerover", () => {
|
this.menuButtonBg.on('pointerover', () => {
|
||||||
this.menuButtonBg.setFillStyle(COLORS.menuButtonHover);
|
this.menuButtonBg.setFillStyle(0x4b5563);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.menuButtonBg.on("pointerout", () => {
|
this.menuButtonBg.on('pointerout', () => {
|
||||||
this.menuButtonBg.setFillStyle(COLORS.menuButton);
|
this.menuButtonBg.setFillStyle(0x6b7280);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.menuButtonBg.on("pointerdown", () => {
|
this.menuButtonBg.on('pointerdown', () => {
|
||||||
this.goToMenu();
|
this.goToMenu();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 跳转到菜单场景 */
|
/** 跳转到菜单场景 */
|
||||||
private async goToMenu(): Promise<void> {
|
private async goToMenu(): Promise<void> {
|
||||||
await this.sceneController.launch("MenuScene");
|
await this.sceneController.launch('MenuScene');
|
||||||
}
|
}
|
||||||
|
|
||||||
private showWinner(winner: string): void {
|
private showWinner(winner: string): void {
|
||||||
|
|
@ -276,34 +273,42 @@ 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
|
const bg = this.add.rectangle(
|
||||||
.rectangle(
|
BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2,
|
||||||
center.x,
|
BOARD_OFFSET.y + (BOARD_SIZE * CELL_SIZE) / 2,
|
||||||
center.y,
|
BOARD_SIZE * CELL_SIZE,
|
||||||
boardWidth,
|
BOARD_SIZE * CELL_SIZE,
|
||||||
boardHeight,
|
0x000000,
|
||||||
COLORS.overlayBg,
|
0.6
|
||||||
0.6,
|
).setInteractive({ useHandCursor: true });
|
||||||
)
|
|
||||||
.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
|
const winText = this.add.text(
|
||||||
.text(center.x, center.y, text, FONTS.winner)
|
BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2,
|
||||||
.setOrigin(0.5);
|
BOARD_OFFSET.y + (BOARD_SIZE * CELL_SIZE) / 2,
|
||||||
|
text,
|
||||||
|
{
|
||||||
|
fontSize: '36px',
|
||||||
|
fontFamily: 'Arial',
|
||||||
|
color: '#fbbf24',
|
||||||
|
}
|
||||||
|
).setOrigin(0.5);
|
||||||
|
|
||||||
this.winnerOverlay.add(winText);
|
this.winnerOverlay.add(winText);
|
||||||
|
|
||||||
createWinnerPulseTween(this, winText);
|
this.tweens.add({
|
||||||
|
targets: winText,
|
||||||
|
scale: 1.2,
|
||||||
|
duration: 500,
|
||||||
|
yoyo: true,
|
||||||
|
repeat: 1,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,2 @@
|
||||||
export { OnitamaScene } from "./OnitamaScene";
|
export { OnitamaScene } from './OnitamaScene';
|
||||||
export { MenuScene } from "./MenuScene";
|
export { MenuScene } from './MenuScene';
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,18 @@
|
||||||
|
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";
|
||||||
|
|
||||||
import type { Card } from "@/game/onitama";
|
export const CARD_WIDTH = 80;
|
||||||
import type { OnitamaScene } from "@/scenes/OnitamaScene";
|
export const CARD_HEIGHT = 120;
|
||||||
import type { Spawner } from "boardgame-phaser";
|
const BOARD_SIZE = 5;
|
||||||
|
const CARD_SPACING = 100; // 确保每张卡牌至少 80x80 区域
|
||||||
import {
|
|
||||||
CARD_WIDTH,
|
|
||||||
CARD_HEIGHT,
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -36,18 +25,11 @@ 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(
|
constructor(scene: OnitamaScene, cardId: string, card: Card, rotation: number = 0) {
|
||||||
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);
|
||||||
|
|
@ -55,16 +37,11 @@ export class CardContainer extends Phaser.GameObjects.Container {
|
||||||
// 应用旋转
|
// 应用旋转
|
||||||
this.angle = rotation;
|
this.angle = rotation;
|
||||||
|
|
||||||
// 使用 CardRenderer 创建卡牌视觉
|
// 创建卡牌视觉
|
||||||
this.cardRenderer.render(this, { card });
|
this.createCardVisual(card);
|
||||||
|
|
||||||
// 使卡牌可点击
|
// 使卡牌可点击
|
||||||
const hitArea = new Phaser.Geom.Rectangle(
|
const hitArea = new Phaser.Geom.Rectangle(-CARD_WIDTH / 2, -CARD_HEIGHT / 2, CARD_WIDTH, CARD_HEIGHT);
|
||||||
-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 监听高亮状态变化
|
||||||
|
|
@ -77,12 +54,13 @@ 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;
|
||||||
createCardRotateTween(
|
const tween = this.scene.tweens.add({
|
||||||
this.scene as OnitamaScene,
|
targets: this,
|
||||||
this,
|
angle: { from: currentAngle, to: rotation },
|
||||||
currentAngle,
|
duration: 400,
|
||||||
rotation,
|
ease: 'Back.easeOut',
|
||||||
);
|
});
|
||||||
|
(this.scene as OnitamaScene).addTweenInterruption(tween);
|
||||||
} else {
|
} else {
|
||||||
this.angle = rotation;
|
this.angle = rotation;
|
||||||
}
|
}
|
||||||
|
|
@ -98,22 +76,33 @@ export class CardContainer extends Phaser.GameObjects.Container {
|
||||||
|
|
||||||
if (!this.highlightRect) {
|
if (!this.highlightRect) {
|
||||||
// 创建高亮边框(初始透明)
|
// 创建高亮边框(初始透明)
|
||||||
const rect = (this.scene as OnitamaScene).add
|
this.highlightRect = (this.scene as OnitamaScene).add.rectangle(
|
||||||
.rectangle(0, 0, CARD_WIDTH + 8, CARD_HEIGHT + 8, color, 0)
|
0, 0, CARD_WIDTH + 8, CARD_HEIGHT + 8, color, 0
|
||||||
|
)
|
||||||
.setStrokeStyle(lineWidth, color)
|
.setStrokeStyle(lineWidth, color)
|
||||||
.setAlpha(0)
|
.setAlpha(0)
|
||||||
.setDepth(-1);
|
.setDepth(-1);
|
||||||
this.highlightRect = rect;
|
|
||||||
this.addAt(this.highlightRect, 0);
|
this.addAt(this.highlightRect, 0);
|
||||||
|
|
||||||
// 淡入动画
|
// 淡入动画
|
||||||
createSelectionFadeInTween(this.scene as OnitamaScene, rect, () => {
|
const fadeIn = this.scene.tweens.add({
|
||||||
// 淡入完成后开始脉冲动画
|
targets: this.highlightRect,
|
||||||
this.highlightTween = createSelectionPulseTween(
|
alpha: 1,
|
||||||
this.scene as OnitamaScene,
|
scale: 1.05,
|
||||||
rect,
|
duration: 200,
|
||||||
lineWidth,
|
ease: 'Power2',
|
||||||
);
|
onComplete: () => {
|
||||||
|
// 淡入完成后开始脉冲动画
|
||||||
|
this.highlightTween = this.scene.tweens.add({
|
||||||
|
targets: this.highlightRect,
|
||||||
|
alpha: 0.7,
|
||||||
|
lineWidth: lineWidth + 1,
|
||||||
|
duration: 500,
|
||||||
|
ease: 'Sine.easeInOut',
|
||||||
|
yoyo: true,
|
||||||
|
repeat: -1,
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// 如果已经存在,停止当前动画并重新开始脉冲
|
// 如果已经存在,停止当前动画并重新开始脉冲
|
||||||
|
|
@ -121,11 +110,15 @@ 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 = createSelectionPulseTween(
|
this.highlightTween = this.scene.tweens.add({
|
||||||
this.scene as OnitamaScene,
|
targets: this.highlightRect,
|
||||||
this.highlightRect,
|
alpha: 0.7,
|
||||||
lineWidth,
|
lineWidth: lineWidth + 1,
|
||||||
);
|
duration: 500,
|
||||||
|
ease: 'Sine.easeInOut',
|
||||||
|
yoyo: true,
|
||||||
|
repeat: -1,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -142,15 +135,18 @@ export class CardContainer extends Phaser.GameObjects.Container {
|
||||||
this.scene.tweens.killTweensOf(this.highlightRect);
|
this.scene.tweens.killTweensOf(this.highlightRect);
|
||||||
|
|
||||||
// 淡出动画
|
// 淡出动画
|
||||||
createSelectionFadeOutTween(
|
this.scene.tweens.add({
|
||||||
this.scene as OnitamaScene,
|
targets: this.highlightRect,
|
||||||
this.highlightRect,
|
alpha: 0,
|
||||||
() => {
|
scale: 0.95,
|
||||||
|
duration: 150,
|
||||||
|
ease: 'Power2',
|
||||||
|
onComplete: () => {
|
||||||
// 淡出完成后销毁矩形
|
// 淡出完成后销毁矩形
|
||||||
this.highlightRect?.destroy();
|
this.highlightRect?.destroy();
|
||||||
this.highlightRect = null;
|
this.highlightRect = null;
|
||||||
},
|
},
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -168,43 +164,117 @@ export class CardContainer extends Phaser.GameObjects.Container {
|
||||||
});
|
});
|
||||||
|
|
||||||
// 在容器销毁时清理 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 + 16, card.id, {
|
||||||
|
fontSize: '12px',
|
||||||
|
fontFamily: 'Arial',
|
||||||
|
color: '#1f2937',
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
this.add(title);
|
||||||
|
|
||||||
|
const grid = (this.scene as OnitamaScene).add.graphics();
|
||||||
|
const cellSize = 14;
|
||||||
|
const gridWidth = 5 * cellSize;
|
||||||
|
const gridHeight = 5 * cellSize;
|
||||||
|
const gridStartX = -gridWidth / 2;
|
||||||
|
const gridStartY = -gridHeight / 2 + 20;
|
||||||
|
|
||||||
|
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 - 16, 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: boardCenterX - (data.index - 0.5) * CARD_SPACING,
|
||||||
|
y: boardBottom + CARD_HEIGHT / 2 + 40,
|
||||||
|
};
|
||||||
|
} else if (data.position === 'black') {
|
||||||
|
// 黑方卡牌在棋盘上方,水平排列
|
||||||
|
return {
|
||||||
|
x: boardCenterX - (data.index - 0.5) * CARD_SPACING,
|
||||||
|
y: boardTop - CARD_HEIGHT / 2 - 40,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// 备用卡牌在棋盘左侧,垂直居中
|
||||||
|
const boardCenterY = boardTop + (BOARD_SIZE * CELL_SIZE) / 2;
|
||||||
|
return {
|
||||||
|
x: boardLeft - CARD_WIDTH / 2 - 40,
|
||||||
|
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;
|
||||||
|
|
@ -212,13 +282,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; // 备用卡牌在黑方回合旋转
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -232,10 +302,18 @@ export class CardSpawner implements Spawner<CardSpawnData, CardContainer> {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pos = getCardPosition(data.position, data.index);
|
const pos = this.getCardPosition(data);
|
||||||
|
|
||||||
// 播放移动动画并添加中断
|
// 播放移动动画并添加中断
|
||||||
createCardMoveTween(this.scene, obj, pos.x, pos.y);
|
const tween = this.scene.tweens.add({
|
||||||
|
targets: obj,
|
||||||
|
x: pos.x,
|
||||||
|
y: pos.y,
|
||||||
|
duration: 600,
|
||||||
|
ease: 'Back.easeOut',
|
||||||
|
});
|
||||||
|
|
||||||
|
this.scene.addTweenInterruption(tween);
|
||||||
this.previousData.set(data.cardId, { ...data });
|
this.previousData.set(data.cardId, { ...data });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -245,58 +323,65 @@ 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,
|
id: data.cardId, regionId: '', position: [],
|
||||||
regionId: "",
|
|
||||||
position: [],
|
|
||||||
moveCandidates: [],
|
moveCandidates: [],
|
||||||
startingPlayer: "red",
|
startingPlayer: 'red'
|
||||||
} as Card);
|
} as Card);
|
||||||
return emptyContainer;
|
return emptyContainer;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算初始旋转角度
|
// 计算初始旋转角度
|
||||||
const isBlackTurn = this.scene.state.currentPlayer === "black";
|
const isBlackTurn = this.scene.state.currentPlayer === 'black';
|
||||||
let initialRotation = 0;
|
let initialRotation = 0;
|
||||||
if (data.position === "black") {
|
if (data.position === 'black') {
|
||||||
initialRotation = -180;
|
initialRotation = -180;
|
||||||
} else if (data.position === "spare" && isBlackTurn) {
|
} else if (data.position === 'spare' && isBlackTurn) {
|
||||||
initialRotation = -180;
|
initialRotation = -180;
|
||||||
}
|
}
|
||||||
|
|
||||||
const container = new CardContainer(
|
const container = new CardContainer(this.scene, data.cardId, card, initialRotation);
|
||||||
this.scene,
|
const pos = this.getCardPosition(data);
|
||||||
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(0.8);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
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);
|
||||||
createCardSpawnTween(this.scene, container);
|
const tween = this.scene.tweens.add({
|
||||||
|
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 {
|
||||||
createCardDespawnTween(this.scene, obj, () => obj.destroy());
|
const tween = this.scene.tweens.add({
|
||||||
|
targets: obj,
|
||||||
|
alpha: 0,
|
||||||
|
scale: 0.8,
|
||||||
|
duration: 200,
|
||||||
|
ease: 'Power2',
|
||||||
|
onComplete: () => obj.destroy(),
|
||||||
|
});
|
||||||
|
this.scene.addTweenInterruption(tween);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,8 @@
|
||||||
import { getAvailableMoves } from "boardgame-core/samples/onitama";
|
import Phaser from 'phaser';
|
||||||
import { Geom } from "phaser";
|
import type { Spawner } from 'boardgame-phaser';
|
||||||
|
import type { OnitamaScene } from '@/scenes/OnitamaScene';
|
||||||
import type { OnitamaScene } from "@/scenes/OnitamaScene";
|
import {getAvailableMoves} from "boardgame-core/samples/onitama";
|
||||||
import type { Spawner } from "boardgame-phaser";
|
import {boardToScreen, CELL_SIZE} from './PawnSpawner';
|
||||||
|
|
||||||
import {
|
|
||||||
boardToScreen,
|
|
||||||
CELL_SIZE,
|
|
||||||
createHighlightSpawnTween,
|
|
||||||
createHighlightDespawnTween,
|
|
||||||
createHighlightClickFeedbackTween,
|
|
||||||
} from "@/config";
|
|
||||||
import { HighlightRenderer } from "@/renderers";
|
|
||||||
|
|
||||||
export interface HighlightData {
|
export interface HighlightData {
|
||||||
key: string;
|
key: string;
|
||||||
|
|
@ -24,15 +15,8 @@ export interface HighlightData {
|
||||||
toY: number;
|
toY: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class HighlightSpawner implements Spawner<
|
export class HighlightSpawner implements Spawner<HighlightData, Phaser.GameObjects.Container> {
|
||||||
HighlightData,
|
constructor(public readonly scene: OnitamaScene) {}
|
||||||
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;
|
||||||
|
|
@ -47,12 +31,10 @@ export class HighlightSpawner implements Spawner<
|
||||||
const availableMoves = getAvailableMoves(state, currentPlayer);
|
const availableMoves = getAvailableMoves(state, currentPlayer);
|
||||||
|
|
||||||
// 过滤出符合当前选择的移动
|
// 过滤出符合当前选择的移动
|
||||||
for (const move of availableMoves) {
|
for(const move of availableMoves){
|
||||||
if (
|
if(move.fromX === uiState.selectedPiece!.x &&
|
||||||
move.fromX === uiState.selectedPiece.x &&
|
move.fromY === uiState.selectedPiece!.y &&
|
||||||
move.fromY === uiState.selectedPiece.y &&
|
move.card === uiState.selectedCard){
|
||||||
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}`,
|
||||||
|
|
@ -62,8 +44,8 @@ export class HighlightSpawner implements Spawner<
|
||||||
fromX: move.fromX,
|
fromX: move.fromX,
|
||||||
fromY: move.fromY,
|
fromY: move.fromY,
|
||||||
toX: move.toX,
|
toX: move.toX,
|
||||||
toY: move.toY,
|
toY: move.toY
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -79,27 +61,77 @@ export class HighlightSpawner implements Spawner<
|
||||||
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 创建视觉元素
|
// 外圈光环(动画)
|
||||||
this.renderer.render(container, { x: data.x, y: data.y });
|
const outerCircle = this.scene.add.circle(
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
CELL_SIZE / 3,
|
||||||
|
0x3b82f6,
|
||||||
|
0.2
|
||||||
|
);
|
||||||
|
container.add(outerCircle);
|
||||||
|
|
||||||
// 设置交互区域
|
// 内圈
|
||||||
const hitArea = new Geom.Circle(0, 0, CELL_SIZE / 3);
|
const innerCircle = this.scene.add.circle(
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
CELL_SIZE / 4,
|
||||||
|
0x3b82f6,
|
||||||
|
0.4
|
||||||
|
);
|
||||||
|
container.add(innerCircle);
|
||||||
|
|
||||||
|
const hitArea = new Phaser.Geom.Circle(0, 0, CELL_SIZE / 3);
|
||||||
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);
|
||||||
createHighlightSpawnTween(this.scene, container);
|
const spawnTween = this.scene.tweens.add({
|
||||||
|
targets: container,
|
||||||
|
alpha: 1,
|
||||||
|
scale: 1,
|
||||||
|
duration: 250,
|
||||||
|
ease: 'Back.easeOut',
|
||||||
|
});
|
||||||
|
this.scene.addTweenInterruption(spawnTween);
|
||||||
|
|
||||||
// 使用 HighlightRenderer 设置脉冲动画
|
// 脉冲动画
|
||||||
this.renderer.setupPulseAnimations(container);
|
this.scene.tweens.add({
|
||||||
|
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', () => {
|
||||||
// 点击时的反馈动画
|
// 点击时的反馈动画
|
||||||
createHighlightClickFeedbackTween(this.scene, container);
|
this.scene.tweens.add({
|
||||||
|
targets: container,
|
||||||
|
scale: 1.5,
|
||||||
|
alpha: 0.8,
|
||||||
|
duration: 150,
|
||||||
|
ease: 'Power2',
|
||||||
|
yoyo: true,
|
||||||
|
});
|
||||||
|
|
||||||
this.scene.onHighlightClick(data);
|
this.scene.onHighlightClick(data);
|
||||||
});
|
});
|
||||||
|
|
@ -109,6 +141,15 @@ export class HighlightSpawner implements Spawner<
|
||||||
|
|
||||||
onDespawn(obj: Phaser.GameObjects.Container): void {
|
onDespawn(obj: Phaser.GameObjects.Container): void {
|
||||||
// 消失动画:缩小并淡出
|
// 消失动画:缩小并淡出
|
||||||
createHighlightDespawnTween(this.scene, obj, () => obj.destroy());
|
const despawnTween = this.scene.tweens.add({
|
||||||
|
targets: obj,
|
||||||
|
scale: 0,
|
||||||
|
alpha: 0,
|
||||||
|
duration: 200,
|
||||||
|
ease: 'Back.easeIn',
|
||||||
|
onComplete: () => obj.destroy(),
|
||||||
|
});
|
||||||
|
this.scene.addTweenInterruption(despawnTween);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,46 +1,43 @@
|
||||||
|
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";
|
|
||||||
|
|
||||||
import type { Pawn } from "@/game/onitama";
|
export const CELL_SIZE = 80;
|
||||||
import type { OnitamaScene } from "@/scenes/OnitamaScene";
|
export const BOARD_OFFSET = { x: 240, y: 240 };
|
||||||
import type { Spawner } from "boardgame-phaser";
|
export const BOARD_SIZE = 5;
|
||||||
|
|
||||||
import {
|
export function boardToScreen(boardX: number, boardY: number): { x: number; y: number } {
|
||||||
boardToScreen,
|
return {
|
||||||
createPawnMoveTween,
|
x: BOARD_OFFSET.x + boardX * CELL_SIZE + CELL_SIZE / 2,
|
||||||
createPawnSpawnTween,
|
y: BOARD_OFFSET.y + (BOARD_SIZE - 1 - boardY) * CELL_SIZE + CELL_SIZE / 2,
|
||||||
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 GameObjects.Container {
|
export class PawnContainer extends Phaser.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.pawnRenderer.render(this, { owner: this._owner, type: this._type });
|
this.createPawnVisual();
|
||||||
|
|
||||||
// 添加选中状态监听
|
// 添加选中状态监听
|
||||||
this.addSelectionEffect(scene);
|
this.addSelectionEffect(scene);
|
||||||
|
|
@ -53,8 +50,33 @@ export class PawnContainer extends GameObjects.Container {
|
||||||
if (!this.active) return;
|
if (!this.active) return;
|
||||||
|
|
||||||
if (!this.selectionRing) {
|
if (!this.selectionRing) {
|
||||||
this.selectionRing = this.selectionRenderer.create(this);
|
// 创建选中光环(初始透明)
|
||||||
this.selectionRenderer.show(this.selectionRing);
|
this.selectionRing = (this.scene as OnitamaScene).add.arc(
|
||||||
|
0, 0, CELL_SIZE / 3 + 5, 0, 360, false, 0xfbbf24, 0
|
||||||
|
)
|
||||||
|
.setStrokeStyle(3, 0xf59e0b, 1)
|
||||||
|
.setAlpha(0);
|
||||||
|
this.addAt(this.selectionRing, 0);
|
||||||
|
|
||||||
|
// 淡入动画
|
||||||
|
const fadeIn = this.scene.tweens.add({
|
||||||
|
targets: this.selectionRing,
|
||||||
|
alpha: 0.8,
|
||||||
|
duration: 200,
|
||||||
|
ease: 'Power2',
|
||||||
|
onComplete: () => {
|
||||||
|
// 淡入完成后开始脉冲动画
|
||||||
|
this.selectionTween = this.scene.tweens.add({
|
||||||
|
targets: this.selectionRing,
|
||||||
|
scale: 1.15,
|
||||||
|
alpha: 0.6,
|
||||||
|
duration: 500,
|
||||||
|
ease: 'Sine.easeInOut',
|
||||||
|
yoyo: true,
|
||||||
|
repeat: -1,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -63,8 +85,25 @@ export class PawnContainer extends GameObjects.Container {
|
||||||
*/
|
*/
|
||||||
hideSelection(): void {
|
hideSelection(): void {
|
||||||
if (this.selectionRing) {
|
if (this.selectionRing) {
|
||||||
this.selectionRenderer.hide(this.selectionRing, () => {
|
// 停止所有动画
|
||||||
this.selectionRing = null;
|
if (this.selectionTween) {
|
||||||
|
this.selectionTween.stop();
|
||||||
|
this.selectionTween = null;
|
||||||
|
}
|
||||||
|
this.scene.tweens.killTweensOf(this.selectionRing);
|
||||||
|
|
||||||
|
// 淡出动画
|
||||||
|
this.scene.tweens.add({
|
||||||
|
targets: this.selectionRing,
|
||||||
|
alpha: 0,
|
||||||
|
scale: 0.9,
|
||||||
|
duration: 150,
|
||||||
|
ease: 'Power2',
|
||||||
|
onComplete: () => {
|
||||||
|
// 淡出完成后销毁
|
||||||
|
this.selectionRing?.destroy();
|
||||||
|
this.selectionRing = null;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -75,9 +114,8 @@ export class PawnContainer extends 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 =
|
const isSelected = uiState.selectedPiece?.x === this._position[0] &&
|
||||||
uiState.selectedPiece?.x === this._position[0] &&
|
uiState.selectedPiece?.y === this._position[1];
|
||||||
uiState.selectedPiece?.y === this._position[1];
|
|
||||||
|
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
this.showSelection();
|
this.showSelection();
|
||||||
|
|
@ -86,7 +124,7 @@ export class PawnContainer extends GameObjects.Container {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.on("destroy", () => {
|
this.on('destroy', () => {
|
||||||
dispose();
|
dispose();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -94,25 +132,42 @@ export class PawnContainer extends GameObjects.Container {
|
||||||
/**
|
/**
|
||||||
* 更新棋子位置
|
* 更新棋子位置
|
||||||
*/
|
*/
|
||||||
updatePosition(
|
updatePosition(newPosition: [number, number], animated: boolean = false): void {
|
||||||
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) {
|
||||||
createPawnMoveTween(
|
const tween = this.scene.tweens.add({
|
||||||
this.scene as OnitamaScene,
|
targets: this,
|
||||||
this,
|
x: targetPos.x,
|
||||||
targetPos.x,
|
y: targetPos.y,
|
||||||
targetPos.y,
|
duration: 400,
|
||||||
);
|
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> {
|
||||||
|
|
@ -122,7 +177,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -156,13 +211,28 @@ export class PawnSpawner implements Spawner<Pawn, PawnContainer> {
|
||||||
|
|
||||||
// 淡入动画
|
// 淡入动画
|
||||||
container.setScale(0);
|
container.setScale(0);
|
||||||
createPawnSpawnTween(this.scene, container);
|
const tween = this.scene.tweens.add({
|
||||||
|
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 {
|
||||||
// 播放消失动画并添加中断
|
// 播放消失动画并添加中断
|
||||||
createPawnDespawnTween(this.scene, obj, () => obj.destroy());
|
const tween = this.scene.tweens.add({
|
||||||
|
targets: obj,
|
||||||
|
scale: 0,
|
||||||
|
alpha: 0,
|
||||||
|
duration: 300,
|
||||||
|
ease: 'Back.easeIn',
|
||||||
|
onComplete: () => obj.destroy(),
|
||||||
|
});
|
||||||
|
|
||||||
|
this.scene.addTweenInterruption(tween);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,3 @@
|
||||||
export { PawnSpawner } from "./PawnSpawner";
|
export { PawnSpawner, CELL_SIZE, BOARD_OFFSET, BOARD_SIZE, boardToScreen } from './PawnSpawner';
|
||||||
export { CardSpawner, type CardSpawnData } from "./CardSpawner";
|
export { CardSpawner, CARD_WIDTH, CARD_HEIGHT, 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,8 +1,2 @@
|
||||||
export {
|
export { createUIState, clearSelection, selectPiece, selectCard, createValidMoves } from './ui';
|
||||||
createUIState,
|
export type { OnitamaUIState, ValidMove } from './ui';
|
||||||
clearSelection,
|
|
||||||
selectPiece,
|
|
||||||
selectCard,
|
|
||||||
createValidMoves,
|
|
||||||
} from "./ui";
|
|
||||||
export type { OnitamaUIState, ValidMove } from "./ui";
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
import { mutableSignal, computed } from "boardgame-core";
|
import { MutableSignal, mutableSignal, computed, ReadonlySignal } from 'boardgame-core';
|
||||||
import { getAvailableMoves } from "boardgame-core/samples/onitama";
|
import {getAvailableMoves, OnitamaState} 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;
|
||||||
|
|
@ -25,26 +22,18 @@ export function createUIState(): MutableSignal<OnitamaUIState> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createValidMoves(
|
export function createValidMoves(state: ReadonlySignal<OnitamaState>, ui: ReadonlySignal<OnitamaUIState>){
|
||||||
state: ReadonlySignal<OnitamaState>,
|
|
||||||
ui: ReadonlySignal<OnitamaUIState>,
|
|
||||||
) {
|
|
||||||
return computed(() => {
|
return computed(() => {
|
||||||
return getAvailableMoves(state.value, state.value.currentPlayer).filter(
|
return getAvailableMoves(state.value, state.value.currentPlayer)
|
||||||
(move) => {
|
.filter(move => {
|
||||||
const { selectedCard, selectedPiece } = ui.value;
|
const {selectedCard, selectedPiece} = ui.value;
|
||||||
return (
|
return selectedPiece?.x === move.fromX && selectedPiece?.y === move.fromY && selectedCard === move.card;
|
||||||
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;
|
||||||
});
|
});
|
||||||
|
|
@ -53,13 +42,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 };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -67,9 +56,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,37 +1,25 @@
|
||||||
import { createGameHost, type GameModule } from "boardgame-core";
|
import { h } from 'preact';
|
||||||
import { PhaserGame, PhaserScene } from "boardgame-phaser";
|
import {PhaserGame, PhaserScene } from 'boardgame-phaser';
|
||||||
import { useMemo } from "preact/hooks";
|
import {MenuScene} from "@/scenes/MenuScene";
|
||||||
|
import {useMemo} from "preact/hooks";
|
||||||
import * as gameModule from "../game/onitama";
|
import * as gameModule from '../game/onitama';
|
||||||
|
import {OnitamaScene} from "@/scenes/OnitamaScene";
|
||||||
import type { OnitamaState } from "@/game/onitama";
|
import {createGameHost, type GameModule} from "boardgame-core";
|
||||||
|
import type {OnitamaState} from "@/game/onitama";
|
||||||
import { MenuScene } from "@/scenes/MenuScene";
|
|
||||||
import { OnitamaScene } from "@/scenes/OnitamaScene";
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const gameHost = useMemo(
|
const gameHost = useMemo(() => createGameHost(gameModule as unknown as GameModule<OnitamaState>), []);
|
||||||
() => createGameHost(gameModule as unknown as GameModule<OnitamaState>),
|
const gameScene = useMemo(() => new OnitamaScene(), []);
|
||||||
[],
|
const menuScene = useMemo(() => new MenuScene(), []);
|
||||||
);
|
|
||||||
const gameScene = useMemo(() => new OnitamaScene(), []);
|
|
||||||
const menuScene = useMemo(() => new MenuScene(), []);
|
|
||||||
|
|
||||||
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
|
<PhaserGame initialScene="MenuScene" config={{ width: 720, height: 840 }}>
|
||||||
initialScene="MenuScene"
|
<PhaserScene sceneKey="MenuScene" scene={menuScene} />
|
||||||
config={{ width: 720, height: 840 }}
|
<PhaserScene sceneKey="OnitamaScene" scene={gameScene} data={{gameHost}}/>
|
||||||
>
|
</PhaserGame>
|
||||||
<PhaserScene sceneKey="MenuScene" scene={menuScene} />
|
</div>
|
||||||
<PhaserScene
|
</div>
|
||||||
sceneKey="OnitamaScene"
|
);
|
||||||
scene={gameScene}
|
|
||||||
data={{ gameHost }}
|
|
||||||
/>
|
|
||||||
</PhaserGame>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue