feat: that's boop for ya

This commit is contained in:
hypercross 2026-04-04 14:22:35 +08:00
parent 951fbc7045
commit 696872d5d7
10 changed files with 801 additions and 0 deletions

View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Boop Game</title>
</head>
<body>
<div id="ui-root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@ -0,0 +1,26 @@
{
"name": "boop-game",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@preact/signals-core": "^1.5.1",
"boardgame-core": "link:../../../boardgame-core",
"boardgame-phaser": "workspace:*",
"mutative": "^1.3.0",
"phaser": "^3.80.1",
"preact": "^10.19.3"
},
"devDependencies": {
"@preact/preset-vite": "^2.8.1",
"@preact/signals": "^2.9.0",
"@tailwindcss/vite": "^4.0.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.3.3",
"vite": "^5.1.0"
}
}

View File

@ -0,0 +1,380 @@
import {
createGameCommandRegistry,
Part,
MutableSignal,
createRegion,
createPart,
isCellOccupied as isCellOccupiedUtil,
getPartAtPosition,
} from 'boardgame-core';
const BOARD_SIZE = 6;
const MAX_PIECES_PER_PLAYER = 8;
const WIN_LENGTH = 3;
export type PlayerType = 'white' | 'black';
export type PieceType = 'kitten' | 'cat';
export type WinnerType = PlayerType | 'draw' | null;
export type BoopPart = Part<{ player: PlayerType; pieceType: PieceType }>;
type PieceSupply = { supply: number; placed: number };
type Player = {
id: PlayerType;
kitten: PieceSupply;
cat: PieceSupply;
};
type PlayerData = Record<PlayerType, Player>;
export function createInitialState() {
return {
board: createRegion('board', [
{ name: 'x', min: 0, max: BOARD_SIZE - 1 },
{ name: 'y', min: 0, max: BOARD_SIZE - 1 },
]),
pieces: {} as Record<string, BoopPart>,
currentPlayer: 'white' as PlayerType,
winner: null as WinnerType,
players: {
white: createPlayer('white'),
black: createPlayer('black'),
},
};
}
function createPlayer(id: PlayerType): Player {
return {
id,
kitten: { supply: MAX_PIECES_PER_PLAYER, placed: 0 },
cat: { supply: 0, placed: 0 },
};
}
export type BoopState = ReturnType<typeof createInitialState>;
const registration = createGameCommandRegistry<BoopState>();
export const registry = registration.registry;
export function getPlayer(host: MutableSignal<BoopState>, player: PlayerType): Player {
return host.value.players[player];
}
export function decrementSupply(player: Player, pieceType: PieceType) {
player[pieceType].supply--;
player[pieceType].placed++;
}
export function incrementSupply(player: Player, pieceType: PieceType, count?: number) {
player[pieceType].supply += count ?? 1;
}
registration.add('setup', async function() {
const {context} = this;
while (true) {
const currentPlayer = context.value.currentPlayer;
const turnOutput = await this.run<{winner: WinnerType}>(`turn ${currentPlayer}`);
if (!turnOutput.success) throw new Error(turnOutput.error);
context.produce(state => {
state.winner = turnOutput.result.winner;
if (!state.winner) {
state.currentPlayer = state.currentPlayer === 'white' ? 'black' : 'white';
}
});
if (context.value.winner) break;
}
return context.value;
});
registration.add('turn <player>', async function(cmd) {
const [turnPlayer] = cmd.params as [PlayerType];
const playCmd = await this.prompt(
'play <player> <row:number> <col:number> [type:string]',
(command) => {
const [player, row, col, type] = command.params as [PlayerType, number, number, PieceType?];
const pieceType = type === 'cat' ? 'cat' : 'kitten';
if (player !== turnPlayer) {
return `Invalid player: ${player}. Expected ${turnPlayer}.`;
}
if (!isValidMove(row, col)) {
return `Invalid position: (${row}, ${col}). Must be between 0 and ${BOARD_SIZE - 1}.`;
}
if (isCellOccupied(this.context, row, col)) {
return `Cell (${row}, ${col}) is already occupied.`;
}
const playerData = getPlayer(this.context, player);
const supply = playerData[pieceType].supply;
if (supply <= 0) {
return `No ${pieceType}s left in ${player}'s supply.`;
}
return null;
},
this.context.value.currentPlayer
);
const [player, row, col, type] = playCmd.params as [PlayerType, number, number, PieceType?];
const pieceType = type === 'cat' ? 'cat' : 'kitten';
placePiece(this.context, row, col, turnPlayer, pieceType);
applyBoops(this.context, row, col, pieceType);
const graduatedLines = checkGraduation(this.context, turnPlayer);
if (graduatedLines.length > 0) {
processGraduation(this.context, turnPlayer, graduatedLines);
}
if (countPiecesOnBoard(this.context, turnPlayer) >= MAX_PIECES_PER_PLAYER) {
const pieces = this.context.value.pieces;
const availableKittens = Object.values(pieces).filter(
p => p.player === turnPlayer && p.pieceType === 'kitten'
);
if (availableKittens.length > 0) {
const graduateCmd = await this.prompt(
'graduate <row:number> <col:number>',
(command) => {
const [row, col] = command.params as [number, number];
const posKey = `${row},${col}`;
const part = availableKittens.find(p => `${p.position[0]},${p.position[1]}` === posKey);
if (!part) return `No kitten at (${row}, ${col}).`;
return null;
},
this.context.value.currentPlayer
);
const [row, col] = graduateCmd.params as [number, number];
const part = availableKittens.find(p => p.position[0] === row && p.position[1] === col)!;
removePieceFromBoard(this.context, part);
const playerData = getPlayer(this.context, turnPlayer);
incrementSupply(playerData, 'cat', 1);
}
}
const winner = checkWinner(this.context);
if (winner) return { winner };
return { winner: null };
});
function isValidMove(row: number, col: number): boolean {
return !isNaN(row) && !isNaN(col) && row >= 0 && row < BOARD_SIZE && col >= 0 && col < BOARD_SIZE;
}
export function getBoardRegion(host: MutableSignal<BoopState>) {
return host.value.board;
}
export function isCellOccupied(host: MutableSignal<BoopState>, row: number, col: number): boolean {
return isCellOccupiedUtil(host.value.pieces, 'board', [row, col]);
}
export function getPartAt(host: MutableSignal<BoopState>, row: number, col: number): BoopPart | null {
return getPartAtPosition(host.value.pieces, 'board', [row, col]) || null;
}
export function placePiece(host: MutableSignal<BoopState>, row: number, col: number, player: PlayerType, pieceType: PieceType) {
const board = getBoardRegion(host);
const playerData = getPlayer(host, player);
const count = playerData[pieceType].placed + 1;
const piece = createPart<{ player: PlayerType; pieceType: PieceType }>(
{ regionId: 'board', position: [row, col], player, pieceType },
`${player}-${pieceType}-${count}`
);
host.produce(s => {
s.pieces[piece.id] = piece;
board.childIds.push(piece.id);
board.partMap[`${row},${col}`] = piece.id;
});
decrementSupply(playerData, pieceType);
}
export function applyBoops(host: MutableSignal<BoopState>, placedRow: number, placedCol: number, placedType: PieceType) {
const board = getBoardRegion(host);
const pieces = host.value.pieces;
const piecesArray = Object.values(pieces);
const piecesToBoop: { part: BoopPart; dr: number; dc: number }[] = [];
for (const part of piecesArray) {
const [r, c] = part.position;
if (r === placedRow && c === placedCol) continue;
const dr = Math.sign(r - placedRow);
const dc = Math.sign(c - placedCol);
if (Math.abs(r - placedRow) <= 1 && Math.abs(c - placedCol) <= 1) {
const booperIsKitten = placedType === 'kitten';
const targetIsCat = part.pieceType === 'cat';
if (booperIsKitten && targetIsCat) continue;
piecesToBoop.push({ part, dr, dc });
}
}
for (const { part, dr, dc } of piecesToBoop) {
const [r, c] = part.position;
const newRow = r + dr;
const newCol = c + dc;
if (newRow < 0 || newRow >= BOARD_SIZE || newCol < 0 || newCol >= BOARD_SIZE) {
const pt = part.pieceType;
const pl = part.player;
const playerData = getPlayer(host, pl);
removePieceFromBoard(host, part);
incrementSupply(playerData, pt);
continue;
}
if (isCellOccupied(host, newRow, newCol)) continue;
part.position = [newRow, newCol];
board.partMap = Object.fromEntries(
board.childIds.map(id => {
const p = pieces[id];
return [p.position.join(','), id];
})
);
}
}
export function removePieceFromBoard(host: MutableSignal<BoopState>, part: BoopPart) {
const board = getBoardRegion(host);
const playerData = getPlayer(host, part.player);
board.childIds = board.childIds.filter(id => id !== part.id);
delete board.partMap[part.position.join(',')];
delete host.value.pieces[part.id];
playerData[part.pieceType].placed--;
}
const DIRECTIONS: [number, number][] = [
[0, 1],
[1, 0],
[1, 1],
[1, -1],
];
export function* linesThrough(r: number, c: number): Generator<number[][]> {
for (const [dr, dc] of DIRECTIONS) {
const minStart = -(WIN_LENGTH - 1);
for (let offset = minStart; offset <= 0; offset++) {
const startR = r + offset * dr;
const startC = c + offset * dc;
const endR = startR + (WIN_LENGTH - 1) * dr;
const endC = startC + (WIN_LENGTH - 1) * dc;
if (startR < 0 || startR >= BOARD_SIZE || startC < 0 || startC >= BOARD_SIZE) continue;
if (endR < 0 || endR >= BOARD_SIZE || endC < 0 || endC >= BOARD_SIZE) continue;
const line: number[][] = [];
for (let i = 0; i < WIN_LENGTH; i++) {
line.push([startR + i * dr, startC + i * dc]);
}
yield line;
}
}
}
export function* allLines(): Generator<number[][]> {
const seen = new Set<string>();
for (let r = 0; r < BOARD_SIZE; r++) {
for (let c = 0; c < BOARD_SIZE; c++) {
for (const line of linesThrough(r, c)) {
const key = line.map(p => p.join(',')).join(';');
if (!seen.has(key)) {
seen.add(key);
yield line;
}
}
}
}
}
export function hasWinningLine(positions: number[][]): boolean {
const posSet = new Set(positions.map(p => `${p[0]},${p[1]}`));
for (const line of allLines()) {
if (line.every(([lr, lc]) => posSet.has(`${lr},${lc}`))) return true;
}
return false;
}
export function checkGraduation(host: MutableSignal<BoopState>, player: PlayerType): number[][][] {
const pieces = host.value.pieces;
const piecesArray = Object.values(pieces);
const posSet = new Set<string>();
for (const part of piecesArray) {
if (part.player === player && part.pieceType === 'kitten') {
posSet.add(`${part.position[0]},${part.position[1]}`);
}
}
const winningLines: number[][][] = [];
for (const line of allLines()) {
if (line.every(([lr, lc]) => posSet.has(`${lr},${lc}`))) {
winningLines.push(line);
}
}
return winningLines;
}
export function processGraduation(host: MutableSignal<BoopState>, player: PlayerType, lines: number[][][]) {
const allPositions = new Set<string>();
for (const line of lines) {
for (const [r, c] of line) {
allPositions.add(`${r},${c}`);
}
}
const board = getBoardRegion(host);
const pieces = host.value.pieces;
const partsToRemove = Object.values(pieces).filter(
p => p.player === player && p.pieceType === 'kitten' && allPositions.has(`${p.position[0]},${p.position[1]}`)
);
for (const part of partsToRemove) {
removePieceFromBoard(host, part);
}
const count = partsToRemove.length;
const playerData = getPlayer(host, player);
incrementSupply(playerData, 'cat', count);
}
export function countPiecesOnBoard(host: MutableSignal<BoopState>, player: PlayerType): number {
const pieces = host.value.pieces;
return Object.values(pieces).filter(p => p.player === player).length;
}
export function checkWinner(host: MutableSignal<BoopState>): WinnerType {
const pieces = host.value.pieces;
const piecesArray = Object.values(pieces);
for (const player of ['white', 'black'] as PlayerType[]) {
const positions = piecesArray
.filter(p => p.player === player && p.pieceType === 'cat')
.map(p => p.position);
if (hasWinningLine(positions)) return player;
}
return null;
}
// 命令构建器
export const commands = {
play: (player: PlayerType, row: number, col: number, type?: PieceType) =>
`play ${player} ${row} ${col}${type ? ` ${type}` : ''}`,
turn: (player: PlayerType) => `turn ${player}`,
graduate: (row: number, col: number) => `graduate ${row} ${col}`,
} as const;
// 导出游戏模块
export const gameModule = {
createInitialState,
registry,
commands,
};

