Compare commits

...
65 Commits
Author SHA1 Message Date
hyper ad2c808482 feat(sts-viewer): add effect application logging and fix command error
handling

- Log effect application details in `CombatTestScene`
- Update `tryPlayCard` to use "global" scope and handle error reasons
- Register desert triggers in `createCombatModule`
2026-04-23 16:28:10 +08:00
hyper f893ac9ff7 feat(framework): add generic types to GameHostScene
Update GameHostScene to support generic types for TResult and TModule,
allowing better type safety when interacting with the game host and
its modules.
2026-04-23 16:11:15 +08:00
hypercross dfbdaa3499 feat(sts-viewer): add spawn/despawn tweens and async interruptions
Add visual tweens for Buff and Card game objects. Update spawners to
use `GameHostScene` and `addInterruption` to ensure animations
complete before the next state change occurs.
2026-04-22 21:29:43 +08:00
hypercross c29b9a43b3 feat(framework): decouple spawner update logic
Extract the core update logic from `spawnEffect` into
`createSpawnUpdate`.
This allows manual updates of spawned objects without relying on
Preact effects, which is useful for non-reactive or imperative
update cycles.

Refactor `Buff` and `BuffSpawner` in `sts-like-viewer` to use the new
`createSpawnUpdate` API for more efficient buff rendering.
2026-04-22 20:59:35 +08:00
hypercross 447a5cfbd0 feat(sts-viewer): improve card and effect visual indicators
- Update `CardContainer` to use descriptive emojis for different target
  types
- Use emoji icons for combat unit status effects instead of keys
- Update `CardSpawner` default target type to 'player'
- Fix target type check in `CombatTestScene` to use 'enemy' instead of
  'single'
2026-04-22 19:47:37 +08:00
hypercross dda290bf9c feat(framework): add hoverEffect utility
Add a `hoverEffect` utility to `boardgame-phaser` to simplify
handling pointerover and pointerout events. Refactor
`CardContainer` in `sts-like-viewer` to use this new utility and
centralize card dimensions in `CARD_CONFIG`.
2026-04-22 15:13:33 +08:00
hypercross 5d84c42b78 style: reformat CombatUnitContainer for readability 2026-04-22 11:46:39 +08:00
hypercross a6ddafb802 build: add @types/node to sts-like-viewer 2026-04-22 11:46:24 +08:00
hypercross a181932fb3 refactor: simplify CombatTestScene instantiation in App
Remove manual instantiation of CombatTestScene in favor of passing
the class directly to PhaserScene, allowing the framework to handle
scene lifecycle.
2026-04-22 00:56:45 +08:00
hypercross 887da22baa fix(framework): check instance of Scene in PhaserScene
Ensure the scene passed to PhaserScene is an instance of Phaser.Scene
before deciding whether to instantiate it.
2026-04-22 00:55:38 +08:00
hypercross 5355afb23e feat(sts-viewer): implement card spawning and combat interaction
Add `CardContainer` and `CardSpawner` to handle visual representation
of cards in the hand. Refactor `CombatTestScene` to use `GameHostScene`
and implement card selection/targeting logic for combat simulation.
2026-04-22 00:53:57 +08:00
hypercross 3f2ba84a06 feat(sts-viewer): add CombatTestScene and unit visuals
Implement a new combat test scene to visualize Slay the Spire-like
combat states. This includes:

- `CombatUnitContainer`: A Phaser container for displaying unit HP,
  name, buffs, and enemy intents.
- `CombatUnitSpawner`: A spawner to manage the lifecycle of combat
  units based on the combat state.
