Compare commits

..

No commits in common. "c25759d1472662948c95e8f024995221a20556d1" and "b0e74a5257163aef347e24a40d5a44db80c249bd" have entirely different histories.

20 changed files with 619 additions and 1386 deletions

View File

@ -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

View File

@ -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") {
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.container = options.container;
}
this.root = options.root; this.root = options.root;
} }

View File

@ -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";

View File

@ -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,
});
}

View File

@ -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();

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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";

View File

@ -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');
} }
} }

View File

@ -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,25 +86,36 @@ 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 {
@ -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,8 +188,7 @@ 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,
});
} }
} }

View File

@ -1,2 +1,2 @@
export { OnitamaScene } from "./OnitamaScene"; export { OnitamaScene } from './OnitamaScene';
export { MenuScene } from "./MenuScene"; export { MenuScene } from './MenuScene';

View File

@ -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,
alpha: 1,
scale: 1.05,
duration: 200,
ease: 'Power2',
onComplete: () => {
// 淡入完成后开始脉冲动画 // 淡入完成后开始脉冲动画
this.highlightTween = createSelectionPulseTween( this.highlightTween = this.scene.tweens.add({
this.scene as OnitamaScene, targets: this.highlightRect,
rect, alpha: 0.7,
lineWidth, 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);
} }
} }

View File

@ -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);
} }
} }

View File

@ -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, () => { // 停止所有动画
if (this.selectionTween) {
this.selectionTween.stop();
this.selectionTween = null;
}
this.scene.tweens.killTweensOf(this.selectionRing);
// 淡出动画
this.scene.tweens.add({
targets: this.selectionRing,
alpha: 0,
scale: 0.9,
duration: 150,
ease: 'Power2',
onComplete: () => {
// 淡出完成后销毁
this.selectionRing?.destroy();
this.selectionRing = null; this.selectionRing = null;
},
}); });
} }
} }
@ -75,8 +114,7 @@ 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) {
@ -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);
} }
} }

View File

@ -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";

View File

@ -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";

View File

@ -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;

View File

@ -1,35 +1,23 @@
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 gameScene = useMemo(() => new OnitamaScene(), []);
const menuScene = useMemo(() => new MenuScene(), []); 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"
config={{ width: 720, height: 840 }}
>
<PhaserScene sceneKey="MenuScene" scene={menuScene} /> <PhaserScene sceneKey="MenuScene" scene={menuScene} />
<PhaserScene <PhaserScene sceneKey="OnitamaScene" scene={gameScene} data={{gameHost}}/>
sceneKey="OnitamaScene"
scene={gameScene}
data={{ gameHost }}
/>
</PhaserGame> </PhaserGame>
</div> </div>
</div> </div>