View File

@ -0,0 +1,13 @@
import { h } from 'preact';
import { GameUI } from 'boardgame-phaser';
import { gameModule } from './game/boop';
import './style.css';
import App from "@/ui/App";
import {GameScene} from "@/scenes/GameScene";
const ui = new GameUI({
container: document.getElementById('ui-root')!,
root: <App gameModule={gameModule} gameScene={GameScene}/>,
});
ui.mount();

View File

@ -0,0 +1,254 @@
import Phaser from 'phaser';
import type {BoopState, BoopPart, PlayerType, PieceType} from '@/game/boop';
import { GameHostScene } from 'boardgame-phaser';
import { spawnEffect, type Spawner } from 'boardgame-phaser';
import type { ReadonlySignal } from '@preact/signals-core';
import {commands} from "@/game/boop";
const BOARD_SIZE = 6;
const CELL_SIZE = 80;
const BOARD_OFFSET = { x: 80, y: 100 };
export class GameScene extends GameHostScene<BoopState> {
private boardContainer!: Phaser.GameObjects.Container;
private gridGraphics!: Phaser.GameObjects.Graphics;
private turnText!: Phaser.GameObjects.Text;
private infoText!: Phaser.GameObjects.Text;
private winnerOverlay?: Phaser.GameObjects.Container;
constructor() {
super('GameScene');
}
create(): void {
super.create();
this.boardContainer = this.add.container(0, 0);
this.gridGraphics = this.add.graphics();
this.drawGrid();
this.disposables.add(spawnEffect(new BoopPartSpawner(this, this.gameHost.state)));
this.watch(() => {
const winner = this.state.winner;
if (winner) {
this.showWinner(winner);
} else if (this.winnerOverlay) {
this.winnerOverlay.destroy();
this.winnerOverlay = undefined;
}
});
this.watch(() => {
const currentPlayer = this.state.currentPlayer;
this.updateTurnText(currentPlayer);
});
this.setupInput();
}
private setupInput(): void {
for (let row = 0; row < BOARD_SIZE; row++) {
for (let col = 0; col < BOARD_SIZE; col++) {
const x = BOARD_OFFSET.x + col * CELL_SIZE + CELL_SIZE / 2;
const y = BOARD_OFFSET.y + row * CELL_SIZE + CELL_SIZE / 2;
const zone = this.add.zone(x, y, CELL_SIZE, CELL_SIZE).setInteractive();
zone.on('pointerdown', () => {
if (this.state.winner) return;
if (this.isCellOccupied(row, col)) return;
const cmd = commands.play(this.state.currentPlayer, row, col, 'kitten');
const error = this.gameHost.onInput(cmd);
if (error) {
console.warn('Invalid move:', error);
}
});
}
}
}
private drawGrid(): void {
const g = this.gridGraphics;
g.lineStyle(2, 0x6b7280);
for (let i = 1; i < BOARD_SIZE; i++) {
g.lineBetween(
BOARD_OFFSET.x + i * CELL_SIZE,
BOARD_OFFSET.y,
BOARD_OFFSET.x + i * CELL_SIZE,
BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE,
);
g.lineBetween(
BOARD_OFFSET.x,
BOARD_OFFSET.y + i * CELL_SIZE,
BOARD_OFFSET.x + BOARD_SIZE * CELL_SIZE,
BOARD_OFFSET.y + i * CELL_SIZE,
);
}
g.strokePath();
this.add.text(BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2, BOARD_OFFSET.y - 50, 'Boop Game', {
fontSize: '32px',
fontFamily: 'Arial',
color: '#1f2937',
}).setOrigin(0.5);
this.turnText = this.add.text(
BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2,
BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE + 30,
'',
{
fontSize: '22px',
fontFamily: 'Arial',
color: '#4b5563',
}
).setOrigin(0.5);
this.infoText = this.add.text(
BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2,
BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE + 60,
'Click to place kitten. Cats win with 3 in a row!',
{
fontSize: '16px',
fontFamily: 'Arial',
color: '#6b7280',
}
).setOrigin(0.5);
this.updateTurnText(this.state.currentPlayer);
}
private updateTurnText(player: PlayerType): void {
if (this.turnText) {
const whitePieces = this.state.players.white;
const blackPieces = this.state.players.black;
const current = player === 'white' ? whitePieces : blackPieces;
this.turnText.setText(
`${player.toUpperCase()}'s turn | Kittens: ${current.kitten.supply} | Cats: ${current.cat.supply}`
);
}
}
private showWinner(winner: PlayerType | 'draw' | null): void {
if (this.winnerOverlay) {
this.winnerOverlay.destroy();
}
this.winnerOverlay = this.add.container();
const text = winner === 'draw' ? "It's a draw!" : winner ? `${winner.toUpperCase()} wins!` : '';
const bg = this.add.rectangle(
BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2,
BOARD_OFFSET.y + (BOARD_SIZE * CELL_SIZE) / 2,
BOARD_SIZE * CELL_SIZE,
BOARD_SIZE * CELL_SIZE,
0x000000,
0.6,
).setInteractive({ useHandCursor: true });
bg.on('pointerdown', () => {
this.gameHost.setup('setup');
});
this.winnerOverlay.add(bg);
const winText = this.add.text(
BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2,
BOARD_OFFSET.y + (BOARD_SIZE * CELL_SIZE) / 2,
text,
{
fontSize: '40px',
fontFamily: 'Arial',
color: '#fbbf24',
},
).setOrigin(0.5);
this.winnerOverlay.add(winText);
this.tweens.add({
targets: winText,
scale: 1.2,
duration: 500,
yoyo: true,
repeat: 1,
});
}
private isCellOccupied(row: number, col: number): boolean {
return !!this.state.board.partMap[`${row},${col}`];
}
}
class BoopPartSpawner implements Spawner<BoopPart, Phaser.GameObjects.Container> {
constructor(public readonly scene: GameScene, public readonly state: ReadonlySignal<BoopState>) {}
*getData() {
for (const part of Object.values(this.state.value.pieces)) {
yield part;
}
}
getKey(part: BoopPart): string {
return part.id;
}
onUpdate(part: BoopPart, obj: Phaser.GameObjects.Container): void {
const [row, col] = part.position;
const x = BOARD_OFFSET.x + col * CELL_SIZE + CELL_SIZE / 2;
const y = BOARD_OFFSET.y + row * CELL_SIZE + CELL_SIZE / 2;
obj.x = x;
obj.y = y;
}
onSpawn(part: BoopPart) {
const [row, col] = part.position;
const x = BOARD_OFFSET.x + col * CELL_SIZE + CELL_SIZE / 2;
const y = BOARD_OFFSET.y + row * CELL_SIZE + CELL_SIZE / 2;
const container = this.scene.add.container(x, y);
const isCat = part.pieceType === 'cat';
const baseColor = part.player === 'white' ? 0xffffff : 0x333333;
const strokeColor = part.player === 'white' ? 0x000000 : 0xffffff;
// 绘制圆形背景
const circle = this.scene.add.circle(0, 0, CELL_SIZE * 0.4, baseColor)
.setStrokeStyle(3, strokeColor);
// 添加文字标识
const text = isCat ? '🐱' : '🐾';
const textObj = this.scene.add.text(0, 0, text, {
fontSize: `${isCat ? 40 : 32}px`,
fontFamily: 'Arial',
}).setOrigin(0.5);
container.add([circle, textObj]);
// 添加落子动画
container.setScale(0);
this.scene.tweens.add({
targets: container,
scale: 1,
duration: 200,
ease: 'Back.easeOut',
});
return container;
}
onDespawn(obj: Phaser.GameObjects.Container) {
this.scene.tweens.add({
targets: obj,
alpha: 0,
scale: 0.5,
duration: 200,
ease: 'Back.easeIn',
onComplete: () => obj.destroy(),
});
}
}

