Compare commits

..
114 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
127 changed files with 10750 additions and 9854 deletions
+28 -7
View File
@@ -36,15 +36,36 @@ Run tests matching a name: `npx vitest run -t "should create"`
```
src/
core/ # game.ts, game-host.ts, part.ts, part-factory.ts, region.ts
samples/ # tic-tac-toe.ts, boop/, onitama/, regicide/, slay-the-spire-like/
utils/ # mutable-signal.ts, rng.ts, async-queue.ts, command/ (8 files)
index.ts # barrel export
global.d.ts # *.yarnproject module declaration
tests/ # mirrors src/ with *.test.ts
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/utils/command/` is a directory: types.ts, command-parse.ts, command-registry.ts, command-runner.ts, command-validate.ts, command-apply.ts, schema-parse.ts, index.ts.
`src/global.d.ts` — `*.yarnproject` module declaration.
`testsgames/` and `testsrules/` — empty directories (reserved).
## Samples Build
+18
View File
@@ -10,6 +10,7 @@
"license": "MIT",
"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",
@@ -958,6 +959,16 @@
"dev": true,
"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": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz",
@@ -2469,6 +2480,13 @@
"dev": true,
"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": {
"version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
+2 -1
View File
@@ -18,7 +18,7 @@
"scripts": {
"build": "tsup",
"build:samples": "tsup --config tsup.samples.config.ts",
"prepare": "npm run build",
"prepare": "npm run build && npm run build:samples",
"test": "vitest",
"test:run": "vitest run",
"typecheck": "tsc --noEmit"
@@ -30,6 +30,7 @@
},
"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",
+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);
});
}
}
+101 -157
View File
@@ -1,180 +1,124 @@
import { ReadonlySignal, Signal } from '@preact/signals-core';
import {
CommandSchema,
CommandRegistry,
PromptEvent,
} from '@/utils/command';
import {createGameCommandRegistry, createGameContext, IGameContext, PromptDef} from './game';
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 type GameHostStatus = "created" | "running" | "disposed";
export class GameHost<TState extends Record<string, unknown>, TResult=unknown> {
readonly state: ReadonlySignal<TState>;
readonly status: ReadonlySignal<GameHostStatus>;
readonly activePromptSchema: ReadonlySignal<CommandSchema | null>;
readonly activePromptPlayer: ReadonlySignal<string | null>;
readonly activePromptHint: ReadonlySignal<string | null>;
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 _activePromptSchema: Signal<CommandSchema | null>;
private _activePromptPlayer: Signal<string | null>;
private _activePromptHint: Signal<string | null>;
private _createInitialState: () => TState;
private _eventListeners: Map<'start' | 'dispose', Set<() => void>>;
private _isDisposed = false;
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(
registry: CommandRegistry<IGameContext<TState>>,
createInitialState: () => TState,
start: (ctx: IGameContext<TState>) => Promise<TResult>
) {
this._createInitialState = createInitialState;
this._eventListeners = new Map();
constructor(public readonly gameModule: TModule) {
const { createInitialState, start } = gameModule;
this._createInitialState = createInitialState;
this._eventListeners = new Map();
const initialState = createInitialState();
this._context = createGameContext(registry, initialState);
this._start = start;
this.state = this._context._state;
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;
this._status = new Signal<GameHostStatus>("created");
this.status = this._status;
}
this._activePromptSchema = new Signal<CommandSchema | null>(null);
this.activePromptSchema = this._activePromptSchema;
/**
* 为下一个 produceAsync 注册中断 Promise(通常用于 UI 动画)。
* @see MutableSignal.addInterruption
*/
addInterruption(promise: Promise<void>): void {
this._context._state.addInterruption(promise);
}
this._activePromptPlayer = new Signal<string | null>(null);
this.activePromptPlayer = this._activePromptPlayer;
/**
* 清除所有未完成的中断。
* @see MutableSignal.clearInterruptions
*/
clearInterruptions(): void {
this._context._state.clearInterruptions();
}
this._activePromptHint = new Signal<string | null>(null);
this.activePromptHint = this._activePromptHint;
this._setupPromptTracking();
start(seed?: number): Promise<TResult> {
if (this._isDisposed) {
throw new Error("GameHost is disposed");
}
private _setupPromptTracking() {
let currentPromptEvent: PromptEvent | null = null;
this.prompts.reset();
this._context._commands.on('prompt', (e) => {
currentPromptEvent = e as PromptEvent;
this._activePromptSchema.value = currentPromptEvent.schema;
this._activePromptPlayer.value = currentPromptEvent.currentPlayer;
this._activePromptHint.value = currentPromptEvent.hintText || null;
});
const initialState = this._createInitialState();
this._context._state.value = initialState as any;
this._context._commands.on('promptEnd', () => {
currentPromptEvent = null;
this._activePromptSchema.value = null;
this._activePromptPlayer.value = null;
this._activePromptHint.value = null;
});
seed = seed || Math.floor(Math.random() * Number.MAX_SAFE_INTEGER);
this._context._rng.setSeed(seed);
const promise = this._start(this._context);
// Initial state
this._activePromptSchema.value = null;
this._activePromptPlayer.value = null;
this._activePromptHint.value = null;
this._status.value = "running";
this._emitEvent("start");
return promise;
}
dispose(): void {
if (this._isDisposed) {
return;
}
tryInput(input: string): string | null {
if (this._isDisposed) {
return 'GameHost is disposed';
}
return this._context._commands._tryCommit(input);
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);
tryAnswerPrompt<TArgs extends any[]>(def: PromptDef<TArgs>, ...args: TArgs){
return this._context._commands._tryCommit({
name: def.schema.name,
params: args,
options: {},
flags: {}
});
}
/**
* 为下一个 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._context._commands._cancel();
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._context._commands._cancel();
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();
}
}
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>, TResult=unknown> = {
registry?: CommandRegistry<IGameContext<TState>>;
createInitialState: () => TState;
start: (ctx: IGameContext<TState>) => Promise<TResult>;
}
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>>(
gameModule: GameModule<TState>
): GameHost<TState> {
return new GameHost(
gameModule.registry || createGameCommandRegistry(),
gameModule.createInitialState,
gameModule.start
);
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);
}
+53 -69
View File
@@ -1,80 +1,64 @@
import {MutableSignal, mutableSignal} from "@/utils/mutable-signal";
import {
Command,
CommandRegistry, CommandResult,
CommandRunnerContextExport,
CommandSchema,
createCommandRegistry,
createCommandRunnerContext, parseCommandSchema,
} from "@/utils/command";
import {PromptValidator} from "@/utils/command/command-runner";
import {Mulberry32RNG, ReadonlyRNG, RNG} from "@/utils/rng";
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) => void): void;
produceAsync(fn: (draft: TState) => void): Promise<void>;
run<T>(input: string): Promise<CommandResult<T>>;
runParsed<T>(command: Command): Promise<CommandResult<T>>;
prompt: <TResult,TArgs extends any[]=any[]>(def: PromptDef<TArgs>, validator: PromptValidator<TResult,TArgs>, currentPlayer?: string | null) => Promise<TResult>;
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>;
_commands: CommandRunnerContextExport<IGameContext<TState>>;
_rng: RNG;
// 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> = {} >(
commandRegistry: CommandRegistry<IGameContext<TState>>,
initialState?: TState | (() => TState)
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);
let commands: CommandRunnerContextExport<IGameContext<TState>> = null as any;
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) {
return state.produce(fn);
},
produceAsync(fn) {
return state.produceAsync(fn);
},
run<T>(input: string) {
return commands.run<T>(input);
},
runParsed<T>(command: Command) {
return commands.runParsed<T>(command);
},
prompt(def, validator, currentPlayer) {
return commands.prompt(def.schema, validator, def.hintText, currentPlayer);
},
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,
_commands: commands,
_rng: new Mulberry32RNG(),
};
_state: state,
_rng: new Mulberry32RNG(),
};
context._commands = commands = createCommandRunnerContext(commandRegistry, context);
return context;
return context;
}
export type PromptDef<TArgs extends any[]=any[]> = {
schema: CommandSchema,
hintText?: string,
}
export function createPromptDef<TArgs extends any[]=any[]>(schema: CommandSchema | string, hintText?: string): PromptDef<TArgs> {
schema = typeof schema === 'string' ? parseCommandSchema(schema) : schema;
return { schema, hintText };
}
export function createGameCommandRegistry<TState extends Record<string, unknown> = {} >() {
return createCommandRegistry<IGameContext<TState>>();
export function createPromptDef<TArgs extends any[] = any[]>(
schema: CommandSchema | string,
hintText?: string,
): PromptDef<TArgs> {
schema = typeof schema === "string" ? parseCommandSchema(schema) : schema;
return { schema, hintText };
}
+2 -2
View File
@@ -1,5 +1,5 @@
import {Part} from "./part";
import {RNG} from "@/utils/rng";
import {ReadonlyRNG} from "@/utils/rng";
export type Region = {
id: string;
@@ -107,7 +107,7 @@ export function applyAlign<TMeta>(region: Region, parts: Record<string, Part<TMe
region.partMap = buildPartMap(region, parts);
}
export function shuffle<TMeta>(region: Region, parts: Record<string, Part<TMeta>>, rng: RNG){
export function shuffle<TMeta>(region: Region, parts: Record<string, Part<TMeta>>, rng: ReadonlyRNG){
if (region.childIds.length <= 1) return;
const childIds = [...region.childIds];
-5
View File
@@ -1,5 +0,0 @@
declare module '*.yarnproject' {
import type { LoadResult } from 'yarn-spinner-loader';
const result: LoadResult;
export default result;
}
+62 -18
View File
@@ -4,31 +4,75 @@
*/
// Core types
export type { IGameContext } from './core/game';
export { createGameCommandRegistry, createPromptDef } from './core/game';
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 { 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 type { Part } from "./core/part";
export { flip, flipTo, roll } from "./core/part";
export { createParts, createPartsFromTable } from './core/part-factory';
export { createParts, createPartsFromTable } from "./core/part-factory";
export type { Region, RegionAxis } from './core/region';
export { createRegion, createRegionAxis, applyAlign, shuffle, moveToRegion } from './core/region';
export type { Region, RegionAxis } from "./core/region";
export {
createRegion,
createRegionAxis,
applyAlign,
shuffle,
moveToRegion,
} from "./core/region";
export * from "./core/game-client";
// Utils
export type { Command, CommandDef, CommandResult, CommandSchema, CommandParamSchema, CommandOptionSchema, CommandFlagSchema } from './utils/command';
export { parseCommand, parseCommandSchema, validateCommand, parseCommandWithSchema, applyCommandSchema } from './utils/command';
export type {
Command,
CommandDef,
CommandResult,
CommandSchema,
CommandParamSchema,
CommandOptionSchema,
CommandFlagSchema,
} from "./utils/command";
export {
parseCommand,
parseCommandSchema,
validateCommand,
parseCommandWithSchema,
applyCommandSchema,
} from "./utils/command";
export type { CommandRunner, CommandRunnerHandler, CommandRunnerContext, PromptEvent, CommandRunnerEvents } from './utils/command';
export { createCommandRegistry, registerCommand, unregisterCommand, hasCommand, getCommand, runCommand, runCommandParsed, createCommandRunnerContext, type CommandRegistry, type CommandRunnerContextExport } from './utils/command';
export type {
CommandRunner,
CommandRunnerHandler,
CommandRunnerContext,
PromptEvent,
CommandRunnerEvents,
} from "./utils/command";
export {
createCommandRegistry,
registerCommand,
unregisterCommand,
hasCommand,
getCommand,
runCommand,
runCommandParsed,
createCommandRunnerContext,
type CommandRegistry,
type CommandRunnerContextExport,
} from "./utils/command";
export type { MutableSignal } from './utils/mutable-signal';
export { mutableSignal } from './utils/mutable-signal';
export type { MutableSignal } from "./utils/mutable-signal";
export { mutableSignal } from "./utils/mutable-signal";
export type { RNG } from './utils/rng';
export { createRNG, Mulberry32RNG } from './utils/rng';
export type { RNG, ReadonlyRNG } from "./utils/rng";
export { createRNG, Mulberry32RNG } from "./utils/rng";
export * from '@preact/signals-core';
export type { MiddlewareChain } from "./utils/middleware";
export { createMiddlewareChain } from "./utils/middleware";
export * from "./utils/spawner";
export * from "@preact/signals-core";
+22 -92
View File
@@ -1,101 +1,31 @@
import {BoopGame} from "@/samples/boop/types-extensions";
import {PlayerType} from "@/samples/boop/types";
import {turn} from "@/samples/boop/commands/turn";
import {createGameCommandRegistry} from "@/core/game";
export const registry = createGameCommandRegistry<BoopGame['value']>();
/**
* 放置棋子到棋盘
*/
const placeCmd = registry.register({
schema: 'place <row:number> <col:number> <player> <type>',
run: async (game, row, col, player, type) => {
const {place} = await import('./place');
return place(game, row, col, player, type);
}
});
/**
* 执行boop - 推动周围棋子
*/
const boopCmd = registry.register({
schema: 'boop <row:number> <col:number> <type>',
run: async (game, row, col, type) => {
const {boop} = await import('./boop');
return boop(game, row, col, type);
}
});
/**
* 检查是否有玩家获胜(三个猫连线)
*/
const checkWinCmd = registry.register({
schema: 'check-win',
run: async (game) => {
const {checkWin} = await import('./win');
return checkWin(game);
}
});
/**
* 检查并执行小猫升级(三个小猫连线变成猫)
*/
const checkGraduatesCmd = registry.register({
schema: 'check-graduates',
run: async (game) => {
const {checkGraduates} = await import('./graduate');
return checkGraduates(game);
}
});
/**
* 检查并执行 8-piece 规则
*/
const checkFullBoardCmd = registry.register({
schema: 'check-full-board <player:string>',
run: async (game, player) => {
const {checkFullBoard} = await import('./full-board');
return checkFullBoard(game, player);
}
});
/**
* 处理一个回合
*/
const turnCmd = registry.register({
schema: 'turn <player:string>',
run: async (game, player) => {
const {turn} = await import('./turn');
return turn(game, player);
}
});
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);
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;
}
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;
return game.value;
}
export {
placeCmd as place,
boopCmd as boop,
checkWinCmd as checkWin,
checkGraduatesCmd as checkGraduates,
checkFullBoardCmd as checkFullBoard,
turnCmd as turn
};
export { place, boop, checkWin, checkGraduates, checkFullBoard, turn };
+26 -34
View File
@@ -1,49 +1,41 @@
// Types
export type {
PlayerType,
PieceType,
WinnerType,
RegionType,
BoopPartMeta,
BoopPart
} from './types';
PlayerType,
PieceType,
WinnerType,
RegionType,
BoopPartMeta,
BoopPart,
} from "./types";
export type {BoopGame} from './types-extensions';
export type { BoopGame } from "./types-extensions";
// Constants
export {
BOARD_SIZE,
MAX_PIECES_PER_PLAYER,
WIN_LENGTH
} from './constants';
export { BOARD_SIZE, MAX_PIECES_PER_PLAYER, WIN_LENGTH } from "./constants";
// State
export {
createInitialState,
type BoopState
} from './state';
export { createInitialState, type BoopState } from "./state";
// Prompts
export {prompts} from './prompts';
export { prompts } from "./prompts";
// Commands
export {
registry,
start,
place as placeCmd,
boop as boopCmd,
checkWin as checkWinCmd,
checkGraduates as checkGraduatesCmd,
checkFullBoard as checkFullBoardCmd,
turn as turnCmd
} from './commands';
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';
getLineCandidates,
isInBounds,
isCellOccupied,
getNeighborPositions,
findPartInRegion,
findPartAtPosition,
} from "./utils";
+294 -264
View File
@@ -1,379 +1,409 @@
import {
OnitamaGame,
OnitamaState,
PlayerType,
prompts,
initializeCards
OnitamaGame,
OnitamaState,
PlayerType,
prompts,
initializeCards,
} from "./types";
import {createGameCommandRegistry} from "@/core/game";
import {moveToRegion} from "@/core/region";
export const registry = createGameCommandRegistry<OnitamaState>();
import { moveToRegion } from "@/core/region";
/**
* 检查位置是否在棋盘范围内
*/
function isInBounds(x: number, y: number): boolean {
return x >= 0 && x < 5 && y >= 0 && y < 5;
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;
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);
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;
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 }));
}
// 黑方需要将卡牌旋转180度
if (player === "black") {
return candidates.map((m) => ({ dx: -m.dx, dy: -m.dy }));
}
return candidates;
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}`;
}
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 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 card = state.cards[cardName];
if (!card) {
return `卡牌 ${cardName} 不存在`;
}
// 计算移动偏移量
const dx = toX - fromX;
const dy = toY - fromY;
// 计算移动偏移量
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})`;
}
// 检查移动是否在卡牌的移动候选项中(黑方需要旋转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}) 超出棋盘范围`;
}
// 检查目标位置是否在棋盘内
if (!isInBounds(toX, toY)) {
return `目标位置 (${toX}, ${toY}) 超出棋盘范围`;
}
// 检查目标位置是否有己方棋子
const toPawn = getPawnAtPosition(state, toX, toY);
if (toPawn && toPawn.owner === player) {
return `目标位置 (${toX}, ${toY}) 已有己方棋子`;
}
// 检查目标位置是否有己方棋子
const toPawn = getPawnAtPosition(state, toX, toY);
if (toPawn && toPawn.owner === player) {
return `目标位置 (${toX}, ${toY}) 已有己方棋子`;
}
return null;
return null;
}
/**
* 执行移动
*/
async function handleMove(game: OnitamaGame, player: PlayerType, cardName: string, fromX: number, fromY: number, toX: number, toY: number) {
const state = game.value;
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 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);
}
const capturedPawnId = getPawnAtPosition(state, toX, toY)?.id || null;
// 移动棋子到目标位置
moveToRegion(pawn, state.regions.board, state.regions.board, [toX, toY]);
});
await game.produceAsync(state => {
const pawn = state.pawns[getPawnAtPosition(state, fromX, fromY)!.id];
// 交换卡牌
await swapCard(game, player, cardName);
// 如果目标位置有敌方棋子,将其移除(吃掉)
if (capturedPawnId) {
const capturedPawn = state.pawns[capturedPawnId];
moveToRegion(capturedPawn, state.regions.board, null);
}
// 移动棋子到目标位置
moveToRegion(pawn, state.regions.board, state.regions.board, [toX, toY]);
});
// 交换卡牌
await handleSwapCard(game, player, cardName);
return {
from: { x: fromX, y: fromY },
to: { x: toX, y: toY },
card: cardName,
captured: capturedPawnId
};
return {
from: { x: fromX, y: fromY },
to: { x: toX, y: toY },
card: cardName,
captured: capturedPawnId,
};
}
const move = registry.register({
schema: 'move <player> <card:string> <fromX:number> <fromY:number> <toX:number> <toY:number>',
run: handleMove
});
/**
* 交换卡牌:将使用的卡牌与备用卡牌交换
*/
async function handleSwapCard(game: OnitamaGame, player: PlayerType, usedCard: string) {
await game.produceAsync(state => {
const spareCard = state.spareCard;
const usedCardData = state.cards[usedCard];
const spareCardData = state.cards[spareCard];
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);
}
// 从玩家手牌中移除使用的卡牌
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;
// 更新卡牌区域
usedCardData.regionId = "spare";
spareCardData.regionId = player;
// 更新备用卡牌
state.spareCard = usedCard;
});
// 更新备用卡牌
state.spareCard = usedCard;
});
}
const swapCard = registry.register({
schema: 'swap-card <player> <card:string>',
run: handleSwapCard
});
/**
* 检查占领胜利条件:玩家的师父棋子到达对手的初始位置
* 红色师父需要到达 (2, 4) - 黑色师父的初始位置
* 黑色师父需要到达 (2, 0) - 红色师父的初始位置
*/
async function handleCheckConquestWin(game: OnitamaGame): Promise<PlayerType | null> {
const state = game.value;
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, 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';
}
// 黑色师父到达 (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;
return null;
}
const checkConquestWin = registry.register({
schema: 'check-conquest-win',
run: handleCheckConquestWin
});
/**
* 检查吃掉胜利条件:对手的师父棋子被吃掉(不在棋盘上)
*/
async function handleCheckCaptureWin(game: OnitamaGame): Promise<PlayerType | null> {
const state = game.value;
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 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';
}
// 黑色师父不在棋盘上,红色获胜
const blackMaster = state.pawns["black-master"];
if (!blackMaster || blackMaster.regionId !== "board") {
return "red";
}
return null;
return null;
}
const checkCaptureWin = registry.register({
schema: 'check-capture-win',
run: handleCheckCaptureWin
});
/**
* 综合胜利检测
*/
async function handleCheckWin(game: OnitamaGame): Promise<PlayerType | null> {
const conquestWinner = await handleCheckConquestWin(game);
if (conquestWinner) {
return conquestWinner;
}
async function checkWin(game: OnitamaGame): Promise<PlayerType | null> {
const conquestWinner = await checkConquestWin(game);
if (conquestWinner) {
return conquestWinner;
}
const captureWinner = await handleCheckCaptureWin(game);
if (captureWinner) {
return captureWinner;
}
const captureWinner = await checkCaptureWin(game);
if (captureWinner) {
return captureWinner;
}
return null;
return null;
}
const checkWin = registry.register({
schema: 'check-win',
run: handleCheckWin
});
/**
* 获取玩家可用的移动
*/
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}> = [];
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 cardNames = player === "red" ? state.redCards : state.blackCards;
// 获取玩家的所有棋子
const playerPawns = Object.values(state.pawns).filter(p => p.owner === player && p.regionId === 'board');
// 获取玩家的所有棋子
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 cardName of cardNames) {
// 获取旋转后的移动候选项(黑方需要旋转180度)
const candidates = getCardMoveCandidates(state, cardName, player);
// 对于每个棋子
for (const pawn of playerPawns) {
const [fromX, fromY] = pawn.position;
// 对于每个棋子
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;
// 对于卡牌的每个移动
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 });
}
}
}
// 检查移动是否合法
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;
return moves;
}
/**
* 处理回合
*/
async function handleTurn(game: OnitamaGame, turnPlayer: PlayerType) {
const state = game.value;
const availableMoves = getAvailableMoves(state, turnPlayer);
async function turn(game: OnitamaGame, turnPlayer: PlayerType) {
const state = game.value;
const availableMoves = getAvailableMoves(state, turnPlayer);
let moveOutput;
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++;
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,
);
return { winner, move: moveOutput };
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 };
}
const turn = registry.register({
schema: 'turn <player>',
run: handleTurn
});
/**
* 开始游戏主循环
*/
export async function start(game: OnitamaGame) {
// Initialize cards with RNG at game start
initializeCards(game);
// Initialize cards with RNG at game start
initializeCards(game);
while (true) {
const currentPlayer = game.value.currentPlayer;
const turnOutput = await turn(game, currentPlayer);
while (true) {
const currentPlayer = game.value.currentPlayer;
const turnOutput = await turn(game, currentPlayer);
if (turnOutput.winner) {
break;
}
if (turnOutput.winner) {
break;
}
}
return game.value;
return game.value;
}
+342 -346
View File
@@ -1,421 +1,417 @@
import {IGameContext} from "@/core/game";
import {RegicideState} from "@/samples/regicide/state";
import {createGameCommandRegistry} from "@/core/game";
import {PlayerType, RegicideCard} from "@/samples/regicide/types";
import {CARD_VALUES, FACE_CARDS} from "@/samples/regicide/constants";
import {isEnemyDefeated} from "@/samples/regicide/utils";
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>;
export const registry = createGameCommandRegistry<RegicideState>();
/**
* 打出一张牌(对当前敌人造成伤害)
*/
const playCmd = registry.register({
schema: 'play <player:string> <cardId:string>',
run: async (game: RegicideGame, player: string, cardId: string) => {
const state = game.value;
const card = state.cards[cardId];
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} 不存在`};
}
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} 的手牌中`};
}
// 检查卡牌是否在玩家手牌中
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: '没有活跃的敌人'};
}
// 检查是否有当前敌人
if (!state.currentEnemy) {
return { success: false, error: "没有活跃的敌人" };
}
// 计算伤害(基础伤害为卡牌面值)
let damage = card.value;
let attackReduction = 0;
// 计算伤害(基础伤害为卡牌面值)
let damage = card.value;
let attackReduction = 0;
// 梅花双倍伤害
if (card.suit === 'clubs') {
damage *= 2;
}
// 梅花双倍伤害
if (card.suit === "clubs") {
damage *= 2;
}
// 黑桃降低敌人攻击力
if (card.suit === 'spades') {
attackReduction = card.value;
}
// 黑桃降低敌人攻击力
if (card.suit === "spades") {
attackReduction = card.value;
}
const enemyHpBefore = state.currentEnemy.hp;
const enemyHpBefore = state.currentEnemy.hp;
await game.produce(state => {
// 对敌人造成伤害
state.currentEnemy!.hp -= damage;
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
}
};
// 记录黑桃的攻击力降低
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配合另一张牌
*/
const playWithACmd = registry.register({
schema: 'play-with-a <player:string> <aceCardId:string> <otherCardId:string>',
run: async (game: RegicideGame, player: string, aceCardId: string, otherCardId: string) => {
const state = game.value;
const aceCard = state.cards[aceCardId];
const otherCard = state.cards[otherCardId];
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: '卡牌不存在'};
}
if (!aceCard || !otherCard) {
return { success: false, error: "卡牌不存在" };
}
// 检查是否是A牌
if (aceCard.rank !== 'A') {
return {success: false, error: `第一张牌必须是A`};
}
// 检查是否是A牌
if (aceCard.rank !== "A") {
return { success: false, error: `第一张牌必须是A` };
}
const playerKey = player as PlayerType;
const playerHand = state.playerHands[playerKey];
const playerKey = player as PlayerType;
const playerHand = state.playerHands[playerKey];
// 检查两张牌都在手牌中
if (!playerHand.includes(aceCardId) || !playerHand.includes(otherCardId)) {
return {success: false, error: '卡牌不在手牌中'};
}
// 检查两张牌都在手牌中
if (!playerHand.includes(aceCardId) || !playerHand.includes(otherCardId)) {
return { success: false, error: "卡牌不在手牌中" };
}
if (!state.currentEnemy) {
return {success: false, error: '没有活跃的敌人'};
}
if (!state.currentEnemy) {
return { success: false, error: "没有活跃的敌人" };
}
// 计算两张牌的总伤害
let totalDamage = aceCard.value + otherCard.value;
// 计算两张牌的总伤害
let totalDamage = aceCard.value + otherCard.value;
// 如果另一张牌是梅花,双倍伤害
if (otherCard.suit === 'clubs') {
totalDamage *= 2;
}
// 如果另一张牌是梅花,双倍伤害
if (otherCard.suit === "clubs") {
totalDamage *= 2;
}
let attackReduction = 0;
if (aceCard.suit === 'spades') {
attackReduction += aceCard.value;
}
if (otherCard.suit === 'spades') {
attackReduction += otherCard.value;
}
let attackReduction = 0;
if (aceCard.suit === "spades") {
attackReduction += aceCard.value;
}
if (otherCard.suit === "spades") {
attackReduction += otherCard.value;
}
await game.produce(state => {
// 对敌人造成伤害
state.currentEnemy!.hp -= totalDamage;
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
}
};
// 记录黑桃的攻击力降低
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,
},
};
}
/**
* 让过(不出牌)
*/
const passCmd = registry.register({
schema: 'pass <player:string>',
run: async (game: RegicideGame, player: string) => {
// 即使让过,也会受到敌人反击(在回合结束时处理)
return {success: true, result: {message: `${player} 让过`}};
}
});
async function passCmd(game: RegicideGame, player: string) {
// 即使让过,也会受到敌人反击(在回合结束时处理)
return { success: true, result: { message: `${player} 让过` } };
}
/**
* 敌人反击 - 玩家必须弃掉点数和 >= 敌人攻击力的牌
*/
const enemyCounterattackCmd = registry.register({
schema: 'counterattack <player:string> <discardCards:string[]>',
run: async (game: RegicideGame, player: string, discardCards: string[]) => {
const state = game.value;
async function enemyCounterattackCmd(
game: RegicideGame,
player: string,
discardCards: string[],
) {
const state = game.value;
if (!state.currentEnemy) {
return {success: false, error: '没有活跃的敌人'};
}
if (!state.currentEnemy) {
return { success: false, error: "没有活跃的敌人" };
}
const playerKey = player as PlayerType;
const playerHand = state.playerHands[playerKey];
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.produce(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
}
};
// 检查要弃的牌都在手牌中
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,
},
};
}
/**
* 检查敌人是否被击败,如果击败则翻开下一个敌人
*/
const checkEnemyDefeatedCmd = registry.register({
schema: 'check-enemy',
run: async (game: RegicideGame) => {
const state = game.value;
async function checkEnemyDefeatedCmd(game: RegicideGame) {
const state = game.value;
if (!state.currentEnemy) {
return {success: false, error: '没有活跃的敌人'};
}
if (!state.currentEnemy) {
return { success: false as const, error: "没有活跃的敌人" };
}
const defeated = state.currentEnemy.hp <= 0;
const defeated = state.currentEnemy.hp <= 0;
if (defeated) {
const defeatedEnemy = {...state.currentEnemy};
if (defeated) {
const defeatedEnemy = { ...state.currentEnemy };
await game.produce(state => {
// 将当前敌人移到弃牌堆
state.regions.discardPile.childIds.push(state.currentEnemy!.id);
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 (state.enemyDeck.length > 0) {
const nextEnemy = state.enemyDeck.shift()!;
state.currentEnemy = nextEnemy;
} else {
// 没有更多敌人了
state.currentEnemy = null;
}
});
// 检查是否胜利(没有更多敌人)
if (!game.value.currentEnemy) {
await game.produce(state => {
state.phase = 'victory';
state.winner = true;
});
}
return {
success: true,
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
}
};
// 检查是否胜利(没有更多敌人)
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,
},
};
}
/**
* 检查玩家是否有可出的牌
*/
const checkCanPlayCmd = registry.register({
schema: 'check-can-play <player:string>',
run: async (game: RegicideGame, player: string) => {
const state = game.value;
const playerKey = player as PlayerType;
const playerHand = state.playerHands[playerKey];
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;
});
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
}
};
}
});
return {
success: true,
result: {
canPlay,
canPlayWithA,
handSize: playerHand.length,
},
};
}
/**
* 检查酒馆牌堆是否为空
*/
const checkTavernDeckCmd = registry.register({
schema: 'check-tavern-deck',
run: async (game: RegicideGame) => {
const state = game.value;
const isEmpty = state.regions.tavernDeck.childIds.length === 0;
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.produce(state => {
state.phase = 'defeat';
state.winner = false;
});
}
}
return {
success: true,
result: {
isEmpty,
cardsRemaining: state.regions.tavernDeck.childIds.length
}
};
// 如果酒馆牌堆为空且所有玩家手牌也为空,则游戏失败
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,
},
};
}
/**
* 下一个玩家回合
*/
const nextTurnCmd = registry.register({
schema: 'next-turn',
run: async (game: RegicideGame) => {
const state = game.value;
await game.produce(state => {
state.currentPlayerIndex = (state.currentPlayerIndex + 1) % state.playerCount;
});
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];
const players: PlayerType[] = ["player1", "player2", "player3", "player4"];
const currentPlayer = players[game.value.currentPlayerIndex];
return {
success: true,
result: {
currentPlayer,
currentPlayerIndex: 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,
playCmd as play,
playWithACmd as playWithA,
passCmd as pass,
enemyCounterattackCmd as enemyCounterattack,
checkEnemyDefeatedCmd as checkEnemy,
checkCanPlayCmd as checkCanPlay,
checkTavernDeckCmd as checkTavernDeck,
nextTurnCmd as nextTurn,
};
+219 -185
View File
@@ -1,8 +1,15 @@
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 { 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>;
@@ -12,228 +19,255 @@ export type RegicideGame = IGameContext<RegicideState>;
* @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 人');
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);
}
if (seed) {
// RNG seeding handled by game context
// 设置敌人牌堆区域(只存储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);
}
}
// 创建所有卡牌
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;
}
});
// 翻开第一个敌人
if (enemyDeck.length > 0) {
const firstEnemy = enemyDeck.shift()!;
state.currentEnemy = firstEnemy;
}
});
}
/**
* 启动游戏主循环
*/
export async function start(game: RegicideGame) {
const state = game.value;
const state = game.value;
// 检查游戏是否已设置
if (!state.currentEnemy) {
throw new Error('请先调用 setupGame 初始化游戏');
// 检查游戏是否已设置
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;
}
const players: PlayerType[] = ['player1', 'player2', 'player3', 'player4'];
// 等待玩家输入(出牌或让过)
// 这里需要外部通过 prompt 系统获取输入
// 实际使用时由 UI 或测试代码提供输入
// 主游戏循环
while (state.phase === 'playing') {
const currentPlayerIndex = state.currentPlayerIndex;
const currentPlayer = players[currentPlayerIndex];
// 循环会在外部调用 play/pass 命令后继续
// 当 phase 变为 'victory' 或 'defeat' 时退出
break;
}
// 检查当前玩家是否有手牌
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;
return game.value;
}
/**
* 处理完整的玩家回合
*/
export async function playTurn(game: RegicideGame, player: PlayerType, action: 'play' | 'pass', cardId?: string, secondCardId?: string) {
const state = 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.phase !== "playing") {
return { success: false, error: "游戏已结束" };
}
if (!state.currentEnemy) {
return {success: false, error: '没有活跃的敌人'};
}
if (!state.currentEnemy) {
return { success: false, error: "没有活跃的敌人" };
}
let playResult: any;
let playResult: any;
// 执行玩家动作
if (action === 'play' && cardId) {
// 检查是否是A配合另一张牌
const card = state.cards[cardId];
if (card.rank === 'A' && secondCardId) {
playResult = await game.run(`play-with-a ${player} ${cardId} ${secondCardId}`);
} else {
playResult = await game.run(`play ${player} ${cardId}`);
}
// 执行玩家动作
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 game.run(`pass ${player}`);
playResult = await playCmd(game, player, cardId);
}
} else {
// 让过
playResult = await pass(game, player);
}
if (!playResult.success) {
return playResult;
}
if (!playResult.success) {
return playResult;
}
// 检查敌人是否被击败
const checkResult = await game.run<{defeated: boolean; currentEnemy?: any; nextEnemy?: any; defeatedEnemy?: any; enemiesRemaining?: number}>('check-enemy');
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 game.run('next-turn');
// 检查敌人是否被击败
const checkResult = await checkEnemy(game);
if (!checkResult.success) {
return checkResult;
}
// 如果敌人未被击败,处理反击
if (!checkResult.result.defeated) {
// 反击逻辑需要玩家选择弃牌,这里返回状态让外部处理
return {
success: true,
result: {
playResult: playResult.result,
enemyDefeated: true,
nextEnemy: state.currentEnemy
}
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 game.run(`counterattack ${player} ${JSON.stringify(discardCardIds)}`);
if (!result.success) {
// 弃牌失败(点数和不足),游戏失败
await game.produceAsync(state => {
state.phase = 'defeat';
state.winner = false;
});
return result;
}
// 弃牌成功,切换到下一个玩家
await game.run('next-turn');
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;
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
};
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,
};
}
+45 -49
View File
@@ -1,67 +1,63 @@
// Types
export type {
SuitType,
CardRank,
PlayerType,
RegionType,
RegicideCardMeta,
RegicideCard,
Enemy,
GamePhase
} from './types';
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';
ENEMY_COUNT,
INITIAL_HAND_SIZE,
CARD_VALUES,
ALL_SUITS,
ALL_RANKS,
FACE_CARDS,
NUMBER_CARDS,
} from "./constants";
// State
export {
createInitialState,
type RegicideState
} from './state';
export { createInitialState, type RegicideState } from "./state";
// Prompts
export {prompts} from './prompts';
export { prompts } from "./prompts";
// Commands
export {
registry,
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';
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';
setupGame,
start,
playTurn,
handleCounterattack,
getGameStatus,
type RegicideGame,
} from "./game";
// Utils
export {
getCardValue,
createCard,
createEnemy,
createAllCards,
buildEnemyDeck,
buildTavernDeck,
drawFromDeck,
isEnemyDefeated,
getPlayerHandRegionId
} from './utils';
getCardValue,
createCard,
createEnemy,
createAllCards,
buildEnemyDeck,
buildTavernDeck,
drawFromDeck,
isEnemyDefeated,
getPlayerHandRegionId,
} from "./utils";
+51 -85
View File
@@ -1,102 +1,68 @@
# slay-the-spire-like
# AGENTS.md - slay-the-spire-like
A Slay the Spire + Backpack Heroes hybrid roguelike sample. Players explore a point-crawl map, manage a tetris-style grid inventory, and fight enemies using cards generated from their equipment.
Roguelike deckbuilder combining Slay the Spire combat with Backpack Hero grid inventory.
## Game Design Docs
## Architecture
Design docs are in the markdown files at this level:
- `01-overview.md` — core game concept, zones, encounter structure, combat rules, buff/debuff system
- `02-fighter.md` — Fighter class items (weapons, armor, tools, consumables, relics)
- `03-desert.md` — Desert zone enemies (minions, elites, boss)
- `data/rules.md` — combat state machine, turn order, effect timing rules
Three-layer design: **system** (content-agnostic) → **data** (CSV content) → **dialogues** (Yarn Spinner).
## Module Structure
### System Layer (`system/`)
This is **not** a `GameModule` yet — there is no `createInitialState`/`start`/`registry` wired up to `createGameHost`. The code is a library of subsystems that can be composed into a game module.
| 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` |
### Subsystems
### Data Layer (`data/desert/`)
| Directory | Purpose | Key exports |
|-----------|---------|-------------|
| `progress/` | Run state, player HP/gold, inventory management, map progression | `createRunState`, `moveToNode`, `resolveEncounter`, `damagePlayer`, `healPlayer`, `addItemFromCsv`, `removeItem`, `getReachableChildren` |
| `map/` | Point-crawl map generation and traversal | `generatePointCrawlMap`, `getNode`, `getChildren`, `getParents`, `hasPath`, `findAllPaths` |
| `grid-inventory/` | Tetris-style grid placement (place, move, rotate, flip items) | `createGridInventory`, `placeItem`, `removeItem`, `moveItem`, `rotateItem`, `flipItem`, `validatePlacement`, `getAdjacentItems` |
| `deck/` | Card/deck system (draw pile, hand, discard, exhaust) | `generateDeckFromInventory`, `createStatusCard`, `createDeckRegions`, `createPlayerDeck` |
| `data/` | CSV game data loaded via `inline-schema/csv-loader`. `.d.ts` files are auto-generated by the csv-loader plugin — do not edit by hand. | `heroItemFighter1Data`, `encounterDesertData`, `enemyDesertData`, `enemyIntentDesertData`, `effectDesertData`, `statusCardDesertData` |
| `dialogue/` | Yarn Spinner dialogue files (placeholder). Loaded via `yarn-spinner-loader`, a local peer dependency at `../yarn-spinner-loader` (like `inline-schema`, it can be changed and published if needed). | `encounters` yarnproject |
| `utils/` | Shape parsing and collision math | `parseShapeString`, `checkCollision`, `checkBounds`, `transformShape`, `rotateTransform`, `flipXTransform`, `flipYTransform` |
All content via CSV (loaded by `inline-schema`):
### Data flow
| 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 |
```
CSV files (data/)
→ inline-schema/csv-loader → typed JS objects (e.g. HeroItemFighter1)
→ parseShapeString() converts shape strings → ParsedShape
→ GridInventory<GameItemMeta> holds placed items
→ generateDeckFromInventory() generates cards per occupied cell
```
### Triggers (`data/desert/triggers/`)
### Key types
Middleware hooks extending the base trigger system:
- **`RunState`** — top-level state: seed, map, player, inventory, currentNodeId, encounter state, resolved set. Designed for `MutableSignal.produce()` mutation.
- **`GridInventory<TMeta>`** — `items: Map<string, InventoryItem<TMeta>>` + `occupiedCells: Set<CellKey>` for O(1) collision. Mutated directly inside `.produce()`.
- **`InventoryItem<TMeta>`** — id, shape (ParsedShape), transform (Transform2D), meta. Shape + transform determines which cells are occupied.
- **`GameCard`** — a `Part<GameCardMeta>` bridging inventory items to the deck system. `sourceItemId` links back to the inventory item; `null` for status cards.
- **`PointCrawlMap`** — layered DAG: 10 layers (start → wild×2 → settlement → wild×2 → settlement → wild×2 → end). Wild = 3 nodes, Settlement = 4 nodes.
- **`MapNode`** — id, type (MapNodeType enum), childIds, optional encounter data from CSV.
| 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 |
### Map generation
### Dialogue Layer (`data/desert/dialogues/`)
`generatePointCrawlMap(seed?)` produces a deterministic map:
- 10 layers: Start → Wild(3) → Wild(3) → Settlement(4) → Wild(3) → Wild(3) → Settlement(4) → Wild(3) → Wild(3) → End
- Settlement layers guarantee ≥1 camp, ≥1 shop, ≥1 curio (4th slot random)
- Wild pair types are optimized to minimize same-type repetition
- Edge patterns avoid crossings: Start→all wild, Wild→Wild 1:1, Wild↔Settlement 3:4 or 4:3, Wild→all End
`story.yarn` — branching encounter dialogues (inside `dialogues.yarnproject`). Commands resolve via `tryAnswerPrompt`.
### Shape system
## Key Mechanics
Items have shapes defined as movement strings parsed by `parseShapeString`:
- `o` = origin cell, `n/s/e/w` = move + fill, `r` = return to previous position
- Example: `"oesw"` = 2×2 block (origin, east, south, west = full square)
- Example: `"oe"` = 1×2 horizontal
- Example: `"onrersrw"` = cross/X shape
- **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
Shapes are positioned via `Transform2D` (offset, rotation, flipX, flipY) and validated against the 6×4 grid.
## Testing
### Grid inventory
All mutation functions (`placeItem`, `removeItem`, `moveItem`, `rotateItem`, `flipItem`) mutate the `GridInventory` **directly** — they must be called inside `produce()` callbacks. `validatePlacement` checks bounds + collisions before placement.
### Card generation
`generateDeckFromInventory(inventory)` creates one card per occupied cell in each item's shape. Cards carry `GameCardMeta` linking back to the source item and cell position. Status cards (wound, venom, etc.) are created separately via `createStatusCard`.
## CSV data format
All CSVs use `inline-schema` typed headers. The first row is a comment header, the second row is the schema row with types and references:
- `'energy'|'uses'` — union type
- `@enemyDesert` — foreign key reference to another CSV
- `[effect: @effectDesert; number][]` — array of structured references
### heroItemFighter1.csv columns
| Column | Type | Notes |
|--------|------|-------|
| type | `'weapon'|'armor'|'consumable'|'tool'` | |
| name | string | Display name (Chinese) |
| shape | string | Movement string for `parseShapeString` |
| costType | `'energy'|'uses'` | Energy = per-turn cost; Uses = limited uses |
| costCount | int | Cost amount |
| targetType | `'single'|'none'` | |
| price | int | Shop price |
| desc | string | Ability description (Chinese) |
| effects | `['self'|'target'|'all'|'random'; @effectDesert; number][]` | Effect references |
## Conventions
- Chinese is used for all user-facing strings (item names, error messages, effect descriptions)
- Discriminated union result types: `{ success: true } | { success: false, reason: string }`
- Mutation functions mutate state directly (inside `produce()`); validation is separate
- `Map` and `Set` are used in `GridInventory` and `PointCrawlMap` (not plain objects) — requires careful handling with `mutative` since it drafts Maps/Sets differently than plain objects
- Starter items defined in `progress/index.ts`: `['治疗药剂', '绷带', '水袋', '短刀', '剑']`
- Default player stats: 50 HP, 50 gold, 6×4 inventory
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
-2
View File
@@ -1,2 +0,0 @@
# 《背包爬塔》肉鸽
@@ -1,538 +0,0 @@
import type { EffectDesert } from "../data/effectDesert.csv";
import type { CardDesert } from "../data/cardDesert.csv";
import { effectDesertData, cardDesertData } from "../data";
import { createStatusCard } from "../deck/factory";
import type { PlayerDeck, GameCard } from "../deck/types";
import type {
BuffTable,
CombatEffectEntry,
CombatState,
EffectTarget,
EffectTiming,
EnemyState,
ItemBuff,
PlayerCombatState,
} from "./types";
import {
drawCardsToHand,
addFatigueCards,
discardCard,
exhaustCard,
getEffectData,
discardHand,
} from "./state";
export type DamageResult = {
damageDealt: number;
blockedByDefend: number;
targetDied: boolean;
};
export function applyDamage(
state: CombatState,
targetKey: "player" | string,
amount: number,
sourceKey?: "player" | string,
): DamageResult {
if (amount <= 0) return { damageDealt: 0, blockedByDefend: 0, targetDied: false };
let actualDamage = amount;
let blockedByDefend = 0;
if (targetKey === "player") {
const defendStacks = state.player.buffs["defend"] ?? 0;
if (defendStacks > 0) {
blockedByDefend = Math.min(defendStacks, actualDamage);
actualDamage -= blockedByDefend;
state.player.buffs["defend"] = defendStacks - blockedByDefend;
if (state.player.buffs["defend"] === 0) {
delete state.player.buffs["defend"];
}
}
const damageReduce = state.player.buffs["damageReduce"] ?? 0;
if (damageReduce > 0 && actualDamage > 0) {
actualDamage = Math.max(0, actualDamage - damageReduce);
}
if (actualDamage > 0) {
state.player.hp = Math.max(0, state.player.hp - actualDamage);
state.player.damageTakenThisTurn += actualDamage;
state.player.damagedThisTurn = true;
}
if (blockedByDefend > 0 && defendStacks - blockedByDefend <= 0) {
for (const enemyId of state.enemyOrder) {
const enemy = state.enemies[enemyId];
if (enemy.isAlive && enemy.buffs["defend"] !== undefined) {
// Not relevant for player, skip
}
}
}
return {
damageDealt: actualDamage,
blockedByDefend,
targetDied: state.player.hp <= 0,
};
}
const enemy = state.enemies[targetKey];
if (!enemy || !enemy.isAlive) {
return { damageDealt: 0, blockedByDefend: 0, targetDied: false };
}
const defendStacks = enemy.buffs["defend"] ?? 0;
if (defendStacks > 0) {
blockedByDefend = Math.min(defendStacks, actualDamage);
actualDamage -= blockedByDefend;
enemy.buffs["defend"] = defendStacks - blockedByDefend;
if (enemy.buffs["defend"] === 0) {
delete enemy.buffs["defend"];
}
if (defendStacks > 0 && defendStacks - blockedByDefend <= 0) {
enemy.hadDefendBroken = true;
}
}
if (actualDamage > 0) {
enemy.hp = Math.max(0, enemy.hp - actualDamage);
if (enemy.hp <= 0) {
enemy.isAlive = false;
}
}
return {
damageDealt: actualDamage,
blockedByDefend,
targetDied: !enemy.isAlive,
};
}
export function applyDefend(
targetBuffs: BuffTable,
amount: number,
): void {
if (amount <= 0) return;
targetBuffs["defend"] = (targetBuffs["defend"] ?? 0) + amount;
}
export function applyBuff(
buffs: BuffTable,
effectId: string,
timing: EffectTiming,
stacks: number,
): void {
if (stacks <= 0) return;
buffs[effectId] = (buffs[effectId] ?? 0) + stacks;
}
export function removeBuff(buffs: BuffTable, effectId: string, stacks?: number): number {
const current = buffs[effectId] ?? 0;
if (stacks === undefined || stacks >= current) {
delete buffs[effectId];
return current;
}
buffs[effectId] = current - stacks;
return stacks;
}
export function updateBuffs(buffs: BuffTable): void {
const toDelete: string[] = [];
const toDecrement: string[] = [];
for (const [effectId] of Object.entries(buffs)) {
const effectData = getEffectData(effectId);
if (!effectData) continue;
switch (effectData.timing) {
case "temporary":
toDelete.push(effectId);
break;
case "lingering":
toDecrement.push(effectId);
break;
}
}
for (const id of toDelete) {
delete buffs[id];
}
for (const id of toDecrement) {
buffs[id] = (buffs[id] ?? 0) - 1;
if (buffs[id] <= 0) {
delete buffs[id];
}
}
}
export type ResolveEffectContext = {
state: CombatState;
rng: { nextInt: (n: number) => number };
};
export function resolveEffect(
ctx: ResolveEffectContext,
target: EffectTarget,
effect: EffectDesert,
stacks: number,
sourceKey?: "player" | string,
sourceCardId?: string,
): void {
const { state } = ctx;
const timing = effect.timing;
switch (timing) {
case "instant":
resolveInstantEffect(ctx, target, effect, stacks, sourceKey, sourceCardId);
break;
case "posture":
applyBuffToTarget(state, target, effect.id, stacks, sourceKey);
break;
case "temporary":
case "lingering":
case "permanent":
applyBuffToTarget(state, target, effect.id, stacks, sourceKey);
break;
case "card":
addStatusCardToDiscard(state, effect.id, stacks);
break;
case "cardDraw":
addStatusCardToDrawPile(state, effect.id, stacks);
break;
case "cardHand":
addStatusCardToHand(state, effect.id, stacks);
break;
case "item":
case "itemUntilPlayed":
applyItemBuff(state, effect.id, timing, stacks, sourceCardId);
break;
}
}
function applyBuffToTarget(
state: CombatState,
target: EffectTarget,
effectId: string,
stacks: number,
sourceKey?: "player" | string,
): void {
if (target === "self") {
if (sourceKey === "player") {
applyBuff(state.player.buffs, effectId, getEffectData(effectId)?.timing ?? "permanent", stacks);
} else if (sourceKey && state.enemies[sourceKey]) {
applyBuff(state.enemies[sourceKey].buffs, effectId, getEffectData(effectId)?.timing ?? "permanent", stacks);
}
} else if (target === "player" || target === "team") {
applyBuff(state.player.buffs, effectId, getEffectData(effectId)?.timing ?? "permanent", stacks);
} else if (target === "target" || target === "all" || target === "random") {
// For attack/defend effects, these are handled by resolveInstantEffect
}
}
function resolveInstantEffect(
ctx: ResolveEffectContext,
target: EffectTarget,
effect: EffectDesert,
stacks: number,
sourceKey?: "player" | string,
sourceCardId?: string,
): void {
const { state, rng } = ctx;
switch (effect.id) {
case "attack": {
const damageAmount = stacks;
if (target === "all") {
for (const enemyId of state.enemyOrder) {
const enemy = state.enemies[enemyId];
if (enemy.isAlive) {
applyDamage(state, enemyId, damageAmount, sourceKey);
}
}
} else if (target === "random") {
const aliveEnemies = state.enemyOrder.filter(id => state.enemies[id].isAlive);
if (aliveEnemies.length > 0) {
const targetId = aliveEnemies[rng.nextInt(aliveEnemies.length)];
applyDamage(state, targetId, damageAmount, sourceKey);
}
} else if (target === "target") {
if (sourceKey && sourceKey !== "player" && state.enemies[sourceKey]?.isAlive) {
applyDamage(state, "player", damageAmount, sourceKey);
}
}
break;
}
case "defend": {
if (target === "self" && sourceKey === "player") {
applyDefend(state.player.buffs, stacks);
} else if (target === "self" && sourceKey && state.enemies[sourceKey]) {
applyDefend(state.enemies[sourceKey].buffs, stacks);
}
break;
}
case "draw": {
drawCardsToHand(state.player.deck, stacks);
break;
}
case "gainEnergy": {
state.player.energy += stacks;
break;
}
case "removeWound": {
removeWoundCards(state.player.deck, stacks);
break;
}
case "tailSting": {
applyDamage(state, "player", stacks, sourceKey);
break;
}
case "rollDamage": {
const rollStacks = sourceKey && sourceKey !== "player"
? state.enemies[sourceKey]?.buffs["roll"] ?? 0
: 0;
if (rollStacks >= 10) {
const damageFromRoll = Math.floor(rollStacks / 10) * 10;
applyDamage(state, "player", Math.floor(damageFromRoll / 10), sourceKey);
removeBuff(state.enemies[sourceKey!].buffs, "roll", damageFromRoll);
}
break;
}
case "crossbow": {
break;
}
case "discard": {
break;
}
case "summonMummy":
case "summonSandwormLarva":
case "reviveMummy": {
break;
}
case "drawChoice":
case "transformRandom": {
break;
}
default:
break;
}
}
function addStatusCardToDiscard(state: CombatState, effectId: string, count: number): void {
const cardDef = cardDesertData.find(c => c.id === effectId);
if (!cardDef) return;
for (let i = 0; i < count; i++) {
const cardId = `status-${effectId}-${Date.now()}-${i}`;
const card = createStatusCard(cardId, cardDef.name, cardDef.desc);
state.player.deck.cards[card.id] = card;
state.player.deck.discardPile.push(card.id);
}
}
function addStatusCardToDrawPile(state: CombatState, effectId: string, count: number): void {
const cardDef = cardDesertData.find(c => c.id === effectId);
if (!cardDef) return;
for (let i = 0; i < count; i++) {
const cardId = `status-${effectId}-${Date.now()}-${i}`;
const card = createStatusCard(cardId, cardDef.name, cardDef.desc);
state.player.deck.cards[card.id] = card;
state.player.deck.drawPile.push(card.id);
}
}
function addStatusCardToHand(state: CombatState, effectId: string, count: number): void {
const cardDef = cardDesertData.find(c => c.id === effectId);
if (!cardDef) return;
for (let i = 0; i < count; i++) {
const cardId = `status-${effectId}-${Date.now()}-${i}`;
const card = createStatusCard(cardId, cardDef.name, cardDef.desc);
state.player.deck.cards[card.id] = card;
state.player.deck.hand.push(card.id);
}
}
function applyItemBuff(
state: CombatState,
effectId: string,
timing: EffectTiming,
stacks: number,
sourceCardId?: string,
): void {
if (!sourceCardId) return;
const card = state.player.deck.cards[sourceCardId];
if (!card || !card.sourceItemId) return;
const itemBuff: ItemBuff = {
effectId,
stacks,
timing,
sourceItemId: card.sourceItemId,
targetItemId: card.sourceItemId,
};
state.player.itemBuffs.push(itemBuff);
}
function removeWoundCards(deck: PlayerDeck, count: number): void {
let removed = 0;
for (let i = deck.drawPile.length - 1; i >= 0 && removed < count; i--) {
const card = deck.cards[deck.drawPile[i]];
if (card && card.itemData === null && card.displayName === "伤口") {
delete deck.cards[deck.drawPile[i]];
deck.drawPile.splice(i, 1);
removed++;
}
}
for (let i = deck.discardPile.length - 1; i >= 0 && removed < count; i--) {
const card = deck.cards[deck.discardPile[i]];
if (card && card.itemData === null && card.displayName === "伤口") {
delete deck.cards[deck.discardPile[i]];
deck.discardPile.splice(i, 1);
removed++;
}
}
}
export function resolveCardEffects(
ctx: ResolveEffectContext,
cardId: string,
targetEnemyId?: string,
): void {
const { state } = ctx;
const card = state.player.deck.cards[cardId];
if (!card || !card.itemData) return;
const sourceKey: "player" | string = "player";
const effects = card.itemData.onPlay as unknown as CombatEffectEntry[];
for (const entry of effects) {
const [target, effect, stacks] = entry;
if (target === "target") {
if (targetEnemyId && state.enemies[targetEnemyId]?.isAlive) {
if (effect.id === "attack") {
const actualDamage = getModifiedAttackDamage(state, cardId, stacks);
applyDamage(state, targetEnemyId, actualDamage, "player");
continue;
}
}
}
resolveEffect(ctx, target, effect, stacks, sourceKey, cardId);
}
}
export function getModifiedAttackDamage(
state: CombatState,
cardId: string,
baseDamage: number,
): number {
let damage = baseDamage;
const attackBuff = state.player.itemBuffs
.filter((b) => b.effectId === "attackBuff" || b.effectId === "attackBuffUntilPlay")
.filter((b) => {
const card = state.player.deck.cards[cardId];
return card && card.sourceItemId === b.targetItemId;
})
.reduce((sum, b) => sum + b.stacks, 0);
damage += attackBuff;
return Math.max(0, damage);
}
export function getModifiedDefendAmount(
state: CombatState,
cardId: string,
baseDefend: number,
): number {
let defend = baseDefend;
const defendBuff = state.player.itemBuffs
.filter((b) => b.effectId === "defendBuff" || b.effectId === "defendBuffUntilPlay")
.filter((b) => {
const card = state.player.deck.cards[cardId];
return card && card.sourceItemId === b.targetItemId;
})
.reduce((sum, b) => sum + b.stacks, 0);
defend += defendBuff;
return Math.max(0, defend);
}
export function canPlayCard(
state: CombatState,
cardId: string,
): { canPlay: boolean; reason?: string } {
const card = state.player.deck.cards[cardId];
if (!card) return { canPlay: false, reason: "卡牌不存在" };
if (!card.itemData) return { canPlay: false, reason: "状态牌不可打出" };
const handIdx = state.player.deck.hand.indexOf(cardId);
if (handIdx < 0) return { canPlay: false, reason: "卡牌不在手牌中" };
if (card.itemData.costType === "energy") {
if (state.player.energy < card.itemData.costCount) {
return { canPlay: false, reason: "能量不足" };
}
}
return { canPlay: true };
}
export function playCard(
ctx: ResolveEffectContext,
cardId: string,
targetEnemyId?: string,
): { success: boolean; reason?: string } {
const { state } = ctx;
const check = canPlayCard(state, cardId);
if (!check.canPlay) return { success: false, reason: check.reason };
const card = state.player.deck.cards[cardId];
if (!card || !card.itemData) return { success: false, reason: "卡牌无效" };
if (card.itemData.costType === "energy") {
state.player.energy -= card.itemData.costCount;
}
resolveCardEffects(ctx, cardId, targetEnemyId);
if (card.itemData.costType === "uses") {
exhaustCard(state.player.deck, cardId);
} else {
discardCard(state.player.deck, cardId);
}
expireItemBuffsOnCardPlayed(state, cardId);
return { success: true };
}
function expireItemBuffsOnCardPlayed(state: CombatState, cardId: string): void {
const card = state.player.deck.cards[cardId];
if (!card || !card.sourceItemId) return;
state.player.itemBuffs = state.player.itemBuffs.filter((buff) => {
if (buff.timing === "itemUntilPlayed" && buff.sourceItemId === card.sourceItemId) {
return false;
}
return true;
});
}
export function areAllEnemiesDead(state: CombatState): boolean {
return state.enemyOrder.every(id => !state.enemies[id].isAlive);
}
export function isPlayerDead(state: CombatState): boolean {
return state.player.hp <= 0;
}
@@ -1,71 +0,0 @@
export type {
BuffTable,
CombatEffectEntry,
CombatEntity,
CombatGameContext,
CombatPhase,
CombatResult,
CombatState,
EffectTarget,
EffectTiming,
EnemyState,
ItemBuff,
LootEntry,
PlayerCombatState,
} from "./types";
export {
createCombatState,
createEnemyInstance,
createPlayerCombatState,
drawCardsToHand,
reshuffleDiscardIntoDraw,
addFatigueCards,
discardHand,
discardCard,
exhaustCard,
getEnemyCurrentIntent,
advanceEnemyIntent,
getEffectTiming,
getEffectData,
INITIAL_HAND_SIZE,
DEFAULT_MAX_ENERGY,
FATIGUE_CARDS_PER_SHUFFLE,
} from "./state";
export {
applyDamage,
applyDefend,
applyBuff,
removeBuff,
updateBuffs,
resolveEffect,
resolveCardEffects,
getModifiedAttackDamage,
getModifiedDefendAmount,
canPlayCard,
playCard,
areAllEnemiesDead,
isPlayerDead,
} from "./effects";
export type {
TriggerContext,
BuffTriggerBehavior,
CombatTriggerRegistry,
TriggerEvent,
} from "./triggers";
export {
createCombatTriggerRegistry,
dispatchTrigger,
dispatchAttackedTrigger,
dispatchDamageTrigger,
dispatchOutgoingDamageTrigger,
dispatchIncomingDamageTrigger,
dispatchShuffleTrigger,
} from "./triggers";
export { prompts } from "./prompts";
export { runCombat } from "./procedure";
@@ -1,309 +0,0 @@
import type { IGameContext } from "@/core/game";
import type { CombatState, CombatResult, CombatGameContext, CombatEffectEntry } from "./types";
import type { CombatTriggerRegistry, TriggerContext } from "./triggers";
import { createCombatTriggerRegistry, dispatchTrigger, dispatchShuffleTrigger, dispatchOutgoingDamageTrigger, dispatchIncomingDamageTrigger, dispatchDamageTrigger, dispatchCardDrawnTrigger } from "./triggers";
import { prompts } from "./prompts";
import {
drawCardsToHand,
addFatigueCards,
discardHand,
discardCard,
getEnemyCurrentIntent,
advanceEnemyIntent,
DEFAULT_MAX_ENERGY,
FATIGUE_CARDS_PER_SHUFFLE,
} from "./state";
import {
applyDamage,
applyDefend,
updateBuffs,
canPlayCard,
playCard,
areAllEnemiesDead,
isPlayerDead,
resolveCardEffects,
removeBuff,
} from "./effects";
export async function runCombat(
game: CombatGameContext,
): Promise<CombatResult> {
const triggerRegistry = createCombatTriggerRegistry();
await game.produceAsync(async (state) => {
state.phase = "playerTurn";
state.player.energy = state.player.maxEnergy;
state.player.damageTakenThisTurn = 0;
state.player.damagedThisTurn = false;
state.player.cardsDiscardedThisTurn = 0;
});
while (true) {
const currentState = game.value;
if (currentState.result) {
return currentState.result;
}
if (currentState.phase === "playerTurn") {
await runPlayerTurn(game, triggerRegistry);
} else if (currentState.phase === "enemyTurn") {
await runEnemyTurn(game, triggerRegistry);
} else {
break;
}
if (isPlayerDead(game.value)) {
await game.produceAsync((state) => {
state.result = "defeat";
state.phase = "combatEnd";
});
return "defeat";
}
if (areAllEnemiesDead(game.value)) {
await game.produceAsync((state) => {
state.result = "victory";
state.phase = "combatEnd";
state.loot = generateLoot(state);
});
return "victory";
}
}
return game.value.result ?? "defeat";
}
async function runPlayerTurn(
game: CombatGameContext,
triggerRegistry: CombatTriggerRegistry,
): Promise<void> {
const triggerCtx = createTriggerContext(game);
await game.produceAsync(async (state) => {
updateBuffs(state.player.buffs);
state.player.damageTakenThisTurn = 0;
state.player.damagedThisTurn = false;
state.player.cardsDiscardedThisTurn = 0;
dispatchTrigger(triggerCtx, "onTurnStart", "player", triggerRegistry);
});
while (game.value.phase === "playerTurn") {
const action = await game.prompt<{ action: "play" | "end"; cardId?: string; targetId?: string }>(
prompts.playCard,
(cardId, targetId) => {
const state = game.value;
if (!cardId) throw "请选择卡牌";
const check = canPlayCard(state, cardId);
if (!check.canPlay) throw check.reason ?? "无法打出";
const card = state.player.deck.cards[cardId];
if (card?.itemData?.targetType === "single") {
const aliveEnemies = state.enemyOrder.filter((id) => state.enemies[id].isAlive);
if (!targetId && aliveEnemies.length > 0) {
throw "请指定目标";
}
if (targetId && !state.enemies[targetId]?.isAlive) {
throw "目标无效";
}
}
return { action: "play" as const, cardId, targetId };
},
"player"
);
if (action.action === "play" && action.cardId) {
const ctx = createEffectContext(game);
await game.produceAsync(async (state) => {
playCard({ state, rng: game._rng }, action.cardId!, action.targetId);
});
if (areAllEnemiesDead(game.value)) {
return;
}
if (isPlayerDead(game.value)) {
return;
}
continue;
}
break;
}
await game.prompt<{ action: "end" }>(
prompts.endTurn,
() => {
return { action: "end" as const };
},
"player"
);
dispatchTrigger(createTriggerContext(game), "onTurnEnd", "player", triggerRegistry);
await game.produceAsync(async (state) => {
for (const cardId of [...state.player.deck.hand]) {
state.player.cardsDiscardedThisTurn++;
}
discardHand(state.player.deck);
});
await game.produceAsync(async (state) => {
if (state.player.deck.drawPile.length === 0) {
reshuffleWithFatigue(state);
dispatchShuffleTrigger(createTriggerContext(game), triggerRegistry);
}
const drawn = drawCardsToHand(state.player.deck, 5);
for (const cardId of drawn) {
dispatchCardDrawnTrigger(createTriggerContext(game), cardId, triggerRegistry);
}
state.player.energy = state.player.maxEnergy;
});
await game.produceAsync(async (state) => {
state.phase = "enemyTurn";
});
}
async function runEnemyTurn(
game: CombatGameContext,
triggerRegistry: CombatTriggerRegistry,
): Promise<void> {
const state = game.value;
await game.produceAsync(async (state) => {
for (const enemyId of state.enemyOrder) {
const enemy = state.enemies[enemyId];
if (!enemy.isAlive) continue;
updateBuffs(enemy.buffs);
}
});
const triggerCtx = createTriggerContext(game);
for (const enemyId of game.value.enemyOrder) {
const enemy = game.value.enemies[enemyId];
if (!enemy.isAlive) continue;
dispatchTrigger(triggerCtx, "onTurnStart", enemyId, triggerRegistry);
}
await game.produceAsync(async (state) => {
for (const enemyId of state.enemyOrder) {
const enemy = state.enemies[enemyId];
if (!enemy.isAlive) continue;
const intent = getEnemyCurrentIntent(enemy);
if (!intent) continue;
const effects = intent.effects as unknown as CombatEffectEntry[];
for (const entry of effects) {
const [target, effect, stacks] = entry;
if (effect.id === "attack") {
let damage = stacks;
damage = dispatchOutgoingDamageTrigger(createTriggerContext(game), enemyId, damage, triggerRegistry);
damage = dispatchIncomingDamageTrigger(createTriggerContext(game), "player", damage, triggerRegistry);
const result = applyDamage(state, "player", damage, enemyId);
if (result.damageDealt > 0) {
dispatchDamageTrigger(createTriggerContext(game), "player", result.damageDealt, triggerRegistry);
}
} else if (effect.id === "defend") {
if (target === "self") {
applyDefend(enemy.buffs, stacks);
}
} else {
resolveEnemyEffect(state, enemyId, target, effect, stacks);
}
}
advanceEnemyIntent(enemy);
}
});
for (const enemyId of game.value.enemyOrder) {
const enemy = game.value.enemies[enemyId];
if (!enemy.isAlive) continue;
dispatchTrigger(createTriggerContext(game), "onTurnEnd", enemyId, triggerRegistry);
}
await game.produceAsync(async (state) => {
state.phase = "playerTurn";
state.turnNumber++;
});
}
function resolveEnemyEffect(
state: CombatState,
enemyId: string,
target: string,
effect: { id: string; timing: string },
stacks: number,
): void {
switch (effect.id) {
case "spike":
case "venom":
case "curse":
case "aim":
case "roll":
case "vultureEye":
case "tailSting":
case "energyDrain":
case "molt":
case "storm":
case "static":
case "charge":
case "discard":
state.enemies[enemyId].buffs[effect.id] = (state.enemies[enemyId].buffs[effect.id] ?? 0) + stacks;
break;
case "summonMummy":
case "summonSandwormLarva":
case "reviveMummy":
break;
default:
break;
}
}
function reshuffleWithFatigue(state: CombatState): void {
if (state.player.deck.discardPile.length === 0) return;
state.player.deck.drawPile.push(...state.player.deck.discardPile);
state.player.deck.discardPile = [];
addFatigueCards(state.player.deck, FATIGUE_CARDS_PER_SHUFFLE, { value: state.player.fatigueAddedCount });
state.player.fatigueAddedCount += FATIGUE_CARDS_PER_SHUFFLE;
for (let i = state.player.deck.drawPile.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[state.player.deck.drawPile[i], state.player.deck.drawPile[j]] = [state.player.deck.drawPile[j], state.player.deck.drawPile[i]];
}
}
function createTriggerContext(game: CombatGameContext): TriggerContext {
return {
state: game.value,
rng: game._rng,
};
}
function createEffectContext(game: CombatGameContext) {
return {
state: game.value,
rng: game._rng,
};
}
function generateLoot(state: CombatState): CombatState["loot"] {
const loot: CombatState["loot"] = [];
let totalGold = 0;
for (const enemyId of state.enemyOrder) {
const enemy = state.enemies[enemyId];
totalGold += Math.floor(enemy.maxHp * 0.5);
}
if (totalGold > 0) {
loot.push({ type: "gold", amount: totalGold });
}
return loot;
}
@@ -1,12 +0,0 @@
import { createPromptDef } from "@/core/game";
export const prompts = {
playCard: createPromptDef<[string, string?]>(
"play-card <cardId:string> [targetId:string]",
"选择卡牌并指定目标"
),
endTurn: createPromptDef<[]>(
"end-turn",
"结束回合"
),
};
@@ -1,254 +0,0 @@
import type { GridInventory } from "../grid-inventory/types";
import type { GameItemMeta, PlayerState } from "../progress/types";
import type { PlayerDeck } from "../deck/types";
import type { EnemyDesert } from "../data/enemyDesert.csv";
import type { EffectDesert } from "../data/effectDesert.csv";
import type { EncounterDesert } from "../data/encounterDesert.csv";
import { generateDeckFromInventory, createStatusCard } from "../deck/factory";
import { enemyDesertData, effectDesertData, cardDesertData } from "../data";
import { createRNG } from "@/utils/rng";
import type {
BuffTable,
CombatState,
CombatPhase,
EnemyState,
PlayerCombatState,
ItemBuff,
LootEntry,
} from "./types";
const INITIAL_HAND_SIZE = 5;
const DEFAULT_MAX_ENERGY = 3;
const FATIGUE_CARDS_PER_SHUFFLE = 2;
export function createEnemyInstance(
templateId: string,
hp: number,
initBuffs: [EffectDesert, number][],
idCounter: { value: number },
): EnemyState {
idCounter.value++;
const id = `enemy-${idCounter.value}`;
const maxHp = hp;
const currentHp = hp;
const buffs: BuffTable = {};
for (const [effect, stacks] of initBuffs) {
buffs[effect.id] = (buffs[effect.id] ?? 0) + stacks;
}
const intentData = buildIntentLookup(templateId);
const initialIntent = findInitialIntent(templateId);
return {
id,
templateId,
hp: currentHp,
maxHp,
buffs,
currentIntentId: initialIntent ?? "",
intentData,
isAlive: true,
hadDefendBroken: false,
};
}
function findInitialIntent(enemyTemplateId: string): string | undefined {
for (const row of enemyDesertData) {
if (row.enemy === enemyTemplateId && row.initialIntent) {
return row.intentId;
}
}
return undefined;
}
function buildIntentLookup(enemyTemplateId: string): Record<string, EnemyDesert> {
const lookup: Record<string, EnemyDesert> = {};
for (const row of enemyDesertData) {
if (row.enemy === enemyTemplateId) {
lookup[row.intentId] = row;
}
}
return lookup;
}
export function createPlayerCombatState(
playerState: PlayerState,
inventory: GridInventory<GameItemMeta>,
): PlayerCombatState {
const deck = generateDeckFromInventory(inventory);
return {
hp: playerState.currentHp,
maxHp: playerState.maxHp,
energy: DEFAULT_MAX_ENERGY,
maxEnergy: DEFAULT_MAX_ENERGY,
buffs: {},
deck,
damageTakenThisTurn: 0,
damagedThisTurn: false,
cardsDiscardedThisTurn: 0,
itemBuffs: [],
fatigueAddedCount: 0,
};
}
export function createCombatState(
playerState: PlayerState,
inventory: GridInventory<GameItemMeta>,
encounter: EncounterDesert,
): CombatState {
const idCounter = { value: 0 };
const player = createPlayerCombatState(playerState, inventory);
const enemies: Record<string, EnemyState> = {};
const enemyOrder: string[] = [];
const enemyTemplateData: Record<string, EnemyDesert> = {};
for (const enemyEntry of encounter.enemies as unknown as [string, number, number][]) {
const [enemyId, hp, bonusHp] = enemyEntry;
// Find initBuffs from enemyDesert (first row for this enemy type)
const enemyRow = enemyDesertData.find((e) => e.enemy === enemyId);
const initBuffs: [EffectDesert, number][] = [];
if (enemyRow) {
for (const [effect, stacks] of enemyRow.initBuffs) {
initBuffs.push([effect, stacks]);
}
}
const totalHp = hp + bonusHp;
const enemyInstance = createEnemyInstance(
enemyId,
totalHp,
initBuffs,
idCounter,
);
enemies[enemyInstance.id] = enemyInstance;
enemyOrder.push(enemyInstance.id);
enemyTemplateData[enemyInstance.templateId] = enemyRow!;
}
shuffleDeck(player.deck.drawPile, createRNG(0));
drawCardsToHand(player.deck, INITIAL_HAND_SIZE);
return {
enemies,
enemyOrder,
player,
phase: "playerTurn" as CombatPhase,
turnNumber: 1,
result: null,
loot: [],
enemyTemplateData,
};
}
export function drawCardsToHand(deck: PlayerDeck, count: number): string[] {
const drawn: string[] = [];
for (let i = 0; i < count; i++) {
if (deck.drawPile.length === 0) {
reshuffleDiscardIntoDraw(deck);
}
if (deck.drawPile.length === 0) break;
const cardId = deck.drawPile.shift()!;
deck.hand.push(cardId);
drawn.push(cardId);
}
return drawn;
}
export function reshuffleDiscardIntoDraw(deck: PlayerDeck): void {
if (deck.discardPile.length === 0) return;
deck.drawPile.push(...deck.discardPile);
deck.discardPile = [];
for (let i = deck.drawPile.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[deck.drawPile[i], deck.drawPile[j]] = [deck.drawPile[j], deck.drawPile[i]];
}
}
export function addFatigueCards(deck: PlayerDeck, count: number, fatigueCounter: { value: number }): number {
let added = 0;
const fatigueDef = cardDesertData.find((c) => c.id === "fatigue");
if (!fatigueDef) return 0;
for (let i = 0; i < count; i++) {
fatigueCounter.value++;
const card = createStatusCard(
`fatigue-${fatigueCounter.value}`,
fatigueDef.name,
fatigueDef.desc,
);
deck.cards[card.id] = card;
deck.drawPile.push(card.id);
added++;
}
return added;
}
export function discardHand(deck: PlayerDeck): void {
const handCards = [...deck.hand];
deck.discardPile.push(...handCards);
deck.hand = [];
}
export function discardCard(deck: PlayerDeck, cardId: string): void {
const handIdx = deck.hand.indexOf(cardId);
if (handIdx >= 0) {
deck.hand.splice(handIdx, 1);
deck.discardPile.push(cardId);
}
}
export function exhaustCard(deck: PlayerDeck, cardId: string): void {
const handIdx = deck.hand.indexOf(cardId);
if (handIdx >= 0) {
deck.hand.splice(handIdx, 1);
deck.exhaustPile.push(cardId);
}
}
export function getEnemyCurrentIntent(enemy: EnemyState): EnemyDesert | undefined {
return enemy.intentData[enemy.currentIntentId];
}
export function advanceEnemyIntent(enemy: EnemyState): void {
const current = getEnemyCurrentIntent(enemy);
if (!current) return;
if (enemy.hadDefendBroken && current.brokenIntent.length > 0) {
const idx = Math.floor(Math.random() * current.brokenIntent.length);
enemy.currentIntentId = current.brokenIntent[idx];
enemy.hadDefendBroken = false;
return;
}
if (current.nextIntents.length > 0) {
const idx = Math.floor(Math.random() * current.nextIntents.length);
enemy.currentIntentId = current.nextIntents[idx];
return;
}
enemy.currentIntentId = current.intentId;
}
function shuffleDeck(drawPile: string[], rng: { nextInt: (n: number) => number }): void {
for (let i = drawPile.length - 1; i > 0; i--) {
const j = rng.nextInt(i + 1);
[drawPile[i], drawPile[j]] = [drawPile[j], drawPile[i]];
}
}
export function getEffectTiming(effectId: string): EffectDesert["timing"] | undefined {
const effect = effectDesertData.find(e => e.id === effectId);
return effect?.timing;
}
export function getEffectData(effectId: string): EffectDesert | undefined {
return effectDesertData.find(e => e.id === effectId);
}
export { INITIAL_HAND_SIZE, DEFAULT_MAX_ENERGY, FATIGUE_CARDS_PER_SHUFFLE };
@@ -1,347 +0,0 @@
import type { EffectDesert } from "../data/effectDesert.csv";
import { cardDesertData } from "../data";
import { createStatusCard } from "../deck/factory";
import type { BuffTable, CombatEffectEntry, CombatState } from "./types";
import { applyDamage, removeBuff } from "./effects";
export type TriggerContext = {
state: CombatState;
rng: { nextInt: (n: number) => number };
};
export type BuffTriggerBehavior = {
onTurnStart?: (ctx: TriggerContext, entityKey: "player" | string, stacks: number) => void;
onTurnEnd?: (ctx: TriggerContext, entityKey: "player" | string, stacks: number) => void;
onAttacked?: (ctx: TriggerContext, attackerKey: "player" | string, defenderKey: "player" | string, damage: number, stacks: number) => number;
onDamage?: (ctx: TriggerContext, targetKey: "player" | string, damage: number, stacks: number) => void;
modifyOutgoingDamage?: (ctx: TriggerContext, sourceKey: "player" | string, damage: number, stacks: number) => number;
modifyIncomingDamage?: (ctx: TriggerContext, targetKey: "player" | string, damage: number, stacks: number) => number;
onShuffle?: (ctx: TriggerContext, stacks: number) => void;
onCardPlayed?: (ctx: TriggerContext, cardId: string, stacks: number) => void;
onCardDiscarded?: (ctx: TriggerContext, cardId: string, stacks: number) => void;
onCardDrawn?: (ctx: TriggerContext, cardId: string, stacks: number) => void;
};
export type TriggerEvent =
| "onTurnStart"
| "onTurnEnd"
| "onAttacked"
| "onDamage"
| "modifyOutgoingDamage"
| "modifyIncomingDamage"
| "onShuffle"
| "onCardPlayed"
| "onCardDiscarded"
| "onCardDrawn";
export type CombatTriggerRegistry = Record<string, BuffTriggerBehavior>;
export function createCombatTriggerRegistry(): CombatTriggerRegistry {
return {
spike: {
onAttacked(ctx, attackerKey, _defenderKey, damage, stacks) {
const { state } = ctx;
applyDamage(state, attackerKey, stacks, _defenderKey);
return damage;
},
},
aim: {
modifyOutgoingDamage(_ctx, _sourceKey, damage, stacks) {
if (stacks > 0) return damage * 2;
return damage;
},
onDamage(ctx, targetKey, damage, stacks) {
const { state } = ctx;
const entity = targetKey === "player" ? null : state.enemies[targetKey];
if (entity) {
const loss = Math.min(stacks, damage);
removeBuff(entity.buffs, "aim", loss);
}
},
},
charge: {
modifyOutgoingDamage(_ctx, _sourceKey, damage, stacks) {
if (stacks > 0) {
return damage * 2;
}
return damage;
},
modifyIncomingDamage(_ctx, _targetKey, damage, stacks) {
if (stacks > 0) {
return damage * 2;
}
return damage;
},
onDamage(ctx, targetKey, damage, stacks) {
const { state } = ctx;
const entity = targetKey === "player"
? { buffs: state.player.buffs } as { buffs: BuffTable }
: state.enemies[targetKey];
if (entity) {
const loss = Math.min(stacks, damage);
removeBuff(entity.buffs, "charge", loss);
}
},
},
roll: {
modifyOutgoingDamage(ctx, sourceKey, damage, stacks) {
if (stacks >= 10) {
const { state } = ctx;
const entity = sourceKey === "player"
? { buffs: state.player.buffs } as { buffs: BuffTable }
: state.enemies[sourceKey];
if (entity) {
const spendable = Math.floor(stacks / 10) * 10;
const bonusDamage = Math.floor(spendable / 10);
removeBuff(entity.buffs, "roll", spendable);
return damage + bonusDamage;
}
}
return damage;
},
},
tailSting: {
onTurnEnd(ctx, entityKey, stacks) {
const { state } = ctx;
if (entityKey !== "player" && state.enemies[entityKey]?.isAlive) {
applyDamage(state, "player", stacks, entityKey);
}
},
},
energyDrain: {
onDamage(ctx, targetKey, _damage, _stacks) {
const { state } = ctx;
if (targetKey === "player" && state.player.damagedThisTurn === false) {
// This is the first damage; mark it.
// actual energy drain happens in onTurnStart check
}
},
onTurnStart(ctx, entityKey, _stacks) {
// energyDrain: first damage each turn loses 1 energy
// We just mark that the enemy has this; actual drain is in onDamage
},
},
molt: {
onDamage(ctx, targetKey, _damage, _stacks) {
const { state } = ctx;
if (targetKey !== "player") {
const enemy = state.enemies[targetKey];
if (enemy && enemy.isAlive) {
const moltStacks = enemy.buffs["molt"] ?? 0;
if (moltStacks >= enemy.maxHp) {
enemy.isAlive = false;
}
}
}
},
},
storm: {
onAttacked(ctx, attackerKey, defenderKey, damage, stacks) {
const { state } = ctx;
if (defenderKey !== "player" && state.enemies[defenderKey]?.isAlive) {
addStatusCardToHand(state, "static", 1);
}
return damage;
},
},
vultureEye: {
onDamage(ctx, targetKey, damage, stacks) {
const { state } = ctx;
if (targetKey === "player" && damage > 0) {
const vultureEnemies = state.enemyOrder.filter(
(id) => state.enemies[id].isAlive && state.enemies[id].buffs["vultureEye"] && state.enemies[id].templateId === "秃鹫"
);
if (vultureEnemies.length > 0) {
for (const vultureId of vultureEnemies) {
const vulture = state.enemies[vultureId];
const intent = vulture.intentData["attack"];
if (intent) {
const effects = intent.effects as unknown as CombatEffectEntry[];
for (const entry of effects) {
if (entry[0] === "player" && entry[1].id === "attack") {
applyDamage(state, "player", entry[2], vultureId);
}
}
}
}
}
}
},
},
venom: {
onCardDiscarded(ctx, cardId, stacks) {
const { state } = ctx;
state.player.cardsDiscardedThisTurn++;
const venomCards = state.player.deck.hand.filter((id) => {
const card = state.player.deck.cards[id];
return card && card.itemData === null && card.displayName === "蛇毒";
});
if (state.player.cardsDiscardedThisTurn > 1 && venomCards.length > 0) {
applyDamage(state, "player", 6, undefined);
}
},
},
static: {
modifyIncomingDamage(_ctx, targetKey, damage, stacks) {
if (targetKey === "player") {
return damage + stacks;
}
return damage;
},
},
discard: {
onShuffle(ctx, stacks) {
// Bandit: shuffle discards random item cards
// Simplified: mark the effect for the procedure to handle
},
},
curse: {
onDamage(ctx, targetKey, damage, stacks) {
// Curse: when attacked, item attack -1 until card from that item is discarded
// This is handled via itemBuffs in effects
},
},
};
}
function addStatusCardToHand(state: CombatState, effectId: string, count: number): void {
const cardDef = cardDesertData.find((c) => c.id === effectId);
if (!cardDef) return;
for (let i = 0; i < count; i++) {
const cardId = `status-${effectId}-${Date.now()}-${i}`;
const card = createStatusCard(cardId, cardDef.name, cardDef.desc);
state.player.deck.cards[card.id] = card;
state.player.deck.hand.push(card.id);
}
}
export function dispatchTrigger(
ctx: TriggerContext,
event: TriggerEvent,
entityKey: "player" | string,
registry: CombatTriggerRegistry,
): void {
const buffs = entityKey === "player"
? ctx.state.player.buffs
: ctx.state.enemies[entityKey]?.buffs;
if (!buffs) return;
for (const [buffId, stacks] of Object.entries(buffs)) {
const behavior = registry[buffId];
if (!behavior) continue;
const handler = behavior[event];
if (handler) {
handler(ctx, entityKey, stacks);
}
}
}
export function dispatchAttackedTrigger(
ctx: TriggerContext,
attackerKey: "player" | string,
defenderKey: "player" | string,
damage: number,
registry: CombatTriggerRegistry,
): number {
const buffs = defenderKey === "player"
? ctx.state.player.buffs
: ctx.state.enemies[defenderKey]?.buffs;
if (!buffs) return damage;
let modifiedDamage = damage;
for (const [buffId, stacks] of Object.entries(buffs)) {
const behavior = registry[buffId];
if (!behavior?.onAttacked) continue;
modifiedDamage = behavior.onAttacked(ctx, attackerKey, defenderKey, modifiedDamage, stacks);
}
return modifiedDamage;
}
export function dispatchDamageTrigger(
ctx: TriggerContext,
targetKey: "player" | string,
damage: number,
registry: CombatTriggerRegistry,
): void {
const buffs = targetKey === "player"
? ctx.state.player.buffs
: ctx.state.enemies[targetKey]?.buffs;
if (!buffs) return;
for (const [buffId, stacks] of Object.entries(buffs)) {
const behavior = registry[buffId];
if (!behavior?.onDamage) continue;
behavior.onDamage(ctx, targetKey, damage, stacks);
}
}
export function dispatchOutgoingDamageTrigger(
ctx: TriggerContext,
sourceKey: "player" | string,
damage: number,
registry: CombatTriggerRegistry,
): number {
const buffs = sourceKey === "player"
? ctx.state.player.buffs
: ctx.state.enemies[sourceKey]?.buffs;
if (!buffs) return damage;
let modifiedDamage = damage;
for (const [buffId, stacks] of Object.entries(buffs)) {
const behavior = registry[buffId];
if (!behavior?.modifyOutgoingDamage) continue;
modifiedDamage = behavior.modifyOutgoingDamage(ctx, sourceKey, modifiedDamage, stacks);
}
return modifiedDamage;
}
export function dispatchIncomingDamageTrigger(
ctx: TriggerContext,
targetKey: "player" | string,
damage: number,
registry: CombatTriggerRegistry,
): number {
const buffs = targetKey === "player"
? ctx.state.player.buffs
: ctx.state.enemies[targetKey]?.buffs;
if (!buffs) return damage;
let modifiedDamage = damage;
for (const [buffId, stacks] of Object.entries(buffs)) {
const behavior = registry[buffId];
if (!behavior?.modifyIncomingDamage) continue;
modifiedDamage = behavior.modifyIncomingDamage(ctx, targetKey, modifiedDamage, stacks);
}
return modifiedDamage;
}
export function dispatchShuffleTrigger(
ctx: TriggerContext,
registry: CombatTriggerRegistry,
): void {
for (const enemyId of ctx.state.enemyOrder) {
const enemy = ctx.state.enemies[enemyId];
if (!enemy.isAlive) continue;
for (const [buffId, stacks] of Object.entries(enemy.buffs)) {
const behavior = registry[buffId];
if (!behavior?.onShuffle) continue;
behavior.onShuffle(ctx, stacks);
}
}
}
export function dispatchCardDrawnTrigger(
ctx: TriggerContext,
cardId: string,
registry: CombatTriggerRegistry,
): void {
const buffs = ctx.state.player.buffs;
if (!buffs) return;
for (const [buffId, stacks] of Object.entries(buffs)) {
const behavior = registry[buffId];
if (!behavior?.onCardDrawn) continue;
behavior.onCardDrawn(ctx, cardId, stacks);
}
}
@@ -1,77 +0,0 @@
import type { EnemyDesert } from "../data/enemyDesert.csv";
import type { EffectDesert } from "../data/effectDesert.csv";
import type { PlayerDeck, GameCard } from "../deck/types";
import type { PlayerState } from "../progress/types";
export type BuffTable = Record<string, number>;
/** Lifecycle timing for effects - matches CSV timing column */
export type EffectTiming = EffectDesert["timing"];
export type EffectTarget = "self" | "target" | "all" | "random" | "player" | "team";
export type ItemBuff = {
effectId: string;
stacks: number;
timing: EffectTiming;
sourceItemId: string;
targetItemId: string;
};
export type EnemyState = {
id: string;
templateId: string;
hp: number;
maxHp: number;
buffs: BuffTable;
currentIntentId: string;
intentData: Record<string, EnemyDesert>;
isAlive: boolean;
hadDefendBroken: boolean;
};
export type PlayerCombatState = {
hp: number;
maxHp: number;
energy: number;
maxEnergy: number;
buffs: BuffTable;
deck: PlayerDeck;
damageTakenThisTurn: number;
damagedThisTurn: boolean;
cardsDiscardedThisTurn: number;
itemBuffs: ItemBuff[];
fatigueAddedCount: number;
};
export type CombatPhase = "playerTurn" | "enemyTurn" | "combatEnd";
export type CombatResult = "victory" | "defeat";
export type LootEntry = {
type: "gold" | "item" | "relic";
amount?: number;
itemId?: string;
};
export type CombatState = {
enemies: Record<string, EnemyState>;
enemyOrder: string[];
player: PlayerCombatState;
phase: CombatPhase;
turnNumber: number;
result: CombatResult | null;
loot: LootEntry[];
enemyTemplateData: Record<string, EnemyDesert>;
};
export type CombatEffectEntry = [EffectTarget, EffectDesert, number];
export type CombatEntity = {
buffs: BuffTable;
hp: number;
maxHp: number;
isAlive: boolean;
};
export type CombatGameContext = import("@/core/game").IGameContext<CombatState>;
@@ -1,41 +0,0 @@
# 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
# unplayable: true for status cards that cannot be played
# onPlay: effects triggered when card is played
# onDraw: effects triggered when card enters hand
# onDiscard: effects triggered when card is discarded
id,name,desc,type,costType,costCount,targetType,unplayable,onPlay,onDraw,onDiscard
string,string,string,'item'|'status','energy'|'uses'|'none',int,'single'|'none',boolean,['self'|'target'|'all'|'random'|'player';@effectDesert;number][],['self'|'target'|'all'|'random'|'player';@effectDesert;number][],['self'|'target'|'all'|'random'|'player';@effectDesert;number][]
sword,剑,【攻击2】【攻击2】,item,energy,1,single,false,[target;attack;2];[target;attack;2],,
greataxe,长斧,对全体【攻击5】,item,energy,2,none,false,[all;attack;5],,
spear,长枪,【攻击2】【攻击2】【攻击2】,item,energy,1,single,false,[target;attack;2];[target;attack;2];[target;attack;2],,
dagger,短刀,【攻击3】【攻击3】,item,energy,1,single,false,[target;attack;3];[target;attack;3],,
dart,飞镖,【攻击1】抓一张牌,item,energy,0,single,false,[target;attack;1];[self;draw;1],,
crossbow,十字弩,【攻击6】对同一目标打出其他十字弩,item,energy,2,single,false,[target;attack;6];[self;crossbow;0],,
shield,盾,【防御3】,item,energy,1,none,false,[self;defend;3],,
hat,斗笠,【防御8】,item,energy,2,none,false,[self;defend;8],,
cape,披风,【防御2】下回合【防御2】,item,energy,1,none,false,[self;defend;2];[self;defendNext;2],,
bracer,护腕,【防御1】抓1张牌,item,energy,0,none,false,[self;defend;1];[self;draw;1],,
greatshield,大盾,【防御5】,item,energy,1,none,false,[self;defend;5],,
chainmail,锁子甲,本回合受到伤害-3,item,energy,1,none,false,[self;damageReduce;3],,
bandage,绷带,从牌堆或弃牌堆随机移除1张伤口,item,uses,3,none,false,[self;removeWound;1],,
poisonPotion,淬毒药剂,周围物品的【攻击】+2,item,uses,3,none,false,[self;attackBuff;2],,
fortifyPotion,强固药剂,周围物品的【防御】+2,item,uses,3,none,false,[self;defendBuff;2],,
vitalityPotion,活力药剂,获得1点能量,item,uses,3,none,false,[self;gainEnergy;1],,
focusPotion,集中药剂,抓2张牌,item,uses,3,none,false,[self;draw;2],,
healingPotion,治疗药剂,从牌堆或弃牌堆移除3张伤口,item,uses,3,none,false,[self;removeWound;3],,
waterBag,水袋,下回合开始时获得1能量抓2张牌,item,energy,1,none,false,[self;energyNext;1];[self;drawNext;2],,
rope,绳索,周围物品的牌【防御】+2直到打出,item,energy,1,none,false,[self;defendBuffUntilPlay;2],,
belt,腰带,从牌堆周围物品的牌当中选择一张加入手牌,item,energy,0,none,false,[self;drawChoice;1],,
torch,火把,下次打出周围物品的牌时将其消耗并获得1能量,item,energy,1,none,false,[self;burnForEnergy;1],,
whetstone,磨刀石,周围物品的牌【攻击】+3直到打出,item,energy,1,none,false,[self;attackBuffUntilPlay;3],,
blacksmithHammer,铁匠锤,从牌堆/弃牌堆选择一张牌随机变为一张周围物品的牌,item,energy,1,none,false,[self;transformRandom;1],,
wound,伤口,无效果占用手牌和牌堆,status,none,0,none,true,,,
venom,蛇毒,弃掉时受到3点伤害,status,none,0,none,true,,,[self;attack;3]
curse,诅咒,受攻击时物品攻击-1直到弃掉一张该物品的牌,status,none,0,none,true,,[self;curse;1],
static,静电,在手里时受电击伤害+1,status,none,0,none,true,,[self;static;1],
fatigue,疲劳,占用手牌,status,none,0,none,true,,,
vultureEye,秃鹫之眼,抓到时获得3层暴露,status,none,0,none,true,,[self;expose;3],
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 # unplayable: true for status cards that cannot be played
6 # onPlay: effects triggered when card is played
7 # onDraw: effects triggered when card enters hand
8 # onDiscard: effects triggered when card is discarded
9 id,name,desc,type,costType,costCount,targetType,unplayable,onPlay,onDraw,onDiscard
10 string,string,string,'item'|'status','energy'|'uses'|'none',int,'single'|'none',boolean,['self'|'target'|'all'|'random'|'player';@effectDesert;number][],['self'|'target'|'all'|'random'|'player';@effectDesert;number][],['self'|'target'|'all'|'random'|'player';@effectDesert;number][]
11 sword,剑,【攻击2】【攻击2】,item,energy,1,single,false,[target;attack;2];[target;attack;2],,
12 greataxe,长斧,对全体【攻击5】,item,energy,2,none,false,[all;attack;5],,
13 spear,长枪,【攻击2】【攻击2】【攻击2】,item,energy,1,single,false,[target;attack;2];[target;attack;2];[target;attack;2],,
14 dagger,短刀,【攻击3】【攻击3】,item,energy,1,single,false,[target;attack;3];[target;attack;3],,
15 dart,飞镖,【攻击1】抓一张牌,item,energy,0,single,false,[target;attack;1];[self;draw;1],,
16 crossbow,十字弩,【攻击6】对同一目标打出其他十字弩,item,energy,2,single,false,[target;attack;6];[self;crossbow;0],,
17 shield,盾,【防御3】,item,energy,1,none,false,[self;defend;3],,
18 hat,斗笠,【防御8】,item,energy,2,none,false,[self;defend;8],,
19 cape,披风,【防御2】下回合【防御2】,item,energy,1,none,false,[self;defend;2];[self;defendNext;2],,
20 bracer,护腕,【防御1】抓1张牌,item,energy,0,none,false,[self;defend;1];[self;draw;1],,
21 greatshield,大盾,【防御5】,item,energy,1,none,false,[self;defend;5],,
22 chainmail,锁子甲,本回合受到伤害-3,item,energy,1,none,false,[self;damageReduce;3],,
23 bandage,绷带,从牌堆或弃牌堆随机移除1张伤口,item,uses,3,none,false,[self;removeWound;1],,
24 poisonPotion,淬毒药剂,周围物品的【攻击】+2,item,uses,3,none,false,[self;attackBuff;2],,
25 fortifyPotion,强固药剂,周围物品的【防御】+2,item,uses,3,none,false,[self;defendBuff;2],,
26 vitalityPotion,活力药剂,获得1点能量,item,uses,3,none,false,[self;gainEnergy;1],,
27 focusPotion,集中药剂,抓2张牌,item,uses,3,none,false,[self;draw;2],,
28 healingPotion,治疗药剂,从牌堆或弃牌堆移除3张伤口,item,uses,3,none,false,[self;removeWound;3],,
29 waterBag,水袋,下回合开始时获得1能量抓2张牌,item,energy,1,none,false,[self;energyNext;1];[self;drawNext;2],,
30 rope,绳索,周围物品的牌【防御】+2直到打出,item,energy,1,none,false,[self;defendBuffUntilPlay;2],,
31 belt,腰带,从牌堆周围物品的牌当中选择一张加入手牌,item,energy,0,none,false,[self;drawChoice;1],,
32 torch,火把,下次打出周围物品的牌时将其消耗并获得1能量,item,energy,1,none,false,[self;burnForEnergy;1],,
33 whetstone,磨刀石,周围物品的牌【攻击】+3直到打出,item,energy,1,none,false,[self;attackBuffUntilPlay;3],,
34 blacksmithHammer,铁匠锤,从牌堆/弃牌堆选择一张牌随机变为一张周围物品的牌,item,energy,1,none,false,[self;transformRandom;1],,
35 wound,伤口,无效果占用手牌和牌堆,status,none,0,none,true,,,
36 venom,蛇毒,弃掉时受到3点伤害,status,none,0,none,true,,,[self;attack;3]
37 curse,诅咒,受攻击时物品攻击-1直到弃掉一张该物品的牌,status,none,0,none,true,,[self;curse;1],
38 static,静电,在手里时受电击伤害+1,status,none,0,none,true,,[self;static;1],
39 fatigue,疲劳,占用手牌,status,none,0,none,true,,,
40 vultureEye,秃鹫之眼,抓到时获得3层暴露,status,none,0,none,true,,[self;expose;3],
@@ -1,20 +0,0 @@
import type { EffectDesert } from './effectDesert.csv';
type CardDesertTable = readonly {
readonly id: string;
readonly name: string;
readonly desc: string;
readonly type: "item" | "status";
readonly costType: "energy" | "uses" | "none";
readonly costCount: number;
readonly targetType: "single" | "none";
readonly unplayable: boolean;
readonly onPlay: readonly ["self" | "target" | "all" | "random" | "player", EffectDesert, number];
readonly onDraw: readonly ["self" | "target" | "all" | "random" | "player", EffectDesert, number];
readonly onDiscard: readonly ["self" | "target" | "all" | "random" | "player", EffectDesert, number];
}[];
export type CardDesert = CardDesertTable[number];
declare function getData(): CardDesertTable;
export default getData;
@@ -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,3 @@
import type { LoadResult } from 'yarn-spinner-loader';
const result: LoadResult;
export default result;
@@ -1,48 +1,52 @@
# instant: 不施加buff,瞬间生效
# temporary: 施加buff,下回合开始时失效
# lingering: 施加buff,下回合开始时失去1层
# permanent: 施加buff
# posture: 施加buff,每受到1点伤害移除1层
# card: 不施加buff,对玩家时在玩家弃牌堆创建同名卡牌,对敌人无效(敌人没有牌堆)
# cardDraw: 不施加buff,在抓牌堆洗入同名卡牌
# cardHand:不施加buff,在玩家手牌中创建同名卡牌
# item: 施加buff到周围物品,永久生效
# itemUntilPlayed: 施加buff到周围物品,物品被打出后失效
id, name, description, timing
string, string, string, 'instant'|'temporary'|'lingering'|'permanent'|'posture'|'card'|'cardDraw'|'cardHand'|'item'|'itemUntilPlayed'
attack, 攻击, 对对手造成伤害, instant
defend, 防御, 抵消下次行动前受到的伤害, posture
spike, 尖刺, 对攻击者造成X点伤害, permanent
venom, 蛇毒, 同名状态牌/1费:打出时移除此牌。弃掉时受到3点伤害, cardHand
curse, 诅咒, 受攻击时物品攻击-1,直到弃掉一张该物品的牌, lingering
aim, 瞄准, 造成双倍伤害,受伤时失去等量瞄准, posture
roll, 滚动, 攻击时每消耗10点滚动造成等量伤害, posture
rollDamage, 滚动攻击, 消耗滚动层数造成的伤害, instant
vultureEye, 秃鹫之眼, 抓到时获得3层暴露(临时debuff,受到的伤害+1/每层), cardDraw
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, itemUntilPlayed
defendBuff, 防御增益, 周围物品的防御+X, itemUntilPlayed
gainEnergy, 获得能量, 获得X点能量, instant
energyNext, 下回合获能量, 下回合开始时获得X点能量, temporary
drawNext, 下回合抓牌, 下回合开始时抓X张牌, temporary
defendBuffUntilPlay, 防御增益直到打出, 周围物品的牌防御+X直到打出, itemUntilPlayed
drawChoice, 选择抓牌, 从牌堆周围物品的牌中选择一张加入手牌, instant
burnForEnergy, 消耗获能量, 打出周围物品的牌时消耗并获得X能量, itemUntilPlayed
attackBuffUntilPlay, 攻击增益直到打出, 周围物品的牌攻击+X直到打出, itemUntilPlayed
transformRandom, 随机变牌, 选择一张牌随机变为周围物品的牌, instant
expose, 暴露, 受到的伤害+1/每层, temporary
# 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 # temporary: 施加buff,下回合开始时失效 # lingering: 施加buff,下回合开始时失去1层
4 # lingering: 施加buff,下回合开始时失去1层 # permanent: 施加buff
5 # permanent: 施加buff # posture: 施加buff,每受到1点伤害移除1层
6 # posture: 施加buff,每受到1点伤害移除1层 # item: 施加buff到周围物品,永久生效
# card: 不施加buff,对玩家时在玩家弃牌堆创建同名卡牌,对敌人无效(敌人没有牌堆)
# cardDraw: 不施加buff,在抓牌堆洗入同名卡牌
# cardHand:不施加buff,在玩家手牌中创建同名卡牌
# item: 施加buff到周围物品,永久生效
# itemUntilPlayed: 施加buff到周围物品,物品被打出后失效
7 id # itemTemporary: 施加buff到周围物品,持续到下次buff更新
8 string # itemUntilPlay: 施加buff到周围物品,物品被打出后失效
9 attack # itemUntilDiscard: 施加buff到周围物品,物品被弃掉后失效
10 defend # itemPermanent: 施加buff到周围物品,持续整场冒险
11 spike # type EffectLifecycle = 'instant' | 'temporary' | 'lingering' | 'permanent' | 'posture' | 'item' | 'itemTemporary' | 'itemUntilPlay' | 'itemUntilDiscard' | 'itemPermanent'
12 venom id
13 curse string
14 aim attack
15 roll defend
16 rollDamage spike
17 vultureEye venom
18 tailSting curse
19 energyDrain aim
20 molt roll
21 discard rollDamage
22 storm vultureEye
23 static tailSting
24 charge energyDrain
25 summonMummy molt
26 summonSandwormLarva discard
27 reviveMummy storm
28 draw static
29 crossbow charge
30 defendNext summonMummy
31 damageReduce summonSandwormLarva
32 removeWound reviveMummy
33 attackBuff draw
34 defendBuff crossbow
35 gainEnergy defendNext
36 energyNext damageReduce
37 drawNext removeWound
38 defendBuffUntilPlay attackBuff
39 drawChoice defendBuff
40 burnForEnergy gainEnergy
41 attackBuffUntilPlay energyNext
42 transformRandom drawNext
43 expose defendBuffUntilPlay
44 drawChoice
45 burnForEnergy
46 attackBuffUntilPlay
47 transformRandom
48 expose
49
50
51
52
@@ -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();
});
}
@@ -0,0 +1,92 @@
import { Triggers } from "@/samples/slay-the-spire-like/system/combat/triggers";
import { addEntityEffect } 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 addTurnStartTriggers(triggers: Triggers) {
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;
}
// discard: random discard at turn start
triggers.onTurnStart.use(async (ctx, next) => {
if (ctx.entityId !== "player") {
await next();
return;
}
const discard = ctx.game.value.player.effects.discard;
if (discard && discard.stacks > 0) {
const handIds = [...ctx.game.value.player.deck.regions.hand.childIds];
if (handIds.length > 0) {
const randomIndex = ctx.game.rng.nextInt(handIds.length);
const randomCardId = handIds[randomIndex];
await triggers.onCardDiscarded.execute(ctx.game, {
cardId: randomCardId,
});
}
}
await next();
});
// defendNext: gain block next turn
// record how many defendNext there is, before buff update
// then apply after buff update
triggers.onTurnStart.use(async (ctx, next) => {
const defendNext = ctx.game.value.player.effects.defendNext;
if (!defendNext || defendNext.stacks <= 0) {
await next();
return;
}
await next();
await ctx.game.produceAsync((draft) => {
addEntityEffect(draft.player, findEffect("defend"), defendNext.stacks);
});
});
// energyNext: gain energy next turn
triggers.onTurnStart.use(async (ctx, next) => {
if (ctx.entityId !== "player") {
await next();
return;
}
const energyNext = ctx.game.value.player.effects.energyNext;
if (energyNext && energyNext.stacks > 0) {
await ctx.game.produceAsync((draft) => {
draft.player.energy += energyNext.stacks;
addEntityEffect(draft.player, energyNext.data, -energyNext.stacks);
});
}
await next();
});
// drawNext: draw extra cards next turn
triggers.onTurnStart.use(async (ctx, next) => {
if (ctx.entityId !== "player") {
await next();
return;
}
const drawNext = ctx.game.value.player.effects.drawNext;
if (drawNext && drawNext.stacks > 0) {
await ctx.game.produceAsync((draft) => {
addEntityEffect(draft.player, drawNext.data, -drawNext.stacks);
});
await triggers.onDraw.execute(ctx.game, { count: drawNext.stacks });
}
await next();
});
}
@@ -1,11 +0,0 @@
type EffectDesertTable = readonly {
readonly id: string;
readonly name: string;
readonly description: string;
readonly timing: "instant" | "temporary" | "lingering" | "permanent" | "posture" | "card" | "cardDraw" | "cardHand" | "item" | "itemUntilPlayed";
}[];
export type EffectDesert = EffectDesertTable[number];
declare function getData(): EffectDesertTable;
export default getData;
@@ -1,36 +0,0 @@
# 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; hp; bonusHp] - bonusHp for scaling
type,name,description,enemies,dialogue
'minion'|'elite'|'event'|'shop'|'camp'|'curio',string,string,[string; int; int][],string
minion,仙人掌怪,概念:防+强化。【尖刺X】:对攻击者造成X点伤害。,[仙人掌怪;20;0];[仙人掌怪;20;0],
minion,蛇,概念:攻+强化。给玩家塞入蛇毒牌(1费:打出时移除此牌。弃掉时受到3点伤害)。,[蛇;14;0];[蛇;14;0],
minion,木乃伊,概念:攻+防。【诅咒】:受攻击时物品【攻击】-1,直到弃掉一张该物品的牌。,[木乃伊;18;0];[仙人掌怪;20;0],
minion,枪手,概念:单回高攻。【瞄准X】:造成双倍伤害。受伤时失去等量【瞄准】,[枪手;16;0],
minion,风卷草,概念:防+强化。【滚动X】:攻击时,每消耗10点【滚动】,造成等量伤害。,[风卷草;22;0];[风卷草;22;0],
minion,秃鹫,概念:攻+防。若造成伤害,玩家获得秃鹫之眼(0费状态牌:打出时移除。抓到时获得3层暴露)。,[秃鹫;16;0];[仙人掌怪;20;0],
minion,沙蝎,概念:攻+强化。【尾刺X】:姿态buff,攻击时,伤害提升X。,[沙蝎;14;0];[蛇;14;0],
minion,幼沙虫,概念:防+强化。每回合第一次受伤时,玩家失去1点能量。,[幼沙虫;24;0],
minion,蜥蜴,概念:攻+防+逃跑。【脱皮】:若脱皮达到生命上限,则怪物逃跑,玩家不能获得战斗奖励。,[蜥蜴;20;0];[蜥蜴;20;0],
minion,沙匪,概念:弱化玩家。【劫掠】:对玩家施加的延时debuff。回合开始时,随机弃掉一张手牌。,[沙匪;16;0];[枪手;16;0],
elite,风暴之灵,【风暴X】:攻击时,玩家获得1张静电。受伤时失去等量【风暴】。(静电:在手里时受【电击】伤害+1),[风暴之灵;44;0],
elite,骑马枪手,【冲锋X】:受到或造成的伤害翻倍并消耗等量的冲锋。,[骑马枪手;50;0];[枪手;20;4],
elite,沙虫王,召唤幼体沙虫;每当玩家弃掉一张牌,恢复1生命。,[沙虫王;55;0],
elite,沙漠守卫,召唤木乃伊;会复活木乃伊2次。,[沙漠守卫;48;0];[木乃伊;20;0],
shop,沙漠商人,商店:可以恢复生命、出售装备、附魔物品。,,
shop,游牧商队,商队:出售稀有物品、移除牌组中一张牌。,,
camp,绿洲篝火,篝火:可以恢复生命、补充药水使用次数、获得下次战斗Buff。,,
camp,岩洞庇护所,篝火:可以恢复生命、升级一张牌。,,
curio,沙中遗物,随机获得一件遗物或受到3点伤害。,,desert_relic_in_sand
curio,枯井,投入1能量:可能获得药水或什么也没有。,,desert_dry_well
curio,古代石碑,阅读碑文:获得随机Buff直到下次战斗结束。,,desert_ancient_stele
curio,沙暴残骸,搜索残骸:随机获得一张物品牌或受到2点伤害。,,desert_storm_wreckage
curio,蜃景宝箱,打开宝箱:50%获得宝藏,50%为蜃景什么也没有。,,desert_mirage_chest
curio,埋藏陶罐,挖掘:获得随机资源(金币、药水或遗物碎片)。,,desert_buried_pot
curio,风化雕像,献祭1生命:获得一件随机遗物。,,desert_weathered_statue
curio,绿洲碎片,小型绿洲:恢复3生命并获得1张随机消耗品。,,desert_oasis_fragment
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; hp; bonusHp] - bonusHp for scaling
8 type,name,description,enemies,dialogue
9 'minion'|'elite'|'event'|'shop'|'camp'|'curio',string,string,[string; int; int][],string
10 minion,仙人掌怪,概念:防+强化。【尖刺X】:对攻击者造成X点伤害。,[仙人掌怪;20;0];[仙人掌怪;20;0],
11 minion,蛇,概念:攻+强化。给玩家塞入蛇毒牌(1费:打出时移除此牌。弃掉时受到3点伤害)。,[蛇;14;0];[蛇;14;0],
12 minion,木乃伊,概念:攻+防。【诅咒】:受攻击时物品【攻击】-1,直到弃掉一张该物品的牌。,[木乃伊;18;0];[仙人掌怪;20;0],
13 minion,枪手,概念:单回高攻。【瞄准X】:造成双倍伤害。受伤时失去等量【瞄准】,[枪手;16;0],
14 minion,风卷草,概念:防+强化。【滚动X】:攻击时,每消耗10点【滚动】,造成等量伤害。,[风卷草;22;0];[风卷草;22;0],
15 minion,秃鹫,概念:攻+防。若造成伤害,玩家获得秃鹫之眼(0费状态牌:打出时移除。抓到时获得3层暴露)。,[秃鹫;16;0];[仙人掌怪;20;0],
16 minion,沙蝎,概念:攻+强化。【尾刺X】:姿态buff,攻击时,伤害提升X。,[沙蝎;14;0];[蛇;14;0],
17 minion,幼沙虫,概念:防+强化。每回合第一次受伤时,玩家失去1点能量。,[幼沙虫;24;0],
18 minion,蜥蜴,概念:攻+防+逃跑。【脱皮】:若脱皮达到生命上限,则怪物逃跑,玩家不能获得战斗奖励。,[蜥蜴;20;0];[蜥蜴;20;0],
19 minion,沙匪,概念:弱化玩家。【劫掠】:对玩家施加的延时debuff。回合开始时,随机弃掉一张手牌。,[沙匪;16;0];[枪手;16;0],
20 elite,风暴之灵,【风暴X】:攻击时,玩家获得1张静电。受伤时失去等量【风暴】。(静电:在手里时受【电击】伤害+1),[风暴之灵;44;0],
21 elite,骑马枪手,【冲锋X】:受到或造成的伤害翻倍并消耗等量的冲锋。,[骑马枪手;50;0];[枪手;20;4],
22 elite,沙虫王,召唤幼体沙虫;每当玩家弃掉一张牌,恢复1生命。,[沙虫王;55;0],
23 elite,沙漠守卫,召唤木乃伊;会复活木乃伊2次。,[沙漠守卫;48;0];[木乃伊;20;0],
24 shop,沙漠商人,商店:可以恢复生命、出售装备、附魔物品。,,
25 shop,游牧商队,商队:出售稀有物品、移除牌组中一张牌。,,
26 camp,绿洲篝火,篝火:可以恢复生命、补充药水使用次数、获得下次战斗Buff。,,
27 camp,岩洞庇护所,篝火:可以恢复生命、升级一张牌。,,
28 curio,沙中遗物,随机获得一件遗物或受到3点伤害。,,desert_relic_in_sand
29 curio,枯井,投入1能量:可能获得药水或什么也没有。,,desert_dry_well
30 curio,古代石碑,阅读碑文:获得随机Buff直到下次战斗结束。,,desert_ancient_stele
31 curio,沙暴残骸,搜索残骸:随机获得一张物品牌或受到2点伤害。,,desert_storm_wreckage
32 curio,蜃景宝箱,打开宝箱:50%获得宝藏,50%为蜃景什么也没有。,,desert_mirage_chest
33 curio,埋藏陶罐,挖掘:获得随机资源(金币、药水或遗物碎片)。,,desert_buried_pot
34 curio,风化雕像,献祭1生命:获得一件随机遗物。,,desert_weathered_statue
35 curio,绿洲碎片,小型绿洲:恢复3生命并获得1张随机消耗品。,,desert_oasis_fragment
36 event,海市蜃楼,随机遭遇:可能获得宝藏或遭遇陷阱,使用d6双阶段结构结算。,,desert_mirage_event
@@ -1,12 +0,0 @@
type EncounterDesertTable = readonly {
readonly type: "minion" | "elite" | "event" | "shop" | "camp" | "curio";
readonly name: string;
readonly description: string;
readonly enemies: readonly [string, number, number];
readonly dialogue: string;
}[];
export type EncounterDesert = EncounterDesertTable[number];
declare function getData(): EncounterDesertTable;
export default getData;
@@ -1,52 +0,0 @@
# enemyDesert: enemy templates and their intent state machines
# enemy: enemy template ID (unique identifier for this enemy type)
# intentId: intent state ID (unique within each enemy)
# 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 enemy type (applied to all instances)
# effects: effects executed when this intent is active
enemy,intentId,initialIntent,nextIntents,brokenIntent,initBuffs,effects
string,string,boolean,string[],string[],[@effectDesert;stacks: int][],['self'|'player'|'team';@effectDesert;number][]
仙人掌怪,boost,true,boost;defend;defend,,[spike;1],[self;spike;1];[self;defend;4]
仙人掌怪,defend,false,attack,,[spike;1],[self;defend;8]
仙人掌怪,attack,false,boost,,[spike;1],[player;attack;5]
蛇,poison,true,attack;attack,,,,[player;venom;1];[player;attack;4]
蛇,attack,false,poison;boost,,,,[player;attack;6]
蛇,boost,false,poison;attack,,,,[self;defend;3];[player;venom;1]
木乃伊,attack,true,defend;curse,,,,[player;attack;6]
木乃伊,defend,false,attack,,,,[self;defend;6]
木乃伊,curse,false,defend;attack,attack,,[player;curse;1]
枪手,aim,true,attack,,,,[self;aim;2]
枪手,attack,false,aim;defend,aim,,[player;attack;8]
枪手,defend,false,aim,aim,,[self;defend;5]
风卷草,boost,true,defend;defend;boost,,,,[self;roll;5];[self;defend;4]
风卷草,defend,false,boost;attack,,,,[self;defend;8]
风卷草,attack,false,boost,,,,[player;rollDamage;0]
秃鹫,attack,true,defend;defend,,,,[player;attack;6];[player;vultureEye;1]
秃鹫,defend,false,attack;attack,,,,[self;defend;5]
沙蝎,boost,true,attack;attack,,[tailSting;1],[self;tailSting;2]
沙蝎,attack,false,boost;attack,,[tailSting;1],[player;attack;6]
幼沙虫,defend,true,defend;boost,,[energyDrain;1],[self;defend;6]
幼沙虫,boost,false,attack;defend,,[energyDrain;1],[self;energyDrain;1];[self;defend;4]
幼沙虫,attack,false,defend;defend,,[energyDrain;1],[player;attack;5]
蜥蜴,attack,true,defend;molt,,,,[player;attack;5]
蜥蜴,defend,false,attack;attack,,,,[self;defend;6]
蜥蜴,molt,false,defend;attack,,,,[self;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,,,,[self;storm;2];[self;defend;3]
风暴之灵,attack,false,storm;defend,,,,[player;attack;8];[player;static;1]
风暴之灵,defend,false,storm;attack,,,,[self;defend;8]
骑马枪手,charge,true,attack,,,,[self;charge;2]
骑马枪手,attack,false,charge;defend,charge,,[player;attack;6]
骑马枪手,defend,false,charge;attack,charge,,[self;defend;5]
沙虫王,summon,true,attack;defend,,,,[self;summonSandwormLarva;1]
沙虫王,attack,false,summon;defend,,,,[player;attack;9]
沙虫王,defend,false,attack;summon,,,,[self;defend;6]
沙漠守卫,summon,true,attack;defend,,,,[self;summonMummy;1]
沙漠守卫,attack,false,defend;summon,,,,[player;attack;8]
沙漠守卫,defend,false,attack;revive,,,,[self;defend;8]
沙漠守卫,revive,false,attack;summon,,,,[self;reviveMummy;1]
1 # enemyDesert: enemy templates and their intent state machines
2 # enemy: enemy template ID (unique identifier for this enemy type)
3 # intentId: intent state ID (unique within each enemy)
4 # initialIntent: true if this is the enemy's starting intent
5 # nextIntents: possible next intent IDs after this intent resolves
6 # brokenIntent: possible intent IDs when defend is broken
7 # initBuffs: initial buffs for this enemy type (applied to all instances)
8 # effects: effects executed when this intent is active
9 enemy,intentId,initialIntent,nextIntents,brokenIntent,initBuffs,effects
10 string,string,boolean,string[],string[],[@effectDesert;stacks: int][],['self'|'player'|'team';@effectDesert;number][]
11 仙人掌怪,boost,true,boost;defend;defend,,[spike;1],[self;spike;1];[self;defend;4]
12 仙人掌怪,defend,false,attack,,[spike;1],[self;defend;8]
13 仙人掌怪,attack,false,boost,,[spike;1],[player;attack;5]
14 蛇,poison,true,attack;attack,,,,[player;venom;1];[player;attack;4]
15 蛇,attack,false,poison;boost,,,,[player;attack;6]
16 蛇,boost,false,poison;attack,,,,[self;defend;3];[player;venom;1]
17 木乃伊,attack,true,defend;curse,,,,[player;attack;6]
18 木乃伊,defend,false,attack,,,,[self;defend;6]
19 木乃伊,curse,false,defend;attack,attack,,[player;curse;1]
20 枪手,aim,true,attack,,,,[self;aim;2]
21 枪手,attack,false,aim;defend,aim,,[player;attack;8]
22 枪手,defend,false,aim,aim,,[self;defend;5]
23 风卷草,boost,true,defend;defend;boost,,,,[self;roll;5];[self;defend;4]
24 风卷草,defend,false,boost;attack,,,,[self;defend;8]
25 风卷草,attack,false,boost,,,,[player;rollDamage;0]
26 秃鹫,attack,true,defend;defend,,,,[player;attack;6];[player;vultureEye;1]
27 秃鹫,defend,false,attack;attack,,,,[self;defend;5]
28 沙蝎,boost,true,attack;attack,,[tailSting;1],[self;tailSting;2]
29 沙蝎,attack,false,boost;attack,,[tailSting;1],[player;attack;6]
30 幼沙虫,defend,true,defend;boost,,[energyDrain;1],[self;defend;6]
31 幼沙虫,boost,false,attack;defend,,[energyDrain;1],[self;energyDrain;1];[self;defend;4]
32 幼沙虫,attack,false,defend;defend,,[energyDrain;1],[player;attack;5]
33 蜥蜴,attack,true,defend;molt,,,,[player;attack;5]
34 蜥蜴,defend,false,attack;attack,,,,[self;defend;6]
35 蜥蜴,molt,false,defend;attack,,,,[self;molt;3]
36 沙匪,attack,true,attack;heavyAttack,,,,[player;attack;6]
37 沙匪,heavyAttack,false,attack;attack;debuff,,,,[player;attack;10]
38 沙匪,debuff,false,attack;attack,,,,[player;discard;1]
39 风暴之灵,storm,true,attack;storm,,,,[self;storm;2];[self;defend;3]
40 风暴之灵,attack,false,storm;defend,,,,[player;attack;8];[player;static;1]
41 风暴之灵,defend,false,storm;attack,,,,[self;defend;8]
42 骑马枪手,charge,true,attack,,,,[self;charge;2]
43 骑马枪手,attack,false,charge;defend,charge,,[player;attack;6]
44 骑马枪手,defend,false,charge;attack,charge,,[self;defend;5]
45 沙虫王,summon,true,attack;defend,,,,[self;summonSandwormLarva;1]
46 沙虫王,attack,false,summon;defend,,,,[player;attack;9]
47 沙虫王,defend,false,attack;summon,,,,[self;defend;6]
48 沙漠守卫,summon,true,attack;defend,,,,[self;summonMummy;1]
49 沙漠守卫,attack,false,defend;summon,,,,[player;attack;8]
50 沙漠守卫,defend,false,attack;revive,,,,[self;defend;8]
51 沙漠守卫,revive,false,attack;summon,,,,[self;reviveMummy;1]
@@ -1,16 +0,0 @@
import type { EffectDesert } from './effectDesert.csv';
type EnemyDesertTable = readonly {
readonly enemy: string;
readonly intentId: string;
readonly initialIntent: boolean;
readonly nextIntents: readonly string[];
readonly brokenIntent: readonly string[];
readonly initBuffs: readonly [EffectDesert, readonly stacks: number];
readonly effects: readonly ["self" | "player" | "team", EffectDesert, number];
}[];
export type EnemyDesert = EnemyDesertTable[number];
declare function getData(): EnemyDesertTable;
export default getData;
@@ -1,26 +0,0 @@
type,name,shape,card,price
string,string,string,@cardDesert,int
weapon,剑,oee,sword,50
weapon,长斧,oees,greataxe,80
weapon,长枪,oeee,spear,75
weapon,短刀,oe,dagger,40
weapon,飞镖,o,dart,30
weapon,十字弩,onrersrw,crossbow,120
armor,盾,oesw,shield,50
armor,斗笠,oerwrn,hat,90
armor,披风,oers,cape,45
armor,护腕,o,bracer,25
armor,大盾,oesswn,greatshield,70
armor,锁子甲,oesw,chainmail,60
consumable,绷带,o,bandage,20
consumable,淬毒药剂,o,poisonPotion,30
consumable,强固药剂,o,fortifyPotion,30
consumable,活力药剂,o,vitalityPotion,25
consumable,集中药剂,o,focusPotion,25
consumable,治疗药剂,o,healingPotion,35
tool,水袋,os,waterBag,35
tool,绳索,ose,rope,30
tool,腰带,owre,belt,40
tool,火把,on,torch,25
tool,磨刀石,o,whetstone,30
tool,铁匠锤,oerwrs,blacksmithHammer,45
1 type name shape card price
2 string string string @cardDesert int
3 weapon 剑 oee sword 50
4 weapon 长斧 oees greataxe 80
5 weapon 长枪 oeee spear 75
6 weapon 短刀 oe dagger 40
7 weapon 飞镖 o dart 30
8 weapon 十字弩 onrersrw crossbow 120
9 armor 盾 oesw shield 50
10 armor 斗笠 oerwrn hat 90
11 armor 披风 oers cape 45
12 armor 护腕 o bracer 25
13 armor 大盾 oesswn greatshield 70
14 armor 锁子甲 oesw chainmail 60
15 consumable 绷带 o bandage 20
16 consumable 淬毒药剂 o poisonPotion 30
17 consumable 强固药剂 o fortifyPotion 30
18 consumable 活力药剂 o vitalityPotion 25
19 consumable 集中药剂 o focusPotion 25
20 consumable 治疗药剂 o healingPotion 35
21 tool 水袋 os waterBag 35
22 tool 绳索 ose rope 30
23 tool 腰带 owre belt 40
24 tool 火把 on torch 25
25 tool 磨刀石 o whetstone 30
26 tool 铁匠锤 oerwrs blacksmithHammer 45
@@ -1,14 +0,0 @@
import type { CardDesert } from './cardDesert.csv';
type HeroItemFighter1Table = readonly {
readonly type: string;
readonly name: string;
readonly shape: string;
readonly card: CardDesert;
readonly price: number;
}[];
export type HeroItemFighter1 = HeroItemFighter1Table[number];
declare function getData(): HeroItemFighter1Table;
export default getData;
+5 -15
View File
@@ -1,16 +1,6 @@
import heroItemFighter1Csv from './heroItemFighter1.csv';
import encounterDesertCsv from './encounterDesert.csv';
import enemyDesertCsv from './enemyDesert.csv';
import effectDesertCsv from './effectDesert.csv';
import cardDesertCsv from './cardDesert.csv';
import * as desert from "./desert";
export type { ContentModule } from "./types";
export const heroItemFighter1Data = heroItemFighter1Csv();
export const encounterDesertData = encounterDesertCsv();
export const enemyDesertData = enemyDesertCsv();
export const effectDesertData = effectDesertCsv();
export const cardDesertData = cardDesertCsv();
export { default as encounterDesertCsv, type EncounterDesert } from './encounterDesert.csv';
export { default as enemyDesertCsv, type EnemyDesert } from './enemyDesert.csv';
export { default as effectDesertCsv, type EffectDesert } from './effectDesert.csv';
export { default as cardDesertCsv, type CardDesert } from './cardDesert.csv';
export default {
desert,
};
@@ -1,72 +0,0 @@
# 战斗规则
## 战斗状态
角色表:
- 敌方角色表
- 我方角色表
角色状态:
- hp/最大hp
- buff表(名称->层数,同名buff合并叠加)
战利品表:
- 类型
- 数量
触发规则表:
- 类型
- 层数
## 战斗开始
战斗开始时,首先进行敌方角色的战斗开始结算,然后进行玩家的战斗开始结算。
战斗开始会让玩家抓5张起始手牌,让敌方角色初始化意图。
## 回合
进行一个玩家回合,然后进行一个敌方回合,以此重复,直到不存在玩家或敌方角色存活。
玩家回合包含以下阶段:
- buff更新:temporary buff清空,lingering buff层数-1。
- 回合开始:触发回合开始结算的效果。
- 玩家行动:玩家可以花费能量打出手牌。点结束来结束行动。
- 回合结束:触发回合结束结算的效果。
- 重置手牌:弃掉剩余的手牌,重新抓5张手牌。
- 重置能量:重置到3点能量。
敌人回合包含以下阶段:
- buff更新:每个敌人的temporary buff清空,lingering buff层数-1。
- 回合开始:触发回合开始结算的效果。
- 敌人行动:每个敌人的意图依次生效,然后更新下一个意图。
- 回合结束:触发回合结束结算的效果。
## 效果
效果结算时,具有以下上下文:
- name: 效果名称
- stacks: 层数
- target: 目标角色,如果有
- source:来源角色,如果有
- card:来源卡牌,如果有
效果有以下类型:
- Instant: 立即结算。
- Buff: 施加为buff,持续整场战斗。
- BuffTemporary: 施加为buff,下次buff更新时清空。
- BuffLingering: 施加为buff,下次buff更新时层数-1。
- BuffPosture: 施加为buff,受到伤害时扣除等量层数。
- Item: 施加为物品buff,对来源卡牌对应的物品周围的物品生效。
- ItemUntilPlayed:施加为物品buff,对来源卡牌对应的物品生效,生效一次后失效。
- ItemTemporary: 施加为物品buff,对来源卡牌对应的物品生效,下次buff更新时清空。
- ItemPermanent: 施加为物品buff,对来源卡牌对应的物品生效,战斗结束后依然有效。
- Card: 施加为状态卡牌,洗入玩家弃牌堆。对敌人无效。
- CardDraw:洗入抓牌堆。
- CardHand:加入手牌。
@@ -0,0 +1,18 @@
import { LoadResult as YarnDialogues } from "yarn-spinner-loader";
import { Triggers } from "../system/combat/triggers";
import type * as desert from "./desert";
import { IRunContext } from "../system/combat";
export type ContentModule = {
readonly getCards: () => readonly desert.Card[];
readonly getEffects: () => readonly desert.Effect[];
readonly getEncounters: () => readonly desert.Encounter[];
readonly getEnemies: () => readonly desert.Enemy[];
readonly getIntents: () => readonly desert.Intent[];
readonly getItems: () => readonly desert.Item[];
readonly getStartingItems: () => readonly desert.Item[];
readonly dialogues: YarnDialogues;
readonly addTriggers: (triggers: Triggers, run: IRunContext) => void;
};
@@ -1,188 +0,0 @@
import type { CellKey, GridInventory, InventoryItem } from '../grid-inventory/types';
import type { GameItemMeta } from '../progress/types';
import { createRegion, createRegionAxis } from '@/core/region';
import type { GameCard, GameCardMeta, PlayerDeck, DeckRegions } from './types';
import { cardDesertData } from '../data';
/**
* Generates a unique card ID for a cell within an item.
*/
function generateCardId(itemId: string, cellIndex: number): string {
return `card-${itemId}-${cellIndex}`;
}
/**
* Collects all cell keys from an item's shape in a deterministic order.
* Iterates the shape grid row by row, left to right, top to bottom.
*/
function getItemCells(item: InventoryItem<GameItemMeta>): CellKey[] {
const cells: CellKey[] = [];
const { shape, transform } = item;
const { grid } = shape;
const { offset, rotation, flipX, flipY } = transform;
// Track local dimensions (may swap on rotation)
let localWidth = grid[0]?.length || 1;
let localHeight = grid.length;
for (let gy = 0; gy < grid.length; gy++) {
for (let gx = 0; gx < grid[gy].length; gx++) {
if (!grid[gy][gx]) continue;
// Start from grid coordinates
let x = gx;
let y = gy;
// Apply rotation (90 degree increments, clockwise)
const rotTimes = ((rotation % 4) + 4) % 4;
for (let r = 0; r < rotTimes; r++) {
const newX = localHeight - 1 - y;
const newY = x;
x = newX;
y = newY;
// Swap dimensions for next iteration
const tmp = localWidth;
localWidth = localHeight;
localHeight = tmp;
}
// Reset local dimensions for fresh computation per cell
localWidth = grid[0]?.length || 1;
localHeight = grid.length;
// Apply flips
if (flipX) {
x = -x;
}
if (flipY) {
y = -y;
}
// Apply offset
const finalX = x + offset.x;
const finalY = y + offset.y;
cells.push(`${finalX},${finalY}`);
}
}
return cells;
}
/**
* Creates a single card from an inventory item.
*/
function createItemCard(
itemId: string,
itemData: GameItemMeta['itemData'],
cellKey: CellKey,
cellIndex: number
): GameCard {
const cardId = generateCardId(itemId, cellIndex);
const cardData = cardDesertData.find(c => c.id === itemData.card.id);
return {
id: cardId,
regionId: '',
position: [],
sourceItemId: itemId,
itemData: cardData ?? null,
cellKey,
displayName: cardData?.name ?? itemData.name,
description: cardData?.desc ?? '',
};
}
/**
* Creates a status card that does not correspond to any inventory item.
* Status cards represent temporary effects like wounds, stuns, etc.
*/
function createStatusCard(
id: string,
displayName: string,
description: string
): GameCard {
return {
id,
regionId: '',
position: [],
sourceItemId: null,
itemData: null,
cellKey: null,
displayName,
description,
};
}
/**
* Generates a complete player deck from the current inventory state.
*/
function generateDeckFromInventory(inventory: GridInventory<GameItemMeta>): PlayerDeck {
const cards: Record<string, GameCard> = {};
const drawPile: string[] = [];
for (const item of inventory.items.values()) {
const itemData = item.meta?.itemData;
if (!itemData) continue;
// Generate one card per occupied cell in the item's shape
const cellCount = item.shape.count;
const cells = getItemCells(item);
for (let i = 0; i < cellCount; i++) {
const cellKey = cells[i] ?? `${i},0`;
const card = createItemCard(item.id, itemData, cellKey, i);
cards[card.id] = card;
drawPile.push(card.id);
}
}
return {
cards,
drawPile,
hand: [],
discardPile: [],
exhaustPile: [],
};
}
/**
* Creates region definitions for deck management.
*/
function createDeckRegions(): DeckRegions {
return {
drawPile: createRegion('drawPile', [
createRegionAxis('index', 0, 0),
]),
hand: createRegion('hand', [
createRegionAxis('index', 0, 0),
]),
discardPile: createRegion('discardPile', [
createRegionAxis('index', 0, 0),
]),
exhaustPile: createRegion('exhaustPile', [
createRegionAxis('index', 0, 0),
]),
};
}
/**
* Creates an empty player deck structure.
*/
function createPlayerDeck(): PlayerDeck {
return {
cards: {},
drawPile: [],
hand: [],
discardPile: [],
exhaustPile: [],
};
}
export {
generateDeckFromInventory,
createStatusCard,
createDeckRegions,
createPlayerDeck,
generateCardId,
};
@@ -1,8 +0,0 @@
export type { GameCard, GameCardMeta, PlayerDeck, DeckRegions } from './types';
export {
generateDeckFromInventory,
createStatusCard,
createDeckRegions,
createPlayerDeck,
generateCardId,
} from './factory';
@@ -1,74 +0,0 @@
import type { Part } from '@/core/part';
import type { Region } from '@/core/region';
import type { HeroItemFighter1 } from '../data/heroItemFighter1.csv';
import type { CardDesert } from '../data/cardDesert.csv';
import type { CellKey } from '../grid-inventory/types';
/**
* Metadata for a game card.
* Bridges inventory item data with the card system.
*/
export interface GameCardMeta {
/**
* Source item instance ID that this card was generated from.
* `null` for status cards (e.g. wound, stun) that don't correspond to an inventory item.
*/
sourceItemId: string | null;
/**
* Original card data from cardDesert.csv. `null` for status cards not in the CSV.
*/
itemData: CardDesert | null;
/**
* The cell key ("x,y") this card represents within the source item's shape.
* `null` for status cards.
*/
cellKey: CellKey | null;
/**
* Display name of the card.
* For item cards: derived from itemData.name.
* For status cards: custom name (e.g. "伤口", "眩晕").
*/
displayName: string;
/**
* Card description / ability text.
* For item cards: derived from itemData.desc.
* For status cards: custom description.
*/
description: string;
}
/**
* A card instance in the game.
* Cards are generated from inventory items or created as status effects.
*/
export type GameCard = Part<GameCardMeta>;
/**
* Player deck structure containing card pools.
*/
export interface PlayerDeck {
/** All cards indexed by ID */
cards: Record<string, GameCard>;
/** Card IDs in the draw pile */
drawPile: string[];
/** Card IDs in the player's hand */
hand: string[];
/** Card IDs in the discard pile */
discardPile: string[];
/** Card IDs in the exhaust pile (removed from combat) */
exhaustPile: string[];
}
/**
* Region structure for deck management.
*/
export interface DeckRegions {
/** Draw pile region */
drawPile: Region;
/** Hand region */
hand: Region;
/** Discard pile region */
discardPile: Region;
/** Exhaust pile region */
exhaustPile: Region;
}
@@ -1 +0,0 @@
export {default as encounters} from './encounters/encounters.yarnproject';
@@ -0,0 +1,41 @@
# 文件结构
## 系统实现
- `system/`:系统实现。
- `combat/`:管理一次战斗内的战斗状态、流程、触发。
- `progress/`:管理一次冒险的进度。
- `map/`:管理地图生成。
- `grid-inventory/`:管理背包状态。
- `deck/`:管理卡组状态。
## 数据
- `data/`:所有的数据文件
- `desert/`:游戏的测试模组,沙漠区域的内容。
- `card.csv`:所有的卡牌定义。
- `effect.csv`:所有的效应定义。
- `encounter.csv`:所有的遭遇定义。
- `enemy.csv`:所有的敌人和意图定义。
- `item.csv`:所有的玩家物品定义。
- `dialogues/`:yarn对话项目目录
- `dialogues.yarnproject`:yarn对话项目入口
- `**.yarn`:具体的yarn对话文件,代码不关心
- `index.ts`:统一导出所有的数据。
- `trigger.ts`:额外的触发器脚本,用于实现测试模组中涉及的效果。
## 开发流程
### SRD / 系统设计
首先编写srd文档。srd文档描述游戏系统和规则,并提供少量内联的基础游戏数据。
SRD是系统实现的基础,系统实现只能基于SRD,不能依赖具体的游戏内容。
### Setting / 环境设定
使用SRD提供的特性来设计环境内容:
- 效应:效应在技术上不依赖其他类型内容,可以首先确定。但在创意上可能不会直接设计效应,而是从若干创意中提取共性,并创建一个效应来作为主题。
- 卡牌:部分效应会创建卡牌或修改卡牌。在设计时需要注意。
- 敌人和意图:结合效应与主题表达设计敌人。通常需要为每个敌人设计独特的效应。
- 玩家物品:结合效应与主题表达设计物品。常见物品的效应容易找到替代,不常见物品则有特殊的效应。
@@ -1,13 +0,0 @@
export type { CellCoordinate, CellKey, GridInventory, InventoryItem, MutationResult, PlacementResult } from './types';
export {
createGridInventory,
flipItem,
getAdjacentItems,
getItemAtCell,
getOccupiedCellSet,
moveItem,
placeItem,
removeItem,
rotateItem,
validatePlacement,
} from './transform';
@@ -1,233 +0,0 @@
import type { ParsedShape } from '../utils/parse-shape';
import type { Transform2D } from '../utils/shape-collision';
import {
checkBoardCollision,
checkBounds,
flipXTransform,
flipYTransform,
rotateTransform,
transformShape,
} from '../utils/shape-collision';
import type { CellKey, GridInventory, InventoryItem, MutationResult, PlacementResult } from './types';
/**
* Creates a new empty grid inventory.
* Note: When used inside `.produce()`, call this before returning the draft.
*/
export function createGridInventory<TMeta = Record<string, unknown>>(width: number, height: number): GridInventory<TMeta> {
return {
width,
height,
items: new Map<string, InventoryItem<TMeta>>(),
occupiedCells: new Set<CellKey>(),
};
}
/**
* Builds a Set of occupied cell keys from a shape and transform.
*/
function getShapeCellKeys(shape: ParsedShape, transform: Transform2D): Set<CellKey> {
const cells = transformShape(shape, transform);
return new Set(cells.map(c => `${c.x},${c.y}` as CellKey));
}
/**
* Validates whether an item can be placed at the given transform.
* Checks bounds and collision with all other items.
*/
export function validatePlacement<TMeta = Record<string, unknown>>(
inventory: GridInventory<TMeta>,
shape: InventoryItem<TMeta>['shape'],
transform: Transform2D
): PlacementResult {
if (!checkBounds(shape, transform, inventory.width, inventory.height)) {
return { valid: false, reason: '超出边界' };
}
if (checkBoardCollision(shape, transform, inventory.occupiedCells)) {
return { valid: false, reason: '与已有物品重叠' };
}
return { valid: true };
}
/**
* Places an item onto the grid.
* **Mutates directly** — call inside a `.produce()` callback.
* Does not validate; call `validatePlacement` first.
*/
export function placeItem<TMeta = Record<string, unknown>>(inventory: GridInventory<TMeta>, item: InventoryItem<TMeta>): void {
const cells = getShapeCellKeys(item.shape, item.transform);
for (const cellKey of cells) {
inventory.occupiedCells.add(cellKey);
}
inventory.items.set(item.id, item);
}
/**
* Removes an item from the grid by its ID.
* **Mutates directly** — call inside a `.produce()` callback.
*/
export function removeItem<TMeta = Record<string, unknown>>(inventory: GridInventory<TMeta>, itemId: string): void {
const item = inventory.items.get(itemId);
if (!item) return;
const cells = getShapeCellKeys(item.shape, item.transform);
for (const cellKey of cells) {
inventory.occupiedCells.delete(cellKey);
}
inventory.items.delete(itemId);
}
/**
* Moves an item to a new position with a new transform.
* **Mutates directly** — call inside a `.produce()` callback.
* Validates before applying; returns result indicating success.
*/
export function moveItem<TMeta = Record<string, unknown>>(
inventory: GridInventory<TMeta>,
itemId: string,
newTransform: Transform2D
): MutationResult {
const item = inventory.items.get(itemId);
if (!item) {
return { success: false, reason: '物品不存在' };
}
// Temporarily remove item's cells for validation
const oldCells = getShapeCellKeys(item.shape, item.transform);
for (const cellKey of oldCells) {
inventory.occupiedCells.delete(cellKey);
}
// Validate new position
const validation = validatePlacement(inventory, item.shape, newTransform);
if (!validation.valid) {
// Restore old cells
for (const cellKey of oldCells) {
inventory.occupiedCells.add(cellKey);
}
return { success: false, reason: validation.reason };
}
// Apply new transform
item.transform = newTransform;
// Add new cells
const newCells = getShapeCellKeys(item.shape, item.transform);
for (const cellKey of newCells) {
inventory.occupiedCells.add(cellKey);
}
return { success: true };
}
/**
* Rotates an item by the given degrees (typically 90, 180, or 270).
* **Mutates directly** — call inside a `.produce()` callback.
* Validates before applying; returns result indicating success.
*/
export function rotateItem<TMeta = Record<string, unknown>>(
inventory: GridInventory<TMeta>,
itemId: string,
degrees: number
): MutationResult {
const item = inventory.items.get(itemId);
if (!item) {
return { success: false, reason: '物品不存在' };
}
const rotatedTransform = rotateTransform(item.transform, degrees);
return moveItem(inventory, itemId, rotatedTransform);
}
/**
* Flips an item horizontally or vertically.
* **Mutates directly** — call inside a `.produce()` callback.
* Validates before applying; returns result indicating success.
*/
export function flipItem<TMeta = Record<string, unknown>>(
inventory: GridInventory<TMeta>,
itemId: string,
axis: 'x' | 'y'
): MutationResult {
const item = inventory.items.get(itemId);
if (!item) {
return { success: false, reason: '物品不存在' };
}
const flippedTransform = axis === 'x'
? flipXTransform(item.transform)
: flipYTransform(item.transform);
return moveItem(inventory, itemId, flippedTransform);
}
/**
* Returns a copy of the occupied cells set.
*/
export function getOccupiedCellSet<TMeta = Record<string, unknown>>(inventory: GridInventory<TMeta>): Set<CellKey> {
return new Set(inventory.occupiedCells);
}
/**
* Finds the item occupying the given cell, if any.
*/
export function getItemAtCell<TMeta = Record<string, unknown>>(
inventory: GridInventory<TMeta>,
x: number,
y: number
): InventoryItem<TMeta> | undefined {
const cellKey = `${x},${y}` as CellKey;
if (!inventory.occupiedCells.has(cellKey)) {
return undefined;
}
for (const item of inventory.items.values()) {
const cells = getShapeCellKeys(item.shape, item.transform);
if (cells.has(cellKey)) {
return item;
}
}
return undefined;
}
/**
* Gets all items adjacent to the given item (orthogonally, not diagonally).
* Returns a Map of itemId -> item for deduplication.
*/
export function getAdjacentItems<TMeta extends Record<string, unknown> = Record<string, unknown>>(
inventory: GridInventory<TMeta>,
itemId: string
): Map<string, InventoryItem<TMeta>> {
const item = inventory.items.get(itemId);
if (!item) {
return new Map();
}
const ownCells = getShapeCellKeys(item.shape, item.transform);
const adjacent = new Map<string, InventoryItem<TMeta>>();
for (const cellKey of ownCells) {
const [cx, cy] = cellKey.split(',').map(Number);
const neighbors: CellKey[] = [
`${cx + 1},${cy}` as CellKey,
`${cx - 1},${cy}` as CellKey,
`${cx},${cy + 1}` as CellKey,
`${cx},${cy - 1}` as CellKey,
];
for (const neighborKey of neighbors) {
if (inventory.occupiedCells.has(neighborKey) && !ownCells.has(neighborKey)) {
const [nx, ny] = neighborKey.split(',').map(Number);
const neighborItem = getItemAtCell(inventory, nx, ny);
if (neighborItem) {
adjacent.set(neighborItem.id, neighborItem);
}
}
}
}
return adjacent;
}
@@ -1,56 +0,0 @@
import type { ParsedShape } from '../utils/parse-shape';
import type { Transform2D } from '../utils/shape-collision';
/**
* String key representing a grid cell in "x,y" format.
*/
export type CellKey = `${number},${number}`;
/**
* Simple 2D coordinate for grid cells.
*/
export interface CellCoordinate {
x: number;
y: number;
}
/**
* An item placed on the grid inventory.
* @template TMeta - Optional metadata type for game-specific data
*/
export interface InventoryItem<TMeta = Record<string, unknown>> {
/** Unique item identifier */
id: string;
/** Reference to the item's shape definition */
shape: ParsedShape;
/** Current transformation (position, rotation, flips) */
transform: Transform2D;
/** Optional metadata for game-specific data */
meta?: TMeta;
}
/**
* Result of a placement validation check.
*/
export type PlacementResult = { valid: true } | { valid: false; reason: string };
/**
* Result of a mutation operation (move, rotate, flip).
*/
export type MutationResult = { success: true } | { success: false; reason: string };
/**
* Grid inventory state.
* Designed to be mutated directly inside a `mutative .produce()` callback.
* @template TMeta - Optional metadata type for items
*/
export interface GridInventory<TMeta = Record<string, unknown>> {
/** Board width in cells */
width: number;
/** Board height in cells */
height: number;
/** Map of itemId -> InventoryItem for all placed items */
items: Map<string, InventoryItem<TMeta>>;
/** Set of occupied cells in "x,y" format for O(1) collision lookups */
occupiedCells: Set<CellKey>;
}
+8 -147
View File
@@ -1,147 +1,8 @@
// Data
export { heroItemFighter1Data, encounterDesertData, enemyDesertData, effectDesertData, cardDesertData } from './data';
export { default as encounterDesertCsv } from './data/encounterDesert.csv';
export type { EncounterDesert, CardDesert } from './data';
// Deck
export type { GameCard, GameCardMeta, PlayerDeck, DeckRegions } from './deck';
export {
generateDeckFromInventory,
createStatusCard,
createDeckRegions,
createPlayerDeck,
generateCardId,
} from './deck';
// Grid Inventory
export type { CellCoordinate, CellKey, GridInventory, InventoryItem, MutationResult, PlacementResult } from './grid-inventory';
export {
createGridInventory,
flipItem,
getAdjacentItems,
getItemAtCell,
getOccupiedCellSet,
moveItem,
placeItem,
removeItem,
rotateItem,
validatePlacement,
} from './grid-inventory';
// Map
export { MapNodeType, MapLayerType } from './map';
export type { MapNode, MapLayer, PointCrawlMap } from './map';
export { generatePointCrawlMap, getNode, getChildren, getParents, hasPath, findAllPaths } from './map';
// Progress Manager
export type {
EncounterResult,
EncounterState,
GameItem,
GameItemMeta,
PlayerState,
RunMutationResult,
RunState,
} from './progress';
export {
addGold,
addItemFromCsv,
canMoveTo,
createRunState,
damagePlayer,
getReachableChildren,
getCurrentNode,
getUnresolvedChildren,
healPlayer,
isAtEndNode,
isAtStartNode,
isEncounterResolved,
moveToNode,
removeItem as removeItemFromRun,
resolveEncounter,
setMaxHp,
spendGold,
} from './progress';
// Utils - Parse Shape
export type { ParsedShape } from './utils/parse-shape';
export { parseShapeString } from './utils/parse-shape';
// Utils - Shape Collision
export type { Point2D, Transform2D } from './utils/shape-collision';
export {
IDENTITY_TRANSFORM,
getOccupiedCells,
transformPoint,
transformShape,
checkCollision,
checkBoardCollision,
checkBounds,
validatePlacement as validateShapePlacement,
rotateTransform,
flipXTransform,
flipYTransform,
} from './utils/shape-collision';
// Combat
export type {
BuffTable,
CombatEffectEntry,
CombatEntity,
CombatGameContext,
CombatPhase,
CombatResult,
CombatState,
EffectTarget,
EffectTiming,
EnemyState,
ItemBuff,
LootEntry,
PlayerCombatState,
} from './combat';
export type {
TriggerContext,
BuffTriggerBehavior,
CombatTriggerRegistry,
TriggerEvent,
} from './combat';
export {
createCombatState,
createEnemyInstance,
createPlayerCombatState,
drawCardsToHand,
addFatigueCards,
discardHand,
discardCard,
exhaustCard,
getEnemyCurrentIntent,
advanceEnemyIntent,
getEffectTiming,
getEffectData,
INITIAL_HAND_SIZE,
DEFAULT_MAX_ENERGY,
FATIGUE_CARDS_PER_SHUFFLE,
applyDamage,
applyDefend,
applyBuff,
removeBuff,
updateBuffs,
resolveEffect,
resolveCardEffects,
getModifiedAttackDamage,
getModifiedDefendAmount,
canPlayCard,
playCard,
areAllEnemiesDead,
isPlayerDead,
createCombatTriggerRegistry,
dispatchTrigger,
dispatchAttackedTrigger,
dispatchDamageTrigger,
dispatchOutgoingDamageTrigger,
dispatchIncomingDamageTrigger,
dispatchShuffleTrigger,
runCombat,
} from './combat';
export * from "./system/combat";
export * from "./system/deck";
export * from "./system/encounter";
export * from "./system/grid-inventory";
export * from "./system/map";
export * from "./system/utils";
export * from "./system/types";
export { default as data } from "./data";
@@ -1,5 +0,0 @@
export { MapNodeType, MapLayerType } from './types';
export type { MapNode, MapLayer, PointCrawlMap, MapGenerationConfig } from './types';
export { generatePointCrawlMap } from './generator';
export { getNode, getChildren, getParents, hasPath, findAllPaths } from './generator';
@@ -1,86 +0,0 @@
/**
* Types of nodes that can appear on the point crawl map.
*/
export enum MapNodeType {
Start = 'start',
End = 'end',
Minion = 'minion',
Elite = 'elite',
Event = 'event',
Camp = 'camp',
Shop = 'shop',
Curio = 'curio',
}
/**
* Semantic type of a layer.
*/
export enum MapLayerType {
Wild = 'wild',
Settlement = 'settlement',
}
/**
* A single node on the map.
*/
export interface MapNode {
/** Unique identifier */
id: string;
/** Which layer this node belongs to */
layerIndex: number;
/** Semantic type of the node */
type: MapNodeType;
/** IDs of nodes in the next layer this node connects to */
childIds: string[];
/** Encounter data assigned to this node (from encounter CSV) */
encounter?: {
name: string;
description: string;
};
}
/**
* A horizontal layer of nodes at the same progression stage.
*/
export interface MapLayer {
/** Layer index (0 = start, last = end) */
index: number;
/** Ordered IDs of nodes in this layer */
nodeIds: string[];
/** Semantic type of the layer */
layerType: MapLayerType | 'start' | 'end';
/** Direct references to nodes in this layer (for performance) */
nodes: MapNode[];
}
/**
* A fully generated point crawl map.
*/
export interface PointCrawlMap {
/** Layers from start to end */
layers: MapLayer[];
/** All nodes keyed by ID */
nodes: Map<string, MapNode>;
/** RNG seed used for generation (for reproducibility) */
seed: number;
/** Reverse index: nodeId → parent node IDs (for fast getParent lookup) */
parentIndex?: Map<string, string[]>;
}
/**
* Configuration for map generation.
*/
export interface MapGenerationConfig {
/** Total number of layers (including start and end) */
totalLayers: number;
/** Number of nodes in each wild layer */
wildLayerNodeCount: number;
/** Number of nodes in each settlement layer */
settlementLayerNodeCount: number;
/** Probability weights for wild node types (should sum to 100) */
wildNodeTypeWeights: {
[MapNodeType.Minion]: number;
[MapNodeType.Elite]: number;
[MapNodeType.Event]: number;
};
}
@@ -1,334 +0,0 @@
import { Mulberry32RNG, type RNG } from '@/utils/rng';
import { generatePointCrawlMap, getNode } from '../map/generator';
import type { MapNode } from '../map/types';
import { placeItem, validatePlacement, createGridInventory, removeItem as gridRemoveItem } from '../grid-inventory/transform';
import type { GameItem, GameItemMeta, RunMutationResult, RunState } from './types';
import type { GridInventory } from '../grid-inventory/types';
import { IDENTITY_TRANSFORM, type Transform2D } from '../utils/shape-collision';
import { parseShapeString, type ParsedShape } from '../utils/parse-shape';
import type { HeroItemFighter1 } from '../data/heroItemFighter1.csv';
import { heroItemFighter1Data } from '../data';
// Re-export types
export type {
EncounterResult,
EncounterState,
GameItem,
GameItemMeta,
PlayerState,
RunMutationResult,
RunState,
} from './types';
// -- Constants --
const INVENTORY_WIDTH = 6;
const INVENTORY_HEIGHT = 4;
const DEFAULT_MAX_HP = 50;
const DEFAULT_GOLD = 50;
/** Starter items to give the player at the beginning of a run. */
const STARTER_ITEM_NAMES = ['治疗药剂', '绷带', '水袋', '短刀', '剑'];
// -- Run creation --
/**
* Creates a new run state with a generated map, player stats, and starter inventory.
*
* @param seed RNG seed for reproducibility. If omitted, uses current timestamp.
* @param rng Optional RNG instance for controlled randomness (overrides seed).
*/
export function createRunState(seed?: number, rng?: RNG): RunState {
const actualSeed = seed ?? new Mulberry32RNG().nextInt(2 ** 31);
const map = generatePointCrawlMap(actualSeed);
// Find the start node
const startNode = map.layers[0].nodes[0];
// Create empty inventory
const inventory = createGridInventory<GameItemMeta>(INVENTORY_WIDTH, INVENTORY_HEIGHT);
const idCounter = { value: 0 };
// Place starter items
for (const itemName of STARTER_ITEM_NAMES) {
const itemData = findItemByName(itemName);
if (!itemData) continue;
const shape = parseShapeString(itemData.shape);
const itemInstance = tryPlaceItemInInventory(inventory, itemData, shape, idCounter);
if (!itemInstance) {
// Inventory too small; item skipped
}
}
return {
seed: actualSeed,
map,
player: {
maxHp: DEFAULT_MAX_HP,
currentHp: DEFAULT_MAX_HP,
gold: DEFAULT_GOLD,
},
inventory,
currentNodeId: startNode.id,
currentEncounter: {
nodeId: startNode.id,
resolved: false,
},
resolvedNodeIds: new Set<string>(),
_idCounter: idCounter,
};
}
// -- Movement --
/**
* Checks whether the player can move to the target node.
* The target must be a child of the current node.
*/
export function canMoveTo(runState: RunState, targetNodeId: string): boolean {
const currentNode = getNode(runState.map, runState.currentNodeId);
if (!currentNode) return false;
return currentNode.childIds.includes(targetNodeId);
}
/**
* Moves the player to the target node.
* The target must be a direct child of the current node.
*
* @returns `{ success: true }` on success, or `{ success: false, reason: string }` on failure.
*/
export function moveToNode(runState: RunState, targetNodeId: string): RunMutationResult {
if (!canMoveTo(runState, targetNodeId)) {
return { success: false, reason: '无法移动到该节点' };
}
const targetNode = getNode(runState.map, targetNodeId);
if (!targetNode) {
return { success: false, reason: '目标节点不存在' };
}
// Update current position
runState.currentNodeId = targetNodeId;
// Create new encounter state for the target node
const isResolved = runState.resolvedNodeIds.has(targetNodeId);
runState.currentEncounter = {
nodeId: targetNodeId,
resolved: isResolved,
};
return { success: true };
}
// -- Encounter management --
/**
* Marks the current encounter as resolved with optional result data.
*/
export function resolveEncounter(runState: RunState, result?: { goldEarned?: number; hpLost?: number; hpGained?: number; itemRewards?: string[] }): RunMutationResult {
if (runState.currentEncounter.resolved) {
return { success: false, reason: '该遭遇已解决' };
}
runState.currentEncounter.resolved = true;
runState.currentEncounter.result = result;
runState.resolvedNodeIds.add(runState.currentNodeId);
// Apply result effects
if (result) {
if (result.goldEarned) {
runState.player.gold += result.goldEarned;
}
if (result.hpLost) {
runState.player.currentHp = Math.max(0, runState.player.currentHp - result.hpLost);
}
if (result.hpGained) {
runState.player.currentHp = Math.min(runState.player.maxHp, runState.player.currentHp + result.hpGained);
}
}
return { success: true };
}
/**
* Checks whether the encounter at the given node has been resolved.
*/
export function isEncounterResolved(runState: RunState, nodeId: string): boolean {
return runState.resolvedNodeIds.has(nodeId);
}
// -- Player stats --
/**
* Damages the player. Clamps HP to 0 minimum.
*/
export function damagePlayer(runState: RunState, amount: number): void {
runState.player.currentHp = Math.max(0, runState.player.currentHp - amount);
}
/**
* Heals the player. Clamps HP to maxHp.
*/
export function healPlayer(runState: RunState, amount: number): void {
runState.player.currentHp = Math.min(runState.player.maxHp, runState.player.currentHp + amount);
}
/**
* Sets the player's maximum HP and adjusts current HP proportionally.
*/
export function setMaxHp(runState: RunState, newMax: number): void {
const diff = newMax - runState.player.maxHp;
runState.player.maxHp = newMax;
runState.player.currentHp = Math.max(0, Math.min(newMax, runState.player.currentHp + diff));
}
/**
* Adds gold to the player.
*/
export function addGold(runState: RunState, amount: number): void {
runState.player.gold += amount;
}
/**
* Spends gold. Returns false if the player doesn't have enough gold.
*/
export function spendGold(runState: RunState, amount: number): RunMutationResult {
if (amount <= 0) {
return { success: false, reason: '金额必须大于零' };
}
if (runState.player.gold < amount) {
return { success: false, reason: '金币不足' };
}
runState.player.gold -= amount;
return { success: true };
}
// -- Inventory management --
/**
* Adds an item from CSV data to the inventory.
* Finds the first available valid position.
*
* @returns The placed item instance, or undefined if no valid position exists.
*/
export function addItemFromCsv(
runState: RunState,
itemData: HeroItemFighter1
): GameItem | undefined {
const shape = parseShapeString(itemData.shape);
return tryPlaceItemInInventory(runState.inventory, itemData, shape, runState._idCounter);
}
/**
* Removes an item from the inventory by its instance ID.
*/
export function removeItem(runState: RunState, instanceId: string): void {
gridRemoveItem(runState.inventory, instanceId);
}
// -- Query helpers --
/**
* Returns the node the player is currently at.
*/
export function getCurrentNode(runState: RunState): MapNode | undefined {
return getNode(runState.map, runState.currentNodeId);
}
/**
* Returns all reachable child nodes from the current position.
*/
export function getReachableChildren(runState: RunState): MapNode[] {
const currentNode = getNode(runState.map, runState.currentNodeId);
if (!currentNode) return [];
return currentNode.childIds
.map(id => getNode(runState.map, id))
.filter((n): n is MapNode => n !== undefined);
}
/**
* Returns available children that haven't been resolved yet.
*/
export function getUnresolvedChildren(runState: RunState): MapNode[] {
return getReachableChildren(runState).filter(
node => !runState.resolvedNodeIds.has(node.id)
);
}
/**
* Checks if the current encounter is the start node (no encounter).
*/
export function isAtStartNode(runState: RunState): boolean {
return runState.currentNodeId === runState.map.layers[0]?.nodes[0]?.id;
}
/**
* Checks if the current encounter is the end node.
*/
export function isAtEndNode(runState: RunState): boolean {
const endLayer = runState.map.layers[runState.map.layers.length - 1];
return endLayer?.nodes[0]?.id === runState.currentNodeId;
}
// -- Internal helpers --
/**
* Finds a hero item by name from the CSV data.
*/
function findItemByName(name: string): HeroItemFighter1 | undefined {
return heroItemFighter1Data.find(item => item.name === name);
}
/**
* Generates a unique item instance ID.
*/
function generateInstanceId(counter: { value: number }): string {
counter.value++;
return `item-${counter.value}`;
}
/**
* Tries to place an item in the inventory by scanning all possible positions.
* Returns the placed item instance, or undefined if no valid position exists.
*/
function tryPlaceItemInInventory(
inventory: GridInventory<GameItemMeta>,
itemData: HeroItemFighter1,
shape: ParsedShape,
idCounter: { value: number }
): GameItem | undefined {
const instanceId = generateInstanceId(idCounter);
// Try to find a valid position
for (let y = 0; y <= inventory.height - shape.height; y++) {
for (let x = 0; x <= inventory.width - shape.width; x++) {
const transform = {
...IDENTITY_TRANSFORM,
offset: { x, y },
};
const validation = validatePlacement(inventory, shape, transform);
if (validation.valid) {
const item: GameItem = {
id: instanceId,
shape,
transform,
meta: {
itemData,
shape,
},
};
placeItem(inventory, item);
return item;
}
}
}
return undefined;
}
@@ -1,87 +0,0 @@
import type { PointCrawlMap } from '../map/types';
import type { GridInventory, InventoryItem } from '../grid-inventory/types';
import type { ParsedShape } from '../utils/parse-shape';
import type { HeroItemFighter1 } from '../data/heroItemFighter1.csv';
/**
* Result of an encounter (combat, event, etc.).
*/
export interface EncounterResult {
/** Gold earned from the encounter */
goldEarned?: number;
/** HP lost during the encounter */
hpLost?: number;
/** HP gained (e.g., from camp heal) */
hpGained?: number;
/** Item IDs rewarded */
itemRewards?: string[];
}
/**
* Runtime state of an encounter at a specific node.
*/
export interface EncounterState {
/** The node ID where this encounter is located */
nodeId: string;
/** Whether the encounter has been resolved */
resolved: boolean;
/** Optional result data after resolution */
result?: EncounterResult;
}
/**
* Metadata attached to each inventory item instance.
* Bridges CSV item data with the grid inventory system.
*/
export interface GameItemMeta {
/** Original CSV item data */
itemData: HeroItemFighter1;
/** Parsed shape for grid placement */
shape: ParsedShape;
}
/**
* An item instance in the game inventory.
* Extends InventoryItem with game-specific metadata.
*/
export type GameItem = InventoryItem<GameItemMeta>;
/**
* Player runtime state.
*/
export interface PlayerState {
/** Maximum HP */
maxHp: number;
/** Current HP */
currentHp: number;
/** Current gold */
gold: number;
}
/**
* Full run state for a game session.
* Designed to be used inside `MutableSignal.produce()` callbacks.
*/
export interface RunState {
/** RNG seed used for map generation */
seed: number;
/** Generated point crawl map */
map: PointCrawlMap;
/** Player HP and gold */
player: PlayerState;
/** Grid inventory with placed items */
inventory: GridInventory<GameItemMeta>;
/** Current node ID where the player is located */
currentNodeId: string;
/** State of the encounter at the current node */
currentEncounter: EncounterState;
/** Set of node IDs whose encounters have been resolved */
resolvedNodeIds: Set<string>;
/** Internal counter for generating unique item instance IDs */
_idCounter: { value: number };
}
/**
* Result of a mutation operation on the run state.
*/
export type RunMutationResult = { success: true } | { success: false; reason: string };
@@ -0,0 +1,171 @@
import {
CombatEntity,
CombatGameContext,
CombatState,
EffectTable,
IRunContext,
PlayerEntity,
} from "./types";
import {
CardData,
CardEffectTarget,
EffectData,
IntentEffectTarget,
} from "@/samples/slay-the-spire-like/system/types";
export function addEffect(
effects: EffectTable,
effect: EffectData,
stacks: number,
) {
let current = effects[effect.id];
if (!current) current = { data: effect, stacks };
else current.stacks += stacks;
if (current.stacks === 0 && effects[effect.id]) delete effects[effect.id];
else if (current.stacks !== 0 && !effects[effect.id])
effects[effect.id] = current;
}
export function addEntityEffect(
entity: CombatEntity,
effect: EffectData,
stacks: number,
) {
addEffect(entity.effects, effect, stacks);
}
export function addItemEffect(
entity: PlayerEntity,
itemKey: string,
effect: EffectData,
stacks: number,
) {
entity.itemEffects[itemKey] = entity.itemEffects[itemKey] || {};
addEffect(entity.itemEffects[itemKey], effect, stacks);
}
export function onEntityEffectUpkeep(entity: CombatEntity) {
for (const effect of Object.values(entity.effects)) {
const lifecycle = effect.data.lifecycle;
if (lifecycle === "temporary")
addEntityEffect(entity, effect.data, -effect.stacks);
else if (lifecycle === "lingering")
addEntityEffect(entity, effect.data, effect.stacks >= 0 ? -1 : 1);
}
}
export function onEntityPostureDamage(entity: CombatEntity, damage: number) {
for (const effect of Object.values(entity.effects)) {
const lifecycle = effect.data.lifecycle;
if (lifecycle === "posture")
addEntityEffect(entity, effect.data, -Math.min(damage, effect.stacks));
}
}
export function onPlayerItemEffectUpkeep(entity: PlayerEntity) {
for (const [itemKey, itemEffects] of Object.entries(entity.itemEffects)) {
for (const effect of Object.values(itemEffects)) {
const lifecycle = effect.data.lifecycle;
if (lifecycle === "itemTemporary")
addItemEffect(entity, itemKey, effect.data, -effect.stacks);
}
}
}
export function onItemPlay(entity: PlayerEntity, itemKey: string) {
const effects = entity.itemEffects[itemKey];
if (!effects) return;
for (const effect of Object.values(effects)) {
if (effect.data.lifecycle === "itemUntilPlay") {
addItemEffect(entity, itemKey, effect.data, -effect.stacks);
}
}
}
export function onItemDiscard(entity: PlayerEntity, itemKey: string) {
const effects = entity.itemEffects[itemKey];
if (!effects) return;
for (const effect of Object.values(effects)) {
if (effect.data.lifecycle === "itemUntilDiscard") {
addItemEffect(entity, itemKey, effect.data, -effect.stacks);
}
}
}
export function* getAliveEnemies(state: CombatState) {
for (let enemy of state.enemies) {
if (enemy.isAlive) {
yield enemy;
}
}
}
export function* getEffectTargets(
target: CardEffectTarget | IntentEffectTarget,
game: CombatGameContext,
targetId?: string,
sourceEntityKey: "player" | string = "player",
) {
if (target === "eachEnemy") {
for (const enemy of getAliveEnemies(game.value)) {
yield enemy;
}
} else if (target === "source") {
const entity = getCombatEntity(game.value, sourceEntityKey);
if (entity) yield entity;
} else if (target === "player") {
yield game.value.player;
} else if (target === "eachTarget") {
if (!targetId) return;
const entity = getCombatEntity(game.value, targetId);
if (entity) yield entity;
} else if (target === "randomEnemy") {
const aliveEnemies = [...getAliveEnemies(game.value)];
if (aliveEnemies.length === 0) return;
const index = game.rng.nextInt(aliveEnemies.length);
yield aliveEnemies[index];
}
}
export function getCombatEntity(state: CombatState, entityKey: string) {
return entityKey === "player"
? state.player
: state.enemies.find((e) => e.id === entityKey);
}
export function canPlayCard(
player: PlayerEntity,
costType: CardData["costType"],
costCount: number,
itemId: string,
run: IRunContext,
): boolean {
if (costType === "energy") {
return player.energy >= costCount;
}
if (costType === "uses") {
const item = run.getItemData(itemId);
if (!item) return false;
const maxUses = item?.card.costType === "uses" ? item.card.costCount : 0;
const consumed = run.getConsumedUses(itemId);
return consumed < maxUses;
}
return true;
}
export async function payCardCost(
player: PlayerEntity,
costType: CardData["costType"],
costCount: number,
itemId: string,
run: IRunContext,
): Promise<void> {
if (costType === "energy") {
player.energy -= costCount;
} else if (costType === "uses") {
const consumed = run.getConsumedUses(itemId);
await run.setConsumedUsesAsync(itemId, consumed + costCount);
}
}
@@ -0,0 +1,104 @@
import {
CombatState,
EffectTable,
EnemyEntity,
IPlayerDeck,
IPlayerState,
PlayerEntity,
} from "./types";
import { EffectData, EncounterData, EnemyData, IntentData } from "../types";
export function buildCombatState(
playerState: IPlayerState,
deck: IPlayerDeck,
encounter: EncounterData,
): CombatState {
const player = createPlayerEntity(playerState, deck);
const enemies = createEnemyEntities(encounter);
return {
enemies,
player,
phase: "playerTurn",
turnNumber: 1,
result: null,
loot: [],
};
}
function createEnemyEntities(encounter: EncounterData): EnemyEntity[] {
const enemies: EnemyEntity[] = [];
let instanceCounter = 0;
for (const [enemyData, hp, encounterBuffs] of encounter.enemies) {
const instanceId = `${enemyData.id}-${instanceCounter++}`;
const intents = buildIntentMap(enemyData);
const initialIntent = findInitialIntent(enemyData);
const effects = buildEffectTable(encounterBuffs);
const entity: EnemyEntity = {
id: instanceId,
enemy: enemyData,
hp,
maxHp: hp,
isAlive: true,
effects,
intents,
currentIntent: initialIntent,
};
enemies.push(entity);
}
return enemies;
}
/**
* Builds a map of intent ID -> IntentData for an enemy.
*/
function buildIntentMap(enemy: EnemyData): Record<string, IntentData> {
const intents: Record<string, IntentData> = {};
for (const intent of enemy.intents) {
intents[intent.id] = intent;
}
return intents;
}
/**
* Finds the initial intent ID for an enemy.
*/
function findInitialIntent(enemy: EnemyData): IntentData {
for (const intent of enemy.intents) {
if (intent.initialIntent) {
return intent;
}
}
if (enemy.intents.length === 0) {
throw new Error(`Enemy "${enemy.id}" has no intents`);
}
return enemy.intents[0];
}
function buildEffectTable(buffs: readonly [EffectData, number][]): EffectTable {
const table: EffectTable = {};
for (const [effect, stacks] of buffs) {
table[effect.id] = { data: effect, stacks };
}
return table;
}
function createPlayerEntity(
playerState: IPlayerState,
deck: IPlayerDeck,
): PlayerEntity {
return {
id: "player",
hp: playerState.hp,
maxHp: playerState.maxHp,
isAlive: playerState.hp > 0,
energy: 3,
maxEnergy: 3,
deck,
itemEffects: playerState.itemEffects,
effects: {},
};
}
@@ -0,0 +1,5 @@
export * from "./effects";
export * from "./factory";
export * from "./prompts";
export * from "./triggers";
export * from "./types";
@@ -0,0 +1,56 @@
import { createPromptDef } from "@/core/game";
import { CombatGameContext, IRunContext } from "./types";
import { canPlayCard } from "@/samples/slay-the-spire-like/system/combat/effects";
export const prompts = {
mainAction: createPromptDef<[string, string?]>(
"main-action <cardId:string> [targetId:string]",
"选择卡牌并指定目标",
),
};
export async function promptMainAction(
game: CombatGameContext,
run: IRunContext,
) {
return await game.prompt(prompts.mainAction, (cardId, targetId) => {
if (cardId === "end-turn")
return {
action: "end-turn" as "end-turn",
};
const exists =
game.value.player.deck.regions.hand.childIds.includes(cardId);
if (!exists) throw `卡牌"${cardId}"不在手牌中`;
const card = game.value.player.deck.cards[cardId];
const { cardData, itemId } = card;
if (
!canPlayCard(
game.value.player,
cardData.costType,
cardData.costCount,
itemId,
run,
)
) {
throw `无法支付卡牌"${cardId}"的费用`;
}
const { targetType } = cardData;
if (targetType === "enemy") {
if (!targetId) throw `请指定目标`;
const target = game.value.enemies.find((e) => e.id === targetId);
if (!target) throw `目标"${targetId}"不存在`;
if (!target.isAlive) throw `目标"${targetId}"已死亡`;
} else if (targetType === "enemies" || targetType === "player") {
if (targetId) throw `目标"${targetId}"无效`;
}
return {
action: "play" as "play",
cardId,
targetId,
};
});
}
@@ -0,0 +1,343 @@
import { CombatGameContext, IRunContext } from "./types";
import {
addEntityEffect,
addItemEffect,
getAliveEnemies,
onEntityPostureDamage,
onEntityEffectUpkeep,
onPlayerItemEffectUpkeep,
onItemDiscard,
onItemPlay,
payCardCost,
getCombatEntity,
getEffectTargets,
} from "@/samples/slay-the-spire-like/system/combat/effects";
import { promptMainAction } from "@/samples/slay-the-spire-like/system/combat/prompts";
import { moveToRegion, shuffle } from "@/core/region";
import { createMiddlewareChain } from "@/utils/middleware";
import { EffectData } from "@/samples/slay-the-spire-like/system/types";
type TriggerTypes = {
onCombatStart: {};
onTurnStart: { entityId: "player" | string };
onTurnEnd: { entityId: "player" | string };
onShuffle: {};
onCardPlayed: {
cardId: string;
targetId?: string;
sourceEntityId?: "player" | string;
};
onCardDiscarded: { cardId: string; sourceEntityId?: "player" | string };
onCardDrawn: { cardId: string; sourceEntityId?: "player" | string };
onDraw: { count: number };
onEffectApplied: {
effect: EffectData;
entityId: "player" | string;
stacks: number;
cardId?: string;
sourceEntityId?: "player" | string;
targetId?: string;
};
onHpChange: { entityId: "player" | string; amount: number };
onDamage: {
entityId: "player" | string;
amount: number;
prevented?: number;
sourceEntityId?: "player" | string;
};
onEnemyIntent: { enemyId: string; sourceEntityId?: "player" | string };
onIntentUpdate: { enemyId: string };
};
export function createTriggers(run: IRunContext) {
const triggers = {
onCombatStart: createTrigger("onCombatStart", async (ctx) => {
await triggers.onShuffle.execute(ctx.game, {});
await triggers.onDraw.execute(ctx.game, { count: 5 });
}),
onTurnStart: createTrigger("onTurnStart", async (ctx) => {
await ctx.game.produceAsync((draft) => {
const entity = getCombatEntity(draft, ctx.entityId);
if (entity) onEntityEffectUpkeep(entity);
if (entity === draft.player) onPlayerItemEffectUpkeep(draft.player);
});
}),
onTurnEnd: createTrigger("onTurnEnd", async (ctx) => {
if (ctx.entityId !== "player") return;
const { regions } = ctx.game.value.player.deck;
for (const cardId of Object.values(regions.hand.childIds)) {
await triggers.onCardDiscarded.execute(ctx.game, { cardId });
}
await ctx.game.produceAsync((draft) => {
draft.player.energy = draft.player.maxEnergy;
});
await triggers.onDraw.execute(ctx.game, { count: 5 });
}),
onShuffle: createTrigger("onShuffle", async (ctx) => {
await ctx.game.produceAsync((draft) => {
const { cards, regions } = draft.player.deck;
for (const cardId of Object.values(regions.discardPile.childIds))
moveToRegion(cards[cardId], regions.discardPile, regions.drawPile);
shuffle(regions.drawPile, cards, ctx.game.rng);
});
}),
onCardPlayed: createTrigger("onCardPlayed", async (ctx) => {
await ctx.game.produceAsync((draft) => {
const { cards, regions } = draft.player.deck;
const card = cards[ctx.cardId];
payCardCost(
draft.player,
card.cardData.costType,
card.cardData.costCount,
card.itemId,
run,
);
moveToRegion(card, regions.hand, regions.discardPile);
onItemPlay(draft.player, card.itemId);
});
const { cards, regions } = ctx.game.value.player.deck;
const card = cards[ctx.cardId];
const source = ctx.sourceEntityId ?? "player";
for (const { trigger, target, effects } of card.cardData.effects) {
if (trigger !== "onPlay") continue;
for (const [effect, stacks] of effects)
for (const entity of getEffectTargets(
target,
ctx.game,
ctx.targetId,
source,
))
await triggers.onEffectApplied.execute(ctx.game, {
effect,
entityId: entity.id,
stacks,
cardId: ctx.cardId,
sourceEntityId: source,
targetId: ctx.targetId,
});
}
}),
onCardDiscarded: createTrigger("onCardDiscarded", async (ctx) => {
await ctx.game.produceAsync((draft) => {
const { cards, regions } = draft.player.deck;
moveToRegion(cards[ctx.cardId], regions.hand, regions.discardPile);
onItemDiscard(draft.player, cards[ctx.cardId].itemId);
});
const { cards, regions } = ctx.game.value.player.deck;
const card = cards[ctx.cardId];
const source = ctx.sourceEntityId ?? "player";
for (const { trigger, target, effects } of card.cardData.effects) {
if (trigger !== "onDiscard") continue;
for (const [effect, stacks] of effects)
for (const entity of getEffectTargets(
target,
ctx.game,
undefined,
source,
))
await triggers.onEffectApplied.execute(ctx.game, {
effect,
entityId: entity.id,
stacks,
cardId: ctx.cardId,
sourceEntityId: source,
});
}
}),
onCardDrawn: createTrigger("onCardDrawn", async (ctx) => {
await ctx.game.produceAsync((draft) => {
const { cards, regions } = draft.player.deck;
moveToRegion(cards[ctx.cardId], regions.drawPile, regions.hand);
});
const { cards, regions } = ctx.game.value.player.deck;
const card = cards[ctx.cardId];
const source = ctx.sourceEntityId ?? "player";
for (const { trigger, target, effects } of card.cardData.effects) {
if (trigger !== "onDraw") continue;
for (const [effect, stacks] of effects)
for (const entity of getEffectTargets(
target,
ctx.game,
undefined,
source,
))
await triggers.onEffectApplied.execute(ctx.game, {
effect,
entityId: entity.id,
stacks,
cardId: ctx.cardId,
sourceEntityId: source,
});
}
}),
onDraw: createTrigger("onDraw", async (ctx) => {
let toDraw = ctx.count;
while (toDraw > 0) {
let inDraw =
ctx.game.value.player.deck.regions.drawPile.childIds.length;
if (inDraw <= 0) await triggers.onShuffle.execute(ctx.game, {});
inDraw = ctx.game.value.player.deck.regions.drawPile.childIds.length;
if (inDraw <= 0) break;
const children = ctx.game.value.player.deck.regions.drawPile.childIds;
const cardId = children[children.length - 1];
await triggers.onCardDrawn.execute(ctx.game, { cardId });
toDraw--;
}
}),
onEffectApplied: createTrigger("onEffectApplied", async (ctx) => {
if (ctx.effect.lifecycle === "instant") return;
if (ctx.effect.lifecycle.startsWith("item")) {
if (ctx.cardId) {
const card = ctx.game.value.player.deck.cards[ctx.cardId];
const nearby = run.getAdjacentItems(card.itemId);
for (const itemId of nearby) {
await ctx.game.produceAsync((draft) => {
addItemEffect(draft.player, itemId, ctx.effect, ctx.stacks);
});
}
}
return;
}
await ctx.game.produceAsync((draft) => {
const entity =
ctx.entityId === "player"
? draft.player
: draft.enemies.find((e) => e.id === ctx.entityId);
if (entity) addEntityEffect(entity, ctx.effect, ctx.stacks);
});
}),
onHpChange: createTrigger("onHpChange", async (ctx) => {
await ctx.game.produceAsync((draft) => {
const entity =
ctx.entityId === "player"
? draft.player
: draft.enemies.find((e) => e.id === ctx.entityId);
if (!entity) return;
entity.hp += ctx.amount;
entity.isAlive = entity.hp > 0;
draft.result = !draft.player.isAlive
? "defeat"
: draft.enemies.every((e) => !e.isAlive)
? "victory"
: null;
});
if (ctx.game.value.result) throw ctx.game.value;
}),
onDamage: createTrigger("onDamage", async (ctx) => {
const entity =
ctx.entityId === "player"
? ctx.game.value.player
: ctx.game.value.enemies.find((e) => e.id === ctx.entityId);
if (!entity || !entity.isAlive) return;
const dealt = Math.min(
Math.max(0, entity.hp),
ctx.amount - (ctx.prevented || 0),
);
await ctx.game.produceAsync((draft) => {
onEntityPostureDamage(entity, dealt);
});
await triggers.onHpChange.execute(ctx.game, {
entityId: ctx.entityId,
amount: -dealt,
});
}),
onEnemyIntent: createTrigger("onEnemyIntent", async (ctx) => {
const enemy = ctx.game.value.enemies.find((e) => e.id === ctx.enemyId);
if (!enemy || !enemy.isAlive) return;
const intent = enemy.currentIntent;
if (!intent) return;
const source = ctx.sourceEntityId ?? enemy.id;
for (const [target, effect, stacks] of intent.effects) {
for (const entity of getEffectTargets(
target,
ctx.game,
undefined,
source,
))
await triggers.onEffectApplied.execute(ctx.game, {
effect,
entityId: entity.id,
stacks,
sourceEntityId: source,
});
}
}),
onIntentUpdate: createTrigger("onIntentUpdate", async (ctx) => {
await ctx.game.produceAsync((draft) => {
const enemy = draft.enemies.find((e) => e.id === ctx.enemyId);
if (!enemy) return;
const intent = enemy.currentIntent;
if (!intent) return;
const nextIntents = intent.nextIntents;
if (nextIntents.length > 0) {
const nextIndex = ctx.game.rng.nextInt(nextIntents.length);
enemy.currentIntent = nextIntents[nextIndex];
}
});
}),
};
return triggers;
}
export type Triggers = ReturnType<typeof createTriggers>;
export function createStart(triggers: Triggers, run: IRunContext) {
return async function (game: CombatGameContext) {
await triggers.onCombatStart.execute(game, {});
try {
while (true) {
await triggers.onTurnStart.execute(game, { entityId: "player" });
while (true) {
const action = await promptMainAction(game, run);
if (action.action === "end-turn") break;
if (action.action === "play") {
await triggers.onCardPlayed.execute(game, action);
}
}
await triggers.onTurnEnd.execute(game, { entityId: "player" });
for (const enemy of getAliveEnemies(game.value)) {
await triggers.onTurnStart.execute(game, { entityId: enemy.id });
}
for (const enemy of getAliveEnemies(game.value)) {
await triggers.onEnemyIntent.execute(game, { enemyId: enemy.id });
await triggers.onIntentUpdate.execute(game, { enemyId: enemy.id });
}
for (const enemy of getAliveEnemies(game.value)) {
await triggers.onTurnEnd.execute(game, { entityId: enemy.id });
}
}
} catch (e) {
if (e === game.value) return game.value.result;
throw e;
}
};
}
type TriggerContext<TKey extends keyof TriggerTypes> = TriggerTypes[TKey] & {
event: TKey;
game: CombatGameContext;
};
function createTrigger<TKey extends keyof TriggerTypes>(
event: TKey,
fallback?: (ctx: TriggerContext<TKey>) => Promise<void>,
) {
const { use, execute } = createMiddlewareChain<TriggerContext<TKey>, void>(
fallback,
);
return {
use,
execute: async (game: CombatGameContext, ctx: TriggerTypes[TKey]) => {
const param = { ...ctx, game, event };
await execute(param);
return param;
},
};
}
@@ -0,0 +1,83 @@
import { Part } from "@/core/part";
import { Region } from "@/core/region";
import {
CardData,
EnemyData,
IntentData,
ItemData,
} from "@/samples/slay-the-spire-like/system/types";
import { EffectData } from "@/samples/slay-the-spire-like/system/types";
export type EffectTable = Record<string, { data: EffectData; stacks: number }>;
export type CombatEntity = {
id: string; // player is just "player"
effects: EffectTable;
hp: number;
maxHp: number;
isAlive: boolean;
};
export interface IPlayerState {
hp: number;
maxHp: number;
itemEffects: Record<string, EffectTable>;
}
export interface IPlayerDeck {
cards: Record<string, Part<{ cardData: CardData; itemId: string }>>;
regions: {
hand: Region;
discardPile: Region;
drawPile: Region;
exhaustPile: Region;
};
}
export type PlayerEntity = CombatEntity & {
energy: number;
maxEnergy: number;
deck: IPlayerDeck;
itemEffects: Record<string, EffectTable>;
};
export type EnemyEntity = CombatEntity & {
enemy: EnemyData;
intents: Record<string, IntentData>;
currentIntent: IntentData;
};
export type CombatPhase = "playerTurn" | "enemyTurn" | "combatEnd";
export type CombatResult = "victory" | "defeat";
export type LootEntry =
| {
type: "gold";
amount: number;
}
| {
type: "item";
itemId: string;
};
export type CombatState = {
enemies: EnemyEntity[];
player: PlayerEntity;
phase: CombatPhase;
turnNumber: number;
result: CombatResult | null;
loot: LootEntry[];
};
export interface IRunContext {
getItemData(id: string): ItemData | null;
getAdjacentItems(id: string): Iterable<string>;
getConsumedUses(id: string): number;
setConsumedUsesAsync(id: string, uses: number): Promise<void>;
}
export type CombatGameContext =
import("@/core/game").IGameContextExport<CombatState>;
@@ -0,0 +1,68 @@
import { moveToRegion } from "@/core/region";
import { createRegion } from "@/core/region";
import type { CardData } from "../types";
import type { DeckRegions, GameCard, IInventory, PlayerDeck } from "./types";
function generateCardId(itemId: string, cellIndex: number): string {
return `card-${itemId}-${cellIndex}`;
}
function createCard(
itemId: string,
cardData: CardData,
cellIndex: number,
): GameCard {
return {
id: generateCardId(itemId, cellIndex),
regionId: "",
position: [],
itemId,
cardData,
};
}
function createDeckRegions(): DeckRegions {
return {
drawPile: createRegion("drawPile", []),
hand: createRegion("hand", []),
discardPile: createRegion("discardPile", []),
exhaustPile: createRegion("exhaustPile", []),
};
}
function generateDeckFromInventory(inventory: IInventory): PlayerDeck {
const cards: Record<string, GameCard> = {};
const regions = createDeckRegions();
for (const item of inventory.items.values()) {
const itemData = item.meta?.itemData;
if (!itemData) continue;
const count = item.shape.count;
for (let i = 0; i < count; i++) {
const card = createCard(item.id, itemData.card, i);
cards[card.id] = card;
moveToRegion(card, null, regions.drawPile);
}
}
return {
cards,
regions,
};
}
function createPlayerDeck(): PlayerDeck {
return {
cards: {},
regions: createDeckRegions(),
};
}
export {
generateDeckFromInventory,
createCard,
createPlayerDeck,
createDeckRegions,
generateCardId,
};
@@ -0,0 +1,2 @@
export * from "./factory";
export * from "./types";
@@ -0,0 +1,51 @@
import type { Part } from "@/core/part";
import { CardData, ItemData } from "@/samples/slay-the-spire-like/system/types";
import { Region } from "@/core/region";
import { ParsedShape } from "../utils/parse-shape";
/**
* Metadata for a game card.
* Bridges inventory item data with the card system.
*/
export interface GameCardMeta {
cardData: CardData;
itemId: string;
}
export interface IInventory {
items: Map<
string,
{
id: string;
meta?: { itemData: ItemData };
shape: ParsedShape;
}
>;
}
/**
* A card instance in the game.
* Cards are generated from inventory items or created as status effects.
*/
export type GameCard = Part<GameCardMeta>;
/**
* Player deck structure containing card pools.
*/
export interface PlayerDeck {
/** All cards indexed by ID */
cards: Record<string, GameCard>;
regions: DeckRegions;
}
export interface DeckRegions {
/** Card IDs in the draw pile */
drawPile: Region;
/** Card IDs in the player's hand */
hand: Region;
/** Card IDs in the discard pile */
discardPile: Region;
/** Card IDs in the exhaust pile (removed from combat) */
exhaustPile: Region;
}
@@ -0,0 +1,2 @@
export * from "./types";
export * from "./run";
@@ -0,0 +1,52 @@
import { RunState } from "./types";
const DEFAULT_MAX_HP = 50;
const DEFAULT_GOLD = 50;
export function createRunState(): RunState {
return {
maxHp: DEFAULT_MAX_HP,
currentHp: DEFAULT_MAX_HP,
gold: DEFAULT_GOLD,
_idCounter: { value: 0 },
};
}
export function damagePlayer(runState: RunState, amount: number): void {
runState.currentHp = Math.max(0, runState.currentHp - amount);
}
export function healPlayer(runState: RunState, amount: number): void {
runState.currentHp = Math.min(runState.maxHp, runState.currentHp + amount);
}
export function setMaxHp(runState: RunState, newMax: number): void {
const diff = Math.max(0, newMax - runState.maxHp);
runState.maxHp = newMax;
runState.currentHp = Math.max(0, Math.min(newMax, runState.currentHp + diff));
}
/**
* Adds gold to the player.
*/
export function addGold(runState: RunState, amount: number): void {
runState.gold += amount;
}
/**
* Spends gold. Returns false if the player doesn't have enough gold.
*/
export function spendGold(
runState: RunState,
amount: number,
): { success: true } | { success: false; reason: string } {
if (amount <= 0) {
return { success: false, reason: "金额必须大于零" };
}
if (runState.gold < amount) {
return { success: false, reason: "金币不足" };
}
runState.gold -= amount;
return { success: true };
}
@@ -0,0 +1,42 @@
import { GridInventory } from "../grid-inventory";
import { GameItemMeta } from "../grid-inventory/types";
import { EncounterData } from "../types";
export interface RunState {
_idCounter: { value: number };
currentHp: number;
maxHp: number;
gold: number;
}
export type EncounterState =
| CombatEncounterState
| ShopEncounterState
| CurioEncounterState
| CampEncounterState
| DialogueEncounterState;
export type CombatEncounterState = {
data: EncounterData<"minion" | "elite">;
blocked: boolean;
};
export type ShopEncounterState = {
data: EncounterData<"shop">;
items: GridInventory<GameItemMeta & { sellPrice: number }>;
};
export type CurioEncounterState = {
data: EncounterData<"curio">;
items: GridInventory<GameItemMeta>;
};
export type CampEncounterState = {
data: EncounterData<"camp">;
};
export type DialogueEncounterState = {
data: EncounterData<"event">;
blocked: boolean;
};
@@ -0,0 +1,121 @@
import { parseShapeString } from "../utils/parse-shape";
import type { ParsedShape } from "../utils/parse-shape";
import { IDENTITY_TRANSFORM, type Transform2D } from "../utils/shape-collision";
import { createGridInventory, placeItem, validatePlacement } from "./transform";
import type { GameItemMeta, GridInventory, MutationResult } from "./types";
import type { ItemData } from "../types";
import { ReadonlyRNG, RNG } from "@/utils/rng";
/**
* Creates and places a GameItemMeta item into the grid inventory.
* Parses the shape from itemData.shape and finds the first valid placement automatically.
* **Mutates directly** — call inside a `.produce()` callback.
*/
export function createItemIn(
inventory: GridInventory<GameItemMeta>,
id: string,
item: ItemData | GameItemMeta,
): MutationResult {
const itemData = "itemData" in item ? item.itemData : item;
const shape = parseShapeString(itemData.shape);
const transform = findFirstValidPlacement(inventory, shape);
if (!transform) {
return { success: false, reason: "无可用位置" };
}
const meta: GameItemMeta = "itemData" in item ? item : { itemData, shape };
placeItem(inventory, { id, shape, transform, meta });
return { success: true };
}
/**
* Searches for the first valid placement in the grid (row by row, left to right).
* Returns the transform or null if no valid placement exists.
*/
function findFirstValidPlacement(
inventory: GridInventory<GameItemMeta>,
shape: ParsedShape,
): Transform2D | null {
for (let y = 0; y <= inventory.height - shape.height; y++) {
for (let x = 0; x <= inventory.width - shape.width; x++) {
const transform: Transform2D = {
offset: { x, y },
rotation: 0,
flipX: false,
flipY: false,
};
const result = validatePlacement(inventory, shape, transform);
if (result.valid) {
return transform;
}
}
}
return null;
}
function generateInstanceId(counter: { value: number }): string {
counter.value++;
return `item-${counter.value}`;
}
export function createShopInventory(
allItems: ItemData[],
rng: ReadonlyRNG,
counter: { value: number },
) {
const shopItems: GameItemMeta[] = [];
const rolledIndices = new Set<number>();
for (let i = 0; i < 5 && rolledIndices.size < allItems.length; i++) {
let index: number;
do {
index = rng.nextInt(allItems.length);
} while (rolledIndices.has(index));
rolledIndices.add(index);
const itemData = allItems[index];
const shape = parseShapeString(itemData.shape);
const tradePrice = Math.floor(
(rng.nextInt(5) + rng.nextInt(5) + 1) * 0.2 * itemData.price,
);
shopItems.push({ itemData, shape, tradePrice });
}
const inventory = createGridInventory<GameItemMeta>(6, 4);
for (let i = 0; i < shopItems.length; i++) {
createItemIn(inventory, generateInstanceId(counter), shopItems[i]);
}
return inventory;
}
export function createCurioItems(
allItems: ItemData[],
rng: RNG,
counter: { value: number },
) {
const curioItems: GameItemMeta[] = [];
const rolledIndices = new Set<number>();
for (let i = 0; i < 2 && rolledIndices.size < allItems.length; i++) {
let index: number;
do {
index = rng.nextInt(allItems.length);
} while (rolledIndices.has(index));
rolledIndices.add(index);
const itemData = allItems[index];
const shape = parseShapeString(itemData.shape);
curioItems.push({ itemData, shape });
}
const inventory = createGridInventory<GameItemMeta>(4, 3);
for (let i = 0; i < curioItems.length; i++) {
createItemIn(inventory, generateInstanceId(counter), curioItems[i]);
}
return inventory;
}
@@ -0,0 +1,3 @@
export * from "./types";
export * from "./transform";
export * from "./factory";
@@ -0,0 +1,277 @@
import { EffectData } from "../types";
import type { ParsedShape } from "../utils/parse-shape";
import type { Transform2D } from "../utils/shape-collision";
import {
checkBoardCollision,
checkBounds,
flipXTransform,
flipYTransform,
rotateTransform,
transformShape,
} from "../utils/shape-collision";
import type {
CellKey,
GameItemMeta,
GridInventory,
InventoryItem,
MutationResult,
PlacementResult,
} from "./types";
/**
* Creates a new empty grid inventory.
* Note: When used inside `.produce()`, call this before returning the draft.
*/
export function createGridInventory<TMeta = Record<string, unknown>>(
width: number,
height: number,
): GridInventory<TMeta> {
return {
width,
height,
items: new Map<string, InventoryItem<TMeta>>(),
occupiedCells: new Set<CellKey>(),
};
}
/**
* Builds a Set of occupied cell keys from a shape and transform.
*/
function getShapeCellKeys(
shape: ParsedShape,
transform: Transform2D,
): Set<CellKey> {
const cells = transformShape(shape, transform);
return new Set(cells.map((c) => `${c.x},${c.y}` as CellKey));
}
/**
* Validates whether an item can be placed at the given transform.
* Checks bounds and collision with all other items.
*/
export function validatePlacement<TMeta = Record<string, unknown>>(
inventory: GridInventory<TMeta>,
shape: InventoryItem<TMeta>["shape"],
transform: Transform2D,
): PlacementResult {
if (!checkBounds(shape, transform, inventory.width, inventory.height)) {
return { valid: false, reason: "超出边界" };
}
if (checkBoardCollision(shape, transform, inventory.occupiedCells)) {
return { valid: false, reason: "与已有物品重叠" };
}
return { valid: true };
}
/**
* Places an item onto the grid.
* **Mutates directly** — call inside a `.produce()` callback.
* Does not validate; call `validatePlacement` first.
*/
export function placeItem<TMeta = Record<string, unknown>>(
inventory: GridInventory<TMeta>,
item: InventoryItem<TMeta>,
): void {
const cells = getShapeCellKeys(item.shape, item.transform);
for (const cellKey of cells) {
inventory.occupiedCells.add(cellKey);
}
inventory.items.set(item.id, item);
}
/**
* Removes an item from the grid by its ID.
* **Mutates directly** — call inside a `.produce()` callback.
*/
export function removeItem<TMeta = Record<string, unknown>>(
inventory: GridInventory<TMeta>,
itemId: string,
): void {
const item = inventory.items.get(itemId);
if (!item) return;
const cells = getShapeCellKeys(item.shape, item.transform);
for (const cellKey of cells) {
inventory.occupiedCells.delete(cellKey);
}
inventory.items.delete(itemId);
}
/**
* Moves an item to a new position with a new transform.
* **Mutates directly** — call inside a `.produce()` callback.
* Validates before applying; returns result indicating success.
*/
export function moveItem<TMeta = Record<string, unknown>>(
inventory: GridInventory<TMeta>,
itemId: string,
newTransform: Transform2D,
): MutationResult {
const item = inventory.items.get(itemId);
if (!item) {
return { success: false, reason: "物品不存在" };
}
// Temporarily remove item's cells for validation
const oldCells = getShapeCellKeys(item.shape, item.transform);
for (const cellKey of oldCells) {
inventory.occupiedCells.delete(cellKey);
}
// Validate new position
const validation = validatePlacement(inventory, item.shape, newTransform);
if (!validation.valid) {
// Restore old cells
for (const cellKey of oldCells) {
inventory.occupiedCells.add(cellKey);
}
return { success: false, reason: validation.reason };
}
// Apply new transform
item.transform = newTransform;
// Add new cells
const newCells = getShapeCellKeys(item.shape, item.transform);
for (const cellKey of newCells) {
inventory.occupiedCells.add(cellKey);
}
return { success: true };
}
/**
* Rotates an item by the given degrees (typically 90, 180, or 270).
* **Mutates directly** — call inside a `.produce()` callback.
* Validates before applying; returns result indicating success.
*/
export function rotateItem<TMeta = Record<string, unknown>>(
inventory: GridInventory<TMeta>,
itemId: string,
degrees: number,
): MutationResult {
const item = inventory.items.get(itemId);
if (!item) {
return { success: false, reason: "物品不存在" };
}
const rotatedTransform = rotateTransform(item.transform, degrees);
return moveItem(inventory, itemId, rotatedTransform);
}
/**
* Flips an item horizontally or vertically.
* **Mutates directly** — call inside a `.produce()` callback.
* Validates before applying; returns result indicating success.
*/
export function flipItem<TMeta = Record<string, unknown>>(
inventory: GridInventory<TMeta>,
itemId: string,
axis: "x" | "y",
): MutationResult {
const item = inventory.items.get(itemId);
if (!item) {
return { success: false, reason: "物品不存在" };
}
const flippedTransform =
axis === "x"
? flipXTransform(item.transform)
: flipYTransform(item.transform);
return moveItem(inventory, itemId, flippedTransform);
}
/**
* Returns a copy of the occupied cells set.
*/
export function getOccupiedCellSet<TMeta = Record<string, unknown>>(
inventory: GridInventory<TMeta>,
): Set<CellKey> {
return new Set(inventory.occupiedCells);
}
/**
* Finds the item occupying the given cell, if any.
*/
export function getItemAtCell<TMeta = Record<string, unknown>>(
inventory: GridInventory<TMeta>,
x: number,
y: number,
): InventoryItem<TMeta> | undefined {
const cellKey = `${x},${y}` as CellKey;
if (!inventory.occupiedCells.has(cellKey)) {
return undefined;
}
for (const item of inventory.items.values()) {
const cells = getShapeCellKeys(item.shape, item.transform);
if (cells.has(cellKey)) {
return item;
}
}
return undefined;
}
/**
* Gets all items adjacent to the given item (orthogonally, not diagonally).
* Returns a Map of itemId -> item for deduplication.
*/
export function getAdjacentItems<TMeta>(
inventory: GridInventory<TMeta>,
itemId: string,
): Map<string, InventoryItem<TMeta>> {
const item = inventory.items.get(itemId);
if (!item) {
return new Map();
}
const ownCells = getShapeCellKeys(item.shape, item.transform);
const adjacent = new Map<string, InventoryItem<TMeta>>();
for (const cellKey of ownCells) {
const [cx, cy] = cellKey.split(",").map(Number);
const neighbors: CellKey[] = [
`${cx + 1},${cy}` as CellKey,
`${cx - 1},${cy}` as CellKey,
`${cx},${cy + 1}` as CellKey,
`${cx},${cy - 1}` as CellKey,
];
for (const neighborKey of neighbors) {
if (
inventory.occupiedCells.has(neighborKey) &&
!ownCells.has(neighborKey)
) {
const [nx, ny] = neighborKey.split(",").map(Number);
const neighborItem = getItemAtCell(inventory, nx, ny);
if (neighborItem) {
adjacent.set(neighborItem.id, neighborItem);
}
}
}
}
return adjacent;
}
// export type EffectTable = Record<string, { data: EffectData; stacks: number }>;
export function getItemEffects(inv: GridInventory<GameItemMeta>) {
const effects = {} as Record<
string,
Record<string, { data: EffectData; stacks: number }>
>;
for (const item of inv.items.values()) {
if (!item.meta) continue;
const { startEffects } = item.meta;
if (!startEffects) continue;
effects[item.id] = startEffects;
}
return effects;
}
@@ -0,0 +1,70 @@
import { EffectData, ItemData } from "../types";
import type { ParsedShape } from "../utils/parse-shape";
import type { Transform2D } from "../utils/shape-collision";
/**
* String key representing a grid cell in "x,y" format.
*/
export type CellKey = `${number},${number}`;
/**
* Simple 2D coordinate for grid cells.
*/
export interface CellCoordinate {
x: number;
y: number;
}
/**
* An item placed on the grid inventory.
* @template TMeta - Optional metadata type for game-specific data
*/
export interface InventoryItem<TMeta> {
/** Unique item identifier */
id: string;
/** Reference to the item's shape definition */
shape: ParsedShape;
/** Current transformation (position, rotation, flips) */
transform: Transform2D;
/** Optional metadata for game-specific data */
meta?: TMeta;
}
/**
* Result of a placement validation check.
*/
export type PlacementResult =
| { valid: true }
| { valid: false; reason: string };
/**
* Result of a mutation operation (move, rotate, flip).
*/
export type MutationResult =
| { success: true }
| { success: false; reason: string };
/**
* Grid inventory state.
* Designed to be mutated directly inside a `mutative .produce()` callback.
* @template TMeta - Optional metadata type for items
*/
export interface GridInventory<TMeta> {
/** Board width in cells */
width: number;
/** Board height in cells */
height: number;
/** Map of itemId -> InventoryItem for all placed items */
items: Map<string, InventoryItem<TMeta>>;
/** Set of occupied cells in "x,y" format for O(1) collision lookups */
occupiedCells: Set<CellKey>;
}
export interface GameItemMeta {
itemData: ItemData;
shape: ParsedShape;
consumedUses?: number;
startEffects?: Record<string, { data: EffectData; stacks: number }>;
tradePrice?: number;
}
export type GameItem = InventoryItem<GameItemMeta>;
@@ -1,16 +1,11 @@
import { Mulberry32RNG, type RNG } from '@/utils/rng';
import encounterDesertCsvAccessor, { type EncounterDesert } from '../data/encounterDesert.csv';
import { ReadonlyRNG } from '@/utils/rng';
import { MapNodeType, MapLayerType } from './types';
import type { MapLayer, MapNode, PointCrawlMap, MapGenerationConfig } from './types';
import {EncounterData} from "@/samples/slay-the-spire-like/system/types";
const encounterDesertCsv = encounterDesertCsvAccessor();
/** Pre-indexed encounters by type */
const encountersByType = buildEncounterIndex();
function buildEncounterIndex(): Map<string, EncounterDesert[]> {
const index = new Map<string, EncounterDesert[]>();
for (const encounter of encounterDesertCsv) {
function buildEncounterIndex(src: Iterable<EncounterData>): Map<string, EncounterData[]> {
const index = new Map<string, EncounterData[]>();
for (const encounter of src) {
const type = encounter.type;
if (!index.has(type)) {
index.set(type, []);
@@ -63,7 +58,7 @@ const LAYER_STRUCTURE: Array<{ layerType: MapLayerType | 'start' | 'end'; count:
* Fisher-Yates shuffle algorithm for unbiased random permutation.
* Mutates the array in place and returns it.
*/
function fisherYatesShuffle<T>(array: T[], rng: RNG): T[] {
function fisherYatesShuffle<T>(array: T[], rng: ReadonlyRNG): T[] {
for (let i = array.length - 1; i > 0; i--) {
const j = rng.nextInt(i + 1);
[array[i], array[j]] = [array[j], array[i]];
@@ -75,11 +70,7 @@ function fisherYatesShuffle<T>(array: T[], rng: RNG): T[] {
* Picks a random encounter for the given node type.
* Returns undefined if no matching encounter exists.
*/
function pickEncounterForNode(type: MapNodeType, rng: RNG): EncounterDesert | undefined {
const encounterType = NODE_TYPE_TO_ENCOUNTER[type];
if (!encounterType) return undefined;
const pool = encountersByType.get(encounterType);
function pickEncounterForNode(pool: EncounterData[] | undefined, rng: ReadonlyRNG): EncounterData | undefined {
if (!pool || pool.length === 0) return undefined;
return pool[rng.nextInt(pool.length)];
@@ -98,12 +89,9 @@ const TOTAL_LAYERS = 10;
* - Each settlement layer has at least 1 of each: camp, shop, curio
* - Wild nodes connect to 1 wild node or 2 settlement nodes
*
* @param seed Random seed for reproducibility
*/
export function generatePointCrawlMap(seed?: number): PointCrawlMap {
const rng = new Mulberry32RNG(seed ?? Date.now());
const actualSeed = rng.getSeed();
export function generatePointCrawlMap(rng: ReadonlyRNG, src: Iterable<EncounterData>): PointCrawlMap {
const encounters = buildEncounterIndex(src);
const layers: MapLayer[] = [];
const nodes = new Map<string, MapNode>();
@@ -136,13 +124,13 @@ export function generatePointCrawlMap(seed?: number): PointCrawlMap {
for (let j = 0; j < structure.count; j++) {
const id = `node-${i}-${j}`;
const type = resolveNodeType(structure.layerType, j, structure.count, rng, wildPairTypes.get(i), j, settlementTypes, j);
const encounter = pickEncounterForNode(type, rng);
const encounter = pickEncounterForNode(encounters.get(NODE_TYPE_TO_ENCOUNTER[type]!), rng);
const node: MapNode = {
id,
layerIndex: i,
type,
childIds: [],
...(encounter ? { encounter: { name: encounter.name, description: encounter.description } } : {}),
encounter
};
nodes.set(id, node);
nodeIds.push(id);
@@ -171,7 +159,7 @@ export function generatePointCrawlMap(seed?: number): PointCrawlMap {
}
}
return { layers, nodes, seed: actualSeed, parentIndex };
return { layers, nodes, parentIndex };
}
/**
@@ -181,7 +169,7 @@ function resolveNodeType(
layerType: MapLayerType | 'start' | 'end',
_nodeIndex: number,
_layerCount: number,
rng: RNG,
rng: ReadonlyRNG,
preGeneratedTypes?: MapNodeType[],
nodeIndex?: number,
settlementTypes?: MapNodeType[],
@@ -213,7 +201,7 @@ function resolveNodeType(
* Picks a random type for a wild node based on configured weights.
* Default: minion: 50%, elite: 25%, event: 25%
*/
function pickWildNodeType(rng: RNG): MapNodeType {
function pickWildNodeType(rng: ReadonlyRNG): MapNodeType {
const weights = DEFAULT_CONFIG.wildNodeTypeWeights;
const roll = rng.nextInt(100);
@@ -226,7 +214,7 @@ function pickWildNodeType(rng: RNG): MapNodeType {
* Generates random types for a pair of wild layers (3 nodes each).
* Returns two arrays of 3 node types each.
*/
function generateWildPair(rng: RNG): [MapNodeType[], MapNodeType[]] {
function generateWildPair(rng: ReadonlyRNG): [MapNodeType[], MapNodeType[]] {
const layer1Types: MapNodeType[] = [];
const layer2Types: MapNodeType[] = [];
@@ -284,7 +272,7 @@ function countRepetitions(
* Generates optimal wild layer pair by trying multiple attempts and selecting the one with fewest repetitions.
*/
function generateOptimalWildPair(
rng: RNG,
rng: ReadonlyRNG,
attempts = 3
): [MapNodeType[], MapNodeType[]] {
let bestLayer1: MapNodeType[] = [];
@@ -313,7 +301,7 @@ function generateOptimalWildPair(
* The 4th node is randomly chosen from the three.
* Returns shuffled array of 4 node types.
*/
function generateSettlementTypes(rng: RNG): MapNodeType[] {
function generateSettlementTypes(rng: ReadonlyRNG): MapNodeType[] {
const requiredTypes = [MapNodeType.Camp, MapNodeType.Shop, MapNodeType.Curio];
const randomType = requiredTypes[rng.nextInt(3)];
const types = [...requiredTypes, randomType];
@@ -325,7 +313,7 @@ function generateSettlementTypes(rng: RNG): MapNodeType[] {
* The 4th node is randomly chosen from the three.
* @deprecated Use generateSettlementTypes() during node creation instead.
*/
function assignSettlementTypes(nodeIds: string[], nodes: MapNode[], rng: RNG): void {
function assignSettlementTypes(nodeIds: string[], nodes: MapNode[], rng: ReadonlyRNG): void {
// Shuffle node order to randomize which position gets which type
const shuffledIndices = fisherYatesShuffle([0, 1, 2, 3], rng);
@@ -347,7 +335,7 @@ function generateLayerEdges(
sourceLayer: MapLayer,
targetLayer: MapLayer,
nodes: Map<string, MapNode>,
rng: RNG
rng: ReadonlyRNG
): void {
// Settlement types are now pre-generated during node creation
// No need to assign them here anymore
@@ -402,7 +390,7 @@ function connectWildToWild(
function connectWildToSettlement(
wildLayer: MapLayer,
settlementLayer: MapLayer,
_rng: RNG
_rng: ReadonlyRNG
): void {
// Non-crossing connection pattern: each wild connects to 2 adjacent settlements.
// Pattern: w[0]→{s[0],s[1]}, w[1]→{s[1],s[2]}, w[2]→{s[2],s[3]}
@@ -427,7 +415,7 @@ function connectWildToSettlement(
function connectSettlementToWild(
settlementLayer: MapLayer,
wildLayer: MapLayer,
_rng: RNG
_rng: ReadonlyRNG
): void {
// Non-crossing pattern: s0→w0, s1→w0,w1, s2→w1,w2, s3→w2
// This pattern guarantees no crossings because when edges are sorted by
@@ -0,0 +1,3 @@
export * from "./generator";
export * from "./navigation";
export * from "./types";
@@ -0,0 +1,65 @@
import { getNode } from "./generator";
import { PointCrawlMap, PointCrawlMapNavigator } from "./types";
export function canMoveTo(
navigator: PointCrawlMapNavigator,
map: PointCrawlMap,
targetNodeId: string,
): boolean {
const currentNode = getNode(map, navigator.currentNodeId);
if (!currentNode) return false;
return currentNode.childIds.includes(targetNodeId);
}
export function moveToNode(
navigator: PointCrawlMapNavigator,
map: PointCrawlMap,
targetNodeId: string,
): boolean {
if (!canMoveTo(navigator, map, targetNodeId)) {
return false;
}
const targetNode = getNode(map, targetNodeId);
if (!targetNode) {
return false;
}
// Update current position
navigator.currentNodeId = targetNodeId;
navigator.visitedNodes.add(targetNodeId);
return true;
}
export function* getReachableChildren(
map: PointCrawlMap,
navigator: PointCrawlMapNavigator,
) {
const currentNode = getNode(map, navigator.currentNodeId);
if (!currentNode) return;
for (const id of currentNode.childIds) {
const node = getNode(map, id);
if (!node) continue;
yield node;
}
}
export function isAtStartNode(
map: PointCrawlMap,
navigator: PointCrawlMapNavigator,
): boolean {
return navigator.currentNodeId === map.layers[0]?.nodes[0]?.id;
}
/**
* Checks if the current encounter is the end node.
*/
export function isAtEndNode(
map: PointCrawlMap,
navigator: PointCrawlMapNavigator,
): boolean {
const endLayer = map.layers[map.layers.length - 1];
return endLayer?.nodes[0]?.id === navigator.currentNodeId;
}
@@ -0,0 +1,88 @@
import { EncounterData } from "@/samples/slay-the-spire-like/system/types";
/**
* Types of nodes that can appear on the point crawl map.
*/
export enum MapNodeType {
Start = "start",
End = "end",
Minion = "minion",
Elite = "elite",
Event = "event",
Camp = "camp",
Shop = "shop",
Curio = "curio",
}
/**
* Semantic type of a layer.
*/
export enum MapLayerType {
Wild = "wild",
Settlement = "settlement",
}
/**
* A single node on the map.
*/
export interface MapNode {
/** Unique identifier */
id: string;
/** Which layer this node belongs to */
layerIndex: number;
/** Semantic type of the node */
type: MapNodeType;
/** IDs of nodes in the next layer this node connects to */
childIds: string[];
/** Encounter data assigned to this node (from encounter CSV) */
encounter?: EncounterData;
}
/**
* A horizontal layer of nodes at the same progression stage.
*/
export interface MapLayer {
/** Layer index (0 = start, last = end) */
index: number;
/** Ordered IDs of nodes in this layer */
nodeIds: string[];
/** Semantic type of the layer */
layerType: MapLayerType | "start" | "end";
/** Direct references to nodes in this layer (for performance) */
nodes: MapNode[];
}
/**
* A fully generated point crawl map.
*/
export interface PointCrawlMap {
/** Layers from start to end */
layers: MapLayer[];
/** All nodes keyed by ID */
nodes: Map<string, MapNode>;
/** Reverse index: nodeId → parent node IDs (for fast getParent lookup) */
parentIndex?: Map<string, string[]>;
}
export interface PointCrawlMapNavigator {
currentNodeId: string;
visitedNodes: Set<string>;
}
/**
* Configuration for map generation.
*/
export interface MapGenerationConfig {
/** Total number of layers (including start and end) */
totalLayers: number;
/** Number of nodes in each wild layer */
wildLayerNodeCount: number;
/** Number of nodes in each settlement layer */
settlementLayerNodeCount: number;
/** Probability weights for wild node types (should sum to 100) */
wildNodeTypeWeights: {
[MapNodeType.Minion]: number;
[MapNodeType.Elite]: number;
[MapNodeType.Event]: number;
};
}
@@ -0,0 +1,25 @@
import {
Card,
Effect,
Encounter,
EncounterType,
Enemy,
Intent,
Item,
} from "../data/desert";
export type CardData = Card;
export type ItemData = Item;
export type EffectData = Effect;
export type IntentData = Intent;
export type EnemyData = Enemy;
export type EncounterData<T extends EncounterType = EncounterType> =
Encounter & { type: T };
export {
CardTargetType,
CardEffectTarget,
IntentEffectTarget,
} from "../data/desert";
export { ContentModule } from "../data";
@@ -0,0 +1,2 @@
export * from "./parse-shape";
export * from "./shape-collision";
@@ -0,0 +1,238 @@
import type { ParsedShape } from "./parse-shape";
/**
* Represents a 2D point in grid coordinates.
*/
export interface Point2D {
x: number;
y: number;
}
/**
* 2D transformation to apply to a shape.
*/
export interface Transform2D {
/** Translation offset in grid units */
offset: Point2D;
/** Rotation in degrees (0, 90, 180, 270) */
rotation: number;
/** Whether to flip horizontally */
flipX: boolean;
/** Whether to flip vertically */
flipY: boolean;
}
/**
* Default transform (identity).
*/
export const IDENTITY_TRANSFORM: Transform2D = {
offset: { x: 0, y: 0 },
rotation: 0,
flipX: false,
flipY: false,
};
/**
* Gets all occupied cell coordinates from a shape.
*/
export function getOccupiedCells(shape: ParsedShape): Point2D[] {
const cells: Point2D[] = [];
for (let y = 0; y < shape.height; y++) {
for (let x = 0; x < shape.width; x++) {
if (shape.grid[y]?.[x]) {
cells.push({ x, y });
}
}
}
return cells;
}
/**
* Applies a 2D transformation to a point.
*/
export function transformPoint(
point: Point2D,
transform: Transform2D,
shapeWidth: number,
shapeHeight: number,
): Point2D {
let { x, y } = point;
// Apply flips
if (transform.flipX) {
x = shapeWidth - 1 - x;
}
if (transform.flipY) {
y = shapeHeight - 1 - y;
}
// Apply rotation (around origin 0,0)
const rotation = ((transform.rotation % 360) + 360) % 360;
let rotatedX = x;
let rotatedY = y;
switch (rotation) {
case 90:
rotatedX = y;
rotatedY = -x;
break;
case 180:
rotatedX = -x;
rotatedY = -y;
break;
case 270:
rotatedX = -y;
rotatedY = x;
break;
}
// Apply offset
return {
x: rotatedX + transform.offset.x,
y: rotatedY + transform.offset.y,
};
}
/**
* Transforms a shape and returnss its occupied cells in world coordinates.
*/
export function transformShape(
shape: ParsedShape,
transform: Transform2D,
): Point2D[] {
const cells = getOccupiedCells(shape);
return cells.map((cell) =>
transformPoint(cell, transform, shape.width, shape.height),
);
}
/**
* Checks if two transformed shapes collide (share any occupied cell).
*/
export function checkCollision(
shapeA: ParsedShape,
transformA: Transform2D,
shapeB: ParsedShape,
transformB: Transform2D,
): boolean {
const cellsA = transformShape(shapeA, transformA);
const cellsB = transformShape(shapeB, transformB);
const setA = new Set(cellsA.map((c) => `${c.x},${c.y}`));
for (const cell of cellsB) {
if (setA.has(`${cell.x},${cell.y}`)) {
return true;
}
}
return false;
}
/**
* Checks if a transformed shape collides with any occupied cells on a board.
* @param shape The shape to check
* @param transform The transform to apply to the shape
* @param occupiedCells Set of occupied board cells in "x,y" format
*/
export function checkBoardCollision(
shape: ParsedShape,
transform: Transform2D,
occupiedCells: Set<string>,
): boolean {
const cells = transformShape(shape, transform);
for (const cell of cells) {
if (occupiedCells.has(`${cell.x},${cell.y}`)) {
return true;
}
}
return false;
}
/**
* Checks if a transformed shape is within bounds.
* @param shape The shape to check
* @param transform The transform to apply to the shape
* @param boardWidth Board width
* @param boardHeight Board height
*/
export function checkBounds(
shape: ParsedShape,
transform: Transform2D,
boardWidth: number,
boardHeight: number,
): boolean {
const cells = transformShape(shape, transform);
for (const cell of cells) {
if (
cell.x < 0 ||
cell.x >= boardWidth ||
cell.y < 0 ||
cell.y >= boardHeight
) {
return false;
}
}
return true;
}
/**
* Validates that a placement is both in bounds and collision-free.
* @returns Object with `valid` flag and optional `reason` string
*/
export function validateShapePlacement(
shape: ParsedShape,
transform: Transform2D,
boardWidth: number,
boardHeight: number,
occupiedCells: Set<string>,
): { valid: true } | { valid: false; reason: string } {
if (!checkBounds(shape, transform, boardWidth, boardHeight)) {
return { valid: false, reason: "超出边界" };
}
if (checkBoardCollision(shape, transform, occupiedCells)) {
return { valid: false, reason: "与已有形状重叠" };
}
return { valid: true };
}
/**
* Rotates a transform by the given degrees.
* @param current The current transform
* @param degrees Degrees to rotate (typically 90, 180, or 270)
*/
export function rotateTransform(
current: Transform2D,
degrees: number,
): Transform2D {
return {
...current,
rotation: (((current.rotation + degrees) % 360) + 360) % 360,
};
}
/**
* Flips a transform horizontally.
*/
export function flipXTransform(current: Transform2D): Transform2D {
return {
...current,
flipX: !current.flipX,
};
}
/**
* Flips a transform vertically.
*/
export function flipYTransform(current: Transform2D): Transform2D {
return {
...current,
flipY: !current.flipY,
};
}
@@ -1,227 +0,0 @@
import type { ParsedShape } from './parse-shape';
/**
* Represents a 2D point in grid coordinates.
*/
export interface Point2D {
x: number;
y: number;
}
/**
* 2D transformation to apply to a shape.
*/
export interface Transform2D {
/** Translation offset in grid units */
offset: Point2D;
/** Rotation in degrees (0, 90, 180, 270) */
rotation: number;
/** Whether to flip horizontally */
flipX: boolean;
/** Whether to flip vertically */
flipY: boolean;
}
/**
* Default transform (identity).
*/
export const IDENTITY_TRANSFORM: Transform2D = {
offset: { x: 0, y: 0 },
rotation: 0,
flipX: false,
flipY: false,
};
/**
* Gets all occupied cell coordinates from a shape.
*/
export function getOccupiedCells(shape: ParsedShape): Point2D[] {
const cells: Point2D[] = [];
for (let y = 0; y < shape.height; y++) {
for (let x = 0; x < shape.width; x++) {
if (shape.grid[y]?.[x]) {
cells.push({ x, y });
}
}
}
return cells;
}
/**
* Applies a 2D transformation to a point.
*/
export function transformPoint(
point: Point2D,
transform: Transform2D,
shapeWidth: number,
shapeHeight: number
): Point2D {
let { x, y } = point;
// Apply flips
if (transform.flipX) {
x = shapeWidth - 1 - x;
}
if (transform.flipY) {
y = shapeHeight - 1 - y;
}
// Apply rotation (around origin 0,0)
const rotation = ((transform.rotation % 360) + 360) % 360;
let rotatedX = x;
let rotatedY = y;
switch (rotation) {
case 90:
rotatedX = y;
rotatedY = -x;
break;
case 180:
rotatedX = -x;
rotatedY = -y;
break;
case 270:
rotatedX = -y;
rotatedY = x;
break;
}
// Apply offset
return {
x: rotatedX + transform.offset.x,
y: rotatedY + transform.offset.y,
};
}
/**
* Transforms a shape and returnss its occupied cells in world coordinates.
*/
export function transformShape(shape: ParsedShape, transform: Transform2D): Point2D[] {
const cells = getOccupiedCells(shape);
return cells.map(cell =>
transformPoint(cell, transform, shape.width, shape.height)
);
}
/**
* Checks if two transformed shapes collide (share any occupied cell).
*/
export function checkCollision(
shapeA: ParsedShape,
transformA: Transform2D,
shapeB: ParsedShape,
transformB: Transform2D
): boolean {
const cellsA = transformShape(shapeA, transformA);
const cellsB = transformShape(shapeB, transformB);
const setA = new Set(cellsA.map(c => `${c.x},${c.y}`));
for (const cell of cellsB) {
if (setA.has(`${cell.x},${cell.y}`)) {
return true;
}
}
return false;
}
/**
* Checks if a transformed shape collides with any occupied cells on a board.
* @param shape The shape to check
* @param transform The transform to apply to the shape
* @param occupiedCells Set of occupied board cells in "x,y" format
*/
export function checkBoardCollision(
shape: ParsedShape,
transform: Transform2D,
occupiedCells: Set<string>
): boolean {
const cells = transformShape(shape, transform);
for (const cell of cells) {
if (occupiedCells.has(`${cell.x},${cell.y}`)) {
return true;
}
}
return false;
}
/**
* Checks if a transformed shape is within bounds.
* @param shape The shape to check
* @param transform The transform to apply to the shape
* @param boardWidth Board width
* @param boardHeight Board height
*/
export function checkBounds(
shape: ParsedShape,
transform: Transform2D,
boardWidth: number,
boardHeight: number
): boolean {
const cells = transformShape(shape, transform);
for (const cell of cells) {
if (cell.x < 0 || cell.x >= boardWidth || cell.y < 0 || cell.y >= boardHeight) {
return false;
}
}
return true;
}
/**
* Validates that a placement is both in bounds and collision-free.
* @returns Object with `valid` flag and optional `reason` string
*/
export function validatePlacement(
shape: ParsedShape,
transform: Transform2D,
boardWidth: number,
boardHeight: number,
occupiedCells: Set<string>
): { valid: true } | { valid: false; reason: string } {
if (!checkBounds(shape, transform, boardWidth, boardHeight)) {
return { valid: false, reason: '超出边界' };
}
if (checkBoardCollision(shape, transform, occupiedCells)) {
return { valid: false, reason: '与已有形状重叠' };
}
return { valid: true };
}
/**
* Rotates a transform by the given degrees.
* @param current The current transform
* @param degrees Degrees to rotate (typically 90, 180, or 270)
*/
export function rotateTransform(current: Transform2D, degrees: number): Transform2D {
return {
...current,
rotation: ((current.rotation + degrees) % 360 + 360) % 360,
};
}
/**
* Flips a transform horizontally.
*/
export function flipXTransform(current: Transform2D): Transform2D {
return {
...current,
flipX: !current.flipX,
};
}
/**
* Flips a transform vertically.
*/
export function flipYTransform(current: Transform2D): Transform2D {
return {
...current,
flipY: !current.flipY,
};
}

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