- `
2026-04-21 23:50:47 +08:00
hypercross 9637312b7c feat(sts-viewer): implement combat state and fix inventory removal
- Add `createCombatState` to initialize combat using inventory and run
  state
- Implement `runContext` to bridge inventory data with combat logic
- Update inventory to use `removeItem` instead of `removeItemFromGrid`
2026-04-21 23:28:47 +08:00
hypercross 82c026628a fix(sts-like-viewer): correct y-coordinate comparison in
InventoryItemContainer

Fix a bug where the y-coordinate of an item's offset was being
compared against the target's x-coordinate instead of its y-coordinate.
2026-04-21 22:17:00 +08:00
hypercross a92b889dfa feat(sts-like-viewer): batch inventory item movement
Wrap item removal and placement in a batch to prevent multiple
reactive updates when moving items between inventories.
2026-04-21 16:50:48 +08:00
hypercross c97c2c4150 feat(sts-viewer): support multiple inventory surfaces
Refactor `InventoryTestScene` to support multiple inventory signals
simultaneously. This introduces `InventorySurface` to manage
positioning and signal mapping for multiple grids, allowing for
testing item movement between different inventory areas.
2026-04-21 16:48:45 +08:00
hypercross 422ddde200 refactor: support multiple inventory surfaces
Introduce `InventorySurface` to allow items to be moved between
different inventory grids.

- Update `InventoryItemContainer` to use surface-specific cell sizes
  and coordinate transformations.
- Update `InventoryItemSpawner` to handle multiple surfaces.
- Refactor `moveItem` to support transferring items from one
  `InventorySignal` to another.
- Add `InventorySurfaceState` to manage surface-specific metadata.
- Remove reliance on global `GRID_CONFIG` in favor of surface data.
2026-04-21 16:39:10 +08:00
hypercross 093eadbefd refactor: encapsulate InventoryItemContainer internals
Make `updateHitArea` and `renderGraphics` private to prevent external
access to internal rendering logic. Move the preview rotation reset
to ensure it always executes after a drag operation.
2026-04-21 09:51:10 +08:00
hypercross eff62bc0ed fix(sts-like-viewer): fix item movement detection and removal
- Update `InventoryItemContainer` to correctly detect rotation changes
- Use `removeItemFromGrid` in `InventoryTestScene` for item removal
2026-04-21 00:16:50 +08:00
hypercross fe0621cedf feat(sts-like-viewer): animate inventory item rotation
Introduce a tween for the item's rotation and update the hit area
calculation to account for the preview rotation. The graphics are now
centered within the container to support these transformations.
2026-04-20 22:15:05 +08:00
hypercross 30e2d9ac36 refactor: improve inventory item interaction and state
- Decouple hit area calculation from the rendering effect in
  `InventoryItemContainer`.
- Implement a dedicated `hitArea` property to track cell rectangles.
- Update `setupInteractive` to use the container itself as the hit area
  with a custom callback.
- Move drag-and-drop logic into a dedicated `setupDnDEffect` method.
- Add `addPreviewRotation` to `InventoryItemState` for cleaner rotation
  updates.
- Normalize `previewRotation` to stay within 0-360 degrees.
2026-04-20 21:59:08 +08:00
hypercross c9f573ef86 feat(sts-viewer): add item rotation support in inventory
Introduces the ability to rotate inventory items using the Alt button
during drag-and-drop.

- Updates `InventoryItemContainer` to handle rotation via
  `previewRotation`
- Adds `newRotation` parameter to `onMoveItem` callback and `moveItem`
  command
- Updates `InventoryItemState` to manage rotation state and compute
  transformed shapes
- Implements rotation logic in `renderGraphics` using `transformShape`
2026-04-20 21:40:23 +08:00
hypercross cc796d1e11 feat(framework): support multi-button drag and drop
Adds support for tracking the specific mouse button used during
drag-and-drop operations. This includes a new `ALTBUTTON` event type
to handle cases where a different button is pressed while a drag is
already in progress, and disables the browser context menu by default
in the Phaser configuration to prevent interference.
2026-04-20 20:33:56 +08:00
hypercross fd347e7a62 refactor: use InventoryItemState in InventoryItemContainer
Replace local signal-based state management with the dedicated
`InventoryItemState` class to encapsulate item properties and
logic.
2026-04-20 19:50:25 +08:00
hypercross 19dfafeb30 refactor: implement cell-based hit area for inventory items
Update `InventoryItemContainer` to use a custom hit area callback
based on individual cell rectangles instead of a single bounding
box. This allows for more precise interaction with non-rectangular
item shapes.
2026-04-20 19:45:01 +08:00
hypercross b2862e34d9 fix: improve inventory item drag-and-drop behavior
- Use `snapBack` instead of immediate coordinate reset when a drag fails
- Adjust grid snapping calculation to use center-relative coordinates
- Validate item placement by simulating removal from the grid first
2026-04-20 19:22:55 +08:00
hypercross fd41db2a89 fix(sts-like-viewer): defer interactive setup until shape is available
Delay calling `setupInteractive` in `InventoryItemContainer` until
the item shape is actually rendered to ensure the hit area matches
the visual representation.
2026-04-20 18:49:56 +08:00
hypercross f03e6c5a7b refactor: move InventoryItemSpawner and update grid drawing
- Move `InventoryItemSpawner` to `@/gameobjects`
- Wrap grid drawing in `addEffect` in `InventoryTestScene` to ensure
  reactivity
- Fix return type in `onMoveItem` callback
2026-04-20 18:44:50 +08:00
hypercross 7039938a72 refactor: delegate signal effects to DisposableBag
Introduce `addEffect` to `DisposableBag` to encapsulate Preact
signal effect creation and disposal. Update `ReactiveScene` to use
this method, simplifying effect management and ensuring proper
cleanup.
2026-04-20 18:18:26 +08:00
hypercross 45844ea800 fix(sts-viewer): use local coordinates for inventory item container
Update `InventoryItemSpawner` to draw graphics and text relative to the
container's local coordinate system instead of world coordinates.
The container is then positioned at the first cell's world position.
This also fixes drag positioning by calculating movement relative to
the initial drag start point.
2026-04-20 17:20:30 +08:00
hypercross 29b516c371 feat(sts-viewer): implement drag-and-drop for inventory items
Adds interactive drag-and-drop functionality to the inventory spawner,
allowing users to move items within the grid.

- Implements `InventoryItemSpawnerCallbacks` to handle item movement
- Adds `moveItem` utility to update inventory state via signals
- Integrates `dragDropEventEffect` for item interaction
- Updates `InventoryTestScene` to demonstrate movement
- Adjusts text colors in `GRID_CONFIG` for better visibility
2026-04-20 17:06:46 +08:00
hypercross 5884e74d8d refactor: implement inventory item spawner and config
- Add cell and text styling constants to `GRID_CONFIG`
- Replace manual item drawing in `InventoryTestScene` with
  `InventoryItemSpawner`
- Update `InventoryTestScene` to use dynamic inventory dimensions for
  grid rendering
- Register `InventoryTestScene` in the main `App` component
2026-04-20 16:45:14 +08:00
hypercross b06876ccc0 feat(sts-like-viewer): add inventory test scene
Implement `InventoryTestScene` to demonstrate reactive inventory
management using signals. Includes a grid-based visualization of items
and controls to add or remove items dynamically.
2026-04-20 16:34:03 +08:00
hypercross 368d9942d2 refactor(onitama): centralize visual constants in config
Introduce `TEXT_POSITION` and `VISUAL` objects to the configuration
to manage magic numbers for positioning, radii, stroke widths, and
alphas. Update renderers and spawners to use these constants instead
of hardcoded values or direct calculations.
2026-04-20 16:04:29 +08:00
hypercross 22817945cc fix(framework): access scene key via sys.settings in PhaserScene
Use `scene.sys.settings.key` instead of `scene.scene.key` to correctly
retrieve the scene key from the Phaser scene instance.
2026-04-20 15:47:56 +08:00
hypercross 85bc4f5400 refactor: remove unused state and widget files in sts-like-viewer 2026-04-20 15:44:09 +08:00
hypercross 033a8e4a40 refactor: clean up scenes and introduce SceneKey enum
- Remove redundant `GameFlowScene` and `PlaceholderEncounterScene`
- Introduce `SceneKey` enum for type-safe scene management
- Clean up unused imports and configuration references in existing
  scenes
- Standardize scene navigation using `SceneKey`
2026-04-20 15:36:12 +08:00
hypercross 1d803dd219 refactor(sts-like-viewer): simplify scene management and config
- Move configuration to `src/config/index.ts`
- Simplify `App.tsx` by passing scene classes directly to `PhaserScene`
- Update `GameUI` initialization to use string ID for container
- Update documentation to mention `initData` for `ReactiveScene`
- Standardize quote usage and formatting in config files
2026-04-20 15:24:07 +08:00
hypercross 2d412eedb5 refactor(framework): improve PhaserScene flexibility
Allow `PhaserScene` to accept either a scene instance or a scene
constructor. This enables more flexible scene registration within
the `PhaserBridge`. Also updates `sceneKey` to be optional,
defaulting to the scene's internal key.
2026-04-20 15:11:45 +08:00
hypercross f32aca2543 refactor: update eslint configuration import for @eslint/js 2026-04-19 14:59:55 +08:00
hypercross 34a7cff964 refactor: remove inventory widget and drag controller
Removes the inventory management system and drag-and-drop
functionality from the STS-like viewer. This includes deleting the
`InventoryWidget`, `InventoryItemSpawner`, and `DragController`
classes, as well as cleaning up references in
`PlaceholderEncounterScene`.
2026-04-19 14:59:39 +08:00
hypercross 82df3f2a2f docs: update game patterns documentation 2026-04-19 13:10:09 +08:00
hypercross 4d34f9fa78 refactor(framework): use callback instead of events in
dragDropEventEffect

Replace Phaser event emissions with a `DragDropCallback` in
`dragDropEventEffect` to provide a more explicit and type-safe API
for handling drag-and-drop interactions.
2026-04-19 13:04:53 +08:00
hypercross c25759d147 refactor(onitama): extract tweens into factory functions
Centralize Onitama animation logic by moving inline Phaser tweens
into a dedicated `tweens.ts` configuration file. This improves
reusability and cleans up spawners and renderers.

Also updated documentation to warn against adding interruptions to
looped tweens to prevent game logic hangs.
2026-04-19 12:45:05 +08:00
hypercross 3568d99e6e fix(onitama): prevent game hang by removing tween interruptions
Remove tween interruptions from the selection fade-in and pulse
animations to prevent the game state from stopping indefinitely.
2026-04-19 12:29:17 +08:00
hypercross b28da1cad3 refactor(onitama): decouple rendering logic into dedicated renderers
Introduces a centralized configuration system and extracts visual
creation logic from spawners into specialized renderer classes:
- CardRenderer
- HighlightRenderer
- PawnRenderer
- SelectionRenderer

This refactor improves separation of concerns by moving Phaser-specific
drawing and animation code out of the spawner/container logic and into
reusable renderer components.
2026-04-19 11:55:18 +08:00
hypercross c7ef992082 refactor(onitama-game): simplify GameUI initialization 2026-04-19 11:31:03 +08:00
hypercross 3afe78f54f refactor(framework): allow string ID for GameUI container
Update GameUIOptions to accept either an HTMLElement or a string ID.
If a string is provided, the class will attempt to find the element
by ID or create a new div if it does not exist. Also update the root
property to accept a JSX.Element.
2026-04-19 11:30:14 +08:00
hypercross 762ed1a63a docs: rename OnitamaGamePatterns.md to GamePatterns.md 2026-04-19 11:20:49 +08:00
hypercross b0e74a5257 refactor(framework): simplify spawner state management
Consolidate object and data tracking into a single Map in `spawnEffect`
to improve clarity and reduce redundant lookups. Also switch to
type-only
imports for Phaser.
2026-04-19 11:00:01 +08:00
hypercross 648e801dad Style: enforce 2-space indent and double quotes 2026-04-19 10:48:32 +08:00
hypercross ddc9d057fd Chore: Add ESLint configuration and lint scripts 2026-04-19 10:39:01 +08:00
hypercross e14e41461f Fix drag race condition and stale pointer events
Mark items as dragging before updating inventory state to prevent
the spawner from destroying containers mid-drag. Move inventory
removal to drop time for lost items, and add guards to ignore
stale pointer events on inactive containers.
2026-04-19 00:43:43 +08:00
hypercross 5af7140958 Refactor inventory to use Spawner pattern
Replace manual ItemRenderer with InventoryItemSpawner and
GridBackgroundRenderer. Uses spawnEffect to automatically sync item
visuals with game state changes. Separates static grid rendering and
tracks dragged items to prevent spawner conflicts.
2026-04-19 00:24:20 +08:00
hypercross a7095c37fc Refactor inventory drag to use dragDropEventEffect
Replace manual pointer event handling with the framework's
dragDropEventEffect utility. Update DragController to manage
DisposableBag for cleanup and pass containers instead of
pointers to drag callbacks. Add framework path alias to
tsconfig and fix loop variable shadowing in LostItemManager.
2026-04-19 00:12:56 +08:00
hypercross 88d0c5bf55 Refactor: centralize config and split inventory
Move magic numbers and style constants to src/config.ts.
Decompose InventoryWidget into DragController, ItemRenderer,
and LostItemManager for better separation of concerns. Add
createButton utility and remove unused CombatUnitWidget.
Update scenes to import from centralized config.
2026-04-19 00:01:25 +08:00
hypercross d993d55576 Refactor dnd utility and add drag lifecycle events
Attach pointerup and pointermove listeners to scene.input
for reliable tracking. Emit dragstart, dragmove, and
dragend events alongside drag. Rename relativeX/Y to
deltaX/Y in DragDropEvent and integrate DisposableBag.
2026-04-18 23:33:13 +08:00
hypercross eefcef861a Add game architecture patterns documentation
Document layered architecture, spawner patterns, reactive
UI state, and tween interruption for framework users.
Includes best practices and common pitfalls to avoid.
docs: add architecture patterns documentation

Provide a reference guide for implementing board games with the
framework. Covers layered architecture, reactive scenes, spawner
patterns, UI state management, and common development pitfalls.
2026-04-18 23:32:02 +08:00
hypercross 23575dd516 feat: dnd 2026-04-17 21:55:14 +08:00
hypercross 0ecef16a4a fix: inventory interaction 2026-04-17 19:45:34 +08:00
hypercross 28a2623bd1 feat: adding widgets to the game 2026-04-17 18:08:51 +08:00
hypercross f7a6154c68 refactor: update to new version 2026-04-17 17:29:13 +08:00
hypercross 706de4e33a fix: inventory preview not actually there 2026-04-14 15:48:47 +08:00
hypercross 5342f1c09d feat: inventory preview? 2026-04-14 15:22:22 +08:00
hypercross 3ca2a16e29 refactor: global state for scenes 2026-04-14 14:21:51 +08:00
63 changed files with 7085 additions and 2157 deletions
+114
View File
@@ -0,0 +1,114 @@
# Game Architecture Patterns & Practices
> Reference guide for implementing board games using `boardgame-phaser` framework.
> Explore `packages/onitama-game/` and `packages/sample-game/` for concrete implementations.
## Architecture Overview
Games follow a layered architecture separating logic, presentation, and UI state:
```
packages/my-game/
├── src/
│ ├── game/ # Pure game logic (re-exported from boardgame-core)
│ ├── scenes/ # Phaser scenes (MenuScene, GameScene)
│ ├── spawners/ # Data-driven object lifecycle
│ ├── gameobjects/ # Phaser GameObject or Container extensions
│ ├── state/ # UI-only reactive state
│ ├── config.ts # Centralized layout & style constants
│ └── ui/App.tsx # Preact root component
```
| Layer | Responsibility |
|-------|----------------|
| `game/` | State, commands, prompts, validation (pure logic) |
| `scenes/` | Phaser lifecycle, input handling, visual composition |
| `spawners/` | Reactive game object spawn/update/despawn |
| `gameobjects/` | Phaser GameObjects or containers |
| `state/` | UI-only reactive state (selection, hover, etc.) |
| `ui/` | Preact components bridging Phaser and DOM |
## Core Patterns
### 1. ReactiveScene / GameHostScene
Extend `ReactiveScene`(`packages\framework\src\scenes\ReactiveScene.ts`) to use reactive integration features.
- Use `this.disposables` for auto-cleanup on shutdown.
- Use `initData` for context data passed from the `<PhaserScene/>` preact component.
### 2. Spawner Pattern
Implement `Spawner<TData, TObj>` for data-driven objects.
- `*getData()`: Yield objects that should exist.
- `getKey()`: Unique identifier for diffing.
- `onSpawn()`: Create Phaser objects.
- `onUpdate()`: Handle data changes (animate if needed).
- `onDespawn()`: Clean up with optional animation.
- See: `packages/onitama-game/src/spawners/`
### 3. Reactive UI State
Use `MutableSignal` for UI-only state, separate from game state.
- Mutate via `.produce()`.
- React via `effect()` from `@preact/signals-core`.
- Clean up effects on object `'destroy'` to prevent leaks.
- See: `packages/onitama-game/src/state/ui.ts`
### 4. Custom GameObjects / Containers
Extend `Phaser.GameObjects.Container` to encapsulate visuals and state.
- Use signals from `states/`, avoid relying on complex interal state.
- Use `effect()` for reactive highlights/selections.
### 5. Tween Interruption
Always register state-related tweens: `this.scene.addTweenInterruption(tween)`.
Prevents visual glitches when game state changes mid-animation.
DO NOT add interruptions for looped tweens.
Otherwise the game logic will hang.
### 6. Scene Navigation
Use `await this.sceneController.launch('SceneKey')`.
Register scenes in `App.tsx` via `<PhaserScene>`. Pass data via `data` prop.
### 7. Central Config
Use `src/config/layout.ts` for shared constants like `CELL_SIZE`, `BOARD_OFFSET`, etc.
Use `src/config/colors.ts` for shared colors.
Use `src/config/text.ts` for text styles.
## Best Practices
- **Centralize Config**: Keep `CELL_SIZE`, `BOARD_OFFSET`, colors, etc., in `src/config.ts` with `as const`. Avoid magic numbers.
- **Type Imports**: Use `import type { Foo } from 'bar'` for type-only imports.
- **Input Handling**: Use `this.add.zone()` for grid/cell-based input zones.
- **Cleanup**: Always dispose `effect()` on `'destroy'`. Use `this.disposables.add()` for scene-level resources.
- *Keep files short and focused**: Each file should have a single responsibility, and not exceed 200 lines.
## Common Pitfalls
| Pitfall | Solution |
|---------|----------|
| Duplicate constants across files | Export from single `config.ts` or shared spawner |
| Missing tween interruptions | Always call `addTweenInterruption()` |
| Effect memory leaks | `this.on('destroy', () => dispose())` |
| Hardcoded magic numbers | Extract to `src/config.ts` |
| UI state staleness | Clear related selections on state change |
## Testing
Add Vitest for UI state transitions, coordinate conversions, and spawner logic.
See `packages/framework/` for test setup examples.
## Quick Reference
| Pattern | Purpose |
|---------|---------|
| `GameHostScene` | Connect Phaser to game host |
| `Spawner<T, TObj>` | Reactive object lifecycle |
| `MutableSignal` | UI-only reactive state |
| `effect()` | React to signal changes in Phaser objects |
| `addTweenInterruption()` | Prevent animation race conditions |
| `sceneController.launch()` | Navigate between scenes |
| `spawnEffect()` | Register spawners |
| `this.disposables.add()` | Auto-cleanup resources |
## Related Documents
- `AGENTS.md` — Project overview, commands, and code style
- `docs/GameModule.md` — GameModule implementation guide
- `packages/framework/src/` — `boardgame-phaser` source code
- `packages/onitama-game/src/` — Complete game implementation reference
+83
View File
@@ -0,0 +1,83 @@
import * as js from "@eslint";
import tseslint from "typescript-eslint";
import importPlugin from "eslint-plugin-import";
export default tseslint.config(
{
ignores: [
"**/dist/**",
"**/node_modules/**",
"**/*.d.ts",
"**/coverage/**",
],
},
js.configs.recommended,
...tseslint.configs.recommended,
importPlugin.flatConfigs.recommended,
importPlugin.flatConfigs.typescript,
{
languageOptions: {
parserOptions: {
projectService: true,
ecmaVersion: 2020,
sourceType: "module",
},
},
},
{
files: ["**/*.ts", "**/*.tsx"],
rules: {
// --- Project Conventions ---
quotes: ["error", "double", { avoidEscape: true }],
"comma-dangle": ["error", "always-multiline"],
indent: ["error", 2, { SwitchCase: 1 }],
// --- TypeScript ---
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/no-unused-vars": [
"error",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
"@typescript-eslint/consistent-type-imports": [
"error",
{ prefer: "type-imports" },
],
"@typescript-eslint/no-non-null-assertion": "warn",
// --- Import Ordering ---
"import/order": [
"error",
{
groups: [
"builtin",
"external",
"internal",
"parent",
"sibling",
"index",
"object",
"type",
],
"newlines-between": "always",
alphabetize: { order: "asc", caseInsensitive: true },
},
],
"import/no-unresolved": "off",
"import/no-duplicates": "error",
// --- General ---
"prefer-const": "error",
"no-var": "error",
eqeqeq: ["error", "always"],
},
},
{
files: ["**/*.test.ts", "**/*.spec.ts"],
rules: {
"no-console": "off",
"@typescript-eslint/no-explicit-any": "off",
},
},
);
+7 -1
View File
@@ -6,10 +6,16 @@
"dev": "pnpm --filter sample-game dev", "dev": "pnpm --filter sample-game dev",
"build": "pnpm --filter boardgame-phaser build && pnpm --filter sample-game build", "build": "pnpm --filter boardgame-phaser build && pnpm --filter sample-game build",
"build:framework": "pnpm --filter boardgame-phaser build", "build:framework": "pnpm --filter boardgame-phaser build",
"preview": "pnpm --filter sample-game preview" "preview": "pnpm --filter sample-game preview",
"lint": "eslint .",
"lint:fix": "eslint . --fix"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.17.0",
"eslint": "^9.17.0",
"eslint-plugin-import": "^2.31.0",
"typescript": "^5.3.3", "typescript": "^5.3.3",
"typescript-eslint": "^8.18.0",
"vitest": "^3.2.4" "vitest": "^3.2.4"
}, },
"pnpm": { "pnpm": {
+32 -8
View File
@@ -1,15 +1,39 @@
// Resource management // Resource management
export { DisposableBag } from './utils'; export { DisposableBag } from "./utils";
export type { IDisposable, DisposableItem } from './utils'; export type { IDisposable, DisposableItem } from "./utils";
// Drag & drop utilities
export { dragDropEventEffect, DragDropEventType } 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 { ReactiveScene, GameHostScene, FadeScene, FADE_SCENE_KEY } from './scenes'; export {
export type { ReactiveSceneOptions, ReactiveScenePhaserData, SceneController, GameHostSceneOptions, FadeSceneData } from './scenes'; ReactiveScene,
GameHostScene,
FadeScene,
FADE_SCENE_KEY,
} from "./scenes";
export type {
ReactiveSceneOptions,
ReactiveScenePhaserData,
SceneController,
GameHostSceneOptions,
FadeSceneData,
} from "./scenes";
// React ↔ Phaser bridge // React ↔ Phaser bridge
export { PhaserGame, PhaserScene, phaserContext, defaultPhaserConfig, GameUI, type PhaserGameContext, type SceneController as PhaserSceneController } from './ui'; export {
export type { PhaserGameProps, PhaserSceneProps, GameUIOptions } from './ui'; PhaserGame,
PhaserScene,
phaserContext,
defaultPhaserConfig,
GameUI,
type PhaserGameContext,
type SceneController as PhaserSceneController,
} from "./ui";
export type { PhaserGameProps, PhaserSceneProps, GameUIOptions } from "./ui";
+66 -72
View File
@@ -1,89 +1,83 @@
import Phaser from 'phaser'; import { ReactiveScene } from "./ReactiveScene";
import { ReactiveScene, type ReactiveScenePhaserData } from './ReactiveScene';
export interface FadeSceneData { export interface FadeSceneData {
[key: string]: unknown; [key: string]: unknown;
} }
/** /**
* 处理淡入淡出到黑色的过渡场景 * 处理淡入淡出到黑色的过渡场景
*/ */
export class FadeScene extends ReactiveScene<FadeSceneData> { export class FadeScene extends ReactiveScene<FadeSceneData> {
private overlay!: Phaser.GameObjects.Rectangle; private overlay!: Phaser.GameObjects.Rectangle;
private isFading = false; private isFading = false;
constructor() { constructor() {
super(FADE_SCENE_KEY); super(FADE_SCENE_KEY);
}
create(): void {
super.create();
// 创建黑色遮罩层,覆盖整个游戏区域
const game = this.game;
this.overlay = this.add
.rectangle(0, 0, game.scale.width, game.scale.height, 0x000000, 1)
.setOrigin(0)
.setAlpha(1)
.setDepth(999999)
.setInteractive({ useHandCursor: false });
// 防止遮罩阻挡输入
this.overlay.disableInteractive();
}
/**
* 淡入(从黑色到透明)
* @param duration 动画时长(毫秒)
*/
fadeIn(duration = 300): Promise<void> {
return this.fadeTo(0, duration);
}
/**
* 淡出(从透明到黑色)
* @param duration 动画时长(毫秒)
*/
fadeOut(duration = 300): Promise<void> {
return this.fadeTo(1, duration);
}
/**
* 淡入淡出到指定透明度
*/
private fadeTo(targetAlpha: number, duration: number): Promise<void> {
// 如果 overlay 还未初始化,直接返回 resolved promise
if (!this.overlay) {
console.warn("FadeScene: overlay 未初始化,跳过过渡动画");
return Promise.resolve();
} }
create(): void { if (this.isFading) {
super.create(); console.warn("FadeScene: 正在进行过渡动画");
// 创建黑色遮罩层,覆盖整个游戏区域
const game = this.game;
this.overlay = this.add.rectangle(
0,
0,
game.scale.width,
game.scale.height,
0x000000,
1
).setOrigin(0)
.setAlpha(1)
.setDepth(999999)
.setInteractive({ useHandCursor: false });
// 防止遮罩阻挡输入
this.overlay.disableInteractive();
} }
/** this.isFading = true;
* 淡入(从黑色到透明) this.overlay.setAlpha(targetAlpha === 1 ? 0 : 1);
* @param duration 动画时长(毫秒)
*/
fadeIn(duration = 300): Promise<void> {
return this.fadeTo(0, duration);
}
/** return new Promise<void>((resolve) => {
* 淡出(从透明到黑色) this.tweens.add({
* @param duration 动画时长(毫秒) targets: this.overlay,
*/ alpha: targetAlpha,
fadeOut(duration = 300): Promise<void> { duration,
return this.fadeTo(1, duration); ease: "Linear",
} onComplete: () => {
this.isFading = false;
/** resolve();
* 淡入淡出到指定透明度 },
*/ });
private fadeTo(targetAlpha: number, duration: number): Promise<void> { });
// 如果 overlay 还未初始化,直接返回 resolved promise }
if (!this.overlay) {
console.warn('FadeScene: overlay 未初始化,跳过过渡动画');
return Promise.resolve();
}
if (this.isFading) {
console.warn('FadeScene: 正在进行过渡动画');
}
this.isFading = true;
this.overlay.setAlpha(targetAlpha === 1 ? 0 : 1);
return new Promise<void>((resolve) => {
this.tweens.add({
targets: this.overlay,
alpha: targetAlpha,
duration,
ease: 'Linear',
onComplete: () => {
this.isFading = false;
resolve();
},
});
});
}
} }
// 导出常量供 PhaserGame 使用 // 导出常量供 PhaserGame 使用
export const FADE_SCENE_KEY = '__fade__'; export const FADE_SCENE_KEY = "__fade__";
+35 -28
View File
@@ -1,36 +1,43 @@
import type { GameHost } from 'boardgame-core'; import { ReactiveScene } from "./ReactiveScene";
import { ReactiveScene } from './ReactiveScene';
export interface GameHostSceneOptions<TState extends Record<string, unknown>> { import type { GameHost, GameModule } from "boardgame-core";
gameHost: GameHost<TState>;
[key: string]: unknown; export interface GameHostSceneOptions<
TState extends Record<string, unknown>,
TResult,
TModule extends GameModule<TState, TResult>,
> {
gameHost: GameHost<TState, TResult, TModule>;
[key: string]: unknown;
} }
export abstract class GameHostScene<TState extends Record<string, unknown>> export abstract class GameHostScene<
extends ReactiveScene<GameHostSceneOptions<TState>> TState extends Record<string, unknown>,
{ 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>> {
if (!gameHost) { public get gameHost(): GameHost<TState, TResult, TModule> {
throw new Error( const gameHost = this.initData.gameHost;
`GameHostScene (${this.scene.key}): gameHost 未提供。` + if (!gameHost) {
`确保在 PhaserScene 组件的 data 属性中传入 gameHost。` throw new Error(
); `GameHostScene (${this.scene.key}): gameHost 未提供。` +
} "确保在 PhaserScene 组件的 data 属性中传入 gameHost。",
return gameHost; );
} }
return gameHost;
}
public get state(): TState { public get state(): TState {
return this.gameHost?.state.value; return this.gameHost?.state.value;
} }
addInterruption(promise: Promise<void>){ addInterruption(promise: Promise<void>) {
this.gameHost?.addInterruption(promise); this.gameHost?.addInterruption(promise);
} }
addTweenInterruption(tween: Phaser.Tweens.Tween){ addTweenInterruption(tween: Phaser.Tweens.Tween) {
this.addInterruption(new Promise( this.addInterruption(
resolve => tween.once('complete', resolve) new Promise((resolve) => tween.once("complete", resolve)),
)); );
} }
} }
+62 -58
View File
@@ -1,86 +1,90 @@
import Phaser from 'phaser'; import { type ReadonlySignal } from "@preact/signals-core";
import { effect, type ReadonlySignal } from '@preact/signals-core'; import { Scene } from "phaser";
import { DisposableBag, type IDisposable } from '../utils';
import { DisposableBag, type IDisposable } from "../utils";
import type Phaser from "phaser";
type CleanupFn = void | (() => void); type CleanupFn = void | (() => void);
// 前向声明,避免循环导入 // 前向声明,避免循环导入
export interface SceneController { export interface SceneController {
launch(sceneKey: string): Promise<void>; launch(sceneKey: string): Promise<void>;
currentScene: ReadonlySignal<string | null>; restart(): Promise<void>;
isTransitioning: ReadonlySignal<boolean>; currentScene: ReadonlySignal<string | null>;
isTransitioning: ReadonlySignal<boolean>;
} }
export interface ReactiveScenePhaserData { export interface ReactiveScenePhaserData {
phaserGame: ReadonlySignal<{ game: Phaser.Game }>; phaserGame: ReadonlySignal<{ game: Phaser.Game }>;
sceneController: SceneController; sceneController: SceneController;
} }
export interface ReactiveSceneOptions<TData extends Record<string, unknown> = {}> { export interface ReactiveSceneOptions {
key?: string; key?: string;
} }
/** /**
* 通用的响应式 Scene 基类 * 通用的响应式 Scene 基类
* @typeparam TData - 通过 init(data) 接收的数据类型(必须包含 phaserGame 和 sceneController) * @typeparam TData - 通过 init(data) 接收的数据类型(必须包含 phaserGame 和 sceneController)
*/ */
export abstract class ReactiveScene<TData extends Record<string, unknown> = {}> export abstract class ReactiveScene<TData = object>
extends Phaser.Scene extends Scene
implements IDisposable implements IDisposable
{ {
protected disposables = new DisposableBag(); protected disposables = new DisposableBag();
private _initData?: TData & ReactiveScenePhaserData; private _initData?: TData & ReactiveScenePhaserData;
/** /**
* 获取通过 init() 注入的数据 * 获取通过 init() 注入的数据
* 在 create() 阶段保证可用 * 在 create() 阶段保证可用
*/ */
public get initData(): TData & ReactiveScenePhaserData { public get initData(): TData & ReactiveScenePhaserData {
if (!this._initData) { if (!this._initData) {
throw new Error( throw new Error(
`ReactiveScene (${this.scene.key}): initData 尚未初始化。` + `ReactiveScene (${this.scene.key}): initData 尚未初始化。` +
`确保场景通过 PhaserScene 组件注册,并在 create() 阶段访问。` "确保场景通过 PhaserScene 组件注册,并在 create() 阶段访问。",
); );
}
return this._initData;
} }
return this._initData;
}
/** /**
* 获取 Phaser game 实例的响应式信号 * 获取 Phaser game 实例的响应式信号
*/ */
public get phaserGame(): ReadonlySignal<{ game: Phaser.Game }> { public get phaserGame(): ReadonlySignal<{ game: Phaser.Game }> {
return this.initData.phaserGame; return this.initData.phaserGame;
} }
/** /**
* 获取场景控制器 * 获取场景控制器
*/ */
public get sceneController(): SceneController { public get sceneController(): SceneController {
return this.initData.sceneController; return this.initData.sceneController;
} }
constructor(key?: string) { constructor(key?: string) {
super(key); super(key);
} }
init(data: TData & ReactiveScenePhaserData): void { init(data: TData & ReactiveScenePhaserData): void {
this._initData = data; this._initData = data;
} }
create(): void { create(): void {
this.events.on('shutdown', this.dispose, this); this.events.on("shutdown", this.dispose, this);
} }
dispose(): void { dispose(): void {
this.disposables.dispose(); this.disposables.dispose();
} }
public addDisposable(disposable: IDisposable): void { public addDisposable(disposable: IDisposable): void {
this.disposables.add(disposable); this.disposables.add(disposable);
} }
/** 注册响应式监听(场景关闭时自动清理) */ /** 注册响应式监听(场景关闭时自动清理) */
public addEffect(fn: () => CleanupFn): void { public addEffect(fn: () => CleanupFn): void {
this.disposables.add(effect(fn)); this.disposables.addEffect(fn);
} }
} }
+10 -6
View File
@@ -1,8 +1,12 @@
export { ReactiveScene } from './ReactiveScene'; export { ReactiveScene } from "./ReactiveScene";
export type { ReactiveSceneOptions, ReactiveScenePhaserData, SceneController } from './ReactiveScene'; export type {
ReactiveSceneOptions,
ReactiveScenePhaserData,
SceneController,
} from "./ReactiveScene";
export { FadeScene, FADE_SCENE_KEY } from './FadeScene'; export { FadeScene, FADE_SCENE_KEY } from "./FadeScene";
export type { FadeSceneData } from './FadeScene'; export type { FadeSceneData } from "./FadeScene";
export { GameHostScene } from './GameHostScene'; export { GameHostScene } from "./GameHostScene";
export type { GameHostSceneOptions } from './GameHostScene'; export type { GameHostSceneOptions } from "./GameHostScene";
+58 -42
View File
@@ -1,54 +1,70 @@
import Phaser from 'phaser'; import { effect } from "@preact/signals-core";
import { effect } from '@preact/signals-core';
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; onSpawn(t: TData): TObject | null;
/** 创建新对象 */ /** 销毁旧对象 */
onSpawn(t: TData): TObject | null; onDespawn(obj: TObject, t: TData): void;
/** 销毁旧对象 */ /** 更新已有对象 */
onDespawn(obj: TObject, t: TData): void; 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 objects = new Map<string, TObject>(); const update = createSpawnUpdate(spawner);
const spawnData = new Map<string, TData>();
return effect(() => { return effect(() => {
const current = new Set<string>(); update(spawner.getData());
});
}
for (const t of spawner.getData()) { export function createSpawnUpdate<TData, TObject extends GO = GO>(
const key = spawner.getKey(t); spawner: SpawnerCallback<TData, TObject>,
current.add(key); ) {
const entries = new Map<string, { object: TObject; data: TData }>();
const existing = objects.get(key); function update(data: Iterable<TData>) {
if (!existing) { const current = new Set<string>();
const obj = spawner.onSpawn(t);
if (obj) { for (const t of data) {
spawnData.set(key, t); const key = spawner.getKey(t);
objects.set(key, obj); current.add(key);
}
} else { const existing = entries.get(key);
if(spawnData.get(key) === t) continue; if (!existing) {
spawner.onUpdate(t, existing); const obj = spawner.onSpawn(t);
spawnData.set(key, t); if (obj) {
} entries.set(key, { object: obj, data: t });
} }
} else {
if (existing.data === t) continue;
spawner.onUpdate(t, existing.object);
existing.data = t;
}
}
for (const [key, obj] of objects) { for (const [key, entry] of entries) {
if (!current.has(key)) { if (!current.has(key)) {
spawner.onDespawn(obj, spawnData.get(key)!); spawner.onDespawn(entry.object, entry.data);
objects.delete(key); entries.delete(key);
spawnData.delete(key); }
} }
} }
});
return update;
} }
+20 -6
View File
@@ -1,25 +1,39 @@
import type { JSX } from "preact";
export interface GameUIOptions { export interface GameUIOptions {
container: HTMLElement; container: HTMLElement | string;
root: any; root: JSX.Element;
} }
export class GameUI { export class GameUI {
private container: HTMLElement; private container: HTMLElement;
private root: any; private root: JSX.Element;
constructor(options: GameUIOptions) { constructor(options: GameUIOptions) {
this.container = options.container; if (typeof options.container === "string") {
const existing = document.getElementById(options.container);
if (existing) {
this.container = existing;
} else {
const newContainer = document.createElement("div");
newContainer.id = options.container;
document.body.appendChild(newContainer);
this.container = newContainer;
}
} else {
this.container = options.container;
}
this.root = options.root; this.root = options.root;
} }
mount(): void { mount(): void {
import('preact').then(({ render }) => { import("preact").then(({ render }) => {
render(this.root, this.container); render(this.root, this.container);
}); });
} }
unmount(): void { unmount(): void {
import('preact').then(({ render }) => { import("preact").then(({ render }) => {
render(null, this.container); render(null, this.container);
}); });
} }
+223 -195
View File
@@ -1,219 +1,247 @@
import Phaser from 'phaser'; import { signal, useSignal, useSignalEffect } from "@preact/signals";
import { signal, useSignal, useSignalEffect } from '@preact/signals'; import Phaser, { AUTO, Scene } from "phaser";
import { createContext, h } from 'preact'; import { createContext } from "preact";
import { useContext, useEffect, useRef } from 'preact/hooks'; import { useContext, useEffect, useRef } from "preact/hooks";
import { ReadonlySignal } from "@preact/signals-core";
import type { ReactiveScene } from '../scenes'; import {
import { FadeScene as FadeSceneClass, FADE_SCENE_KEY } from '../scenes/FadeScene'; FadeScene as FadeSceneClass,
FADE_SCENE_KEY,
} from "../scenes/FadeScene";
import type { ReactiveScene } from "../scenes";
import type { ReadonlySignal } from "@preact/signals-core";
export interface SceneController { export interface SceneController {
/** 启动场景(带淡入淡出过渡) */ /** 启动场景(带淡入淡出过渡) */
launch(sceneKey: string): Promise<void>; launch(sceneKey: string): Promise<void>;
/** 重新启动当前场景(带淡入淡出过渡) */ /** 重新启动当前场景(带淡入淡出过渡) */
restart(): Promise<void>; restart(): Promise<void>;
/** 当前活跃场景 key */ /** 当前活跃场景 key */
currentScene: ReadonlySignal<string | null>; currentScene: ReadonlySignal<string | null>;
/** 是否正在过渡 */ /** 是否正在过渡 */
isTransitioning: ReadonlySignal<boolean>; isTransitioning: ReadonlySignal<boolean>;
} }
export interface PhaserGameContext { export interface PhaserGameContext {
game: Phaser.Game; game: Phaser.Game;
sceneController: SceneController; sceneController: SceneController;
} }
export const phaserContext = createContext<ReadonlySignal<PhaserGameContext> | null>(null); export const phaserContext =
createContext<ReadonlySignal<PhaserGameContext> | null>(null);
export const defaultPhaserConfig: Phaser.Types.Core.GameConfig = { export const defaultPhaserConfig: Phaser.Types.Core.GameConfig = {
type: Phaser.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 {
config?: Partial<Phaser.Types.Core.GameConfig>; config?: Partial<Phaser.Types.Core.GameConfig>;
/** 初始启动的场景 key */ /** 初始启动的场景 key */
initialScene?: string; initialScene?: string;
children?: any; children?: preact.ComponentChildren;
} }
export function PhaserGame(props: PhaserGameProps) { export function PhaserGame(props: PhaserGameProps) {
const gameSignal = useSignal<PhaserGameContext>({ game: undefined!, sceneController: undefined! }); const gameSignal = useSignal<PhaserGameContext>({
const initialSceneLaunched = useRef(false); game: undefined!,
sceneController: undefined!,
});
const initialSceneLaunched = useRef(false);
useSignalEffect(() => { useSignalEffect(() => {
const config: Phaser.Types.Core.GameConfig = { const config: Phaser.Types.Core.GameConfig = {
...defaultPhaserConfig, ...defaultPhaserConfig,
...props.config, ...props.config,
}; };
const phaserGame = new Phaser.Game(config); const phaserGame = new Phaser.Game(config);
// 添加 FadeScene 并启动它来初始化 overlay // 添加 FadeScene 并启动它来初始化 overlay
const fadeScene = new FadeSceneClass(); const fadeScene = new FadeSceneClass();
phaserGame.scene.add(FADE_SCENE_KEY, fadeScene, true); // 改为 true 以触发 create phaserGame.scene.add(FADE_SCENE_KEY, fadeScene, true); // 改为 true 以触发 create
// 创建 SceneController // 创建 SceneController
const currentScene = signal<string | null>(null); const currentScene = signal<string | null>(null);
const isTransitioning = signal(false); const isTransitioning = signal(false);
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;
}
// 等待场景注册完成(最多等待 100ms)
let retries = 0;
while (!phaserGame.scene.getScene(sceneKey) && retries < 10) {
await new Promise(resolve => setTimeout(resolve, 10));
retries++;
}
// 验证场景是否已注册
if (!phaserGame.scene.getScene(sceneKey)) {
console.error(`SceneController: 场景 "${sceneKey}" 未注册`);
return;
}
isTransitioning.value = true;
const fade = phaserGame.scene.getScene(FADE_SCENE_KEY) as FadeSceneClass;
// 淡出到黑色
phaserGame.scene.bringToTop(FADE_SCENE_KEY);
await fade.fadeOut(300);
// 停止当前场景
if (currentScene.value) {
phaserGame.scene.stop(currentScene.value);
}
// 确保场景已注册后再启动
// (场景应该已经在 PhaserScene 组件中注册)
if (!phaserGame.scene.getScene(sceneKey)) {
console.error(`SceneController: 场景 "${sceneKey}" 在切换时仍未注册`);
isTransitioning.value = false;
return;
}
// 启动新场景
phaserGame.scene.start(sceneKey);
currentScene.value = sceneKey;
// 淡入
await fade.fadeIn(300);
isTransitioning.value = false;
},
async restart() {
if (isTransitioning.value) {
console.warn('SceneController: 正在进行场景切换');
return;
}
if (!currentScene.value) {
console.warn('SceneController: 没有当前场景,无法 restart');
return;
}
const sceneKey = currentScene.value;
const scene = phaserGame.scene.getScene(sceneKey);
if (!scene) {
console.error(`SceneController: 场景 "${sceneKey}" 不存在`);
return;
}
isTransitioning.value = true;
const fade = phaserGame.scene.getScene(FADE_SCENE_KEY) as FadeSceneClass;
// 淡出到黑色
phaserGame.scene.bringToTop(FADE_SCENE_KEY);
await fade.fadeOut(300);
// 重启当前场景
scene.scene.restart();
// 淡入
await fade.fadeIn(300);
isTransitioning.value = false;
},
currentScene,
isTransitioning,
};
gameSignal.value = { game: phaserGame, sceneController };
return () => {
gameSignal.value = { game: undefined!, sceneController: undefined! };
initialSceneLaunched.current = false;
phaserGame.destroy(true);
};
});
// 启动初始场景(仅一次)
useEffect(() => {
const ctx = gameSignal.value;
if (!initialSceneLaunched.current && props.initialScene && ctx?.sceneController) {
initialSceneLaunched.current = true;
// 使用 microtask 确保所有子组件的场景注册已完成
Promise.resolve().then(() => {
ctx.sceneController.launch(props.initialScene!);
});
}
}, [gameSignal.value, props.initialScene]);
return (
<div id="phaser-container" className="w-full h-full">
<phaserContext.Provider value={gameSignal}>
{props.children}
</phaserContext.Provider>
</div>
);
}
export interface PhaserSceneProps<TData extends Record<string, unknown> = {}> {
sceneKey: string;
scene: ReactiveScene<TData>;
data?: TData;
children?: any;
}
export const phaserSceneContext = createContext<ReadonlySignal<ReactiveScene> | null>(null);
export function PhaserScene<TData extends Record<string, unknown> = {}>(props: PhaserSceneProps<TData>) {
const phaserGameSignal = useContext(phaserContext);
const sceneSignal = useSignal<ReactiveScene<TData>>();
const registered = useRef(false);
useSignalEffect(() => {
if (!phaserGameSignal) return;
const ctx = phaserGameSignal.value;
if (!ctx?.game) return;
const game = ctx.game;
// 注册场景到 Phaser(但不启动)
if (!game.scene.getScene(props.sceneKey)) {
const initData = {
...props.data,
phaserGame: phaserGameSignal,
sceneController: ctx.sceneController,
};
game.scene.add(props.sceneKey, props.scene, false, initData);
} }
sceneSignal.value = props.scene; // 等待场景注册完成(最多等待 100ms)
registered.current = true; let retries = 0;
while (!phaserGame.scene.getScene(sceneKey) && retries < 10) {
await new Promise((resolve) => setTimeout(resolve, 10));
retries++;
}
return () => { // 验证场景是否已注册
sceneSignal.value = undefined; if (!phaserGame.scene.getScene(sceneKey)) {
registered.current = false; console.error(`SceneController: 场景 "${sceneKey}" 未注册`);
// 不在这里移除场景,让 SceneController 管理生命周期 return;
}; }
});
return <phaserSceneContext.Provider value={sceneSignal as ReadonlySignal<ReactiveScene>}>{props.children}</phaserSceneContext.Provider>; isTransitioning.value = true;
const fade = phaserGame.scene.getScene(
FADE_SCENE_KEY,
) as FadeSceneClass;
// 淡出到黑色
phaserGame.scene.bringToTop(FADE_SCENE_KEY);
await fade.fadeOut(300);
// 停止当前场景
if (currentScene.value) {
phaserGame.scene.stop(currentScene.value);
}
// 确保场景已注册后再启动
// (场景应该已经在 PhaserScene 组件中注册)
if (!phaserGame.scene.getScene(sceneKey)) {
console.error(`SceneController: 场景 "${sceneKey}" 在切换时仍未注册`);
isTransitioning.value = false;
return;
}
// 启动新场景
phaserGame.scene.start(sceneKey);
currentScene.value = sceneKey;
// 淡入
await fade.fadeIn(300);
isTransitioning.value = false;
},
async restart() {
if (isTransitioning.value) {
console.warn("SceneController: 正在进行场景切换");
return;
}
if (!currentScene.value) {
console.warn("SceneController: 没有当前场景,无法 restart");
return;
}
const sceneKey = currentScene.value;
const scene = phaserGame.scene.getScene(sceneKey);
if (!scene) {
console.error(`SceneController: 场景 "${sceneKey}" 不存在`);
return;
}
isTransitioning.value = true;
const fade = phaserGame.scene.getScene(
FADE_SCENE_KEY,
) as FadeSceneClass;
// 淡出到黑色
phaserGame.scene.bringToTop(FADE_SCENE_KEY);
await fade.fadeOut(300);
// 重启当前场景
scene.scene.restart();
// 淡入
await fade.fadeIn(300);
isTransitioning.value = false;
},
currentScene,
isTransitioning,
};
gameSignal.value = { game: phaserGame, sceneController };
return () => {
gameSignal.value = { game: undefined!, sceneController: undefined! };
initialSceneLaunched.current = false;
phaserGame.destroy(true);
};
});
// 启动初始场景(仅一次)
useEffect(() => {
const ctx = gameSignal.value;
if (
!initialSceneLaunched.current &&
props.initialScene &&
ctx?.sceneController
) {
initialSceneLaunched.current = true;
// 使用 microtask 确保所有子组件的场景注册已完成
Promise.resolve().then(() => {
ctx.sceneController.launch(props.initialScene!);
});
}
}, [gameSignal.value, props.initialScene]);
return (
<div id="phaser-container" className="w-full h-full">
<phaserContext.Provider value={gameSignal}>
{props.children}
</phaserContext.Provider>
</div>
);
}
export interface PhaserSceneProps<TData = {}> {
sceneKey?: string;
scene: ReactiveScene<TData> | { new (): ReactiveScene<TData> };
data?: TData;
children?: any;
}
export const phaserSceneContext =
createContext<ReadonlySignal<ReactiveScene> | null>(null);
export function PhaserScene<TData = {}>(props: PhaserSceneProps<TData>) {
const phaserGameSignal = useContext(phaserContext);
const sceneSignal = useSignal<ReactiveScene<TData>>();
const registered = useRef(false);
useSignalEffect(() => {
if (!phaserGameSignal) return;
const ctx = phaserGameSignal.value;
if (!ctx?.game) return;
const game = ctx.game;
// 注册场景到 Phaser(但不启动)
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 = {
...props.data,
phaserGame: phaserGameSignal,
sceneController: ctx.sceneController,
};
game.scene.add(sceneKey, props.scene, false, initData);
}
sceneSignal.value = scene;
registered.current = true;
return () => {
sceneSignal.value = undefined;
registered.current = false;
// 不在这里移除场景,让 SceneController 管理生命周期
};
});
return (
<phaserSceneContext.Provider
value={sceneSignal as ReadonlySignal<ReactiveScene>}
>
{props.children}
</phaserSceneContext.Provider>
);
} }
+11 -4
View File
@@ -1,5 +1,12 @@
export { GameUI } from './GameUI'; export { GameUI } from "./GameUI";
export type { GameUIOptions } from './GameUI'; export type { GameUIOptions } from "./GameUI";
export { PhaserGame, PhaserScene, phaserContext, defaultPhaserConfig, type PhaserGameContext, type SceneController } from './PhaserBridge'; export {
export type { PhaserGameProps, PhaserSceneProps } from './PhaserBridge'; PhaserGame,
PhaserScene,
phaserContext,
defaultPhaserConfig,
type PhaserGameContext,
type SceneController,
} from "./PhaserBridge";
export type { PhaserGameProps, PhaserSceneProps } from "./PhaserBridge";
+41 -35
View File
@@ -1,46 +1,52 @@
import { effect } from "@preact/signals-core";
export interface IDisposable { export interface IDisposable {
dispose(): void; dispose(): void;
} }
export type DisposableItem = IDisposable | (() => void); export type DisposableItem = IDisposable | (() => void);
export class DisposableBag implements IDisposable { export class DisposableBag implements IDisposable {
private _disposables = new Set<DisposableItem>(); private _disposables = new Set<DisposableItem>();
private _isDisposed = false; private _isDisposed = false;
get isDisposed(): boolean { constructor(go?: Phaser.GameObjects.GameObject) {
return this._isDisposed; if (go) go.on("shutdown", () => this.dispose());
}
get isDisposed(): boolean {
return this._isDisposed;
}
add(item: DisposableItem): void {
if (this._isDisposed) {
this._execute(item);
return;
}
this._disposables.add(item);
}
addEffect(fn: () => void) {
this.add(effect(fn));
}
dispose(): void {
if (this._isDisposed) return;
this._isDisposed = true;
for (const item of this._disposables) {
this._execute(item);
} }
add(item: DisposableItem): void { this._disposables.clear();
if (this._isDisposed) { }
this._execute(item);
return; private _execute(item: DisposableItem): void {
} if (typeof item === "function") {
this._disposables.add(item); item();
} } else {
item.dispose();
dispose(): void {
if (this._isDisposed) return;
this._isDisposed = true;
for (const item of this._disposables) {
try {
this._execute(item);
} catch (error) {
console.error('Error during resource disposal:', error);
}
}
this._disposables.clear();
}
private _execute(item: DisposableItem): void {
if (typeof item === 'function') {
item();
} else {
item.dispose();
}
} }
}
} }
+106
View File
@@ -0,0 +1,106 @@
import type { DisposableBag } from "./disposable";
type PointerRecord = {
id: number;
button: number;
x: number;
y: number;
};
export enum DragDropEventType {
DOWN,
UP,
MOVE,
ALTBUTTON,
}
export type DragDropEvent = {
type: DragDropEventType;
deltaX: number;
deltaY: number;
};
export type DragDropCallback = (event: DragDropEvent) => void;
export function dragDropEventEffect(
gameObject: Phaser.GameObjects.GameObject,
callback: DragDropCallback,
disposables?: DisposableBag,
): () => void {
let isDragging = false;
let down: PointerRecord | null = null;
function onPointerDown(pointer: Phaser.Input.Pointer) {
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;
down = {
id: pointer.id,
button: pointer.button,
x: pointer.x,
y: pointer.y,
};
const event: DragDropEvent = {
type: DragDropEventType.DOWN,
deltaX: 0,
deltaY: 0,
};
callback(event);
}
function onPointerUp(pointer: Phaser.Input.Pointer) {
if (
!isDragging ||
!down ||
pointer.id !== down.id ||
pointer.button !== down.button
)
return;
const deltaX = pointer.x - down.x;
const deltaY = pointer.y - down.y;
isDragging = false;
const event: DragDropEvent = {
type: DragDropEventType.UP,
deltaX,
deltaY,
};
callback(event);
down = null;
}
function onPointerMove(pointer: Phaser.Input.Pointer) {
if (!isDragging || !down || pointer.id !== down.id) return;
const deltaX = pointer.x - down.x;
const deltaY = pointer.y - down.y;
const event: DragDropEvent = {
type: DragDropEventType.MOVE,
deltaX,
deltaY,
};
callback(event);
}
gameObject.on("pointerdown", onPointerDown);
gameObject.scene.input.on("pointerup", onPointerUp);
gameObject.scene.input.on("pointermove", onPointerMove);
const dispose = () => {
gameObject.off("pointerdown", onPointerDown);
gameObject.scene.input.off("pointerup", onPointerUp);
gameObject.scene.input.off("pointermove", onPointerMove);
};
disposables?.add(dispose);
return dispose;
}
+28
View File
@@ -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;
}
+9 -2
View File
@@ -1,2 +1,9 @@
export { DisposableBag } from './disposable'; export { DisposableBag } from "./disposable";
export type { IDisposable, DisposableItem } from './disposable'; export type { IDisposable, DisposableItem } from "./disposable";
export {
dragDropEventEffect,
DragDropEventType,
type DragDropEvent,
type DragDropCallback,
} from "./dnd";
export * from "./hover";
+275
View File
@@ -0,0 +1,275 @@
/**
* Centralized configuration for Onitama game
* All layout, style, and game constants should be defined here
*/
import { Display } from "phaser";
// Board configuration
export const BOARD_SIZE = 5;
export const CELL_SIZE = 80;
export const BOARD_OFFSET = { x: 240, y: 240 } as const;
// Card configuration
export const CARD_WIDTH = 80;
export const CARD_HEIGHT = 120;
export const CARD_SPACING = 100;
export const CARD_LABEL_OFFSET = 40; // Distance from board edge
// Menu configuration
export const MENU_CONFIG = {
colors: {
title: "#1f2937",
buttonText: "#ffffff",
buttonBg: 0x3b82f6,
buttonBgHover: 0x2563eb,
subtitle: "#6b7280",
},
fontSize: {
title: "48px",
button: "24px",
subtitle: "16px",
},
button: {
width: 200,
height: 80,
},
positions: {
titleY: -120,
buttonY: 40,
subtitleY: 160,
},
} as const;
// UI colors
export const COLORS = {
red: 0xef4444,
black: 0x3b82f6,
highlight: 0xfbbf24,
highlightStroke: 0xf59e0b,
gridLine: 0x6b7280,
pawnStroke: 0x1f2937,
cardBg: 0xf9fafb,
cardStroke: 0x6b7280,
cardCenter: 0x3b82f6,
cardTarget: 0xef4444,
textDark: "#1f2937",
textGray: "#6b7280",
textInfo: "#4b5563",
winnerGold: "#fbbf24",
overlayBg: 0x000000,
menuButton: 0x6b7280,
menuButtonHover: 0x4b5563,
} as const;
// Font configuration
export const FONTS = {
title: {
fontSize: "28px",
fontFamily: "Arial",
color: COLORS.textDark,
},
info: {
fontSize: "16px",
fontFamily: "Arial",
color: COLORS.textInfo,
},
cardTitle: {
fontSize: "12px",
fontFamily: "Arial",
color: COLORS.textDark,
},
cardPlayer: {
fontSize: "10px",
fontFamily: "Arial",
color: COLORS.textGray,
},
pawnLabel: {
fontSize: "24px",
fontFamily: "Arial",
color: "#ffffff",
},
cardLabel: {
fontSize: "16px",
fontFamily: "Arial",
},
winner: {
fontSize: "36px",
fontFamily: "Arial",
color: COLORS.winnerGold,
},
menuButton: {
fontSize: "18px",
fontFamily: "Arial",
color: "#ffffff",
},
} as const;
// Menu button configuration
export const MENU_BUTTON = {
x: 680,
y: 40,
width: 120,
height: 40,
} as const;
// Text positioning
export const TEXT_POSITION = {
titleX: 40,
titleY: 40,
infoX: 40,
} as const;
// Animation durations (in ms)
export const ANIMATIONS = {
pawnSpawn: 300,
pawnDespawn: 300,
pawnMove: 400,
cardSpawn: 300,
cardDespawn: 200,
cardMove: 600,
cardRotate: 400,
highlightSpawn: 250,
highlightDespawn: 200,
selectionFadeIn: 200,
selectionFadeOut: 150,
selectionPulse: 500,
menuTitle: 600,
buttonHover: 100,
winnerPulse: 500,
clickFeedback: 150,
} as const;
// Grid cell size for card move visualization
export const CARD_GRID = {
cellSize: 14,
gridSize: 5,
} as const;
// 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
export function boardToScreen(
boardX: number,
boardY: number,
): { x: number; y: number } {
return {
x: BOARD_OFFSET.x + boardX * CELL_SIZE + CELL_SIZE / 2,
y: BOARD_OFFSET.y + (BOARD_SIZE - 1 - boardY) * CELL_SIZE + CELL_SIZE / 2,
};
}
// Helper function to get board center
export function getBoardCenter(): { x: number; y: number } {
return {
x: BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2,
y: BOARD_OFFSET.y + (BOARD_SIZE * CELL_SIZE) / 2,
};
}
// Helper function to get card label position
export function getCardLabelPosition(position: "red" | "black"): {
x: number;
y: number;
originX: number;
originY: number;
} {
const boardLeft = BOARD_OFFSET.x;
const boardTop = BOARD_OFFSET.y;
const boardBottom = BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE;
const centerX = boardLeft + (BOARD_SIZE * CELL_SIZE) / 2;
if (position === "red") {
return {
x: centerX,
y: boardBottom + CARD_LABEL_OFFSET,
originX: 0.5,
originY: 0,
};
} else {
return {
x: centerX,
y: boardTop - CARD_LABEL_OFFSET,
originX: 0.5,
originY: 1,
};
}
}
// Helper function to get card position
export function getCardPosition(
position: "red" | "black" | "spare",
index: number,
): { x: number; y: number } {
const boardLeft = BOARD_OFFSET.x;
const boardTop = BOARD_OFFSET.y;
const boardCenterX = boardLeft + (BOARD_SIZE * CELL_SIZE) / 2;
const boardBottom = BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE;
const boardCenterY = boardTop + (BOARD_SIZE * CELL_SIZE) / 2;
if (position === "red") {
return {
x: boardCenterX - (index - 0.5) * CARD_SPACING,
y: boardBottom + CARD_HEIGHT / 2 + CARD_LABEL_OFFSET,
};
} else if (position === "black") {
return {
x: boardCenterX - (index - 0.5) * CARD_SPACING,
y: boardTop - CARD_HEIGHT / 2 - CARD_LABEL_OFFSET,
};
} else {
return {
x: boardLeft - CARD_WIDTH / 2 - CARD_LABEL_OFFSET,
y: boardCenterY,
};
}
}
export function colorToStr(hex: number) {
return Display.Color.ValueToColor(hex).toString();
}
// Tween factory functions
export {
// Card tweens
createCardMoveTween,
createCardRotateTween,
createCardSpawnTween,
createCardDespawnTween,
// Pawn tweens
createPawnMoveTween,
createPawnSpawnTween,
createPawnDespawnTween,
// Highlight tweens
createHighlightSpawnTween,
createHighlightDespawnTween,
createHighlightClickFeedbackTween,
createHighlightInnerPulseTween,
createHighlightOuterPulseTween,
// Selection tweens
createSelectionFadeInTween,
createSelectionPulseTween,
createSelectionFadeOutTween,
createSelectionShowTween,
createSelectionRingPulseTween,
createSelectionHideTween,
// Overlay tweens
createWinnerPulseTween,
// Types
type TweenScene,
} from "@/config/tweens";
+322
View File
@@ -0,0 +1,322 @@
import type Phaser from "phaser";
import { ANIMATIONS } from "@/config";
export type TweenScene = Phaser.Scene & {
addTweenInterruption(tween: Phaser.Tweens.Tween): void;
};
// ─────────────────────────────────────────────────────────────
// Card Tweens
// ─────────────────────────────────────────────────────────────
export function createCardMoveTween(
scene: TweenScene,
target: Phaser.GameObjects.GameObject,
x: number,
y: number,
): Phaser.Tweens.Tween {
const tween = scene.tweens.add({
targets: target,
x,
y,
duration: ANIMATIONS.cardMove,
ease: "Back.easeOut",
});
scene.addTweenInterruption(tween);
return tween;
}
export function createCardRotateTween(
scene: TweenScene,
target: Phaser.GameObjects.GameObject,
fromAngle: number,
toAngle: number,
): Phaser.Tweens.Tween {
const tween = scene.tweens.add({
targets: target,
angle: { from: fromAngle, to: toAngle },
duration: ANIMATIONS.cardRotate,
ease: "Back.easeOut",
});
scene.addTweenInterruption(tween);
return tween;
}
export function createCardSpawnTween(
scene: TweenScene,
target: Phaser.GameObjects.GameObject,
): Phaser.Tweens.Tween {
const tween = scene.tweens.add({
targets: target,
alpha: 1,
duration: ANIMATIONS.cardSpawn,
ease: "Power2",
});
scene.addTweenInterruption(tween);
return tween;
}
export function createCardDespawnTween(
scene: TweenScene,
target: Phaser.GameObjects.GameObject,
onComplete: () => void,
): Phaser.Tweens.Tween {
const tween = scene.tweens.add({
targets: target,
alpha: 0,
scale: 0.8,
duration: ANIMATIONS.cardDespawn,
ease: "Power2",
onComplete,
});
scene.addTweenInterruption(tween);
return tween;
}
// ─────────────────────────────────────────────────────────────
// Pawn Tweens
// ─────────────────────────────────────────────────────────────
export function createPawnMoveTween(
scene: TweenScene,
target: Phaser.GameObjects.GameObject,
x: number,
y: number,
): Phaser.Tweens.Tween {
const tween = scene.tweens.add({
targets: target,
x,
y,
duration: ANIMATIONS.pawnMove,
ease: "Back.easeOut",
});
scene.addTweenInterruption(tween);
return tween;
}
export function createPawnSpawnTween(
scene: TweenScene,
target: Phaser.GameObjects.GameObject,
): Phaser.Tweens.Tween {
const tween = scene.tweens.add({
targets: target,
scale: 1,
duration: ANIMATIONS.pawnSpawn,
ease: "Back.easeOut",
});
scene.addTweenInterruption(tween);
return tween;
}
export function createPawnDespawnTween(
scene: TweenScene,
target: Phaser.GameObjects.GameObject,
onComplete: () => void,
): Phaser.Tweens.Tween {
const tween = scene.tweens.add({
targets: target,
scale: 0,
alpha: 0,
duration: ANIMATIONS.pawnDespawn,
ease: "Back.easeIn",
onComplete,
});
scene.addTweenInterruption(tween);
return tween;
}
// ─────────────────────────────────────────────────────────────
// Highlight Tweens
// ─────────────────────────────────────────────────────────────
export function createHighlightSpawnTween(
scene: TweenScene,
target: Phaser.GameObjects.GameObject,
): Phaser.Tweens.Tween {
const tween = scene.tweens.add({
targets: target,
alpha: 1,
scale: 1,
duration: ANIMATIONS.highlightSpawn,
ease: "Back.easeOut",
});
scene.addTweenInterruption(tween);
return tween;
}
export function createHighlightDespawnTween(
scene: TweenScene,
target: Phaser.GameObjects.GameObject,
onComplete: () => void,
): Phaser.Tweens.Tween {
const tween = scene.tweens.add({
targets: target,
scale: 0,
alpha: 0,
duration: ANIMATIONS.highlightDespawn,
ease: "Back.easeIn",
onComplete,
});
scene.addTweenInterruption(tween);
return tween;
}
export function createHighlightClickFeedbackTween(
scene: TweenScene,
target: Phaser.GameObjects.GameObject,
): Phaser.Tweens.Tween {
return scene.tweens.add({
targets: target,
scale: 1.5,
alpha: 0.8,
duration: ANIMATIONS.clickFeedback,
ease: "Power2",
yoyo: true,
});
}
export function createHighlightInnerPulseTween(
scene: TweenScene,
targets: Phaser.GameObjects.GameObject[],
): Phaser.Tweens.Tween {
return scene.tweens.add({
targets,
scale: 1.2,
alpha: 0.6,
duration: 600,
ease: "Sine.easeInOut",
yoyo: true,
repeat: -1,
});
}
export function createHighlightOuterPulseTween(
scene: TweenScene,
target: Phaser.GameObjects.GameObject,
): Phaser.Tweens.Tween {
return scene.tweens.add({
targets: target,
scale: 1.3,
alpha: 0.3,
duration: 800,
ease: "Sine.easeInOut",
yoyo: true,
repeat: -1,
delay: 200,
});
}
// ─────────────────────────────────────────────────────────────
// Selection Tweens
// ─────────────────────────────────────────────────────────────
export function createSelectionFadeInTween(
scene: TweenScene,
target: Phaser.GameObjects.GameObject,
onComplete: () => void,
): Phaser.Tweens.Tween {
return scene.tweens.add({
targets: target,
alpha: 1,
scale: 1.05,
duration: ANIMATIONS.selectionFadeIn,
ease: "Power2",
onComplete,
});
}
export function createSelectionPulseTween(
scene: TweenScene,
target: Phaser.GameObjects.GameObject,
lineWidth: number,
): Phaser.Tweens.Tween {
return scene.tweens.add({
targets: target,
alpha: 0.7,
lineWidth: lineWidth + 1,
duration: ANIMATIONS.selectionPulse,
ease: "Sine.easeInOut",
yoyo: true,
repeat: -1,
});
}
export function createSelectionFadeOutTween(
scene: TweenScene,
target: Phaser.GameObjects.GameObject,
onComplete: () => void,
): Phaser.Tweens.Tween {
return scene.tweens.add({
targets: target,
alpha: 0,
scale: 0.95,
duration: ANIMATIONS.selectionFadeOut,
ease: "Power2",
onComplete,
});
}
export function createSelectionShowTween(
scene: TweenScene,
target: Phaser.GameObjects.GameObject,
onCompleteFadeIn: () => void,
): Phaser.Tweens.Tween {
return scene.tweens.add({
targets: target,
alpha: 0.8,
duration: ANIMATIONS.selectionFadeIn,
ease: "Power2",
onComplete: onCompleteFadeIn,
});
}
export function createSelectionRingPulseTween(
scene: TweenScene,
target: Phaser.GameObjects.GameObject,
): Phaser.Tweens.Tween {
return scene.tweens.add({
targets: target,
scale: 1.15,
alpha: 0.6,
duration: ANIMATIONS.selectionPulse,
ease: "Sine.easeInOut",
yoyo: true,
repeat: -1,
});
}
export function createSelectionHideTween(
scene: TweenScene,
target: Phaser.GameObjects.GameObject,
onComplete: () => void,
): Phaser.Tweens.Tween {
return scene.tweens.add({
targets: target,
alpha: 0,
scale: 0.9,
duration: ANIMATIONS.selectionFadeOut,
ease: "Power2",
onComplete: () => {
target.destroy();
onComplete();
},
});
}
// ─────────────────────────────────────────────────────────────
// Overlay Tweens
// ─────────────────────────────────────────────────────────────
export function createWinnerPulseTween(
scene: TweenScene,
target: Phaser.GameObjects.GameObject,
): Phaser.Tweens.Tween {
return scene.tweens.add({
targets: target,
scale: 1.2,
duration: ANIMATIONS.winnerPulse,
yoyo: true,
repeat: 1,
});
}
+5 -5
View File
@@ -1,11 +1,11 @@
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,121 @@
import type { Card } from "@/game/onitama";
import type { OnitamaScene } from "@/scenes/OnitamaScene";
import {
CARD_WIDTH,
CARD_HEIGHT,
COLORS,
FONTS,
CARD_GRID,
VISUAL,
} from "@/config";
export interface CardRenderOptions {
card: Card;
}
/**
* Renderer for card game objects
* Extracts visual creation logic from CardContainer
*/
export class CardRenderer {
constructor(private readonly scene: OnitamaScene) {}
/**
* Render card visuals into a container
* @param container - The container to add visuals to
* @param options - Card rendering options
*/
render(
container: Phaser.GameObjects.Container,
options: CardRenderOptions,
): void {
const { card } = options;
// Create background rectangle
const bg = this.scene.add
.rectangle(0, 0, CARD_WIDTH, CARD_HEIGHT, COLORS.cardBg, 1)
.setStrokeStyle(VISUAL.cardStrokeWidth, COLORS.cardStroke);
container.add(bg);
// Create title text
const title = this.scene.add
.text(
0,
-CARD_HEIGHT / 2 + VISUAL.cardTitleOffset,
card.id,
FONTS.cardTitle,
)
.setOrigin(0.5);
container.add(title);
// Create move candidate grid
this.renderMoveGrid(container, card);
// Create starting player text
const playerText = this.scene.add
.text(
0,
CARD_HEIGHT / 2 - VISUAL.cardPlayerOffset,
card.startingPlayer,
FONTS.cardPlayer,
)
.setOrigin(0.5);
container.add(playerText);
}
/**
* Render the 5x5 grid showing move candidates
*/
private renderMoveGrid(
container: Phaser.GameObjects.Container,
card: Card,
): void {
const grid = this.scene.add.graphics();
const { cellSize, gridSize } = CARD_GRID;
const gridWidth = gridSize * cellSize;
const gridHeight = gridSize * cellSize;
const gridStartX = -gridWidth / 2;
const gridStartY = -gridHeight / 2 + 20;
for (let row = 0; row < gridSize; row++) {
for (let col = 0; col < gridSize; col++) {
const x = gridStartX + col * cellSize;
const y = gridStartY + row * cellSize;
const centerX = x + cellSize / 2;
const centerY = y + cellSize / 2;
const radius = cellSize / 3;
// Center position marker
if (row === 2 && col === 2) {
grid.fillStyle(COLORS.cardCenter, 1);
grid.fillCircle(centerX, centerY, radius);
} else {
// Check if this cell is a move candidate
const isTarget = card.moveCandidates.some(
(m) => m.dx === col - 2 && m.dy === 2 - row,
);
if (isTarget) {
grid.fillStyle(COLORS.cardTarget, 0.6);
grid.fillCircle(centerX, centerY, radius);
}
}
}
}
container.add(grid);
}
/**
* Create a standalone card visual at the specified position
* Useful for previews or temporary displays
*/
createStandalone(
x: number,
y: number,
options: CardRenderOptions,
): Phaser.GameObjects.Container {
const container = this.scene.add.container(x, y);
this.render(container, options);
return container;
}
}
@@ -0,0 +1,91 @@
import type { OnitamaScene } from "@/scenes/OnitamaScene";
import {
COLORS,
VISUAL,
createHighlightInnerPulseTween,
createHighlightOuterPulseTween,
} from "@/config";
export interface HighlightRenderOptions {
x: number;
y: number;
}
/**
* Renderer for move target highlight visuals
* Extracts visual creation logic from HighlightSpawner
*/
export class HighlightRenderer {
constructor(private readonly scene: OnitamaScene) {}
/**
* Render highlight visuals into a container
* @param container - The container to add visuals to
* @param options - Highlight rendering options
*/
render(
container: Phaser.GameObjects.Container,
options: HighlightRenderOptions,
): void {
const { x, y } = options;
// Set container position
container.setPosition(x, y);
// Outer circle (animated pulse)
const outerCircle = this.scene.add.circle(
0,
0,
VISUAL.highlightOuterRadius,
COLORS.black,
0.2,
);
container.add(outerCircle);
// Inner circle
const innerCircle = this.scene.add.circle(
0,
0,
VISUAL.highlightInnerRadius,
COLORS.black,
0.4,
);
container.add(innerCircle);
// Store references for animation
container.setData("outerCircle", outerCircle);
container.setData("innerCircle", innerCircle);
}
/**
* Create a standalone highlight visual at the specified position
* Useful for previews or temporary displays
*/
createStandalone(x: number, y: number): Phaser.GameObjects.Container {
const container = this.scene.add.container(x, y);
this.render(container, { x, y });
return container;
}
/**
* Setup pulse animations for highlight circles
* @param container - The highlight container
*/
setupPulseAnimations(container: Phaser.GameObjects.Container): void {
const outerCircle = container.getData("outerCircle") as
| Phaser.GameObjects.Arc
| undefined;
const innerCircle = container.getData("innerCircle") as
| Phaser.GameObjects.Arc
| undefined;
if (!outerCircle || !innerCircle) return;
// Inner circle pulse
createHighlightInnerPulseTween(this.scene, [outerCircle, innerCircle]);
// Outer circle staggered pulse
createHighlightOuterPulseTween(this.scene, outerCircle);
}
}
@@ -0,0 +1,59 @@
import type { OnitamaScene } from "@/scenes/OnitamaScene";
import { CELL_SIZE, COLORS, FONTS, VISUAL } from "@/config";
export type PawnType = "master" | "student";
export type PawnOwner = "red" | "black";
export interface PawnRenderOptions {
owner: PawnOwner;
type: PawnType;
}
/**
* Renderer for pawn game objects
* Extracts visual creation logic from PawnContainer
*/
export class PawnRenderer {
constructor(private readonly scene: OnitamaScene) {}
/**
* Render pawn visuals into a container
* @param container - The container to add visuals to
* @param options - Pawn rendering options
*/
render(
container: Phaser.GameObjects.Container,
options: PawnRenderOptions,
): void {
const { owner, type } = options;
// Create background circle
const bgColor = owner === "red" ? COLORS.red : COLORS.black;
const circle = this.scene.add
.circle(0, 0, VISUAL.pawnRadius, bgColor, 1)
.setStrokeStyle(VISUAL.pawnStrokeWidth, COLORS.pawnStroke);
container.add(circle);
// Create label text
const label = type === "master" ? "M" : "S";
const text = this.scene.add
.text(0, 0, label, FONTS.pawnLabel)
.setOrigin(0.5);
container.add(text);
}
/**
* Create a standalone pawn visual (circle + text) at the specified position
* Useful for previews or temporary displays
*/
createStandalone(
x: number,
y: number,
options: PawnRenderOptions,
): Phaser.GameObjects.Container {
const container = this.scene.add.container(x, y);
this.render(container, options);
return container;
}
}
@@ -0,0 +1,118 @@
import { GameObjects } from "phaser";
import type { OnitamaScene } from "@/scenes/OnitamaScene";
import {
CELL_SIZE,
COLORS,
VISUAL,
createSelectionShowTween,
createSelectionRingPulseTween,
createSelectionHideTween,
} from "@/config";
export interface SelectionRenderOptions {
x: number;
y: number;
}
/**
* Renderer for pawn selection ring visuals
* Extracts selection ring creation and animation logic from PawnContainer
*/
export class SelectionRenderer {
constructor(private readonly scene: OnitamaScene) {}
/**
* Create a selection ring visual
* @param parent - The parent container or game object to add the ring to
* @returns The selection ring game object
*/
create(
parent: Phaser.GameObjects.Container | Phaser.GameObjects.GameObject,
): Phaser.GameObjects.Arc {
const ring = this.scene.add
.arc(
0,
0,
VISUAL.pawnRadius + VISUAL.selectionRingOffset,
0,
360,
false,
COLORS.highlight,
0,
)
.setStrokeStyle(
VISUAL.selectionRingStrokeWidth,
COLORS.highlightStroke,
1,
)
.setAlpha(0);
// Add to parent at index 0 (behind other visuals)
if (parent instanceof GameObjects.Container) {
parent.addAt(ring, 0);
}
return ring;
}
/**
* Show selection with fade-in and pulse animation
* @param ring - The selection ring to animate
* @returns Cleanup function to stop animations
*/
show(ring: Phaser.GameObjects.Arc): () => void {
if (!ring.active) return () => {};
let pulseTween: Phaser.Tweens.Tween | null = null;
const tweens = this.scene.tweens;
// Fade in animation
// DO NOT add interruption here
// otherwise the game will stop indefinitely
createSelectionShowTween(this.scene, ring, () => {
// Start pulse animation after fade-in completes
pulseTween = createSelectionRingPulseTween(this.scene, ring);
});
// Return cleanup function
return () => {
if (pulseTween) {
pulseTween.stop();
pulseTween = null;
}
tweens.killTweensOf(ring);
};
}
/**
* Hide selection with fade-out animation
* @param ring - The selection ring to animate
* @param onComplete - Callback when animation completes
*/
hide(ring: Phaser.GameObjects.Arc, onComplete?: () => void): void {
if (!ring.active) {
onComplete?.();
return;
}
const tweens = this.scene.tweens;
// Stop any existing tweens on this ring
tweens.killTweensOf(ring);
// Fade out animation
createSelectionHideTween(this.scene, ring, () => onComplete?.());
}
/**
* Create a standalone selection ring at the specified position
* Useful for previews or temporary displays
*/
createStandalone(x: number, y: number): Phaser.GameObjects.Container {
const container = this.scene.add.container(x, y);
this.create(container);
return container;
}
}
@@ -0,0 +1,11 @@
export { PawnRenderer } from "./PawnRenderer";
export type { PawnRenderOptions, PawnType, PawnOwner } from "./PawnRenderer";
export { CardRenderer } from "./CardRenderer";
export type { CardRenderOptions } from "./CardRenderer";
export { HighlightRenderer } from "./HighlightRenderer";
export type { HighlightRenderOptions } from "./HighlightRenderer";
export { SelectionRenderer } from "./SelectionRenderer";
export type { SelectionRenderOptions } from "./SelectionRenderer";
+38 -69
View File
@@ -1,30 +1,6 @@
import { ReactiveScene } from 'boardgame-phaser'; import { ReactiveScene } from "boardgame-phaser";
import Phaser from 'phaser';
/** 菜单场景配置 */ import { MENU_CONFIG, ANIMATIONS } from "@/config";
const MENU_CONFIG = {
colors: {
title: '#1f2937',
buttonText: '#ffffff',
buttonBg: 0x3b82f6,
buttonBgHover: 0x2563eb,
subtitle: '#6b7280',
},
fontSize: {
title: '48px',
button: '24px',
subtitle: '16px',
},
button: {
width: 200,
height: 80,
},
positions: {
titleY: -120,
buttonY: 40,
subtitleY: 160,
},
} as const;
export class MenuScene extends ReactiveScene { export class MenuScene extends ReactiveScene {
private titleText!: Phaser.GameObjects.Text; private titleText!: Phaser.GameObjects.Text;
@@ -33,7 +9,7 @@ export class MenuScene extends ReactiveScene {
private startButtonText!: Phaser.GameObjects.Text; private startButtonText!: Phaser.GameObjects.Text;
constructor() { constructor() {
super('MenuScene'); super("MenuScene");
} }
create(): void { create(): void {
@@ -56,24 +32,21 @@ export class MenuScene extends ReactiveScene {
/** 创建标题文本 */ /** 创建标题文本 */
private createTitle(center: { x: number; y: number }): void { private createTitle(center: { x: number; y: number }): void {
this.titleText = this.add.text( this.titleText = this.add
center.x, .text(center.x, center.y + MENU_CONFIG.positions.titleY, "Onitama", {
center.y + MENU_CONFIG.positions.titleY,
'Onitama',
{
fontSize: MENU_CONFIG.fontSize.title, fontSize: MENU_CONFIG.fontSize.title,
fontFamily: 'Arial', fontFamily: "Arial",
color: MENU_CONFIG.colors.title, color: MENU_CONFIG.colors.title,
} })
).setOrigin(0.5); .setOrigin(0.5);
// 标题入场动画 // 标题入场动画
this.titleText.setScale(0); this.titleText.setScale(0);
this.tweens.add({ this.tweens.add({
targets: this.titleText, targets: this.titleText,
scale: 1, scale: 1,
duration: 600, duration: ANIMATIONS.menuTitle,
ease: 'Back.easeOut', ease: "Back.easeOut",
}); });
} }
@@ -81,29 +54,23 @@ export class MenuScene extends ReactiveScene {
private createStartButton(center: { x: number; y: number }): void { private createStartButton(center: { x: number; y: number }): void {
const { button, colors } = MENU_CONFIG; const { button, colors } = MENU_CONFIG;
this.startButtonBg = this.add.rectangle( this.startButtonBg = this.add
0, .rectangle(0, 0, button.width, button.height, colors.buttonBg)
0, .setOrigin(0.5)
button.width, .setInteractive({ useHandCursor: true });
button.height,
colors.buttonBg
).setOrigin(0.5).setInteractive({ useHandCursor: true });
this.startButtonText = this.add.text( this.startButtonText = this.add
0, .text(0, 0, "Start Game", {
0,
'Start Game',
{
fontSize: MENU_CONFIG.fontSize.button, fontSize: MENU_CONFIG.fontSize.button,
fontFamily: 'Arial', fontFamily: "Arial",
color: colors.buttonText, color: colors.buttonText,
} })
).setOrigin(0.5); .setOrigin(0.5);
this.startButtonContainer = this.add.container( this.startButtonContainer = this.add.container(
center.x, center.x,
center.y + MENU_CONFIG.positions.buttonY, center.y + MENU_CONFIG.positions.buttonY,
[this.startButtonBg, this.startButtonText] [this.startButtonBg, this.startButtonText],
); );
// 按钮交互 // 按钮交互
@@ -112,45 +79,47 @@ export class MenuScene extends ReactiveScene {
/** 设置按钮交互效果 */ /** 设置按钮交互效果 */
private setupButtonInteraction(): void { private setupButtonInteraction(): void {
this.startButtonBg.on('pointerover', () => { this.startButtonBg.on("pointerover", () => {
this.startButtonBg.setFillStyle(MENU_CONFIG.colors.buttonBgHover); this.startButtonBg.setFillStyle(MENU_CONFIG.colors.buttonBgHover);
this.tweens.add({ this.tweens.add({
targets: this.startButtonContainer, targets: this.startButtonContainer,
scale: 1.05, scale: 1.05,
duration: 100, duration: ANIMATIONS.buttonHover,
}); });
}); });
this.startButtonBg.on('pointerout', () => { this.startButtonBg.on("pointerout", () => {
this.startButtonBg.setFillStyle(MENU_CONFIG.colors.buttonBg); this.startButtonBg.setFillStyle(MENU_CONFIG.colors.buttonBg);
this.tweens.add({ this.tweens.add({
targets: this.startButtonContainer, targets: this.startButtonContainer,
scale: 1, scale: 1,
duration: 100, duration: ANIMATIONS.buttonHover,
}); });
}); });
this.startButtonBg.on('pointerdown', () => { this.startButtonBg.on("pointerdown", () => {
this.startGame(); this.startGame();
}); });
} }
/** 创建副标题 */ /** 创建副标题 */
private createSubtitle(center: { x: number; y: number }): void { private createSubtitle(center: { x: number; y: number }): void {
this.add.text( this.add
center.x, .text(
center.y + MENU_CONFIG.positions.subtitleY, center.x,
'Click to start playing', center.y + MENU_CONFIG.positions.subtitleY,
{ "Click to start playing",
fontSize: MENU_CONFIG.fontSize.subtitle, {
fontFamily: 'Arial', fontSize: MENU_CONFIG.fontSize.subtitle,
color: MENU_CONFIG.colors.subtitle, fontFamily: "Arial",
} color: MENU_CONFIG.colors.subtitle,
).setOrigin(0.5); },
)
.setOrigin(0.5);
} }
/** 开始游戏 */ /** 开始游戏 */
private async startGame(): Promise<void> { private async startGame(): Promise<void> {
await this.sceneController.launch('OnitamaScene'); await this.sceneController.launch("OnitamaScene");
} }
} }
+120 -113
View File
@@ -1,16 +1,38 @@
import Phaser from 'phaser'; import { GameHostScene, spawnEffect } from "boardgame-phaser";
import type { OnitamaState, Pawn } from '@/game/onitama';
import {getAvailableMoves, prompts} from '@/game/onitama'; import type { OnitamaState, Pawn } from "@/game/onitama";
import { GameHostScene } from 'boardgame-phaser'; import type { HighlightData } from "@/spawners/HighlightSpawner";
import { spawnEffect } from 'boardgame-phaser'; import type { OnitamaUIState } from "@/state";
import type { MutableSignal } from 'boardgame-core'; import type { MutableSignal } from "boardgame-core";
import type Phaser from "phaser";
import { import {
PawnSpawner, CardSpawner, BOARD_OFFSET, CELL_SIZE, CARD_WIDTH, CARD_HEIGHT, boardToScreen, BOARD_SIZE, COLORS,
HighlightSpawner FONTS,
} from '@/spawners'; MENU_BUTTON,
import type { HighlightData } from '@/spawners/HighlightSpawner'; TEXT_POSITION,
import {createUIState, clearSelection, selectPiece, selectCard, createValidMoves} from '@/state'; VISUAL,
import type { OnitamaUIState, ValidMove } from '@/state'; getBoardCenter,
getCardLabelPosition,
colorToStr,
createWinnerPulseTween,
} from "@/config";
import { prompts } from "@/game/onitama";
import {
PawnSpawner,
CardSpawner,
BOARD_OFFSET,
CELL_SIZE,
BOARD_SIZE,
boardToScreen,
HighlightSpawner,
} from "@/spawners";
import {
createUIState,
clearSelection,
selectPiece,
selectCard,
} from "@/state";
export class OnitamaScene extends GameHostScene<OnitamaState> { export class OnitamaScene extends GameHostScene<OnitamaState> {
private boardContainer!: Phaser.GameObjects.Container; private boardContainer!: Phaser.GameObjects.Container;
@@ -26,7 +48,7 @@ export class OnitamaScene extends GameHostScene<OnitamaState> {
public uiState!: MutableSignal<OnitamaUIState>; public uiState!: MutableSignal<OnitamaUIState>;
constructor() { constructor() {
super('OnitamaScene'); super("OnitamaScene");
} }
create(): void { create(): void {
@@ -60,14 +82,10 @@ export class OnitamaScene extends GameHostScene<OnitamaState> {
// Info text // Info text
this.infoText = this.add.text( this.infoText = this.add.text(
40, TEXT_POSITION.infoX,
BOARD_OFFSET.y, BOARD_OFFSET.y,
'', "",
{ FONTS.info,
fontSize: '16px',
fontFamily: 'Arial',
color: '#4b5563',
}
); );
// Update info text when UI state changes // Update info text when UI state changes
@@ -86,41 +104,30 @@ export class OnitamaScene extends GameHostScene<OnitamaState> {
} }
private createCardLabels(): void { private createCardLabels(): void {
const boardLeft = BOARD_OFFSET.x;
const boardTop = BOARD_OFFSET.y;
const boardRight = BOARD_OFFSET.x + BOARD_SIZE * CELL_SIZE;
const boardBottom = BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE;
// Red cards label - 棋盘下方 // Red cards label - 棋盘下方
const redLabel = this.add.text( const redPos = getCardLabelPosition("red");
boardLeft + (BOARD_SIZE * CELL_SIZE) / 2, const redLabel = this.add
boardBottom + 40, .text(redPos.x, redPos.y, "RED", {
"RED", ...FONTS.cardLabel,
{ color: colorToStr(COLORS.red),
fontSize: '16px', })
fontFamily: 'Arial', .setOrigin(redPos.originX, redPos.originY);
color: '#ef4444', this.cardLabelContainers.set("red", redLabel);
}
).setOrigin(0.5, 0);
this.cardLabelContainers.set('red', redLabel);
// Black cards label - 棋盘上方 // Black cards label - 棋盘上方
const blackLabel = this.add.text( const blackPos = getCardLabelPosition("black");
boardLeft + (BOARD_SIZE * CELL_SIZE) / 2, const blackLabel = this.add
boardTop - 40, .text(blackPos.x, blackPos.y, "BLACK", {
"BLACK", ...FONTS.cardLabel,
{ color: colorToStr(COLORS.black),
fontSize: '16px', })
fontFamily: 'Arial', .setOrigin(blackPos.originX, blackPos.originY);
color: '#3b82f6', this.cardLabelContainers.set("black", blackLabel);
}
).setOrigin(0.5, 1);
this.cardLabelContainers.set('black', blackLabel);
} }
private updateInfoText(): void { private updateInfoText(): void {
const currentPlayer = this.state.currentPlayer; const currentPlayer = this.state.currentPlayer;
if (this.state.winner) { if (this.state.winner) {
this.infoText.setText(`${this.state.winner} wins!`); this.infoText.setText(`${this.state.winner} wins!`);
} else { } else {
@@ -130,34 +137,30 @@ export class OnitamaScene extends GameHostScene<OnitamaState> {
private drawBoard(): void { private drawBoard(): void {
const g = this.gridGraphics; const g = this.gridGraphics;
g.lineStyle(2, 0x6b7280); g.lineStyle(2, COLORS.gridLine);
for (let i = 0; i <= BOARD_SIZE; i++) { for (let i = 0; i <= BOARD_SIZE; i++) {
g.lineBetween( g.lineBetween(
BOARD_OFFSET.x + i * CELL_SIZE, BOARD_OFFSET.x + i * CELL_SIZE,
BOARD_OFFSET.y, BOARD_OFFSET.y,
BOARD_OFFSET.x + i * CELL_SIZE, BOARD_OFFSET.x + i * CELL_SIZE,
BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE,
); );
g.lineBetween( g.lineBetween(
BOARD_OFFSET.x, BOARD_OFFSET.x,
BOARD_OFFSET.y + i * CELL_SIZE, BOARD_OFFSET.y + i * CELL_SIZE,
BOARD_OFFSET.x + BOARD_SIZE * CELL_SIZE, BOARD_OFFSET.x + BOARD_SIZE * CELL_SIZE,
BOARD_OFFSET.y + i * CELL_SIZE BOARD_OFFSET.y + i * CELL_SIZE,
); );
} }
g.strokePath(); g.strokePath();
this.add.text( this.add.text(
40, TEXT_POSITION.titleX,
40, TEXT_POSITION.titleY,
'Onitama', "Onitama",
{ FONTS.title,
fontSize: '28px',
fontFamily: 'Arial',
color: '#1f2937',
}
); );
} }
@@ -167,9 +170,11 @@ export class OnitamaScene extends GameHostScene<OnitamaState> {
for (let col = 0; col < BOARD_SIZE; col++) { for (let col = 0; col < BOARD_SIZE; col++) {
const pos = boardToScreen(col, row); const pos = boardToScreen(col, row);
const zone = this.add.zone(pos.x, pos.y, CELL_SIZE, CELL_SIZE).setInteractive(); const zone = this.add
.zone(pos.x, pos.y, CELL_SIZE, CELL_SIZE)
.setInteractive();
zone.on('pointerdown', () => { zone.on("pointerdown", () => {
if (this.state.winner) return; if (this.state.winner) return;
this.handleCellClick(col, row); this.handleCellClick(col, row);
}); });
@@ -179,7 +184,7 @@ export class OnitamaScene extends GameHostScene<OnitamaState> {
private handleCellClick(x: number, y: number): void { private handleCellClick(x: number, y: number): void {
const pawn = this.getPawnAtPosition(x, y); const pawn = this.getPawnAtPosition(x, y);
if(pawn?.owner !== this.state.currentPlayer){ if (pawn?.owner !== this.state.currentPlayer) {
return; return;
} }
selectPiece(this.uiState, x, y); selectPiece(this.uiState, x, y);
@@ -188,8 +193,9 @@ export class OnitamaScene extends GameHostScene<OnitamaState> {
public onCardClick(cardId: string): void { public onCardClick(cardId: string): void {
// 只能选择当前玩家的手牌 // 只能选择当前玩家的手牌
const currentPlayer = this.state.currentPlayer; const currentPlayer = this.state.currentPlayer;
const playerCards = currentPlayer === 'red' ? this.state.redCards : this.state.blackCards; const playerCards =
currentPlayer === "red" ? this.state.redCards : this.state.blackCards;
if (!playerCards.includes(cardId)) { if (!playerCards.includes(cardId)) {
return; return;
} }
@@ -208,7 +214,13 @@ export class OnitamaScene extends GameHostScene<OnitamaState> {
}); });
} }
private executeMove(move: { card: string; fromX: number; fromY: number; toX: number; toY: number }): void { private executeMove(move: {
card: string;
fromX: number;
fromY: number;
toX: number;
toY: number;
}): void {
const error = this.gameHost.tryAnswerPrompt( const error = this.gameHost.tryAnswerPrompt(
prompts.move, prompts.move,
this.state.currentPlayer, this.state.currentPlayer,
@@ -216,10 +228,10 @@ export class OnitamaScene extends GameHostScene<OnitamaState> {
move.fromX, move.fromX,
move.fromY, move.fromY,
move.toX, move.toX,
move.toY move.toY,
); );
if (error) { if (error) {
console.warn('Invalid move:', error); console.warn("Invalid move:", error);
} }
} }
@@ -231,39 +243,42 @@ export class OnitamaScene extends GameHostScene<OnitamaState> {
/** 创建菜单按钮 */ /** 创建菜单按钮 */
private createMenuButton(): void { private createMenuButton(): void {
const buttonX = 680; this.menuButtonBg = this.add
const buttonY = 40; .rectangle(
MENU_BUTTON.x,
this.menuButtonBg = this.add.rectangle(buttonX, buttonY, 120, 40, 0x6b7280) MENU_BUTTON.y,
MENU_BUTTON.width,
MENU_BUTTON.height,
COLORS.menuButton,
)
.setInteractive({ useHandCursor: true }); .setInteractive({ useHandCursor: true });
this.menuButtonText = this.add.text(buttonX, buttonY, 'Menu', { this.menuButtonText = this.add
fontSize: '18px', .text(MENU_BUTTON.x, MENU_BUTTON.y, "Menu", FONTS.menuButton)
fontFamily: 'Arial', .setOrigin(0.5);
color: '#ffffff',
}).setOrigin(0.5);
this.menuButtonContainer = this.add.container(buttonX, buttonY, [ this.menuButtonContainer = this.add.container(
this.menuButtonBg, MENU_BUTTON.x,
this.menuButtonText, MENU_BUTTON.y,
]); [this.menuButtonBg, this.menuButtonText],
);
this.menuButtonBg.on('pointerover', () => { this.menuButtonBg.on("pointerover", () => {
this.menuButtonBg.setFillStyle(0x4b5563); this.menuButtonBg.setFillStyle(COLORS.menuButtonHover);
}); });
this.menuButtonBg.on('pointerout', () => { this.menuButtonBg.on("pointerout", () => {
this.menuButtonBg.setFillStyle(0x6b7280); this.menuButtonBg.setFillStyle(COLORS.menuButton);
}); });
this.menuButtonBg.on('pointerdown', () => { this.menuButtonBg.on("pointerdown", () => {
this.goToMenu(); this.goToMenu();
}); });
} }
/** 跳转到菜单场景 */ /** 跳转到菜单场景 */
private async goToMenu(): Promise<void> { private async goToMenu(): Promise<void> {
await this.sceneController.launch('MenuScene'); await this.sceneController.launch("MenuScene");
} }
private showWinner(winner: string): void { private showWinner(winner: string): void {
@@ -273,42 +288,34 @@ export class OnitamaScene extends GameHostScene<OnitamaState> {
this.winnerOverlay = this.add.container(); this.winnerOverlay = this.add.container();
const text = winner === 'draw' ? "It's a draw!" : `${winner} wins!`; const text = winner === "draw" ? "It's a draw!" : `${winner} wins!`;
const center = getBoardCenter();
const boardWidth = BOARD_SIZE * CELL_SIZE;
const boardHeight = BOARD_SIZE * CELL_SIZE;
const bg = this.add.rectangle( const bg = this.add
BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2, .rectangle(
BOARD_OFFSET.y + (BOARD_SIZE * CELL_SIZE) / 2, center.x,
BOARD_SIZE * CELL_SIZE, center.y,
BOARD_SIZE * CELL_SIZE, boardWidth,
0x000000, boardHeight,
0.6 COLORS.overlayBg,
).setInteractive({ useHandCursor: true }); VISUAL.overlayAlpha,
)
.setInteractive({ useHandCursor: true });
bg.on('pointerdown', () => { bg.on("pointerdown", () => {
this.gameHost.start(); this.gameHost.start();
}); });
this.winnerOverlay.add(bg); this.winnerOverlay.add(bg);
const winText = this.add.text( const winText = this.add
BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2, .text(center.x, center.y, text, FONTS.winner)
BOARD_OFFSET.y + (BOARD_SIZE * CELL_SIZE) / 2, .setOrigin(0.5);
text,
{
fontSize: '36px',
fontFamily: 'Arial',
color: '#fbbf24',
}
).setOrigin(0.5);
this.winnerOverlay.add(winText); this.winnerOverlay.add(winText);
this.tweens.add({ createWinnerPulseTween(this, winText);
targets: winText,
scale: 1.2,
duration: 500,
yoyo: true,
repeat: 1,
});
} }
} }
+2 -2
View File
@@ -1,2 +1,2 @@
export { OnitamaScene } from './OnitamaScene'; export { OnitamaScene } from "./OnitamaScene";
export { MenuScene } from './MenuScene'; export { MenuScene } from "./MenuScene";
+102 -185
View File
@@ -1,18 +1,31 @@
import Phaser from 'phaser';
import type { Card } from '@/game/onitama';
import type { Spawner } from 'boardgame-phaser';
import type { OnitamaScene } from '@/scenes/OnitamaScene';
import { BOARD_OFFSET, CELL_SIZE } from './PawnSpawner';
import { effect } from "@preact/signals-core"; import { effect } from "@preact/signals-core";
export const CARD_WIDTH = 80; import type { Card } from "@/game/onitama";
export const CARD_HEIGHT = 120; import type { OnitamaScene } from "@/scenes/OnitamaScene";
const BOARD_SIZE = 5; import type { Spawner } from "boardgame-phaser";
const CARD_SPACING = 100; // 确保每张卡牌至少 80x80 区域
import {
CARD_WIDTH,
CARD_HEIGHT,
COLORS,
VISUAL,
getCardPosition,
createCardMoveTween,
createCardRotateTween,
createCardSpawnTween,
createCardDespawnTween,
createSelectionFadeInTween,
createSelectionPulseTween,
createSelectionFadeOutTween,
} from "@/config";
import { CardRenderer } from "@/renderers";
// Re-export for backward compatibility
export { CARD_WIDTH, CARD_HEIGHT } from "@/config";
export interface CardSpawnData { export interface CardSpawnData {
cardId: string; cardId: string;
position: 'red' | 'black' | 'spare'; position: "red" | "black" | "spare";
index: number; index: number;
} }
@@ -25,11 +38,18 @@ export class CardContainer extends Phaser.GameObjects.Container {
private highlightTween: Phaser.Tweens.Tween | null = null; private highlightTween: Phaser.Tweens.Tween | null = null;
private _cardId: string; private _cardId: string;
private _rotation: number = 0; private _rotation: number = 0;
private cardRenderer: CardRenderer;
constructor(scene: OnitamaScene, cardId: string, card: Card, rotation: number = 0) { constructor(
scene: OnitamaScene,
cardId: string,
card: Card,
rotation: number = 0,
) {
super(scene, 0, 0); super(scene, 0, 0);
this._cardId = cardId; this._cardId = cardId;
this._rotation = rotation; this._rotation = rotation;
this.cardRenderer = new CardRenderer(scene);
// 将容器添加到场景 // 将容器添加到场景
scene.add.existing(this); scene.add.existing(this);
@@ -37,11 +57,16 @@ export class CardContainer extends Phaser.GameObjects.Container {
// 应用旋转 // 应用旋转
this.angle = rotation; this.angle = rotation;
// 创建卡牌视觉 // 使用 CardRenderer 创建卡牌视觉
this.createCardVisual(card); this.cardRenderer.render(this, { card });
// 使卡牌可点击 // 使卡牌可点击
const hitArea = new Phaser.Geom.Rectangle(-CARD_WIDTH / 2, -CARD_HEIGHT / 2, CARD_WIDTH, CARD_HEIGHT); const hitArea = new Phaser.Geom.Rectangle(
-CARD_WIDTH / 2,
-CARD_HEIGHT / 2,
CARD_WIDTH,
CARD_HEIGHT,
);
this.setInteractive(hitArea, Phaser.Geom.Rectangle.Contains); this.setInteractive(hitArea, Phaser.Geom.Rectangle.Contains);
// 添加场景 effect 监听高亮状态变化 // 添加场景 effect 监听高亮状态变化
@@ -54,13 +79,12 @@ export class CardContainer extends Phaser.GameObjects.Container {
setRotation(rotation: number, animated: boolean = false): this { setRotation(rotation: number, animated: boolean = false): this {
if (animated) { if (animated) {
const currentAngle = this.angle; const currentAngle = this.angle;
const tween = this.scene.tweens.add({ createCardRotateTween(
targets: this, this.scene as OnitamaScene,
angle: { from: currentAngle, to: rotation }, this,
duration: 400, currentAngle,
ease: 'Back.easeOut', rotation,
}); );
(this.scene as OnitamaScene).addTweenInterruption(tween);
} else { } else {
this.angle = rotation; this.angle = rotation;
} }
@@ -76,33 +100,22 @@ export class CardContainer extends Phaser.GameObjects.Container {
if (!this.highlightRect) { if (!this.highlightRect) {
// 创建高亮边框(初始透明) // 创建高亮边框(初始透明)
this.highlightRect = (this.scene as OnitamaScene).add.rectangle( const rect = (this.scene as OnitamaScene).add
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.addAt(this.highlightRect, 0); this.addAt(this.highlightRect, 0);
// 淡入动画 // 淡入动画
const fadeIn = this.scene.tweens.add({ createSelectionFadeInTween(this.scene as OnitamaScene, rect, () => {
targets: this.highlightRect, // 淡入完成后开始脉冲动画
alpha: 1, this.highlightTween = createSelectionPulseTween(
scale: 1.05, this.scene as OnitamaScene,
duration: 200, rect,
ease: 'Power2', lineWidth,
onComplete: () => { );
// 淡入完成后开始脉冲动画
this.highlightTween = this.scene.tweens.add({
targets: this.highlightRect,
alpha: 0.7,
lineWidth: lineWidth + 1,
duration: 500,
ease: 'Sine.easeInOut',
yoyo: true,
repeat: -1,
});
},
}); });
} else { } else {
// 如果已经存在,停止当前动画并重新开始脉冲 // 如果已经存在,停止当前动画并重新开始脉冲
@@ -110,15 +123,11 @@ export class CardContainer extends Phaser.GameObjects.Container {
this.highlightTween.stop(); this.highlightTween.stop();
} }
this.highlightRect.setStrokeStyle(lineWidth, color); this.highlightRect.setStrokeStyle(lineWidth, color);
this.highlightTween = this.scene.tweens.add({ this.highlightTween = createSelectionPulseTween(
targets: this.highlightRect, this.scene as OnitamaScene,
alpha: 0.7, this.highlightRect,
lineWidth: lineWidth + 1, lineWidth,
duration: 500, );
ease: 'Sine.easeInOut',
yoyo: true,
repeat: -1,
});
} }
} }
@@ -135,18 +144,15 @@ export class CardContainer extends Phaser.GameObjects.Container {
this.scene.tweens.killTweensOf(this.highlightRect); this.scene.tweens.killTweensOf(this.highlightRect);
// 淡出动画 // 淡出动画
this.scene.tweens.add({ createSelectionFadeOutTween(
targets: this.highlightRect, this.scene as OnitamaScene,
alpha: 0, this.highlightRect,
scale: 0.95, () => {
duration: 150,
ease: 'Power2',
onComplete: () => {
// 淡出完成后销毁矩形 // 淡出完成后销毁矩形
this.highlightRect?.destroy(); this.highlightRect?.destroy();
this.highlightRect = null; this.highlightRect = null;
}, },
}); );
} }
} }
@@ -157,124 +163,50 @@ 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();
} }
}); });
// 在容器销毁时清理 effect // 在容器销毁时清理 effect
this.on('destroy', () => { this.on("destroy", () => {
dispose(); dispose();
}); });
} }
/**
* 创建卡牌视觉元素
*/
private createCardVisual(card: Card): void {
const bg = (this.scene as OnitamaScene).add.rectangle(0, 0, CARD_WIDTH, CARD_HEIGHT, 0xf9fafb, 1)
.setStrokeStyle(2, 0x6b7280);
this.add(bg);
const title = (this.scene as OnitamaScene).add.text(0, -CARD_HEIGHT / 2 + 16, card.id, {
fontSize: '12px',
fontFamily: 'Arial',
color: '#1f2937',
}).setOrigin(0.5);
this.add(title);
const grid = (this.scene as OnitamaScene).add.graphics();
const cellSize = 14;
const gridWidth = 5 * cellSize;
const gridHeight = 5 * cellSize;
const gridStartX = -gridWidth / 2;
const gridStartY = -gridHeight / 2 + 20;
for (let row = 0; row < 5; row++) {
for (let col = 0; col < 5; col++) {
const x = gridStartX + col * cellSize;
const y = gridStartY + row * cellSize;
if (row === 2 && col === 2) {
grid.fillStyle(0x3b82f6, 1);
grid.fillCircle(x + cellSize / 2, y + cellSize / 2, cellSize / 3);
} else {
const isTarget = card.moveCandidates.some(m => m.dx === col - 2 && m.dy === 2 - row);
if (isTarget) {
grid.fillStyle(0xef4444, 0.6);
grid.fillCircle(x + cellSize / 2, y + cellSize / 2, cellSize / 3);
}
}
}
}
this.add(grid);
const playerText = (this.scene as OnitamaScene).add.text(0, CARD_HEIGHT / 2 - 16, card.startingPlayer, {
fontSize: '10px',
fontFamily: 'Arial',
color: '#6b7280',
}).setOrigin(0.5);
this.add(playerText);
}
} }
export class CardSpawner implements Spawner<CardSpawnData, CardContainer> { export class CardSpawner implements Spawner<CardSpawnData, CardContainer> {
private previousData = new Map<string, CardSpawnData>(); private previousData = new Map<string, CardSpawnData>();
private cardRenderer: CardRenderer;
constructor(public readonly scene: OnitamaScene) {} constructor(public readonly scene: OnitamaScene) {
this.cardRenderer = new CardRenderer(scene);
}
*getData(): Iterable<CardSpawnData> { *getData(): Iterable<CardSpawnData> {
const state = this.scene.state; const state = this.scene.state;
// 红方卡牌 // 红方卡牌
for (let i = 0; i < state.redCards.length; i++) { for (let i = 0; i < state.redCards.length; i++) {
yield { cardId: state.redCards[i], position: 'red', index: i }; yield { cardId: state.redCards[i], position: "red", index: i };
} }
// 黑方卡牌 // 黑方卡牌
for (let i = 0; i < state.blackCards.length; i++) { for (let i = 0; i < state.blackCards.length; i++) {
yield { cardId: state.blackCards[i], position: 'black', index: i }; yield { cardId: state.blackCards[i], position: "black", index: i };
} }
// 备用卡牌 // 备用卡牌
if(state.spareCard) if (state.spareCard) {
yield { cardId: state.spareCard, position: 'spare', index: 0 }; yield { cardId: state.spareCard, position: "spare", index: 0 };
}
} }
getKey(data: CardSpawnData): string { getKey(data: CardSpawnData): string {
return data.cardId; return data.cardId;
} }
private getCardPosition(data: CardSpawnData): { x: number, y: number } {
const boardLeft = BOARD_OFFSET.x;
const boardTop = BOARD_OFFSET.y;
const boardRight = BOARD_OFFSET.x + BOARD_SIZE * CELL_SIZE;
const boardCenterX = boardLeft + (BOARD_SIZE * CELL_SIZE) / 2;
const boardBottom = BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE;
if (data.position === 'red') {
// 红方卡牌在棋盘下方,水平排列
return {
x: boardCenterX - (data.index - 0.5) * CARD_SPACING,
y: boardBottom + CARD_HEIGHT / 2 + 40,
};
} else if (data.position === 'black') {
// 黑方卡牌在棋盘上方,水平排列
return {
x: boardCenterX - (data.index - 0.5) * CARD_SPACING,
y: boardTop - CARD_HEIGHT / 2 - 40,
};
} else {
// 备用卡牌在棋盘左侧,垂直居中
const boardCenterY = boardTop + (BOARD_SIZE * CELL_SIZE) / 2;
return {
x: boardLeft - CARD_WIDTH / 2 - 40,
y: boardCenterY,
};
}
}
private hasPositionChanged(data: CardSpawnData): boolean { private hasPositionChanged(data: CardSpawnData): boolean {
const prev = this.previousData.get(data.cardId); const prev = this.previousData.get(data.cardId);
if (!prev) return true; if (!prev) return true;
@@ -282,13 +214,13 @@ export class CardSpawner implements Spawner<CardSpawnData, CardContainer> {
} }
onUpdate(data: CardSpawnData, obj: CardContainer): void { onUpdate(data: CardSpawnData, obj: CardContainer): void {
const isBlackTurn = this.scene.state.currentPlayer === 'black'; const isBlackTurn = this.scene.state.currentPlayer === "black";
// 检查卡牌是否需要旋转 // 检查卡牌是否需要旋转
let targetRotation = 0; let targetRotation = 0;
if (data.position === 'black') { if (data.position === "black") {
targetRotation = -180; // 黑方卡牌始终旋转 targetRotation = -180; // 黑方卡牌始终旋转
} else if (data.position === 'spare' && isBlackTurn) { } else if (data.position === "spare" && isBlackTurn) {
targetRotation = -180; // 备用卡牌在黑方回合旋转 targetRotation = -180; // 备用卡牌在黑方回合旋转
} }
@@ -302,18 +234,10 @@ export class CardSpawner implements Spawner<CardSpawnData, CardContainer> {
return; return;
} }
const pos = this.getCardPosition(data); const pos = getCardPosition(data.position, data.index);
// 播放移动动画并添加中断 // 播放移动动画并添加中断
const tween = this.scene.tweens.add({ createCardMoveTween(this.scene, obj, pos.x, pos.y);
targets: obj,
x: pos.x,
y: pos.y,
duration: 600,
ease: 'Back.easeOut',
});
this.scene.addTweenInterruption(tween);
this.previousData.set(data.cardId, { ...data }); this.previousData.set(data.cardId, { ...data });
} }
@@ -323,65 +247,58 @@ export class CardSpawner implements Spawner<CardSpawnData, CardContainer> {
this.previousData.set(data.cardId, { ...data }); this.previousData.set(data.cardId, { ...data });
// 返回空容器 // 返回空容器
const emptyContainer = new CardContainer(this.scene, data.cardId, { const emptyContainer = new CardContainer(this.scene, data.cardId, {
id: data.cardId, regionId: '', position: [], id: data.cardId,
regionId: "",
position: [],
moveCandidates: [], moveCandidates: [],
startingPlayer: 'red' startingPlayer: "red",
} as Card); } as Card);
return emptyContainer; return emptyContainer;
} }
// 计算初始旋转角度 // 计算初始旋转角度
const isBlackTurn = this.scene.state.currentPlayer === 'black'; const isBlackTurn = this.scene.state.currentPlayer === "black";
let initialRotation = 0; let initialRotation = 0;
if (data.position === 'black') { if (data.position === "black") {
initialRotation = -180; initialRotation = -180;
} else if (data.position === 'spare' && isBlackTurn) { } else if (data.position === "spare" && isBlackTurn) {
initialRotation = -180; initialRotation = -180;
} }
const container = new CardContainer(this.scene, data.cardId, card, initialRotation); const container = new CardContainer(
const pos = this.getCardPosition(data); this.scene,
data.cardId,
card,
initialRotation,
);
const pos = getCardPosition(data.position, data.index);
container.x = pos.x; container.x = pos.x;
container.y = pos.y; container.y = pos.y;
// 设置悬停效果 // 设置悬停效果
container.on('pointerover', () => { container.on("pointerover", () => {
if (this.scene.uiState.value.selectedCard !== data.cardId) { if (this.scene.uiState.value.selectedCard !== data.cardId) {
container.setAlpha(0.8); container.setAlpha(VISUAL.cardDisabledAlpha);
} }
}); });
container.on('pointerout', () => { container.on("pointerout", () => {
container.setAlpha(1); container.setAlpha(1);
}); });
container.on('pointerdown', () => { container.on("pointerdown", () => {
this.scene.onCardClick(data.cardId); this.scene.onCardClick(data.cardId);
}); });
// 初始状态为透明,然后淡入 // 初始状态为透明,然后淡入
container.setAlpha(0); container.setAlpha(0);
const tween = this.scene.tweens.add({ createCardSpawnTween(this.scene, container);
targets: container,
alpha: 1,
duration: 300,
ease: 'Power2',
});
this.scene.addTweenInterruption(tween);
this.previousData.set(data.cardId, { ...data }); this.previousData.set(data.cardId, { ...data });
return container; return container;
} }
onDespawn(obj: CardContainer): void { onDespawn(obj: CardContainer): void {
const tween = this.scene.tweens.add({ createCardDespawnTween(this.scene, obj, () => obj.destroy());
targets: obj,
alpha: 0,
scale: 0.8,
duration: 200,
ease: 'Power2',
onComplete: () => obj.destroy(),
});
this.scene.addTweenInterruption(tween);
} }
} }
@@ -1,8 +1,18 @@
import Phaser from 'phaser'; import { getAvailableMoves } from "boardgame-core/samples/onitama";
import type { Spawner } from 'boardgame-phaser'; import { Geom } from "phaser";
import type { OnitamaScene } from '@/scenes/OnitamaScene';
import {getAvailableMoves} from "boardgame-core/samples/onitama"; import type { OnitamaScene } from "@/scenes/OnitamaScene";
import {boardToScreen, CELL_SIZE} from './PawnSpawner'; import type { Spawner } from "boardgame-phaser";
import {
boardToScreen,
CELL_SIZE,
VISUAL,
createHighlightSpawnTween,
createHighlightDespawnTween,
createHighlightClickFeedbackTween,
} from "@/config";
import { HighlightRenderer } from "@/renderers";
export interface HighlightData { export interface HighlightData {
key: string; key: string;
@@ -15,8 +25,15 @@ export interface HighlightData {
toY: number; toY: number;
} }
export class HighlightSpawner implements Spawner<HighlightData, Phaser.GameObjects.Container> { export class HighlightSpawner implements Spawner<
constructor(public readonly scene: OnitamaScene) {} HighlightData,
Phaser.GameObjects.Container
> {
private renderer: HighlightRenderer;
constructor(public readonly scene: OnitamaScene) {
this.renderer = new HighlightRenderer(scene);
}
*getData(): Iterable<HighlightData> { *getData(): Iterable<HighlightData> {
const state = this.scene.state; const state = this.scene.state;
@@ -31,10 +48,12 @@ export class HighlightSpawner implements Spawner<HighlightData, Phaser.GameObjec
const availableMoves = getAvailableMoves(state, currentPlayer); const availableMoves = getAvailableMoves(state, currentPlayer);
// 过滤出符合当前选择的移动 // 过滤出符合当前选择的移动
for(const move of availableMoves){ for (const move of availableMoves) {
if(move.fromX === uiState.selectedPiece!.x && if (
move.fromY === uiState.selectedPiece!.y && move.fromX === uiState.selectedPiece.x &&
move.card === uiState.selectedCard){ move.fromY === uiState.selectedPiece.y &&
move.card === uiState.selectedCard
) {
const pos = boardToScreen(move.toX, move.toY); const pos = boardToScreen(move.toX, move.toY);
yield { yield {
key: `${move.fromX}-${move.fromY}-${move.card}-${move.toX}-${move.toY}`, key: `${move.fromX}-${move.fromY}-${move.card}-${move.toX}-${move.toY}`,
@@ -44,8 +63,8 @@ export class HighlightSpawner implements Spawner<HighlightData, Phaser.GameObjec
fromX: move.fromX, fromX: move.fromX,
fromY: move.fromY, fromY: move.fromY,
toX: move.toX, toX: move.toX,
toY: move.toY toY: move.toY,
} };
} }
} }
} }
@@ -61,77 +80,27 @@ export class HighlightSpawner implements Spawner<HighlightData, Phaser.GameObjec
onSpawn(data: HighlightData): Phaser.GameObjects.Container { onSpawn(data: HighlightData): Phaser.GameObjects.Container {
const container = this.scene.add.container(data.x, data.y); const container = this.scene.add.container(data.x, data.y);
// 外圈光环(动画) // 使用 HighlightRenderer 创建视觉元素
const outerCircle = this.scene.add.circle( this.renderer.render(container, { x: data.x, y: data.y });
0,
0,
CELL_SIZE / 3,
0x3b82f6,
0.2
);
container.add(outerCircle);
// 内圈 // 设置交互区域
const innerCircle = this.scene.add.circle( const hitArea = new Geom.Circle(0, 0, VISUAL.highlightHitAreaRadius);
0,
0,
CELL_SIZE / 4,
0x3b82f6,
0.4
);
container.add(innerCircle);
const hitArea = new Phaser.Geom.Circle(0, 0, CELL_SIZE / 3);
container.setInteractive(hitArea, Phaser.Geom.Circle.Contains); container.setInteractive(hitArea, Phaser.Geom.Circle.Contains);
if (container.input) { if (container.input) {
container.input.cursor = 'pointer'; container.input.cursor = "pointer";
} }
// 出现动画:从0缩放和透明淡入 // 出现动画:从0缩放和透明淡入
container.setAlpha(0); container.setAlpha(0);
container.setScale(0); container.setScale(0);
const spawnTween = this.scene.tweens.add({ createHighlightSpawnTween(this.scene, container);
targets: container,
alpha: 1,
scale: 1,
duration: 250,
ease: 'Back.easeOut',
});
this.scene.addTweenInterruption(spawnTween);
// 脉冲动画 // 使用 HighlightRenderer 设置脉冲动画
this.scene.tweens.add({ this.renderer.setupPulseAnimations(container);
targets: [outerCircle, innerCircle],
scale: 1.2,
alpha: 0.6,
duration: 600,
ease: 'Sine.easeInOut',
yoyo: true,
repeat: -1,
});
// 外圈延迟动画,形成错开效果 container.on("pointerdown", () => {
this.scene.tweens.add({
targets: outerCircle,
scale: 1.3,
alpha: 0.3,
duration: 800,
ease: 'Sine.easeInOut',
yoyo: true,
repeat: -1,
delay: 200,
});
container.on('pointerdown', () => {
// 点击时的反馈动画 // 点击时的反馈动画
this.scene.tweens.add({ createHighlightClickFeedbackTween(this.scene, container);
targets: container,
scale: 1.5,
alpha: 0.8,
duration: 150,
ease: 'Power2',
yoyo: true,
});
this.scene.onHighlightClick(data); this.scene.onHighlightClick(data);
}); });
@@ -141,15 +110,6 @@ export class HighlightSpawner implements Spawner<HighlightData, Phaser.GameObjec
onDespawn(obj: Phaser.GameObjects.Container): void { onDespawn(obj: Phaser.GameObjects.Container): void {
// 消失动画:缩小并淡出 // 消失动画:缩小并淡出
const despawnTween = this.scene.tweens.add({ createHighlightDespawnTween(this.scene, obj, () => obj.destroy());
targets: obj,
scale: 0,
alpha: 0,
duration: 200,
ease: 'Back.easeIn',
onComplete: () => obj.destroy(),
});
this.scene.addTweenInterruption(despawnTween);
} }
} }
+44 -114
View File
@@ -1,43 +1,46 @@
import Phaser from 'phaser';
import type { Pawn } from '@/game/onitama';
import type { Spawner } from 'boardgame-phaser';
import type { OnitamaScene } from '@/scenes/OnitamaScene';
import type { OnitamaUIState } from '@/state';
import { effect } from "@preact/signals-core"; import { effect } from "@preact/signals-core";
import { GameObjects } from "phaser";
export const CELL_SIZE = 80; import type { Pawn } from "@/game/onitama";
export const BOARD_OFFSET = { x: 240, y: 240 }; import type { OnitamaScene } from "@/scenes/OnitamaScene";
export const BOARD_SIZE = 5; import type { Spawner } from "boardgame-phaser";
export function boardToScreen(boardX: number, boardY: number): { x: number; y: number } { import {
return { boardToScreen,
x: BOARD_OFFSET.x + boardX * CELL_SIZE + CELL_SIZE / 2, createPawnMoveTween,
y: BOARD_OFFSET.y + (BOARD_SIZE - 1 - boardY) * CELL_SIZE + CELL_SIZE / 2, createPawnSpawnTween,
}; createPawnDespawnTween,
} } from "@/config";
import { PawnRenderer, SelectionRenderer } from "@/renderers";
// Re-export for backward compatibility
export { CELL_SIZE, BOARD_OFFSET, BOARD_SIZE, boardToScreen } from "@/config";
/** /**
* 继承自 Phaser.GameObjects.Container 的棋子容器类 * 继承自 Phaser.GameObjects.Container 的棋子容器类
* 管理棋子视觉元素和选中状态 * 管理棋子视觉元素和选中状态
*/ */
export class PawnContainer extends Phaser.GameObjects.Container { export class PawnContainer extends GameObjects.Container {
private selectionRing: Phaser.GameObjects.Arc | null = null; private selectionRing: Phaser.GameObjects.Arc | null = null;
private selectionTween: Phaser.Tweens.Tween | null = null;
private _position: [number, number]; private _position: [number, number];
private _owner: 'red' | 'black'; private _owner: "red" | "black";
private _type: 'master' | 'student'; private _type: "master" | "student";
private pawnRenderer: PawnRenderer;
private selectionRenderer: SelectionRenderer;
constructor(scene: OnitamaScene, pawn: Pawn) { constructor(scene: OnitamaScene, pawn: Pawn) {
super(scene, 0, 0); super(scene, 0, 0);
this._owner = pawn.owner; this._owner = pawn.owner;
this._type = pawn.type; this._type = pawn.type;
this._position = pawn.position as [number, number]; this._position = pawn.position as [number, number];
this.pawnRenderer = new PawnRenderer(scene);
this.selectionRenderer = new SelectionRenderer(scene);
// 将容器添加到场景 // 将容器添加到场景
scene.add.existing(this); scene.add.existing(this);
// 创建棋子视觉 // 使用 PawnRenderer 创建棋子视觉
this.createPawnVisual(); this.pawnRenderer.render(this, { owner: this._owner, type: this._type });
// 添加选中状态监听 // 添加选中状态监听
this.addSelectionEffect(scene); this.addSelectionEffect(scene);
@@ -50,33 +53,8 @@ export class PawnContainer extends Phaser.GameObjects.Container {
if (!this.active) return; if (!this.active) return;
if (!this.selectionRing) { if (!this.selectionRing) {
// 创建选中光环(初始透明) this.selectionRing = this.selectionRenderer.create(this);
this.selectionRing = (this.scene as OnitamaScene).add.arc( this.selectionRenderer.show(this.selectionRing);
0, 0, CELL_SIZE / 3 + 5, 0, 360, false, 0xfbbf24, 0
)
.setStrokeStyle(3, 0xf59e0b, 1)
.setAlpha(0);
this.addAt(this.selectionRing, 0);
// 淡入动画
const fadeIn = this.scene.tweens.add({
targets: this.selectionRing,
alpha: 0.8,
duration: 200,
ease: 'Power2',
onComplete: () => {
// 淡入完成后开始脉冲动画
this.selectionTween = this.scene.tweens.add({
targets: this.selectionRing,
scale: 1.15,
alpha: 0.6,
duration: 500,
ease: 'Sine.easeInOut',
yoyo: true,
repeat: -1,
});
},
});
} }
} }
@@ -85,25 +63,8 @@ export class PawnContainer extends Phaser.GameObjects.Container {
*/ */
hideSelection(): void { hideSelection(): void {
if (this.selectionRing) { if (this.selectionRing) {
// 停止所有动画 this.selectionRenderer.hide(this.selectionRing, () => {
if (this.selectionTween) { this.selectionRing = null;
this.selectionTween.stop();
this.selectionTween = null;
}
this.scene.tweens.killTweensOf(this.selectionRing);
// 淡出动画
this.scene.tweens.add({
targets: this.selectionRing,
alpha: 0,
scale: 0.9,
duration: 150,
ease: 'Power2',
onComplete: () => {
// 淡出完成后销毁
this.selectionRing?.destroy();
this.selectionRing = null;
},
}); });
} }
} }
@@ -114,8 +75,9 @@ export class PawnContainer extends Phaser.GameObjects.Container {
private addSelectionEffect(scene: OnitamaScene): void { private addSelectionEffect(scene: OnitamaScene): void {
const dispose = effect(() => { const dispose = effect(() => {
const uiState = scene.uiState.value; const uiState = scene.uiState.value;
const isSelected = uiState.selectedPiece?.x === this._position[0] && const isSelected =
uiState.selectedPiece?.y === this._position[1]; uiState.selectedPiece?.x === this._position[0] &&
uiState.selectedPiece?.y === this._position[1];
if (isSelected) { if (isSelected) {
this.showSelection(); this.showSelection();
@@ -124,7 +86,7 @@ export class PawnContainer extends Phaser.GameObjects.Container {
} }
}); });
this.on('destroy', () => { this.on("destroy", () => {
dispose(); dispose();
}); });
} }
@@ -132,42 +94,25 @@ export class PawnContainer extends Phaser.GameObjects.Container {
/** /**
* 更新棋子位置 * 更新棋子位置
*/ */
updatePosition(newPosition: [number, number], animated: boolean = false): void { updatePosition(
newPosition: [number, number],
animated: boolean = false,
): void {
this._position = newPosition; this._position = newPosition;
const targetPos = boardToScreen(newPosition[0], newPosition[1]); const targetPos = boardToScreen(newPosition[0], newPosition[1]);
if (animated) { if (animated) {
const tween = this.scene.tweens.add({ createPawnMoveTween(
targets: this, this.scene as OnitamaScene,
x: targetPos.x, this,
y: targetPos.y, targetPos.x,
duration: 400, targetPos.y,
ease: 'Back.easeOut', );
});
(this.scene as OnitamaScene).addTweenInterruption(tween);
} else { } else {
this.x = targetPos.x; this.x = targetPos.x;
this.y = targetPos.y; this.y = targetPos.y;
} }
} }
/**
* 创建棋子视觉元素
*/
private createPawnVisual(): void {
const bgColor = this._owner === 'red' ? 0xef4444 : 0x3b82f6;
const circle = (this.scene as OnitamaScene).add.circle(0, 0, CELL_SIZE / 3, bgColor, 1)
.setStrokeStyle(2, 0x1f2937);
this.add(circle);
const label = this._type === 'master' ? 'M' : 'S';
const text = (this.scene as OnitamaScene).add.text(0, 0, label, {
fontSize: '24px',
fontFamily: 'Arial',
color: '#ffffff',
}).setOrigin(0.5);
this.add(text);
}
} }
export class PawnSpawner implements Spawner<Pawn, PawnContainer> { export class PawnSpawner implements Spawner<Pawn, PawnContainer> {
@@ -177,7 +122,7 @@ export class PawnSpawner implements Spawner<Pawn, PawnContainer> {
*getData() { *getData() {
for (const pawn of Object.values(this.scene.state.pawns)) { for (const pawn of Object.values(this.scene.state.pawns)) {
if (pawn.regionId === 'board') { if (pawn.regionId === "board") {
yield pawn; yield pawn;
} }
} }
@@ -211,28 +156,13 @@ export class PawnSpawner implements Spawner<Pawn, PawnContainer> {
// 淡入动画 // 淡入动画
container.setScale(0); container.setScale(0);
const tween = this.scene.tweens.add({ createPawnSpawnTween(this.scene, container);
targets: container,
scale: 1,
duration: 300,
ease: 'Back.easeOut',
});
this.scene.addTweenInterruption(tween);
return container; return container;
} }
onDespawn(obj: PawnContainer): void { onDespawn(obj: PawnContainer): void {
// 播放消失动画并添加中断 // 播放消失动画并添加中断
const tween = this.scene.tweens.add({ createPawnDespawnTween(this.scene, obj, () => obj.destroy());
targets: obj,
scale: 0,
alpha: 0,
duration: 300,
ease: 'Back.easeIn',
onComplete: () => obj.destroy(),
});
this.scene.addTweenInterruption(tween);
} }
} }
+14 -3
View File
@@ -1,3 +1,14 @@
export { PawnSpawner, CELL_SIZE, BOARD_OFFSET, BOARD_SIZE, boardToScreen } from './PawnSpawner'; export { PawnSpawner } from "./PawnSpawner";
export { CardSpawner, CARD_WIDTH, CARD_HEIGHT, type CardSpawnData } from './CardSpawner'; export { CardSpawner, type CardSpawnData } from "./CardSpawner";
export { HighlightSpawner, type HighlightData } from './HighlightSpawner'; export { HighlightSpawner, type HighlightData } from "./HighlightSpawner";
// Re-export config constants for backward compatibility
// New code should import these directly from '@/config'
export {
CELL_SIZE,
BOARD_OFFSET,
BOARD_SIZE,
boardToScreen,
CARD_WIDTH,
CARD_HEIGHT,
} from "@/config";
+8 -2
View File
@@ -1,2 +1,8 @@
export { createUIState, clearSelection, selectPiece, selectCard, createValidMoves } from './ui'; export {
export type { OnitamaUIState, ValidMove } from './ui'; createUIState,
clearSelection,
selectPiece,
selectCard,
createValidMoves,
} from "./ui";
export type { OnitamaUIState, ValidMove } from "./ui";
+26 -15
View File
@@ -1,5 +1,8 @@
import { MutableSignal, mutableSignal, computed, ReadonlySignal } from 'boardgame-core'; import { mutableSignal, computed } from "boardgame-core";
import {getAvailableMoves, OnitamaState} from "boardgame-core/samples/onitama"; import { getAvailableMoves } from "boardgame-core/samples/onitama";
import type { MutableSignal, ReadonlySignal } from "boardgame-core";
import type { OnitamaState } from "boardgame-core/samples/onitama";
export interface ValidMove { export interface ValidMove {
card: string; card: string;
@@ -22,18 +25,26 @@ export function createUIState(): MutableSignal<OnitamaUIState> {
}); });
} }
export function createValidMoves(state: ReadonlySignal<OnitamaState>, ui: ReadonlySignal<OnitamaUIState>){ export function createValidMoves(
state: ReadonlySignal<OnitamaState>,
ui: ReadonlySignal<OnitamaUIState>,
) {
return computed(() => { return computed(() => {
return getAvailableMoves(state.value, state.value.currentPlayer) return getAvailableMoves(state.value, state.value.currentPlayer).filter(
.filter(move => { (move) => {
const {selectedCard, selectedPiece} = ui.value; const { selectedCard, selectedPiece } = ui.value;
return selectedPiece?.x === move.fromX && selectedPiece?.y === move.fromY && selectedCard === move.card; return (
}) selectedPiece?.x === move.fromX &&
selectedPiece?.y === move.fromY &&
selectedCard === move.card
);
},
);
}); });
} }
export function clearSelection(uiState: MutableSignal<OnitamaUIState>): void { export function clearSelection(uiState: MutableSignal<OnitamaUIState>): void {
uiState.produce(state => { uiState.produce((state) => {
state.selectedPiece = null; state.selectedPiece = null;
state.selectedCard = null; state.selectedCard = null;
}); });
@@ -42,13 +53,13 @@ export function clearSelection(uiState: MutableSignal<OnitamaUIState>): void {
export function selectPiece( export function selectPiece(
uiState: MutableSignal<OnitamaUIState>, uiState: MutableSignal<OnitamaUIState>,
x: number, x: number,
y: number y: number,
): void { ): void {
uiState.produce(state => { uiState.produce((state) => {
// 如果点击已选中的棋子,取消选择 // 如果点击已选中的棋子,取消选择
if(state.selectedPiece?.x === x && state.selectedPiece?.y === y){ if (state.selectedPiece?.x === x && state.selectedPiece?.y === y) {
state.selectedPiece = null; state.selectedPiece = null;
}else{ } else {
state.selectedPiece = { x, y }; state.selectedPiece = { x, y };
} }
}); });
@@ -56,9 +67,9 @@ export function selectPiece(
export function selectCard( export function selectCard(
uiState: MutableSignal<OnitamaUIState>, uiState: MutableSignal<OnitamaUIState>,
card: string card: string,
): void { ): void {
uiState.produce(state => { uiState.produce((state) => {
// 如果点击已选中的卡牌,取消选择 // 如果点击已选中的卡牌,取消选择
if (state.selectedCard === card) { if (state.selectedCard === card) {
state.selectedCard = null; state.selectedCard = null;
+33 -21
View File
@@ -1,25 +1,37 @@
import { h } from 'preact'; import { createGameHost, type GameModule } from "boardgame-core";
import {PhaserGame, PhaserScene } from 'boardgame-phaser'; import { PhaserGame, PhaserScene } from "boardgame-phaser";
import {MenuScene} from "@/scenes/MenuScene"; import { useMemo } from "preact/hooks";
import {useMemo} from "preact/hooks";
import * as gameModule from '../game/onitama'; import * as gameModule from "../game/onitama";
import {OnitamaScene} from "@/scenes/OnitamaScene";
import {createGameHost, type GameModule} from "boardgame-core"; import type { OnitamaState } from "@/game/onitama";
import type {OnitamaState} from "@/game/onitama";
import { MenuScene } from "@/scenes/MenuScene";
import { OnitamaScene } from "@/scenes/OnitamaScene";
export default function App() { export default function App() {
const gameHost = useMemo(() => createGameHost(gameModule as unknown as GameModule<OnitamaState>), []); const gameHost = useMemo(
const gameScene = useMemo(() => new OnitamaScene(), []); () => createGameHost(gameModule as unknown as GameModule<OnitamaState>),
const menuScene = useMemo(() => new MenuScene(), []); [],
);
const gameScene = useMemo(() => new OnitamaScene(), []);
const menuScene = useMemo(() => new MenuScene(), []);
return ( return (
<div className="flex flex-col h-screen"> <div className="flex flex-col h-screen">
<div className="flex-1 flex relative justify-center items-center"> <div className="flex-1 flex relative justify-center items-center">
<PhaserGame initialScene="MenuScene" config={{ width: 720, height: 840 }}> <PhaserGame
<PhaserScene sceneKey="MenuScene" scene={menuScene} /> initialScene="MenuScene"
<PhaserScene sceneKey="OnitamaScene" scene={gameScene} data={{gameHost}}/> config={{ width: 720, height: 840 }}
</PhaserGame> >
</div> <PhaserScene sceneKey="MenuScene" scene={menuScene} />
</div> <PhaserScene
); sceneKey="OnitamaScene"
scene={gameScene}
data={{ gameHost }}
/>
</PhaserGame>
</div>
</div>
);
} }
+1
View File
@@ -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"
@@ -0,0 +1,168 @@
/**
* Centralized layout and style constants for sts-like-viewer.
* All magic numbers should be defined here and imported where needed.
*/
import { PhaserGameProps } from "boardgame-phaser";
// ── Map Layout ──────────────────────────────────────────────────────────────
export const MAP_CONFIG = {
/** Horizontal spacing between map layers (left-to-right) */
LAYER_HEIGHT: 110,
/** Vertical spacing between nodes within a layer */
NODE_SPACING: 140,
/** Radius of each map node circle */
NODE_RADIUS: 28,
/** Total number of layers in the point-crawl map */
TOTAL_LAYERS: 10,
/** Maximum nodes in any single layer */
MAX_NODES_PER_LAYER: 5,
} as const;
// ── Grid Inventory ──────────────────────────────────────────────────────────
export const GRID_CONFIG = {
/** Default grid width (columns) */
WIDTH: 6,
/** Default grid height (rows) */
HEIGHT: 4,
/** Cell size for the standalone grid viewer */
VIEWER_CELL_SIZE: 60,
/** Cell size for the encounter inventory widget */
WIDGET_CELL_SIZE: 80,
/** Gap between grid cells (pixels) */
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;
// ── Shape Viewer ────────────────────────────────────────────────────────────
export const SHAPE_CONFIG = {
/** Cell size for rendering item shapes */
CELL_SIZE: 40,
/** Number of items per row in the shape viewer grid */
ITEMS_PER_ROW: 4,
/** Maximum items to display at once */
MAX_ITEMS: 12,
/** Horizontal spacing between shape previews */
SPACING_X: 220,
/** Vertical spacing between shape previews */
SPACING_Y: 140,
} as const;
// ── Combat Unit Widget ──────────────────────────────────────────────────────
export const COMBAT_WIDGET_CONFIG = {
/** Default widget width */
WIDTH: 240,
/** Default widget height */
HEIGHT: 120,
/** HP bar width */
HP_BAR_WIDTH: 180,
/** HP bar height */
HP_BAR_HEIGHT: 16,
/** Buff/debuff icon size */
BUFF_ICON_SIZE: 28,
/** Gap between buff icons */
BUFF_ICON_GAP: 6,
} as const;
// ── UI / Button ─────────────────────────────────────────────────────────────
export const UI_CONFIG = {
/** Standard button width */
BUTTON_WIDTH: 120,
/** Standard button height */
BUTTON_HEIGHT: 36,
/** Large button width */
BUTTON_WIDTH_LARGE: 220,
/** Large button height */
BUTTON_HEIGHT_LARGE: 50,
/** Button background color */
BUTTON_BG: 0x444466,
/** Button hover background color */
BUTTON_BG_HOVER: 0x555588,
/** Button border color */
BUTTON_BORDER: 0x7777aa,
/** Button text color */
BUTTON_TEXT_COLOR: "#ffffff",
/** Button font size */
BUTTON_FONT_SIZE: "16px",
} as const;
// ── Colors ──────────────────────────────────────────────────────────────────
export const NODE_COLORS = {
start: 0x44aa44,
end: 0xcc8844,
minion: 0xcc4444,
elite: 0xcc44cc,
event: 0xaaaa44,
camp: 0x44cccc,
shop: 0x4488cc,
curio: 0x8844cc,
} as const;
export const NODE_LABELS = {
start: "起点",
end: "终点",
minion: "战斗",
elite: "精英",
event: "事件",
camp: "营地",
shop: "商店",
curio: "奇遇",
} as const;
export const ITEM_COLORS = [
0x4488cc, 0xcc8844, 0x44cc88, 0xcc4488, 0x8844cc, 0x44cccc,
] as const;
// ── Positive/Negative Effects (for buff icons) ──────────────────────────────
export const POSITIVE_EFFECTS = new Set([
"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));
}
+4 -5
View File
@@ -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,437 +0,0 @@
import Phaser from 'phaser';
import { ReactiveScene } from 'boardgame-phaser';
import {
createRunState,
canMoveTo,
moveToNode,
getCurrentNode,
getReachableChildren,
isAtEndNode,
type RunState,
type MapNode,
type EncounterResult,
type EncounterState,
} from 'boardgame-core/samples/slay-the-spire-like';
import { PlaceholderEncounterScene, type EncounterData } from './PlaceholderEncounterScene';
const NODE_COLORS: Record<string, number> = {
start: 0x44aa44,
end: 0xcc8844,
minion: 0xcc4444,
elite: 0xcc44cc,
event: 0xaaaa44,
camp: 0x44cccc,
shop: 0x4488cc,
curio: 0x8844cc,
};
const NODE_LABELS: Record<string, string> = {
start: '起点',
end: '终点',
minion: '战斗',
elite: '精英',
event: '事件',
camp: '营地',
shop: '商店',
curio: '奇遇',
};
export class GameFlowScene extends ReactiveScene {
private runState: RunState;
private seed: number;
// Layout constants
private readonly LAYER_HEIGHT = 110;
private readonly NODE_SPACING = 140;
private readonly NODE_RADIUS = 28;
// 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() {
super('GameFlowScene');
this.seed = Date.now();
this.runState = createRunState(this.seed);
}
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
this.createButton('返回菜单', width - 100, 25, 140, 36, async () => {
await this.sceneController.launch('IndexScene');
});
}
private updateHUD(): void {
const { player, currentNodeId, map } = this.runState;
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;
// Calculate map bounds
const maxLayer = 9;
const maxNodesInLayer = 5;
const mapWidth = (maxNodesInLayer - 1) * this.NODE_SPACING + 200;
const mapHeight = maxLayer * this.LAYER_HEIGHT + 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 } = this.runState;
const reachableChildren = getReachableChildren(this.runState);
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, this.NODE_RADIUS);
if (isCurrent) {
nodeGraphics.lineStyle(3, 0xffff44);
} else if (isReachable) {
nodeGraphics.lineStyle(2, 0xaaddaa);
} else {
nodeGraphics.lineStyle(2, 0x888888);
}
nodeGraphics.strokeCircle(posX, posY, this.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 + this.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, this.NODE_RADIUS, 0x000000, 0)
.setInteractive({ useHandCursor: true });
hitZone.on('pointerover', () => {
this.hoveredNode = nodeId;
nodeGraphics.clear();
nodeGraphics.fillStyle(this.brightenColor(baseColor));
nodeGraphics.fillCircle(posX, posY, this.NODE_RADIUS);
nodeGraphics.lineStyle(3, 0xaaddaa);
nodeGraphics.strokeCircle(posX, posY, this.NODE_RADIUS);
});
hitZone.on('pointerout', () => {
this.hoveredNode = null;
nodeGraphics.clear();
nodeGraphics.fillStyle(baseColor);
nodeGraphics.fillCircle(posX, posY, this.NODE_RADIUS);
nodeGraphics.lineStyle(2, 0xaaddaa);
nodeGraphics.strokeCircle(posX, posY, this.NODE_RADIUS);
});
hitZone.on('pointerdown', () => {
this.onNodeClick(nodeId);
});
}
}
// Setup drag-to-scroll
this.input.on('pointerdown', (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;
});
this.input.on('pointermove', (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);
});
this.input.on('pointerup', () => {
this.isDragging = false;
});
this.input.on('pointerout', () => {
this.isDragging = false;
});
// 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> {
if (!canMoveTo(this.runState, nodeId)) {
return;
}
// Move to target node
const result = moveToNode(this.runState, nodeId);
if (!result.success) {
console.warn(`无法移动到节点: ${result.reason}`);
return;
}
// Update visuals
this.updateHUD();
this.redrawMapHighlights();
// Check if at end node
if (isAtEndNode(this.runState)) {
this.showEndScreen();
return;
}
// Launch encounter scene
const currentNode = getCurrentNode(this.runState);
if (!currentNode || !currentNode.encounter) {
return;
}
// Create encounter data
const encounterData: EncounterData = {
runState: this.runState,
nodeId: currentNode.id,
encounter: {
type: currentNode.type,
name: currentNode.encounter.name,
description: currentNode.encounter.description,
},
onComplete: (result: EncounterResult) => {
// Encounter completed, update HUD
this.updateHUD();
this.redrawMapHighlights();
},
};
// Re-add encounter scene with new data
const phaserGame = this.phaserGame.value.game;
const encounterScene = new PlaceholderEncounterScene();
if (!phaserGame.scene.getScene('PlaceholderEncounterScene')) {
phaserGame.scene.add('PlaceholderEncounterScene', encounterScene, false, {
...encounterData,
phaserGame: this.phaserGame,
sceneController: this.sceneController,
});
}
await this.sceneController.launch('PlaceholderEncounterScene');
}
private redrawMapHighlights(): void {
const { map, currentNodeId } = this.runState;
const reachableChildren = getReachableChildren(this.runState);
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), this.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), this.NODE_RADIUS);
}
}
private showEndScreen(): void {
const { width, height } = this.scale;
// 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 } = this.runState;
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);
this.createButton('返回菜单', width / 2, height / 2 + 100, 200, 50, async () => {
await this.sceneController.launch('IndexScene');
}, 300);
}
private getNodeX(node: MapNode): number {
const layer = this.runState.map.layers[node.layerIndex];
const nodeIndex = layer.nodeIds.indexOf(node.id);
const totalNodes = layer.nodeIds.length;
const layerWidth = (totalNodes - 1) * this.NODE_SPACING;
return -layerWidth / 2 + nodeIndex * this.NODE_SPACING;
}
private getNodeY(node: MapNode): number {
return -600 + node.layerIndex * this.LAYER_HEIGHT;
}
private brightenColor(color: number): number {
// Simple color brightening
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;
}
private createButton(
label: string,
x: number,
y: number,
width: number,
height: number,
onClick: () => void,
depth: number = 200
): void {
const bg = this.add.rectangle(x, y, width, height, 0x444466)
.setStrokeStyle(2, 0x7777aa)
.setInteractive({ useHandCursor: true })
.setDepth(depth);
const text = this.add.text(x, y, label, {
fontSize: '16px',
color: '#ffffff',
}).setOrigin(0.5).setDepth(depth);
bg.on('pointerover', () => {
bg.setFillStyle(0x555588);
text.setScale(1.05);
});
bg.on('pointerout', () => {
bg.setFillStyle(0x444466);
text.setScale(1);
});
bg.on('pointerdown', onClick);
}
}
@@ -1,77 +1,84 @@
import Phaser from 'phaser'; import { ReactiveScene } from "boardgame-phaser";
import { createButton } from "@/utils/createButton";
import { GRID_CONFIG, ITEM_COLORS } from "@/config";
import { import {
createGridInventory, createGridInventory,
placeItem, placeItem,
getAdjacentItems, getAdjacentItems,
parseShapeString, parseShapeString,
heroItemFighter1Data, data,
type GridInventory, type GridInventory,
type InventoryItem, type InventoryItem,
} from 'boardgame-core/samples/slay-the-spire-like'; type GameItemMeta,
import { ReactiveScene } from 'boardgame-phaser'; } 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; private inventory: GridInventory<GameItemMeta>;
private readonly CELL_SIZE = 60;
private readonly GRID_WIDTH = 6;
private readonly GRID_HEIGHT = 4;
private gridOffsetX = 0; private gridOffsetX = 0;
private gridOffsetY = 0; private gridOffsetY = 0;
constructor() { constructor() {
super('GridViewerScene'); super("GridViewerScene");
this.inventory = createGridInventory(this.GRID_WIDTH, this.GRID_HEIGHT); this.inventory = createGridInventory<GameItemMeta>(
GRID_CONFIG.WIDTH,
GRID_CONFIG.HEIGHT,
);
} }
create(): void { create(): void {
super.create(); super.create();
const { width, height } = this.scale; const { width, height } = this.scale;
this.gridOffsetX = (width - this.GRID_WIDTH * this.CELL_SIZE) / 2; this.gridOffsetX =
this.gridOffsetY = (height - this.GRID_HEIGHT * this.CELL_SIZE) / 2 + 20; (width - GRID_CONFIG.WIDTH * GRID_CONFIG.VIEWER_CELL_SIZE) / 2;
this.gridOffsetY =
(height - GRID_CONFIG.HEIGHT * GRID_CONFIG.VIEWER_CELL_SIZE) / 2 + 20;
// Place some sample items
this.placeSampleItems(); this.placeSampleItems();
// Draw grid
this.drawGrid(); this.drawGrid();
// Draw items
this.drawItems(); this.drawItems();
// Title this.add
this.add.text(width / 2, 30, 'Grid Inventory Viewer (4x6)', { .text(width / 2, 30, "Grid Inventory Viewer (4x6)", {
fontSize: '24px', fontSize: "24px",
color: '#ffffff', color: "#ffffff",
fontStyle: 'bold', fontStyle: "bold",
}).setOrigin(0.5); })
.setOrigin(0.5);
// Controls
this.createControls(); this.createControls();
// Info text this.add
this.add.text(width / 2, height - 40, 'Hover over cells to see item details', { .text(width / 2, height - 40, "Hover over cells to see item details", {
fontSize: '14px', fontSize: "14px",
color: '#aaaaaa', color: "#aaaaaa",
}).setOrigin(0.5); })
.setOrigin(0.5);
} }
private placeSampleItems(): void { private placeSampleItems(): void {
// Place a few items from heroItemFighter1Data 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 },
{ index: 6, x: 0, y: 2 }, // 盾 { index: 6, x: 0, y: 2 },
{ index: 12, x: 3, y: 2 }, // 披风 { index: 12, x: 3, y: 2 },
]; ];
for (const { index, x, y } of sampleItems) { for (const { index, x, y } of sampleItems) {
const data = heroItemFighter1Data[index]; if (index >= items.length) continue;
const shape = parseShapeString(data.shape); const itemData = items[index];
const item: InventoryItem = { const shape = parseShapeString(itemData.shape);
const item: InventoryItem<GameItemMeta> = {
id: `item-${index}`, id: `item-${index}`,
shape, shape,
transform: { offset: { x, y }, rotation: 0, flipX: false, flipY: false }, transform: {
meta: { name: data.name, data }, offset: { x, y },
rotation: 0,
flipX: false,
flipY: false,
},
meta: { itemData, shape },
}; };
placeItem(this.inventory, item); placeItem(this.inventory, item);
} }
@@ -80,19 +87,28 @@ export class GridViewerScene extends ReactiveScene {
private drawGrid(): void { private drawGrid(): void {
const graphics = this.add.graphics(); const graphics = this.add.graphics();
// Draw cells for (let y = 0; y < GRID_CONFIG.HEIGHT; y++) {
for (let y = 0; y < this.GRID_HEIGHT; y++) { for (let x = 0; x < GRID_CONFIG.WIDTH; x++) {
for (let x = 0; x < this.GRID_WIDTH; x++) { const px = this.gridOffsetX + x * GRID_CONFIG.VIEWER_CELL_SIZE;
const px = this.gridOffsetX + x * this.CELL_SIZE; const py = this.gridOffsetY + y * GRID_CONFIG.VIEWER_CELL_SIZE;
const py = this.gridOffsetY + y * this.CELL_SIZE;
const isOccupied = this.inventory.occupiedCells.has(`${x},${y}`); const isOccupied = this.inventory.occupiedCells.has(`${x},${y}`);
const color = isOccupied ? 0x334455 : 0x222233; const color = isOccupied ? 0x334455 : 0x222233;
graphics.fillStyle(color); graphics.fillStyle(color);
graphics.fillRect(px + 1, py + 1, this.CELL_SIZE - 2, this.CELL_SIZE - 2); graphics.fillRect(
px + 1,
py + 1,
GRID_CONFIG.VIEWER_CELL_SIZE - 2,
GRID_CONFIG.VIEWER_CELL_SIZE - 2,
);
graphics.lineStyle(1, 0x555577); graphics.lineStyle(1, 0x555577);
graphics.strokeRect(px, py, this.CELL_SIZE, this.CELL_SIZE); graphics.strokeRect(
px,
py,
GRID_CONFIG.VIEWER_CELL_SIZE,
GRID_CONFIG.VIEWER_CELL_SIZE,
);
} }
} }
} }
@@ -102,7 +118,6 @@ export class GridViewerScene extends ReactiveScene {
const shape = item.shape; const shape = item.shape;
const transform = item.transform; const transform = item.transform;
// Get occupied cells
const cells: { x: number; y: number }[] = []; const cells: { x: number; y: number }[] = [];
for (let y = 0; y < shape.height; y++) { for (let y = 0; y < shape.height; y++) {
for (let x = 0; x < shape.width; x++) { for (let x = 0; x < shape.width; x++) {
@@ -114,87 +129,127 @@ export class GridViewerScene extends ReactiveScene {
} }
} }
// Draw item cells with a unique color per item
const itemColor = this.getItemColor(itemId); const itemColor = this.getItemColor(itemId);
const graphics = this.add.graphics(); const graphics = this.add.graphics();
for (const cell of cells) { for (const cell of cells) {
const px = this.gridOffsetX + cell.x * this.CELL_SIZE; const px = this.gridOffsetX + cell.x * GRID_CONFIG.VIEWER_CELL_SIZE;
const py = this.gridOffsetY + cell.y * this.CELL_SIZE; const py = this.gridOffsetY + cell.y * GRID_CONFIG.VIEWER_CELL_SIZE;
graphics.fillStyle(itemColor); graphics.fillStyle(itemColor);
graphics.fillRect(px + 2, py + 2, this.CELL_SIZE - 4, this.CELL_SIZE - 4); graphics.fillRect(
px + 2,
py + 2,
GRID_CONFIG.VIEWER_CELL_SIZE - 4,
GRID_CONFIG.VIEWER_CELL_SIZE - 4,
);
} }
// Item name label (at first cell)
if (cells.length > 0) { if (cells.length > 0) {
const firstCell = cells[0]; const firstCell = cells[0];
const px = this.gridOffsetX + firstCell.x * this.CELL_SIZE; const px =
const py = this.gridOffsetY + firstCell.y * this.CELL_SIZE; this.gridOffsetX + firstCell.x * GRID_CONFIG.VIEWER_CELL_SIZE;
const itemName = (item.meta?.name as string) ?? item.id; const py =
this.gridOffsetY + firstCell.y * GRID_CONFIG.VIEWER_CELL_SIZE;
const itemName = item.meta?.itemData.name ?? item.id;
this.add.text(px + this.CELL_SIZE / 2, py + this.CELL_SIZE / 2, itemName, { this.add
fontSize: '11px', .text(
color: '#ffffff', px + GRID_CONFIG.VIEWER_CELL_SIZE / 2,
fontStyle: 'bold', py + GRID_CONFIG.VIEWER_CELL_SIZE / 2,
}).setOrigin(0.5); itemName,
{
fontSize: "11px",
color: "#ffffff",
fontStyle: "bold",
},
)
.setOrigin(0.5);
} }
// Adjacent items info
const adjacent = getAdjacentItems(this.inventory, itemId); const adjacent = getAdjacentItems(this.inventory, itemId);
if (adjacent.size > 0) { if (adjacent.size > 0) {
const adjacentNames = Array.from(adjacent.values()).map(i => (i.meta?.name as string) ?? i.id).join(', '); const adjacentNames = Array.from(adjacent.values())
.map((i) => i.meta?.itemData.name ?? i.id)
.join(", ");
const firstCell = cells[0]; const firstCell = cells[0];
const px = this.gridOffsetX + firstCell.x * this.CELL_SIZE; const px =
const py = this.gridOffsetY + firstCell.y * this.CELL_SIZE - 20; this.gridOffsetX + firstCell.x * GRID_CONFIG.VIEWER_CELL_SIZE;
const py =
this.gridOffsetY + firstCell.y * GRID_CONFIG.VIEWER_CELL_SIZE - 20;
this.add.text(px + this.CELL_SIZE / 2, py, `邻接: ${adjacentNames}`, { this.add
fontSize: '10px', .text(
color: '#ffff88', px + GRID_CONFIG.VIEWER_CELL_SIZE / 2,
}).setOrigin(0.5); py,
`邻接: ${adjacentNames}`,
{
fontSize: "10px",
color: "#ffff88",
},
)
.setOrigin(0.5);
} }
} }
} }
private getItemColor(itemId: string): number { private getItemColor(itemId: string): number {
// Generate a consistent color based on item ID const hash = itemId.split("").reduce((acc, c) => acc + c.charCodeAt(0), 0);
const hash = itemId.split('').reduce((acc, c) => acc + c.charCodeAt(0), 0); return ITEM_COLORS[hash % ITEM_COLORS.length];
const colors = [0x4488cc, 0xcc8844, 0x44cc88, 0xcc4488, 0x8844cc, 0x44cccc];
return colors[hash % colors.length];
} }
private createControls(): void { private createControls(): void {
const { width, height } = this.scale; const { width, height } = this.scale;
// Back button createButton({
this.createButton('返回菜单', 100, 40, async () => { scene: this,
await this.sceneController.launch('IndexScene'); label: "返回菜单",
x: 100,
y: 40,
onClick: async () => {
await this.sceneController.launch(SceneKey.IndexScene);
},
}); });
// Clear button createButton({
this.createButton('清空', width - 260, 40, async () => { scene: this,
this.inventory = createGridInventory(this.GRID_WIDTH, this.GRID_HEIGHT); label: "清空",
await this.sceneController.restart(); x: width - 260,
y: 40,
onClick: async () => {
this.inventory = createGridInventory<GameItemMeta>(
GRID_CONFIG.WIDTH,
GRID_CONFIG.HEIGHT,
);
await this.sceneController.restart();
},
}); });
// Random fill button createButton({
this.createButton('随机填充', width - 130, 40, async () => { scene: this,
this.randomFill(); label: "随机填充",
await this.sceneController.restart(); x: width - 130,
y: 40,
onClick: async () => {
this.randomFill();
await this.sceneController.restart();
},
}); });
} }
private randomFill(): void { private randomFill(): void {
this.inventory = createGridInventory(this.GRID_WIDTH, this.GRID_HEIGHT); this.inventory = createGridInventory<GameItemMeta>(
GRID_CONFIG.WIDTH,
GRID_CONFIG.HEIGHT,
);
const items = data.desert.getItems();
// Try to place random items
let itemIndex = 0; let itemIndex = 0;
for (let y = 0; y < this.GRID_HEIGHT && itemIndex < heroItemFighter1Data.length; y++) { for (let y = 0; y < GRID_CONFIG.HEIGHT && itemIndex < items.length; y++) {
for (let x = 0; x < this.GRID_WIDTH && itemIndex < heroItemFighter1Data.length; x++) { for (let x = 0; x < GRID_CONFIG.WIDTH && itemIndex < items.length; x++) {
const data = heroItemFighter1Data[itemIndex]; const itemData = items[itemIndex];
const shape = parseShapeString(data.shape); const shape = parseShapeString(itemData.shape);
// Check if placement is valid
const occupiedCells = new Set<string>(); const occupiedCells = new Set<string>();
for (let sy = 0; sy < shape.height; sy++) { for (let sy = 0; sy < shape.height; sy++) {
for (let sx = 0; sx < shape.width; sx++) { for (let sx = 0; sx < shape.width; sx++) {
@@ -204,22 +259,30 @@ export class GridViewerScene extends ReactiveScene {
} }
} }
// Simple check if any cell is out of bounds or occupied
let valid = true; let valid = true;
for (const cell of occupiedCells) { for (const cell of occupiedCells) {
const [cx, cy] = cell.split(',').map(Number); const [cx, cy] = cell.split(",").map(Number);
if (cx >= this.GRID_WIDTH || cy >= this.GRID_HEIGHT || this.inventory.occupiedCells.has(cell)) { if (
cx >= GRID_CONFIG.WIDTH ||
cy >= GRID_CONFIG.HEIGHT ||
this.inventory.occupiedCells.has(cell as `${number},${number}`)
) {
valid = false; valid = false;
break; break;
} }
} }
if (valid) { if (valid) {
const item: InventoryItem = { const item: InventoryItem<GameItemMeta> = {
id: `item-${itemIndex}`, id: `item-${itemIndex}`,
shape, shape,
transform: { offset: { x, y }, rotation: 0, flipX: false, flipY: false }, transform: {
meta: { name: data.name, data }, offset: { x, y },
rotation: 0,
flipX: false,
flipY: false,
},
meta: { itemData, shape },
}; };
placeItem(this.inventory, item); placeItem(this.inventory, item);
itemIndex++; itemIndex++;
@@ -227,30 +290,4 @@ export class GridViewerScene extends ReactiveScene {
} }
} }
} }
private createButton(label: string, x: number, y: number, onClick: () => void): void {
const buttonWidth = 120;
const buttonHeight = 36;
const bg = this.add.rectangle(x, y, buttonWidth, buttonHeight, 0x444466)
.setStrokeStyle(2, 0x7777aa)
.setInteractive({ useHandCursor: true });
const text = this.add.text(x, y, label, {
fontSize: '16px',
color: '#ffffff',
}).setOrigin(0.5);
bg.on('pointerover', () => {
bg.setFillStyle(0x555588);
text.setScale(1.05);
});
bg.on('pointerout', () => {
bg.setFillStyle(0x444466);
text.setScale(1);
});
bg.on('pointerdown', onClick);
}
} }
@@ -1,9 +1,12 @@
import Phaser from 'phaser'; import Phaser from "phaser";
import { ReactiveScene } from 'boardgame-phaser'; import { ReactiveScene } from "boardgame-phaser";
import { createButton } from "@/utils/createButton";
import { UI_CONFIG } from "@/config";
import { SceneKey } from "./types";
export class IndexScene extends ReactiveScene { export class IndexScene extends ReactiveScene {
constructor() { constructor() {
super('IndexScene'); super("IndexScene");
} }
create(): void { create(): void {
@@ -13,24 +16,45 @@ export class IndexScene extends ReactiveScene {
const centerY = height / 2; const centerY = height / 2;
// Title // Title
this.add.text(centerX, centerY - 150, 'Slay-the-Spire-Like Viewer', { this.add
fontSize: '36px', .text(centerX, centerY - 150, "Slay-the-Spire-Like Viewer", {
color: '#ffffff', fontSize: "36px",
fontStyle: 'bold', color: "#ffffff",
}).setOrigin(0.5); fontStyle: "bold",
})
.setOrigin(0.5);
// Subtitle // Subtitle
this.add.text(centerX, centerY - 100, 'Choose a viewer to explore:', { this.add
fontSize: '18px', .text(centerX, centerY - 100, "Choose a viewer to explore:", {
color: '#aaaaaa', fontSize: "18px",
}).setOrigin(0.5); color: "#aaaaaa",
})
.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;
{ label: 'Grid Inventory Viewer', scene: 'GridViewerScene', y: centerY + 70 }, y: number;
{ label: 'Shape Viewer', scene: 'ShapeViewerScene', y: centerY + 140 }, }[] = [
{ label: "Combat Test", scene: SceneKey.CombatTestScene, y: centerY },
{ label: "Map Viewer", scene: SceneKey.MapViewerScene, y: centerY + 70 },
{
label: "Grid Inventory Viewer",
scene: SceneKey.GridViewerScene,
y: centerY + 140,
},
{
label: "Shape Viewer",
scene: SceneKey.ShapeViewerScene,
y: centerY + 210,
},
{
label: "Inventory Test",
scene: SceneKey.InventoryTestScene,
y: centerY + 280,
},
]; ];
for (const btn of buttons) { for (const btn of buttons) {
@@ -38,34 +62,22 @@ export class IndexScene extends ReactiveScene {
} }
} }
private createButton(label: string, targetScene: string, x: number, y: number): void { private createButton(
const buttonWidth = 300; label: string,
const buttonHeight = 50; targetScene: SceneKey,
x: number,
// Background y: number,
const bg = this.add.rectangle(x, y, buttonWidth, buttonHeight, 0x333355) ): void {
.setStrokeStyle(2, 0x6666aa) createButton({
.setInteractive({ useHandCursor: true }); scene: this,
label,
// Text x,
const text = this.add.text(x, y, label, { y,
fontSize: '20px', width: UI_CONFIG.BUTTON_WIDTH_LARGE,
color: '#ffffff', height: UI_CONFIG.BUTTON_HEIGHT_LARGE,
}).setOrigin(0.5); onClick: async () => {
await this.sceneController.launch(targetScene);
// Hover effects },
bg.on('pointerover', () => {
bg.setFillStyle(0x444477);
text.setScale(1.05);
});
bg.on('pointerout', () => {
bg.setFillStyle(0x333355);
text.setScale(1);
});
bg.on('pointerdown', async () => {
await this.sceneController.launch(targetScene);
}); });
} }
} }
@@ -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);
});
}
}
@@ -1,38 +1,21 @@
import Phaser from 'phaser'; import Phaser from "phaser";
import { ReactiveScene } from 'boardgame-phaser'; import { ReactiveScene } from "boardgame-phaser";
import { generatePointCrawlMap, type PointCrawlMap, type MapNode, MapNodeType } from 'boardgame-core/samples/slay-the-spire-like'; import { createButton } from "@/utils/createButton";
import { UI_CONFIG, MAP_CONFIG, NODE_COLORS, NODE_LABELS } from "@/config";
const NODE_COLORS: Record<MapNodeType, number> = { import { createRNG } from "boardgame-core";
[MapNodeType.Start]: 0x44aa44, import {
[MapNodeType.End]: 0xcc8844, generatePointCrawlMap,
[MapNodeType.Minion]: 0xcc4444, data,
[MapNodeType.Elite]: 0xcc44cc, type PointCrawlMap,
[MapNodeType.Event]: 0xaaaa44, type MapNode,
[MapNodeType.Camp]: 0x44cccc, type MapNodeType,
[MapNodeType.Shop]: 0x4488cc, } from "boardgame-core/samples/slay-the-spire-like";
[MapNodeType.Curio]: 0x8844cc, import { SceneKey } from "./types";
};
const NODE_LABELS: Record<MapNodeType, string> = {
[MapNodeType.Start]: '起点',
[MapNodeType.End]: '终点',
[MapNodeType.Minion]: '战斗',
[MapNodeType.Elite]: '精英',
[MapNodeType.Event]: '事件',
[MapNodeType.Camp]: '篝火',
[MapNodeType.Shop]: '商店',
[MapNodeType.Curio]: '奇遇',
};
export class MapViewerScene extends ReactiveScene { export class MapViewerScene extends ReactiveScene {
private map: PointCrawlMap | null = null; private map: PointCrawlMap | null = null;
private seed: number = Date.now(); private seed: number = Date.now();
// Layout constants
private readonly LAYER_HEIGHT = 110;
private readonly NODE_SPACING = 140;
private readonly NODE_RADIUS = 28;
// Scroll state // Scroll state
private mapContainer!: Phaser.GameObjects.Container; private mapContainer!: Phaser.GameObjects.Container;
private isDragging = false; private isDragging = false;
@@ -43,14 +26,10 @@ export class MapViewerScene extends ReactiveScene {
// Fixed UI (always visible, not scrolled) // Fixed UI (always visible, not scrolled)
private titleText!: Phaser.GameObjects.Text; private titleText!: Phaser.GameObjects.Text;
private backButtonBg!: Phaser.GameObjects.Rectangle;
private backButtonText!: Phaser.GameObjects.Text;
private regenButtonBg!: Phaser.GameObjects.Rectangle;
private regenButtonText!: Phaser.GameObjects.Text;
private legendContainer!: Phaser.GameObjects.Container; private legendContainer!: Phaser.GameObjects.Container;
constructor() { constructor() {
super('MapViewerScene'); super("MapViewerScene");
} }
create(): void { create(): void {
@@ -60,107 +39,106 @@ export class MapViewerScene extends ReactiveScene {
} }
private drawFixedUI(): void { private drawFixedUI(): void {
const { width } = this.scale; const { width, height } = this.scale;
// Title // Title
this.titleText = this.add.text(width / 2, 30, '', { this.titleText = this.add
fontSize: '24px', .text(width / 2, 30, "", {
color: '#ffffff', fontSize: "24px",
fontStyle: 'bold', color: "#ffffff",
}).setOrigin(0.5).setDepth(100); fontStyle: "bold",
})
.setOrigin(0.5)
.setDepth(100);
// Back button // Back button
this.backButtonBg = this.add.rectangle(100, 40, 140, 36, 0x444466) createButton({
.setStrokeStyle(2, 0x7777aa) scene: this,
.setInteractive({ useHandCursor: true }) label: "返回菜单",
.setDepth(100); x: 100,
this.backButtonText = this.add.text(100, 40, '返回菜单', { y: 40,
fontSize: '16px', onClick: async () => {
color: '#ffffff', await this.sceneController.launch(SceneKey.IndexScene);
}).setOrigin(0.5).setDepth(100); },
depth: 100,
this.backButtonBg.on('pointerover', () => {
this.backButtonBg.setFillStyle(0x555588);
this.backButtonText.setScale(1.05);
});
this.backButtonBg.on('pointerout', () => {
this.backButtonBg.setFillStyle(0x444466);
this.backButtonText.setScale(1);
});
this.backButtonBg.on('pointerdown', async () => {
await this.sceneController.launch('IndexScene');
}); });
// Regenerate button // Regenerate button
this.regenButtonBg = this.add.rectangle(width - 120, 40, 140, 36, 0x444466) createButton({
.setStrokeStyle(2, 0x7777aa) scene: this,
.setInteractive({ useHandCursor: true }) label: "重新生成",
.setDepth(100); x: width - 120,
this.regenButtonText = this.add.text(width - 120, 40, '重新生成', { y: 40,
fontSize: '16px', onClick: () => {
color: '#ffffff', this.seed = Date.now();
}).setOrigin(0.5).setDepth(100); this.mapContainer.destroy();
this.drawMap();
this.regenButtonBg.on('pointerover', () => { },
this.regenButtonBg.setFillStyle(0x555588); depth: 100,
this.regenButtonText.setScale(1.05);
});
this.regenButtonBg.on('pointerout', () => {
this.regenButtonBg.setFillStyle(0x444466);
this.regenButtonText.setScale(1);
});
this.regenButtonBg.on('pointerdown', () => {
this.seed = Date.now();
this.mapContainer.destroy();
this.drawMap();
}); });
// Legend (bottom-left, fixed) // Legend (bottom-left, fixed)
this.legendContainer = this.add.container(20, this.scale.height - 200).setDepth(100); this.legendContainer = this.add.container(20, height - 180).setDepth(100);
const legendBg = this.add.rectangle(75, 90, 150, 180, 0x222222, 0.8); const legendBg = this.add.rectangle(75, 80, 150, 160, 0x222222, 0.8);
this.legendContainer.add(legendBg); this.legendContainer.add(legendBg);
this.legendContainer.add( this.legendContainer.add(
this.add.text(10, 5, '图例:', { fontSize: '14px', color: '#ffffff', fontStyle: 'bold' }) this.add.text(10, 5, "图例:", {
fontSize: "14px",
color: "#ffffff",
fontStyle: "bold",
}),
); );
let offsetY = 30; let offsetY = 30;
for (const [type, color] of Object.entries(NODE_COLORS)) { for (const [type, color] of Object.entries(NODE_COLORS)) {
this.legendContainer.add(this.add.circle(20, offsetY, 8, color));
this.legendContainer.add( this.legendContainer.add(
this.add.circle(20, offsetY, 8, color) this.add.text(40, offsetY - 5, NODE_LABELS[type as MapNodeType], {
fontSize: "12px",
color: "#ffffff",
}),
); );
this.legendContainer.add( offsetY += 20;
this.add.text(40, offsetY - 5, NODE_LABELS[type as MapNodeType], { fontSize: '12px', color: '#ffffff' })
);
offsetY += 22;
} }
// Hint text // Hint text
this.add.text(width / 2, this.scale.height - 20, '拖拽滚动查看地图', { this.add
fontSize: '14px', .text(width / 2, height - 20, "拖拽滚动查看地图 (从左到右)", {
color: '#888888', fontSize: "14px",
}).setOrigin(0.5).setDepth(100); color: "#888888",
})
.setOrigin(0.5)
.setDepth(100);
} }
private drawMap(): void { private drawMap(): void {
this.map = generatePointCrawlMap(this.seed); const rng = createRNG(this.seed);
this.map = generatePointCrawlMap(rng, data.desert.getEncounters());
const { width, height } = this.scale; const { width, height } = this.scale;
const {
LAYER_HEIGHT,
NODE_SPACING,
NODE_RADIUS,
TOTAL_LAYERS,
MAX_NODES_PER_LAYER,
} = MAP_CONFIG;
// Update title // Update title
this.titleText.setText(`Map Viewer (Seed: ${this.seed})`); this.titleText.setText(`Map Viewer (Seed: ${this.seed})`);
// Calculate map bounds // Calculate map bounds
const maxLayer = 9; // TOTAL_LAYERS - 1 (10 layers: 0-9) const maxLayer = TOTAL_LAYERS - 1;
const maxNodesInLayer = 5; // widest layer (settlement has 4 nodes) const mapWidth = maxLayer * LAYER_HEIGHT + 200;
const mapWidth = (maxNodesInLayer - 1) * this.NODE_SPACING + 200; const mapHeight = (MAX_NODES_PER_LAYER - 1) * NODE_SPACING + 200;
const mapHeight = maxLayer * this.LAYER_HEIGHT + 200;
// Create scrollable container // Create scrollable container
this.mapContainer = this.add.container(width / 2, height / 2); this.mapContainer = this.add.container(width / 2, height / 2);
// Background panel for the map area // Background panel for the map area
const bg = this.add.rectangle(0, 0, mapWidth, mapHeight, 0x111122, 0.5) const bg = this.add
.rectangle(0, 0, mapWidth, mapHeight, 0x111122, 0.5)
.setOrigin(0.5); .setOrigin(0.5);
this.mapContainer.add(bg); this.mapContainer.add(bg);
@@ -191,63 +169,77 @@ export class MapViewerScene extends ReactiveScene {
// Node circle // Node circle
graphics.fillStyle(color); graphics.fillStyle(color);
graphics.fillCircle(posX, posY, this.NODE_RADIUS); graphics.fillCircle(posX, posY, NODE_RADIUS);
graphics.lineStyle(2, 0xffffff); graphics.lineStyle(2, 0xffffff);
graphics.strokeCircle(posX, posY, this.NODE_RADIUS); graphics.strokeCircle(posX, posY, NODE_RADIUS);
// Node label // Node label
const label = NODE_LABELS[node.type as MapNodeType] ?? node.type; const label = NODE_LABELS[node.type as MapNodeType] ?? node.type;
this.mapContainer.add( this.mapContainer.add(
this.add.text(posX, posY, label, { this.add
fontSize: '12px', .text(posX, posY, label, {
color: '#ffffff', fontSize: "12px",
}).setOrigin(0.5) color: "#ffffff",
})
.setOrigin(0.5),
); );
// Encounter name (if available) // Encounter name (if available)
if (node.encounter) { if (node.encounter) {
this.mapContainer.add( this.mapContainer.add(
this.add.text(posX, posY + this.NODE_RADIUS + 12, node.encounter.name, { this.add
fontSize: '10px', .text(posX, posY + NODE_RADIUS + 12, node.encounter.name, {
color: '#cccccc', fontSize: "10px",
}).setOrigin(0.5) color: "#cccccc",
})
.setOrigin(0.5),
); );
} }
} }
// Setup drag-to-scroll // Setup drag-to-scroll with disposables cleanup
this.input.on('pointerdown', (pointer: Phaser.Input.Pointer) => { const onPointerDown = (pointer: Phaser.Input.Pointer) => {
this.isDragging = true; this.isDragging = true;
this.dragStartX = pointer.x; this.dragStartX = pointer.x;
this.dragStartY = pointer.y; this.dragStartY = pointer.y;
this.dragStartContainerX = this.mapContainer.x; this.dragStartContainerX = this.mapContainer.x;
this.dragStartContainerY = this.mapContainer.y; this.dragStartContainerY = this.mapContainer.y;
}); };
this.input.on('pointermove', (pointer: Phaser.Input.Pointer) => { const onPointerMove = (pointer: Phaser.Input.Pointer) => {
if (!this.isDragging) return; if (!this.isDragging) return;
this.mapContainer.x = this.dragStartContainerX + (pointer.x - this.dragStartX); this.mapContainer.x =
this.mapContainer.y = this.dragStartContainerY + (pointer.y - this.dragStartY); this.dragStartContainerX + (pointer.x - this.dragStartX);
}); this.mapContainer.y =
this.dragStartContainerY + (pointer.y - this.dragStartY);
};
this.input.on('pointerup', () => { const onPointerUp = () => {
this.isDragging = false; this.isDragging = false;
}); };
this.input.on('pointerout', () => { this.input.on("pointerdown", onPointerDown);
this.isDragging = false; 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);
}); });
} }
private getNodeX(node: MapNode): number { private getNodeX(node: MapNode): number {
const layer = this.map!.layers[node.layerIndex]; return -500 + node.layerIndex * MAP_CONFIG.LAYER_HEIGHT;
const nodeIndex = layer.nodeIds.indexOf(node.id);
const totalNodes = layer.nodeIds.length;
const layerWidth = (totalNodes - 1) * this.NODE_SPACING;
return -layerWidth / 2 + nodeIndex * this.NODE_SPACING;
} }
private getNodeY(node: MapNode): number { private getNodeY(node: MapNode): number {
return -600 + node.layerIndex * this.LAYER_HEIGHT; const layer = this.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;
} }
} }
@@ -1,187 +0,0 @@
import Phaser from 'phaser';
import { ReactiveScene } from 'boardgame-phaser';
import {
resolveEncounter,
type RunState,
type EncounterResult,
type MapNodeType,
} from 'boardgame-core/samples/slay-the-spire-like';
/** 遭遇场景接收的数据 */
export interface EncounterData {
runState: RunState;
nodeId: string;
encounter: { type: MapNodeType; name: string; description: string };
onComplete: (result: EncounterResult) => void;
}
/**
* 占位符遭遇场景
*
* 当前实现:显示遭遇信息并提供"完成遭遇"按钮
*
* 后续扩展:根据 encounter.type 路由到不同的专用遭遇场景
* - MapNodeType.Minion / Elite → CombatEncounterScene
* - MapNodeType.Shop → ShopEncounterScene
* - MapNodeType.Camp → CampEncounterScene
* - MapNodeType.Event → EventEncounterScene
* - MapNodeType.Curio → CurioEncounterScene
*/
export class PlaceholderEncounterScene extends ReactiveScene<EncounterData> {
constructor() {
super('PlaceholderEncounterScene');
}
create(): void {
super.create();
const { width, height } = this.scale;
const centerX = width / 2;
const centerY = height / 2;
const { encounter, nodeId } = this.initData;
// Title
this.add.text(centerX, centerY - 150, '遭遇', {
fontSize: '32px',
color: '#ffffff',
fontStyle: 'bold',
}).setOrigin(0.5);
// Encounter type badge
const typeLabel = this.getEncounterTypeLabel(encounter.type);
const typeBg = this.add.rectangle(centerX, centerY - 80, 120, 36, this.getEncounterTypeColor(encounter.type));
this.add.text(centerX, centerY - 80, typeLabel, {
fontSize: '16px',
color: '#ffffff',
fontStyle: 'bold',
}).setOrigin(0.5);
// Encounter name
this.add.text(centerX, centerY - 30, encounter.name, {
fontSize: '24px',
color: '#ffffff',
}).setOrigin(0.5);
// Encounter description
this.add.text(centerX, centerY + 20, encounter.description || '(暂无描述)', {
fontSize: '16px',
color: '#aaaaaa',
wordWrap: { width: 600 },
align: 'center',
}).setOrigin(0.5);
// Node ID info
this.add.text(centerX, centerY + 80, `节点: ${nodeId}`, {
fontSize: '12px',
color: '#666666',
}).setOrigin(0.5);
// Placeholder notice
this.add.text(centerX, centerY + 130, '(此为占位符遭遇,后续将替换为真实遭遇场景)', {
fontSize: '14px',
color: '#ff8844',
fontStyle: 'italic',
}).setOrigin(0.5);
// Complete button
this.createButton('完成遭遇', centerX, centerY + 200, 200, 50, async () => {
await this.completeEncounter();
});
// Back button (without resolving)
this.createButton('暂不处理', centerX, centerY + 270, 200, 40, async () => {
await this.sceneController.launch('GameFlowScene');
});
}
private async completeEncounter(): Promise<void> {
const { runState, nodeId, encounter, onComplete } = this.initData;
// 生成模拟遭遇结果
const result: EncounterResult = this.generatePlaceholderResult(encounter.type);
// 调用进度管理器结算遭遇
resolveEncounter(runState, result);
// 回调通知上层
onComplete(result);
// 返回游戏流程场景
await this.sceneController.launch('GameFlowScene');
}
private generatePlaceholderResult(type: MapNodeType): EncounterResult {
// 根据遭遇类型生成不同的模拟结果
switch (type) {
case 'minion':
case 'elite':
return { hpLost: type === 'elite' ? 15 : 8, goldEarned: type === 'elite' ? 30 : 15 };
case 'camp':
return { hpGained: 15 };
case 'shop':
return { goldEarned: 0 };
case 'curio':
case 'event':
return { goldEarned: 20 };
default:
return {};
}
}
private getEncounterTypeLabel(type: MapNodeType): string {
const labels: Record<MapNodeType, string> = {
start: '起点',
end: '终点',
minion: '战斗',
elite: '精英战斗',
event: '事件',
camp: '营地',
shop: '商店',
curio: '奇遇',
};
return labels[type] ?? type;
}
private getEncounterTypeColor(type: MapNodeType): number {
const colors: Record<MapNodeType, number> = {
start: 0x44aa44,
end: 0xcc8844,
minion: 0xcc4444,
elite: 0xcc44cc,
event: 0xaaaa44,
camp: 0x44cccc,
shop: 0x4488cc,
curio: 0x8844cc,
};
return colors[type] ?? 0x888888;
}
private createButton(
label: string,
x: number,
y: number,
width: number,
height: number,
onClick: () => void
): void {
const bg = this.add.rectangle(x, y, width, height, 0x444466)
.setStrokeStyle(2, 0x7777aa)
.setInteractive({ useHandCursor: true });
const text = this.add.text(x, y, label, {
fontSize: '16px',
color: '#ffffff',
}).setOrigin(0.5);
bg.on('pointerover', () => {
bg.setFillStyle(0x555588);
text.setScale(1.05);
});
bg.on('pointerout', () => {
bg.setFillStyle(0x444466);
text.setScale(1);
});
bg.on('pointerdown', onClick);
}
}
@@ -1,166 +1,177 @@
import Phaser from 'phaser'; import { ReactiveScene } from "boardgame-phaser";
import { ReactiveScene } from 'boardgame-phaser'; import { createButton } from "@/utils/createButton";
import { parseShapeString, heroItemFighter1Data, type ParsedShape } from 'boardgame-core/samples/slay-the-spire-like'; import { SHAPE_CONFIG } from "@/config";
import {
parseShapeString,
data,
type ParsedShape,
} from "boardgame-core/samples/slay-the-spire-like";
import { SceneKey } from "./types";
export class ShapeViewerScene extends ReactiveScene { export class ShapeViewerScene extends ReactiveScene {
private readonly CELL_SIZE = 40;
private readonly ITEMS_PER_ROW = 4;
private currentIndex = 0;
private currentRotation = 0;
private currentFlipX = false;
private currentFlipY = false;
constructor() { constructor() {
super('ShapeViewerScene'); super("ShapeViewerScene");
} }
create(): void { create(): void {
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 {
this.children.removeAll(); this.children.removeAll();
const { width, height } = this.scale; const { width } = this.scale;
// Title this.add
this.add.text(width / 2, 30, 'Shape Viewer - Item Shapes', { .text(width / 2, 30, "Shape Viewer - Item Shapes", {
fontSize: '24px', fontSize: "24px",
color: '#ffffff', color: "#ffffff",
fontStyle: 'bold', fontStyle: "bold",
}).setOrigin(0.5); })
.setOrigin(0.5);
// Draw all shapes in a grid
this.drawAllShapes(); this.drawAllShapes();
} }
private drawAllShapes(): void { private drawAllShapes(): void {
const { width } = this.scale; const { width } = this.scale;
const startY = 80; const startY = 80;
const spacingX = 220; const { SPACING_X, SPACING_Y, ITEMS_PER_ROW, MAX_ITEMS } = SHAPE_CONFIG;
const spacingY = 140;
// Show first 12 items for clarity const itemsToShow = data.desert.getItems().slice(0, MAX_ITEMS);
const itemsToShow = heroItemFighter1Data.slice(0, 12);
for (let i = 0; i < itemsToShow.length; i++) { for (let i = 0; i < itemsToShow.length; i++) {
const data = itemsToShow[i]; const itemData = itemsToShow[i];
const shape = parseShapeString(data.shape); const shape = parseShapeString(itemData.shape);
const col = i % this.ITEMS_PER_ROW; const col = i % ITEMS_PER_ROW;
const row = Math.floor(i / this.ITEMS_PER_ROW); const row = Math.floor(i / ITEMS_PER_ROW);
const x = 60 + col * spacingX; const x = 60 + col * SPACING_X;
const y = startY + row * spacingY; const y = startY + row * SPACING_Y;
this.drawSingleShape(x, y, data, shape); this.drawSingleShape(x, y, itemData, shape);
} }
} }
private drawSingleShape(startX: number, startY: number, data: any, shape: ParsedShape): void { private drawSingleShape(
startX: number,
startY: number,
itemData: any,
shape: ParsedShape,
): void {
const graphics = this.add.graphics(); const graphics = this.add.graphics();
const { CELL_SIZE } = SHAPE_CONFIG;
// Draw shape background const shapeWidth = shape.width * CELL_SIZE;
const shapeWidth = shape.width * this.CELL_SIZE; const shapeHeight = shape.height * CELL_SIZE;
const shapeHeight = shape.height * this.CELL_SIZE;
// Title - item name this.add
this.add.text(startX + shapeWidth / 2, startY - 20, data.name, { .text(startX + shapeWidth / 2, startY - 20, itemData.name, {
fontSize: '14px', fontSize: "14px",
color: '#ffffff', color: "#ffffff",
fontStyle: 'bold', fontStyle: "bold",
}).setOrigin(0.5); })
.setOrigin(0.5);
// Draw shape cells
for (let y = 0; y < shape.height; y++) { for (let y = 0; y < shape.height; y++) {
for (let x = 0; x < shape.width; x++) { for (let x = 0; x < shape.width; x++) {
if (shape.grid[y]?.[x]) { if (shape.grid[y]?.[x]) {
const px = startX + x * this.CELL_SIZE; const px = startX + x * CELL_SIZE;
const py = startY + y * this.CELL_SIZE; const py = startY + y * CELL_SIZE;
// Determine if this is the origin cell
const isOrigin = x === shape.originX && y === shape.originY; const isOrigin = x === shape.originX && y === shape.originY;
const color = isOrigin ? 0x88cc44 : 0x4488cc; const color = isOrigin ? 0x88cc44 : 0x4488cc;
graphics.fillStyle(color); graphics.fillStyle(color);
graphics.fillRect(px + 1, py + 1, this.CELL_SIZE - 2, this.CELL_SIZE - 2); graphics.fillRect(px + 1, py + 1, CELL_SIZE - 2, CELL_SIZE - 2);
graphics.lineStyle(2, 0xffffff); graphics.lineStyle(2, 0xffffff);
graphics.strokeRect(px, py, this.CELL_SIZE, this.CELL_SIZE); graphics.strokeRect(px, py, CELL_SIZE, CELL_SIZE);
// Mark origin with 'O'
if (isOrigin) { if (isOrigin) {
this.add.text(px + this.CELL_SIZE / 2, py + this.CELL_SIZE / 2, 'O', { this.add
fontSize: '16px', .text(px + CELL_SIZE / 2, py + CELL_SIZE / 2, "O", {
color: '#ffffff', fontSize: "16px",
fontStyle: 'bold', color: "#ffffff",
}).setOrigin(0.5); fontStyle: "bold",
})
.setOrigin(0.5);
} }
} }
} }
} }
// Shape string this.add
this.add.text(startX + shapeWidth / 2, startY + shapeHeight + 10, `形状: ${data.shape}`, { .text(
fontSize: '11px', startX + shapeWidth / 2,
color: '#aaaaaa', startY + shapeHeight + 10,
}).setOrigin(0.5); `形状: ${itemData.shape}`,
{
fontSize: "11px",
color: "#aaaaaa",
},
)
.setOrigin(0.5);
// Type and cost this.add
this.add.text(startX + shapeWidth / 2, startY + shapeHeight + 28, .text(
`类型: ${data.type} | 费用: ${data.costCount} ${data.costType}`, { startX + shapeWidth / 2,
fontSize: '11px', startY + shapeHeight + 28,
color: '#cccccc', `类型: ${itemData.type} | 费用: ${itemData.costCount} ${itemData.costType}`,
}).setOrigin(0.5); {
fontSize: "11px",
color: "#cccccc",
},
)
.setOrigin(0.5);
// Description this.add
this.add.text(startX + shapeWidth / 2, startY + shapeHeight + 46, data.desc, { .text(startX + shapeWidth / 2, startY + shapeHeight + 46, itemData.desc, {
fontSize: '10px', fontSize: "10px",
color: '#888888', color: "#888888",
wordWrap: { width: shapeWidth }, wordWrap: { width: shapeWidth },
}).setOrigin(0.5); })
.setOrigin(0.5);
} }
private createControls(): void { private createControls(): void {
const { width, height } = this.scale; const { width, height } = this.scale;
// Back button createButton({
this.createButton('返回菜单', 100, height - 40, async () => { scene: this,
await this.sceneController.launch('IndexScene'); label: "返回菜单",
x: 100,
y: height - 40,
onClick: async () => {
await this.sceneController.launch(SceneKey.IndexScene);
},
}); });
// Info text this.add
this.add.text(width / 2, height - 40, .text(
`Showing first 12 items | Green = Origin | Blue = Normal`, { width / 2,
fontSize: '14px', height - 40,
color: '#aaaaaa', `Showing first ${SHAPE_CONFIG.MAX_ITEMS} items | Green = Origin | Blue = Normal`,
}).setOrigin(0.5); {
} fontSize: "14px",
color: "#aaaaaa",
private createButton(label: string, x: number, y: number, onClick: () => void): void { },
const buttonWidth = 120; )
const buttonHeight = 36; .setOrigin(0.5);
const bg = this.add.rectangle(x, y, buttonWidth, buttonHeight, 0x444466)
.setStrokeStyle(2, 0x7777aa)
.setInteractive({ useHandCursor: true });
const text = this.add.text(x, y, label, {
fontSize: '16px',
color: '#ffffff',
}).setOrigin(0.5);
bg.on('pointerover', () => {
bg.setFillStyle(0x555588);
text.setScale(1.05);
});
bg.on('pointerout', () => {
bg.setFillStyle(0x444466);
text.setScale(1);
});
bg.on('pointerdown', onClick);
} }
} }
@@ -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,
};
}
@@ -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,
};
}
+29 -22
View File
@@ -1,31 +1,38 @@
import { h } from 'preact'; import { GAME_CONFIG } from "@/config";
import { PhaserGame, PhaserScene } from 'boardgame-phaser'; import { CombatTestScene } from "@/scenes/CombatTestScene";
import { useMemo } from 'preact/hooks'; import { GridViewerScene } from "@/scenes/GridViewerScene";
import { IndexScene } from '@/scenes/IndexScene'; import { IndexScene } from "@/scenes/IndexScene";
import { MapViewerScene } from '@/scenes/MapViewerScene'; import { InventoryTestScene } from "@/scenes/InventoryTestScene";
import { GridViewerScene } from '@/scenes/GridViewerScene'; import { MapViewerScene } from "@/scenes/MapViewerScene";
import { ShapeViewerScene } from '@/scenes/ShapeViewerScene'; import { ShapeViewerScene } from "@/scenes/ShapeViewerScene";
import { GameFlowScene } from '@/scenes/GameFlowScene'; import { CombatModule, createCombatModule } from "@/state/combatState";
import { PlaceholderEncounterScene } from '@/scenes/PlaceholderEncounterScene'; import { createGameHost } from "boardgame-core";
import {
CombatResult,
CombatState,
} from "boardgame-core/samples/slay-the-spire-like";
import { PhaserGame, PhaserScene } from "boardgame-phaser";
import { useMemo } from "preact/hooks";
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(), []); ),
const placeholderEncounterScene = useMemo(() => new PlaceholderEncounterScene(), []); [],
);
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 initialScene="IndexScene" config={{ width: 1920, height: 1080 }}> <PhaserGame initialScene="IndexScene" config={GAME_CONFIG}>
<PhaserScene sceneKey="IndexScene" scene={indexScene} /> <PhaserScene scene={IndexScene} />
<PhaserScene sceneKey="MapViewerScene" scene={mapViewerScene} /> <PhaserScene scene={CombatTestScene} data={{ gameHost }} />
<PhaserScene sceneKey="GridViewerScene" scene={gridViewerScene} /> <PhaserScene scene={InventoryTestScene} />
<PhaserScene sceneKey="ShapeViewerScene" scene={shapeViewerScene} /> <PhaserScene scene={MapViewerScene} />
<PhaserScene sceneKey="GameFlowScene" scene={gameFlowScene} /> <PhaserScene scene={GridViewerScene} />
<PhaserScene sceneKey="PlaceholderEncounterScene" scene={placeholderEncounterScene} /> <PhaserScene scene={ShapeViewerScene} />
</PhaserGame> </PhaserGame>
</div> </div>
</div> </div>
@@ -0,0 +1,86 @@
import Phaser from 'phaser';
import { UI_CONFIG } from '@/config';
export interface ButtonOptions {
/** The Phaser scene to create the button in */
scene: Phaser.Scene;
/** Button label text */
label: string;
/** X position (center of button) */
x: number;
/** Y position (center of button) */
y: number;
/** Button width (defaults to UI_CONFIG.BUTTON_WIDTH) */
width?: number;
/** Button height (defaults to UI_CONFIG.BUTTON_HEIGHT) */
height?: number;
/** Click handler */
onClick: () => void;
/** Phaser depth (defaults to 100) */
depth?: number;
/** Background color (defaults to UI_CONFIG.BUTTON_BG) */
bgColor?: number;
/** Hover background color (defaults to UI_CONFIG.BUTTON_BG_HOVER) */
hoverBgColor?: number;
/** Border color (defaults to UI_CONFIG.BUTTON_BORDER) */
borderColor?: number;
/** Text color (defaults to UI_CONFIG.BUTTON_TEXT_COLOR) */
textColor?: string;
/** Font size (defaults to UI_CONFIG.BUTTON_FONT_SIZE) */
fontSize?: string;
}
export interface ButtonObjects {
/** Background rectangle */
bg: Phaser.GameObjects.Rectangle;
/** Text label */
text: Phaser.GameObjects.Text;
}
/**
* Create a styled interactive button in a Phaser scene.
*
* Handles hover effects (fill color change + text scale) and click callback.
* Returns both the bg and text objects for further customization if needed.
*/
export function createButton(options: ButtonOptions): ButtonObjects {
const {
scene,
label,
x,
y,
width = UI_CONFIG.BUTTON_WIDTH,
height = UI_CONFIG.BUTTON_HEIGHT,
onClick,
depth = 100,
bgColor = UI_CONFIG.BUTTON_BG,
hoverBgColor = UI_CONFIG.BUTTON_BG_HOVER,
borderColor = UI_CONFIG.BUTTON_BORDER,
textColor = UI_CONFIG.BUTTON_TEXT_COLOR,
fontSize = UI_CONFIG.BUTTON_FONT_SIZE,
} = options;
const bg = scene.add.rectangle(x, y, width, height, bgColor)
.setStrokeStyle(2, borderColor)
.setInteractive({ useHandCursor: true })
.setDepth(depth);
const text = scene.add.text(x, y, label, {
fontSize,
color: textColor,
}).setOrigin(0.5).setDepth(depth);
bg.on('pointerover', () => {
bg.setFillStyle(hoverBgColor);
text.setScale(1.05);
});
bg.on('pointerout', () => {
bg.setFillStyle(bgColor);
text.setScale(1);
});
bg.on('pointerdown', onClick);
return { bg, text };
}
+4 -3
View File
@@ -3,14 +3,15 @@
"compilerOptions": { "compilerOptions": {
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@/*": ["src/*"] "@/*": ["src/*"],
"boardgame-phaser": ["../framework/src/index.ts"],
}, },
"jsx": "react-jsx", "jsx": "react-jsx",
"jsxImportSource": "preact", "jsxImportSource": "preact",
"noEmit": true, "noEmit": true,
"declaration": false, "declaration": false,
"declarationMap": false, "declarationMap": false,
"sourceMap": false "sourceMap": false,
}, },
"include": ["src/**/*"] "include": ["src/**/*"],
} }
+2106 -37
View File
File diff suppressed because it is too large Load Diff