View File

@ -0,0 +1,9 @@
@import "tailwindcss";
#ui-root {
pointer-events: none;
}
#ui-root * {
pointer-events: auto;
}

View File

@ -0,0 +1,38 @@
import {useComputed} from '@preact/signals';
import { createGameHost, type GameModule } from 'boardgame-core';
import Phaser from 'phaser';
import { h } from 'preact';
import { PhaserGame, PhaserScene } from 'boardgame-phaser';
export default function App<TState extends Record<string, unknown>>(props: { gameModule: GameModule<TState>, gameScene: { new(): Phaser.Scene } }) {
const gameHost = useComputed(() => {
const gameHost = createGameHost(props.gameModule);
return { gameHost };
});
const scene = useComputed(() => new props.gameScene());
const handleReset = async () => {
gameHost.value.gameHost.setup('setup');
};
const label = useComputed(() => gameHost.value.gameHost.status.value === 'running' ? 'Restart' : 'Start');
return (
<div className="flex flex-col h-screen">
<div className="flex-1 relative">
<PhaserGame>
<PhaserScene sceneKey="GameScene" scene={scene.value} autoStart data={gameHost.value} />
</PhaserGame>
</div>
<div className="p-4 bg-gray-100 border-t border-gray-200">
<button
onClick={handleReset}
className="px-6 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors font-medium"
>
{label}
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,16 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"jsx": "react-jsx",
"jsxImportSource": "preact",
"noEmit": true,
"declaration": false,
"declarationMap": false,
"sourceMap": false
},
"include": ["src/**/*"]
}

