Compare commits

..
299 Commits
Author SHA1 Message Date
hypercross 9ed23295ae build: include samples in prepare script 2026-04-28 16:52:03 +08:00
hypercross ca86bfc427 refactor: use effect for assignSelector in BaseGameClient
Replace subscribe with effect in assignSelector to ensure the
selector value is tracked and the callback is executed within
the reactive context.
2026-04-28 15:50:00 +08:00
hypercross 09c3fc443b feat(core): wrap prompt middleware in try-finally block
Ensure the prompt status is cleared even if the middleware chain
throws an error.
2026-04-28 15:29:08 +08:00
hypercross a0b2003c65 refactor(samples/tic-tac-toe): simplify Client implementation
Remove generic EntityMap and TriggerMap types in favor of explicit
selector methods. This simplifies the Client class by removing
unnecessary interface implementations and conditional logic in
selectors.
2026-04-25 17:58:16 +08:00
hypercross 03db1e8a06 refactor: simplify GameClient API using signals
Replaces the middleware-based `useClient` system with a more direct
reactive API using Preact signals.

- Replace `_setStatus` middleware with a `_status` signal
- Replace `useClient` with `selectPrompt`, `selectStatus`, and
  `selectResult`
- Implement `selectPrompt` to provide a `tryAnswer` function for
  handling prompt resolutions
- Add `_result` signal to track game completion results
- Simplify `setStatus` logic to manage lifecycle and state resets
2026-04-25 17:54:21 +08:00
hypercross 20993d3b72 feat: add BaseGameClient for external integration
Introduces `BaseGameClient` to allow external environments (like C#)
to interact with the game engine. It provides mechanisms for:
- Reactive state selection via `select`
- Async event interception via `use`
- Lifecycle management and status tracking
- Interruption handling for async game loops

Updated `middleware.ts` to allow unregistering middleware via the
returned function from `use`.
2026-04-25 17:31:15 +08:00
hypercross 59391c74d9 refactor(samples): use produceAsync in tic-tac-toe
Update the tic-tac-toe sample to use `produceAsync` instead of `produce`
to ensure state mutations correctly await interruption promises.
2026-04-25 15:58:10 +08:00
hyper fb3c98b1ef refactor: update GameHost to use generic state and result types
Decouple GameHost from GameState and GameResult types by introducing
generic parameters for TState and TResult. This improves type safety
and flexibility when defining GameModules.
2026-04-23 16:03:49 +08:00
hyper 5c1ef232fd export GameState and GameResult types from game-host 2026-04-23 15:36:25 +08:00
hyper f5df1c26dc refactor: remove registry from Boop and Regicide index files 2026-04-23 15:29:14 +08:00
hyper 2014162819 refactor: update Regicide commands and improve code style 2026-04-23 15:28:45 +08:00
hyper d932c2f5e2 refactor: remove Boop command registry and simplify exports
Remove the command registry from the Boop sample and export command
functions directly. This simplifies the command structure and replaces
dynamic imports with static imports.
2026-04-23 15:21:13 +08:00
hyper 1bd49c32e0 refactor: format Onitama commands and Tic-Tac-Toe imports 2026-04-23 15:18:37 +08:00
hyper b009189b9a Extract PromptContext from GameHost into separate module
The prompt system is now managed by a dedicated PromptContext instead of
being embedded in GameHost. This simplifies the API by removing
individual prompt signals and consolidating prompt logic into a reusable
context that can be shared between components.
2026-04-23 15:09:06 +08:00
hyper 59b99d2042 Fix command prompt to properly return promise results
Add comprehensive tests for createPromptContext covering prompt,
tryCommit, cancel, and handleCall middleware.
2026-04-23 14:44:24 +08:00
hyper 32408c3cee feat: add command prompt context with middleware support 2026-04-23 14:22:17 +08:00
hypercross 23ac09ff21 refactor: simplify GameHost constructor to use GameModule
Update `GameHost` to accept a `GameModule` directly instead of
individual parameters. This simplifies the `createGameHost` factory
and improves type safety by preserving the module type.

Also apply `readonly` modifiers to `ContentModule` in the
slay-the-spire-like sample.
2026-04-23 10:37:28 +08:00
hypercross e8c995f74f feat: add spawner utility
Introduce `createSpawner` and `spawnerEffect` to manage the lifecycle
of side effects based on a collection of IDs. `spawnerEffect` uses
Preact signals to automatically trigger spawners when IDs are added
to an iterable and cleanup functions when they are removed.
2026-04-23 10:20:13 +08:00
hypercross bbab3cc43b feat(slay-the-spire-like): implement CombatGameHost
Introduce CombatGameHost to manage the combat lifecycle and
integrate ContentModule triggers with the combat run context.
2026-04-23 09:45:56 +08:00
hypercross 2e2ddebec4 refactor(slay-the-spire-like): simplify desert trigger registration
Remove the intermediate `effect.ts` file and consolidate the
`addTriggers` logic directly into the desert triggers index file.
2026-04-23 09:37:05 +08:00
hypercross 0d7a84edee test: update desert triggers test to use onEffectApplied 2026-04-23 01:04:27 +08:00
hypercross bedde1ef4b refactor(slay-the-spire-like): move spike damage logic to instant
effects
2026-04-23 00:58:15 +08:00
hypercross 359e7b7485 fix(slay-the-spire-like): pass correct sourceEntityId in instant
triggers

Simplify the sourceEntityId logic to use the context value directly.
2026-04-23 00:53:05 +08:00
hypercross 63e55a828e refactor: rename entityKey to entityId in combat triggers
Rename `entityKey` and `sourceEntityKey` to `entityId` and
`sourceEntityId` across the slay-the-spire-like sample to improve
naming consistency with the rest of the project.
2026-04-23 00:38:10 +08:00
hypercross 8ec95cbf81 refactor: rename 'user' target to 'source' in slay-the-spire-like
Updates the card effect and intent target types from 'user' to 'source'
within the desert data files and combat effect system to maintain
consistency. Also reformat command tests to use 2-space indentation.
2026-04-23 00:35:28 +08:00
hypercross f4c96a4a72 refactor: update turn start triggers in desert sample
Fixes logic in `defendNext` to ensure effects are applied after
buff updates by calling `next()` before mutation. Also updates
indentation and formatting to match project style guidelines.
2026-04-22 20:13:31 +08:00
hypercross a5e2e4888e refactor(slay-the-spire-like): update combat targeting and effect logic
Refactor combat targeting types and effect application logic in the
Slay the Spire-like sample.

- Update `getEffectTargets` to use `IntentEffectTarget` and more
  descriptive target keys (e.g., `eachEnemy`, `randomEnemy`).
- Update `promptMainAction` to use `enemy` instead of `single` for
  card target types.
- Refactor `addInstantEffectTriggers` to remove unused effect loading
  and improve enemy/card instantiation logic
2026-04-22 19:35:37 +08:00
hypercross 38fd46618e refactor(slay-the-spire-like): use data definitions in system types
Remove redundant type definitions in `system/types.ts` and instead
import them from the generated CSV declaration files. Also update
declaration files to use single quotes for consistency with project
style rules.
2026-04-22 19:26:58 +08:00
hypercross 72647a8268 refactor(samples): export desert data types
Export type definitions from desert CSV declaration files and
update the content module to use these exported types instead of
generic system types.
2026-04-22 16:48:54 +08:00
hypercross dbbbba14e2 refactor(slay-the-spire-like): update card effect and intent schemas
Refactor the data schemas for desert card effects and intents to use
dedicated type aliases for triggers, targets, and effect lists. This
improves type safety and consistency across the sample data.
2026-04-22 16:13:48 +08:00
hypercross 888b7b822d refactor(slay-the-spire-like): extract types in desert data schemas 2026-04-22 16:00:15 +08:00
hypercross bb4528cd27 refactor(samples): update desert card and effect schemas 2026-04-22 15:59:41 +08:00
hypercross 877cc9d779 refactor: update schema definitions in slay-the-spire-like data
Refactor CSV schema headers in the desert sample to use named type
aliases instead of inline union strings. This improves readability and
maintains consistency with the project's use of `inline-schema`.

Changes include:
- Updating `card.csv` to use `CardType`, `CardCostType`, and
  `CardTargetType`.
- Updating `encounter.csv` to use `EncounterType` and `EnemyList`.
- Updating `intent.csv` to use `IntentEffectTarget` and replacing
  `self` with `user` for effect targets.
2026-04-22 15:57:52 +08:00
hypercross 2eec668851 refactor: update damage triggers and combat start logic
- Update `defend` effect lifecycle to `temporary` in desert data
- Refactor `onDamage` triggers to improve readability and logic flow
- Implement shuffle and draw actions on `onCombatStart` trigger
2026-04-22 11:06:19 +08:00
hypercross 5235ba7def feat(slay-the-spire-like): support source entity in effect targeting
Update `getEffectTargets` to accept a `sourceEntityKey`. This allows
"self" targets to correctly resolve to the entity triggering the
effect (e.g., a player or a specific enemy) rather than defaulting
to a hardcoded player reference.
2026-04-22 08:56:06 +08:00
hypercross 6cb0626f05 refactor: update produce signature to return undefined
Change the return type of `produce` and `produceAsync` from `void` to
`undefined` in `MutableSignal` and `IGameContext` to more accurately
reflect that the mutation functions should not return a value.
2026-04-22 01:13:39 +08:00
hypercross 270b9b744e refactor(slay-the-spire-like): rename getNeighborItems to
getAdjacentItems
2026-04-21 23:22:19 +08:00
hypercross 113d240f71 refactor: simplify item effect handling in grid inventory
Update `getItemEffects` to directly use `startEffects` from item meta
instead of performing a manual lookup against the effect table. This
is made possible by updating `GameItemMeta` to store the full effect
object (data and stacks) instead of just the effect ID and stack count.
2026-04-21 23:17:19 +08:00
hypercross 0547180074 feat(slay-the-spire-like): add getItemEffects to grid inventory 2026-04-21 23:15:02 +08:00
hypercross d1605a9ee3 feat(samples/slay-the-spire-like): export data module 2026-04-21 23:02:27 +08:00
hypercross 093738cd42 test: expand shape-utils coverage and refactor style 2026-04-21 23:01:26 +08:00
hypercross 97ff61985a refactor(slay-the-spire-like): clean up barrel exports and formatting
- Convert explicit barrel exports to `export *` patterns in several
  systems
- Create a new `utils/index.ts` barrel for the system utils
- Rename `validatePlacement` to `validateShapePlacement` for clarity
- Reformat `shape-collision.ts` to use 2-space indentation and
  consistent
  styling
- Fix import paths in `index.ts`
2026-04-21 23:01:19 +08:00
hypercross 03d367c7b0 feat(samples/slay-the-spire-like): export system types 2026-04-21 22:58:17 +08:00
hypercross b509284bc6 refactor(slay-the-spire-like): simplify exports and reorganize system
Clean up the sample's entry point by using barrel exports and move
encounter logic into the grid-inventory factory to reduce duplication
and improve module structure.
2026-04-21 22:57:28 +08:00
hypercross 9b20f7ad6f refactor(slay-the-spire-like): reorganize combat system files
Move combat factory to a dedicated combat directory and update
`buildCombatState` to use `IPlayerState` and `IPlayerDeck` instead of
deriving them from `RunState` and `GridInventory`.
2026-04-21 22:35:14 +08:00
hypercross 2823089f06 feat(samples): update card effect schema in desert data
Update the `card.csv` schema to use `inject effects` instead of
`effects`
to match the expected property name for card effects.
2026-04-21 22:21:28 +08:00
hypercross f834e15412 refactor(slay-the-spire-like): update deck and inventory types
Refactor the player deck and inventory structures to use more explicit
interfaces and improve type safety for combat entities.
2026-04-21 22:17:31 +08:00
hypercross 2ec0e323c4 test: fix type assertion in grid-inventory tests 2026-04-20 16:32:57 +08:00
hypercross 140b7aed86 test: add createItemIn tests for grid-inventory 2026-04-20 16:28:14 +08:00
hypercross ca9912a5f5 feat(slay-the-spire-like): add automatic item placement to grid
inventory

Introduce `createItemIn` to automatically find the first valid
placement for a new item within the grid inventory.
2026-04-20 16:24:33 +08:00
hypercross 34cb828f60 test: refactor and format test files
- Update `game.test.ts` to use `_ctx` when `this` is not required
- Fix type assertion in `boop.test.ts`
- Update `command-runner.test.ts` to use `parseCommandSchema` in prompt
  tests
- Reformat `middleware.test.ts` to use double quotes and consistent
  indentation
2026-04-20 15:50:32 +08:00
hypercross 974c1a828c test: reformat and expand test coverage 2026-04-20 15:37:15 +08:00
hypercross b83ff28f60 style: format tic-tac-toe sample and boop tests 2026-04-20 15:19:27 +08:00
hypercross 0c94e6923a test: reformat tests to use double quotes and improve style 2026-04-20 15:16:03 +08:00
hypercross d5ec099540 feat(slay-the-spire-like): implement encounter system
Add logic for handling different encounter types including combat,
shop, curio, camp, and events. Includes shop item generation and
grid inventory placement for shop encounters.
2026-04-20 14:57:03 +08:00
hypercross 52b6cecd64 refactor(slay-the-spire-like): update GameItemMeta import path and
reformat tests

Update imports for `GameItemMeta` from `progress/types` to
`encounter/types`
in combat and deck tests. Reformat `deck/factory.test.ts` to use double
quotes and consistent indentation.
2026-04-20 13:01:40 +08:00
hypercross f336a989e2 refactor(slay-the-spire-like): restructure encounter and progress
systems

Refactor the sample game logic by moving encounter-related logic from
the monolithic `progress` system into a dedicated `encounter` module.
This includes:

- Moving combat state construction and run state management to
  `system/encounter`.
- Decoupling player stats and inventory from the general progress
  logic.
- Improving type safety for `EncounterData` using generics.
- Adding navigation helpers to the map system.
- Cleaning up imports and improving code formatting.
2026-04-20 13:00:30 +08:00
hypercross 423cc7c841 feat(slay-the-spire-like): add map navigation logic
Implement `canMoveTo` and `moveToNode` for navigating the point
crawl map, and add `PointCrawlMapNavigator` type. Also reformat
map module files to use double quotes.
2026-04-20 11:59:52 +08:00
hypercross 88d31430a6 test: refactor slay-the-spire-like combat tests
Update combat effect tests to use the new transform offset structure
and fix type mismatches in entity and inventory creation.
2026-04-20 11:37:18 +08:00
hypercross 90cb97e0ae chore: add @types/node and update tsconfig
Add @types/node as a dev dependency and include it in tsconfig types
to support Node.js globals. Also expand tsconfig include to cover
configuration files.
2026-04-20 11:34:40 +08:00
hypercross 5e172c61bb refactor(slay-the-spire-like): use IRunContext in combat system
Update the combat system to use `IRunContext` instead of passing
raw inventory objects. This provides a cleaner abstraction for
accessing item data and managing consumed uses.

- Update `canPlayCard` and `payCardCost` to use `IRunContext`
- Pass `IRunContext` through the trigger registration chain
- Fix logic in `canPlayCard` for "uses" cost validation
- Update combat effect tests to use a mock `IRunContext`
2026-04-20 11:25:52 +08:00
hypercross 5019bc6324 refactor(slay-the-spire-like): use IRunContext for combat logic
Decouple combat systems from the inventory and progress state by
introducing `IRunContext`. This replaces direct access to
`GridInventory` and `GameItemMeta` with abstract methods for
retrieving item data, neighbors, and managing consumed uses.
2026-04-20 11:03:44 +08:00
hypercross 9bed2ca13e refactor(slay-the-spire-like): rename depletion to consumedUses
Rename `depletion` to `consumedUses` in `GameItemMeta` to better reflect
its purpose. Update combat effect logic and tests to use the new field
name. Also apply consistent formatting and import organization to
combat effect modules.
2026-04-20 10:28:58 +08:00
hypercross 08c6a67d16 refactor: use English IDs for desert card effects
Replace Chinese card names with English IDs in the desert card effect
CSV to ensure consistency with the card identifiers. Update tests to
verify the data import using the new English identifiers.
2026-04-20 00:23:41 +08:00
hypercross dda8f4cfe9 style: reformat card-events.ts with 2-space indentation 2026-04-20 00:00:42 +08:00
hypercross 2f2e4e56b5 refactor: decouple card effects from card data in desert sample
Moves card effects from `card.csv` to a dedicated `cardEffect.csv` file.
This allows for more granular control over card triggers (onPlay,
onDraw, onDiscard) and targets, improving the data model for the
slay-the-spire-like sample. Also updates triggers and tests to
reflect this new structure.
2026-04-20 00:00:41 +08:00
hypercross 3840c3d739 test: expand slay-the-spire-like map generator tests 2026-04-20 00:00:41 +08:00
hypercross 43bb246ab9 build: set rootDir to current directory in tsconfig 2026-04-20 00:00:41 +08:00
hypercross 25b44fd6d1 build: update tsconfig and tsup sample configuration
- Include tests in tsconfig for better type checking
- Update tsup.samples.config.ts to use double quotes
- Add esbuild alias for `@` in sample builds
2026-04-20 00:00:41 +08:00
hypercross 601eb0f417 refactor: reformat code and introduce IGameContextExport
- Reformat `src/core/game.ts` and sample types to use 2-space
  indentation
- Add `IGameContextExport` to hide internal test properties
- Update `CombatGameContext` to use the exported context type
2026-04-20 00:00:41 +08:00
hyper a82b6b0685 docs: fleshed out agents.md 2026-04-19 18:36:10 +08:00
hypercross 1e1d04777f refactor: reformat code and fix type signatures in GameHost
Reformat `src/core/game-host.ts` to use double quotes and consistent
spacing. Update `createGameHost` to correctly propagate the `TResult`
generic from `GameModule`.
2026-04-19 15:44:59 +08:00
hypercross 8142fbfa60 refactor: simplify buildCombatState and fix formatting in encounter
system

Refactor `buildCombatState` to derive encounter data directly from
`runState` instead of requiring it as an argument. Also apply
consistent 2-space indentation and formatting to the encounter
lifecycle module.
2026-04-19 15:42:47 +08:00
hypercross 89d96d838b refactor(slay-the-spire-like): wrap content module getters in functions 2026-04-19 15:23:17 +08:00
hypercross 6b50e9014a feat(samples/slay-the-spire-like): make ContentModule required 2026-04-19 15:22:45 +08:00
hypercross 39ac37b5f2 style: format slay-the-spire-like index with double quotes 2026-04-19 15:22:02 +08:00
hypercross b264cea305 refactor(slay-the-spire-like): update data export pattern
Convert static data exports to getter functions and update the
ContentModule type to reflect these changes.
2026-04-19 15:20:31 +08:00
hypercross 6577897a42 feat(samples/slay-the-spire-like): add ContentModule type definition 2026-04-19 15:10:17 +08:00
hypercross 4e9e426519 feat(samples): add Item type to desert data
Export the Item type from item.csv and add explicit typing to
startingItems in the desert sample data.
2026-04-19 14:47:58 +08:00
hypercross 4f509da373 docs(samples): update intent.csv comment style 2026-04-19 14:39:54 +08:00
hypercross 63733bc62d refactor: update imports and add starting items to desert sample
- Convert single quotes to double quotes for local imports
- Reformat barrel exports in `src/index.ts` for better readability
- Add `startingItems` to the desert sample data in `slay-the-spire-like`
2026-04-19 13:51:03 +08:00
hyper 3aa92b2d2a refactor: rename that to desert triggers 2026-04-18 15:28:47 +08:00
hyper fa92b5d865 refactor: reorganize trigger code 2026-04-18 15:08:50 +08:00
hyper e46822b45b refactor: avoid globalThis 2026-04-18 14:40:56 +08:00
hyper 03add589c6 refactor: use stacks for the hp of summons 2026-04-18 14:30:20 +08:00
hypercross 4f5747d81d chore: tests for desert triggers 2026-04-17 18:07:49 +08:00
hypercross 0222b66bed feat: export middleware chain 2026-04-17 17:27:46 +08:00
hypercross 90ca8fa761 feat: more effects 2026-04-17 17:15:31 +08:00
hypercross 131af2c0bb feat: implmenet some effects for the design 2026-04-17 16:57:29 +08:00
hypercross 02c159f8ae refactor: move .d.ts to per-file for yarn 2026-04-17 16:55:08 +08:00
hypercross e09e24e274 refactor: barrel export 2026-04-17 16:10:19 +08:00
hypercross a80852bc59 fix: encounter generation 2026-04-17 15:45:52 +08:00
hypercross af0906561c fix: add effect triggering fixes 2026-04-17 15:30:28 +08:00
hypercross aedf82d264 fix: type issues 2026-04-17 15:14:01 +08:00
hypercross 2f085cc0b6 refactor: update csv types 2026-04-17 14:46:09 +08:00
hypercross fb66ec55c4 feat: cost implementation for card play 2026-04-17 14:10:28 +08:00
hypercross 8155747cac chore: tests for effects and middleware 2026-04-17 13:48:18 +08:00
hypercross 7601a97ec9 chore: non-combat tests update 2026-04-17 12:58:29 +08:00
hypercross f775d51a58 feat: effect and triggers 2026-04-17 12:58:12 +08:00
hypercross aa36f3ea67 feat: enemy intent update 2026-04-17 12:42:29 +08:00
hypercross 0f04af2c6e refactor: update progress 2026-04-17 12:23:10 +08:00
hypercross c11bceeb44 refactor: update generator 2026-04-17 12:10:10 +08:00
hypercross 1d749f59a6 feat: add posture damage & item effect update trigger 2026-04-17 11:57:40 +08:00
hypercross 65afe6dc8f refactor: remove card effect cycle, just use instant instead 2026-04-17 11:13:28 +08:00
hypercross 3a135a4ad1 refactor: reorg 2026-04-17 11:06:09 +08:00
hypercross 4deebf67c3 chore: remove old tests 2026-04-17 10:20:04 +08:00
hypercross 1c238aec3a refactor: type rewrite 2026-04-17 10:18:37 +08:00
hypercross a469b4024a refactor: combat rewrite 2026-04-17 09:27:20 +08:00
hypercross 7d8684a16f refactor: middle ware triggers 2026-04-17 08:33:02 +08:00
hypercross 3dc566c2fd refactor: types 2026-04-17 01:28:43 +08:00
hypercross 3f3490fad8 refactor: reorg 2026-04-17 01:05:48 +08:00
hypercross 5e55b58c43 refactor: data reorg complete 2026-04-17 01:01:26 +08:00
hypercross f8c008b67d wip: data reorg 2026-04-17 00:52:47 +08:00
hypercross c0fa0e91b2 refactor: avoid using types from the csv 2026-04-17 00:28:25 +08:00
hypercross e3014e47a8 refactor: adjust implementation details for combat 2026-04-16 21:52:28 +08:00
hyper f7b59a1790 refactor: bunch of reorg updates 2026-04-16 19:27:25 +08:00
hyper b28ec823a7 refactor: reorganize csv data 2026-04-16 19:14:58 +08:00
hyper b18e232cc8 chore: todos 2026-04-16 19:14:46 +08:00
hyper 51da1e5308 fix: update tests 2026-04-16 18:33:13 +08:00
hyper 8c783da857 fix: align data with design 2026-04-16 17:52:20 +08:00
hyper a010a0bca6 refactor: update doc and .d.ts 2026-04-16 17:14:13 +08:00
hypercross 693c279e0f fix: resolve inconsistencies in slay-the-spire-like design docs 2026-04-16 14:07:28 +08:00
hypercross 94c7c91745 feat: slay-the-spire-like combat procedures 2026-04-16 14:00:49 +08:00
hypercross 976ee43ed3 chore: AGENTS.md updated 2026-04-16 12:29:02 +08:00
hypercross 6984e54bdf refactor: add status cards and rules. 2026-04-15 16:28:18 +08:00
hypercross 7b954bb5a5 refactor: add more effect 2026-04-15 15:41:20 +08:00
hypercross 7472095822 refactor: add team effect 2026-04-15 15:21:44 +08:00
hypercross e5da41c1cb refactor: op -> player 2026-04-15 15:19:58 +08:00
hypercross 509e121275 refactor: add timing to effect 2026-04-15 15:16:45 +08:00
hypercross e77e2d0737 refactor: self-referencing intent table! 2026-04-15 14:59:34 +08:00
hypercross 09148f5b13 refactor: update inline-schema to import accessors 2026-04-15 14:56:48 +08:00
hypercross 7e98e5e546 chore: add identifier to fields 2026-04-15 14:30:56 +08:00
hypercross 33095d5226 chore: add tests for data loading 2026-04-15 13:27:12 +08:00
hypercross 726856af35 feat: add enemy/effect data 2026-04-15 13:15:09 +08:00
hypercross 630e35ad4e feat: add enemy intent data 2026-04-15 12:43:41 +08:00
hypercross 5860f2a247 feat: upgrade yarn-spinner-loader to 0.2.0 and add encounters.yarnproject test 2026-04-15 10:19:03 +08:00
hypercross 8f34104332 feat: add yarn-spinner-loader 2026-04-14 20:53:21 +08:00
hypercross 760cfc9954 feat: deck generation 2026-04-14 15:46:08 +08:00
hypercross 4fbd65e98c fix: encounter data assignment 2026-04-14 14:35:23 +08:00
hypercross 204198b10f refactor: add progress 2026-04-14 13:35:26 +08:00
hypercross 6b724df7e7 refactor: add price 2026-04-14 11:54:44 +08:00
hypercross e35871accd refactor: types on the inventory 2026-04-14 11:11:29 +08:00
hyper ef9557cba7 refactor: minimize repetitions 2026-04-13 21:18:06 +08:00
hyper 1e5e4e9f7e fix: fix map gen again 2026-04-13 20:06:23 +08:00
hyper c30db2f8a4 refactor: update encounter table design 2026-04-13 19:29:53 +08:00
hyper 5d1dc487f8 refactor: map gen? 2026-04-13 19:06:37 +08:00
hypercross 06a2236a1d refactor: redesign map gen 2026-04-13 14:56:33 +08:00
hypercross fe361dc877 fix: avoid paths corssing each other 2026-04-13 12:56:39 +08:00
hypercross 17dca6303c fix: export 2026-04-13 12:20:55 +08:00
hypercross d1aa6dbd32 fix: export for stsl 2026-04-13 12:19:49 +08:00
hypercross 88eeee6ab7 feat: add encounter map 2026-04-13 11:59:56 +08:00
hypercross 2a4383ff10 feat: add grid-inventory 2026-04-13 11:07:57 +08:00
hyper 4bfc6db60f chore: add data import test 2026-04-12 21:04:38 +08:00
hyper 238254c4e5 chore: add tests for shape stuff 2026-04-12 20:51:19 +08:00
hyper 6281044f14 feat: add som shape parsing 2026-04-12 20:44:35 +08:00
hyper 18eae59891 feat: add hero fighter item pool 1 2026-04-12 20:34:08 +08:00
hyper 6dc85b443e feat: shape parsing 2026-04-12 20:26:46 +08:00
hyper d5f65fa6cd chore: export TicTactToePart 2026-04-12 16:28:50 +08:00
hypercross 6ac5ac4b9a chore: update inline-schema output 2026-04-12 10:13:57 +08:00
hyper eb4716200a refactor: some thoughts on design 2026-04-11 18:03:57 +08:00
hyper 547d378d50 feat: fleshing out the design 2026-04-11 17:07:59 +08:00
hyper 330aa43d7b feat: ideas for slay-the-backpack 2026-04-10 20:48:50 +08:00
hyper cf3123b3f1 refactor: shorter skill 2026-04-10 15:56:57 +08:00
hyper 5849c0a343 feat: update skill def 2026-04-10 13:45:10 +08:00
hyper 617057988d feat: regicide full 2026-04-10 13:43:12 +08:00
hyper 28e548d3de feat: regicide code 2026-04-10 13:24:29 +08:00
hyper 8b271448d2 feat: regicide rule 2026-04-10 13:15:03 +08:00
hyper b8e4f73126 refactor: rewritten skill 2026-04-10 13:14:43 +08:00
hyper 9d6e7a75f8 refactor: add export for createPart 2026-04-10 12:34:47 +08:00
hyper 3d5a484e52 feat: skill doc for game module 2026-04-10 12:34:34 +08:00
hypercross 244a8bb35a fix: onitama win con 2026-04-08 12:25:50 +08:00
hypercross cbf6dce237 fix: export more stuff 2026-04-08 09:28:44 +08:00
hypercross 35df1801c5 fix: export new getCardMoveCandidates 2026-04-08 09:04:24 +08:00
hypercross 5b310f400d fix: fix onitama black orientation 2026-04-08 09:00:25 +08:00
hypercross 98a12b9266 refactor: externalize imports 2026-04-07 16:09:47 +08:00
hypercross 91c993b223 refactor: clean up boop implementation structure 2026-04-07 15:43:17 +08:00
hypercross 10393f45b6 refactor: add rng & seeding 2026-04-07 15:32:06 +08:00
hypercross 6b736ab083 fix: more tests 2026-04-07 15:13:10 +08:00
hypercross a5cc584121 fix: onitama test 2026-04-07 15:03:20 +08:00
hypercross beb8088009 refactor: api change to add prompt text 2026-04-07 15:03:15 +08:00
hypercross b3cea805b0 feat: onitama tests 2026-04-07 14:53:50 +08:00
hypercross 4cb9f2dbd6 feat: onitama rules & cards 2026-04-07 14:40:44 +08:00
hypercross a84aa2426b refactor: add samples to export & use inline-schema's esbuild plugin 2026-04-07 11:37:05 +08:00
hypercross 83658c9898 chore: remove unused api 2026-04-07 11:26:43 +08:00
hypercross 6352977791 chore: add more tests 2026-04-06 16:11:26 +08:00
hypercross 6cfb3b6df8 refactor: PromptDef 2026-04-06 15:54:02 +08:00
hypercross fe3bef0a01 chore: update api / test 2026-04-06 15:02:31 +08:00
hypercross 111d2e69eb chore: just remove all the docs 2026-04-06 14:08:25 +08:00
hypercross aa7e99bd0f chore: make sure peers are not bundled 2026-04-06 12:42:45 +08:00
hypercross a8774f34bc refactor: make stuff peer dependencies 2026-04-06 12:38:24 +08:00
hypercross 49109963bc refactor: hide api 2026-04-06 12:05:11 +08:00
hypercross 1e7dbea129 fix: api tests 2026-04-06 11:12:20 +08:00
hypercross b3ff589aa5 refactor: api changes 2026-04-06 11:07:11 +08:00
hypercross 21b91edc1a fix: api tests 2026-04-06 10:52:22 +08:00
hypercross e673f60657 refactor: update api 2026-04-06 10:39:10 +08:00
hypercross 129c58fb08 refactor: update samples 2026-04-06 10:18:55 +08:00
hypercross 70b1ac1e43 refactor: change how we define command 2026-04-06 10:06:04 +08:00
hypercross 2d5200bdb7 refactor: renaming api 2026-04-06 09:48:20 +08:00
hypercross 8c2c6dc94c fix: fix tests 2026-04-05 10:42:38 +08:00
hypercross c4290841e5 refactor: clean up exports 2026-04-05 10:41:42 +08:00
hypercross cc7f302677 refactor: make game host return the setup promise 2026-04-04 23:58:12 +08:00
hypercross dd73deabb0 refactor: just throw Error if fail 2026-04-04 23:42:49 +08:00
hypercross b929a126a1 fix: api doc 2026-04-04 22:54:58 +08:00
hypercross f5830ea637 docs: some fixes 2026-04-04 22:48:04 +08:00
hypercross bd2988902a fix: boop 2026-04-04 22:29:25 +08:00
hypercross b9105efd03 fix: fix tests 2026-04-04 22:23:15 +08:00
hypercross a02403d2c7 docs: update docs 2026-04-04 22:19:41 +08:00
hypercross e58690c9c6 fix: impl 2026-04-04 22:11:02 +08:00
hypercross 28782aaf9b fix: tests for boop 2026-04-04 21:57:25 +08:00
hypercross b90a4bba52 refactor: massive way of writing fix 2026-04-04 21:53:37 +08:00
hypercross 8f86b88c88 refactor: update sample 2026-04-04 21:38:16 +08:00
hypercross 9e2947a8d6 refactor: prompt validation 2026-04-04 21:38:07 +08:00
hypercross af9254603a refactor: don't use this 2026-04-04 20:57:58 +08:00
hypercross 6e1c42015f refactor: api surface change 2026-04-04 20:50:17 +08:00
hypercross 467a56bd84 fix: fix tic tac toe tests 2026-04-04 18:42:21 +08:00
hypercross de7006ef19 refactor: api change 2026-04-04 18:33:44 +08:00
hypercross 97ef1df4fb refactor: moveRegion & add csv loader 2026-04-04 17:51:26 +08:00
hypercross d4562b8534 refactor: add csvLoader from inline-schema 2026-04-04 16:55:38 +08:00
hypercross 80f0796f3c refactor: async boop 2026-04-04 15:51:27 +08:00
hypercross 775bb00bed refactor: addInterrupt is now on GameHost 2026-04-04 15:37:22 +08:00
hypercross 60a6bcad77 docs: update docs 2026-04-04 15:27:37 +08:00
hypercross a0dd5c94f5 feat: produceAsync 2026-04-04 15:08:34 +08:00
hypercross 25f9992be6 refactor: remove host options 2026-04-04 14:06:27 +08:00
hypercross d7d484e4d3 docs: keep docs shorter 2026-04-04 13:20:34 +08:00
hypercross ceaf4e8ded docs: update readme 2026-04-04 13:05:08 +08:00
hypercross 22d40fdc50 refactor: remove setup command from host creation 2026-04-04 12:48:51 +08:00
hypercross ae98e0735a chore: add GameModule type 2026-04-04 11:06:41 +08:00
hypercross be4ff7ae08 feat: add currentPlayer to prompt 2026-04-04 11:01:25 +08:00
hypercross d1264f3b9e chore: add full game test 2026-04-04 01:11:09 +08:00
hypercross a89aac9c21 docs: update docs 2026-04-04 01:03:12 +08:00
hypercross 6c8d6e0790 refactor: add promptEnd event 2026-04-04 01:00:46 +08:00
hypercross 697d23e932 feat: add game host 2026-04-04 00:55:09 +08:00
hypercross 50c146964d chore: update tests and imports 2026-04-03 17:56:35 +08:00
hypercross 65a3d682b6 refactor: Part[] -> Record<string, Part> 2026-04-03 17:36:25 +08:00
hypercross 118007168a fix: export command result as well 2026-04-03 15:12:51 +08:00
hypercross 4bf6eb2f6b refactor: add some part management utilities 2026-04-03 15:00:25 +08:00
hypercross 86714e7837 refactor: entity -> MutableSignal 2026-04-03 14:19:44 +08:00
hypercross b1b059de8c refactor: improved PromptEvent handling 2026-04-03 14:10:42 +08:00
hypercross 8b2a8888d3 refactor: use part map for tictactoe 2026-04-03 13:09:40 +08:00
hypercross eb0ebf5411 refactor: transition to non child entity format 2026-04-03 12:46:50 +08:00
hypercross cf7cfcd86b docs: agent and readme 2026-04-03 11:20:35 +08:00
hypercross 768c6ebc84 refactor: update readme 2026-04-03 10:07:40 +08:00
hyper b71ba12454 feat: add new functions 2026-04-02 21:58:11 +08:00
hyper 11d6cbd030 chore: switch to npm link based dependencies 2026-04-02 21:07:43 +08:00
hyper ab1f27e662 chore: add prepare script 2026-04-02 20:11:55 +08:00
hyper 39e1f52f71 refactor: allow the player to choose kitten to graduate in 8-piece-rule 2026-04-02 19:56:55 +08:00
hyper 793c7d834b refactor: update boop implementation 2026-04-02 19:46:49 +08:00
hyper 15122defcc refactor: update usage pattern for tic tac toe and boop 2026-04-02 19:32:07 +08:00
hyper 5f812a3478 fix: fix tests for PromptEvent refactor 2026-04-02 19:27:36 +08:00
hyper c886e904a8 refactor: change PromptEvent reject/resolve to cancel/tryCommit 2026-04-02 19:08:14 +08:00
hyper a766050773 feat: upgrade inline-schema 2026-04-02 18:39:59 +08:00
hypercross d4d428b577 refactor: improved line checks 2026-04-02 17:45:03 +08:00
hypercross 975d363769 refactor: fix boop placement 2026-04-02 17:36:42 +08:00
hypercross ecb09c01a1 feat: add boop sample 2026-04-02 16:53:17 +08:00
hypercross 06c801e6ae refactor: improve tic tac toe with region entity 2026-04-02 16:52:58 +08:00
hypercross b1a6619ae3 refactor: add region entity 2026-04-02 16:32:26 +08:00
hypercross 2581a8e0e6 refactor: add src alias to @/ 2026-04-02 16:03:44 +08:00
hypercross f4649e0dac chore: AGENTS.md 2026-04-02 15:37:16 +08:00
hypercross 4f49599527 fix: fix imports 2026-04-02 15:36:32 +08:00
hypercross bd9569992b fix: fix tic tac toe test 2026-04-02 15:25:17 +08:00
hypercross 0948e5a742 fix: fix game/region tests. 2026-04-02 15:16:30 +08:00
hypercross b7c5312b60 fix: fix tic tac toe tests 2026-04-02 15:06:04 +08:00
hypercross e945d28fc3 refactor: even tighter api 2026-04-02 14:46:34 +08:00
hypercross b2b35c3a99 refactor: reduce api surface 2026-04-02 14:11:35 +08:00
hypercross e3bc63b088 refactor: make sides/side optional 2026-04-02 13:58:52 +08:00
hypercross e78caf481d refactor: use mutative for entity signals 2026-04-02 13:56:44 +08:00
hypercross 705e2f9396 refactor: install mutative 2026-04-02 13:11:45 +08:00
hypercross 74f56b9da4 refactor: state signal 2026-04-02 12:53:49 +08:00
hypercross 004d49c36f refactor: update api 2026-04-02 12:48:29 +08:00
hypercross 846badc081 tests: game and tic tac toe 2026-04-02 11:31:57 +08:00
hypercross 1cb7fa05ec refactor: rewrite game & tic tac toe 2026-04-02 11:06:29 +08:00
hypercross 5042d6ebc7 refactor: allow run command to return a typed result 2026-04-02 10:38:31 +08:00
hypercross d4a8668b54 refactor: remove context. add game. 2026-04-02 10:26:42 +08:00
hypercross 9c7baa29ef refactor: add a new async queue 2026-04-02 10:04:22 +08:00
hypercross 40788d445d refactor: replace rule.ts with a command runner based solution 2026-04-02 09:33:03 +08:00
hypercross bcb31da773 refactor: move runner context to handler's this 2026-04-02 09:05:47 +08:00
hypercross 3bc35df63c feat: add command runner and registry 2026-04-02 08:58:11 +08:00
hypercross 281cbf845d refactor: change CommandSchema's options/flags to be Records 2026-04-02 08:29:40 +08:00
hypercross f1b1741db8 refactor: move command.ts to command/index.ts 2026-04-02 08:19:14 +08:00
hypercross e06dc8ecba refactor: improve rule handling 2026-04-02 00:44:29 +08:00
hypercross ff9d9bd9a1 refactor: fix command parsing 2026-04-02 00:14:43 +08:00
hypercross a8ff79e4e5 fix: fix tic-tac-toe test 2026-04-02 00:01:45 +08:00
hypercross b33c901c11 feat: add tic-tac-toe with rule invoking rule 2026-04-01 23:58:07 +08:00
hypercross dbd2a25185 refactor: fix reactivity 2026-04-01 22:57:33 +08:00
hypercross 4761806a02 refactor: break up command.ts 2026-04-01 22:37:15 +08:00
hypercross ad0d349090 chore: tests for rule 2026-04-01 22:31:07 +08:00
hypercross 170217db30 feat: rule dispatch 2026-04-01 22:20:38 +08:00
hypercross de730c6479 docs: readme.md 2026-04-01 22:00:16 +08:00
hypercross dbe567ea1d refactor: various improvements 2026-04-01 21:44:20 +08:00
hypercross 033fb6c894 refactor: command syntax improvement 2026-04-01 21:18:58 +08:00
hypercross d1ea04c442 fix: command parsing 2026-04-01 21:12:34 +08:00
hyper c315e0643b feat: add inline-schema for command schema 2026-04-01 19:04:09 +08:00
hyper 95015b090c feat: add command schema 2026-04-01 18:54:02 +08:00
hyper 00bed92d46 fix: change the way region align works 2026-04-01 18:33:09 +08:00
hyper 729f15c2bf fix: fix region align & shuffle 2026-04-01 17:48:40 +08:00
hyper 284251ddf2 fix: command parsing quotes 2026-04-01 17:40:28 +08:00
hyper 8d778f9867 fix: rng impl 2026-04-01 17:37:24 +08:00
hyper df9698b67b feat: rng & commands impl & tests 2026-04-01 17:34:21 +08:00
hyper ea337acacb chore: clean up 2026-04-01 17:08:08 +08:00
188 changed files with 19015 additions and 7771 deletions
+175
View File
@@ -0,0 +1,175 @@
---
name: Create Game Module
description: Create a runnable logic module for a board game with 'boardgame-core', '@preact/signals' and 'mutative'
---
# 如何编写游戏模组
## 必须导出的接口
```typescript
import { IGameContext, createGameCommandRegistry } from '@/index';
export type GameState = { /* 你的状态类型 */ };
export type Game = IGameContext<GameState>;
export function createInitialState(): GameState { /* ... */ }
export async function start(game: Game) { /* 游戏主循环 */ }
```
或导出为 `GameModule` 对象:
```typescript
export const gameModule: GameModule<GameState> = { registry, createInitialState, start };
```
## 流程
### 0. 确认规则
在 `rule.md` 中描述:主题、配件、布置形式、游戏流程、玩家行动、胜利条件。
### 1. 创建类型 (`types.ts`)
- 使用字符串枚举表示游戏概念(如 `PlayerType = 'X' | 'O'`)
- 使用 `Part<TMeta>` 表示配件(棋子、卡牌等)
- 使用 `Region` 表示区域(棋盘、牌堆等)
- 状态必须可序列化(不支持函数、`Map`、`Set`)
```typescript
import { Part, Region } from '@/index';
export type Piece = Part<{ owner: 'X' | 'O' }>;
export type GameState = {
board: Region;
pieces: Record<string, Piece>;
currentPlayer: 'X' | 'O';
turn: number;
winner: 'X' | 'O' | null;
};
```
### 2. 创建配件
少量配件直接在代码创建:
```typescript
const pieces = createParts({ owner: 'X' }, (i) => `piece-${i}`, 5);
```
大量配件使用 CSV:
```csv
# parts.csv
type,player,count
string,string,int
kitten,white,8
```
```typescript
import parts from "./parts.csv";
const pieces = createPartsFromTable(parts, (item, i) => `${item.type}-${i}`, (item) => item.count);
```
### 3. 创建 Prompts
```typescript
export const prompts = {
play: createPromptDef<['X' | 'O', number, number]>(
'play <player> <row:number> <col:number>',
'选择下子位置'
),
};
```
Schema 语法:`<param>` 必需,`[param]` 可选,`[param:type]` 带验证。详见 [API 参考](./references/api.md)。
### 4. 创建游戏流程
```typescript
export async function start(game: Game) {
while (!game.value.winner) {
const { row, col } = await game.prompt(
prompts.play,
(player, row, col) => {
if (player !== game.value.currentPlayer) throw '无效玩家';
if (!isValidMove(row, col)) throw '无效位置';
if (isCellOccupied(game, row, col)) throw '位置已被占用';
return { row, col };
},
game.value.currentPlayer
);
game.produce((state) => {
state.pieces[`p-${row}-${col}`] = { /* ... */ };
});
const winner = checkWinner(game);
if (winner) {
game.produce((state) => { state.winner = winner; });
break;
}
game.produce((state) => {
state.currentPlayer = state.currentPlayer === 'X' ? 'O' : 'X';
state.turn++;
});
}
return game.value;
}
```
**注意事项:**
- `game.produce(fn)` 同步更新,`game.produceAsync(fn)` 异步更新(等待动画)
- 验证器抛出字符串表示失败,返回值表示成功
- 玩家取消时 `game.prompt()` 抛出异常
- 循环必须有明确退出条件
### 5. 创建测试
位于 `tests/samples/<game-name>.test.ts`:
```typescript
import { describe, it, expect } from 'vitest';
import { createGameContext } from '@/core/game';
import { registry, createInitialState } from './my-game';
describe('My Game', () => {
function createTestContext() {
return createGameContext(registry, createInitialState());
}
it('should perform action correctly', async () => {
const game = createTestContext();
await game.run('play X 0 0');
expect(game.value.pieces['p-0-0']).toBeDefined();
});
it('should fail on invalid input', async () => {
const game = createTestContext();
const result = await game.run('invalid');
expect(result.success).toBe(false);
});
it('should complete a full game cycle', async () => {
// 模拟完整游戏流程,验证胜利条件
});
});
```
**要求:**
- 每种游戏结束条件、玩家行动、边界情况各至少一条测试
- 使用 `createGameContext(registry, initialState)` 创建上下文
- 使用 `game.run('command')` 执行命令,验证 `game.value` 状态
- 测试命名使用 `should...` 格式,异步测试用 `async/await`
**运行测试:**
```bash
npm run test:run # 所有测试
npx vitest run tests/samples/my-game.test.ts # 特定文件
npx vitest run -t "should perform" # 特定用例
```
## 完整示例
参考 `src/samples/boop/` 和 `src/samples/regicide/`。
## 相关资源
- [API 参考](./references/api.md) - 完整 API 文档
- [AGENTS.md](../../AGENTS.md) - 项目代码规范
@@ -0,0 +1,759 @@
# API 参考
本文档记录游戏模组开发者需要使用的公共 API。
## 核心接口
### `IGameContext<TState>`
游戏运行的核心上下文,提供状态访问、随机数、命令执行和提示系统。
```typescript
interface IGameContext<TState extends Record<string, unknown> = {}> {
readonly value: TState; // 当前游戏状态(只读)
readonly rng: ReadonlyRNG; // 随机数生成器(只读)
// 状态更新
produce(fn: (draft: TState) => void): void; // 同步变更状态(基于 mutative)
produceAsync(fn: (draft: TState) => void): Promise<void>; // 异步变更状态(等待中断)
// 命令执行
run<T>(input: string): Promise<CommandResult<T>>; // 执行命令字符串
runParsed<T>(command: Command): Promise<CommandResult<T>>; // 执行已解析的命令
// 提示系统
prompt<TResult, TArgs>(
def: PromptDef<TArgs>,
validator: PromptValidator<TResult, TArgs>,
currentPlayer?: string | null
): Promise<TResult>;
}
```
**使用示例:**
```typescript
// 读取状态
const currentPlayer = game.value.currentPlayer;
// 同步更新状态
game.produce((state) => {
state.score += 10;
});
// 异步更新状态(等待动画完成)
await game.produceAsync((state) => {
state.phase = 'next';
});
// 等待玩家输入
const result = await game.prompt(
prompts.move,
(from, to) => {
if (!isValidMove(from, to)) {
throw '无效移动';
}
return { from, to };
}
);
```
---
### `GameModule<TState, TResult>`
游戏模块的类型定义,这是开发者创建游戏时需要导出的核心结构。
```typescript
type GameModule<TState extends Record<string, unknown>, TResult = unknown> = {
registry?: CommandRegistry<IGameContext<TState>>; // 可选的命令注册表
createInitialState: () => TState; // 创建初始状态
start: (ctx: IGameContext<TState>) => Promise<TResult>; // 游戏主循环
}
```
---
### `createGameCommandRegistry<TState>()`
创建游戏命令注册表,游戏模组用它来注册自定义命令。
```typescript
function createGameCommandRegistry<TState extends Record<string, unknown> = {}>(): CommandRegistry<IGameContext<TState>>
```
**使用示例:**
```typescript
import { createGameCommandRegistry, IGameContext } from '@/index';
export type GameState = { score: number };
export const registry = createGameCommandRegistry<GameState>();
// 注册命令
registry.register('addScore <amount:number>', async function(ctx, amount) {
ctx.produce((state) => {
state.score += amount;
});
return { success: true, result: undefined };
});
```
---
## 提示系统
### `createPromptDef<TArgs>(schema, hintText?)`
从字符串模式创建 `PromptDef`。
```typescript
function createPromptDef<TArgs>(
schema: CommandSchema | string,
hintText?: string
): PromptDef<TArgs>
```
**使用示例:**
```typescript
import { createPromptDef } from '@/index';
export const prompts = {
// 必需参数
play: createPromptDef<[PlayerType, number, number]>(
'play <player> <row:number> <col:number>',
'选择下子位置'
),
// 可选参数
draw: createPromptDef<[number?]>(
'draw [count:number]',
'抽牌'
),
// 带选项
trade: createPromptDef<[string, string]>(
'trade <give> <receive> [--force]',
'交易'
),
};
```
---
### `PromptDef<TArgs>`
提示定义,用于 `context.prompt()` 方法。
```typescript
type PromptDef<TArgs extends any[] = any[]> = {
schema: CommandSchema; // 命令模式定义
hintText?: string; // 可选的提示文本
}
```
---
### `PromptValidator<TResult, TArgs>`
提示验证函数类型。验证器函数接收解析后的参数,应返回结果或抛出字符串错误。
```typescript
type PromptValidator<TResult, TArgs extends any[] = any[]> = (...params: TArgs) => TResult;
```
**验证器规则:**
- 返回任意值表示验证成功,该值将作为 `prompt()` 的返回值
- 抛出字符串错误表示验证失败,错误消息会返回给玩家,玩家可重新输入
- 玩家取消输入时,`prompt()` 会抛出异常
---
### `PromptEvent`
提示事件对象,通过 `commandRunnerContext.on('prompt', handler)` 监听。
```typescript
type PromptEvent = {
schema: CommandSchema;
hintText?: string;
currentPlayer: string | null;
tryCommit: (commandOrInput: Command | string) => string | null; // null=成功,string=错误消息
cancel: (reason?: string) => void;
}
```
---
## 零件系统 (Part)
### `Part<TMeta>`
游戏中的可操作物件(棋子、卡牌、骰子等)。
```typescript
type Part<TMeta = {}> = {
id: string; // 唯一标识
sides?: number; // 总面数(用于骰子/多面牌)
side?: number; // 当前面
alignments?: string[]; // 可用对齐方式
alignment?: string; // 当前对齐方式
regionId: string; // 所属区域 ID
position: number[]; // 在区域中的位置坐标
} & Immutable<TMeta>; // 自定义元数据(不可变)
```
**使用示例:**
```typescript
import { Part } from '@/index';
export type PieceMeta = {
owner: 'X' | 'O';
type: 'pawn' | 'king';
};
export type Piece = Part<PieceMeta>;
// 访问元数据
const piece: Piece = ...;
console.log(piece.owner); // 'X'
console.log(piece.type); // 'pawn'
```
---
### 零件操作函数
#### `flip<TMeta>(part)`
翻转到下一面(循环)。
```typescript
function flip<TMeta>(part: Part<TMeta>): void
```
#### `flipTo<TMeta>(part, side)`
翻转到指定面。
```typescript
function flipTo<TMeta>(part: Part<TMeta>, side: number): void
```
#### `roll<TMeta>(part, rng)`
用 RNG 随机掷骰子。
```typescript
function roll<TMeta>(part: Part<TMeta>, rng: RNG): void
```
---
## 零件工厂 (Part Factory)
### `createParts<T>(item, getId, count?)`
创建多个相同类型的零件。
```typescript
function createParts<T>(
item: T,
getId: (index: number) => string,
count?: number
): Record<string, Part<T>>
```
**使用示例:**
```typescript
import { createParts } from '@/index';
const pieces = createParts(
{ owner: 'X', type: 'pawn' },
(i) => `piece-x-${i}`,
5 // 创建 5 个
);
```
---
### `createPartsFromTable<T>(items, getId, getCount?)`
从配置表批量创建零件。
```typescript
function createPartsFromTable<T>(
items: readonly T[],
getId: (item: T, index: number) => string,
getCount?: ((item: T) => number) | number
): Record<string, Part<T>>
```
**使用示例:**
```typescript
import { createPartsFromTable } from '@/index';
const cardTable = [
{ name: 'fireball', damage: 3 },
{ name: 'shield', defense: 2 },
];
const cards = createPartsFromTable(
cardTable,
(item) => item.name,
(item) => item.name === 'fireball' ? 4 : 2 // 每种卡牌的数量
);
```
---
## 区域系统 (Region)
### `Region`
游戏区域(棋盘、手牌区等)。
```typescript
type Region = {
id: string; // 区域 ID
axes: RegionAxis[]; // 坐标轴定义
childIds: string[]; // 包含的零件 ID 列表
partMap: Record<string, string>; // 位置 -> 零件 ID 映射
}
```
---
### `RegionAxis`
区域的一个坐标轴。
```typescript
type RegionAxis = {
name: string;
min?: number;
max?: number;
align?: 'start' | 'end' | 'center'; // 对齐方式
}
```
---
### `createRegion(id, axes)`
创建区域。
```typescript
function createRegion(id: string, axes: RegionAxis[]): Region
```
**使用示例:**
```typescript
import { createRegion, createRegionAxis } from '@/index';
// 创建 3x3 棋盘
const board = createRegion('board', [
createRegionAxis('row', 0, 2),
createRegionAxis('col', 0, 2),
]);
// 或简写
const board = createRegion('board', [
{ name: 'row', min: 0, max: 2 },
{ name: 'col', min: 0, max: 2 },
]);
```
---
### `createRegionAxis(name, min?, max?, align?)`
创建坐标轴。
```typescript
function createRegionAxis(
name: string,
min?: number,
max?: number,
align?: 'start' | 'end' | 'center'
): RegionAxis
```
---
### 区域操作函数
#### `applyAlign<TMeta>(region, parts)`
根据轴的 `align` 配置重新排列零件位置。
```typescript
function applyAlign<TMeta>(region: Region, parts: Record<string, Part<TMeta>>): void
```
#### `shuffle<TMeta>(region, parts, rng)`
在区域内随机打乱零件位置。
```typescript
function shuffle<TMeta>(region: Region, parts: Record<string, Part<TMeta>>, rng: RNG): void
```
#### `moveToRegion<TMeta>(part, sourceRegion, targetRegion, position?)`
将零件从一个区域移动到另一个区域。
```typescript
function moveToRegion<TMeta>(
part: Part<TMeta>,
sourceRegion: Region | null,
targetRegion: Region | null,
position?: number[]
): void
```
---
## 命令系统 (Command System)
### `Command`
解析后的命令对象。
```typescript
type Command = {
name: string; // 命令名
flags: Record<string, true>; // 标志(如 --verbose)
options: Record<string, unknown>; // 选项(如 --player X)
params: unknown[]; // 位置参数
}
```
---
### `CommandSchema`
命令模式定义,用于验证和解析。
```typescript
type CommandSchema = {
name: string;
params: CommandParamSchema[];
options: Record<string, CommandOptionSchema>;
flags: Record<string, CommandFlagSchema>;
}
```
---
### `CommandResult<T>`
命令执行结果(判别联合类型)。
```typescript
type CommandResult<T = unknown> =
| { success: true; result: T }
| { success: false; error: string }
```
**使用示例:**
```typescript
const result = await game.run('move piece1 piece2');
if (result.success) {
console.log('命令执行成功', result.result);
} else {
console.error('命令执行失败', result.error);
}
```
---
### `CommandDef<TContext, TFunc>`
命令定义对象,用于 `registry.register()`。
```typescript
type CommandDef<TContext, TFunc extends CommandFunction<TContext>> = {
schema: string | CommandSchema;
run: TFunc;
}
type CommandFunction<TContext> = (ctx: TContext, ...args: any[]) => Promise<unknown>;
```
---
### `CommandRegistry<TContext>`
命令注册表。
```typescript
class CommandRegistry<TContext> extends Map<string, CommandRunner<TContext>> {
register<TFunc>(
...args: [schema: CommandSchema | string, run: TFunc] | [CommandDef<TContext, TFunc>]
): (ctx, ...args) => Promise<TResult>
}
```
**注册命令的两种方式:**
```typescript
// 方式 1:直接传入 schema 和函数
registry.register('move <from> <to>', async function(ctx, from, to) {
ctx.produce((state) => { /* 修改状态 */ });
return { success: true, result: undefined };
});
// 方式 2:使用 CommandDef 对象
registry.register({
schema: 'move <from> <to>',
run: async function(ctx, from, to) {
ctx.produce((state) => { /* 修改状态 */ });
return { success: true, result: undefined };
}
});
```
---
### `parseCommand(input, schema?)`
解析命令字符串为 `Command` 对象。
```typescript
function parseCommand(input: string, schema?: CommandSchema): Command
```
---
### `parseCommandSchema(schemaStr, name?)`
从字符串模式解析命令模式。
```typescript
function parseCommandSchema(schemaStr: string, name?: string): CommandSchema
```
**Schema 语法:**
- `<param>` - 必需参数
- `[param]` - 可选参数
- `[param:type]` - 带类型验证的参数(如 `[count:number]`)
- `--option:value` - 必需选项
- `[-o value]` - 可选选项
- `[--flag]` - 可选标志
---
## 随机数生成器 (RNG)
### `ReadonlyRNG`
只读 RNG 接口(`IGameContext.rng` 返回此类型)。
```typescript
interface ReadonlyRNG {
next(max?: number): number; // [0,1) 随机数,或 [0,max)
nextInt(max: number): number; // [0,max) 随机整数
}
```
---
### `RNG`
可设置种子的 RNG 接口。
```typescript
interface RNG extends ReadonlyRNG {
setSeed(seed: number): void;
getSeed(): number;
}
```
**使用示例:**
```typescript
// 在 IGameContext 中使用
const roll = game.rng.nextInt(6) + 1; // 1-6 的随机数
// 在区域操作中使用时
shuffle(region, parts, rng); // 需要传入 RNG
```
---
## 可变信号 (MutableSignal)
### `MutableSignal<T>`
扩展自 Preact Signal 的可变信号类,支持 mutative-style 的 `produce` 方法。
```typescript
class MutableSignal<T> extends Signal<T> {
produce(fn: (draft: T) => void): void;
addInterruption(promise: Promise<void>): void; // 添加中断 Promise(用于动画等待)
clearInterruptions(): void; // 清除所有中断
produceAsync(fn: (draft: T) => void): Promise<void>; // 等待中断后更新状态
}
```
---
### `mutableSignal<T>(initial?, options?)`
创建可变信号。
```typescript
function mutableSignal<T>(initial?: T, options?: SignalOptions<T>): MutableSignal<T>
```
---
## 游戏主机 (GameHost)
### `GameHost<TState, TResult>`
游戏会话的生命周期管理器。
```typescript
class GameHost<TState, TResult> {
readonly state: ReadonlySignal<TState>; // 游戏状态(响应式)
readonly status: ReadonlySignal<GameHostStatus>; // 运行状态
readonly activePromptSchema: ReadonlySignal<CommandSchema | null>; // 当前活动提示的模式
readonly activePromptPlayer: ReadonlySignal<string | null>; // 当前等待输入的玩家
readonly activePromptHint: ReadonlySignal<string | null>; // 当前提示文本
tryInput(input: string): string | null; // 尝试提交输入,返回错误信息或 null
tryAnswerPrompt<TArgs>(def: PromptDef<TArgs>, ...args: TArgs): void; // 尝试回答提示
addInterruption(promise: Promise<void>): void; // 注册中断 Promise(用于动画)
clearInterruptions(): void; // 清除所有中断
start(seed?: number): Promise<TResult>; // 启动游戏
dispose(): void; // 销毁游戏
on(event: 'start' | 'dispose', listener: () => void): () => void; // 注册事件监听
}
```
---
### `GameHostStatus`
```typescript
type GameHostStatus = 'created' | 'running' | 'disposed';
```
---
### `createGameHost<TState>(gameModule)`
从游戏模块创建 `GameHost` 实例。
```typescript
function createGameHost<TState extends Record<string, unknown>>(
gameModule: GameModule<TState>
): GameHost<TState>
```
**使用示例:**
```typescript
import { createGameHost } from '@/index';
import { gameModule } from './my-game';
const host = createGameHost(gameModule);
// 启动游戏
const result = await host.start(42); // 传入种子
// 提交玩家输入
const error = host.tryInput('play X 0 0');
if (error) {
console.error('输入错误:', error);
}
// 监听事件
host.on('start', () => console.log('游戏开始'));
host.on('dispose', () => console.log('游戏结束'));
// 销毁游戏
host.dispose();
```
---
## Preact Signals 重新导出
```typescript
export * from '@preact/signals-core';
```
开发者可直接使用 `@preact/signals-core` 的所有导出,包括:
- `Signal<T>` - 基础信号类
- `ReadonlySignal<T>` - 只读信号类型
- `signal<T>(value)` - 创建信号
- `computed<T>(fn)` - 创建计算信号
- `effect(fn)` - 创建副作用
- `batch(fn)` - 批量更新
- `untracked(fn)` - 非追踪读取
---
## 测试辅助函数
以下函数主要用于测试代码:
### `createGameContext(options)`
创建游戏上下文实例。
```typescript
function createGameContext<TState>(options: {
initialState: TState;
registry?: CommandRegistry<IGameContext<TState>>;
rng?: RNG;
}): IGameContext<TState>
```
**使用示例:**
```typescript
import { createGameContext } from '@/core/game';
import { createInitialState, registry } from './my-game';
const ctx = createGameContext({
initialState: createInitialState(),
registry,
});
// 执行命令
await ctx.run('move piece1 piece2');
// 断言状态
expect(ctx.value.score).toBe(10);
```
---
### `createTestContext()`
创建用于测试的游戏上下文(简化版)。
```typescript
function createTestContext<TState>(initialState: TState): IGameContext<TState>
```
---
### `createTestRegion()`
创建用于测试的区域。
```typescript
function createTestRegion(): Region
```
+1
View File
@@ -0,0 +1 @@
!node_modules
+93
View File
@@ -0,0 +1,93 @@
# AGENTS.md - boardgame-core
## Commands
Shell environment: git bash (bash.exe) on Windows.
```bash
npm run build # ESM bundle + declarations to dist/ (tsup)
npm run build:samples # Build samples separately (tsup.samples.config.ts)
npm run test # vitest in watch mode
npm run test:run # vitest once (no watch)
npm run typecheck # tsc --noEmit
```
Run a single test file: `npx vitest run tests/core/game.test.ts`
Run tests matching a name: `npx vitest run -t "should create"`
`npm run prepare` runs `npm run build` on install — if `inline-schema` isn't available, installs will fail.
## Local Dependency
`inline-schema` is a **local peer dependency** at `file:../inline-schema` — must exist as a sibling directory before `npm install`. Both vitest and tsup load custom plugins from it (`inline-schema/csv-loader/rollup` and `inline-schema/csv-loader/esbuild`). `yarn-spinner-loader` is also a local dependency at `file:../yarn-spinner-loader` — it can be changed and published if needed. It provides vitest and tsup/esbuild plugins for `.yarnproject` dialogue files.
## Architecture
- **MutableSignal\<T\>**: extends Preact Signal — `.value` to read, `.produce(draft => ...)` to mutate (uses `mutative`). `.produceAsync()` awaits interruption promises first, then mutates.
- **GameHost**: lifecycle manager — `start(seed?)`, `tryInput(string)`, `tryAnswerPrompt(def, ...args)`, `dispose()`. Reactive signals: `state`, `status`, `activePromptSchema`, `activePromptPlayer`, `activePromptHint`.
- **GameModule**: `{ registry?, createInitialState, start }`. `registry` defaults to `createGameCommandRegistry()` if omitted.
- **Command system**: CLI-style parsing with schema validation via `inline-schema`. `CommandRunner.run()` uses `this` bound to a `CommandRunnerContext`.
- **Prompt system**: `game.prompt(def, validator, currentPlayer?)` — validator **throws a string** to reject, **returns a value** to accept. `PromptEvent.tryCommit(Command | string)` returns `null` on success, error string on failure. `promptEnd` event fires after resolve or cancel.
- **Part collections**: `Record<string, Part<TMeta>>` keyed by ID. `Object.values()` for iteration, `Object.keys()` for count/IDs.
- **Region system**: `createRegion()` factory; `partMap` maps position keys to part IDs.
- **Barrel exports**: `src/index.ts` is the single public API surface.
## Project Structure
```
src/
index.ts # Barrel export (single public API)
core/
game.ts # Game core logic
game-host.ts # GameHost lifecycle manager
part.ts # Part entity
part-factory.ts # Part factory
region.ts # Region/space management
utils/
mutable-signal.ts # MutableSignal<T> (extends Preact Signal)
rng.ts # Mulberry32 seeded PRNG
async-queue.ts # Async queue
middleware.ts # Middleware chain
command/ # CLI-style command system (7 files)
types.ts, command-parse.ts, command-registry.ts
command-runner.ts, command-validate.ts, command-apply.ts, schema-parse.ts, index.ts
samples/
tic-tac-toe.ts # Simple sample game
boop/ # Boop board game
onitama/ # Onitama card game
regicide/ # Regicide card game
slay-the-spire-like/ # Roguelike deckbuilder (see samples/slay-the-spire-like/AGENTS.md)
tests/ # Mirrors src/ with *.test.ts (20 files)
core/ # game-host, game, part, part-factory, region
samples/ # Each sample + slay-the-spire-like submodules
utils/ # mutable-signal, rng, async-queue, middleware, command
```
`src/global.d.ts` — `*.yarnproject` module declaration.
`testsgames/` and `testsrules/` — empty directories (reserved).
## Samples Build
`tsup.samples.config.ts` auto-discovers entries from `src/samples/` (directories → `index.ts`, files → direct `.ts`). It rewrites `@/core/*`, `@/utils/*`, and `@/index` imports to external `boardgame-core`.
## Code Style
- Double quotes for local imports, single quotes for npm packages
- `@/*` alias maps to `src/*` (configured in tsconfig, vitest, and tsup)
- Group imports: npm packages first, then local `@/` imports, separated by blank line
- 4-space indentation, semicolons, trailing commas in multi-line
- Arrow functions for callbacks; `function` keyword for methods needing `this` (e.g. command handlers)
- Strict TypeScript — no `any`; type aliases for object shapes (not interfaces)
- Discriminated unions for results: `{ success: true; result: T } | { success: false; error: string }`
- Derive state types via `ReturnType<typeof createInitialState>`
- Factory function prefix: `create` or `mutable` — `createGameHost`, `mutableSignal`
- Validation error messages in Chinese (e.g. `"参数不足"`)
## Testing
- Vitest with globals enabled, but **test files import explicitly**: `import { describe, it, expect } from 'vitest'`
- Use `@/` imports in test files (vitest resolve alias)
- Command/prompt test pattern: `waitForPrompt(ctx)` → `promptEvent.tryCommit(Command)` → returns `null` | error string
- No mocking — real implementations. Define inline helpers (`createTestContext()`, `waitForPrompt()`) per file.
- Narrow result types: `if (result.success)` before accessing `result.result`
+36 -166
View File
@@ -1,182 +1,52 @@
# boardgame-core # boardgame-core
基于 Preact Signals 的桌游状态管理库。 基于 [Preact Signals](https://preactjs.com/guide/v10/signals/) 的桌游状态管理库。
使用响应式状态、实体集合、空间区域和命令驱动的游戏循环来构建回合制桌游。
## 特性 ## 特性
- **响应式状态管理**: 使用 [@preact/signals-core](https://preactjs.com/guide/v10/signals/) 实现细粒度响应式 - **响应式状态管理**:基于 [@preact/signals-core](https://preactjs.com/guide/v10/signals/) 的细粒度响应
- **类型安全**: 完整的 TypeScript 支持 - **类型安全**:完整的 TypeScript 支持,启用严格模式和泛型上下文扩展
- **核心概念**: - **区域系统**:支持多轴定位、对齐和洗牌的空间管理
- **Part**: 游戏组件(棋子、卡牌、板块) - **命令系统**:CLI 风格的命令解析,带 schema 校验、类型转换和玩家输入提示
- **Region**: 容器区域(支持 keyed/unkeyed 两种模式) - **游戏生命周期管理**:`GameHost` 类提供清晰的游戏设置/重置/销毁生命周期
- **Placement**: Part 在 Region 中的引用 - **确定性 RNG**:Mulberry32 种子伪随机数生成器,用于可复现的游戏状态
## 安装
```bash
npm install
```
## 快速开始 ## 快速开始
### 1. 创建游戏状态
```typescript
import { createGameState } from 'boardgame-core';
const gameState = createGameState({
id: 'game-1',
name: 'My Board Game',
});
```
### 2. 创建 Parts(游戏组件)
```typescript
import { createMeepleAction, createCardAction, createTileAction } from 'boardgame-core';
// 创建棋子
const meeple = createMeepleAction(gameState, 'meeple-1', 'red', {
name: 'Player 1',
});
// 创建卡牌
const card = createCardAction(gameState, 'card-1', {
suit: 'hearts',
value: 10,
});
// 创建板块
const tile = createTileAction(gameState, 'tile-1', {
pattern: 'forest',
rotation: 90,
});
```
### 3. 创建 Regions(区域)
```typescript
import { createRegionAction, RegionType } from 'boardgame-core';
// Unkeyed Region - 适用于牌库、弃牌堆等
const deck = createRegionAction(gameState, {
id: 'deck',
type: RegionType.Unkeyed,
name: 'Draw Deck',
});
// Keyed Region - 适用于版图、玩家区域等有固定位置的区域
const board = createRegionAction(gameState, {
id: 'board',
type: RegionType.Keyed,
name: 'Game Board',
});
// 带容量的区域(如手牌限制)
const hand = createRegionAction(gameState, {
id: 'hand',
type: RegionType.Unkeyed,
capacity: 5,
});
```
### 4. 创建 Placements(放置)
```typescript
import { createPlacementAction } from 'boardgame-core';
// 将棋子放置在版图上
const placement = createPlacementAction(gameState, {
id: 'placement-1',
partId: 'meeple-1',
regionId: 'board',
position: { x: 3, y: 4 },
});
```
### 5. 使用 Actions 操作状态
```typescript
import {
movePlacementAction,
flipPlacementAction,
setSlotAction,
updatePartAction,
} from 'boardgame-core';
// 移动 Placement 到另一个区域
movePlacementAction(gameState, 'placement-1', 'hand');
// 翻转卡牌(面朝上/面朝下)
flipPlacementAction(gameState, 'placement-1');
// 在 Keyed Region 中设置槽位
setSlotAction(gameState, 'board', 'A1', 'placement-1');
// 更新 Part 属性
updatePartAction(gameState, 'meeple-1', { metadata: { score: 10 } });
```
## API 参考
### Part Actions
| Action | 描述 |
|--------|------|
| `createPartAction` | 创建通用 Part |
| `createMeepleAction` | 创建棋子 |
| `createCardAction` | 创建卡牌 |
| `createTileAction` | 创建板块 |
| `updatePartAction` | 更新 Part 属性 |
| `removePartAction` | 移除 Part |
| `getPartAction` | 获取 Part |
### Region Actions
| Action | 描述 |
|--------|------|
| `createRegionAction` | 创建 Region |
| `getRegionAction` | 获取 Region |
| `removeRegionAction` | 移除 Region |
| `addPlacementToRegionAction` | 添加 Placement 到 Unkeyed Region |
| `removePlacementFromRegionAction` | 从 Region 移除 Placement |
| `setSlotAction` | 设置 Keyed Region 的槽位 |
| `getSlotAction` | 获取 Keyed Region 的槽位 |
| `clearRegionAction` | 清空 Region |
| `getRegionPlacementCountAction` | 获取 Region 中 Placement 数量 |
| `isRegionEmptyAction` | 检查 Region 是否为空 |
| `isRegionFullAction` | 检查 Region 是否已满 |
### Placement Actions
| Action | 描述 |
|--------|------|
| `createPlacementAction` | 创建 Placement |
| `getPlacementAction` | 获取 Placement |
| `removePlacementAction` | 移除 Placement |
| `movePlacementAction` | 移动 Placement 到另一个 Region |
| `updatePlacementPositionAction` | 更新 Placement 位置 |
| `updatePlacementRotationAction` | 更新 Placement 旋转角度 |
| `flipPlacementAction` | 翻转 Placement |
| `updatePlacementPartAction` | 更新 Placement 的 Part 引用 |
| `swapPlacementsAction` | 交换两个 Placement 的位置 |
| `setPlacementFaceAction` | 设置 Placement 面朝上/下 |
| `getPlacementsInRegionAction` | 获取 Region 中的所有 Placements |
| `getPlacementsOfPartAction` | 获取 Part 的所有 Placements |
## 运行测试
```bash ```bash
npm test # 监视模式 npm install boardgame-core
npm run test:run # 运行一次
``` ```
## 构建 ```ts
import { createGameHost } from 'boardgame-core';
import * as tictactoe from 'boardgame-core/samples/tic-tac-toe';
```bash const host = createGameHost(tictactoe);
npm run build await host.start('start');
// 监听状态变化
effect(() => {
const state = host.state.value;
console.log(`${state.currentPlayer}'s turn`);
});
// 处理玩家输入
const error = host.onInput('play X 1 2');
``` ```
## 许可证 ## 文档导航
| 文档 | 说明 |
|---|---|
| [使用 GameHost](docs/game-host.md) | GameHost 生命周期、响应式状态、事件处理 |
| [编写 GameModule](docs/game-module.md) | 定义状态、注册命令、prompt 系统、Part/Region 使用 |
| [棋子、区域与 RNG](docs/parts-regions-rng.md) | Part 创建、池管理、区域操作、RNG 使用 |
| [动画与状态同步](docs/animation-sync.md) | produceAsync 和中断机制 |
| [API 参考](docs/api-reference.md) | 所有导出 API 的完整列表 |
| [开发指南](docs/development.md) | 安装、构建脚本、测试命令 |
## License
MIT MIT
-51
View File
@@ -1,51 +0,0 @@
{
"id": "card-play-001",
"name": "Play Card",
"description": "Draw a card from deck and play it to discard pile",
"steps": [
{
"action": "createCard",
"params": {
"id": "card-hearts-10",
"suit": "hearts",
"value": 10,
"name": "10 of Hearts"
}
},
{
"action": "createPlacement",
"params": {
"id": "placement-card-1",
"partId": "card-hearts-10",
"regionId": "player-hand",
"faceUp": false
}
},
{
"action": "addPlacementToRegion",
"params": {
"regionId": "player-hand",
"placementId": "placement-card-1"
}
},
{
"action": "setPlacementFace",
"params": {
"placementId": "placement-card-1",
"faceUp": true
}
},
{
"action": "movePlacement",
"params": {
"placementId": "placement-card-1",
"targetRegionId": "discard"
}
}
],
"metadata": {
"version": "1.0",
"category": "action",
"cardGame": true
}
}
-81
View File
@@ -1,81 +0,0 @@
# Board Game CLI Commands
# 命令格式:<command> <args> [--flags]
# ========== 游戏设置 ==========
# 创建游戏区域
region board keyed --name="Game Board"
region supply unkeyed --name=Supply
region discard unkeyed --name="Discard Pile"
region hand unkeyed --name=Hand --capacity=5
# 设置游戏阶段
phase setup
# ========== 创建组件 ==========
# 创建棋子
create meeple red-1 --color=red --name="Red Player 1"
create meeple blue-1 --color=blue --name="Blue Player 1"
# 创建卡牌
create card hearts-10 --suit=hearts --value=10
create card spades-ace --suit=spades --value=ace
# 创建板块
create tile forest-1 --pattern=forest --rotation=90
# ========== 放置组件 ==========
# 放置棋子到版图
place red-1 board 3 4 --rotation=0 --faceup=true
# 放置卡牌到手牌(面朝下)
place hearts-10 hand 0 0 --faceup=false
# ========== 移动和操作 ==========
# 移动棋子
move red-1 board --key=B3
# 翻转卡牌
flip hearts-10
# 旋转板块
rotate forest-1 180
# 设置位置
position red-1 5 2
# 交换两个棋子
swap red-1 blue-1
# ========== 卡牌操作 ==========
# 抽牌
draw supply 1 --to=hand
# 洗牌(带种子)
shuffle discard --seed=2026
# 出牌到弃牌堆
discard hearts-10 --to=discard
# ========== 清理 ==========
# 清空区域
clear hand
# 移除组件
remove part red-1
remove placement p1
remove region temp
# ========== 帮助 ==========
# 显示所有命令
help
# 显示特定命令帮助
help move
help shuffle
-57
View File
@@ -1,57 +0,0 @@
# Movement Commands Examples
# 移动命令示例
# ========== 基本移动 ==========
# 简单移动
move piece-1 board
# 移动到指定槽位
move piece-1 board --key=A1
# ========== 位置操作 ==========
# 设置精确位置
position piece-1 0 0
position piece-1 3 5
position piece-1 -2 4
# 旋转
rotate tile-1 90
rotate tile-1 180
rotate tile-1 270
rotate tile-1 -45
# ========== 翻转 ==========
# 翻转(切换面朝上/下)
flip card-1
flip tile-2
# ========== 交换 ==========
# 交换两个棋子位置
swap meeple-1 meeple-2
# ========== 组合移动示例 ==========
# 移动并旋转
move knight-1 board --key=C3
rotate knight-1 45
# 移动并设置位置
move bishop-1 board
position bishop-1 4 4
# ========== 高级用法 ==========
# 移动多个棋子(需要多行)
move rook-1 board --key=A1
move rook-2 board --key=H1
move king-1 board --key=E1
move queen-1 board --key=D1
# 批量翻转
flip card-1
flip card-2
flip card-3
-72
View File
@@ -1,72 +0,0 @@
{
"id": "combo-001",
"name": "Combo Move",
"description": "A complex combo: place meeple, move it, then flip a card",
"steps": [
{
"action": "createMeeple",
"params": {
"id": "meeple-blue-1",
"color": "blue"
}
},
{
"action": "createPlacement",
"params": {
"id": "placement-blue-1",
"partId": "meeple-blue-1",
"regionId": "supply",
"position": {
"x": 0,
"y": 0
}
}
},
{
"action": "movePlacement",
"params": {
"placementId": "placement-blue-1",
"targetRegionId": "board",
"key": "A1"
}
},
{
"action": "updatePlacementPosition",
"params": {
"placementId": "placement-blue-1",
"position": {
"x": 1,
"y": 1
}
}
},
{
"action": "createCard",
"params": {
"id": "card-spades-ace",
"suit": "spades",
"value": "ace"
}
},
{
"action": "createPlacement",
"params": {
"id": "placement-card-ace",
"partId": "card-spades-ace",
"regionId": "discard",
"faceUp": false
}
},
{
"action": "flipPlacement",
"params": {
"placementId": "placement-card-ace"
}
}
],
"metadata": {
"version": "1.0",
"category": "combo",
"difficulty": "medium"
}
}
-22
View File
@@ -1,22 +0,0 @@
{
"id": "move-meeple-001",
"name": "Move Meeple",
"description": "Move a meeple from one position to another on the board",
"steps": [
{
"action": "updatePlacementPosition",
"params": {
"placementId": "placement-red-1",
"position": {
"x": 5,
"y": 2
}
}
}
],
"metadata": {
"version": "1.0",
"category": "action",
"playerCount": 1
}
}
-44
View File
@@ -1,44 +0,0 @@
{
"id": "phase-change-001",
"name": "Phase Change",
"description": "Change the game phase with setup steps",
"steps": [
{
"action": "setPhase",
"params": {
"phase": "setup"
}
},
{
"action": "createRegion",
"params": {
"id": "board",
"type": "keyed",
"name": "Game Board"
}
},
{
"action": "setPhase",
"params": {
"phase": "main"
}
},
{
"action": "createMeeple",
"params": {
"id": "meeple-green-1",
"color": "green"
}
},
{
"action": "setPhase",
"params": {
"phase": "end"
}
}
],
"metadata": {
"version": "1.0",
"category": "game-flow"
}
}
-42
View File
@@ -1,42 +0,0 @@
{
"id": "place-meeple-001",
"name": "Place Meeple",
"description": "Place a meeple on the board at specified position",
"steps": [
{
"action": "createMeeple",
"params": {
"id": "meeple-red-1",
"color": "red",
"name": "Red Meeple 1"
}
},
{
"action": "createPlacement",
"params": {
"id": "placement-red-1",
"partId": "meeple-red-1",
"regionId": "board",
"position": {
"x": 3,
"y": 4
},
"rotation": 0,
"faceUp": true
}
},
{
"action": "setSlot",
"params": {
"regionId": "board",
"key": "C4",
"placementId": "placement-red-1"
}
}
],
"metadata": {
"version": "1.0",
"category": "action",
"playerCount": 1
}
}
-44
View File
@@ -1,44 +0,0 @@
{
"id": "setup-game-001",
"name": "Setup Game",
"description": "Initialize the game board with basic regions and starting components",
"steps": [
{
"action": "createRegion",
"params": {
"id": "board",
"type": "keyed",
"name": "Game Board"
}
},
{
"action": "createRegion",
"params": {
"id": "supply",
"type": "unkeyed",
"name": "Supply"
}
},
{
"action": "createRegion",
"params": {
"id": "discard",
"type": "unkeyed",
"name": "Discard Pile"
}
},
{
"action": "createRegion",
"params": {
"id": "player-hand",
"type": "unkeyed",
"name": "Player Hand",
"capacity": 5
}
}
],
"metadata": {
"version": "1.0",
"category": "setup"
}
}
-43
View File
@@ -1,43 +0,0 @@
{
"id": "tile-place-001",
"name": "Place Tile",
"description": "Place a terrain tile on the board with rotation",
"steps": [
{
"action": "createTile",
"params": {
"id": "tile-forest-1",
"pattern": "forest",
"rotation": 90,
"name": "Forest Tile 1"
}
},
{
"action": "createPlacement",
"params": {
"id": "placement-tile-1",
"partId": "tile-forest-1",
"regionId": "board",
"position": {
"x": 2,
"y": 3
},
"rotation": 90,
"faceUp": true
}
},
{
"action": "setSlot",
"params": {
"regionId": "board",
"key": "B3",
"placementId": "placement-tile-1"
}
}
],
"metadata": {
"version": "1.0",
"category": "action",
"tileGame": true
}
}
+402 -8
View File
@@ -8,14 +8,48 @@
"name": "boardgame-core", "name": "boardgame-core",
"version": "1.0.0", "version": "1.0.0",
"license": "MIT", "license": "MIT",
"dependencies": {
"@preact/signals-core": "^1.5.1",
"boardgame-core": "file:"
},
"devDependencies": { "devDependencies": {
"@preact/signals-core": "^1.5.1",
"@types/node": "^25.6.0",
"inline-schema": "file:../inline-schema",
"mutative": "^1.3.0",
"tsup": "^8.0.2", "tsup": "^8.0.2",
"typescript": "^5.3.3", "typescript": "^5.3.3",
"vitest": "^1.3.1" "vitest": "^1.3.1",
"yarn-spinner-loader": "^0.2.0"
},
"peerDependencies": {
"@preact/signals-core": "^1.5.1",
"inline-schema": "file:../inline-schema",
"mutative": "^1.3.0"
}
},
"../inline-schema": {
"version": "1.0.0",
"dev": true,
"license": "ISC",
"dependencies": {
"csv-parse": "^5.5.6"
},
"devDependencies": {
"@rspack/core": "^1.1.6",
"@types/node": "^25.5.0",
"tsup": "^8.5.1",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"vitest": "^4.1.4"
},
"peerDependencies": {
"@rspack/core": "^1.x",
"esbuild": "*"
},
"peerDependenciesMeta": {
"@rspack/core": {
"optional": true
},
"esbuild": {
"optional": true
}
} }
}, },
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
@@ -512,10 +546,49 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "2.0.5",
"run-parallel": "^1.1.9"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/@nodelib/fs.stat": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/@nodelib/fs.walk": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.scandir": "2.1.5",
"fastq": "^1.6.0"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/@preact/signals-core": { "node_modules/@preact/signals-core": {
"version": "1.14.0", "version": "1.14.0",
"resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.14.0.tgz", "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.14.0.tgz",
"integrity": "sha512-AowtCcCU/33lFlh1zRFf/u+12rfrhtNakj7UpaGEsmMwUKpKWMVvcktOGcwBBNiB4lWrZWc01LhiyyzVklJyaQ==", "integrity": "sha512-AowtCcCU/33lFlh1zRFf/u+12rfrhtNakj7UpaGEsmMwUKpKWMVvcktOGcwBBNiB4lWrZWc01LhiyyzVklJyaQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
@@ -886,6 +959,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": {
"version": "25.6.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.19.0"
}
},
"node_modules/@vitest/expect": { "node_modules/@vitest/expect": {
"version": "1.6.1", "version": "1.6.1",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz",
@@ -1000,6 +1083,23 @@
"node": ">=0.4.0" "node": ">=0.4.0"
} }
}, },
"node_modules/ajv": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ansi-styles": { "node_modules/ansi-styles": {
"version": "5.2.0", "version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
@@ -1030,9 +1130,18 @@
"node": "*" "node": "*"
} }
}, },
"node_modules/boardgame-core": { "node_modules/braces": {
"resolved": "", "version": "3.0.3",
"link": true "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"fill-range": "^7.1.1"
},
"engines": {
"node": ">=8"
}
}, },
"node_modules/bundle-require": { "node_modules/bundle-require": {
"version": "5.1.0", "version": "5.1.0",
@@ -1267,6 +1376,57 @@
"url": "https://github.com/sindresorhus/execa?sponsor=1" "url": "https://github.com/sindresorhus/execa?sponsor=1"
} }
}, },
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT"
},
"node_modules/fast-glob": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "^2.0.2",
"@nodelib/fs.walk": "^1.2.3",
"glob-parent": "^5.1.2",
"merge2": "^1.3.0",
"micromatch": "^4.0.8"
},
"engines": {
"node": ">=8.6.0"
}
},
"node_modules/fast-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause"
},
"node_modules/fastq": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
"integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
"dev": true,
"license": "ISC",
"dependencies": {
"reusify": "^1.0.4"
}
},
"node_modules/fdir": { "node_modules/fdir": {
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -1285,6 +1445,19 @@
} }
} }
}, },
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"license": "MIT",
"dependencies": {
"to-regex-range": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/fix-dts-default-cjs-exports": { "node_modules/fix-dts-default-cjs-exports": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz",
@@ -1335,6 +1508,19 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/human-signals": { "node_modules/human-signals": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz",
@@ -1345,6 +1531,43 @@
"node": ">=16.17.0" "node": ">=16.17.0"
} }
}, },
"node_modules/inline-schema": {
"resolved": "../inline-schema",
"link": true
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.12.0"
}
},
"node_modules/is-stream": { "node_modules/is-stream": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz",
@@ -1382,6 +1605,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"dev": true,
"license": "MIT"
},
"node_modules/lilconfig": { "node_modules/lilconfig": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
@@ -1456,6 +1686,43 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/micromatch": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
"license": "MIT",
"dependencies": {
"braces": "^3.0.3",
"picomatch": "^2.3.1"
},
"engines": {
"node": ">=8.6"
}
},
"node_modules/micromatch/node_modules/picomatch": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/mimic-fn": { "node_modules/mimic-fn": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz",
@@ -1489,6 +1756,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/mutative": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/mutative/-/mutative-1.3.0.tgz",
"integrity": "sha512-8MJj6URmOZAV70dpFe1YnSppRTKC4DsMkXQiBDFayLcDI4ljGokHxmpqaBQuDWa4iAxWaJJ1PS8vAmbntjjKmQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0"
}
},
"node_modules/mz": { "node_modules/mz": {
"version": "2.7.0", "version": "2.7.0",
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
@@ -1747,6 +2024,27 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0" "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
} }
}, },
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/react-is": { "node_modules/react-is": {
"version": "18.3.1", "version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
@@ -1768,6 +2066,16 @@
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
} }
}, },
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/resolve-from": { "node_modules/resolve-from": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
@@ -1778,6 +2086,17 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/reusify": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
"dev": true,
"license": "MIT",
"engines": {
"iojs": ">=1.0.0",
"node": ">=0.10.0"
}
},
"node_modules/rollup": { "node_modules/rollup": {
"version": "4.60.1", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
@@ -1823,6 +2142,30 @@
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
} }
}, },
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"queue-microtask": "^1.2.2"
}
},
"node_modules/shebang-command": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -2023,6 +2366,19 @@
"node": ">=14.0.0" "node": ">=14.0.0"
} }
}, },
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-number": "^7.0.0"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/tree-kill": { "node_modules/tree-kill": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
@@ -2124,6 +2480,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/undici-types": {
"version": "7.19.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
"dev": true,
"license": "MIT"
},
"node_modules/vite": { "node_modules/vite": {
"version": "5.4.21", "version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
@@ -2750,6 +3113,37 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/yarn-spinner-loader": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/yarn-spinner-loader/-/yarn-spinner-loader-0.2.0.tgz",
"integrity": "sha512-tWq+n7qziugn7pj+0Zf/XXWXqVxXZez1JkqQgk7JJD4yikHbY1OihYwqSvsUiNeav52j5VCrpU8vWlerwCawPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"ajv": "^8.17.1",
"fast-glob": "^3.3.2"
},
"peerDependencies": {
"esbuild": ">=0.20.0",
"rollup": ">=4.0.0",
"vite": ">=5.0.0",
"webpack": ">=5.0.0"
},
"peerDependenciesMeta": {
"esbuild": {
"optional": true
},
"rollup": {
"optional": true
},
"vite": {
"optional": true
},
"webpack": {
"optional": true
}
}
},
"node_modules/yocto-queue": { "node_modules/yocto-queue": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz",
+17 -4
View File
@@ -9,21 +9,34 @@
".": { ".": {
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"import": "./dist/index.js" "import": "./dist/index.js"
},
"./samples/*": {
"types": "./dist/samples/*.d.ts",
"import": "./dist/samples/*.js"
} }
}, },
"scripts": { "scripts": {
"build": "tsup", "build": "tsup",
"build:samples": "tsup --config tsup.samples.config.ts",
"prepare": "npm run build && npm run build:samples",
"test": "vitest", "test": "vitest",
"test:run": "vitest run" "test:run": "vitest run",
"typecheck": "tsc --noEmit"
}, },
"dependencies": { "peerDependencies": {
"@preact/signals-core": "^1.5.1", "@preact/signals-core": "^1.5.1",
"boardgame-core": "file:" "inline-schema": "file:../inline-schema",
"mutative": "^1.3.0"
}, },
"devDependencies": { "devDependencies": {
"@preact/signals-core": "^1.5.1",
"@types/node": "^25.6.0",
"inline-schema": "file:../inline-schema",
"mutative": "^1.3.0",
"tsup": "^8.0.2", "tsup": "^8.0.2",
"typescript": "^5.3.3", "typescript": "^5.3.3",
"vitest": "^1.3.1" "vitest": "^1.3.1",
"yarn-spinner-loader": "^0.2.0"
}, },
"keywords": [ "keywords": [
"boardgame", "boardgame",
-95
View File
@@ -1,95 +0,0 @@
import type { GameState } from '../core/GameState';
import type { Part, MeeplePart, CardPart, TilePart } from '../core/Part';
import { PartType } from '../core/Part';
/**
* 创建 Part 并添加到 GameState
*/
export function createPartAction<T extends Part>(gameState: GameState, part: T): T {
gameState.addPart(part);
return part;
}
/**
* 创建 Meeple 并添加到 GameState
*/
export function createMeepleAction(
gameState: GameState,
id: string,
color: string,
options?: { name?: string; metadata?: Record<string, unknown> }
): MeeplePart {
const part: MeeplePart = {
id,
type: PartType.Meeple,
color,
...options,
};
gameState.addPart(part);
return part;
}
/**
* 创建 Card 并添加到 GameState
*/
export function createCardAction(
gameState: GameState,
id: string,
options?: { suit?: string; value?: number | string; name?: string; metadata?: Record<string, unknown> }
): CardPart {
const part: CardPart = {
id,
type: PartType.Card,
...options,
};
gameState.addPart(part);
return part;
}
/**
* 创建 Tile 并添加到 GameState
*/
export function createTileAction(
gameState: GameState,
id: string,
options?: { pattern?: string; rotation?: number; name?: string; metadata?: Record<string, unknown> }
): TilePart {
const part: TilePart = {
id,
type: PartType.Tile,
...options,
};
gameState.addPart(part);
return part;
}
/**
* 更新 Part
*/
export function updatePartAction<T extends Part>(
gameState: GameState,
partId: string,
updates: Partial<T>
): void {
gameState.updatePart<T>(partId, updates);
}
/**
* 移除 Part
*/
export function removePartAction(gameState: GameState, partId: string): void {
// 先移除所有引用该 Part 的 Placement
const placements = gameState.getPlacementsOfPart(partId);
for (const placement of placements) {
gameState.removePlacement(placement.id);
}
gameState.removePart(partId);
}
/**
* 获取 Part
*/
export function getPartAction(gameState: GameState, partId: string): Part | undefined {
return gameState.getPart(partId);
}
-222
View File
@@ -1,222 +0,0 @@
import type { GameState } from '../core/GameState';
import type { Placement, Position } from '../core/Placement';
import type { Part } from '../core/Part';
/**
* 创建 Placement 并添加到 GameState
*/
export function createPlacementAction(
gameState: GameState,
options: {
id: string;
partId: string;
regionId: string;
position?: Position;
rotation?: number;
faceUp?: boolean;
metadata?: Record<string, unknown>;
}
): Placement {
const part = gameState.getPart(options.partId);
if (!part) {
throw new Error(`Part ${options.partId} not found`);
}
const region = gameState.getRegion(options.regionId);
if (!region) {
throw new Error(`Region ${options.regionId} not found`);
}
const placement: Placement = {
id: options.id,
partId: options.partId,
regionId: options.regionId,
part,
position: options.position,
rotation: options.rotation ?? 0,
faceUp: options.faceUp ?? true,
metadata: options.metadata,
};
gameState.addPlacement(placement);
return placement;
}
/**
* 获取 Placement
*/
export function getPlacementAction(gameState: GameState, placementId: string): Placement | undefined {
return gameState.getPlacement(placementId);
}
/**
* 移除 Placement
*/
export function removePlacementAction(gameState: GameState, placementId: string): void {
gameState.removePlacement(placementId);
}
/**
* 移动 Placement 到另一个 Region
*/
export function movePlacementAction(
gameState: GameState,
placementId: string,
targetRegionId: string,
key?: string
): void {
gameState.movePlacement(placementId, targetRegionId, key);
}
/**
* 更新 Placement 的位置
*/
export function updatePlacementPositionAction(
gameState: GameState,
placementId: string,
position: Position
): void {
const placement = gameState.getPlacement(placementId);
if (!placement) {
throw new Error(`Placement ${placementId} not found`);
}
const updated = { ...placement, position };
const placements = new Map(gameState.placements.value);
placements.set(placementId, updated);
gameState.placements.value = placements;
}
/**
* 更新 Placement 的旋转角度
*/
export function updatePlacementRotationAction(
gameState: GameState,
placementId: string,
rotation: number
): void {
const placement = gameState.getPlacement(placementId);
if (!placement) {
throw new Error(`Placement ${placementId} not found`);
}
const updated = { ...placement, rotation };
const placements = new Map(gameState.placements.value);
placements.set(placementId, updated);
gameState.placements.value = placements;
}
/**
* 翻转 Placement
*/
export function flipPlacementAction(gameState: GameState, placementId: string): void {
const placement = gameState.getPlacement(placementId);
if (!placement) {
throw new Error(`Placement ${placementId} not found`);
}
const updated = { ...placement, faceUp: !placement.faceUp };
const placements = new Map(gameState.placements.value);
placements.set(placementId, updated);
gameState.placements.value = placements;
}
/**
* 更新 Placement 的 Part 引用
*/
export function updatePlacementPartAction(
gameState: GameState,
placementId: string,
part: Part | null
): void {
gameState.updatePlacementPart(placementId, part);
}
/**
* 交换两个 Placement 的位置
*/
export function swapPlacementsAction(
gameState: GameState,
placementId1: string,
placementId2: string
): void {
const placement1 = gameState.getPlacement(placementId1);
const placement2 = gameState.getPlacement(placementId2);
if (!placement1) {
throw new Error(`Placement ${placementId1} not found`);
}
if (!placement2) {
throw new Error(`Placement ${placementId2} not found`);
}
if (placement1.regionId !== placement2.regionId) {
throw new Error('Cannot swap placements in different regions directly');
}
const region = gameState.getRegion(placement1.regionId);
if (!region) {
throw new Error(`Region ${placement1.regionId} not found`);
}
// 如果是 keyed region,交换 slots
if (region.type === 'keyed' && region.slots) {
const slots = new Map(region.slots.value);
let key1: string | null = null;
let key2: string | null = null;
for (const [key, value] of slots.entries()) {
if (value === placementId1) key1 = key;
if (value === placementId2) key2 = key;
}
if (key1 && key2) {
slots.set(key1, placementId2);
slots.set(key2, placementId1);
region.slots.value = slots;
}
} else {
// unkeyed region:交换在 placements 列表中的位置
const placements = [...region.placements.value];
const index1 = placements.indexOf(placementId1);
const index2 = placements.indexOf(placementId2);
if (index1 !== -1 && index2 !== -1) {
[placements[index1], placements[index2]] = [placements[index2], placements[index1]];
region.placements.value = placements;
}
}
}
/**
* 将 Placement 设置为面朝上/面朝下
*/
export function setPlacementFaceAction(
gameState: GameState,
placementId: string,
faceUp: boolean
): void {
const placement = gameState.getPlacement(placementId);
if (!placement) {
throw new Error(`Placement ${placementId} not found`);
}
const updated = { ...placement, faceUp };
const placements = new Map(gameState.placements.value);
placements.set(placementId, updated);
gameState.placements.value = placements;
}
/**
* 获取 Region 中的所有 Placements
*/
export function getPlacementsInRegionAction(gameState: GameState, regionId: string): Placement[] {
return gameState.getPlacementsInRegion(regionId);
}
/**
* 获取 Part 的所有 Placements
*/
export function getPlacementsOfPartAction(gameState: GameState, partId: string): Placement[] {
return gameState.getPlacementsOfPart(partId);
}
-209
View File
@@ -1,209 +0,0 @@
import type { GameState } from '../core/GameState';
import type { Region, RegionProperties } from '../core/Region';
import { RegionType } from '../core/Region';
import type { Placement } from '../core/Placement';
import { signal } from '@preact/signals-core';
/**
* 创建 Region 并添加到 GameState
*/
export function createRegionAction(gameState: GameState, properties: RegionProperties): Region {
const region = {
...properties,
placements: signal<string[]>([]),
...(properties.type === RegionType.Keyed ? { slots: signal<Map<string, string | null>>(new Map()) } : {}),
} as Region;
gameState.addRegion(region);
return region;
}
/**
* 获取 Region
*/
export function getRegionAction(gameState: GameState, regionId: string): Region | undefined {
return gameState.getRegion(regionId);
}
/**
* 移除 Region
*/
export function removeRegionAction(gameState: GameState, regionId: string): void {
const region = gameState.getRegion(regionId);
if (region) {
// 先移除所有 Placement
const placementIds = [...region.placements.value];
for (const placementId of placementIds) {
gameState.removePlacement(placementId);
}
}
gameState.removeRegion(regionId);
}
/**
* 添加 Placement 到 Region (unkeyed)
*/
export function addPlacementToRegionAction(
gameState: GameState,
regionId: string,
placementId: string
): void {
const region = gameState.getRegion(regionId);
if (!region) {
throw new Error(`Region ${regionId} not found`);
}
if (region.slots !== undefined) {
throw new Error('Cannot use addPlacementToRegionAction on a keyed region. Use setSlotAction instead.');
}
// 检查容量
if (region.capacity !== undefined && region.placements.value.length >= region.capacity) {
throw new Error(`Region ${regionId} has reached its capacity of ${region.capacity}`);
}
region.placements.value = [...region.placements.value, placementId];
}
/**
* 从 Region 移除 Placement
*/
export function removePlacementFromRegionAction(
gameState: GameState,
regionId: string,
placementId: string
): void {
const region = gameState.getRegion(regionId);
if (!region) {
throw new Error(`Region ${regionId} not found`);
}
const current = region.placements.value;
const index = current.indexOf(placementId);
if (index !== -1) {
const updated = [...current];
updated.splice(index, 1);
region.placements.value = updated;
}
// 如果是 keyed region,清理 slot
if (region.type === RegionType.Keyed && region.slots) {
const slots = new Map(region.slots.value);
for (const [key, value] of slots.entries()) {
if (value === placementId) {
slots.set(key, null);
break;
}
}
region.slots.value = slots;
}
}
/**
* 设置 Keyed Region 的槽位
*/
export function setSlotAction(
gameState: GameState,
regionId: string,
key: string,
placementId: string | null
): void {
const region = gameState.getRegion(regionId);
if (!region) {
throw new Error(`Region ${regionId} not found`);
}
if (region.type !== RegionType.Keyed || !region.slots) {
throw new Error('Cannot use setSlotAction on an unkeyed region.');
}
const slots = new Map(region.slots.value);
// 如果是放置新 placement,需要更新 placements 列表
if (placementId !== null) {
const currentPlacements = region.placements.value;
if (!currentPlacements.includes(placementId)) {
region.placements.value = [...currentPlacements, placementId];
}
}
slots.set(key, placementId);
region.slots.value = slots;
}
/**
* 获取 Keyed Region 的槽位
*/
export function getSlotAction(
gameState: GameState,
regionId: string,
key: string
): string | null {
const region = gameState.getRegion(regionId);
if (!region) {
throw new Error(`Region ${regionId} not found`);
}
if (region.type !== RegionType.Keyed || !region.slots) {
throw new Error('Cannot use getSlotAction on an unkeyed region.');
}
return region.slots.value.get(key) ?? null;
}
/**
* 清空 Region
*/
export function clearRegionAction(gameState: GameState, regionId: string): void {
const region = gameState.getRegion(regionId);
if (!region) {
throw new Error(`Region ${regionId} not found`);
}
// 移除所有 Placement
const placementIds = [...region.placements.value];
for (const placementId of placementIds) {
gameState.removePlacement(placementId);
}
region.placements.value = [];
if (region.slots) {
region.slots.value = new Map();
}
}
/**
* 获取 Region 中 Placement 的数量
*/
export function getRegionPlacementCountAction(gameState: GameState, regionId: string): number {
const region = gameState.getRegion(regionId);
if (!region) {
return 0;
}
return region.placements.value.length;
}
/**
* 检查 Region 是否为空
*/
export function isRegionEmptyAction(gameState: GameState, regionId: string): boolean {
const region = gameState.getRegion(regionId);
if (!region) {
return true;
}
return region.placements.value.length === 0;
}
/**
* 检查 Region 是否已满
*/
export function isRegionFullAction(gameState: GameState, regionId: string): boolean {
const region = gameState.getRegion(regionId);
if (!region) {
return false;
}
if (region.capacity === undefined) {
return false;
}
return region.placements.value.length >= region.capacity;
}
-86
View File
@@ -1,86 +0,0 @@
/**
* CLI 命令参数
*/
export interface CliCommandArgs {
/** 位置参数 */
positional: string[];
/** 标志参数 (--key=value 或 --flag) */
flags: Record<string, string | boolean>;
}
/**
* CLI 命令定义
*/
export interface CliCommand {
/** 命令名称 */
name: string;
/** 命令描述 */
description: string;
/** 使用示例 */
usage: string;
/** 位置参数定义 */
args?: CliCommandArgDef[];
/** 标志参数定义 */
flags?: CliCommandFlagDef[];
/** 命令处理器 */
handler: (args: CliCommandArgs) => CliCommandStep[];
}
/**
* 位置参数定义
*/
export interface CliCommandArgDef {
/** 参数名称 */
name: string;
/** 参数描述 */
description: string;
/** 是否必需 */
required?: boolean;
/** 默认值 */
default?: string;
}
/**
* 标志参数定义
*/
export interface CliCommandFlagDef {
/** 标志名称 */
name: string;
/** 标志描述 */
description: string;
/** 是否必需 */
required?: boolean;
/** 默认值 */
default?: string | boolean;
/** 参数类型 */
type?: 'string' | 'number' | 'boolean';
/** 简写 */
alias?: string;
}
/**
* CLI 命令执行结果
*/
export interface CliCommandResult {
success: boolean;
output?: string;
error?: string;
steps: CliCommandStep[];
}
/**
* CLI 命令步骤(转换为标准 CommandStep)
*/
export interface CliCommandStep {
action: string;
params: Record<string, unknown>;
}
/**
* 命令解析结果
*/
export interface ParsedCliCommand {
commandName: string;
args: CliCommandArgs;
raw: string;
}
-115
View File
@@ -1,115 +0,0 @@
/**
* 命令步骤类型
*/
export enum CommandActionType {
// Part actions
CreateMeeple = 'createMeeple',
CreateCard = 'createCard',
CreateTile = 'createTile',
UpdatePart = 'updatePart',
RemovePart = 'removePart',
// Region actions
CreateRegion = 'createRegion',
RemoveRegion = 'removeRegion',
AddPlacementToRegion = 'addPlacementToRegion',
RemovePlacementFromRegion = 'removePlacementFromRegion',
SetSlot = 'setSlot',
ClearRegion = 'clearRegion',
// Placement actions
CreatePlacement = 'createPlacement',
RemovePlacement = 'removePlacement',
MovePlacement = 'movePlacement',
UpdatePlacementPosition = 'updatePlacementPosition',
UpdatePlacementRotation = 'updatePlacementRotation',
FlipPlacement = 'flipPlacement',
SetPlacementFace = 'setPlacementFace',
SwapPlacements = 'swapPlacements',
// Game actions
SetPhase = 'setPhase',
}
/**
* 命令步骤
*/
export interface CommandStep {
action: CommandActionType;
params: Record<string, unknown>;
}
/**
* 命令执行结果
*/
export interface CommandExecutionResult {
success: boolean;
error?: string;
executedSteps: number;
totalSteps: number;
}
/**
* 命令定义
*/
export interface Command {
/** 命令唯一标识 */
id: string;
/** 命令名称 */
name: string;
/** 命令描述 */
description?: string;
/** 命令步骤 */
steps: CommandStep[];
/** 元数据 */
metadata?: Record<string, unknown>;
}
/**
* 命令日志条目
*/
export interface CommandLogEntry {
/** 时间戳 */
timestamp: number;
/** 命令 ID */
commandId: string;
/** 命令名称 */
commandName: string;
/** 执行结果 */
result: CommandExecutionResult;
/** 执行的步骤详情 */
stepResults: StepResult[];
}
/**
* 步骤执行结果
*/
export interface StepResult {
stepIndex: number;
action: CommandActionType;
success: boolean;
error?: string;
params: Record<string, unknown>;
}
/**
* 命令状态
*/
export enum CommandStatus {
Pending = 'pending',
Executing = 'executing',
Completed = 'completed',
Failed = 'failed',
}
/**
* 待执行命令
*/
export interface QueuedCommand {
id: string;
command: Command;
status: CommandStatus;
queuedAt: number;
executedAt?: number;
result?: CommandExecutionResult;
}
-301
View File
@@ -1,301 +0,0 @@
import type { GameState } from '../core/GameState';
import { PartType } from '../core/Part';
import { RegionType } from '../core/Region';
import type { Command, CommandStep, CommandExecutionResult, StepResult, CommandActionType } from './Command';
import {
createMeepleAction,
createCardAction,
createTileAction,
updatePartAction,
removePartAction,
} from '../actions/part.actions';
import {
createRegionAction,
removeRegionAction,
addPlacementToRegionAction,
removePlacementFromRegionAction,
setSlotAction,
clearRegionAction,
} from '../actions/region.actions';
import {
createPlacementAction,
removePlacementAction,
movePlacementAction,
updatePlacementPositionAction,
updatePlacementRotationAction,
flipPlacementAction,
setPlacementFaceAction,
swapPlacementsAction,
} from '../actions/placement.actions';
/**
* 命令执行器
* 负责解析并执行命令中的每一步
*/
export class CommandExecutor {
private gameState: GameState;
constructor(gameState: GameState) {
this.gameState = gameState;
}
/**
* 执行命令
*/
execute(command: Command): CommandExecutionResult {
const stepResults: StepResult[] = [];
let hasError = false;
let errorMessage: string | undefined;
for (let i = 0; i < command.steps.length; i++) {
const step = command.steps[i];
const stepResult = this.executeStep(step, i);
stepResults.push(stepResult);
if (!stepResult.success) {
hasError = true;
errorMessage = stepResult.error;
break; // 遇到错误时停止执行
}
}
return {
success: !hasError,
error: errorMessage,
executedSteps: stepResults.filter((s) => s.success).length,
totalSteps: command.steps.length,
};
}
/**
* 执行单个步骤
*/
private executeStep(step: CommandStep, stepIndex: number): StepResult {
try {
const { action, params } = step;
switch (action) {
// Part actions
case 'createMeeple':
this.handleCreateMeeple(params);
break;
case 'createCard':
this.handleCreateCard(params);
break;
case 'createTile':
this.handleCreateTile(params);
break;
case 'updatePart':
this.handleUpdatePart(params);
break;
case 'removePart':
this.handleRemovePart(params);
break;
// Region actions
case 'createRegion':
this.handleCreateRegion(params);
break;
case 'removeRegion':
this.handleRemoveRegion(params);
break;
case 'addPlacementToRegion':
this.handleAddPlacementToRegion(params);
break;
case 'removePlacementFromRegion':
this.handleRemovePlacementFromRegion(params);
break;
case 'setSlot':
this.handleSetSlot(params);
break;
case 'clearRegion':
this.handleClearRegion(params);
break;
// Placement actions
case 'createPlacement':
this.handleCreatePlacement(params);
break;
case 'removePlacement':
this.handleRemovePlacement(params);
break;
case 'movePlacement':
this.handleMovePlacement(params);
break;
case 'updatePlacementPosition':
this.handleUpdatePlacementPosition(params);
break;
case 'updatePlacementRotation':
this.handleUpdatePlacementRotation(params);
break;
case 'flipPlacement':
this.handleFlipPlacement(params);
break;
case 'setPlacementFace':
this.handleSetPlacementFace(params);
break;
case 'swapPlacements':
this.handleSwapPlacements(params);
break;
// Game actions
case 'setPhase':
this.handleSetPhase(params);
break;
default:
throw new Error(`Unknown action type: ${action}`);
}
return {
stepIndex,
action,
success: true,
params,
};
} catch (error) {
return {
stepIndex,
action: step.action,
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
params: step.params,
};
}
}
// ========== Part action handlers ==========
private handleCreateMeeple(params: Record<string, unknown>): void {
const { id, color, name, metadata } = params;
createMeepleAction(this.gameState, id as string, color as string, {
name: name as string,
metadata: metadata as Record<string, unknown>,
});
}
private handleCreateCard(params: Record<string, unknown>): void {
const { id, suit, value, name, metadata } = params;
createCardAction(this.gameState, id as string, {
suit: suit as string,
value: value as number | string,
name: name as string,
metadata: metadata as Record<string, unknown>,
});
}
private handleCreateTile(params: Record<string, unknown>): void {
const { id, pattern, rotation, name, metadata } = params;
createTileAction(this.gameState, id as string, {
pattern: pattern as string,
rotation: rotation as number,
name: name as string,
metadata: metadata as Record<string, unknown>,
});
}
private handleUpdatePart(params: Record<string, unknown>): void {
const { partId, updates } = params;
updatePartAction(this.gameState, partId as string, updates as Record<string, unknown>);
}
private handleRemovePart(params: Record<string, unknown>): void {
const { partId } = params;
removePartAction(this.gameState, partId as string);
}
// ========== Region action handlers ==========
private handleCreateRegion(params: Record<string, unknown>): void {
const { id, type, name, capacity, metadata } = params;
createRegionAction(this.gameState, {
id: id as string,
type: type as RegionType,
name: name as string,
capacity: capacity as number,
metadata: metadata as Record<string, unknown>,
});
}
private handleRemoveRegion(params: Record<string, unknown>): void {
const { regionId } = params;
removeRegionAction(this.gameState, regionId as string);
}
private handleAddPlacementToRegion(params: Record<string, unknown>): void {
const { regionId, placementId } = params;
addPlacementToRegionAction(this.gameState, regionId as string, placementId as string);
}
private handleRemovePlacementFromRegion(params: Record<string, unknown>): void {
const { regionId, placementId } = params;
removePlacementFromRegionAction(this.gameState, regionId as string, placementId as string);
}
private handleSetSlot(params: Record<string, unknown>): void {
const { regionId, key, placementId } = params;
setSlotAction(this.gameState, regionId as string, key as string, placementId as string | null);
}
private handleClearRegion(params: Record<string, unknown>): void {
const { regionId } = params;
clearRegionAction(this.gameState, regionId as string);
}
// ========== Placement action handlers ==========
private handleCreatePlacement(params: Record<string, unknown>): void {
const { id, partId, regionId, position, rotation, faceUp, metadata } = params;
createPlacementAction(this.gameState, {
id: id as string,
partId: partId as string,
regionId: regionId as string,
position: position as { x: number; y: number },
rotation: rotation as number,
faceUp: faceUp as boolean,
metadata: metadata as Record<string, unknown>,
});
}
private handleRemovePlacement(params: Record<string, unknown>): void {
const { placementId } = params;
removePlacementAction(this.gameState, placementId as string);
}
private handleMovePlacement(params: Record<string, unknown>): void {
const { placementId, targetRegionId, key } = params;
movePlacementAction(this.gameState, placementId as string, targetRegionId as string, key as string);
}
private handleUpdatePlacementPosition(params: Record<string, unknown>): void {
const { placementId, position } = params;
updatePlacementPositionAction(this.gameState, placementId as string, position as { x: number; y: number });
}
private handleUpdatePlacementRotation(params: Record<string, unknown>): void {
const { placementId, rotation } = params;
updatePlacementRotationAction(this.gameState, placementId as string, rotation as number);
}
private handleFlipPlacement(params: Record<string, unknown>): void {
const { placementId } = params;
flipPlacementAction(this.gameState, placementId as string);
}
private handleSetPlacementFace(params: Record<string, unknown>): void {
const { placementId, faceUp } = params;
setPlacementFaceAction(this.gameState, placementId as string, faceUp as boolean);
}
private handleSwapPlacements(params: Record<string, unknown>): void {
const { placementId1, placementId2 } = params;
swapPlacementsAction(this.gameState, placementId1 as string, placementId2 as string);
}
// ========== Game action handlers ==========
private handleSetPhase(params: Record<string, unknown>): void {
const { phase } = params;
this.gameState.setPhase(phase as string);
}
}
-203
View File
@@ -1,203 +0,0 @@
import { signal, Signal } from '@preact/signals-core';
import type { Command, CommandLogEntry, CommandExecutionResult, StepResult, QueuedCommand } from './Command';
import { CommandStatus } from './Command';
/**
* 命令日志过滤器
*/
export interface CommandLogFilter {
commandId?: string;
success?: boolean;
startTime?: number;
endTime?: number;
}
/**
* 命令日志类
* 记录所有执行的命令及其结果
*/
export class CommandLog {
/** 日志条目信号 */
private entries: Signal<CommandLogEntry[]>;
/** 待执行队列 */
private queue: QueuedCommand[];
constructor() {
this.entries = signal<CommandLogEntry[]>([]);
this.queue = [];
}
/**
* 记录命令执行
*/
log(
command: Command,
result: CommandExecutionResult,
stepResults: StepResult[]
): void {
const entry: CommandLogEntry = {
timestamp: Date.now(),
commandId: command.id,
commandName: command.name,
result,
stepResults,
};
const current = this.entries.value;
this.entries.value = [...current, entry];
}
/**
* 获取所有日志条目
*/
getEntries(): CommandLogEntry[] {
return this.entries.value;
}
/**
* 获取日志信号
*/
getEntriesSignal(): Signal<CommandLogEntry[]> {
return this.entries;
}
/**
* 根据过滤器获取日志条目
*/
getFilteredEntries(filter: CommandLogFilter): CommandLogEntry[] {
return this.entries.value.filter((entry) => {
if (filter.commandId && entry.commandId !== filter.commandId) {
return false;
}
if (filter.success !== undefined && entry.result.success !== filter.success) {
return false;
}
if (filter.startTime && entry.timestamp < filter.startTime) {
return false;
}
if (filter.endTime && entry.timestamp > filter.endTime) {
return false;
}
return true;
});
}
/**
* 获取命令的执行历史
*/
getCommandHistory(commandId: string): CommandLogEntry[] {
return this.getFilteredEntries({ commandId });
}
/**
* 获取失败的命令
*/
getFailedCommands(): CommandLogEntry[] {
return this.getFilteredEntries({ success: false });
}
/**
* 获取成功的命令
*/
getSuccessfulCommands(): CommandLogEntry[] {
return this.getFilteredEntries({ success: true });
}
/**
* 清空日志
*/
clear(): void {
this.entries.value = [];
}
/**
* 导出日志为 JSON
*/
exportToJson(): string {
return JSON.stringify(this.entries.value, null, 2);
}
/**
* 获取日志条目数量
*/
getCount(): number {
return this.entries.value.length;
}
/**
* 获取最后一个日志条目
*/
getLastEntry(): CommandLogEntry | null {
const entries = this.entries.value;
return entries.length > 0 ? entries[entries.length - 1] : null;
}
// ========== 队列管理 ==========
/**
* 添加命令到队列
*/
enqueue(command: Command): QueuedCommand {
const queued: QueuedCommand = {
id: command.id,
command,
status: CommandStatus.Pending,
queuedAt: Date.now(),
};
this.queue.push(queued);
return queued;
}
/**
* 从队列中移除命令
*/
dequeue(): QueuedCommand | null {
if (this.queue.length === 0) {
return null;
}
return this.queue.shift() || null;
}
/**
* 获取队列中的所有命令
*/
getQueue(): QueuedCommand[] {
return [...this.queue];
}
/**
* 更新队列中命令的状态
*/
updateQueueStatus(commandId: string, status: CommandStatus, result?: CommandExecutionResult): void {
const index = this.queue.findIndex((q) => q.command.id === commandId);
if (index !== -1) {
this.queue[index].status = status;
if (status === CommandStatus.Completed || status === CommandStatus.Failed) {
this.queue[index].executedAt = Date.now();
this.queue[index].result = result;
}
}
}
/**
* 清空队列
*/
clearQueue(): void {
this.queue = [];
}
/**
* 获取队列长度
*/
getQueueLength(): number {
return this.queue.length;
}
}
/**
* 创建命令日志
*/
export function createCommandLog(): CommandLog {
return new CommandLog();
}
-163
View File
@@ -1,163 +0,0 @@
import type { ParsedCliCommand, CliCommandArgs } from './CliCommand';
/**
* 命令解析错误
*/
export class CommandParseError extends Error {
constructor(message: string) {
super(message);
this.name = 'CommandParseError';
}
}
/**
* CLI 命令解析器
* 解析 CLI 风格的命令字符串
*/
export class CommandParser {
/**
* 解析命令字符串
* @param input 命令字符串,如 "move card-1 discard --faceup=true"
*/
parse(input: string): ParsedCliCommand {
const trimmed = input.trim();
if (!trimmed) {
throw new CommandParseError('Empty command');
}
const tokens = this.tokenize(trimmed);
if (tokens.length === 0) {
throw new CommandParseError('No command found');
}
const commandName = tokens[0];
const args = this.parseArgs(tokens.slice(1));
return {
commandName,
args,
raw: input,
};
}
/**
* 将命令字符串分词
*/
private tokenize(input: string): string[] {
const tokens: string[] = [];
let current = '';
let inQuotes = false;
let quoteChar = '';
for (let i = 0; i < input.length; i++) {
const char = input[i];
if (inQuotes) {
if (char === quoteChar) {
tokens.push(current);
current = '';
inQuotes = false;
} else {
current += char;
}
} else if (char === '"' || char === "'") {
inQuotes = true;
quoteChar = char;
} else if (char === ' ' || char === '\t') {
if (current) {
tokens.push(current);
current = '';
}
} else {
current += char;
}
}
if (current) {
tokens.push(current);
}
if (inQuotes) {
throw new CommandParseError('Unclosed quote in command');
}
return tokens;
}
/**
* 解析参数
*/
private parseArgs(tokens: string[]): CliCommandArgs {
const positional: string[] = [];
const flags: Record<string, string | boolean> = {};
for (const token of tokens) {
if (token.startsWith('--')) {
// 长标志 --key=value 或 --flag
const flagMatch = token.match(/^--([^=]+)(?:=(.+))?$/);
if (flagMatch) {
const [, key, value] = flagMatch;
flags[key] = value !== undefined ? this.parseFlagValue(value) : true;
}
} else if (token.startsWith('-') && token.length === 2) {
// 短标志 -f 或 -k=v
const flagMatch = token.match(/^-([^=]+)(?:=(.+))?$/);
if (flagMatch) {
const [, key, value] = flagMatch;
flags[key] = value !== undefined ? this.parseFlagValue(value) : true;
}
} else {
// 位置参数
positional.push(token);
}
}
return { positional, flags };
}
/**
* 解析标志值
*/
private parseFlagValue(value: string): string | boolean {
// 布尔值
if (value.toLowerCase() === 'true') return true;
if (value.toLowerCase() === 'false') return false;
// 数字转换为字符串
return value;
}
/**
* 格式化命令用于显示
*/
static formatCommand(name: string, args?: CliCommandArgs): string {
if (!args) {
return name;
}
const parts = [name];
// 添加位置参数
parts.push(...args.positional);
// 添加标志参数
for (const [key, value] of Object.entries(args.flags)) {
if (value === true) {
parts.push(`--${key}`);
} else {
parts.push(`--${key}=${value}`);
}
}
return parts.join(' ');
}
}
/**
* 创建命令解析器
*/
export function createCommandParser(): CommandParser {
return new CommandParser();
}
-209
View File
@@ -1,209 +0,0 @@
import type { CliCommand, CliCommandArgs, CliCommandResult, CliCommandStep } from './CliCommand';
import { CommandParser } from './CommandParser';
/**
* 命令注册表
* 注册和管理 CLI 命令
*/
export class CommandRegistry {
private commands: Map<string, CliCommand>;
private parser: CommandParser;
constructor() {
this.commands = new Map();
this.parser = new CommandParser();
}
/**
* 注册命令
*/
register(command: CliCommand): void {
this.commands.set(command.name, command);
}
/**
* 注册多个命令
*/
registerAll(commands: CliCommand[]): void {
for (const command of commands) {
this.register(command);
}
}
/**
* 获取命令
*/
get(name: string): CliCommand | undefined {
return this.commands.get(name);
}
/**
* 检查命令是否存在
*/
has(name: string): boolean {
return this.commands.has(name);
}
/**
* 移除命令
*/
unregister(name: string): void {
this.commands.delete(name);
}
/**
* 获取所有命令
*/
getAll(): CliCommand[] {
return Array.from(this.commands.values());
}
/**
* 解析并执行命令
*/
execute(input: string): CliCommandResult {
try {
const parsed = this.parser.parse(input);
const command = this.commands.get(parsed.commandName);
if (!command) {
return {
success: false,
error: `Unknown command: ${parsed.commandName}`,
steps: [],
};
}
// 验证参数
const validationError = this.validateArgs(command, parsed.args);
if (validationError) {
return {
success: false,
error: validationError,
steps: [],
};
}
// 执行命令处理器
const steps = command.handler(parsed.args);
return {
success: true,
steps,
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
steps: [],
};
}
}
/**
* 验证参数
*/
private validateArgs(command: CliCommand, args: CliCommandArgs): string | null {
// 验证位置参数
if (command.args) {
for (const argDef of command.args) {
const index = command.args.indexOf(argDef);
if (argDef.required && index >= args.positional.length) {
return `Missing required argument: ${argDef.name}`;
}
}
}
// 验证标志参数
if (command.flags) {
for (const flagDef of command.flags) {
if (flagDef.required && !(flagDef.name in args.flags)) {
// 检查别名
const hasAlias = flagDef.alias && args.flags[flagDef.alias];
if (!hasAlias) {
return `Missing required flag: --${flagDef.name}`;
}
}
}
}
return null;
}
/**
* 生成帮助信息
*/
help(commandName?: string): string {
if (commandName) {
const command = this.commands.get(commandName);
if (!command) {
return `Unknown command: ${commandName}`;
}
return this.formatCommandHelp(command);
}
// 所有命令的帮助
const lines = ['Available commands:', ''];
for (const command of this.commands.values()) {
lines.push(` ${command.name.padEnd(15)} ${command.description}`);
}
lines.push('');
lines.push('Use "help <command>" for more information.');
return lines.join('\n');
}
/**
* 格式化单个命令的帮助
*/
private formatCommandHelp(command: CliCommand): string {
const lines = [
`Command: ${command.name}`,
`Description: ${command.description}`,
`Usage: ${command.usage}`,
];
if (command.args && command.args.length > 0) {
lines.push('');
lines.push('Arguments:');
for (const arg of command.args) {
const required = arg.required ? '(required)' : '(optional)';
lines.push(` ${arg.name.padEnd(15)} ${arg.description} ${required}`);
}
}
if (command.flags && command.flags.length > 0) {
lines.push('');
lines.push('Flags:');
for (const flag of command.flags) {
const alias = flag.alias ? `-${flag.alias}, ` : '';
const required = flag.required ? '(required)' : '(optional)';
const defaultVal = flag.default !== undefined ? `(default: ${flag.default})` : '';
lines.push(` ${alias}--${flag.name.padEnd(12)} ${flag.description} ${required} ${defaultVal}`);
}
}
return lines.join('\n');
}
/**
* 清除所有命令
*/
clear(): void {
this.commands.clear();
}
/**
* 获取命令数量
*/
getCount(): number {
return this.commands.size;
}
}
/**
* 创建命令注册表
*/
export function createCommandRegistry(): CommandRegistry {
return new CommandRegistry();
}
-485
View File
@@ -1,485 +0,0 @@
import type { CliCommand } from './CliCommand';
import { RegionType } from '../core/Region';
/**
* CLI 命令定义集合
*/
/**
* move <placementId> <targetRegionId> [--key=slotKey]
* 移动 Placement 到另一个区域
*/
export const moveCommand: CliCommand = {
name: 'move',
description: 'Move a placement to another region',
usage: 'move <placementId> <targetRegionId> [--key=slotKey]',
args: [
{ name: 'placementId', description: 'The placement ID to move', required: true },
{ name: 'targetRegionId', description: 'The target region ID', required: true },
],
flags: [
{ name: 'key', description: 'Slot key for keyed regions', type: 'string' },
],
handler: (args) => {
const [placementId, targetRegionId] = args.positional;
return [
{
action: 'movePlacement',
params: {
placementId,
targetRegionId,
key: args.flags.key as string | undefined,
},
},
];
},
};
/**
* place <partId> <regionId> [x] [y] [--rotation=0] [--faceup=true]
* 创建 Placement 并放置到区域
*/
export const placeCommand: CliCommand = {
name: 'place',
description: 'Place a part in a region',
usage: 'place <partId> <regionId> [x] [y] [--rotation=0] [--faceup=true]',
args: [
{ name: 'partId', description: 'The part ID to place', required: true },
{ name: 'regionId', description: 'The region ID to place in', required: true },
{ name: 'x', description: 'X position', default: '0' },
{ name: 'y', description: 'Y position', default: '0' },
],
flags: [
{ name: 'rotation', description: 'Rotation angle', type: 'number', default: '0' },
{ name: 'faceup', description: 'Face up or down', type: 'boolean', default: 'true' },
],
handler: (args) => {
const [partId, regionId] = args.positional;
const x = parseInt(args.positional[2] || '0', 10);
const y = parseInt(args.positional[3] || '0', 10);
const rotation = typeof args.flags.rotation === 'string'
? parseInt(args.flags.rotation, 10)
: 0;
return [
{
action: 'createPlacement',
params: {
id: `placement-${partId}-${Date.now()}`,
partId,
regionId,
position: { x, y },
rotation,
faceUp: args.flags.faceup === true || args.flags.faceup === 'true',
},
},
];
},
};
/**
* flip <placementId>
* 翻转 Placement
*/
export const flipCommand: CliCommand = {
name: 'flip',
description: 'Flip a placement face up/down',
usage: 'flip <placementId>',
args: [
{ name: 'placementId', description: 'The placement ID to flip', required: true },
],
handler: (args) => {
const [placementId] = args.positional;
return [
{
action: 'flipPlacement',
params: { placementId },
},
];
},
};
/**
* create <type> <id> [options...]
* 创建 Part(meeple/card/tile)
*/
export const createCommand: CliCommand = {
name: 'create',
description: 'Create a part (meeple, card, or tile)',
usage: 'create <type> <id> [--color=color] [--suit=suit] [--value=value] [--pattern=pattern]',
args: [
{ name: 'type', description: 'Part type (meeple, card, tile)', required: true },
{ name: 'id', description: 'Part ID', required: true },
],
flags: [
{ name: 'color', description: 'Meeple color', type: 'string' },
{ name: 'suit', description: 'Card suit', type: 'string' },
{ name: 'value', description: 'Card value', type: 'string' },
{ name: 'pattern', description: 'Tile pattern', type: 'string' },
{ name: 'rotation', description: 'Tile rotation', type: 'number' },
{ name: 'name', description: 'Part name', type: 'string' },
],
handler: (args) => {
const [type, id] = args.positional;
const steps = [];
if (type === 'meeple') {
steps.push({
action: 'createMeeple',
params: {
id,
color: (args.flags.color as string) || 'red',
name: args.flags.name as string,
},
});
} else if (type === 'card') {
steps.push({
action: 'createCard',
params: {
id,
suit: args.flags.suit as string,
value: args.flags.value as string,
name: args.flags.name as string,
},
});
} else if (type === 'tile') {
const rotation = typeof args.flags.rotation === 'string'
? parseInt(args.flags.rotation, 10)
: 0;
steps.push({
action: 'createTile',
params: {
id,
pattern: args.flags.pattern as string,
rotation,
name: args.flags.name as string,
},
});
} else {
throw new Error(`Unknown part type: ${type}`);
}
return steps;
},
};
/**
* region <id> <type> [--name=name] [--capacity=n]
* 创建 Region
*/
export const regionCommand: CliCommand = {
name: 'region',
description: 'Create a region',
usage: 'region <id> <type> [--name=name] [--capacity=n]',
args: [
{ name: 'id', description: 'Region ID', required: true },
{ name: 'type', description: 'Region type (keyed/unkeyed)', required: true },
],
flags: [
{ name: 'name', description: 'Region name', type: 'string' },
{ name: 'capacity', description: 'Maximum capacity', type: 'number' },
],
handler: (args) => {
const [id, type] = args.positional;
const capacity = typeof args.flags.capacity === 'string'
? parseInt(args.flags.capacity, 10)
: undefined;
return [
{
action: 'createRegion',
params: {
id,
type: type.toLowerCase() === 'keyed' ? RegionType.Keyed : RegionType.Unkeyed,
name: args.flags.name as string,
capacity,
},
},
];
},
};
/**
* draw <deckId> [count] [--to=handId]
* 从牌库抽牌
*/
export const drawCommand: CliCommand = {
name: 'draw',
description: 'Draw cards from a deck',
usage: 'draw <deckId> [count] [--to=handId]',
args: [
{ name: 'deckId', description: 'Source deck/region ID', required: true },
{ name: 'count', description: 'Number of cards to draw', default: '1' },
],
flags: [
{ name: 'to', description: 'Target hand region ID', type: 'string', default: 'hand' },
],
handler: (args) => {
// 注意:这是一个简化版本,实际抽牌需要更复杂的逻辑
const [deckId] = args.positional;
const count = parseInt(args.positional[1] || '1', 10);
const targetHand = args.flags.to as string;
const steps = [];
for (let i = 0; i < count; i++) {
steps.push({
action: 'createCard',
params: {
id: `card-${deckId}-${Date.now()}-${i}`,
},
});
}
return steps;
},
};
/**
* shuffle <regionId> [--seed=number]
* 洗牌
*/
export const shuffleCommand: CliCommand = {
name: 'shuffle',
description: 'Shuffle placements in a region',
usage: 'shuffle <regionId> [--seed=number]',
args: [
{ name: 'regionId', description: 'Region ID to shuffle', required: true },
],
flags: [
{ name: 'seed', description: 'Random seed for reproducibility', type: 'number' },
],
handler: (args) => {
// shuffle 命令需要特殊的执行逻辑,这里返回一个标记步骤
// 实际执行时需要在 CommandExecutor 中特殊处理
const [regionId] = args.positional;
const seed = typeof args.flags.seed === 'string'
? parseInt(args.flags.seed, 10)
: undefined;
return [
{
action: 'shuffleRegion',
params: {
regionId,
seed,
},
},
];
},
};
/**
* discard <placementId> [--to=discardId]
* 将 Placement 移到弃牌堆
*/
export const discardCommand: CliCommand = {
name: 'discard',
description: 'Move a placement to discard pile',
usage: 'discard <placementId> [--to=discardId]',
args: [
{ name: 'placementId', description: 'Placement ID to discard', required: true },
],
flags: [
{ name: 'to', description: 'Discard region ID', type: 'string', default: 'discard' },
],
handler: (args) => {
const [placementId] = args.positional;
const discardId = args.flags.to as string;
return [
{
action: 'movePlacement',
params: {
placementId,
targetRegionId: discardId,
},
},
];
},
};
/**
* swap <placementId1> <placementId2>
* 交换两个 Placement
*/
export const swapCommand: CliCommand = {
name: 'swap',
description: 'Swap two placements',
usage: 'swap <placementId1> <placementId2>',
args: [
{ name: 'placementId1', description: 'First placement ID', required: true },
{ name: 'placementId2', description: 'Second placement ID', required: true },
],
handler: (args) => {
const [placementId1, placementId2] = args.positional;
return [
{
action: 'swapPlacements',
params: { placementId1, placementId2 },
},
];
},
};
/**
* rotate <placementId> <degrees>
* 旋转 Placement
*/
export const rotateCommand: CliCommand = {
name: 'rotate',
description: 'Rotate a placement',
usage: 'rotate <placementId> <degrees>',
args: [
{ name: 'placementId', description: 'Placement ID to rotate', required: true },
{ name: 'degrees', description: 'Rotation angle in degrees', required: true },
],
handler: (args) => {
const [placementId, degreesStr] = args.positional;
const degrees = parseInt(degreesStr, 10);
return [
{
action: 'updatePlacementRotation',
params: {
placementId,
rotation: degrees,
},
},
];
},
};
/**
* position <placementId> <x> <y>
* 设置 Placement 位置
*/
export const positionCommand: CliCommand = {
name: 'position',
description: 'Set placement position',
usage: 'position <placementId> <x> <y>',
args: [
{ name: 'placementId', description: 'Placement ID', required: true },
{ name: 'x', description: 'X coordinate', required: true },
{ name: 'y', description: 'Y coordinate', required: true },
],
handler: (args) => {
const [placementId, xStr, yStr] = args.positional;
return [
{
action: 'updatePlacementPosition',
params: {
placementId,
position: {
x: parseInt(xStr, 10),
y: parseInt(yStr, 10),
},
},
},
];
},
};
/**
* phase <phaseName>
* 设置游戏阶段
*/
export const phaseCommand: CliCommand = {
name: 'phase',
description: 'Set game phase',
usage: 'phase <phaseName>',
args: [
{ name: 'phaseName', description: 'New phase name', required: true },
],
handler: (args) => {
const [phase] = args.positional;
return [
{
action: 'setPhase',
params: { phase },
},
];
},
};
/**
* clear <regionId>
* 清空区域
*/
export const clearCommand: CliCommand = {
name: 'clear',
description: 'Clear all placements from a region',
usage: 'clear <regionId>',
args: [
{ name: 'regionId', description: 'Region ID to clear', required: true },
],
handler: (args) => {
const [regionId] = args.positional;
return [
{
action: 'clearRegion',
params: { regionId },
},
];
},
};
/**
* remove <type> <id>
* 移除 Part/Placement/Region
*/
export const removeCommand: CliCommand = {
name: 'remove',
description: 'Remove a part, placement, or region',
usage: 'remove <type> <id>',
args: [
{ name: 'type', description: 'Type (part/placement/region)', required: true },
{ name: 'id', description: 'ID to remove', required: true },
],
handler: (args) => {
const [type, id] = args.positional;
if (type === 'part') {
return [{ action: 'removePart', params: { partId: id } }];
} else if (type === 'placement') {
return [{ action: 'removePlacement', params: { placementId: id } }];
} else if (type === 'region') {
return [{ action: 'removeRegion', params: { regionId: id } }];
} else {
throw new Error(`Unknown type: ${type}`);
}
},
};
/**
* help [command]
* 显示帮助信息
*/
export const helpCommand: CliCommand = {
name: 'help',
description: 'Show help information',
usage: 'help [command]',
args: [
{ name: 'command', description: 'Command name to get help for', required: false },
],
handler: () => {
// help 命令由 CommandRegistry 特殊处理
return [];
},
};
/**
* 所有 CLI 命令
*/
export const cliCommands: CliCommand[] = [
moveCommand,
placeCommand,
flipCommand,
createCommand,
regionCommand,
drawCommand,
shuffleCommand,
discardCommand,
swapCommand,
rotateCommand,
positionCommand,
phaseCommand,
clearCommand,
removeCommand,
helpCommand,
];
-285
View File
@@ -1,285 +0,0 @@
import type { Command } from './Command';
import { CommandActionType } from './Command';
import { RegionType } from '../core/Region';
/**
* 内置命令集合
*/
/**
* 设置游戏:创建基础区域
*/
export const setupGameCommand: Command = {
id: 'setup-game',
name: 'Setup Game',
description: 'Initialize the game with basic regions',
steps: [
{
action: CommandActionType.CreateRegion,
params: {
id: 'board',
type: RegionType.Keyed,
name: 'Game Board',
},
},
{
action: CommandActionType.CreateRegion,
params: {
id: 'supply',
type: RegionType.Unkeyed,
name: 'Supply',
},
},
{
action: CommandActionType.CreateRegion,
params: {
id: 'discard',
type: RegionType.Unkeyed,
name: 'Discard Pile',
},
},
],
};
/**
* 放置棋子命令
*/
export const placeMeepleCommand: Command = {
id: 'place-meeple',
name: 'Place Meeple',
description: 'Place a meeple on the board',
steps: [
{
action: CommandActionType.CreateMeeple,
params: {
id: '${meepleId}',
color: '${color}',
},
},
{
action: CommandActionType.CreatePlacement,
params: {
id: '${placementId}',
partId: '${meepleId}',
regionId: 'board',
position: { x: 0, y: 0 },
},
},
],
};
/**
* 移动棋子命令
*/
export const moveMeepleCommand: Command = {
id: 'move-meeple',
name: 'Move Meeple',
description: 'Move a meeple to a new position',
steps: [
{
action: CommandActionType.UpdatePlacementPosition,
params: {
placementId: '${placementId}',
position: { x: '${x}', y: '${y}' },
},
},
],
};
/**
* 抽牌命令
*/
export const drawCardCommand: Command = {
id: 'draw-card',
name: 'Draw Card',
description: 'Draw a card from the deck',
steps: [
{
action: CommandActionType.CreateRegion,
params: {
id: 'hand',
type: RegionType.Unkeyed,
name: 'Hand',
capacity: 5,
},
},
{
action: CommandActionType.CreateCard,
params: {
id: '${cardId}',
suit: '${suit}',
value: '${value}',
},
},
{
action: CommandActionType.CreatePlacement,
params: {
id: '${placementId}',
partId: '${cardId}',
regionId: 'hand',
faceUp: false,
},
},
{
action: CommandActionType.AddPlacementToRegion,
params: {
regionId: 'hand',
placementId: '${placementId}',
},
},
],
};
/**
* 出牌命令
*/
export const playCardCommand: Command = {
id: 'play-card',
name: 'Play Card',
description: 'Play a card from hand',
steps: [
{
action: CommandActionType.SetPlacementFace,
params: {
placementId: '${placementId}',
faceUp: true,
},
},
{
action: CommandActionType.MovePlacement,
params: {
placementId: '${placementId}',
targetRegionId: 'discard',
},
},
],
};
/**
* 放置板块命令
*/
export const placeTileCommand: Command = {
id: 'place-tile',
name: 'Place Tile',
description: 'Place a tile on the board',
steps: [
{
action: CommandActionType.CreateTile,
params: {
id: '${tileId}',
pattern: '${pattern}',
rotation: '${rotation}',
},
},
{
action: CommandActionType.CreatePlacement,
params: {
id: '${placementId}',
partId: '${tileId}',
regionId: 'board',
position: { x: '${x}', y: '${y}' },
rotation: '${rotation}',
},
},
{
action: CommandActionType.SetSlot,
params: {
regionId: 'board',
key: '${slotKey}',
placementId: '${placementId}',
},
},
],
};
/**
* 翻转板块命令
*/
export const flipTileCommand: Command = {
id: 'flip-tile',
name: 'Flip Tile',
description: 'Flip a tile face down or face up',
steps: [
{
action: CommandActionType.FlipPlacement,
params: {
placementId: '${placementId}',
},
},
],
};
/**
* 交换位置命令
*/
export const swapPlacementsCommand: Command = {
id: 'swap-placements',
name: 'Swap Placements',
description: 'Swap two placements in the same region',
steps: [
{
action: CommandActionType.SwapPlacements,
params: {
placementId1: '${placementId1}',
placementId2: '${placementId2}',
},
},
],
};
/**
* 设置游戏阶段
*/
export const setPhaseCommand: Command = {
id: 'set-phase',
name: 'Set Phase',
description: 'Set the current game phase',
steps: [
{
action: CommandActionType.SetPhase,
params: {
phase: '${phase}',
},
},
],
};
/**
* 清空区域命令
*/
export const clearRegionCommand: Command = {
id: 'clear-region',
name: 'Clear Region',
description: 'Clear all placements from a region',
steps: [
{
action: CommandActionType.ClearRegion,
params: {
regionId: '${regionId}',
},
},
],
};
/**
* 所有内置命令
*/
export const defaultCommands: Command[] = [
setupGameCommand,
placeMeepleCommand,
moveMeepleCommand,
drawCardCommand,
playCardCommand,
placeTileCommand,
flipTileCommand,
swapPlacementsCommand,
setPhaseCommand,
clearRegionCommand,
];
/**
* 根据 ID 获取内置命令
*/
export function getDefaultCommand(commandId: string): Command | undefined {
return defaultCommands.find((cmd) => cmd.id === commandId);
}
-38
View File
@@ -1,38 +0,0 @@
import {createModel, Signal, signal} from '@preact/signals-core';
import {createEntityCollection} from "../utils/entity";
import {Part} from "./part";
import {Region} from "./region";
export type Context = {
type: string;
}
export const GameContext = createModel((root: Context) => {
const parts = createEntityCollection<Part>();
const regions = createEntityCollection<Region>();
const contexts = signal([signal(root)]);
function pushContext(context: Context) {
const ctxSignal = signal(context);
contexts.value = [...contexts.value, ctxSignal];
return context;
}
function popContext() {
contexts.value = contexts.value.slice(0, -1);
}
function latestContext<T extends Context>(type: T['type']){
for(let i = contexts.value.length - 1; i >= 0; i--){
if(contexts.value[i].value.type === type){
return contexts.value[i] as Signal<T>;
}
}
}
return {
parts,
regions,
contexts,
pushContext,
popContext,
latestContext,
}
})
+118
View File
@@ -0,0 +1,118 @@
import { createPromptContext, PromptDef } from "@/utils/command";
import { computed, effect, signal } from "@preact/signals-core";
import { createGameContext, IGameContext } from "./game";
export enum ClientStatus {
Idle = "idle",
Running = "running",
Disposed = "disposed",
}
export type PromptInfo =
| {
hasPrompt: false;
player: string;
}
| {
hasPrompt: true;
player: string;
def: PromptDef<unknown[]>;
tryAnswer(...args: unknown[]): string | null;
};
/**
* 1. create a client instance
* 2. setStatus("running")
* 3. select("selector", t => {}) for c# to capture game state
* 4. useClient("status" | "prompt") for c# to handle prompt / game status
*/
export abstract class BaseGameClient<
T extends Record<string, unknown> = Record<string, unknown>,
R = unknown,
> {
protected _initialState: T;
protected _context: IGameContext<T>;
protected _prompts = createPromptContext();
protected _status = signal(ClientStatus.Idle);
protected _result = signal(null as null | R);
public get state() {
return this._context.value;
}
constructor(is: T) {
this._initialState = is;
this._context = createGameContext(this._prompts, is);
}
async setStatus(status: ClientStatus) {
this._status.value = status;
if (status === ClientStatus.Disposed) {
this._prompts.reset();
} else if (status === ClientStatus.Running) {
this._prompts.reset();
this._context._state.value = this._initialState;
this._result.value = null;
this._result.value = await this.start(this._context);
this.setStatus(ClientStatus.Idle);
}
}
abstract start(ctx: IGameContext<T>): Promise<R>;
assignSelector<S>(selector: (t: T) => S, callback: (s: S) => void) {
const selected = computed(() => selector(this._context.value));
return effect(() => {
callback(selected.value);
});
}
addInterruption() {
let resolve!: () => void;
const promise = new Promise<void>((resolver) => {
resolve = resolver;
});
this._context._state.addInterruption(promise);
return resolve;
}
selectPrompt(callback: (prompt: PromptInfo) => void) {
return this._prompts.handleCall.use(async (ctx, next) => {
function tryAnswer(...args: any[]) {
try {
const res = ctx.validator(...args);
ctx.resolve(res);
return null;
} catch (reason) {
if (typeof reason === "string") {
return reason;
}
throw reason;
}
}
try {
callback({
hasPrompt: true,
player: ctx.player || "global",
def: ctx.def,
tryAnswer,
});
return await next();
} finally {
callback({
hasPrompt: false,
player: ctx.player || "global",
});
}
});
}
selectStatus(callback: (status: ClientStatus) => void) {
return effect(() => {
callback(this._status.value);
});
}
selectResult(callback: (status: R | null) => void) {
return effect(() => {
callback(this._result.value);
});
}
}
+124
View File
@@ -0,0 +1,124 @@
import { createPromptContext } from "@/utils/command";
import { PromptContext } from "@/utils/command/command-prompt";
import { ReadonlySignal, Signal } from "@preact/signals-core";
import { createGameContext, IGameContext } from "./game";
export type GameHostStatus = "created" | "running" | "disposed";
export class GameHost<
TState extends Record<string, unknown>,
TResult = unknown,
TModule extends GameModule<TState, TResult> = GameModule<TState, TResult>,
> {
readonly state: ReadonlySignal<TState>;
readonly status: ReadonlySignal<GameHostStatus>;
readonly prompts: PromptContext;
private _context: IGameContext<TState>;
private _start: (ctx: IGameContext<TState>) => Promise<TResult>;
private _status: Signal<GameHostStatus>;
private _createInitialState: () => TState;
private _eventListeners: Map<"start" | "dispose", Set<() => void>>;
private _isDisposed = false;
constructor(public readonly gameModule: TModule) {
const { createInitialState, start } = gameModule;
this._createInitialState = createInitialState;
this._eventListeners = new Map();
this.prompts = createPromptContext();
const initialState = createInitialState();
this._context = createGameContext(this.prompts, initialState);
this._start = start;
this.state = this._context._state;
this._status = new Signal<GameHostStatus>("created");
this.status = this._status;
}
/**
* 为下一个 produceAsync 注册中断 Promise(通常用于 UI 动画)。
* @see MutableSignal.addInterruption
*/
addInterruption(promise: Promise<void>): void {
this._context._state.addInterruption(promise);
}
/**
* 清除所有未完成的中断。
* @see MutableSignal.clearInterruptions
*/
clearInterruptions(): void {
this._context._state.clearInterruptions();
}
start(seed?: number): Promise<TResult> {
if (this._isDisposed) {
throw new Error("GameHost is disposed");
}
this.prompts.reset();
const initialState = this._createInitialState();
this._context._state.value = initialState as any;
seed = seed || Math.floor(Math.random() * Number.MAX_SAFE_INTEGER);
this._context._rng.setSeed(seed);
const promise = this._start(this._context);
this._status.value = "running";
this._emitEvent("start");
return promise;
}
dispose(): void {
if (this._isDisposed) {
return;
}
this._isDisposed = true;
this.prompts.reset();
this._status.value = "disposed";
// Emit dispose event BEFORE clearing listeners
this._emitEvent("dispose");
this._eventListeners.clear();
}
on(event: "start" | "dispose", listener: () => void): () => void {
if (!this._eventListeners.has(event)) {
this._eventListeners.set(event, new Set());
}
this._eventListeners.get(event)!.add(listener);
return () => {
this._eventListeners.get(event)?.delete(listener);
};
}
private _emitEvent(event: "start" | "dispose") {
const listeners = this._eventListeners.get(event);
if (listeners) {
for (const listener of listeners) {
listener();
}
}
}
}
export type GameModule<
TState extends Record<string, unknown> = Record<string, unknown>,
TResult = unknown,
> = {
createInitialState: () => TState;
start: (ctx: IGameContext<TState>) => Promise<TResult>;
};
export function createGameHost<
TState extends Record<string, unknown> = Record<string, unknown>,
TResult = unknown,
TModule extends GameModule<TState, TResult> = GameModule<TState, TResult>,
>(gameModule: TModule): GameHost<TState, TResult, TModule> {
return new GameHost(gameModule);
}
+64
View File
@@ -0,0 +1,64 @@
import { MutableSignal, mutableSignal } from "@/utils/mutable-signal";
import { CommandSchema, parseCommandSchema, PromptDef } from "@/utils/command";
import { PromptValidator } from "@/utils/command/command-runner";
import { Mulberry32RNG, ReadonlyRNG, RNG } from "@/utils/rng";
import { PromptContext } from "@/utils/command/command-prompt";
export interface IGameContext<TState extends Record<string, unknown> = {}> {
get value(): TState;
get rng(): ReadonlyRNG;
produce(fn: (draft: TState) => undefined): void;
produceAsync(fn: (draft: TState) => undefined): Promise<void>;
prompt: <TResult, TArgs extends any[] = any[]>(
def: PromptDef<TArgs>,
validator: PromptValidator<TResult, TArgs>,
player?: string,
) => Promise<TResult>;
// test only
_state: MutableSignal<TState>;
_rng: RNG;
}
export type IGameContextExport<TState extends Record<string, unknown> = {}> =
Omit<IGameContext<TState>, "_state" | "_commands" | "_rng">;
export function createGameContext<TState extends Record<string, unknown> = {}>(
promptContext: PromptContext,
initialState?: TState | (() => TState),
): IGameContext<TState> {
const stateValue =
typeof initialState === "function"
? initialState()
: (initialState ?? ({} as TState));
const state = mutableSignal(stateValue);
const { prompt } = promptContext;
const context: IGameContext<TState> = {
get value(): TState {
return state.value;
},
get rng() {
return this._rng;
},
produce(fn: (draft: TState) => undefined) {
return state.produce(fn);
},
produceAsync(fn: (draft: TState) => undefined) {
return state.produceAsync(fn);
},
prompt,
_state: state,
_rng: new Mulberry32RNG(),
};
return context;
}
export function createPromptDef<TArgs extends any[] = any[]>(
schema: CommandSchema | string,
hintText?: string,
): PromptDef<TArgs> {
schema = typeof schema === "string" ? parseCommandSchema(schema) : schema;
return { schema, hintText };
}
+27
View File
@@ -0,0 +1,27 @@
import {Part} from "./part";
import {Immutable} from "mutative";
export function createPartsFromTable<T>(items: readonly T[], getId: (item: T, index: number) => string, getCount?: ((item: T) => number) | number){
const pool: Record<string, Part<T>> = {};
for (const entry of items) {
const count = getCount ? (typeof getCount === 'function' ? getCount(entry) : getCount) : 1;
for (let i = 0; i < count; i++) {
const id = getId(entry, i);
pool[id] = createPart(id, entry);
}
}
return pool;
}
export function createParts<T>(item: T, getId: (index: number) => string, count?: number){
return createPartsFromTable([item], (_,index) => getId(index), count);
}
export function createPart<T>(id: string, entry: T): Part<T> {
return {
id,
regionId: '',
position: [],
...entry as Immutable<T>
}
}
+17 -21
View File
@@ -1,33 +1,29 @@
import {Entity, EntityAccessor} from "../utils/entity"; import {RNG} from '@/utils/rng';
import {Region} from "./region"; import {Immutable} from "mutative";
import {RNG} from "../utils/rng";
export type Part = Entity & { export type Part<TMeta = {}> = {
// cards have 2 sides, dices have multiple, tokens have 1 id: string;
sides: number;
sides?: number;
side?: number;
// mostly rotations, if relevant
alignments?: string[]; alignments?: string[];
// current side
side: number;
// current alignment
alignment?: string; alignment?: string;
regionId: string;
// current region
region: EntityAccessor<Region>;
// current position in region
position: number[]; position: number[];
} & Immutable<TMeta>;
export function flip<TMeta>(part: Part<TMeta>) {
if(!part.sides) return;
part.side = ((part.side || 0) + 1) % part.sides;
} }
export function flip(part: Part) { export function flipTo<TMeta>(part: Part<TMeta>, side: number) {
part.side = (part.side + 1) % part.sides; if(!part.sides || side >= part.sides) return;
}
export function flipTo(part: Part, side: number) {
part.side = side; part.side = side;
} }
export function roll(part: Part, rng: RNG) { export function roll<TMeta>(part: Part<TMeta>, rng: RNG) {
if(!part.sides) return;
part.side = rng.nextInt(part.sides); part.side = rng.nextInt(part.sides);
} }
+130 -26
View File
@@ -1,13 +1,11 @@
import {Entity, EntityAccessor} from "../utils/entity";
import {Part} from "./part"; import {Part} from "./part";
import {RNG} from "../utils/rng"; import {ReadonlyRNG} from "@/utils/rng";
export type Region = Entity & { export type Region = {
// aligning axes of the region id: string;
axes: RegionAxis[]; axes: RegionAxis[];
childIds: string[];
// current children; expect no overlapped positions partMap: Record<string, string>;
children: EntityAccessor<Part>[];
} }
export type RegionAxis = { export type RegionAxis = {
@@ -17,25 +15,131 @@ export type RegionAxis = {
align?: 'start' | 'end' | 'center'; align?: 'start' | 'end' | 'center';
} }
/** export function createRegion(id: string, axes: RegionAxis[]): Region {
* for each axis, try to remove gaps in positions. return {
* - if min exists and align is start, and there are parts at (for example) min+2 and min+4, then move them to min and min+1 id,
* - if max exists and align is end, and there are parts at (for example) max-2 and max-4, then move them to max-1 and max-3 axes,
* - for center, move parts to the center, possibly creating parts placed at 0.5 positions childIds: [],
* - sort children so that they're in ascending order on each axes. partMap: {},
* @param region };
*/
export function applyAlign(region: Region){
for (const axis of region.axes) {
// TODO implement this
}
} }
/** export function createRegionAxis(name: string, min?: number, max?: number, align?: 'start' | 'end' | 'center'): RegionAxis {
* shuffle on each axis. for each axis, try to swap position. return {
* @param region name,
* @param rng min,
*/ max,
export function shuffle(region: Region, rng: RNG){ align,
// TODO implement this };
}
function buildPartMap<TMeta>(region: Region, parts: Record<string, Part<TMeta>>) {
const map: Record<string, string> = {};
for (const childId of region.childIds) {
const part = parts[childId];
if (part) {
map[part.position.join(',')] = childId;
}
}
return map;
}
export function applyAlign<TMeta>(region: Region, parts: Record<string, Part<TMeta>>) {
if (region.childIds.length === 0) return;
for (let axisIndex = 0; axisIndex < region.axes.length; axisIndex++) {
const axis = region.axes[axisIndex];
if (!axis.align) continue;
const positionValues = new Set<number>();
for (const childId of region.childIds) {
const part = parts[childId];
if (part) {
positionValues.add(part.position[axisIndex] ?? 0);
}
}
const sortedPositions = Array.from(positionValues).sort((a, b) => a - b);
const positionMap = new Map<number, number>();
if (axis.align === 'start' && axis.min !== undefined) {
sortedPositions.forEach((pos, index) => {
positionMap.set(pos, axis.min! + index);
});
} else if (axis.align === 'end' && axis.max !== undefined) {
const count = sortedPositions.length;
sortedPositions.forEach((pos, index) => {
positionMap.set(pos, axis.max! - (count - 1 - index));
});
} else if (axis.align === 'center') {
const count = sortedPositions.length;
const min = axis.min ?? 0;
const max = axis.max ?? count - 1;
const range = max - min;
const center = min + range / 2;
sortedPositions.forEach((pos, index) => {
const offset = index - (count - 1) / 2;
positionMap.set(pos, center + offset);
});
}
for (const childId of region.childIds) {
const part = parts[childId];
if (part) {
const currentPos = part.position[axisIndex] ?? 0;
part.position[axisIndex] = positionMap.get(currentPos) ?? currentPos;
}
}
}
region.childIds.sort((aId, bId) => {
const a = parts[aId];
const b = parts[bId];
if (!a || !b) return 0;
for (let i = 0; i < region.axes.length; i++) {
const diff = (a.position[i] ?? 0) - (b.position[i] ?? 0);
if (diff !== 0) return diff;
}
return 0;
});
region.partMap = buildPartMap(region, parts);
}
export function shuffle<TMeta>(region: Region, parts: Record<string, Part<TMeta>>, rng: ReadonlyRNG){
if (region.childIds.length <= 1) return;
const childIds = [...region.childIds];
for (let i = childIds.length - 1; i > 0; i--) {
const j = rng.nextInt(i + 1);
const partI = parts[childIds[i]];
const partJ = parts[childIds[j]];
if (!partI || !partJ) continue;
const posI = [...partI.position];
const posJ = [...partJ.position];
partI.position = posJ;
partJ.position = posI;
}
region.partMap = buildPartMap(region, parts);
}
export function moveToRegion<TMeta>(part: Part<TMeta>, sourceRegion: Region | null, targetRegion: Region | null, position?: number[]) {
if (sourceRegion && part.regionId === sourceRegion.id) {
sourceRegion.childIds = sourceRegion.childIds.filter(id => id !== part.id);
delete sourceRegion.partMap[part.position.join(',')];
}
if (position) {
part.position = position;
}
if(targetRegion){
targetRegion.childIds.push(part.id);
targetRegion.partMap[part.position.join(',')] = part.id;
}
part.regionId = targetRegion?.id || '';
} }
-32
View File
@@ -1,32 +0,0 @@
import {Context} from "./context";
import {Command} from "../utils/command";
import {effect} from "@preact/signals-core";
export type RuleContext<T> = Context & {
actions: Command[];
handledActions: number;
invocations: RuleContext<unknown>[];
resolution?: T;
}
function invokeRuleContext<T>(pushContext: (context: Context) => void, type: string, rule: Generator<string, T, Command>){
const ctx: RuleContext<T> = {
type,
actions: [],
handledActions: 0,
invocations: [],
resolution: undefined,
}
const dispose = effect(() => {
if(ctx.resolution) {
dispose();
return;
}
});
pushContext(rule);
}
function* rule(){
const play: Command = yield 'play';
}
-210
View File
@@ -1,210 +0,0 @@
import type { Command } from '../../commands/Command';
import { CommandActionType } from '../../commands/Command';
import { RegionType } from '../../core/Region';
import type { Player } from './TicTacToeState';
import { getAllCellIds, DEFAULT_BOARD_CONFIG } from './TicTacToeState';
/**
* 井字棋游戏命令集合
*/
/**
* 开始游戏命令
* 初始化 3x3 棋盘和游戏状态
*/
export const startGameCommand: Command = {
id: 'tictactoe-start-game',
name: 'startGame',
description: 'Start a new Tic Tac Toe game',
steps: [
// 创建棋盘区域
{
action: CommandActionType.CreateRegion,
params: {
id: 'board',
type: RegionType.Keyed,
name: 'Tic Tac Toe Board',
},
},
// 创建所有单元格槽位
...getAllCellIds(DEFAULT_BOARD_CONFIG.size).map((cellId) => ({
action: CommandActionType.SetSlot as CommandActionType,
params: {
regionId: 'board',
key: cellId,
placementId: null,
},
})),
// 初始化游戏元数据
{
action: CommandActionType.SetPhase,
params: {
phase: 'playing',
},
},
],
};
/**
* 标记单元格命令
* 玩家在指定单元格放置 X 或 O
*/
export const markCellCommand: (cell: string, player: Player) => Command = (cell, player) => ({
id: `tictactoe-mark-${cell}-${player}`,
name: 'markCell',
description: `Mark cell ${cell} with ${player}`,
steps: [
// 创建玩家标记(Part)
{
action: CommandActionType.CreateMeeple,
params: {
id: `marker-${cell}-${player}`,
color: player === 'X' ? 'blue' : 'red',
name: `${player}'s marker`,
metadata: {
player,
cell,
},
},
},
// 创建放置
{
action: CommandActionType.CreatePlacement,
params: {
id: cell,
partId: `marker-${cell}-${player}`,
regionId: 'board',
metadata: {
player,
cell,
},
},
},
// 设置槽位
{
action: CommandActionType.SetSlot,
params: {
regionId: 'board',
key: cell,
placementId: cell,
},
},
],
});
/**
* 重置游戏命令
* 清空棋盘,准备新游戏
*/
export const resetGameCommand: Command = {
id: 'tictactoe-reset-game',
name: 'resetGame',
description: 'Reset the Tic Tac Toe board for a new game',
steps: [
// 清空棋盘
{
action: CommandActionType.ClearRegion,
params: {
regionId: 'board',
},
},
// 重置所有槽位
...getAllCellIds(DEFAULT_BOARD_CONFIG.size).map((cellId) => ({
action: CommandActionType.SetSlot as CommandActionType,
params: {
regionId: 'board',
key: cellId,
placementId: null,
},
})),
// 重置游戏阶段
{
action: CommandActionType.SetPhase,
params: {
phase: 'playing',
},
},
],
};
/**
* 设置玩家命令
* 设置玩家 X 和 O 的信息
*/
export const setPlayersCommand: (playerX: string, playerO: string) => Command = (playerX, playerO) => ({
id: 'tictactoe-set-players',
name: 'setPlayers',
description: 'Set player names for X and O',
steps: [
{
action: CommandActionType.CreateMeeple,
params: {
id: 'player-x',
color: 'blue',
name: playerX,
metadata: {
role: 'player',
symbol: 'X',
},
},
},
{
action: CommandActionType.CreateMeeple,
params: {
id: 'player-o',
color: 'red',
name: playerO,
metadata: {
role: 'player',
symbol: 'O',
},
},
},
],
});
/**
* 获取单元格状态命令
* 查询指定单元格的状态
*/
export const getCellCommand: (cell: string) => Command = (cell) => ({
id: `tictactoe-get-${cell}`,
name: 'getCell',
description: `Get the state of cell ${cell}`,
steps: [
{
action: CommandActionType.CreatePlacement,
params: {
id: `query-${cell}`,
partId: 'query',
regionId: 'board',
metadata: {
query: true,
cell,
},
},
},
],
});
/**
* 所有井字棋命令
*/
export const ticTacToeCommands: Command[] = [
startGameCommand,
resetGameCommand,
];
/**
* 创建标记单元格命令的辅助函数
*/
export function createMarkCellCommand(cell: string, player: Player): Command {
return markCellCommand(cell, player);
}
/**
* 创建设置玩家命令的辅助函数
*/
export function createSetPlayersCommand(playerX: string, playerO: string): Command {
return setPlayersCommand(playerX, playerO);
}
-354
View File
@@ -1,354 +0,0 @@
import type { Rule, RuleResult, RuleContext } from '../../rules/Rule';
import { createValidationRule, createEffectRule, createTriggerRule } from '../../rules/Rule';
import type { Player, TicTacToeMetadata, MoveRecord } from './TicTacToeState';
import { getWinningCombinations, parseCellId } from './TicTacToeState';
/**
* 井字棋游戏规则集合
*/
/**
* 获取当前玩家
*/
function getCurrentPlayer(gameState: any): Player {
const metadata = gameState.data.value.metadata as Record<string, unknown> | undefined;
const ticTacToeMetadata = metadata?.ticTacToe as TicTacToeMetadata | undefined;
return ticTacToeMetadata?.currentPlayer || 'X';
}
/**
* 检查游戏是否结束
*/
function isGameEnded(gameState: any): boolean {
const metadata = gameState.data.value.metadata as Record<string, unknown> | undefined;
const ticTacToeMetadata = metadata?.ticTacToe as TicTacToeMetadata | undefined;
return ticTacToeMetadata?.gameEnded || false;
}
/**
* 更新游戏 metadata
*/
function updateGameMetadata(gameState: any, updates: Partial<TicTacToeMetadata>): void {
const metadata = gameState.data.value.metadata as Record<string, unknown> | undefined;
const currentTicTacToe = (metadata?.ticTacToe as TicTacToeMetadata) || {
currentPlayer: 'X',
gameEnded: false,
winner: null,
moveHistory: [],
totalMoves: 0,
};
gameState.data.value = {
...gameState.data.value,
metadata: {
...metadata,
ticTacToe: { ...currentTicTacToe, ...updates },
},
};
}
/**
* 获取单元格的玩家标记
*/
function getCellPlayer(
context: RuleContext,
cellId: string
): Player | null {
const placement = context.gameState.placements.value.get(cellId);
if (!placement?.metadata?.player) {
return null;
}
return placement.metadata.player as Player;
}
/**
* 检查是否有玩家获胜
*/
function checkWin(
context: RuleContext,
size: number = 3
): { winner: Player; combination: string[] } | null {
const combinations = getWinningCombinations(size);
for (const combination of combinations) {
const players = combination.map((cellId) => getCellPlayer(context, cellId));
const firstPlayer = players[0];
if (firstPlayer && players.every((p) => p === firstPlayer)) {
return { winner: firstPlayer, combination };
}
}
return null;
}
/**
* 检查是否平局(所有单元格都被填充且无获胜者)
*/
function isDraw(context: RuleContext, size: number = 3): boolean {
const totalCells = size * size;
const filledCells = Array.from(context.gameState.placements.value.values()).filter(
(p) => p.metadata?.player
).length;
return filledCells === totalCells;
}
/**
* 规则 1:验证轮到当前玩家
* 在玩家尝试下子时检查是否是他们的回合
*/
export const validateTurnRule = createValidationRule({
id: 'tictactoe-validate-turn',
name: 'Validate Turn',
description: 'Check if it is the current player turn',
priority: 1,
gameType: 'tictactoe',
applicableCommands: ['markCell'],
validate: async (context: RuleContext): Promise<RuleResult> => {
const cellId = context.command.steps[0]?.params?.cell as string;
const expectedPlayer = context.command.steps[0]?.params?.player as Player;
const currentPlayer = getCurrentPlayer(context.gameState);
if (!cellId) {
return {
success: false,
error: 'Cell ID is required',
};
}
if (expectedPlayer && expectedPlayer !== currentPlayer) {
return {
success: false,
error: `It is ${currentPlayer}'s turn, not ${expectedPlayer}'s`,
};
}
return { success: true };
},
});
/**
* 规则 2:验证单元格为空
* 检查目标单元格是否已经被占用
*/
export const validateCellEmptyRule = createValidationRule({
id: 'tictactoe-validate-cell-empty',
name: 'Validate Cell Empty',
description: 'Check if the target cell is empty',
priority: 2,
gameType: 'tictactoe',
applicableCommands: ['markCell'],
validate: async (context: RuleContext): Promise<RuleResult> => {
const cellId = context.command.steps[0]?.params?.cell as string;
if (!cellId) {
return {
success: false,
error: 'Cell ID is required',
};
}
const cellPlayer = getCellPlayer(context, cellId);
if (cellPlayer !== null) {
return {
success: false,
error: `Cell ${cellId} is already occupied by ${cellPlayer}`,
};
}
return { success: true };
},
});
/**
* 规则 3:验证游戏未结束
* 游戏结束后不允许继续下子
*/
export const validateGameNotEndedRule = createValidationRule({
id: 'tictactoe-validate-game-not-ended',
name: 'Validate Game Not Ended',
description: 'Check if the game has already ended',
priority: 0,
gameType: 'tictactoe',
applicableCommands: ['markCell'],
validate: async (context: RuleContext): Promise<RuleResult> => {
const gameEnded = isGameEnded(context.gameState);
if (gameEnded) {
return {
success: false,
error: 'Game has already ended',
blockCommand: true,
};
}
return { success: true };
},
});
/**
* 效果规则:切换玩家
* 在玩家下子后自动切换到下一个玩家
*/
export const switchTurnRule = createEffectRule({
id: 'tictactoe-switch-turn',
name: 'Switch Turn',
description: 'Switch to the next player after a move',
priority: 10,
gameType: 'tictactoe',
applicableCommands: ['markCell'],
apply: async (context: RuleContext): Promise<RuleResult> => {
const currentPlayer = getCurrentPlayer(context.gameState);
const nextPlayer: Player = currentPlayer === 'X' ? 'O' : 'X';
// 直接更新 metadata
updateGameMetadata(context.gameState, { currentPlayer: nextPlayer });
return {
success: true,
};
},
});
/**
* 效果规则:记录移动历史
* 记录玩家的每一步移动
*/
export const recordMoveHistoryRule = createEffectRule({
id: 'tictactoe-record-history',
name: 'Record Move History',
description: 'Record the move in game history',
priority: 9,
gameType: 'tictactoe',
applicableCommands: ['markCell'],
apply: async (context: RuleContext): Promise<RuleResult> => {
const cellId = context.command.steps[0]?.params?.cell as string;
const player = context.command.steps[0]?.params?.player as Player;
const metadata = context.gameState.data.value.metadata || {};
const ticTacToeMetadata = (metadata?.ticTacToe as TicTacToeMetadata) || {
currentPlayer: 'X',
gameEnded: false,
winner: null,
moveHistory: [],
totalMoves: 0,
};
const moveRecord: MoveRecord = {
player,
cellId,
timestamp: Date.now(),
};
const moveHistory = ticTacToeMetadata.moveHistory || [];
moveHistory.push(moveRecord);
// 直接更新 metadata
updateGameMetadata(context.gameState, {
moveHistory,
totalMoves: (ticTacToeMetadata.totalMoves || 0) + 1,
});
return {
success: true,
};
},
});
/**
* 触发规则:检查获胜条件
* 当有玩家连成一线时触发
*/
export const checkWinConditionRule = createTriggerRule({
id: 'tictactoe-check-win',
name: 'Check Win Condition',
description: 'Check if a player has won the game',
priority: 100,
gameType: 'tictactoe',
condition: async (context: RuleContext): Promise<boolean> => {
const winResult = checkWin(context);
return winResult !== null;
},
action: async (context: RuleContext): Promise<RuleResult> => {
const winResult = checkWin(context);
if (!winResult) {
return { success: false, error: 'No winner detected' };
}
const { winner, combination } = winResult;
// 直接更新 metadata
updateGameMetadata(context.gameState, {
gameEnded: true,
winner,
winningCombination: combination,
});
return {
success: true,
};
},
});
/**
* 触发规则:检查平局条件
* 当所有单元格都被填充且无获胜者时触发
*/
export const checkDrawConditionRule = createTriggerRule({
id: 'tictactoe-check-draw',
name: 'Check Draw Condition',
description: 'Check if the game is a draw',
priority: 101,
gameType: 'tictactoe',
condition: async (context: RuleContext): Promise<boolean> => {
const winResult = checkWin(context);
if (winResult !== null) {
return false; // 有获胜者,不是平局
}
return isDraw(context);
},
action: async (context: RuleContext): Promise<RuleResult> => {
// 直接更新 metadata
updateGameMetadata(context.gameState, {
gameEnded: true,
winner: null, // null 表示平局
});
return {
success: true,
};
},
});
/**
* 所有井字棋游戏规则
*/
export const ticTacToeRules: Rule[] = [
validateTurnRule,
validateCellEmptyRule,
validateGameNotEndedRule,
switchTurnRule,
recordMoveHistoryRule,
checkWinConditionRule,
checkDrawConditionRule,
];
/**
* 获取井字棋验证规则
*/
export function getTicTacToeValidationRules(): Rule[] {
return [validateTurnRule, validateCellEmptyRule, validateGameNotEndedRule];
}
/**
* 获取井字棋效果规则
*/
export function getTicTacToeEffectRules(): Rule[] {
return [switchTurnRule, recordMoveHistoryRule];
}
/**
* 获取井字棋触发规则
*/
export function getTicTacToeTriggerRules(): Rule[] {
return [checkWinConditionRule, checkDrawConditionRule];
}
-161
View File
@@ -1,161 +0,0 @@
/**
* 井字棋游戏状态扩展
*/
/**
* 玩家类型
*/
export type Player = 'X' | 'O';
/**
* 单元格状态
*/
export interface CellState {
/** 单元格 ID(如 A1, B2, C3) */
id: string;
/** 行索引 (0-2) */
row: number;
/** 列索引 (0-2) */
col: number;
/** 当前玩家标记,null 表示空 */
player: Player | null;
}
/**
* 井字棋游戏元数据
*/
export interface TicTacToeMetadata {
/** 当前玩家 */
currentPlayer: Player;
/** 游戏是否结束 */
gameEnded: boolean;
/** 获胜者,null 表示平局或未结束 */
winner: Player | null;
/** 获胜的组合(如果有) */
winningCombination?: string[];
/** 游戏历史 */
moveHistory: MoveRecord[];
/** 总回合数 */
totalMoves: number;
}
/**
* 移动记录
*/
export interface MoveRecord {
/** 移动的玩家 */
player: Player;
/** 移动的单元格 ID */
cellId: string;
/** 移动时间戳 */
timestamp: number;
}
/**
* 获胜组合类型
*/
export type WinningLine =
| { type: 'row'; index: number }
| { type: 'column'; index: number }
| { type: 'diagonal'; direction: 'main' | 'anti' };
/**
* 井字棋棋盘配置
*/
export interface TicTacToeBoardConfig {
/** 棋盘大小(默认 3x3) */
size: number;
/** 单元格 ID 前缀 */
cellIdPrefix: string;
}
/**
* 默认的 3x3 棋盘配置
*/
export const DEFAULT_BOARD_CONFIG: TicTacToeBoardConfig = {
size: 3,
cellIdPrefix: 'cell',
};
/**
* 获取单元格 ID
*/
export function getCellId(row: number, col: number, prefix: string = 'cell'): string {
const rowLabel = String.fromCharCode('A'.charCodeAt(0) + row);
return `${prefix}-${rowLabel}${col + 1}`;
}
/**
* 解析单元格 ID
*/
export function parseCellId(cellId: string): { row: number; col: number } | null {
const match = cellId.match(/^cell-([A-Z])(\d+)$/);
if (!match) return null;
return {
row: match[1].charCodeAt(0) - 'A'.charCodeAt(0),
col: parseInt(match[2], 10) - 1,
};
}
/**
* 检查是否是有效的单元格 ID
*/
export function isValidCellId(cellId: string, size: number = 3): boolean {
const parsed = parseCellId(cellId);
if (!parsed) return false;
return parsed.row >= 0 && parsed.row < size && parsed.col >= 0 && parsed.col < size;
}
/**
* 生成所有单元格 ID
*/
export function getAllCellIds(size: number = 3, prefix: string = 'cell'): string[] {
const cells: string[] = [];
for (let row = 0; row < size; row++) {
for (let col = 0; col < size; col++) {
cells.push(getCellId(row, col, prefix));
}
}
return cells;
}
/**
* 获取所有可能的获胜组合
*/
export function getWinningCombinations(size: number = 3): string[][] {
const combinations: string[][] = [];
// 行
for (let row = 0; row < size; row++) {
const rowCells: string[] = [];
for (let col = 0; col < size; col++) {
rowCells.push(getCellId(row, col));
}
combinations.push(rowCells);
}
// 列
for (let col = 0; col < size; col++) {
const colCells: string[] = [];
for (let row = 0; row < size; row++) {
colCells.push(getCellId(row, col));
}
combinations.push(colCells);
}
// 主对角线
const mainDiagonal: string[] = [];
for (let i = 0; i < size; i++) {
mainDiagonal.push(getCellId(i, i));
}
combinations.push(mainDiagonal);
// 反对角线
const antiDiagonal: string[] = [];
for (let i = 0; i < size; i++) {
antiDiagonal.push(getCellId(i, size - 1 - i));
}
combinations.push(antiDiagonal);
return combinations;
}
-82
View File
@@ -1,82 +0,0 @@
/**
* 井字棋游戏模块
* Tic Tac Toe game implementation with rule enforcement
*/
import type { Command } from '../../commands/Command';
import type { Rule } from '../../rules/Rule';
import {
startGameCommand,
markCellCommand,
resetGameCommand,
setPlayersCommand,
getCellCommand,
ticTacToeCommands,
createMarkCellCommand,
createSetPlayersCommand,
} from './TicTacToeCommands';
import {
ticTacToeRules,
getTicTacToeValidationRules,
getTicTacToeEffectRules,
getTicTacToeTriggerRules,
} from './TicTacToeRules';
// State types
export type {
Player,
CellState,
TicTacToeMetadata,
MoveRecord,
WinningLine,
TicTacToeBoardConfig,
} from './TicTacToeState';
export {
DEFAULT_BOARD_CONFIG,
getCellId,
parseCellId,
isValidCellId,
getAllCellIds,
getWinningCombinations,
} from './TicTacToeState';
// Rules
export {
validateTurnRule,
validateCellEmptyRule,
validateGameNotEndedRule,
switchTurnRule,
recordMoveHistoryRule,
checkWinConditionRule,
checkDrawConditionRule,
ticTacToeRules,
getTicTacToeValidationRules,
getTicTacToeEffectRules,
getTicTacToeTriggerRules,
} from './TicTacToeRules';
// Commands
export {
startGameCommand,
markCellCommand,
resetGameCommand,
setPlayersCommand,
getCellCommand,
ticTacToeCommands,
createMarkCellCommand,
createSetPlayersCommand,
} from './TicTacToeCommands';
/**
* 创建井字棋游戏初始化命令
*/
export function createTicTacToeGame(): {
commands: Command[];
rules: Rule[];
} {
return {
commands: [startGameCommand, resetGameCommand],
rules: ticTacToeRules,
};
}
+63 -198
View File
@@ -3,211 +3,76 @@
* 基于 Preact Signals 的桌游状态管理库 * 基于 Preact Signals 的桌游状态管理库
*/ */
// Rules engine
export type {
Rule,
RuleContext,
RuleResult,
ValidationRule,
EffectRule,
TriggerRule,
RuleLogEntry,
} from './rules/Rule';
export {
isValidationRule,
isEffectRule,
isTriggerRule,
createValidationRule,
createEffectRule,
createTriggerRule,
} from './rules/Rule';
export { RuleEngine, createRuleEngine } from './rules/RuleEngine';
export type { RuleEngineOptions, RuleEngineExecutionResult } from './rules/RuleEngine';
export { RuleRegistry, createRuleRegistry } from './rules/RuleRegistry';
export type { RuleGroup } from './rules/RuleRegistry';
// Tic Tac Toe game
export type {
Player,
CellState,
TicTacToeMetadata,
MoveRecord,
WinningLine,
TicTacToeBoardConfig,
} from './games/tictactoe/TicTacToeState';
export {
DEFAULT_BOARD_CONFIG,
getCellId,
parseCellId,
isValidCellId,
getAllCellIds,
getWinningCombinations,
} from './games/tictactoe/TicTacToeState';
export {
validateTurnRule,
validateCellEmptyRule,
validateGameNotEndedRule,
switchTurnRule,
recordMoveHistoryRule,
checkWinConditionRule,
checkDrawConditionRule,
ticTacToeRules,
getTicTacToeValidationRules,
getTicTacToeEffectRules,
getTicTacToeTriggerRules,
createTicTacToeGame,
} from './games/tictactoe';
export {
startGameCommand,
markCellCommand,
resetGameCommand,
setPlayersCommand,
getCellCommand,
ticTacToeCommands,
createMarkCellCommand,
createSetPlayersCommand,
} from './games/tictactoe';
// Core types // Core types
export { PartType } from './core/Part'; export type { IGameContext } from "./core/game";
export { createPromptDef } from "./core/game";
export type { GameHost, GameHostStatus, GameModule } from "./core/game-host";
export { createGameHost } from "./core/game-host";
export type { Part } from "./core/part";
export { flip, flipTo, roll } from "./core/part";
export { createParts, createPartsFromTable } from "./core/part-factory";
export type { Region, RegionAxis } from "./core/region";
export {
createRegion,
createRegionAxis,
applyAlign,
shuffle,
moveToRegion,
} from "./core/region";
export * from "./core/game-client";
// Utils
export type { export type {
Part, Command,
PartBase, CommandDef,
MeeplePart, CommandResult,
CardPart, CommandSchema,
TilePart, CommandParamSchema,
PartSignal, CommandOptionSchema,
} from './core/Part'; CommandFlagSchema,
} from "./utils/command";
export { RegionType } from './core/Region';
export type { Region, RegionProperties, Slot } from './core/Region';
export type { Placement, PlacementProperties, Position, PlacementSignal } from './core/Placement';
export type { GameStateData } from './core/GameState';
// Core classes and functions
export { export {
createPart, parseCommand,
createMeeple, parseCommandSchema,
createCard, validateCommand,
createTile, parseCommandWithSchema,
} from './core/Part'; applyCommandSchema,
} from "./utils/command";
export { createRegion, createRegion as createRegionCore } from './core/Region'; export type {
export type { Region as RegionClass } from './core/Region'; CommandRunner,
CommandRunnerHandler,
export { createPlacement } from './core/Placement'; CommandRunnerContext,
PromptEvent,
export { GameState, createGameState } from './core/GameState'; CommandRunnerEvents,
} from "./utils/command";
// Part actions
export { export {
createPartAction, createCommandRegistry,
createMeepleAction, registerCommand,
createCardAction, unregisterCommand,
createTileAction, hasCommand,
updatePartAction, getCommand,
removePartAction, runCommand,
getPartAction, runCommandParsed,
} from './actions/part.actions'; createCommandRunnerContext,
type CommandRegistry,
type CommandRunnerContextExport,
} from "./utils/command";
// Region actions export type { MutableSignal } from "./utils/mutable-signal";
export { export { mutableSignal } from "./utils/mutable-signal";
createRegionAction,
getRegionAction,
removeRegionAction,
addPlacementToRegionAction,
removePlacementFromRegionAction,
setSlotAction,
getSlotAction,
clearRegionAction,
getRegionPlacementCountAction,
isRegionEmptyAction,
isRegionFullAction,
} from './actions/region.actions';
// Placement actions export type { RNG, ReadonlyRNG } from "./utils/rng";
export { export { createRNG, Mulberry32RNG } from "./utils/rng";
createPlacementAction,
getPlacementAction,
removePlacementAction,
movePlacementAction,
updatePlacementPositionAction,
updatePlacementRotationAction,
flipPlacementAction,
updatePlacementPartAction,
swapPlacementsAction,
setPlacementFaceAction,
getPlacementsInRegionAction,
getPlacementsOfPartAction,
} from './actions/placement.actions';
// Commands export type { MiddlewareChain } from "./utils/middleware";
export { export { createMiddlewareChain } from "./utils/middleware";
CommandActionType,
type Command,
type CommandStep,
type CommandExecutionResult,
type CommandLogEntry,
type StepResult,
type CommandStatus,
type QueuedCommand,
} from './commands/Command';
export { CommandExecutor } from './commands/CommandExecutor'; export * from "./utils/spawner";
export { CommandLog, createCommandLog } from './commands/CommandLog'; export * from "@preact/signals-core";
export {
setupGameCommand,
placeMeepleCommand,
moveMeepleCommand,
drawCardCommand,
playCardCommand,
placeTileCommand,
flipTileCommand,
swapPlacementsCommand,
setPhaseCommand,
clearRegionCommand,
defaultCommands,
getDefaultCommand,
} from './commands/default.commands';
// CLI Commands
export {
type CliCommand,
type CliCommandArgs,
type CliCommandResult,
type CliCommandStep,
type ParsedCliCommand,
} from './commands/CliCommand';
export { CommandParser, createCommandParser, CommandParseError } from './commands/CommandParser';
export { CommandRegistry, createCommandRegistry } from './commands/CommandRegistry';
export {
moveCommand,
placeCommand,
flipCommand,
createCommand,
regionCommand,
drawCommand,
shuffleCommand,
discardCommand,
swapCommand,
rotateCommand,
positionCommand,
phaseCommand,
clearCommand,
removeCommand,
helpCommand,
cliCommands,
} from './commands/cli.commands';
-186
View File
@@ -1,186 +0,0 @@
import type { GameState } from '../core/GameState';
import type { Command } from '../commands/Command';
import type { CommandExecutionResult } from '../commands/Command';
/**
* 规则执行上下文
*/
export interface RuleContext {
/** 游戏状态 */
gameState: GameState;
/** 当前命令 */
command: Command;
/** 命令执行结果(执行后规则可用) */
executionResult?: CommandExecutionResult;
/** 规则执行时的元数据 */
metadata: Record<string, unknown>;
}
/**
* 规则执行结果
*/
export interface RuleResult {
/** 规则是否通过 */
success: boolean;
/** 错误信息(如果失败) */
error?: string;
/** 状态修改(规则可以对状态进行修改) */
stateUpdates?: Record<string, unknown>;
/** 是否阻止命令执行 */
blockCommand?: boolean;
/** 触发的额外命令 */
triggeredCommands?: Command[];
}
/**
* 验证规则
* 在命令执行前运行,用于验证命令是否合法
*/
export interface ValidationRule {
/** 规则唯一标识 */
id: string;
/** 规则名称 */
name: string;
/** 规则描述 */
description?: string;
/** 规则优先级(数字越小越先执行) */
priority: number;
/** 适用的游戏类型 */
gameType?: string;
/** 适用的命令名称列表 */
applicableCommands?: string[];
/** 验证函数 */
validate: (context: RuleContext) => Promise<RuleResult>;
}
/**
* 效果规则
* 在命令执行后运行,用于更新状态或触发额外效果
*/
export interface EffectRule {
/** 规则唯一标识 */
id: string;
/** 规则名称 */
name: string;
/** 规则描述 */
description?: string;
/** 规则优先级(数字越小越先执行) */
priority: number;
/** 适用的游戏类型 */
gameType?: string;
/** 适用的命令名称列表 */
applicableCommands?: string[];
/** 效果函数 */
apply: (context: RuleContext) => Promise<RuleResult>;
}
/**
* 触发规则
* 监听特定状态变化并触发相应动作
*/
export interface TriggerRule {
/** 规则唯一标识 */
id: string;
/** 规则名称 */
name: string;
/** 规则描述 */
description?: string;
/** 规则优先级 */
priority: number;
/** 适用的游戏类型 */
gameType?: string;
/** 适用的命令名称列表 */
applicableCommands?: string[];
/** 触发条件 */
condition: (context: RuleContext) => Promise<boolean>;
/** 触发后的动作 */
action: (context: RuleContext) => Promise<RuleResult>;
}
/**
* 通用规则类型
*/
export type Rule = ValidationRule | EffectRule | TriggerRule;
/**
* 判断是否为验证规则
*/
export function isValidationRule(rule: Rule): rule is ValidationRule {
return 'validate' in rule;
}
/**
* 判断是否为效果规则
*/
export function isEffectRule(rule: Rule): rule is EffectRule {
return 'apply' in rule;
}
/**
* 判断是否为触发规则
*/
export function isTriggerRule(rule: Rule): rule is TriggerRule {
return 'condition' in rule && 'action' in rule;
}
/**
* 创建验证规则
*/
export function createValidationRule(rule: Omit<ValidationRule, 'id' | 'name'> & { id?: string; name?: string }): ValidationRule {
return {
id: rule.id || `validation-${Date.now()}`,
name: rule.name || 'Unnamed Validation Rule',
description: rule.description,
priority: rule.priority ?? 0,
gameType: rule.gameType,
applicableCommands: rule.applicableCommands,
validate: rule.validate,
};
}
/**
* 创建效果规则
*/
export function createEffectRule(rule: Omit<EffectRule, 'id' | 'name'> & { id?: string; name?: string }): EffectRule {
return {
id: rule.id || `effect-${Date.now()}`,
name: rule.name || 'Unnamed Effect Rule',
description: rule.description,
priority: rule.priority ?? 0,
gameType: rule.gameType,
applicableCommands: rule.applicableCommands,
apply: rule.apply,
};
}
/**
* 创建触发规则
*/
export function createTriggerRule(rule: Omit<TriggerRule, 'id' | 'name'> & { id?: string; name?: string }): TriggerRule {
return {
id: rule.id || `trigger-${Date.now()}`,
name: rule.name || 'Unnamed Trigger Rule',
description: rule.description,
priority: rule.priority ?? 0,
condition: rule.condition,
action: rule.action,
};
}
/**
* 规则执行日志
*/
export interface RuleLogEntry {
/** 时间戳 */
timestamp: number;
/** 规则 ID */
ruleId: string;
/** 规则名称 */
ruleName: string;
/** 规则类型 */
ruleType: 'validation' | 'effect' | 'trigger';
/** 执行结果 */
result: RuleResult;
/** 命令 ID */
commandId: string;
}
-378
View File
@@ -1,378 +0,0 @@
import type { GameState } from '../core/GameState';
import type { Command, CommandExecutionResult } from '../commands/Command';
import { CommandExecutor } from '../commands/CommandExecutor';
import type {
Rule,
RuleContext,
RuleResult,
ValidationRule,
EffectRule,
TriggerRule,
RuleLogEntry,
} from './Rule';
import { isValidationRule, isEffectRule, isTriggerRule } from './Rule';
/**
* 规则引擎配置
*/
export interface RuleEngineOptions {
/** 游戏类型 */
gameType?: string;
/** 是否启用规则日志 */
enableLogging?: boolean;
/** 是否自动执行触发规则 */
autoExecuteTriggers?: boolean;
}
/**
* 规则引擎执行结果
*/
export interface RuleEngineExecutionResult extends CommandExecutionResult {
/** 执行的验证规则 */
validationRules: RuleLogEntry[];
/** 执行的效果规则 */
effectRules: RuleLogEntry[];
/** 触发的规则 */
triggerRules: RuleLogEntry[];
/** 触发的额外命令 */
triggeredCommands: Command[];
}
/**
* 规则引擎
* 负责在命令执行前后运行规则,并处理触发规则
*/
export class RuleEngine {
private gameState: GameState;
private executor: CommandExecutor;
private rules: Rule[] = [];
private options: RuleEngineOptions;
private logs: RuleLogEntry[] = [];
private isExecuting: boolean = false;
private triggerQueue: Command[] = [];
constructor(gameState: GameState, options: RuleEngineOptions = {}) {
this.gameState = gameState;
this.executor = new CommandExecutor(gameState);
this.options = {
enableLogging: true,
autoExecuteTriggers: true,
...options,
};
}
/**
* 注册规则
*/
registerRule(rule: Rule): void {
// 如果指定了游戏类型,只有匹配时才注册
if (this.options.gameType && rule.gameType && rule.gameType !== this.options.gameType) {
return;
}
this.rules.push(rule);
// 按优先级排序
this.rules.sort((a, b) => a.priority - b.priority);
}
/**
* 注册多个规则
*/
registerRules(rules: Rule[]): void {
for (const rule of rules) {
this.registerRule(rule);
}
}
/**
* 移除规则
*/
unregisterRule(ruleId: string): void {
this.rules = this.rules.filter((r) => r.id !== ruleId);
}
/**
* 清除所有规则
*/
clearRules(): void {
this.rules = [];
}
/**
* 获取所有规则
*/
getRules(): Rule[] {
return [...this.rules];
}
/**
* 执行命令(带规则验证)
*/
async executeCommand(command: Command): Promise<RuleEngineExecutionResult> {
if (this.isExecuting) {
throw new Error('Rule engine is already executing a command');
}
this.isExecuting = true;
const validationLogs: RuleLogEntry[] = [];
const effectLogs: RuleLogEntry[] = [];
const triggerLogs: RuleLogEntry[] = [];
const triggeredCommands: Command[] = [];
try {
// 创建规则上下文
const context: RuleContext = {
gameState: this.gameState,
command,
metadata: {},
};
// 1. 执行验证规则
const validationRules = this.rules.filter(isValidationRule);
for (const rule of validationRules) {
if (!this.isRuleApplicable(rule, command)) {
continue;
}
const result = await rule.validate(context);
const logEntry = this.createLogEntry(rule, 'validation', result, command.id);
validationLogs.push(logEntry);
if (!result.success) {
return this.createFailedResult(validationLogs, effectLogs, triggerLogs, triggeredCommands, result.error);
}
if (result.blockCommand) {
return this.createFailedResult(
validationLogs,
effectLogs,
triggerLogs,
triggeredCommands,
`Command blocked by rule: ${rule.name}`
);
}
// 应用状态更新
if (result.stateUpdates) {
Object.assign(context.metadata, result.stateUpdates);
}
// 收集触发的命令
if (result.triggeredCommands) {
triggeredCommands.push(...result.triggeredCommands);
}
}
// 2. 执行命令
const executionResult = this.executor.execute(command);
context.executionResult = executionResult;
if (!executionResult.success) {
return this.createFailedResult(
validationLogs,
effectLogs,
triggerLogs,
triggeredCommands,
executionResult.error
);
}
// 3. 执行效果规则
const effectRules = this.rules.filter(isEffectRule);
for (const rule of effectRules) {
if (!this.isRuleApplicable(rule, command)) {
continue;
}
const result = await rule.apply(context);
const logEntry = this.createLogEntry(rule, 'effect', result, command.id);
effectLogs.push(logEntry);
if (!result.success) {
// 效果规则失败不影响命令执行,只记录日志
continue;
}
// 应用状态更新
if (result.stateUpdates) {
Object.assign(context.metadata, result.stateUpdates);
}
// 收集触发的命令
if (result.triggeredCommands) {
triggeredCommands.push(...result.triggeredCommands);
}
}
// 4. 执行触发规则
if (this.options.autoExecuteTriggers) {
const triggerRules = this.rules.filter(isTriggerRule);
for (const rule of triggerRules) {
const shouldTrigger = await rule.condition(context);
if (shouldTrigger) {
const result = await rule.action(context);
const logEntry = this.createLogEntry(rule, 'trigger', result, command.id);
triggerLogs.push(logEntry);
if (result.triggeredCommands) {
triggeredCommands.push(...result.triggeredCommands);
}
}
}
}
// 5. 执行触发的命令(在循环外执行,避免递归)
} finally {
this.isExecuting = false;
}
// 在主要执行完成后执行触发的命令
for (const triggeredCommand of triggeredCommands) {
try {
const triggerResult = await this.executeCommand(triggeredCommand);
if (!triggerResult.success) {
// 触发命令失败,记录但不影响主命令
this.logs.push({
timestamp: Date.now(),
ruleId: 'triggered-command',
ruleName: 'Triggered Command',
ruleType: 'trigger',
result: { success: false, error: `Triggered command ${triggeredCommand.id} failed: ${triggerResult.error}` },
commandId: triggeredCommand.id,
});
}
} catch (error) {
// 忽略触发命令的异常
}
}
return {
success: true,
executedSteps: executionResult.executedSteps,
totalSteps: executionResult.totalSteps,
validationRules: validationLogs,
effectRules: effectLogs,
triggerRules: triggerLogs,
triggeredCommands,
};
}
/**
* 检查规则是否适用于当前命令
*/
private isRuleApplicable(
rule: ValidationRule | EffectRule,
command: Command
): boolean {
// 检查游戏类型
if (rule.gameType && rule.gameType !== this.options.gameType) {
return false;
}
// 检查命令名称
if (rule.applicableCommands && !rule.applicableCommands.includes(command.name)) {
return false;
}
return true;
}
/**
* 创建日志条目
*/
private createLogEntry(
rule: Rule,
ruleType: 'validation' | 'effect' | 'trigger',
result: RuleResult,
commandId: string
): RuleLogEntry {
const entry: RuleLogEntry = {
timestamp: Date.now(),
ruleId: rule.id,
ruleName: rule.name,
ruleType,
result,
commandId,
};
if (this.options.enableLogging) {
this.logs.push(entry);
}
return entry;
}
/**
* 创建失败结果
*/
private createFailedResult(
validationLogs: RuleLogEntry[],
effectLogs: RuleLogEntry[],
triggerLogs: RuleLogEntry[],
triggeredCommands: Command[],
error?: string
): RuleEngineExecutionResult {
return {
success: false,
error,
executedSteps: 0,
totalSteps: 0,
validationRules: validationLogs,
effectRules: effectLogs,
triggerRules: triggerLogs,
triggeredCommands,
};
}
/**
* 获取规则日志
*/
getLogs(): RuleLogEntry[] {
return [...this.logs];
}
/**
* 清除日志
*/
clearLogs(): void {
this.logs = [];
}
/**
* 获取游戏状态
*/
getGameState(): GameState {
return this.gameState;
}
/**
* 手动触发规则
*/
async triggerRules(): Promise<RuleLogEntry[]> {
const context: RuleContext = {
gameState: this.gameState,
command: { id: 'trigger-manual', name: 'manual-trigger', steps: [] },
metadata: {},
};
const logs: RuleLogEntry[] = [];
const triggerRules = this.rules.filter(isTriggerRule);
for (const rule of triggerRules) {
const shouldTrigger = await rule.condition(context);
if (shouldTrigger) {
const result = await rule.action(context);
const logEntry = this.createLogEntry(rule, 'trigger', result, 'manual');
logs.push(logEntry);
}
}
return logs;
}
}
/**
* 创建规则引擎
*/
export function createRuleEngine(gameState: GameState, options?: RuleEngineOptions): RuleEngine {
return new RuleEngine(gameState, options);
}
-199
View File
@@ -1,199 +0,0 @@
import type { Rule, ValidationRule, EffectRule, TriggerRule } from './Rule';
import { isValidationRule, isEffectRule, isTriggerRule } from './Rule';
/**
* 规则组
*/
export interface RuleGroup {
/** 组名称 */
name: string;
/** 组描述 */
description?: string;
/** 规则列表 */
rules: Rule[];
}
/**
* 规则注册表
* 按游戏类型注册和管理规则
*/
export class RuleRegistry {
private rulesByGameType: Map<string, Rule[]>;
private globalRules: Rule[];
private ruleGroups: Map<string, RuleGroup>;
private enabledRules: Set<string>;
constructor() {
this.rulesByGameType = new Map();
this.globalRules = [];
this.ruleGroups = new Map();
this.enabledRules = new Set();
}
/**
* 注册规则到特定游戏类型
*/
register(rule: Rule, gameType?: string): void {
const targetRules = gameType
? this.getRulesForGameType(gameType)
: this.globalRules;
targetRules.push(rule);
this.enabledRules.add(rule.id);
// 按优先级排序
targetRules.sort((a, b) => a.priority - b.priority);
}
/**
* 注册多个规则
*/
registerAll(rules: Rule[], gameType?: string): void {
for (const rule of rules) {
this.register(rule, gameType);
}
}
/**
* 注册规则组
*/
registerGroup(group: RuleGroup, gameType?: string): void {
this.ruleGroups.set(group.name, group);
this.registerAll(group.rules, gameType);
}
/**
* 获取特定游戏类型的规则
*/
getRulesForGameType(gameType: string): Rule[] {
const gameRules = this.rulesByGameType.get(gameType) || [];
return [...gameRules, ...this.globalRules];
}
/**
* 获取所有规则
*/
getAllRules(): Rule[] {
const allRules = [...this.globalRules];
for (const rules of this.rulesByGameType.values()) {
allRules.push(...rules);
}
return allRules;
}
/**
* 获取验证规则
*/
getValidationRules(gameType?: string): ValidationRule[] {
const rules = gameType
? this.getRulesForGameType(gameType)
: this.getAllRules();
return rules.filter(isValidationRule);
}
/**
* 获取效果规则
*/
getEffectRules(gameType?: string): EffectRule[] {
const rules = gameType
? this.getRulesForGameType(gameType)
: this.getAllRules();
return rules.filter(isEffectRule);
}
/**
* 获取触发规则
*/
getTriggerRules(gameType?: string): TriggerRule[] {
const rules = gameType
? this.getRulesForGameType(gameType)
: this.getAllRules();
return rules.filter(isTriggerRule);
}
/**
* 获取规则组
*/
getGroup(groupName: string): RuleGroup | undefined {
return this.ruleGroups.get(groupName);
}
/**
* 移除规则
*/
unregister(ruleId: string): void {
this.globalRules = this.globalRules.filter((r) => r.id !== ruleId);
for (const [gameType, rules] of this.rulesByGameType.entries()) {
this.rulesByGameType.set(
gameType,
rules.filter((r) => r.id !== ruleId)
);
}
this.enabledRules.delete(ruleId);
}
/**
* 启用规则
*/
enableRule(ruleId: string): void {
this.enabledRules.add(ruleId);
}
/**
* 禁用规则
*/
disableRule(ruleId: string): void {
this.enabledRules.delete(ruleId);
}
/**
* 检查规则是否启用
*/
isRuleEnabled(ruleId: string): boolean {
return this.enabledRules.has(ruleId);
}
/**
* 获取按游戏类型分类的规则
*/
getRulesByGameType(): Map<string, Rule[]> {
return new Map(this.rulesByGameType);
}
/**
* 清除特定游戏类型的规则
*/
clearGameTypeRules(gameType: string): void {
this.rulesByGameType.delete(gameType);
}
/**
* 清除所有规则
*/
clearAllRules(): void {
this.rulesByGameType.clear();
this.globalRules = [];
this.enabledRules.clear();
}
/**
* 获取规则数量
*/
getRuleCount(): number {
return this.getAllRules().length;
}
/**
* 获取启用的规则数量
*/
getEnabledRuleCount(): number {
return this.enabledRules.size;
}
}
/**
* 创建规则注册表
*/
export function createRuleRegistry(): RuleRegistry {
return new RuleRegistry();
}
+6
View File
@@ -0,0 +1,6 @@
type,player,count
string,string,int
kitten,white,8
kitten,black,8
cat,white,8
cat,black,8
1 type player count
2 string string int
3 kitten white 8
4 kitten black 8
5 cat white 8
6 cat black 8
+10
View File
@@ -0,0 +1,10 @@
type BoopPartTable = readonly {
readonly type: string;
readonly player: string;
readonly count: number;
}[];
export type BoopPart = BoopPartTable[number];
declare function getData(): BoopPartTable;
export default getData;
+46
View File
@@ -0,0 +1,46 @@
import {BoopGame} from "@/samples/boop/types-extensions";
import {PieceType} from "@/samples/boop/types";
import {findPartAtPosition, getNeighborPositions, isCellOccupied, isInBounds} from "@/samples/boop/utils";
import {moveToRegion} from "@/core/region";
/**
* 执行boop - 推动周围棋子
*/
export async function boop(game: BoopGame, row: number, col: number, type: PieceType) {
const booped: string[] = [];
await game.produceAsync(state => {
// 按照远离放置位置的方向推动
for (const [dr, dc] of getNeighborPositions()) {
const nr = row + dr;
const nc = col + dc;
if (!isInBounds(nr, nc)) continue;
// 从 state 中查找,而不是 game
const part = findPartAtPosition(state, nr, nc);
if (!part) continue;
// 小猫不能推动猫
if (type === 'kitten' && part.type === 'cat') continue;
// 计算推动后的位置
const newRow = nr + dr;
const newCol = nc + dc;
// 检查新位置是否为空或在棋盘外
if (!isInBounds(newRow, newCol)) {
// 棋子被推出棋盘,返回玩家supply
booped.push(part.id);
moveToRegion(part, state.regions.board, state.regions[part.player]);
} else if (!isCellOccupied(state, newRow, newCol)) {
// 新位置为空,移动过去
booped.push(part.id);
moveToRegion(part, state.regions.board, state.regions.board, [newRow, newCol]);
}
// 如果新位置被占用,则不移动(两个棋子都保持原位)
}
});
return { booped };
}
+45
View File
@@ -0,0 +1,45 @@
import {BoopGame} from "@/samples/boop/types-extensions";
import {PlayerType} from "@/samples/boop/types";
import {findPartAtPosition, findPartInRegion, isInBounds} from "@/samples/boop/utils";
import {MAX_PIECES_PER_PLAYER} from "@/samples/boop/constants";
import {prompts} from "@/samples/boop/prompts";
import {moveToRegion} from "@/core/region";
/**
* 检查并执行 8-piece 规则
*/
export async function checkFullBoard(game: BoopGame, turnPlayer: PlayerType){
// 检查8-piece规则: 如果玩家所有8个棋子都在棋盘上且没有获胜,强制升级一个小猫
const playerPieces = Object.values(game.value.pieces).filter(
p => p.player === turnPlayer && p.regionId === 'board'
);
if(playerPieces.length < MAX_PIECES_PER_PLAYER || game.value.winner !== null){
return;
}
const partId = await game.prompt(
prompts.choose,
(player, row, col) => {
if (player !== turnPlayer) {
throw `Invalid player: ${player}. Expected ${turnPlayer}.`;
}
if (!isInBounds(row, col)) {
throw `Invalid position: (${row}, ${col}). Must be between 0 and 5.`;
}
const part = findPartAtPosition(game, row, col);
if (!part || part.player !== turnPlayer) {
throw `No ${player} piece at (${row}, ${col}).`;
}
return part.id;
}
);
await game.produceAsync(state => {
const part = state.pieces[partId];
moveToRegion(part, state.regions.board, null);
const cat = findPartInRegion(state, '', 'cat');
moveToRegion(cat || part, null, state.regions[turnPlayer]);
});
}
+40
View File
@@ -0,0 +1,40 @@
import {BoopGame} from "@/samples/boop/types-extensions";
import {PlayerType} from "@/samples/boop/types";
import {findPartAtPosition, findPartInRegion, getLineCandidates} from "@/samples/boop/utils";
import {WIN_LENGTH} from "@/samples/boop/constants";
import {moveToRegion} from "@/core/region";
/**
* 检查并执行小猫升级(三个小猫连线变成猫)
*/
export async function checkGraduates(game: BoopGame){
const toUpgrade = new Set<string>();
for(const line of getLineCandidates()){
let whites = 0;
let blacks = 0;
for(const [row, col] of line){
const part = findPartAtPosition(game, row, col);
if (part?.player === 'white') whites++;
else if(part?.player === 'black') blacks++;
}
const player = whites >= WIN_LENGTH ? 'white' : blacks >= WIN_LENGTH ? 'black' : null;
if(!player) continue;
for(const [row, col] of line){
const part = findPartAtPosition(game, row, col);
part && toUpgrade.add(part.id);
}
}
await game.produceAsync(state => {
for(const partId of toUpgrade){
const part = state.pieces[partId];
const [row, col] = part.position;
const player = part.player;
moveToRegion(part, state.regions.board, null);
const newPart = findPartInRegion(state, '', 'cat', player);
moveToRegion(newPart || part, null, state.regions[player], [row, col]);
}
});
}
+31
View File
@@ -0,0 +1,31 @@
import { BoopGame } from "@/samples/boop/types-extensions";
import { PlayerType } from "@/samples/boop/types";
import { turn } from "@/samples/boop/commands/turn";
import { boop } from "./boop";
import { checkFullBoard } from "./full-board";
import { checkGraduates } from "./graduate";
import { place } from "./place";
import { checkWin } from "./win";
/**
* 启动游戏主循环
*/
export async function start(game: BoopGame) {
while (true) {
const currentPlayer = game.value.currentPlayer;
const turnOutput = await turn(game, currentPlayer);
await game.produceAsync((state) => {
state.winner = turnOutput.winner;
if (!state.winner) {
state.currentPlayer =
state.currentPlayer === "white" ? "black" : "white";
}
});
if (game.value.winner) break;
}
return game.value;
}
export { place, boop, checkWin, checkGraduates, checkFullBoard, turn };
+27
View File
@@ -0,0 +1,27 @@
import {BoopGame} from "@/samples/boop/types-extensions";
import {PieceType, PlayerType} from "@/samples/boop/types";
import {findPartInRegion} from "@/samples/boop/utils";
import {moveToRegion} from "@/core/region";
/**
* 放置棋子到棋盘
*/
export async function place(game: BoopGame, row: number, col: number, player: PlayerType, type: PieceType) {
const value = game.value;
// 从玩家supply中找到对应类型的棋子
const part = findPartInRegion(game, player, type);
if (!part) {
throw new Error(`No ${type} available in ${player}'s supply`);
}
const partId = part.id;
await game.produceAsync(state => {
// 将棋子从supply移动到棋盘
const part = state.pieces[partId];
moveToRegion(part, state.regions[player], state.regions.board, [row, col]);
});
return { row, col, player, type, partId };
}
+49
View File
@@ -0,0 +1,49 @@
import {BoopGame} from "@/samples/boop/types-extensions";
import {PieceType, PlayerType, WinnerType} from "@/samples/boop/types";
import {prompts} from "@/samples/boop/prompts";
import {findPartInRegion, isCellOccupied, isInBounds} from "@/samples/boop/utils";
import {place} from "@/samples/boop/commands/place";
import {boop} from "@/samples/boop/commands/boop";
import {checkWin} from "@/samples/boop/commands/win";
import {checkGraduates} from "@/samples/boop/commands/graduate";
import {checkFullBoard} from "@/samples/boop/commands/full-board";
import {BOARD_SIZE} from "@/samples/boop/constants";
/**
* 处理一个回合
*/
export async function turn(game: BoopGame, turnPlayer: PlayerType) {
const {row, col, type} = await game.prompt(
prompts.play,
(player, row, col, type) => {
const pieceType = type === 'cat' ? 'cat' : 'kitten';
if (player !== turnPlayer) {
throw `Invalid player: ${player}. Expected ${turnPlayer}.`;
}
if (!isInBounds(row, col)) {
throw `Invalid position: (${row}, ${col}). Must be between 0 and ${BOARD_SIZE - 1}.`;
}
if (isCellOccupied(game, row, col)) {
throw `Cell (${row}, ${col}) is already occupied.`;
}
const found = findPartInRegion(game, player, pieceType);
if (!found) {
throw `No ${pieceType}s left in ${player}'s supply.`;
}
return {player, row,col,type};
},
game.value.currentPlayer
);
const pieceType = type === 'cat' ? 'cat' : 'kitten';
await place(game, row, col, turnPlayer, pieceType);
await boop(game, row, col, pieceType);
const winner = await checkWin(game);
if(winner) return { winner: winner };
await checkGraduates(game);
await checkFullBoard(game, turnPlayer);
return { winner: null };
}
+27
View File
@@ -0,0 +1,27 @@
import {BoopGame} from "@/samples/boop/types-extensions";
import {PlayerType, WinnerType} from "@/samples/boop/types";
import {findPartAtPosition, getLineCandidates} from "@/samples/boop/utils";
import {WIN_LENGTH} from "@/samples/boop/constants";
/**
* 检查是否有玩家获胜(三个猫连线)
*/
export async function checkWin(game: BoopGame): Promise<WinnerType | null> {
for(const line of getLineCandidates()){
let whites = 0;
let blacks = 0;
for(const [row, col] of line){
const part = findPartAtPosition(game, row, col);
if(part?.type !== 'cat') continue;
if (part.player === 'white') whites++;
else blacks++;
}
if(whites >= WIN_LENGTH) {
return 'white';
}
if(blacks >= WIN_LENGTH) {
return 'black';
}
}
return null;
}
+3
View File
@@ -0,0 +1,3 @@
export const BOARD_SIZE = 6;
export const MAX_PIECES_PER_PLAYER = 8;
export const WIN_LENGTH = 3;
+41
View File
@@ -0,0 +1,41 @@
// Types
export type {
PlayerType,
PieceType,
WinnerType,
RegionType,
BoopPartMeta,
BoopPart,
} from "./types";
export type { BoopGame } from "./types-extensions";
// Constants
export { BOARD_SIZE, MAX_PIECES_PER_PLAYER, WIN_LENGTH } from "./constants";
// State
export { createInitialState, type BoopState } from "./state";
// Prompts
export { prompts } from "./prompts";
// Commands
export {
start,
place as placeCmd,
boop as boopCmd,
checkWin as checkWinCmd,
checkGraduates as checkGraduatesCmd,
checkFullBoard as checkFullBoardCmd,
turn as turnCmd,
} from "./commands";
// Utils
export {
getLineCandidates,
isInBounds,
isCellOccupied,
getNeighborPositions,
findPartInRegion,
findPartAtPosition,
} from "./utils";
+9
View File
@@ -0,0 +1,9 @@
import {createPromptDef} from "@/core/game";
import {PieceType, PlayerType} from "@/samples/boop/types";
export const prompts = {
play: createPromptDef<[PlayerType, number, number, PieceType?]>(
'play <player> <row:number> <col:number> [type:string]'),
choose: createPromptDef<[PlayerType, number, number]>(
'choose <player> <row:number> <col:number>')
}
+66
View File
@@ -0,0 +1,66 @@
# Boop
## Game Overview
**"boop."** is a deceptively cute, oh-so-snoozy strategy game. Players compete to place their cats on a quilted bed, pushing other pieces out of the way.
- **Players:** 2
- **Ages:** 10+
- **Play Time:** 15–20 minutes
## Components
- 1 Quilted Fabric Board (the "Bed") — 6×6 grid
- 8 White Kittens and 8 White Cats
- 8 Black Kittens and 8 Black Cats
## Objective
Be the first player to line up **three Cats** in a row (horizontally, vertically, or diagonally) on the 6×6 grid.
## Setup
- Each player takes their 8 Kittens into their personal supply.
- Cats are kept off to the side until a player "graduates" their Kittens.
- The board starts empty.
## How to Play
On your turn, perform the following steps:
### 1. Placing Pieces
Place one piece (Kitten or Cat) from your supply onto any empty space on the bed.
### 2. The "Boop" Mechanic
Placing a piece causes a **"boop."** Every piece (yours or your opponent's) in the 8 spaces immediately surrounding the piece you just played is pushed one space away from the placed piece.
- **Chain Reactions:** A "booped" piece does **not** cause another boop. Only the piece being *placed* triggers boops.
- **Obstructions:** If there is a piece behind the piece being booped (i.e., the space it would be pushed into is occupied), the boop does not happen — both pieces stay put.
- **Falling off the Bed:** If a piece is booped off the edge of the 6×6 grid, it is returned to its owner's supply.
### 3. Kittens vs. Cats (The Hierarchy)
- **Kittens** can boop other Kittens.
- **Kittens** **cannot** boop Cats.
- **Cats** can boop both Kittens and other Cats.
## Graduation (Getting Cats)
To win, you need Cats. You obtain Cats by lining up Kittens:
1. **Three in a Row:** If you line up three of your Kittens in a row (horizontally, vertically, or diagonally), they "graduate."
2. **The Process:** Remove the three Kittens from the board and return them to the box. Add three **Cats** to your personal supply.
3. **Multiple Rows:** If placing a piece creates multiple rows of three, you graduate all pieces involved in those rows.
4. **The 8-Piece Rule:** If a player has all 8 of their pieces on the board (a mix of Kittens and Cats) and no one has three-in-a-row, the player must choose one of their Kittens on the board to graduate into a Cat to free up a piece.
## How to Win
A player wins immediately when they get **three Cats in a row** on the bed (horizontally, vertically, or diagonally).
> **Note:** If you line up three Cats during a Kitten graduation move (e.g., three Cats are moved into a row because of a Kitten being placed), you also win.
## Strategy Tips
Because every move pushes other pieces away, players must think several steps ahead to "trap" their own pieces into a row while knocking their opponent's pieces off the board or out of alignment.
+45
View File
@@ -0,0 +1,45 @@
import getParts from './boopPart.csv';
import {createRegion, moveToRegion, Region} from "@/core/region";
import {createPartsFromTable} from "@/core/part-factory";
import {BoopPart} from "@/samples/boop/types";
import {BOARD_SIZE} from "@/samples/boop/constants";
import {PlayerType, PieceType, RegionType} from "@/samples/boop/types";
export function createInitialState() {
const parts = getParts();
const pieces = createPartsFromTable(
parts,
(item, index) => `${item.player}-${item.type}-${index + 1}`,
(item) => item.count
) as Record<string, BoopPart>;
// Initialize region childIds
const whiteRegion = createRegion('white', []);
const blackRegion = createRegion('black', []);
const boardRegion = createRegion('board', [
{ name: 'x', min: 0, max: BOARD_SIZE - 1 },
{ name: 'y', min: 0, max: BOARD_SIZE - 1 },
]);
// Populate region childIds based on piece regionId
for (const part of Object.values(pieces)) {
if(part.type !== 'kitten') continue;
if (part.player === 'white' ) {
moveToRegion(part, null, whiteRegion);
} else if (part.player === 'black') {
moveToRegion(part, null, blackRegion);
}
}
return {
regions: {
white: whiteRegion,
black: blackRegion,
board: boardRegion,
} as Record<RegionType, Region>,
pieces,
currentPlayer: 'white' as PlayerType,
winner: null as PlayerType | 'draw' | null,
};
}
export type BoopState = ReturnType<typeof createInitialState>;
+4
View File
@@ -0,0 +1,4 @@
import {IGameContext} from "@/core/game";
import {BoopState} from "@/samples/boop/state";
export type BoopGame = IGameContext<BoopState>;
+9
View File
@@ -0,0 +1,9 @@
import {Part} from "@/core/part";
export type PlayerType = 'white' | 'black';
export type PieceType = 'kitten' | 'cat';
export type WinnerType = PlayerType | 'draw' | null;
export type RegionType = 'white' | 'black' | 'board' | '';
export type BoopPartMeta = { player: PlayerType; type: PieceType };
export type BoopPart = Part<BoopPartMeta>;
+62
View File
@@ -0,0 +1,62 @@
import {BOARD_SIZE, WIN_LENGTH} from "@/samples/boop/constants";
import {BoopState} from "@/samples/boop/state";
import {BoopGame} from "@/samples/boop/types-extensions";
const DIRS = [
[0, 1],
[1, 0],
[1, 1],
[-1, 1]
]
type PT = [number, number];
type Line = PT[];
/**
* 生成所有可能的连线候选(用于检查胜利或升级)
*/
export function* getLineCandidates(){
for(const [dx, dy] of DIRS){
for(let x = 0; x < BOARD_SIZE; x ++)
for(let y = 0; y < BOARD_SIZE; y ++){
if(!isInBounds(x + dx * (WIN_LENGTH-1), y + dy * (WIN_LENGTH-1))) continue;
const line = [];
for(let i = 0; i < WIN_LENGTH; i ++){
line.push([x + i * dx, y + i * dy]);
}
yield line as Line;
}
}
}
/**
* 检查位置是否在棋盘范围内
*/
export function isInBounds(x: number, y: number): boolean {
return x >= 0 && x < BOARD_SIZE && y >= 0 && y < BOARD_SIZE;
}
/**
* 检查单元格是否被占用
*/
export function isCellOccupied(game: BoopGame | BoopState, x: number, y: number): boolean {
const state = getState(game);
const id = `${x},${y}`;
return state.regions.board.partMap[id] !== undefined;
}
/**
* 获取邻居位置生成器
*/
export function* getNeighborPositions(x: number = 0, y: number = 0){
for(let dx = -1; dx <= 1; dx ++)
for(let dy = -1; dy <= 1; dy ++)
if(dx !== 0 || dy !== 0)
yield [x + dx, y + dy] as PT;
}
function getState(ctx: BoopGame | BoopState): BoopState {
if('value' in ctx){
return ctx.value;
}
return ctx;
}
+11
View File
@@ -0,0 +1,11 @@
export {
getLineCandidates,
isInBounds,
isCellOccupied,
getNeighborPositions
} from './board';
export {
findPartInRegion,
findPartAtPosition
} from './pieces';
+40
View File
@@ -0,0 +1,40 @@
import {BoopPart, PieceType, PlayerType, RegionType} from "@/samples/boop/types";
import {BoopState} from "@/samples/boop/state";
import {BoopGame} from "@/samples/boop/types-extensions";
/**
* 在区域中查找棋子
*/
export function findPartInRegion(
ctx: BoopGame | BoopState,
regionId: keyof BoopGame['value']['regions'],
type: PieceType,
player?: PlayerType
): BoopPart | null {
const state = getState(ctx);
if(!regionId){
return Object.values(state.pieces).find(part => match(regionId, part, type, player)) || null;
}
const id = state.regions[regionId].childIds.find(id => match(regionId, state.pieces[id], type, player));
return id ? state.pieces[id] || null : null;
}
/**
* 在指定位置查找棋子
*/
export function findPartAtPosition(ctx: BoopGame | BoopState, row: number, col: number): BoopPart | null {
const state = getState(ctx);
const id = state.regions.board.partMap[`${row},${col}`];
return id ? state.pieces[id] || null : null;
}
function match(regionId: RegionType, part: BoopPart, type: PieceType, player?: PlayerType){
return regionId === part.regionId && part.type === type && (!player || part.player === player);
}
function getState(ctx: BoopGame | BoopState): BoopState {
if('value' in ctx){
return ctx.value;
}
return ctx;
}
+104
View File
@@ -0,0 +1,104 @@
# pattern forms a grid of 5x5
# o is the move's starting postion
# x are move end position candidates
# . is space
# first row, first position is dx=-2 dy=2
name,startingPlayer,row,pattern
string,string,number,string
tiger,black,0,..x..
tiger,black,1,.....
tiger,black,2,..o..
tiger,black,3,..x..
tiger,black,4,.....
dragon,red,0,.....
dragon,red,1,x...x
dragon,red,2,..o..
dragon,red,3,.x.x.
dragon,red,4,.....
frog,black,0,.....
frog,black,1,.x...
frog,black,2,x.o..
frog,black,3,...x.
frog,black,4,.....
rabbit,black,0,.....
rabbit,black,1,...x.
rabbit,black,2,..o.x
rabbit,black,3,.x...
rabbit,black,4,.....
crab,black,0,.....
crab,black,1,..x..
crab,black,2,x.o.x
crab,black,3,.....
crab,black,4,.....
elephant,red,0,.....
elephant,red,1,.x.x.
elephant,red,2,.xox.
elephant,red,3,.....
elephant,red,4,.....
goose,black,0,.....
goose,black,1,.x...
goose,black,2,.xox.
goose,black,3,...x.
goose,black,4,.....
rooster,red,0,.....
rooster,red,1,...x.
rooster,red,2,.xox.
rooster,red,3,.x...
rooster,red,4,.....
monkey,black,0,.....
monkey,black,1,.x.x.
monkey,black,2,..o..
monkey,black,3,.x.x.
monkey,black,4,.....
mantis,red,0,.....
mantis,red,1,.x.x.
mantis,red,2,..o..
mantis,red,3,..x..
mantis,red,4,.....
horse,red,0,.....
horse,red,1,..x..
horse,red,2,.xo..
horse,red,3,..x..
horse,red,4,.....
ox,black,0,.....
ox,black,1,..x..
ox,black,2,..ox.
ox,black,3,..x..
ox,black,4,.....
crane,black,0,.....
crane,black,1,..x..
crane,black,2,..o..
crane,black,3,.x.x.
crane,black,4,.....
boar,red,0,.....
boar,red,1,..x..
boar,red,2,.xox.
boar,red,3,.....
boar,red,4,.....
eel,black,0,.....
eel,black,1,.x...
eel,black,2,..ox.
eel,black,3,.x...
eel,black,4,.....
cobra,red,0,.....
cobra,red,1,...x.
cobra,red,2,.xo..
cobra,red,3,...x.
cobra,red,4,.....
1 # pattern forms a grid of 5x5
2 # o is the move's starting postion
3 # x are move end position candidates
4 # . is space
5 # first row, first position is dx=-2 dy=2
6 name,startingPlayer,row,pattern
7 string,string,number,string
8 tiger,black,0,..x..
9 tiger,black,1,.....
10 tiger,black,2,..o..
11 tiger,black,3,..x..
12 tiger,black,4,.....
13 dragon,red,0,.....
14 dragon,red,1,x...x
15 dragon,red,2,..o..
16 dragon,red,3,.x.x.
17 dragon,red,4,.....
18 frog,black,0,.....
19 frog,black,1,.x...
20 frog,black,2,x.o..
21 frog,black,3,...x.
22 frog,black,4,.....
23 rabbit,black,0,.....
24 rabbit,black,1,...x.
25 rabbit,black,2,..o.x
26 rabbit,black,3,.x...
27 rabbit,black,4,.....
28 crab,black,0,.....
29 crab,black,1,..x..
30 crab,black,2,x.o.x
31 crab,black,3,.....
32 crab,black,4,.....
33 elephant,red,0,.....
34 elephant,red,1,.x.x.
35 elephant,red,2,.xox.
36 elephant,red,3,.....
37 elephant,red,4,.....
38 goose,black,0,.....
39 goose,black,1,.x...
40 goose,black,2,.xox.
41 goose,black,3,...x.
42 goose,black,4,.....
43 rooster,red,0,.....
44 rooster,red,1,...x.
45 rooster,red,2,.xox.
46 rooster,red,3,.x...
47 rooster,red,4,.....
48 monkey,black,0,.....
49 monkey,black,1,.x.x.
50 monkey,black,2,..o..
51 monkey,black,3,.x.x.
52 monkey,black,4,.....
53 mantis,red,0,.....
54 mantis,red,1,.x.x.
55 mantis,red,2,..o..
56 mantis,red,3,..x..
57 mantis,red,4,.....
58 horse,red,0,.....
59 horse,red,1,..x..
60 horse,red,2,.xo..
61 horse,red,3,..x..
62 horse,red,4,.....
63 ox,black,0,.....
64 ox,black,1,..x..
65 ox,black,2,..ox.
66 ox,black,3,..x..
67 ox,black,4,.....
68 crane,black,0,.....
69 crane,black,1,..x..
70 crane,black,2,..o..
71 crane,black,3,.x.x.
72 crane,black,4,.....
73 boar,red,0,.....
74 boar,red,1,..x..
75 boar,red,2,.xox.
76 boar,red,3,.....
77 boar,red,4,.....
78 eel,black,0,.....
79 eel,black,1,.x...
80 eel,black,2,..ox.
81 eel,black,3,.x...
82 eel,black,4,.....
83 cobra,red,0,.....
84 cobra,red,1,...x.
85 cobra,red,2,.xo..
86 cobra,red,3,...x.
87 cobra,red,4,.....
+11
View File
@@ -0,0 +1,11 @@
type CardsTable = readonly {
readonly name: string;
readonly startingPlayer: string;
readonly row: number;
readonly pattern: string;
}[];
export type Cards = CardsTable[number];
declare function getData(): CardsTable;
export default getData;
+409
View File
@@ -0,0 +1,409 @@
import {
OnitamaGame,
OnitamaState,
PlayerType,
prompts,
initializeCards,
} from "./types";
import { moveToRegion } from "@/core/region";
/**
* 检查位置是否在棋盘范围内
*/
function isInBounds(x: number, y: number): boolean {
return x >= 0 && x < 5 && y >= 0 && y < 5;
}
/**
* 检查位置是否有棋子
*/
function getPawnAtPosition(state: OnitamaState, x: number, y: number) {
const key = `${x},${y}`;
const pawnId = state.regions.board.partMap[key];
return pawnId ? state.pawns[pawnId] : null;
}
/**
* 检查玩家是否拥有某张卡牌
*/
function playerHasCard(
state: OnitamaState,
player: PlayerType,
cardName: string,
): boolean {
const cardList = player === "red" ? state.redCards : state.blackCards;
return cardList.includes(cardName);
}
/**
* 获取卡牌的移动候选项,根据玩家视角进行旋转
* 黑方需要将卡牌旋转180度
*/
export function getCardMoveCandidates(
state: OnitamaState,
cardName: string,
player: PlayerType,
) {
const card = state.cards[cardName];
const candidates = card.moveCandidates;
// 黑方需要将卡牌旋转180度
if (player === "black") {
return candidates.map((m) => ({ dx: -m.dx, dy: -m.dy }));
}
return candidates;
}
/**
* 检查移动是否合法
*/
export function isValidMove(
state: OnitamaState,
cardName: string,
fromX: number,
fromY: number,
toX: number,
toY: number,
player: PlayerType,
): string | null {
// 检查玩家是否拥有该卡牌
if (!playerHasCard(state, player, cardName)) {
return `玩家 ${player} 不拥有卡牌 ${cardName}`;
}
// 检查起始位置是否有玩家的棋子
const fromPawn = getPawnAtPosition(state, fromX, fromY);
if (!fromPawn) {
return `位置 (${fromX}, ${fromY}) 没有棋子`;
}
if (fromPawn.owner !== player) {
return `位置 (${fromX}, ${fromY}) 的棋子不属于玩家 ${player}`;
}
// 检查卡牌是否存在
const card = state.cards[cardName];
if (!card) {
return `卡牌 ${cardName} 不存在`;
}
// 计算移动偏移量
const dx = toX - fromX;
const dy = toY - fromY;
// 检查移动是否在卡牌的移动候选项中(黑方需要旋转180度)
const candidates = getCardMoveCandidates(state, cardName, player);
const isValid = candidates.some((m) => m.dx === dx && m.dy === dy);
if (!isValid) {
return `卡牌 ${cardName} 不支持移动 (${dx}, ${dy})`;
}
// 检查目标位置是否在棋盘内
if (!isInBounds(toX, toY)) {
return `目标位置 (${toX}, ${toY}) 超出棋盘范围`;
}
// 检查目标位置是否有己方棋子
const toPawn = getPawnAtPosition(state, toX, toY);
if (toPawn && toPawn.owner === player) {
return `目标位置 (${toX}, ${toY}) 已有己方棋子`;
}
return null;
}
/**
* 执行移动
*/
async function move(
game: OnitamaGame,
player: PlayerType,
cardName: string,
fromX: number,
fromY: number,
toX: number,
toY: number,
) {
const state = game.value;
// 验证移动
const error = isValidMove(state, cardName, fromX, fromY, toX, toY, player);
if (error) {
throw new Error(error);
}
const capturedPawnId = getPawnAtPosition(state, toX, toY)?.id || null;
await game.produceAsync((state) => {
const pawn = state.pawns[getPawnAtPosition(state, fromX, fromY)!.id];
// 如果目标位置有敌方棋子,将其移除(吃掉)
if (capturedPawnId) {
const capturedPawn = state.pawns[capturedPawnId];
moveToRegion(capturedPawn, state.regions.board, null);
}
// 移动棋子到目标位置
moveToRegion(pawn, state.regions.board, state.regions.board, [toX, toY]);
});
// 交换卡牌
await swapCard(game, player, cardName);
return {
from: { x: fromX, y: fromY },
to: { x: toX, y: toY },
card: cardName,
captured: capturedPawnId,
};
}
/**
* 交换卡牌:将使用的卡牌与备用卡牌交换
*/
async function swapCard(
game: OnitamaGame,
player: PlayerType,
usedCard: string,
) {
await game.produceAsync((state) => {
const spareCard = state.spareCard;
const usedCardData = state.cards[usedCard];
const spareCardData = state.cards[spareCard];
// 从玩家手牌中移除使用的卡牌
if (player === "red") {
state.redCards = state.redCards.filter((c) => c !== usedCard);
state.redCards.push(spareCard);
} else {
state.blackCards = state.blackCards.filter((c) => c !== usedCard);
state.blackCards.push(spareCard);
}
// 更新卡牌区域
usedCardData.regionId = "spare";
spareCardData.regionId = player;
// 更新备用卡牌
state.spareCard = usedCard;
});
}
/**
* 检查占领胜利条件:玩家的师父棋子到达对手的初始位置
* 红色师父需要到达 (2, 4) - 黑色师父的初始位置
* 黑色师父需要到达 (2, 0) - 红色师父的初始位置
*/
async function checkConquestWin(game: OnitamaGame): Promise<PlayerType | null> {
const state = game.value;
// 红色师父到达 (2, 4)(黑色师父的初始位置)
const redMaster = state.pawns["red-master"];
if (
redMaster &&
redMaster.regionId === "board" &&
redMaster.position[0] === 2 &&
redMaster.position[1] === 4
) {
return "red";
}
// 黑色师父到达 (2, 0)(红色师父的初始位置)
const blackMaster = state.pawns["black-master"];
if (
blackMaster &&
blackMaster.regionId === "board" &&
blackMaster.position[0] === 2 &&
blackMaster.position[1] === 0
) {
return "black";
}
return null;
}
/**
* 检查吃掉胜利条件:对手的师父棋子被吃掉(不在棋盘上)
*/
async function checkCaptureWin(game: OnitamaGame): Promise<PlayerType | null> {
const state = game.value;
// 红色师父不在棋盘上,黑色获胜
const redMaster = state.pawns["red-master"];
if (!redMaster || redMaster.regionId !== "board") {
return "black";
}
// 黑色师父不在棋盘上,红色获胜
const blackMaster = state.pawns["black-master"];
if (!blackMaster || blackMaster.regionId !== "board") {
return "red";
}
return null;
}
/**
* 综合胜利检测
*/
async function checkWin(game: OnitamaGame): Promise<PlayerType | null> {
const conquestWinner = await checkConquestWin(game);
if (conquestWinner) {
return conquestWinner;
}
const captureWinner = await checkCaptureWin(game);
if (captureWinner) {
return captureWinner;
}
return null;
}
/**
* 获取玩家可用的移动
*/
export function getAvailableMoves(
state: OnitamaState,
player: PlayerType,
): Array<{
card: string;
fromX: number;
fromY: number;
toX: number;
toY: number;
}> {
const moves: Array<{
card: string;
fromX: number;
fromY: number;
toX: number;
toY: number;
}> = [];
// 获取玩家的所有卡牌
const cardNames = player === "red" ? state.redCards : state.blackCards;
// 获取玩家的所有棋子
const playerPawns = Object.values(state.pawns).filter(
(p) => p.owner === player && p.regionId === "board",
);
// 对于每张卡牌
for (const cardName of cardNames) {
// 获取旋转后的移动候选项(黑方需要旋转180度)
const candidates = getCardMoveCandidates(state, cardName, player);
// 对于每个棋子
for (const pawn of playerPawns) {
const [fromX, fromY] = pawn.position;
// 对于卡牌的每个移动
for (const move of candidates) {
const toX = fromX + move.dx;
const toY = fromY + move.dy;
// 检查移动是否合法
if (isInBounds(toX, toY)) {
const targetPawn = getPawnAtPosition(state, toX, toY);
// 目标位置为空或有敌方棋子
if (!targetPawn || targetPawn.owner !== player) {
moves.push({ card: cardName, fromX, fromY, toX, toY });
}
}
}
}
}
return moves;
}
/**
* 处理回合
*/
async function turn(game: OnitamaGame, turnPlayer: PlayerType) {
const state = game.value;
const availableMoves = getAvailableMoves(state, turnPlayer);
let moveOutput;
if (availableMoves.length === 0) {
// 没有可用移动,玩家必须交换一张卡牌
const cardToSwap = await game.prompt(
prompts.move,
(player, card, _fromX, _fromY, _toX, _toY) => {
if (player !== turnPlayer) {
throw `Invalid player: ${player}. Expected ${turnPlayer}.`;
}
if (!playerHasCard(state, player, card)) {
throw `Player ${player} does not have card ${card}.`;
}
return card;
},
turnPlayer,
);
await swapCard(game, turnPlayer, cardToSwap);
moveOutput = { swappedCard: cardToSwap, noMoves: true };
} else {
// 有可用移动,提示玩家选择
moveOutput = await game.prompt(
prompts.move,
(player, card, fromX, fromY, toX, toY) => {
if (player !== turnPlayer) {
throw `Invalid player: ${player}. Expected ${turnPlayer}.`;
}
const error = isValidMove(state, card, fromX, fromY, toX, toY, player);
if (error) {
throw error;
}
return { player, card, fromX, fromY, toX, toY };
},
turnPlayer,
);
await move(
game,
moveOutput.player,
moveOutput.card,
moveOutput.fromX,
moveOutput.fromY,
moveOutput.toX,
moveOutput.toY,
);
}
// 检查胜利
const winner = await checkWin(game);
await game.produceAsync((state) => {
state.winner = winner;
if (!winner) {
state.currentPlayer = state.currentPlayer === "red" ? "black" : "red";
state.turn++;
}
});
return { winner, move: moveOutput };
}
/**
* 开始游戏主循环
*/
export async function start(game: OnitamaGame) {
// Initialize cards with RNG at game start
initializeCards(game);
while (true) {
const currentPlayer = game.value.currentPlayer;
const turnOutput = await turn(game, currentPlayer);
if (turnOutput.winner) {
break;
}
}
return game.value;
}
+2
View File
@@ -0,0 +1,2 @@
export * from './types';
export * from './commands';
+47
View File
@@ -0,0 +1,47 @@
# Onitama
## 游戏目标
玩家在棋盘上移动棋子,达成两种胜利条件当中的一种即可赢得游戏:
- 占领:将师父棋子移动到对手师父棋子的初始位置
- 吃掉:将对手的师父棋子吃掉
## 游戏配件
公共配件:
- 卡牌:16张,每张标记一位起始玩家,和一个移动方式图示
- 棋盘:1个,5x5
玩家配件:
- 师父棋子:1个
- 徒弟棋子:4个
## 游戏布置
1. 将所有玩家的棋子放在棋盘的初始位置上:
```
bbBbb
.....
.....
.....
rrRrr
```
其中b表示黑方,r表示红方,大写表示师父,小写表示徒弟。
2. 将所有卡牌洗混,每名玩家抓2张放在自己面前。
3. 翻开一张卡牌作为额外卡牌。额外卡牌标记的玩家成为起始玩家。
## 游戏进行
从起始玩家开始,轮流进行以下步骤:
- 选择一张卡牌,按照其描述移动棋子。
- 将选择的卡牌与额外卡牌交换。
移动时,可以选择自己的任意棋子。
移动落点必须是空位或者对手的棋子。
若移动到对手的棋子上,则吃掉该棋子。
若玩家没有任何能够进行的移动,可以任意选择一张卡牌与额外卡牌交换,然后直接结束回合。
之后若有玩家达成胜利条件,游戏结束。
+200
View File
@@ -0,0 +1,200 @@
import getCards from './cards.csv';
import {Part} from "@/core/part";
import {createRegion, createRegionAxis, Region} from "@/core/region";
import {createPromptDef, IGameContext} from "@/core/game";
export type PlayerType = 'red' | 'black';
export type CardData = {
moveCandidates: {
dx: number,
dy: number,
}[];
startingPlayer: PlayerType
}
export type Card = Part<CardData>;
export type Pawn = Part<{
type: 'master' | 'student',
owner: PlayerType,
}>;
export function createRegions(){
return {
board: createRegion('board', [
createRegionAxis('x', 0, 4),
createRegionAxis('y', 0, 4),
]),
red: createRegion('red', []),
black: createRegion('black', []),
spare: createRegion('spare', []),
}
}
export type RegionType = keyof ReturnType<typeof createRegions>;
export function createGameInfo(){
return {
turn: 0,
currentPlayer: 'red' as PlayerType,
}
}
export function createCards(){
const cards = getCards();
const dataMap = {} as Record<string, CardData>;
for(const entry of cards){
const card = dataMap[entry.name] = dataMap[entry.name] || {
moveCandidates: [],
startingPlayer: entry.startingPlayer,
}
for(let i = 0; i < entry.pattern.length; i++){
if(entry.pattern[i] !== 'x') continue;
const dx = i - 2;
const dy = 2 - entry.row;
card.moveCandidates.push({dx, dy});
}
}
const cardsMap = {} as Record<string, Card>;
for(const [name, data] of Object.entries(dataMap)){
cardsMap[name] = {
id: name,
regionId: '',
position: [],
...data
}
}
return cardsMap;
}
export function createPawns(){
const pawns: Record<string, Pawn> = {};
// Red player: 1 master (R) at y=0, 4 students (r) at y=0
// Position: rrRrr at y=0
pawns['red-master'] = {
id: 'red-master',
regionId: 'board',
position: [2, 0],
type: 'master',
owner: 'red',
};
for(let i = 0; i < 4; i++){
const x = i < 2 ? i : i + 1; // Skip position 2 (master)
pawns[`red-student-${i+1}`] = {
id: `red-student-${i+1}`,
regionId: 'board',
position: [x, 0],
type: 'student',
owner: 'red',
};
}
// Black player: 1 master (B) at y=4, 4 students (b) at y=4
// Position: bbBbb at y=4
pawns['black-master'] = {
id: 'black-master',
regionId: 'board',
position: [2, 4],
type: 'master',
owner: 'black',
};
for(let i = 0; i < 4; i++){
const x = i < 2 ? i : i + 1; // Skip position 2 (master)
pawns[`black-student-${i+1}`] = {
id: `black-student-${i+1}`,
regionId: 'board',
position: [x, 4],
type: 'student',
owner: 'black',
};
}
return pawns;
}
export const prompts = {
move: createPromptDef<[PlayerType, string, number, number, number, number]>(
'move <player> <card> <fromX:number> <fromY:number> <toX:number> <toY:number>',
'选择卡牌并移动棋子'
),
};
export function createInitialState() {
const regions = createRegions();
const pawns = createPawns();
const cards = createCards();
// Cards will be shuffled and distributed in start()
// Initialize with empty card lists
const redCards: string[] = [];
const blackCards: string[] = [];
const spareCard = '';
// Populate board region childIds
for(const pawn of Object.values(pawns)){
if(pawn.regionId === 'board'){
regions.board.childIds.push(pawn.id);
regions.board.partMap[pawn.position.join(',')] = pawn.id;
}
}
return {
regions,
pawns,
cards,
currentPlayer: 'red' as PlayerType,
winner: null as PlayerType | null,
spareCard,
redCards,
blackCards,
turn: 0,
};
}
/**
* 洗牌并分配卡牌,设置起始玩家
*/
export function initializeCards(game: OnitamaGame) {
const state = game.value;
const rng = game.rng;
const cardNames = Object.keys(state.cards);
// Fisher-Yates shuffle using game.rng
const shuffled = [...cardNames];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = rng.nextInt(i + 1);
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
const redCards = shuffled.slice(0, 2);
const blackCards = shuffled.slice(2, 4);
const spareCard = shuffled[4];
game.produce(state => {
// Set card regions
for(const cardName of redCards){
state.cards[cardName].regionId = 'red';
state.regions.red.childIds.push(cardName);
}
for(const cardName of blackCards){
state.cards[cardName].regionId = 'black';
state.regions.black.childIds.push(cardName);
}
state.cards[spareCard].regionId = 'spare';
state.regions.spare.childIds.push(spareCard);
// Set card lists
state.redCards = redCards;
state.blackCards = blackCards;
state.spareCard = spareCard;
// Determine starting player from spare card
state.currentPlayer = state.cards[spareCard].startingPlayer;
});
}
export type OnitamaState = ReturnType<typeof createInitialState>;
export type OnitamaGame = IGameContext<OnitamaState>;
+417
View File
@@ -0,0 +1,417 @@
import { IGameContext } from "@/core/game";
import { RegicideState } from "@/samples/regicide/state";
import { PlayerType, RegicideCard } from "@/samples/regicide/types";
import { CARD_VALUES, FACE_CARDS } from "@/samples/regicide/constants";
import { isEnemyDefeated } from "@/samples/regicide/utils";
export type RegicideGame = IGameContext<RegicideState>;
/**
* 打出一张牌(对当前敌人造成伤害)
*/
async function playCmd(game: RegicideGame, player: string, cardId: string) {
const state = game.value;
const card = state.cards[cardId];
if (!card) {
return { success: false, error: `卡牌 ${cardId} 不存在` };
}
// 检查卡牌是否在玩家手牌中
const playerKey = player as PlayerType;
const playerHand = state.playerHands[playerKey];
if (!playerHand || !playerHand.includes(cardId)) {
return {
success: false,
error: `卡牌 ${cardId} 不在玩家 ${player} 的手牌中`,
};
}
// 检查是否有当前敌人
if (!state.currentEnemy) {
return { success: false, error: "没有活跃的敌人" };
}
// 计算伤害(基础伤害为卡牌面值)
let damage = card.value;
let attackReduction = 0;
// 梅花双倍伤害
if (card.suit === "clubs") {
damage *= 2;
}
// 黑桃降低敌人攻击力
if (card.suit === "spades") {
attackReduction = card.value;
}
const enemyHpBefore = state.currentEnemy.hp;
await game.produceAsync((state) => {
// 对敌人造成伤害
state.currentEnemy!.hp -= damage;
// 记录黑桃的攻击力降低
if (attackReduction > 0) {
state.currentEnemy!.value = Math.max(
0,
state.currentEnemy!.value - attackReduction,
);
}
// 从手牌移除卡牌
const hand = state.playerHands[playerKey];
const cardIndex = hand.indexOf(cardId);
if (cardIndex !== -1) {
hand.splice(cardIndex, 1);
}
// 将卡牌移到弃牌堆
state.cards[cardId].regionId = "discardPile";
// 红心能力:将弃牌堆洗回酒馆牌堆
if (card.suit === "hearts") {
const discardIds = state.regions.discardPile.childIds.filter(
(id) => id !== state.currentEnemy!.id,
);
if (discardIds.length > 0) {
// 将弃牌堆(除当前敌人外)移回酒馆牌堆
for (const discardId of discardIds) {
state.cards[discardId].regionId = "tavernDeck";
}
state.regions.tavernDeck.childIds.push(...discardIds);
state.regions.discardPile.childIds = [state.currentEnemy!.id];
}
}
// 方块能力:从酒馆牌堆抓牌
if (card.suit === "diamonds") {
const tavernDeckCount = state.regions.tavernDeck.childIds.length;
if (tavernDeckCount > 0) {
const drawCardId = state.regions.tavernDeck.childIds.shift()!;
state.cards[drawCardId].regionId = `hand_${player}`;
hand.push(drawCardId);
}
}
});
// 检查敌人是否被击败
const enemyDefeated = isEnemyDefeated(game.value.currentEnemy);
return {
success: true,
result: {
damage,
attackReduction,
enemyHpBefore,
enemyHpAfter: game.value.currentEnemy!.hp,
enemyDefeated,
suitAbility: card.suit,
},
};
}
/**
* 打出A配合另一张牌
*/
async function playWithACmd(
game: RegicideGame,
player: string,
aceCardId: string,
otherCardId: string,
) {
const state = game.value;
const aceCard = state.cards[aceCardId];
const otherCard = state.cards[otherCardId];
if (!aceCard || !otherCard) {
return { success: false, error: "卡牌不存在" };
}
// 检查是否是A牌
if (aceCard.rank !== "A") {
return { success: false, error: `第一张牌必须是A` };
}
const playerKey = player as PlayerType;
const playerHand = state.playerHands[playerKey];
// 检查两张牌都在手牌中
if (!playerHand.includes(aceCardId) || !playerHand.includes(otherCardId)) {
return { success: false, error: "卡牌不在手牌中" };
}
if (!state.currentEnemy) {
return { success: false, error: "没有活跃的敌人" };
}
// 计算两张牌的总伤害
let totalDamage = aceCard.value + otherCard.value;
// 如果另一张牌是梅花,双倍伤害
if (otherCard.suit === "clubs") {
totalDamage *= 2;
}
let attackReduction = 0;
if (aceCard.suit === "spades") {
attackReduction += aceCard.value;
}
if (otherCard.suit === "spades") {
attackReduction += otherCard.value;
}
await game.produceAsync((state) => {
// 对敌人造成伤害
state.currentEnemy!.hp -= totalDamage;
// 记录黑桃的攻击力降低
if (attackReduction > 0) {
state.currentEnemy!.value = Math.max(
0,
state.currentEnemy!.value - attackReduction,
);
}
// 从手牌移除两张牌
const hand = state.playerHands[playerKey];
const aceIndex = hand.indexOf(aceCardId);
const otherIndex = hand.indexOf(otherCardId);
if (aceIndex !== -1) hand.splice(aceIndex, 1);
if (otherIndex !== -1) hand.splice(otherIndex, 1);
// 将卡牌移到弃牌堆
state.cards[aceCardId].regionId = "discardPile";
state.cards[otherCardId].regionId = "discardPile";
});
const enemyDefeated = isEnemyDefeated(state.currentEnemy);
return {
success: true,
result: {
damage: totalDamage,
attackReduction,
enemyHp: state.currentEnemy!.hp,
enemyDefeated,
},
};
}
/**
* 让过(不出牌)
*/
async function passCmd(game: RegicideGame, player: string) {
// 即使让过,也会受到敌人反击(在回合结束时处理)
return { success: true, result: { message: `${player} 让过` } };
}
/**
* 敌人反击 - 玩家必须弃掉点数和 >= 敌人攻击力的牌
*/
async function enemyCounterattackCmd(
game: RegicideGame,
player: string,
discardCards: string[],
) {
const state = game.value;
if (!state.currentEnemy) {
return { success: false, error: "没有活跃的敌人" };
}
const playerKey = player as PlayerType;
const playerHand = state.playerHands[playerKey];
// 检查要弃的牌都在手牌中
for (const cardId of discardCards) {
if (!playerHand.includes(cardId)) {
return { success: false, error: `卡牌 ${cardId} 不在手牌中` };
}
}
// 计算弃牌的点数和
let totalValue = 0;
for (const cardId of discardCards) {
const card = state.cards[cardId];
if (card) {
totalValue += card.value;
}
}
const enemyAttack = state.currentEnemy.value;
// 检查点数和是否 >= 敌人攻击力
if (totalValue < enemyAttack) {
return {
success: false,
error: `弃牌点数和 (${totalValue}) 小于敌人攻击力 (${enemyAttack}),游戏失败`,
};
}
// 执行弃牌
await game.produceAsync((state) => {
const hand = state.playerHands[playerKey];
for (const cardId of discardCards) {
const index = hand.indexOf(cardId);
if (index !== -1) {
hand.splice(index, 1);
}
state.cards[cardId].regionId = "discardPile";
}
});
return {
success: true,
result: {
discardedCards: discardCards,
totalValue,
enemyAttack,
},
};
}
/**
* 检查敌人是否被击败,如果击败则翻开下一个敌人
*/
async function checkEnemyDefeatedCmd(game: RegicideGame) {
const state = game.value;
if (!state.currentEnemy) {
return { success: false as const, error: "没有活跃的敌人" };
}
const defeated = state.currentEnemy.hp <= 0;
if (defeated) {
const defeatedEnemy = { ...state.currentEnemy };
await game.produceAsync((state) => {
// 将当前敌人移到弃牌堆
state.regions.discardPile.childIds.push(state.currentEnemy!.id);
// 翻开下一个敌人
if (state.enemyDeck.length > 0) {
const nextEnemy = state.enemyDeck.shift()!;
state.currentEnemy = nextEnemy;
} else {
// 没有更多敌人了
state.currentEnemy = null;
}
});
// 检查是否胜利(没有更多敌人)
if (!game.value.currentEnemy) {
await game.produceAsync((state) => {
state.phase = "victory";
state.winner = true;
});
}
return {
success: true as const,
result: {
defeated: true,
defeatedEnemy,
nextEnemy: game.value.currentEnemy,
enemiesRemaining: game.value.enemyDeck.length,
},
};
}
return {
success: true,
result: {
defeated: false,
currentEnemy: { ...state.currentEnemy },
enemiesRemaining: state.enemyDeck.length,
},
};
}
/**
* 检查玩家是否有可出的牌
*/
async function checkCanPlayCmd(game: RegicideGame, player: string) {
const state = game.value;
const playerKey = player as PlayerType;
const playerHand = state.playerHands[playerKey];
const canPlay = playerHand.length > 0;
const canPlayWithA = playerHand.some((cardId) => {
const card = state.cards[cardId];
return card && card.rank === "A" && playerHand.length > 1;
});
return {
success: true,
result: {
canPlay,
canPlayWithA,
handSize: playerHand.length,
},
};
}
/**
* 检查酒馆牌堆是否为空
*/
async function checkTavernDeckCmd(game: RegicideGame) {
const state = game.value;
const isEmpty = state.regions.tavernDeck.childIds.length === 0;
// 如果酒馆牌堆为空且所有玩家手牌也为空,则游戏失败
if (isEmpty) {
const allHandsEmpty = Object.values(state.playerHands).every(
(hand) => hand.length === 0,
);
if (allHandsEmpty) {
await game.produceAsync((state) => {
state.phase = "defeat";
state.winner = false;
});
}
}
return {
success: true,
result: {
isEmpty,
cardsRemaining: state.regions.tavernDeck.childIds.length,
},
};
}
/**
* 下一个玩家回合
*/
async function nextTurnCmd(game: RegicideGame) {
const state = game.value;
await game.produce((state) => {
state.currentPlayerIndex =
(state.currentPlayerIndex + 1) % state.playerCount;
});
const players: PlayerType[] = ["player1", "player2", "player3", "player4"];
const currentPlayer = players[game.value.currentPlayerIndex];
return {
success: true,
result: {
currentPlayer,
currentPlayerIndex: game.value.currentPlayerIndex,
},
};
}
export {
playCmd as play,
playWithACmd as playWithA,
passCmd as pass,
enemyCounterattackCmd as enemyCounterattack,
checkEnemyDefeatedCmd as checkEnemy,
checkCanPlayCmd as checkCanPlay,
checkTavernDeckCmd as checkTavernDeck,
nextTurnCmd as nextTurn,
};
+36
View File
@@ -0,0 +1,36 @@
import {CardRank, SuitType} from "@/samples/regicide/types";
// 敌人总数
export const ENEMY_COUNT = 12;
// 初始手牌数
export const INITIAL_HAND_SIZE = 6;
// 卡牌面值映射
export const CARD_VALUES: Record<CardRank, number> = {
'A': 1,
'2': 2,
'3': 3,
'4': 4,
'5': 5,
'6': 6,
'7': 7,
'8': 8,
'9': 9,
'10': 10,
'J': 10,
'Q': 15,
'K': 20,
};
// 所有花色
export const ALL_SUITS: SuitType[] = ['spades', 'hearts', 'diamonds', 'clubs'];
// 所有牌面值(不含大小王)
export const ALL_RANKS: CardRank[] = ['A', '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K'];
// 人头牌(J/Q/K)
export const FACE_CARDS: CardRank[] = ['J', 'Q', 'K'];
// 数字牌(A-10)
export const NUMBER_CARDS: CardRank[] = ['A', '2', '3', '4', '5', '6', '7', '8', '9', '10'];
+273
View File
@@ -0,0 +1,273 @@
import { IGameContext } from "@/core/game";
import { RegicideState } from "@/samples/regicide/state";
import {
buildEnemyDeck,
buildTavernDeck,
createAllCards,
getPlayerHandRegionId,
} from "@/samples/regicide/utils";
import { INITIAL_HAND_SIZE } from "@/samples/regicide/constants";
import { Enemy, PlayerType, RegicideCard } from "@/samples/regicide/types";
import { checkEnemy, nextTurn, pass, playWithA } from "./commands";
import { enemyCounterattackCmd, playCmd } from ".";
export type RegicideGame = IGameContext<RegicideState>;
/**
* 初始化游戏设置
* @param game 游戏上下文
* @param playerCount 玩家数量(1-4)
* @param seed 随机种子(可选)
*/
export async function setupGame(
game: RegicideGame,
playerCount: number,
seed?: number,
) {
if (playerCount < 1 || playerCount > 4) {
throw new Error("玩家数量必须为 1-4 人");
}
if (seed) {
// RNG seeding handled by game context
}
// 创建所有卡牌
const allCards = createAllCards();
// 构建敌人牌堆(J/Q/K)
const enemyDeck = buildEnemyDeck(game._rng);
// 构建酒馆牌堆(A-10)
const tavernDeck = buildTavernDeck(game._rng);
// 初始化游戏状态
await game.produceAsync((state) => {
state.cards = allCards;
state.playerCount = playerCount;
state.currentPlayerIndex = 0;
state.enemyDeck = enemyDeck;
// 设置酒馆牌堆区域
for (const card of tavernDeck) {
card.regionId = "tavernDeck";
state.regions.tavernDeck.childIds.push(card.id);
}
// 设置敌人牌堆区域(只存储ID,敌人是独立对象)
state.regions.enemyDeck.childIds = enemyDeck.map((e) => e.id);
// 给每个玩家发牌
const players: PlayerType[] = ["player1", "player2", "player3", "player4"];
for (let i = 0; i < playerCount; i++) {
const player = players[i];
const regionId = getPlayerHandRegionId(player);
for (let j = 0; j < INITIAL_HAND_SIZE; j++) {
if (tavernDeck.length === 0) break;
const card = tavernDeck.shift()!;
card.regionId = regionId;
state.playerHands[player].push(card.id);
const region = state.regions[regionId as keyof typeof state.regions];
region.childIds.push(card.id);
}
}
// 翻开第一个敌人
if (enemyDeck.length > 0) {
const firstEnemy = enemyDeck.shift()!;
state.currentEnemy = firstEnemy;
}
});
}
/**
* 启动游戏主循环
*/
export async function start(game: RegicideGame) {
const state = game.value;
// 检查游戏是否已设置
if (!state.currentEnemy) {
throw new Error("请先调用 setupGame 初始化游戏");
}
const players: PlayerType[] = ["player1", "player2", "player3", "player4"];
// 主游戏循环
while (state.phase === "playing") {
const currentPlayerIndex = state.currentPlayerIndex;
const currentPlayer = players[currentPlayerIndex];
// 检查当前玩家是否有手牌
const currentHand = state.playerHands[currentPlayer];
if (currentHand.length === 0) {
// 玩家没有手牌,跳过回合
await game.produceAsync((state) => {
state.currentPlayerIndex =
(state.currentPlayerIndex + 1) % state.playerCount;
});
continue;
}
// 等待玩家输入(出牌或让过)
// 这里需要外部通过 prompt 系统获取输入
// 实际使用时由 UI 或测试代码提供输入
// 循环会在外部调用 play/pass 命令后继续
// 当 phase 变为 'victory' 或 'defeat' 时退出
break;
}
return game.value;
}
/**
* 处理完整的玩家回合
*/
export async function playTurn(
game: RegicideGame,
player: PlayerType,
action: "play" | "pass",
cardId?: string,
secondCardId?: string,
) {
const state = game.value;
if (state.phase !== "playing") {
return { success: false, error: "游戏已结束" };
}
if (!state.currentEnemy) {
return { success: false, error: "没有活跃的敌人" };
}
let playResult: any;
// 执行玩家动作
if (action === "play" && cardId) {
// 检查是否是A配合另一张牌
const card = state.cards[cardId];
if (card.rank === "A" && secondCardId) {
playResult = await playWithA(game, player, cardId, secondCardId);
} else {
playResult = await playCmd(game, player, cardId);
}
} else {
// 让过
playResult = await pass(game, player);
}
if (!playResult.success) {
return playResult;
}
// 检查敌人是否被击败
const checkResult = await checkEnemy(game);
if (!checkResult.success) {
return checkResult;
}
// 如果敌人未被击败,处理反击
if (!checkResult.result.defeated) {
// 反击逻辑需要玩家选择弃牌,这里返回状态让外部处理
return {
success: true,
result: {
playResult: playResult.result,
enemyDefeated: false,
needsDiscard: true,
enemyAttack: state.currentEnemy.value,
playerHand: state.playerHands[player],
},
};
}
// 敌人被击败,检查是否还有更多敌人
if (
state.enemyDeck.length === 0 &&
state.currentEnemy &&
state.currentEnemy.hp <= 0
) {
await game.produceAsync((state) => {
state.phase = "victory";
state.winner = true;
});
return {
success: true,
result: {
playResult: playResult.result,
enemyDefeated: true,
gameWon: true,
},
};
}
// 切换到下一个玩家
await nextTurn(game);
return {
success: true,
result: {
playResult: playResult.result,
enemyDefeated: true,
nextEnemy: state.currentEnemy,
},
};
}
/**
* 处理反击阶段的弃牌
*/
export async function handleCounterattack(
game: RegicideGame,
player: PlayerType,
discardCardIds: string[],
) {
const result = await enemyCounterattackCmd(game, player, discardCardIds);
if (!result.success) {
// 弃牌失败(点数和不足),游戏失败
await game.produceAsync((state) => {
state.phase = "defeat";
state.winner = false;
});
return result;
}
// 弃牌成功,切换到下一个玩家
await nextTurn(game);
return result;
}
/**
* 获取当前游戏状态摘要
*/
export function getGameStatus(game: RegicideGame) {
const state = game.value;
return {
phase: state.phase,
currentPlayer: ["player1", "player2", "player3", "player4"][
state.currentPlayerIndex
],
currentEnemy: state.currentEnemy
? {
...state.currentEnemy,
hpPercent: Math.round(
(state.currentEnemy.hp / state.currentEnemy.maxHp) * 100,
),
}
: null,
enemiesRemaining: state.enemyDeck.length,
tavernDeckCount: state.regions.tavernDeck.childIds.length,
discardPileCount: state.regions.discardPile.childIds.length,
playerHands: Object.fromEntries(
Object.entries(state.playerHands).map(([player, hand]) => [
player,
hand.length,
]),
),
winner: state.winner,
};
}
+63
View File
@@ -0,0 +1,63 @@
// Types
export type {
SuitType,
CardRank,
PlayerType,
RegionType,
RegicideCardMeta,
RegicideCard,
Enemy,
GamePhase,
} from "./types";
// Constants
export {
ENEMY_COUNT,
INITIAL_HAND_SIZE,
CARD_VALUES,
ALL_SUITS,
ALL_RANKS,
FACE_CARDS,
NUMBER_CARDS,
} from "./constants";
// State
export { createInitialState, type RegicideState } from "./state";
// Prompts
export { prompts } from "./prompts";
// Commands
export {
play as playCmd,
playWithA as playWithACmd,
pass as passCmd,
enemyCounterattack as enemyCounterattackCmd,
checkEnemy as checkEnemyCmd,
checkCanPlay as checkCanPlayCmd,
checkTavernDeck as checkTavernDeckCmd,
nextTurn as nextTurnCmd,
} from "./commands";
// Game
export {
setupGame,
start,
playTurn,
handleCounterattack,
getGameStatus,
type RegicideGame,
} from "./game";
// Utils
export {
getCardValue,
createCard,
createEnemy,
createAllCards,
buildEnemyDeck,
buildTavernDeck,
drawFromDeck,
isEnemyDefeated,
getPlayerHandRegionId,
} from "./utils";
+20
View File
@@ -0,0 +1,20 @@
import {createPromptDef} from "@/core/game";
export const prompts = {
playCard: createPromptDef<[string, string]>(
'play <player:string> <cardId:string>',
'选择要打出的卡牌'
),
playWithA: createPromptDef<[string, string, string]>(
'play-with-a <player:string> <aceCardId:string> <otherCardId:string>',
'打出A配合另一张牌'
),
pass: createPromptDef<[string]>(
'pass <player:string>',
'让过不出牌'
),
discard: createPromptDef<[string, string[]]>(
'counterattack <player:string> <discardCards:string[]>',
'选择要弃掉的卡牌以应对敌人反击'
),
};
+78
View File
@@ -0,0 +1,78 @@
# Regicide (弑君游戏)
## 游戏概述
**Regicide** 是一个合作式奇幻主题卡牌游戏,使用标准扑克牌进行游戏。
- **玩家人数:** 1-4 人(合作)
- **年龄:** 10+
- **游戏时间:** 约30分钟
## 游戏目标
玩家们协作对抗,使用手牌击败12个强大的敌人(每个等级3个敌人)。如果成功击败所有12个敌人则获胜;如果任何敌人的生命值归零前未能击败它,或者牌库耗尽时仍有敌人存活,则游戏失败。
## 游戏配件
- **标准扑克牌:** 52张(不包含大小王)
## 卡牌说明
### 数字牌(A-10)
- 面值等于牌面数字
- 每张牌有独特的花色能力
### 人头牌(J、Q、K)
- **J(侍卫):** 面值 10
- **Q(皇后):** 面值 15
- **K(国王):** 面值 20
### 花色能力
- **黑桃(Spades):** 降低敌人的攻击力
- **红心(Hearts):** 将弃牌堆的牌洗回酒馆牌堆
- **方块(Diamonds):** 从酒馆牌堆抓牌
- **梅花(Clubs):** 造成双倍伤害
## 游戏设置
0. 将所有的J/Q/K从牌堆移出,将4张K洗混后放在最下,4张Q洗混后放在中间,4张J洗混后放在最上形成敌人牌堆
1. 将剩余的扑克牌洗匀形成酒馆牌堆,每位玩家抽取 6 张手牌
2. 翻开一张敌人牌
3. 决定起始玩家(单人模式下自动为当前玩家)
## 游戏流程
从起始玩家开始,轮流进行以下操作:
### 玩家回合
在你的回合,你可以:
1. **打出一张牌:** 选择一张手牌打出,对其目标敌人造成伤害或触发效果
2. **打出一张A和另一张牌:** A可以配合任何其他牌一起打出
3. **让过(Pass):** 不出牌,结束回合
### 出牌流程
1. 打出的牌对当前敌人造成伤害
2. 触发牌的花色能力
3. 如果敌人的生命值降至0或以下,该敌人被击败,放置到弃牌堆
4. 如果当前敌人被击败,翻开下一张敌人牌,否则受到敌人反击,必须弃掉点数和大于等于敌人攻击力的手牌
即使玩家让过,也会受到敌人的反击。
### 限制
- 玩家之间不能交流或暗示关于手牌的信息
- 每个回合只能打出一张牌(除了A配合其他牌的情况)
## 敌人机制
### 敌人属性
- **生命值:** 敌人需要承受的伤害量,为卡牌面值的两倍
- **攻击力:** 敌人的面值即敌人的攻击力
## 胜利与失败条件
### 胜利条件
- 击败所有12个敌人
### 失败条件
- 任何玩家在敌人反击后无法弃掉点数和达到敌人攻击力以上的牌
- 任何玩家无法出牌
+38
View File
@@ -0,0 +1,38 @@
import {createRegion, Region} from "@/core/region";
import {Enemy, GamePhase, PlayerType, RegionType} from "@/samples/regicide/types";
import {RegicideCard} from "@/samples/regicide/types";
import {INITIAL_HAND_SIZE} from "@/samples/regicide/constants";
export function createInitialState() {
const regions: Record<RegionType, Region> = {
enemyDeck: createRegion('enemyDeck', []),
tavernDeck: createRegion('tavernDeck', []),
discardPile: createRegion('discardPile', []),
currentEnemy: createRegion('currentEnemy', []),
hand_player1: createRegion('hand_player1', []),
hand_player2: createRegion('hand_player2', []),
hand_player3: createRegion('hand_player3', []),
hand_player4: createRegion('hand_player4', []),
};
const playerHands: Record<PlayerType, string[]> = {
player1: [],
player2: [],
player3: [],
player4: [],
};
return {
regions,
cards: {} as Record<string, RegicideCard>,
playerHands,
currentPlayerIndex: 0,
playerCount: 1,
currentEnemy: null as Enemy | null,
enemyDeck: [] as Enemy[],
phase: 'playing' as GamePhase,
winner: null as boolean | null,
};
}
export type RegicideState = ReturnType<typeof createInitialState>;
+44
View File
@@ -0,0 +1,44 @@
import {Part} from "@/core/part";
// 花色类型
export type SuitType = 'spades' | 'hearts' | 'diamonds' | 'clubs';
// 牌面值
export type CardRank = 'A' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | '10' | 'J' | 'Q' | 'K';
// 玩家类型(1-4人合作)
export type PlayerType = 'player1' | 'player2' | 'player3' | 'player4';
// 区域类型
export type RegionType =
| 'enemyDeck' // 敌人牌堆
| 'tavernDeck' // 酒馆牌堆
| 'discardPile' // 弃牌堆
| 'currentEnemy' // 当前敌人
| 'hand_player1'
| 'hand_player2'
| 'hand_player3'
| 'hand_player4';
// 卡牌元数据
export type RegicideCardMeta = {
suit: SuitType;
rank: CardRank;
value: number; // 卡牌面值
};
// 卡牌部件类型
export type RegicideCard = Part<RegicideCardMeta>;
// 敌人类型
export type Enemy = {
id: string;
rank: CardRank;
suit: SuitType;
value: number; // 敌人面值(即攻击力)
hp: number; // 当前生命值
maxHp: number; // 最大生命值(面值的2倍)
};
// 游戏阶段
export type GamePhase = 'playing' | 'victory' | 'defeat';
+132
View File
@@ -0,0 +1,132 @@
import {ALL_RANKS, ALL_SUITS, CARD_VALUES, ENEMY_COUNT, FACE_CARDS} from "@/samples/regicide/constants";
import {CardRank, Enemy, RegicideCard, SuitType} from "@/samples/regicide/types";
import {RNG} from "@/utils/rng";
/**
* 获取卡牌面值
*/
export function getCardValue(rank: CardRank): number {
return CARD_VALUES[rank];
}
/**
* 创建卡牌部件
*/
export function createCard(id: string, suit: SuitType, rank: CardRank): RegicideCard {
return {
id,
regionId: '',
position: [],
suit,
rank,
value: getCardValue(rank),
};
}
/**
* 创建敌人
*/
export function createEnemy(id: string, rank: CardRank, suit: SuitType): Enemy {
const value = getCardValue(rank);
return {
id,
rank,
suit,
value,
hp: value * 2,
maxHp: value * 2,
};
}
/**
* 生成完整卡牌列表(52张,不含大小王)
*/
export function createAllCards(): Record<string, RegicideCard> {
const cards: Record<string, RegicideCard> = {};
for (const suit of ALL_SUITS) {
for (const rank of ALL_RANKS) {
const id = `${suit}_${rank}`;
cards[id] = createCard(id, suit, rank);
}
}
return cards;
}
/**
* 构建敌人牌堆(J/Q/K 共12张)
* 规则:4张K在最下,4张Q在中间,4张J在最上
*/
export function buildEnemyDeck(rng: RNG): Enemy[] {
const enemies: Enemy[] = [];
let idCounter = 0;
// 创建所有J/Q/K敌人
for (const rank of FACE_CARDS) {
for (const suit of ALL_SUITS) {
const id = `enemy_${idCounter++}`;
enemies.push(createEnemy(id, rank, suit));
}
}
// 分离J、Q、K
const jEnemies = enemies.filter(e => e.rank === 'J');
const qEnemies = enemies.filter(e => e.rank === 'Q');
const kEnemies = enemies.filter(e => e.rank === 'K');
// 分别洗牌
shuffleArray(jEnemies, rng);
shuffleArray(qEnemies, rng);
shuffleArray(kEnemies, rng);
// J在最上(先遇到),Q在中间,K在最下
return [...jEnemies, ...qEnemies, ...kEnemies];
}
/**
* 构建酒馆牌堆(移除J/Q/K后的剩余牌)
*/
export function buildTavernDeck(rng: RNG): RegicideCard[] {
const allCards = createAllCards();
const numberCards = Object.values(allCards).filter(card => !FACE_CARDS.includes(card.rank));
shuffleArray(numberCards, rng);
return numberCards;
}
/**
* 洗牌辅助函数
*/
function shuffleArray<T>(array: T[], rng: RNG): void {
for (let i = array.length - 1; i > 0; i--) {
const j = rng.nextInt(i + 1);
[array[i], array[j]] = [array[j], array[i]];
}
}
/**
* 从牌堆抽牌
*/
export function drawFromDeck(
deck: RegicideCard[],
count: number
): RegicideCard[] {
const drawn = deck.splice(0, count);
return drawn;
}
/**
* 检查敌人是否被击败
*/
export function isEnemyDefeated(enemy: Enemy | null): boolean {
return enemy !== null && enemy.hp <= 0;
}
/**
* 获取当前玩家手牌区域ID
*/
export function getPlayerHandRegionId(playerId: string): string {
return `hand_${playerId}`;
}
+68
View File
@@ -0,0 +1,68 @@
# AGENTS.md - slay-the-spire-like
Roguelike deckbuilder combining Slay the Spire combat with Backpack Hero grid inventory.
## Architecture
Three-layer design: **system** (content-agnostic) → **data** (CSV content) → **dialogues** (Yarn Spinner).
### System Layer (`system/`)
| Module | Files | Responsibility |
|---|---|---|
| `combat/` | types.ts, effects.ts, triggers.ts, prompts.ts | Combat state, effects pipeline, middleware triggers |
| `deck/` | types.ts, factory.ts, index.ts | Deck state management |
| `grid-inventory/` | types.ts, transform.ts, index.ts | Grid backpack, shape collision, adjacency |
| `map/` | types.ts, generator.ts, index.ts | Procedural node generation (point crawl) |
| `progress/` | types.ts, encounter.ts, index.ts | Adventure progress, node traversal |
| `utils/` | parse-shape.ts, shape-collision.ts | Shape parsing and collision detection |
| `types.ts` | — | `EffectData`, `EffectLifecycle`, `EnemyData`, `CardData`, `IntentData`, `EncounterData`, `ItemData` |
### Data Layer (`data/desert/`)
All content via CSV (loaded by `inline-schema`):
| File | Content |
|---|---|
| `card.csv` | Card definitions |
| `effect.csv` | Effect definitions |
| `enemy.csv` | Enemies + intents |
| `intent.csv` | Intent definitions |
| `item.csv` | Equipment items |
| `encounter.csv` | Encounter nodes |
### Triggers (`data/desert/triggers/`)
Middleware hooks extending the base trigger system:
| File | Hooks |
|---|---|
| `effect.ts` | Orchestrator — calls all sub-modules |
| `instant.ts` | `onEffectApplied` — attack, draw, gainEnergy, removeWound, summon, etc. |
| `damage.ts` | `onDamage` — block, spike, expose, aim, roll, charge, molt, tailSting, energyDrain |
| `turn-start.ts` | `onTurnStart` — discard, defendNext, energyNext, drawNext |
| `card-events.ts` | `onEnemyIntent`, `onCardPlayed`, `onCardDiscarded`, `onDamage` — storm, crossbow, burnForEnergy, sandwormKing, vulture |
### Dialogue Layer (`data/desert/dialogues/`)
`story.yarn` — branching encounter dialogues (inside `dialogues.yarnproject`). Commands resolve via `tryAnswerPrompt`.
## Key Mechanics
- **Energy**: 3/turn, spent on weapon/armor/tool cards
- **Uses**: Consumable cards use `uses` cost type instead of energy; tracked as `depletion` on `GameItemMeta`
- **Grid adjacency**: Items buff adjacent items (e.g. burnForEnergy consumes adjacent item on card play)
- **Effect lifecycles**: `instant`, `temporary`, `lingering`, `permanent`, `posture`, `item`, `itemTemporary`, `itemUntilPlay`, `itemUntilDiscard`, `itemPermanent`
- **Trigger pipeline**: `createMiddlewareChain` with `use(async (ctx, next) => ...)` pattern
- **Cards from items**: `GameCard` instances are generated from `GameItem` inventory items via deck factory
## Testing
Tests live in `tests/samples/slay-the-spire-like/`:
- `combat/effects.test.ts` — effect application, upkeep, posture damage
- `combat/triggers.test.ts` — full desert trigger integration tests
- `deck/factory.test.ts` — deck creation
- `grid-inventory.test.ts` — placement, adjacency, collision
- `map/generator.test.ts` — procedural generation
- `utils/shape-utils.test.ts` — shape parsing
- `data/index.test.ts` — data loading validation
@@ -0,0 +1,43 @@
# cardDesert: unified card definitions for item cards and status cards
# type: 'item' = inventory item card, 'status' = status effect card
# costType: 'energy' = costs energy per turn, 'uses' = limited uses, 'none' = free
# targetType: 'single' = target one enemy, 'none' = no target
# type CardType = 'item' | 'status'
# type CardCostType = 'energy' | 'uses' | 'none'
# type CardTargetType = 'player' | 'enemy' | 'enemies'
# inject effects = ~cardEffect(card)
id,name,desc,type,costType,costCount,targetType
string,string,string,CardType,CardCostType,int,CardTargetType
sword,剑,【攻击2】【攻击2】,item,energy,1,enemy
greataxe,长斧,对全体【攻击5】,item,energy,2,enemies
spear,长枪,【攻击2】【攻击2】【攻击2】,item,energy,1,enemy
dagger,短刀,【攻击3】【攻击3】,item,energy,1,enemy
dart,飞镖,【攻击1】抓一张牌,item,energy,0,enemy
crossbow,十字弩,【攻击6】对同一目标打出其他十字弩,item,energy,2,enemy
shield,盾,【防御3】,item,energy,1,player
hat,斗笠,【防御8】,item,energy,2,player
cape,披风,【防御2】下回合【防御2】,item,energy,1,player
bracer,护腕,【防御1】抓1张牌,item,energy,0,player
greatshield,大盾,【防御5】,item,energy,1,player
chainmail,锁子甲,本回合受到伤害-3,item,energy,1,player
bandage,绷带,从牌堆或弃牌堆随机移除1张伤口,item,uses,3,player
poisonPotion,淬毒药剂,周围物品的【攻击】+2,item,uses,3,player
fortifyPotion,强固药剂,周围物品的【防御】+2,item,uses,3,player
vitalityPotion,活力药剂,获得1点能量,item,uses,3,player
focusPotion,集中药剂,抓2张牌,item,uses,3,player
healingPotion,治疗药剂,从牌堆或弃牌堆移除3张伤口,item,uses,3,player
waterBag,水袋,下回合开始时获得1能量抓2张牌,item,energy,1,player
rope,绳索,周围物品的牌【防御】+2直到打出,item,energy,1,player
belt,腰带,从牌堆周围物品的牌当中选择一张加入手牌,item,energy,0,player
torch,火把,下次打出周围物品的牌时将其消耗并获得1能量,item,energy,1,player
whetstone,磨刀石,周围物品的牌【攻击】+3直到打出,item,energy,1,player
blacksmithHammer,铁匠锤,从牌堆/弃牌堆选择一张牌随机变为一张周围物品的牌,item,energy,1,player
wound,伤口,无效果占用手牌和牌堆,status,none,0,player
venom,蛇毒,弃掉时受到3点伤害,status,none,0,player
curse,诅咒,受攻击时物品攻击-1直到弃掉一张该物品的牌,status,none,0,player
static,静电,在手里时受电击伤害+1,status,none,0,player
fatigue,疲劳,占用手牌,status,none,0,player
vultureEye,秃鹫之眼,抓到时获得3层暴露,status,none,0,player
1 # cardDesert: unified card definitions for item cards and status cards
2 # type: 'item' = inventory item card, 'status' = status effect card
3 # costType: 'energy' = costs energy per turn, 'uses' = limited uses, 'none' = free
4 # targetType: 'single' = target one enemy, 'none' = no target
5 # type CardType = 'item' | 'status'
6 # type CardCostType = 'energy' | 'uses' | 'none'
7 # type CardTargetType = 'player' | 'enemy' | 'enemies'
8 # inject effects = ~cardEffect(card)
9 id,name,desc,type,costType,costCount,targetType
10 string,string,string,CardType,CardCostType,int,CardTargetType
11 sword,剑,【攻击2】【攻击2】,item,energy,1,enemy
12 greataxe,长斧,对全体【攻击5】,item,energy,2,enemies
13 spear,长枪,【攻击2】【攻击2】【攻击2】,item,energy,1,enemy
14 dagger,短刀,【攻击3】【攻击3】,item,energy,1,enemy
15 dart,飞镖,【攻击1】抓一张牌,item,energy,0,enemy
16 crossbow,十字弩,【攻击6】对同一目标打出其他十字弩,item,energy,2,enemy
17 shield,盾,【防御3】,item,energy,1,player
18 hat,斗笠,【防御8】,item,energy,2,player
19 cape,披风,【防御2】下回合【防御2】,item,energy,1,player
20 bracer,护腕,【防御1】抓1张牌,item,energy,0,player
21 greatshield,大盾,【防御5】,item,energy,1,player
22 chainmail,锁子甲,本回合受到伤害-3,item,energy,1,player
23 bandage,绷带,从牌堆或弃牌堆随机移除1张伤口,item,uses,3,player
24 poisonPotion,淬毒药剂,周围物品的【攻击】+2,item,uses,3,player
25 fortifyPotion,强固药剂,周围物品的【防御】+2,item,uses,3,player
26 vitalityPotion,活力药剂,获得1点能量,item,uses,3,player
27 focusPotion,集中药剂,抓2张牌,item,uses,3,player
28 healingPotion,治疗药剂,从牌堆或弃牌堆移除3张伤口,item,uses,3,player
29 waterBag,水袋,下回合开始时获得1能量抓2张牌,item,energy,1,player
30 rope,绳索,周围物品的牌【防御】+2直到打出,item,energy,1,player
31 belt,腰带,从牌堆周围物品的牌当中选择一张加入手牌,item,energy,0,player
32 torch,火把,下次打出周围物品的牌时将其消耗并获得1能量,item,energy,1,player
33 whetstone,磨刀石,周围物品的牌【攻击】+3直到打出,item,energy,1,player
34 blacksmithHammer,铁匠锤,从牌堆/弃牌堆选择一张牌随机变为一张周围物品的牌,item,energy,1,player
35 wound,伤口,无效果占用手牌和牌堆,status,none,0,player
36 venom,蛇毒,弃掉时受到3点伤害,status,none,0,player
37 curse,诅咒,受攻击时物品攻击-1直到弃掉一张该物品的牌,status,none,0,player
38 static,静电,在手里时受电击伤害+1,status,none,0,player
39 fatigue,疲劳,占用手牌,status,none,0,player
40 vultureEye,秃鹫之眼,抓到时获得3层暴露,status,none,0,player
@@ -0,0 +1,21 @@
import type { CardEffect } from './cardEffect.csv';
export type CardType = 'item' | 'status';
export type CardCostType = 'energy' | 'uses' | 'none';
export type CardTargetType = 'player' | 'enemy' | 'enemies';
type CardTable = readonly {
readonly id: string;
readonly name: string;
readonly desc: string;
readonly type: CardType;
readonly costType: CardCostType;
readonly costCount: number;
readonly targetType: CardTargetType;
readonly effects: CardEffect[];
}[];
export type Card = CardTable[number];
declare function getData(): CardTable;
export default getData;
@@ -0,0 +1,36 @@
# type CardEffectTrigger = 'onPlay' | 'onDraw' | 'onDiscard'
# type CardEffectTarget = 'source' | 'eachTarget' | 'eachEnemy' | 'randomEnemy' | 'player'
# type CardEffectList = [effect: @effect; stacks: number][]
id,card,trigger,target,effects
string,@card,CardEffectTrigger,CardEffectTarget,CardEffectList
sword,sword,onPlay,eachTarget,[attack;2];[attack;2]
greataxe,greataxe,onPlay,eachTarget,[attack;5]
spear,spear,onPlay,eachTarget,[attack;2];[attack;2];[attack;2]
dagger,dagger,onPlay,eachTarget,[attack;3];[attack;3]
dart,dart,onPlay,eachTarget,[attack;1]
dart-draw,dart,onPlay,source,[draw;1]
crossbow,crossbow,onPlay,eachTarget,[attack;6]
crossbow-combo,crossbow,onPlay,source,[crossbow;0]
shield,shield,onPlay,source,[defend;3]
hat,hat,onPlay,source,[defend;8]
cape,cape,onPlay,source,[defend;2];[defendNext;2]
bracer,bracer,onPlay,source,[defend;1];[draw;1]
greatshield,greatshield,onPlay,source,[defend;5]
chainmail,chainmail,onPlay,source,[damageReduce;3]
bandage,bandage,onPlay,source,[removeWound;1]
poisonPotion,poisonPotion,onPlay,source,[attackBuff;2]
fortifyPotion,fortifyPotion,onPlay,source,[defendBuff;2]
vitalityPotion,vitalityPotion,onPlay,source,[gainEnergy;1]
focusPotion,focusPotion,onPlay,source,[draw;2]
healingPotion,healingPotion,onPlay,source,[removeWound;3]
waterBag,waterBag,onPlay,source,[energyNext;1];[drawNext;2]
rope,rope,onPlay,source,[defendBuffUntilPlay;2]
belt,belt,onPlay,source,[drawChoice;1]
torch,torch,onPlay,source,[burnForEnergy;1]
whetstone,whetstone,onPlay,source,[attackBuffUntilPlay;3]
blacksmithHammer,blacksmithHammer,onPlay,source,[transformRandom;1]
venom,venom,onDiscard,source,[attack;3]
curse,curse,onDraw,source,[curse;1]
static,static,onDraw,source,[static;1]
vultureEye,vultureEye,onDraw,source,[expose;3]
1 # type CardEffectTrigger = 'onPlay' | 'onDraw' | 'onDiscard'
2 # type CardEffectTarget = 'source' | 'eachTarget' | 'eachEnemy' | 'randomEnemy' | 'player'
3 # type CardEffectList = [effect: @effect; stacks: number][]
4 id,card,trigger,target,effects
5 string,@card,CardEffectTrigger,CardEffectTarget,CardEffectList
6 sword,sword,onPlay,eachTarget,[attack;2];[attack;2]
7 greataxe,greataxe,onPlay,eachTarget,[attack;5]
8 spear,spear,onPlay,eachTarget,[attack;2];[attack;2];[attack;2]
9 dagger,dagger,onPlay,eachTarget,[attack;3];[attack;3]
10 dart,dart,onPlay,eachTarget,[attack;1]
11 dart-draw,dart,onPlay,source,[draw;1]
12 crossbow,crossbow,onPlay,eachTarget,[attack;6]
13 crossbow-combo,crossbow,onPlay,source,[crossbow;0]
14 shield,shield,onPlay,source,[defend;3]
15 hat,hat,onPlay,source,[defend;8]
16 cape,cape,onPlay,source,[defend;2];[defendNext;2]
17 bracer,bracer,onPlay,source,[defend;1];[draw;1]
18 greatshield,greatshield,onPlay,source,[defend;5]
19 chainmail,chainmail,onPlay,source,[damageReduce;3]
20 bandage,bandage,onPlay,source,[removeWound;1]
21 poisonPotion,poisonPotion,onPlay,source,[attackBuff;2]
22 fortifyPotion,fortifyPotion,onPlay,source,[defendBuff;2]
23 vitalityPotion,vitalityPotion,onPlay,source,[gainEnergy;1]
24 focusPotion,focusPotion,onPlay,source,[draw;2]
25 healingPotion,healingPotion,onPlay,source,[removeWound;3]
26 waterBag,waterBag,onPlay,source,[energyNext;1];[drawNext;2]
27 rope,rope,onPlay,source,[defendBuffUntilPlay;2]
28 belt,belt,onPlay,source,[drawChoice;1]
29 torch,torch,onPlay,source,[burnForEnergy;1]
30 whetstone,whetstone,onPlay,source,[attackBuffUntilPlay;3]
31 blacksmithHammer,blacksmithHammer,onPlay,source,[transformRandom;1]
32 venom,venom,onDiscard,source,[attack;3]
33 curse,curse,onDraw,source,[curse;1]
34 static,static,onDraw,source,[static;1]
35 vultureEye,vultureEye,onDraw,source,[expose;3]
@@ -0,0 +1,19 @@
import type { Card } from './card.csv';
import type { Effect } from './effect.csv';
export type CardEffectTrigger = 'onPlay' | 'onDraw' | 'onDiscard';
export type CardEffectTarget = 'source' | 'eachTarget' | 'eachEnemy' | 'randomEnemy' | 'player';
export type CardEffectList = [effect: Effect, stacks: number][];
type CardEffectTable = readonly {
readonly id: string;
readonly card: Card;
readonly trigger: CardEffectTrigger;
readonly target: CardEffectTarget;
readonly effects: CardEffectList;
}[];
export type CardEffect = CardEffectTable[number];
declare function getData(): CardEffectTable;
export default getData;
@@ -0,0 +1,17 @@
{
"projectFileVersion": 4,
"sourceFiles": [
"**/*.yarn"
],
"excludeFiles": [],
"baseLanguage": "en",
"projectName": "encounters",
"authorName": [
"hyper"
],
"editorOptions": {
"yarnScriptEditor": {
"presenter": "try"
}
}
}
@@ -0,0 +1,3 @@
import type { LoadResult } from 'yarn-spinner-loader';
const result: LoadResult;
export default result;
@@ -0,0 +1,42 @@
title: Start
---
// A linear story progresses from one scene to the next.
// Great for cutscenes, tutorials, or scripted sequences.
<<jump Scene1>>
===
title: Scene1
---
The sun rose over the quiet village.
Birds sang their morning chorus as the first rays of light crept through the window.
-> Continue
<<jump Scene2>>
===
title: Scene2
---
In the kitchen, a kettle began to whistle.
The smell of fresh bread filled the air.
-> Continue
<<jump Scene3>>
===
title: Scene3
---
A knock at the door broke the peaceful morning.
Who could be visiting so early?
-> Open the door
<<jump Scene4>>
===
title: Scene4
---
A messenger stood at the threshold, parchment in hand.
"News from the capital," they said breathlessly. "You must read this at once."
// Your story continues from here!
===
@@ -0,0 +1,52 @@
# instant: 不施加buff,瞬间生效
# temporary: 施加buff,下回合开始时失效
# lingering: 施加buff,下回合开始时失去1层
# permanent: 施加buff
# posture: 施加buff,每受到1点伤害移除1层
# item: 施加buff到周围物品,永久生效
# itemTemporary: 施加buff到周围物品,持续到下次buff更新
# itemUntilPlay: 施加buff到周围物品,物品被打出后失效
# itemUntilDiscard: 施加buff到周围物品,物品被弃掉后失效
# itemPermanent: 施加buff到周围物品,持续整场冒险
# type EffectLifecycle = 'instant' | 'temporary' | 'lingering' | 'permanent' | 'posture' | 'item' | 'itemTemporary' | 'itemUntilPlay' | 'itemUntilDiscard' | 'itemPermanent'
id, name, description, lifecycle, emoji
string, string, string, EffectLifecycle, string
attack, 攻击, 对对手造成伤害, instant, ⚔️
defend, 防御, 抵消下次行动前受到的伤害, temporary, 🛡️
spike, 尖刺, 对攻击者造成X点伤害, permanent, 🌵
venom, 蛇毒, 同名状态牌/1费:打出时移除此牌。弃掉时受到3点伤害, instant, 🧪
curse, 诅咒, 受攻击时物品攻击-1,直到弃掉一张该物品的牌, lingering, 💀
aim, 瞄准, 造成双倍伤害,受伤时失去等量瞄准, posture, 🎯
roll, 滚动, 攻击时每消耗10点滚动造成等量伤害, posture, 🎲
rollDamage, 滚动攻击, 消耗滚动层数造成的伤害, instant, 💥
vultureEye, 秃鹫之眼, 抓到时获得3层暴露(临时debuff,受到的伤害+1/每层), instant, 👁️
tailSting, 尾刺, 攻击时,伤害提升X, posture, 🦂
energyDrain, 能量吸取, 受伤时,玩家失去X点能量, lingering, 🔋
molt, 脱皮, 若脱皮达到生命上限则怪物逃跑, posture, 🐚
discard, 劫掠, 回合开始时随机弃掉一张手牌, lingering, 🗑️
storm, 风暴, 攻击时给玩家塞入1张静电, permanent, ⚡
static, 静电, 在手里时受电击伤害+1, permanent, ⚡
charge, 冲锋, 受到或造成的伤害翻倍并消耗等量冲锋, lingering, 🐎
summonMummy, 召唤木乃伊, 召唤1个木乃伊, instant, 🧟
summonSandwormLarva, 召唤幼沙虫, 召唤1个幼沙虫, instant, 🐛
reviveMummy, 复活木乃伊, 复活1个已死亡的木乃伊, instant, 🌅
draw, 抓牌, 抓X张牌, instant, 🃏
crossbow, 十字弩连击, 对同一目标打出其他十字弩, instant, 🏹
defendNext, 下回合防御, 下回合开始时获得防御, temporary, 🛡️
damageReduce, 减伤, 本回合受到的伤害减少X, temporary, 📉
removeWound, 移除伤口, 从牌堆或弃牌堆移除X张伤口, instant, 🩹
attackBuff, 攻击增益, 周围物品的攻击+X, itemUntilPlay, ⬆️
defendBuff, 防御增益, 周围物品的防御+X, itemUntilPlay, ⬆️
gainEnergy, 获得能量, 获得X点能量, instant, ⚡
energyNext, 下回合获能量, 下回合开始时获得X点能量, temporary, ⚡
drawNext, 下回合抓牌, 下回合开始时抓X张牌, temporary, 🃏
defendBuffUntilPlay, 防御增益直到打出, 周围物品的牌防御+X直到打出, itemUntilPlay, 🛡️
drawChoice, 选择抓牌, 从牌堆周围物品的牌中选择一张加入手牌, instant, 🔍
burnForEnergy, 消耗获能量, 打出周围物品的牌时消耗并获得X能量, itemUntilPlay, 🔥
attackBuffUntilPlay, 攻击增益直到打出, 周围物品的牌攻击+X直到打出, itemUntilPlay, ⚔️
transformRandom, 随机变牌, 选择一张牌随机变为周围物品的牌, instant, 🌀
expose, 暴露, 受到的伤害+1/每层, temporary, 👁️
1 # instant: 不施加buff,瞬间生效
2 # temporary: 施加buff,下回合开始时失效
3 # lingering: 施加buff,下回合开始时失去1层
4 # permanent: 施加buff
5 # posture: 施加buff,每受到1点伤害移除1层
6 # item: 施加buff到周围物品,永久生效
7 # itemTemporary: 施加buff到周围物品,持续到下次buff更新
8 # itemUntilPlay: 施加buff到周围物品,物品被打出后失效
9 # itemUntilDiscard: 施加buff到周围物品,物品被弃掉后失效
10 # itemPermanent: 施加buff到周围物品,持续整场冒险
11 # type EffectLifecycle = 'instant' | 'temporary' | 'lingering' | 'permanent' | 'posture' | 'item' | 'itemTemporary' | 'itemUntilPlay' | 'itemUntilDiscard' | 'itemPermanent'
12 id name description lifecycle emoji
13 string string string EffectLifecycle string
14 attack 攻击 对对手造成伤害 instant ⚔️
15 defend 防御 抵消下次行动前受到的伤害 temporary 🛡️
16 spike 尖刺 对攻击者造成X点伤害 permanent 🌵
17 venom 蛇毒 同名状态牌/1费:打出时移除此牌。弃掉时受到3点伤害 instant 🧪
18 curse 诅咒 受攻击时物品攻击-1,直到弃掉一张该物品的牌 lingering 💀
19 aim 瞄准 造成双倍伤害,受伤时失去等量瞄准 posture 🎯
20 roll 滚动 攻击时每消耗10点滚动造成等量伤害 posture 🎲
21 rollDamage 滚动攻击 消耗滚动层数造成的伤害 instant 💥
22 vultureEye 秃鹫之眼 抓到时获得3层暴露(临时debuff,受到的伤害+1/每层) instant 👁️
23 tailSting 尾刺 攻击时,伤害提升X posture 🦂
24 energyDrain 能量吸取 受伤时,玩家失去X点能量 lingering 🔋
25 molt 脱皮 若脱皮达到生命上限则怪物逃跑 posture 🐚
26 discard 劫掠 回合开始时随机弃掉一张手牌 lingering 🗑️
27 storm 风暴 攻击时给玩家塞入1张静电 permanent ⚡
28 static 静电 在手里时受电击伤害+1 permanent ⚡
29 charge 冲锋 受到或造成的伤害翻倍并消耗等量冲锋 lingering 🐎
30 summonMummy 召唤木乃伊 召唤1个木乃伊 instant 🧟
31 summonSandwormLarva 召唤幼沙虫 召唤1个幼沙虫 instant 🐛
32 reviveMummy 复活木乃伊 复活1个已死亡的木乃伊 instant 🌅
33 draw 抓牌 抓X张牌 instant 🃏
34 crossbow 十字弩连击 对同一目标打出其他十字弩 instant 🏹
35 defendNext 下回合防御 下回合开始时获得防御 temporary 🛡️
36 damageReduce 减伤 本回合受到的伤害减少X temporary 📉
37 removeWound 移除伤口 从牌堆或弃牌堆移除X张伤口 instant 🩹
38 attackBuff 攻击增益 周围物品的攻击+X itemUntilPlay ⬆️
39 defendBuff 防御增益 周围物品的防御+X itemUntilPlay ⬆️
40 gainEnergy 获得能量 获得X点能量 instant ⚡
41 energyNext 下回合获能量 下回合开始时获得X点能量 temporary ⚡
42 drawNext 下回合抓牌 下回合开始时抓X张牌 temporary 🃏
43 defendBuffUntilPlay 防御增益直到打出 周围物品的牌防御+X直到打出 itemUntilPlay 🛡️
44 drawChoice 选择抓牌 从牌堆周围物品的牌中选择一张加入手牌 instant 🔍
45 burnForEnergy 消耗获能量 打出周围物品的牌时消耗并获得X能量 itemUntilPlay 🔥
46 attackBuffUntilPlay 攻击增益直到打出 周围物品的牌攻击+X直到打出 itemUntilPlay ⚔️
47 transformRandom 随机变牌 选择一张牌随机变为周围物品的牌 instant 🌀
48 expose 暴露 受到的伤害+1/每层 temporary 👁️
@@ -0,0 +1,14 @@
export type EffectLifecycle = 'instant' | 'temporary' | 'lingering' | 'permanent' | 'posture' | 'item' | 'itemTemporary' | 'itemUntilPlay' | 'itemUntilDiscard' | 'itemPermanent';
type EffectTable = readonly {
readonly id: string;
readonly name: string;
readonly description: string;
readonly lifecycle: EffectLifecycle;
readonly emoji: string;
}[];
export type Effect = EffectTable[number];
declare function getData(): EffectTable;
export default getData;
@@ -0,0 +1,40 @@
# minion (10): minor enemies
# elite (4): dangerous enemies
# event (1): random dangerous event that requires reaction
# shop (2): merchant who sells different stuff
# camp (2): consumable restock and heal
# curio (8): random pickup of treasure or resources
# enemies: array of [enemyId; initialHp; buffs[]]
# type EncounterType = 'minion' | 'elite' | 'event' | 'shop' | 'camp' | 'curio'
# type EnemyList = [data: @enemy; hp: int; effects: [effect: @effect; stacks: int][]][]
id,type,name,description,enemies,dialogue
string,EncounterType,string,string,EnemyList,string
cactus_pair,minion,仙人掌怪,概念:防+强化。【尖刺X】:对攻击者造成X点伤害。,[仙人掌怪;12;[]];[仙人掌怪;12;[]],
snake_pair,minion,蛇,概念:攻+强化。给玩家塞入蛇毒牌(1费:打出时移除此牌。弃掉时受到3点伤害)。,[蛇;10;[]],
mummy_cactus,minion,木乃伊,概念:攻+防。【诅咒】:受攻击时物品【攻击】-1,直到弃掉一张该物品的牌。,[木乃伊;14;[]];[仙人掌怪;12;[]],
gunslinger,minion,枪手,概念:单回高攻。【瞄准X】:造成双倍伤害。受伤时失去等量【瞄准】,[枪手;12;[]],
tumbleweed_pair,minion,风卷草,概念:防+强化。【滚动X】:攻击时,每消耗10点【滚动】,造成等量伤害。,[风卷草;16;[]],
vulture_cactus,minion,秃鹫,概念:攻+防。若造成伤害,玩家获得秃鹫之眼(0费状态牌:打出时移除。抓到时获得3层暴露)。,[秃鹫;12;[]];[仙人掌怪;12;[]],
scorpion_snake,minion,沙蝎,概念:攻+强化。【尾刺X】:姿态buff,攻击时,伤害提升X。,[沙蝎;10;[]];[蛇;10;[]],
sandworm_larva,minion,幼沙虫,概念:防+强化。每回合第一次受伤时,玩家失去1点能量。,[幼沙虫;18;[]],
lizard_pair,minion,蜥蜴,概念:攻+防+逃跑。【脱皮】:若脱皮达到生命上限,则怪物逃跑,玩家不能获得战斗奖励。,[蜥蜴;14;[]],
bandit_gunslinger,minion,沙匪,概念:弱化玩家。【劫掠】:对玩家施加的延时debuff。回合开始时,随机弃掉一张手牌。,[沙匪;12;[]];[枪手;12;[]],
storm_spirit,elite,风暴之灵,【风暴X】:攻击时,玩家获得1张静电。受伤时失去等量【风暴】。(静电:在手里时受【电击】伤害+1),[风暴之灵;30;[]],
mounted_gunslinger,elite,骑马枪手,【冲锋X】:受到或造成的伤害翻倍并消耗等量的冲锋。,[骑马枪手;25;[]];[枪手;12;[]],
sandworm_king,elite,沙虫王,召唤幼体沙虫;每当玩家弃掉一张牌,恢复1生命。,[沙虫王;40;[]],
desert_guard,elite,沙漠守卫,召唤木乃伊;会复活木乃伊2次。,[沙漠守卫;35;[]];[木乃伊;14;[]],
desert_merchant,shop,沙漠商人,商店:可以恢复生命、出售装备、附魔物品。,,
nomad_caravan,shop,游牧商队,商队:出售稀有物品、移除牌组中一张牌。,,
oasis_campfire,camp,绿洲篝火,篝火:可以恢复生命、补充药水使用次数、获得下次战斗Buff。,,
cave_shelter,camp,岩洞庇护所,篝火:可以恢复生命、升级一张牌。,,
desert_relic_in_sand,curio,沙中遗物,随机获得一件遗物或受到3点伤害。,,desert_relic_in_sand
desert_dry_well,curio,枯井,投入1能量:可能获得药水或什么也没有。,,desert_dry_well
desert_ancient_stele,curio,古代石碑,阅读碑文:获得随机Buff直到下次战斗结束。,,desert_ancient_stele
desert_storm_wreckage,curio,沙暴残骸,搜索残骸:随机获得一张物品牌或受到2点伤害。,,desert_storm_wreckage
desert_mirage_chest,curio,蜃景宝箱,打开宝箱:50%获得宝藏,50%为蜃景什么也没有。,,desert_mirage_chest
desert_buried_pot,curio,埋藏陶罐,挖掘:获得随机资源(金币、药水或遗物碎片)。,,desert_buried_pot
desert_weathered_statue,curio,风化雕像,献祭1生命:获得一件随机遗物。,,desert_weathered_statue
desert_oasis_fragment,curio,绿洲碎片,小型绿洲:恢复3生命并获得1张随机消耗品。,,desert_oasis_fragment
desert_mirage_event,event,海市蜃楼,随机遭遇:可能获得宝藏或遭遇陷阱,使用d6双阶段结构结算。,,desert_mirage_event
1 # minion (10): minor enemies
2 # elite (4): dangerous enemies
3 # event (1): random dangerous event that requires reaction
4 # shop (2): merchant who sells different stuff
5 # camp (2): consumable restock and heal
6 # curio (8): random pickup of treasure or resources
7 # enemies: array of [enemyId; initialHp; buffs[]]
8 # type EncounterType = 'minion' | 'elite' | 'event' | 'shop' | 'camp' | 'curio'
9 # type EnemyList = [data: @enemy; hp: int; effects: [effect: @effect; stacks: int][]][]
10 id type name description enemies dialogue
11 string EncounterType string string EnemyList string
12 cactus_pair minion 仙人掌怪 概念:防+强化。【尖刺X】:对攻击者造成X点伤害。 [仙人掌怪;12;[]];[仙人掌怪;12;[]]
13 snake_pair minion 蛇 概念:攻+强化。给玩家塞入蛇毒牌(1费:打出时移除此牌。弃掉时受到3点伤害)。 [蛇;10;[]]
14 mummy_cactus minion 木乃伊 概念:攻+防。【诅咒】:受攻击时物品【攻击】-1,直到弃掉一张该物品的牌。 [木乃伊;14;[]];[仙人掌怪;12;[]]
15 gunslinger minion 枪手 概念:单回高攻。【瞄准X】:造成双倍伤害。受伤时失去等量【瞄准】 [枪手;12;[]]
16 tumbleweed_pair minion 风卷草 概念:防+强化。【滚动X】:攻击时,每消耗10点【滚动】,造成等量伤害。 [风卷草;16;[]]
17 vulture_cactus minion 秃鹫 概念:攻+防。若造成伤害,玩家获得秃鹫之眼(0费状态牌:打出时移除。抓到时获得3层暴露)。 [秃鹫;12;[]];[仙人掌怪;12;[]]
18 scorpion_snake minion 沙蝎 概念:攻+强化。【尾刺X】:姿态buff,攻击时,伤害提升X。 [沙蝎;10;[]];[蛇;10;[]]
19 sandworm_larva minion 幼沙虫 概念:防+强化。每回合第一次受伤时,玩家失去1点能量。 [幼沙虫;18;[]]
20 lizard_pair minion 蜥蜴 概念:攻+防+逃跑。【脱皮】:若脱皮达到生命上限,则怪物逃跑,玩家不能获得战斗奖励。 [蜥蜴;14;[]]
21 bandit_gunslinger minion 沙匪 概念:弱化玩家。【劫掠】:对玩家施加的延时debuff。回合开始时,随机弃掉一张手牌。 [沙匪;12;[]];[枪手;12;[]]
22 storm_spirit elite 风暴之灵 【风暴X】:攻击时,玩家获得1张静电。受伤时失去等量【风暴】。(静电:在手里时受【电击】伤害+1) [风暴之灵;30;[]]
23 mounted_gunslinger elite 骑马枪手 【冲锋X】:受到或造成的伤害翻倍并消耗等量的冲锋。 [骑马枪手;25;[]];[枪手;12;[]]
24 sandworm_king elite 沙虫王 召唤幼体沙虫;每当玩家弃掉一张牌,恢复1生命。 [沙虫王;40;[]]
25 desert_guard elite 沙漠守卫 召唤木乃伊;会复活木乃伊2次。 [沙漠守卫;35;[]];[木乃伊;14;[]]
26 desert_merchant shop 沙漠商人 商店:可以恢复生命、出售装备、附魔物品。
27 nomad_caravan shop 游牧商队 商队:出售稀有物品、移除牌组中一张牌。
28 oasis_campfire camp 绿洲篝火 篝火:可以恢复生命、补充药水使用次数、获得下次战斗Buff。
29 cave_shelter camp 岩洞庇护所 篝火:可以恢复生命、升级一张牌。
30 desert_relic_in_sand curio 沙中遗物 随机获得一件遗物或受到3点伤害。 desert_relic_in_sand
31 desert_dry_well curio 枯井 投入1能量:可能获得药水或什么也没有。 desert_dry_well
32 desert_ancient_stele curio 古代石碑 阅读碑文:获得随机Buff直到下次战斗结束。 desert_ancient_stele
33 desert_storm_wreckage curio 沙暴残骸 搜索残骸:随机获得一张物品牌或受到2点伤害。 desert_storm_wreckage
34 desert_mirage_chest curio 蜃景宝箱 打开宝箱:50%获得宝藏,50%为蜃景什么也没有。 desert_mirage_chest
35 desert_buried_pot curio 埋藏陶罐 挖掘:获得随机资源(金币、药水或遗物碎片)。 desert_buried_pot
36 desert_weathered_statue curio 风化雕像 献祭1生命:获得一件随机遗物。 desert_weathered_statue
37 desert_oasis_fragment curio 绿洲碎片 小型绿洲:恢复3生命并获得1张随机消耗品。 desert_oasis_fragment
38 desert_mirage_event event 海市蜃楼 随机遭遇:可能获得宝藏或遭遇陷阱,使用d6双阶段结构结算。 desert_mirage_event
@@ -0,0 +1,19 @@
import type { Enemy } from './enemy.csv';
import type { Effect } from './effect.csv';
export type EncounterType = 'minion' | 'elite' | 'event' | 'shop' | 'camp' | 'curio';
export type EnemyList = [data: Enemy, hp: int, effects: [effect: Effect, stacks: int][]][];
type EncounterTable = readonly {
readonly id: string;
readonly type: EncounterType;
readonly name: string;
readonly description: string;
readonly enemies: EnemyList;
readonly dialogue: string;
}[];
export type Encounter = EncounterTable[number];
declare function getData(): EncounterTable;
export default getData;
@@ -0,0 +1,16 @@
id,name,intents,description
string,string,@intent[],string
仙人掌怪,仙人掌怪,[仙人掌怪-boost;仙人掌怪-defend;仙人掌怪-attack],防+强化。【尖刺X】:对攻击者造成X点伤害。
蛇,蛇,[蛇-poison;蛇-attack;蛇-boost],攻+强化。给玩家塞入蛇毒牌(1费:打出时移除此牌。弃掉时受到3点伤害)。
木乃伊,木乃伊,[木乃伊-attack;木乃伊-defend;木乃伊-curse],攻+防。【诅咒】:受攻击时物品【攻击】-1,直到弃掉一张该物品的牌。
枪手,枪手,[枪手-aim;枪手-attack;枪手-defend],单回高攻。【瞄准X】:造成双倍伤害。受伤时失去等量【瞄准】。
风卷草,风卷草,[风卷草-boost;风卷草-defend;风卷草-attack],防+强化。【滚动X】:攻击时,每消耗10点【滚动】,造成等量伤害。
秃鹫,秃鹫,[秃鹫-attack;秃鹫-defend],攻+防。若造成伤害,玩家获得秃鹫之眼(0费状态牌:打出时移除。抓到时获得3层暴露)。
沙蝎,沙蝎,[沙蝎-boost;沙蝎-attack],攻+强化。【尾刺X】:姿态buff,攻击时,伤害提升X。
幼沙虫,幼沙虫,[幼沙虫-defend;幼沙虫-boost;幼沙虫-attack],防+强化。每回合第一次受伤时,玩家失去1点能量。
蜥蜴,蜥蜴,[蜥蜴-attack;蜥蜴-defend;蜥蜴-molt],攻+防+逃跑。【脱皮】:若脱皮达到生命上限,则怪物逃跑,玩家不能获得战斗奖励。
沙匪,沙匪,[沙匪-attack;沙匪-heavyAttack;沙匪-debuff],弱化玩家。【劫掠】:对玩家施加的延时debuff。回合开始时,随机弃掉一张手牌。
风暴之灵,风暴之灵,[风暴之灵-storm;风暴之灵-attack;风暴之灵-defend],【风暴X】:攻击时,玩家获得1张静电。受伤时失去等量【风暴】。(静电:在手里时受【电击】伤害+1)
骑马枪手,骑马枪手,[骑马枪手-charge;骑马枪手-attack;骑马枪手-defend],【冲锋X】:受到或造成的伤害翻倍并消耗等量的冲锋。
沙虫王,沙虫王,[沙虫王-summon;沙虫王-attack;沙虫王-defend],召唤幼体沙虫;每当玩家弃掉一张牌,恢复1生命。
沙漠守卫,沙漠守卫,[沙漠守卫-summon;沙漠守卫-attack;沙漠守卫-defend;沙漠守卫-revive],召唤木乃伊;会复活木乃伊2次。
1 id name intents description
2 string string @intent[] string
3 仙人掌怪 仙人掌怪 [仙人掌怪-boost;仙人掌怪-defend;仙人掌怪-attack] 防+强化。【尖刺X】:对攻击者造成X点伤害。
4 蛇 蛇 [蛇-poison;蛇-attack;蛇-boost] 攻+强化。给玩家塞入蛇毒牌(1费:打出时移除此牌。弃掉时受到3点伤害)。
5 木乃伊 木乃伊 [木乃伊-attack;木乃伊-defend;木乃伊-curse] 攻+防。【诅咒】:受攻击时物品【攻击】-1,直到弃掉一张该物品的牌。
6 枪手 枪手 [枪手-aim;枪手-attack;枪手-defend] 单回高攻。【瞄准X】:造成双倍伤害。受伤时失去等量【瞄准】。
7 风卷草 风卷草 [风卷草-boost;风卷草-defend;风卷草-attack] 防+强化。【滚动X】:攻击时,每消耗10点【滚动】,造成等量伤害。
8 秃鹫 秃鹫 [秃鹫-attack;秃鹫-defend] 攻+防。若造成伤害,玩家获得秃鹫之眼(0费状态牌:打出时移除。抓到时获得3层暴露)。
9 沙蝎 沙蝎 [沙蝎-boost;沙蝎-attack] 攻+强化。【尾刺X】:姿态buff,攻击时,伤害提升X。
10 幼沙虫 幼沙虫 [幼沙虫-defend;幼沙虫-boost;幼沙虫-attack] 防+强化。每回合第一次受伤时,玩家失去1点能量。
11 蜥蜴 蜥蜴 [蜥蜴-attack;蜥蜴-defend;蜥蜴-molt] 攻+防+逃跑。【脱皮】:若脱皮达到生命上限,则怪物逃跑,玩家不能获得战斗奖励。
12 沙匪 沙匪 [沙匪-attack;沙匪-heavyAttack;沙匪-debuff] 弱化玩家。【劫掠】:对玩家施加的延时debuff。回合开始时,随机弃掉一张手牌。
13 风暴之灵 风暴之灵 [风暴之灵-storm;风暴之灵-attack;风暴之灵-defend] 【风暴X】:攻击时,玩家获得1张静电。受伤时失去等量【风暴】。(静电:在手里时受【电击】伤害+1)
14 骑马枪手 骑马枪手 [骑马枪手-charge;骑马枪手-attack;骑马枪手-defend] 【冲锋X】:受到或造成的伤害翻倍并消耗等量的冲锋。
15 沙虫王 沙虫王 [沙虫王-summon;沙虫王-attack;沙虫王-defend] 召唤幼体沙虫;每当玩家弃掉一张牌,恢复1生命。
16 沙漠守卫 沙漠守卫 [沙漠守卫-summon;沙漠守卫-attack;沙漠守卫-defend;沙漠守卫-revive] 召唤木乃伊;会复活木乃伊2次。
@@ -0,0 +1,13 @@
import type { Intent } from './intent.csv';
type EnemyTable = readonly {
readonly id: string;
readonly name: string;
readonly intents: Intent[];
readonly description: string;
}[];
export type Enemy = EnemyTable[number];
declare function getData(): EnemyTable;
export default getData;
@@ -0,0 +1,25 @@
import getItems, { Item } from "./item.csv";
export type * from "./card.csv";
export type * from "./cardEffect.csv";
export type * from "./effect.csv";
export type * from "./encounter.csv";
export type * from "./enemy.csv";
export type * from "./intent.csv";
export type * from "./item.csv";
export { default as getCards } from "./card.csv";
export { default as getEffects } from "./effect.csv";
export { default as getEncounters } from "./encounter.csv";
export { default as getEnemies } from "./enemy.csv";
export { default as getIntents } from "./intent.csv";
export { default as getItems } from "./item.csv";
export { default as dialogues } from "./dialogues/dialogues.yarnproject";
export { addTriggers } from "./triggers";
const itemSet = ["剑", "盾", "水袋", "绷带"] as string[];
export function getStartingItems(): Item[] {
return itemSet.map(function (name): Item {
return getItems().find((item: Item) => item.name === name) as Item;
});
}
@@ -0,0 +1,55 @@
# intentId: unique intent state ID (e.g. '仙人掌怪-boost')
# enemy: enemy template this intent belongs to
# initialIntent: true if this is the enemy's starting intent
# nextIntents: possible next intent IDs after this intent resolves
# brokenIntent: possible intent IDs when defend is broken
# initBuffs: initial buffs for this intent (applied when intent becomes active)
# effects: effects executed when this intent is active
# type IntentEffectTarget = 'source' | 'eachEnemy' | 'randomEnemy' | 'player'
# type IntentEffect = [IntentEffectTarget;@effect;number]
# type IntentEffectList = IntentEffect[]
id,enemy,initialIntent,nextIntents,brokenIntent,effects
string,@enemy,boolean,@intent[],@intent[],IntentEffectList
仙人掌怪-boost,仙人掌怪,true,仙人掌怪-boost;仙人掌怪-defend,,[source;spike;1];[source;defend;4]
仙人掌怪-defend,仙人掌怪,false,仙人掌怪-attack,,[source;defend;8]
仙人掌怪-attack,仙人掌怪,false,仙人掌怪-boost,,[player;attack;5]
蛇-poison,蛇,true,蛇-attack;蛇-attack,,[player;venom;1];[player;attack;4]
蛇-attack,蛇,false,蛇-poison;蛇-boost,,[player;attack;6]
蛇-boost,蛇,false,蛇-poison;蛇-attack,,[source;defend;3];[player;venom;1]
木乃伊-attack,木乃伊,true,木乃伊-defend;木乃伊-curse,,[player;attack;6]
木乃伊-defend,木乃伊,false,木乃伊-attack,,[source;defend;6]
木乃伊-curse,木乃伊,false,木乃伊-defend;木乃伊-attack,木乃伊-attack,[player;curse;1]
枪手-aim,枪手,true,枪手-attack,,[source;aim;2]
枪手-attack,枪手,false,枪手-aim;枪手-defend,枪手-aim,[player;attack;8]
枪手-defend,枪手,false,枪手-aim,枪手-aim,[source;defend;5]
风卷草-boost,风卷草,true,风卷草-defend;风卷草-defend;风卷草-boost,,[source;roll;5];[source;defend;4]
风卷草-defend,风卷草,false,风卷草-boost;风卷草-attack,,[source;defend;8]
风卷草-attack,风卷草,false,风卷草-boost,,[player;rollDamage;0]
秃鹫-attack,秃鹫,true,秃鹫-defend;秃鹫-defend,,[player;attack;6];[player;vultureEye;1]
秃鹫-defend,秃鹫,false,秃鹫-attack;秃鹫-attack,,[source;defend;5]
沙蝎-boost,沙蝎,true,沙蝎-attack;沙蝎-attack,,[source;tailSting;2]
沙蝎-attack,沙蝎,false,沙蝎-boost;沙蝎-attack,,[player;attack;6]
幼沙虫-defend,幼沙虫,true,幼沙虫-defend;幼沙虫-boost,,[source;defend;6]
幼沙虫-boost,幼沙虫,false,幼沙虫-attack;幼沙虫-defend,,[source;energyDrain;1];[source;defend;4]
幼沙虫-attack,幼沙虫,false,幼沙虫-defend;幼沙虫-defend,,[player;attack;5]
蜥蜴-attack,蜥蜴,true,蜥蜴-defend;蜥蜴-molt,,[player;attack;5]
蜥蜴-defend,蜥蜴,false,蜥蜴-attack;蜥蜴-attack,,[source;defend;6]
蜥蜴-molt,蜥蜴,false,蜥蜴-defend;蜥蜴-attack,,[source;molt;3]
沙匪-attack,沙匪,true,沙匪-attack;沙匪-heavyAttack,,[player;attack;6]
沙匪-heavyAttack,沙匪,false,沙匪-attack;沙匪-attack;沙匪-debuff,,[player;attack;10]
沙匪-debuff,沙匪,false,沙匪-attack;沙匪-attack,,[player;discard;1]
风暴之灵-storm,风暴之灵,true,风暴之灵-attack;风暴之灵-storm,,[source;storm;2];[source;defend;3]
风暴之灵-attack,风暴之灵,false,风暴之灵-storm;风暴之灵-defend,,[player;attack;8];[player;static;1]
风暴之灵-defend,风暴之灵,false,风暴之灵-storm;风暴之灵-attack,,[source;defend;8]
骑马枪手-charge,骑马枪手,true,骑马枪手-attack,,[source;charge;2]
骑马枪手-attack,骑马枪手,false,骑马枪手-charge;骑马枪手-defend,骑马枪手-charge,[player;attack;6]
骑马枪手-defend,骑马枪手,false,骑马枪手-charge;骑马枪手-attack,骑马枪手-charge,[source;defend;5]
沙虫王-summon,沙虫王,true,沙虫王-attack;沙虫王-defend,,[source;summonSandwormLarva;18]
沙虫王-attack,沙虫王,false,沙虫王-summon;沙虫王-defend,,[player;attack;9]
沙虫王-defend,沙虫王,false,沙虫王-attack;沙虫王-summon,,[source;defend;6]
沙漠守卫-summon,沙漠守卫,true,沙漠守卫-attack;沙漠守卫-defend,,[source;summonMummy;14]
沙漠守卫-attack,沙漠守卫,false,沙漠守卫-defend;沙漠守卫-summon,,[player;attack;8]
沙漠守卫-defend,沙漠守卫,false,沙漠守卫-attack;沙漠守卫-revive,,[source;defend;8]
沙漠守卫-revive,沙漠守卫,false,沙漠守卫-attack;沙漠守卫-summon,,[source;reviveMummy;1]
1 # intentId: unique intent state ID (e.g. '仙人掌怪-boost')
2 # enemy: enemy template this intent belongs to
3 # initialIntent: true if this is the enemy's starting intent
4 # nextIntents: possible next intent IDs after this intent resolves
5 # brokenIntent: possible intent IDs when defend is broken
6 # initBuffs: initial buffs for this intent (applied when intent becomes active)
7 # effects: effects executed when this intent is active
8 # type IntentEffectTarget = 'source' | 'eachEnemy' | 'randomEnemy' | 'player'
9 # type IntentEffect = [IntentEffectTarget;@effect;number]
10 # type IntentEffectList = IntentEffect[]
11 id enemy initialIntent nextIntents brokenIntent effects
12 string @enemy boolean @intent[] @intent[] IntentEffectList
13 仙人掌怪-boost 仙人掌怪 true 仙人掌怪-boost;仙人掌怪-defend [source;spike;1];[source;defend;4]
14 仙人掌怪-defend 仙人掌怪 false 仙人掌怪-attack [source;defend;8]
15 仙人掌怪-attack 仙人掌怪 false 仙人掌怪-boost [player;attack;5]
16 蛇-poison 蛇 true 蛇-attack;蛇-attack [player;venom;1];[player;attack;4]
17 蛇-attack 蛇 false 蛇-poison;蛇-boost [player;attack;6]
18 蛇-boost 蛇 false 蛇-poison;蛇-attack [source;defend;3];[player;venom;1]
19 木乃伊-attack 木乃伊 true 木乃伊-defend;木乃伊-curse [player;attack;6]
20 木乃伊-defend 木乃伊 false 木乃伊-attack [source;defend;6]
21 木乃伊-curse 木乃伊 false 木乃伊-defend;木乃伊-attack 木乃伊-attack [player;curse;1]
22 枪手-aim 枪手 true 枪手-attack [source;aim;2]
23 枪手-attack 枪手 false 枪手-aim;枪手-defend 枪手-aim [player;attack;8]
24 枪手-defend 枪手 false 枪手-aim 枪手-aim [source;defend;5]
25 风卷草-boost 风卷草 true 风卷草-defend;风卷草-defend;风卷草-boost [source;roll;5];[source;defend;4]
26 风卷草-defend 风卷草 false 风卷草-boost;风卷草-attack [source;defend;8]
27 风卷草-attack 风卷草 false 风卷草-boost [player;rollDamage;0]
28 秃鹫-attack 秃鹫 true 秃鹫-defend;秃鹫-defend [player;attack;6];[player;vultureEye;1]
29 秃鹫-defend 秃鹫 false 秃鹫-attack;秃鹫-attack [source;defend;5]
30 沙蝎-boost 沙蝎 true 沙蝎-attack;沙蝎-attack [source;tailSting;2]
31 沙蝎-attack 沙蝎 false 沙蝎-boost;沙蝎-attack [player;attack;6]
32 幼沙虫-defend 幼沙虫 true 幼沙虫-defend;幼沙虫-boost [source;defend;6]
33 幼沙虫-boost 幼沙虫 false 幼沙虫-attack;幼沙虫-defend [source;energyDrain;1];[source;defend;4]
34 幼沙虫-attack 幼沙虫 false 幼沙虫-defend;幼沙虫-defend [player;attack;5]
35 蜥蜴-attack 蜥蜴 true 蜥蜴-defend;蜥蜴-molt [player;attack;5]
36 蜥蜴-defend 蜥蜴 false 蜥蜴-attack;蜥蜴-attack [source;defend;6]
37 蜥蜴-molt 蜥蜴 false 蜥蜴-defend;蜥蜴-attack [source;molt;3]
38 沙匪-attack 沙匪 true 沙匪-attack;沙匪-heavyAttack [player;attack;6]
39 沙匪-heavyAttack 沙匪 false 沙匪-attack;沙匪-attack;沙匪-debuff [player;attack;10]
40 沙匪-debuff 沙匪 false 沙匪-attack;沙匪-attack [player;discard;1]
41 风暴之灵-storm 风暴之灵 true 风暴之灵-attack;风暴之灵-storm [source;storm;2];[source;defend;3]
42 风暴之灵-attack 风暴之灵 false 风暴之灵-storm;风暴之灵-defend [player;attack;8];[player;static;1]
43 风暴之灵-defend 风暴之灵 false 风暴之灵-storm;风暴之灵-attack [source;defend;8]
44 骑马枪手-charge 骑马枪手 true 骑马枪手-attack [source;charge;2]
45 骑马枪手-attack 骑马枪手 false 骑马枪手-charge;骑马枪手-defend 骑马枪手-charge [player;attack;6]
46 骑马枪手-defend 骑马枪手 false 骑马枪手-charge;骑马枪手-attack 骑马枪手-charge [source;defend;5]
47 沙虫王-summon 沙虫王 true 沙虫王-attack;沙虫王-defend [source;summonSandwormLarva;18]
48 沙虫王-attack 沙虫王 false 沙虫王-summon;沙虫王-defend [player;attack;9]
49 沙虫王-defend 沙虫王 false 沙虫王-attack;沙虫王-summon [source;defend;6]
50 沙漠守卫-summon 沙漠守卫 true 沙漠守卫-attack;沙漠守卫-defend [source;summonMummy;14]
51 沙漠守卫-attack 沙漠守卫 false 沙漠守卫-defend;沙漠守卫-summon [player;attack;8]
52 沙漠守卫-defend 沙漠守卫 false 沙漠守卫-attack;沙漠守卫-revive [source;defend;8]
53 沙漠守卫-revive 沙漠守卫 false 沙漠守卫-attack;沙漠守卫-summon [source;reviveMummy;1]
@@ -0,0 +1,20 @@
import type { Enemy } from './enemy.csv';
import type { Effect } from './effect.csv';
export type IntentEffectTarget = 'source' | 'eachEnemy' | 'randomEnemy' | 'player';
export type IntentEffect = [IntentEffectTarget, Effect, number];
export type IntentEffectList = IntentEffect[];
type IntentTable = readonly {
readonly id: string;
readonly enemy: Enemy;
readonly initialIntent: boolean;
readonly nextIntents: Intent[];
readonly brokenIntent: Intent[];
readonly effects: IntentEffectList;
}[];
export type Intent = IntentTable[number];
declare function getData(): IntentTable;
export default getData;
@@ -0,0 +1,26 @@
id,type,name,shape,card,price,description
string,string,string,string,@card,int,string
sword,weapon,剑,oee,sword,50,【攻击2】【攻击2】
greataxe,weapon,长斧,oees,greataxe,80,对全体【攻击5】
spear,weapon,长枪,oeee,spear,75,【攻击2】【攻击2】【攻击2】
dagger,weapon,短刀,oe,dagger,40,【攻击3】【攻击3】
dart,weapon,飞镖,o,dart,30,【攻击1】抓一张牌
crossbow,weapon,十字弩,onrersrw,crossbow,120,【攻击6】对同一目标打出其他十字弩
shield,armor,盾,oesw,shield,50,【防御3】
hat,armor,斗笠,oerwrn,hat,90,【防御8】
cape,armor,披风,oers,cape,45,【防御2】下回合【防御2】
bracer,armor,护腕,o,bracer,25,【防御1】抓1张牌
greatshield,armor,大盾,oesswn,greatshield,70,【防御5】
chainmail,armor,锁子甲,oesw,chainmail,60,本回合受到伤害-3
bandage,consumable,绷带,o,bandage,20,从牌堆或弃牌堆随机移除1张伤口
poisonPotion,consumable,淬毒药剂,o,poisonPotion,30,周围物品的【攻击】+2
fortifyPotion,consumable,强固药剂,o,fortifyPotion,30,周围物品的【防御】+2
vitalityPotion,consumable,活力药剂,o,vitalityPotion,25,获得1点能量
focusPotion,consumable,集中药剂,o,focusPotion,25,抓2张牌
healingPotion,consumable,治疗药剂,o,healingPotion,35,从牌堆或弃牌堆移除3张伤口
waterBag,tool,水袋,os,waterBag,35,下回合开始时获得1能量抓2张牌
rope,tool,绳索,ose,rope,30,周围物品的牌【防御】+2直到打出
belt,tool,腰带,owre,belt,40,从牌堆周围物品的牌当中选择一张加入手牌
torch,tool,火把,on,torch,25,下次打出周围物品的牌时将其消耗并获得1能量
whetstone,tool,磨刀石,o,whetstone,30,周围物品的牌【攻击】+3直到打出
blacksmithHammer,tool,铁匠锤,oerwrs,blacksmithHammer,45,从牌堆/弃牌堆选择一张牌随机变为一张周围物品的牌
1 id type name shape card price description
2 string string string string @card int string
3 sword weapon 剑 oee sword 50 【攻击2】【攻击2】
4 greataxe weapon 长斧 oees greataxe 80 对全体【攻击5】
5 spear weapon 长枪 oeee spear 75 【攻击2】【攻击2】【攻击2】
6 dagger weapon 短刀 oe dagger 40 【攻击3】【攻击3】
7 dart weapon 飞镖 o dart 30 【攻击1】抓一张牌
8 crossbow weapon 十字弩 onrersrw crossbow 120 【攻击6】对同一目标打出其他十字弩
9 shield armor 盾 oesw shield 50 【防御3】
10 hat armor 斗笠 oerwrn hat 90 【防御8】
11 cape armor 披风 oers cape 45 【防御2】下回合【防御2】
12 bracer armor 护腕 o bracer 25 【防御1】抓1张牌
13 greatshield armor 大盾 oesswn greatshield 70 【防御5】
14 chainmail armor 锁子甲 oesw chainmail 60 本回合受到伤害-3
15 bandage consumable 绷带 o bandage 20 从牌堆或弃牌堆随机移除1张伤口
16 poisonPotion consumable 淬毒药剂 o poisonPotion 30 周围物品的【攻击】+2
17 fortifyPotion consumable 强固药剂 o fortifyPotion 30 周围物品的【防御】+2
18 vitalityPotion consumable 活力药剂 o vitalityPotion 25 获得1点能量
19 focusPotion consumable 集中药剂 o focusPotion 25 抓2张牌
20 healingPotion consumable 治疗药剂 o healingPotion 35 从牌堆或弃牌堆移除3张伤口
21 waterBag tool 水袋 os waterBag 35 下回合开始时获得1能量抓2张牌
22 rope tool 绳索 ose rope 30 周围物品的牌【防御】+2直到打出
23 belt tool 腰带 owre belt 40 从牌堆周围物品的牌当中选择一张加入手牌
24 torch tool 火把 on torch 25 下次打出周围物品的牌时将其消耗并获得1能量
25 whetstone tool 磨刀石 o whetstone 30 周围物品的牌【攻击】+3直到打出
26 blacksmithHammer tool 铁匠锤 oerwrs blacksmithHammer 45 从牌堆/弃牌堆选择一张牌随机变为一张周围物品的牌
@@ -0,0 +1,16 @@
import type { Card } from './card.csv';
type ItemTable = readonly {
readonly id: string;
readonly type: string;
readonly name: string;
readonly shape: string;
readonly card: Card;
readonly price: number;
readonly description: string;
}[];
export type Item = ItemTable[number];
declare function getData(): ItemTable;
export default getData;
@@ -0,0 +1,129 @@
import { Triggers } from "@/samples/slay-the-spire-like/system/combat/triggers";
import { getCombatEntity } from "@/samples/slay-the-spire-like/system/combat/effects";
import { EffectData } from "@/samples/slay-the-spire-like/system/types";
import getEffects from "../effect.csv";
import { IRunContext } from "@/samples/slay-the-spire-like/system/combat/types";
export function addCardEventTriggers(triggers: Triggers, run: IRunContext) {
const effects = getEffects();
function findEffect(id: string): EffectData {
const found = effects.find((e) => e.id === id);
if (found) return found;
return {
id,
name: id,
description: "",
lifecycle: "instant",
} as EffectData;
}
// storm: give static card to player when storm enemy attacks
triggers.onEnemyIntent.use(async (ctx, next) => {
await next();
const enemy = getCombatEntity(ctx.game.value, ctx.enemyId);
if (!enemy || !enemy.isAlive) return;
const storm = enemy.effects.storm?.stacks ?? 0;
if (storm > 0) {
for (let i = 0; i < storm; i++) {
await triggers.onEffectApplied.execute(ctx.game, {
effect: findEffect("static"),
entityId: "player",
stacks: 1,
sourceEntityId: ctx.enemyId,
});
}
}
});
// crossbow: replay other crossbows on same target
triggers.onEffectApplied.use(async (ctx, next) => {
await next();
if (ctx.effect.id !== "crossbow" || !ctx.cardId || !ctx.targetId) return;
const { cards, regions } = ctx.game.value.player.deck;
const handIds = [...regions.hand.childIds];
for (const id of handIds) {
const card = cards[id];
if (card && card.itemId === "crossbow" && id !== ctx.cardId) {
await triggers.onCardPlayed.execute(ctx.game, {
cardId: id,
targetId: ctx.targetId,
sourceEntityId: "player",
});
}
}
});
// burnForEnergy: consume adjacent item, gain energy when its card is played
triggers.onCardPlayed.use(async (ctx, next) => {
await next();
const card = ctx.game.value.player.deck.cards[ctx.cardId];
if (!card) return;
const playedItemId = card.itemId;
const adjacent = run.getAdjacentItems(playedItemId);
for (const adjItemId of adjacent) {
const adjEffects = ctx.game.value.player.itemEffects[adjItemId];
if (!adjEffects) continue;
const burn = adjEffects.burnForEnergy;
if (!burn || burn.stacks <= 0) continue;
const item = run.getItemData(adjItemId);
const maxUses =
item?.card.costType === "energy" ? item.card.costCount : 0;
const consumed = run.getConsumedUses(adjItemId);
const toConsume = Math.min(maxUses - consumed, burn.stacks);
await run.setConsumedUsesAsync(adjItemId, consumed + toConsume);
await ctx.game.produceAsync((draft) => {
draft.player.energy += burn.stacks;
delete draft.player.itemEffects[adjItemId];
});
break;
}
});
// sandwormKing: heal 10 hp when player discards fatigue
triggers.onCardDiscarded.use(async (ctx, next) => {
await next();
const card = ctx.game.value.player.deck.cards[ctx.cardId];
if (!card || card.cardData.id !== "fatigue") return;
const sandwormKing = ctx.game.value.enemies.find(
(e) => e.enemy.id === "沙虫王" && e.isAlive,
);
if (!sandwormKing) return;
await ctx.game.produceAsync((draft) => {
const king = draft.enemies.find((e) => e.id === sandwormKing.id);
if (king) {
king.hp = Math.min(king.hp + 10, king.maxHp);
}
});
});
// vulture: give vultureEye when vulture deals damage
triggers.onDamage.use(async (ctx, next) => {
await next();
const dealt = ctx.amount - (ctx.prevented ?? 0);
if (dealt <= 0 || !ctx.sourceEntityId) return;
const attacker = getCombatEntity(ctx.game.value, ctx.sourceEntityId);
if (!attacker || !("enemy" in attacker) || attacker.enemy.id !== "秃鹫")
return;
await triggers.onEffectApplied.execute(ctx.game, {
effect: findEffect("vultureEye"),
entityId: "player",
stacks: 1,
sourceEntityId: ctx.sourceEntityId,
});
});
}
@@ -0,0 +1,194 @@
import { Triggers } from "@/samples/slay-the-spire-like/system/combat/triggers";
import {
addEntityEffect,
getCombatEntity,
} from "@/samples/slay-the-spire-like/system/combat/effects";
import { EffectData } from "@/samples/slay-the-spire-like/system/types";
import getEffects from "../effect.csv";
export function addDamageTriggers(triggers: Triggers) {
const effects = getEffects();
function findEffect(id: string): EffectData {
const found = effects.find((e: EffectData) => e.id === id);
if (found) return found;
return {
id,
name: id,
description: "",
lifecycle: "instant",
} as EffectData;
}
// block / damage prevention
triggers.onDamage.use(async (ctx, next) => {
const entity = getCombatEntity(ctx.game.value, ctx.entityId);
if (!entity) return;
let preventable = ctx.amount - (ctx.prevented ?? 0);
// 1. apply damageReduce before block
const damageReduce = entity.effects.damageReduce?.stacks ?? 0;
if (damageReduce > 0) {
const reduced = Math.min(damageReduce, preventable);
ctx.prevented = (ctx.prevented ?? 0) + reduced;
preventable -= reduced;
}
// 2. consume defend for damage prevented
const blocks = entity.effects.defend?.stacks ?? 0;
const blocked = Math.min(blocks, preventable);
if (blocked > 0) {
ctx.prevented = (ctx.prevented ?? 0) + blocked;
preventable -= blocked;
await ctx.game.produceAsync((draft) => {
const e = getCombatEntity(draft, ctx.entityId);
if (e) addEntityEffect(e, findEffect("defend"), -blocked);
});
}
const expose = entity.effects.expose?.stacks ?? 0;
if (expose > 0) {
ctx.amount += expose;
}
await next();
});
// energyDrain: player loses energy when enemy takes damage
triggers.onDamage.use(async (ctx, next) => {
const entity = getCombatEntity(ctx.game.value, ctx.entityId);
if (!entity) return;
const energyDrain = entity.effects.energyDrain?.stacks ?? 0;
if (energyDrain > 0 && ctx.entityId !== "player") {
const dealt = Math.min(
Math.max(0, entity.hp),
ctx.amount - (ctx.prevented ?? 0),
);
if (dealt > 0) {
await ctx.game.produceAsync((draft) => {
draft.player.energy = Math.max(0, draft.player.energy - energyDrain);
});
}
}
await next();
});
// molt: enemy flees if molt >= maxHp
triggers.onDamage.use(async (ctx, next) => {
const entity = getCombatEntity(ctx.game.value, ctx.entityId);
if (!entity || !entity.isAlive) {
await next();
return;
}
const molt = entity.effects.molt?.stacks ?? 0;
if (molt >= entity.maxHp) {
await ctx.game.produceAsync((draft) => {
const e = draft.enemies.find((en) => en.id === ctx.entityId);
if (e) {
e.isAlive = false;
e.hp = 0;
}
draft.result = draft.enemies.every((en) => !en.isAlive)
? "victory"
: null;
});
if (ctx.game.value.result) throw ctx.game.value;
return;
}
await next();
});
// aim: double damage, lose aim on damage
triggers.onDamage.use(async (ctx, next) => {
if (ctx.sourceEntityId === "player") {
const player = ctx.game.value.player;
const aim = player.effects.aim?.stacks ?? 0;
if (aim > 0) {
ctx.amount *= 2;
}
}
await next();
});
// roll: consume 10 roll per 10 damage
triggers.onDamage.use(async (ctx, next) => {
if (ctx.sourceEntityId === "player") {
const player = ctx.game.value.player;
const roll = player.effects.roll?.stacks ?? 0;
if (roll >= 10) {
const rollDamage = Math.floor(roll / 10) * 10;
ctx.amount += rollDamage;
await ctx.game.produceAsync((draft) => {
addEntityEffect(draft.player, findEffect("roll"), -rollDamage);
});
}
}
await next();
});
// tailSting: bonus damage on attack
triggers.onDamage.use(async (ctx, next) => {
if (ctx.sourceEntityId && ctx.sourceEntityId !== "player") {
const attacker = getCombatEntity(ctx.game.value, ctx.sourceEntityId);
if (attacker) {
const tailSting = attacker.effects.tailSting?.stacks ?? 0;
if (tailSting > 0) {
ctx.amount += tailSting;
}
}
}
await next();
});
// charge: double damage dealt/received, consume equal charge
triggers.onDamage.use(async (ctx, next) => {
const entity = getCombatEntity(ctx.game.value, ctx.entityId);
if (entity) {
const charge = entity.effects.charge?.stacks ?? 0;
if (charge > 0) {
const dealt = Math.min(
Math.max(0, entity.hp),
ctx.amount - (ctx.prevented ?? 0),
);
const consumed = Math.min(charge, dealt);
ctx.amount += dealt;
if (consumed > 0) {
await ctx.game.produceAsync((draft) => {
const e = getCombatEntity(draft, ctx.entityId);
if (e) addEntityEffect(e, findEffect("charge"), -consumed);
});
}
}
}
if (ctx.sourceEntityId) {
const attacker = getCombatEntity(ctx.game.value, ctx.sourceEntityId);
if (attacker) {
const charge = attacker.effects.charge?.stacks ?? 0;
if (charge > 0) {
const baseAmount = ctx.amount;
const targetEntity = getCombatEntity(ctx.game.value, ctx.entityId);
const dealt = Math.min(
Math.max(0, targetEntity?.hp ?? 0),
baseAmount - (ctx.prevented ?? 0),
);
const consumed = Math.min(charge, dealt);
ctx.amount += dealt;
if (consumed > 0) {
await ctx.game.produceAsync((draft) => {
const a = getCombatEntity(draft, ctx.sourceEntityId!);
if (a) addEntityEffect(a, findEffect("charge"), -consumed);
});
}
}
}
}
await next();
});
}
@@ -0,0 +1,15 @@
import { addInstantEffectTriggers } from "./instant";
import { addDamageTriggers } from "./damage";
import { addTurnStartTriggers } from "./turn-start";
import { addCardEventTriggers } from "./card-events";
import {
IRunContext,
Triggers,
} from "@/samples/slay-the-spire-like/system/combat";
export function addTriggers(triggers: Triggers, run: IRunContext) {
addInstantEffectTriggers(triggers);
addDamageTriggers(triggers);
addTurnStartTriggers(triggers);
addCardEventTriggers(triggers, run);
}
@@ -0,0 +1,178 @@
import { Triggers } from "@/samples/slay-the-spire-like/system/combat/triggers";
import {
addEntityEffect,
getCombatEntity,
} from "@/samples/slay-the-spire-like/system/combat/effects";
import { moveToRegion } from "@/core/region";
import { CombatGameContext } from "@/samples/slay-the-spire-like/system/combat/types";
import { GameCard } from "@/samples/slay-the-spire-like/system/deck";
import getCards from "../card.csv";
export function addInstantEffectTriggers(triggers: Triggers) {
const cards = getCards();
function findCard(id: string) {
return cards.find((c) => c.id === id);
}
function createStatusCard(
draft: CombatGameContext["value"],
cardId: string,
): void {
const cardData = findCard(cardId);
if (!cardData) return;
const instanceId = `${cardId}-${draft.player.deck.regions.drawPile.childIds.length}-${draft.player.deck.regions.discardPile.childIds.length}`;
const card: GameCard = {
id: instanceId,
regionId: "",
position: [],
itemId: cardId,
cardData: {
id: cardData.id,
name: cardData.name,
desc: cardData.desc,
type: cardData.type,
costType: cardData.costType,
costCount: cardData.costCount,
targetType: cardData.targetType,
effects: cardData.effects,
},
};
draft.player.deck.cards[instanceId] = card;
moveToRegion(card, null, draft.player.deck.regions.drawPile);
}
function getAllEnemyDataFromState(state: CombatGameContext["value"]) {
const seen = new Set<string>();
const result: (typeof state.enemies)[number]["enemy"][] = [];
for (const enemy of state.enemies) {
if (!seen.has(enemy.enemy.id)) {
seen.add(enemy.enemy.id);
result.push(enemy.enemy);
}
}
return result;
}
function summonEnemy(
draft: CombatGameContext["value"],
enemyId: string,
hp: number,
) {
for (const enemyData of getAllEnemyDataFromState(draft)) {
if (enemyData.id === enemyId) {
const existing = draft.enemies.find(
(e) => e.enemy.id === enemyId && !e.isAlive,
);
if (existing) {
existing.isAlive = true;
existing.hp = existing.maxHp;
return;
}
const intent =
enemyData.intents.find((i) => i.initialIntent) ??
enemyData.intents[0];
const instanceId = `${enemyData.id}-${draft.enemies.length}`;
const intents: Record<string, typeof intent> = {};
for (const i of enemyData.intents) {
intents[i.id] = i;
}
draft.enemies.push({
id: instanceId,
enemy: enemyData,
hp,
maxHp: hp,
isAlive: true,
effects: {},
intents,
currentIntent: intent,
});
break;
}
}
}
triggers.onEffectApplied.use(async (ctx, next) => {
if (ctx.effect.id === "attack") {
await triggers.onDamage.execute(ctx.game, {
entityId: ctx.entityId,
amount: ctx.stacks,
sourceEntityId: ctx.sourceEntityId,
});
const entity = getCombatEntity(ctx.game.value, ctx.entityId);
const spike = entity?.effects.spike?.stacks ?? 0;
if (spike > 0 && ctx.sourceEntityId) {
await triggers.onDamage.execute(ctx.game, {
entityId: ctx.sourceEntityId,
amount: spike,
sourceEntityId: ctx.entityId,
});
}
} else if (ctx.effect.id === "draw") {
await triggers.onDraw.execute(ctx.game, { count: ctx.stacks });
} else if (ctx.effect.id === "gainEnergy") {
await ctx.game.produceAsync((draft) => {
draft.player.energy += ctx.stacks;
});
} else if (ctx.effect.id === "removeWound") {
await ctx.game.produceAsync((draft) => {
const { cards, regions } = draft.player.deck;
let removed = 0;
const allPileIds = [
...regions.drawPile.childIds,
...regions.discardPile.childIds,
];
for (const cardId of allPileIds) {
if (removed >= ctx.stacks) break;
const card = cards[cardId];
if (card && card.cardData.id === "wound") {
const sourceRegion =
card.regionId === "drawPile"
? regions.drawPile
: regions.discardPile;
moveToRegion(card, sourceRegion, null);
delete cards[cardId];
removed++;
}
}
});
} else if (ctx.effect.id === "venom") {
await ctx.game.produceAsync((draft) => {
createStatusCard(draft, "venom");
});
} else if (ctx.effect.id === "vultureEye") {
await ctx.game.produceAsync((draft) => {
createStatusCard(draft, "vultureEye");
});
} else if (ctx.effect.id === "static") {
await ctx.game.produceAsync((draft) => {
createStatusCard(draft, "static");
});
} else if (ctx.effect.id === "summonMummy") {
await ctx.game.produceAsync((draft) => {
summonEnemy(draft, "木乃伊", ctx.stacks);
});
} else if (ctx.effect.id === "summonSandwormLarva") {
await ctx.game.produceAsync((draft) => {
summonEnemy(draft, "幼沙虫", ctx.stacks);
});
} else if (ctx.effect.id === "reviveMummy") {
await ctx.game.produceAsync((draft) => {
const deadMummy = draft.enemies.find(
(e) => e.enemy.id === "木乃伊" && !e.isAlive,
);
if (deadMummy) {
deadMummy.isAlive = true;
deadMummy.hp = deadMummy.maxHp;
}
});
} else if (ctx.effect.id === "curse") {
await ctx.game.produceAsync((draft) => {
addEntityEffect(draft.player, ctx.effect, ctx.stacks);
});
}
await next();
});
}

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