Compare commits

...
115 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
hypercross 7fb9edcbf0 feat: add game process scene 2026-04-14 13:51:11 +08:00
hypercross 65684e6cf5 refactor: udpate to new design 2026-04-14 13:04:34 +08:00
hypercross c9acb7f228 refactor: scene control 2026-04-14 11:10:14 +08:00
hypercross c639218b53 refactor: viewr 2026-04-13 14:57:00 +08:00
hypercross e91e8d2ab7 feat: sts-like-viewer 2026-04-13 12:38:41 +08:00
hyper 02d2f45da2 refactor: fix onitama sizing 2026-04-12 19:24:47 +08:00
hyper e5b53c6332 refactor: fix onitama api usage 2026-04-12 19:09:41 +08:00
hyper ecfa89e383 fix: boop supply 2026-04-12 18:55:35 +08:00
hyper 8bd790db67 refactor: boop sizing 2026-04-12 18:54:18 +08:00
hyper 93587541ce refactor: boop to use new api 2026-04-12 18:47:04 +08:00
hyper cc80cbad06 fix: fading 2026-04-12 18:18:45 +08:00
hyper 6584578316 fix: overlay init 2026-04-12 18:11:23 +08:00
hyper f6f18b4789 fix: wait for fade scene init 2026-04-12 18:02:10 +08:00
hyper 70334fa9e3 refactor: wait to launch scene 2026-04-12 18:00:08 +08:00
hyper fbf3f5e636 refactor:refactor: improve ttt 2026-04-12 17:54:46 +08:00
hyper 9ab7ae3e60 refactor: improved scene manager 2026-04-12 17:52:44 +08:00
hyper fe57583a8f refactor: api 2026-04-12 17:34:25 +08:00
hyper 713a14c128 refactor: simplify scene routing 2026-04-12 17:21:49 +08:00
hyper 50531446c2 feat: scene manager and transitions 2026-04-12 16:52:53 +08:00
hyper f8a19653ba refactor: details 2026-04-12 16:31:10 +08:00
hyper 59fa0e6122 refactor: ReactiveScene 2026-04-12 16:26:52 +08:00
hyper 21a7afa276 chore: fix vite config 2026-04-12 15:02:23 +08:00
hypercross 71ccb8732e refactor: some manual changes 2026-04-08 14:21:33 +08:00
hypercross 92e1a5bec1 feat: rotation and more 2026-04-08 11:49:25 +08:00
hypercross 3e064f437b feat: add more tweens 2026-04-08 11:33:59 +08:00
hypercross 00fd395873 fix: more issues with ui 2026-04-08 11:06:34 +08:00
hypercross 2beff5c75c fix: fix ui bugs 2026-04-08 08:57:59 +08:00
hypercross d4819f7cc3 feat: more onitama goodness 2026-04-07 17:46:37 +08:00
hypercross 8bbf20f457 feat: onitama 2026-04-07 16:33:41 +08:00
hypercross 27e6d52cf3 docs: update docs 2026-04-07 16:29:08 +08:00
hypercross deb8c91239 refactor: use boardgame-core's sample exports 2026-04-07 16:16:04 +08:00
hypercross cedb69e55e refactor: more regicide stuff 2026-04-06 17:17:04 +08:00
hypercross a0412ddd88 refactor: fix regicide, presumably? 2026-04-06 16:45:52 +08:00
hypercross 20f722818d refactor: prompt def udpate 2026-04-06 16:20:52 +08:00
hypercross bf20e53c6b feat: regicide card game 2026-04-06 14:21:44 +08:00
hypercross e76010272c docs: update AGENTS.md & add vitest 2026-04-06 14:13:43 +08:00
hypercross a53f162c03 refactor: update api 2026-04-06 13:59:00 +08:00
hypercross fa104d10f2 refactor: update api 2026-04-06 11:31:51 +08:00
hypercross 0e7aaea7da docs: update Agents.md 2026-04-05 09:51:49 +08:00
hypercross acb2fc82ba feat: animate after booped 2026-04-05 09:45:01 +08:00
hypercross 2f0fb2bca8 feat: error reporting 2026-04-05 00:31:34 +08:00
hypercross c678974489 feat: add error display & upgrade pick 2026-04-05 00:24:06 +08:00
hypercross df1c0cbb81 feat: add game start overlay 2026-04-05 00:12:05 +08:00
hypercross 6ebfcc6c4d fix: update button to choose cat 2026-04-05 00:06:48 +08:00
hypercross 449727d650 fix: api change 2026-04-05 00:01:26 +08:00
hypercross de4e83e4ea fix: api change for bop 2026-04-04 23:25:43 +08:00
hypercross 61857b8256 fix: api changes fix 2026-04-04 23:14:30 +08:00
hypercross c85fdb604c chore: update game implementation for boardgame-core 2026-04-04 22:52:54 +08:00
hypercross 99dc5e29e7 chore: install inline-schema 2026-04-04 22:52:40 +08:00
hypercross 76687de672 refactor: misc 2026-04-04 16:34:17 +08:00
122 changed files with 12416 additions and 1220 deletions
+1
View File
@@ -9,3 +9,4 @@ packages/sample-game/src/**/*.js
packages/sample-game/src/**/*.js.map
packages/sample-game/src/**/*.d.ts
packages/sample-game/src/**/*.d.ts.map
/packages/regicide-game/debug
+2
View File
@@ -0,0 +1,2 @@
!node_modules
!debug
+29 -31
View File
@@ -2,42 +2,43 @@
## Project Overview
A Phaser 3 framework for building web board games, built on top of `boardgame-core` (state management with Preact Signals + Mutative). Uses a pnpm monorepo with two packages:
A Phaser 3 framework for building web board games, built on top of `boardgame-core` (state management with Preact Signals + Mutative). Uses a pnpm monorepo with four packages:
- **`packages/framework`** (`boardgame-phaser`) — Reusable library: reactive scenes, signal→Phaser bindings, input/command bridge, Preact UI components
- **`packages/sample-game`** — Demo Tic-Tac-Toe game using the framework
- **`packages/boop-game`** — Boop sample game
- **`packages/regicide-game`** — Regicide game
## boardgame-core Usage
For detailed boardgame-core API documentation and examples, see **[boardgame-core Guide](docs/boardgame-core-guide.md)**.
For detailed boardgame-core API documentation and examples, see `packages/framework/node_modules/boardgame-core/`
Key concepts:
- **MutableSignal** — Reactive state container with `.value` and `.produce()`
- **Command System** — CLI-style parsing with schema validation and prompt support
- **Region System** — Spatial management with `createRegion()`, `applyAlign()`, `shuffle()`, `moveToRegion()`
- **Part System** — Game pieces with `createPart()`, `createPartPool()`, `flip()`, `roll()`
- **Part System** — Game pieces with `createPartsFromTable()`, `flip()`, `roll()`
- **RNG** — Deterministic PRNG via `createRNG(seed)` for reproducible game states
### Quick Example
```ts
import { createGameCommandRegistry, createRegion, MutableSignal } from 'boardgame-core';
import { createGameCommandRegistry, createRegion, IGameContext } from 'boardgame-core';
type GameState = {
board: Region;
parts: Part<{ player: 'X' | 'O' }>[];
currentPlayer: 'X' | 'O';
};
type Game = IGameContext<GameState>;
const registration = createGameCommandRegistry<GameState>();
export const registry = registration.registry;
const registry = createGameCommandRegistry<GameState>();
registration.add('place <row:number> <col:number>', async function(cmd) {
const [row, col] = cmd.params as [number, number];
this.context.produce(state => {
registry.register('place <row:number> <col:number>', async function(game: Game, row: number, col: number) {
await game.produceAsync(state => {
state.parts.push({ id: `p-${row}-${col}`, regionId: 'board', position: [row, col], player: state.currentPlayer });
});
return { success: true };
return true;
});
```
@@ -71,23 +72,20 @@ pnpm --filter sample-game typecheck # tsc --noEmit (add to scripts first)
```bash
# boardgame-core is a local dependency via symlink (link:../../../boardgame-core)
# After changes to boardgame-core, simply rebuild it:
cd ../boardgame-core && pnpm build
cd ../boardgame-core && npm build
# The symlink automatically resolves to the updated dist/
```
### Testing
No test framework is configured yet. When adding tests, use **Vitest** (consistent with boardgame-core):
Framework and regicide-game have **Vitest** configured:
```bash
# In packages/framework:
pnpm add -D vitest
# Run all tests:
pnpm vitest run
# Run a single test file:
pnpm vitest run src/bindings/index.test.ts
# Run tests matching a pattern:
pnpm vitest run -t "bindRegion"
# Watch mode:
pnpm vitest
pnpm --filter boardgame-phaser test # Run all tests
pnpm --filter boardgame-phaser test:watch # Watch mode
# In packages/regicide-game:
pnpm --filter regicide-game test # Run all tests
pnpm --filter regicide-game test:watch # Watch mode
```
## Code Style
@@ -112,24 +110,24 @@ pnpm vitest
- Use `as any` sparingly; prefer `as unknown as Record<string, unknown>` for type narrowing
### Naming conventions
- **Classes**: PascalCase (`ReactiveScene`, `InputMapper`, `PromptHandler`)
- **Interfaces**: PascalCase with descriptive names (`ReactiveSceneOptions`, `BindRegionOptions`)
- **Functions**: camelCase (`bindSignal`, `createInputMapper`, `createPromptHandler`)
- **Classes**: PascalCase (`GameHostScene`, `PhaserGame`, `GameUI`)
- **Interfaces**: PascalCase with descriptive names (`GameHostSceneOptions`, `PhaserGameProps`)
- **Functions**: camelCase (`spawnEffect`, `bindRegion`)
- **Constants**: UPPER_SNAKE_CASE (`CELL_SIZE`, `BOARD_OFFSET`)
- **Type aliases**: PascalCase (`DisposeFn`, `CommandResult`)
- **Factory functions**: `create*` prefix (`createInputMapper`, `createPromptHandler`)
- **Factory functions**: `create*` prefix (`createRegion`, `createRNG`)
### Architecture patterns
- **ReactiveScene**: Abstract base class extending `Phaser.Scene`. Subclasses implement `onStateReady()` and `setupBindings()`. Effects are auto-cleaned on scene shutdown via `this.events.on('shutdown', ...)`.
- **Bindings**: Use `effect()` from `@preact/signals-core` to reactively sync signal state to Phaser objects. Always return cleanup functions.
- **Input/Command bridge**: Maps Phaser pointer events to `boardgame-core` command strings. Uses `create*` factory pattern.
- **GameHostScene**: Abstract base class extending `Phaser.Scene`. Subclasses implement game-specific logic. Provides `gameHost` property for state access and `addInterruption()`/`addTweenInterruption()` for async flow control. Disposables are auto-cleaned on scene shutdown via `this.events.on('shutdown', ...)`.
- **Spawner**: Use `spawnEffect()` for data-driven object creation with signal-based configuration.
- **UI components**: Preact functional components with hooks. Use `className` (not `class`) for CSS classes.
- **Phaser Bridge**: Use `PhaserGame`, `PhaserScene`, and `GameUI` components from `boardgame-phaser` for React/Phaser integration.
### Error handling
- Command results use `{ success: true, result } | { success: false, error }` pattern
- Scene effects are cleaned up via the `shutdown` event, not by overriding `shutdown()`
- Use `DisposableBag` for managing multiple disposables in scenes
- Scene effects are cleaned up via the `shutdown` event
- Use `as any` casts only when Phaser types are incomplete (e.g., `setInteractive`)
- Prompt handlers return `string | null` for validation errors
- Game commands return validation errors as `string | null`
### JSX
- `jsxImportSource: "preact"` — no React import needed
+71
View File
@@ -0,0 +1,71 @@
# boardgame-phaser
基于Phaser3/boardgame-core的游戏框架
## 概述
项目使用pnpm monorepo管理,包含以下包:
- `framework`:通用框架
- `boop-game`:boop样例
- `sample-game`:tic tac toe样例
游戏应当使用vite构建,基于`preact/signals`进行状态管理,使用`phaser3`实现游戏功能。
## boardgame-core
项目使用`boardgame-core`进行游戏定义。
`boardgame-core`的内容可以在`framework/node_modules/boardgame-core`找到。
这个文件夹被.gitignore忽略,查看时需要绕开这一限制。
## 编写Phaser App
使用`framework/src/ui/PhaserBridge`来创建Phaser App。
使用`framework/src/scenes/GameHostScene`来创建游戏场景。
使用`GameHost`来控制游戏状态。
```typescript
export class GameHost<TState extends Record<string, unknown>, TResult=unknown> {
// 获取游戏状态的只读快照
get state(): TState{}
// 获取随机数
get rng(): ReadonlyRNG{}
// 运行状态
readonly status: ReadonlySignal<GameHostStatus>;
// 运行中途需要玩家输入时使用
readonly activePromptSchema: ReadonlySignal<CommandSchema | null>;
readonly activePromptPlayer: ReadonlySignal<string | null>;
// 玩家响应activePrompt的输入,若报错则返回string,否则返回null
// promptDef应当从game module中导出
tryAnswerPrompt<TArgs extends any[]>(promptDef: Promptdef<TArgs>,...args: TArgs): string | null {}
// 添加中断,context.produceAsync会等待所有中断结束之后再继续
addInterruption(promise: Promise<void>): void {}
// 开始或者重新开始游戏
start(seed?: number): Promise<TResult>{}
// 销毁
dispose(): void {}
// 事件侦听
on(event: 'start' | 'dispose', listener: () => void): () => void {}
}
```
使用`Spawner`来创建动态游戏对象:
- 卡牌
- 棋子
- UI高亮提示对象
使用`MutableSignal`创建状态信号。
```typescript
import {MutableSignal} from "boardgame-core"
export const x = MutableSignal(0);
x.produce(x => x + 1);
```
+107
View File
@@ -0,0 +1,107 @@
## 编写GameModule
游戏逻辑以GameModule的形式定义:
```typescript
import {createGameCommandRegistry, IGameContext} from "boardgame-core";
// 创建mutative游戏初始状态
export function createInitialState(): GameState {
//...
}
// 运行游戏
export async function start(game: IGameContext<GameState>) {
// ...
}
// 可选
export const registry = createGameCommandRegistry();
```
使用以下步骤创建GameModule:
### 1. 定义状态
通常使用一个`regions: Record<string, Region>`和一个`parts: Record<string, Part<TMeta>>`来记录桌游物件的摆放。
```typescript
import {Region} from "boardgame-core";
type GameState = {
regions: {
white: Region,
black: Region,
board: Region,
},
pieces: Record<string, Part<PartsTable['0']>>,
currentPlayer: PlayerType,
winner: WinnerType,
};
```
游戏的部件可以从`csv`加载。详情见`boop-game/node_modules/inline-schema/`。
```
/// parts.csv
type, player, count
string, string, number
cat, white, 8
cat, black, 8
/// parts.csv.d.ts
type PartsTable = {
type: string;
player: string;
count: number;
}[];
declare const data: PartsTable;
export default data;
```
### 2. 定义流程
使用`async function start(game: IGameContext<GameState>)`作为入口。
使用`game.value`读取游戏当前状态
```typescript
async function gameEnd(game: IGameContext<GameState>) {
return game.value.winner;
}
```
需要修改状态时,使用`game.produce`或`game.produceAsync`。
```typescript
async function start(game: IGameContext<GameState>) {
await game.produceAsync(state => {
state.currentPlayer = 'white';
});
}
```
需要等待玩家交互时,使用`await game.prompt(promptDef, validator, player)`。
```typescript
import {createPromptDef} from "boardgame-core";
export const prompts = {
play: createPromptDef<[PlayerType, number, number]>('play <player> <row:number> <col:number>'),
}
async function turn(game: IGameContext<GameState>, currentPlayer: PlayerType) {
const {player, row, col} = await game.prompt(
prompts.play,
(player, row, col) => {
if (player !== currentPlayer)
throw `Wrong player!`
return {player, row, col};
}
)
}
```
### 3. 编写测试
使用`vitest`编写测试,测试应当使用`GameHost`来模拟游戏环境。
+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",
"build": "pnpm --filter boardgame-phaser build && pnpm --filter sample-game 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": {
"@eslint/js": "^9.17.0",
"eslint": "^9.17.0",
"eslint-plugin-import": "^2.31.0",
"typescript": "^5.3.3",
"typescript-eslint": "^8.18.0",
"vitest": "^3.2.4"
},
"pnpm": {
+1
View File
@@ -11,6 +11,7 @@
"@preact/signals-core": "^1.5.1",
"boardgame-core": "link:../../../boardgame-core",
"boardgame-phaser": "workspace:*",
"inline-schema": "link:../../../inline-schema",
"mutative": "^1.3.0",
"phaser": "^3.80.1",
"preact": "^10.19.3"
+17
View File
@@ -0,0 +1,17 @@
/**
* Re-export boop game module from boardgame-core
* This provides a convenient import path within boop-game
*/
export {
registry,
prompts,
start,
createInitialState,
type BoopState,
type BoopPart,
type BoopGame,
type PlayerType,
type PieceType,
type WinnerType,
type RegionType,
} from "boardgame-core/samples/boop";
-434
View File
@@ -1,434 +0,0 @@
import {
createGameCommandRegistry,
Part,
MutableSignal,
createRegion,
createPartPool,
createPart,
moveToRegion,
isCellOccupied as isCellOccupiedUtil,
getPartAtPosition,
applyAlign,
} from 'boardgame-core';
const BOARD_SIZE = 6;
const MAX_PIECES_PER_PLAYER = 8;
const WIN_LENGTH = 3;
export type PlayerType = 'white' | 'black';
export type PieceType = 'kitten' | 'cat';
export type WinnerType = PlayerType | 'draw' | null;
export type RegionId = 'board' | 'white-kitten' | 'white-cat' | 'black-kitten' | 'black-cat';
export type BoopPart = Part<{ player: PlayerType; pieceType: PieceType }>;
type Player = {
id: PlayerType;
kittenPool: ReturnType<typeof createPartPool<{ player: PlayerType; pieceType: PieceType }>>;
catPool: ReturnType<typeof createPartPool<{ player: PlayerType; pieceType: PieceType }>>;
graduatedCount: number; // 已毕业但未放置的大猫数量
};
type PlayerData = Record<PlayerType, Player>;
export type BoopState = {
board: ReturnType<typeof createRegion>;
pieces: Record<string, BoopPart>;
currentPlayer: PlayerType;
winner: WinnerType;
players: PlayerData;
};
function createPlayer(id: PlayerType): Player {
return {
id,
kittenPool: createPartPool(
{ regionId: `${id}-kitten`, player: id, pieceType: 'kitten' as PieceType },
MAX_PIECES_PER_PLAYER,
`${id}-kitten`
),
catPool: createPartPool(
{ regionId: `${id}-cat`, player: id, pieceType: 'cat' as PieceType },
MAX_PIECES_PER_PLAYER, // 预创建 MAX_PIECES_PER_PLAYER 只猫
`${id}-cat`
),
graduatedCount: 0,
};
}
export function createInitialState(): BoopState {
return {
board: createRegion('board', [
{ name: 'x', min: 0, max: BOARD_SIZE - 1 },
{ name: 'y', min: 0, max: BOARD_SIZE - 1 },
]),
pieces: {} as Record<string, BoopPart>,
currentPlayer: 'white' as PlayerType,
winner: null as WinnerType,
players: {
white: createPlayer('white'),
black: createPlayer('black'),
},
};
}
export type BoopStateType = ReturnType<typeof createInitialState>;
const registration = createGameCommandRegistry<BoopStateType>();
export const registry = registration.registry;
export function getPlayer(host: MutableSignal<BoopStateType>, player: PlayerType): Player {
return host.value.players[player];
}
registration.add('setup', async function() {
const {context} = this;
while (true) {
const currentPlayer = context.value.currentPlayer;
const turnOutput = await this.run<{winner: WinnerType}>(`turn ${currentPlayer}`);
if (!turnOutput.success) throw new Error(turnOutput.error);
context.produce(state => {
state.winner = turnOutput.result.winner;
if (!state.winner) {
state.currentPlayer = state.currentPlayer === 'white' ? 'black' : 'white';
}
});
if (context.value.winner) break;
}
return context.value;
});
registration.add('turn <player>', async function(cmd) {
const [turnPlayer] = cmd.params as [PlayerType];
const playCmd = await this.prompt(
'play <player> <row:number> <col:number> [type:string]',
(command) => {
const [player, row, col, type] = command.params as [PlayerType, number, number, PieceType?];
const pieceType = type === 'cat' ? 'cat' : 'kitten';
if (player !== turnPlayer) {
return `Invalid player: ${player}. Expected ${turnPlayer}.`;
}
if (!isValidMove(row, col)) {
return `Invalid position: (${row}, ${col}). Must be between 0 and ${BOARD_SIZE - 1}.`;
}
if (isCellOccupied(this.context, row, col)) {
return `Cell (${row}, ${col}) is already occupied.`;
}
const playerData = getPlayer(this.context, player);
if (pieceType === 'kitten') {
if (playerData.kittenPool.remaining() <= 0) {
return `No kittens left in ${player}'s supply.`;
}
} else {
// Can place cat if pool has remaining OR graduatedCount > 0
const availableCats = playerData.catPool.remaining() + playerData.graduatedCount;
if (availableCats <= 0) {
return `No cats available for ${player}. Graduate some kittens first.`;
}
}
return null;
},
this.context.value.currentPlayer
);
const [player, row, col, type] = playCmd.params as [PlayerType, number, number, PieceType?];
const pieceType = type === 'cat' ? 'cat' : 'kitten';
placePiece(this.context, row, col, turnPlayer, pieceType);
applyBoops(this.context, row, col, pieceType);
const graduatedLines = checkGraduation(this.context, turnPlayer);
if (graduatedLines.length > 0) {
processGraduation(this.context, turnPlayer, graduatedLines);
}
if (countPiecesOnBoard(this.context, turnPlayer) >= MAX_PIECES_PER_PLAYER) {
const pieces = this.context.value.pieces;
const availableKittens = Object.values(pieces).filter(
p => p.player === turnPlayer && p.pieceType === 'kitten'
);
if (availableKittens.length > 0) {
const graduateCmd = await this.prompt(
'graduate <row:number> <col:number>',
(command) => {
const [row, col] = command.params as [number, number];
const part = availableKittens.find(p => p.position[0] === row && p.position[1] === col);
if (!part) return `No kitten at (${row}, ${col}).`;
return null;
},
this.context.value.currentPlayer
);
const [row, col] = graduateCmd.params as [number, number];
graduatePiece(this.context, row, col, turnPlayer);
}
}
const winner = checkWinner(this.context);
if (winner) return { winner };
return { winner: null };
});
function isValidMove(row: number, col: number): boolean {
return !isNaN(row) && !isNaN(col) && row >= 0 && row < BOARD_SIZE && col >= 0 && col < BOARD_SIZE;
}
export function getBoardRegion(host: MutableSignal<BoopStateType>) {
return host.value.board;
}
export function isCellOccupied(host: MutableSignal<BoopStateType>, row: number, col: number): boolean {
return isCellOccupiedUtil(host.value.pieces, 'board', [row, col]);
}
export function getPartAt(host: MutableSignal<BoopStateType>, row: number, col: number): BoopPart | null {
return getPartAtPosition(host.value.pieces, 'board', [row, col]) || null;
}
export function placePiece(host: MutableSignal<BoopStateType>, row: number, col: number, player: PlayerType, pieceType: PieceType) {
const board = getBoardRegion(host);
const playerData = getPlayer(host, player);
let piece: BoopPart;
if (pieceType === 'kitten') {
const drawn = playerData.kittenPool.draw();
if (!drawn) throw new Error(`No kitten available in ${player}'s supply`);
piece = drawn;
moveToRegion(piece, null, board, [row, col]);
} else {
// Try to use graduated count first
if (playerData.graduatedCount > 0) {
// Create a new cat piece (graduated)
const count = playerData.catPool.remaining() + playerData.graduatedCount;
piece = createPart<{ player: PlayerType; pieceType: PieceType }>(
{ regionId: 'board', position: [row, col], player, pieceType },
`${player}-cat-graduated-${count}`
);
playerData.graduatedCount--;
} else {
const drawn = playerData.catPool.draw();
if (!drawn) throw new Error(`No cat available in ${player}'s supply`);
piece = drawn;
moveToRegion(piece, null, board, [row, col]);
}
}
host.produce(s => {
s.pieces[piece.id] = piece;
});
}
export function applyBoops(host: MutableSignal<BoopStateType>, placedRow: number, placedCol: number, placedType: PieceType) {
const board = getBoardRegion(host);
const pieces = host.value.pieces;
const piecesArray = Object.values(pieces);
const piecesToBoop: { part: BoopPart; dr: number; dc: number }[] = [];
const piecesOffBoard: BoopPart[] = [];
for (const part of piecesArray) {
const [r, c] = part.position;
if (r === placedRow && c === placedCol) continue;
const dr = Math.sign(r - placedRow);
const dc = Math.sign(c - placedCol);
if (Math.abs(r - placedRow) <= 1 && Math.abs(c - placedCol) <= 1) {
const booperIsKitten = placedType === 'kitten';
const targetIsCat = part.pieceType === 'cat';
if (booperIsKitten && targetIsCat) continue;
piecesToBoop.push({ part, dr, dc });
}
}
host.produce(state => {
for (const { part, dr, dc } of piecesToBoop) {
const [r, c] = part.position;
const newRow = r + dr;
const newCol = c + dc;
if (newRow < 0 || newRow >= BOARD_SIZE || newCol < 0 || newCol >= BOARD_SIZE) {
// Mark for removal
piecesOffBoard.push(part);
delete state.pieces[part.id];
state.board.childIds = state.board.childIds.filter(id => id !== part.id);
continue;
}
if (isCellOccupied(host, newRow, newCol)) continue;
moveToRegion(part, board, board, [newRow, newCol]);
}
});
// Return booped pieces to pools outside of produce
for (const part of piecesOffBoard) {
const playerData = getPlayer(host, part.player);
const pool = part.pieceType === 'kitten' ? playerData.kittenPool : playerData.catPool;
pool.return(part);
}
}
export function graduatePiece(host: MutableSignal<BoopStateType>, row: number, col: number, player: PlayerType) {
const pieces = host.value.pieces;
const part = Object.values(pieces).find(p => p.player === player && p.pieceType === 'kitten' && p.position[0] === row && p.position[1] === col);
if (!part) return;
const board = getBoardRegion(host);
const playerData = getPlayer(host, player);
host.produce(state => {
// Remove from board
delete state.pieces[part.id];
state.board.childIds = state.board.childIds.filter(id => id !== part.id);
});
// Return kitten to supply
playerData.kittenPool.return(part);
// Increment graduated count (available cats to place)
playerData.graduatedCount++;
}
export function removePieceFromBoard(host: MutableSignal<BoopStateType>, part: BoopPart) {
const board = getBoardRegion(host);
const playerData = getPlayer(host, part.player);
const pool = part.pieceType === 'kitten' ? playerData.kittenPool : playerData.catPool;
host.produce(state => {
delete state.pieces[part.id];
state.board.childIds = state.board.childIds.filter(id => id !== part.id);
});
pool.return(part);
}
const DIRECTIONS: [number, number][] = [
[0, 1],
[1, 0],
[1, 1],
[1, -1],
];
export function* linesThrough(r: number, c: number): Generator<number[][]> {
for (const [dr, dc] of DIRECTIONS) {
const minStart = -(WIN_LENGTH - 1);
for (let offset = minStart; offset <= 0; offset++) {
const startR = r + offset * dr;
const startC = c + offset * dc;
const endR = startR + (WIN_LENGTH - 1) * dr;
const endC = startC + (WIN_LENGTH - 1) * dc;
if (startR < 0 || startR >= BOARD_SIZE || startC < 0 || startC >= BOARD_SIZE) continue;
if (endR < 0 || endR >= BOARD_SIZE || endC < 0 || endC >= BOARD_SIZE) continue;
const line: number[][] = [];
for (let i = 0; i < WIN_LENGTH; i++) {
line.push([startR + i * dr, startC + i * dc]);
}
yield line;
}
}
}
export function* allLines(): Generator<number[][]> {
const seen = new Set<string>();
for (let r = 0; r < BOARD_SIZE; r++) {
for (let c = 0; c < BOARD_SIZE; c++) {
for (const line of linesThrough(r, c)) {
const key = line.map(p => p.join(',')).join(';');
if (!seen.has(key)) {
seen.add(key);
yield line;
}
}
}
}
}
export function hasWinningLine(positions: number[][]): boolean {
const posSet = new Set(positions.map(p => `${p[0]},${p[1]}`));
for (const line of allLines()) {
if (line.every(([lr, lc]) => posSet.has(`${lr},${lc}`))) return true;
}
return false;
}
export function checkGraduation(host: MutableSignal<BoopStateType>, player: PlayerType): number[][][] {
const pieces = host.value.pieces;
const piecesArray = Object.values(pieces);
const posSet = new Set<string>();
for (const part of piecesArray) {
if (part.player === player && part.pieceType === 'kitten') {
posSet.add(`${part.position[0]},${part.position[1]}`);
}
}
const winningLines: number[][][] = [];
for (const line of allLines()) {
if (line.every(([lr, lc]) => posSet.has(`${lr},${lc}`))) {
winningLines.push(line);
}
}
return winningLines;
}
export function processGraduation(host: MutableSignal<BoopStateType>, player: PlayerType, lines: number[][][]) {
const allPositions = new Set<string>();
for (const line of lines) {
for (const [r, c] of line) {
allPositions.add(`${r},${c}`);
}
}
const pieces = host.value.pieces;
const partsToRemove = Object.values(pieces).filter(
p => p.player === player && p.pieceType === 'kitten' && allPositions.has(`${p.position[0]},${p.position[1]}`)
);
for (const part of partsToRemove) {
graduatePiece(host, part.position[0], part.position[1], player);
}
}
export function countPiecesOnBoard(host: MutableSignal<BoopStateType>, player: PlayerType): number {
const pieces = host.value.pieces;
return Object.values(pieces).filter(p => p.player === player).length;
}
export function checkWinner(host: MutableSignal<BoopStateType>): WinnerType {
const pieces = host.value.pieces;
const piecesArray = Object.values(pieces);
for (const player of ['white', 'black'] as PlayerType[]) {
const positions = piecesArray
.filter(p => p.player === player && p.pieceType === 'cat')
.map(p => p.position);
if (hasWinningLine(positions)) return player;
}
return null;
}
// 命令构建器
export const commands = {
play: (player: PlayerType, row: number, col: number, type?: PieceType) =>
`play ${player} ${row} ${col}${type ? ` ${type}` : ''}`,
turn: (player: PlayerType) => `turn ${player}`,
graduate: (row: number, col: number) => `graduate ${row} ${col}`,
} as const;
// 导出游戏模块
export const gameModule = {
createInitialState,
registry,
commands,
};
+1 -3
View File
@@ -1,13 +1,11 @@
import { h } from 'preact';
import { GameUI } from 'boardgame-phaser';
import { gameModule } from './game/boop';
import './style.css';
import App from "@/ui/App";
import {GameScene} from "@/scenes/GameScene";
const ui = new GameUI({
container: document.getElementById('ui-root')!,
root: <App gameModule={gameModule} gameScene={GameScene}/>,
root: <App/>,
});
ui.mount();
+30 -14
View File
@@ -1,10 +1,9 @@
import Phaser from 'phaser';
import type { BoopState, PlayerType } from '@/game/boop';
import type { ReadonlySignal } from '@preact/signals-core';
import type { BoopState, PlayerType, BoopPart } from '@/game';
const BOARD_SIZE = 6;
const CELL_SIZE = 80;
const BOARD_OFFSET = { x: 80, y: 100 };
const BOARD_OFFSET = { x: 80, y: 80 };
export { BOARD_SIZE, CELL_SIZE, BOARD_OFFSET };
@@ -21,7 +20,7 @@ export class BoardRenderer {
this.turnText = this.scene.add.text(
BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2,
BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE + 30,
BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE + 40,
'',
{
fontSize: '22px',
@@ -32,8 +31,8 @@ export class BoardRenderer {
this.infoText = this.scene.add.text(
BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2,
BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE + 60,
'Click to place kitten. Cats win with 3 in a row!',
BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE + 80,
'点击空白处放置小猫,三个大猫连线获胜!',
{
fontSize: '16px',
fontFamily: 'Arial',
@@ -63,25 +62,37 @@ export class BoardRenderer {
g.strokePath();
this.scene.add.text(BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2, BOARD_OFFSET.y - 50, 'Boop Game', {
this.scene.add.text(BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2, BOARD_OFFSET.y - 40, 'Boop Game', {
fontSize: '32px',
fontFamily: 'Arial',
color: '#1f2937',
}).setOrigin(0.5);
}
private countPieces(state: BoopState, player: PlayerType) {
const pieces = Object.values(state.pieces);
const playerPieces = pieces.filter((p: BoopPart) => p.player === player);
const kittensInSupply = playerPieces.filter((p: BoopPart) => p.type === 'kitten' && p.regionId === player).length;
const catsInSupply = playerPieces.filter((p: BoopPart) => p.type === 'cat' && p.regionId === player).length;
const piecesOnBoard = playerPieces.filter((p: BoopPart) => p.regionId === 'board').length;
return { kittensInSupply, catsInSupply, piecesOnBoard };
}
updateTurnText(player: PlayerType, state: BoopState): void {
const current = player === 'white' ? state.players.white : state.players.black;
const catsAvailable = current.catPool.remaining() + current.graduatedCount;
const { kittensInSupply, catsInSupply } = this.countPieces(state, player);
this.turnText.setText(
`${player.toUpperCase()}'s turn | Kittens: ${current.kittenPool.remaining()} | Cats: ${catsAvailable}`
`${player.toUpperCase()}'s turn | Kittens: ${kittensInSupply} | Cats: ${catsInSupply}`
);
}
setupInput(
state: ReadonlySignal<BoopState>,
onCellClick: (row: number, col: number) => void
getState: () => BoopState,
onCellClick: (row: number, col: number) => void,
onPieceClick: (row: number, col: number) => void,
checkWinner: () => boolean
): void {
for (let row = 0; row < BOARD_SIZE; row++) {
for (let col = 0; col < BOARD_SIZE; col++) {
@@ -91,8 +102,13 @@ export class BoardRenderer {
const zone = this.scene.add.zone(x, y, CELL_SIZE, CELL_SIZE).setInteractive();
zone.on('pointerdown', () => {
const isOccupied = !!state.value.board.partMap[`${row},${col}`];
if (!isOccupied && !state.value.winner) {
const state = getState();
const isOccupied = !!state.regions.board.partMap[`${row},${col}`];
if (checkWinner()) return;
if (isOccupied) {
onPieceClick(row, col);
} else {
onCellClick(row, col);
}
});
@@ -0,0 +1,105 @@
import Phaser from 'phaser';
import { BOARD_OFFSET, CELL_SIZE, BOARD_SIZE } from './BoardRenderer';
export class ErrorOverlay {
private overlay?: Phaser.GameObjects.Container;
private hideTimeout?: Phaser.Time.TimerEvent;
constructor(private scene: Phaser.Scene) {
// 初始时不显示
}
show(message: string, duration: number = 2000): void {
// 清除之前的定时器
this.hideTimeout?.remove();
// 销毁之前的 overlay
if (this.overlay) {
this.overlay.destroy();
}
this.overlay = this.scene.add.container();
// 半透明背景(可点击关闭)
const bg = this.scene.add.rectangle(
BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2,
BOARD_OFFSET.y + (BOARD_SIZE * CELL_SIZE) / 2,
BOARD_SIZE * CELL_SIZE + 200,
BOARD_SIZE * CELL_SIZE + 200,
0x000000,
0.4,
).setInteractive();
bg.on('pointerdown', () => {
this.hide();
});
this.overlay.add(bg);
// 错误提示框(居中显示)
const centerX = BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2;
const centerY = BOARD_OFFSET.y + (BOARD_SIZE * CELL_SIZE) / 2;
const errorBox = this.scene.add.container(centerX, centerY);
// 错误框背景
const boxBg = this.scene.add.rectangle(0, 0, 450, 100, 0xef4444, 0.95)
.setStrokeStyle(4, 0xdc2626);
// 错误图标
const iconText = this.scene.add.text(-140, 0, '❌', {
fontSize: '36px',
}).setOrigin(0.5);
// 错误文本
const errorText = this.scene.add.text(60, 0, message, {
fontSize: '22px',
fontFamily: 'Arial',
color: '#ffffff',
align: 'center',
wordWrap: { width: 300 },
}).setOrigin(0.5);
errorBox.add([boxBg, iconText, errorText]);
this.overlay.add(errorBox);
// 出现动画
errorBox.setScale(0);
errorBox.setAlpha(0);
this.scene.tweens.add({
targets: errorBox,
scale: 1,
alpha: 1,
duration: 300,
ease: 'Back.easeOut',
});
// 自动隐藏
this.hideTimeout = this.scene.time.delayedCall(duration, () => {
this.hide();
});
}
hide(): void {
this.hideTimeout?.remove();
this.hideTimeout = undefined;
if (this.overlay) {
const overlay = this.overlay;
// 消失动画
this.scene.tweens.add({
targets: overlay,
alpha: 0,
duration: 200,
onComplete: () => {
overlay.destroy();
this.overlay = undefined;
},
});
}
}
destroy(): void {
this.hideTimeout?.remove();
this.overlay?.destroy();
}
}
+31 -18
View File
@@ -1,18 +1,19 @@
import Phaser from 'phaser';
import type { BoopState, PlayerType, PieceType } from '@/game/boop';
import type { BoopState } from '@/game';
import { GameHostScene } from 'boardgame-phaser';
import { commands } from '@/game/boop';
import { prompts } from '@/game';
import { BoardRenderer } from './BoardRenderer';
import { createPieceSpawner } from './PieceSpawner';
import { SupplyUI } from './SupplyUI';
import { PieceTypeSelector } from './PieceTypeSelector';
import { WinnerOverlay } from './WinnerOverlay';
import { ErrorOverlay } from './ErrorOverlay';
export class GameScene extends GameHostScene<BoopState> {
private boardRenderer!: BoardRenderer;
private supplyUI!: SupplyUI;
private pieceTypeSelector!: PieceTypeSelector;
private winnerOverlay!: WinnerOverlay;
private errorOverlay!: ErrorOverlay;
constructor() {
super('GameScene');
@@ -25,18 +26,22 @@ export class GameScene extends GameHostScene<BoopState> {
this.boardRenderer = new BoardRenderer(this);
this.supplyUI = new SupplyUI(this);
this.pieceTypeSelector = new PieceTypeSelector(this);
this.winnerOverlay = new WinnerOverlay(this, () => this.restartGame());
this.winnerOverlay = new WinnerOverlay(this, () => this.gameHost.start());
this.errorOverlay = new ErrorOverlay(this);
// 设置棋子生成器
this.disposables.add(createPieceSpawner(this));
// 设置输入处理
this.boardRenderer.setupInput(this.gameHost.state, (row, col) => {
this.handleCellClick(row, col);
});
// 设置输入处理(空单元格和已有棋子统一处理)
this.boardRenderer.setupInput(
() => this.state,
(row, col) => this.handleCellClick(row, col),
(row, col) => this.handlePieceClick(row, col),
() => this.gameHost.status.value !== 'running' || !!this.state.winner
);
// 监听状态变化
this.watch(() => {
// 监听胜负状态
this.addEffect(() => {
const winner = this.state.winner;
if (winner) {
this.winnerOverlay.show(winner);
@@ -45,27 +50,35 @@ export class GameScene extends GameHostScene<BoopState> {
}
});
this.watch(() => {
this.addEffect(() => {
const currentPlayer = this.state.currentPlayer;
this.boardRenderer.updateTurnText(currentPlayer, this.state);
this.supplyUI.update(this.state);
this.pieceTypeSelector.update(this.state);
});
// 设置棋子类型选择器回调
// 可以在这里添加类型改变时的额外逻辑
// 启动游戏
this.gameHost.start();
}
private handleCellClick(row: number, col: number): void {
const selectedType = this.pieceTypeSelector.getSelectedType();
const cmd = commands.play(this.state.currentPlayer, row, col, selectedType);
const error = this.gameHost.onInput(cmd);
const error = this.gameHost.tryAnswerPrompt(prompts.play, this.state.currentPlayer, row, col, selectedType);
if (error) {
console.warn('Invalid move:', error);
this.errorOverlay.show(error);
}
}
private restartGame(): void {
this.gameHost.setup('setup');
private handlePieceClick(row: number, col: number): void {
// 棋盘满时,点击棋子触发升级
const error = this.gameHost.tryAnswerPrompt(prompts.choose, this.state.currentPlayer, row, col);
if (error) {
this.errorOverlay.show(error);
}
}
/** 跳转到菜单场景 */
private async goToMenu(): Promise<void> {
await this.sceneController.launch('MenuScene');
}
}
+168
View File
@@ -0,0 +1,168 @@
import { ReactiveScene } from 'boardgame-phaser';
import Phaser from 'phaser';
/** 菜单场景配置 */
const MENU_CONFIG = {
colors: {
title: '#fbbf24',
buttonText: '#1f2937',
buttonBg: 0xfbbf24,
buttonBgHover: 0xf59e0b,
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 {
private titleText!: Phaser.GameObjects.Text;
private startButtonContainer!: Phaser.GameObjects.Container;
private startButtonBg!: Phaser.GameObjects.Rectangle;
private startButtonText!: Phaser.GameObjects.Text;
constructor() {
super('MenuScene');
}
create(): void {
super.create();
const center = this.getCenterPosition();
this.createTitle(center);
this.createStartButton(center);
this.createSubtitle(center);
}
/** 获取屏幕中心位置 */
private getCenterPosition(): { x: number; y: number } {
return {
x: this.game.scale.width / 2,
y: this.game.scale.height / 2,
};
}
/** 创建标题文本 */
private createTitle(center: { x: number; y: number }): void {
this.titleText = this.add.text(
center.x,
center.y + MENU_CONFIG.positions.titleY,
'🐱 BOOP 🐾',
{
fontSize: MENU_CONFIG.fontSize.title,
fontFamily: 'Arial',
color: MENU_CONFIG.colors.title,
fontStyle: 'bold',
}
).setOrigin(0.5);
// 标题入场动画
this.titleText.setScale(0);
this.tweens.add({
targets: this.titleText,
scale: 1,
duration: 600,
ease: 'Back.easeOut',
});
// 标题呼吸动画
this.tweens.add({
targets: this.titleText,
scale: 1.05,
duration: 1000,
yoyo: true,
repeat: -1,
ease: 'Sine.easeInOut',
});
}
/** 创建开始按钮 */
private createStartButton(center: { x: number; y: number }): void {
const { button, colors } = MENU_CONFIG;
this.startButtonBg = this.add.rectangle(
0,
0,
button.width,
button.height,
colors.buttonBg
).setOrigin(0.5).setInteractive({ useHandCursor: true });
this.startButtonText = this.add.text(
0,
0,
'Start Game',
{
fontSize: MENU_CONFIG.fontSize.button,
fontFamily: 'Arial',
color: colors.buttonText,
fontStyle: 'bold',
}
).setOrigin(0.5);
this.startButtonContainer = this.add.container(
center.x,
center.y + MENU_CONFIG.positions.buttonY,
[this.startButtonBg, this.startButtonText]
);
// 按钮交互
this.setupButtonInteraction();
}
/** 设置按钮交互效果 */
private setupButtonInteraction(): void {
this.startButtonBg.on('pointerover', () => {
this.startButtonBg.setFillStyle(MENU_CONFIG.colors.buttonBgHover);
this.tweens.add({
targets: this.startButtonContainer,
scale: 1.05,
duration: 100,
});
});
this.startButtonBg.on('pointerout', () => {
this.startButtonBg.setFillStyle(MENU_CONFIG.colors.buttonBg);
this.tweens.add({
targets: this.startButtonContainer,
scale: 1,
duration: 100,
});
});
this.startButtonBg.on('pointerdown', () => {
this.startGame();
});
}
/** 创建副标题 */
private createSubtitle(center: { x: number; y: number }): void {
this.add.text(
center.x,
center.y + MENU_CONFIG.positions.subtitleY,
'Click to start playing',
{
fontSize: MENU_CONFIG.fontSize.subtitle,
fontFamily: 'Arial',
color: MENU_CONFIG.colors.subtitle,
}
).setOrigin(0.5);
}
/** 开始游戏 */
private async startGame(): Promise<void> {
await this.sceneController.launch('GameScene');
}
}
@@ -1,5 +1,5 @@
import Phaser from 'phaser';
import type { BoopState, BoopPart } from '@/game/boop';
import type { BoopState, BoopPart } from '@/game';
import { GameHostScene, spawnEffect, type Spawner } from 'boardgame-phaser';
import { BOARD_OFFSET, CELL_SIZE } from './BoardRenderer';
@@ -8,7 +8,8 @@ class BoopPartSpawner implements Spawner<BoopPart, Phaser.GameObjects.Container>
*getData() {
for (const part of Object.values(this.scene.state.pieces)) {
yield part;
if(part.regionId === 'board')
yield part;
}
}
@@ -21,13 +22,14 @@ class BoopPartSpawner implements Spawner<BoopPart, Phaser.GameObjects.Container>
const x = BOARD_OFFSET.x + col * CELL_SIZE + CELL_SIZE / 2;
const y = BOARD_OFFSET.y + row * CELL_SIZE + CELL_SIZE / 2;
this.scene.tweens.add({
const tween = this.scene.tweens.add({
targets: obj,
x: x,
y: y,
duration: 200,
ease: 'Power2',
});
this.scene.addTweenInterruption(tween);
}
onSpawn(part: BoopPart) {
@@ -37,7 +39,7 @@ class BoopPartSpawner implements Spawner<BoopPart, Phaser.GameObjects.Container>
const container = this.scene.add.container(x, y);
const isCat = part.pieceType === 'cat';
const isCat = part.type === 'cat';
const baseColor = part.player === 'white' ? 0xffffff : 0x333333;
const strokeColor = part.player === 'white' ? 0x000000 : 0xffffff;
@@ -1,5 +1,5 @@
import Phaser from 'phaser';
import type { BoopState, PlayerType, PieceType } from '@/game/boop';
import type { BoopState, PlayerType, PieceType, BoopPart } from '@/game';
import { BOARD_OFFSET, CELL_SIZE, BOARD_SIZE } from './BoardRenderer';
export class PieceTypeSelector {
@@ -11,18 +11,18 @@ export class PieceTypeSelector {
constructor(private scene: Phaser.Scene) {
const boardCenterX = BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2;
const selectorY = BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE + 100;
const selectorY = BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE + 120;
this.container = this.scene.add.container(boardCenterX, selectorY);
const label = this.scene.add.text(-160, 0, '放置:', {
fontSize: '18px',
fontSize: '16px',
fontFamily: 'Arial',
color: '#4b5563',
}).setOrigin(0.5, 0.5);
this.kittenButton = this.createPieceButton('kitten', '🐾 小猫', -50);
this.catButton = this.createPieceButton('cat', '🐱 大猫', 70);
this.kittenButton = this.createPieceButton('kitten', '🐾 小猫', -40);
this.catButton = this.createPieceButton('cat', '🐱 大猫', 80);
this.container.add([label, this.kittenButton, this.catButton]);
}
@@ -30,7 +30,7 @@ export class PieceTypeSelector {
private createPieceButton(type: PieceType, text: string, xOffset: number): Phaser.GameObjects.Container {
const container = this.scene.add.container(xOffset, 0);
const bg = this.scene.add.rectangle(0, 0, 100, 40, 0xe5e7eb)
const bg = this.scene.add.rectangle(0, 0, 120, 40, 0xe5e7eb)
.setStrokeStyle(2, 0x9ca3af);
const textObj = this.scene.add.text(0, 0, text, {
@@ -44,6 +44,8 @@ export class PieceTypeSelector {
bg.setInteractive({ useHandCursor: true });
bg.on('pointerdown', () => {
this.selectedType = type;
// 立即更新按钮高亮状态
this.updateButtonsVisual();
this.typeChangeCallback?.(this.selectedType);
});
@@ -54,27 +56,55 @@ export class PieceTypeSelector {
this.typeChangeCallback = callback;
}
/**
* 立即更新按钮的视觉高亮状态(不依赖游戏状态变化)
*/
private updateButtonsVisual(): void {
const kittenBg = this.kittenButton.list[0] as Phaser.GameObjects.Rectangle;
const kittenText = this.kittenButton.list[1] as Phaser.GameObjects.Text;
const catBg = this.catButton.list[0] as Phaser.GameObjects.Rectangle;
const catText = this.catButton.list[1] as Phaser.GameObjects.Text;
// 更新小猫按钮
kittenBg.setFillStyle(this.selectedType === 'kitten' ? 0xfbbf24 : 0xe5e7eb);
// 更新大猫按钮
catBg.setFillStyle(this.selectedType === 'cat' ? 0xfbbf24 : 0xe5e7eb);
}
getSelectedType(): PieceType {
return this.selectedType;
}
private countPieces(state: BoopState, player: PlayerType) {
const pieces = Object.values(state.pieces);
const playerPieces = pieces.filter((p: BoopPart) => p.player === player);
const kittensInSupply = playerPieces.filter((p: BoopPart) => p.type === 'kitten' && p.regionId === player).length;
const catsInSupply = playerPieces.filter((p: BoopPart) => p.type === 'cat' && p.regionId === player).length;
const catsOnBoard = playerPieces.filter((p: BoopPart) => p.type === 'cat' && p.regionId === 'board').length;
return { kittensInSupply, catsInSupply, catsOnBoard };
}
update(state: BoopState): void {
const currentPlayer = state.players[state.currentPlayer];
const kittenAvailable = currentPlayer.kittenPool.remaining() > 0;
const catsAvailable = currentPlayer.catPool.remaining() + currentPlayer.graduatedCount > 0;
const currentPlayer = state.currentPlayer;
const { kittensInSupply, catsInSupply } = this.countPieces(state, currentPlayer);
const kittenAvailable = kittensInSupply > 0;
const catsAvailable = catsInSupply > 0;
this.updateButton(
this.kittenButton,
kittenAvailable,
this.selectedType === 'kitten',
`🐾 小猫 (${currentPlayer.kittenPool.remaining()})`
`🐾 小猫 (${kittensInSupply})`
);
this.updateButton(
this.catButton,
catsAvailable,
this.selectedType === 'cat',
`🐱 大猫 (${currentPlayer.catPool.remaining() + currentPlayer.graduatedCount})`
`🐱 大猫 (${catsInSupply})`
);
// 自动切换到可用类型
@@ -0,0 +1,126 @@
import Phaser from 'phaser';
import { BOARD_OFFSET, CELL_SIZE, BOARD_SIZE } from './BoardRenderer';
export class StartOverlay {
private overlay?: Phaser.GameObjects.Container;
private onStartCallback?: () => void;
constructor(
private scene: Phaser.Scene,
onStart: () => void
) {
this.onStartCallback = onStart;
this.show();
}
show(): void {
if (this.overlay) {
this.overlay.destroy();
}
this.overlay = this.scene.add.container();
// 背景遮罩
const bg = this.scene.add.rectangle(
BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2,
BOARD_OFFSET.y + (BOARD_SIZE * CELL_SIZE) / 2,
BOARD_SIZE * CELL_SIZE + 200,
BOARD_SIZE * CELL_SIZE + 200,
0x000000,
0.7,
);
this.overlay.add(bg);
// 游戏标题
const titleText = this.scene.add.text(
BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2,
BOARD_OFFSET.y + (BOARD_SIZE * CELL_SIZE) / 2 - 80,
'🐱 BOOP 🐾',
{
fontSize: '60px',
fontFamily: 'Arial',
color: '#fbbf24',
fontStyle: 'bold',
},
).setOrigin(0.5);
this.overlay.add(titleText);
// 游戏说明
const rulesText = this.scene.add.text(
BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2,
BOARD_OFFSET.y + (BOARD_SIZE * CELL_SIZE) / 2 - 10,
'将小猫或大猫放在棋盘上\n推动周围的小猫\n先连成3个猫的玩家获胜!',
{
fontSize: '20px',
fontFamily: 'Arial',
color: '#e5e7eb',
align: 'center',
lineSpacing: 10,
},
).setOrigin(0.5);
this.overlay.add(rulesText);
// 开始按钮
const buttonY = BOARD_OFFSET.y + (BOARD_SIZE * CELL_SIZE) / 2 + 80;
const startButton = this.scene.add.container(
BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2,
buttonY
);
const buttonBg = this.scene.add.rectangle(0, 0, 200, 60, 0xfbbf24)
.setStrokeStyle(3, 0xf59e0b)
.setInteractive({ useHandCursor: true });
const buttonText = this.scene.add.text(0, 0, '开始游戏', {
fontSize: '24px',
fontFamily: 'Arial',
color: '#1f2937',
fontStyle: 'bold',
}).setOrigin(0.5);
startButton.add([buttonBg, buttonText]);
this.overlay.add(startButton);
// 按钮交互效果
buttonBg.on('pointerover', () => {
buttonBg.setFillStyle(0xfcd34d);
});
buttonBg.on('pointerout', () => {
buttonBg.setFillStyle(0xfbbf24);
});
buttonBg.on('pointerdown', () => {
buttonBg.setFillStyle(0xf59e0b);
});
buttonBg.on('pointerup', () => {
buttonBg.setFillStyle(0xfcd34d);
this.onStartCallback?.();
});
// 标题呼吸动画
this.scene.tweens.add({
targets: titleText,
scale: 1.1,
duration: 1000,
yoyo: true,
repeat: -1,
ease: 'Sine.easeInOut',
});
}
hide(): void {
if (this.overlay) {
this.overlay.destroy();
this.overlay = undefined;
}
}
destroy(): void {
this.hide();
}
}
+26 -15
View File
@@ -1,5 +1,5 @@
import Phaser from 'phaser';
import type { BoopState, PlayerType } from '@/game/boop';
import type { BoopState, PlayerType, BoopPart } from '@/game';
import { BOARD_OFFSET, CELL_SIZE, BOARD_SIZE } from './BoardRenderer';
export class SupplyUI {
@@ -12,13 +12,13 @@ export class SupplyUI {
constructor(private scene: Phaser.Scene) {
const boardCenterX = BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2;
const uiY = BOARD_OFFSET.y - 20;
const uiY = BOARD_OFFSET.y - 40;
// 白色玩家容器
this.whiteContainer = this.scene.add.container(boardCenterX - 150, uiY);
this.whiteBg = this.scene.add.rectangle(0, 0, 120, 50, 0x000000);
this.whiteContainer = this.scene.add.container(boardCenterX - 160, uiY);
this.whiteBg = this.scene.add.rectangle(0, 0, 120, 40, 0x000000);
this.whiteText = this.scene.add.text(0, 0, '', {
fontSize: '16px',
fontSize: '12px',
fontFamily: 'Arial',
color: '#ffffff',
align: 'center',
@@ -27,10 +27,10 @@ export class SupplyUI {
this.whiteContainer.setDepth(100);
// 黑色玩家容器
this.blackContainer = this.scene.add.container(boardCenterX + 150, uiY);
this.blackBg = this.scene.add.rectangle(0, 0, 120, 50, 0x333333);
this.blackContainer = this.scene.add.container(boardCenterX + 160, uiY);
this.blackBg = this.scene.add.rectangle(0, 0, 120, 40, 0x333333);
this.blackText = this.scene.add.text(0, 0, '', {
fontSize: '16px',
fontSize: '12px',
fontFamily: 'Arial',
color: '#ffffff',
align: 'center',
@@ -39,19 +39,30 @@ export class SupplyUI {
this.blackContainer.setDepth(100);
}
update(state: BoopState): void {
const white = state.players.white;
const black = state.players.black;
private countPieces(state: BoopState, player: PlayerType) {
const pieces = Object.values(state.pieces);
const playerPieces = pieces.filter((p: BoopPart) => p.player === player);
const whiteCatsAvailable = white.catPool.remaining() + white.graduatedCount;
const blackCatsAvailable = black.catPool.remaining() + black.graduatedCount;
const kittensInSupply = playerPieces.filter((p: BoopPart) => p.type === 'kitten' && p.regionId === player).length;
const catsInSupply = playerPieces.filter((p: BoopPart) => p.type === 'cat' && p.regionId === player).length;
const catsOnBoard = playerPieces.filter((p: BoopPart) => p.type === 'cat' && p.regionId === 'board').length;
return { kittensInSupply, catsInSupply, catsOnBoard };
}
update(state: BoopState): void {
const white = this.countPieces(state, 'white');
const black = this.countPieces(state, 'black');
const whiteCatsAvailable = white.catsInSupply + white.catsOnBoard;
const blackCatsAvailable = black.catsInSupply + black.catsOnBoard;
this.whiteText.setText(
`⚪ WHITE\n🐾 ${white.kittenPool.remaining()} | 🐱 ${whiteCatsAvailable}`
`⚪ WHITE\n🐾 ${white.kittensInSupply} | 🐱 ${whiteCatsAvailable}`
);
this.blackText.setText(
`⚫ BLACK\n🐾 ${black.kittenPool.remaining()} | 🐱 ${blackCatsAvailable}`
`⚫ BLACK\n🐾 ${black.kittensInSupply} | 🐱 ${blackCatsAvailable}`
);
this.updateHighlight(state.currentPlayer);
@@ -1,5 +1,5 @@
import Phaser from 'phaser';
import type { BoopState, WinnerType } from '@/game/boop';
import type { BoopState, WinnerType } from '@/game';
import { BOARD_OFFSET, CELL_SIZE, BOARD_SIZE } from './BoardRenderer';
export class WinnerOverlay {
+1
View File
@@ -1,4 +1,5 @@
export { GameScene } from './GameScene';
export { MenuScene } from './MenuScene';
export { BoardRenderer, BOARD_SIZE, CELL_SIZE, BOARD_OFFSET } from './BoardRenderer';
export { createPieceSpawner } from './PieceSpawner';
export { SupplyUI } from './SupplyUI';
+15 -28
View File
@@ -1,38 +1,25 @@
import {useComputed} from '@preact/signals';
import { createGameHost, type GameModule } from 'boardgame-core';
import Phaser from 'phaser';
import { h } from 'preact';
import { PhaserGame, PhaserScene } from 'boardgame-phaser';
import {PhaserGame, PhaserScene } from 'boardgame-phaser';
import {MenuScene} from "@/scenes/MenuScene";
import {useMemo} from "preact/hooks";
import * as gameModule from '../game';
import {GameScene} from "@/scenes/GameScene";
import {createGameHost, type GameModule} from "boardgame-core";
import type {BoopState} from "@/game";
export default function App<TState extends Record<string, unknown>>(props: { gameModule: GameModule<TState>, gameScene: { new(): Phaser.Scene } }) {
const gameHost = useComputed(() => {
const gameHost = createGameHost(props.gameModule);
return { gameHost };
});
const scene = useComputed(() => new props.gameScene());
const handleReset = async () => {
gameHost.value.gameHost.setup('setup');
};
const label = useComputed(() => gameHost.value.gameHost.status.value === 'running' ? 'Restart' : 'Start');
export default function App() {
const gameHost = useMemo(() => createGameHost(gameModule as unknown as GameModule<BoopState>), []);
const gameScene = useMemo(() => new GameScene(), []);
const menuScene = useMemo(() => new MenuScene(), []);
return (
<div className="flex flex-col h-screen">
<div className="flex-1 relative">
<PhaserGame config={{ width: 640, height: 750 }}>
<PhaserScene sceneKey="GameScene" scene={scene.value} autoStart data={gameHost.value} />
<div className="flex-1 flex relative justify-center items-center">
<PhaserGame initialScene="MenuScene" config={{ width: 640, height: 720 }}>
<PhaserScene sceneKey="MenuScene" scene={menuScene} />
<PhaserScene sceneKey="GameScene" scene={gameScene} data={{gameHost}}/>
</PhaserGame>
</div>
<div className="p-4 bg-gray-100 border-t border-gray-200">
<button
onClick={handleReset}
className="px-6 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors font-medium"
>
{label}
</button>
</div>
</div>
);
}
+3 -1
View File
@@ -2,12 +2,14 @@ import { defineConfig } from 'vite';
import preact from '@preact/preset-vite';
import tailwindcss from '@tailwindcss/vite';
import path from 'path';
import {csvLoader} from 'inline-schema/csv-loader/rollup';
export default defineConfig({
plugins: [preact(), tailwindcss()],
plugins: [preact(), tailwindcss(), csvLoader()],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
'boardgame-phaser': path.resolve(__dirname, '../framework/src/index.ts'),
},
},
});
-147
View File
@@ -1,147 +0,0 @@
import Phaser from 'phaser';
import { effect, type Signal } from '@preact/signals-core';
import type { MutableSignal, Region, Part } from 'boardgame-core';
type DisposeFn = () => void;
export function bindSignal<T, K extends keyof T>(
signal: MutableSignal<T>,
getter: (state: T) => T[K],
setter: (value: T[K]) => void,
): DisposeFn {
return effect(() => {
const val = getter(signal.value);
setter(val);
});
}
export function bindGameObjectProperty<T>(
signal: Signal<T>,
target: Phaser.GameObjects.GameObject,
prop: string,
): DisposeFn {
return effect(() => {
(target as unknown as Record<string, unknown>)[prop] = signal.value;
});
}
export interface BindRegionOptions<TPart extends Part> {
cellSize: { x: number; y: number };
offset?: { x: number; y: number };
factory: (part: TPart, position: Phaser.Math.Vector2) => Phaser.GameObjects.GameObject;
update?: (part: TPart, obj: Phaser.GameObjects.GameObject) => void;
}
export function bindRegion<TState, TMeta>(
state: MutableSignal<TState>,
partsGetter: (state: TState) => Record<string, Part<TMeta>>,
regionGetter: (state: TState) => Region,
options: BindRegionOptions<Part<TMeta>>,
container: Phaser.GameObjects.Container,
): { cleanup: () => void; objects: Map<string, Phaser.GameObjects.GameObject> } {
const objects = new Map<string, Phaser.GameObjects.GameObject>();
const offset = options.offset ?? { x: 0, y: 0 };
const dispose = effect(function(this: { dispose: () => void }) {
const currentState = state.value;
const parts = partsGetter(currentState);
const region = regionGetter(currentState);
const currentIds = new Set(region.childIds);
// 移除不在 region 中的对象
for (const [id, obj] of objects) {
if (!currentIds.has(id)) {
obj.destroy();
objects.delete(id);
}
}
// 同步 region 中的 parts
for (const childId of region.childIds) {
const part = parts[childId];
if (!part) continue;
// 支持动态维度:取前两个维度作为 x, y
// position[0] = row (y轴), position[1] = col (x轴)
const pos = new Phaser.Math.Vector2(
(part.position[1] ?? 0) * options.cellSize.x + offset.x,
(part.position[0] ?? 0) * options.cellSize.y + offset.y,
);
let obj = objects.get(childId);
if (!obj) {
obj = options.factory(part, pos);
objects.set(childId, obj);
container.add(obj);
} else {
// 更新位置
if ('setPosition' in obj && typeof obj.setPosition === 'function') {
(obj as any).setPosition(pos.x, pos.y);
}
// 调用自定义更新函数
if (options.update) {
options.update(part, obj);
}
}
}
});
return {
cleanup: () => {
dispose();
for (const [, obj] of objects) obj.destroy();
objects.clear();
},
objects,
};
}
export interface BindCollectionOptions<T extends { id: string }> {
factory: (item: T) => Phaser.GameObjects.GameObject;
update?: (item: T, obj: Phaser.GameObjects.GameObject) => void;
}
export function bindCollection<T extends { id: string }>(
collection: Signal<Record<string, MutableSignal<T>>>,
options: BindCollectionOptions<T>,
container: Phaser.GameObjects.Container,
): { cleanup: () => void; objects: Map<string, Phaser.GameObjects.GameObject> } {
const objects = new Map<string, Phaser.GameObjects.GameObject>();
const effects: DisposeFn[] = [];
function syncCollection() {
const entries = Object.entries(collection.value);
const currentIds = new Set(entries.map(([id]) => id));
for (const [id, obj] of objects) {
if (!currentIds.has(id)) {
obj.destroy();
objects.delete(id);
}
}
for (const [id, signal] of entries) {
let obj = objects.get(id);
if (!obj) {
obj = options.factory(signal.value);
objects.set(id, obj);
container.add(obj);
} else if (options.update) {
options.update(signal.value, obj);
}
}
}
const e = effect(syncCollection);
effects.push(e);
return {
cleanup: () => {
for (const e of effects) e();
for (const [, obj] of objects) obj.destroy();
objects.clear();
},
objects,
};
}
+32 -8
View File
@@ -1,15 +1,39 @@
// Resource management
export { DisposableBag } from './utils';
export type { IDisposable, DisposableItem } from './utils';
export { DisposableBag } 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
export { spawnEffect } from './spawner';
export type { Spawner } from './spawner';
export { spawnEffect, createSpawnUpdate } from "./spawner";
export type { Spawner, SpawnerCallback } from "./spawner";
// Scene base classes
export { GameHostScene } from './scenes';
export type { GameHostSceneOptions } from './scenes';
export {
ReactiveScene,
GameHostScene,
FadeScene,
FADE_SCENE_KEY,
} from "./scenes";
export type {
ReactiveSceneOptions,
ReactiveScenePhaserData,
SceneController,
GameHostSceneOptions,
FadeSceneData,
} from "./scenes";
// React ↔ Phaser bridge
export { PhaserGame, PhaserScene, phaserContext, defaultPhaserConfig, GameUI } from './ui';
export type { PhaserGameProps, PhaserSceneProps, GameUIOptions } from './ui';
export {
PhaserGame,
PhaserScene,
phaserContext,
defaultPhaserConfig,
GameUI,
type PhaserGameContext,
type SceneController as PhaserSceneController,
} from "./ui";
export type { PhaserGameProps, PhaserSceneProps, GameUIOptions } from "./ui";
@@ -0,0 +1,83 @@
import { ReactiveScene } from "./ReactiveScene";
export interface FadeSceneData {
[key: string]: unknown;
}
/**
* 处理淡入淡出到黑色的过渡场景
*/
export class FadeScene extends ReactiveScene<FadeSceneData> {
private overlay!: Phaser.GameObjects.Rectangle;
private isFading = false;
constructor() {
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();
}
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 使用
export const FADE_SCENE_KEY = "__fade__";
+34 -41
View File
@@ -1,50 +1,43 @@
import Phaser from 'phaser';
import { effect, type ReadonlySignal } from '@preact/signals-core';
import type { GameHost } from 'boardgame-core';
import { DisposableBag, type IDisposable } from '../utils';
import { ReactiveScene } from "./ReactiveScene";
type CleanupFn = void | (() => void);
import type { GameHost, GameModule } from "boardgame-core";
export interface GameHostSceneOptions<TState extends Record<string, unknown>> {
gameHost: GameHost<TState>;
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>>
extends Phaser.Scene
implements IDisposable
{
protected disposables = new DisposableBag();
protected gameHost!: GameHost<TState>;
addInterruption(promise: Promise<void>){
this.gameHost?.addInterruption(promise);
export abstract class GameHostScene<
TState extends Record<string, unknown>,
TResult,
TModule extends GameModule<TState, TResult>,
> extends ReactiveScene<GameHostSceneOptions<TState, TResult, TModule>> {
public get gameHost(): GameHost<TState, TResult, TModule> {
const gameHost = this.initData.gameHost;
if (!gameHost) {
throw new Error(
`GameHostScene (${this.scene.key}): gameHost 未提供。` +
"确保在 PhaserScene 组件的 data 属性中传入 gameHost。",
);
}
return gameHost;
}
addTweenInterruption(tween: Phaser.Tweens.Tween){
this.addInterruption(new Promise(
resolve => tween.once('complete', resolve)
));
}
public get state(): TState {
return this.gameHost?.state.value;
}
init(data: GameHostSceneOptions<TState>): void {
this.gameHost = data.gameHost;
}
addInterruption(promise: Promise<void>) {
this.gameHost?.addInterruption(promise);
}
create(): void {
this.events.on('shutdown', this.dispose, this);
}
dispose(): void {
this.disposables.dispose();
}
/** 获取当前状态的只读快照 */
public get state(): TState {
return this.gameHost.state.value;
}
/** 注册响应式监听(场景关闭时自动清理) */
protected watch(fn: () => CleanupFn): void {
this.disposables.add(effect(fn));
}
addTweenInterruption(tween: Phaser.Tweens.Tween) {
this.addInterruption(
new Promise((resolve) => tween.once("complete", resolve)),
);
}
}
@@ -0,0 +1,90 @@
import { type ReadonlySignal } from "@preact/signals-core";
import { Scene } from "phaser";
import { DisposableBag, type IDisposable } from "../utils";
import type Phaser from "phaser";
type CleanupFn = void | (() => void);
// 前向声明,避免循环导入
export interface SceneController {
launch(sceneKey: string): Promise<void>;
restart(): Promise<void>;
currentScene: ReadonlySignal<string | null>;
isTransitioning: ReadonlySignal<boolean>;
}
export interface ReactiveScenePhaserData {
phaserGame: ReadonlySignal<{ game: Phaser.Game }>;
sceneController: SceneController;
}
export interface ReactiveSceneOptions {
key?: string;
}
/**
* 通用的响应式 Scene 基类
* @typeparam TData - 通过 init(data) 接收的数据类型(必须包含 phaserGame 和 sceneController)
*/
export abstract class ReactiveScene<TData = object>
extends Scene
implements IDisposable
{
protected disposables = new DisposableBag();
private _initData?: TData & ReactiveScenePhaserData;
/**
* 获取通过 init() 注入的数据
* 在 create() 阶段保证可用
*/
public get initData(): TData & ReactiveScenePhaserData {
if (!this._initData) {
throw new Error(
`ReactiveScene (${this.scene.key}): initData 尚未初始化。` +
"确保场景通过 PhaserScene 组件注册,并在 create() 阶段访问。",
);
}
return this._initData;
}
/**
* 获取 Phaser game 实例的响应式信号
*/
public get phaserGame(): ReadonlySignal<{ game: Phaser.Game }> {
return this.initData.phaserGame;
}
/**
* 获取场景控制器
*/
public get sceneController(): SceneController {
return this.initData.sceneController;
}
constructor(key?: string) {
super(key);
}
init(data: TData & ReactiveScenePhaserData): void {
this._initData = data;
}
create(): void {
this.events.on("shutdown", this.dispose, this);
}
dispose(): void {
this.disposables.dispose();
}
public addDisposable(disposable: IDisposable): void {
this.disposables.add(disposable);
}
/** 注册响应式监听(场景关闭时自动清理) */
public addEffect(fn: () => CleanupFn): void {
this.disposables.addEffect(fn);
}
}
+12 -2
View File
@@ -1,2 +1,12 @@
export { GameHostScene } from './GameHostScene';
export type { GameHostSceneOptions } from './GameHostScene';
export { ReactiveScene } from "./ReactiveScene";
export type {
ReactiveSceneOptions,
ReactiveScenePhaserData,
SceneController,
} from "./ReactiveScene";
export { FadeScene, FADE_SCENE_KEY } from "./FadeScene";
export type { FadeSceneData } from "./FadeScene";
export { GameHostScene } from "./GameHostScene";
export type { GameHostSceneOptions } from "./GameHostScene";
+58 -37
View File
@@ -1,49 +1,70 @@
import Phaser from 'phaser';
import { effect, type ReadonlySignal } from '@preact/signals-core';
import { effect } from "@preact/signals-core";
import type Phaser from "phaser";
type GO = Phaser.GameObjects.GameObject;
export interface Spawner<TData, TObject extends GO = GO> {
/** 数据源迭代器 */
getData(): Iterable<TData>;
/** 获取数据的唯一键 */
getKey(t: TData): string;
/** 创建新对象 */
onSpawn(t: TData): TObject | null;
/** 销毁旧对象 */
onDespawn(obj: TObject): void;
/** 更新已有对象 */
onUpdate(t: TData, obj: TObject): void;
export interface SpawnerCallback<TData, TObject extends GO = GO> {
/** 获取数据的唯一键 */
getKey(t: TData): string;
/** 创建新对象 */
onSpawn(t: TData): TObject | null;
/** 销毁旧对象 */
onDespawn(obj: TObject, t: TData): 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>(
spawner: Spawner<TData, TObject>,
): () => void {
const objects = new Map<string, TObject>();
spawner: Spawner<TData, TObject>,
) {
const update = createSpawnUpdate(spawner);
return effect(() => {
const current = new Set<string>();
return effect(() => {
update(spawner.getData());
});
}
for (const t of spawner.getData()) {
const key = spawner.getKey(t);
current.add(key);
export function createSpawnUpdate<TData, TObject extends GO = GO>(
spawner: SpawnerCallback<TData, TObject>,
) {
const entries = new Map<string, { object: TObject; data: TData }>();
const existing = objects.get(key);
if (!existing) {
const obj = spawner.onSpawn(t);
if (obj) {
objects.set(key, obj);
}
} else {
spawner.onUpdate(t, existing);
}
function update(data: Iterable<TData>) {
const current = new Set<string>();
for (const t of data) {
const key = spawner.getKey(t);
current.add(key);
const existing = entries.get(key);
if (!existing) {
const obj = spawner.onSpawn(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) {
if (!current.has(key)) {
spawner.onDespawn(obj);
objects.delete(key);
}
}
});
for (const [key, entry] of entries) {
if (!current.has(key)) {
spawner.onDespawn(entry.object, entry.data);
entries.delete(key);
}
}
}
return update;
}
+19 -21
View File
@@ -1,42 +1,40 @@
import { h, Fragment } from 'preact';
import { effect } from '@preact/signals-core';
type DisposeFn = () => void;
import type { JSX } from "preact";
export interface GameUIOptions {
container: HTMLElement;
root: any;
container: HTMLElement | string;
root: JSX.Element;
}
export class GameUI {
private container: HTMLElement;
private root: any;
private effects: DisposeFn[] = [];
private root: JSX.Element;
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;
}
mount(): void {
import('preact').then(({ render }) => {
import("preact").then(({ render }) => {
render(this.root, this.container);
});
}
watch(fn: () => void): DisposeFn {
const e = effect(fn);
this.effects.push(e);
return e;
}
unmount(): void {
import('preact').then(({ render }) => {
import("preact").then(({ render }) => {
render(null, this.container);
});
for (const e of this.effects) {
e();
}
this.effects = [];
}
}
+227 -55
View File
@@ -1,75 +1,247 @@
import Phaser from 'phaser';
import { signal, useSignal, useSignalEffect, type Signal } from '@preact/signals';
import { createContext, h } from 'preact';
import { useContext } from 'preact/hooks';
import {ReadonlySignal} from "@preact/signals-core";
import { signal, useSignal, useSignalEffect } from "@preact/signals";
import Phaser, { AUTO, Scene } from "phaser";
import { createContext } from "preact";
import { useContext, useEffect, useRef } from "preact/hooks";
export const phaserContext = createContext<ReadonlySignal<Phaser.Game | undefined>>(signal(undefined));
import {
FadeScene as FadeSceneClass,
FADE_SCENE_KEY,
} from "../scenes/FadeScene";
import type { ReactiveScene } from "../scenes";
import type { ReadonlySignal } from "@preact/signals-core";
export interface SceneController {
/** 启动场景(带淡入淡出过渡) */
launch(sceneKey: string): Promise<void>;
/** 重新启动当前场景(带淡入淡出过渡) */
restart(): Promise<void>;
/** 当前活跃场景 key */
currentScene: ReadonlySignal<string | null>;
/** 是否正在过渡 */
isTransitioning: ReadonlySignal<boolean>;
}
export interface PhaserGameContext {
game: Phaser.Game;
sceneController: SceneController;
}
export const phaserContext =
createContext<ReadonlySignal<PhaserGameContext> | null>(null);
export const defaultPhaserConfig: Phaser.Types.Core.GameConfig = {
type: Phaser.AUTO,
width: 560,
height: 560,
parent: 'phaser-container',
backgroundColor: '#f9fafb',
scene: [],
type: AUTO,
width: 560,
height: 560,
parent: "phaser-container",
backgroundColor: "#f9fafb",
scene: [],
disableContextMenu: true,
};
export interface PhaserGameProps {
config?: Partial<Phaser.Types.Core.GameConfig>;
children?: any;
config?: Partial<Phaser.Types.Core.GameConfig>;
/** 初始启动的场景 key */
initialScene?: string;
children?: preact.ComponentChildren;
}
export function PhaserGame(props: PhaserGameProps) {
const gameSignal = useSignal<Phaser.Game>();
const gameSignal = useSignal<PhaserGameContext>({
game: undefined!,
sceneController: undefined!,
});
const initialSceneLaunched = useRef(false);
useSignalEffect(() => {
const config: Phaser.Types.Core.GameConfig = {
...defaultPhaserConfig,
...props.config,
};
const phaserGame = new Phaser.Game(config);
gameSignal.value = phaserGame;
useSignalEffect(() => {
const config: Phaser.Types.Core.GameConfig = {
...defaultPhaserConfig,
...props.config,
};
const phaserGame = new Phaser.Game(config);
return () => {
gameSignal.value = undefined;
phaserGame.destroy(true);
};
});
// 添加 FadeScene 并启动它来初始化 overlay
const fadeScene = new FadeSceneClass();
phaserGame.scene.add(FADE_SCENE_KEY, fadeScene, true); // 改为 true 以触发 create
return (
<div id="phaser-container" className="w-full h-full">
<phaserContext.Provider value={gameSignal}>
{props.children}
</phaserContext.Provider>
</div>
);
// 创建 SceneController
const currentScene = signal<string | null>(null);
const isTransitioning = signal(false);
const sceneController: SceneController = {
async launch(sceneKey: string) {
if (isTransitioning.value) {
console.warn("SceneController: 正在进行场景切换");
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 {
sceneKey: string;
scene: Phaser.Scene;
autoStart: boolean;
data?: object;
children?: any;
export interface PhaserSceneProps<TData = {}> {
sceneKey?: string;
scene: ReactiveScene<TData> | { new (): ReactiveScene<TData> };
data?: TData;
children?: any;
}
export const phaserSceneContext = createContext<ReadonlySignal<Phaser.Scene | undefined>>(signal(undefined));
export function PhaserScene(props: PhaserSceneProps) {
const context = useContext(phaserContext);
const sceneSignal = useSignal<Phaser.Scene>();
export const phaserSceneContext =
createContext<ReadonlySignal<ReactiveScene> | null>(null);
useSignalEffect(() => {
const game = context.value;
if (!game) return;
export function PhaserScene<TData = {}>(props: PhaserSceneProps<TData>) {
const phaserGameSignal = useContext(phaserContext);
const sceneSignal = useSignal<ReactiveScene<TData>>();
const registered = useRef(false);
game.scene.add(props.sceneKey, props.scene, props.autoStart, props.data);
sceneSignal.value = game.scene.getScene(props.sceneKey);
return () => {
sceneSignal.value = undefined;
game.scene.remove(props.sceneKey);
};
});
useSignalEffect(() => {
if (!phaserGameSignal) return;
const ctx = phaserGameSignal.value;
if (!ctx?.game) return;
return <phaserSceneContext.Provider value={sceneSignal}>{props.children}</phaserSceneContext.Provider>;
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 type { GameUIOptions } from './GameUI';
export { GameUI } from "./GameUI";
export type { GameUIOptions } from "./GameUI";
export { PhaserGame, PhaserScene, phaserContext, defaultPhaserConfig } from './PhaserBridge';
export type { PhaserGameProps, PhaserSceneProps } from './PhaserBridge';
export {
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 {
dispose(): void;
dispose(): void;
}
export type DisposableItem = IDisposable | (() => void);
export class DisposableBag implements IDisposable {
private _disposables = new Set<DisposableItem>();
private _isDisposed = false;
private _disposables = new Set<DisposableItem>();
private _isDisposed = false;
get isDisposed(): boolean {
return this._isDisposed;
constructor(go?: Phaser.GameObjects.GameObject) {
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 {
if (this._isDisposed) {
this._execute(item);
return;
}
this._disposables.add(item);
}
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();
}
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 type { IDisposable, DisposableItem } from './disposable';
export { DisposableBag } from "./disposable";
export type { IDisposable, DisposableItem } from "./disposable";
export {
dragDropEventEffect,
DragDropEventType,
type DragDropEvent,
type DragDropCallback,
} from "./dnd";
export * from "./hover";
+14
View File
@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Onitama - boardgame-phaser</title>
</head>
<body>
<div id="app">
<div id="ui-root"></div>
</div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
{
"name": "onitama-game",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@preact/signals-core": "^1.5.1",
"boardgame-core": "link:../../../boardgame-core",
"boardgame-phaser": "workspace:*",
"mutative": "^1.3.0",
"phaser": "^3.80.1",
"preact": "^10.19.3"
},
"devDependencies": {
"@preact/preset-vite": "^2.8.1",
"@preact/signals": "^2.9.0",
"@tailwindcss/vite": "^4.0.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.3.3",
"vite": "^5.1.0"
}
}
+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,
});
}
+25
View File
@@ -0,0 +1,25 @@
/**
* Re-export onitama game module from boardgame-core
* This provides a convenient import path within onitama-game
*/
export {
registry,
prompts,
start,
createInitialState,
initializeCards,
getAvailableMoves,
isValidMove,
getCardMoveCandidates,
createRegions,
createCards,
createPawns,
createGameInfo,
type OnitamaState,
type OnitamaGame,
type PlayerType,
type Card,
type CardData,
type Pawn,
type RegionType,
} from "boardgame-core/samples/onitama";
+11
View File
@@ -0,0 +1,11 @@
import { GameUI } from "boardgame-phaser";
import "./style.css";
import App from "@/ui/App";
const ui = new GameUI({
container: "ui-root",
root: <App />,
});
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";
@@ -0,0 +1,125 @@
import { ReactiveScene } from "boardgame-phaser";
import { MENU_CONFIG, ANIMATIONS } from "@/config";
export class MenuScene extends ReactiveScene {
private titleText!: Phaser.GameObjects.Text;
private startButtonContainer!: Phaser.GameObjects.Container;
private startButtonBg!: Phaser.GameObjects.Rectangle;
private startButtonText!: Phaser.GameObjects.Text;
constructor() {
super("MenuScene");
}
create(): void {
super.create();
const center = this.getCenterPosition();
this.createTitle(center);
this.createStartButton(center);
this.createSubtitle(center);
}
/** 获取屏幕中心位置 */
private getCenterPosition(): { x: number; y: number } {
return {
x: this.game.scale.width / 2,
y: this.game.scale.height / 2,
};
}
/** 创建标题文本 */
private createTitle(center: { x: number; y: number }): void {
this.titleText = this.add
.text(center.x, center.y + MENU_CONFIG.positions.titleY, "Onitama", {
fontSize: MENU_CONFIG.fontSize.title,
fontFamily: "Arial",
color: MENU_CONFIG.colors.title,
})
.setOrigin(0.5);
// 标题入场动画
this.titleText.setScale(0);
this.tweens.add({
targets: this.titleText,
scale: 1,
duration: ANIMATIONS.menuTitle,
ease: "Back.easeOut",
});
}
/** 创建开始按钮 */
private createStartButton(center: { x: number; y: number }): void {
const { button, colors } = MENU_CONFIG;
this.startButtonBg = this.add
.rectangle(0, 0, button.width, button.height, colors.buttonBg)
.setOrigin(0.5)
.setInteractive({ useHandCursor: true });
this.startButtonText = this.add
.text(0, 0, "Start Game", {
fontSize: MENU_CONFIG.fontSize.button,
fontFamily: "Arial",
color: colors.buttonText,
})
.setOrigin(0.5);
this.startButtonContainer = this.add.container(
center.x,
center.y + MENU_CONFIG.positions.buttonY,
[this.startButtonBg, this.startButtonText],
);
// 按钮交互
this.setupButtonInteraction();
}
/** 设置按钮交互效果 */
private setupButtonInteraction(): void {
this.startButtonBg.on("pointerover", () => {
this.startButtonBg.setFillStyle(MENU_CONFIG.colors.buttonBgHover);
this.tweens.add({
targets: this.startButtonContainer,
scale: 1.05,
duration: ANIMATIONS.buttonHover,
});
});
this.startButtonBg.on("pointerout", () => {
this.startButtonBg.setFillStyle(MENU_CONFIG.colors.buttonBg);
this.tweens.add({
targets: this.startButtonContainer,
scale: 1,
duration: ANIMATIONS.buttonHover,
});
});
this.startButtonBg.on("pointerdown", () => {
this.startGame();
});
}
/** 创建副标题 */
private createSubtitle(center: { x: number; y: number }): void {
this.add
.text(
center.x,
center.y + MENU_CONFIG.positions.subtitleY,
"Click to start playing",
{
fontSize: MENU_CONFIG.fontSize.subtitle,
fontFamily: "Arial",
color: MENU_CONFIG.colors.subtitle,
},
)
.setOrigin(0.5);
}
/** 开始游戏 */
private async startGame(): Promise<void> {
await this.sceneController.launch("OnitamaScene");
}
}
@@ -0,0 +1,321 @@
import { GameHostScene, spawnEffect } from "boardgame-phaser";
import type { OnitamaState, Pawn } from "@/game/onitama";
import type { HighlightData } from "@/spawners/HighlightSpawner";
import type { OnitamaUIState } from "@/state";
import type { MutableSignal } from "boardgame-core";
import type Phaser from "phaser";
import {
COLORS,
FONTS,
MENU_BUTTON,
TEXT_POSITION,
VISUAL,
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> {
private boardContainer!: Phaser.GameObjects.Container;
private gridGraphics!: Phaser.GameObjects.Graphics;
private infoText!: Phaser.GameObjects.Text;
private winnerOverlay?: Phaser.GameObjects.Container;
private cardLabelContainers: Map<string, Phaser.GameObjects.Text> = new Map();
private menuButtonContainer!: Phaser.GameObjects.Container;
private menuButtonBg!: Phaser.GameObjects.Rectangle;
private menuButtonText!: Phaser.GameObjects.Text;
// UI State managed by MutableSignal
public uiState!: MutableSignal<OnitamaUIState>;
constructor() {
super("OnitamaScene");
}
create(): void {
super.create();
// Create UI state signal
this.uiState = createUIState();
this.boardContainer = this.add.container(0, 0);
this.gridGraphics = this.add.graphics();
this.drawBoard();
// Add spawners
this.disposables.add(spawnEffect(new PawnSpawner(this)));
this.disposables.add(spawnEffect(new CardSpawner(this)));
this.disposables.add(spawnEffect(new HighlightSpawner(this)));
// Create card labels
this.createCardLabels();
// Winner overlay effect
this.addEffect(() => {
const winner = this.state.winner;
if (winner) {
this.showWinner(winner);
} else if (this.winnerOverlay) {
this.winnerOverlay.destroy();
this.winnerOverlay = undefined;
}
});
// Info text
this.infoText = this.add.text(
TEXT_POSITION.infoX,
BOARD_OFFSET.y,
"",
FONTS.info,
);
// Update info text when UI state changes
this.addEffect(() => {
this.updateInfoText();
});
// Input handling
this.setupInput();
// Menu button
this.createMenuButton();
// Start the game
this.gameHost.start();
}
private createCardLabels(): void {
// Red cards label - 棋盘下方
const redPos = getCardLabelPosition("red");
const redLabel = this.add
.text(redPos.x, redPos.y, "RED", {
...FONTS.cardLabel,
color: colorToStr(COLORS.red),
})
.setOrigin(redPos.originX, redPos.originY);
this.cardLabelContainers.set("red", redLabel);
// Black cards label - 棋盘上方
const blackPos = getCardLabelPosition("black");
const blackLabel = this.add
.text(blackPos.x, blackPos.y, "BLACK", {
...FONTS.cardLabel,
color: colorToStr(COLORS.black),
})
.setOrigin(blackPos.originX, blackPos.originY);
this.cardLabelContainers.set("black", blackLabel);
}
private updateInfoText(): void {
const currentPlayer = this.state.currentPlayer;
if (this.state.winner) {
this.infoText.setText(`${this.state.winner} wins!`);
} else {
this.infoText.setText(`Turn ${this.state.turn + 1}\n\n${currentPlayer}`);
}
}
private drawBoard(): void {
const g = this.gridGraphics;
g.lineStyle(2, COLORS.gridLine);
for (let i = 0; i <= BOARD_SIZE; i++) {
g.lineBetween(
BOARD_OFFSET.x + i * CELL_SIZE,
BOARD_OFFSET.y,
BOARD_OFFSET.x + i * CELL_SIZE,
BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE,
);
g.lineBetween(
BOARD_OFFSET.x,
BOARD_OFFSET.y + i * CELL_SIZE,
BOARD_OFFSET.x + BOARD_SIZE * CELL_SIZE,
BOARD_OFFSET.y + i * CELL_SIZE,
);
}
g.strokePath();
this.add.text(
TEXT_POSITION.titleX,
TEXT_POSITION.titleY,
"Onitama",
FONTS.title,
);
}
private setupInput(): void {
// Board cell clicks
for (let row = 0; row < BOARD_SIZE; row++) {
for (let col = 0; col < BOARD_SIZE; col++) {
const pos = boardToScreen(col, row);
const zone = this.add
.zone(pos.x, pos.y, CELL_SIZE, CELL_SIZE)
.setInteractive();
zone.on("pointerdown", () => {
if (this.state.winner) return;
this.handleCellClick(col, row);
});
}
}
}
private handleCellClick(x: number, y: number): void {
const pawn = this.getPawnAtPosition(x, y);
if (pawn?.owner !== this.state.currentPlayer) {
return;
}
selectPiece(this.uiState, x, y);
}
public onCardClick(cardId: string): void {
// 只能选择当前玩家的手牌
const currentPlayer = this.state.currentPlayer;
const playerCards =
currentPlayer === "red" ? this.state.redCards : this.state.blackCards;
if (!playerCards.includes(cardId)) {
return;
}
selectCard(this.uiState, cardId);
}
public onHighlightClick(data: HighlightData): void {
clearSelection(this.uiState);
this.executeMove({
card: data.card,
fromX: data.fromX,
fromY: data.fromY,
toX: data.toX,
toY: data.toY,
});
}
private executeMove(move: {
card: string;
fromX: number;
fromY: number;
toX: number;
toY: number;
}): void {
const error = this.gameHost.tryAnswerPrompt(
prompts.move,
this.state.currentPlayer,
move.card,
move.fromX,
move.fromY,
move.toX,
move.toY,
);
if (error) {
console.warn("Invalid move:", error);
}
}
private getPawnAtPosition(x: number, y: number): Pawn | null {
const key = `${x},${y}`;
const pawnId = this.state.regions.board.partMap[key];
return pawnId ? this.state.pawns[pawnId] : null;
}
/** 创建菜单按钮 */
private createMenuButton(): void {
this.menuButtonBg = this.add
.rectangle(
MENU_BUTTON.x,
MENU_BUTTON.y,
MENU_BUTTON.width,
MENU_BUTTON.height,
COLORS.menuButton,
)
.setInteractive({ useHandCursor: true });
this.menuButtonText = this.add
.text(MENU_BUTTON.x, MENU_BUTTON.y, "Menu", FONTS.menuButton)
.setOrigin(0.5);
this.menuButtonContainer = this.add.container(
MENU_BUTTON.x,
MENU_BUTTON.y,
[this.menuButtonBg, this.menuButtonText],
);
this.menuButtonBg.on("pointerover", () => {
this.menuButtonBg.setFillStyle(COLORS.menuButtonHover);
});
this.menuButtonBg.on("pointerout", () => {
this.menuButtonBg.setFillStyle(COLORS.menuButton);
});
this.menuButtonBg.on("pointerdown", () => {
this.goToMenu();
});
}
/** 跳转到菜单场景 */
private async goToMenu(): Promise<void> {
await this.sceneController.launch("MenuScene");
}
private showWinner(winner: string): void {
if (this.winnerOverlay) {
this.winnerOverlay.destroy();
}
this.winnerOverlay = this.add.container();
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(
center.x,
center.y,
boardWidth,
boardHeight,
COLORS.overlayBg,
VISUAL.overlayAlpha,
)
.setInteractive({ useHandCursor: true });
bg.on("pointerdown", () => {
this.gameHost.start();
});
this.winnerOverlay.add(bg);
const winText = this.add
.text(center.x, center.y, text, FONTS.winner)
.setOrigin(0.5);
this.winnerOverlay.add(winText);
createWinnerPulseTween(this, winText);
}
}
@@ -0,0 +1,2 @@
export { OnitamaScene } from "./OnitamaScene";
export { MenuScene } from "./MenuScene";
@@ -0,0 +1,304 @@
import { effect } from "@preact/signals-core";
import type { Card } from "@/game/onitama";
import type { OnitamaScene } from "@/scenes/OnitamaScene";
import type { Spawner } from "boardgame-phaser";
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 {
cardId: string;
position: "red" | "black" | "spare";
index: number;
}
/**
* 继承自 Phaser.GameObjects.Container 的卡牌容器类
* 管理卡牌视觉元素和高亮状态
*/
export class CardContainer extends Phaser.GameObjects.Container {
private highlightRect: Phaser.GameObjects.Rectangle | null = null;
private highlightTween: Phaser.Tweens.Tween | null = null;
private _cardId: string;
private _rotation: number = 0;
private cardRenderer: CardRenderer;
constructor(
scene: OnitamaScene,
cardId: string,
card: Card,
rotation: number = 0,
) {
super(scene, 0, 0);
this._cardId = cardId;
this._rotation = rotation;
this.cardRenderer = new CardRenderer(scene);
// 将容器添加到场景
scene.add.existing(this);
// 应用旋转
this.angle = rotation;
// 使用 CardRenderer 创建卡牌视觉
this.cardRenderer.render(this, { card });
// 使卡牌可点击
const hitArea = new Phaser.Geom.Rectangle(
-CARD_WIDTH / 2,
-CARD_HEIGHT / 2,
CARD_WIDTH,
CARD_HEIGHT,
);
this.setInteractive(hitArea, Phaser.Geom.Rectangle.Contains);
// 添加场景 effect 监听高亮状态变化
this.addHighlightEffect(scene);
}
/**
* 更新卡牌旋转(带动画)
*/
setRotation(rotation: number, animated: boolean = false): this {
if (animated) {
const currentAngle = this.angle;
createCardRotateTween(
this.scene as OnitamaScene,
this,
currentAngle,
rotation,
);
} else {
this.angle = rotation;
}
this._rotation = rotation;
return this;
}
/**
* 高亮卡牌
*/
highlight(color: number, lineWidth: number): void {
if (!this.active) return;
if (!this.highlightRect) {
// 创建高亮边框(初始透明)
const rect = (this.scene as OnitamaScene).add
.rectangle(0, 0, CARD_WIDTH + 8, CARD_HEIGHT + 8, color, 0)
.setStrokeStyle(lineWidth, color)
.setAlpha(0)
.setDepth(VISUAL.cardBackgroundDepth);
this.highlightRect = rect;
this.addAt(this.highlightRect, 0);
// 淡入动画
createSelectionFadeInTween(this.scene as OnitamaScene, rect, () => {
// 淡入完成后开始脉冲动画
this.highlightTween = createSelectionPulseTween(
this.scene as OnitamaScene,
rect,
lineWidth,
);
});
} else {
// 如果已经存在,停止当前动画并重新开始脉冲
if (this.highlightTween) {
this.highlightTween.stop();
}
this.highlightRect.setStrokeStyle(lineWidth, color);
this.highlightTween = createSelectionPulseTween(
this.scene as OnitamaScene,
this.highlightRect,
lineWidth,
);
}
}
/**
* 取消高亮
*/
unhighlight(): void {
if (this.highlightRect) {
// 停止所有动画
if (this.highlightTween) {
this.highlightTween.stop();
this.highlightTween = null;
}
this.scene.tweens.killTweensOf(this.highlightRect);
// 淡出动画
createSelectionFadeOutTween(
this.scene as OnitamaScene,
this.highlightRect,
() => {
// 淡出完成后销毁矩形
this.highlightRect?.destroy();
this.highlightRect = null;
},
);
}
}
/**
* 添加高亮效果的 effect 监听
*/
private addHighlightEffect(scene: OnitamaScene): void {
// 创建一个 effect 来持续监听高亮状态变化
const dispose = effect(() => {
if (scene.uiState.value.selectedCard === this._cardId) {
this.highlight(COLORS.highlight, VISUAL.selectionRingStrokeWidth);
} else {
this.unhighlight();
}
});
// 在容器销毁时清理 effect
this.on("destroy", () => {
dispose();
});
}
}
export class CardSpawner implements Spawner<CardSpawnData, CardContainer> {
private previousData = new Map<string, CardSpawnData>();
private cardRenderer: CardRenderer;
constructor(public readonly scene: OnitamaScene) {
this.cardRenderer = new CardRenderer(scene);
}
*getData(): Iterable<CardSpawnData> {
const state = this.scene.state;
// 红方卡牌
for (let i = 0; i < state.redCards.length; i++) {
yield { cardId: state.redCards[i], position: "red", index: i };
}
// 黑方卡牌
for (let i = 0; i < state.blackCards.length; i++) {
yield { cardId: state.blackCards[i], position: "black", index: i };
}
// 备用卡牌
if (state.spareCard) {
yield { cardId: state.spareCard, position: "spare", index: 0 };
}
}
getKey(data: CardSpawnData): string {
return data.cardId;
}
private hasPositionChanged(data: CardSpawnData): boolean {
const prev = this.previousData.get(data.cardId);
if (!prev) return true;
return prev.position !== data.position || prev.index !== data.index;
}
onUpdate(data: CardSpawnData, obj: CardContainer): void {
const isBlackTurn = this.scene.state.currentPlayer === "black";
// 检查卡牌是否需要旋转
let targetRotation = 0;
if (data.position === "black") {
targetRotation = -180; // 黑方卡牌始终旋转
} else if (data.position === "spare" && isBlackTurn) {
targetRotation = -180; // 备用卡牌在黑方回合旋转
}
if (obj.angle !== targetRotation) {
obj.setRotation(targetRotation, true);
}
// 只在位置实际变化时才播放移动动画
if (!this.hasPositionChanged(data)) {
this.previousData.set(data.cardId, { ...data });
return;
}
const pos = getCardPosition(data.position, data.index);
// 播放移动动画并添加中断
createCardMoveTween(this.scene, obj, pos.x, pos.y);
this.previousData.set(data.cardId, { ...data });
}
onSpawn(data: CardSpawnData): CardContainer {
const card = this.scene.state.cards[data.cardId];
if (!card) {
this.previousData.set(data.cardId, { ...data });
// 返回空容器
const emptyContainer = new CardContainer(this.scene, data.cardId, {
id: data.cardId,
regionId: "",
position: [],
moveCandidates: [],
startingPlayer: "red",
} as Card);
return emptyContainer;
}
// 计算初始旋转角度
const isBlackTurn = this.scene.state.currentPlayer === "black";
let initialRotation = 0;
if (data.position === "black") {
initialRotation = -180;
} else if (data.position === "spare" && isBlackTurn) {
initialRotation = -180;
}
const container = new CardContainer(
this.scene,
data.cardId,
card,
initialRotation,
);
const pos = getCardPosition(data.position, data.index);
container.x = pos.x;
container.y = pos.y;
// 设置悬停效果
container.on("pointerover", () => {
if (this.scene.uiState.value.selectedCard !== data.cardId) {
container.setAlpha(VISUAL.cardDisabledAlpha);
}
});
container.on("pointerout", () => {
container.setAlpha(1);
});
container.on("pointerdown", () => {
this.scene.onCardClick(data.cardId);
});
// 初始状态为透明,然后淡入
container.setAlpha(0);
createCardSpawnTween(this.scene, container);
this.previousData.set(data.cardId, { ...data });
return container;
}
onDespawn(obj: CardContainer): void {
createCardDespawnTween(this.scene, obj, () => obj.destroy());
}
}
@@ -0,0 +1,115 @@
import { getAvailableMoves } from "boardgame-core/samples/onitama";
import { Geom } from "phaser";
import type { OnitamaScene } from "@/scenes/OnitamaScene";
import type { Spawner } from "boardgame-phaser";
import {
boardToScreen,
CELL_SIZE,
VISUAL,
createHighlightSpawnTween,
createHighlightDespawnTween,
createHighlightClickFeedbackTween,
} from "@/config";
import { HighlightRenderer } from "@/renderers";
export interface HighlightData {
key: string;
x: number;
y: number;
card: string;
fromX: number;
fromY: number;
toX: number;
toY: number;
}
export class HighlightSpawner implements Spawner<
HighlightData,
Phaser.GameObjects.Container
> {
private renderer: HighlightRenderer;
constructor(public readonly scene: OnitamaScene) {
this.renderer = new HighlightRenderer(scene);
}
*getData(): Iterable<HighlightData> {
const state = this.scene.state;
const uiState = this.scene.uiState.value;
// 如果没有选择卡牌或棋子,不显示高亮
if (!uiState.selectedCard || !uiState.selectedPiece) {
return;
}
const currentPlayer = state.currentPlayer;
const availableMoves = getAvailableMoves(state, currentPlayer);
// 过滤出符合当前选择的移动
for (const move of availableMoves) {
if (
move.fromX === uiState.selectedPiece.x &&
move.fromY === uiState.selectedPiece.y &&
move.card === uiState.selectedCard
) {
const pos = boardToScreen(move.toX, move.toY);
yield {
key: `${move.fromX}-${move.fromY}-${move.card}-${move.toX}-${move.toY}`,
x: pos.x,
y: pos.y,
card: move.card,
fromX: move.fromX,
fromY: move.fromY,
toX: move.toX,
toY: move.toY,
};
}
}
}
getKey(data: HighlightData): string {
return data.key;
}
onUpdate(data: HighlightData, obj: Phaser.GameObjects.Container): void {
obj.setPosition(data.x, data.y);
}
onSpawn(data: HighlightData): Phaser.GameObjects.Container {
const container = this.scene.add.container(data.x, data.y);
// 使用 HighlightRenderer 创建视觉元素
this.renderer.render(container, { x: data.x, y: data.y });
// 设置交互区域
const hitArea = new Geom.Circle(0, 0, VISUAL.highlightHitAreaRadius);
container.setInteractive(hitArea, Phaser.Geom.Circle.Contains);
if (container.input) {
container.input.cursor = "pointer";
}
// 出现动画:从0缩放和透明淡入
container.setAlpha(0);
container.setScale(0);
createHighlightSpawnTween(this.scene, container);
// 使用 HighlightRenderer 设置脉冲动画
this.renderer.setupPulseAnimations(container);
container.on("pointerdown", () => {
// 点击时的反馈动画
createHighlightClickFeedbackTween(this.scene, container);
this.scene.onHighlightClick(data);
});
return container;
}
onDespawn(obj: Phaser.GameObjects.Container): void {
// 消失动画:缩小并淡出
createHighlightDespawnTween(this.scene, obj, () => obj.destroy());
}
}
@@ -0,0 +1,168 @@
import { effect } from "@preact/signals-core";
import { GameObjects } from "phaser";
import type { Pawn } from "@/game/onitama";
import type { OnitamaScene } from "@/scenes/OnitamaScene";
import type { Spawner } from "boardgame-phaser";
import {
boardToScreen,
createPawnMoveTween,
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 的棋子容器类
* 管理棋子视觉元素和选中状态
*/
export class PawnContainer extends GameObjects.Container {
private selectionRing: Phaser.GameObjects.Arc | null = null;
private _position: [number, number];
private _owner: "red" | "black";
private _type: "master" | "student";
private pawnRenderer: PawnRenderer;
private selectionRenderer: SelectionRenderer;
constructor(scene: OnitamaScene, pawn: Pawn) {
super(scene, 0, 0);
this._owner = pawn.owner;
this._type = pawn.type;
this._position = pawn.position as [number, number];
this.pawnRenderer = new PawnRenderer(scene);
this.selectionRenderer = new SelectionRenderer(scene);
// 将容器添加到场景
scene.add.existing(this);
// 使用 PawnRenderer 创建棋子视觉
this.pawnRenderer.render(this, { owner: this._owner, type: this._type });
// 添加选中状态监听
this.addSelectionEffect(scene);
}
/**
* 显示选中光环
*/
showSelection(): void {
if (!this.active) return;
if (!this.selectionRing) {
this.selectionRing = this.selectionRenderer.create(this);
this.selectionRenderer.show(this.selectionRing);
}
}
/**
* 隐藏选中光环
*/
hideSelection(): void {
if (this.selectionRing) {
this.selectionRenderer.hide(this.selectionRing, () => {
this.selectionRing = null;
});
}
}
/**
* 添加选中状态的 effect 监听
*/
private addSelectionEffect(scene: OnitamaScene): void {
const dispose = effect(() => {
const uiState = scene.uiState.value;
const isSelected =
uiState.selectedPiece?.x === this._position[0] &&
uiState.selectedPiece?.y === this._position[1];
if (isSelected) {
this.showSelection();
} else {
this.hideSelection();
}
});
this.on("destroy", () => {
dispose();
});
}
/**
* 更新棋子位置
*/
updatePosition(
newPosition: [number, number],
animated: boolean = false,
): void {
this._position = newPosition;
const targetPos = boardToScreen(newPosition[0], newPosition[1]);
if (animated) {
createPawnMoveTween(
this.scene as OnitamaScene,
this,
targetPos.x,
targetPos.y,
);
} else {
this.x = targetPos.x;
this.y = targetPos.y;
}
}
}
export class PawnSpawner implements Spawner<Pawn, PawnContainer> {
private previousPositions = new Map<string, [number, number]>();
constructor(public readonly scene: OnitamaScene) {}
*getData() {
for (const pawn of Object.values(this.scene.state.pawns)) {
if (pawn.regionId === "board") {
yield pawn;
}
}
}
getKey(pawn: Pawn): string {
return pawn.id;
}
onUpdate(pawn: Pawn, obj: PawnContainer): void {
const [x, y] = pawn.position;
const prevPos = this.previousPositions.get(pawn.id);
const hasMoved = !prevPos || prevPos[0] !== x || prevPos[1] !== y;
if (hasMoved) {
obj.updatePosition([x, y], !!prevPos);
}
this.previousPositions.set(pawn.id, [x, y]);
}
onSpawn(pawn: Pawn): PawnContainer {
const container = new PawnContainer(this.scene, pawn);
const [x, y] = pawn.position;
const pos = boardToScreen(x, y);
container.x = pos.x;
container.y = pos.y;
this.previousPositions.set(pawn.id, [x, y]);
// 淡入动画
container.setScale(0);
createPawnSpawnTween(this.scene, container);
return container;
}
onDespawn(obj: PawnContainer): void {
// 播放消失动画并添加中断
createPawnDespawnTween(this.scene, obj, () => obj.destroy());
}
}
@@ -0,0 +1,14 @@
export { PawnSpawner } from "./PawnSpawner";
export { CardSpawner, type CardSpawnData } from "./CardSpawner";
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
View File
@@ -0,0 +1,8 @@
export {
createUIState,
clearSelection,
selectPiece,
selectCard,
createValidMoves,
} from "./ui";
export type { OnitamaUIState, ValidMove } from "./ui";
+81
View File
@@ -0,0 +1,81 @@
import { mutableSignal, computed } from "boardgame-core";
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 {
card: string;
fromX: number;
fromY: number;
toX: number;
toY: number;
}
// 先选择牌,然后选择棋子,最后选择移动
export interface OnitamaUIState {
selectedPiece: { x: number; y: number } | null;
selectedCard: string | null;
}
export function createUIState(): MutableSignal<OnitamaUIState> {
return mutableSignal<OnitamaUIState>({
selectedPiece: null,
selectedCard: null,
});
}
export function createValidMoves(
state: ReadonlySignal<OnitamaState>,
ui: ReadonlySignal<OnitamaUIState>,
) {
return computed(() => {
return getAvailableMoves(state.value, state.value.currentPlayer).filter(
(move) => {
const { selectedCard, selectedPiece } = ui.value;
return (
selectedPiece?.x === move.fromX &&
selectedPiece?.y === move.fromY &&
selectedCard === move.card
);
},
);
});
}
export function clearSelection(uiState: MutableSignal<OnitamaUIState>): void {
uiState.produce((state) => {
state.selectedPiece = null;
state.selectedCard = null;
});
}
export function selectPiece(
uiState: MutableSignal<OnitamaUIState>,
x: number,
y: number,
): void {
uiState.produce((state) => {
// 如果点击已选中的棋子,取消选择
if (state.selectedPiece?.x === x && state.selectedPiece?.y === y) {
state.selectedPiece = null;
} else {
state.selectedPiece = { x, y };
}
});
}
export function selectCard(
uiState: MutableSignal<OnitamaUIState>,
card: string,
): void {
uiState.produce((state) => {
// 如果点击已选中的卡牌,取消选择
if (state.selectedCard === card) {
state.selectedCard = null;
} else {
// 选择新卡牌,清除棋子选择
state.selectedCard = card;
}
});
}
+1
View File
@@ -0,0 +1 @@
@import "tailwindcss";
+37
View File
@@ -0,0 +1,37 @@
import { createGameHost, type GameModule } from "boardgame-core";
import { PhaserGame, PhaserScene } from "boardgame-phaser";
import { useMemo } from "preact/hooks";
import * as gameModule from "../game/onitama";
import type { OnitamaState } from "@/game/onitama";
import { MenuScene } from "@/scenes/MenuScene";
import { OnitamaScene } from "@/scenes/OnitamaScene";
export default function App() {
const gameHost = useMemo(
() => createGameHost(gameModule as unknown as GameModule<OnitamaState>),
[],
);
const gameScene = useMemo(() => new OnitamaScene(), []);
const menuScene = useMemo(() => new MenuScene(), []);
return (
<div className="flex flex-col h-screen">
<div className="flex-1 flex relative justify-center items-center">
<PhaserGame
initialScene="MenuScene"
config={{ width: 720, height: 840 }}
>
<PhaserScene sceneKey="MenuScene" scene={menuScene} />
<PhaserScene
sceneKey="OnitamaScene"
scene={gameScene}
data={{ gameHost }}
/>
</PhaserGame>
</div>
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"jsx": "react-jsx",
"jsxImportSource": "preact",
"noEmit": true,
"declaration": false,
"declarationMap": false,
"sourceMap": false
},
"include": ["src/**/*"]
}
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig } from 'vite';
import preact from '@preact/preset-vite';
import tailwindcss from '@tailwindcss/vite';
import path from 'path';
export default defineConfig({
plugins: [preact(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
'boardgame-phaser': path.resolve(__dirname, '../framework/src/index.ts'),
},
},
});
+114
View File
@@ -0,0 +1,114 @@
# Regicide - 单人卡牌游戏
基于 Phaser 3 + boardgame-core 框架实现的 Regicide 合作卡牌游戏单人模式。
## 游戏简介
Regicide 是一款合作奇幻卡牌游戏,使用标准扑克牌进行游戏。玩家需要击败12个强大的敌人(4个J、4个Q、4个K)来取得胜利。
## 游戏规则
### 目标
按任意顺序击败城堡牌堆中的所有12个敌人(J/Q/K)。
### 设置
- **城堡牌堆**: 12张敌人卡(4J、4Q、4K)
- **酒馆牌堆**: 所有数字牌(2-10)和A
- **初始手牌**: 8张
- **小丑牌**: 2张(用于重抽手牌)
### 敌人属性
| 敌人 | HP | 反击伤害 | 分值 |
|------|----|----------|------|
| J | 20 | 10 | 10 |
| Q | 30 | 15 | 15 |
| K | 40 | 20 | 20 |
### 花色效果
- **红桃 ♥**: 从弃牌堆回收卡牌到酒馆牌堆
- **方片 ♦**: 抽取等量卡牌
- **梅花 ♣**: 攻击力翻倍
- **黑桃 ♠**: 减免反击伤害
### 游戏流程
1. **出牌阶段**: 打出一张或多张同点数的牌(总点数≤10)
2. **伤害结算**: 对敌人造成等同于牌面点数的伤害
3. **反击阶段**: 如果敌人未被击败,它会反击
4. **抵消反击**: 弃置点数总和≥反击伤害的牌
### 特殊规则
- **敌人免疫**: 每个敌人免疫与其花色相同的卡牌效果
- **组合牌**: 可打出多张同点数牌,但总点数不能超过10
- **小丑牌**: 可弃光手牌并重新抽取8张(整局限用2次)
### 胜利等级
- 🥇 **金胜利**: 未使用小丑牌获胜
- 🥈 **银胜利**: 使用1张小丑牌获胜
- 🥉 **铜胜利**: 使用2张小丑牌获胜
## 运行游戏
```bash
# 安装依赖
pnpm install
# 启动开发服务器
cd packages/regicide-game
pnpm dev
# 构建生产版本
pnpm build
# 预览生产版本
pnpm preview
```
## 项目结构
```
packages/regicide-game/
├── src/
│ ├── game/
│ │ ├── types.ts # 类型定义
│ │ ├── card-utils.ts # 卡牌工具函数
│ │ └── regicide.ts # 游戏逻辑和命令
│ ├── scenes/
│ │ ├── GameScene.ts # 主游戏场景
│ │ └── effects.ts # 特效系统
│ └── ui/
│ └── App.tsx # Preact UI组件
├── index.html
├── package.json
└── vite.config.ts
```
## 技术栈
- **Phaser 3**: 游戏引擎
- **boardgame-core**: 状态管理(Preact Signals + Mutative)
- **boardgame-phaser**: Phaser 集成框架
- **Preact**: UI 组件
- **TypeScript**: 类型安全
- **Vite**: 构建工具
- **Tailwind CSS**: UI 样式
## 开发说明
### 命令系统
游戏使用 boardgame-core 的命令系统:
- `setup` - 初始化游戏
- `play <cardId>` - 打出单张卡牌
- `combo <cardIds>` - 打出组合牌
- `yield` - 放弃回合
- `counterattack <cardIds>` - 反击时弃牌
- `useJester` - 使用小丑牌重抽
### 状态管理
游戏状态使用 MutableSignal 响应式系统,通过 `game.produce()` 进行不可变更新。
### 场景系统
GameScene 继承自 GameHostScene,使用 `spawnEffect` 和 `effect()` 实现响应式渲染。
## 许可证
MIT
+14
View File
@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Regicide - Solo Card Game</title>
</head>
<body>
<div id="app"></div>
<div id="ui-root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+29
View File
@@ -0,0 +1,29 @@
{
"name": "regicide-game",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@preact/signals-core": "^1.5.1",
"boardgame-core": "link:../../../boardgame-core",
"boardgame-phaser": "workspace:*",
"mutative": "^1.3.0",
"phaser": "^3.80.1",
"preact": "^10.19.3"
},
"devDependencies": {
"@preact/preset-vite": "^2.8.1",
"@preact/signals": "^2.9.0",
"@tailwindcss/vite": "^4.0.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.3.3",
"vite": "^5.1.0",
"vitest": "^3.2.4"
}
}
+49
View File
@@ -0,0 +1,49 @@
# Regicide 快速入门指南
## 🎯 游戏目标
击败所有12个敌人(4个J、4个Q、4个K)
## 🃏 出牌规则
### 基本操作
1. **点击手牌** - 出牌攻击敌人
2. **反击时点击手牌** - 弃牌抵消反击伤害
### 出牌方式
- **单张牌**: 造成牌面点数伤害
- **组合牌**: 多张同点数牌,总伤害=点数总和(需≤10)
- **放弃**: 不出牌,直接承受反击伤害
### 花色特效
- ♥ 红桃 - 回收弃牌
- ♦ 方片 - 抽取新牌
- ♣ 梅花 - 伤害翻倍
- ♠ 黑桃 - 减免反击
## ⚔️ 敌人信息
| 敌人 | 生命值 | 反击伤害 |
|------|--------|----------|
| J | 20 HP | 10 |
| Q | 30 HP | 15 |
| K | 40 HP | 20 |
**注意**: 每个敌人免疫与其花色相同的卡牌效果!
## 🃏 小丑牌
- 可以**弃光手牌**并**重新抽取8张**
- 整局限用**2次**
- 使用次数影响胜利等级
## 🏆 胜利等级
- 🥇 金: 未使用小丑牌
- 🥈 银: 使用1张小丑牌
- 🥉 铜: 使用2张小丑牌
## 💡 游戏提示
1. 合理使用花色特效
2. 保留高点数牌应对强敌
3. 注意敌人的花色免疫
4. 谨慎使用小丑牌
祝你好运,勇者!⚔️
+14
View File
@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Regicide - Solo Card Game</title>
</head>
<body>
<div id="app"></div>
<div id="ui-root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
@@ -0,0 +1,142 @@
import type { Card, CardSuit, CardRank, EnemyCard, JesterCard } from './types';
// 创建标准扑克牌组 (不含小丑)
export function createStandardDeck(): Card[] {
const suits: CardSuit[] = ['hearts', 'diamonds', 'clubs', 'spades'];
const ranks: CardRank[] = ['2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K', 'A'];
const deck: Card[] = [];
for (const suit of suits) {
for (const rank of ranks) {
const value = getCardValue(rank);
deck.push({
id: `card-${suit}-${rank}`,
suit,
rank,
value,
});
}
}
return deck;
}
// 获取卡牌点数对应的数值
export function getCardValue(rank: CardRank): number {
if (rank === 'jester') return 0;
if (rank === 'A') return 1;
if (rank === 'J') return 10;
if (rank === 'Q') return 15;
if (rank === 'K') return 20;
return parseInt(rank);
}
// 创建城堡牌堆 (J/Q/K 作为敌人)
// TODO 不要全部洗混,把J/Q/K分别洗混再合并,并且J先翻出K最后翻出
export function createCastleDeck(): EnemyCard[] {
const suits: CardSuit[] = ['hearts', 'diamonds', 'clubs', 'spades'];
const castleCards: EnemyCard[] = [];
// 4个K (40HP, 20反击)
for (let i = 0; i < 4; i++) {
castleCards.push({
id: `enemy-K-${i}`,
suit: suits[i],
rank: 'K' as const,
value: 20,
hp: 40,
counterDamage: 20,
immunitySuit: suits[i],
});
}
// 4个Q (30HP, 15反击)
for (let i = 0; i < 4; i++) {
castleCards.push({
id: `enemy-Q-${i}`,
suit: suits[i],
rank: 'Q' as const,
value: 15,
hp: 30,
counterDamage: 15,
immunitySuit: suits[i],
});
}
// 4个J (20HP, 10反击)
for (let i = 0; i < 4; i++) {
castleCards.push({
id: `enemy-J-${i}`,
suit: suits[i],
rank: 'J' as const,
value: 10,
hp: 20,
counterDamage: 10,
immunitySuit: suits[i],
});
}
return shuffleDeck(castleCards);
}
// 创建酒馆牌堆 (数字牌 + A)
export function createTavernDeck(): Card[] {
const deck = createStandardDeck();
// 过滤出数字牌和A,去除J/Q/K
return deck.filter(card =>
card.rank !== 'J' && card.rank !== 'Q' && card.rank !== 'K'
);
}
// 创建小丑牌
export function createJesters(): JesterCard[] {
return [
{ id: 'jester-1', rank: 'jester', used: false },
{ id: 'jester-2', rank: 'jester', used: false },
];
}
// 洗牌算法 (Fisher-Yates)
export function shuffleDeck<T>(deck: T[]): T[] {
const shuffled = [...deck];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return shuffled;
}
// 从牌堆顶部抽牌
export function drawFromDeck<T>(deck: T[], count: number): T[] {
return deck.splice(0, count);
}
// 获取花色的中文名称
export function getSuitName(suit: CardSuit | null): string {
if (!suit) return '无花色';
const names: Record<CardSuit, string> = {
hearts: '红桃 ♥',
diamonds: '方片 ♦',
clubs: '梅花 ♣',
spades: '黑桃 ♠',
};
return names[suit];
}
// 获取花色的颜色
export function getSuitColor(suit: CardSuit | null): string {
if (!suit) return '#9ca3af'; // 灰色
return (suit === 'hearts' || suit === 'diamonds') ? '#dc2626' : '#1f2937';
}
// 获取卡牌显示文本
export function getCardDisplay(card: Card | JesterCard): string {
if (card.rank === 'jester') return '🃏';
const suitSymbols: Record<CardSuit, string> = {
hearts: '♥',
diamonds: '♦',
clubs: '♣',
spades: '♠',
};
return `${card.rank}${suitSymbols[card.suit as CardSuit]}`;
}
@@ -0,0 +1,246 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { createGameHost, GameHost } from 'boardgame-core';
import { gameModule, createInitialState, prompts } from './regicide';
import type { RegicideState, Card, Enemy } from './types';
describe('Regicide Game Module', () => {
let gameHost: GameHost<RegicideState>;
let gamePromise: Promise<any>;
beforeEach(() => {
gameHost = createGameHost(gameModule);
});
afterEach(async () => {
// 等待游戏循环完成,然后 dispose
await new Promise(resolve => setTimeout(resolve, 50));
// 等待 gamePromise 结束,suppress 预期的 cancel 错误
gamePromise?.catch(() => {});
gameHost.dispose();
// 等待 dispose 完成
await new Promise(resolve => setTimeout(resolve, 20));
});
describe('Initial State', () => {
it('should create valid initial state', () => {
const state = createInitialState();
expect(state.castleDeck).toBeDefined();
expect(state.tavernDeck).toBeDefined();
expect(state.discardPile).toBeDefined();
expect(state.currentEnemy).toBeNull();
expect(state.defeatedEnemies).toEqual([]);
expect(state.hand).toEqual([]);
expect(state.jesters).toHaveLength(2);
expect(state.jestersUsed).toBe(0);
expect(state.phase).toBe('playerTurn');
expect(state.currentPlayed).toBeNull();
expect(state.victoryLevel).toBeNull();
expect(state.isGameOver).toBe(false);
});
it('should have jesters with correct structure', () => {
const state = createInitialState();
expect(state.jesters[0]).toMatchObject({
id: 'jester-1',
rank: 'jester',
used: false,
});
expect(state.jesters[1]).toMatchObject({
id: 'jester-2',
rank: 'jester',
used: false,
});
});
});
describe('Setup Command', () => {
it('should initialize game with setup command', async () => {
// 开始游戏(会自动调用 setup 和主循环)
gamePromise = gameHost.start();
// 等待游戏进入 prompt 状态(等待玩家出牌)
await waitForPrompt(gameHost);
// 验证游戏状态
const state = gameHost.state.value;
expect(state.phase).toBe('playerTurn');
expect(state.currentEnemy).not.toBeNull();
expect(state.currentEnemy!.currentHp).toBeGreaterThan(0);
expect(state.hand.length).toBe(8); // 初始手牌数
expect(state.castleDeck.length).toBeGreaterThan(0);
expect(state.tavernDeck.length).toBeGreaterThan(0);
});
it('should shuffle decks during setup', async () => {
gamePromise = gameHost.start();
await waitForPrompt(gameHost);
const state = gameHost.state.value;
// 验证牌堆已被洗牌(不应该按顺序排列)
const firstFewCards = state.tavernDeck.slice(0, 10);
const hasDifferentValues = firstFewCards.some(card => card.value !== firstFewCards[0].value);
expect(hasDifferentValues).toBe(true);
});
});
describe('Play Card Command', () => {
it('should play a single card and damage enemy', async () => {
gamePromise = gameHost.start();
await waitForPrompt(gameHost);
const state = gameHost.state.value;
const enemy = state.currentEnemy!;
const initialHp = enemy.currentHp;
const card = state.hand[0];
// 出牌 - 使用 tryAnswerPrompt 命令格式
const error = gameHost.tryAnswerPrompt(prompts.playerAction, 'play', [card.id]);
expect(error).toBeNull(); // 验证成功
// 等待下一个 prompt
await waitForPrompt(gameHost);
const newState = gameHost.state.value;
const newEnemy = newState.currentEnemy;
// 验证敌人受到了伤害或进入反击阶段
if (newEnemy) {
// HP 应该减少(至少减少了card.value)
const damage = initialHp - newEnemy.currentHp;
expect(damage).toBeGreaterThanOrEqual(card.value);
}
// 验证卡牌从手牌移除
expect(newState.hand.find(c => c.id === card.id)).toBeUndefined();
expect(newState.discardPile.find(c => c.id === card.id)).toBeDefined();
});
it('should fail to play card not in hand', async () => {
gamePromise = gameHost.start();
await waitForPrompt(gameHost);
const stateBefore = gameHost.state.value;
const handSizeBefore = stateBefore.hand.length;
// 尝试打出不存在的牌 - 应该被 validator 拒绝
const error = gameHost.tryAnswerPrompt(prompts.playerAction, 'play', ['non-existent-card']);
// 错误会被 catch 并继续循环,所以返回 null
expect(error).toBeNull();
// 等待下一个 prompt(因为命令失败,会再次提示)
await waitForPrompt(gameHost);
const stateAfter = gameHost.state.value;
// 验证手牌没有变化
expect(stateAfter.hand.length).toBe(handSizeBefore);
});
});
describe('Counterattack Mechanism', () => {
it('should enter counterattack phase after playing card', async () => {
gamePromise = gameHost.start();
await waitForPrompt(gameHost);
const state = gameHost.state.value;
const enemy = state.currentEnemy!;
// 出一张牌造成伤害(不一定击败敌人)
const card = state.hand[0];
gameHost.tryAnswerPrompt(prompts.playerAction, 'play', [card.id]);
// 等待反击阶段或下一个 prompt
await waitForPrompt(gameHost);
const counterState = gameHost.state.value;
// 验证进入了反击阶段或敌人被击败
expect(
counterState.phase === 'enemyCounterattack' ||
counterState.phase === 'enemyDefeated' ||
counterState.isGameOver ||
counterState.phase === 'playerTurn' // 如果敌人被立即击败
).toBe(true);
});
});
describe('Game End Conditions', () => {
it('should track game state correctly', async () => {
gamePromise = gameHost.start();
await waitForPrompt(gameHost);
const state = gameHost.state.value;
expect(state.defeatedEnemies).toHaveLength(0);
expect(state.isGameOver).toBe(false);
});
});
describe('Yield Turn', () => {
it('should yield turn', async () => {
gamePromise = gameHost.start();
await waitForPrompt(gameHost);
// 放弃回合 - 使用 tryAnswerPrompt 命令格式
const error = gameHost.tryAnswerPrompt(prompts.playerAction, 'yield', []);
// 等待处理完成
await new Promise(resolve => setTimeout(resolve, 100));
const state = gameHost.state.value;
// yield 命令可能被接受或拒绝,取决于游戏状态
// 这里只验证游戏仍在运行
expect(state.isGameOver).toBeDefined();
});
});
describe('Use Jester', () => {
it('should use jester to redraw hand', async () => {
gamePromise = gameHost.start();
await waitForPrompt(gameHost);
const state = gameHost.state.value;
const initialHandSize = state.hand.length;
// 使用小丑牌 - 使用 tryAnswerPrompt 命令格式
const error = gameHost.tryAnswerPrompt(prompts.playerAction, 'useJester', []);
// 等待处理完成
await new Promise(resolve => setTimeout(resolve, 100));
const newState = gameHost.state.value;
// useJester 可能被接受或拒绝
// 验证游戏仍在运行
expect(newState.isGameOver).toBeDefined();
});
});
});
// 辅助函数
function waitForPrompt(gameHost: GameHost<RegicideState>): Promise<void> {
return new Promise((resolve) => {
const check = () => {
if (gameHost.activePromptSchema.value !== null) {
resolve();
} else {
setTimeout(check, 10);
}
};
check();
});
}
function waitForPromptOrGameOver(gameHost: GameHost<RegicideState>): Promise<void> {
return new Promise((resolve) => {
const check = () => {
const state = gameHost.state.value;
if (gameHost.activePromptSchema.value !== null || state.isGameOver) {
resolve();
} else {
setTimeout(check, 10);
}
};
check();
});
}
+488
View File
@@ -0,0 +1,488 @@
import {
createGameCommandRegistry,
createPromptDef,
IGameContext,
GameModule,
} from 'boardgame-core';
import type { RegicideState, Enemy, PlayedCards, Card, JesterCard, CardSuit, EnemyCard } from './types';
import {
createCastleDeck,
createTavernDeck,
createJesters,
shuffleDeck,
drawFromDeck,
} from './card-utils';
const INITIAL_HAND_SIZE = 8;
const MAX_COMBINED_VALUE = 10;
export const registry = createGameCommandRegistry<RegicideState>();
export function createInitialState(): RegicideState {
return {
castleDeck: [],
tavernDeck: [],
discardPile: [],
currentEnemy: null,
defeatedEnemies: [],
hand: [],
jesters: createJesters(),
jestersUsed: 0,
phase: 'playerTurn',
currentPlayed: null,
victoryLevel: null,
isGameOver: false,
};
}
export type RegicideGame = IGameContext<RegicideState>;
// setup 命令 - 初始化游戏
async function setup(game: RegicideGame) {
const castleDeck = shuffleDeck(createCastleDeck());
const tavernDeck = shuffleDeck(createTavernDeck());
const jesters = createJesters();
const initialHand = drawFromDeck(tavernDeck, INITIAL_HAND_SIZE);
const firstEnemyCard = castleDeck.shift();
if (!firstEnemyCard) throw new Error('城堡牌堆为空');
const firstEnemy: Enemy = {
...firstEnemyCard,
currentHp: firstEnemyCard.hp,
isDefeated: false,
};
game.produce((state: RegicideState) => {
state.castleDeck = castleDeck;
state.tavernDeck = tavernDeck;
state.discardPile = [];
state.currentEnemy = firstEnemy;
state.defeatedEnemies = [];
state.hand = initialHand;
state.jesters = jesters;
state.jestersUsed = 0;
state.phase = 'playerTurn';
state.currentPlayed = null;
state.victoryLevel = null;
state.isGameOver = false;
});
return game.value;
}
registry.register('setup', setup);
// play <cardId> - 打出单张卡牌
async function playCard(game: RegicideGame, cardId: string) {
const state = game.value;
if (state.phase !== 'playerTurn') {
throw new Error('不是玩家回合');
}
if (!state.currentEnemy || state.currentEnemy.isDefeated) {
throw new Error('没有活跃的敌人');
}
const card = state.hand.find(c => c.id === cardId);
if (!card) throw new Error(`卡牌 ${cardId} 不在手牌中`);
// 计算伤害
let damage = card.value;
const suits = card.suit ? [card.suit] : [];
if (suits.includes('clubs')) damage *= 2;
let isImmune = false;
const enemy = state.currentEnemy;
if (enemy.immunitySuit && suits.includes(enemy.immunitySuit)) {
isImmune = true;
damage = card.value;
}
game.produce((state: RegicideState) => {
const cardIndex = state.hand.findIndex(c => c.id === cardId);
if (cardIndex !== -1) state.hand.splice(cardIndex, 1);
state.discardPile.push(card);
// 计算最终伤害并应用到敌人
let finalDamage = damage;
const e = state.currentEnemy!;
e.currentHp -= finalDamage;
const isDefeated = e.currentHp <= 0;
if (isDefeated) {
e.isDefeated = true;
state.defeatedEnemies.push({ ...e });
state.currentEnemy = null;
state.phase = 'enemyDefeated';
if (state.defeatedEnemies.length >= 12) {
state.isGameOver = true;
state.victoryLevel = calculateVictoryLevel(state.jestersUsed);
state.phase = 'gameOver';
}
} else {
state.phase = 'enemyCounterattack';
}
state.currentPlayed = {
cards: [card],
totalDamage: isImmune ? card.value : finalDamage,
suits,
hasJester: false,
};
});
// 从 produce 后的状态读取结果
const finalEnemy = game.value.defeatedEnemies.find(e => e.id === enemy.id) || game.value.currentEnemy;
const wasDefeated = game.value.defeatedEnemies.some(e => e.id === enemy.id);
return { damage, remainingHp: wasDefeated ? 0 : Math.max(0, (game.value.currentEnemy?.currentHp ?? 0)), isImmune, isDefeated: wasDefeated };
}
registry.register('play <cardId:string>', playCard);
// combo <cardIds> - 打出组合牌
async function playCombo(game: RegicideGame, cardIds: string[]) {
const state = game.value;
if (state.phase !== 'playerTurn') throw new Error('不是玩家回合');
if (cardIds.length < 2) throw new Error('组合牌至少需要2张');
const selectedCards: Card[] = [];
for (const cardId of cardIds) {
const card = state.hand.find(c => c.id === cardId);
if (!card) throw new Error(`卡牌 ${cardId} 不在手牌中`);
selectedCards.push(card);
}
const firstRank = selectedCards[0].rank;
if (!selectedCards.every(c => c.rank === firstRank)) {
throw new Error('组合牌必须是相同点数');
}
const totalValue = selectedCards.reduce((sum, c) => sum + c.value, 0);
if (totalValue > MAX_COMBINED_VALUE) {
throw new Error(`组合牌总点数不能超过 ${MAX_COMBINED_VALUE}`);
}
let damage = totalValue;
const suits = selectedCards.map(c => c.suit).filter((s): s is CardSuit => s !== null);
if (suits.includes('clubs')) damage *= 2;
let isImmune = false;
const enemy = state.currentEnemy!;
if (enemy.immunitySuit && suits.includes(enemy.immunitySuit)) {
isImmune = true;
damage = totalValue;
}
game.produce((state: RegicideState) => {
for (const card of selectedCards) {
const cardIndex = state.hand.findIndex(c => c.id === card.id);
if (cardIndex !== -1) state.hand.splice(cardIndex, 1);
state.discardPile.push(card);
}
// 计算最终伤害并应用到敌人
let finalDamage = damage;
const e = state.currentEnemy!;
e.currentHp -= finalDamage;
const isDefeated = e.currentHp <= 0;
if (isDefeated) {
e.isDefeated = true;
state.defeatedEnemies.push({ ...e });
state.currentEnemy = null;
state.phase = 'enemyDefeated';
if (state.defeatedEnemies.length >= 12) {
state.isGameOver = true;
state.victoryLevel = calculateVictoryLevel(state.jestersUsed);
state.phase = 'gameOver';
}
} else {
state.phase = 'enemyCounterattack';
}
state.currentPlayed = {
cards: selectedCards,
totalDamage: isImmune ? totalValue : finalDamage,
suits,
hasJester: false,
};
});
const wasDefeated = game.value.defeatedEnemies.some(e => e.id === enemy.id);
return { damage, remainingHp: wasDefeated ? 0 : Math.max(0, (game.value.currentEnemy?.currentHp ?? 0)), isImmune, isDefeated: wasDefeated };
}
registry.register('combo <cardIds>', playCombo);
// yield - 放弃回合
async function yieldTurn(game: RegicideGame) {
const state = game.value;
if (state.phase !== 'playerTurn') throw new Error('不是玩家回合');
game.produce((state: RegicideState) => {
state.phase = 'enemyCounterattack';
state.currentPlayed = {
cards: [],
totalDamage: 0,
suits: [],
hasJester: false,
};
});
return { message: '放弃回合,承受反击伤害' };
}
registry.register('yield', yieldTurn);
// counterattack <cardIds> - 反击时弃牌
async function counterattack(game: RegicideGame, cardIds: string[]) {
const state = game.value;
if (state.phase !== 'enemyCounterattack') throw new Error('不是反击阶段');
if (!state.currentEnemy) throw new Error('没有活跃的敌人');
// 空牌提交 = 认输
if (cardIds.length === 0) {
game.produce((state: RegicideState) => {
state.isGameOver = true;
state.victoryLevel = null;
state.phase = 'gameOver';
});
return { success: false, required: state.currentEnemy.counterDamage, available: 0, isSurrender: true };
}
const selectedCards: Card[] = [];
for (const cardId of cardIds) {
const card = state.hand.find(c => c.id === cardId);
if (!card) throw new Error(`卡牌 ${cardId} 不在手牌中`);
selectedCards.push(card);
}
const totalValue = selectedCards.reduce((sum, c) => sum + c.value, 0);
const requiredValue = state.currentEnemy.counterDamage;
if (totalValue < requiredValue) {
game.produce((state: RegicideState) => {
state.isGameOver = true;
state.victoryLevel = null;
state.phase = 'gameOver';
});
return { success: false, required: requiredValue, available: totalValue };
}
game.produce((state: RegicideState) => {
for (const card of selectedCards) {
const index = state.hand.findIndex(c => c.id === card.id);
if (index !== -1) {
state.hand.splice(index, 1);
state.discardPile.push(card);
}
}
const cardsToDraw = Math.min(
INITIAL_HAND_SIZE - state.hand.length,
state.tavernDeck.length
);
if (cardsToDraw > 0) {
const newCards = drawFromDeck(state.tavernDeck, cardsToDraw);
state.hand.push(...newCards);
}
state.phase = 'playerTurn';
state.currentPlayed = null;
state.currentEnemy = null;
if (state.castleDeck.length > 0) {
const nextEnemyCard = state.castleDeck.shift();
if (nextEnemyCard && 'hp' in nextEnemyCard && 'counterDamage' in nextEnemyCard) {
const nextEnemy: Enemy = {
...(nextEnemyCard as EnemyCard),
currentHp: (nextEnemyCard as EnemyCard).hp,
isDefeated: false,
};
state.currentEnemy = nextEnemy;
}
}
});
return { success: true, cardsUsed: totalValue, required: requiredValue };
}
registry.register('counterattack <cardIds>', counterattack);
// useJester - 使用小丑牌重抽
async function useJester(game: RegicideGame) {
const state = game.value;
if (state.jestersUsed >= 2) throw new Error('小丑牌已用完');
const unusedJester = state.jesters.find(j => !j.used);
if (!unusedJester) throw new Error('没有可用的小丑牌');
game.produce((state: RegicideState) => {
state.discardPile.push(...state.hand);
state.hand = [];
const cardsToDraw = Math.min(INITIAL_HAND_SIZE, state.tavernDeck.length);
const newCards = drawFromDeck(state.tavernDeck, cardsToDraw);
state.hand.push(...newCards);
const jesterIndex = state.jesters.findIndex(j => j.id === unusedJester.id);
if (jesterIndex !== -1) {
state.jesters[jesterIndex].used = true;
state.jestersUsed++;
}
});
return { message: `使用小丑牌,重新抽取 ${INITIAL_HAND_SIZE} 张牌` };
}
registry.register('useJester', useJester);
function calculateVictoryLevel(jestersUsed: number): 'gold' | 'silver' | 'bronze' {
if (jestersUsed === 0) return 'gold';
if (jestersUsed === 1) return 'silver';
return 'bronze';
}
export const prompts = {
playerAction: createPromptDef<[action: string, cardIds: string[]]>(
'play <action:string> <cardIds:string[]>'
),
counterattack: createPromptDef<[cardIds: string[]]>(
'counterattack <cardIds:string[]>'
),
};
// start 函数 - 游戏主循环
export async function start(game: RegicideGame) {
// 首先执行 setup
await setup(game);
// 主游戏循环
while (!game.value.isGameOver) {
const state = game.value;
// 玩家回合 - 等待玩家输入任何命令
if (state.phase === 'playerTurn' && state.currentEnemy && !state.currentEnemy.isDefeated) {
const { action, cardIds } = await game.prompt(
prompts.playerAction,
(action: string, cardIds: string[]) => {
const parts = cardIds.filter(a => a !== undefined && a !== '');
if (parts.length === 0) {
throw '请输入有效的命令';
}
return { action, cardIds: parts };
},
state.currentEnemy.id
);
// 解析并执行命令
try {
if (action === 'play' && cardIds.length >= 1) {
await playCard(game, cardIds[0]);
} else if (action === 'combo' && cardIds.length >= 2) {
await playCombo(game, cardIds);
} else if (action === 'yield') {
await yieldTurn(game);
} else if (action === 'useJester') {
await useJester(game);
} else {
// 无效命令,继续等待
continue;
}
} catch (e) {
// 命令执行失败,继续循环(玩家会再次被提示)
continue;
}
}
// 敌人反击阶段
if (state.phase === 'enemyCounterattack') {
// 等待玩家出牌来抵消反击伤害
if (state.currentEnemy) {
const requiredValue = state.currentEnemy.counterDamage;
const { cardIds } = await game.prompt(
prompts.counterattack,
(cardIds: string[]) => {
// 允许空牌提交(认输)
if (cardIds.length === 0) {
return { cardIds: [], totalValue: 0 };
}
const selectedCards: Card[] = [];
for (const cardId of cardIds) {
const card = state.hand.find(c => c.id === cardId);
if (!card) {
throw `卡牌 ${cardId} 不在手牌中`;
}
selectedCards.push(card);
}
const totalValue = selectedCards.reduce((sum, c) => sum + c.value, 0);
// 不再抛出错误,允许提交不足的牌,由 counterattack 处理失败逻辑
return { cardIds, totalValue };
},
state.currentEnemy.id
);
await counterattack(game, cardIds);
}
}
// 敌人被击败阶段
if (state.phase === 'enemyDefeated') {
game.produce((state: RegicideState) => {
// 准备迎接下一个敌人
if (state.castleDeck.length > 0) {
const nextEnemyCard = state.castleDeck.shift();
if (nextEnemyCard && 'hp' in nextEnemyCard && 'counterDamage' in nextEnemyCard) {
const nextEnemy: Enemy = {
...(nextEnemyCard as EnemyCard),
currentHp: (nextEnemyCard as EnemyCard).hp,
isDefeated: false,
};
state.currentEnemy = nextEnemy;
state.phase = 'playerTurn';
} else {
// 城堡牌堆没有有效的敌人卡牌,游戏胜利
state.isGameOver = true;
state.victoryLevel = calculateVictoryLevel(state.jestersUsed);
state.currentEnemy = null;
state.phase = 'gameOver';
}
} else {
// 城堡牌堆已空,游戏胜利
state.isGameOver = true;
state.victoryLevel = calculateVictoryLevel(state.jestersUsed);
state.currentEnemy = null;
state.phase = 'gameOver';
}
state.currentPlayed = null;
});
}
}
return game.value.victoryLevel;
}
export const gameModule: GameModule<RegicideState> = {
registry,
createInitialState,
start,
};
export type { RegicideState, Card, Enemy, JesterCard } from './types';
+85
View File
@@ -0,0 +1,85 @@
// 卡牌花色
export type CardSuit = 'hearts' | 'diamonds' | 'clubs' | 'spades';
// 卡牌点数
export type CardRank =
| '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | '10'
| 'J' | 'Q' | 'K' | 'A'
| 'jester';
// 基础卡牌
export interface Card {
id: string;
suit: CardSuit | null; // jester 没有花色
rank: CardRank;
value: number; // 攻击力/分数值
}
// 敌人卡 (J/Q/K)
export interface EnemyCard extends Card {
rank: 'J' | 'Q' | 'K';
hp: number;
counterDamage: number;
immunitySuit?: CardSuit; // 免疫的花色
}
// 小丑牌
export interface JesterCard {
id: string;
rank: 'jester';
used: boolean;
}
// 游戏中的敌人实例
export interface Enemy extends EnemyCard {
currentHp: number;
isDefeated: boolean;
}
// 游戏阶段
export type GamePhase =
| 'playerTurn' // 玩家回合 - 出牌
| 'enemyCounterattack' // 敌人反击
| 'enemyDefeated' // 敌人被击败
| 'gameOver'; // 游戏结束
// 游戏结果
export type VictoryLevel = 'gold' | 'silver' | 'bronze' | null;
// 打出的卡牌组合
export interface PlayedCards {
cards: Card[];
totalDamage: number;
suits: CardSuit[];
hasJester: boolean;
}
// Regicide 游戏状态
export interface RegicideState {
// 牌堆
castleDeck: Card[]; // 敌人牌堆 (J/Q/K)
tavernDeck: Card[]; // 酒馆牌堆 (数字牌+A)
discardPile: Card[]; // 弃牌堆
// 敌人
currentEnemy: Enemy | null;
defeatedEnemies: Enemy[];
// 玩家
hand: Card[];
// 小丑牌
jesters: JesterCard[];
jestersUsed: number;
// 当前回合信息
phase: GamePhase;
currentPlayed: PlayedCards | null;
// 游戏结果
victoryLevel: VictoryLevel;
isGameOver: boolean;
// 索引签名以满足 Record<string, unknown> 约束
[key: string]: unknown;
}
+13
View File
@@ -0,0 +1,13 @@
import { h } from 'preact';
import { GameUI } from 'boardgame-phaser';
import { gameModule } from './game/regicide';
import './style.css';
import App from "@/ui/App";
import { GameScene } from "@/scenes/GameScene";
const ui = new GameUI({
container: document.getElementById('ui-root')!,
root: <App gameModule={gameModule} gameScene={GameScene} />,
});
ui.mount();
@@ -0,0 +1,321 @@
import Phaser from 'phaser';
import type { RegicideState, Card, Enemy } from '@/game/types';
import { GameHostScene } from 'boardgame-phaser';
import { spawnEffect, type Spawner } from 'boardgame-phaser';
import { GameUI, CardView, EnemyView, PromptDisplay } from './views';
import { prompts } from '@/game/regicide';
import { counterattackInfo, setGameSceneRef, deckInfo } from '@/ui/App';
const GAME_WIDTH = 800;
const GAME_HEIGHT = 700;
const CENTER_X = GAME_WIDTH / 2;
const HAND_Y = 580;
const CARD_SPACING = 70;
export class GameScene extends GameHostScene<RegicideState> {
private gameUI!: GameUI;
private promptDisplay!: PromptDisplay;
// 追踪当前卡牌容器,用于点击检测
public cardContainers: Phaser.GameObjects.Container[] = [];
// 卡牌选择状态(反击阶段)
private selectedCardIds: Set<string> = new Set();
constructor() {
super('RegicideGameScene');
}
create(): void {
super.create();
setGameSceneRef(this);
// 创建 UI 组件
this.gameUI = new GameUI(this);
this.promptDisplay = new PromptDisplay(this);
// 生成效果
this.disposables.add(spawnEffect(new EnemySpawner(this)));
// 手牌管理 - 手动跟踪卡牌容器
const cardViews = new Map<string, CardView>();
this.addEffect(() => {
const hand = this.state.hand;
const handSize = hand.length;
const spacing = Math.min(CARD_SPACING, 600 / Math.max(1, handSize));
const startX = CENTER_X - (handSize - 1) * spacing / 2;
const currentIds = new Set(hand.map(c => c.id));
// 移除不存在的卡牌
for (const [id, view] of cardViews) {
if (!currentIds.has(id)) {
this.tweens.add({
targets: view.container,
alpha: 0,
scale: 0.5,
y: view.container.y - 100,
duration: 200,
ease: 'Back.easeIn',
onComplete: () => {
const idx = this.cardContainers.indexOf(view.container);
if (idx !== -1) this.cardContainers.splice(idx, 1);
view.destroy();
},
});
cardViews.delete(id);
}
}
// 添加或更新卡牌
for (let i = 0; i < handSize; i++) {
const card = hand[i];
const x = startX + i * spacing;
let view = cardViews.get(card.id);
if (!view) {
view = new CardView(this, card, CENTER_X, HAND_Y);
cardViews.set(card.id, view);
this.cardContainers.push(view.container);
// 入场动画
this.tweens.add({
targets: view.container,
x,
y: HAND_Y,
duration: 300,
ease: 'Back.easeOut',
});
} else {
view.setPosition(x, HAND_Y);
}
}
});
// 状态效果
this.addEffect(() => {
this.gameUI.updateEnemyDisplay(this, this.state.currentEnemy);
});
this.addEffect(() => {
this.gameUI.updatePhaseText(this.state.phase);
});
this.addEffect(() => {
if (this.state.isGameOver) {
this.gameUI.showGameOver(this, this.state.victoryLevel);
} else {
this.gameUI.hideGameOver();
}
});
// 监听 activePrompt 信号
this.addEffect(() => {
const schema = this.gameHost.activePromptSchema?.value;
const player = this.gameHost.activePromptPlayer?.value;
this.promptDisplay.update(schema, player);
});
// 监听阶段变化,更新反击信息
this.addEffect(() => {
const phase = this.state.phase;
const enemy = this.state.currentEnemy;
const totalValue = this.getTotalSelectedCardValue();
const required = enemy?.counterDamage ?? 0;
const maxHandValue = this.getMaxHandValue();
counterattackInfo.value = {
phase,
selectedCards: Array.from(this.selectedCardIds),
totalValue,
maxHandValue,
requiredValue: required,
canSubmit: this.selectedCardIds.size > 0 && totalValue >= required,
canWin: maxHandValue >= required,
error: null,
};
if (phase !== 'enemyCounterattack') {
this.selectedCardIds.clear();
}
});
// 监听牌堆变化,更新牌堆信息
this.addEffect(() => {
const state = this.state;
deckInfo.value = {
castleDeck: state.castleDeck.length,
tavernDeck: state.tavernDeck.length,
discardPile: state.discardPile.length,
hand: state.hand.length,
defeatedEnemies: state.defeatedEnemies.length,
jestersUsed: state.jestersUsed,
};
});
// 设置背景点击
this.setupBackgroundInput();
}
private setupBackgroundInput(): void {
this.input.on('pointerdown', (pointer: Phaser.Input.Pointer) => {
if (this.isPointerOnCard(pointer)) {
return;
}
// 点击空白区域取消选中(反击阶段)
if (this.state.phase === 'enemyCounterattack' && this.selectedCardIds.size > 0) {
this.selectedCardIds.clear();
for (const container of this.cardContainers) {
const view = container.getData('cardView') as CardView | undefined;
if (view) view.setSelected(false, this);
}
this.updateCounterattackInfo();
return;
}
if (this.state.isGameOver) {
this.gameHost.start();
} else if (!this.state.currentEnemy && this.state.phase === 'playerTurn') {
this.gameHost.start();
}
});
}
// 公共方法:供 Preact UI 调用提交反击
submitCounterattack(): void {
const cardIds = Array.from(this.selectedCardIds);
if (cardIds.length === 0) return;
const error = this.gameHost.tryAnswerPrompt(prompts.counterattack, cardIds);
if (error) {
counterattackInfo.value = {
...counterattackInfo.value,
error,
};
}
this.selectedCardIds.clear();
for (const container of this.cardContainers) {
const view = container.getData('cardView') as CardView | undefined;
if (view) view.setSelected(false, this);
}
this.updateCounterattackInfo();
}
// 公共方法:反击阶段认输
surrenderCounterattack(): void {
// 直接提交空牌,触发游戏结束
this.gameHost.tryAnswerPrompt(prompts.counterattack, []);
this.selectedCardIds.clear();
for (const container of this.cardContainers) {
const view = container.getData('cardView') as CardView | undefined;
if (view) view.setSelected(false, this);
}
this.updateCounterattackInfo();
}
private isPointerOnCard(pointer: Phaser.Input.Pointer): boolean {
for (const container of this.cardContainers) {
if (!container || !container.active) continue;
const bounds = container.getBounds();
if (
pointer.x >= bounds.left &&
pointer.x <= bounds.right &&
pointer.y >= bounds.top &&
pointer.y <= bounds.bottom
) {
return true;
}
}
return false;
}
// 卡牌点击回调(反击阶段)
onCardClicked(cardView: CardView): void {
const card = cardView.getCard();
if (this.selectedCardIds.has(card.id)) {
this.selectedCardIds.delete(card.id);
} else {
this.selectedCardIds.add(card.id);
}
this.updateCounterattackInfo();
}
private updateCounterattackInfo(): void {
const enemy = this.state.currentEnemy;
const totalValue = this.getTotalSelectedCardValue();
const required = enemy?.counterDamage ?? 0;
const maxHandValue = this.getMaxHandValue();
counterattackInfo.value = {
phase: this.state.phase,
selectedCards: Array.from(this.selectedCardIds),
totalValue,
maxHandValue,
requiredValue: required,
canSubmit: this.selectedCardIds.size > 0 && totalValue >= required,
canWin: maxHandValue >= required,
error: null,
};
}
private getTotalSelectedCardValue(): number {
let total = 0;
for (const id of this.selectedCardIds) {
const card = this.state.hand.find(c => c.id === id);
if (card) total += card.value;
}
return total;
}
private getMaxHandValue(): number {
return this.state.hand.reduce((sum, c) => sum + c.value, 0);
}
}
// 敌人生成器
class EnemySpawner implements Spawner<Enemy, Phaser.GameObjects.Container> {
constructor(public readonly scene: GameScene) {}
*getData() {
if (this.scene.state.currentEnemy && !this.scene.state.currentEnemy.isDefeated) {
yield this.scene.state.currentEnemy;
}
}
getKey(enemy: Enemy): string {
return enemy.id;
}
onUpdate(enemy: Enemy, container: Phaser.GameObjects.Container): void {
const view = container.getData('enemyView') as EnemyView | undefined;
if (view) {
view.setPosition(CENTER_X, 200);
view.updateEnemy(enemy);
}
}
onSpawn(enemy: Enemy): Phaser.GameObjects.Container {
const view = new EnemyView(this.scene, enemy, CENTER_X, 200);
const container = view.container;
container.setData('enemyView', view);
return container;
}
onDespawn(container: Phaser.GameObjects.Container): void {
const view = container.getData('enemyView') as EnemyView | undefined;
if (view) {
this.scene.tweens.add({
targets: container,
alpha: 0,
y: container.y - 150,
scale: 0.3,
duration: 400,
ease: 'Back.easeIn',
onComplete: () => view.destroy(),
});
} else {
container.destroy();
}
}
}
@@ -0,0 +1,259 @@
import Phaser from 'phaser';
// 伤害数字特效
export function showDamageNumber(
scene: Phaser.Scene,
x: number,
y: number,
damage: number,
color: number = 0xef4444
): void {
const text = scene.add.text(x, y, `-${damage}`, {
fontSize: '32px',
fontFamily: 'Arial',
color: `#${color.toString(16).padStart(6, '0')}`,
fontStyle: 'bold',
}).setOrigin(0.5);
scene.tweens.add({
targets: text,
y: y - 100,
alpha: 0,
scale: 1.5,
duration: 800,
ease: 'Power2',
onComplete: () => text.destroy(),
});
}
// 治疗数字特效
export function showHealNumber(
scene: Phaser.Scene,
x: number,
y: number,
amount: number
): void {
const text = scene.add.text(x, y, `+${amount}`, {
fontSize: '28px',
fontFamily: 'Arial',
color: '#22c55e',
fontStyle: 'bold',
}).setOrigin(0.5);
scene.tweens.add({
targets: text,
y: y - 80,
alpha: 0,
duration: 800,
ease: 'Power2',
onComplete: () => text.destroy(),
});
}
// 卡牌飞行特效
export function flyCardToTarget(
scene: Phaser.Scene,
card: Phaser.GameObjects.Container,
targetX: number,
targetY: number,
duration: number = 400,
onComplete?: () => void
): void {
const startX = card.x;
const startY = card.y;
// 创建飞行路径
const path = [
{ x: startX, y: startY },
{ x: startX + (targetX - startX) / 2, y: startY - 100 },
{ x: targetX, y: targetY },
];
scene.tweens.add({
targets: card,
x: targetX,
y: targetY,
scale: 0.5,
alpha: 0,
duration,
ease: 'Back.easeIn',
onComplete: () => {
if (onComplete) onComplete();
},
});
}
// 敌人受击特效
export function enemyHitEffect(
scene: Phaser.Scene,
enemyContainer: Phaser.GameObjects.Container
): void {
// 红色闪烁
const flash = scene.add.rectangle(
enemyContainer.x,
enemyContainer.y,
150,
200,
0xff0000,
0.5
);
scene.tweens.add({
targets: flash,
alpha: 0,
duration: 300,
onComplete: () => flash.destroy(),
});
// 震动效果
scene.tweens.add({
targets: enemyContainer,
x: enemyContainer.x + 10,
duration: 50,
yoyo: true,
repeat: 3,
});
}
// 敌人死亡特效
export function enemyDeathEffect(
scene: Phaser.Scene,
enemyContainer: Phaser.GameObjects.Container
): void {
// 粒子爆炸
const particles = scene.add.particles(enemyContainer.x, enemyContainer.y, 'particle', {
speed: { min: 50, max: 150 },
angle: { min: 0, max: 360 },
scale: { start: 1, end: 0 },
lifespan: 1000,
gravityY: 100,
quantity: 20,
emitting: true,
});
// 淡出并上升
scene.tweens.add({
targets: enemyContainer,
y: enemyContainer.y - 200,
alpha: 0,
scale: 0.5,
duration: 800,
ease: 'Back.easeIn',
onComplete: () => {
enemyContainer.destroy();
particles.destroy();
},
});
}
// 抽牌特效
export function drawCardEffect(
scene: Phaser.Scene,
fromX: number,
fromY: number,
toX: number,
toY: number
): void {
// 创建临时卡牌
const tempCard = scene.add.rectangle(fromX, fromY, 60, 90, 0x3b82f6, 0.8)
.setStrokeStyle(2, 0x60a5fa);
scene.tweens.add({
targets: tempCard,
x: toX,
y: toY,
duration: 300,
ease: 'Back.easeOut',
onComplete: () => tempCard.destroy(),
});
}
// 反击警告特效
export function counterattackWarning(
scene: Phaser.Scene,
x: number,
y: number,
damage: number
): void {
const warning = scene.add.text(x, y, `⚠️ 反击伤害: ${damage}`, {
fontSize: '24px',
fontFamily: 'Arial',
color: '#ef4444',
fontStyle: 'bold',
}).setOrigin(0.5);
// 闪烁动画
scene.tweens.add({
targets: warning,
alpha: 0.3,
duration: 300,
yoyo: true,
repeat: 2,
});
// 消失
scene.time.delayedCall(1500, () => {
scene.tweens.add({
targets: warning,
alpha: 0,
y: y - 50,
duration: 300,
onComplete: () => warning.destroy(),
});
});
}
// 胜利特效
export function victoryEffect(
scene: Phaser.Scene,
x: number,
y: number
): void {
// 烟花粒子
for (let i = 0; i < 3; i++) {
scene.time.delayedCall(i * 300, () => {
const particles = scene.add.particles(
x + (Math.random() - 0.5) * 200,
y + (Math.random() - 0.5) * 100,
'particle',
{
speed: { min: 30, max: 100 },
angle: { min: 0, max: 360 },
scale: { start: 1.5, end: 0 },
lifespan: 1500,
gravityY: 50,
quantity: 30,
emitting: true,
}
);
scene.time.delayedCall(1500, () => particles.destroy());
});
}
}
// 闪烁文本
export function createFlashingText(
scene: Phaser.Scene,
x: number,
y: number,
text: string,
color: string = '#fbbf24'
): Phaser.GameObjects.Text {
const flashingText = scene.add.text(x, y, text, {
fontSize: '20px',
fontFamily: 'Arial',
color,
fontStyle: 'bold',
}).setOrigin(0.5);
scene.tweens.add({
targets: flashingText,
alpha: 0.3,
duration: 500,
yoyo: true,
repeat: -1,
});
return flashingText;
}
@@ -0,0 +1,151 @@
import Phaser from 'phaser';
import type { Card } from '@/game/types';
import { getCardDisplay, getSuitColor } from '@/game/card-utils';
import type { GameScene } from '../GameScene';
import { prompts } from '@/game/regicide';
export const CARD_WIDTH = 80;
export const CARD_HEIGHT = 120;
export class CardView {
public readonly container: Phaser.GameObjects.Container;
private readonly bg: Phaser.GameObjects.Rectangle;
private readonly text: Phaser.GameObjects.Text;
private readonly valueText: Phaser.GameObjects.Text;
private readonly card: Card;
private isHovering = false;
private isSelected = false;
private baseY: number;
private selectedBorderColor = 0xfbbf24; // 金色边框表示选中
constructor(scene: GameScene, card: Card, x: number, y: number) {
this.card = card;
this.baseY = y;
this.container = scene.add.container(x, y);
// 卡牌背景
this.bg = scene.add.rectangle(0, 0, CARD_WIDTH, CARD_HEIGHT, 0xf9fafb)
.setStrokeStyle(3, 0x6b7280);
// 卡牌边框颜色
const borderColor = getSuitColor(card.suit);
this.bg.setStrokeStyle(3, Phaser.Display.Color.HexStringToColor(borderColor).color);
// 卡牌文本
const display = getCardDisplay(card);
const color = getSuitColor(card.suit);
this.text = scene.add.text(0, 0, display, {
fontSize: '24px',
fontFamily: 'Arial',
color,
fontStyle: 'bold',
}).setOrigin(0.5);
// 卡牌数值提示
this.valueText = scene.add.text(0, -CARD_HEIGHT / 2 + 15, `+${card.value}`, {
fontSize: '12px',
fontFamily: 'Arial',
color: '#6b7280',
}).setOrigin(0.5);
this.container.add([this.bg, this.valueText, this.text]);
// 设置交互
this.bg.setInteractive({ useHandCursor: true });
this.setupInteraction(scene);
// 出现动画
this.container.setScale(0);
scene.tweens.add({
targets: this.container,
scale: 1,
duration: 200,
ease: 'Back.easeOut',
});
}
setPosition(x: number, y: number): void {
this.baseY = y;
this.container.setPosition(x, y);
}
hover(offset: number = -30): void {
if (!this.isHovering) {
this.isHovering = true;
const scene = this.container.scene as Phaser.Scene;
scene.tweens.add({
targets: this.container,
y: this.baseY + offset + (this.isSelected ? -15 : 0),
duration: 100,
ease: 'Power2',
});
}
}
unhover(): void {
if (this.isHovering) {
this.isHovering = false;
const scene = this.container.scene as Phaser.Scene;
scene.tweens.add({
targets: this.container,
y: this.baseY + (this.isSelected ? -15 : 0),
duration: 100,
ease: 'Power2',
});
}
}
setSelected(selected: boolean, scene: GameScene): void {
if (this.isSelected === selected) return;
this.isSelected = selected;
// 更新边框颜色
const color = selected ? this.selectedBorderColor : getSuitColor(this.card.suit);
this.bg.setStrokeStyle(3, Phaser.Display.Color.HexStringToColor(
selected ? '#fbbf24' : getSuitColor(this.card.suit)
).color);
// 更新位置
const targetY = this.baseY + (selected ? -15 : 0);
scene.tweens.add({
targets: this.container,
y: targetY,
duration: 100,
ease: 'Power2',
});
}
getSelected(): boolean {
return this.isSelected;
}
getCard(): Card {
return this.card;
}
destroy(): void {
this.container.destroy();
}
private setupInteraction(scene: GameScene): void {
this.bg.on('pointerover', () => {
this.hover();
});
this.bg.on('pointerout', () => {
this.unhover();
});
this.bg.on('pointerdown', () => {
if (scene.state.phase === 'playerTurn') {
scene.gameHost.tryAnswerPrompt(prompts.playerAction, 'play', [this.card.id]);
} else if (scene.state.phase === 'enemyCounterattack') {
// 切换选中状态
const newSelected = !this.isSelected;
this.setSelected(newSelected, scene);
// 通知 GameScene 更新选中状态
scene.onCardClicked(this);
}
});
}
}
@@ -0,0 +1,110 @@
import Phaser from 'phaser';
import type { Enemy } from '@/game/types';
import type { GameScene } from '../GameScene';
const ENEMY_CARD_WIDTH = 144;
const ENEMY_CARD_HEIGHT = 180;
export class EnemyView {
public readonly container: Phaser.GameObjects.Container;
private readonly bg: Phaser.GameObjects.Rectangle;
private readonly nameText: Phaser.GameObjects.Text;
private readonly immunityText: Phaser.GameObjects.Text;
private readonly counterText: Phaser.GameObjects.Text;
private readonly hpText: Phaser.GameObjects.Text;
private enemy: Enemy;
constructor(scene: GameScene, enemy: Enemy, x: number, y: number) {
this.enemy = enemy;
this.container = scene.add.container(x, y);
// 敌人卡牌背景
this.bg = scene.add.rectangle(0, 0, ENEMY_CARD_WIDTH, ENEMY_CARD_HEIGHT, 0x1f2937)
.setStrokeStyle(4, 0xef4444);
// 敌人名称
const suitSymbols: Record<string, string> = {
hearts: '♥',
diamonds: '♦',
clubs: '♣',
spades: '♠',
};
const suitSymbol = enemy.suit ? suitSymbols[enemy.suit] : '';
this.nameText = scene.add.text(0, -40, `${enemy.rank} ${suitSymbol}`, {
fontSize: '32px',
fontFamily: 'Arial',
color: '#fbbf24',
fontStyle: 'bold',
}).setOrigin(0.5);
// 花色免疫提示
const suitNames: Record<string, string> = {
hearts: '红桃 ♥',
diamonds: '方片 ♦',
clubs: '梅花 ♣',
spades: '黑桃 ♠',
};
const immunityText = enemy.immunitySuit
? `🛡️ 免疫: ${suitNames[enemy.immunitySuit]}`
: '';
this.immunityText = scene.add.text(0, 0, immunityText, {
fontSize: '14px',
fontFamily: 'Arial',
color: '#9ca3af',
}).setOrigin(0.5);
// 反击伤害提示
this.counterText = scene.add.text(0, 30, `⚔️ 反击伤害: ${enemy.counterDamage}`, {
fontSize: '16px',
fontFamily: 'Arial',
color: '#ef4444',
fontStyle: 'bold',
}).setOrigin(0.5);
// HP提示
this.hpText = scene.add.text(0, 60, `❤️ ${enemy.hp} HP`, {
fontSize: '16px',
fontFamily: 'Arial',
color: '#22c55e',
fontStyle: 'bold',
}).setOrigin(0.5);
this.container.add([this.bg, this.nameText, this.immunityText, this.counterText, this.hpText]);
// 出现动画
this.container.setScale(0);
scene.tweens.add({
targets: this.container,
scale: 1,
duration: 400,
ease: 'Back.easeOut',
});
// 抖动效果
scene.tweens.add({
targets: this.container,
x: x + 5,
duration: 100,
yoyo: true,
repeat: 3,
});
}
updateEnemy(enemy: Enemy): void {
this.enemy = enemy;
this.hpText.setText(`❤️ ${enemy.currentHp}/${enemy.hp} HP`);
this.counterText.setText(`⚔️ 反击伤害: ${enemy.counterDamage}`);
}
setPosition(x: number, y: number): void {
this.container.setPosition(x, y);
}
getEnemy(): Enemy {
return this.enemy;
}
destroy(): void {
this.container.destroy();
}
}
@@ -0,0 +1,171 @@
import Phaser from 'phaser';
import type { Enemy, VictoryLevel } from '@/game/types';
import type { GameScene } from '../GameScene';
import { EnemyView } from './EnemyView';
const GAME_WIDTH = 800;
const GAME_HEIGHT = 700;
const CENTER_X = GAME_WIDTH / 2;
const ENEMY_Y = 200;
const BAR_WIDTH = 200;
const BAR_HEIGHT = 20;
export class GameUI {
public readonly container: Phaser.GameObjects.Container;
private readonly hpBar: Phaser.GameObjects.Graphics;
private readonly infoText: Phaser.GameObjects.Text;
private readonly phaseText: Phaser.GameObjects.Text;
private gameOverOverlay?: Phaser.GameObjects.Container;
private enemyView?: EnemyView;
constructor(scene: GameScene) {
this.container = scene.add.container(0, 0);
// 创建HP条
this.hpBar = scene.add.graphics();
// 创建文本
this.infoText = scene.add.text(CENTER_X, 30, '⚔️ Regicide - 击败所有12个敌人!', {
fontSize: '22px',
fontFamily: 'Arial',
color: '#fbbf24',
}).setOrigin(0.5);
this.phaseText = scene.add.text(CENTER_X, 60, '点击任意位置开始游戏', {
fontSize: '16px',
fontFamily: 'Arial',
color: '#9ca3af',
}).setOrigin(0.5);
}
updateEnemyDisplay(scene: GameScene, enemy: Enemy | null): void {
this.hpBar.clear();
if (!enemy) {
if (this.enemyView) {
this.enemyView.destroy();
this.enemyView = undefined;
}
return;
}
// 更新或创建敌人视图
if (this.enemyView) {
this.enemyView.updateEnemy(enemy);
} else {
this.enemyView = new EnemyView(scene, enemy, CENTER_X, ENEMY_Y);
}
// 绘制HP条
this.drawHpBar(enemy);
}
updatePhaseText(phase: string): void {
const phaseNames: Record<string, string> = {
playerTurn: '🎯 你的回合 - 点击卡牌攻击敌人',
enemyCounterattack: '💥 敌人反击! - 点击卡牌抵消伤害',
enemyDefeated: '✨ 敌人被击败! 准备迎战下一个敌人',
gameOver: '🏁 游戏结束',
};
this.phaseText.setText(phaseNames[phase] || phase);
}
showGameOver(scene: GameScene, victoryLevel: VictoryLevel): void {
if (this.gameOverOverlay) {
this.gameOverOverlay.destroy();
}
this.gameOverOverlay = scene.add.container();
// 半透明背景
const bg = scene.add.rectangle(CENTER_X, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.85)
.setInteractive({ useHandCursor: true });
bg.on('pointerdown', () => {
scene.gameHost.start();
});
this.gameOverOverlay.add(bg);
// 游戏结束文本
let resultText = '游戏结束';
let resultColor = '#ef4444';
if (victoryLevel === 'gold') {
resultText = '🥇 金胜利! 完美通关!';
resultColor = '#fbbf24';
} else if (victoryLevel === 'silver') {
resultText = '🥈 银胜利! 使用1张小丑牌';
resultColor = '#9ca3af';
} else if (victoryLevel === 'bronze') {
resultText = '🥉 铜胜利! 使用2张小丑牌';
resultColor = '#b45309';
} else {
resultText = '💀 失败! 无法抵消反击伤害';
}
const text = scene.add.text(CENTER_X, 250, resultText, {
fontSize: '36px',
fontFamily: 'Arial',
color: resultColor,
}).setOrigin(0.5);
this.gameOverOverlay.add(text);
const restartText = scene.add.text(CENTER_X, 350, '点击重新开始', {
fontSize: '24px',
fontFamily: 'Arial',
color: '#6b7280',
}).setOrigin(0.5);
this.gameOverOverlay.add(restartText);
// 动画
scene.tweens.add({
targets: text,
scale: 1.1,
duration: 500,
yoyo: true,
repeat: 2,
});
}
hideGameOver(): void {
if (this.gameOverOverlay) {
this.gameOverOverlay.destroy();
this.gameOverOverlay = undefined;
}
}
dispose(): void {
this.hpBar.destroy();
this.infoText.destroy();
this.phaseText.destroy();
if (this.gameOverOverlay) {
this.gameOverOverlay.destroy();
}
if (this.enemyView) {
this.enemyView.destroy();
}
this.container.destroy();
}
private drawHpBar(enemy: Enemy): void {
const hpPercent = enemy.currentHp / enemy.hp;
const barX = CENTER_X - BAR_WIDTH / 2;
const barY = ENEMY_Y + 100;
// 背景
this.hpBar.fillStyle(0x374151);
this.hpBar.fillRoundedRect(barX, barY, BAR_WIDTH, BAR_HEIGHT, 5);
// 前景
const hpColor = hpPercent > 0.5 ? 0x22c55e : hpPercent > 0.25 ? 0xf59e0b : 0xef4444;
this.hpBar.fillStyle(hpColor);
this.hpBar.fillRoundedRect(barX, barY, BAR_WIDTH * hpPercent, BAR_HEIGHT, 5);
// 边框
this.hpBar.lineStyle(2, 0x6b7280);
this.hpBar.strokeRoundedRect(barX, barY, BAR_WIDTH, BAR_HEIGHT, 5);
}
}
@@ -0,0 +1,83 @@
import Phaser from 'phaser';
import type { Card } from '@/game/types';
import { CardView, CARD_WIDTH } from './CardView';
import type { GameScene } from '../GameScene';
const HAND_Y = 580;
const MAX_HAND_WIDTH = 600;
const CARD_SPACING = 70;
export class HandContainer {
public readonly container: Phaser.GameObjects.Container;
private cardViews: Map<string, CardView> = new Map();
private centerX: number;
private handY: number;
constructor(scene: GameScene, centerX: number, handY: number = HAND_Y) {
this.container = scene.add.container(0, 0);
this.centerX = centerX;
this.handY = handY;
}
updateCards(scene: GameScene, cards: Card[]): void {
const currentCardIds = new Set(cards.map(c => c.id));
// 移除不在手牌中的卡牌
for (const [cardId, view] of this.cardViews) {
if (!currentCardIds.has(cardId)) {
this.animateCardExit(scene, view);
this.cardViews.delete(cardId);
}
}
// 更新或创建卡牌
const handSize = cards.length;
const spacing = Math.min(CARD_SPACING, MAX_HAND_WIDTH / handSize);
const startX = this.centerX - (handSize - 1) * spacing / 2;
for (let i = 0; i < cards.length; i++) {
const card = cards[i];
const existingView = this.cardViews.get(card.id);
if (existingView) {
// 更新位置
const targetX = startX + i * spacing;
const targetY = this.handY;
existingView.setPosition(targetX, targetY);
} else {
// 创建新卡牌
const x = startX + i * spacing;
const view = new CardView(scene, card, x, this.handY);
this.cardViews.set(card.id, view);
}
}
}
getCardView(cardId: string): CardView | undefined {
return this.cardViews.get(cardId);
}
getAllViews(): CardView[] {
return Array.from(this.cardViews.values());
}
dispose(): void {
for (const view of this.cardViews.values()) {
view.destroy();
}
this.cardViews.clear();
this.container.destroy();
}
private animateCardExit(scene: GameScene, view: CardView): void {
scene.tweens.add({
targets: view.container,
alpha: 0,
scale: 0.5,
y: view.container.y - 100,
duration: 200,
ease: 'Back.easeIn',
onComplete: () => view.destroy(),
});
}
}
@@ -0,0 +1,133 @@
import Phaser from 'phaser';
import type { CommandSchema } from 'boardgame-core';
import type { GameScene } from '../GameScene';
const GAME_WIDTH = 800;
const CENTER_X = GAME_WIDTH / 2;
export class PromptDisplay {
public readonly container: Phaser.GameObjects.Container;
private readonly bg: Phaser.GameObjects.Graphics;
private readonly promptText: Phaser.GameObjects.Text;
private readonly playerText: Phaser.GameObjects.Text;
private isVisible = false;
constructor(scene: GameScene) {
this.container = scene.add.container(CENTER_X, 450);
this.container.setDepth(100);
// 创建背景
this.bg = scene.add.graphics();
// 提示文本
this.promptText = scene.add.text(0, -15, '', {
fontSize: '18px',
fontFamily: 'Arial',
color: '#fbbf24',
fontStyle: 'bold',
align: 'center',
}).setOrigin(0.5);
// 玩家文本
this.playerText = scene.add.text(0, 15, '', {
fontSize: '14px',
fontFamily: 'Arial',
color: '#9ca3af',
align: 'center',
}).setOrigin(0.5);
this.container.add([this.bg, this.promptText, this.playerText]);
this.container.setVisible(false);
}
update(schema: CommandSchema | null, player: string | null): void {
if (!schema) {
if (this.isVisible) {
this.hide();
}
return;
}
if (!this.isVisible) {
this.show();
}
// 格式化 schema 显示
const formattedSchema = this.formatSchema(schema);
this.promptText.setText(formattedSchema);
if (player) {
this.playerText.setText(`等待玩家: ${player}`);
} else {
this.playerText.setText('');
}
}
private show(): void {
this.isVisible = true;
this.container.setVisible(true);
this.container.setScale(0);
this.container.setAlpha(0);
const scene = this.container.scene as Phaser.Scene;
scene.tweens.add({
targets: this.container,
scale: 1,
alpha: 1,
duration: 200,
ease: 'Back.easeOut',
});
this.drawBackground();
}
private hide(): void {
if (!this.isVisible) return;
this.isVisible = false;
const scene = this.container.scene as Phaser.Scene;
scene.tweens.add({
targets: this.container,
scale: 0.8,
alpha: 0,
duration: 150,
ease: 'Power2',
onComplete: () => this.container.setVisible(false),
});
}
private drawBackground(): void {
this.bg.clear();
const padding = 20;
const width = Math.max(
this.promptText.width,
this.playerText.width
) + padding * 2;
const height = 60;
// 半透明背景
this.bg.fillStyle(0x1f2937, 0.9);
this.bg.fillRoundedRect(-width / 2, -height / 2, width, height, 10);
// 边框
this.bg.lineStyle(2, 0xfbbf24, 0.6);
this.bg.strokeRoundedRect(-width / 2, -height / 2, width, height, 10);
}
private formatSchema(schema: CommandSchema): string {
// 格式化命令 schema 为可读字符串
const params = schema.params.map(p => {
return p.variadic ? `<${p.name}...>` : `<${p.name}>`;
}).join(' ');
return `${schema.name} ${params}`;
}
destroy(): void {
this.bg.destroy();
this.promptText.destroy();
this.playerText.destroy();
this.container.destroy();
}
}
@@ -0,0 +1,5 @@
export { CardView, CARD_WIDTH, CARD_HEIGHT } from './CardView';
export { EnemyView } from './EnemyView';
export { HandContainer } from './HandContainer';
export { GameUI } from './GameUI';
export { PromptDisplay } from './PromptDisplay';
+26
View File
@@ -0,0 +1,26 @@
@import "tailwindcss";
body {
margin: 0;
padding: 0;
overflow: hidden;
background-color: #111827;
}
#app {
width: 100vw;
height: 100vh;
}
#ui-root {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
#ui-root > * {
pointer-events: auto;
}
+209
View File
@@ -0,0 +1,209 @@
import { useComputed, signal } from '@preact/signals';
import { createGameHost, type GameModule } from 'boardgame-core';
import Phaser from 'phaser';
import { h } from 'preact';
import { PhaserGame, PhaserScene } from 'boardgame-phaser';
// 全局信号:从 GameScene 传递反击阶段的状态到 UI
export const counterattackInfo = signal<{
phase: string;
selectedCards: string[];
totalValue: number;
maxHandValue: number; // 手牌最大可提供的点数
requiredValue: number;
canSubmit: boolean;
canWin: boolean; // 手牌是否足以获胜
error: string | null;
}>({
phase: '',
selectedCards: [],
totalValue: 0,
maxHandValue: 0,
requiredValue: 0,
canSubmit: false,
canWin: false,
error: null,
});
// 全局信号:牌堆信息
export const deckInfo = signal<{
castleDeck: number; // 敌人牌堆剩余
tavernDeck: number; // 酒馆牌堆剩余
discardPile: number; // 弃牌堆数量
hand: number; // 手牌数
defeatedEnemies: number; // 已击败敌人数
jestersUsed: number; // 已用小丑牌数
}>({
castleDeck: 0,
tavernDeck: 0,
discardPile: 0,
hand: 0,
defeatedEnemies: 0,
jestersUsed: 0,
});
// 存储当前场景引用(由 GameScene 在 create 时设置)
let currentGameScene: any = null;
export function setGameSceneRef(scene: any) {
currentGameScene = scene;
}
export default function App<TState extends Record<string, unknown>>(props: { gameModule: GameModule<TState>, gameScene: { new(): Phaser.Scene } }) {
const gameHost = useComputed(() => {
const gameHost = createGameHost(props.gameModule);
return { gameHost };
});
const scene = useComputed(() => new props.gameScene());
const handleReset = async () => {
const result = await gameHost.value.gameHost.start();
console.log('Game finished!', result);
};
const label = useComputed(() =>
gameHost.value.gameHost.status.value === 'running' ? '重新开始' : '开始游戏'
);
// 反击阶段提交
const handleSubmitCounterattack = () => {
if (currentGameScene && typeof currentGameScene.submitCounterattack === 'function') {
currentGameScene.submitCounterattack();
}
};
// 反击阶段认输
const handleSurrender = () => {
if (currentGameScene && typeof currentGameScene.surrenderCounterattack === 'function') {
currentGameScene.surrenderCounterattack();
}
};
// Phaser 画布配置
const phaserConfig = {
type: Phaser.AUTO,
width: 800,
height: 700,
backgroundColor: '#111827',
};
return (
<div class="flex flex-col h-screen bg-gray-900">
{/* Phaser 游戏场景 */}
<div class="flex-1 relative flex items-center justify-center">
<PhaserGame config={phaserConfig}>
<PhaserScene sceneKey="RegicideGameScene" scene={scene.value} autoStart data={gameHost.value} />
</PhaserGame>
</div>
{/* 底部控制栏 */}
<div class="p-4 bg-gray-900 border-t border-gray-700 flex flex-col gap-3">
{/* 牌堆信息 */}
<div class="flex items-center justify-center gap-6 text-sm">
<div class="flex items-center gap-1.5">
<span class="text-lg">🏰</span>
<span class="text-gray-400">城堡牌堆:</span>
<span class="text-purple-400 font-bold">{deckInfo.value.castleDeck}</span>
</div>
<div class="flex items-center gap-1.5">
<span class="text-lg">🍺</span>
<span class="text-gray-400">酒馆牌堆:</span>
<span class="text-amber-400 font-bold">{deckInfo.value.tavernDeck}</span>
</div>
<div class="flex items-center gap-1.5">
<span class="text-lg">🗑️</span>
<span class="text-gray-400">弃牌堆:</span>
<span class="text-gray-300 font-bold">{deckInfo.value.discardPile}</span>
</div>
<div class="flex items-center gap-1.5">
<span class="text-lg">🃏</span>
<span class="text-gray-400">手牌:</span>
<span class="text-blue-400 font-bold">{deckInfo.value.hand}</span>
</div>
<div class="flex items-center gap-1.5">
<span class="text-lg">💀</span>
<span class="text-gray-400">已击败:</span>
<span class="text-red-400 font-bold">{deckInfo.value.defeatedEnemies}/12</span>
</div>
<div class="flex items-center gap-1.5">
<span class="text-lg">🃏</span>
<span class="text-gray-400">小丑牌:</span>
<span class="text-green-400 font-bold">{2 - deckInfo.value.jestersUsed}/2</span>
</div>
</div>
{/* 反击阶段信息 + 按钮 */}
<div class="flex justify-between items-center">
<div class="text-sm text-gray-400">
⚔️ Regicide - 击败所有12个敌人
</div>
{/* 反击阶段信息面板 */}
{counterattackInfo.value.phase === 'enemyCounterattack' && (
<div class="flex items-center gap-4">
<div class="text-sm text-gray-300">
<span class="text-yellow-400 font-bold">💥 反击阶段</span>
<span class="ml-2">需要: </span>
<span class="text-red-400 font-bold">{counterattackInfo.value.requiredValue}</span>
<span class="ml-2">| 手牌最多: </span>
<span class={counterattackInfo.value.canWin ? 'text-green-400 font-bold' : 'text-red-400 font-bold'}>
{counterattackInfo.value.maxHandValue}
</span>
</div>
{counterattackInfo.value.selectedCards.length > 0 && (
<div class="text-sm text-gray-300">
<span>已选: </span>
<span class="text-blue-400 font-bold">{counterattackInfo.value.selectedCards.length}张</span>
<span class="ml-2">点数: </span>
<span class={counterattackInfo.value.canSubmit ? 'text-green-400 font-bold' : 'text-orange-400 font-bold'}>
{counterattackInfo.value.totalValue}
</span>
</div>
)}
{counterattackInfo.value.error && (
<div class="text-sm text-red-400">
❌ {counterattackInfo.value.error}
</div>
)}
</div>
)}
<div class="flex gap-2">
{/* 反击确认按钮 */}
{counterattackInfo.value.phase === 'enemyCounterattack' && counterattackInfo.value.selectedCards.length > 0 && (
<button
onClick={handleSubmitCounterattack}
disabled={!counterattackInfo.value.canSubmit}
class={`px-4 py-2 rounded font-medium transition-colors ${
counterattackInfo.value.canSubmit
? 'bg-green-600 text-white hover:bg-green-700'
: 'bg-gray-600 text-gray-400 cursor-not-allowed'
}`}
>
✓ 确认出牌
</button>
)}
{/* 认输按钮 */}
{counterattackInfo.value.phase === 'enemyCounterattack' && !counterattackInfo.value.canWin && (
<button
onClick={handleSurrender}
class="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 transition-colors font-medium"
>
🏳️ 认输
</button>
)}
<button
onClick={handleReset}
class="px-6 py-2 bg-yellow-600 text-white rounded hover:bg-yellow-700 disabled:bg-gray-600 disabled:cursor-not-allowed transition-colors font-medium"
>
{label}
</button>
</div>
</div>
</div>
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"jsx": "react-jsx",
"jsxImportSource": "preact",
"noEmit": true,
"declaration": false,
"declarationMap": false,
"sourceMap": false
},
"include": ["src/**/*"]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig } from 'vite';
import preact from '@preact/preset-vite';
import tailwindcss from '@tailwindcss/vite';
import { resolve } from 'path';
export default defineConfig({
plugins: [preact(), tailwindcss()],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
'boardgame-phaser': resolve(__dirname, '../framework/src/index.ts'),
},
},
});
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/*.test.ts'],
},
resolve: {
alias: {
'@': '/src',
},
},
});
+17 -159
View File
@@ -1,161 +1,19 @@
import {
createGameCommandRegistry,
type Part,
createRegion,
type MutableSignal,
type GameModule,
} from 'boardgame-core';
const BOARD_SIZE = 3;
const MAX_TURNS = BOARD_SIZE * BOARD_SIZE;
const WINNING_LINES: number[][][] = [
[[0, 0], [0, 1], [0, 2]],
[[1, 0], [1, 1], [1, 2]],
[[2, 0], [2, 1], [2, 2]],
[[0, 0], [1, 0], [2, 0]],
[[0, 1], [1, 1], [2, 1]],
[[0, 2], [1, 2], [2, 2]],
[[0, 0], [1, 1], [2, 2]],
[[0, 2], [1, 1], [2, 0]],
];
export type PlayerType = 'X' | 'O';
export type WinnerType = PlayerType | 'draw' | null;
export type TicTacToePart = Part<{ player: PlayerType }>;
export function createInitialState() {
return {
board: createRegion('board', [
{ name: 'x', min: 0, max: BOARD_SIZE - 1 },
{ name: 'y', min: 0, max: BOARD_SIZE - 1 },
]),
parts: {} as Record<string, TicTacToePart>,
currentPlayer: 'X' as PlayerType,
winner: null as WinnerType,
turn: 0,
};
}
export type TicTacToeState = ReturnType<typeof createInitialState>;
const registration = createGameCommandRegistry<TicTacToeState>();
export const registry = registration.registry;
export const gameModule: GameModule<TicTacToeState> = {
/**
* Re-export tic-tac-toe game module from boardgame-core
* This provides a convenient import path within sample-game
*/
export {
registry,
prompts,
start,
createInitialState,
};
registration.add('setup', async function () {
const { context } = this;
while (true) {
const currentPlayer = context.value.currentPlayer;
const turnNumber = context.value.turn + 1;
const turnOutput = await this.run<{ winner: WinnerType }>(`turn ${currentPlayer} ${turnNumber}`);
if (!turnOutput.success) throw new Error(turnOutput.error);
context.produce(state => {
state.winner = turnOutput.result.winner;
if (!state.winner) {
state.currentPlayer = state.currentPlayer === 'X' ? 'O' : 'X';
state.turn = turnNumber;
}
});
if (context.value.winner) break;
}
return context.value;
});
registration.add('reset', async function () {
const { context } = this;
context.produce(state => {
state.parts = {};
state.board.childIds = [];
state.board.partMap = {};
state.currentPlayer = 'X';
state.winner = null;
state.turn = 0;
});
// 重启主循环
return this.run('setup');
});
registration.add('turn <player> <turn:number>', async function (cmd) {
const [turnPlayer, turnNumber] = cmd.params as [PlayerType, number];
const playCmd = await this.prompt(
'play <player> <row:number> <col:number>',
(command) => {
const [player, row, col] = command.params as [PlayerType, number, number];
if (player !== turnPlayer) {
return `Invalid player: ${player}. Expected ${turnPlayer}.`;
}
if (row < 0 || row >= BOARD_SIZE || col < 0 || col >= BOARD_SIZE) {
return `Invalid position: (${row}, ${col}).`;
}
const state = this.context.value;
const partId = state.board.partMap[`${row},${col}`];
if (partId) {
return `Cell (${row}, ${col}) is already occupied.`;
}
return null;
},
);
const [player, row, col] = playCmd.params as [PlayerType, number, number];
placePiece(this.context, row, col, turnPlayer);
const winner = checkWinner(this.context);
if (winner) return { winner };
if (turnNumber >= MAX_TURNS) return { winner: 'draw' as WinnerType };
return { winner: null };
});
export function isCellOccupied(host: MutableSignal<TicTacToeState>, row: number, col: number): boolean {
return !!host.value.board.partMap[`${row},${col}`];
}
export function hasWinningLine(positions: number[][]): boolean {
return WINNING_LINES.some(line =>
line.every(([r, c]) =>
positions.some(([pr, pc]) => pr === r && pc === c),
),
);
}
export function checkWinner(host: MutableSignal<TicTacToeState>): WinnerType {
const parts = host.value.parts;
const xPositions = Object.values(parts).filter((p: TicTacToePart) => p.player === 'X').map((p: TicTacToePart) => p.position);
const oPositions = Object.values(parts).filter((p: TicTacToePart) => p.player === 'O').map((p: TicTacToePart) => p.position);
if (hasWinningLine(xPositions)) return 'X';
if (hasWinningLine(oPositions)) return 'O';
if (Object.keys(parts).length >= MAX_TURNS) return 'draw';
return null;
}
export function placePiece(host: MutableSignal<TicTacToeState>, row: number, col: number, player: PlayerType) {
const moveNumber = Object.keys(host.value.parts).length + 1;
const piece: TicTacToePart = {
id: `piece-${player}-${moveNumber}`,
regionId: 'board',
position: [row, col],
player,
};
host.produce(state => {
state.parts[piece.id] = piece;
state.board.childIds.push(piece.id);
state.board.partMap[`${row},${col}`] = piece.id;
});
}
/** 命令构建器:类型安全地生成命令字符串 */
export const commands = {
play: (player: PlayerType, row: number, col: number) => `play ${player} ${row} ${col}`,
turn: (player: PlayerType, turn: number) => `turn ${player} ${turn}`,
} as const;
isCellOccupied,
hasWinningLine,
checkWinner,
placePiece,
type TicTacToePart,
type TicTacToeState,
type TicTacToeGame,
type PlayerType,
type WinnerType,
} from "boardgame-core/samples/tic-tac-toe";
+1 -3
View File
@@ -1,13 +1,11 @@
import { h } from 'preact';
import { GameUI } from 'boardgame-phaser';
import { gameModule } from './game/tic-tac-toe';
import './style.css';
import App from "@/ui/App";
import {GameScene} from "@/scenes/GameScene";
const ui = new GameUI({
container: document.getElementById('ui-root')!,
root: <App gameModule={gameModule} gameScene={GameScene}/>,
root: <App/>,
});
ui.mount();
+192 -87
View File
@@ -2,18 +2,41 @@ import Phaser from 'phaser';
import type {TicTacToeState, TicTacToePart} from '@/game/tic-tac-toe';
import { GameHostScene } from 'boardgame-phaser';
import { spawnEffect, type Spawner } from 'boardgame-phaser';
import type { ReadonlySignal } from '@preact/signals-core';
import {commands} from "@/game/tic-tac-toe";
import {prompts} from "@/game/tic-tac-toe";
const CELL_SIZE = 120;
const BOARD_OFFSET = { x: 100, y: 100 };
const BOARD_SIZE = 3;
// 棋盘配置常量
export const BOARD_CONFIG = {
cellSize: 120,
boardOffset: { x: 100, y: 100 },
boardSize: 3,
colors: {
grid: 0x6b7280,
x: '#3b82f6',
o: '#ef4444',
title: '#1f2937',
turn: '#4b5563',
menuButton: 0x6b7280,
menuButtonHover: 0x4b5563,
overlay: 0x000000,
winText: '#fbbf24',
},
fontSize: {
title: '28px',
turn: '20px',
cell: '64px',
menuButton: '18px',
winText: '36px',
},
} as const;
export class GameScene extends GameHostScene<TicTacToeState> {
private boardContainer!: Phaser.GameObjects.Container;
private gridGraphics!: Phaser.GameObjects.Graphics;
private turnText!: Phaser.GameObjects.Text;
private winnerOverlay?: Phaser.GameObjects.Container;
private menuButtonContainer!: Phaser.GameObjects.Container;
private menuButtonBg!: Phaser.GameObjects.Rectangle;
private menuButtonText!: Phaser.GameObjects.Text;
constructor() {
super('GameScene');
@@ -24,11 +47,14 @@ export class GameScene extends GameHostScene<TicTacToeState> {
this.boardContainer = this.add.container(0, 0);
this.gridGraphics = this.add.graphics();
this.drawGrid();
this.disposables.add(spawnEffect(new TicTacToePartSpawner(this, this.gameHost.state)));
this.createBoardVisuals();
this.createMenuButton();
this.createInputZones();
this.watch(() => {
this.disposables.add(spawnEffect(new TicTacToePartSpawner(this)));
this.addEffect(() => {
const winner = this.state.winner;
if (winner) {
this.showWinner(winner);
@@ -38,82 +64,141 @@ export class GameScene extends GameHostScene<TicTacToeState> {
}
});
this.watch(() => {
this.addEffect(() => {
const currentPlayer = this.state.currentPlayer;
this.updateTurnText(currentPlayer);
});
this.setupInput();
this.gameHost.start();
}
private isCellOccupied(row: number, col: number): boolean {
return !!this.state.board.partMap[`${row},${col}`];
/** 创建棋盘视觉元素(网格、标题、回合提示) */
private createBoardVisuals(): void {
this.drawGrid();
const { boardSize, cellSize, boardOffset } = BOARD_CONFIG;
const centerX = boardOffset.x + (boardSize * cellSize) / 2;
this.add.text(centerX, boardOffset.y - 40, 'Tic-Tac-Toe', {
fontSize: BOARD_CONFIG.fontSize.title,
fontFamily: 'Arial',
color: BOARD_CONFIG.colors.title,
}).setOrigin(0.5);
this.turnText = this.add.text(
centerX,
boardOffset.y + boardSize * cellSize + 20,
'',
{
fontSize: BOARD_CONFIG.fontSize.turn,
fontFamily: 'Arial',
color: BOARD_CONFIG.colors.turn,
}
).setOrigin(0.5);
this.updateTurnText(this.state.currentPlayer);
}
private setupInput(): void {
for (let row = 0; row < BOARD_SIZE; row++) {
for (let col = 0; col < BOARD_SIZE; col++) {
const x = BOARD_OFFSET.x + col * CELL_SIZE + CELL_SIZE / 2;
const y = BOARD_OFFSET.y + row * CELL_SIZE + CELL_SIZE / 2;
/** 绘制棋盘网格 */
private drawGrid(): void {
const g = this.gridGraphics;
const { boardSize, cellSize, boardOffset } = BOARD_CONFIG;
const zone = this.add.zone(x, y, CELL_SIZE, CELL_SIZE).setInteractive();
g.lineStyle(3, BOARD_CONFIG.colors.grid);
for (let i = 1; i < boardSize; i++) {
g.lineBetween(
boardOffset.x + i * cellSize,
boardOffset.y,
boardOffset.x + i * cellSize,
boardOffset.y + boardSize * cellSize,
);
g.lineBetween(
boardOffset.x,
boardOffset.y + i * cellSize,
boardOffset.x + boardSize * cellSize,
boardOffset.y + i * cellSize,
);
}
g.strokePath();
}
/** 创建菜单按钮 */
private createMenuButton(): void {
const buttonX = this.game.scale.width - 80;
const buttonY = 30;
this.menuButtonBg = this.add.rectangle(buttonX, buttonY, 120, 40, BOARD_CONFIG.colors.menuButton)
.setInteractive({ useHandCursor: true });
this.menuButtonText = this.add.text(buttonX, buttonY, 'Menu', {
fontSize: BOARD_CONFIG.fontSize.menuButton,
fontFamily: 'Arial',
color: '#ffffff',
}).setOrigin(0.5);
this.menuButtonContainer = this.add.container(buttonX, buttonY, [
this.menuButtonBg,
this.menuButtonText,
]);
// 按钮交互
this.menuButtonBg.on('pointerover', () => {
this.menuButtonBg.setFillStyle(BOARD_CONFIG.colors.menuButtonHover);
});
this.menuButtonBg.on('pointerout', () => {
this.menuButtonBg.setFillStyle(BOARD_CONFIG.colors.menuButton);
});
this.menuButtonBg.on('pointerdown', () => {
this.goToMenu();
});
}
/** 创建输入区域 */
private createInputZones(): void {
const { boardSize, cellSize, boardOffset } = BOARD_CONFIG;
for (let row = 0; row < boardSize; row++) {
for (let col = 0; col < boardSize; col++) {
const x = boardOffset.x + col * cellSize + cellSize / 2;
const y = boardOffset.y + row * cellSize + cellSize / 2;
const zone = this.add.zone(x, y, cellSize, cellSize).setInteractive();
zone.on('pointerdown', () => {
if (this.state.winner) return;
if (this.isCellOccupied(row, col)) return;
const cmd = commands.play(this.state.currentPlayer, row, col);
const error = this.gameHost.onInput(cmd);
if (error) {
console.warn('Invalid move:', error);
}
this.handleCellClick(row, col);
});
}
}
}
private drawGrid(): void {
const g = this.gridGraphics;
g.lineStyle(3, 0x6b7280);
/** 处理格子点击 */
private handleCellClick(row: number, col: number): void {
if (this.state.winner) return;
if (this.isCellOccupied(row, col)) return;
for (let i = 1; i < BOARD_SIZE; i++) {
g.lineBetween(
BOARD_OFFSET.x + i * CELL_SIZE,
BOARD_OFFSET.y,
BOARD_OFFSET.x + i * CELL_SIZE,
BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE,
);
g.lineBetween(
BOARD_OFFSET.x,
BOARD_OFFSET.y + i * CELL_SIZE,
BOARD_OFFSET.x + BOARD_SIZE * CELL_SIZE,
BOARD_OFFSET.y + i * CELL_SIZE,
);
const error = this.gameHost.tryAnswerPrompt(prompts.play, this.state.currentPlayer, row, col);
if (error) {
console.warn('Invalid move:', error);
}
g.strokePath();
this.add.text(BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2, BOARD_OFFSET.y - 40, 'Tic-Tac-Toe', {
fontSize: '28px',
fontFamily: 'Arial',
color: '#1f2937',
}).setOrigin(0.5);
this.turnText = this.add.text(BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2, BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE + 20, '', {
fontSize: '20px',
fontFamily: 'Arial',
color: '#4b5563',
}).setOrigin(0.5);
this.updateTurnText(this.state.currentPlayer);
}
/** 检查格子是否被占用 */
private isCellOccupied(row: number, col: number): boolean {
return !!this.state.board.partMap[`${row},${col}`];
}
/** 更新回合提示文本 */
private updateTurnText(player: string): void {
if (this.turnText) {
this.turnText.setText(`${player}'s turn`);
}
}
/** 显示获胜者 */
private showWinner(winner: string): void {
// 清理旧的覆盖层防止叠加
if (this.winnerOverlay) {
@@ -123,32 +208,30 @@ export class GameScene extends GameHostScene<TicTacToeState> {
this.winnerOverlay = this.add.container();
const text = winner === 'draw' ? "It's a draw!" : `${winner} wins!`;
const { boardSize, cellSize, boardOffset } = BOARD_CONFIG;
const centerX = boardOffset.x + (boardSize * cellSize) / 2;
const centerY = boardOffset.y + (boardSize * cellSize) / 2;
const bg = this.add.rectangle(
BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2,
BOARD_OFFSET.y + (BOARD_SIZE * CELL_SIZE) / 2,
BOARD_SIZE * CELL_SIZE,
BOARD_SIZE * CELL_SIZE,
0x000000,
centerX,
centerY,
boardSize * cellSize,
boardSize * cellSize,
BOARD_CONFIG.colors.overlay,
0.6,
).setInteractive({ useHandCursor: true });
bg.on('pointerdown', () => {
this.gameHost.setup('setup');
this.gameHost.start();
});
this.winnerOverlay.add(bg);
const winText = this.add.text(
BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2,
BOARD_OFFSET.y + (BOARD_SIZE * CELL_SIZE) / 2,
text,
{
fontSize: '36px',
fontFamily: 'Arial',
color: '#fbbf24',
},
).setOrigin(0.5);
const winText = this.add.text(centerX, centerY, text, {
fontSize: BOARD_CONFIG.fontSize.winText,
fontFamily: 'Arial',
color: BOARD_CONFIG.colors.winText,
}).setOrigin(0.5);
this.winnerOverlay.add(winText);
@@ -160,34 +243,39 @@ export class GameScene extends GameHostScene<TicTacToeState> {
repeat: 1,
});
}
/** 跳转到菜单场景 */
private async goToMenu(): Promise<void> {
await this.sceneController.launch('MenuScene');
}
}
/** 棋子生成器 */
class TicTacToePartSpawner implements Spawner<TicTacToePart, Phaser.GameObjects.Text> {
constructor(public readonly scene: GameScene, public readonly state: ReadonlySignal<TicTacToeState>) {}
constructor(public readonly scene: GameScene) {}
*getData() {
for (const part of Object.values(this.state.value.parts)) {
for (const part of Object.values(this.scene.state.parts)) {
yield part;
}
}
getKey(part: TicTacToePart): string {
return part.id;
}
onUpdate(part: TicTacToePart, obj: Phaser.GameObjects.Text): void {
const [yIndex, xIndex] = part.position;
const x = xIndex * CELL_SIZE + BOARD_OFFSET.x;
const y = yIndex * CELL_SIZE + BOARD_OFFSET.y;
obj.x = x + CELL_SIZE / 2;
obj.y = y + CELL_SIZE / 2;
this.updatePosition(part, obj);
}
onSpawn(part: TicTacToePart) {
const [yIndex, xIndex] = part.position;
const x = xIndex * CELL_SIZE + BOARD_OFFSET.x;
const y = yIndex * CELL_SIZE + BOARD_OFFSET.y;
const text = this.scene.add.text(x + CELL_SIZE / 2, y + CELL_SIZE / 2, part.player, {
fontSize: '64px',
const { cellSize, boardOffset } = BOARD_CONFIG;
const pos = this.calculatePosition(part.position);
const text = this.scene.add.text(pos.x, pos.y, part.player, {
fontSize: BOARD_CONFIG.fontSize.cell,
fontFamily: 'Arial',
color: part.player === 'X' ? '#3b82f6' : '#ef4444',
color: part.player === 'X' ? BOARD_CONFIG.colors.x : BOARD_CONFIG.colors.o,
}).setOrigin(0.5);
// 添加落子动画
@@ -211,4 +299,21 @@ class TicTacToePartSpawner implements Spawner<TicTacToePart, Phaser.GameObjects.
onComplete: () => obj.destroy(),
});
}
/** 计算格子的屏幕位置 */
private calculatePosition(position: number[]): { x: number; y: number } {
const { cellSize, boardOffset } = BOARD_CONFIG;
const [yIndex, xIndex] = position;
return {
x: xIndex * cellSize + boardOffset.x + cellSize / 2,
y: yIndex * cellSize + boardOffset.y + cellSize / 2,
};
}
/** 更新对象位置 */
private updatePosition(part: TicTacToePart, obj: Phaser.GameObjects.Text): void {
const pos = this.calculatePosition(part.position);
obj.x = pos.x;
obj.y = pos.y;
}
}
@@ -0,0 +1,155 @@
import { ReactiveScene } from 'boardgame-phaser';
/** 菜单场景配置 */
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: 60,
},
positions: {
titleY: -100,
buttonY: 40,
subtitleY: 140,
},
} as const;
export class MenuScene extends ReactiveScene {
private titleText!: Phaser.GameObjects.Text;
private startButtonContainer!: Phaser.GameObjects.Container;
private startButtonBg!: Phaser.GameObjects.Rectangle;
private startButtonText!: Phaser.GameObjects.Text;
constructor() {
super('MenuScene');
}
create(): void {
super.create();
const center = this.getCenterPosition();
this.createTitle(center);
this.createStartButton(center);
this.createSubtitle(center);
}
/** 获取屏幕中心位置 */
private getCenterPosition(): { x: number; y: number } {
return {
x: this.game.scale.width / 2,
y: this.game.scale.height / 2,
};
}
/** 创建标题文本 */
private createTitle(center: { x: number; y: number }): void {
this.titleText = this.add.text(
center.x,
center.y + MENU_CONFIG.positions.titleY,
'Tic-Tac-Toe',
{
fontSize: MENU_CONFIG.fontSize.title,
fontFamily: 'Arial',
color: MENU_CONFIG.colors.title,
}
).setOrigin(0.5);
// 标题入场动画
this.titleText.setScale(0);
this.tweens.add({
targets: this.titleText,
scale: 1,
duration: 600,
ease: 'Back.easeOut',
});
}
/** 创建开始按钮 */
private createStartButton(center: { x: number; y: number }): void {
const { button, colors } = MENU_CONFIG;
this.startButtonBg = this.add.rectangle(
-button.width/2,
-button.height/2,
button.width,
button.height,
colors.buttonBg
).setOrigin(0.5).setInteractive({ useHandCursor: true });
this.startButtonText = this.add.text(
-button.width/2,
-button.height/2,
'Start Game',
{
fontSize: MENU_CONFIG.fontSize.button,
fontFamily: 'Arial',
color: colors.buttonText,
}
).setOrigin(0.5);
this.startButtonContainer = this.add.container(
center.x,
center.y + MENU_CONFIG.positions.buttonY,
[this.startButtonBg, this.startButtonText]
);
// 按钮交互
this.setupButtonInteraction();
}
/** 设置按钮交互效果 */
private setupButtonInteraction(): void {
this.startButtonBg.on('pointerover', () => {
this.startButtonBg.setFillStyle(MENU_CONFIG.colors.buttonBgHover);
this.tweens.add({
targets: this.startButtonContainer,
scale: 1.05,
duration: 100,
});
});
this.startButtonBg.on('pointerout', () => {
this.startButtonBg.setFillStyle(MENU_CONFIG.colors.buttonBg);
this.tweens.add({
targets: this.startButtonContainer,
scale: 1,
duration: 100,
});
});
this.startButtonBg.on('pointerdown', () => {
this.startGame();
});
}
/** 创建副标题 */
private createSubtitle(center: { x: number; y: number }): void {
this.add.text(
center.x,
center.y + MENU_CONFIG.positions.subtitleY,
'Click to start playing',
{
fontSize: MENU_CONFIG.fontSize.subtitle,
fontFamily: 'Arial',
color: MENU_CONFIG.colors.subtitle,
}
).setOrigin(0.5);
}
/** 开始游戏 */
private async startGame(): Promise<void> {
await this.sceneController.launch('GameScene');
}
}
+15 -29
View File
@@ -1,38 +1,24 @@
import {useComputed} from '@preact/signals';
import { createGameHost, type GameModule } from 'boardgame-core';
import Phaser from 'phaser';
import { h } from 'preact';
import { PhaserGame, PhaserScene } from 'boardgame-phaser';
import { h } from 'preact';
import {PhaserGame, PhaserScene } from 'boardgame-phaser';
import {MenuScene} from "@/scenes/MenuScene";
import {useMemo} from "preact/hooks";
import * as gameModule from '../game/tic-tac-toe';
import {GameScene} from "@/scenes/GameScene";
import {createGameHost} from "boardgame-core";
export default function App<TState extends Record<string, unknown>>(props: { gameModule: GameModule<TState>, gameScene: { new(): Phaser.Scene } }) {
const gameHost = useComputed(() => {
const gameHost = createGameHost(props.gameModule);
return { gameHost };
});
const scene = useComputed(() => new props.gameScene());
const handleReset = async () => {
gameHost.value.gameHost.setup('setup');
};
const label = useComputed(() => gameHost.value.gameHost.status.value === 'running' ? 'Restart' : 'Start');
export default function App() {
const gameHost = useMemo(() => createGameHost(gameModule), []);
const gameScene = useMemo(() => new GameScene(), []);
const menuScene = useMemo(() => new MenuScene(), []);
return (
<div className="flex flex-col h-screen">
<div className="flex-1 relative">
<PhaserGame>
<PhaserScene sceneKey="GameScene" scene={scene.value} autoStart data={gameHost.value} />
<div className="flex-1 flex relative justify-center items-center">
<PhaserGame initialScene="MenuScene">
<PhaserScene sceneKey="MenuScene" scene={menuScene} />
<PhaserScene sceneKey="GameScene" scene={gameScene} data={{gameHost}}/>
</PhaserGame>
</div>
<div className="p-4 bg-gray-100 border-t border-gray-200">
<button
onClick={handleReset}
className="px-6 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors font-medium"
>
{label}
</button>
</div>
</div>
);
}
+1
View File
@@ -8,6 +8,7 @@ export default defineConfig({
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
'boardgame-phaser': path.resolve(__dirname, '../framework/src/index.ts'),
},
},
});
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>STS-Like Viewer</title>
</head>
<body>
<div id="ui-root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+27
View File
@@ -0,0 +1,27 @@
{
"name": "sts-like-viewer",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@preact/signals-core": "^1.5.1",
"boardgame-core": "link:../../../boardgame-core",
"boardgame-phaser": "workspace:*",
"mutative": "^1.3.0",
"phaser": "^3.80.1",
"preact": "^10.19.3"
},
"devDependencies": {
"@preact/preset-vite": "^2.8.1",
"@preact/signals": "^2.9.0",
"@tailwindcss/vite": "^4.0.0",
"@types/node": "^25.6.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.3.3",
"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);
}
}

Some files were not shown because too many files have changed in this diff Show More