fix: fix ui bugs

This commit is contained in:
hypercross 2026-04-08 08:50:45 +08:00
parent d4819f7cc3
commit 2beff5c75c
6 changed files with 87 additions and 76 deletions

13
QWEN.md
View File

@ -56,3 +56,16 @@ export class GameHost<TState extends Record<string, unknown>, TResult=unknown> {
on(event: 'start' | 'dispose', listener: () => void): () => void {} on(event: 'start' | 'dispose', listener: () => void): () => void {}
} }
``` ```
使用`Spawner`来创建动态游戏对象:
- 卡牌
- 棋子
- UI高亮提示对象
使用`MutableSignal`创建状态信号。
```typescript
import {MutableSignal} from "boardgame-core"
export const x = MutableSignal(0);
x.produce(x => x + 1);
```

View File

@ -4,14 +4,12 @@ import { prompts } from '@/game/onitama';
import { GameHostScene } from 'boardgame-phaser'; import { GameHostScene } from 'boardgame-phaser';
import { spawnEffect } from 'boardgame-phaser'; import { spawnEffect } from 'boardgame-phaser';
import { effect } from '@preact/signals-core'; import { effect } from '@preact/signals-core';
import type { Signal } from '@preact/signals'; import type { MutableSignal } from 'boardgame-core';
import { PawnSpawner, CardSpawner, BOARD_OFFSET, CELL_SIZE, CARD_WIDTH, CARD_HEIGHT } from '@/spawners'; import { PawnSpawner, CardSpawner, BOARD_OFFSET, CELL_SIZE, CARD_WIDTH, CARD_HEIGHT, boardToScreen, BOARD_SIZE } from '@/spawners';
import type { HighlightData } from '@/spawners/HighlightSpawner'; import type { HighlightData } from '@/spawners/HighlightSpawner';
import { createOnitamaUIState, clearSelection, selectPiece, selectCard, deselectCard, setValidMoves } from '@/state'; import { createOnitamaUIState, clearSelection, selectPiece, selectCard, deselectCard, setValidMoves } from '@/state';
import type { OnitamaUIState, ValidMove } from '@/state'; import type { OnitamaUIState, ValidMove } from '@/state';
const BOARD_SIZE = 5;
export class OnitamaScene extends GameHostScene<OnitamaState> { export class OnitamaScene extends GameHostScene<OnitamaState> {
private boardContainer!: Phaser.GameObjects.Container; private boardContainer!: Phaser.GameObjects.Container;
private gridGraphics!: Phaser.GameObjects.Graphics; private gridGraphics!: Phaser.GameObjects.Graphics;
@ -19,8 +17,8 @@ export class OnitamaScene extends GameHostScene<OnitamaState> {
private winnerOverlay?: Phaser.GameObjects.Container; private winnerOverlay?: Phaser.GameObjects.Container;
private cardLabelContainers: Map<string, Phaser.GameObjects.Text> = new Map(); private cardLabelContainers: Map<string, Phaser.GameObjects.Text> = new Map();
// UI State managed by signal // UI State managed by MutableSignal
public uiState!: Signal<OnitamaUIState>; public uiState!: MutableSignal<OnitamaUIState>;
private highlightContainers: Map<string, Phaser.GameObjects.GameObject> = new Map(); private highlightContainers: Map<string, Phaser.GameObjects.GameObject> = new Map();
private highlightDispose?: () => void; private highlightDispose?: () => void;
@ -189,10 +187,9 @@ export class OnitamaScene extends GameHostScene<OnitamaState> {
// Board cell clicks // Board cell clicks
for (let row = 0; row < BOARD_SIZE; row++) { for (let row = 0; row < BOARD_SIZE; row++) {
for (let col = 0; col < BOARD_SIZE; col++) { for (let col = 0; col < BOARD_SIZE; col++) {
const x = BOARD_OFFSET.x + col * CELL_SIZE + CELL_SIZE / 2; const pos = boardToScreen(col, row);
const y = BOARD_OFFSET.y + row * CELL_SIZE + CELL_SIZE / 2;
const zone = this.add.zone(x, y, CELL_SIZE, CELL_SIZE).setInteractive(); const zone = this.add.zone(pos.x, pos.y, CELL_SIZE, CELL_SIZE).setInteractive();
zone.on('pointerdown', () => { zone.on('pointerdown', () => {
if (this.state.winner) return; if (this.state.winner) return;
@ -308,16 +305,15 @@ export class OnitamaScene extends GameHostScene<OnitamaState> {
// Create new highlights // Create new highlights
for (const move of validMoves) { for (const move of validMoves) {
const key = `${move.card}-${move.toX}-${move.toY}`; const key = `${move.card}-${move.toX}-${move.toY}`;
const x = BOARD_OFFSET.x + move.toX * CELL_SIZE + CELL_SIZE / 2; const pos = boardToScreen(move.toX, move.toY);
const y = BOARD_OFFSET.y + move.toY * CELL_SIZE + CELL_SIZE / 2;
const circle = this.add.circle(x, y, CELL_SIZE / 3, 0x3b82f6, 0.3).setDepth(100); const circle = this.add.circle(pos.x, pos.y, CELL_SIZE / 3, 0x3b82f6, 0.3).setDepth(100);
circle.setInteractive({ useHandCursor: true }); circle.setInteractive({ useHandCursor: true });
circle.on('pointerdown', () => { circle.on('pointerdown', () => {
this.onHighlightClick({ this.onHighlightClick({
key, key,
x, x: pos.x,
y, y: pos.y,
card: move.card, card: move.card,
fromX: move.fromX, fromX: move.fromX,
fromY: move.fromY, fromY: move.fromY,

View File

@ -3,6 +3,7 @@ import type { Card } from '@/game/onitama';
import type { Spawner } from 'boardgame-phaser'; import type { Spawner } from 'boardgame-phaser';
import type { OnitamaScene } from '@/scenes/OnitamaScene'; import type { OnitamaScene } from '@/scenes/OnitamaScene';
import { BOARD_OFFSET, CELL_SIZE } from './PawnSpawner'; import { BOARD_OFFSET, CELL_SIZE } from './PawnSpawner';
import {effect} from "@preact/signals-core";
export const CARD_WIDTH = 100; export const CARD_WIDTH = 100;
export const CARD_HEIGHT = 140; export const CARD_HEIGHT = 140;
@ -71,16 +72,6 @@ export class CardSpawner implements Spawner<CardSpawnData, Phaser.GameObjects.Co
} }
onUpdate(data: CardSpawnData, obj: Phaser.GameObjects.Container): void { onUpdate(data: CardSpawnData, obj: Phaser.GameObjects.Container): void {
// 检查是否是选中的卡牌
const isSelected = this.scene.uiState.value.selectedCard === data.cardId;
// 高亮选中的卡牌
if (isSelected) {
this.highlightCard(obj, 0xfbbf24, 3);
} else {
this.unhighlightCard(obj);
}
// 只在位置实际变化时才播放移动动画 // 只在位置实际变化时才播放移动动画
if (!this.hasPositionChanged(data)) { if (!this.hasPositionChanged(data)) {
this.previousData.set(data.cardId, { ...data }); this.previousData.set(data.cardId, { ...data });
@ -176,6 +167,13 @@ export class CardSpawner implements Spawner<CardSpawnData, Phaser.GameObjects.Co
}); });
this.scene.addTweenInterruption(tween); this.scene.addTweenInterruption(tween);
container.once('destroy', effect(() => {
if(this.scene.uiState.value.selectedCard === data.cardId)
this.highlightCard(container, 0xfbbf24, 3);
else
this.unhighlightCard(container);
}));
this.previousData.set(data.cardId, { ...data }); this.previousData.set(data.cardId, { ...data });
return container; return container;
} }

View File

@ -5,6 +5,14 @@ import type { OnitamaScene } from '@/scenes/OnitamaScene';
export const CELL_SIZE = 80; export const CELL_SIZE = 80;
export const BOARD_OFFSET = { x: 200, y: 180 }; export const BOARD_OFFSET = { x: 200, y: 180 };
export const BOARD_SIZE = 5;
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,
};
}
export class PawnSpawner implements Spawner<Pawn, Phaser.GameObjects.Container> { export class PawnSpawner implements Spawner<Pawn, Phaser.GameObjects.Container> {
private previousPositions = new Map<string, [number, number]>(); private previousPositions = new Map<string, [number, number]>();
@ -30,13 +38,12 @@ export class PawnSpawner implements Spawner<Pawn, Phaser.GameObjects.Container>
if (hasMoved && prevPos) { if (hasMoved && prevPos) {
// 播放移动动画并添加中断 // 播放移动动画并添加中断
const targetX = BOARD_OFFSET.x + x * CELL_SIZE + CELL_SIZE / 2; const targetPos = boardToScreen(x, y);
const targetY = BOARD_OFFSET.y + y * CELL_SIZE + CELL_SIZE / 2;
const tween = this.scene.tweens.add({ const tween = this.scene.tweens.add({
targets: obj, targets: obj,
x: targetX, x: targetPos.x,
y: targetY, y: targetPos.y,
duration: 400, duration: 400,
ease: 'Back.easeOut', ease: 'Back.easeOut',
}); });
@ -44,8 +51,9 @@ export class PawnSpawner implements Spawner<Pawn, Phaser.GameObjects.Container>
this.scene.addTweenInterruption(tween); this.scene.addTweenInterruption(tween);
} else if (!prevPos) { } else if (!prevPos) {
// 初次生成,直接设置位置 // 初次生成,直接设置位置
obj.x = BOARD_OFFSET.x + x * CELL_SIZE + CELL_SIZE / 2; const pos = boardToScreen(x, y);
obj.y = BOARD_OFFSET.y + y * CELL_SIZE + CELL_SIZE / 2; obj.x = pos.x;
obj.y = pos.y;
} }
this.previousPositions.set(pawn.id, [x, y]); this.previousPositions.set(pawn.id, [x, y]);
@ -68,8 +76,9 @@ export class PawnSpawner implements Spawner<Pawn, Phaser.GameObjects.Container>
container.add(text); container.add(text);
const [x, y] = pawn.position; const [x, y] = pawn.position;
container.x = BOARD_OFFSET.x + x * CELL_SIZE + CELL_SIZE / 2; const pos = boardToScreen(x, y);
container.y = BOARD_OFFSET.y + y * CELL_SIZE + CELL_SIZE / 2; container.x = pos.x;
container.y = pos.y;
this.previousPositions.set(pawn.id, [x, y]); this.previousPositions.set(pawn.id, [x, y]);

View File

@ -1,3 +1,3 @@
export { PawnSpawner, CELL_SIZE, BOARD_OFFSET } from './PawnSpawner'; export { PawnSpawner, CELL_SIZE, BOARD_OFFSET, BOARD_SIZE, boardToScreen } from './PawnSpawner';
export { CardSpawner, CARD_WIDTH, CARD_HEIGHT, 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';

View File

@ -1,4 +1,4 @@
import { signal, type Signal } from '@preact/signals'; import { MutableSignal, mutableSignal } from 'boardgame-core';
export interface ValidMove { export interface ValidMove {
card: string; card: string;
@ -8,79 +8,74 @@ export interface ValidMove {
toY: number; toY: number;
} }
// 先选择牌,然后选择棋子,最后选择移动
export interface OnitamaUIState { export interface OnitamaUIState {
selectedPiece: { x: number; y: number } | null; selectedPiece: { x: number; y: number } | null;
selectedCard: string | null; selectedCard: string | null;
validMoves: ValidMove[]; validMoves: ValidMove[];
} }
export function createOnitamaUIState(): Signal<OnitamaUIState> { export function createOnitamaUIState(): MutableSignal<OnitamaUIState> {
return signal<OnitamaUIState>({ return mutableSignal<OnitamaUIState>({
selectedPiece: null, selectedPiece: null,
selectedCard: null, selectedCard: null,
validMoves: [], validMoves: [],
}); });
} }
export function clearSelection(uiState: Signal<OnitamaUIState>): void { export function clearSelection(uiState: MutableSignal<OnitamaUIState>): void {
uiState.value = { uiState.produce(state => {
selectedPiece: null, state.selectedPiece = null;
selectedCard: null, state.selectedCard = null;
validMoves: [], state.validMoves = [];
}; });
} }
export function selectPiece( export function selectPiece(
uiState: Signal<OnitamaUIState>, uiState: MutableSignal<OnitamaUIState>,
x: number, x: number,
y: number y: number
): void { ): void {
uiState.value = { uiState.produce(state => {
...uiState.value, state.selectedPiece = { x, y };
selectedPiece: { x, y }, });
selectedCard: null,
};
} }
export function selectCard( export function selectCard(
uiState: Signal<OnitamaUIState>, uiState: MutableSignal<OnitamaUIState>,
card: string card: string
): void { ): void {
uiState.produce(state => {
// 如果点击已选中的卡牌,取消选择 // 如果点击已选中的卡牌,取消选择
if (uiState.value.selectedCard === card) { if (state.selectedCard === card) {
uiState.value = { state.selectedPiece = null;
selectedPiece: null, state.selectedCard = null;
selectedCard: null, state.validMoves = [];
validMoves: [],
};
} else { } else {
// 选择新卡牌,清除棋子选择 // 选择新卡牌,清除棋子选择
uiState.value = { state.selectedPiece = null;
selectedPiece: null, state.selectedCard = card;
selectedCard: card, state.validMoves = [];
validMoves: [],
};
} }
});
} }
export function deselectCard( export function deselectCard(
uiState: Signal<OnitamaUIState> uiState: MutableSignal<OnitamaUIState>
): void { ): void {
uiState.value = { uiState.produce(state => {
...uiState.value, state.selectedCard = null;
selectedCard: null, state.selectedPiece = null;
selectedPiece: null, state.validMoves = [];
validMoves: [], });
};
} }
export function setValidMoves( export function setValidMoves(
uiState: Signal<OnitamaUIState>, uiState: MutableSignal<OnitamaUIState>,
moves: ValidMove[] moves: ValidMove[]
): void { ): void {
uiState.value = { uiState.produce(state => {
...uiState.value, state.validMoves = moves;
validMoves: moves, });
};
} }