first commit

This commit is contained in:
richarjiang
2026-09-21 14:45:24 +08:00
commit 12380c715c
34 changed files with 6609 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules/
dist/
test-results/
playwright-report/
.DS_Store

1
.prettierrc.json Normal file
View File

@@ -0,0 +1 @@
{ "singleQuote": true, "printWidth": 100, "trailingComma": "all" }

29
AGENTS.md Normal file
View File

@@ -0,0 +1,29 @@
# 项目规则
## 目标与工作边界
- 开发基于 Web 的小丑牌式单人构筑游戏,以规则正确、完整对局和复古牌桌表现为优先级。
- 用户已授权实施;实施范围以 `docs/implementation-plan.md` 为依据,实际首版规则以 `docs/rules.md` 为准。
- 附图仅作为视觉参考;图中局内数值不能直接作为默认规则。
- 已核实规则、社区资料和本项目设计选择应明确区分;不声明尚未验证的原作一致性。
## 目录约定
- 根目录:项目规范、README、依赖和构建配置。
- `docs/`:方案、规则研究、验收记录;Markdown 文件用小写短横线命名。
- `src/core/`:纯 TypeScript 规则、状态、随机数与结算,不依赖 DOM、渲染引擎或音频。
- `src/content/`:带稳定 ID 的牌型、小丑、盲注、消耗品等内容定义。
- `src/app/`:用户命令、状态流转、存档及表现层协调。
- `src/render/`:牌桌、卡牌、动效、着色器、输入命中;只能消费核心状态与事件。
- `src/platform/`:浏览器存储、音频、窗口和生命周期适配。
- `public/assets/`:运行时美术、字体、音频;来源与许可记录在 `docs/assets.md`,素材导入前建立该文件。
- `tests/`:规则测试、组合用例、对局与浏览器验收;禁止用生产实现自身生成唯一预期结果。
- `artifacts/`:可复查的截图和验收输出;临时文件使用系统临时目录,阶段结束清理无用输出。
- 不提前创建空目录;新增职责或改变边界先修改此文档。若后续增加 CLAUDE.md,须与此规范保持一致。
## 实现约束
- 所有 shell 命令遵守 `/Users/richard/.codex/RTK.md`,通过 `rtk` 或 `rtk proxy` 执行。
- 计分由核心引擎一次确定并输出事件序列;动画不负责修改得分或发放奖励。
- 随机数可设种子、可保存状态;商店、抽牌和效果使用独立随机流。
- 实例 ID 与内容 ID 分离;允许重复点数、花色和重复内容实例。
- 禁止把小丑效果、Boss 规则写进卡牌绘制或点击处理函数。
- 使用自制或可使用的素材;截图不能充当交互界面或直接拆作牌面资源。
- 先做规则和完整流程的针对性验证,再验证画面、触屏、性能与兼容性;如实记录未验证平台。

64
README.md Normal file
View File

@@ -0,0 +1,64 @@
# 天鹅绒小丑 · Velvet Joker
一款可在浏览器游玩的单人扑克构筑游戏。使用 TypeScript + PixiJS 8,在 Canvas / WebGL 中绘制与操作卡牌;界面文字和按钮使用 HTML,保障中文清晰和键盘操作。
## 启动
需要 Node.js 22(已验证 22.15.1)。
```sh
npm ci
npm run dev
```
打开终端给出的本地地址。开发服务器默认端口 5173;仅本机访问可使用 `npm run dev -- --host 127.0.0.1`。
```sh
npm test # 规则测试 + 16 个固定种子的自然对局检查
npm run build # TypeScript 检查与生产构建
npm run preview # 预览 dist 构建产物
```
`dist/` 可部署到静态站点。游戏不需要账号、API 或服务器数据;无外部字体、图片、音频请求。首次页面加载仍需静态服务器,未实现 Service Worker 离线缓存。
## 操作
- 点击卡牌选择,最多 5 张;拖动手牌或小丑调整顺序。
- `1–8` 选牌,`Enter` 出牌,`D` 弃牌,`Esc` 取消选择或关闭弹窗。
- 小丑从左向右触发;点击查看效果、调整顺序或出售。
- 右上角消耗品点击使用;塔罗需先选择目标手牌。右键或长按手牌查看信息。
- 过关后进入商店,购买、刷新、开包,随后挑战下一盲注。
- 设置中可调整音效、CRT 效果、结算速度,或输入种子开始新局。
- 成功操作后自动保存;刷新会恢复已提交状态,不重播奖励。浏览器不同来源/端口的存档彼此独立。
## 首版内容
24 张独立小丑、12 类牌型及行星升级、9 种塔罗、4 类强化和红色蜡封、6 种常规 Boss、1 种终局 Boss、8 Ante / 24 个盲注。支持跳盲奖励、购买出售、开包、利息、胜利失败和本地续局。
规则参考 Balatro 的基础机制,但卡池、美术与部分规则为本作独立设计。具体数值、触发顺序和差异见 [规则文档](docs/rules.md),不声称完整复刻原作。
## 代码结构
- `src/core/`:纯规则、牌型、计分事件、随机数、事务状态机。
- `src/content/`:卡牌、牌型和 Boss 内容。
- `src/app/`:界面、命令协调、结算动画和样式。
- `src/render/`:PixiJS 牌桌、程序卡面、拖拽与命中检测。
- `src/platform/`:存档校验与 Web Audio 音效。
- `tests/`:手算规则用例、自然规则模拟、浏览器交互。
增加小丑需要同时定义内容、计分处理、金样测试,并更新规则文档。动画不可反向修改计分状态。
## 浏览器验证
先启动开发服务器,再运行 `npm run test:browser`。脚本默认使用 macOS 系统 Chrome 的独立临时环境,其他环境可设置 `CHROME_PATH` 指向 Chrome;也可使用安装了 Playwright Chromium 的环境。
测试覆盖真实 Canvas 点击/拖动、出牌/弃牌、刷新恢复、小丑重排、消耗品、商店/卡包和跳盲。测试中的定向 UI 夹具用于覆盖指定分支,不是自然通关证据;自然通关由 `tests/balance.test.ts` 另行验证。截图写入 `artifacts/`。
## 当前边界
- 需要 WebGL;不可用时显示明确提示。桌面横屏优先,手机横屏可玩,竖屏提示旋转。
- 已验证 Chrome 软件 WebGL 与多个视口;Safari、真实手机触摸和硬件帧率尚未验收。
- 固定种子贪心玩家能正常胜败,不代表全部流派已经平衡;目前对子成长流更容易形成稳定组合。
- 不包含完整原作卡池、挑战/解锁系统、云存档和无尽大数模式。
详见 [验收记录](docs/verification.md) 和 [资源来源](docs/assets.md)。

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
artifacts/desktop-1280.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 755 KiB

BIN
artifacts/joker-table.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 309 KiB

BIN
artifacts/playing.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

BIN
artifacts/shop.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

6
docs/assets.md Normal file
View File

@@ -0,0 +1,6 @@
# 资源约定与来源
- 所有扑克花色、牌背、人头牌、小丑、行星与塔罗插画由本项目 Canvas 绘图代码生成,位置 src/render/art.ts,无原作拆包或截图裁切。
- 音效使用 Web Audio 振荡器合成,位置 src/platform/audio.ts。
- 字体使用设备自带 Georgia、Courier New 及中文宋体/黑体回退,无外部字体或网络素材依赖。
- 首版无需 public/assets 文件夹;图像纹理在初始化时生成并复用,资源增加时更新此记录。

130
docs/implementation-plan.md Normal file
View File

