From 3e064f437b5ad88ad2bb02bcffec8484bdd55fb5 Mon Sep 17 00:00:00 2001 From: hypercross Date: Wed, 8 Apr 2026 11:30:31 +0800 Subject: [PATCH] feat: add more tweens --- .../onitama-game/src/spawners/CardSpawner.ts | 301 ++++++++++++------ .../src/spawners/HighlightSpawner.ts | 111 +++++-- .../onitama-game/src/spawners/PawnSpawner.ts | 203 +++++++++--- 3 files changed, 449 insertions(+), 166 deletions(-) diff --git a/packages/onitama-game/src/spawners/CardSpawner.ts b/packages/onitama-game/src/spawners/CardSpawner.ts index 93b675d..d1abad8 100644 --- a/packages/onitama-game/src/spawners/CardSpawner.ts +++ b/packages/onitama-game/src/spawners/CardSpawner.ts @@ -3,7 +3,7 @@ import type { Card } from '@/game/onitama'; import type { Spawner } from 'boardgame-phaser'; import type { OnitamaScene } from '@/scenes/OnitamaScene'; import { BOARD_OFFSET, CELL_SIZE } from './PawnSpawner'; -import {effect} from "@preact/signals-core"; +import { effect } from "@preact/signals-core"; export const CARD_WIDTH = 100; export const CARD_HEIGHT = 140; @@ -15,7 +15,185 @@ export interface CardSpawnData { index: number; } -export class CardSpawner implements Spawner { +/** + * 继承自 Phaser.GameObjects.Container 的卡牌容器类 + * 管理卡牌视觉元素和高亮状态 + */ +export class CardContainer extends Phaser.GameObjects.Container { + private highlightRect: Phaser.GameObjects.Rectangle | null = null; + private highlightTween: Phaser.Tweens.Tween | null = null; + private _cardId: string; + + constructor(scene: OnitamaScene, cardId: string, card: Card) { + super(scene, 0, 0); + this._cardId = cardId; + + // 将容器添加到场景 + scene.add.existing(this); + + // 创建卡牌视觉 + this.createCardVisual(card); + + // 使卡牌可点击 + const hitArea = new Phaser.Geom.Rectangle(-CARD_WIDTH / 2, -CARD_HEIGHT / 2, CARD_WIDTH, CARD_HEIGHT); + this.setInteractive(hitArea, Phaser.Geom.Rectangle.Contains); + + // 添加场景 effect 监听高亮状态变化 + this.addHighlightEffect(scene); + } + + /** + * 高亮卡牌 + */ + highlight(color: number, lineWidth: number): void { + if (!this.active) return; + + if (!this.highlightRect) { + // 创建高亮边框(初始透明) + this.highlightRect = (this.scene as OnitamaScene).add.rectangle( + 0, 0, CARD_WIDTH + 8, CARD_HEIGHT + 8, color, 0 + ) + .setStrokeStyle(lineWidth, color) + .setAlpha(0) + .setDepth(-1); + this.addAt(this.highlightRect, 0); + + // 淡入动画 + const fadeIn = this.scene.tweens.add({ + targets: this.highlightRect, + alpha: 1, + scale: 1.05, + duration: 200, + ease: 'Power2', + onComplete: () => { + // 淡入完成后开始脉冲动画 + this.highlightTween = this.scene.tweens.add({ + targets: this.highlightRect, + alpha: 0.7, + lineWidth: lineWidth + 1, + duration: 500, + ease: 'Sine.easeInOut', + yoyo: true, + repeat: -1, + }); + }, + }); + } else { + // 如果已经存在,停止当前动画并重新开始脉冲 + if (this.highlightTween) { + this.highlightTween.stop(); + } + this.highlightRect.setStrokeStyle(lineWidth, color); + this.highlightTween = this.scene.tweens.add({ + targets: this.highlightRect, + alpha: 0.7, + lineWidth: lineWidth + 1, + duration: 500, + ease: 'Sine.easeInOut', + yoyo: true, + repeat: -1, + }); + } + } + + /** + * 取消高亮 + */ + unhighlight(): void { + if (this.highlightRect) { + // 停止所有动画 + if (this.highlightTween) { + this.highlightTween.stop(); + this.highlightTween = null; + } + this.scene.tweens.killTweensOf(this.highlightRect); + + // 淡出动画 + this.scene.tweens.add({ + targets: this.highlightRect, + alpha: 0, + scale: 0.95, + duration: 150, + ease: 'Power2', + onComplete: () => { + // 淡出完成后销毁矩形 + this.highlightRect?.destroy(); + this.highlightRect = null; + }, + }); + } + } + + /** + * 添加高亮效果的 effect 监听 + */ + private addHighlightEffect(scene: OnitamaScene): void { + // 创建一个 effect 来持续监听高亮状态变化 + const dispose = effect(() => { + if (scene.uiState.value.selectedCard === this._cardId) { + this.highlight(0xfbbf24, 3); + } else { + this.unhighlight(); + } + }); + + // 在容器销毁时清理 effect + this.on('destroy', () => { + dispose(); + }); + } + + /** + * 创建卡牌视觉元素 + */ + private createCardVisual(card: Card): void { + const bg = (this.scene as OnitamaScene).add.rectangle(0, 0, CARD_WIDTH, CARD_HEIGHT, 0xf9fafb, 1) + .setStrokeStyle(2, 0x6b7280); + this.add(bg); + + const title = (this.scene as OnitamaScene).add.text(0, -CARD_HEIGHT / 2 + 15, card.id, { + fontSize: '12px', + fontFamily: 'Arial', + color: '#1f2937', + }).setOrigin(0.5); + this.add(title); + + const grid = (this.scene as OnitamaScene).add.graphics(); + const cellSize = 16; + const gridWidth = 5 * cellSize; + const gridHeight = 5 * cellSize; + const gridStartX = -gridWidth / 2; + const gridStartY = -gridHeight / 2 + 30; + + for (let row = 0; row < 5; row++) { + for (let col = 0; col < 5; col++) { + const x = gridStartX + col * cellSize; + const y = gridStartY + row * cellSize; + + if (row === 2 && col === 2) { + grid.fillStyle(0x3b82f6, 1); + grid.fillCircle(x + cellSize / 2, y + cellSize / 2, cellSize / 3); + } else { + const isTarget = card.moveCandidates.some(m => m.dx === col - 2 && m.dy === 2 - row); + if (isTarget) { + grid.fillStyle(0xef4444, 0.6); + grid.fillCircle(x + cellSize / 2, y + cellSize / 2, cellSize / 3); + } + } + } + } + this.add(grid); + + const playerText = (this.scene as OnitamaScene).add.text(0, CARD_HEIGHT / 2 - 15, card.startingPlayer, { + fontSize: '10px', + fontFamily: 'Arial', + color: '#6b7280', + }).setOrigin(0.5); + this.add(playerText); + } +} + +export class CardSpawner implements Spawner { private previousData = new Map(); constructor(public readonly scene: OnitamaScene) {} @@ -34,7 +212,8 @@ export class CardSpawner implements Spawner child instanceof Phaser.GameObjects.Rectangle && child.getData('isHighlight') - ) as Phaser.GameObjects.Rectangle; - - if (!highlight) { - // 创建高亮边框 - highlight = this.scene.add.rectangle(0, 0, CARD_WIDTH + 8, CARD_HEIGHT + 8, color, 0) - .setStrokeStyle(lineWidth, color) - .setData('isHighlight', true); - container.addAt(highlight, 0); - } else { - // 更新现有高亮边框 - highlight.setStrokeStyle(lineWidth, color); - highlight.setAlpha(1); - } - } - - private unhighlightCard(container: Phaser.GameObjects.Container): void { - const highlight = container.list.find( - child => child instanceof Phaser.GameObjects.Rectangle && child.getData('isHighlight') - ) as Phaser.GameObjects.Rectangle; - - if (highlight) { - highlight.setAlpha(0); - } - } - - onSpawn(data: CardSpawnData): Phaser.GameObjects.Container { + onSpawn(data: CardSpawnData): CardContainer { const card = this.scene.state.cards[data.cardId]; if (!card) { this.previousData.set(data.cardId, { ...data }); - return this.scene.add.container(0, 0); + // 返回空容器 + const emptyContainer = new CardContainer(this.scene, data.cardId, { + id: data.cardId, regionId: '', position: [], + moveCandidates: [], + startingPlayer: 'red' + } as Card); + return emptyContainer; } - const container = this.scene.add.container(0, 0); + const container = new CardContainer(this.scene, data.cardId, card); const pos = this.getCardPosition(data); container.x = pos.x; container.y = pos.y; - // 创建卡牌视觉 - const cardVisual = this.createCardVisual(card); - container.add(cardVisual); - - // 使卡牌可点击(设置矩形点击区域) - const hitArea = new Phaser.Geom.Rectangle(-CARD_WIDTH / 2, -CARD_HEIGHT / 2, CARD_WIDTH, CARD_HEIGHT); - container.setInteractive(hitArea, Phaser.Geom.Rectangle.Contains); - - // 悬停效果 + // 设置悬停效果 container.on('pointerover', () => { if (this.scene.uiState.value.selectedCard !== data.cardId) { container.setAlpha(0.8); } }); - + container.on('pointerout', () => { container.setAlpha(1); }); - + container.on('pointerdown', () => { this.scene.onCardClick(data.cardId); }); @@ -166,19 +314,12 @@ export class CardSpawner implements Spawner { - if(this.scene.uiState.value.selectedCard === data.cardId) - this.highlightCard(container, 0xfbbf24, 3); - else - this.unhighlightCard(container); - })); this.previousData.set(data.cardId, { ...data }); return container; } - onDespawn(obj: Phaser.GameObjects.Container): void { + onDespawn(obj: CardContainer): void { const tween = this.scene.tweens.add({ targets: obj, alpha: 0, @@ -189,54 +330,4 @@ export class CardSpawner implements Spawner 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); - } - } - } - } - container.add(grid); - - const playerText = this.scene.add.text(0, CARD_HEIGHT / 2 - 15, card.startingPlayer, { - fontSize: '10px', - fontFamily: 'Arial', - color: '#6b7280', - }).setOrigin(0.5); - container.add(playerText); - - return container; - } } diff --git a/packages/onitama-game/src/spawners/HighlightSpawner.ts b/packages/onitama-game/src/spawners/HighlightSpawner.ts index 4d0a23e..6e41683 100644 --- a/packages/onitama-game/src/spawners/HighlightSpawner.ts +++ b/packages/onitama-game/src/spawners/HighlightSpawner.ts @@ -21,7 +21,7 @@ export class HighlightSpawner implements Spawner { const state = this.scene.state; const uiState = this.scene.uiState.value; - + // 如果没有选择卡牌或棋子,不显示高亮 if (!uiState.selectedCard || !uiState.selectedPiece) { return; @@ -29,25 +29,23 @@ export class HighlightSpawner implements Spawner - move.fromX === uiState.selectedPiece!.x && - move.fromY === uiState.selectedPiece!.y && - move.card === uiState.selectedCard - ); - for(const move of filteredMoves){ - const pos = boardToScreen(move.toX, move.toY); - yield { - key: `${move.fromX}-${move.fromY}-${move.card}-${move.toX}-${move.toY}`, - x: pos.x, - y: pos.y, - card: move.card, - fromX: move.fromX, - fromY: move.fromY, - toX: move.toX, - toY: move.toY + // 过滤出符合当前选择的移动 + for(const move of availableMoves){ + if(move.fromX === uiState.selectedPiece!.x && + move.fromY === uiState.selectedPiece!.y && + move.card === uiState.selectedCard){ + const pos = boardToScreen(move.toX, move.toY); + yield { + key: `${move.fromX}-${move.fromY}-${move.card}-${move.toX}-${move.toY}`, + x: pos.x, + y: pos.y, + card: move.card, + fromX: move.fromX, + fromY: move.fromY, + toX: move.toX, + toY: move.toY + } } } } @@ -63,22 +61,78 @@ export class HighlightSpawner implements Spawner { + // 点击时的反馈动画 + this.scene.tweens.add({ + targets: container, + scale: 1.5, + alpha: 0.8, + duration: 150, + ease: 'Power2', + yoyo: true, + }); + this.scene.onHighlightClick(data); }); @@ -86,7 +140,16 @@ export class HighlightSpawner implements Spawner obj.destroy(), + }); + this.scene.addTweenInterruption(despawnTween); } } diff --git a/packages/onitama-game/src/spawners/PawnSpawner.ts b/packages/onitama-game/src/spawners/PawnSpawner.ts index 3ff3501..deb04a6 100644 --- a/packages/onitama-game/src/spawners/PawnSpawner.ts +++ b/packages/onitama-game/src/spawners/PawnSpawner.ts @@ -2,6 +2,8 @@ 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"; export const CELL_SIZE = 80; export const BOARD_OFFSET = { x: 200, y: 180 }; @@ -14,7 +16,161 @@ export function boardToScreen(boardX: number, boardY: number): { x: number; y: n }; } -export class PawnSpawner implements Spawner { +/** + * 继承自 Phaser.GameObjects.Container 的棋子容器类 + * 管理棋子视觉元素和选中状态 + */ +export class PawnContainer extends Phaser.GameObjects.Container { + private selectionRing: Phaser.GameObjects.Arc | null = null; + private selectionTween: Phaser.Tweens.Tween | null = null; + private _position: [number, number]; + private _owner: 'red' | 'black'; + private _type: 'master' | 'student'; + + constructor(scene: OnitamaScene, pawn: Pawn) { + super(scene, 0, 0); + this._owner = pawn.owner; + this._type = pawn.type; + this._position = pawn.position as [number, number]; + + // 将容器添加到场景 + scene.add.existing(this); + + // 创建棋子视觉 + this.createPawnVisual(); + + // 添加选中状态监听 + this.addSelectionEffect(scene); + } + + /** + * 显示选中光环 + */ + showSelection(): void { + if (!this.active) return; + + if (!this.selectionRing) { + // 创建选中光环(初始透明) + this.selectionRing = (this.scene as OnitamaScene).add.arc( + 0, 0, CELL_SIZE / 3 + 5, 0, 360, false, 0xfbbf24, 0 + ) + .setStrokeStyle(3, 0xf59e0b, 1) + .setAlpha(0); + this.addAt(this.selectionRing, 0); + + // 淡入动画 + const fadeIn = this.scene.tweens.add({ + targets: this.selectionRing, + alpha: 0.8, + duration: 200, + ease: 'Power2', + onComplete: () => { + // 淡入完成后开始脉冲动画 + this.selectionTween = this.scene.tweens.add({ + targets: this.selectionRing, + scale: 1.15, + alpha: 0.6, + duration: 500, + ease: 'Sine.easeInOut', + yoyo: true, + repeat: -1, + }); + }, + }); + } + } + + /** + * 隐藏选中光环 + */ + hideSelection(): void { + if (this.selectionRing) { + // 停止所有动画 + if (this.selectionTween) { + this.selectionTween.stop(); + this.selectionTween = null; + } + this.scene.tweens.killTweensOf(this.selectionRing); + + // 淡出动画 + this.scene.tweens.add({ + targets: this.selectionRing, + alpha: 0, + scale: 0.9, + duration: 150, + ease: 'Power2', + onComplete: () => { + // 淡出完成后销毁 + this.selectionRing?.destroy(); + this.selectionRing = null; + }, + }); + } + } + + /** + * 添加选中状态的 effect 监听 + */ + private addSelectionEffect(scene: OnitamaScene): void { + const dispose = effect(() => { + const uiState = scene.uiState.value; + const isSelected = uiState.selectedPiece?.x === this._position[0] && + uiState.selectedPiece?.y === this._position[1]; + + if (isSelected) { + this.showSelection(); + } else { + this.hideSelection(); + } + }); + + this.on('destroy', () => { + dispose(); + }); + } + + /** + * 更新棋子位置 + */ + updatePosition(newPosition: [number, number], animated: boolean = false): void { + this._position = newPosition; + const targetPos = boardToScreen(newPosition[0], newPosition[1]); + + if (animated) { + const tween = this.scene.tweens.add({ + targets: this, + x: targetPos.x, + y: targetPos.y, + duration: 400, + ease: 'Back.easeOut', + }); + (this.scene as OnitamaScene).addTweenInterruption(tween); + } else { + this.x = targetPos.x; + this.y = targetPos.y; + } + } + + /** + * 创建棋子视觉元素 + */ + private createPawnVisual(): void { + const bgColor = this._owner === 'red' ? 0xef4444 : 0x3b82f6; + const circle = (this.scene as OnitamaScene).add.circle(0, 0, CELL_SIZE / 3, bgColor, 1) + .setStrokeStyle(2, 0x1f2937); + this.add(circle); + + const label = this._type === 'master' ? 'M' : 'S'; + const text = (this.scene as OnitamaScene).add.text(0, 0, label, { + fontSize: '24px', + fontFamily: 'Arial', + color: '#ffffff', + }).setOrigin(0.5); + this.add(text); + } +} + +export class PawnSpawner implements Spawner { private previousPositions = new Map(); constructor(public readonly scene: OnitamaScene) {} @@ -31,49 +187,20 @@ export class PawnSpawner implements Spawner return pawn.id; } - onUpdate(pawn: Pawn, obj: Phaser.GameObjects.Container): void { + onUpdate(pawn: Pawn, obj: PawnContainer): void { const [x, y] = pawn.position; const prevPos = this.previousPositions.get(pawn.id); const hasMoved = !prevPos || prevPos[0] !== x || prevPos[1] !== y; - if (hasMoved && prevPos) { - // 播放移动动画并添加中断 - const targetPos = boardToScreen(x, y); - - const tween = this.scene.tweens.add({ - targets: obj, - x: targetPos.x, - y: targetPos.y, - duration: 400, - ease: 'Back.easeOut', - }); - - this.scene.addTweenInterruption(tween); - } else if (!prevPos) { - // 初次生成,直接设置位置 - const pos = boardToScreen(x, y); - obj.x = pos.x; - obj.y = pos.y; + if (hasMoved) { + obj.updatePosition([x, y], !!prevPos); } this.previousPositions.set(pawn.id, [x, y]); } - onSpawn(pawn: Pawn) { - const container = this.scene.add.container(0, 0); - - const bgColor = pawn.owner === 'red' ? 0xef4444 : 0x3b82f6; - const circle = this.scene.add.circle(0, 0, CELL_SIZE / 3, bgColor, 1) - .setStrokeStyle(2, 0x1f2937); - container.add(circle); - - const label = pawn.type === 'master' ? 'M' : 'S'; - const text = this.scene.add.text(0, 0, label, { - fontSize: '24px', - fontFamily: 'Arial', - color: '#ffffff', - }).setOrigin(0.5); - container.add(text); + onSpawn(pawn: Pawn): PawnContainer { + const container = new PawnContainer(this.scene, pawn); const [x, y] = pawn.position; const pos = boardToScreen(x, y); @@ -82,18 +209,20 @@ export class PawnSpawner implements Spawner this.previousPositions.set(pawn.id, [x, y]); + // 淡入动画 container.setScale(0); - this.scene.tweens.add({ + const tween = this.scene.tweens.add({ targets: container, scale: 1, duration: 300, ease: 'Back.easeOut', }); + this.scene.addTweenInterruption(tween); return container; } - onDespawn(obj: Phaser.GameObjects.Container) { + onDespawn(obj: PawnContainer): void { // 播放消失动画并添加中断 const tween = this.scene.tweens.add({ targets: obj,