first commit
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
test-results/
|
||||
playwright-report/
|
||||
.DS_Store
|
||||
1
.prettierrc.json
Normal file
1
.prettierrc.json
Normal file
@@ -0,0 +1 @@
|
||||
{ "singleQuote": true, "printWidth": 100, "trailingComma": "all" }
|
||||
29
AGENTS.md
Normal file
29
AGENTS.md
Normal 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
64
README.md
Normal 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)。
|
||||
BIN
artifacts/blind-selection.png
Normal file
BIN
artifacts/blind-selection.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
BIN
artifacts/desktop-1280.png
Normal file
BIN
artifacts/desktop-1280.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 755 KiB |
BIN
artifacts/joker-table.png
Normal file
BIN
artifacts/joker-table.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
BIN
artifacts/mobile-landscape.png
Normal file
BIN
artifacts/mobile-landscape.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 309 KiB |
BIN
artifacts/playing.png
Normal file
BIN
artifacts/playing.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
BIN
artifacts/shop.png
Normal file
BIN
artifacts/shop.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
6
docs/assets.md
Normal file
6
docs/assets.md
Normal 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
130
docs/implementation-plan.md
Normal 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
45
docs/rules.md
Normal 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
58
docs/verification.md
Normal 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
18
index.html
Normal 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
1691
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
package.json
Normal file
25
package.json
Normal 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
451
src/app/main.ts
Normal 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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
1206
src/app/style.css
Normal file
File diff suppressed because it is too large
Load Diff
288
src/content/catalog.ts
Normal file
288
src/content/catalog.ts
Normal 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
41
src/core/poker.ts
Normal 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
22
src/core/random.ts
Normal 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
349
src/core/run.ts
Normal 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
164
src/core/scoring.ts
Normal 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
82
src/core/types.ts
Normal 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
37
src/platform/audio.ts
Normal 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
177
src/platform/storage.ts
Normal 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
675
src/render/art.ts
Normal 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
345
src/render/board.ts
Normal 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
146
tests/balance.test.ts
Normal 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
182
tests/browser.mjs
Normal 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
355
tests/core.test.ts
Normal 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
13
tsconfig.json
Normal 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
4
vite.config.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { defineConfig } from 'vite';
|
||||
export default defineConfig({
|
||||
build: { target: 'es2022', rollupOptions: { output: { manualChunks: { pixi: ['pixi.js'] } } } },
|
||||
});
|
||||
Reference in New Issue
Block a user