@@ -0,0 +1,130 @@
# Web 小丑牌实现方案
日期:2026-09-21。状态:用户已授权,首版已实现。实际规则及范围以 rules.md 为准,验收情况见 verification.md。
## 1. 产品目标与取舍
实现可在浏览器直接游玩的单人扑克构筑游戏。重点是构筑选择、计分链与完整对局,其次是贴近参考图的复古牌桌体验。第一版使用中文,桌面横屏优先,兼容平板及手机横屏;离线本地对局,无账号和服务器依赖。
推荐 TypeScript + Vite + PixiJS 8,使用 HTML Canvas 承载 WebGL 渲染。Canvas 是呈现载体,不限定必须使用 Canvas 2D API。锁定实际安装的小版本;第一版不依赖实验性 Canvas 2D 回退或 WebGPU。
Cocos Creator 可以发布 Web,也适合后续小游戏平台,但目前没有现成场景、Prefab 或跨平台交付需求。此阶段增加编辑器、资源导入和构建链的收益有限。若确认主要目标变成微信/抖音小游戏,再选择 Cocos;独立规则模块可以复用,表现层仍需重做。
不把完整原作内容量作为第一版承诺。规则采用可追踪的 Balatro 基础规则参考,牌面和音效制作独立资产。第一版限制卡池,但包含通关、失败和存档,不停留在可点卡牌的展示页。
## 2. 图片观察与视觉方案
参考图约为 16:9。左侧约四分之一为状态栏,右侧是大面积绿色牌桌;上方放小丑,下方是手牌,中部留给出牌、提示和计分。右上是消耗品,右下是牌堆。
- 深墨绿底色与缓慢流动的同色纹理,低对比,不抢牌面。
- 深灰状态板、偏暖白纸牌、蓝色筹码与红色倍率;金黄用于金钱和奖励。
- 卡牌有粗边、轻微歪斜、短硬阴影、像素插画和扇形重叠,像桌上的实体纸牌。
- 英文数字可用像素字体;中文优先保证辨认,使用许可明确的点阵风格字体,避免极细字形。
- 微弱扫描线、暗角、少量色差作为可关闭的全屏效果。正文和点数保持清晰,不照搬截图的模糊感。
- 悬停轻抬与倾斜、选中上移、出牌滑向中央、逐张触发弹跳、倍率脉冲、过关盖章。
- 点击、发牌、加筹码、加倍率、乘倍率使用不同短音效;触发链越长,音高与节奏有递进。
设计坐标建议 1440×810,按窗口等比布局并重新分配边距,不拉伸卡牌。小屏横屏收紧状态栏;竖屏先提供明确横屏提示。鼠标悬停信息在触屏改为长按;拖拽设置阈值,避免选牌误触。键盘可选牌、出牌、弃牌和取消选择。
美术交付先做一套完整牌桌、扑克花色和人头牌,再制作首批独立小丑插画及牌背。不能仅用 emoji 或文字方框替代最终卡面。最终资源统一调色板、描边和像素密度。
## 3. 核心玩法理解
基本循环:选择盲注 → 抽牌 → 选 1–5 张出牌或弃牌 → 按顺序触发效果并累计分数 → 达标结算或次数耗尽失败 → 商店构筑 → 下一盲注。
每个 Ante 包含小盲、大盲、Boss;传统通关目标为 Ante 8 的最终 Boss。Boss 改变局内约束,小丑改变计分、经济或规则,塔罗修改牌组。这些主结构由官方 FAQ 确认。[S1]
核心公式是最终筹码乘最终倍率,但不能把所有增益先求和再相乘。社区触发指南明确区分 Boss / 出牌前效果、逐张计分牌、留手效果、小丑独立效果等阶段;同阶段的顺序也是策略的一部分。[S2]
实现要区分四个概念:选出的牌、判定的主要牌型、牌型包含关系、真正参与计分的牌。例如“包含对子”的效果不应只在最终牌型名称恰好为对子时生效。
牌型引擎覆盖高牌、对子、两对、三条、顺子、同花、葫芦、四条、同花顺,以及改牌后可出现的五条、同花葫芦、同花五条。皇家同花顺作为同花顺的展示变体,不独立建升级账本。牌型优先级不随升级后的潜在分数改变。[S3]
图片中的 6/6 小丑和 10/10 手牌是局内状态,来源无法仅靠图片确定。第一版拟采用基础配置:52 张牌、8 张手牌、4 次出牌、3 次弃牌、5 个小丑槽、2 个消耗品槽;这些值在规则冻结阶段逐项核实,统一配置,不散落在 UI 内。
## 4. 首版内容边界
以下数量是项目范围建议,不声称是原作内容清单。
| 系统 | 第一版目标 | 后续扩展 |
| --- | --- | --- |
| 对局 | 1 个基础牌组、1 个难度、8 个 Ante | 多牌组、多难度、无尽 |
| 扑克牌 | 52 张初始牌、复制/删除/改点数与花色 | 更多特殊牌规则 |
| 牌型 | 12 类计分与升级,皇家同花顺展示变体 | 特殊小丑改变判型 |
| 小丑 | 24 张,覆盖筹码、加倍率、乘倍率、条件、成长、经济、重触发 | 复制效果、复杂规则改写 |
| Boss | 6 种常规机制、1 种终局机制;重复抽取规则明确 | 完整 Boss 池 |
| 消耗品 | 对应牌型的行星、9 种塔罗(新增女皇投放多倍强化) | 幻灵与更多塔罗 |
| 卡牌修饰 | 奖励、多倍、玻璃、钢铁及红色蜡封等分阶段落地 | 全部版本、蜡封与贴纸 |
| 商店 | 购买/出售/刷新、小丑与消耗品、基础补充包 | 更多优惠券与包型 |
| 跳盲 | 基础跳过与有限标签池,Boss 不可跳过 | 完整标签池 |
| 持久化 | 新开局、续局、设置、种子与规则版本 | 收集解锁、成就、挑战 |
24 张小丑必须支持至少四类有不同决策的构筑,例如对子成长、花色逐张触发、留手钢铁、经济转化。不是把同一加分效果复制 24 次。
## 5. 规则与状态架构
依赖方向:内容与纯规则 → 应用协调 → 渲染和浏览器适配。引擎输出状态与事件,渲染不得反向拥有规则。
核心模块:
- `HandEvaluator`:主要牌型、包含关系、计分牌集合;支持 A 的低位顺子与高位顺子,拒绝跨首尾非法顺子。
- `ScoringEngine`:按明确阶段计算;返回最终值及逐步 ScoreTrace。
- `EffectRegistry`:有类型的效果处理器,声明触发阶段、条件、顺序与可重触发性;不建设不必要的通用脚本语言。
- `RunState`:牌组实例、盲注、回合次数、金钱、槽位、牌型等级与历史。
- `ShopEngine`:商品池、权重、价格、刷新、购买与出售的原子更新。
- `SeededRng`:抽牌、商店、概率效果分别使用可保存的随机流。
- `SaveCodec`:版本号、规则版本、种子、随机状态、内容 ID 和实例状态;损坏存档有明确恢复提示。
流程状态:主菜单 → 盲注选择 → 发牌 → 等待操作 → 计分 → 达标结算 / 继续抽牌 / 失败 → 商店 → 下一盲注;最终 Boss 后进入胜利页。
以 `dispatch(command, state)` 的事务边界验证操作。计分时禁止重复出牌和不合法重排;结算奖励只发一次。动画使用引擎结果播放,快进、后台恢复和刷新不改变得分。
计分事件包含阶段、来源实例、目标、操作类型、前值与后值;例如“某张红桃触发 +3 Mult”。同一份事件支持动画、对局日志和测试诊断。
保留三个顺序:出牌顺序、留手顺序、小丑顺序;排序按钮不应无意改变已确认的计分事务。重触发不是递归调用整轮结算,不可重复触发出牌次数或回合奖励。
随机预览不消耗真实 RNG,也不承诺随机效果的确切最终分数;默认只显示牌型、基础筹码/倍率和确定触发条件。
分数运算集中封装,明确乘数、小数和最终取整规则,规则冻结时核对;不使用 32 位位运算储存得分。首版限制到正常 8 Ante 范围,无尽大数另行设计。
## 6. 规则研究交付
在编码前补齐 `docs/rules.md`,每条规则记录来源、适用版本、输入例子、预期输出、核实状态与本项目差异。
优先核实:牌型基础值和每级增长、包含关系、默认资源、盲注目标曲线、Boss 倍率及抽取条件、奖励/利息/刷新/卖价、补充包选择流程、弃牌补牌与牌库耗尽、失效牌与修饰牌交互、触发及取整顺序。
官方 FAQ 只足以确认主循环;社区资料提供细节但并非官方规范。本次部分 Wiki 页面正文抓取失败,搜索摘要不能替代全部边界验证。已找到的资料不等于已完成全量原作复刻研究。对争议交互采用可重复对局证据;无法核实的内容先不进入首版卡池。
## 7. 分阶段实施与退出条件
| 阶段 | 交付物 | 进入下一阶段的条件 |
| --- | --- | --- |
| A 规则冻结 | 规则表、24 张小丑候选效果清单、固定计分用例 | 首版每个效果均有触发阶段与预期例子 |
| B 纯逻辑 | 牌型、抽弃牌、计分、Boss、商店和对局状态机 | 无界面可运行固定种子流程;计分及状态边界通过 |
| C 纵向切片 | 1 个 Ante、约 6 张代表性小丑、商店、牌桌和音效 | 从开局到 Boss 的真实循环可玩,可看懂每次加分 |
| D 完整首版 | 8 Ante、完整首批卡池、消耗品、跳盲、存档 | 至少可复现一局胜利和一局失败,恢复进度正确 |
| E 打磨验收 | 成套美术、CRT、反馈、触控、性能、构建产物 | 浏览器流程验证和视觉验收完成,限制有记录 |
完整首版比普通单页卡牌演示复杂,应按以上交付门槛推进,不在规则未冻结时给固定工期承诺。
## 8. 验证方法
- 单元测试:牌型识别、A 的两种合法顺子、重复实例、主要牌型与包含关系、非计分牌、手牌等级、Boss 限制。
- 计分金样:手算预期并记录阶段;加倍率/乘倍率换位必须产生正确差异;逐张与留手重触发范围分别验证。
- 性质测试:无生成/销毁效果时卡牌守恒、实例不重复占区、非法命令不改状态、同种子同命令同结果。
- 交易测试:重复点击购买/结算不重复扣钱或发奖;槽位满和余额不足不丢商品。
- 存档测试:计分期间刷新恢复到已提交状态;随机流恢复一致;旧版本和损坏数据处理明确。
- 浏览器流程:新局、排序、拖拽、出牌、弃牌、购买、跳盲、消耗品、胜败、刷新续局;Playwright 跑关键流程。
- 视觉检查:1440×810、1280×720、平板和手机横屏;中文不溢出,提示框不出屏,点击命中跟随变换后牌面。
- 性能目标:约定桌面设备上的正常场景目标 60 FPS;低性能设备关闭后处理并降低渲染分辨率,结果以实测为准。
- 平衡测试:固定种子与脚本策略检查明显死局、数值异常和商店供给;自动策略胜率不等于人类体验,仍需人工玩不同流派。
## 9. 来源
- [S1 官方 FAQ](https://www.playbalatro.com/faq):主循环、Ante 8、Joker 与塔罗的职责。
- [S2 社区触发顺序指南](https://balatrogame.fandom.com/wiki/Guide:_Activation_Sequence):计分阶段与顺序,非官方资料。
- [S3 社区牌型页](https://balatrogame.fandom.com/wiki/Poker_Hands):本次以搜索摘要取得部分信息,正文未成功获取;完整表待核实。
- [S4 社区盲注页](https://balatrogame.fandom.com/wiki/Blinds_and_Antes):本次仅获取搜索摘要,完整数值待核实。
- [S5 PixiJS Application](https://pixijs.com/8.x/guides/components/application):Canvas、渲染器与应用初始化。
- [S6 Cocos Creator Web 发布](https://docs.cocos.com/creator/3.8/manual/en/editor/publish/publish-web.html):Cocos 的 Web 构建支持。
以上链接访问日期为 2026-09-21。依赖版本及社区规则在实施时固定,不能把网页更新自动当作项目规则变更。

45
docs/rules.md Normal file
View File

@@ -0,0 +1,45 @@
# 首版冻结规则 · velvet-v1
本作是独立内容的 Balatro 式构筑游戏,不声称完整复刻原作。基础牌型、升级数值和白注目标参考社区资料;以下具体交互以本文件及测试为准。
## 基础与牌型
- 52 张普通牌,初始 $4,手牌 8、出牌 4、弃牌 3、小丑槽 5、消耗品槽 2。每次选 1–5 张。每轮重新洗整副存活牌;轮内已打出/弃掉的牌不回抽牌堆,牌库耗尽不重洗,仍可打剩余手牌。手牌和牌库都空且未达标则失败。
- 主要牌型按强弱取最高类别,不以潜在分数选类别。只有组成牌型的牌计分。A 可作 A2345 或 TJQKA,不可绕接。相同牌面可有不同实例。
- 高牌 5×1 (+10/+1),对子 10×2 (+15/+1),两对 20×2 (+20/+1),三条 30×3 (+20/+2),顺子 30×4 (+30/+3),同花 35×4 (+15/+2),葫芦 40×4 (+25/+2),四条 60×7 (+30/+3),同花顺 100×8 (+40/+4),五条 120×12 (+35/+3),同花葫芦 140×14 (+40/+4),同花五条 160×16 (+50/+3)。括号是升级增量。
- 数字牌按点数,J/Q/K=10,A=11。包含对子定义为至少两个同点数,包含三条为至少三个;两对必须有两个不同的对子点数。
## 结算顺序
1. 判型、升级值、Boss 基础值修正,成长小丑更新。
2. 按出牌顺序处理计分牌:点数、强化、逐张小丑;红封和回声分别增加一次完整逐张触发,不递归增生触发。
3. 留手牌从左到右处理钢铁,再处理月后。
4. 小丑从左到右处理独立筹码/加倍率/乘倍率。最终向下取整筹码×倍率。
5. 玻璃牌仅在本次参与计分后各有 1/4 概率销毁一次;轮末经济与奖励各结算一次。
- 示例:K K 7 为对子,(10+10+10)×2=60;+4 倍率之后 ×2 得 360,交换这两个效果得 240。
- 基础奖励牌 +30 筹码,多倍牌 +4 倍率,玻璃牌 ×2,钢铁留手 ×1.5;只能有一种强化,红封可叠加。失效牌保留判型但不触发点数/强化/逐张效果。
## 关卡与经济
- 8 Ante 基础目标为 300/800/2000/5000/11000/20000/35000/50000,小盲 ×1,大盲 ×1.5,Boss 通常 ×2;终局 ×4。
- 六种常规 Boss:红桃失效、黑桃失效、手牌上限 -1、无弃牌、必须出 5 张、牌型基础筹码/倍率减半(向上取整);Ante 1–6 不重复,Ante 7 重用随机一种,8 为终局。
- 奖励依次 $3/$4/$5,加剩余出牌次数,加结算前余额利息 floor(金钱/5) 上限 $5,加经济小丑收入。
- 商店 3 张商品、2 个包;刷新起价 $5,每次加 $1,只刷新商品。卖价 floor(原价/2),最低 $1。已持有小丑不再次出现在普通商店。
- 购买必须同时满足余额/槽位/当前阶段;失败不修改任何状态。
- 自定义跳盲标签:小盲跳过后下次商店刷新免费一次;大盲跳过后击败 Boss 获得 $15。Boss 不可跳过。
- 本作小丑均为独立命名与平衡设计,具体效果文本与实现统一定义于 src/content/catalog.ts。
- 行星:每种牌型升级 1 级,特殊牌型出现前不入随机商品池。
- 九种塔罗:最多 3 张转红桃;最多 3 张转黑桃;最多 2 张点数 +1(A→2);复制 1 张;销毁最多 2 张(牌组保留至少 1 张);强化 1 张玻璃;强化 1 张钢铁;强化 1 张奖励并加红封;最多 2 张强化为多倍牌。相比初始方案增加女皇,确保多倍强化在正常游戏中可获取。
- 消耗品仅可在等待出牌时使用;行星无需选牌,其余需按说明选牌。包内选 1 张加入库存,可以放弃;不替代已满槽位。
- 牌组耗尽或删牌后,如果无法满足最低出牌张数,且剩余弃牌/抽牌与复制消耗品都无法恢复到所需张数,则立即失败,避免卡在无法操作的局面。
## 存档与随机
- 每个成功命令保存已提交状态,动画只播放日志;刷新不会再次结算。
- 种子分别派生抽牌、商店、效果随机流,动画和预览不消耗随机数。
- 初始存档版本 1,不兼容或损坏存档需提示并允许新开局;不静默覆盖。
## 来源与适用范围
- https://www.playbalatro.com/faq (主循环,已查阅)
- https://balatrogame.fandom.com/wiki/Guide:_Activation_Sequence (社区触发阶段,已查阅;首版不含完整原作例外)
- https://balatrogame.fandom.com/wiki/Poker_Hands (牌型资料,部分经搜索摘录;本表作为本作明确规则)
- https://balatrogame.fandom.com/wiki/Planet_Cards (升级资料;本表作为本作明确规则)
- https://balatrogame.fandom.com/wiki/Blinds_and_Antes (白注基础曲线,已取得表格)
检索日期 2026-09-21。经济、小丑、Boss 池、跳过奖励、商店供给及塔罗范围均按上述首版规则实现,不宣称逐项与原作一致。

58
docs/verification.md Normal file
View File

@@ -0,0 +1,58 @@
# 首版验收记录
日期:2026-09-21。规则版本:velvet-v1。
## 已完成
- TypeScript 静态检查与 Vite 生产构建通过。
- 80 个规则/状态/存档用例通过,加 1 个固定种子对局测试,共 81 个测试。
- 牌型测试覆盖 12 类牌型、A 高低顺子与非法绕接、牌型包含关系、计分牌顺序。
- 计分金样覆盖加倍率与乘倍率顺序、红封与回声叠加、留手钢铁和月后、Boss 失效与钝刃、成长与牌型升级、经济先后顺序。
- 事务覆盖重复购买、重复领奖、槽位已满、非法目标、卡包选择、跳盲标签、牌库守恒、无法出牌的死局、存档校验与恢复。
- Chrome 浏览器交互通过:Canvas 选牌、拖拽、出牌、弃牌、刷新恢复、图鉴、小丑调序、行星使用、领奖、购买、刷新、开包、跳盲。
- Chrome 模拟触屏通过:点击选牌、长按查看手牌、触摸拖动排序。
- 1440×900、1280×720、932×430 视口截图检查;左侧状态与操作区域无溢出。
- 浏览器测试最终运行无页面脚本错误。
## 自然规则流程检查
使用 `tests/balance.test.ts` 中的有限贪心策略,运行种子 VELVET-1 到 VELVET-16。没有修改金钱、牌面、次数或等级,仅通过正常命令操作。
- 11 局通关,5 局失败。
- VELVET-1 完成全部 24 个盲注,最高单手 72,000。
- VELVET-5 在 Ante 4 失败;VELVET-12 到达最终 Boss 后失败。
- 每次成功命令后检查存档结构和实例唯一性。
这只证明正常规则下有可复现的胜败流程,不是人类胜率估计。贪心策略偏好对子与成长组合,不能据此宣称留手、同花等所有流派已平衡。
## 界面截图
- `artifacts/blind-selection.png`:正常新局盲注选择。
- `artifacts/playing.png`:正常新局出牌界面。
- `artifacts/joker-table.png`:固定 UI 夹具,用来验证小丑重排和行星效果;牌面经过测试设置,不是自然进程截图。
- `artifacts/shop.png`:该 UI 夹具的商店流程。
- `artifacts/desktop-1280.png` / `artifacts/mobile-landscape.png`:相同夹具的尺寸验收。
所有截图均来自实际运行的浏览器,没有绘图替代或后期修改。
## 环境与限制
- Node.js 22.15.1;PixiJS 8.6.6;Vite 6.1.0;TypeScript 5.7.3。
- 浏览器自动化使用 macOS 系统 Chrome 的独立临时上下文和 SwiftShader 软件 WebGL,不读取个人浏览器资料。
- Headless Chrome 默认环境未提供可用 WebGL,测试显式启用软件 WebGL;产品在 WebGL 不可用时显示启动提示并保留已有存档。
- 未测真实手机、Safari、Edge 或硬件帧率;60 FPS 仍为目标,不作为已验证结论。
- 手机横屏使用等比缩放;文字和按钮比桌面更紧凑。竖屏提示旋转。
- 本地存档按浏览器来源区分;未实现云同步或安装型离线缓存。
- 首版未实现原作全部卡池、挑战解锁、无尽大数模式。
## 运行命令
```sh
npm run build
npm test
npm run format:check
# 先启动 npm run dev,再运行:
npm run test:browser
```
浏览器测试采用专用临时上下文,不修改实际预览面板里的用户对局。

18
index.html Normal file
View File

@@ -0,0 +1,18 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover" />
<meta name="theme-color" content="#112e28" />
<meta name="description" content="天鹅绒小丑 · 一场关于运气与算计的纸牌游戏" />
<link
rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='12' fill='%231b4234'/%3E%3Ctext x='32' y='49' text-anchor='middle' font-size='52' fill='%23dbc084'%3E♠%3C/text%3E%3C/svg%3E"
/>
<title>天鹅绒小丑 · VELVET JOKER</title>
</head>
<body>
<main id="app"></main>
<script type="module" src="/src/app/main.ts"></script>
</body>
</html>

1691
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "velvet-joker",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "tsc --noEmit && vite build",
"test": "vitest run",
"test:browser": "node tests/browser.mjs",
"format": "prettier --write src tests *.json *.ts index.html",
"format:check": "prettier --check src tests *.json *.ts index.html",
"preview": "vite preview --host 0.0.0.0"
},
"dependencies": {
"pixi.js": "8.6.6"
},
"devDependencies": {
"@playwright/test": "1.51.1",
"prettier": "3.5.3",
"typescript": "5.7.3",
"vite": "6.1.0",
"vitest": "3.0.5"
}
}

451
src/app/main.ts Normal file
View File

@@ -0,0 +1,451 @@
import './style.css';
import { Board } from '../render/board';
import { createRun, dispatch, target, cardName } from '../core/run';
import { evaluate } from '../core/poker';
import { HANDS, JOKERS, TAROTS, BOSSES, jokerDef } from '../content/catalog';
import { load, save, readSetting, writeSetting } from '../platform/storage';
import { sound } from '../platform/audio';
import type { Command, Item, Run, Score } from '../core/types';
const root = document.querySelector<HTMLDivElement>('#app')!;
root.innerHTML = `<div id="fit"><div id="game"><div class="felt"></div><div class="table-grain"></div><div id="canvas"></div><div id="ui"></div><div id="tooltip" role="tooltip" hidden></div><div id="toast" role="status" aria-live="polite"></div><div id="modal"></div><div class="vignette"></div><div class="scanlines"></div></div></div><div class="rotate">↻<strong>横过来,牌桌更宽敞</strong><span>请横屏游玩天鹅绒小丑</span></div>`;
const ui = document.querySelector<HTMLElement>('#ui')!,
modal = document.querySelector<HTMLElement>('#modal')!,
tooltip = document.querySelector<HTMLElement>('#tooltip')!,
game = document.querySelector<HTMLElement>('#game')!;
const loaded = load();
let run: Run = loaded.run || createRun(makeSeed()),
selected: number[] = [],
busy = false,
modalOpen = false,
speed = [1, 2, 4].includes(Number(readSetting('velvet-speed')))
? Number(readSetting('velvet-speed'))
: 1;
let crt = readSetting('velvet-crt') !== 'off';
game.classList.toggle('no-crt', !crt);
const board = new Board();
let toastTimer: ReturnType<typeof setTimeout>;
function makeSeed() {
return crypto.getRandomValues(new Uint32Array(1))[0].toString(36).toUpperCase().slice(0, 8);
}
const esc = (s: unknown) =>
String(s).replace(
/[&<>"']/g,
(c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]!,
);
const fmt = (n: number) =>
n >= 1e9 ? n.toExponential(2) : n.toLocaleString('en-US', { maximumFractionDigits: 1 });
const btn = (label: string, action: string, cls = '', disabled = false, extra = '') =>
`<button class="button ${cls}" data-action="${action}" ${disabled ? 'disabled' : ''} ${extra}>${label}</button>`;
function resize() {
const scale = Math.min(innerWidth / 1440, innerHeight / 900);
game.style.transform = `scale(${scale})`;
document.querySelector<HTMLElement>('#fit')!.style.cssText =
`width:${1440 * scale}px;height:${900 * scale}px`;
}
window.addEventListener('resize', resize);
resize();
function toast(s: string) {
const e = document.querySelector<HTMLElement>('#toast')!;
e.textContent = s;
e.classList.add('visible');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => e.classList.remove('visible'), 3400);
}
function persist() {
if (!save(run)) toast('浏览器存储不可用:当前对局尚未保存');
}
function itemName(i: Item) {
return i.kind === 'joker'
? JOKERS[i.ref].name
: i.kind === 'planet'
? HANDS[i.ref][5]
: i.kind === 'tarot'
? TAROTS[i.ref].name
: i.ref === 0
? '小丑补充包'
: '天象补充包';
}
function itemDesc(i: Item) {
return i.kind === 'joker'
? JOKERS[i.ref].desc
: i.kind === 'planet'
? `${HANDS[i.ref][0]}升级 1 级:+${HANDS[i.ref][3]} 筹码、+${HANDS[i.ref][4]} 倍率。`
: i.kind === 'tarot'
? TAROTS[i.ref].desc
: '打开 3 张牌,选择 1 张加入库存;可以放弃。';
}
function blindName(b = run.blind) {
return b === 0 ? '小盲注' : b === 1 ? '大盲注' : BOSSES[run.bosses[run.ante - 1]].name;
}
function preview() {
if (!selected.length) return null;
const cards = selected.map((id) => run.cards.find((c) => c.id === id)).filter((c) => !!c);
return cards.length ? evaluate(cards) : null;
}
function render() {
selected = selected.filter((id) => run.hand.includes(id));
const p = preview(),
h = p ? HANDS[p.hand] : null,
lv = p ? run.levels[p.hand] - 1 : 0;
const chips = h ? h[1] + h[3] * lv : run.last?.chips || 0,
mult = h ? h[2] + h[4] * lv : run.last?.mult || 0;
const boss = run.blind === 2 ? BOSSES[run.bosses[run.ante - 1]] : null;
ui.innerHTML = `<header><a class="brand" href="#" data-action="about"><span class="brand-mark">♠</span><span>VELVET <i>JOKER</i><small>天 鹅 绒 小 丑</small></span></a><div class="header-center"><span class="tiny-star">✦</span> A LITTLE LUCK. A LITTLE MADNESS. <span class="tiny-star">✦</span></div><nav><button data-action="help">玩法指南 <span>?</span></button><button data-action="collection">牌型图鉴 <span>▦</span></button><button data-action="settings">设置 <span>⚙</span></button></nav></header>
<aside class="sidebar">
<div class="ante-label"><span>THE CURRENT RUN</span><span>白注 · 基础牌组</span></div>
<section class="blind-panel"><div class="blind-head"><span class="round-dot"></span>${esc(blindName())}<span class="mini">${run.blind === 2 ? 'BOSS' : 'BLIND ' + (run.blind + 1)}</span></div>
<div class="blind-target"><span class="blind-coin">${run.blind === 2 ? '♛' : run.blind === 1 ? '♠' : '♣'}</span><div><label>目标分数</label><strong>${fmt(run.target)}</strong><small>过关奖励 <b>${'$'.repeat(3 + run.blind)}</b></small></div></div>
<div class="blind-note">${boss ? esc(boss.desc) : '打出你的好牌,让好运翻倍。'}</div></section>
<section class="score-panel"><label>本轮得分 <span>ROUND SCORE</span></label><strong id="round-score">${fmt(run.score)}</strong><div class="progress-track"><i style="width:${Math.min(100, (run.score / run.target) * 100)}%"></i></div><div class="score-goal">${Math.min(100, Math.floor((run.score / run.target) * 100))}%<span>目标 ${fmt(run.target)}</span></div></section>
<section class="hand-panel"><div class="hand-title" id="hand-title">${esc(p ? p.name : run.last?.name || '等待好牌')}<small>${p ? 'Lv.' + (lv + 1) : 'POKER HAND'}</small></div><div class="equation"><div class="chips"><label>筹码</label><strong id="chips">${fmt(chips)}</strong></div><span>×</span><div class="mult"><label>倍率</label><strong id="mult">${fmt(mult)}</strong></div></div><p id="equation-note">${p ? '牌型基础值 · 效果将在出牌后触发' : '筹码 × 倍率 = 本次得分'}</p></section>
<div class="counters"><div><label>剩余出牌</label><strong class="blue">${run.hands}</strong></div><div><label>剩余弃牌</label><strong class="red">${run.discards}</strong></div></div>
<div class="wallet"><span>可用资金 <small>BANK</small></span><strong>$${run.money}</strong></div>
<div class="rounds"><div><label>底注 ANTE</label><strong>${run.ante}<small> / 8</small></strong></div><div><label>回合 ROUND</label><strong>${String(run.round).padStart(2, '0')}</strong></div></div>
<button class="run-info" data-action="runinfo">本局记录 <span>↗</span></button>
</aside>
<div class="inventory-help">${run.jokers.length ? '拖动小丑调整触发顺序 · 点击查看详情' : '等待你的第一位小丑 · 在商店组建阵容'}</div>
<div class="table-content" id="table-content">${center()}</div>
${run.phase === 'play' ? `<div class="hand-actions">${btn('出牌 <span>↵</span>', 'play', 'blue-button', busy || !selected.length)}<div class="sort-control"><span>整理手牌</span><button data-action="sort-rank" ${busy ? 'disabled' : ''}>点数</button><button data-action="sort-suit" ${busy ? 'disabled' : ''}>花色</button></div>${btn('弃牌 <span>D</span>', 'discard', 'red-button', busy || !selected.length || run.discards === 0)}</div>` : ''}
<footer><span><i class="live-dot"></i> ${busy ? '正在结算' : '本地对局 · 自动保存'} <b>种子 ${esc(run.seed)}</b></span><span>拖动排序 <em>·</em> 1–8 选牌 <em>·</em> ENTER 出牌 <em>·</em> D 弃牌</span><button data-action="sound">${sound.enabled ? '♪ 声音开启' : '♪ 声音关闭'}</button></footer>
<div class="sr-only" aria-label="手牌操作">${run.phase === 'play' ? run.hand.map((id) => `<button data-action="select" data-id="${id}" aria-pressed="${selected.includes(id)}" ${busy ? 'disabled' : ''}>选择 ${esc(cardName(run.cards.find((c) => c.id === id)!))}</button>`).join('') : ''}</div>`;
board.draw(run, selected, busy);
}
function center() {
if (busy)
return `<div class="scoring-caption"><span class="eyebrow">THE CARDS ARE SPEAKING</span><h2 id="scoring-source">开始计分</h2><p id="scoring-label">好戏,现在上演。</p></div>`;
if (run.phase === 'play')
return `<div class="table-idle"><div class="table-seal">V<span>J</span></div><span class="eyebrow">MAKE YOUR NEXT MOVE</span><h2>${run.last ? `上手得分 <b>${fmt(run.last.total)}</b>` : '好牌,也需要一点巧思。'}</h2><p>${selected.length ? '已选 ' + selected.length + ' 张 · 可以拖动调整计分顺序' : '选择 1–5 张牌出牌,或弃牌寻找更好的组合'}</p></div>`;
if (run.phase === 'blind')
return `<div class="section-heading"><div><span class="eyebrow">ANTE ${String(run.ante).padStart(2, '0')} / 08</span><h2>下一场,好戏。</h2></div><span>选择挑战,或留待下一次机会。</span></div><div class="blind-options">${[0, 1, 2].map((b) => `<article class="blind-option ${b === run.blind ? 'active' : ''} ${b < run.blind ? 'completed' : ''}"><div class="option-meta">${b === 0 ? 'SMALL BLIND' : b === 1 ? 'BIG BLIND' : 'BOSS BLIND'}<span>${b < run.blind ? '已结束' : b === run.blind ? '当前挑战' : '待解锁'}</span></div><div class="token token-${b}">${b === 0 ? '♣' : b === 1 ? '♠' : '♛'}</div><h3>${esc(blindName(b))}</h3><p class="target-text">${fmt(target(run, b))}<small>分</small></p><p class="option-desc">${b === 2 ? esc(BOSSES[run.bosses[run.ante - 1]].desc) : '奖励 ' + '$'.repeat(3 + b) + ' · 剩余出牌奖励'}</p>${b === run.blind ? btn('接受挑战 →', 'enter', 'gold-button') : '<div class="locked-label">' + (b < run.blind ? '— 已经过场 —' : '— 等待入场 —') + '</div>'}${b === run.blind && b !== 2 ? `<button class="skip" data-action="skip">跳过 · ${b === 0 ? '下次免费刷新' : '击败 Boss 后 +$15'}</button>` : ''}</article>`).join('')}</div>`;
if (run.phase === 'shop' || run.phase === 'pack') {
const pack = run.phase === 'pack',
items = pack ? run.packChoices : run.shop;
return `<div class="section-heading"><div><span class="eyebrow">${pack ? 'A LITTLE SURPRISE' : 'THE CURIOSITY SHOP'}</span><h2>${pack ? '挑一张,带走好运。' : '好戏背后的秘密。'}</h2></div><span>${pack ? '选择 1 张 · 需要空闲槽位' : '小丑从左向右触发 · 排列也是策略'}</span></div><div class="shop-labels">${items.map((i, n) => `<div style="left:${510 + n * 210 - 345 - 90}px;width:180px"><strong>${esc(itemName(i))}</strong><small>${i.kind === 'joker' ? JOKERS[i.ref].rarity : i.kind === 'planet' ? '牌型升级' : '塔罗牌'}</small>${btn(pack ? '选择此牌' : `购买 $${i.price}`, pack ? 'take' : 'buy', 'shop-buy', !pack && run.money < i.price, `data-id="${i.uid}"`)}</div>`).join('')}${pack ? '' : run.packs.map((i, n) => `<div class="pack-label" style="left:${1150 + n * 141 - 345 - 65}px;width:130px"><strong>${esc(itemName(i))}</strong><small>3 选 1</small>${btn(`打开 $${i.price}`, 'buy', 'shop-buy', run.money < i.price, `data-id="${i.uid}"`)}</div>`).join('')}</div><div class="shop-bottom">${pack ? btn('放弃本次选择', 'closePack', 'quiet-button') : btn(`刷新商品 ${run.freeReroll ? '免费' : '$' + run.reroll}`, 'reroll', 'quiet-button', !run.freeReroll && run.money < run.reroll)}<p>${pack ? '已打开的卡包不会退还费用' : '攒下每 $5,过关赚取 $1 利息,最多 $5'}</p>${pack ? '' : btn('下一盲注 →', 'next', 'gold-button')}</div>`;
}
if (run.phase === 'reward')
return `<div class="result-panel"><span class="eyebrow">A ROUND WELL PLAYED</span><h2>漂亮,过关。</h2><p>本轮收获 <strong class="gold">$${run.reward!.total}</strong></p><div class="receipt"><span>盲注奖励 <b>$${run.reward!.base}</b></span><span>剩余出牌 <b>$${run.reward!.hands}</b></span><span>利息收入 <b>$${run.reward!.interest}</b></span>${run.reward!.bonus ? `<span>额外收入 <b>$${run.reward!.bonus}</b></span>` : ''}</div>${btn('前往商店 →', 'cashout', 'gold-button')}</div>`;
return `<div class="result-panel end-panel"><span class="eyebrow">${run.phase === 'won' ? 'THE FINAL CURTAIN' : 'EVERY END IS A NEW HAND'}</span><h2>${run.phase === 'won' ? '今夜,你是主角。' : '好戏,暂时落幕。'}</h2><p>${run.phase === 'won' ? '你征服了全部 8 个底注。' : '没有足够的分数越过这次盲注。'}</p><div class="end-stats"><span>最高单手<b>${fmt(run.best)}</b></span><span>累计得分<b>${fmt(run.totalScore)}</b></span><span>抵达底注<b>${run.ante} / 8</b></span></div>${btn('再开一局 →', 'new', 'gold-button')}</div>`;
}
async function command(c: Command) {
if (busy) return;
const previous = run,
result = dispatch(run, c);
if (result.error) {
toast(result.error);
sound.play();
return;
}
run = result.state;
persist();
closeModal();
if (c.type === 'play') {
selected = [];
busy = true;
render();
const visual = { ...run, phase: 'play' as const };
board.draw(visual, [], true);
board.showPlayed(run.last!.played);
await animateScore(run.last!, previous.score);
busy = false;
board.clearHighlight();
render();
if (run.phase === 'reward' || run.phase === 'won') sound.play('win');
} else {
if (['discard', 'use', 'enter'].includes(c.type)) selected = [];
sound.play(c.type === 'enter' || c.type === 'discard' ? 'card' : 'click');
render();
if (c.type === 'use') toast('消耗品已生效');
}
}
async function animateScore(s: Score, prior: number) {
document.querySelector('#round-score')!.textContent = fmt(prior);
const delay = Math.min(260, 3500 / Math.max(1, s.trace.length)) / speed;
for (let i = 0; i < s.trace.length; i++) {
const t = s.trace[i];
document.querySelector('#chips')!.textContent = fmt(t.chips);
document.querySelector('#mult')!.textContent = fmt(t.mult);
document.querySelector('#hand-title')!.textContent = s.name;
document.querySelector('#scoring-source')!.textContent = t.source;
document.querySelector('#scoring-label')!.textContent = t.label;
board.highlight(t.cardId, t.jokerUid);
sound.play('score', i);
await new Promise((r) => setTimeout(r, delay));
}
document.querySelector('#round-score')!.textContent = fmt(prior + s.total);
document.querySelector('#scoring-source')!.textContent = '+' + fmt(s.total);
document.querySelector('#scoring-label')!.textContent = '筹码落定';
await new Promise((r) => setTimeout(r, 650 / speed));
}
function select(id: number) {
if (busy || run.phase !== 'play') return;
if (selected.includes(id)) selected = selected.filter((x) => x !== id);
else if (selected.length < 5) selected.push(id);
else {
toast('每次最多选择 5 张牌');
return;
}
selected = run.hand.filter((id) => selected.includes(id));
sound.play('card');
render();
}
let focusBefore: HTMLElement | null = null;
function openModal(title: string, body: string, wide = false) {
focusBefore = document.activeElement as HTMLElement;
modalOpen = true;
tooltip.hidden = true;
modal.innerHTML = `<div class="modal-backdrop"><section class="dialog ${wide ? 'wide' : ''}" role="dialog" aria-modal="true" aria-label="${esc(title)}"><button class="dialog-close" data-action="close" aria-label="关闭">×</button><span class="eyebrow">VELVET JOKER</span><h2>${esc(title)}</h2>${body}</section></div>`;
modal.querySelector<HTMLButtonElement>('button')?.focus();
}
function closeModal() {
if (!modalOpen) return;
modalOpen = false;
modal.innerHTML = '';
focusBefore?.focus();
}
function inspect(kind: 'joker' | 'item' | 'card', id: number) {
if (busy) return;
if (kind === 'joker') {
const j = run.jokers.find((j) => j.uid === id);
if (!j) return;
const d = jokerDef(j.id),
index = run.jokers.indexOf(j);
openModal(
d.name,
`<div class="detail-tag">${d.en} · ${d.rarity}</div><p class="detail-description">${d.desc}</p>${j.id === 'growth' ? `<p>当前积累:+${j.value} 倍率</p>` : ''}<div class="dialog-actions">${btn('← 左移', 'move-left', 'quiet-button', index === 0, `data-id="${id}"`)}${btn('右移 →', 'move-right', 'quiet-button', index === run.jokers.length - 1, `data-id="${id}"`)}${btn(`出售 $${Math.floor(d.price / 2)}`, 'sell', 'red-button', !['play', 'shop', 'blind'].includes(run.phase), `data-id="${id}"`)}</div>`,
);
return;
}
if (kind === 'card') {
const c = run.cards.find((c) => c.id === id)!;
openModal(
cardName(c),
`<p>基础筹码 ${c.rank === 14 ? 11 : Math.min(10, c.rank)}${c.enh ? ' · 强化 ' + c.enh : ''}${c.seal ? ' · 红色蜡封' : ''}</p>`,
);
return;
}
const inv = run.consumables.find((i) => i.uid === id),
it = inv || [...run.shop, ...run.packs, ...run.packChoices].find((i) => i.uid === id);
if (!it) return;
openModal(
itemName(it),
`<p class="detail-description">${esc(itemDesc(it))}</p>${inv ? `<p class="muted">${it.kind === 'planet' ? '无需选牌。' : `先选中目标手牌;当前已选 ${selected.length} 张。`}消耗品在出牌阶段使用。</p><div class="dialog-actions">${btn('使用', 'use', 'gold-button', run.phase !== 'play', `data-id="${id}"`)}${btn('出售 $1', 'sell', 'quiet-button', !['play', 'shop', 'blind'].includes(run.phase), `data-id="${id}"`)}</div>` : `<div class="dialog-actions">${btn(run.phase === 'pack' ? '选择此牌' : `购买 $${it.price}`, run.phase === 'pack' ? 'take' : 'buy', 'gold-button', run.phase !== 'pack' && run.money < it.price, `data-id="${id}"`)}</div>`}`,
);
}
function collection() {
openModal(
'牌型图鉴',
`<p class="muted">按牌型优先级判定;只有组成牌型的牌参与计分。括号为每次升级增量。</p><table class="hand-table"><thead><tr><th>牌型</th><th>等级</th><th>筹码</th><th>倍率</th><th>本局次数</th></tr></thead><tbody>${[
...HANDS,
]
.map(
(h, i) =>
`<tr><td>${h[0]}</td><td>Lv.${run.levels[i]}</td><td class="blue">${h[1] + (run.levels[i] - 1) * h[3]} <small>(+${h[3]})</small></td><td class="red">${h[2] + (run.levels[i] - 1) * h[4]} <small>(+${h[4]})</small></td><td>${run.counts[i]}</td></tr>`,
)
.reverse()
.join('')}</tbody></table>`,
true,
);
}
function settings() {
openModal(
'牌桌设置',
`<div class="setting"><span>复古屏幕<small>扫描线与暗角</small></span>${btn(crt ? '开启' : '关闭', 'crt', 'quiet-button')}</div><div class="setting"><span>游戏音效<small>发牌与递进计分反馈</small></span>${btn(sound.enabled ? '开启' : '关闭', 'sound', 'quiet-button')}</div><div class="setting"><span>结算速度</span>${btn(speed + '×', 'speed', 'quiet-button')}</div><div class="dialog-actions">${btn('新开一局', 'new', 'red-button')}${btn('继续游戏', 'close', 'gold-button')}</div>`,
);
}
function newDialog() {
openModal(
'新的一局,新的可能。',
`<p class="muted">开始后将替换当前对局。输入相同种子可重现初始牌序与商品。</p><label class="seed-label" for="seed">对局种子</label><input id="seed" maxlength="32" value="${makeSeed()}" autocomplete="off"><div class="dialog-actions">${btn('取消', 'close', 'quiet-button')}${btn('开始新对局 →', 'confirm-new', 'gold-button')}</div>`,
);
}
root.addEventListener('click', (e) => {
const el = (e.target as HTMLElement).closest<HTMLElement>('[data-action]');
if (!el) return;
e.preventDefault();
if (el.hasAttribute('disabled')) return;
const a = el.dataset.action!,
id = Number(el.dataset.id);
if (busy && !['sound'].includes(a)) {
toast('正在结算,请稍候');
return;
}
if (['enter', 'skip', 'cashout', 'next', 'reroll', 'closePack'].includes(a)) {
void command({ type: a } as Command);
return;
}
if (['buy', 'sell', 'take'].includes(a)) {
void command({ type: a, uid: id } as Command);
return;
}
if (a === 'play' || a === 'discard') {
void command({ type: a, ids: [...selected] });
return;
}
if (a === 'use') {
void command({ type: 'use', uid: id, ids: [...selected] });
return;
}
if (a === 'select') {
select(id);
return;
}
if (a === 'sort-rank' || a === 'sort-suit') {
const ids = [...run.hand].sort((a, b) => {
const x = run.cards.find((c) => c.id === a)!,
y = run.cards.find((c) => c.id === b)!;
return a === b
? 0
: el.dataset.action === 'sort-suit'
? x.suit - y.suit || y.rank - x.rank
: y.rank - x.rank || x.suit - y.suit;
});
selected = ids.filter((id) => selected.includes(id));
void command({ type: 'order', zone: 'hand', ids });
return;
}
if (a === 'move-left' || a === 'move-right') {
const ids = run.jokers.map((j) => j.uid),
i = ids.indexOf(id),
to = i + (a === 'move-left' ? -1 : 1);
if (to >= 0 && to < ids.length) {
[ids[i], ids[to]] = [ids[to], ids[i]];
void command({ type: 'order', zone: 'jokers', ids });
}
return;
}
switch (a) {
case 'close':
closeModal();
break;
case 'collection':
collection();
break;
case 'settings':
settings();
break;
case 'new':
newDialog();
break;
case 'confirm-new': {
const seed =
(document.querySelector<HTMLInputElement>('#seed')?.value || makeSeed()).trim() ||
makeSeed();
run = createRun(seed);
selected = [];
persist();
closeModal();
render();
break;
}
case 'sound':
sound.toggle();
if (modalOpen) settings();
render();
break;
case 'speed':
speed = speed === 1 ? 2 : speed === 2 ? 4 : 1;
writeSetting('velvet-speed', String(speed));
settings();
break;
case 'crt':
crt = !crt;
writeSetting('velvet-crt', crt ? 'on' : 'off');
game.classList.toggle('no-crt', !crt);
settings();
break;
case 'runinfo':
openModal(
'这一局的故事',
`<div class="end-stats"><span>最高单手<b>${fmt(run.best)}</b></span><span>累计得分<b>${fmt(run.totalScore)}</b></span><span>跳过盲注<b>${run.skips}</b></span></div><p class="muted">种子 ${esc(run.seed)} · 牌组 ${run.cards.length} 张 · ${run.freeReroll ? '免费刷新待用' : '无免费刷新'}${run.investment ? ' · Boss 投资奖励待领取' : ''}</p><div class="history">${run.history.length ? run.history.map((s) => `<p>${esc(s)}</p>`).join('') : '故事才刚开始。'}</div>`,
true,
);
break;
case 'help':
openModal(
'让每张牌,都有戏。',
`<ol class="guide"><li><strong>凑出牌型,越过盲注</strong><p>每次打出 1–5 张牌。筹码 × 倍率得到本次分数,累计达到目标即可过关。出牌次数用完则失败。</p></li><li><strong>弃掉平庸,留下可能</strong><p>点击选牌,拖动调整顺序。弃牌会补充手牌,但次数有限;同一轮已打出和弃掉的牌不会重新抽到。</p></li><li><strong>小丑让规则为你服务</strong><p>过关后进入商店。先加倍率,再乘倍率通常更有利。拖动上方小丑即可调整顺序,点击可查看或出售。</p></li><li><strong>用星辰与塔罗改变命运</strong><p>行星升级牌型;塔罗改造已选手牌。点击右上消耗品使用。留钱可在过关时赚取利息。</p></li><li><strong>走过八幕,击败最终 Boss</strong><p>每个底注有小盲、大盲和 Boss。普通盲注可跳过获取标签,Boss 不可跳过。</p></li></ol><div class="dialog-actions">${btn('入席吧 →', 'close', 'gold-button')}</div>`,
true,
);
break;
case 'about':
openModal(
'天鹅绒小丑',
`<p class="detail-description">A little luck.<br>A little madness.</p><p class="muted">一款独立卡面与内容的扑克构筑游戏。受 Balatro 启发,采用本作明确的规则与卡池。</p><p class="muted">24 位小丑 · 12 类牌型 · 8 幕挑战<br>原创程序绘制插画 · 全本地存档</p>`,
);
break;
}
});
document.addEventListener('keydown', (e) => {
if (modalOpen) {
if (e.key === 'Escape') {
closeModal();
return;
}
if (e.key === 'Tab') {
const nodes = [...modal.querySelectorAll<HTMLElement>('button:not(:disabled),input')];
if (!nodes.length) return;
const first = nodes[0],
last = nodes[nodes.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
return;
}
if (busy || run.phase !== 'play' || e.repeat) return;
if ((e.target as HTMLElement).tagName === 'BUTTON' && e.key === 'Enter') return;
if (/^[1-9]$/.test(e.key)) {
const id = run.hand[Number(e.key) - 1];
if (id) select(id);
}
if (e.key === 'Enter' && selected.length) {
e.preventDefault();
void command({ type: 'play', ids: [...selected] });
}
if (e.key.toLowerCase() === 'd' && selected.length)
void command({ type: 'discard', ids: [...selected] });
if (e.key === 'Escape') {
selected = [];
render();
}
});
try {
await board.init(document.querySelector<HTMLElement>('#canvas')!, {
select,
inspect,
hover(title, body, x, y) {
if (modalOpen) return;
tooltip.innerHTML = `<strong>${esc(title)}</strong><p>${esc(body)}</p>`;
tooltip.style.left = Math.min(1110, Math.max(330, x - 125)) + 'px';
tooltip.style.top = y + 'px';
tooltip.hidden = false;
},
unhover() {
tooltip.hidden = true;
},
reorder(zone, from, to) {
const ids = zone === 'hand' ? [...run.hand] : run.jokers.map((j) => j.uid),
i = ids.indexOf(from),
j = ids.indexOf(to);
if (i < 0 || j < 0) return;
ids.splice(i, 1);
ids.splice(j, 0, from);
if (zone === 'hand') selected = ids.filter((id) => selected.includes(id));
void command({ type: 'order', zone, ids });
},
});
render();
if (loaded.error) toast(loaded.error);
else if (loaded.run) toast('牌桌还在,欢迎回来。');
else persist();
} catch (error) {
console.error('Game renderer could not start', error);
openModal(
'牌桌未能启动',
`<p>浏览器未提供可用的 WebGL 渲染环境。请开启浏览器图形加速,或使用支持 WebGL 的新版 Chrome、Edge 或 Safari 后重试。</p><p class="muted">已有存档不会被修改。</p><button class="button gold-button" onclick="location.reload()">重新加载</button>`,
);
}

1206
src/app/style.css Normal file

File diff suppressed because it is too large Load Diff

288
src/content/catalog.ts Normal file
View File

@@ -0,0 +1,288 @@
export const HANDS = [
['高牌', 5, 1, 10, 1, '冥王星'],
['对子', 10, 2, 15, 1, '水星'],
['两对', 20, 2, 20, 1, '天王星'],
['三条', 30, 3, 20, 2, '金星'],
['顺子', 30, 4, 30, 3, '土星'],
['同花', 35, 4, 15, 2, '木星'],
['葫芦', 40, 4, 25, 2, '地球'],
['四条', 60, 7, 30, 3, '火星'],
['同花顺', 100, 8, 40, 4, '海王星'],
['五条', 120, 12, 35, 3, 'X 行星'],
['同花葫芦', 140, 14, 40, 4, '谷神星'],
['同花五条', 160, 16, 50, 3, '阋神星'],
] as const;
export type JokerDef = {
id: string;
name: string;
en: string;
desc: string;
price: number;
rarity: string;
color: string;
motif: number;
};
export const JOKERS: JokerDef[] = [
{
id: 'smile',
name: '微笑先生',
en: 'THE SMILER',
desc: '每次出牌 +4 倍率。',
price: 4,
rarity: '普通',
color: '#d36856',
motif: 0,
},
{
id: 'pair',
name: '双面演员',
en: 'TWO FACED',
desc: '打出的牌包含对子时,+8 倍率。',
price: 5,
rarity: '普通',
color: '#7296a0',
motif: 1,
},
{
id: 'triple',
name: '三重奏',
en: 'TRIO',
desc: '包含三条时,+20 倍率。',
price: 6,
rarity: '普通',
color: '#b69253',
motif: 2,
},
{
id: 'flush',
name: '花色诗人',
en: 'SUIT POET',
desc: '5 张同花色时,+18 倍率。',
price: 6,
rarity: '普通',
color: '#749574',
motif: 3,
},
{
id: 'straight',
name: '走钢丝',
en: 'TIGHTROPE',
desc: '包含顺子时,+100 筹码。',
price: 5,
rarity: '普通',
color: '#bb795f',
motif: 4,
},
{
id: 'chips',
name: '筹码匠',
en: 'CHIP MAKER',
desc: '每次出牌 +80 筹码。',
price: 5,
rarity: '普通',
color: '#5896aa',
motif: 5,
},
{
id: 'hearts',
name: '红心皇后',
en: 'QUEEN OF HEARTS',
desc: '每张计分红桃 +4 倍率。',
price: 6,
rarity: '普通',
color: '#bc5869',
motif: 6,
},
{
id: 'spades',
name: '夜行客',
en: 'NIGHT WALKER',
desc: '每张计分黑桃 +35 筹码。',
price: 5,
rarity: '普通',
color: '#6c779b',
motif: 7,
},
{
id: 'diamonds',
name: '钻石眼',
en: 'DIAMOND EYE',
desc: '每张计分方块赚取 $1。',
price: 6,
rarity: '罕见',
color: '#c59b62',
motif: 8,
},
{
id: 'clubs',
name: '三叶草',
en: 'CLOVER',
desc: '每张计分梅花 +4 倍率。',
price: 5,
rarity: '普通',
color: '#639176',
motif: 9,
},
{
id: 'faces',
name: '宫廷画师',
en: 'COURT PAINTER',
desc: '每张计分 J、Q、K +5 倍率。',
price: 6,
rarity: '普通',
color: '#b08b5c',
motif: 10,
},
{
id: 'aces',
name: '王牌魔术',
en: 'ACE MAGIC',
desc: '每张计分 A +30 筹码、+4 倍率。',
price: 6,
rarity: '罕见',
color: '#839aac',
motif: 11,
},
{
id: 'even',
name: '偶数哲学',
en: 'EVEN ODDS',
desc: '每张计分 2、4、6、8、10 +4 倍率。',
price: 5,
rarity: '普通',
color: '#8c9c72',
motif: 12,
},
{
id: 'odd',
name: '奇数俱乐部',
en: 'ODD SOCIETY',
desc: '每张计分 A、3、5、7、9 +35 筹码。',
price: 5,
rarity: '普通',
color: '#a67d98',
motif: 13,
},
{
id: 'echo',
name: '回声',
en: 'ENCORE',
desc: '第一张计分牌额外触发一次。',
price: 8,
rarity: '罕见',
color: '#70a5a0',
motif: 14,
},
{
id: 'moon',
name: '月后',
en: 'MOON QUEEN',
desc: '每张留在手中的 Q +13 倍率。',
price: 6,
rarity: '罕见',
color: '#8a83b0',
motif: 15,
},
{
id: 'double',
name: '双倍赌注',
en: 'DOUBLE DOWN',
desc: '打出的牌包含对子时,倍率 ×2。',
price: 9,
rarity: '稀有',
color: '#bb6656',
motif: 16,
},
{
id: 'minimal',
name: '独幕剧',
en: 'SOLO ACT',
desc: '打出不超过 3 张牌时,倍率 ×2。',
price: 8,
rarity: '罕见',
color: '#7797a0',
motif: 17,
},
{
id: 'growth',
name: '常青藤',
en: 'EVERGREEN',
desc: '每次出牌永久积累 +2 倍率;弃牌减少 1,最低 0。',
price: 7,
rarity: '罕见',
color: '#73946b',
motif: 18,
},
{
id: 'scholar',
name: '老学者',
en: 'THE SCHOLAR',
desc: '倍率增加本局该牌型已打出次数 ×3(含本次)。',
price: 7,
rarity: '罕见',
color: '#ad9570',
motif: 19,
},
{
id: 'bank',
name: '银行家',
en: 'THE BANKER',
desc: '每持有 $5,+4 倍率(结算到此刻的余额)。',
price: 7,
rarity: '罕见',
color: '#8aa579',
motif: 20,
},
{
id: 'egg',
name: '金蛋',
en: 'GOLDEN EGG',
desc: '每次过关额外赚取 $4。',
price: 5,
rarity: '普通',
color: '#ceac65',
motif: 21,
},
{
id: 'last',
name: '最后一舞',
en: 'LAST DANCE',
desc: '本轮最后一次出牌,倍率 ×3。',
price: 7,
rarity: '罕见',
color: '#a77a93',
motif: 22,
},
{
id: 'wanderer',
name: '流浪艺人',
en: 'THE WANDERER',
desc: '每跳过一个盲注,永久增加 ×0.35 倍率,初始 ×1。',
price: 8,
rarity: '稀有',
color: '#af9367',
motif: 23,
},
];
export const TAROTS = [
{ name: '恋人', desc: '选择 1–3 张牌,转为红桃。', max: 3 },
{ name: '月亮', desc: '选择 1–3 张牌,转为黑桃。', max: 3 },
{ name: '力量', desc: '选择 1–2 张牌,点数 +1(A 变为 2)。', max: 2 },
{ name: '魔术师', desc: '选择 1 张牌,复制到手牌与牌组。', max: 1 },
{ name: '倒吊人', desc: '选择 1–2 张牌,永久移除。', max: 2 },
{ name: '正义', desc: '选择 1 张牌,变成玻璃牌:计分 ×2 倍率,1/4 概率破碎。', max: 1 },
{ name: '战车', desc: '选择 1 张牌,变成钢铁牌:留手 ×1.5 倍率。', max: 1 },
{ name: '太阳', desc: '选择 1 张牌,增加 +30 筹码强化与红色蜡封(重触发)。', max: 1 },
{ name: '女皇', desc: '选择 1–2 张牌,变成多倍牌:计分时 +4 倍率。', max: 2 },
];
export const BOSSES = [
{ name: '红心禁令', en: 'THE HEART', desc: '所有红桃牌失效:仍可组成牌型,但不触发计分。' },
{ name: '黑夜帷幕', en: 'THE VEIL', desc: '所有黑桃牌失效:仍可组成牌型,但不触发计分。' },
{ name: '狭窄舞台', en: 'THE STAGE', desc: '本轮手牌上限减少 1。' },
{ name: '孤注一掷', en: 'THE GAMBLE', desc: '本轮没有弃牌次数。' },
{ name: '满员谢幕', en: 'THE CAST', desc: '每次必须打出 5 张牌。' },
{ name: '钝刃', en: 'THE FLINT', desc: '牌型基础筹码与倍率减半,向上取整。' },
{ name: '天鹅绒终幕', en: 'THE FINAL CURTAIN', desc: '最终挑战:目标分数为基础分数的 4 倍。' },
];
export const BASE_TARGETS = [300, 800, 2000, 5000, 11000, 20000, 35000, 50000];
export const jokerDef = (id: string) => JOKERS.find((j) => j.id === id)!;

41
src/core/poker.ts Normal file
View File

@@ -0,0 +1,41 @@
import { HANDS } from '../content/catalog';
import type { Card } from './types';
export function evaluate(cards: Card[]) {
if (!cards.length || cards.length > 5) throw Error('请选择 1–5 张牌');
const groups = new Map<number, Card[]>();
for (const c of cards) groups.set(c.rank, [...(groups.get(c.rank) || []), c]);
const sorted = [...groups.values()].sort((a, b) => b.length - a.length || b[0].rank - a[0].rank);
const ranks = [...groups.keys()].sort((a, b) => a - b);
const flush = cards.length === 5 && cards.every((c) => c.suit === cards[0].suit);
const straight =
ranks.length === 5 && (ranks[4] - ranks[0] === 4 || ranks.join(',') === '2,3,4,5,14');
const max = sorted[0].length,
pairs = sorted.filter((g) => g.length >= 2).length;
let hand = 0,
scoring: Card[] = [];
if (max === 5 && flush) hand = 11;
else if (max === 3 && pairs === 2 && flush) hand = 10;
else if (max === 5) hand = 9;
else if (straight && flush) hand = 8;
else if (max === 4) hand = 7;
else if (max === 3 && pairs === 2) hand = 6;
else if (flush) hand = 5;
else if (straight) hand = 4;
else if (max === 3) hand = 3;
else if (pairs === 2) hand = 2;
else if (max === 2) hand = 1;
if ([4, 5, 6, 8, 9, 10, 11].includes(hand)) scoring = cards;
else if (hand === 2) scoring = sorted.slice(0, 2).flat();
else if (hand === 0) scoring = [cards.reduce((a, b) => (a.rank >= b.rank ? a : b))];
else scoring = sorted[0];
const ids = new Set(scoring.map((c) => c.id));
return {
hand,
name: hand === 8 && ranks[0] === 10 ? '皇家同花顺' : HANDS[hand][0],
scoring: cards.filter((c) => ids.has(c.id)),
pair: max >= 2,
triple: max >= 3,
flush,
straight,
};
}

22
src/core/random.ts Normal file
View File

@@ -0,0 +1,22 @@
import type { Run } from './types';
export function hash(s: string) {
let n = 2166136261;
for (const c of s) n = Math.imul(n ^ c.charCodeAt(0), 16777619);
return n >>> 0 || 1;
}
export function random(run: Run, stream: keyof Run['rng']) {
let n = run.rng[stream];
n ^= n << 13;
n ^= n >>> 17;
n ^= n << 5;
run.rng[stream] = n >>> 0;
return (n >>> 0) / 4294967296;
}
export function shuffle<T>(run: Run, list: T[], stream: keyof Run['rng'] = 'deck') {
const a = [...list];
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(random(run, stream) * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}

349
src/core/run.ts Normal file
View File

@@ -0,0 +1,349 @@
import { BASE_TARGETS, HANDS, JOKERS, TAROTS, jokerDef } from '../content/catalog';
import { hash, random, shuffle } from './random';
import { scoreHand } from './scoring';
import type { Card, Command, Item, Run, Suit } from './types';
export function createRun(seed: string): Run {
const r: Run = {
version: 1,
rules: 'velvet-v1',
seed,
phase: 'blind',
ante: 1,
blind: 0,
round: 0,
money: 4,
score: 0,
target: 300,
hands: 4,
discards: 3,
handSize: 8,
cards: [],
hand: [],
draw: [],
spent: [],
jokers: [],
consumables: [],
levels: HANDS.map(() => 1),
counts: HANDS.map(() => 0),
bosses: [],
rng: { deck: hash(seed + 'deck'), shop: hash(seed + 'shop'), effect: hash(seed + 'effect') },
nextId: 100,
shop: [],
packs: [],
packChoices: [],
reroll: 5,
freeReroll: false,
investment: false,
skips: 0,
last: null,
reward: null,
best: 0,
totalScore: 0,
history: [],
};
for (let suit = 0; suit < 4; suit++)
for (let rank = 2; rank <= 14; rank++)
r.cards.push({ id: r.cards.length + 1, rank, suit: suit as Suit });
r.bosses = [...shuffle(r, [0, 1, 2, 3, 4, 5], 'effect'), Math.floor(random(r, 'effect') * 6), 6];
return r;
}
export function target(r: Run, blind = r.blind) {
return BASE_TARGETS[r.ante - 1] * (blind === 0 ? 1 : blind === 1 ? 1.5 : r.ante === 8 ? 4 : 2);
}
const fill = (r: Run) => {
while (r.hand.length < r.handSize && r.draw.length) r.hand.push(r.draw.pop()!);
};
function item(r: Run, kind: Item['kind'], ref: number, price: number): Item {
return { uid: r.nextId++, kind, ref, price };
}
function rollItem(r: Run, kind?: 'joker' | 'planet' | 'tarot', excluded: string[] = []): Item {
const q = random(r, 'shop');
kind ??= q < 0.58 ? 'joker' : q < 0.81 ? 'planet' : 'tarot';
if (kind === 'joker') {
const pool = JOKERS.filter(
(j) => !r.jokers.some((x) => x.id === j.id) && !excluded.includes(j.id),
);
if (pool.length) {
const j = pool[Math.floor(random(r, 'shop') * pool.length)];
return item(r, 'joker', JOKERS.indexOf(j), j.price);
}
kind = 'planet';
}
if (kind === 'planet') {
const pool = HANDS.map((_, i) => i).filter((i) => i < 9 || r.counts[i] > 0);
return item(r, 'planet', pool[Math.floor(random(r, 'shop') * pool.length)], 3);
}
return item(r, 'tarot', Math.floor(random(r, 'shop') * TAROTS.length), 3);
}
function stock(r: Run) {
r.shop = [];
for (let i = 0; i < 3; i++)
r.shop.push(
rollItem(
r,
i === 0 ? 'joker' : undefined,
r.shop.filter((s) => s.kind === 'joker').map((s) => JOKERS[s.ref].id),
),
);
}
const requirePhase = (r: Run, ...phases: Run['phase'][]) => {
if (!phases.includes(r.phase)) throw Error('当前阶段无法进行此操作');
};
function picked(r: Run, ids: number[]) {
if (
!ids.length ||
ids.length > 5 ||
new Set(ids).size !== ids.length ||
ids.some((id) => !r.hand.includes(id))
)
throw Error('请选择 1–5 张手牌');
return ids.map((id) => r.cards.find((c) => c.id === id)!);
}
function advance(r: Run) {
if (r.blind === 2) {
r.blind = 0;
r.ante++;
} else r.blind++;
r.phase = 'blind';
r.target = target(r);
r.score = 0;
r.hands = 4;
r.discards = r.blind === 2 && r.bosses[r.ante - 1] === 3 ? 0 : 3;
r.reward = null;
r.last = null;
r.hand = [];
r.draw = [];
r.spent = [];
}
function give(r: Run, i: Item) {
if (i.kind === 'joker') {
if (r.jokers.length >= 5) throw Error('小丑槽已满,请先出售一张');
r.jokers.push({ uid: i.uid, id: JOKERS[i.ref].id, value: 0 });
} else {
if (r.consumables.length >= 2) throw Error('消耗品槽已满,请先使用或出售');
r.consumables.push(i);
}
}
function apply(r: Run, c: Command) {
switch (c.type) {
case 'enter': {
requirePhase(r, 'blind');
r.phase = 'play';
r.round++;
r.score = 0;
r.hands = 4;
r.discards = r.blind === 2 && r.bosses[r.ante - 1] === 3 ? 0 : 3;
r.handSize = r.blind === 2 && r.bosses[r.ante - 1] === 2 ? 7 : 8;
r.target = target(r);
r.hand = [];
r.spent = [];
r.draw = shuffle(
r,
r.cards.map((c) => c.id),
);
r.last = null;
r.reward = null;
fill(r);
if (!r.hand.length) r.phase = 'lost';
break;
}
case 'skip':
requirePhase(r, 'blind');
if (r.blind === 2) throw Error('Boss 盲注不可跳过');
r.skips++;
if (r.blind === 0) r.freeReroll = true;
else r.investment = true;
advance(r);
break;
case 'play': {
requirePhase(r, 'play');
const cards = picked(r, c.ids);
if (r.hands <= 0) throw Error('没有出牌次数');
if (r.blind === 2 && r.bosses[r.ante - 1] === 4 && cards.length !== 5)
throw Error('这个 Boss 要求每次打出 5 张牌');
const result = scoreHand(r, cards);
r.last = result;
r.hands--;
r.score += result.total;
r.totalScore += result.total;
r.best = Math.max(r.best, result.total);
r.hand = r.hand.filter((id) => !c.ids.includes(id));
r.spent.push(...c.ids.filter((id) => r.cards.some((x) => x.id === id)));
r.history.unshift(`${result.name} · ${result.total.toLocaleString()} 分`);
r.history = r.history.slice(0, 20);
if (r.score >= r.target) {
const base = 3 + r.blind,
interest = Math.min(5, Math.floor(r.money / 5)),
bonus =
r.jokers.filter((j) => j.id === 'egg').length * 4 +
(r.blind === 2 && r.investment ? 15 : 0);
r.reward = {
base,
hands: r.hands,
interest,
bonus,
total: base + r.hands + interest + bonus,
};
r.money += r.reward.total;
if (r.blind === 2) r.investment = false;
r.phase = r.ante === 8 && r.blind === 2 ? 'won' : 'reward';
} else if (r.hands === 0 || r.hand.length + r.draw.length === 0) r.phase = 'lost';
else fill(r);
break;
}
case 'discard': {
requirePhase(r, 'play');
picked(r, c.ids);
if (r.discards <= 0) throw Error('没有弃牌次数');
r.discards--;
r.hand = r.hand.filter((id) => !c.ids.includes(id));
r.spent.push(...c.ids);
for (const j of r.jokers) if (j.id === 'growth') j.value = Math.max(0, j.value - 1);
fill(r);
if (!r.hand.length) r.phase = 'lost';
break;
}
case 'cashout':
requirePhase(r, 'reward');
r.phase = 'shop';
r.reroll = 5;
stock(r);
r.packs = [item(r, 'pack', 0, 4), item(r, 'pack', 1, 4)];
break;
case 'next':
requirePhase(r, 'shop');
advance(r);
break;
case 'reroll':
requirePhase(r, 'shop');
if (!r.freeReroll) {
if (r.money < r.reroll) throw Error('余额不足');
r.money -= r.reroll;
r.reroll++;
} else r.freeReroll = false;
stock(r);
break;
case 'buy': {
requirePhase(r, 'shop');
const i = [...r.shop, ...r.packs].find((i) => i.uid === c.uid);
if (!i) throw Error('商品已售出');
if (r.money < i.price) throw Error('余额不足');
if (i.kind === 'pack') {
r.packChoices = [];
for (let n = 0; n < 3; n++)
r.packChoices.push(
rollItem(
r,
i.ref === 0 ? 'joker' : random(r, 'shop') < 0.5 ? 'planet' : 'tarot',
r.packChoices.filter((x) => x.kind === 'joker').map((x) => JOKERS[x.ref].id),
),
);
r.phase = 'pack';
r.packs = r.packs.filter((x) => x.uid !== i.uid);
} else {
give(r, i);
r.shop = r.shop.filter((x) => x.uid !== i.uid);
}
r.money -= i.price;
break;
}
case 'take': {
requirePhase(r, 'pack');
const i = r.packChoices.find((i) => i.uid === c.uid);
if (!i) throw Error('无效的卡包选项');
give(r, i);
r.packChoices = [];
r.phase = 'shop';
break;
}
case 'closePack':
requirePhase(r, 'pack');
r.packChoices = [];
r.phase = 'shop';
break;
case 'sell': {
requirePhase(r, 'shop', 'play', 'blind');
const j = r.jokers.find((j) => j.uid === c.uid),
i = r.consumables.find((i) => i.uid === c.uid);
if (!j && !i) throw Error('卡牌不存在');
r.money += Math.max(1, Math.floor((j ? jokerDef(j.id).price : i!.price) / 2));
r.jokers = r.jokers.filter((j) => j.uid !== c.uid);
r.consumables = r.consumables.filter((i) => i.uid !== c.uid);
break;
}
case 'use': {
requirePhase(r, 'play');
const i = r.consumables.find((i) => i.uid === c.uid);
if (!i) throw Error('消耗品不存在');
if (i.kind === 'planet') r.levels[i.ref]++;
else {
const cs = picked(r, c.ids);
if (cs.length > TAROTS[i.ref].max) throw Error(`最多选择 ${TAROTS[i.ref].max} 张牌`);
if (i.ref === 4 && r.cards.length <= cs.length) throw Error('牌组至少保留 1 张牌');
for (const card of cs) {
if (i.ref === 0) card.suit = 0;
if (i.ref === 1) card.suit = 1;
if (i.ref === 2) card.rank = card.rank === 14 ? 2 : card.rank + 1;
if (i.ref === 3) {
const copy = { ...card, id: r.nextId++ };
r.cards.push(copy);
r.hand.push(copy.id);
}
if (i.ref === 4) {
r.cards = r.cards.filter((x) => x.id !== card.id);
r.hand = r.hand.filter((id) => id !== card.id);
}
if (i.ref === 5) card.enh = 'glass';
if (i.ref === 6) card.enh = 'steel';
if (i.ref === 7) {
card.enh = 'bonus';
card.seal = true;
}
if (i.ref === 8) card.enh = 'mult';
}
}
r.consumables = r.consumables.filter((x) => x.uid !== c.uid);
break;
}
case 'order': {
requirePhase(r, 'play', 'shop', 'blind');
const old = c.zone === 'hand' ? r.hand : r.jokers.map((j) => j.uid);
if (
old.length !== c.ids.length ||
new Set(c.ids).size !== old.length ||
c.ids.some((id) => !old.includes(id))
)
throw Error('无效的排序');
if (c.zone === 'hand') r.hand = c.ids;
else r.jokers = c.ids.map((id) => r.jokers.find((j) => j.uid === id)!);
break;
}
}
}
export function dispatch(state: Run, command: Command): { state: Run; error?: string } {
const next = structuredClone(state);
try {
apply(next, command);
// A depleted or heavily trimmed deck must never leave an unwinnable input screen.
if (next.phase === 'play') {
const required = next.blind === 2 && next.bosses[next.ante - 1] === 4 ? 5 : 1;
const canDraw =
next.discards > 0 &&
next.hand.length > 0 &&
next.draw.length > 0 &&
Math.min(next.handSize, next.hand.length - 1 + next.draw.length) >= required;
const copies = next.consumables.filter((i) => i.kind === 'tarot' && i.ref === 3).length;
const canCopy = next.hand.length > 0 && next.hand.length + copies >= required;
if (next.hand.length < required && !canDraw && !canCopy) next.phase = 'lost';
}
return { state: next };
} catch (e) {
return { state, error: e instanceof Error ? e.message : '操作失败' };
}
}
export function cardName(c: Card) {
return (
['红桃', '黑桃', '方块', '梅花'][c.suit] +
({ 11: 'J', 12: 'Q', 13: 'K', 14: 'A' }[c.rank] || c.rank)
);
}

164
src/core/scoring.ts Normal file
View File

@@ -0,0 +1,164 @@
import { HANDS, jokerDef } from '../content/catalog';
import { evaluate } from './poker';
import { random } from './random';
import type { Card, Run, Score, Trace } from './types';
export const debuffed = (run: Run, c: Card) =>
run.blind === 2 &&
((run.bosses[run.ante - 1] === 0 && c.suit === 0) ||
(run.bosses[run.ante - 1] === 1 && c.suit === 1));
export function scoreHand(run: Run, played: Card[]): Score {
const e = evaluate(played),
h = HANDS[e.hand],
lv = run.levels[e.hand] - 1;
let chips: number = h[1] + h[3] * lv,
mult: number = h[2] + h[4] * lv;
if (run.blind === 2 && run.bosses[run.ante - 1] === 5) {
chips = Math.ceil(chips / 2);
mult = Math.ceil(mult / 2);
}
const trace: Trace[] = [{ source: e.name, label: `${e.name} · Lv.${lv + 1}`, chips, mult }];
const add = (
source: string,
label: string,
c = 0,
m = 0,
x = 1,
cardId?: number,
jokerUid?: number,
) => {
chips += c;
mult = (mult + m) * x;
trace.push({ source, label, chips, mult, cardId, jokerUid });
};
run.counts[e.hand]++;
for (const j of run.jokers) if (j.id === 'growth') j.value += 2;
e.scoring.forEach((c, index) => {
if (debuffed(run, c)) {
trace.push({ source: '失效', label: 'Boss 封印', chips, mult, cardId: c.id });
return;
}
const times =
1 + (c.seal ? 1 : 0) + (index === 0 ? run.jokers.filter((j) => j.id === 'echo').length : 0);
for (let t = 0; t < times; t++) {
add(
t ? '再次触发' : '扑克牌',
`+${c.rank === 14 ? 11 : Math.min(c.rank, 10)} 筹码`,
c.rank === 14 ? 11 : Math.min(c.rank, 10),
0,
1,
c.id,
);
if (c.enh === 'bonus') add('奖励牌', '+30 筹码', 30, 0, 1, c.id);
if (c.enh === 'mult') add('多倍牌', '+4 倍率', 0, 4, 1, c.id);
if (c.enh === 'glass') add('玻璃牌', '×2 倍率', 0, 0, 2, c.id);
for (const j of run.jokers) {
let cb = 0,
mb = 0;
if (j.id === 'hearts' && c.suit === 0) mb = 4;
if (j.id === 'spades' && c.suit === 1) cb = 35;
if (j.id === 'clubs' && c.suit === 3) mb = 4;
if (j.id === 'diamonds' && c.suit === 2) {
run.money++;
add(jokerDef(j.id).name, '+$1', 0, 0, 1, c.id, j.uid);
}
if (j.id === 'faces' && c.rank >= 11 && c.rank <= 13) mb = 5;
if (j.id === 'aces' && c.rank === 14) {
cb = 30;
mb = 4;
}
if (j.id === 'even' && [2, 4, 6, 8, 10].includes(c.rank)) mb = 4;
if (j.id === 'odd' && [3, 5, 7, 9, 14].includes(c.rank)) cb = 35;
if (cb || mb)
add(
jokerDef(j.id).name,
[cb ? `+${cb} 筹码` : '', mb ? `+${mb} 倍率` : ''].filter(Boolean).join(' · '),
cb,
mb,
1,
c.id,
j.uid,
);
}
}
});
const selected = new Set(played.map((c) => c.id));
for (const id of run.hand) {
const c = run.cards.find((c) => c.id === id)!;
if (selected.has(id) || debuffed(run, c)) continue;
for (let t = 0; t < (c.seal ? 2 : 1); t++) {
if (c.enh === 'steel') add('钢铁牌', '×1.5 倍率', 0, 0, 1.5, c.id);
for (const j of run.jokers)
if (j.id === 'moon' && c.rank === 12) add('月后', '+13 倍率', 0, 13, 1, c.id, j.uid);
}
}
for (const j of run.jokers) {
let cb = 0,
mb = 0,
x = 1;
switch (j.id) {
case 'smile':
mb = 4;
break;
case 'pair':
if (e.pair) mb = 8;
break;
case 'triple':
if (e.triple) mb = 20;
break;
case 'flush':
if (e.flush) mb = 18;
break;
case 'straight':
if (e.straight) cb = 100;
break;
case 'chips':
cb = 80;
break;
case 'double':
if (e.pair) x = 2;
break;
case 'minimal':
if (played.length <= 3) x = 2;
break;
case 'growth':
mb = j.value;
break;
case 'scholar':
mb = run.counts[e.hand] * 3;
break;
case 'bank':
mb = Math.floor(run.money / 5) * 4;
break;
case 'last':
if (run.hands === 1) x = 3;
break;
case 'wanderer':
x = 1 + run.skips * 0.35;
break;
}
if (cb || mb || x !== 1)
add(
jokerDef(j.id).name,
cb ? `+${cb} 筹码` : mb ? `+${mb} 倍率` : `×${Number(x.toFixed(2))} 倍率`,
cb,
mb,
x,
undefined,
j.uid,
);
}
for (const c of e.scoring)
if (c.enh === 'glass' && !debuffed(run, c) && random(run, 'effect') < 0.25) {
run.cards = run.cards.filter((v) => v.id !== c.id);
add('玻璃破碎', '永久移除', 0, 0, 1, c.id);
}
return {
hand: e.hand,
name: e.name,
chips,
mult,
total: Math.floor(chips * mult),
trace,
played: structuredClone(played),
};
}

82
src/core/types.ts Normal file
View File

@@ -0,0 +1,82 @@
export type Suit = 0 | 1 | 2 | 3;
export type Card = {
id: number;
rank: number;
suit: Suit;
enh?: 'bonus' | 'mult' | 'glass' | 'steel';
seal?: boolean;
};
export type Joker = { uid: number; id: string; value: number };
export type Item = {
uid: number;
kind: 'joker' | 'planet' | 'tarot' | 'pack';
ref: number;
price: number;
};
export type Phase = 'blind' | 'play' | 'reward' | 'shop' | 'pack' | 'won' | 'lost';
export type Trace = {
source: string;
label: string;
chips: number;
mult: number;
cardId?: number;
jokerUid?: number;
};
export type Score = {
hand: number;
name: string;
chips: number;
mult: number;
total: number;
trace: Trace[];
played: Card[];
};
export type Run = {
version: 1;
rules: 'velvet-v1';
seed: string;
phase: Phase;
ante: number;
blind: number;
round: number;
money: number;
score: number;
target: number;
hands: number;
discards: number;
handSize: number;
cards: Card[];
hand: number[];
draw: number[];
spent: number[];
jokers: Joker[];
consumables: Item[];
levels: number[];
counts: number[];
bosses: number[];
rng: { deck: number; shop: number; effect: number };
nextId: number;
shop: Item[];
packs: Item[];
packChoices: Item[];
reroll: number;
freeReroll: boolean;
investment: boolean;
skips: number;
last: Score | null;
reward: { base: number; hands: number; interest: number; bonus: number; total: number } | null;
best: number;
totalScore: number;
history: string[];
};
export type Command =
| { type: 'enter' }
| { type: 'skip' }
| { type: 'play' | 'discard'; ids: number[] }
| { type: 'cashout' }
| { type: 'next' }
| { type: 'reroll' }
| { type: 'buy' | 'sell' | 'take'; uid: number }
| { type: 'use'; uid: number; ids: number[] }
| { type: 'closePack' }
| { type: 'order'; zone: 'hand' | 'jokers'; ids: number[] };

37
src/platform/audio.ts Normal file
View File

@@ -0,0 +1,37 @@
import { readSetting, writeSetting } from './storage';
let ctx: AudioContext | undefined;
export const sound = {
enabled: readSetting('velvet-sound') !== 'off',
play(kind: 'click' | 'card' | 'score' | 'win' = 'click', step = 0) {
if (!this.enabled) return;
try {
ctx ??= new AudioContext();
void ctx.resume();
const now = ctx.currentTime,
o = ctx.createOscillator(),
g = ctx.createGain();
o.type = kind === 'score' ? 'triangle' : 'sine';
const hz =
kind === 'win'
? 660
: kind === 'score'
? 260 * Math.pow(1.065, Math.min(step, 28))
: kind === 'card'
? 360
: 220;
o.frequency.setValueAtTime(hz, now);
o.frequency.exponentialRampToValueAtTime(hz * (kind === 'card' ? 0.6 : 1.4), now + 0.12);
g.gain.setValueAtTime(0.045, now);
g.gain.exponentialRampToValueAtTime(0.001, now + 0.18);
o.connect(g).connect(ctx.destination);
o.start(now);
o.stop(now + 0.2);
} catch {
/* Audio is optional. */
}
},
toggle() {
this.enabled = !this.enabled;
writeSetting('velvet-sound', this.enabled ? 'on' : 'off');
},
};

177
src/platform/storage.ts Normal file
View File

@@ -0,0 +1,177 @@
import { JOKERS, HANDS, TAROTS } from '../content/catalog';
import type { Run } from '../core/types';
const KEY = 'velvet-joker-run-v1';
export function readSetting(key: string): string | null {
try {
return localStorage.getItem(key);
} catch {
return null;
}
}
export function writeSetting(key: string, value: string) {
try {
localStorage.setItem(key, value);
} catch {
/* Settings remain usable for this session. */
}
}
export function validateRun(value: unknown): value is Run {
if (!value || typeof value !== 'object') return false;
const r = value as Run;
const ints = (a: unknown) =>
Array.isArray(a) && a.every((n) => Number.isSafeInteger(n) && n >= 0);
if (
r.version !== 1 ||
r.rules !== 'velvet-v1' ||
typeof r.seed !== 'string' ||
!['blind', 'play', 'reward', 'shop', 'pack', 'won', 'lost'].includes(r.phase)
)
return false;
if (
![
r.ante,
r.blind,
r.round,
r.money,
r.hands,
r.discards,
r.handSize,
r.nextId,
r.reroll,
r.skips,
].every(Number.isSafeInteger) ||
r.ante < 1 ||
r.ante > 8 ||
r.blind < 0 ||
r.blind > 2 ||
r.money < 0 ||
r.hands < 0 ||
r.discards < 0
)
return false;
if (![r.score, r.target, r.best, r.totalScore].every((n) => Number.isFinite(n) && n >= 0))
return false;
if (
!ints(r.levels) ||
r.levels.length !== HANDS.length ||
r.levels.some((n) => n < 1) ||
!ints(r.counts) ||
r.counts.length !== 12 ||
!ints(r.bosses) ||
r.bosses.length !== 8 ||
r.bosses.some((n) => n > 6)
)
return false;
if (
!r.rng ||
!['deck', 'shop', 'effect'].every(
(k) =>
Number.isSafeInteger(r.rng[k as keyof Run['rng']]) &&
r.rng[k as keyof Run['rng']] > 0 &&
r.rng[k as keyof Run['rng']] <= 4294967295,
)
)
return false;
if (
!Array.isArray(r.cards) ||
!r.cards.every(
(c) =>
Number.isSafeInteger(c.id) &&
c.rank >= 2 &&
c.rank <= 14 &&
Number.isInteger(c.rank) &&
[0, 1, 2, 3].includes(c.suit) &&
(!c.enh || ['bonus', 'mult', 'glass', 'steel'].includes(c.enh)),
)
)
return false;
const cardIds = r.cards.map((c) => c.id);
if (new Set(cardIds).size !== cardIds.length) return false;
if (![r.hand, r.draw, r.spent].every(ints)) return false;
const zones = [...r.hand, ...r.draw, ...r.spent];
if (new Set(zones).size !== zones.length || zones.some((id) => !cardIds.includes(id)))
return false;
if (
!Array.isArray(r.jokers) ||
r.jokers.length > 5 ||
!r.jokers.every(
(j) =>
JOKERS.some((d) => d.id === j.id) &&
Number.isSafeInteger(j.uid) &&
Number.isFinite(j.value),
)
)
return false;
if (
![r.consumables, r.shop, r.packs, r.packChoices].every(
(a) =>
Array.isArray(a) &&
a.every(
(i) =>
Number.isSafeInteger(i.uid) &&
Number.isInteger(i.ref) &&
i.ref >= 0 &&
Number.isSafeInteger(i.price) &&
i.price >= 0 &&
({ joker: JOKERS.length, planet: 12, tarot: TAROTS.length, pack: 2 }[i.kind] ?? 0) >
i.ref,
),
)
)
return false;
if (
r.consumables.length > 2 ||
r.consumables.some((i) => i.kind !== 'planet' && i.kind !== 'tarot') ||
!Array.isArray(r.history) ||
!r.history.every((x) => typeof x === 'string')
)
return false;
if (
r.phase === 'reward' &&
(!r.reward ||
![r.reward.base, r.reward.hands, r.reward.interest, r.reward.bonus, r.reward.total].every(
(n) => Number.isFinite(n) && n >= 0,
))
)
return false;
if (
r.last &&
(!Number.isFinite(r.last.total) ||
!Number.isFinite(r.last.chips) ||
!Number.isFinite(r.last.mult) ||
typeof r.last.name !== 'string' ||
!Array.isArray(r.last.trace) ||
!Array.isArray(r.last.played))
)
return false;
const allIds = [
...cardIds,
...r.jokers.map((j) => j.uid),
...r.consumables.map((i) => i.uid),
...r.shop.map((i) => i.uid),
...r.packs.map((i) => i.uid),
...r.packChoices.map((i) => i.uid),
];
if (new Set(allIds).size !== allIds.length || allIds.some((id) => id >= r.nextId)) return false;
return true;
}
export function load(): { run: Run | null; error?: string } {
try {
const data = localStorage.getItem(KEY);
if (!data) return { run: null };
const r = JSON.parse(data);
return validateRun(r)
? { run: r }
: { run: null, error: '存档版本不兼容或数据损坏。请新开一局。' };
} catch {
return { run: null, error: '无法读取本地存档,请检查浏览器存储设置。' };
}
}
export function save(run: Run) {
try {
localStorage.setItem(KEY, JSON.stringify(run));
return true;
} catch {
return false;
}
}

675
src/render/art.ts Normal file
View File

@@ -0,0 +1,675 @@
import { Texture } from 'pixi.js';
import { JOKERS, HANDS, TAROTS } from '../content/catalog';
import type { Card, Item } from '../core/types';
const cache = new Map<string, Texture>();
const cream = '#eee5cb',
ink = '#263831';
function surface(w = 180, h = 254) {
const canvas = document.createElement('canvas');
canvas.width = w * 2;
canvas.height = h * 2;
const c = canvas.getContext('2d')!;
c.scale(2, 2);
return { canvas, c };
}
function path(c: CanvasRenderingContext2D, pts: number[][], color: string) {
c.fillStyle = color;
c.beginPath();
pts.forEach(([x, y], i) => (i ? c.lineTo(x, y) : c.moveTo(x, y)));
c.closePath();
c.fill();
}
function circle(c: CanvasRenderingContext2D, x: number, y: number, r: number, color: string) {
c.fillStyle = color;
c.beginPath();
c.arc(x, y, r, 0, Math.PI * 2);
c.fill();
}
function line(
c: CanvasRenderingContext2D,
x: number,
y: number,
x2: number,
y2: number,
color: string,
w = 1,
) {
c.strokeStyle = color;
c.lineWidth = w;
c.beginPath();
c.moveTo(x, y);
c.lineTo(x2, y2);
c.stroke();
}
function text(
c: CanvasRenderingContext2D,
s: string,
x: number,
y: number,
size = 16,
color = ink,
font = 'Georgia',
align: CanvasTextAlign = 'center',
) {
c.font = `bold ${size}px ${font}`;
c.fillStyle = color;
c.textAlign = align;
c.fillText(s, x, y);
}
function paper(c: CanvasRenderingContext2D, bg = cream) {
c.fillStyle = '#152d27';
c.beginPath();
c.roundRect(0, 0, 180, 254, 9);
c.fill();
c.fillStyle = bg;
c.beginPath();
c.roundRect(2, 2, 176, 250, 7);
c.fill();
c.strokeStyle = '#b4af94';
c.lineWidth = 0.8;
c.strokeRect(8, 8, 164, 238);
for (let i = 0; i < 1100; i++) {
const x = ((i * 97.37) % 176) + 2,
y = ((i * 41.63) % 250) + 2;
c.fillStyle = i % 2 ? '#00000009' : '#ffffff25';
c.fillRect(x, y, 1, 1);
}
}
export function suit(
c: CanvasRenderingContext2D,
n: number,
x: number,
y: number,
s: number,
color?: string,
) {
c.save();
c.translate(x, y);
c.scale(s / 20, s / 20);
const co = color || (n === 0 || n === 2 ? '#b8413d' : ink);
if (n === 2)
path(
c,
[
[0, -12],
[9, 0],
[0, 12],
[-9, 0],
],
co,
);
if (n === 0) {
circle(c, -5, -4, 6.5, co);
circle(c, 5, -4, 6.5, co);
path(
c,
[
[-11, -2],
[11, -2],
[0, 12],
],
co,
);
}
if (n === 1) {
path(
c,
[
[0, -12],
[-10, 0],
[10, 0],
],
co,
);
circle(c, -4, 1, 6, co);
circle(c, 4, 1, 6, co);
path(
c,
[
[0, 0],
[-4, 12],
[4, 12],
],
co,
);
}
if (n === 3) {
circle(c, 0, -6, 6, co);
circle(c, -6, 2, 6, co);
circle(c, 6, 2, 6, co);
path(
c,
[
[0, 0],
[-4, 12],
[4, 12],
],
co,
);
}
c.restore();
}
function jester(c: CanvasRenderingContext2D, variant: number, color: string) {
c.save();
c.translate(90, 121);
// Printed diamond backdrop.
for (let x = -70; x < 70; x += 20)
for (let y = -80; y < 95; y += 24)
path(
c,
[
[x, y - 9],
[x + 7, y],
[x, y + 9],
[x - 7, y],
],
'#ffffff20',
);
circle(c, 0, -5, 58, '#f1dcac');
circle(c, 0, -5, 51, color);
circle(c, 0, -5, 47, '#293e37');
path(
c,
[
[-57, 93],
[-48, 47],
[-27, 25],
[0, 38],
[27, 25],
[48, 47],
[57, 93],
],
cream,
);
path(
c,
[
[-45, 90],
[-37, 48],
[-15, 33],
[0, 55],
[0, 90],
],
color,
);
path(
c,
[
[0, 55],
[15, 33],
[37, 48],
[45, 90],
[0, 90],
],
'#344e47',
);
for (let y = 60; y < 96; y += 20) {
path(
c,
[
[-24, y - 6],
[-17, y],
[-24, y + 6],
[-31, y],
],
'#344e47',
);
path(
c,
[
[24, y - 6],
[31, y],
[24, y + 6],
[17, y],
],
color,
);
}
path(
c,
[
[-30, -15],
[-32, 9],
[-16, 34],
[0, 40],
[16, 34],
[32, 9],
[30, -15],
],
cream,
);
path(
c,
[
[0, -13],
[26, -13],
[22, 9],
[8, 5],
[0, 14],
],
'#d2c398',
);
// Three-point jester cap, pixel stepped silhouette.
path(
c,
[
[-34, -8],
[-46, -19],
[-52, -41],
[-36, -36],
[-23, -22],
[-15, -56],
[0, -77],
[14, -58],
[22, -25],
[39, -40],
[53, -44],
[47, -18],
[32, -8],
[17, -17],
[0, -21],
[-18, -16],
],
color,
);
path(
c,
[
[0, -77],
[14, -58],
[22, -25],
[39, -40],
[53, -44],
[47, -18],
[32, -8],
[17, -17],
[0, -21],
],
'#263c36',
);
[
[-52, -42],
[0, -78],
[54, -44],
].forEach(([x, y]) => {
circle(c, x, y, 6, '#d7b25f');
circle(c, x - 1, y - 1, 2, cream);
});
c.fillStyle = ink;
c.fillRect(-21, -2, 12, 5);
c.fillRect(9, -2, 12, 5);
c.fillRect(-17, 3, 4, 6);
c.fillRect(13, 3, 4, 6);
if (variant % 4 === 1) {
c.strokeStyle = color;
c.lineWidth = 4;
c.strokeRect(-24, -7, 19, 15);
c.strokeRect(5, -7, 19, 15);
line(c, -5, 0, 5, 0, color, 3);
}
if (variant % 4 === 2)
path(
c,
[
[9, -7],
[24, -7],
[21, 8],
[12, 8],
],
color,
);
path(
c,
[
[0, 0],
[-5, 14],
[5, 14],
],
'#b65e4b',
);
path(
c,
[
[-15, 20],
[0, 24],
[15, 20],
[8, 30],
[-7, 30],
],
'#a74239',
);
line(c, -10, 23, 10, 23, cream, 3);
path(
c,
[
[-30, 28],
[-41, 38],
[-20, 54],
[0, 41],
[20, 54],
[41, 38],
[30, 28],
[15, 40],
[0, 35],
[-15, 40],
],
'#f5e9cc',
);
for (let y = 57; y < 90; y += 14) circle(c, 0, y, 3, '#d7b25f');
if (variant % 4 === 3) {
line(c, -45, 81, 51, 37, '#d8b76d', 5);
suit(c, variant % 4, 50, 28, 23, '#d8b76d');
}
c.restore();
}
function court(c: CanvasRenderingContext2D, card: Card) {
c.save();
c.beginPath();
c.rect(32, 34, 116, 186);
c.clip();
c.fillStyle = '#dfcfa6';
c.fillRect(32, 34, 116, 186);
const co = card.suit % 2 === 0 ? '#b54740' : '#31555e';
for (let i = 0; i < 2; i++) {
c.save();
if (i) {
c.translate(180, 254);
c.rotate(Math.PI);
}
path(
c,
[
[35, 128],
[47, 85],
[75, 68],
[108, 75],
[142, 111],
[144, 130],
],
co,
);
for (let x = 42; x < 143; x += 12) {
line(c, x, 104, x + 10, 126, '#c3a457', 3);
}
c.fillStyle = ink;
c.fillRect(65, 48, 48, 42);
c.fillStyle = cream;
c.fillRect(75, 48, 31, 42);
path(
c,
[
[106, 64],
[115, 73],
[105, 75],
],
cream,
);
c.fillStyle = ink;
c.fillRect(99, 61, 4, 4);
line(c, 96, 80, 105, 80, co, 2);
path(
c,
[
[64, 50],
[62, 33],
[73, 39],
[80, 27],
[89, 39],
[100, 27],
[106, 39],
[117, 33],
[112, 50],
],
'#b99a4e',
);
c.strokeStyle = ink;
c.strokeRect(65, 48, 48, 4);
suit(c, card.suit, 54, 65, 21);
line(c, 128, 58, 128, 129, cream, 5);
path(
c,
[
[128, 50],
[120, 65],
[136, 65],
],
'#c3a457',
);
c.restore();
}
c.restore();
c.strokeStyle = ink;
c.strokeRect(32, 34, 116, 186);
}
export function cardTexture(card: Card): Texture {
const key = `c${card.rank}-${card.suit}-${card.enh}-${card.seal}`;
if (cache.has(key)) return cache.get(key)!;
const { canvas, c } = surface();
paper(c, card.enh === 'steel' ? '#ccd7d3' : card.enh === 'glass' ? '#e2edef' : cream);
const r =
({ 11: 'J', 12: 'Q', 13: 'K', 14: 'A' } as Record<number, string>)[card.rank] ||
String(card.rank),
color = card.suit % 2 === 0 ? '#b8413d' : ink;
for (let i = 0; i < 2; i++) {
c.save();
if (i) {
c.translate(180, 254);
c.rotate(Math.PI);
}
text(c, r, 20, 31, 25, color);
suit(c, card.suit, 20, 49, 17);
c.restore();
}
if (card.rank >= 11 && card.rank <= 13) court(c, card);
else if (card.rank === 14) {
suit(c, card.suit, 90, 125, 68);
text(c, 'VELVET', 90, 194, 9, color, 'Courier New');
} else {
const pos: Record<number, number[][]> = {
2: [
[90, 62],
[90, 191],
],
3: [
[90, 62],
[90, 127],
[90, 191],
],
4: [
[57, 62],
[123, 62],
[57, 191],
[123, 191],
],
5: [
[57, 62],
[123, 62],
[90, 127],
[57, 191],
[123, 191],
],
6: [
[57, 62],
[123, 62],
[57, 127],
[123, 127],
[57, 191],
[123, 191],
],
7: [
[57, 62],
[123, 62],
[90, 94],
[57, 127],
[123, 127],
[57, 191],
[123, 191],
],
8: [
[57, 62],
[123, 62],
[90, 94],
[57, 127],
[123, 127],
[90, 160],
[57, 191],
[123, 191],
],
9: [
[57, 56],
[123, 56],
[57, 102],
[123, 102],
[90, 126],
[57, 151],
[123, 151],
[57, 197],
[123, 197],
],
10: [
[57, 56],
[123, 56],
[90, 79],
[57, 102],
[123, 102],
[57, 151],
[123, 151],
[90, 174],
[57, 197],
[123, 197],
],
};
pos[card.rank].forEach(([x, y]) => {
c.save();
c.translate(x, y);
if (y > 130) c.rotate(Math.PI);
suit(c, card.suit, 0, 0, 25);
c.restore();
});
}
if (card.enh) {
c.strokeStyle = card.enh === 'glass' ? '#81b8c5' : '#a88b41';
c.lineWidth = 3;
c.strokeRect(5, 5, 170, 244);
text(c, { bonus: '+30', mult: '+4', glass: '×2', steel: '×1.5' }[card.enh], 90, 241, 10);
}
if (card.seal) {
circle(c, 141, 213, 11, '#ad4139');
circle(c, 141, 213, 7, '#cb5c48');
text(c, '↻', 141, 218, 15, cream);
}
const t = Texture.from(canvas);
cache.set(key, t);
return t;
}
export function jokerTexture(index: number): Texture {
const key = `j${index}`;
if (cache.has(key)) return cache.get(key)!;
const j = JOKERS[index],
{ canvas, c } = surface();
paper(c);
c.fillStyle = j.color;
c.fillRect(13, 13, 154, 210);
jester(c, j.motif, j.color);
for (let n = 0; n < 5; n++) text(c, 'JOKER'[n], 23, 33 + n * 14, 12, cream, 'Courier New');
text(c, j.en, 90, 239, 10, ink, 'Courier New');
const t = Texture.from(canvas);
cache.set(key, t);
return t;
}
export function itemTexture(i: Item): Texture {
if (i.kind === 'joker') return jokerTexture(i.ref);
const key = `${i.kind}${i.ref}`;
if (cache.has(key)) return cache.get(key)!;
const { canvas, c } = surface();
paper(c);
c.fillStyle = i.kind === 'planet' ? '#334857' : i.kind === 'pack' ? '#86703e' : '#69526b';
c.fillRect(13, 13, 154, 211);
for (let n = 0; n < 45; n++) {
const x = 23 + ((n * 39.7) % 133),
y = 22 + ((n * 59.3) % 190);
circle(c, x, y, n % 5 === 0 ? 1.5 : 0.6, '#dfd1ad');
}
if (i.kind === 'planet') {
const color = [
'#b4aaa0',
'#c5a48a',
'#83b2b0',
'#cdae74',
'#b7b87b',
'#b7916f',
'#83ad9a',
'#ba7d64',
'#82a3b1',
][i.ref % 9];
circle(c, 90, 117, 45, color);
c.save();
c.beginPath();
c.arc(90, 117, 45, 0, Math.PI * 2);
c.clip();
for (let y = 80; y < 160; y += 9) line(c, 40, y, 140, y + 8, '#ffffff18', 4);
circle(c, 111, 113, 42, '#162e3444');
c.restore();
c.strokeStyle = cream;
c.lineWidth = 2;
c.beginPath();
c.ellipse(90, 117, 68, 17, -0.35, 0, Math.PI * 2);
c.stroke();
text(c, HANDS[i.ref][5], 90, 204, 16, cream, 'serif');
text(c, 'CELESTIAL', 90, 240, 10, ink, 'Courier New');
} else {
c.strokeStyle = '#d2b673';
c.lineWidth = 1.5;
c.strokeRect(22, 24, 136, 188);
for (let n = 0; n < 12; n++) {
const a = (n * Math.PI) / 6;
line(
c,
90 + Math.cos(a) * 41,
116 + Math.sin(a) * 41,
90 + Math.cos(a) * 57,
116 + Math.sin(a) * 57,
'#d2b673',
2,
);
}
circle(c, 90, 116, 35, '#dfce9e');
circle(c, 90, 116, 29, '#69526b');
suit(c, i.ref % 4, 90, 115, 32, '#dfce9e');
text(c, i.kind === 'pack' ? '精选补充包' : TAROTS[i.ref].name, 90, 198, 16, cream, 'serif');
text(c, i.kind === 'pack' ? 'THE COLLECTION' : 'ARCANA', 90, 240, 10, ink, 'Courier New');
}
const t = Texture.from(canvas);
cache.set(key, t);
return t;
}
export function backTexture(): Texture {
if (cache.has('back')) return cache.get('back')!;
const { canvas, c } = surface();
paper(c);
c.fillStyle = '#a64d3e';
c.fillRect(13, 13, 154, 228);
for (let x = 20; x < 165; x += 12)
for (let y = 20; y < 236; y += 12)
path(
c,
[
[x, y - 4],
[x + 4, y],
[x, y + 4],
[x - 4, y],
],
'#e6c78d88',
);
c.strokeStyle = cream;
c.lineWidth = 2;
c.strokeRect(19, 19, 142, 216);
c.strokeRect(23, 23, 134, 208);
circle(c, 90, 127, 46, '#a64d3e');
circle(c, 90, 127, 40, cream);
circle(c, 90, 127, 36, '#a64d3e');
suit(c, 1, 90, 126, 43, cream);
const t = Texture.from(canvas);
cache.set('back', t);
return t;
}

345
src/render/board.ts Normal file
View File

@@ -0,0 +1,345 @@
import { Application, Container, Graphics, Sprite, Text, Rectangle } from 'pixi.js';
import { JOKERS } from '../content/catalog';
import { cardTexture, jokerTexture, itemTexture, backTexture } from './art';
import { debuffed } from '../core/scoring';
import type { Run, Card, Item } from '../core/types';
type Callbacks = {
select: (id: number) => void;
inspect: (kind: 'joker' | 'item' | 'card', id: number) => void;
hover: (title: string, body: string, x: number, y: number) => void;
unhover: () => void;
reorder: (zone: 'hand' | 'jokers', from: number, to: number) => void;
};
export class Board {
app = new Application();
layer = new Container();
glow = new Container();
nodes = new Map<string, Container>();
time = 0;
longPress: ReturnType<typeof setTimeout> | undefined;
drag: {
zone: 'hand' | 'jokers';
id: number;
x: number;
y: number;
node: Container;
startX: number;
startY: number;
} | null = null;
async init(host: HTMLElement, publicCallbacks: Callbacks) {
this.callbacks = publicCallbacks;
await this.app.init({
width: 1440,
height: 900,
backgroundAlpha: 0,
antialias: true,
resolution: Math.min(window.devicePixelRatio || 1, 2),
autoDensity: true,
preference: 'webgl',
});
host.append(this.app.canvas);
this.app.canvas.setAttribute('aria-label', '可交互的纸牌桌面');
this.app.stage.addChild(this.layer, this.glow);
this.app.stage.eventMode = 'static';
this.app.stage.hitArea = new Rectangle(0, 0, 1440, 900);
this.app.stage.on('globalpointermove', (e) => {
if (this.drag) {
const d = this.drag;
if (Math.hypot(e.global.x - d.x, e.global.y - d.y) > 8) clearTimeout(this.longPress);
d.node.x = d.startX + e.global.x - d.x;
d.node.y = d.startY + e.global.y - d.y;
d.node.rotation = 0;
}
});
this.app.stage.on('pointerup', (e) => this.finishDrag(e.global.x));
this.app.stage.on('pointerupoutside', (e) => this.finishDrag(e.global.x));
this.app.canvas.addEventListener('pointercancel', () => {
clearTimeout(this.longPress);
this.drag = null;
this.callbacks.unhover();
if (this.run) this.draw(this.run, this.selection, this.busy);
});
this.app.canvas.addEventListener('pointermove', (e) => {
if (!this.drag) return;
const rect = this.app.canvas.getBoundingClientRect();
const x = ((e.clientX - rect.left) / rect.width) * 1440;
const y = ((e.clientY - rect.top) / rect.height) * 900;
if (Math.hypot(x - this.drag.x, y - this.drag.y) > 8) clearTimeout(this.longPress);
});
this.app.ticker.add((t) => {
this.time += t.deltaMS;
this.layer.children.forEach((n) => {
const a = n as Container & { baseY?: number; float?: number };
if (a.baseY !== undefined && a !== this.drag?.node)
a.y = a.baseY + Math.sin(this.time * 0.0015 + (a.float || 0)) * 1.2;
});
});
}
callbacks!: Callbacks;
run!: Run;
selection: number[] = [];
busy = false;
label(
s: string,
x: number,
y: number,
size = 12,
color = '#c6c9b6',
align: 'left' | 'center' = 'left',
) {
const t = new Text({
text: s,
style: {
fontFamily: 'Courier New, serif',
fontSize: size,
fill: color,
letterSpacing: 2,
fontWeight: 'bold',
align,
},
});
t.position.set(x, y);
if (align === 'center') t.anchor.x = 0.5;
this.layer.addChild(t);
return t;
}
private finishDrag(x: number) {
clearTimeout(this.longPress);
const d = this.drag;
if (!d) return;
this.drag = null;
const moved = Math.abs(x - d.x) > 18 || Math.abs(d.node.y - d.startY) > 18;
if (moved) {
const ids = d.zone === 'hand' ? this.run.hand : this.run.jokers.map((j) => j.uid);
const centers =
d.zone === 'hand'
? ids.map((_, i) => this.handX(i, ids.length))
: ids.map((_, i) => 421 + i * 146);
let near = 0;
centers.forEach((cx, i) => {
if (Math.abs(cx - x) < Math.abs(centers[near] - x)) near = i;
});
this.callbacks.reorder(d.zone, d.id, ids[near]);
} else if (d.zone === 'hand') this.callbacks.select(d.id);
else this.callbacks.inspect('joker', d.id);
if (this.run) this.draw(this.run, this.selection, this.busy);
}
handX(i: number, n: number) {
return 820 + (i - (n - 1) / 2) * Math.min(99, 790 / Math.max(1, n - 1));
}
private card(
tex: ReturnType<typeof cardTexture>,
x: number,
y: number,
w: number,
h: number,
rot = 0,
) {
const n = new Container();
n.position.set(x, y);
n.rotation = rot;
const shadow = new Graphics()
.roundRect(-w / 2 + 4, -h / 2 + 9, w, h, 7)
.fill({ color: 0x071c17, alpha: 0.55 });
n.addChild(shadow);
const s = new Sprite(tex);
s.anchor.set(0.5);
s.width = w;
s.height = h;
n.addChild(s);
this.layer.addChild(n);
return n;
}
draw(r: Run, selected: number[] = [], busy = false) {
this.run = r;
this.selection = selected;
this.busy = busy;
if (this.drag) return;
for (const child of this.layer.removeChildren()) child.destroy({ children: true });
this.nodes.clear();
const ornaments = new Graphics();
ornaments.roundRect(331, 111, 1077, 721, 22).stroke({ width: 1, color: 0xd0b67c, alpha: 0.16 });
ornaments.roundRect(342, 122, 1055, 699, 17).stroke({ width: 1, color: 0xd0b67c, alpha: 0.08 });
ornaments.moveTo(363, 333).lineTo(1375, 333).stroke({ width: 1, color: 0xd1bf8b, alpha: 0.17 });
this.layer.addChild(ornaments);
this.label('JOKERS', 369, 132, 11, '#adbaa8');
this.label(`${r.jokers.length} / 5`, 1062, 132, 11, '#adbaa8');
this.label('CONSUMABLES', 1190, 132, 10, '#adbaa8');
for (let i = 0; i < 5; i++) {
const j = r.jokers[i],
x = 421 + i * 146;
if (!j) {
const g = new Graphics()
.roundRect(x - 56, 168, 112, 144, 7)
.stroke({ width: 1, color: 0xd8c69b, alpha: 0.18 });
this.layer.addChild(g);
this.label('✦', x, 228, 24, '#597568', 'center');
continue;
}
const idx = JOKERS.findIndex((d) => d.id === j.id),
def = JOKERS[idx],
n = this.card(jokerTexture(idx), x, 241, 108, 153, (i - 2) * 0.018);
this.nodes.set('j' + j.uid, n);
Object.assign(n, { baseY: 241, float: i });
n.eventMode = busy ? 'none' : 'static';
n.cursor = 'grab';
n.on('pointerover', () => {
this.callbacks.hover(def.name, def.desc, x, 326);
n.scale.set(1.045);
});
n.on('pointerout', () => {
this.callbacks.unhover();
n.scale.set(1);
});
n.on('pointerdown', (e) => {
this.callbacks.unhover();
this.drag = {
zone: 'jokers',
id: j.uid,
x: e.global.x,
y: e.global.y,
node: n,
startX: n.x,
startY: n.y,
};
this.layer.setChildIndex(n, this.layer.children.length - 1);
});
}
for (let i = 0; i < 2; i++) {
const it = r.consumables[i],
x = 1232 + i * 92;
if (it) {
const n = this.card(itemTexture(it), x, 228, 77, 109, (i ? 1 : -1) * 0.035);
n.eventMode = busy ? 'none' : 'static';
n.cursor = 'pointer';
n.on('pointertap', () => this.callbacks.inspect('item', it.uid));
} else {
this.layer.addChild(
new Graphics()
.roundRect(x - 36, 174, 72, 107, 5)
.stroke({ width: 1, color: 0xcac49e, alpha: 0.2 }),
);
this.label('+', x, 214, 22, '#688375', 'center');
}
}
if (['play', 'reward', 'lost', 'won'].includes(r.phase)) {
for (let i = 0; i < 3; i++)
this.card(backTexture(), 1336 + i * 2, 690 - i * 3, 92, 130, 0.065);
this.label(`${r.draw.length} / ${r.cards.length}`, 1340, 779, 12, '#e5d9b8', 'center');
this.label('牌 库', 1340, 804, 10, '#91a694', 'center');
r.hand.forEach((id, i) => {
const c = r.cards.find((c) => c.id === id)!;
this.handCard(c, i, r.hand.length, selected.includes(id), busy);
});
this.label(`${selected.length} 已选 / 最多 5 张`, 820, 785, 11, '#a7baaa', 'center');
}
if (r.phase === 'shop' || r.phase === 'pack') {
const items = r.phase === 'shop' ? r.shop : r.packChoices;
items.forEach((it, i) => {
const n = this.card(itemTexture(it), 510 + i * 210, 506, 120, 169, (i - 1) * 0.025);
n.eventMode = 'static';
n.cursor = 'pointer';
n.on('pointertap', () => this.callbacks.inspect('item', it.uid));
});
if (r.phase === 'shop')
r.packs.forEach((it, i) => {
const n = this.card(itemTexture(it), 1150 + i * 141, 510, 98, 139, (i ? 1 : -1) * 0.04);
n.eventMode = 'static';
n.cursor = 'pointer';
n.on('pointertap', () => this.callbacks.inspect('item', it.uid));
});
}
// Commit world transforms before exposing the newly rebuilt interactive scene.
this.app.render();
}
private handCard(c: Card, i: number, count: number, selected: boolean, busy: boolean) {
const x = this.handX(i, count),
y = 664 + Math.abs(i - (count - 1) / 2) * 2 - (selected ? 30 : 0),
rot = (i - (count - 1) / 2) * 0.018;
const n = this.card(cardTexture(c), x, y, 121, 171, rot);
this.nodes.set('c' + c.id, n);
if (debuffed(this.run, c)) {
n.alpha = 0.5;
}
if (selected)
n.addChild(
new Graphics()
.roundRect(-62, -87, 124, 174, 7)
.stroke({ width: 2, color: 0xe3c77e, alpha: 0.95 }),
);
n.eventMode = busy || this.run.phase !== 'play' ? 'none' : 'static';
n.cursor = 'grab';
n.on('pointerover', () => {
if (!this.drag) {
n.y -= 12;
n.scale.set(1.025);
}
const note = c.enh
? {
bonus: '+30 筹码',
mult: '+4 倍率',
glass: '计分 ×2 倍率,1/4 概率破碎',
steel: '留手 ×1.5 倍率',
}[c.enh]
: '';
if (note || c.seal)
this.callbacks.hover('强化牌', note + (c.seal ? ' · 红封:再次触发' : ''), x, 510);
});
n.on('pointerout', () => {
if (!this.drag) n.y = y;
n.scale.set(1);
this.callbacks.unhover();
});
n.on('pointerdown', (e) => {
if (e.button !== 0) return;
this.callbacks.unhover();
this.drag = {
zone: 'hand',
id: c.id,
x: e.global.x,
y: e.global.y,
node: n,
startX: n.x,
startY: n.y,
};
this.layer.setChildIndex(n, this.layer.children.length - 1);
if (e.pointerType === 'touch')
this.longPress = setTimeout(() => {
this.drag = null;
this.callbacks.inspect('card', c.id);
this.draw(this.run, this.selection, this.busy);
}, 550);
});
n.on('rightclick', () => this.callbacks.inspect('card', c.id));
}
showPlayed(cards: Card[]) {
cards.forEach((c, i) => {
const n = this.card(
cardTexture(c),
820 + (i - (cards.length - 1) / 2) * 91,
453,
77,
108,
(i - (cards.length - 1) / 2) * 0.025,
);
this.nodes.set('p' + c.id, n);
});
}
highlight(cardId?: number, jokerUid?: number) {
for (const c of this.glow.removeChildren()) c.destroy();
const n = jokerUid
? this.nodes.get('j' + jokerUid)
: this.nodes.get('p' + cardId) || this.nodes.get('c' + cardId);
if (!n) return;
const g = new Graphics()
.roundRect(-59, -83, 118, 166, 7)
.stroke({ width: 3, color: 0xf4d68d, alpha: 0.85 });
g.position.copyFrom(n.position);
g.rotation = n.rotation;
if (this.nodes.get('p' + cardId) === n) g.scale.set(0.72);
this.glow.addChild(g);
}
clearHighlight() {
for (const c of this.glow.removeChildren()) c.destroy();
}
}

146
tests/balance.test.ts Normal file
View File

@@ -0,0 +1,146 @@
import { expect, it } from 'vitest';
import { createRun, dispatch } from '../src/core/run';
import { scoreHand } from '../src/core/scoring';
import { evaluate } from '../src/core/poker';
import { JOKERS, TAROTS } from '../src/content/catalog';
import { validateRun } from '../src/platform/storage';
import type { Run, Command } from '../src/core/types';
// A bounded, simple greedy player; this checks reachability, not human difficulty.
const weights: Record<string, number> = {
growth: 130,
scholar: 120,
minimal: 110,
double: 100,
chips: 85,
bank: 80,
echo: 65,
pair: 45,
smile: 30,
egg: 25,
last: 50,
hearts: 40,
spades: 35,
clubs: 35,
aces: 30,
even: 35,
odd: 35,
faces: 35,
diamonds: 20,
moon: 15,
triple: 40,
flush: 40,
straight: 30,
wanderer: 5,
};
function best(r: Run) {
let score = -1,
ids: number[] = [];
const visit = (at: number, chosen: number[]) => {
if (chosen.length) {
if (!(r.blind === 2 && r.bosses[r.ante - 1] === 4 && chosen.length !== 5)) {
const s = scoreHand(
structuredClone(r),
chosen.map((id) => r.cards.find((c) => c.id === id)!),
).total;
if (s > score) {
score = s;
ids = [...chosen];
}
}
}
if (chosen.length === 5) return;
for (let i = at; i < r.hand.length; i++) visit(i + 1, [...chosen, r.hand[i]]);
};
visit(0, []);
return { score, ids };
}
function simulate(seed: string) {
let r = createRun(seed),
steps = 0;
const act = (c: Command) => {
const res = dispatch(r, c);
if (res.error) throw Error(res.error);
r = res.state;
expect(validateRun(r)).toBe(true);
};
while (!['won', 'lost'].includes(r.phase) && steps++ < 450) {
if (r.phase === 'blind') {
act({ type: 'enter' });
continue;
}
if (r.phase === 'reward') {
act({ type: 'cashout' });
continue;
}
if (r.phase === 'shop') {
const score = (id: string) => weights[id] || 10;
for (const it of [...r.shop].sort(
(a, b) =>
(b.kind === 'joker' ? score(JOKERS[b.ref].id) : 0) -
(a.kind === 'joker' ? score(JOKERS[a.ref].id) : 0),
)) {
if (it.price > r.money) continue;
if (it.kind === 'joker') {
if (r.jokers.length === 5) {
const low = [...r.jokers].sort((a, b) => score(a.id) - score(b.id))[0];
if (score(JOKERS[it.ref].id) <= score(low.id)) continue;
act({ type: 'sell', uid: low.uid });
}
act({ type: 'buy', uid: it.uid });
} else if (
r.consumables.length < 2 &&
it.kind === 'planet' &&
(it.ref < 3 || r.counts[it.ref] > 2)
)
act({ type: 'buy', uid: it.uid });
}
if (r.money > 12 && r.reroll <= 7) {
act({ type: 'reroll' });
continue;
}
const ids = [...r.jokers]
.sort(
(a, b) =>
(['double', 'minimal', 'last', 'wanderer'].includes(a.id) ? 1 : 0) -
(['double', 'minimal', 'last', 'wanderer'].includes(b.id) ? 1 : 0),
)
.map((j) => j.uid);
act({ type: 'order', zone: 'jokers', ids });
act({ type: 'next' });
continue;
}
if (r.phase === 'play') {
for (const item of [...r.consumables]) {
const ids = item.kind === 'planet' ? [] : r.hand.slice(0, TAROTS[item.ref].max);
act({ type: 'use', uid: item.uid, ids });
}
const b = best(r);
if (r.discards > 0 && b.score < ((r.target - r.score) / r.hands) * 1.3) {
const keep = evaluate(b.ids.map((id) => r.cards.find((c) => c.id === id)!)).scoring.map(
(c) => c.id,
);
const drop = r.hand.filter((id) => !keep.includes(id)).slice(0, 5);
if (drop.length && r.draw.length) {
act({ type: 'discard', ids: drop });
continue;
}
}
act({ type: 'play', ids: b.ids });
}
}
return {
seed,
phase: r.phase,
ante: r.ante,
round: r.round,
best: r.best,
jokers: r.jokers.map((j) => j.id),
};
}
it('固定种子的自然规则对局不会陷入非法状态,并至少可达成一次通关', () => {
const results = Array.from({ length: 16 }, (_, i) => simulate(`VELVET-${i + 1}`));
console.log('Greedy baseline (no altered cards / money / levels):', JSON.stringify(results));
expect(results.every((r) => r.phase === 'won' || r.phase === 'lost')).toBe(true);
expect(results.some((r) => r.phase === 'won')).toBe(true);
}, 60000);

182
tests/browser.mjs Normal file
View File

@@ -0,0 +1,182 @@
import { chromium } from '@playwright/test';
import assert from 'node:assert/strict';
import { mkdir } from 'node:fs/promises';
await mkdir('artifacts', { recursive: true });
const browser = await chromium.launch({
executablePath:
process.env.CHROME_PATH ||
(process.platform === 'darwin'
? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
: undefined),
headless: true,
args: ['--no-sandbox', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'],
});
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
const errors = [];
page.on('pageerror', (e) => {
errors.push(String(e));
console.log('PAGE ERROR', String(e));
});
page.on('console', (m) => {
if (m.type() === 'error') console.log('CONSOLE ERROR', m.text());
});
await page.goto('http://127.0.0.1:5173/');
try {
await page.getByRole('button', { name: '接受挑战' }).waitFor({ timeout: 10000 });
} catch (e) {
await page.screenshot({ path: 'artifacts/startup-error.png' });
console.log(await page.locator('body').innerText());
await browser.close();
throw e;
}
await page.screenshot({ path: 'artifacts/blind-selection.png' });
await page.getByRole('button', { name: '接受挑战' }).click();
await page.keyboard.press('1');
await page.keyboard.press('2');
assert.equal(await page.locator('.sr-only button[aria-pressed=true]').count(), 2);
await page.screenshot({ path: 'artifacts/playing.png' });
await page.keyboard.press('d');
assert.equal(await page.locator('.counters strong').nth(1).textContent(), '2');
const before = await page.evaluate(() => JSON.parse(localStorage.getItem('velvet-joker-run-v1')));
await page.reload();
await page.getByRole('button', { name: '出牌', exact: false }).waitFor();
const after = await page.evaluate(() => JSON.parse(localStorage.getItem('velvet-joker-run-v1')));
assert.deepEqual(after, before);
await page.mouse.move(474, 665);
await page.mouse.down();
await page.mouse.move(1120, 665, { steps: 15 });
await page.mouse.up();
const moved = await page.evaluate(
() => JSON.parse(localStorage.getItem('velvet-joker-run-v1')).hand,
);
if (JSON.stringify(moved) === JSON.stringify(before.hand)) {
await page.screenshot({ path: 'artifacts/drag-error.png' });
console.log('DRAG MODAL', await page.locator('#modal').innerText());
}
assert.notDeepEqual(moved, before.hand);
await page.keyboard.press('1');
await page.keyboard.press('Enter');
await page.waitForFunction(() => !document.querySelector('#scoring-source'));
assert.equal(await page.locator('.counters strong').first().textContent(), '3');
await page.getByRole('button', { name: '牌型图鉴' }).click();
assert.equal(await page.locator('.hand-table tbody tr').count(), 12);
await page.keyboard.press('Escape');
// Deterministic fixtures exercise full UI flows; they do not assert natural win rate.
await page.evaluate(async () => {
const { createRun, dispatch } = await import('/src/core/run.ts');
let r = createRun('BROWSER');
r = dispatch(r, { type: 'enter' }).state;
r.cards.slice(0, 5).forEach((c, i) => {
c.rank = i === 4 ? 9 : 13;
});
r.hand = r.cards.slice(0, 8).map((c) => c.id);
r.draw = r.cards.slice(8).map((c) => c.id);
r.money = 30;
r.target = 300;
r.jokers = [
{ uid: 200, id: 'smile', value: 0 },
{ uid: 201, id: 'pair', value: 0 },
{ uid: 202, id: 'double', value: 0 },
];
r.consumables = [{ uid: 203, kind: 'planet', ref: 1, price: 3 }];
r.nextId = 300;
localStorage.setItem('velvet-joker-run-v1', JSON.stringify(r));
localStorage.setItem('velvet-speed', '4');
});
await page.reload();
await page.getByRole('button', { name: '出牌', exact: false }).waitFor();
await page.mouse.click(421, 240);
await page.getByRole('button', { name: '右移' }).click();
assert.equal(
await page.evaluate(() => JSON.parse(localStorage.getItem('velvet-joker-run-v1')).jokers[0].id),
'pair',
);
await page.waitForFunction(() => !document.querySelector('.dialog'));
await page.screenshot({ path: 'artifacts/before-consumable.png' });
await page.mouse.click(1232, 228);
try {
await page.getByRole('button', { name: '使用', exact: true }).click({ timeout: 6000 });
} catch (e) {
await page.screenshot({ path: 'artifacts/consumable-error.png' });
console.log('DOM', await page.locator('#modal').innerText());
await browser.close();
throw e;
}
assert.equal(
await page.evaluate(() => JSON.parse(localStorage.getItem('velvet-joker-run-v1')).levels[1]),
2,
);
await page.keyboard.press('1');
await page.keyboard.press('2');
await page.screenshot({ path: 'artifacts/joker-table.png' });
await page.keyboard.press('Enter');
await page.getByRole('button', { name: '前往商店' }).waitFor({ timeout: 10000 });
const paid = await page.evaluate(
() => JSON.parse(localStorage.getItem('velvet-joker-run-v1')).money,
);
await page.reload();
await page.getByRole('button', { name: '前往商店' }).click();
assert.equal(
await page.evaluate(() => JSON.parse(localStorage.getItem('velvet-joker-run-v1')).money),
paid,
);
await page.screenshot({ path: 'artifacts/shop.png' });
await page.locator('[data-action=buy]').first().click();
await page.locator('[data-action=reroll]').click();
await page.locator('[data-action=buy]').last().click();
await page.getByRole('button', { name: '选择此牌' }).first().click();
await page.getByRole('button', { name: '下一盲注' }).click();
await page.getByRole('button', { name: '跳过' }).click();
assert.match(await page.locator('.blind-head').textContent(), /BOSS/);
await page.setViewportSize({ width: 1280, height: 720 });
await page.screenshot({ path: 'artifacts/desktop-1280.png' });
await page.setViewportSize({ width: 932, height: 430 });
await page.screenshot({ path: 'artifacts/mobile-landscape.png' });
await page.getByRole('button', { name: '接受挑战' }).click();
assert.equal(await page.locator('canvas').count(), 1);
assert.deepEqual(errors, []);
const mobile = await browser.newPage({
viewport: { width: 932, height: 430 },
hasTouch: true,
isMobile: true,
});
mobile.on('pageerror', (e) => errors.push(String(e)));
await mobile.goto('http://127.0.0.1:5173/');
await mobile.getByRole('button', { name: '接受挑战' }).click();
const bounds = await mobile.locator('canvas').boundingBox();
const point = (x, y) => ({
x: bounds.x + (x * bounds.width) / 1440,
y: bounds.y + (y * bounds.height) / 900,
});
await mobile.touchscreen.tap(point(474, 665).x, point(474, 665).y);
assert.equal(await mobile.locator('.sr-only button[aria-pressed=true]').count(), 1);
const touch = await mobile.context().newCDPSession(mobile);
await touch.send('Input.dispatchTouchEvent', {
type: 'touchStart',
touchPoints: [point(573, 665)],
});
await mobile.getByRole('dialog').waitFor({ timeout: 4000 });
await touch.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
assert.match(await mobile.locator('.dialog').innerText(), /基础筹码/);
await mobile.getByRole('button', { name: '关闭', exact: true }).click();
const mobileBefore = await mobile.evaluate(
() => JSON.parse(localStorage.getItem('velvet-joker-run-v1')).hand,
);
await touch.send('Input.dispatchTouchEvent', {
type: 'touchStart',
touchPoints: [point(474, 665)],
});
await touch.send('Input.dispatchTouchEvent', {
type: 'touchMove',
touchPoints: [point(1100, 665)],
});
await touch.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
assert.notDeepEqual(
await mobile.evaluate(() => JSON.parse(localStorage.getItem('velvet-joker-run-v1')).hand),
mobileBefore,
);
assert.deepEqual(errors, []);
console.log(
'Browser smoke passed: Canvas selection/drag, play/discard, reload, joker order, consumable, reward, shop, pack, skip, responsive sizes, emulated touch tap/long-press/drag.',
);
await browser.close();

355
tests/core.test.ts Normal file
View File

@@ -0,0 +1,355 @@
import { describe, it, expect } from 'vitest';
import { createRun, dispatch, target } from '../src/core/run';
import { evaluate } from '../src/core/poker';
import { scoreHand } from '../src/core/scoring';
import { validateRun } from '../src/platform/storage';
import { JOKERS } from '../src/content/catalog';
import type { Card, Run, Command } from '../src/core/types';
const cards = (ranks: number[], suits: number[] = []): Card[] =>
ranks.map((rank, i) => ({ id: i + 1, rank, suit: (suits[i] ?? i % 4) as Card['suit'] }));
function fixture(cs: Card[], jokers: string[] = []) {
const r = createRun('TEST');
r.phase = 'play';
r.cards = cs;
r.hand = cs.map((c) => c.id);
r.draw = [];
r.jokers = jokers.map((id, i) => ({ id, uid: 100 + i, value: 0 }));
return r;
}
function act(r: Run, c: Command) {
const x = dispatch(r, c);
expect(x.error).toBeUndefined();
return x.state;
}
describe('牌型分类与计分牌', () => {
const cases: [number[], number[], number, number][] = [
[[14, 9, 7, 4, 2], [], 0, 1],
[[13, 13, 7], [], 1, 2],
[[13, 13, 7, 7, 2], [], 2, 4],
[[9, 9, 9, 2, 3], [], 3, 3],
[[2, 3, 4, 5, 6], [], 4, 5],
[[2, 5, 7, 9, 13], [0, 0, 0, 0, 0], 5, 5],
[[8, 8, 8, 5, 5], [], 6, 5],
[[8, 8, 8, 8, 2], [], 7, 4],
[[2, 3, 4, 5, 6], [0, 0, 0, 0, 0], 8, 5],
[[8, 8, 8, 8, 8], [], 9, 5],
[[8, 8, 8, 5, 5], [1, 1, 1, 1, 1], 10, 5],
[[8, 8, 8, 8, 8], [1, 1, 1, 1, 1], 11, 5],
];
it.each(cases)('%j → 牌型 %s', (rs, ss, hand, count) => {
const e = evaluate(cards(rs, ss));
expect(e.hand).toBe(hand);
expect(e.scoring).toHaveLength(count);
});
it('A 可高可低,但不能绕接', () => {
expect(evaluate(cards([14, 2, 3, 4, 5])).hand).toBe(4);
expect(evaluate(cards([10, 11, 12, 13, 14])).hand).toBe(4);
expect(evaluate(cards([12, 13, 14, 2, 3])).hand).toBe(0);
});
it('牌型包含关系不依赖最终牌型名称', () => {
expect(evaluate(cards([8, 8, 8, 5, 5]))).toMatchObject({ hand: 6, pair: true, triple: true });
});
it('计分牌保留用户顺序', () => {
const cs = cards([3, 13, 3, 13, 5]);
expect(evaluate(cs).scoring.map((c) => c.id)).toEqual([1, 2, 3, 4]);
});
});
describe('分阶段计分金样', () => {
it('K K 7:只有对子计分,30×2=60', () => {
const cs = cards([13, 13, 7]);
expect(scoreHand(fixture(cs), cs).total).toBe(60);
});
it('加倍率 / 乘倍率顺序分别为 360 / 240', () => {
const cs = cards([13, 13, 7]);
expect(scoreHand(fixture(cs, ['smile', 'double']), cs).total).toBe(360);
expect(scoreHand(fixture(cs, ['double', 'smile']), cs).total).toBe(240);
});
it('红封和回声相加,不递归:高牌 A 触发三次,38 分', () => {
const cs = cards([14]);
cs[0].seal = true;
expect(scoreHand(fixture(cs, ['echo']), cs).total).toBe(38);
});
it('红封完整重触发逐张小丑', () => {
const cs = cards([14], [0]);
cs[0].seal = true;
expect(scoreHand(fixture(cs, ['hearts']), cs).total).toBe(27 * 9);
});
it('留手钢铁先于月后:16 × (1×1.5+13)', () => {
const cs = cards([14, 12]);
cs[1].enh = 'steel';
expect(scoreHand(fixture(cs, ['moon']), [cs[0]]).total).toBe(232);
});
it('失效牌能成对子但不给点数与逐张加成', () => {
const cs = cards([13, 13], [0, 0]),
r = fixture(cs, ['hearts']);
r.blind = 2;
r.bosses[0] = 0;
expect(scoreHand(r, cs).total).toBe(20);
});
it('成长在当前出牌即生效', () => {
const cs = cards([14]),
r = fixture(cs, ['growth']);
expect(scoreHand(r, cs).total).toBe(48);
expect(r.jokers[0].value).toBe(2);
});
it('牌型等级修改基础值', () => {
const cs = cards([13, 13]),
r = fixture(cs);
r.levels[1] = 2;
expect(scoreHand(r, cs).total).toBe(135);
});
it('钝刃基础值向上取整', () => {
const cs = cards([14]),
r = fixture(cs);
r.blind = 2;
r.bosses[0] = 5;
expect(scoreHand(r, cs).total).toBe(14);
});
it('非计分玻璃牌不会触发或破碎', () => {
const cs = cards([13, 13, 7]),
r = fixture(cs);
cs[2].enh = 'glass';
const rng = r.rng.effect;
expect(scoreHand(r, cs).total).toBe(60);
expect(r.cards).toHaveLength(3);
expect(r.rng.effect).toBe(rng);
});
it('最后一次出牌 ×3 以扣除次数前判断', () => {
const cs = cards([14]),
r = fixture(cs, ['last']);
r.hands = 1;
expect(scoreHand(r, cs).total).toBe(48);
});
it('逐张经济先于银行家独立触发', () => {
const cs = cards([14], [2]),
r = fixture(cs, ['diamonds', 'bank']);
r.money = 4;
expect(scoreHand(r, cs).total).toBe(80);
expect(r.money).toBe(5);
});
it.each(JOKERS.map((j) => [j.id]))('所有小丑 %s 的结果与事件有限', (id) => {
const cs = cards([13, 13, 13, 7, 7]),
r = fixture(cs, [id]);
const s = scoreHand(r, cs);
expect(Number.isFinite(s.total)).toBe(true);
expect(s.trace.at(-1)).toMatchObject({ chips: s.chips, mult: s.mult });
});
});
describe('对局、经济与事务', () => {
it('相同种子产生相同状态与抽牌', () => {
expect(act(createRun('SEED'), { type: 'enter' })).toEqual(
act(createRun('SEED'), { type: 'enter' }),
);
});
it('弃牌不回牌库,卡牌守恒', () => {
let r = act(createRun('S'), { type: 'enter' });
for (let i = 0; i < 3; i++) {
r = act(r, { type: 'discard', ids: r.hand.slice(0, 5) });
expect(new Set([...r.hand, ...r.draw, ...r.spent]).size).toBe(52);
expect(r.hand).toHaveLength(8);
}
expect(r.draw).toHaveLength(29);
});
it('非法操作原子拒绝', () => {
const r = createRun('S');
const before = structuredClone(r);
expect(dispatch(r, { type: 'play', ids: [1] }).error).toBeTruthy();
expect(r).toEqual(before);
expect(dispatch(r, { type: 'play', ids: [1] }).state).toBe(r);
});
it('重复选择同一实例不能计分两次', () => {
const r = act(createRun('S'), { type: 'enter' });
expect(dispatch(r, { type: 'play', ids: [r.hand[0], r.hand[0]] }).error).toBeTruthy();
});
it('胜利即时结算奖励,cashout 不再加钱', () => {
const cs = cards([14]),
r = fixture(cs);
r.target = 1;
r.money = 25;
const next = act(r, { type: 'play', ids: [1] });
expect(next.phase).toBe('reward');
expect(next.money).toBe(36);
const shop = act(next, { type: 'cashout' });
expect(shop.money).toBe(36);
expect(dispatch(shop, { type: 'cashout' }).error).toBeTruthy();
});
it('购买后商品消失,重复购买不扣钱', () => {
let r = createRun('S');
r.phase = 'reward';
r.money = 100;
r = act(r, { type: 'cashout' });
const it = r.shop[0];
r = act(r, { type: 'buy', uid: it.uid });
const money = r.money;
expect(r.jokers).toHaveLength(1);
const again = dispatch(r, { type: 'buy', uid: it.uid });
expect(again.error).toBeTruthy();
expect(again.state.money).toBe(money);
});
it('满槽购买不扣钱、不移除商品', () => {
let r = createRun('S');
r.phase = 'reward';
r.money = 100;
r = act(r, { type: 'cashout' });
r.jokers = JOKERS.slice(0, 5).map((j, i) => ({ id: j.id, uid: 200 + i, value: 0 }));
const result = dispatch(r, { type: 'buy', uid: r.shop[0].uid });
expect(result.error).toBeTruthy();
expect(result.state).toBe(r);
});
it('免费刷新消耗一次,之后价格从 $5 起', () => {
let r = createRun('S');
r.phase = 'reward';
r.money = 100;
r.freeReroll = true;
r = act(r, { type: 'cashout' });
r = act(r, { type: 'reroll' });
expect(r.money).toBe(100);
expect(r.reroll).toBe(5);
r = act(r, { type: 'reroll' });
expect(r.money).toBe(95);
expect(r.reroll).toBe(6);
});
it('跳盲记录标签,Boss 不能跳过', () => {
let r = act(createRun('S'), { type: 'skip' });
expect(r.freeReroll).toBe(true);
r = act(r, { type: 'skip' });
expect(r.investment).toBe(true);
expect(r.blind).toBe(2);
expect(dispatch(r, { type: 'skip' }).error).toBeTruthy();
});
it('Boss 对手牌/弃牌/出牌的限制', () => {
let r = createRun('S');
r.blind = 2;
r.bosses[0] = 2;
expect(act(r, { type: 'enter' }).hand).toHaveLength(7);
r.bosses[0] = 3;
expect(act(r, { type: 'enter' }).discards).toBe(0);
r.bosses[0] = 4;
r = act(r, { type: 'enter' });
expect(dispatch(r, { type: 'play', ids: [r.hand[0]] }).error).toBeTruthy();
});
it('打完最后次数且未达标失败', () => {
let r = fixture(cards([2]));
r.hands = 1;
r.target = 300;
r = act(r, { type: 'play', ids: [1] });
expect(r.phase).toBe('lost');
});
it('卡包先扣钱,选牌仅能一次', () => {
let r = createRun('S');
r.phase = 'reward';
r.money = 20;
r = act(r, { type: 'cashout' });
r = act(r, { type: 'buy', uid: r.packs[0].uid });
expect(r.phase).toBe('pack');
expect(r.money).toBe(16);
const id = r.packChoices[0].uid;
r = act(r, { type: 'take', uid: id });
expect(r.jokers).toHaveLength(1);
expect(dispatch(r, { type: 'take', uid: id }).error).toBeTruthy();
});
it('完整 24 关流程的状态机通关(测试夹具提升牌型等级)', () => {
let r = createRun('FLOW');
r.levels = r.levels.map(() => 10000);
for (let n = 0; n < 24; n++) {
r = act(r, { type: 'enter' });
const ids =
r.blind === 2 && r.bosses[r.ante - 1] === 4 ? r.hand.slice(0, 5) : r.hand.slice(0, 1);
r = act(r, { type: 'play', ids });
if (n === 23) {
expect(r.phase).toBe('won');
break;
}
expect(r.phase).toBe('reward');
r = act(r, { type: 'cashout' });
r = act(r, { type: 'next' });
}
expect(r.ante).toBe(8);
expect(r.round).toBe(24);
});
it('目标曲线与终局倍率', () => {
const r = createRun('S');
expect(target(r)).toBe(300);
r.ante = 8;
r.blind = 2;
expect(target(r)).toBe(200000);
});
});
describe('消耗品与持久化', () => {
it('删牌后无法凑齐 Boss 所需张数时失败,不留下死局', () => {
let r = fixture(cards([2, 3, 4, 5, 6]));
r.blind = 2;
r.bosses[0] = 4;
r.discards = 0;
r.consumables = [{ uid: 999, kind: 'tarot', ref: 4, price: 3 }];
r = act(r, { type: 'use', uid: 999, ids: [1] });
expect(r.phase).toBe('lost');
});
it('可使用复制牌恢复张数时保留出牌阶段', () => {
let r = fixture(cards([2, 3, 4, 5, 6]));
r.blind = 2;
r.bosses[0] = 4;
r.discards = 0;
r.consumables = [
{ uid: 999, kind: 'tarot', ref: 4, price: 3 },
{ uid: 998, kind: 'tarot', ref: 3, price: 3 },
];
r = act(r, { type: 'use', uid: 999, ids: [1] });
expect(r.phase).toBe('play');
r = act(r, { type: 'use', uid: 998, ids: [2] });
expect(r.hand).toHaveLength(5);
});
it('空牌组进入盲注直接失败', () => {
const r = createRun('EMPTY');
r.cards = [];
expect(act(r, { type: 'enter' }).phase).toBe('lost');
});
it('行星只升级指定牌型,并从库存消失', () => {
let r = act(createRun('S'), { type: 'enter' });
r.consumables = [{ uid: 999, kind: 'planet', ref: 1, price: 3 }];
r = act(r, { type: 'use', uid: 999, ids: [] });
expect(r.levels[1]).toBe(2);
expect(r.consumables).toHaveLength(0);
});
it('塔罗复制产生新实例,保持属性', () => {
let r = fixture(cards([13, 13]));
r.cards[0].seal = true;
r.consumables = [{ uid: 999, kind: 'tarot', ref: 3, price: 3 }];
r = act(r, { type: 'use', uid: 999, ids: [1] });
expect(r.cards).toHaveLength(3);
expect(r.cards[2]).toMatchObject({ rank: 13, seal: true });
expect(r.cards[2].id).not.toBe(1);
expect(new Set(r.hand).size).toBe(3);
});
it('销毁不能清空牌组;非法目标不消耗塔罗', () => {
const r = fixture(cards([14]));
r.consumables = [{ uid: 999, kind: 'tarot', ref: 4, price: 3 }];
expect(dispatch(r, { type: 'use', uid: 999, ids: [1] }).state).toBe(r);
});
it.each([0, 1, 2, 5, 6, 7, 8])('塔罗 %s 修改正确属性', (ref) => {
let r = fixture(cards([14], [2]));
r.consumables = [{ uid: 999, kind: 'tarot', ref, price: 3 }];
r = act(r, { type: 'use', uid: 999, ids: [1] });
if (ref === 0) expect(r.cards[0].suit).toBe(0);
if (ref === 1) expect(r.cards[0].suit).toBe(1);
if (ref === 2) expect(r.cards[0].rank).toBe(2);
if (ref === 5) expect(r.cards[0].enh).toBe('glass');
if (ref === 6) expect(r.cards[0].enh).toBe('steel');
if (ref === 7) expect(r.cards[0]).toMatchObject({ enh: 'bonus', seal: true });
if (ref === 8) expect(r.cards[0].enh).toBe('mult');
});
it('序列化恢复保留抽牌随机状态', () => {
const r = act(createRun('SAVE'), { type: 'enter' }),
copy = JSON.parse(JSON.stringify(r));
expect(validateRun(copy)).toBe(true);
expect(act(r, { type: 'discard', ids: r.hand.slice(0, 3) })).toEqual(
act(copy, { type: 'discard', ids: copy.hand.slice(0, 3) }),
);
});
it('损坏或未知规则的存档被拒绝', () => {
const r = createRun('S');
expect(validateRun({ ...r, rules: 'other' })).toBe(false);
expect(validateRun({ ...r, cards: [{ id: 1, rank: 99, suit: 0 }] })).toBe(false);
expect(validateRun({ ...r, hand: [1, 1] })).toBe(false);
});
});

13
tsconfig.json Normal file
View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"allowImportingTsExtensions": true
},
"include": ["src", "tests"]
}

4
vite.config.ts Normal file
View File

@@ -0,0 +1,4 @@
import { defineConfig } from 'vite';
export default defineConfig({
build: { target: 'es2022', rollupOptions: { output: { manualChunks: { pixi: ['pixi.js'] } } } },
});