Compare commits
41
Commits
82df3f2a2f
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad2c808482 | ||
|
|
f893ac9ff7 | ||
|
|
dfbdaa3499 | ||
|
|
c29b9a43b3 | ||
|
|
447a5cfbd0 | ||
|
|
dda290bf9c | ||
|
|
5d84c42b78 | ||
|
|
a6ddafb802 | ||
|
|
a181932fb3 | ||
|
|
887da22baa | ||
|
|
5355afb23e | ||
|
|
3f2ba84a06 | ||
|
|
9637312b7c | ||
|
|
82c026628a | ||
|
|
a92b889dfa | ||
|
|
c97c2c4150 | ||
|
|
422ddde200 | ||
|
|
093eadbefd | ||
|
|
eff62bc0ed | ||
|
|
fe0621cedf | ||
|
|
30e2d9ac36 | ||
|
|
c9f573ef86 | ||
|
|
cc796d1e11 | ||
|
|
fd347e7a62 | ||
|
|
19dfafeb30 | ||
|
|
b2862e34d9 | ||
|
|
fd41db2a89 | ||
|
|
f03e6c5a7b | ||
|
|
7039938a72 | ||
|
|
45844ea800 | ||
|
|
29b516c371 | ||
|
|
5884e74d8d | ||
|
|
b06876ccc0 | ||
|
|
368d9942d2 | ||
|
|
22817945cc | ||
|
|
85bc4f5400 | ||
|
|
033a8e4a40 | ||
|
|
1d803dd219 | ||
|
|
2d412eedb5 | ||
|
|
f32aca2543 | ||
|
|
34a7cff964 |
@@ -32,8 +32,8 @@ packages/my-game/
|
|||||||
|
|
||||||
### 1. ReactiveScene / GameHostScene
|
### 1. ReactiveScene / GameHostScene
|
||||||
Extend `ReactiveScene`(`packages\framework\src\scenes\ReactiveScene.ts`) to use reactive integration features.
|
Extend `ReactiveScene`(`packages\framework\src\scenes\ReactiveScene.ts`) to use reactive integration features.
|
||||||
- Access game context for scene navigation
|
|
||||||
- Use `this.disposables` for auto-cleanup on shutdown.
|
- Use `this.disposables` for auto-cleanup on shutdown.
|
||||||
|
- Use `initData` for context data passed from the `<PhaserScene/>` preact component.
|
||||||
|
|
||||||
### 2. Spawner Pattern
|
### 2. Spawner Pattern
|
||||||
Implement `Spawner<TData, TObj>` for data-driven objects.
|
Implement `Spawner<TData, TObj>` for data-driven objects.
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import js from "@eslint/js";
|
import * as js from "@eslint";
|
||||||
import tseslint from "typescript-eslint";
|
import tseslint from "typescript-eslint";
|
||||||
import importPlugin from "eslint-plugin-import";
|
import importPlugin from "eslint-plugin-import";
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ export type { IDisposable, DisposableItem } from "./utils";
|
|||||||
// Drag & drop utilities
|
// Drag & drop utilities
|
||||||
export { dragDropEventEffect, DragDropEventType } from "./utils";
|
export { dragDropEventEffect, DragDropEventType } from "./utils";
|
||||||
export type { DragDropEvent, DragDropCallback } from "./utils";
|
export type { DragDropEvent, DragDropCallback } from "./utils";
|
||||||
|
export { hoverEffect } from "./utils";
|
||||||
|
|
||||||
// Data-driven object spawning
|
// Data-driven object spawning
|
||||||
export { spawnEffect } from "./spawner";
|
export { spawnEffect, createSpawnUpdate } from "./spawner";
|
||||||
export type { Spawner } from "./spawner";
|
export type { Spawner, SpawnerCallback } from "./spawner";
|
||||||
|
|
||||||
// Scene base classes
|
// Scene base classes
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -1,17 +1,23 @@
|
|||||||
import { ReactiveScene } from "./ReactiveScene";
|
import { ReactiveScene } from "./ReactiveScene";
|
||||||
|
|
||||||
import type { GameHost } from "boardgame-core";
|
import type { GameHost, GameModule } from "boardgame-core";
|
||||||
|
|
||||||
export interface GameHostSceneOptions<TState extends Record<string, unknown>> {
|
export interface GameHostSceneOptions<
|
||||||
gameHost: GameHost<TState>;
|
TState extends Record<string, unknown>,
|
||||||
|
TResult,
|
||||||
|
TModule extends GameModule<TState, TResult>,
|
||||||
|
> {
|
||||||
|
gameHost: GameHost<TState, TResult, TModule>;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
export abstract class GameHostScene<
|
export abstract class GameHostScene<
|
||||||
TState extends Record<string, unknown>,
|
TState extends Record<string, unknown>,
|
||||||
> extends ReactiveScene<GameHostSceneOptions<TState>> {
|
TResult,
|
||||||
public get gameHost(): GameHost<TState> {
|
TModule extends GameModule<TState, TResult>,
|
||||||
const gameHost = this.initData.gameHost as GameHost<TState>;
|
> extends ReactiveScene<GameHostSceneOptions<TState, TResult, TModule>> {
|
||||||
|
public get gameHost(): GameHost<TState, TResult, TModule> {
|
||||||
|
const gameHost = this.initData.gameHost;
|
||||||
if (!gameHost) {
|
if (!gameHost) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`GameHostScene (${this.scene.key}): gameHost 未提供。` +
|
`GameHostScene (${this.scene.key}): gameHost 未提供。` +
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { effect, type ReadonlySignal } from "@preact/signals-core";
|
import { type ReadonlySignal } from "@preact/signals-core";
|
||||||
import { Scene } from "phaser";
|
import { Scene } from "phaser";
|
||||||
|
|
||||||
import { DisposableBag, type IDisposable } from "../utils";
|
import { DisposableBag, type IDisposable } from "../utils";
|
||||||
@@ -85,6 +85,6 @@ export abstract class ReactiveScene<TData = object>
|
|||||||
|
|
||||||
/** 注册响应式监听(场景关闭时自动清理) */
|
/** 注册响应式监听(场景关闭时自动清理) */
|
||||||
public addEffect(fn: () => CleanupFn): void {
|
public addEffect(fn: () => CleanupFn): void {
|
||||||
this.disposables.add(effect(fn));
|
this.disposables.addEffect(fn);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,7 @@ import type Phaser from "phaser";
|
|||||||
|
|
||||||
type GO = Phaser.GameObjects.GameObject;
|
type GO = Phaser.GameObjects.GameObject;
|
||||||
|
|
||||||
export interface Spawner<TData, TObject extends GO = GO> {
|
export interface SpawnerCallback<TData, TObject extends GO = GO> {
|
||||||
/** 数据源迭代器 */
|
|
||||||
getData(): Iterable<TData>;
|
|
||||||
/** 获取数据的唯一键 */
|
/** 获取数据的唯一键 */
|
||||||
getKey(t: TData): string;
|
getKey(t: TData): string;
|
||||||
/** 创建新对象 */
|
/** 创建新对象 */
|
||||||
@@ -17,15 +15,33 @@ export interface Spawner<TData, TObject extends GO = GO> {
|
|||||||
onUpdate(t: TData, obj: TObject): void;
|
onUpdate(t: TData, obj: TObject): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Spawner<
|
||||||
|
TData,
|
||||||
|
TObject extends GO = GO,
|
||||||
|
> extends SpawnerCallback<TData, TObject> {
|
||||||
|
/** 数据源迭代器 */
|
||||||
|
getData(): Iterable<TData>;
|
||||||
|
}
|
||||||
|
|
||||||
export function spawnEffect<TData, TObject extends GO = GO>(
|
export function spawnEffect<TData, TObject extends GO = GO>(
|
||||||
spawner: Spawner<TData, TObject>,
|
spawner: Spawner<TData, TObject>,
|
||||||
): () => void {
|
) {
|
||||||
const entries = new Map<string, { object: TObject; data: TData }>();
|
const update = createSpawnUpdate(spawner);
|
||||||
|
|
||||||
return effect(() => {
|
return effect(() => {
|
||||||
|
update(spawner.getData());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSpawnUpdate<TData, TObject extends GO = GO>(
|
||||||
|
spawner: SpawnerCallback<TData, TObject>,
|
||||||
|
) {
|
||||||
|
const entries = new Map<string, { object: TObject; data: TData }>();
|
||||||
|
|
||||||
|
function update(data: Iterable<TData>) {
|
||||||
const current = new Set<string>();
|
const current = new Set<string>();
|
||||||
|
|
||||||
for (const t of spawner.getData()) {
|
for (const t of data) {
|
||||||
const key = spawner.getKey(t);
|
const key = spawner.getKey(t);
|
||||||
current.add(key);
|
current.add(key);
|
||||||
|
|
||||||
@@ -48,5 +64,7 @@ export function spawnEffect<TData, TObject extends GO = GO>(
|
|||||||
entries.delete(key);
|
entries.delete(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
|
return update;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { signal, useSignal, useSignalEffect } from '@preact/signals';
|
import { signal, useSignal, useSignalEffect } from "@preact/signals";
|
||||||
import Phaser, { AUTO } from 'phaser';
|
import Phaser, { AUTO, Scene } from "phaser";
|
||||||
import { createContext } from 'preact';
|
import { createContext } from "preact";
|
||||||
import { useContext, useEffect, useRef } from 'preact/hooks';
|
import { useContext, useEffect, useRef } from "preact/hooks";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
FadeScene as FadeSceneClass,
|
FadeScene as FadeSceneClass,
|
||||||
FADE_SCENE_KEY,
|
FADE_SCENE_KEY,
|
||||||
} from '../scenes/FadeScene';
|
} from "../scenes/FadeScene";
|
||||||
|
|
||||||
import type { ReactiveScene } from '../scenes';
|
import type { ReactiveScene } from "../scenes";
|
||||||
import type { ReadonlySignal } from '@preact/signals-core';
|
import type { ReadonlySignal } from "@preact/signals-core";
|
||||||
|
|
||||||
export interface SceneController {
|
export interface SceneController {
|
||||||
/** 启动场景(带淡入淡出过渡) */
|
/** 启动场景(带淡入淡出过渡) */
|
||||||
@@ -34,9 +34,10 @@ export const defaultPhaserConfig: Phaser.Types.Core.GameConfig = {
|
|||||||
type: AUTO,
|
type: AUTO,
|
||||||
width: 560,
|
width: 560,
|
||||||
height: 560,
|
height: 560,
|
||||||
parent: 'phaser-container',
|
parent: "phaser-container",
|
||||||
backgroundColor: '#f9fafb',
|
backgroundColor: "#f9fafb",
|
||||||
scene: [],
|
scene: [],
|
||||||
|
disableContextMenu: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface PhaserGameProps {
|
export interface PhaserGameProps {
|
||||||
@@ -71,7 +72,7 @@ export function PhaserGame(props: PhaserGameProps) {
|
|||||||
const sceneController: SceneController = {
|
const sceneController: SceneController = {
|
||||||
async launch(sceneKey: string) {
|
async launch(sceneKey: string) {
|
||||||
if (isTransitioning.value) {
|
if (isTransitioning.value) {
|
||||||
console.warn('SceneController: 正在进行场景切换');
|
console.warn("SceneController: 正在进行场景切换");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,12 +121,12 @@ export function PhaserGame(props: PhaserGameProps) {
|
|||||||
},
|
},
|
||||||
async restart() {
|
async restart() {
|
||||||
if (isTransitioning.value) {
|
if (isTransitioning.value) {
|
||||||
console.warn('SceneController: 正在进行场景切换');
|
console.warn("SceneController: 正在进行场景切换");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!currentScene.value) {
|
if (!currentScene.value) {
|
||||||
console.warn('SceneController: 没有当前场景,无法 restart');
|
console.warn("SceneController: 没有当前场景,无法 restart");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,8 +193,8 @@ export function PhaserGame(props: PhaserGameProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface PhaserSceneProps<TData = {}> {
|
export interface PhaserSceneProps<TData = {}> {
|
||||||
sceneKey: string;
|
sceneKey?: string;
|
||||||
scene: ReactiveScene<TData>;
|
scene: ReactiveScene<TData> | { new (): ReactiveScene<TData> };
|
||||||
data?: TData;
|
data?: TData;
|
||||||
children?: any;
|
children?: any;
|
||||||
}
|
}
|
||||||
@@ -214,16 +215,19 @@ export function PhaserScene<TData = {}>(props: PhaserSceneProps<TData>) {
|
|||||||
const game = ctx.game;
|
const game = ctx.game;
|
||||||
|
|
||||||
// 注册场景到 Phaser(但不启动)
|
// 注册场景到 Phaser(但不启动)
|
||||||
if (!game.scene.getScene(props.sceneKey)) {
|
const scene =
|
||||||
|
props.scene instanceof Scene ? props.scene : new props.scene();
|
||||||
|
const sceneKey = props.sceneKey ?? scene.sys.settings.key;
|
||||||
|
if (!game.scene.getScene(sceneKey)) {
|
||||||
const initData = {
|
const initData = {
|
||||||
...props.data,
|
...props.data,
|
||||||
phaserGame: phaserGameSignal,
|
phaserGame: phaserGameSignal,
|
||||||
sceneController: ctx.sceneController,
|
sceneController: ctx.sceneController,
|
||||||
};
|
};
|
||||||
game.scene.add(props.sceneKey, props.scene, false, initData);
|
game.scene.add(sceneKey, props.scene, false, initData);
|
||||||
}
|
}
|
||||||
|
|
||||||
sceneSignal.value = props.scene;
|
sceneSignal.value = scene;
|
||||||
registered.current = true;
|
registered.current = true;
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { effect } from "@preact/signals-core";
|
||||||
|
|
||||||
export interface IDisposable {
|
export interface IDisposable {
|
||||||
dispose(): void;
|
dispose(): void;
|
||||||
}
|
}
|
||||||
@@ -8,6 +10,10 @@ export class DisposableBag implements IDisposable {
|
|||||||
private _disposables = new Set<DisposableItem>();
|
private _disposables = new Set<DisposableItem>();
|
||||||
private _isDisposed = false;
|
private _isDisposed = false;
|
||||||
|
|
||||||
|
constructor(go?: Phaser.GameObjects.GameObject) {
|
||||||
|
if (go) go.on("shutdown", () => this.dispose());
|
||||||
|
}
|
||||||
|
|
||||||
get isDisposed(): boolean {
|
get isDisposed(): boolean {
|
||||||
return this._isDisposed;
|
return this._isDisposed;
|
||||||
}
|
}
|
||||||
@@ -20,6 +26,10 @@ export class DisposableBag implements IDisposable {
|
|||||||
this._disposables.add(item);
|
this._disposables.add(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
addEffect(fn: () => void) {
|
||||||
|
this.add(effect(fn));
|
||||||
|
}
|
||||||
|
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
if (this._isDisposed) return;
|
if (this._isDisposed) return;
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
type PointerRecord = {
|
type PointerRecord = {
|
||||||
id: number;
|
id: number;
|
||||||
|
button: number;
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
};
|
};
|
||||||
@@ -10,6 +11,7 @@ export enum DragDropEventType {
|
|||||||
DOWN,
|
DOWN,
|
||||||
UP,
|
UP,
|
||||||
MOVE,
|
MOVE,
|
||||||
|
ALTBUTTON,
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DragDropEvent = {
|
export type DragDropEvent = {
|
||||||
@@ -29,9 +31,22 @@ export function dragDropEventEffect(
|
|||||||
let down: PointerRecord | null = null;
|
let down: PointerRecord | null = null;
|
||||||
|
|
||||||
function onPointerDown(pointer: Phaser.Input.Pointer) {
|
function onPointerDown(pointer: Phaser.Input.Pointer) {
|
||||||
if (isDragging) return;
|
if (down !== null) {
|
||||||
|
if (pointer.button === down.button) return;
|
||||||
|
callback({
|
||||||
|
type: DragDropEventType.ALTBUTTON,
|
||||||
|
deltaX: pointer.x - down.x,
|
||||||
|
deltaY: pointer.y - down.y,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
isDragging = true;
|
isDragging = true;
|
||||||
down = { id: pointer.id, x: pointer.x, y: pointer.y };
|
down = {
|
||||||
|
id: pointer.id,
|
||||||
|
button: pointer.button,
|
||||||
|
x: pointer.x,
|
||||||
|
y: pointer.y,
|
||||||
|
};
|
||||||
|
|
||||||
const event: DragDropEvent = {
|
const event: DragDropEvent = {
|
||||||
type: DragDropEventType.DOWN,
|
type: DragDropEventType.DOWN,
|
||||||
@@ -42,12 +57,23 @@ export function dragDropEventEffect(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onPointerUp(pointer: Phaser.Input.Pointer) {
|
function onPointerUp(pointer: Phaser.Input.Pointer) {
|
||||||
if (!isDragging || !down || pointer.id !== down.id) return;
|
if (
|
||||||
|
!isDragging ||
|
||||||
|
!down ||
|
||||||
|
pointer.id !== down.id ||
|
||||||
|
pointer.button !== down.button
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
isDragging = false;
|
|
||||||
const deltaX = pointer.x - down.x;
|
const deltaX = pointer.x - down.x;
|
||||||
const deltaY = pointer.y - down.y;
|
const deltaY = pointer.y - down.y;
|
||||||
const event: DragDropEvent = { type: DragDropEventType.UP, deltaX, deltaY };
|
|
||||||
|
isDragging = false;
|
||||||
|
const event: DragDropEvent = {
|
||||||
|
type: DragDropEventType.UP,
|
||||||
|
deltaX,
|
||||||
|
deltaY,
|
||||||
|
};
|
||||||
callback(event);
|
callback(event);
|
||||||
down = null;
|
down = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
type HoverCallback = (hovering: boolean) => void;
|
||||||
|
export function hoverEffect(
|
||||||
|
gameObject: Phaser.GameObjects.GameObject,
|
||||||
|
callback: HoverCallback,
|
||||||
|
) {
|
||||||
|
let isHovering = false;
|
||||||
|
const onPointerOver = () => {
|
||||||
|
if (isHovering) return;
|
||||||
|
isHovering = true;
|
||||||
|
callback(true);
|
||||||
|
};
|
||||||
|
const onPointerOut = () => {
|
||||||
|
if (!isHovering) return;
|
||||||
|
isHovering = false;
|
||||||
|
callback(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
gameObject.on("pointerover", onPointerOver);
|
||||||
|
gameObject.on("pointerout", onPointerOut);
|
||||||
|
const cleanup = () => {
|
||||||
|
gameObject.off("pointerover", onPointerOver);
|
||||||
|
gameObject.off("pointerout", onPointerOut);
|
||||||
|
gameObject.off("destroy", cleanup);
|
||||||
|
};
|
||||||
|
|
||||||
|
gameObject.once("destroy", cleanup);
|
||||||
|
return cleanup;
|
||||||
|
}
|
||||||
@@ -6,3 +6,4 @@ export {
|
|||||||
type DragDropEvent,
|
type DragDropEvent,
|
||||||
type DragDropCallback,
|
type DragDropCallback,
|
||||||
} from "./dnd";
|
} from "./dnd";
|
||||||
|
export * from "./hover";
|
||||||
|
|||||||
@@ -113,6 +113,13 @@ export const MENU_BUTTON = {
|
|||||||
height: 40,
|
height: 40,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
// Text positioning
|
||||||
|
export const TEXT_POSITION = {
|
||||||
|
titleX: 40,
|
||||||
|
titleY: 40,
|
||||||
|
infoX: 40,
|
||||||
|
} as const;
|
||||||
|
|
||||||
// Animation durations (in ms)
|
// Animation durations (in ms)
|
||||||
export const ANIMATIONS = {
|
export const ANIMATIONS = {
|
||||||
pawnSpawn: 300,
|
pawnSpawn: 300,
|
||||||
@@ -139,6 +146,23 @@ export const CARD_GRID = {
|
|||||||
gridSize: 5,
|
gridSize: 5,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
// Visual style constants
|
||||||
|
export const VISUAL = {
|
||||||
|
pawnRadius: CELL_SIZE / 3,
|
||||||
|
pawnStrokeWidth: 2,
|
||||||
|
selectionRingOffset: 5,
|
||||||
|
selectionRingStrokeWidth: 3,
|
||||||
|
highlightOuterRadius: CELL_SIZE / 3,
|
||||||
|
highlightInnerRadius: CELL_SIZE / 4,
|
||||||
|
highlightHitAreaRadius: CELL_SIZE / 3,
|
||||||
|
cardStrokeWidth: 2,
|
||||||
|
cardTitleOffset: 16,
|
||||||
|
cardPlayerOffset: 16,
|
||||||
|
cardDisabledAlpha: 0.8,
|
||||||
|
overlayAlpha: 0.6,
|
||||||
|
cardBackgroundDepth: -1,
|
||||||
|
} as const;
|
||||||
|
|
||||||
// Helper function to convert board coordinates to screen coordinates
|
// Helper function to convert board coordinates to screen coordinates
|
||||||
export function boardToScreen(
|
export function boardToScreen(
|
||||||
boardX: number,
|
boardX: number,
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import type { Card } from "@/game/onitama";
|
import type { Card } from "@/game/onitama";
|
||||||
import type { OnitamaScene } from "@/scenes/OnitamaScene";
|
import type { OnitamaScene } from "@/scenes/OnitamaScene";
|
||||||
|
|
||||||
import { CARD_WIDTH, CARD_HEIGHT, COLORS, FONTS, CARD_GRID } from "@/config";
|
import {
|
||||||
|
CARD_WIDTH,
|
||||||
|
CARD_HEIGHT,
|
||||||
|
COLORS,
|
||||||
|
FONTS,
|
||||||
|
CARD_GRID,
|
||||||
|
VISUAL,
|
||||||
|
} from "@/config";
|
||||||
|
|
||||||
export interface CardRenderOptions {
|
export interface CardRenderOptions {
|
||||||
card: Card;
|
card: Card;
|
||||||
@@ -28,12 +35,17 @@ export class CardRenderer {
|
|||||||
// Create background rectangle
|
// Create background rectangle
|
||||||
const bg = this.scene.add
|
const bg = this.scene.add
|
||||||
.rectangle(0, 0, CARD_WIDTH, CARD_HEIGHT, COLORS.cardBg, 1)
|
.rectangle(0, 0, CARD_WIDTH, CARD_HEIGHT, COLORS.cardBg, 1)
|
||||||
.setStrokeStyle(2, COLORS.cardStroke);
|
.setStrokeStyle(VISUAL.cardStrokeWidth, COLORS.cardStroke);
|
||||||
container.add(bg);
|
container.add(bg);
|
||||||
|
|
||||||
// Create title text
|
// Create title text
|
||||||
const title = this.scene.add
|
const title = this.scene.add
|
||||||
.text(0, -CARD_HEIGHT / 2 + 16, card.id, FONTS.cardTitle)
|
.text(
|
||||||
|
0,
|
||||||
|
-CARD_HEIGHT / 2 + VISUAL.cardTitleOffset,
|
||||||
|
card.id,
|
||||||
|
FONTS.cardTitle,
|
||||||
|
)
|
||||||
.setOrigin(0.5);
|
.setOrigin(0.5);
|
||||||
container.add(title);
|
container.add(title);
|
||||||
|
|
||||||
@@ -42,7 +54,12 @@ export class CardRenderer {
|
|||||||
|
|
||||||
// Create starting player text
|
// Create starting player text
|
||||||
const playerText = this.scene.add
|
const playerText = this.scene.add
|
||||||
.text(0, CARD_HEIGHT / 2 - 16, card.startingPlayer, FONTS.cardPlayer)
|
.text(
|
||||||
|
0,
|
||||||
|
CARD_HEIGHT / 2 - VISUAL.cardPlayerOffset,
|
||||||
|
card.startingPlayer,
|
||||||
|
FONTS.cardPlayer,
|
||||||
|
)
|
||||||
.setOrigin(0.5);
|
.setOrigin(0.5);
|
||||||
container.add(playerText);
|
container.add(playerText);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { OnitamaScene } from "@/scenes/OnitamaScene";
|
import type { OnitamaScene } from "@/scenes/OnitamaScene";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CELL_SIZE,
|
|
||||||
COLORS,
|
COLORS,
|
||||||
|
VISUAL,
|
||||||
createHighlightInnerPulseTween,
|
createHighlightInnerPulseTween,
|
||||||
createHighlightOuterPulseTween,
|
createHighlightOuterPulseTween,
|
||||||
} from "@/config";
|
} from "@/config";
|
||||||
@@ -37,7 +37,7 @@ export class HighlightRenderer {
|
|||||||
const outerCircle = this.scene.add.circle(
|
const outerCircle = this.scene.add.circle(
|
||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
CELL_SIZE / 3,
|
VISUAL.highlightOuterRadius,
|
||||||
COLORS.black,
|
COLORS.black,
|
||||||
0.2,
|
0.2,
|
||||||
);
|
);
|
||||||
@@ -47,7 +47,7 @@ export class HighlightRenderer {
|
|||||||
const innerCircle = this.scene.add.circle(
|
const innerCircle = this.scene.add.circle(
|
||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
CELL_SIZE / 4,
|
VISUAL.highlightInnerRadius,
|
||||||
COLORS.black,
|
COLORS.black,
|
||||||
0.4,
|
0.4,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { OnitamaScene } from "@/scenes/OnitamaScene";
|
import type { OnitamaScene } from "@/scenes/OnitamaScene";
|
||||||
|
|
||||||
import { CELL_SIZE, COLORS, FONTS } from "@/config";
|
import { CELL_SIZE, COLORS, FONTS, VISUAL } from "@/config";
|
||||||
|
|
||||||
export type PawnType = "master" | "student";
|
export type PawnType = "master" | "student";
|
||||||
export type PawnOwner = "red" | "black";
|
export type PawnOwner = "red" | "black";
|
||||||
@@ -31,8 +31,8 @@ export class PawnRenderer {
|
|||||||
// Create background circle
|
// Create background circle
|
||||||
const bgColor = owner === "red" ? COLORS.red : COLORS.black;
|
const bgColor = owner === "red" ? COLORS.red : COLORS.black;
|
||||||
const circle = this.scene.add
|
const circle = this.scene.add
|
||||||
.circle(0, 0, CELL_SIZE / 3, bgColor, 1)
|
.circle(0, 0, VISUAL.pawnRadius, bgColor, 1)
|
||||||
.setStrokeStyle(2, COLORS.pawnStroke);
|
.setStrokeStyle(VISUAL.pawnStrokeWidth, COLORS.pawnStroke);
|
||||||
container.add(circle);
|
container.add(circle);
|
||||||
|
|
||||||
// Create label text
|
// Create label text
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { OnitamaScene } from "@/scenes/OnitamaScene";
|
|||||||
import {
|
import {
|
||||||
CELL_SIZE,
|
CELL_SIZE,
|
||||||
COLORS,
|
COLORS,
|
||||||
|
VISUAL,
|
||||||
createSelectionShowTween,
|
createSelectionShowTween,
|
||||||
createSelectionRingPulseTween,
|
createSelectionRingPulseTween,
|
||||||
createSelectionHideTween,
|
createSelectionHideTween,
|
||||||
@@ -31,8 +32,21 @@ export class SelectionRenderer {
|
|||||||
parent: Phaser.GameObjects.Container | Phaser.GameObjects.GameObject,
|
parent: Phaser.GameObjects.Container | Phaser.GameObjects.GameObject,
|
||||||
): Phaser.GameObjects.Arc {
|
): Phaser.GameObjects.Arc {
|
||||||
const ring = this.scene.add
|
const ring = this.scene.add
|
||||||
.arc(0, 0, CELL_SIZE / 3 + 5, 0, 360, false, COLORS.highlight, 0)
|
.arc(
|
||||||
.setStrokeStyle(3, COLORS.highlightStroke, 1)
|
0,
|
||||||
|
0,
|
||||||
|
VISUAL.pawnRadius + VISUAL.selectionRingOffset,
|
||||||
|
0,
|
||||||
|
360,
|
||||||
|
false,
|
||||||
|
COLORS.highlight,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
.setStrokeStyle(
|
||||||
|
VISUAL.selectionRingStrokeWidth,
|
||||||
|
COLORS.highlightStroke,
|
||||||
|
1,
|
||||||
|
)
|
||||||
.setAlpha(0);
|
.setAlpha(0);
|
||||||
|
|
||||||
// Add to parent at index 0 (behind other visuals)
|
// Add to parent at index 0 (behind other visuals)
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
COLORS,
|
COLORS,
|
||||||
FONTS,
|
FONTS,
|
||||||
MENU_BUTTON,
|
MENU_BUTTON,
|
||||||
|
TEXT_POSITION,
|
||||||
|
VISUAL,
|
||||||
getBoardCenter,
|
getBoardCenter,
|
||||||
getCardLabelPosition,
|
getCardLabelPosition,
|
||||||
colorToStr,
|
colorToStr,
|
||||||
@@ -79,7 +81,12 @@ 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(
|
||||||
|
TEXT_POSITION.infoX,
|
||||||
|
BOARD_OFFSET.y,
|
||||||
|
"",
|
||||||
|
FONTS.info,
|
||||||
|
);
|
||||||
|
|
||||||
// Update info text when UI state changes
|
// Update info text when UI state changes
|
||||||
this.addEffect(() => {
|
this.addEffect(() => {
|
||||||
@@ -149,7 +156,12 @@ export class OnitamaScene extends GameHostScene<OnitamaState> {
|
|||||||
|
|
||||||
g.strokePath();
|
g.strokePath();
|
||||||
|
|
||||||
this.add.text(40, 40, "Onitama", FONTS.title);
|
this.add.text(
|
||||||
|
TEXT_POSITION.titleX,
|
||||||
|
TEXT_POSITION.titleY,
|
||||||
|
"Onitama",
|
||||||
|
FONTS.title,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private setupInput(): void {
|
private setupInput(): void {
|
||||||
@@ -288,7 +300,7 @@ export class OnitamaScene extends GameHostScene<OnitamaState> {
|
|||||||
boardWidth,
|
boardWidth,
|
||||||
boardHeight,
|
boardHeight,
|
||||||
COLORS.overlayBg,
|
COLORS.overlayBg,
|
||||||
0.6,
|
VISUAL.overlayAlpha,
|
||||||
)
|
)
|
||||||
.setInteractive({ useHandCursor: true });
|
.setInteractive({ useHandCursor: true });
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import type { Spawner } from "boardgame-phaser";
|
|||||||
import {
|
import {
|
||||||
CARD_WIDTH,
|
CARD_WIDTH,
|
||||||
CARD_HEIGHT,
|
CARD_HEIGHT,
|
||||||
|
COLORS,
|
||||||
|
VISUAL,
|
||||||
getCardPosition,
|
getCardPosition,
|
||||||
createCardMoveTween,
|
createCardMoveTween,
|
||||||
createCardRotateTween,
|
createCardRotateTween,
|
||||||
@@ -102,7 +104,7 @@ export class CardContainer extends Phaser.GameObjects.Container {
|
|||||||
.rectangle(0, 0, CARD_WIDTH + 8, CARD_HEIGHT + 8, color, 0)
|
.rectangle(0, 0, CARD_WIDTH + 8, CARD_HEIGHT + 8, color, 0)
|
||||||
.setStrokeStyle(lineWidth, color)
|
.setStrokeStyle(lineWidth, color)
|
||||||
.setAlpha(0)
|
.setAlpha(0)
|
||||||
.setDepth(-1);
|
.setDepth(VISUAL.cardBackgroundDepth);
|
||||||
this.highlightRect = rect;
|
this.highlightRect = rect;
|
||||||
this.addAt(this.highlightRect, 0);
|
this.addAt(this.highlightRect, 0);
|
||||||
|
|
||||||
@@ -161,7 +163,7 @@ export class CardContainer extends Phaser.GameObjects.Container {
|
|||||||
// 创建一个 effect 来持续监听高亮状态变化
|
// 创建一个 effect 来持续监听高亮状态变化
|
||||||
const dispose = effect(() => {
|
const dispose = effect(() => {
|
||||||
if (scene.uiState.value.selectedCard === this._cardId) {
|
if (scene.uiState.value.selectedCard === this._cardId) {
|
||||||
this.highlight(0xfbbf24, 3);
|
this.highlight(COLORS.highlight, VISUAL.selectionRingStrokeWidth);
|
||||||
} else {
|
} else {
|
||||||
this.unhighlight();
|
this.unhighlight();
|
||||||
}
|
}
|
||||||
@@ -276,7 +278,7 @@ export class CardSpawner implements Spawner<CardSpawnData, CardContainer> {
|
|||||||
// 设置悬停效果
|
// 设置悬停效果
|
||||||
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(VISUAL.cardDisabledAlpha);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { Spawner } from "boardgame-phaser";
|
|||||||
import {
|
import {
|
||||||
boardToScreen,
|
boardToScreen,
|
||||||
CELL_SIZE,
|
CELL_SIZE,
|
||||||
|
VISUAL,
|
||||||
createHighlightSpawnTween,
|
createHighlightSpawnTween,
|
||||||
createHighlightDespawnTween,
|
createHighlightDespawnTween,
|
||||||
createHighlightClickFeedbackTween,
|
createHighlightClickFeedbackTween,
|
||||||
@@ -83,7 +84,7 @@ export class HighlightSpawner implements Spawner<
|
|||||||
this.renderer.render(container, { x: data.x, y: data.y });
|
this.renderer.render(container, { x: data.x, y: data.y });
|
||||||
|
|
||||||
// 设置交互区域
|
// 设置交互区域
|
||||||
const hitArea = new Geom.Circle(0, 0, CELL_SIZE / 3);
|
const hitArea = new Geom.Circle(0, 0, VISUAL.highlightHitAreaRadius);
|
||||||
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";
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"@preact/preset-vite": "^2.8.1",
|
"@preact/preset-vite": "^2.8.1",
|
||||||
"@preact/signals": "^2.9.0",
|
"@preact/signals": "^2.9.0",
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
|
"@types/node": "^25.6.0",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"typescript": "^5.3.3",
|
"typescript": "^5.3.3",
|
||||||
"vite": "^5.1.0"
|
"vite": "^5.1.0"
|
||||||
|
|||||||
+58
-12
@@ -3,6 +3,8 @@
|
|||||||
* All magic numbers should be defined here and imported where needed.
|
* All magic numbers should be defined here and imported where needed.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { PhaserGameProps } from "boardgame-phaser";
|
||||||
|
|
||||||
// ── Map Layout ──────────────────────────────────────────────────────────────
|
// ── Map Layout ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const MAP_CONFIG = {
|
export const MAP_CONFIG = {
|
||||||
@@ -31,6 +33,29 @@ export const GRID_CONFIG = {
|
|||||||
WIDGET_CELL_SIZE: 80,
|
WIDGET_CELL_SIZE: 80,
|
||||||
/** Gap between grid cells (pixels) */
|
/** Gap between grid cells (pixels) */
|
||||||
GRID_GAP: 2,
|
GRID_GAP: 2,
|
||||||
|
/** Empty cell background color */
|
||||||
|
CELL_EMPTY_COLOR: 0x222233,
|
||||||
|
/** Occupied cell background color */
|
||||||
|
CELL_OCCUPIED_COLOR: 0x334455,
|
||||||
|
/** Grid line color */
|
||||||
|
GRID_LINE_COLOR: 0x555577,
|
||||||
|
/** Title text style */
|
||||||
|
TITLE_STYLE: {
|
||||||
|
fontSize: "24px",
|
||||||
|
color: "#888",
|
||||||
|
fontStyle: "bold",
|
||||||
|
} as const,
|
||||||
|
/** Subtitle/hint text style */
|
||||||
|
SUBTITLE_STYLE: {
|
||||||
|
fontSize: "14px",
|
||||||
|
color: "#aaaaaa",
|
||||||
|
} as const,
|
||||||
|
/** Item name text style */
|
||||||
|
ITEM_NAME_STYLE: {
|
||||||
|
fontSize: "11px",
|
||||||
|
color: "#888",
|
||||||
|
fontStyle: "bold",
|
||||||
|
} as const,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// ── Shape Viewer ────────────────────────────────────────────────────────────
|
// ── Shape Viewer ────────────────────────────────────────────────────────────
|
||||||
@@ -83,9 +108,9 @@ export const UI_CONFIG = {
|
|||||||
/** Button border color */
|
/** Button border color */
|
||||||
BUTTON_BORDER: 0x7777aa,
|
BUTTON_BORDER: 0x7777aa,
|
||||||
/** Button text color */
|
/** Button text color */
|
||||||
BUTTON_TEXT_COLOR: '#ffffff',
|
BUTTON_TEXT_COLOR: "#ffffff",
|
||||||
/** Button font size */
|
/** Button font size */
|
||||||
BUTTON_FONT_SIZE: '16px',
|
BUTTON_FONT_SIZE: "16px",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// ── Colors ──────────────────────────────────────────────────────────────────
|
// ── Colors ──────────────────────────────────────────────────────────────────
|
||||||
@@ -102,14 +127,14 @@ export const NODE_COLORS = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const NODE_LABELS = {
|
export const NODE_LABELS = {
|
||||||
start: '起点',
|
start: "起点",
|
||||||
end: '终点',
|
end: "终点",
|
||||||
minion: '战斗',
|
minion: "战斗",
|
||||||
elite: '精英',
|
elite: "精英",
|
||||||
event: '事件',
|
event: "事件",
|
||||||
camp: '营地',
|
camp: "营地",
|
||||||
shop: '商店',
|
shop: "商店",
|
||||||
curio: '奇遇',
|
curio: "奇遇",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const ITEM_COLORS = [
|
export const ITEM_COLORS = [
|
||||||
@@ -118,5 +143,26 @@ export const ITEM_COLORS = [
|
|||||||
|
|
||||||
// ── Positive/Negative Effects (for buff icons) ──────────────────────────────
|
// ── Positive/Negative Effects (for buff icons) ──────────────────────────────
|
||||||
|
|
||||||
export const POSITIVE_EFFECTS = new Set(['block', 'strength', 'dexterity', 'regen']);
|
export const POSITIVE_EFFECTS = new Set([
|
||||||
export const NEGATIVE_EFFECTS = new Set(['weak', 'vulnerable', 'frail', 'poison']);
|
"block",
|
||||||
|
"strength",
|
||||||
|
"dexterity",
|
||||||
|
"regen",
|
||||||
|
]);
|
||||||
|
export const NEGATIVE_EFFECTS = new Set([
|
||||||
|
"weak",
|
||||||
|
"vulnerable",
|
||||||
|
"frail",
|
||||||
|
"poison",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const CARD_CONFIG = {
|
||||||
|
WIDTH: 140,
|
||||||
|
HEIGHT: 200,
|
||||||
|
CORNER_RADIUS: 8,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const GAME_CONFIG: Phaser.Types.Core.GameConfig = {
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
};
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import Phaser from "phaser";
|
||||||
|
|
||||||
|
export type BuffData = {
|
||||||
|
id: string;
|
||||||
|
stacks: number;
|
||||||
|
emoji: string;
|
||||||
|
isPositive: boolean;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ICON_SIZE = 28;
|
||||||
|
const ICON_COLOR_POSITIVE = 0x44aa44;
|
||||||
|
const ICON_COLOR_NEGATIVE = 0xaa4444;
|
||||||
|
const TOOLTIP_WIDTH = 200;
|
||||||
|
const TOOLTIP_PADDING = 8;
|
||||||
|
|
||||||
|
export class Buff extends Phaser.GameObjects.Container {
|
||||||
|
private bg!: Phaser.GameObjects.Rectangle;
|
||||||
|
private label!: Phaser.GameObjects.Text;
|
||||||
|
private tooltipContainer!: Phaser.GameObjects.Container;
|
||||||
|
private tooltipBg!: Phaser.GameObjects.Rectangle;
|
||||||
|
private tooltipText!: Phaser.GameObjects.Text;
|
||||||
|
|
||||||
|
private currentData!: BuffData;
|
||||||
|
|
||||||
|
constructor(scene: Phaser.Scene, x: number, y: number, data: BuffData) {
|
||||||
|
super(scene, x, y);
|
||||||
|
scene.add.existing(this);
|
||||||
|
|
||||||
|
this.currentData = data;
|
||||||
|
this.createVisuals();
|
||||||
|
this.createTooltip();
|
||||||
|
this.setupInteraction();
|
||||||
|
this.updateFromData(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
private createVisuals(): void {
|
||||||
|
const color = this.currentData.isPositive
|
||||||
|
? ICON_COLOR_POSITIVE
|
||||||
|
: ICON_COLOR_NEGATIVE;
|
||||||
|
|
||||||
|
this.bg = this.scene.add
|
||||||
|
.rectangle(0, 0, ICON_SIZE, ICON_SIZE, color)
|
||||||
|
.setStrokeStyle(1, 0xffffff);
|
||||||
|
|
||||||
|
this.label = this.scene.add
|
||||||
|
.text(0, 0, "", {
|
||||||
|
fontSize: "12px",
|
||||||
|
color: "#ffffff",
|
||||||
|
fontStyle: "bold",
|
||||||
|
})
|
||||||
|
.setOrigin(0.5);
|
||||||
|
|
||||||
|
this.add([this.bg, this.label]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private createTooltip(): void {
|
||||||
|
this.tooltipContainer = this.scene.add.container(0, 0).setVisible(false);
|
||||||
|
|
||||||
|
this.tooltipBg = this.scene.add
|
||||||
|
.rectangle(0, 0, 0, 0, 0x000000, 0.9)
|
||||||
|
.setStrokeStyle(1, 0xffffff);
|
||||||
|
|
||||||
|
this.tooltipText = this.scene.add
|
||||||
|
.text(0, 0, "", {
|
||||||
|
fontSize: "12px",
|
||||||
|
color: "#ffffff",
|
||||||
|
align: "center",
|
||||||
|
wordWrap: { width: TOOLTIP_WIDTH - TOOLTIP_PADDING * 2 },
|
||||||
|
})
|
||||||
|
.setOrigin(0.5);
|
||||||
|
|
||||||
|
this.tooltipContainer.add([this.tooltipBg, this.tooltipText]);
|
||||||
|
this.tooltipContainer.setDepth(1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupInteraction(): void {
|
||||||
|
this.setInteractive(
|
||||||
|
new Phaser.Geom.Rectangle(
|
||||||
|
-ICON_SIZE / 2,
|
||||||
|
-ICON_SIZE / 2,
|
||||||
|
ICON_SIZE,
|
||||||
|
ICON_SIZE,
|
||||||
|
),
|
||||||
|
Phaser.Geom.Rectangle.Contains,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.on("pointerover", this.onPointerOver, this);
|
||||||
|
this.on("pointerout", this.onPointerOut, this);
|
||||||
|
}
|
||||||
|
|
||||||
|
private onPointerOver(): void {
|
||||||
|
const desc = this.currentData.description ?? "";
|
||||||
|
const content = desc;
|
||||||
|
|
||||||
|
this.tooltipText.setText(content);
|
||||||
|
|
||||||
|
const bounds = this.tooltipText.getBounds();
|
||||||
|
const width = Math.min(bounds.width + TOOLTIP_PADDING * 2, TOOLTIP_WIDTH);
|
||||||
|
const height = bounds.height + TOOLTIP_PADDING * 2;
|
||||||
|
|
||||||
|
this.tooltipBg.setDisplaySize(width, height);
|
||||||
|
|
||||||
|
const worldPos = this.getBounds();
|
||||||
|
this.tooltipContainer.setPosition(
|
||||||
|
worldPos.x + worldPos.width / 2,
|
||||||
|
worldPos.y - height / 2 - 4,
|
||||||
|
);
|
||||||
|
this.tooltipContainer.setDepth(1000);
|
||||||
|
this.scene.children.bringToTop(this.tooltipContainer);
|
||||||
|
this.tooltipContainer.setVisible(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private onPointerOut(): void {
|
||||||
|
this.tooltipContainer.setVisible(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFromData(data: BuffData): void {
|
||||||
|
this.currentData = data;
|
||||||
|
this.label.setText(`${data.emoji}${data.stacks}`);
|
||||||
|
|
||||||
|
const color = data.isPositive ? ICON_COLOR_POSITIVE : ICON_COLOR_NEGATIVE;
|
||||||
|
this.bg.setFillStyle(color);
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(fromScene?: boolean): void {
|
||||||
|
this.off("pointerover", this.onPointerOver, this);
|
||||||
|
this.off("pointerout", this.onPointerOut, this);
|
||||||
|
this.tooltipContainer.destroy(fromScene);
|
||||||
|
super.destroy(fromScene);
|
||||||
|
}
|
||||||
|
|
||||||
|
playSpawnTween() {
|
||||||
|
this.setScale(0);
|
||||||
|
this.scene.tweens.add({
|
||||||
|
targets: this,
|
||||||
|
scale: 1,
|
||||||
|
duration: 200,
|
||||||
|
ease: "Back.Out",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
playDespawnTween() {
|
||||||
|
this.scene.tweens.add({
|
||||||
|
targets: this,
|
||||||
|
scale: 0,
|
||||||
|
duration: 200,
|
||||||
|
ease: "Back.In",
|
||||||
|
onComplete: () => this.destroy(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { GameHostScene } from "boardgame-phaser";
|
||||||
|
import { BuffData, Buff } from "./Buff";
|
||||||
|
import { SpawnerCallback } from "boardgame-phaser";
|
||||||
|
import { CombatState } from "boardgame-core/samples/slay-the-spire-like";
|
||||||
|
|
||||||
|
export type BuffDataWithIndex = BuffData & { x: number };
|
||||||
|
export class BuffSpawner implements SpawnerCallback<BuffDataWithIndex, Buff> {
|
||||||
|
constructor(
|
||||||
|
public scene: GameHostScene<CombatState>,
|
||||||
|
public buffContainer: Phaser.GameObjects.Container,
|
||||||
|
) {}
|
||||||
|
getKey(t: BuffDataWithIndex) {
|
||||||
|
return t.id;
|
||||||
|
}
|
||||||
|
onSpawn(t: BuffDataWithIndex) {
|
||||||
|
const buff = new Buff(this.scene, t.x, 0, t);
|
||||||
|
this.buffContainer.add(buff);
|
||||||
|
buff.playSpawnTween();
|
||||||
|
this.scene.addInterruption(new Promise((r) => setTimeout(r, 50)));
|
||||||
|
return buff;
|
||||||
|
}
|
||||||
|
onDespawn(obj: Buff) {
|
||||||
|
this.scene.addInterruption(new Promise((r) => setTimeout(r, 50)));
|
||||||
|
obj.playDespawnTween();
|
||||||
|
}
|
||||||
|
onUpdate(t: BuffDataWithIndex, obj: Buff) {
|
||||||
|
obj.setPosition(t.x, 0);
|
||||||
|
obj.updateFromData(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import Phaser, { Tweens } from "phaser";
|
||||||
|
import type { GameCard } from "boardgame-core/samples/slay-the-spire-like";
|
||||||
|
import { CARD_CONFIG } from "@/config";
|
||||||
|
import { hoverEffect } from "boardgame-phaser";
|
||||||
|
|
||||||
|
export interface CardContainerOptions {
|
||||||
|
card: GameCard;
|
||||||
|
onClick?: (cardId: string) => void;
|
||||||
|
playable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CARD_WIDTH = CARD_CONFIG.WIDTH;
|
||||||
|
const CARD_HEIGHT = CARD_CONFIG.HEIGHT;
|
||||||
|
const CORNER_RADIUS = CARD_CONFIG.CORNER_RADIUS;
|
||||||
|
|
||||||
|
export class CardContainer extends Phaser.GameObjects.Container {
|
||||||
|
private bg!: Phaser.GameObjects.Rectangle;
|
||||||
|
private nameText!: Phaser.GameObjects.Text;
|
||||||
|
private costBg!: Phaser.GameObjects.Container;
|
||||||
|
private costText!: Phaser.GameObjects.Text;
|
||||||
|
private descText!: Phaser.GameObjects.Text;
|
||||||
|
private highlight!: Phaser.GameObjects.Rectangle | null;
|
||||||
|
private cardId: string;
|
||||||
|
private _selected = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
scene: Phaser.Scene,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
options: CardContainerOptions,
|
||||||
|
) {
|
||||||
|
super(scene, x, y);
|
||||||
|
scene.add.existing(this);
|
||||||
|
|
||||||
|
this.cardId = options.card.id;
|
||||||
|
this.createVisuals(options);
|
||||||
|
this.setupInteractive(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private createVisuals(options: CardContainerOptions): void {
|
||||||
|
const cardData = options.card.cardData;
|
||||||
|
const isPlayable = options.playable ?? true;
|
||||||
|
|
||||||
|
// Background
|
||||||
|
const bgColor = isPlayable ? 0x2a2a3a : 0x1a1a24;
|
||||||
|
this.bg = this.scene.add
|
||||||
|
.rectangle(0, 0, CARD_WIDTH, CARD_HEIGHT, bgColor)
|
||||||
|
.setStrokeStyle(2, isPlayable ? 0x555577 : 0x333344);
|
||||||
|
this.add(this.bg);
|
||||||
|
|
||||||
|
// Name
|
||||||
|
this.nameText = this.scene.add
|
||||||
|
.text(0, -CARD_HEIGHT / 2 + 24, cardData.name, {
|
||||||
|
fontSize: "13px",
|
||||||
|
color: isPlayable ? "#ffffff" : "#666666",
|
||||||
|
fontStyle: "bold",
|
||||||
|
})
|
||||||
|
.setOrigin(0.5);
|
||||||
|
this.add(this.nameText);
|
||||||
|
|
||||||
|
// Cost badge (top-right)
|
||||||
|
this.costBg = this.scene.add.container(
|
||||||
|
CARD_WIDTH / 2 - 18,
|
||||||
|
-CARD_HEIGHT / 2 + 18,
|
||||||
|
);
|
||||||
|
const costCircle = this.scene.add.circle(0, 0, 14, 0x3b82f6);
|
||||||
|
this.costText = this.scene.add
|
||||||
|
.text(0, 0, `${cardData.costCount}`, {
|
||||||
|
fontSize: "14px",
|
||||||
|
color: "#ffffff",
|
||||||
|
fontStyle: "bold",
|
||||||
|
})
|
||||||
|
.setOrigin(0.5);
|
||||||
|
this.costBg.add([costCircle, this.costText]);
|
||||||
|
this.add(this.costBg);
|
||||||
|
|
||||||
|
// Description
|
||||||
|
this.descText = this.scene.add
|
||||||
|
.text(0, 16, cardData.desc, {
|
||||||
|
fontSize: "10px",
|
||||||
|
color: isPlayable ? "#aaaaaa" : "#444444",
|
||||||
|
align: "center",
|
||||||
|
wordWrap: { width: CARD_WIDTH - 16 },
|
||||||
|
})
|
||||||
|
.setOrigin(0.5);
|
||||||
|
this.add(this.descText);
|
||||||
|
|
||||||
|
// Target indicator
|
||||||
|
const targetLabel = (() => {
|
||||||
|
switch (cardData.targetType) {
|
||||||
|
case "enemy":
|
||||||
|
return "⚔️ Enemy";
|
||||||
|
case "enemies":
|
||||||
|
return "⚔️ Enemies";
|
||||||
|
case "player":
|
||||||
|
return "🛡️ Player";
|
||||||
|
default:
|
||||||
|
return "❓ Unknown";
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
const targetText = this.scene.add
|
||||||
|
.text(0, CARD_HEIGHT / 2 - 20, targetLabel, {
|
||||||
|
fontSize: "10px",
|
||||||
|
color: isPlayable ? "#8888cc" : "#444466",
|
||||||
|
})
|
||||||
|
.setOrigin(0.5);
|
||||||
|
this.add(targetText);
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupInteractive(options: CardContainerOptions): void {
|
||||||
|
const hitArea = new Phaser.Geom.Rectangle(
|
||||||
|
-CARD_WIDTH / 2,
|
||||||
|
-CARD_HEIGHT / 2,
|
||||||
|
CARD_WIDTH,
|
||||||
|
CARD_HEIGHT,
|
||||||
|
);
|
||||||
|
this.setInteractive(hitArea, Phaser.Geom.Rectangle.Contains);
|
||||||
|
if (this.input) {
|
||||||
|
this.input.cursor = options.playable ? "pointer" : "not-allowed";
|
||||||
|
}
|
||||||
|
|
||||||
|
hoverEffect(this, (hovering) => {
|
||||||
|
if (!options.playable) return;
|
||||||
|
this.playHoverTween(hovering);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.on("pointerdown", () => {
|
||||||
|
if (options.playable && options.onClick) {
|
||||||
|
options.onClick(this.cardId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelected(selected: boolean): void {
|
||||||
|
this._selected = selected;
|
||||||
|
if (selected) {
|
||||||
|
if (!this.highlight) {
|
||||||
|
this.highlight = this.scene.add
|
||||||
|
.rectangle(0, 0, CARD_WIDTH + 10, CARD_HEIGHT + 10, 0x000000, 0)
|
||||||
|
.setStrokeStyle(4, 0xfbbf24)
|
||||||
|
.setDepth(-1);
|
||||||
|
this.addAt(this.highlight, 0);
|
||||||
|
}
|
||||||
|
this.highlight.setAlpha(1);
|
||||||
|
this.scene.tweens.add({
|
||||||
|
targets: this,
|
||||||
|
scale: 1.05,
|
||||||
|
duration: 100,
|
||||||
|
ease: "Power2",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (this.highlight) {
|
||||||
|
this.highlight.setAlpha(0);
|
||||||
|
}
|
||||||
|
this.scene.tweens.add({
|
||||||
|
targets: this,
|
||||||
|
scale: 1,
|
||||||
|
duration: 100,
|
||||||
|
ease: "Power2",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get selected(): boolean {
|
||||||
|
return this._selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
playSpawnTween(delay = 0) {
|
||||||
|
this.setAlpha(0);
|
||||||
|
this.setScale(0.5);
|
||||||
|
return this.scene.tweens.add({
|
||||||
|
targets: this,
|
||||||
|
alpha: 1,
|
||||||
|
scale: 1,
|
||||||
|
duration: 300,
|
||||||
|
delay,
|
||||||
|
ease: "Back.easeOut",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
playDespawnTween(onComplete?: () => void) {
|
||||||
|
return this.scene.tweens.add({
|
||||||
|
targets: this,
|
||||||
|
alpha: 0,
|
||||||
|
scale: 0.5,
|
||||||
|
y: this.y - 50,
|
||||||
|
duration: 200,
|
||||||
|
ease: "Back.easeIn",
|
||||||
|
onComplete: () => {
|
||||||
|
if (onComplete) onComplete();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private playHoverTween(isHovering: boolean) {
|
||||||
|
this.scene.tweens.add({
|
||||||
|
targets: this,
|
||||||
|
y: isHovering ? this.y - 10 : this.y + 10,
|
||||||
|
scale: isHovering ? 1.08 : 1,
|
||||||
|
duration: 150,
|
||||||
|
ease: "Power2",
|
||||||
|
});
|
||||||
|
this.bg.setStrokeStyle(
|
||||||
|
isHovering ? 3 : 2,
|
||||||
|
isHovering ? 0x88aaff : 0x555577,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(fromScene?: boolean): void {
|
||||||
|
this.highlight = null;
|
||||||
|
super.destroy(fromScene);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { spawnEffect, type Spawner } from "boardgame-phaser";
|
||||||
|
import type {
|
||||||
|
CombatState,
|
||||||
|
GameCard,
|
||||||
|
} from "boardgame-core/samples/slay-the-spire-like";
|
||||||
|
import { CardContainer } from "./CardContainer";
|
||||||
|
import { GameHostScene } from "boardgame-phaser";
|
||||||
|
|
||||||
|
export interface CardSpawnData {
|
||||||
|
cardId: string;
|
||||||
|
index: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HAND_Y = 140;
|
||||||
|
const CARD_SPACING = 160;
|
||||||
|
const HAND_MARGIN = 100;
|
||||||
|
|
||||||
|
export class CardSpawner implements Spawner<CardSpawnData, CardContainer> {
|
||||||
|
constructor(
|
||||||
|
private scene: GameHostScene<CombatState>,
|
||||||
|
private getState: () => CombatState,
|
||||||
|
private onCardClick: (cardId: string) => void,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
*getData(): Iterable<CardSpawnData> {
|
||||||
|
const state = this.getState();
|
||||||
|
const handIds = state.player.deck.regions.hand.childIds;
|
||||||
|
const total = handIds.length;
|
||||||
|
for (let i = 0; i < total; i++) {
|
||||||
|
yield { cardId: handIds[i], index: i, total };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getKey(data: CardSpawnData): string {
|
||||||
|
return data.cardId;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSpawn(data: CardSpawnData): CardContainer {
|
||||||
|
const state = this.getState();
|
||||||
|
const card = state.player.deck.cards[data.cardId];
|
||||||
|
const { x, y } = this.getCardPosition(data.index, data.total);
|
||||||
|
|
||||||
|
const container = new CardContainer(this.scene, x, y, {
|
||||||
|
card: card ?? this.createFallbackCard(data.cardId),
|
||||||
|
onClick: this.onCardClick,
|
||||||
|
playable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
container.playSpawnTween(data.index * 40);
|
||||||
|
this.scene.addInterruption(new Promise((r) => setTimeout(r, 40)));
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
onUpdate(data: CardSpawnData, obj: CardContainer): void {
|
||||||
|
const { x, y } = this.getCardPosition(data.index, data.total);
|
||||||
|
if (obj.x !== x || obj.y !== y) {
|
||||||
|
this.scene.tweens.add({
|
||||||
|
targets: obj,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
duration: 250,
|
||||||
|
ease: "Power2",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onDespawn(obj: CardContainer, data: CardSpawnData): void {
|
||||||
|
obj.playDespawnTween(() => obj.destroy());
|
||||||
|
this.scene.addInterruption(new Promise((r) => setTimeout(r, 40)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCardPosition(
|
||||||
|
index: number,
|
||||||
|
total: number,
|
||||||
|
): { x: number; y: number } {
|
||||||
|
const { width, height } = this.scene.scale;
|
||||||
|
const spacing = Math.min(
|
||||||
|
CARD_SPACING,
|
||||||
|
(width - HAND_MARGIN * 2) / Math.max(1, total),
|
||||||
|
);
|
||||||
|
const startX = width / 2 - ((total - 1) * spacing) / 2;
|
||||||
|
return {
|
||||||
|
x: startX + index * spacing,
|
||||||
|
y: height - HAND_Y,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private createFallbackCard(id: string): GameCard {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
regionId: "",
|
||||||
|
position: [],
|
||||||
|
cardData: {
|
||||||
|
id,
|
||||||
|
name: "Unknown",
|
||||||
|
desc: "Card data missing",
|
||||||
|
type: "item",
|
||||||
|
costType: "none",
|
||||||
|
costCount: 0,
|
||||||
|
targetType: "player",
|
||||||
|
effects: [],
|
||||||
|
},
|
||||||
|
itemId: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCardSpawner(
|
||||||
|
scene: GameHostScene<CombatState>,
|
||||||
|
getState: () => CombatState,
|
||||||
|
onCardClick: (cardId: string) => void,
|
||||||
|
) {
|
||||||
|
return spawnEffect(new CardSpawner(scene, getState, onCardClick));
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
import Phaser from "phaser";
|
||||||
|
import type {
|
||||||
|
CombatEntity,
|
||||||
|
CombatState,
|
||||||
|
EffectTable,
|
||||||
|
EnemyEntity,
|
||||||
|
} from "boardgame-core/samples/slay-the-spire-like";
|
||||||
|
import { BuffData } from "./Buff";
|
||||||
|
import { createSpawnUpdate } from "boardgame-phaser";
|
||||||
|
import { BuffDataWithIndex, BuffSpawner } from "./BuffSpawner";
|
||||||
|
import { GameHostScene } from "boardgame-phaser";
|
||||||
|
|
||||||
|
export type CombatUnitData = {
|
||||||
|
key: string;
|
||||||
|
entity: CombatEntity;
|
||||||
|
name: string;
|
||||||
|
isPlayer: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CONTAINER_WIDTH = 200;
|
||||||
|
const CONTAINER_HEIGHT = 260;
|
||||||
|
const HP_BAR_WIDTH = 160;
|
||||||
|
const HP_BAR_HEIGHT = 16;
|
||||||
|
const BUFF_ICON_SIZE = 28;
|
||||||
|
const BUFF_ICON_GAP = 6;
|
||||||
|
|
||||||
|
export class CombatUnitContainer extends Phaser.GameObjects.Container {
|
||||||
|
private bg!: Phaser.GameObjects.Rectangle;
|
||||||
|
private nameText!: Phaser.GameObjects.Text;
|
||||||
|
private hpBarBg!: Phaser.GameObjects.Rectangle;
|
||||||
|
private hpBarFill!: Phaser.GameObjects.Rectangle;
|
||||||
|
private hpText!: Phaser.GameObjects.Text;
|
||||||
|
private buffContainer!: Phaser.GameObjects.Container;
|
||||||
|
private intentText!: Phaser.GameObjects.Text | null;
|
||||||
|
private hostScene: GameHostScene<CombatState>;
|
||||||
|
private updateBuffs!: (buffs: Iterable<BuffData & { x: number }>) => void;
|
||||||
|
|
||||||
|
private currentEntity!: CombatEntity;
|
||||||
|
private currentName: string;
|
||||||
|
private currentIsPlayer: boolean;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
scene: GameHostScene<CombatState>,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
data: CombatUnitData,
|
||||||
|
) {
|
||||||
|
super(scene, x, y);
|
||||||
|
scene.add.existing(this);
|
||||||
|
this.hostScene = scene;
|
||||||
|
|
||||||
|
this.currentEntity = data.entity;
|
||||||
|
this.currentName = data.name;
|
||||||
|
this.currentIsPlayer = data.isPlayer;
|
||||||
|
|
||||||
|
this.createVisuals();
|
||||||
|
this.updateFromData(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
private createVisuals(): void {
|
||||||
|
const bgColor = this.currentIsPlayer ? 0x224466 : 0x662222;
|
||||||
|
const borderColor = this.currentIsPlayer ? 0x44aaff : 0xff4444;
|
||||||
|
|
||||||
|
this.bg = this.scene.add
|
||||||
|
.rectangle(0, 0, CONTAINER_WIDTH, CONTAINER_HEIGHT, bgColor)
|
||||||
|
.setStrokeStyle(3, borderColor);
|
||||||
|
|
||||||
|
this.nameText = this.scene.add
|
||||||
|
.text(0, -CONTAINER_HEIGHT / 2 + 24, this.currentName, {
|
||||||
|
fontSize: "18px",
|
||||||
|
color: "#ffffff",
|
||||||
|
fontStyle: "bold",
|
||||||
|
})
|
||||||
|
.setOrigin(0.5);
|
||||||
|
|
||||||
|
this.hpBarBg = this.scene.add.rectangle(
|
||||||
|
0,
|
||||||
|
-CONTAINER_HEIGHT / 2 + 60,
|
||||||
|
HP_BAR_WIDTH,
|
||||||
|
HP_BAR_HEIGHT,
|
||||||
|
0x333333,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.hpBarFill = this.scene.add
|
||||||
|
.rectangle(
|
||||||
|
-HP_BAR_WIDTH / 2,
|
||||||
|
-CONTAINER_HEIGHT / 2 + 60,
|
||||||
|
HP_BAR_WIDTH,
|
||||||
|
HP_BAR_HEIGHT,
|
||||||
|
0x22c55e,
|
||||||
|
)
|
||||||
|
.setOrigin(0, 0.5);
|
||||||
|
|
||||||
|
this.hpText = this.scene.add
|
||||||
|
.text(0, -CONTAINER_HEIGHT / 2 + 60, "", {
|
||||||
|
fontSize: "12px",
|
||||||
|
color: "#ffffff",
|
||||||
|
fontStyle: "bold",
|
||||||
|
})
|
||||||
|
.setOrigin(0.5);
|
||||||
|
|
||||||
|
this.buffContainer = this.scene.add.container(0, CONTAINER_HEIGHT / 2 - 40);
|
||||||
|
this.updateBuffs = createSpawnUpdate(
|
||||||
|
new BuffSpawner(this.hostScene, this.buffContainer),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!this.currentIsPlayer) {
|
||||||
|
this.intentText = this.scene.add
|
||||||
|
.text(0, -CONTAINER_HEIGHT / 2 + 86, "", {
|
||||||
|
fontSize: "12px",
|
||||||
|
color: "#ffcc88",
|
||||||
|
})
|
||||||
|
.setOrigin(0.5);
|
||||||
|
} else {
|
||||||
|
this.intentText = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.add([
|
||||||
|
this.bg,
|
||||||
|
this.nameText,
|
||||||
|
this.hpBarBg,
|
||||||
|
this.hpBarFill,
|
||||||
|
this.hpText,
|
||||||
|
this.buffContainer,
|
||||||
|
...(this.intentText ? [this.intentText] : []),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFromData(data: CombatUnitData): void {
|
||||||
|
this.currentEntity = data.entity;
|
||||||
|
this.currentName = data.name;
|
||||||
|
|
||||||
|
this.nameText.setText(this.currentName);
|
||||||
|
|
||||||
|
const hpPercent =
|
||||||
|
this.currentEntity.maxHp > 0
|
||||||
|
? this.currentEntity.hp / this.currentEntity.maxHp
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const fillWidth = Math.max(0, HP_BAR_WIDTH * hpPercent);
|
||||||
|
this.hpBarFill.setDisplaySize(fillWidth, HP_BAR_HEIGHT);
|
||||||
|
|
||||||
|
const hpColor =
|
||||||
|
hpPercent > 0.5 ? 0x22c55e : hpPercent > 0.25 ? 0xf59e0b : 0xef4444;
|
||||||
|
this.hpBarFill.setFillStyle(hpColor);
|
||||||
|
|
||||||
|
this.hpText.setText(
|
||||||
|
`${this.currentEntity.hp} / ${this.currentEntity.maxHp} HP`,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.updateBuffs(this.getBuffEntries());
|
||||||
|
|
||||||
|
if (!this.currentIsPlayer && this.intentText) {
|
||||||
|
const enemyEntity = data.entity as EnemyEntity;
|
||||||
|
const intent = enemyEntity.currentIntent;
|
||||||
|
const intentName = intent?.id ?? "unknown";
|
||||||
|
this.intentText.setText(`Intent: ${intentName}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private *getBuffEntries() {
|
||||||
|
const entries = Object.values(this.currentEntity.effects);
|
||||||
|
const totalWidth =
|
||||||
|
entries.length * BUFF_ICON_SIZE + (entries.length - 1) * BUFF_ICON_GAP;
|
||||||
|
const startX = -totalWidth / 2 + BUFF_ICON_SIZE / 2;
|
||||||
|
for (let i = 0; i < entries.length; i++) {
|
||||||
|
const buff = entries[i];
|
||||||
|
yield {
|
||||||
|
...buff.data,
|
||||||
|
stacks: buff.stacks,
|
||||||
|
isPositive: true,
|
||||||
|
x: startX + i * BUFF_ICON_SIZE,
|
||||||
|
} as BuffDataWithIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
playSpawnEffect(): void {
|
||||||
|
this.setScale(0);
|
||||||
|
this.setAlpha(0);
|
||||||
|
|
||||||
|
this.scene.tweens.add({
|
||||||
|
targets: this,
|
||||||
|
scale: 1,
|
||||||
|
alpha: 1,
|
||||||
|
duration: 400,
|
||||||
|
ease: "Back.easeOut",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
playDamageEffect(): void {
|
||||||
|
const flash = this.scene.add
|
||||||
|
.rectangle(
|
||||||
|
this.x,
|
||||||
|
this.y,
|
||||||
|
CONTAINER_WIDTH,
|
||||||
|
CONTAINER_HEIGHT,
|
||||||
|
0xff0000,
|
||||||
|
0.4,
|
||||||
|
)
|
||||||
|
.setDepth(200);
|
||||||
|
|
||||||
|
this.scene.tweens.add({
|
||||||
|
targets: this,
|
||||||
|
x: this.x + 8,
|
||||||
|
duration: 60,
|
||||||
|
yoyo: true,
|
||||||
|
repeat: 3,
|
||||||
|
onComplete: () => {
|
||||||
|
this.scene.tweens.add({
|
||||||
|
targets: flash,
|
||||||
|
alpha: 0,
|
||||||
|
duration: 200,
|
||||||
|
onComplete: () => flash.destroy(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
playHealEffect(): void {
|
||||||
|
const healText = this.scene.add
|
||||||
|
.text(this.x, this.y - 40, "+HP", {
|
||||||
|
fontSize: "20px",
|
||||||
|
color: "#22c55e",
|
||||||
|
fontStyle: "bold",
|
||||||
|
})
|
||||||
|
.setOrigin(0.5)
|
||||||
|
.setDepth(200);
|
||||||
|
|
||||||
|
this.scene.tweens.add({
|
||||||
|
targets: healText,
|
||||||
|
y: this.y - 80,
|
||||||
|
alpha: 0,
|
||||||
|
duration: 800,
|
||||||
|
ease: "Power2",
|
||||||
|
onComplete: () => healText.destroy(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(fromScene?: boolean): void {
|
||||||
|
this.buffContainer.removeAll(true);
|
||||||
|
super.destroy(fromScene);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import type { Spawner } from "boardgame-phaser";
|
||||||
|
import { spawnEffect } from "boardgame-phaser";
|
||||||
|
import type { CombatState } from "boardgame-core/samples/slay-the-spire-like";
|
||||||
|
import {
|
||||||
|
CombatUnitContainer,
|
||||||
|
type CombatUnitData,
|
||||||
|
} from "./CombatUnitContainer";
|
||||||
|
import { GameHostScene } from "boardgame-phaser";
|
||||||
|
|
||||||
|
export class CombatUnitSpawner implements Spawner<
|
||||||
|
CombatUnitData,
|
||||||
|
CombatUnitContainer
|
||||||
|
> {
|
||||||
|
constructor(private scene: GameHostScene<CombatState>) {}
|
||||||
|
|
||||||
|
*getData(): Iterable<CombatUnitData> {
|
||||||
|
const combat = this.getCombatState();
|
||||||
|
if (!combat) return;
|
||||||
|
|
||||||
|
yield {
|
||||||
|
key: "player",
|
||||||
|
entity: combat.player,
|
||||||
|
name: "Player",
|
||||||
|
isPlayer: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < combat.enemies.length; i++) {
|
||||||
|
const enemy = combat.enemies[i];
|
||||||
|
yield {
|
||||||
|
key: `enemy-${i}`,
|
||||||
|
entity: enemy,
|
||||||
|
name: enemy.enemy.name,
|
||||||
|
isPlayer: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getKey(t: CombatUnitData): string {
|
||||||
|
return t.key;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSpawn(t: CombatUnitData): CombatUnitContainer | null {
|
||||||
|
const { width, height } = this.scene.scale;
|
||||||
|
const combat = this.getCombatState();
|
||||||
|
if (!combat) return null;
|
||||||
|
|
||||||
|
const totalUnits = 1 + combat.enemies.length;
|
||||||
|
const spacing = 220;
|
||||||
|
const totalWidth = (totalUnits - 1) * spacing;
|
||||||
|
const startX = width / 2 - totalWidth / 2;
|
||||||
|
|
||||||
|
let x = startX;
|
||||||
|
if (t.key.startsWith("enemy-")) {
|
||||||
|
const index = parseInt(t.key.replace("enemy-", ""), 10);
|
||||||
|
x = startX + (index + 1) * spacing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const y = height / 2;
|
||||||
|
|
||||||
|
const container = new CombatUnitContainer(this.scene, x, y, t);
|
||||||
|
container.playSpawnEffect();
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
onUpdate(t: CombatUnitData, obj: CombatUnitContainer): void {
|
||||||
|
obj.updateFromData(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
onDespawn(obj: CombatUnitContainer): void {
|
||||||
|
obj.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCombatState(): CombatState | null {
|
||||||
|
const registry = this.scene.registry;
|
||||||
|
return (registry.get("combatState") as CombatState | undefined) ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCombatUnitSpawner(scene: GameHostScene<CombatState>) {
|
||||||
|
return spawnEffect(new CombatUnitSpawner(scene));
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import Phaser from "phaser";
|
||||||
|
import { dragDropEventEffect, DragDropEventType } from "boardgame-phaser";
|
||||||
|
import { GRID_CONFIG } from "@/config";
|
||||||
|
import {
|
||||||
|
IDENTITY_TRANSFORM,
|
||||||
|
ParsedShape,
|
||||||
|
Point2D,
|
||||||
|
Transform2D,
|
||||||
|
transformShape,
|
||||||
|
type GameItem,
|
||||||
|
} from "boardgame-core/samples/slay-the-spire-like";
|
||||||
|
import { DisposableBag } from "../../../framework/dist";
|
||||||
|
import { InventoryItemState } from "@/state/InventoryItemState";
|
||||||
|
import { moveItem } from "@/state/inventory";
|
||||||
|
import {
|
||||||
|
InventorySurface,
|
||||||
|
InventorySurfaceState,
|
||||||
|
inventoryToScene,
|
||||||
|
sceneToInventory,
|
||||||
|
} from "@/state/inventorySurfaceState";
|
||||||
|
|
||||||
|
export class InventoryItemContainer extends Phaser.GameObjects.Container {
|
||||||
|
private itemState: InventoryItemState;
|
||||||
|
private surfaceState: InventorySurfaceState;
|
||||||
|
private surfaces: Iterable<InventorySurface>;
|
||||||
|
private hitArea: Point2D[] = [];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
scene: Phaser.Scene,
|
||||||
|
item: GameItem,
|
||||||
|
surface: InventorySurface,
|
||||||
|
surfaces: Iterable<InventorySurface>,
|
||||||
|
) {
|
||||||
|
super(scene, surface.gridOffsetX, surface.gridOffsetY);
|
||||||
|
scene.add.existing(this);
|
||||||
|
|
||||||
|
const cellSize = surface.cellSize;
|
||||||
|
const graphics = this.scene.add.graphics();
|
||||||
|
graphics.setPosition(cellSize / 2, cellSize / 2);
|
||||||
|
const label = this.scene.add.text(
|
||||||
|
cellSize / 2,
|
||||||
|
cellSize / 2,
|
||||||
|
"",
|
||||||
|
GRID_CONFIG.ITEM_NAME_STYLE,
|
||||||
|
);
|
||||||
|
this.add([graphics, label]);
|
||||||
|
this.setupInteractive();
|
||||||
|
|
||||||
|
this.itemState = new InventoryItemState(item);
|
||||||
|
this.surfaceState = new InventorySurfaceState(surface);
|
||||||
|
this.surfaces = surfaces;
|
||||||
|
|
||||||
|
const disposables = new DisposableBag(this);
|
||||||
|
|
||||||
|
disposables.addEffect(() => {
|
||||||
|
label.setText(this.itemState.name.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
disposables.add(this.setupDnDEffect());
|
||||||
|
|
||||||
|
disposables.addEffect(() => {
|
||||||
|
graphics.clear();
|
||||||
|
this.renderGraphics(
|
||||||
|
graphics,
|
||||||
|
this.surfaceState.cellSize,
|
||||||
|
this.itemState.shape.value,
|
||||||
|
this.itemState.color.value,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
disposables.addEffect(() => {
|
||||||
|
this.scene.tweens.add({
|
||||||
|
targets: graphics,
|
||||||
|
rotation: -(this.itemState.previewRotation.value * Math.PI) / 180,
|
||||||
|
duration: 150,
|
||||||
|
ease: "Power2",
|
||||||
|
});
|
||||||
|
this.hitArea = this.updateHitArea(this.itemState.previewRotation.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
disposables.addEffect(() => {
|
||||||
|
this.snapBack(this.itemState.transform.value);
|
||||||
|
this.itemState.setPreviewRotation(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setSurfaceItem(surface: InventorySurface, item: GameItem): void {
|
||||||
|
this.surfaceState.setSurface(surface);
|
||||||
|
this.itemState.setItem(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateHitArea(value: number): Point2D[] {
|
||||||
|
const shape = this.itemState.shape.value;
|
||||||
|
if (!shape) return [];
|
||||||
|
|
||||||
|
const rotation = value;
|
||||||
|
const cells = transformShape(shape, {
|
||||||
|
rotation,
|
||||||
|
offset: { x: 0, y: 0 },
|
||||||
|
flipX: false,
|
||||||
|
flipY: false,
|
||||||
|
});
|
||||||
|
const cellSize = this.surfaceState.cellSize;
|
||||||
|
|
||||||
|
return cells.map((cell) => ({
|
||||||
|
x: (cell.x - cells[0].x) * cellSize,
|
||||||
|
y: (cell.y - cells[0].y) * cellSize,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderGraphics(
|
||||||
|
graphics: Phaser.GameObjects.Graphics,
|
||||||
|
cellSize: number,
|
||||||
|
shape: ParsedShape,
|
||||||
|
itemColor: number,
|
||||||
|
) {
|
||||||
|
const cells = transformShape(shape, IDENTITY_TRANSFORM);
|
||||||
|
|
||||||
|
for (const cell of cells) {
|
||||||
|
const localX = (cell.x - cells[0].x) * cellSize;
|
||||||
|
const localY = (cell.y - cells[0].y) * cellSize;
|
||||||
|
|
||||||
|
graphics.fillStyle(itemColor);
|
||||||
|
graphics.fillRect(
|
||||||
|
localX + 2 - cellSize / 2,
|
||||||
|
localY + 2 - cellSize / 2,
|
||||||
|
cellSize - 4,
|
||||||
|
cellSize - 4,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupInteractive() {
|
||||||
|
this.setScrollFactor(0);
|
||||||
|
this.setInteractive({
|
||||||
|
hitArea: this,
|
||||||
|
hitAreaCallback: (
|
||||||
|
hitArea: InventoryItemContainer,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
) =>
|
||||||
|
hitArea.hitArea.some(
|
||||||
|
(cell) =>
|
||||||
|
x >= cell.x &&
|
||||||
|
x < cell.x + this.surfaceState.cellSize &&
|
||||||
|
y >= cell.y &&
|
||||||
|
y < cell.y + this.surfaceState.cellSize,
|
||||||
|
),
|
||||||
|
useHandCursor: true,
|
||||||
|
} as Phaser.Types.Input.InputConfiguration);
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupDnDEffect() {
|
||||||
|
let startX = 0;
|
||||||
|
let startY = 0;
|
||||||
|
return dragDropEventEffect(this, (event) => {
|
||||||
|
if (event.type === DragDropEventType.DOWN) {
|
||||||
|
startX = this.x;
|
||||||
|
startY = this.y;
|
||||||
|
this.setAlpha(0.7);
|
||||||
|
} else if (event.type === DragDropEventType.MOVE) {
|
||||||
|
this.x = startX + event.deltaX;
|
||||||
|
this.y = startY + event.deltaY;
|
||||||
|
} else if (event.type === DragDropEventType.ALTBUTTON) {
|
||||||
|
this.itemState.addPreviewRotation(90);
|
||||||
|
} else if (event.type === DragDropEventType.UP) {
|
||||||
|
const finalRotation = this.itemState.previewRotation.peek();
|
||||||
|
const finalX = this.x;
|
||||||
|
const finalY = this.y;
|
||||||
|
if (!this.handleDragEnd(finalX, finalY, finalRotation)) {
|
||||||
|
const t = this.itemState.transform.peek();
|
||||||
|
t && this.snapBack(t);
|
||||||
|
}
|
||||||
|
this.setAlpha(1);
|
||||||
|
this.itemState.setPreviewRotation(0);
|
||||||
|
startX = startY = 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleDragEnd(
|
||||||
|
finalX: number,
|
||||||
|
finalY: number,
|
||||||
|
finalRotation: number,
|
||||||
|
): boolean {
|
||||||
|
const item = this.itemState.item;
|
||||||
|
if (!item) return false;
|
||||||
|
|
||||||
|
const target = this.findDropSurface(finalX, finalY);
|
||||||
|
if (!target) return false;
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.surfaceState.surface === target.surface &&
|
||||||
|
item.transform.offset.x === target.x &&
|
||||||
|
item.transform.offset.y === target.y &&
|
||||||
|
item.transform.rotation === finalRotation
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return moveItem(
|
||||||
|
this.surfaceState.invSignal,
|
||||||
|
target.surface.invSignal,
|
||||||
|
item.id,
|
||||||
|
target.x,
|
||||||
|
target.y,
|
||||||
|
finalRotation,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private findDropSurface(x: number, y: number) {
|
||||||
|
for (const surface of this.surfaces) {
|
||||||
|
const target = sceneToInventory(surface, x, y);
|
||||||
|
if (target)
|
||||||
|
return {
|
||||||
|
surface,
|
||||||
|
...target,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private snapBack(transform: Transform2D): void {
|
||||||
|
const { x, y } = transform.offset;
|
||||||
|
const target = inventoryToScene(this.surfaceState, x, y);
|
||||||
|
|
||||||
|
this.scene.tweens.add({
|
||||||
|
targets: this,
|
||||||
|
...target,
|
||||||
|
duration: 150,
|
||||||
|
ease: "Power2",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import Phaser from "phaser";
|
||||||
|
import type { Spawner } from "boardgame-phaser";
|
||||||
|
import type { GameItem } from "boardgame-core/samples/slay-the-spire-like";
|
||||||
|
import { spawnEffect } from "boardgame-phaser";
|
||||||
|
import { InventoryItemContainer } from "./InventoryItemContainer";
|
||||||
|
import { InventorySurface } from "@/state/inventorySurfaceState";
|
||||||
|
|
||||||
|
export class InventoryItemSpawner implements Spawner<
|
||||||
|
[InventorySurface, GameItem],
|
||||||
|
InventoryItemContainer
|
||||||
|
> {
|
||||||
|
constructor(
|
||||||
|
private scene: Phaser.Scene,
|
||||||
|
private surfaces: Iterable<InventorySurface>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
*getData(): Iterable<[InventorySurface, GameItem]> {
|
||||||
|
for (const surface of this.surfaces) {
|
||||||
|
const inv = surface.invSignal.value;
|
||||||
|
for (const item of inv.items.values()) {
|
||||||
|
yield [surface, item];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getKey(entry: [InventorySurface, GameItem]): string {
|
||||||
|
return entry[1].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSpawn(entry: [InventorySurface, GameItem]): InventoryItemContainer | null {
|
||||||
|
const [surface, item] = entry;
|
||||||
|
|
||||||
|
const container = new InventoryItemContainer(
|
||||||
|
this.scene,
|
||||||
|
item,
|
||||||
|
surface,
|
||||||
|
this.surfaces,
|
||||||
|
);
|
||||||
|
|
||||||
|
container.setSurfaceItem(surface, item);
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
onUpdate(
|
||||||
|
entry: [InventorySurface, GameItem],
|
||||||
|
container: InventoryItemContainer,
|
||||||
|
): void {
|
||||||
|
const [surface, item] = entry;
|
||||||
|
|
||||||
|
container.setSurfaceItem(surface, item);
|
||||||
|
}
|
||||||
|
|
||||||
|
onDespawn(container: InventoryItemContainer): void {
|
||||||
|
// TODO: add tween
|
||||||
|
container.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createInventoryItemSpawner(
|
||||||
|
scene: Phaser.Scene,
|
||||||
|
surfaces: Iterable<InventorySurface>,
|
||||||
|
) {
|
||||||
|
return spawnEffect(new InventoryItemSpawner(scene, surfaces));
|
||||||
|
}
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
import { h } from 'preact';
|
import { GameUI } from "boardgame-phaser";
|
||||||
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: document.getElementById('ui-root')!,
|
container: "ui-root",
|
||||||
root: <App/>,
|
root: <App />,
|
||||||
});
|
});
|
||||||
|
|
||||||
ui.mount();
|
ui.mount();
|
||||||
|
|||||||
@@ -0,0 +1,286 @@
|
|||||||
|
import Phaser from "phaser";
|
||||||
|
import { GameHostScene } from "boardgame-phaser";
|
||||||
|
import { spawnEffect, type Spawner } from "boardgame-phaser";
|
||||||
|
import {
|
||||||
|
CombatResult,
|
||||||
|
type CombatState,
|
||||||
|
prompts,
|
||||||
|
} from "boardgame-core/samples/slay-the-spire-like";
|
||||||
|
import { createButton } from "@/utils/createButton";
|
||||||
|
import { SceneKey } from "./types";
|
||||||
|
import {
|
||||||
|
CombatUnitContainer,
|
||||||
|
type CombatUnitData,
|
||||||
|
} from "@/gameobjects/CombatUnitContainer";
|
||||||
|
import { CardSpawner } from "@/gameobjects/CardSpawner";
|
||||||
|
import { CombatModule } from "@/state/combatState";
|
||||||
|
|
||||||
|
const CARD_SPACING = 160;
|
||||||
|
const HAND_MARGIN = 100;
|
||||||
|
const HAND_Y = 140;
|
||||||
|
|
||||||
|
export class CombatTestScene extends GameHostScene<
|
||||||
|
CombatState,
|
||||||
|
CombatResult | null,
|
||||||
|
CombatModule
|
||||||
|
> {
|
||||||
|
private selectedCardId: string | null = null;
|
||||||
|
private isTargeting = false;
|
||||||
|
private targetingText!: Phaser.GameObjects.Text;
|
||||||
|
private selectionRect!: Phaser.GameObjects.Rectangle;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super("CombatTestScene");
|
||||||
|
}
|
||||||
|
|
||||||
|
create(): void {
|
||||||
|
super.create();
|
||||||
|
|
||||||
|
const { width, height } = this.scale;
|
||||||
|
|
||||||
|
// Title
|
||||||
|
this.add
|
||||||
|
.text(width / 2, 30, "Combat Test — Card Play", {
|
||||||
|
fontSize: "24px",
|
||||||
|
color: "#ffffff",
|
||||||
|
fontStyle: "bold",
|
||||||
|
})
|
||||||
|
.setOrigin(0.5);
|
||||||
|
|
||||||
|
// Info text (reactive)
|
||||||
|
const infoText = this.add
|
||||||
|
.text(width / 2, 60, "", {
|
||||||
|
fontSize: "14px",
|
||||||
|
color: "#aaaaaa",
|
||||||
|
})
|
||||||
|
.setOrigin(0.5);
|
||||||
|
|
||||||
|
this.addEffect(() => {
|
||||||
|
const s = this.state;
|
||||||
|
infoText.setText(
|
||||||
|
`Turn ${s.turnNumber} | Phase: ${s.phase} | Energy: ${s.player.energy}/${s.player.maxEnergy}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Targeting indicator
|
||||||
|
this.targetingText = this.add
|
||||||
|
.text(width / 2, height - 220, "", {
|
||||||
|
fontSize: "18px",
|
||||||
|
color: "#fbbf24",
|
||||||
|
fontStyle: "bold",
|
||||||
|
})
|
||||||
|
.setOrigin(0.5)
|
||||||
|
.setAlpha(0);
|
||||||
|
|
||||||
|
// Selection rectangle overlay for selected card
|
||||||
|
this.selectionRect = this.add
|
||||||
|
.rectangle(0, 0, 150, 210, 0x000000, 0)
|
||||||
|
.setStrokeStyle(4, 0xfbbf24)
|
||||||
|
.setAlpha(0)
|
||||||
|
.setDepth(50);
|
||||||
|
|
||||||
|
// Unit spawner (player + enemies)
|
||||||
|
this.disposables.add(spawnEffect(new UnitSpawner(this)));
|
||||||
|
|
||||||
|
// Card spawner (hand)
|
||||||
|
this.disposables.add(
|
||||||
|
spawnEffect(
|
||||||
|
new CardSpawner(
|
||||||
|
this,
|
||||||
|
() => this.state,
|
||||||
|
(cardId) => this.onCardClick(cardId),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Watch hand changes to clear stale selection
|
||||||
|
this.addEffect(() => {
|
||||||
|
const handIds = this.state.player.deck.regions.hand.childIds;
|
||||||
|
if (this.selectedCardId && !handIds.includes(this.selectedCardId)) {
|
||||||
|
this.clearTargeting();
|
||||||
|
} else {
|
||||||
|
this.updateSelectionRect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Controls
|
||||||
|
createButton({
|
||||||
|
scene: this,
|
||||||
|
label: "返回菜单",
|
||||||
|
x: 100,
|
||||||
|
y: 40,
|
||||||
|
onClick: async () => {
|
||||||
|
await this.sceneController.launch(SceneKey.IndexScene);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
createButton({
|
||||||
|
scene: this,
|
||||||
|
label: "End Turn",
|
||||||
|
x: width - 100,
|
||||||
|
y: 40,
|
||||||
|
onClick: () => {
|
||||||
|
this.tryPlayCard("end-turn");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start the game loop
|
||||||
|
this.gameHost.start();
|
||||||
|
this.gameHost.gameModule.triggers.onEffectApplied.use(async (ctx, next) => {
|
||||||
|
const { effect, entityId, stacks, targetId, cardId } = ctx;
|
||||||
|
console.log(
|
||||||
|
`${effect.name} x${stacks} ${entityId}/${cardId} :${targetId}`,
|
||||||
|
);
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private onCardClick(cardId: string): void {
|
||||||
|
const state = this.state;
|
||||||
|
const card = state.player.deck.cards[cardId];
|
||||||
|
if (!card) return;
|
||||||
|
|
||||||
|
const targetType = card.cardData.targetType;
|
||||||
|
|
||||||
|
if (targetType === "enemy") {
|
||||||
|
this.selectedCardId = cardId;
|
||||||
|
this.isTargeting = true;
|
||||||
|
this.targetingText.setText("Select a target!");
|
||||||
|
this.targetingText.setAlpha(1);
|
||||||
|
this.updateSelectionRect();
|
||||||
|
} else {
|
||||||
|
this.tryPlayCard(cardId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public onEnemyClick(enemyId: string): void {
|
||||||
|
if (!this.isTargeting || !this.selectedCardId) return;
|
||||||
|
this.tryPlayCard(this.selectedCardId, enemyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getIsTargeting(): boolean {
|
||||||
|
return this.isTargeting;
|
||||||
|
}
|
||||||
|
|
||||||
|
private tryPlayCard(cardId: string, targetId?: string): void {
|
||||||
|
const error = this.gameHost.prompts.tryCommit(
|
||||||
|
prompts.mainAction,
|
||||||
|
"global",
|
||||||
|
cardId,
|
||||||
|
targetId,
|
||||||
|
);
|
||||||
|
if (!error.ok) {
|
||||||
|
console.warn("Play failed:", error.reason);
|
||||||
|
}
|
||||||
|
this.clearTargeting();
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearTargeting(): void {
|
||||||
|
this.selectedCardId = null;
|
||||||
|
this.isTargeting = false;
|
||||||
|
this.targetingText.setAlpha(0);
|
||||||
|
this.selectionRect.setAlpha(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateSelectionRect(): void {
|
||||||
|
if (!this.selectedCardId) {
|
||||||
|
this.selectionRect.setAlpha(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = this.state;
|
||||||
|
const handIds = state.player.deck.regions.hand.childIds;
|
||||||
|
const index = handIds.indexOf(this.selectedCardId);
|
||||||
|
if (index === -1) {
|
||||||
|
this.selectionRect.setAlpha(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { width, height } = this.scale;
|
||||||
|
const total = handIds.length;
|
||||||
|
const spacing = Math.min(
|
||||||
|
CARD_SPACING,
|
||||||
|
(width - HAND_MARGIN * 2) / Math.max(1, total),
|
||||||
|
);
|
||||||
|
const startX = width / 2 - ((total - 1) * spacing) / 2;
|
||||||
|
const x = startX + index * spacing;
|
||||||
|
const y = height - HAND_Y;
|
||||||
|
|
||||||
|
this.selectionRect.setPosition(x, y);
|
||||||
|
this.selectionRect.setAlpha(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class UnitSpawner implements Spawner<CombatUnitData, CombatUnitContainer> {
|
||||||
|
constructor(private scene: CombatTestScene) {}
|
||||||
|
|
||||||
|
*getData(): Iterable<CombatUnitData> {
|
||||||
|
const state = this.scene.state;
|
||||||
|
yield {
|
||||||
|
key: "player",
|
||||||
|
entity: state.player,
|
||||||
|
name: "Player",
|
||||||
|
isPlayer: true,
|
||||||
|
};
|
||||||
|
for (let i = 0; i < state.enemies.length; i++) {
|
||||||
|
const enemy = state.enemies[i];
|
||||||
|
yield {
|
||||||
|
key: `enemy-${i}`,
|
||||||
|
entity: enemy,
|
||||||
|
name: enemy.enemy.name,
|
||||||
|
isPlayer: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getKey(t: CombatUnitData): string {
|
||||||
|
return t.key;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSpawn(t: CombatUnitData): CombatUnitContainer {
|
||||||
|
const { width, height } = this.scene.scale;
|
||||||
|
const state = this.scene.state;
|
||||||
|
const totalUnits = 1 + state.enemies.length;
|
||||||
|
const spacing = 220;
|
||||||
|
const totalWidth = (totalUnits - 1) * spacing;
|
||||||
|
const startX = width / 2 - totalWidth / 2;
|
||||||
|
|
||||||
|
let x = startX;
|
||||||
|
if (t.key.startsWith("enemy-")) {
|
||||||
|
const index = parseInt(t.key.replace("enemy-", ""), 10);
|
||||||
|
x = startX + (index + 1) * spacing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = new CombatUnitContainer(
|
||||||
|
this.scene,
|
||||||
|
x,
|
||||||
|
height / 2 - 80,
|
||||||
|
t,
|
||||||
|
);
|
||||||
|
container.playSpawnEffect();
|
||||||
|
|
||||||
|
// Make enemies clickable when targeting
|
||||||
|
if (!t.isPlayer) {
|
||||||
|
const hitArea = new Phaser.Geom.Rectangle(-100, -130, 200, 260);
|
||||||
|
container.setInteractive(hitArea, Phaser.Geom.Rectangle.Contains);
|
||||||
|
if (container.input) {
|
||||||
|
container.input.cursor = "pointer";
|
||||||
|
}
|
||||||
|
container.on("pointerdown", () => {
|
||||||
|
if (this.scene.getIsTargeting()) {
|
||||||
|
this.scene.onEnemyClick(t.entity.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
onUpdate(t: CombatUnitData, obj: CombatUnitContainer): void {
|
||||||
|
obj.updateFromData(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
onDespawn(obj: CombatUnitContainer): void {
|
||||||
|
obj.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,439 +0,0 @@
|
|||||||
import Phaser from "phaser";
|
|
||||||
import { ReactiveScene } from "boardgame-phaser";
|
|
||||||
import { createButton } from "@/utils/createButton";
|
|
||||||
import { UI_CONFIG, MAP_CONFIG, NODE_COLORS, NODE_LABELS } from "@/config";
|
|
||||||
import { MutableSignal } from "boardgame-core";
|
|
||||||
import {
|
|
||||||
canMoveTo,
|
|
||||||
moveToNode,
|
|
||||||
getCurrentNode,
|
|
||||||
getReachableChildren,
|
|
||||||
isAtEndNode,
|
|
||||||
type RunState,
|
|
||||||
type MapNode,
|
|
||||||
} from "boardgame-core/samples/slay-the-spire-like";
|
|
||||||
|
|
||||||
export class GameFlowScene extends ReactiveScene {
|
|
||||||
/** 全局游戏状态(由 App.tsx 注入) */
|
|
||||||
private gameState: MutableSignal<RunState>;
|
|
||||||
|
|
||||||
// UI elements
|
|
||||||
private hudContainer!: Phaser.GameObjects.Container;
|
|
||||||
private hpText!: Phaser.GameObjects.Text;
|
|
||||||
private goldText!: Phaser.GameObjects.Text;
|
|
||||||
private nodeText!: Phaser.GameObjects.Text;
|
|
||||||
|
|
||||||
// Map elements
|
|
||||||
private mapContainer!: Phaser.GameObjects.Container;
|
|
||||||
private isDragging = false;
|
|
||||||
private dragStartX = 0;
|
|
||||||
private dragStartY = 0;
|
|
||||||
private dragStartContainerX = 0;
|
|
||||||
private dragStartContainerY = 0;
|
|
||||||
|
|
||||||
// Interaction
|
|
||||||
private hoveredNode: string | null = null;
|
|
||||||
private nodeGraphics: Map<string, Phaser.GameObjects.Graphics> = new Map();
|
|
||||||
|
|
||||||
constructor(gameState: MutableSignal<RunState>) {
|
|
||||||
super("GameFlowScene");
|
|
||||||
this.gameState = gameState;
|
|
||||||
}
|
|
||||||
|
|
||||||
create(): void {
|
|
||||||
super.create();
|
|
||||||
this.drawHUD();
|
|
||||||
this.drawMap();
|
|
||||||
this.updateHUD();
|
|
||||||
}
|
|
||||||
|
|
||||||
private drawHUD(): void {
|
|
||||||
const { width } = this.scale;
|
|
||||||
|
|
||||||
// HUD background
|
|
||||||
const hudBg = this.add.rectangle(width / 2, 25, 400, 40, 0x111122, 0.8);
|
|
||||||
this.hudContainer = this.add.container(width / 2, 25).setDepth(200);
|
|
||||||
this.hudContainer.add(hudBg);
|
|
||||||
|
|
||||||
// HP
|
|
||||||
this.hpText = this.add
|
|
||||||
.text(-150, 0, "", {
|
|
||||||
fontSize: "16px",
|
|
||||||
color: "#ff6666",
|
|
||||||
fontStyle: "bold",
|
|
||||||
})
|
|
||||||
.setOrigin(0, 0.5);
|
|
||||||
this.hudContainer.add(this.hpText);
|
|
||||||
|
|
||||||
// Gold
|
|
||||||
this.goldText = this.add
|
|
||||||
.text(-50, 0, "", {
|
|
||||||
fontSize: "16px",
|
|
||||||
color: "#ffcc44",
|
|
||||||
fontStyle: "bold",
|
|
||||||
})
|
|
||||||
.setOrigin(0, 0.5);
|
|
||||||
this.hudContainer.add(this.goldText);
|
|
||||||
|
|
||||||
// Current node
|
|
||||||
this.nodeText = this.add
|
|
||||||
.text(50, 0, "", {
|
|
||||||
fontSize: "16px",
|
|
||||||
color: "#ffffff",
|
|
||||||
})
|
|
||||||
.setOrigin(0, 0.5);
|
|
||||||
this.hudContainer.add(this.nodeText);
|
|
||||||
|
|
||||||
// Back to menu button
|
|
||||||
createButton({
|
|
||||||
scene: this,
|
|
||||||
label: "返回菜单",
|
|
||||||
x: width - 100,
|
|
||||||
y: 25,
|
|
||||||
width: UI_CONFIG.BUTTON_WIDTH_LARGE,
|
|
||||||
height: UI_CONFIG.BUTTON_HEIGHT_LARGE,
|
|
||||||
onClick: async () => {
|
|
||||||
await this.sceneController.launch("IndexScene");
|
|
||||||
},
|
|
||||||
depth: 200,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private updateHUD(): void {
|
|
||||||
const state = this.gameState.value;
|
|
||||||
const { player, currentNodeId, map } = state;
|
|
||||||
const currentNode = map.nodes.get(currentNodeId);
|
|
||||||
|
|
||||||
this.hpText.setText(`HP: ${player.currentHp}/${player.maxHp}`);
|
|
||||||
this.goldText.setText(`💰 ${player.gold}`);
|
|
||||||
|
|
||||||
if (currentNode) {
|
|
||||||
const typeLabel = NODE_LABELS[currentNode.type] ?? currentNode.type;
|
|
||||||
const encounterName = currentNode.encounter?.name ?? typeLabel;
|
|
||||||
this.nodeText.setText(`当前: ${encounterName}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private drawMap(): void {
|
|
||||||
const { width, height } = this.scale;
|
|
||||||
const state = this.gameState.value;
|
|
||||||
const {
|
|
||||||
LAYER_HEIGHT,
|
|
||||||
NODE_SPACING,
|
|
||||||
NODE_RADIUS,
|
|
||||||
MAX_NODES_PER_LAYER,
|
|
||||||
TOTAL_LAYERS,
|
|
||||||
} = MAP_CONFIG;
|
|
||||||
|
|
||||||
// Calculate map bounds (left-to-right: layers along X, nodes along Y)
|
|
||||||
const maxLayer = TOTAL_LAYERS - 1;
|
|
||||||
const mapWidth = maxLayer * LAYER_HEIGHT + 200;
|
|
||||||
const mapHeight = (MAX_NODES_PER_LAYER - 1) * NODE_SPACING + 200;
|
|
||||||
|
|
||||||
// Create scrollable container
|
|
||||||
this.mapContainer = this.add.container(width / 2, height / 2 + 50);
|
|
||||||
|
|
||||||
// Background panel
|
|
||||||
const bg = this.add
|
|
||||||
.rectangle(0, 0, mapWidth, mapHeight, 0x111122, 0.5)
|
|
||||||
.setOrigin(0.5);
|
|
||||||
this.mapContainer.add(bg);
|
|
||||||
|
|
||||||
const graphics = this.add.graphics();
|
|
||||||
this.mapContainer.add(graphics);
|
|
||||||
|
|
||||||
const { map, currentNodeId } = state;
|
|
||||||
const reachableChildren = getReachableChildren(state);
|
|
||||||
const reachableIds = new Set(reachableChildren.map((n) => n.id));
|
|
||||||
|
|
||||||
// Draw edges
|
|
||||||
graphics.lineStyle(2, 0x666666);
|
|
||||||
for (const [nodeId, node] of map.nodes) {
|
|
||||||
const posX = this.getNodeX(node);
|
|
||||||
const posY = this.getNodeY(node);
|
|
||||||
|
|
||||||
for (const childId of node.childIds) {
|
|
||||||
const child = map.nodes.get(childId);
|
|
||||||
if (child) {
|
|
||||||
const childX = this.getNodeX(child);
|
|
||||||
const childY = this.getNodeY(child);
|
|
||||||
graphics.lineBetween(posX, posY, childX, childY);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw nodes
|
|
||||||
for (const [nodeId, node] of map.nodes) {
|
|
||||||
const posX = this.getNodeX(node);
|
|
||||||
const posY = this.getNodeY(node);
|
|
||||||
const isCurrent = nodeId === currentNodeId;
|
|
||||||
const isReachable = reachableIds.has(nodeId);
|
|
||||||
const baseColor = NODE_COLORS[node.type] ?? 0x888888;
|
|
||||||
|
|
||||||
// Node circle
|
|
||||||
const nodeGraphics = this.add.graphics();
|
|
||||||
this.mapContainer.add(nodeGraphics);
|
|
||||||
this.nodeGraphics.set(nodeId, nodeGraphics);
|
|
||||||
|
|
||||||
const color = isCurrent
|
|
||||||
? 0xffffff
|
|
||||||
: isReachable
|
|
||||||
? this.brightenColor(baseColor)
|
|
||||||
: baseColor;
|
|
||||||
nodeGraphics.fillStyle(color);
|
|
||||||
nodeGraphics.fillCircle(posX, posY, NODE_RADIUS);
|
|
||||||
|
|
||||||
if (isCurrent) {
|
|
||||||
nodeGraphics.lineStyle(3, 0xffff44);
|
|
||||||
} else if (isReachable) {
|
|
||||||
nodeGraphics.lineStyle(2, 0xaaddaa);
|
|
||||||
} else {
|
|
||||||
nodeGraphics.lineStyle(2, 0x888888);
|
|
||||||
}
|
|
||||||
nodeGraphics.strokeCircle(posX, posY, NODE_RADIUS);
|
|
||||||
|
|
||||||
// Node label
|
|
||||||
const label = NODE_LABELS[node.type] ?? node.type;
|
|
||||||
this.mapContainer.add(
|
|
||||||
this.add
|
|
||||||
.text(posX, posY, label, {
|
|
||||||
fontSize: "11px",
|
|
||||||
color: "#ffffff",
|
|
||||||
fontStyle: isCurrent ? "bold" : "normal",
|
|
||||||
})
|
|
||||||
.setOrigin(0.5),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Encounter name
|
|
||||||
if (node.encounter) {
|
|
||||||
this.mapContainer.add(
|
|
||||||
this.add
|
|
||||||
.text(posX, posY + NODE_RADIUS + 12, node.encounter.name, {
|
|
||||||
fontSize: "10px",
|
|
||||||
color: "#cccccc",
|
|
||||||
})
|
|
||||||
.setOrigin(0.5),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make reachable nodes interactive
|
|
||||||
if (isReachable) {
|
|
||||||
const hitZone = this.add
|
|
||||||
.circle(posX, posY, NODE_RADIUS, 0x000000, 0)
|
|
||||||
.setInteractive({ useHandCursor: true });
|
|
||||||
this.mapContainer.add(hitZone);
|
|
||||||
|
|
||||||
hitZone.on("pointerover", () => {
|
|
||||||
this.hoveredNode = nodeId;
|
|
||||||
nodeGraphics.clear();
|
|
||||||
nodeGraphics.fillStyle(this.brightenColor(baseColor));
|
|
||||||
nodeGraphics.fillCircle(posX, posY, NODE_RADIUS);
|
|
||||||
nodeGraphics.lineStyle(3, 0xaaddaa);
|
|
||||||
nodeGraphics.strokeCircle(posX, posY, NODE_RADIUS);
|
|
||||||
});
|
|
||||||
|
|
||||||
hitZone.on("pointerout", () => {
|
|
||||||
this.hoveredNode = null;
|
|
||||||
nodeGraphics.clear();
|
|
||||||
nodeGraphics.fillStyle(baseColor);
|
|
||||||
nodeGraphics.fillCircle(posX, posY, NODE_RADIUS);
|
|
||||||
nodeGraphics.lineStyle(2, 0xaaddaa);
|
|
||||||
nodeGraphics.strokeCircle(posX, posY, NODE_RADIUS);
|
|
||||||
});
|
|
||||||
|
|
||||||
hitZone.on("pointerdown", () => {
|
|
||||||
this.onNodeClick(nodeId);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Setup drag-to-scroll with disposables cleanup
|
|
||||||
const onPointerDown = (pointer: Phaser.Input.Pointer) => {
|
|
||||||
this.isDragging = true;
|
|
||||||
this.dragStartX = pointer.x;
|
|
||||||
this.dragStartY = pointer.y;
|
|
||||||
this.dragStartContainerX = this.mapContainer.x;
|
|
||||||
this.dragStartContainerY = this.mapContainer.y;
|
|
||||||
};
|
|
||||||
|
|
||||||
const onPointerMove = (pointer: Phaser.Input.Pointer) => {
|
|
||||||
if (!this.isDragging) return;
|
|
||||||
this.mapContainer.x =
|
|
||||||
this.dragStartContainerX + (pointer.x - this.dragStartX);
|
|
||||||
this.mapContainer.y =
|
|
||||||
this.dragStartContainerY + (pointer.y - this.dragStartY);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onPointerUp = () => {
|
|
||||||
this.isDragging = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
this.input.on("pointerdown", onPointerDown);
|
|
||||||
this.input.on("pointermove", onPointerMove);
|
|
||||||
this.input.on("pointerup", onPointerUp);
|
|
||||||
this.input.on("pointerout", onPointerUp);
|
|
||||||
|
|
||||||
this.disposables.add(() => {
|
|
||||||
this.input.off("pointerdown", onPointerDown);
|
|
||||||
this.input.off("pointermove", onPointerMove);
|
|
||||||
this.input.off("pointerup", onPointerUp);
|
|
||||||
this.input.off("pointerout", onPointerUp);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Hint text
|
|
||||||
this.add
|
|
||||||
.text(
|
|
||||||
width / 2,
|
|
||||||
this.scale.height - 20,
|
|
||||||
"点击可到达的节点进入遭遇 | 拖拽滚动查看地图",
|
|
||||||
{
|
|
||||||
fontSize: "14px",
|
|
||||||
color: "#888888",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.setOrigin(0.5)
|
|
||||||
.setDepth(200);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async onNodeClick(nodeId: string): Promise<void> {
|
|
||||||
const state = this.gameState.value;
|
|
||||||
if (!canMoveTo(state, nodeId)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Move to target node
|
|
||||||
const result = moveToNode(state, nodeId);
|
|
||||||
if (!result.success) {
|
|
||||||
console.warn(`无法移动到节点: ${result.reason}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update visuals
|
|
||||||
this.updateHUD();
|
|
||||||
this.redrawMapHighlights();
|
|
||||||
|
|
||||||
// Check if at end node
|
|
||||||
if (isAtEndNode(state)) {
|
|
||||||
this.showEndScreen();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Launch encounter scene
|
|
||||||
const currentNode = getCurrentNode(state);
|
|
||||||
if (!currentNode || !currentNode.encounter) {
|
|
||||||
console.warn("当前节点没有遭遇数据");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.sceneController.launch("PlaceholderEncounterScene");
|
|
||||||
}
|
|
||||||
|
|
||||||
private redrawMapHighlights(): void {
|
|
||||||
const state = this.gameState.value;
|
|
||||||
const { map, currentNodeId } = state;
|
|
||||||
const reachableChildren = getReachableChildren(state);
|
|
||||||
const reachableIds = new Set(reachableChildren.map((n) => n.id));
|
|
||||||
|
|
||||||
for (const [nodeId, nodeGraphics] of this.nodeGraphics) {
|
|
||||||
const node = map.nodes.get(nodeId);
|
|
||||||
if (!node) continue;
|
|
||||||
|
|
||||||
const isCurrent = nodeId === currentNodeId;
|
|
||||||
const isReachable = reachableIds.has(nodeId);
|
|
||||||
const baseColor = NODE_COLORS[node.type] ?? 0x888888;
|
|
||||||
|
|
||||||
nodeGraphics.clear();
|
|
||||||
const color = isCurrent
|
|
||||||
? 0xffffff
|
|
||||||
: isReachable
|
|
||||||
? this.brightenColor(baseColor)
|
|
||||||
: baseColor;
|
|
||||||
nodeGraphics.fillStyle(color);
|
|
||||||
nodeGraphics.fillCircle(
|
|
||||||
this.getNodeX(node),
|
|
||||||
this.getNodeY(node),
|
|
||||||
MAP_CONFIG.NODE_RADIUS,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isCurrent) {
|
|
||||||
nodeGraphics.lineStyle(3, 0xffff44);
|
|
||||||
} else if (isReachable) {
|
|
||||||
nodeGraphics.lineStyle(2, 0xaaddaa);
|
|
||||||
} else {
|
|
||||||
nodeGraphics.lineStyle(2, 0x888888);
|
|
||||||
}
|
|
||||||
nodeGraphics.strokeCircle(
|
|
||||||
this.getNodeX(node),
|
|
||||||
this.getNodeY(node),
|
|
||||||
MAP_CONFIG.NODE_RADIUS,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private showEndScreen(): void {
|
|
||||||
const { width, height } = this.scale;
|
|
||||||
const state = this.gameState.value;
|
|
||||||
|
|
||||||
// Overlay
|
|
||||||
const overlay = this.add
|
|
||||||
.rectangle(width / 2, height / 2, width, height, 0x000000, 0.7)
|
|
||||||
.setDepth(300);
|
|
||||||
|
|
||||||
// End message
|
|
||||||
this.add
|
|
||||||
.text(width / 2, height / 2 - 40, "恭喜通关!", {
|
|
||||||
fontSize: "36px",
|
|
||||||
color: "#ffcc44",
|
|
||||||
fontStyle: "bold",
|
|
||||||
})
|
|
||||||
.setOrigin(0.5)
|
|
||||||
.setDepth(300);
|
|
||||||
|
|
||||||
const { player } = state;
|
|
||||||
this.add
|
|
||||||
.text(
|
|
||||||
width / 2,
|
|
||||||
height / 2 + 20,
|
|
||||||
`剩余 HP: ${player.currentHp}/${player.maxHp}\n剩余金币: ${player.gold}`,
|
|
||||||
{
|
|
||||||
fontSize: "20px",
|
|
||||||
color: "#ffffff",
|
|
||||||
align: "center",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.setOrigin(0.5)
|
|
||||||
.setDepth(300);
|
|
||||||
|
|
||||||
createButton({
|
|
||||||
scene: this,
|
|
||||||
label: "返回菜单",
|
|
||||||
x: width / 2,
|
|
||||||
y: height / 2 + 100,
|
|
||||||
width: UI_CONFIG.BUTTON_WIDTH_LARGE,
|
|
||||||
height: UI_CONFIG.BUTTON_HEIGHT_LARGE,
|
|
||||||
onClick: async () => {
|
|
||||||
await this.sceneController.launch("IndexScene");
|
|
||||||
},
|
|
||||||
depth: 300,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private getNodeX(node: MapNode): number {
|
|
||||||
return -500 + node.layerIndex * MAP_CONFIG.LAYER_HEIGHT;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getNodeY(node: MapNode): number {
|
|
||||||
const layer = this.gameState.value.map.layers[node.layerIndex];
|
|
||||||
const nodeIndex = layer.nodeIds.indexOf(node.id);
|
|
||||||
const totalNodes = layer.nodeIds.length;
|
|
||||||
const layerHeight = (totalNodes - 1) * MAP_CONFIG.NODE_SPACING;
|
|
||||||
return -layerHeight / 2 + nodeIndex * MAP_CONFIG.NODE_SPACING;
|
|
||||||
}
|
|
||||||
|
|
||||||
private brightenColor(color: number): number {
|
|
||||||
const r = Math.min(255, ((color >> 16) & 0xff) + 40);
|
|
||||||
const g = Math.min(255, ((color >> 8) & 0xff) + 40);
|
|
||||||
const b = Math.min(255, (color & 0xff) + 40);
|
|
||||||
return (r << 16) | (g << 8) | b;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import Phaser from "phaser";
|
|
||||||
import { ReactiveScene } from "boardgame-phaser";
|
import { ReactiveScene } from "boardgame-phaser";
|
||||||
import { createButton } from "@/utils/createButton";
|
import { createButton } from "@/utils/createButton";
|
||||||
import { GRID_CONFIG, UI_CONFIG, ITEM_COLORS } from "@/config";
|
import { GRID_CONFIG, ITEM_COLORS } from "@/config";
|
||||||
import {
|
import {
|
||||||
createGridInventory,
|
createGridInventory,
|
||||||
placeItem,
|
placeItem,
|
||||||
@@ -12,6 +11,7 @@ import {
|
|||||||
type InventoryItem,
|
type InventoryItem,
|
||||||
type GameItemMeta,
|
type GameItemMeta,
|
||||||
} from "boardgame-core/samples/slay-the-spire-like";
|
} from "boardgame-core/samples/slay-the-spire-like";
|
||||||
|
import { SceneKey } from "./types";
|
||||||
|
|
||||||
export class GridViewerScene extends ReactiveScene {
|
export class GridViewerScene extends ReactiveScene {
|
||||||
private inventory: GridInventory<GameItemMeta>;
|
private inventory: GridInventory<GameItemMeta>;
|
||||||
@@ -57,7 +57,7 @@ export class GridViewerScene extends ReactiveScene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private placeSampleItems(): void {
|
private placeSampleItems(): void {
|
||||||
const items = data.desert.items;
|
const items = data.desert.getItems();
|
||||||
const sampleItems = [
|
const sampleItems = [
|
||||||
{ index: 0, x: 0, y: 0 },
|
{ index: 0, x: 0, y: 0 },
|
||||||
{ index: 3, x: 3, y: 0 },
|
{ index: 3, x: 3, y: 0 },
|
||||||
@@ -207,7 +207,7 @@ export class GridViewerScene extends ReactiveScene {
|
|||||||
x: 100,
|
x: 100,
|
||||||
y: 40,
|
y: 40,
|
||||||
onClick: async () => {
|
onClick: async () => {
|
||||||
await this.sceneController.launch("IndexScene");
|
await this.sceneController.launch(SceneKey.IndexScene);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -242,7 +242,7 @@ export class GridViewerScene extends ReactiveScene {
|
|||||||
GRID_CONFIG.WIDTH,
|
GRID_CONFIG.WIDTH,
|
||||||
GRID_CONFIG.HEIGHT,
|
GRID_CONFIG.HEIGHT,
|
||||||
);
|
);
|
||||||
const items = data.desert.items;
|
const items = data.desert.getItems();
|
||||||
|
|
||||||
let itemIndex = 0;
|
let itemIndex = 0;
|
||||||
for (let y = 0; y < GRID_CONFIG.HEIGHT && itemIndex < items.length; y++) {
|
for (let y = 0; y < GRID_CONFIG.HEIGHT && itemIndex < items.length; y++) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import Phaser from "phaser";
|
|||||||
import { ReactiveScene } from "boardgame-phaser";
|
import { ReactiveScene } from "boardgame-phaser";
|
||||||
import { createButton } from "@/utils/createButton";
|
import { createButton } from "@/utils/createButton";
|
||||||
import { UI_CONFIG } from "@/config";
|
import { UI_CONFIG } from "@/config";
|
||||||
|
import { SceneKey } from "./types";
|
||||||
|
|
||||||
export class IndexScene extends ReactiveScene {
|
export class IndexScene extends ReactiveScene {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -32,15 +33,28 @@ export class IndexScene extends ReactiveScene {
|
|||||||
.setOrigin(0.5);
|
.setOrigin(0.5);
|
||||||
|
|
||||||
// Buttons
|
// Buttons
|
||||||
const buttons = [
|
const buttons: {
|
||||||
{ label: "开始游戏", scene: "GameFlowScene", y: centerY - 70 },
|
label: string;
|
||||||
{ label: "Map Viewer", scene: "MapViewerScene", y: centerY },
|
scene: SceneKey;
|
||||||
|
y: number;
|
||||||
|
}[] = [
|
||||||
|
{ label: "Combat Test", scene: SceneKey.CombatTestScene, y: centerY },
|
||||||
|
{ label: "Map Viewer", scene: SceneKey.MapViewerScene, y: centerY + 70 },
|
||||||
{
|
{
|
||||||
label: "Grid Inventory Viewer",
|
label: "Grid Inventory Viewer",
|
||||||
scene: "GridViewerScene",
|
scene: SceneKey.GridViewerScene,
|
||||||
y: centerY + 70,
|
y: centerY + 140,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Shape Viewer",
|
||||||
|
scene: SceneKey.ShapeViewerScene,
|
||||||
|
y: centerY + 210,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Inventory Test",
|
||||||
|
scene: SceneKey.InventoryTestScene,
|
||||||
|
y: centerY + 280,
|
||||||
},
|
},
|
||||||
{ label: "Shape Viewer", scene: "ShapeViewerScene", y: centerY + 140 },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const btn of buttons) {
|
for (const btn of buttons) {
|
||||||
@@ -50,7 +64,7 @@ export class IndexScene extends ReactiveScene {
|
|||||||
|
|
||||||
private createButton(
|
private createButton(
|
||||||
label: string,
|
label: string,
|
||||||
targetScene: string,
|
targetScene: SceneKey,
|
||||||
x: number,
|
x: number,
|
||||||
y: number,
|
y: number,
|
||||||
): void {
|
): void {
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import { ReactiveScene } from "boardgame-phaser";
|
||||||
|
import { createButton } from "@/utils/createButton";
|
||||||
|
import { GRID_CONFIG } from "@/config";
|
||||||
|
import { createInventorySignal, type InventorySignal } from "@/state/inventory";
|
||||||
|
import { createItemIn, data } from "boardgame-core/samples/slay-the-spire-like";
|
||||||
|
import { createInventoryItemSpawner } from "@/gameobjects/InventoryItemSpawner";
|
||||||
|
import { SceneKey } from "./types";
|
||||||
|
import { InventorySurface } from "@/state/inventorySurfaceState";
|
||||||
|
|
||||||
|
export class InventoryTestScene extends ReactiveScene {
|
||||||
|
private readonly _surfaces: InventorySurface[] = [];
|
||||||
|
private _leftInv!: InventorySignal;
|
||||||
|
private _rightInv!: InventorySignal;
|
||||||
|
private _leftGridOffsetX = 0;
|
||||||
|
private _leftGridOffsetY = 0;
|
||||||
|
private _rightGridOffsetX = 0;
|
||||||
|
private _rightGridOffsetY = 0;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super("InventoryTestScene");
|
||||||
|
}
|
||||||
|
|
||||||
|
create(): void {
|
||||||
|
super.create();
|
||||||
|
|
||||||
|
const { width, height } = this.scale;
|
||||||
|
|
||||||
|
this._leftInv = createInventorySignal();
|
||||||
|
this._rightInv = createInventorySignal();
|
||||||
|
|
||||||
|
const invWidth = 4;
|
||||||
|
const invHeight = 6;
|
||||||
|
const cellSize = GRID_CONFIG.VIEWER_CELL_SIZE;
|
||||||
|
const gap = 80;
|
||||||
|
|
||||||
|
this._leftGridOffsetX =
|
||||||
|
(width / 2 - gap / 2) / 2 - (invWidth * cellSize) / 2;
|
||||||
|
this._leftGridOffsetY = (height - invHeight * cellSize) / 2 + 40;
|
||||||
|
|
||||||
|
this._rightGridOffsetX =
|
||||||
|
width / 2 +
|
||||||
|
gap / 2 +
|
||||||
|
(width / 2 - gap / 2) / 2 -
|
||||||
|
(invWidth * cellSize) / 2;
|
||||||
|
this._rightGridOffsetY = (height - invHeight * cellSize) / 2 + 40;
|
||||||
|
|
||||||
|
this._surfaces.push(
|
||||||
|
{
|
||||||
|
invSignal: this._leftInv,
|
||||||
|
gridOffsetX: this._leftGridOffsetX,
|
||||||
|
gridOffsetY: this._leftGridOffsetY,
|
||||||
|
cellSize,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
invSignal: this._rightInv,
|
||||||
|
gridOffsetX: this._rightGridOffsetX,
|
||||||
|
gridOffsetY: this._rightGridOffsetY,
|
||||||
|
cellSize,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
this.drawGrid(
|
||||||
|
this._leftInv,
|
||||||
|
invWidth,
|
||||||
|
invHeight,
|
||||||
|
this._leftGridOffsetX,
|
||||||
|
this._leftGridOffsetY,
|
||||||
|
);
|
||||||
|
this.drawGrid(
|
||||||
|
this._rightInv,
|
||||||
|
invWidth,
|
||||||
|
invHeight,
|
||||||
|
this._rightGridOffsetX,
|
||||||
|
this._rightGridOffsetY,
|
||||||
|
);
|
||||||
|
this.setupItemSpawner();
|
||||||
|
|
||||||
|
this.add
|
||||||
|
.text(
|
||||||
|
width / 2,
|
||||||
|
30,
|
||||||
|
"Inventory Signal Test (4x6 x2)",
|
||||||
|
GRID_CONFIG.TITLE_STYLE,
|
||||||
|
)
|
||||||
|
.setOrigin(0.5);
|
||||||
|
|
||||||
|
this.createControls();
|
||||||
|
|
||||||
|
this.add
|
||||||
|
.text(
|
||||||
|
width / 2,
|
||||||
|
height - 40,
|
||||||
|
"Drag items between inventories",
|
||||||
|
GRID_CONFIG.SUBTITLE_STYLE,
|
||||||
|
)
|
||||||
|
.setOrigin(0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawGrid(
|
||||||
|
inv: InventorySignal,
|
||||||
|
invWidth: number,
|
||||||
|
invHeight: number,
|
||||||
|
offsetX: number,
|
||||||
|
offsetY: number,
|
||||||
|
): void {
|
||||||
|
const graphics = this.add.graphics();
|
||||||
|
|
||||||
|
this.addEffect(() => {
|
||||||
|
for (let y = 0; y < invHeight; y++) {
|
||||||
|
for (let x = 0; x < invWidth; x++) {
|
||||||
|
const px = offsetX + x * GRID_CONFIG.VIEWER_CELL_SIZE;
|
||||||
|
const py = offsetY + y * GRID_CONFIG.VIEWER_CELL_SIZE;
|
||||||
|
|
||||||
|
const isOccupied = inv.value.occupiedCells.has(`${x},${y}`);
|
||||||
|
graphics.fillStyle(
|
||||||
|
isOccupied
|
||||||
|
? GRID_CONFIG.CELL_OCCUPIED_COLOR
|
||||||
|
: GRID_CONFIG.CELL_EMPTY_COLOR,
|
||||||
|
);
|
||||||
|
graphics.fillRect(
|
||||||
|
px + 1,
|
||||||
|
py + 1,
|
||||||
|
GRID_CONFIG.VIEWER_CELL_SIZE - 2,
|
||||||
|
GRID_CONFIG.VIEWER_CELL_SIZE - 2,
|
||||||
|
);
|
||||||
|
graphics.lineStyle(1, GRID_CONFIG.GRID_LINE_COLOR);
|
||||||
|
graphics.strokeRect(
|
||||||
|
px,
|
||||||
|
py,
|
||||||
|
GRID_CONFIG.VIEWER_CELL_SIZE,
|
||||||
|
GRID_CONFIG.VIEWER_CELL_SIZE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupItemSpawner(): void {
|
||||||
|
const spawner = createInventoryItemSpawner(this, this._surfaces);
|
||||||
|
this.disposables.add(spawner);
|
||||||
|
}
|
||||||
|
|
||||||
|
private createControls(): void {
|
||||||
|
const { width } = this.scale;
|
||||||
|
|
||||||
|
createButton({
|
||||||
|
scene: this,
|
||||||
|
label: "返回菜单",
|
||||||
|
x: 100,
|
||||||
|
y: 40,
|
||||||
|
onClick: async () => {
|
||||||
|
await this.sceneController.launch(SceneKey.IndexScene);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
createButton({
|
||||||
|
scene: this,
|
||||||
|
label: "添加道具(左)",
|
||||||
|
x: width - 350,
|
||||||
|
y: 40,
|
||||||
|
onClick: () => {
|
||||||
|
this.addRandomItem(this._leftInv);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
createButton({
|
||||||
|
scene: this,
|
||||||
|
label: "添加道具(右)",
|
||||||
|
x: width - 180,
|
||||||
|
y: 40,
|
||||||
|
onClick: () => {
|
||||||
|
this.addRandomItem(this._rightInv);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private addRandomItem(inv: InventorySignal): void {
|
||||||
|
const items = data.desert.getItems();
|
||||||
|
|
||||||
|
inv.produce((inventory) => {
|
||||||
|
const usedIndices = new Set<number>();
|
||||||
|
|
||||||
|
for (const item of inventory.items.values()) {
|
||||||
|
const match = item.id.match(/^item-(\d+)-/);
|
||||||
|
if (match) {
|
||||||
|
usedIndices.add(parseInt(match[1], 10));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let availableIndex = 0;
|
||||||
|
while (usedIndices.has(availableIndex) && availableIndex < items.length) {
|
||||||
|
availableIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (availableIndex >= items.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemData = items[availableIndex];
|
||||||
|
const id = `item-${availableIndex}-${Date.now().toString(16).slice(-4)}`;
|
||||||
|
createItemIn(inventory, id, itemData);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
type MapNode,
|
type MapNode,
|
||||||
type MapNodeType,
|
type MapNodeType,
|
||||||
} from "boardgame-core/samples/slay-the-spire-like";
|
} from "boardgame-core/samples/slay-the-spire-like";
|
||||||
|
import { SceneKey } from "./types";
|
||||||
|
|
||||||
export class MapViewerScene extends ReactiveScene {
|
export class MapViewerScene extends ReactiveScene {
|
||||||
private map: PointCrawlMap | null = null;
|
private map: PointCrawlMap | null = null;
|
||||||
@@ -57,7 +58,7 @@ export class MapViewerScene extends ReactiveScene {
|
|||||||
x: 100,
|
x: 100,
|
||||||
y: 40,
|
y: 40,
|
||||||
onClick: async () => {
|
onClick: async () => {
|
||||||
await this.sceneController.launch("IndexScene");
|
await this.sceneController.launch(SceneKey.IndexScene);
|
||||||
},
|
},
|
||||||
depth: 100,
|
depth: 100,
|
||||||
});
|
});
|
||||||
@@ -113,7 +114,7 @@ export class MapViewerScene extends ReactiveScene {
|
|||||||
|
|
||||||
private drawMap(): void {
|
private drawMap(): void {
|
||||||
const rng = createRNG(this.seed);
|
const rng = createRNG(this.seed);
|
||||||
this.map = generatePointCrawlMap(rng, data.desert.encounters);
|
this.map = generatePointCrawlMap(rng, data.desert.getEncounters());
|
||||||
|
|
||||||
const { width, height } = this.scale;
|
const { width, height } = this.scale;
|
||||||
const {
|
const {
|
||||||
|
|||||||
@@ -1,235 +0,0 @@
|
|||||||
import Phaser from "phaser";
|
|
||||||
import { ReactiveScene } from "boardgame-phaser";
|
|
||||||
import { createButton } from "@/utils/createButton";
|
|
||||||
import { UI_CONFIG, GRID_CONFIG, NODE_COLORS, NODE_LABELS } from "@/config";
|
|
||||||
import { MutableSignal } from "boardgame-core";
|
|
||||||
import {
|
|
||||||
resolveEncounter,
|
|
||||||
removeItem,
|
|
||||||
type RunState,
|
|
||||||
type EncounterResult,
|
|
||||||
type MapNodeType,
|
|
||||||
type MapNode,
|
|
||||||
} from "boardgame-core/samples/slay-the-spire-like";
|
|
||||||
import { InventoryWidget } from "@/widgets/InventoryWidget";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 占位符遭遇场景
|
|
||||||
*
|
|
||||||
* 左侧显示背包网格(使用 InventoryWidget),右侧显示遭遇信息。
|
|
||||||
*/
|
|
||||||
export class PlaceholderEncounterScene extends ReactiveScene {
|
|
||||||
private gameState: MutableSignal<RunState>;
|
|
||||||
private inventoryWidget: InventoryWidget | null = null;
|
|
||||||
|
|
||||||
constructor(gameState: MutableSignal<RunState>) {
|
|
||||||
super("PlaceholderEncounterScene");
|
|
||||||
this.gameState = gameState;
|
|
||||||
}
|
|
||||||
|
|
||||||
create(): void {
|
|
||||||
super.create();
|
|
||||||
const { width, height } = this.scale;
|
|
||||||
const state = this.gameState.value;
|
|
||||||
|
|
||||||
const gridCols = state.inventory.width;
|
|
||||||
const gridRows = state.inventory.height;
|
|
||||||
const cellSize = GRID_CONFIG.WIDGET_CELL_SIZE;
|
|
||||||
const gridW = gridCols * cellSize + (gridCols - 1) * GRID_CONFIG.GRID_GAP;
|
|
||||||
const gridH = gridRows * cellSize + (gridRows - 1) * GRID_CONFIG.GRID_GAP;
|
|
||||||
const leftPanelW = gridW + 40;
|
|
||||||
|
|
||||||
this.inventoryWidget = new InventoryWidget({
|
|
||||||
scene: this,
|
|
||||||
gameState: this.gameState,
|
|
||||||
x: 60,
|
|
||||||
y: (height - gridH) / 2 + 20,
|
|
||||||
cellSize,
|
|
||||||
gridGap: GRID_CONFIG.GRID_GAP,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.cameras.main.setBounds(0, 0, width, height);
|
|
||||||
this.cameras.main.setScroll(0, 0);
|
|
||||||
|
|
||||||
// Panel background
|
|
||||||
this.add
|
|
||||||
.rectangle(
|
|
||||||
60 + leftPanelW / 2,
|
|
||||||
this.inventoryWidgetY(gridH),
|
|
||||||
leftPanelW + 10,
|
|
||||||
gridH + 50,
|
|
||||||
0x111122,
|
|
||||||
0.9,
|
|
||||||
)
|
|
||||||
.setStrokeStyle(2, 0x5555aa);
|
|
||||||
|
|
||||||
// "背包" title
|
|
||||||
this.add
|
|
||||||
.text(60 + gridW / 2, (height - gridH) / 2, "背包", {
|
|
||||||
fontSize: "22px",
|
|
||||||
color: "#ffffff",
|
|
||||||
fontStyle: "bold",
|
|
||||||
})
|
|
||||||
.setOrigin(0.5);
|
|
||||||
|
|
||||||
const node = state.map.nodes.get(state.currentNodeId);
|
|
||||||
if (!node || !node.encounter) {
|
|
||||||
const rightX = leftPanelW + 80;
|
|
||||||
this.add
|
|
||||||
.text(rightX + 300, height / 2, "没有遭遇数据", {
|
|
||||||
fontSize: "24px",
|
|
||||||
color: "#ff4444",
|
|
||||||
})
|
|
||||||
.setOrigin(0.5);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.drawRightPanel(
|
|
||||||
node as MapNode & { encounter: { name: string; description: string } },
|
|
||||||
leftPanelW,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private inventoryWidgetY(gridH: number): number {
|
|
||||||
const { height } = this.scale;
|
|
||||||
return (height - gridH) / 2 + 20 + gridH / 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
private drawRightPanel(
|
|
||||||
node: MapNode & { encounter: { name: string; description: string } },
|
|
||||||
leftPanelW: number,
|
|
||||||
width: number,
|
|
||||||
height: number,
|
|
||||||
): void {
|
|
||||||
const encounter = {
|
|
||||||
type: node.type as MapNodeType,
|
|
||||||
name: node.encounter.name,
|
|
||||||
description: node.encounter.description,
|
|
||||||
};
|
|
||||||
const nodeId = node.id as string;
|
|
||||||
|
|
||||||
const rightX = leftPanelW + 60;
|
|
||||||
const rightW = width - rightX - 40;
|
|
||||||
const cx = rightX + rightW / 2;
|
|
||||||
const cy = height / 2;
|
|
||||||
|
|
||||||
this.add
|
|
||||||
.text(cx, cy - 180, "遭遇", {
|
|
||||||
fontSize: "36px",
|
|
||||||
color: "#fff",
|
|
||||||
fontStyle: "bold",
|
|
||||||
})
|
|
||||||
.setOrigin(0.5);
|
|
||||||
|
|
||||||
const typeLabel = this.getTypeLabel(encounter.type);
|
|
||||||
const badgeColor = this.getTypeColor(encounter.type);
|
|
||||||
this.add.rectangle(cx, cy - 110, 140, 40, badgeColor);
|
|
||||||
this.add
|
|
||||||
.text(cx, cy - 110, typeLabel, {
|
|
||||||
fontSize: "18px",
|
|
||||||
color: "#fff",
|
|
||||||
fontStyle: "bold",
|
|
||||||
})
|
|
||||||
.setOrigin(0.5);
|
|
||||||
|
|
||||||
this.add
|
|
||||||
.text(cx, cy - 50, encounter.name, {
|
|
||||||
fontSize: "28px",
|
|
||||||
color: "#fff",
|
|
||||||
})
|
|
||||||
.setOrigin(0.5);
|
|
||||||
|
|
||||||
this.add
|
|
||||||
.text(cx, cy + 10, encounter.description || "(暂无描述)", {
|
|
||||||
fontSize: "18px",
|
|
||||||
color: "#bbb",
|
|
||||||
wordWrap: { width: rightW - 40 },
|
|
||||||
align: "center",
|
|
||||||
})
|
|
||||||
.setOrigin(0.5);
|
|
||||||
|
|
||||||
this.add
|
|
||||||
.text(cx, cy + 80, `节点: ${nodeId}`, {
|
|
||||||
fontSize: "14px",
|
|
||||||
color: "#666",
|
|
||||||
})
|
|
||||||
.setOrigin(0.5);
|
|
||||||
|
|
||||||
this.add
|
|
||||||
.text(cx, cy + 130, "(此为占位符遭遇,后续将替换为真实遭遇场景)", {
|
|
||||||
fontSize: "14px",
|
|
||||||
color: "#ff8844",
|
|
||||||
fontStyle: "italic",
|
|
||||||
})
|
|
||||||
.setOrigin(0.5);
|
|
||||||
|
|
||||||
createButton({
|
|
||||||
scene: this,
|
|
||||||
label: "完成遭遇",
|
|
||||||
x: cx,
|
|
||||||
y: cy + 200,
|
|
||||||
width: UI_CONFIG.BUTTON_WIDTH_LARGE,
|
|
||||||
height: UI_CONFIG.BUTTON_HEIGHT_LARGE,
|
|
||||||
onClick: async () => {
|
|
||||||
await this.completeEncounter();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
createButton({
|
|
||||||
scene: this,
|
|
||||||
label: "暂不处理",
|
|
||||||
x: cx,
|
|
||||||
y: cy + 270,
|
|
||||||
onClick: async () => {
|
|
||||||
await this.sceneController.launch("GameFlowScene");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private getTypeLabel(type: MapNodeType): string {
|
|
||||||
return NODE_LABELS[type] ?? type;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getTypeColor(type: MapNodeType): number {
|
|
||||||
return NODE_COLORS[type] ?? 0x888888;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async completeEncounter(): Promise<void> {
|
|
||||||
const state = this.gameState.value;
|
|
||||||
const node = state.map.nodes.get(state.currentNodeId);
|
|
||||||
if (!node || !node.encounter) return;
|
|
||||||
|
|
||||||
// Clear lost items from inventory
|
|
||||||
if (this.inventoryWidget) {
|
|
||||||
const lostIds = this.inventoryWidget.getLostItems();
|
|
||||||
for (const lostId of lostIds) {
|
|
||||||
removeItem(state, lostId);
|
|
||||||
}
|
|
||||||
this.inventoryWidget.clearLostItems();
|
|
||||||
}
|
|
||||||
|
|
||||||
const result: EncounterResult = this.generatePlaceholderResult(node.type);
|
|
||||||
resolveEncounter(state, result);
|
|
||||||
await this.sceneController.launch("GameFlowScene");
|
|
||||||
}
|
|
||||||
|
|
||||||
private generatePlaceholderResult(type: MapNodeType): EncounterResult {
|
|
||||||
switch (type) {
|
|
||||||
case "minion":
|
|
||||||
return { hpLost: 8, goldEarned: 15 };
|
|
||||||
case "elite":
|
|
||||||
return { hpLost: 15, goldEarned: 30 };
|
|
||||||
case "camp":
|
|
||||||
return { hpGained: 15 };
|
|
||||||
case "shop":
|
|
||||||
return { goldEarned: 0 };
|
|
||||||
case "curio":
|
|
||||||
case "event":
|
|
||||||
return { goldEarned: 20 };
|
|
||||||
default:
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import Phaser from "phaser";
|
|
||||||
import { ReactiveScene } from "boardgame-phaser";
|
import { ReactiveScene } from "boardgame-phaser";
|
||||||
import { createButton } from "@/utils/createButton";
|
import { createButton } from "@/utils/createButton";
|
||||||
import { UI_CONFIG, SHAPE_CONFIG, NODE_LABELS, NODE_COLORS } from "@/config";
|
import { SHAPE_CONFIG } from "@/config";
|
||||||
import {
|
import {
|
||||||
parseShapeString,
|
parseShapeString,
|
||||||
data,
|
data,
|
||||||
type ParsedShape,
|
type ParsedShape,
|
||||||
} from "boardgame-core/samples/slay-the-spire-like";
|
} from "boardgame-core/samples/slay-the-spire-like";
|
||||||
|
import { SceneKey } from "./types";
|
||||||
|
|
||||||
export class ShapeViewerScene extends ReactiveScene {
|
export class ShapeViewerScene extends ReactiveScene {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -17,6 +17,19 @@ export class ShapeViewerScene extends ReactiveScene {
|
|||||||
super.create();
|
super.create();
|
||||||
this.drawShapeViewer();
|
this.drawShapeViewer();
|
||||||
this.createControls();
|
this.createControls();
|
||||||
|
this.createBackButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
private createBackButton(): void {
|
||||||
|
createButton({
|
||||||
|
scene: this,
|
||||||
|
label: "← Back",
|
||||||
|
x: 80,
|
||||||
|
y: 30,
|
||||||
|
onClick: async () => {
|
||||||
|
await this.sceneController.launch(SceneKey.IndexScene);
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private drawShapeViewer(): void {
|
private drawShapeViewer(): void {
|
||||||
@@ -40,7 +53,7 @@ export class ShapeViewerScene extends ReactiveScene {
|
|||||||
const startY = 80;
|
const startY = 80;
|
||||||
const { SPACING_X, SPACING_Y, ITEMS_PER_ROW, MAX_ITEMS } = SHAPE_CONFIG;
|
const { SPACING_X, SPACING_Y, ITEMS_PER_ROW, MAX_ITEMS } = SHAPE_CONFIG;
|
||||||
|
|
||||||
const itemsToShow = data.desert.items.slice(0, MAX_ITEMS);
|
const itemsToShow = data.desert.getItems().slice(0, MAX_ITEMS);
|
||||||
|
|
||||||
for (let i = 0; i < itemsToShow.length; i++) {
|
for (let i = 0; i < itemsToShow.length; i++) {
|
||||||
const itemData = itemsToShow[i];
|
const itemData = itemsToShow[i];
|
||||||
@@ -145,7 +158,7 @@ export class ShapeViewerScene extends ReactiveScene {
|
|||||||
x: 100,
|
x: 100,
|
||||||
y: height - 40,
|
y: height - 40,
|
||||||
onClick: async () => {
|
onClick: async () => {
|
||||||
await this.sceneController.launch("IndexScene");
|
await this.sceneController.launch(SceneKey.IndexScene);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export enum SceneKey {
|
||||||
|
CombatTestScene = "CombatTestScene",
|
||||||
|
GridViewerScene = "GridViewerScene",
|
||||||
|
IndexScene = "IndexScene",
|
||||||
|
InventoryTestScene = "InventoryTestScene",
|
||||||
|
MapViewerScene = "MapViewerScene",
|
||||||
|
ShapeViewerScene = "ShapeViewerScene",
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { computed, ReadonlySignal, signal, Signal } from "@preact/signals-core";
|
||||||
|
import { ITEM_COLORS } from "@/config";
|
||||||
|
import {
|
||||||
|
type GameItem,
|
||||||
|
type ParsedShape,
|
||||||
|
type Transform2D,
|
||||||
|
} from "boardgame-core/samples/slay-the-spire-like";
|
||||||
|
|
||||||
|
export class InventoryItemState {
|
||||||
|
private readonly _item: Signal<GameItem>;
|
||||||
|
private readonly _previewRotation: Signal<number>;
|
||||||
|
|
||||||
|
readonly name: ReadonlySignal<string>;
|
||||||
|
readonly shape: ReadonlySignal<ParsedShape>;
|
||||||
|
readonly color: ReadonlySignal<number>;
|
||||||
|
readonly transform: ReadonlySignal<Transform2D>;
|
||||||
|
readonly previewRotation: ReadonlySignal<number>;
|
||||||
|
|
||||||
|
constructor(initialItem: GameItem) {
|
||||||
|
this._item = signal(initialItem);
|
||||||
|
this._previewRotation = signal(0);
|
||||||
|
|
||||||
|
this.name = computed(() => {
|
||||||
|
const item = this._item.value;
|
||||||
|
return item.meta?.itemData.name ?? item?.id ?? "";
|
||||||
|
});
|
||||||
|
|
||||||
|
this.shape = computed(() => this._item.value.shape);
|
||||||
|
|
||||||
|
this.color = computed(() => this.computeColor(this._item.value.id ?? ""));
|
||||||
|
|
||||||
|
this.transform = computed(() => this._item.value.transform);
|
||||||
|
|
||||||
|
this.previewRotation = computed(() => {
|
||||||
|
const base = this._item.value.transform.rotation ?? 0;
|
||||||
|
return (base + this._previewRotation.value) % 360;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get item(): GameItem {
|
||||||
|
return this._item.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
setItem(item: GameItem): void {
|
||||||
|
this._item.value = item;
|
||||||
|
this._previewRotation.value = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPreviewRotation(rotation: number): void {
|
||||||
|
this._previewRotation.value = rotation;
|
||||||
|
}
|
||||||
|
|
||||||
|
addPreviewRotation(rotation: number): void {
|
||||||
|
this._previewRotation.value += rotation;
|
||||||
|
}
|
||||||
|
|
||||||
|
private computeColor(itemId: string): number {
|
||||||
|
const hash = itemId.split("").reduce((acc, c) => acc + c.charCodeAt(0), 0);
|
||||||
|
return ITEM_COLORS[hash % ITEM_COLORS.length];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import {
|
||||||
|
buildCombatState,
|
||||||
|
createRunState,
|
||||||
|
createStart,
|
||||||
|
createTriggers,
|
||||||
|
data,
|
||||||
|
generateDeckFromInventory,
|
||||||
|
getAdjacentItems,
|
||||||
|
getItemEffects,
|
||||||
|
IRunContext,
|
||||||
|
} from "boardgame-core/samples/slay-the-spire-like";
|
||||||
|
import { createInventorySignal } from "./inventory";
|
||||||
|
|
||||||
|
export type CombatModule = ReturnType<typeof createCombatModule>;
|
||||||
|
export function createCombatModule() {
|
||||||
|
const inventory = createInventorySignal(true);
|
||||||
|
|
||||||
|
const deck = generateDeckFromInventory(inventory.value);
|
||||||
|
|
||||||
|
const run = createRunState();
|
||||||
|
|
||||||
|
const encounter = data.desert.getEncounters()[0];
|
||||||
|
|
||||||
|
const effects = getItemEffects(inventory.value);
|
||||||
|
const combat = buildCombatState(
|
||||||
|
{
|
||||||
|
hp: run.currentHp,
|
||||||
|
maxHp: run.maxHp,
|
||||||
|
itemEffects: effects,
|
||||||
|
},
|
||||||
|
deck,
|
||||||
|
encounter,
|
||||||
|
);
|
||||||
|
|
||||||
|
const runContext: IRunContext = {
|
||||||
|
getItemData(id: string) {
|
||||||
|
return inventory.value.items.get(id)?.meta?.itemData || null;
|
||||||
|
},
|
||||||
|
getAdjacentItems(id: string) {
|
||||||
|
return getAdjacentItems(inventory.value, id).keys();
|
||||||
|
},
|
||||||
|
getConsumedUses(id: string) {
|
||||||
|
return inventory.value.items.get(id)?.meta?.consumedUses || 0;
|
||||||
|
},
|
||||||
|
async setConsumedUsesAsync(id: string, uses: number) {
|
||||||
|
await inventory.produceAsync((draft) => {
|
||||||
|
const item = draft.items.get(id);
|
||||||
|
if (!item?.meta) return;
|
||||||
|
item.meta.consumedUses = uses;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggers = createTriggers(runContext);
|
||||||
|
data.desert.addTriggers(triggers, runContext);
|
||||||
|
const start = createStart(triggers, runContext);
|
||||||
|
|
||||||
|
return {
|
||||||
|
start,
|
||||||
|
createInitialState: () => combat,
|
||||||
|
triggers,
|
||||||
|
runContext,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import { MutableSignal, mutableSignal, createRNG } from 'boardgame-core';
|
|
||||||
import { createRunState, generatePointCrawlMap, data, type RunState, type ItemData } from 'boardgame-core/samples/slay-the-spire-like';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 全局游戏运行状态 Signal
|
|
||||||
*
|
|
||||||
* 在 App.tsx 中创建为单例,所有场景共享。
|
|
||||||
* 遭遇场景通过读取此 signal 的当前遭遇状态来构建 UI。
|
|
||||||
*/
|
|
||||||
export function createGameState(seed?: number): MutableSignal<RunState> {
|
|
||||||
const actualSeed = seed ?? Date.now();
|
|
||||||
const rng = createRNG(actualSeed);
|
|
||||||
const encounters = data.desert.encounters;
|
|
||||||
const map = generatePointCrawlMap(rng, encounters);
|
|
||||||
const starterItems: ItemData[] = data.desert.items.slice(0, 3);
|
|
||||||
return mutableSignal<RunState>(createRunState(map, starterItems));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 获取当前遭遇数据(computed getter) */
|
|
||||||
export function currentEncounter(
|
|
||||||
gameState: MutableSignal<RunState>
|
|
||||||
): { nodeId: string; encounter: { name: string; description: string; type: string } } | null {
|
|
||||||
const state = gameState.value;
|
|
||||||
const node = state.map.nodes.get(state.currentNodeId);
|
|
||||||
if (!node || !node.encounter) return null;
|
|
||||||
return {
|
|
||||||
nodeId: node.id,
|
|
||||||
encounter: {
|
|
||||||
type: node.type,
|
|
||||||
name: node.encounter.name,
|
|
||||||
description: node.encounter.description,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { create } from "mutative";
|
||||||
|
import { mutableSignal } from "boardgame-core";
|
||||||
|
import {
|
||||||
|
createGridInventory,
|
||||||
|
createItemIn,
|
||||||
|
data,
|
||||||
|
GameItemMeta,
|
||||||
|
placeItem,
|
||||||
|
removeItem,
|
||||||
|
Transform2D,
|
||||||
|
validatePlacement,
|
||||||
|
} from "boardgame-core/samples/slay-the-spire-like";
|
||||||
|
import { batch } from "@preact/signals-core";
|
||||||
|
|
||||||
|
function genId() {
|
||||||
|
return Math.random().toString(16).slice(-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InventorySignal = ReturnType<typeof createInventorySignal>;
|
||||||
|
|
||||||
|
export function createInventorySignal(giveStart = false) {
|
||||||
|
const inventory = createGridInventory<GameItemMeta>(4, 6);
|
||||||
|
|
||||||
|
if (giveStart) {
|
||||||
|
const startingItems = data.desert.getStartingItems();
|
||||||
|
for (const d of startingItems) {
|
||||||
|
createItemIn(inventory, `${d.id}-${genId()}`, d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mutableSignal(inventory);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move an item to a new position in the inventory.
|
||||||
|
* Returns true if the move was successful, false if the new position is invalid.
|
||||||
|
*/
|
||||||
|
export function moveItem(
|
||||||
|
from: InventorySignal,
|
||||||
|
to: InventorySignal,
|
||||||
|
itemId: string,
|
||||||
|
newX: number,
|
||||||
|
newY: number,
|
||||||
|
newRotation?: number,
|
||||||
|
): boolean {
|
||||||
|
const inventory = from.value;
|
||||||
|
const item = inventory.items.get(itemId);
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newTransform: Transform2D = {
|
||||||
|
offset: { x: newX, y: newY },
|
||||||
|
rotation: newRotation === undefined ? item.transform.rotation : newRotation,
|
||||||
|
flipX: false,
|
||||||
|
flipY: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const removed = create(to.value, (inv) => {
|
||||||
|
removeItem(inv, itemId);
|
||||||
|
});
|
||||||
|
const validation = validatePlacement(removed, item.shape, newTransform);
|
||||||
|
if (!validation.valid) return false;
|
||||||
|
|
||||||
|
batch(() => {
|
||||||
|
from.produce((inv) => {
|
||||||
|
removeItem(inv, itemId);
|
||||||
|
});
|
||||||
|
to.produce((inv) => {
|
||||||
|
placeItem(inv, {
|
||||||
|
...item,
|
||||||
|
transform: newTransform,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { InventorySignal } from "./inventory";
|
||||||
|
import { MutableSignal, mutableSignal } from "boardgame-core";
|
||||||
|
|
||||||
|
export type InventorySurface = {
|
||||||
|
invSignal: InventorySignal;
|
||||||
|
gridOffsetX: number;
|
||||||
|
gridOffsetY: number;
|
||||||
|
cellSize: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class InventorySurfaceState {
|
||||||
|
private readonly _signal: MutableSignal<InventorySurface>;
|
||||||
|
|
||||||
|
constructor(init: InventorySurface) {
|
||||||
|
this._signal = mutableSignal(init);
|
||||||
|
}
|
||||||
|
|
||||||
|
public get surface() {
|
||||||
|
return this._signal.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get invSignal() {
|
||||||
|
return this._signal.value.invSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get gridOffsetX() {
|
||||||
|
return this._signal.value.gridOffsetX;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get gridOffsetY() {
|
||||||
|
return this._signal.value.gridOffsetY;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get cellSize() {
|
||||||
|
return this._signal.value.cellSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setSurface(surface: InventorySurface) {
|
||||||
|
this._signal.value = surface;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sceneToInventory(
|
||||||
|
surface: InventorySurface | InventorySurfaceState,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
): { x: number; y: number } | null {
|
||||||
|
const invX = Math.round((x - surface.gridOffsetX) / surface.cellSize);
|
||||||
|
const invY = Math.round((y - surface.gridOffsetY) / surface.cellSize);
|
||||||
|
|
||||||
|
const { width, height } = surface.invSignal.peek();
|
||||||
|
if (invX < 0 || invY < 0 || invX >= width || invY >= height) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { x: invX, y: invY };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inventoryToScene(
|
||||||
|
surface: InventorySurface | InventorySurfaceState,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
): { x: number; y: number } {
|
||||||
|
return {
|
||||||
|
x: x * surface.cellSize + surface.gridOffsetX,
|
||||||
|
y: y * surface.cellSize + surface.gridOffsetY,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,53 +1,38 @@
|
|||||||
import { h } from "preact";
|
import { GAME_CONFIG } from "@/config";
|
||||||
|
import { CombatTestScene } from "@/scenes/CombatTestScene";
|
||||||
|
import { GridViewerScene } from "@/scenes/GridViewerScene";
|
||||||
|
import { IndexScene } from "@/scenes/IndexScene";
|
||||||
|
import { InventoryTestScene } from "@/scenes/InventoryTestScene";
|
||||||
|
import { MapViewerScene } from "@/scenes/MapViewerScene";
|
||||||
|
import { ShapeViewerScene } from "@/scenes/ShapeViewerScene";
|
||||||
|
import { CombatModule, createCombatModule } from "@/state/combatState";
|
||||||
|
import { createGameHost } from "boardgame-core";
|
||||||
|
import {
|
||||||
|
CombatResult,
|
||||||
|
CombatState,
|
||||||
|
} from "boardgame-core/samples/slay-the-spire-like";
|
||||||
import { PhaserGame, PhaserScene } from "boardgame-phaser";
|
import { PhaserGame, PhaserScene } from "boardgame-phaser";
|
||||||
import { useMemo } from "preact/hooks";
|
import { useMemo } from "preact/hooks";
|
||||||
import { IndexScene } from "@/scenes/IndexScene";
|
|
||||||
import { MapViewerScene } from "@/scenes/MapViewerScene";
|
|
||||||
import { GridViewerScene } from "@/scenes/GridViewerScene";
|
|
||||||
import { ShapeViewerScene } from "@/scenes/ShapeViewerScene";
|
|
||||||
import { GameFlowScene } from "@/scenes/GameFlowScene";
|
|
||||||
import { PlaceholderEncounterScene } from "@/scenes/PlaceholderEncounterScene";
|
|
||||||
import { createGameState } from "@/state/gameState";
|
|
||||||
|
|
||||||
// 全局游戏状态单例
|
|
||||||
const gameState = createGameState();
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const indexScene = useMemo(() => new IndexScene(), []);
|
const gameHost = useMemo(
|
||||||
const mapViewerScene = useMemo(() => new MapViewerScene(), []);
|
() =>
|
||||||
const gridViewerScene = useMemo(() => new GridViewerScene(), []);
|
createGameHost<CombatState, CombatResult | null, CombatModule>(
|
||||||
const shapeViewerScene = useMemo(() => new ShapeViewerScene(), []);
|
createCombatModule(),
|
||||||
const gameFlowScene = useMemo(() => new GameFlowScene(gameState), []);
|
),
|
||||||
const placeholderEncounterScene = useMemo(
|
|
||||||
() => new PlaceholderEncounterScene(gameState),
|
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
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="IndexScene" config={GAME_CONFIG}>
|
||||||
initialScene="IndexScene"
|
<PhaserScene scene={IndexScene} />
|
||||||
config={{ width: 1920, height: 1080 }}
|
<PhaserScene scene={CombatTestScene} data={{ gameHost }} />
|
||||||
>
|
<PhaserScene scene={InventoryTestScene} />
|
||||||
<PhaserScene sceneKey="IndexScene" scene={indexScene as any} />
|
<PhaserScene scene={MapViewerScene} />
|
||||||
<PhaserScene
|
<PhaserScene scene={GridViewerScene} />
|
||||||
sceneKey="MapViewerScene"
|
<PhaserScene scene={ShapeViewerScene} />
|
||||||
scene={mapViewerScene as any}
|
|
||||||
/>
|
|
||||||
<PhaserScene
|
|
||||||
sceneKey="GridViewerScene"
|
|
||||||
scene={gridViewerScene as any}
|
|
||||||
/>
|
|
||||||
<PhaserScene
|
|
||||||
sceneKey="ShapeViewerScene"
|
|
||||||
scene={shapeViewerScene as any}
|
|
||||||
/>
|
|
||||||
<PhaserScene sceneKey="GameFlowScene" scene={gameFlowScene as any} />
|
|
||||||
<PhaserScene
|
|
||||||
sceneKey="PlaceholderEncounterScene"
|
|
||||||
scene={placeholderEncounterScene as any}
|
|
||||||
/>
|
|
||||||
</PhaserGame>
|
</PhaserGame>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,477 +0,0 @@
|
|||||||
import Phaser from "phaser";
|
|
||||||
import {
|
|
||||||
type InventoryItem,
|
|
||||||
type GameItemMeta,
|
|
||||||
type GridInventory,
|
|
||||||
validatePlacement,
|
|
||||||
transformShape,
|
|
||||||
} from "boardgame-core/samples/slay-the-spire-like";
|
|
||||||
import { dragDropEventEffect, DragDropEventType } from "boardgame-phaser";
|
|
||||||
import { DisposableBag } from "boardgame-phaser";
|
|
||||||
|
|
||||||
export interface DragSession {
|
|
||||||
itemId: string;
|
|
||||||
itemShape: InventoryItem<GameItemMeta>["shape"];
|
|
||||||
itemTransform: InventoryItem<GameItemMeta>["transform"];
|
|
||||||
itemMeta: InventoryItem<GameItemMeta>["meta"];
|
|
||||||
ghostContainer: Phaser.GameObjects.Container;
|
|
||||||
previewGraphics: Phaser.GameObjects.Graphics;
|
|
||||||
disposables: DisposableBag;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DragControllerOptions {
|
|
||||||
scene: Phaser.Scene;
|
|
||||||
container: Phaser.GameObjects.Container;
|
|
||||||
cellSize: number;
|
|
||||||
gridGap: number;
|
|
||||||
gridX: number;
|
|
||||||
gridY: number;
|
|
||||||
getInventory: () => GridInventory<GameItemMeta>;
|
|
||||||
getItemColor: (itemId: string) => number;
|
|
||||||
onPlaceItem: (item: InventoryItem<GameItemMeta>) => void;
|
|
||||||
onCreateLostItem: (
|
|
||||||
itemId: string,
|
|
||||||
shape: InventoryItem<GameItemMeta>["shape"],
|
|
||||||
transform: InventoryItem<GameItemMeta>["transform"],
|
|
||||||
meta: InventoryItem<GameItemMeta>["meta"],
|
|
||||||
x: number,
|
|
||||||
y: number,
|
|
||||||
) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Event-driven drag controller using dragDropEventEffect from boardgame-phaser.
|
|
||||||
* Manages ghost visuals, placement preview, rotation, and validation.
|
|
||||||
*/
|
|
||||||
export class DragController {
|
|
||||||
private scene: Phaser.Scene;
|
|
||||||
private container: Phaser.GameObjects.Container;
|
|
||||||
private cellSize: number;
|
|
||||||
private gridGap: number;
|
|
||||||
private gridX: number;
|
|
||||||
private gridY: number;
|
|
||||||
private getInventory: () => GridInventory<GameItemMeta>;
|
|
||||||
private getItemColor: (itemId: string) => number;
|
|
||||||
private onPlaceItem: (item: InventoryItem<GameItemMeta>) => void;
|
|
||||||
private onCreateLostItem: (
|
|
||||||
itemId: string,
|
|
||||||
shape: InventoryItem<GameItemMeta>["shape"],
|
|
||||||
transform: InventoryItem<GameItemMeta>["transform"],
|
|
||||||
meta: InventoryItem<GameItemMeta>["meta"],
|
|
||||||
x: number,
|
|
||||||
y: number,
|
|
||||||
) => void;
|
|
||||||
|
|
||||||
private activeSession: DragSession | null = null;
|
|
||||||
|
|
||||||
constructor(options: DragControllerOptions) {
|
|
||||||
this.scene = options.scene;
|
|
||||||
this.container = options.container;
|
|
||||||
this.cellSize = options.cellSize;
|
|
||||||
this.gridGap = options.gridGap;
|
|
||||||
this.gridX = options.gridX;
|
|
||||||
this.gridY = options.gridY;
|
|
||||||
this.getInventory = options.getInventory;
|
|
||||||
this.getItemColor = options.getItemColor;
|
|
||||||
this.onPlaceItem = options.onPlaceItem;
|
|
||||||
this.onCreateLostItem = options.onCreateLostItem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start a drag session for an inventory item.
|
|
||||||
* Uses dragDropEventEffect for pointer tracking and event emission.
|
|
||||||
*/
|
|
||||||
startDrag(
|
|
||||||
itemId: string,
|
|
||||||
item: InventoryItem<GameItemMeta>,
|
|
||||||
itemContainer: Phaser.GameObjects.Container,
|
|
||||||
): () => void {
|
|
||||||
const cells = this.getItemCells(item);
|
|
||||||
const firstCell = cells[0];
|
|
||||||
const worldX =
|
|
||||||
this.container.x +
|
|
||||||
this.gridX +
|
|
||||||
firstCell.x * (this.cellSize + this.gridGap);
|
|
||||||
const worldY =
|
|
||||||
this.container.y +
|
|
||||||
this.gridY +
|
|
||||||
firstCell.y * (this.cellSize + this.gridGap);
|
|
||||||
|
|
||||||
const ghostContainer = this.createGhostContainer(
|
|
||||||
worldX,
|
|
||||||
worldY,
|
|
||||||
item.shape,
|
|
||||||
item.transform,
|
|
||||||
this.getItemColor(itemId),
|
|
||||||
);
|
|
||||||
const previewGraphics = this.scene.add
|
|
||||||
.graphics()
|
|
||||||
.setDepth(999)
|
|
||||||
.setAlpha(0.5);
|
|
||||||
|
|
||||||
const disposables = new DisposableBag();
|
|
||||||
const session: DragSession = {
|
|
||||||
itemId,
|
|
||||||
itemShape: item.shape,
|
|
||||||
itemTransform: {
|
|
||||||
...item.transform,
|
|
||||||
offset: { ...item.transform.offset },
|
|
||||||
},
|
|
||||||
itemMeta: item.meta,
|
|
||||||
ghostContainer,
|
|
||||||
previewGraphics,
|
|
||||||
disposables,
|
|
||||||
};
|
|
||||||
|
|
||||||
this.activeSession = session;
|
|
||||||
|
|
||||||
// Set up drag-drop event handling via framework utility
|
|
||||||
const disposeDrag = dragDropEventEffect(
|
|
||||||
itemContainer as Phaser.GameObjects.GameObject,
|
|
||||||
disposables,
|
|
||||||
);
|
|
||||||
|
|
||||||
itemContainer.on("dragstart", () => {
|
|
||||||
ghostContainer.setVisible(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
itemContainer.on("dragmove", () => {
|
|
||||||
this.handleDragMove(session);
|
|
||||||
});
|
|
||||||
|
|
||||||
itemContainer.on("dragend", () => {
|
|
||||||
this.handleDragEnd(session);
|
|
||||||
disposeDrag();
|
|
||||||
this.activeSession = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
disposeDrag();
|
|
||||||
this.destroySession(session);
|
|
||||||
this.activeSession = null;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start a drag session for a lost item.
|
|
||||||
*/
|
|
||||||
startLostItemDrag(
|
|
||||||
itemId: string,
|
|
||||||
shape: InventoryItem<GameItemMeta>["shape"],
|
|
||||||
transform: InventoryItem<GameItemMeta>["transform"],
|
|
||||||
meta: InventoryItem<GameItemMeta>["meta"],
|
|
||||||
lostContainer: Phaser.GameObjects.Container,
|
|
||||||
): () => void {
|
|
||||||
const pointer = this.scene.input.activePointer;
|
|
||||||
const ghostContainer = this.createGhostContainer(
|
|
||||||
pointer.x,
|
|
||||||
pointer.y,
|
|
||||||
shape,
|
|
||||||
transform,
|
|
||||||
this.getItemColor(itemId),
|
|
||||||
);
|
|
||||||
const previewGraphics = this.scene.add
|
|
||||||
.graphics()
|
|
||||||
.setDepth(999)
|
|
||||||
.setAlpha(0.5);
|
|
||||||
|
|
||||||
const disposables = new DisposableBag();
|
|
||||||
const session: DragSession = {
|
|
||||||
itemId,
|
|
||||||
itemShape: shape,
|
|
||||||
itemTransform: { ...transform, offset: { ...transform.offset } },
|
|
||||||
itemMeta: meta,
|
|
||||||
ghostContainer,
|
|
||||||
previewGraphics,
|
|
||||||
disposables,
|
|
||||||
};
|
|
||||||
|
|
||||||
this.activeSession = session;
|
|
||||||
|
|
||||||
const disposeDrag = dragDropEventEffect(
|
|
||||||
lostContainer as Phaser.GameObjects.GameObject,
|
|
||||||
disposables,
|
|
||||||
);
|
|
||||||
|
|
||||||
lostContainer.on("dragstart", () => {
|
|
||||||
ghostContainer.setVisible(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
lostContainer.on("dragmove", () => {
|
|
||||||
this.handleDragMove(session);
|
|
||||||
});
|
|
||||||
|
|
||||||
lostContainer.on("dragend", () => {
|
|
||||||
this.handleDragEnd(session);
|
|
||||||
disposeDrag();
|
|
||||||
this.activeSession = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
disposeDrag();
|
|
||||||
this.destroySession(session);
|
|
||||||
this.activeSession = null;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rotate the currently dragged item by 90 degrees.
|
|
||||||
*/
|
|
||||||
rotateDraggedItem(): void {
|
|
||||||
if (!this.activeSession) return;
|
|
||||||
|
|
||||||
const currentRotation =
|
|
||||||
(this.activeSession.itemTransform.rotation + 90) % 360;
|
|
||||||
this.activeSession.itemTransform = {
|
|
||||||
...this.activeSession.itemTransform,
|
|
||||||
rotation: currentRotation,
|
|
||||||
};
|
|
||||||
|
|
||||||
this.updateGhostVisuals(this.activeSession);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if currently dragging.
|
|
||||||
*/
|
|
||||||
isDragging(): boolean {
|
|
||||||
return this.activeSession !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the ID of the item being dragged, or null.
|
|
||||||
*/
|
|
||||||
getDraggedItemId(): string | null {
|
|
||||||
return this.activeSession?.itemId ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the current position of the dragged ghost container.
|
|
||||||
*/
|
|
||||||
getDraggedItemPosition(): { x: number; y: number } {
|
|
||||||
if (!this.activeSession) return { x: 0, y: 0 };
|
|
||||||
return {
|
|
||||||
x: this.activeSession.ghostContainer.x,
|
|
||||||
y: this.activeSession.ghostContainer.y,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clean up active session and destroy all visuals.
|
|
||||||
*/
|
|
||||||
destroy(): void {
|
|
||||||
if (this.activeSession) {
|
|
||||||
this.destroySession(this.activeSession);
|
|
||||||
this.activeSession = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private createGhostContainer(
|
|
||||||
x: number,
|
|
||||||
y: number,
|
|
||||||
shape: InventoryItem<GameItemMeta>["shape"],
|
|
||||||
transform: InventoryItem<GameItemMeta>["transform"],
|
|
||||||
color: number,
|
|
||||||
): Phaser.GameObjects.Container {
|
|
||||||
const ghostContainer = this.scene.add.container(x, y).setDepth(1000);
|
|
||||||
const ghostGraphics = this.scene.add.graphics();
|
|
||||||
|
|
||||||
const cells = transformShape(shape, transform);
|
|
||||||
for (const cell of cells) {
|
|
||||||
ghostGraphics.fillStyle(color, 0.7);
|
|
||||||
ghostGraphics.fillRect(
|
|
||||||
cell.x * (this.cellSize + this.gridGap),
|
|
||||||
cell.y * (this.cellSize + this.gridGap),
|
|
||||||
this.cellSize - 2,
|
|
||||||
this.cellSize - 2,
|
|
||||||
);
|
|
||||||
ghostGraphics.lineStyle(2, 0xffffff);
|
|
||||||
ghostGraphics.strokeRect(
|
|
||||||
cell.x * (this.cellSize + this.gridGap),
|
|
||||||
cell.y * (this.cellSize + this.gridGap),
|
|
||||||
this.cellSize,
|
|
||||||
this.cellSize,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
ghostContainer.add(ghostGraphics);
|
|
||||||
|
|
||||||
return ghostContainer;
|
|
||||||
}
|
|
||||||
|
|
||||||
private updateGhostVisuals(session: DragSession): void {
|
|
||||||
session.ghostContainer.removeAll(true);
|
|
||||||
const ghostGraphics = this.scene.add.graphics();
|
|
||||||
const color = this.getItemColor(session.itemId);
|
|
||||||
|
|
||||||
const cells = transformShape(session.itemShape, session.itemTransform);
|
|
||||||
for (const cell of cells) {
|
|
||||||
ghostGraphics.fillStyle(color, 0.7);
|
|
||||||
ghostGraphics.fillRect(
|
|
||||||
cell.x * (this.cellSize + this.gridGap),
|
|
||||||
cell.y * (this.cellSize + this.gridGap),
|
|
||||||
this.cellSize - 2,
|
|
||||||
this.cellSize - 2,
|
|
||||||
);
|
|
||||||
ghostGraphics.lineStyle(2, 0xffffff);
|
|
||||||
ghostGraphics.strokeRect(
|
|
||||||
cell.x * (this.cellSize + this.gridGap),
|
|
||||||
cell.y * (this.cellSize + this.gridGap),
|
|
||||||
this.cellSize,
|
|
||||||
this.cellSize,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
session.ghostContainer.add(ghostGraphics);
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleDragMove(session: DragSession): void {
|
|
||||||
const pointer = this.scene.input.activePointer;
|
|
||||||
session.ghostContainer.setPosition(pointer.x, pointer.y);
|
|
||||||
|
|
||||||
const gridCell = this.getWorldGridCell(pointer.x, pointer.y);
|
|
||||||
session.previewGraphics.clear();
|
|
||||||
|
|
||||||
if (gridCell) {
|
|
||||||
const inventory = this.getInventory();
|
|
||||||
const testTransform = {
|
|
||||||
...session.itemTransform,
|
|
||||||
offset: { x: gridCell.x, y: gridCell.y },
|
|
||||||
};
|
|
||||||
const validation = validatePlacement(
|
|
||||||
inventory,
|
|
||||||
session.itemShape,
|
|
||||||
testTransform,
|
|
||||||
);
|
|
||||||
|
|
||||||
const cells = transformShape(session.itemShape, testTransform);
|
|
||||||
for (const cell of cells) {
|
|
||||||
const px = this.gridX + cell.x * (this.cellSize + this.gridGap);
|
|
||||||
const py = this.gridY + cell.y * (this.cellSize + this.gridGap);
|
|
||||||
|
|
||||||
if (validation.valid) {
|
|
||||||
session.previewGraphics.fillStyle(0x33ff33, 0.3);
|
|
||||||
session.previewGraphics.fillRect(
|
|
||||||
px,
|
|
||||||
py,
|
|
||||||
this.cellSize,
|
|
||||||
this.cellSize,
|
|
||||||
);
|
|
||||||
session.previewGraphics.lineStyle(2, 0x33ff33);
|
|
||||||
session.previewGraphics.strokeRect(
|
|
||||||
px,
|
|
||||||
py,
|
|
||||||
this.cellSize,
|
|
||||||
this.cellSize,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
session.previewGraphics.fillStyle(0xff3333, 0.3);
|
|
||||||
session.previewGraphics.fillRect(
|
|
||||||
px,
|
|
||||||
py,
|
|
||||||
this.cellSize,
|
|
||||||
this.cellSize,
|
|
||||||
);
|
|
||||||
session.previewGraphics.lineStyle(2, 0xff3333);
|
|
||||||
session.previewGraphics.strokeRect(
|
|
||||||
px,
|
|
||||||
py,
|
|
||||||
this.cellSize,
|
|
||||||
this.cellSize,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleDragEnd(session: DragSession): void {
|
|
||||||
const pointer = this.scene.input.activePointer;
|
|
||||||
const gridCell = this.getWorldGridCell(pointer.x, pointer.y);
|
|
||||||
const inventory = this.getInventory();
|
|
||||||
|
|
||||||
session.ghostContainer.destroy();
|
|
||||||
session.previewGraphics.destroy();
|
|
||||||
session.disposables.dispose();
|
|
||||||
|
|
||||||
if (gridCell) {
|
|
||||||
const testTransform = {
|
|
||||||
...session.itemTransform,
|
|
||||||
offset: { x: gridCell.x, y: gridCell.y },
|
|
||||||
};
|
|
||||||
const validation = validatePlacement(
|
|
||||||
inventory,
|
|
||||||
session.itemShape,
|
|
||||||
testTransform,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (validation.valid) {
|
|
||||||
const item: InventoryItem<GameItemMeta> = {
|
|
||||||
id: session.itemId,
|
|
||||||
shape: session.itemShape,
|
|
||||||
transform: testTransform,
|
|
||||||
meta: session.itemMeta,
|
|
||||||
};
|
|
||||||
this.onPlaceItem(item);
|
|
||||||
} else {
|
|
||||||
this.onCreateLostItem(
|
|
||||||
session.itemId,
|
|
||||||
session.itemShape,
|
|
||||||
session.itemTransform,
|
|
||||||
session.itemMeta,
|
|
||||||
session.ghostContainer.x,
|
|
||||||
session.ghostContainer.y,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.onCreateLostItem(
|
|
||||||
session.itemId,
|
|
||||||
session.itemShape,
|
|
||||||
session.itemTransform,
|
|
||||||
session.itemMeta,
|
|
||||||
session.ghostContainer.x,
|
|
||||||
session.ghostContainer.y,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private getWorldGridCell(
|
|
||||||
worldX: number,
|
|
||||||
worldY: number,
|
|
||||||
): { x: number; y: number } | null {
|
|
||||||
const localX = worldX - this.container.x - this.gridX;
|
|
||||||
const localY = worldY - this.container.y - this.gridY;
|
|
||||||
|
|
||||||
const cellX = Math.floor(localX / (this.cellSize + this.gridGap));
|
|
||||||
const cellY = Math.floor(localY / (this.cellSize + this.gridGap));
|
|
||||||
|
|
||||||
const inventory = this.getInventory();
|
|
||||||
if (
|
|
||||||
cellX < 0 ||
|
|
||||||
cellY < 0 ||
|
|
||||||
cellX >= inventory.width ||
|
|
||||||
cellY >= inventory.height
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { x: cellX, y: cellY };
|
|
||||||
}
|
|
||||||
|
|
||||||
private getItemCells(
|
|
||||||
item: InventoryItem<GameItemMeta>,
|
|
||||||
): { x: number; y: number }[] {
|
|
||||||
const cells: { x: number; y: number }[] = [];
|
|
||||||
const { offset } = item.transform;
|
|
||||||
for (let y = 0; y < item.shape.height; y++) {
|
|
||||||
for (let x = 0; x < item.shape.width; x++) {
|
|
||||||
if (item.shape.grid[y]?.[x]) {
|
|
||||||
cells.push({ x: x + offset.x, y: y + offset.y });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return cells;
|
|
||||||
}
|
|
||||||
|
|
||||||
private destroySession(session: DragSession): void {
|
|
||||||
session.disposables.dispose();
|
|
||||||
session.ghostContainer.destroy();
|
|
||||||
session.previewGraphics.destroy();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import Phaser from "phaser";
|
|
||||||
|
|
||||||
export interface GridBackgroundRendererOptions {
|
|
||||||
scene: Phaser.Scene;
|
|
||||||
parentContainer: Phaser.GameObjects.Container;
|
|
||||||
cellSize: number;
|
|
||||||
gridGap: number;
|
|
||||||
gridX: number;
|
|
||||||
gridY: number;
|
|
||||||
/** Background fill color for each cell */
|
|
||||||
cellBgColor?: number;
|
|
||||||
/** Border/stroke color for each cell */
|
|
||||||
cellBorderColor?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renders the static grid background (empty cells with borders).
|
|
||||||
* Separated from item rendering so it can be drawn once and left alone.
|
|
||||||
*/
|
|
||||||
export class GridBackgroundRenderer {
|
|
||||||
private scene: Phaser.Scene;
|
|
||||||
private parentContainer: Phaser.GameObjects.Container;
|
|
||||||
private cellSize: number;
|
|
||||||
private gridGap: number;
|
|
||||||
private gridX: number;
|
|
||||||
private gridY: number;
|
|
||||||
private cellBgColor: number;
|
|
||||||
private cellBorderColor: number;
|
|
||||||
|
|
||||||
private graphics!: Phaser.GameObjects.Graphics;
|
|
||||||
|
|
||||||
constructor(options: GridBackgroundRendererOptions) {
|
|
||||||
this.scene = options.scene;
|
|
||||||
this.parentContainer = options.parentContainer;
|
|
||||||
this.cellSize = options.cellSize;
|
|
||||||
this.gridGap = options.gridGap;
|
|
||||||
this.gridX = options.gridX;
|
|
||||||
this.gridY = options.gridY;
|
|
||||||
this.cellBgColor = options.cellBgColor ?? 0x1a1a2e;
|
|
||||||
this.cellBorderColor = options.cellBorderColor ?? 0x444477;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Draw the grid background for the given dimensions.
|
|
||||||
* Should be called once during initialization.
|
|
||||||
*/
|
|
||||||
draw(width: number, height: number): void {
|
|
||||||
this.graphics = this.scene.add.graphics();
|
|
||||||
|
|
||||||
for (let y = 0; y < height; y++) {
|
|
||||||
for (let x = 0; x < width; x++) {
|
|
||||||
const px = this.gridX + x * (this.cellSize + this.gridGap);
|
|
||||||
const py = this.gridY + y * (this.cellSize + this.gridGap);
|
|
||||||
|
|
||||||
this.graphics.fillStyle(this.cellBgColor);
|
|
||||||
this.graphics.fillRect(px, py, this.cellSize, this.cellSize);
|
|
||||||
this.graphics.lineStyle(2, this.cellBorderColor);
|
|
||||||
this.graphics.strokeRect(px, py, this.cellSize, this.cellSize);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.parentContainer.add(this.graphics);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Destroy the graphics object.
|
|
||||||
*/
|
|
||||||
destroy(): void {
|
|
||||||
this.graphics?.destroy();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,277 +0,0 @@
|
|||||||
import Phaser from "phaser";
|
|
||||||
import { MutableSignal } from "boardgame-core";
|
|
||||||
import {
|
|
||||||
type InventoryItem,
|
|
||||||
type GameItemMeta,
|
|
||||||
type RunState,
|
|
||||||
type GridInventory,
|
|
||||||
} from "boardgame-core/samples/slay-the-spire-like";
|
|
||||||
import type { Spawner } from "boardgame-phaser";
|
|
||||||
|
|
||||||
const ITEM_COLORS = [
|
|
||||||
0x3388ff, 0xff8833, 0x33ff88, 0xff3388, 0x8833ff, 0x33ffff, 0xffff33,
|
|
||||||
0xff6633,
|
|
||||||
];
|
|
||||||
|
|
||||||
export interface InventoryItemSpawnerOptions {
|
|
||||||
scene: Phaser.Scene;
|
|
||||||
gameState: MutableSignal<RunState>;
|
|
||||||
parentContainer: Phaser.GameObjects.Container;
|
|
||||||
cellSize: number;
|
|
||||||
gridGap: number;
|
|
||||||
gridX: number;
|
|
||||||
gridY: number;
|
|
||||||
isLocked: () => boolean;
|
|
||||||
isDragging: () => boolean;
|
|
||||||
onItemDragStart: (
|
|
||||||
itemId: string,
|
|
||||||
item: InventoryItem<GameItemMeta>,
|
|
||||||
itemContainer: Phaser.GameObjects.Container,
|
|
||||||
) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Spawner for inventory items using the boardgame-phaser Spawner pattern.
|
|
||||||
* Reactively spawns/despawns/updates item visuals when gameState.inventory changes.
|
|
||||||
*
|
|
||||||
* Items currently being dragged are excluded from getData() to prevent
|
|
||||||
* the spawner from respawning them while they're in flight.
|
|
||||||
*/
|
|
||||||
export class InventoryItemSpawner implements Spawner<
|
|
||||||
InventoryItem<GameItemMeta>,
|
|
||||||
Phaser.GameObjects.Container
|
|
||||||
> {
|
|
||||||
private scene: Phaser.Scene;
|
|
||||||
private gameState: MutableSignal<RunState>;
|
|
||||||
private parentContainer: Phaser.GameObjects.Container;
|
|
||||||
private cellSize: number;
|
|
||||||
private gridGap: number;
|
|
||||||
private gridX: number;
|
|
||||||
private gridY: number;
|
|
||||||
private isLocked: () => boolean;
|
|
||||||
private isDragging: () => boolean;
|
|
||||||
private onItemDragStart: (
|
|
||||||
itemId: string,
|
|
||||||
item: InventoryItem<GameItemMeta>,
|
|
||||||
itemContainer: Phaser.GameObjects.Container,
|
|
||||||
) => void;
|
|
||||||
|
|
||||||
private colorMap = new Map<string, number>();
|
|
||||||
private colorIdx = 0;
|
|
||||||
private draggingIds = new Set<string>();
|
|
||||||
|
|
||||||
constructor(options: InventoryItemSpawnerOptions) {
|
|
||||||
this.scene = options.scene;
|
|
||||||
this.gameState = options.gameState;
|
|
||||||
this.parentContainer = options.parentContainer;
|
|
||||||
this.cellSize = options.cellSize;
|
|
||||||
this.gridGap = options.gridGap;
|
|
||||||
this.gridX = options.gridX;
|
|
||||||
this.gridY = options.gridY;
|
|
||||||
this.isLocked = options.isLocked;
|
|
||||||
this.isDragging = options.isDragging;
|
|
||||||
this.onItemDragStart = options.onItemDragStart;
|
|
||||||
}
|
|
||||||
|
|
||||||
*getData(): Iterable<InventoryItem<GameItemMeta>> {
|
|
||||||
const inventory = this.getInventory();
|
|
||||||
for (const [, item] of inventory.items) {
|
|
||||||
if (!this.draggingIds.has(item.id)) {
|
|
||||||
yield item;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getKey(item: InventoryItem<GameItemMeta>): string {
|
|
||||||
return item.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
onSpawn(item: InventoryItem<GameItemMeta>): Phaser.GameObjects.Container {
|
|
||||||
const color =
|
|
||||||
this.colorMap.get(item.id) ??
|
|
||||||
ITEM_COLORS[this.colorIdx++ % ITEM_COLORS.length];
|
|
||||||
this.colorMap.set(item.id, color);
|
|
||||||
|
|
||||||
const container = this.createItemVisuals(item, color);
|
|
||||||
this.setupInteraction(item, container);
|
|
||||||
this.parentContainer.add(container);
|
|
||||||
|
|
||||||
return container;
|
|
||||||
}
|
|
||||||
|
|
||||||
onDespawn(
|
|
||||||
obj: Phaser.GameObjects.Container,
|
|
||||||
_item: InventoryItem<GameItemMeta>,
|
|
||||||
): void {
|
|
||||||
obj.removeAllListeners();
|
|
||||||
obj.destroy();
|
|
||||||
}
|
|
||||||
|
|
||||||
onUpdate(
|
|
||||||
item: InventoryItem<GameItemMeta>,
|
|
||||||
obj: Phaser.GameObjects.Container,
|
|
||||||
): void {
|
|
||||||
const color = this.colorMap.get(item.id) ?? 0x888888;
|
|
||||||
this.rebuildItemVisuals(obj, item, color);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mark an item as being dragged so the spawner excludes it from getData().
|
|
||||||
* Call this before removing the item from the inventory.
|
|
||||||
*/
|
|
||||||
markDragging(itemId: string): void {
|
|
||||||
this.draggingIds.add(itemId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unmark an item after drag ends (placed or lost).
|
|
||||||
*/
|
|
||||||
unmarkDragging(itemId: string): void {
|
|
||||||
this.draggingIds.delete(itemId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the color assigned to an item (creates one if not yet assigned).
|
|
||||||
*/
|
|
||||||
getItemColor(itemId: string): number {
|
|
||||||
if (!this.colorMap.has(itemId)) {
|
|
||||||
this.colorMap.set(
|
|
||||||
itemId,
|
|
||||||
ITEM_COLORS[this.colorIdx++ % ITEM_COLORS.length],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return this.colorMap.get(itemId)!;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getInventory(): GridInventory<GameItemMeta> {
|
|
||||||
return this.gameState.value
|
|
||||||
.inventory as unknown as GridInventory<GameItemMeta>;
|
|
||||||
}
|
|
||||||
|
|
||||||
private createItemVisuals(
|
|
||||||
item: InventoryItem<GameItemMeta>,
|
|
||||||
color: number,
|
|
||||||
): Phaser.GameObjects.Container {
|
|
||||||
const container = this.scene.add.container(0, 0);
|
|
||||||
|
|
||||||
const graphics = this.scene.add.graphics();
|
|
||||||
const cells = this.getItemCells(item);
|
|
||||||
|
|
||||||
for (const cell of cells) {
|
|
||||||
const px = this.gridX + cell.x * (this.cellSize + this.gridGap);
|
|
||||||
const py = this.gridY + cell.y * (this.cellSize + this.gridGap);
|
|
||||||
|
|
||||||
graphics.fillStyle(color);
|
|
||||||
graphics.fillRect(px + 1, py + 1, this.cellSize - 2, this.cellSize - 2);
|
|
||||||
graphics.lineStyle(2, 0xffffff);
|
|
||||||
graphics.strokeRect(px, py, this.cellSize, this.cellSize);
|
|
||||||
}
|
|
||||||
|
|
||||||
const firstCell = cells[0];
|
|
||||||
const name = item.meta?.itemData.name ?? item.id;
|
|
||||||
const fontSize = Math.max(10, Math.floor(this.cellSize / 5));
|
|
||||||
const text = this.scene.add
|
|
||||||
.text(
|
|
||||||
this.gridX +
|
|
||||||
firstCell.x * (this.cellSize + this.gridGap) +
|
|
||||||
this.cellSize / 2,
|
|
||||||
this.gridY +
|
|
||||||
firstCell.y * (this.cellSize + this.gridGap) +
|
|
||||||
this.cellSize / 2,
|
|
||||||
name,
|
|
||||||
{ fontSize: `${fontSize}px`, color: "#fff", fontStyle: "bold" },
|
|
||||||
)
|
|
||||||
.setOrigin(0.5);
|
|
||||||
|
|
||||||
container.add(graphics);
|
|
||||||
container.add(text);
|
|
||||||
|
|
||||||
const hitRect = new Phaser.Geom.Rectangle(
|
|
||||||
this.gridX + firstCell.x * (this.cellSize + this.gridGap),
|
|
||||||
this.gridY + firstCell.y * (this.cellSize + this.gridGap),
|
|
||||||
this.cellSize,
|
|
||||||
this.cellSize,
|
|
||||||
);
|
|
||||||
container.setInteractive(hitRect, Phaser.Geom.Rectangle.Contains);
|
|
||||||
|
|
||||||
return container;
|
|
||||||
}
|
|
||||||
|
|
||||||
private rebuildItemVisuals(
|
|
||||||
container: Phaser.GameObjects.Container,
|
|
||||||
item: InventoryItem<GameItemMeta>,
|
|
||||||
color: number,
|
|
||||||
): void {
|
|
||||||
container.removeAll(true);
|
|
||||||
|
|
||||||
const graphics = this.scene.add.graphics();
|
|
||||||
const cells = this.getItemCells(item);
|
|
||||||
|
|
||||||
for (const cell of cells) {
|
|
||||||
const px = this.gridX + cell.x * (this.cellSize + this.gridGap);
|
|
||||||
const py = this.gridY + cell.y * (this.cellSize + this.gridGap);
|
|
||||||
|
|
||||||
graphics.fillStyle(color);
|
|
||||||
graphics.fillRect(px + 1, py + 1, this.cellSize - 2, this.cellSize - 2);
|
|
||||||
graphics.lineStyle(2, 0xffffff);
|
|
||||||
graphics.strokeRect(px, py, this.cellSize, this.cellSize);
|
|
||||||
}
|
|
||||||
|
|
||||||
const firstCell = cells[0];
|
|
||||||
const name = item.meta?.itemData.name ?? item.id;
|
|
||||||
const fontSize = Math.max(10, Math.floor(this.cellSize / 5));
|
|
||||||
const text = this.scene.add
|
|
||||||
.text(
|
|
||||||
this.gridX +
|
|
||||||
firstCell.x * (this.cellSize + this.gridGap) +
|
|
||||||
this.cellSize / 2,
|
|
||||||
this.gridY +
|
|
||||||
firstCell.y * (this.cellSize + this.gridGap) +
|
|
||||||
this.cellSize / 2,
|
|
||||||
name,
|
|
||||||
{ fontSize: `${fontSize}px`, color: "#fff", fontStyle: "bold" },
|
|
||||||
)
|
|
||||||
.setOrigin(0.5);
|
|
||||||
|
|
||||||
container.add(graphics);
|
|
||||||
container.add(text);
|
|
||||||
|
|
||||||
const hitRect = new Phaser.Geom.Rectangle(
|
|
||||||
this.gridX + firstCell.x * (this.cellSize + this.gridGap),
|
|
||||||
this.gridY + firstCell.y * (this.cellSize + this.gridGap),
|
|
||||||
this.cellSize,
|
|
||||||
this.cellSize,
|
|
||||||
);
|
|
||||||
container.setInteractive(hitRect, Phaser.Geom.Rectangle.Contains);
|
|
||||||
}
|
|
||||||
|
|
||||||
private setupInteraction(
|
|
||||||
item: InventoryItem<GameItemMeta>,
|
|
||||||
container: Phaser.GameObjects.Container,
|
|
||||||
): void {
|
|
||||||
container.on("pointerdown", (pointer: Phaser.Input.Pointer) => {
|
|
||||||
// Guard against stale events firing on destroyed containers
|
|
||||||
if (!container.scene || !container.active) return;
|
|
||||||
if (this.isLocked()) return;
|
|
||||||
if (this.isDragging()) return;
|
|
||||||
if (pointer.button === 0) {
|
|
||||||
this.onItemDragStart(item.id, item, container);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private getItemCells(
|
|
||||||
item: InventoryItem<GameItemMeta>,
|
|
||||||
): { x: number; y: number }[] {
|
|
||||||
const cells: { x: number; y: number }[] = [];
|
|
||||||
const { offset } = item.transform;
|
|
||||||
for (let y = 0; y < item.shape.height; y++) {
|
|
||||||
for (let x = 0; x < item.shape.width; x++) {
|
|
||||||
if (item.shape.grid[y]?.[x]) {
|
|
||||||
cells.push({ x: x + offset.x, y: y + offset.y });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return cells;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,239 +0,0 @@
|
|||||||
import Phaser from "phaser";
|
|
||||||
import { MutableSignal } from "boardgame-core";
|
|
||||||
import { spawnEffect } from "boardgame-phaser";
|
|
||||||
import {
|
|
||||||
type GridInventory,
|
|
||||||
type InventoryItem,
|
|
||||||
type GameItemMeta,
|
|
||||||
type RunState,
|
|
||||||
removeItemFromGrid,
|
|
||||||
placeItem,
|
|
||||||
} from "boardgame-core/samples/slay-the-spire-like";
|
|
||||||
import { InventoryItemSpawner } from "./InventoryItemSpawner";
|
|
||||||
import { GridBackgroundRenderer } from "./GridBackgroundRenderer";
|
|
||||||
import { DragController } from "./DragController";
|
|
||||||
import { LostItemManager } from "./LostItemManager";
|
|
||||||
|
|
||||||
export interface InventoryWidgetOptions {
|
|
||||||
scene: Phaser.Scene;
|
|
||||||
gameState: MutableSignal<RunState>;
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
cellSize: number;
|
|
||||||
gridGap?: number;
|
|
||||||
isLocked?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Inventory widget using the Spawner pattern for reactive item rendering.
|
|
||||||
*
|
|
||||||
* Architecture:
|
|
||||||
* - InventoryItemSpawner + spawnEffect: reactive spawn/despawn/update of item visuals
|
|
||||||
* - GridBackgroundRenderer: static grid background drawn once
|
|
||||||
* - DragController: event-driven drag logic via dragDropEventEffect
|
|
||||||
* - LostItemManager: tracks items dropped outside valid placement
|
|
||||||
*/
|
|
||||||
export class InventoryWidget {
|
|
||||||
private scene: Phaser.Scene;
|
|
||||||
private gameState: MutableSignal<RunState>;
|
|
||||||
private container: Phaser.GameObjects.Container;
|
|
||||||
private cellSize: number;
|
|
||||||
private gridGap: number;
|
|
||||||
private gridX = 0;
|
|
||||||
private gridY = 0;
|
|
||||||
private isLocked: boolean;
|
|
||||||
|
|
||||||
private itemSpawner: InventoryItemSpawner;
|
|
||||||
private backgroundRenderer: GridBackgroundRenderer;
|
|
||||||
private dragController: DragController;
|
|
||||||
private lostItemManager: LostItemManager;
|
|
||||||
|
|
||||||
private spawnDispose: (() => void) | null = null;
|
|
||||||
private rightClickHandler!: (pointer: Phaser.Input.Pointer) => void;
|
|
||||||
|
|
||||||
constructor(options: InventoryWidgetOptions) {
|
|
||||||
this.scene = options.scene;
|
|
||||||
this.gameState = options.gameState;
|
|
||||||
this.cellSize = options.cellSize;
|
|
||||||
this.gridGap = options.gridGap ?? 2;
|
|
||||||
this.isLocked = options.isLocked ?? false;
|
|
||||||
|
|
||||||
const inventory = this.getInventory();
|
|
||||||
|
|
||||||
this.container = this.scene.add.container(options.x, options.y);
|
|
||||||
|
|
||||||
// 1. Static grid background (drawn once)
|
|
||||||
this.backgroundRenderer = new GridBackgroundRenderer({
|
|
||||||
scene: this.scene,
|
|
||||||
parentContainer: this.container,
|
|
||||||
cellSize: this.cellSize,
|
|
||||||
gridGap: this.gridGap,
|
|
||||||
gridX: this.gridX,
|
|
||||||
gridY: this.gridY,
|
|
||||||
});
|
|
||||||
this.backgroundRenderer.draw(inventory.width, inventory.height);
|
|
||||||
|
|
||||||
// 2. Reactive item spawner
|
|
||||||
this.itemSpawner = new InventoryItemSpawner({
|
|
||||||
scene: this.scene,
|
|
||||||
gameState: this.gameState,
|
|
||||||
parentContainer: this.container,
|
|
||||||
cellSize: this.cellSize,
|
|
||||||
gridGap: this.gridGap,
|
|
||||||
gridX: this.gridX,
|
|
||||||
gridY: this.gridY,
|
|
||||||
isLocked: () => this.isLocked,
|
|
||||||
isDragging: () => this.dragController.isDragging(),
|
|
||||||
onItemDragStart: (itemId, item, itemContainer) => {
|
|
||||||
this.handleItemDragStart(itemId, item, itemContainer);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 3. Drag controller
|
|
||||||
this.dragController = new DragController({
|
|
||||||
scene: this.scene,
|
|
||||||
container: this.container,
|
|
||||||
cellSize: this.cellSize,
|
|
||||||
gridGap: this.gridGap,
|
|
||||||
gridX: this.gridX,
|
|
||||||
gridY: this.gridY,
|
|
||||||
getInventory: () => this.getInventory(),
|
|
||||||
getItemColor: (id) => this.itemSpawner.getItemColor(id),
|
|
||||||
onPlaceItem: (item) => this.handlePlaceItem(item),
|
|
||||||
onCreateLostItem: (id, shape, transform, meta, x, y) =>
|
|
||||||
this.handleCreateLostItem(id, shape, transform, meta, x, y),
|
|
||||||
});
|
|
||||||
|
|
||||||
// 4. Lost item manager
|
|
||||||
this.lostItemManager = new LostItemManager({
|
|
||||||
scene: this.scene,
|
|
||||||
cellSize: this.cellSize,
|
|
||||||
gridGap: this.gridGap,
|
|
||||||
getItemColor: (id) => this.itemSpawner.getItemColor(id),
|
|
||||||
onLostItemDragStart: (id, lostContainer) =>
|
|
||||||
this.dragController.startLostItemDrag(
|
|
||||||
id,
|
|
||||||
this.getLostItemShape(id),
|
|
||||||
this.getLostItemTransform(id),
|
|
||||||
this.getLostItemMeta(id),
|
|
||||||
lostContainer,
|
|
||||||
),
|
|
||||||
isDragging: () => this.dragController.isDragging(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Activate the spawner effect (auto-cleans up on dispose)
|
|
||||||
this.spawnDispose = spawnEffect(this.itemSpawner);
|
|
||||||
|
|
||||||
// Right-click rotation handler
|
|
||||||
this.setupInput();
|
|
||||||
|
|
||||||
this.scene.events.once("shutdown", () => this.destroy());
|
|
||||||
}
|
|
||||||
|
|
||||||
private getInventory(): GridInventory<GameItemMeta> {
|
|
||||||
return this.gameState.value
|
|
||||||
.inventory as unknown as GridInventory<GameItemMeta>;
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleItemDragStart(
|
|
||||||
itemId: string,
|
|
||||||
item: InventoryItem<GameItemMeta>,
|
|
||||||
itemContainer: Phaser.GameObjects.Container,
|
|
||||||
): void {
|
|
||||||
// Mark as dragging FIRST so spawner excludes it from getData().
|
|
||||||
// This prevents the spawner effect from destroying the container
|
|
||||||
// when we later update the inventory state.
|
|
||||||
this.itemSpawner.markDragging(itemId);
|
|
||||||
|
|
||||||
// Start drag session
|
|
||||||
this.dragController.startDrag(itemId, item, itemContainer);
|
|
||||||
}
|
|
||||||
|
|
||||||
private handlePlaceItem(item: InventoryItem<GameItemMeta>): void {
|
|
||||||
this.gameState.produce((state) => {
|
|
||||||
placeItem(state.inventory, item);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Unmark dragging so spawner picks it up on next effect run
|
|
||||||
this.itemSpawner.unmarkDragging(item.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleCreateLostItem(
|
|
||||||
itemId: string,
|
|
||||||
shape: InventoryItem<GameItemMeta>["shape"],
|
|
||||||
transform: InventoryItem<GameItemMeta>["transform"],
|
|
||||||
meta: InventoryItem<GameItemMeta>["meta"],
|
|
||||||
x: number,
|
|
||||||
y: number,
|
|
||||||
): void {
|
|
||||||
// Remove from inventory since it's dropped outside valid placement
|
|
||||||
this.gameState.produce((state) => {
|
|
||||||
removeItemFromGrid(state.inventory, itemId);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.lostItemManager.createLostItem(itemId, shape, transform, meta, x, y);
|
|
||||||
|
|
||||||
// Unmark dragging — item is now "lost" and managed by LostItemManager
|
|
||||||
this.itemSpawner.unmarkDragging(itemId);
|
|
||||||
}
|
|
||||||
|
|
||||||
private getLostItemShape(itemId: string) {
|
|
||||||
return this.lostItemManager.getLostItem(itemId)?.shape!;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getLostItemTransform(itemId: string) {
|
|
||||||
return this.lostItemManager.getLostItem(itemId)?.transform!;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getLostItemMeta(itemId: string) {
|
|
||||||
return this.lostItemManager.getLostItem(itemId)?.meta!;
|
|
||||||
}
|
|
||||||
|
|
||||||
private setupInput(): void {
|
|
||||||
this.rightClickHandler = (pointer: Phaser.Input.Pointer) => {
|
|
||||||
if (!this.dragController.isDragging()) return;
|
|
||||||
if (pointer.button === 1) {
|
|
||||||
this.dragController.rotateDraggedItem();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
this.scene.input.on("pointerdown", this.rightClickHandler);
|
|
||||||
}
|
|
||||||
|
|
||||||
public setLocked(locked: boolean): void {
|
|
||||||
this.isLocked = locked;
|
|
||||||
}
|
|
||||||
|
|
||||||
public getLostItems(): string[] {
|
|
||||||
return this.lostItemManager.getLostItemIds();
|
|
||||||
}
|
|
||||||
|
|
||||||
public clearLostItems(): void {
|
|
||||||
this.lostItemManager.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Force re-sync of item visuals with current inventory state.
|
|
||||||
* With spawnEffect this is usually automatic, but useful after
|
|
||||||
* external state changes that don't trigger the effect.
|
|
||||||
*/
|
|
||||||
public refresh(): void {
|
|
||||||
// The spawner effect automatically re-syncs when gameState.value changes.
|
|
||||||
// If immediate refresh is needed, reading the signal triggers the effect.
|
|
||||||
void this.gameState.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
public destroy(): void {
|
|
||||||
this.scene.input.off("pointerdown", this.rightClickHandler);
|
|
||||||
|
|
||||||
if (this.spawnDispose) {
|
|
||||||
this.spawnDispose();
|
|
||||||
this.spawnDispose = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.dragController.destroy();
|
|
||||||
this.lostItemManager.destroy();
|
|
||||||
this.backgroundRenderer.destroy();
|
|
||||||
this.container.destroy();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
import Phaser from "phaser";
|
|
||||||
import {
|
|
||||||
type InventoryItem,
|
|
||||||
type GameItemMeta,
|
|
||||||
} from "boardgame-core/samples/slay-the-spire-like";
|
|
||||||
|
|
||||||
export interface LostItem {
|
|
||||||
id: string;
|
|
||||||
container: Phaser.GameObjects.Container;
|
|
||||||
shape: InventoryItem<GameItemMeta>["shape"];
|
|
||||||
transform: InventoryItem<GameItemMeta>["transform"];
|
|
||||||
meta: InventoryItem<GameItemMeta>["meta"];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LostItemManagerOptions {
|
|
||||||
scene: Phaser.Scene;
|
|
||||||
cellSize: number;
|
|
||||||
gridGap: number;
|
|
||||||
getItemColor: (itemId: string) => number;
|
|
||||||
onLostItemDragStart: (
|
|
||||||
itemId: string,
|
|
||||||
container: Phaser.GameObjects.Container,
|
|
||||||
) => void;
|
|
||||||
isDragging: () => boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Manages "lost" items — items that were dropped outside valid grid placement.
|
|
||||||
* Renders them as semi-transparent red-bordered containers that can be re-dragged.
|
|
||||||
*/
|
|
||||||
export class LostItemManager {
|
|
||||||
private scene: Phaser.Scene;
|
|
||||||
private cellSize: number;
|
|
||||||
private gridGap: number;
|
|
||||||
private getItemColor: (itemId: string) => number;
|
|
||||||
private onLostItemDragStart: (
|
|
||||||
itemId: string,
|
|
||||||
container: Phaser.GameObjects.Container,
|
|
||||||
) => void;
|
|
||||||
private isDragging: () => boolean;
|
|
||||||
|
|
||||||
private lostItems = new Map<string, LostItem>();
|
|
||||||
|
|
||||||
constructor(options: LostItemManagerOptions) {
|
|
||||||
this.scene = options.scene;
|
|
||||||
this.cellSize = options.cellSize;
|
|
||||||
this.gridGap = options.gridGap;
|
|
||||||
this.getItemColor = options.getItemColor;
|
|
||||||
this.onLostItemDragStart = options.onLostItemDragStart;
|
|
||||||
this.isDragging = options.isDragging;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a visual representation of a lost item at the given position.
|
|
||||||
*/
|
|
||||||
createLostItem(
|
|
||||||
itemId: string,
|
|
||||||
shape: InventoryItem<GameItemMeta>["shape"],
|
|
||||||
transform: InventoryItem<GameItemMeta>["transform"],
|
|
||||||
meta: InventoryItem<GameItemMeta>["meta"],
|
|
||||||
x: number,
|
|
||||||
y: number,
|
|
||||||
): void {
|
|
||||||
const container = this.scene.add.container(x, y).setDepth(500);
|
|
||||||
|
|
||||||
const graphics = this.scene.add.graphics();
|
|
||||||
const color = this.getItemColor(itemId);
|
|
||||||
|
|
||||||
for (let gy = 0; gy < shape.height; gy++) {
|
|
||||||
for (let gx = 0; gx < shape.width; gx++) {
|
|
||||||
if (shape.grid[gy]?.[gx]) {
|
|
||||||
graphics.fillStyle(color, 0.5);
|
|
||||||
graphics.fillRect(
|
|
||||||
gx * (this.cellSize + this.gridGap),
|
|
||||||
gy * (this.cellSize + this.gridGap),
|
|
||||||
this.cellSize - 2,
|
|
||||||
this.cellSize - 2,
|
|
||||||
);
|
|
||||||
graphics.lineStyle(2, 0xff4444);
|
|
||||||
graphics.strokeRect(
|
|
||||||
gx * (this.cellSize + this.gridGap),
|
|
||||||
gy * (this.cellSize + this.gridGap),
|
|
||||||
this.cellSize,
|
|
||||||
this.cellSize,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
container.add(graphics);
|
|
||||||
|
|
||||||
const name = meta?.itemData.name ?? itemId;
|
|
||||||
const text = this.scene.add
|
|
||||||
.text(0, -20, `${name} (lost)`, {
|
|
||||||
fontSize: "12px",
|
|
||||||
color: "#ff4444",
|
|
||||||
fontStyle: "italic",
|
|
||||||
})
|
|
||||||
.setOrigin(0.5);
|
|
||||||
container.add(text);
|
|
||||||
|
|
||||||
const hitRect = new Phaser.Geom.Rectangle(
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
this.cellSize,
|
|
||||||
this.cellSize,
|
|
||||||
);
|
|
||||||
container.setInteractive(hitRect, Phaser.Geom.Rectangle.Contains);
|
|
||||||
|
|
||||||
container.on("pointerdown", (pointer: Phaser.Input.Pointer) => {
|
|
||||||
// Guard against stale events firing on destroyed containers
|
|
||||||
if (!container.scene || !container.active) return;
|
|
||||||
if (this.isDragging()) return;
|
|
||||||
if (pointer.button === 0) {
|
|
||||||
this.onLostItemDragStart(itemId, container);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.lostItems.set(itemId, {
|
|
||||||
id: itemId,
|
|
||||||
container,
|
|
||||||
shape,
|
|
||||||
transform: { ...transform },
|
|
||||||
meta,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all lost item IDs.
|
|
||||||
*/
|
|
||||||
getLostItemIds(): string[] {
|
|
||||||
return Array.from(this.lostItems.keys());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a lost item by ID, or undefined if not found.
|
|
||||||
*/
|
|
||||||
getLostItem(itemId: string): LostItem | undefined {
|
|
||||||
return this.lostItems.get(itemId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Destroy and clear all lost items.
|
|
||||||
*/
|
|
||||||
clear(): void {
|
|
||||||
for (const lost of this.lostItems.values()) {
|
|
||||||
lost.container.destroy();
|
|
||||||
}
|
|
||||||
this.lostItems.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Destroy all managed visuals.
|
|
||||||
*/
|
|
||||||
destroy(): void {
|
|
||||||
this.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Generated
+55
-37
@@ -25,7 +25,7 @@ importers:
|
|||||||
version: 8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
|
version: 8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^3.2.4
|
specifier: ^3.2.4
|
||||||
version: 3.2.4(lightningcss@1.32.0)
|
version: 3.2.4(@types/node@25.6.0)(lightningcss@1.32.0)
|
||||||
|
|
||||||
packages/boop-game:
|
packages/boop-game:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -53,13 +53,13 @@ importers:
|
|||||||
devDependencies:
|
devDependencies:
|
||||||
'@preact/preset-vite':
|
'@preact/preset-vite':
|
||||||
specifier: ^2.8.1
|
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))
|
version: 2.10.5(@babel/core@7.29.0)(preact@10.29.0)(rollup@4.60.1)(vite@5.4.21(@types/node@25.6.0)(lightningcss@1.32.0))
|
||||||
'@preact/signals':
|
'@preact/signals':
|
||||||
specifier: ^2.9.0
|
specifier: ^2.9.0
|
||||||
version: 2.9.0(preact@10.29.0)
|
version: 2.9.0(preact@10.29.0)
|
||||||
'@tailwindcss/vite':
|
'@tailwindcss/vite':
|
||||||
specifier: ^4.0.0
|
specifier: ^4.0.0
|
||||||
version: 4.2.2(vite@5.4.21(lightningcss@1.32.0))
|
version: 4.2.2(vite@5.4.21(@types/node@25.6.0)(lightningcss@1.32.0))
|
||||||
tailwindcss:
|
tailwindcss:
|
||||||
specifier: ^4.0.0
|
specifier: ^4.0.0
|
||||||
version: 4.2.2
|
version: 4.2.2
|
||||||
@@ -68,7 +68,7 @@ importers:
|
|||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
vite:
|
vite:
|
||||||
specifier: ^5.1.0
|
specifier: ^5.1.0
|
||||||
version: 5.4.21(lightningcss@1.32.0)
|
version: 5.4.21(@types/node@25.6.0)(lightningcss@1.32.0)
|
||||||
|
|
||||||
packages/framework:
|
packages/framework:
|
||||||
devDependencies:
|
devDependencies:
|
||||||
@@ -98,7 +98,7 @@ importers:
|
|||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^3.2.4
|
specifier: ^3.2.4
|
||||||
version: 3.2.4(lightningcss@1.32.0)
|
version: 3.2.4(@types/node@25.6.0)(lightningcss@1.32.0)
|
||||||
|
|
||||||
packages/onitama-game:
|
packages/onitama-game:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -123,13 +123,13 @@ importers:
|
|||||||
devDependencies:
|
devDependencies:
|
||||||
'@preact/preset-vite':
|
'@preact/preset-vite':
|
||||||
specifier: ^2.8.1
|
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))
|
version: 2.10.5(@babel/core@7.29.0)(preact@10.29.0)(rollup@4.60.1)(vite@5.4.21(@types/node@25.6.0)(lightningcss@1.32.0))
|
||||||
'@preact/signals':
|
'@preact/signals':
|
||||||
specifier: ^2.9.0
|
specifier: ^2.9.0
|
||||||
version: 2.9.0(preact@10.29.0)
|
version: 2.9.0(preact@10.29.0)
|
||||||
'@tailwindcss/vite':
|
'@tailwindcss/vite':
|
||||||
specifier: ^4.0.0
|
specifier: ^4.0.0
|
||||||
version: 4.2.2(vite@5.4.21(lightningcss@1.32.0))
|
version: 4.2.2(vite@5.4.21(@types/node@25.6.0)(lightningcss@1.32.0))
|
||||||
tailwindcss:
|
tailwindcss:
|
||||||
specifier: ^4.0.0
|
specifier: ^4.0.0
|
||||||
version: 4.2.2
|
version: 4.2.2
|
||||||
@@ -138,7 +138,7 @@ importers:
|
|||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
vite:
|
vite:
|
||||||
specifier: ^5.1.0
|
specifier: ^5.1.0
|
||||||
version: 5.4.21(lightningcss@1.32.0)
|
version: 5.4.21(@types/node@25.6.0)(lightningcss@1.32.0)
|
||||||
|
|
||||||
packages/regicide-game:
|
packages/regicide-game:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -163,13 +163,13 @@ importers:
|
|||||||
devDependencies:
|
devDependencies:
|
||||||
'@preact/preset-vite':
|
'@preact/preset-vite':
|
||||||
specifier: ^2.8.1
|
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))
|
version: 2.10.5(@babel/core@7.29.0)(preact@10.29.0)(rollup@4.60.1)(vite@5.4.21(@types/node@25.6.0)(lightningcss@1.32.0))
|
||||||
'@preact/signals':
|
'@preact/signals':
|
||||||
specifier: ^2.9.0
|
specifier: ^2.9.0
|
||||||
version: 2.9.0(preact@10.29.0)
|
version: 2.9.0(preact@10.29.0)
|
||||||
'@tailwindcss/vite':
|
'@tailwindcss/vite':
|
||||||
specifier: ^4.0.0
|
specifier: ^4.0.0
|
||||||
version: 4.2.2(vite@5.4.21(lightningcss@1.32.0))
|
version: 4.2.2(vite@5.4.21(@types/node@25.6.0)(lightningcss@1.32.0))
|
||||||
tailwindcss:
|
tailwindcss:
|
||||||
specifier: ^4.0.0
|
specifier: ^4.0.0
|
||||||
version: 4.2.2
|
version: 4.2.2
|
||||||
@@ -178,10 +178,10 @@ importers:
|
|||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
vite:
|
vite:
|
||||||
specifier: ^5.1.0
|
specifier: ^5.1.0
|
||||||
version: 5.4.21(lightningcss@1.32.0)
|
version: 5.4.21(@types/node@25.6.0)(lightningcss@1.32.0)
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^3.2.4
|
specifier: ^3.2.4
|
||||||
version: 3.2.4(lightningcss@1.32.0)
|
version: 3.2.4(@types/node@25.6.0)(lightningcss@1.32.0)
|
||||||
|
|
||||||
packages/sample-game:
|
packages/sample-game:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -206,13 +206,13 @@ importers:
|
|||||||
devDependencies:
|
devDependencies:
|
||||||
'@preact/preset-vite':
|
'@preact/preset-vite':
|
||||||
specifier: ^2.8.1
|
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))
|
version: 2.10.5(@babel/core@7.29.0)(preact@10.29.0)(rollup@4.60.1)(vite@5.4.21(@types/node@25.6.0)(lightningcss@1.32.0))
|
||||||
'@preact/signals':
|
'@preact/signals':
|
||||||
specifier: ^2.9.0
|
specifier: ^2.9.0
|
||||||
version: 2.9.0(preact@10.29.0)
|
version: 2.9.0(preact@10.29.0)
|
||||||
'@tailwindcss/vite':
|
'@tailwindcss/vite':
|
||||||
specifier: ^4.0.0
|
specifier: ^4.0.0
|
||||||
version: 4.2.2(vite@5.4.21(lightningcss@1.32.0))
|
version: 4.2.2(vite@5.4.21(@types/node@25.6.0)(lightningcss@1.32.0))
|
||||||
tailwindcss:
|
tailwindcss:
|
||||||
specifier: ^4.0.0
|
specifier: ^4.0.0
|
||||||
version: 4.2.2
|
version: 4.2.2
|
||||||
@@ -221,7 +221,7 @@ importers:
|
|||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
vite:
|
vite:
|
||||||
specifier: ^5.1.0
|
specifier: ^5.1.0
|
||||||
version: 5.4.21(lightningcss@1.32.0)
|
version: 5.4.21(@types/node@25.6.0)(lightningcss@1.32.0)
|
||||||
|
|
||||||
packages/sts-like-viewer:
|
packages/sts-like-viewer:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -246,13 +246,16 @@ importers:
|
|||||||
devDependencies:
|
devDependencies:
|
||||||
'@preact/preset-vite':
|
'@preact/preset-vite':
|
||||||
specifier: ^2.8.1
|
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))
|
version: 2.10.5(@babel/core@7.29.0)(preact@10.29.0)(rollup@4.60.1)(vite@5.4.21(@types/node@25.6.0)(lightningcss@1.32.0))
|
||||||
'@preact/signals':
|
'@preact/signals':
|
||||||
specifier: ^2.9.0
|
specifier: ^2.9.0
|
||||||
version: 2.9.0(preact@10.29.0)
|
version: 2.9.0(preact@10.29.0)
|
||||||
'@tailwindcss/vite':
|
'@tailwindcss/vite':
|
||||||
specifier: ^4.0.0
|
specifier: ^4.0.0
|
||||||
version: 4.2.2(vite@5.4.21(lightningcss@1.32.0))
|
version: 4.2.2(vite@5.4.21(@types/node@25.6.0)(lightningcss@1.32.0))
|
||||||
|
'@types/node':
|
||||||
|
specifier: ^25.6.0
|
||||||
|
version: 25.6.0
|
||||||
tailwindcss:
|
tailwindcss:
|
||||||
specifier: ^4.0.0
|
specifier: ^4.0.0
|
||||||
version: 4.2.2
|
version: 4.2.2
|
||||||
@@ -261,7 +264,7 @@ importers:
|
|||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
vite:
|
vite:
|
||||||
specifier: ^5.1.0
|
specifier: ^5.1.0
|
||||||
version: 5.4.21(lightningcss@1.32.0)
|
version: 5.4.21(@types/node@25.6.0)(lightningcss@1.32.0)
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
@@ -1020,6 +1023,9 @@ packages:
|
|||||||
'@types/json5@0.0.29':
|
'@types/json5@0.0.29':
|
||||||
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
|
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
|
||||||
|
|
||||||
|
'@types/node@25.6.0':
|
||||||
|
resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==}
|
||||||
|
|
||||||
'@typescript-eslint/eslint-plugin@8.58.2':
|
'@typescript-eslint/eslint-plugin@8.58.2':
|
||||||
resolution: {integrity: sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw==}
|
resolution: {integrity: sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
@@ -2337,6 +2343,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
|
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
undici-types@7.19.2:
|
||||||
|
resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==}
|
||||||
|
|
||||||
update-browserslist-db@1.2.3:
|
update-browserslist-db@1.2.3:
|
||||||
resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
|
resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -2814,19 +2823,19 @@ snapshots:
|
|||||||
'@jridgewell/resolve-uri': 3.1.2
|
'@jridgewell/resolve-uri': 3.1.2
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|
||||||
'@preact/preset-vite@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/preset-vite@2.10.5(@babel/core@7.29.0)(preact@10.29.0)(rollup@4.60.1)(vite@5.4.21(@types/node@25.6.0)(lightningcss@1.32.0))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.29.0
|
'@babel/core': 7.29.0
|
||||||
'@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.29.0)
|
'@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.29.0)
|
||||||
'@babel/plugin-transform-react-jsx-development': 7.27.1(@babel/core@7.29.0)
|
'@babel/plugin-transform-react-jsx-development': 7.27.1(@babel/core@7.29.0)
|
||||||
'@prefresh/vite': 2.4.12(preact@10.29.0)(vite@5.4.21(lightningcss@1.32.0))
|
'@prefresh/vite': 2.4.12(preact@10.29.0)(vite@5.4.21(@types/node@25.6.0)(lightningcss@1.32.0))
|
||||||
'@rollup/pluginutils': 5.3.0(rollup@4.60.1)
|
'@rollup/pluginutils': 5.3.0(rollup@4.60.1)
|
||||||
babel-plugin-transform-hook-names: 1.0.2(@babel/core@7.29.0)
|
babel-plugin-transform-hook-names: 1.0.2(@babel/core@7.29.0)
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
magic-string: 0.30.21
|
magic-string: 0.30.21
|
||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
vite: 5.4.21(lightningcss@1.32.0)
|
vite: 5.4.21(@types/node@25.6.0)(lightningcss@1.32.0)
|
||||||
vite-prerender-plugin: 0.5.13(vite@5.4.21(lightningcss@1.32.0))
|
vite-prerender-plugin: 0.5.13(vite@5.4.21(@types/node@25.6.0)(lightningcss@1.32.0))
|
||||||
zimmerframe: 1.1.4
|
zimmerframe: 1.1.4
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- preact
|
- preact
|
||||||
@@ -2848,7 +2857,7 @@ snapshots:
|
|||||||
|
|
||||||
'@prefresh/utils@1.2.1': {}
|
'@prefresh/utils@1.2.1': {}
|
||||||
|
|
||||||
'@prefresh/vite@2.4.12(preact@10.29.0)(vite@5.4.21(lightningcss@1.32.0))':
|
'@prefresh/vite@2.4.12(preact@10.29.0)(vite@5.4.21(@types/node@25.6.0)(lightningcss@1.32.0))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.29.0
|
'@babel/core': 7.29.0
|
||||||
'@prefresh/babel-plugin': 0.5.3
|
'@prefresh/babel-plugin': 0.5.3
|
||||||
@@ -2856,7 +2865,7 @@ snapshots:
|
|||||||
'@prefresh/utils': 1.2.1
|
'@prefresh/utils': 1.2.1
|
||||||
'@rollup/pluginutils': 4.2.1
|
'@rollup/pluginutils': 4.2.1
|
||||||
preact: 10.29.0
|
preact: 10.29.0
|
||||||
vite: 5.4.21(lightningcss@1.32.0)
|
vite: 5.4.21(@types/node@25.6.0)(lightningcss@1.32.0)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
@@ -3011,12 +3020,12 @@ snapshots:
|
|||||||
'@tailwindcss/oxide-win32-arm64-msvc': 4.2.2
|
'@tailwindcss/oxide-win32-arm64-msvc': 4.2.2
|
||||||
'@tailwindcss/oxide-win32-x64-msvc': 4.2.2
|
'@tailwindcss/oxide-win32-x64-msvc': 4.2.2
|
||||||
|
|
||||||
'@tailwindcss/vite@4.2.2(vite@5.4.21(lightningcss@1.32.0))':
|
'@tailwindcss/vite@4.2.2(vite@5.4.21(@types/node@25.6.0)(lightningcss@1.32.0))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tailwindcss/node': 4.2.2
|
'@tailwindcss/node': 4.2.2
|
||||||
'@tailwindcss/oxide': 4.2.2
|
'@tailwindcss/oxide': 4.2.2
|
||||||
tailwindcss: 4.2.2
|
tailwindcss: 4.2.2
|
||||||
vite: 5.4.21(lightningcss@1.32.0)
|
vite: 5.4.21(@types/node@25.6.0)(lightningcss@1.32.0)
|
||||||
|
|
||||||
'@types/chai@5.2.3':
|
'@types/chai@5.2.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -3031,6 +3040,10 @@ snapshots:
|
|||||||
|
|
||||||
'@types/json5@0.0.29': {}
|
'@types/json5@0.0.29': {}
|
||||||
|
|
||||||
|
'@types/node@25.6.0':
|
||||||
|
dependencies:
|
||||||
|
undici-types: 7.19.2
|
||||||
|
|
||||||
'@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
|
'@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@eslint-community/regexpp': 4.12.2
|
'@eslint-community/regexpp': 4.12.2
|
||||||
@@ -3130,13 +3143,13 @@ snapshots:
|
|||||||
chai: 5.3.3
|
chai: 5.3.3
|
||||||
tinyrainbow: 2.0.0
|
tinyrainbow: 2.0.0
|
||||||
|
|
||||||
'@vitest/mocker@3.2.4(vite@5.4.21(lightningcss@1.32.0))':
|
'@vitest/mocker@3.2.4(vite@5.4.21(@types/node@25.6.0)(lightningcss@1.32.0))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vitest/spy': 3.2.4
|
'@vitest/spy': 3.2.4
|
||||||
estree-walker: 3.0.3
|
estree-walker: 3.0.3
|
||||||
magic-string: 0.30.21
|
magic-string: 0.30.21
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
vite: 5.4.21(lightningcss@1.32.0)
|
vite: 5.4.21(@types/node@25.6.0)(lightningcss@1.32.0)
|
||||||
|
|
||||||
'@vitest/pretty-format@3.2.4':
|
'@vitest/pretty-format@3.2.4':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -4567,6 +4580,8 @@ snapshots:
|
|||||||
has-symbols: 1.1.0
|
has-symbols: 1.1.0
|
||||||
which-boxed-primitive: 1.1.1
|
which-boxed-primitive: 1.1.1
|
||||||
|
|
||||||
|
undici-types@7.19.2: {}
|
||||||
|
|
||||||
update-browserslist-db@1.2.3(browserslist@4.28.2):
|
update-browserslist-db@1.2.3(browserslist@4.28.2):
|
||||||
dependencies:
|
dependencies:
|
||||||
browserslist: 4.28.2
|
browserslist: 4.28.2
|
||||||
@@ -4577,13 +4592,13 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
punycode: 2.3.1
|
punycode: 2.3.1
|
||||||
|
|
||||||
vite-node@3.2.4(lightningcss@1.32.0):
|
vite-node@3.2.4(@types/node@25.6.0)(lightningcss@1.32.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
cac: 6.7.14
|
cac: 6.7.14
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
es-module-lexer: 1.7.0
|
es-module-lexer: 1.7.0
|
||||||
pathe: 2.0.3
|
pathe: 2.0.3
|
||||||
vite: 5.4.21(lightningcss@1.32.0)
|
vite: 5.4.21(@types/node@25.6.0)(lightningcss@1.32.0)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@types/node'
|
- '@types/node'
|
||||||
- less
|
- less
|
||||||
@@ -4595,7 +4610,7 @@ snapshots:
|
|||||||
- supports-color
|
- supports-color
|
||||||
- terser
|
- terser
|
||||||
|
|
||||||
vite-prerender-plugin@0.5.13(vite@5.4.21(lightningcss@1.32.0)):
|
vite-prerender-plugin@0.5.13(vite@5.4.21(@types/node@25.6.0)(lightningcss@1.32.0)):
|
||||||
dependencies:
|
dependencies:
|
||||||
kolorist: 1.8.0
|
kolorist: 1.8.0
|
||||||
magic-string: 0.30.21
|
magic-string: 0.30.21
|
||||||
@@ -4603,22 +4618,23 @@ snapshots:
|
|||||||
simple-code-frame: 1.3.0
|
simple-code-frame: 1.3.0
|
||||||
source-map: 0.7.6
|
source-map: 0.7.6
|
||||||
stack-trace: 1.0.0-pre2
|
stack-trace: 1.0.0-pre2
|
||||||
vite: 5.4.21(lightningcss@1.32.0)
|
vite: 5.4.21(@types/node@25.6.0)(lightningcss@1.32.0)
|
||||||
|
|
||||||
vite@5.4.21(lightningcss@1.32.0):
|
vite@5.4.21(@types/node@25.6.0)(lightningcss@1.32.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
esbuild: 0.21.5
|
esbuild: 0.21.5
|
||||||
postcss: 8.5.8
|
postcss: 8.5.8
|
||||||
rollup: 4.60.1
|
rollup: 4.60.1
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
|
'@types/node': 25.6.0
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
lightningcss: 1.32.0
|
lightningcss: 1.32.0
|
||||||
|
|
||||||
vitest@3.2.4(lightningcss@1.32.0):
|
vitest@3.2.4(@types/node@25.6.0)(lightningcss@1.32.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/chai': 5.2.3
|
'@types/chai': 5.2.3
|
||||||
'@vitest/expect': 3.2.4
|
'@vitest/expect': 3.2.4
|
||||||
'@vitest/mocker': 3.2.4(vite@5.4.21(lightningcss@1.32.0))
|
'@vitest/mocker': 3.2.4(vite@5.4.21(@types/node@25.6.0)(lightningcss@1.32.0))
|
||||||
'@vitest/pretty-format': 3.2.4
|
'@vitest/pretty-format': 3.2.4
|
||||||
'@vitest/runner': 3.2.4
|
'@vitest/runner': 3.2.4
|
||||||
'@vitest/snapshot': 3.2.4
|
'@vitest/snapshot': 3.2.4
|
||||||
@@ -4636,9 +4652,11 @@ snapshots:
|
|||||||
tinyglobby: 0.2.15
|
tinyglobby: 0.2.15
|
||||||
tinypool: 1.1.1
|
tinypool: 1.1.1
|
||||||
tinyrainbow: 2.0.0
|
tinyrainbow: 2.0.0
|
||||||
vite: 5.4.21(lightningcss@1.32.0)
|
vite: 5.4.21(@types/node@25.6.0)(lightningcss@1.32.0)
|
||||||
vite-node: 3.2.4(lightningcss@1.32.0)
|
vite-node: 3.2.4(@types/node@25.6.0)(lightningcss@1.32.0)
|
||||||
why-is-node-running: 2.3.0
|
why-is-node-running: 2.3.0
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/node': 25.6.0
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- less
|
- less
|
||||||
- lightningcss
|
- lightningcss
|
||||||
|
|||||||
Reference in New Issue
Block a user