View File

@ -0,0 +1,13 @@
import { defineConfig } from 'vite';
import preact from '@preact/preset-vite';
import tailwindcss from '@tailwindcss/vite';
import path from 'path';
export default defineConfig({
plugins: [preact(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
});

View File

@ -15,6 +15,46 @@ importers:
specifier: ^3.2.4 specifier: ^3.2.4
version: 3.2.4(lightningcss@1.32.0) version: 3.2.4(lightningcss@1.32.0)
packages/boop-game:
dependencies:
'@preact/signals-core':
specifier: ^1.5.1
version: 1.14.1
boardgame-core:
specifier: link:../../../boardgame-core
version: link:../../../boardgame-core
boardgame-phaser:
specifier: workspace:*
version: link:../framework
mutative:
specifier: ^1.3.0
version: 1.3.0
phaser:
specifier: ^3.80.1
version: 3.90.0
preact:
specifier: ^10.19.3
version: 10.29.0
devDependencies:
'@preact/preset-vite':
specifier: ^2.8.1
version: 2.10.5(@babel/core@7.29.0)(preact@10.29.0)(rollup@4.60.1)(vite@5.4.21(lightningcss@1.32.0))
'@preact/signals':
specifier: ^2.9.0
version: 2.9.0(preact@10.29.0)
'@tailwindcss/vite':
specifier: ^4.0.0
version: 4.2.2(vite@5.4.21(lightningcss@1.32.0))
tailwindcss:
specifier: ^4.0.0
version: 4.2.2
typescript:
specifier: ^5.3.3
version: 5.9.3
vite:
specifier: ^5.1.0
version: 5.4.21(lightningcss@1.32.0)
packages/framework: packages/framework:
devDependencies: devDependencies:
'@preact/signals': '@preact/signals':