Initial import of HEROLL source and design documents

This commit is contained in:
toshiocn
2026-10-26 16:05:39 +08:00
commit fdb55672c1
1846 changed files with 290692 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
node_modules/
*.log
.server.pid
+113
View File
@@ -0,0 +1,113 @@
# HEROLL 玩法验证 MVP
这是 `Heroll_Gameplay_Validation_GDD.md` 的本地单机 H5 垂直切片。目标是验证“公平双骰 → 绕棋盘推进 → 自动战斗 → 局内构筑 → 十层 Boss”的可玩闭环,不是完整 IAA 产品,也不包含其他项目内容。
> 仅限本机内部验证。提取的 HEROLL 原型资产未取得发行授权,不得公开、上传或分发。
![棋盘实机截图](tests/artifacts/heroll-mvp-board.png)
最新整合玩法文档:[`../docs/2026-08-24_HEROLL-MVP-latest.md`](../docs/2026-08-24_HEROLL-MVP-latest.md)。
本版本已按 `参考资料/功能浏览与关卡流程.mp4` 逐帧校正玩法界面。录像展示的是火山13层关卡;本切片采用Grass、10层和40个独立实际地块,并复刻录像中的固定环形棋盘、玩家顺时针移动、HUD、双骰演出、战斗构图、商店分区和事件表现。
## 双击运行(推荐)
直接双击项目根目录的 [`HEROLL_MVP_START.cmd`](../HEROLL_MVP_START.cmd)(中文名入口也保留)。启动器使用 Windows CRLF/ASCII 包装,自动编译 TypeScript、启动隐藏的本地服务器并打开浏览器;游玩期间保持启动窗口开启,关闭该窗口即可停止服务器。若系统只阻止自动打开浏览器,服务器会继续运行并显示可复制地址;真正的启动错误不会闪退,完整原因会保存到 `mvp/launcher-last-error.txt`。
启动链自测(不打开浏览器,验证编译、服务器和 HTTP 200):
```cmd
set HEROLL_MVP_SELF_TEST=1
HEROLL_MVP_START.cmd
```
## PowerShell 运行
在 PowerShell 中执行:
```powershell
cd C:\Users\toshio\Documents\ChatGPT\heroll\mvp
.\Run-Heroll-MVP.ps1
```
脚本会编译 TypeScript、启动 `http://127.0.0.1:4173/mvp/` 并打开默认浏览器。保持 PowerShell 窗口开启,按 `Ctrl+C` 停止服务器。
也可手动执行:
```powershell
node node_modules\typescript\bin\tsc -p tsconfig.json
node tools\server.mjs
```
## 已实现的验证闭环
| 模块 | MVP 行为 | 规范依据 |
|---|---|---|
| 骰子 | A/B 两枚独立公平 D6;36 个有序结果等权;对子触发机缘;无暗改、保底或重掷 | GDD 4.1 |
| 第三骰 | 获得对应祝福后,在基础双骰之后选择替换 A、替换 B 或放弃 | GDD 8.4 |
| 棋盘/楼层 | 40个独立实际地块;棋盘固定不旋转;1点严格移动1格;玩家从底部起点沿左侧、顶部、右侧顺时针移动;越过起点升层并恢复30%最大生命 | GDD 4.2、4.4 |
| Boss 循环 | 玩家 ROLL 2 次后开始 4 次 ROLL 倒计时;倒计时结束,Boss 将 4 个非祝福地块转为怪物地块,然后循环 | GDD 4.3 |
| 内容保证 | 商人固定在 3/6/9 层;商人、宝箱、营火、铁匠、祝福、挖掘在十层内均可自然生成 | GDD 9–10 |
| 战斗 | 1–4骷髅地块严格对应1–4只怪物;另有小Boss和污染Boss地块;攻击/`skill_0`自动执行;保留反击;玩家只手动使用唯一丹药 | GDD 5–7 |
| 复活 | 仅保留源码 `Revive` 祝福;祝福不可用或结算后仍死亡时直接失败 | GDD 5.6 |
| 装备 | 读取 40 件装备、13 种属性、五档品质及 `210:60:28:4:1` 权重;四固定槽;替换自动分解 | GDD 8.2–8.3 |
| 祝福 | 读取 31 个启用源码祝福 + 第三骰祝福;单卡揭示并直接接受 | GDD 8.4 |
| 丹药/商店 | 唯一丹药采用逆向确认的固定 HP 曲线 `50/80/110…650`;3 个固定商品为补 1 瓶/上限 +1/基础恢复 +30 HP,另有 3 个非丹药随机商品 | GDD 7.2、8 |
| 事件 | 13个事件入口全部可从调试台验收;事件或战斗完整结算后地块变为荒地,荒地跨层保留,直到被污染或玩家新增地块覆盖 | GDD 9–10 |
| 广告入口 | 当前构建不显示广告、即时增强、广告复活、广告重抽或补次数按钮;相关旧事务结构仅作未来预留,不可从 UI 触发 | GDD 2.4、9.3 |
| 安全点 | 本地双副本 + SHA-256 校验;地块、战斗、购买和选择完整结算后保存 | GDD 13 |
| 调试台 | 指定楼层/点数、强制 13 事件、战斗、死亡、Boss、4×战斗及日志导出 | GDD 14 |
## 原作还原层
- 40个画面格全部是独立逻辑落点;棋盘全程固定,骑士从底部起点沿左侧、顶部、右侧顺时针逐格移动,ROLL多少点就播放多少次逐格位移。
- 使用逆向导出的原作 `knight_m_idle/run_anim_f0–f3`、`boss_0`、红蓝骰面、`tile_battle`、1–4怪物图标、小Boss/污染Boss地块、商店图标和12类 `Installtile_*` 事件图标。
- 四类普通敌人不再使用 CSS 占位:分别解析并接入 `enemy_sword_0 → m_004`、`enemy_straight_0 → m_002`、`enemy_curved_0 → m_010`、`enemy_rush_0 → m_005`。
- 掷骰演出完成前保持棋盘画面,不会抢先切换战斗或事件;主按钮按状态显示 `ROLL / EXIT / GET / FIGHTING...`。
- 骰子定格后明确显示总点数,骑士严格按总点数沿40格固定棋盘逐格顺时针跑动;落地反馈结束后才触发地块。
- 战斗入口锁定 `BATTLE / BOSS BATTLE` 演出;每次攻击按“出手 → 受击 → 伤害数字”表现,最后一击后先播放 `VICTORY / DEFEAT`,再进入装备、源码祝福复生或结算界面。
- 最终结算使用原作胜利骑士与背景素材,奖励数字滚动计入;这些节奏锁不提供跳过入口,调试操作也不能绕过移动和胜负演出。
- 右侧 Boss 区先显示 `0/2 → 1/2`;第二次 ROLL 后切换为红色 `4 → 3 → 2 → 1 → 0` 倒计时。结束时播放 Boss 警示与棋盘震动,并逐个感染 4 个地块;祝福格和起点不会被转换。
- 动画节奏不是只保护移动和 Boss:按钮、骰子、逐格移动、落地、楼层恢复、战斗进出、双方攻击/受击、伤害与治疗数字、装备、祝福、商店购买、事件、源码祝福复生和结算都必须经过至少一段可读反馈;普通游玩没有跳过入口或直接硬切。
- 详细 Evidence → Finding → Implementation → Test 对照见 [`../analysis/reference-study/RESTORATION_REPORT.md`](../analysis/reference-study/RESTORATION_REPORT.md)。
## 操作
- `ROLL`:手动掷双骰。
- 右下丹药:只在战斗中可用;基础恢复为固定 HP,百分比治疗只来自独立的 `Potion_PercentHeal` 强化。
- 左下地图按钮:打开验证调试台;4×速度只属于调试入口。
- 调试台“结束战斗”用于验证击杀、胜利字样和最终结算动画,仍会经过完整表现链。
- 顶部暂停:安全点已自动写入,可返回标题后继续。
- 当前构建没有广告、`▶` 即时增强、广告复活、广告重抽或补次数按钮。
## 结构与 Cocos 迁移
- `src/core.ts`:与 DOM 无关的确定性单局状态机,可迁移为 Cocos Creator 3.x TypeScript service。
- `src/rng.ts`:六条独立 RNG 流及可序列化状态。
- `src/data.ts`:直接读取权威 GDD 的 CSV 配置并校验 10/40/13/32/97/13 等规模。
- `src/storage.ts`:安全点、备份和校验。
- `src/main.ts`:浏览器输入、渲染、轻引导和调试台适配层。
- `styles.css`:750×1334 竖屏、整数像素语言和 HEROLL 内部原型资源引用。
Cocos 移植时保留 `core/rng/types`,将 `main.ts` 的 DOM 事件映射为 Creator Component,将 CSS 布局映射为 Canvas/Widget/Layout 即可;随机、存档和事务语义不需要重写。
## 自动化验收
```powershell
.\Validate-Heroll-MVP.ps1
```
当前基线:
- TypeScript 严格模式编译通过。
- 24/24核心测试通过,包括100万次双骰统计、40格棋盘、1点=1格、1–4怪物数量、小Boss、污染Boss三次跨层污染、事件转荒地、荒地跨层、Boss 2+4循环、反击、旧存档迁移和前三组怪物连续通关。
- Chrome 750×1334 浏览器冒烟通过:开局 → 掷骰 → 商店 → 挖掘 → 装备 → 祝福 → 战斗 → 无广告失败结算 → Boss → 安全点。
- Chrome 758×1352原录像分辨率还原测试通过:固定棋盘、40个独立落点、点数与位移一致、原作1–4怪物及Boss地块图标、骰子时序、战斗角色/敌人和商店3+3结构。
- 控制台错误与资源加载失败均为 0;浏览器在测试结束后关闭。
## 验证边界
- 当前战斗使用 GDD 中明确标注的“验证版基准公式”,没有冒充已经恢复原作精确公式。原作伤害、命中、防御、攻速和 DDA 的黑盒拟合仍受干净录像样本不足限制。
- `功能浏览与关卡流程.mp4` 的火山 13 层主题、`抽卡.mp4` 与 `截图说明.jpg` 中的抽卡/商城/任务属于本验证切片之外;后两者只用于校正像素 UI 语言,不会把局外或付费系统混入 MVP。
- 当前交付用于负责人完成十局现场签收;单局 10–12 分钟、P50 装备查看 6–8 件和三类构筑体验仍需通过真实试玩调参。
- 无账号、服务器、局外成长、体力、抽卡、剧情、BGM、IAP、真实广告 SDK 或用户统计。
+77
View File
@@ -0,0 +1,77 @@
param(
[switch] $SelfTest,
[switch] $PauseOnError
)
$ErrorActionPreference = "Stop"
$projectRoot = $PSScriptRoot
$errorLogPath = Join-Path $projectRoot "launcher-last-error.txt"
try {
$bundledNodePath = Join-Path $env:USERPROFILE ".cache\codex-runtimes\codex-primary-runtime\dependencies\node\bin\node.exe"
$nodeCommand = Get-Command node -ErrorAction SilentlyContinue
$nodePath = if (Test-Path -LiteralPath $bundledNodePath) {
$bundledNodePath
} elseif ($nodeCommand) {
$nodeCommand.Source
} else {
$bundledNodePath
}
if (-not (Test-Path -LiteralPath $nodePath)) {
throw "未找到 Node.js。请安装 Node.js 20+,或从 Codex 桌面环境运行。"
}
Set-Location -LiteralPath $projectRoot
$tscPath = Join-Path $projectRoot "node_modules\typescript\bin\tsc"
if (Test-Path -LiteralPath $tscPath) {
& $nodePath $tscPath -p tsconfig.json
if ($LASTEXITCODE -ne 0) { throw "TypeScript 编译失败。" }
} elseif (-not (Test-Path -LiteralPath (Join-Path $projectRoot "dist\main.js"))) {
throw "没有可用的 dist 构建,也未安装 TypeScript。请先执行 pnpm install。"
}
$server = Start-Process -FilePath $nodePath -ArgumentList @("tools/server.mjs", "--parent-pid", $PID) -WorkingDirectory $projectRoot -WindowStyle Hidden -PassThru
$url = "http://127.0.0.1:4173/mvp/"
try {
$ready = $false
for ($attempt = 0; $attempt -lt 30; $attempt += 1) {
if ($server.HasExited) { throw "本地服务器启动失败,可能是 4173 端口被占用。" }
try {
$response = Invoke-WebRequest -Uri $url -UseBasicParsing -TimeoutSec 1
if ($response.StatusCode -eq 200) { $ready = $true; break }
} catch {
Start-Sleep -Milliseconds 100
}
}
if (-not $ready) { throw "本地服务器未在预期时间内就绪。" }
if (Test-Path -LiteralPath $errorLogPath) {
Remove-Item -LiteralPath $errorLogPath -Force -ErrorAction SilentlyContinue
}
if ($SelfTest) {
Write-Host "SELF-TEST PASS: TypeScript compiled and $url returned HTTP 200." -ForegroundColor Green
return
}
Write-Host "HEROLL 玩法验证 MVP 已启动:$url" -ForegroundColor Green
Write-Host "保持此窗口打开;关闭本窗口或按 Ctrl+C 即可结束本地服务器。"
try {
Start-Process $url
} catch {
Write-Host "浏览器未能自动打开,但游戏服务器仍在正常运行。请复制上面的地址到浏览器。" -ForegroundColor Yellow
}
while (-not $server.HasExited) {
Start-Sleep -Seconds 1
$server.Refresh()
}
} finally {
if (-not $server.HasExited) { Stop-Process -Id $server.Id -Force }
}
} catch {
$details = ($_ | Out-String).Trim()
$details | Set-Content -LiteralPath $errorLogPath -Encoding UTF8
Write-Host "HEROLL MVP 启动失败:" -ForegroundColor Red
Write-Host $details -ForegroundColor Red
Write-Host "错误日志:$errorLogPath" -ForegroundColor Yellow
if ($PauseOnError) { Read-Host "按 Enter 关闭窗口" | Out-Null }
exit 1
}
+52
View File
@@ -0,0 +1,52 @@
$ErrorActionPreference = "Stop"
$projectRoot = $PSScriptRoot
$nodeCommand = Get-Command node -ErrorAction SilentlyContinue
$nodePath = if ($nodeCommand) {
$nodeCommand.Source
} else {
Join-Path $env:USERPROFILE ".cache\codex-runtimes\codex-primary-runtime\dependencies\node\bin\node.exe"
}
if (-not (Test-Path -LiteralPath $nodePath)) { throw "Node.js was not found." }
Set-Location -LiteralPath $projectRoot
& $nodePath "node_modules\typescript\bin\tsc" -p tsconfig.json
if ($LASTEXITCODE -ne 0) { throw "TypeScript compilation failed." }
& $nodePath "tests\core.test.mjs"
if ($LASTEXITCODE -ne 0) { throw "Core tests failed." }
$testUrl = "http://127.0.0.1:4173/mvp/"
$server = $null
$ownsServer = $false
try {
$response = Invoke-WebRequest -Uri $testUrl -UseBasicParsing -TimeoutSec 1
$ready = $response.StatusCode -eq 200
} catch {
$ready = $false
}
if (-not $ready) {
$server = Start-Process -FilePath $nodePath -ArgumentList @("tools/server.mjs") -WorkingDirectory $projectRoot -WindowStyle Hidden -PassThru
$ownsServer = $true
}
try {
if (-not $ready) {
for ($attempt = 0; $attempt -lt 30; $attempt += 1) {
if ($server.HasExited) { throw "Test server failed to start." }
try {
$response = Invoke-WebRequest -Uri $testUrl -UseBasicParsing -TimeoutSec 1
if ($response.StatusCode -eq 200) { $ready = $true; break }
} catch {
Start-Sleep -Milliseconds 100
}
}
}
if (-not $ready) { throw "Test server did not become ready." }
& $nodePath "tests\browser-smoke.mjs"
if ($LASTEXITCODE -ne 0) { throw "Browser smoke test failed." }
& $nodePath "tests\restoration.test.mjs"
if ($LASTEXITCODE -ne 0) { throw "Original-restoration comparison test failed." }
Write-Host "All validation checks passed." -ForegroundColor Green
} finally {
if ($ownsServer -and -not $server.HasExited) { Stop-Process -Id $server.Id -Force }
}
+124
View File
@@ -0,0 +1,124 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no" />
<meta name="theme-color" content="#11101d" />
<title>HEROLL 玩法验证 MVP</title>
<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='10' fill='%23503b62'/%3E%3Ccircle cx='19' cy='19' r='5' fill='%23ffe77f'/%3E%3Ccircle cx='45' cy='45' r='5' fill='%23ffe77f'/%3E%3C/svg%3E" />
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<main id="app" aria-label="HEROLL 玩法验证 MVP">
<div id="legal-strip">仅限本机内部验证 · 不得公开或分发</div>
<section id="start-screen" class="screen active">
<div class="start-art" aria-hidden="true"></div>
<div class="title-mark">
<small>10 层 · 公平双骰 · 单机 H5</small>
<h1>HEROLL</h1>
<p>玩法验证 MVP</p>
</div>
<div class="start-actions panel">
<label class="seed-field">种子 <input id="seed-input" maxlength="32" autocomplete="off" /></label>
<button id="new-run-button" class="button primary">开始新局</button>
<button id="continue-button" class="button secondary" hidden>继续安全点</button>
<button id="clear-save-button" class="button ghost" hidden>清除本局存档</button>
<p class="scope-note">无账号、局外成长、体力、抽卡、付费和广告按钮。</p>
</div>
</section>
<section id="game-screen" class="screen">
<header id="top-hud">
<div class="resource-chip"><span class="point-gem">◆</span><strong id="points-value">0</strong></div>
<div id="auto-indicator" aria-label="自动模式关闭">AUTO</div>
<button id="pause-button" class="icon-button" aria-label="暂停">Ⅱ</button>
</header>
<div id="board-view" class="play-view active">
<div class="scene-back"></div>
<div id="boss-figure" aria-hidden="true"></div>
<div id="board-ring" aria-label="十格逻辑棋盘,四十格表现路径"></div>
<div id="hero-marker" aria-label="玩家位置"></div>
<aside id="floor-meter" data-mode="BUILDUP" aria-label="Boss:玩家 ROLL 两次后开始四次 ROLL 倒计时,结束时转换四个地块">
<span class="boss-meter-icon"></span>
<div class="meter-rail"><i id="floor-meter-fill"></i></div>
<span class="floor-step-icon"></span>
<strong id="floor-value">0/2</strong>
</aside>
<div id="floor-banner" aria-live="polite"></div>
</div>
<div id="battle-view" class="play-view">
<div class="battle-sky"></div>
<div id="battle-versus" aria-label="双方生命对比">
<i class="versus-player"></i><strong>VS</strong><i class="versus-enemy"></i>
</div>
<div id="battle-enemies"></div>
<div id="battle-player" class="combatant player">
<div class="pixel-fighter player-silhouette"></div>
<div class="entity-name">剑士</div>
</div>
<div id="battle-log" aria-live="polite"></div>
<div id="battle-speed" hidden>1×</div>
<div id="battle-announcer" aria-live="assertive"></div>
</div>
<div id="dice-result" aria-live="polite"></div>
<footer id="bottom-hud">
<div class="hp-row">
<span class="level-diamond" id="hud-floor">1</span>
<div class="bar hp"><i id="hp-fill"></i><strong id="hp-label">150/150</strong></div>
<div class="battle-mode-lock" aria-label="战斗速度锁定为一倍">» 🔒</div>
</div>
<div id="gear-slots" aria-label="装备槽">
<button data-slot="WEAPON" title="武器">⚔</button>
<button data-slot="RING" title="戒指">◉</button>
<button data-slot="GAUNTLET" title="护手">✊</button>
<button data-slot="BOOTS" title="鞋子">♞</button>
</div>
<div class="action-row">
<button id="debug-toggle" class="map-button" aria-label="地图与调试台"><span></span></button>
<button id="roll-button" class="roll-button">ROLL</button>
<button id="potion-button" class="potion-button"><span></span><strong id="potion-count">1/1</strong></button>
</div>
</footer>
</section>
<div id="modal-layer" aria-live="polite"></div>
<div id="toast-layer" aria-live="polite"></div>
<div id="tutorial-layer"></div>
<aside id="debug-panel" class="panel" hidden>
<div class="debug-title"><strong>验证调试台</strong><button id="debug-close">×</button></div>
<label>种子 <output id="debug-seed"></output></label>
<label>楼层 <input id="debug-floor" type="number" min="1" max="10" /></label>
<label>点数 <input id="debug-points" type="number" min="0" max="999" /></label>
<div class="debug-grid">
<button data-debug="apply">应用</button>
<button data-debug="chest">宝箱</button>
<button data-debug="campfire">营火</button>
<button data-debug="smithy">铁匠</button>
<button data-debug="blessing">祝福</button>
<button data-debug="dig">挖掘</button>
<button data-debug="merchant">商人</button>
<button data-debug="legend">传奇装备</button>
<button data-debug="lotto">彩票</button>
<button data-debug="blackjack">21点</button>
<button data-debug="claw">抓娃娃</button>
<button data-debug="carriage">黄金马车</button>
<button data-debug="machine">骰子机</button>
<button data-debug="bubble">泡泡龙</button>
<button data-debug="battle">战斗</button>
<button data-debug="win">结束战斗</button>
<button data-debug="death">强制死亡</button>
<button data-debug="boss">跳到 Boss</button>
<button data-debug="speed">战斗 4×</button>
<button data-debug="export">导出日志</button>
</div>
</aside>
</main>
<script type="module" src="./dist/main.js"></script>
</body>
</html>
+17
View File
@@ -0,0 +1,17 @@
{
"name": "heroll-gameplay-validation-mvp",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "tsc -p tsconfig.json",
"dev": "node tools/server.mjs",
"test": "node tests/core.test.mjs",
"test:browser": "node tests/browser-smoke.mjs",
"test:restoration": "node tests/restoration.test.mjs",
"check": "pnpm run build && pnpm run test"
},
"devDependencies": {
"typescript": "5.9.3"
}
}
+24
View File
@@ -0,0 +1,24 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
devDependencies:
typescript:
specifier: 5.9.3
version: 5.9.3
packages:
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
snapshots:
typescript@5.9.3: {}
+28
View File
@@ -0,0 +1,28 @@
let context: AudioContext | null = null;
function ctx(): AudioContext {
context ??= new AudioContext();
return context;
}
export function sound(kind: "roll" | "hit" | "crit" | "heal" | "reward" | "click" | "floor"): void {
try {
const audio = ctx();
const oscillator = audio.createOscillator();
const gain = audio.createGain();
const map = {
roll: [180, 0.08], hit: [105, 0.05], crit: [230, 0.12], heal: [520, 0.12], reward: [700, 0.16], click: [300, 0.035], floor: [440, 0.2],
} as const;
const [frequency, length] = map[kind];
oscillator.type = kind === "hit" ? "square" : "triangle";
oscillator.frequency.setValueAtTime(frequency, audio.currentTime);
if (kind === "roll") oscillator.frequency.exponentialRampToValueAtTime(320, audio.currentTime + length);
gain.gain.setValueAtTime(0.045, audio.currentTime);
gain.gain.exponentialRampToValueAtTime(0.0001, audio.currentTime + length);
oscillator.connect(gain).connect(audio.destination);
oscillator.start();
oscillator.stop(audio.currentTime + length);
} catch {
// 音频是非关键表现层;浏览器拒绝自动播放时静默退化。
}
}
+1439
View File
File diff suppressed because it is too large Load Diff
+148
View File
@@ -0,0 +1,148 @@
import type {
BlessingRow,
DataTables,
EventRow,
FloorRow,
GearCatalogRow,
GearSlot,
GearStatusRow,
PotionRow,
ShopOfferRow,
} from "./types.js";
const BASE = "/design/gameplay-validation/specs";
export function parseCsv(text: string): Record<string, string>[] {
const rows: string[][] = [];
let row: string[] = [];
let cell = "";
let quoted = false;
for (let i = 0; i < text.length; i += 1) {
const char = text[i];
if (quoted) {
if (char === '"' && text[i + 1] === '"') {
cell += '"';
i += 1;
} else if (char === '"') quoted = false;
else cell += char;
} else if (char === '"') quoted = true;
else if (char === ",") {
row.push(cell);
cell = "";
} else if (char === "\n") {
row.push(cell.replace(/\r$/, ""));
rows.push(row);
row = [];
cell = "";
} else cell += char;
}
if (cell.length || row.length) {
row.push(cell.replace(/\r$/, ""));
rows.push(row);
}
const headers = rows.shift() ?? [];
return rows
.filter((values) => values.some(Boolean))
.map((values) => Object.fromEntries(headers.map((header, index) => [header, values[index] ?? ""])));
}
const number = (value: string, fallback = 0): number => {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
};
const bool = (value: string): boolean => value.toLowerCase() === "true";
async function load(name: string): Promise<Record<string, string>[]> {
const response = await fetch(`${BASE}/${name}`, { cache: "no-store" });
if (!response.ok) throw new Error(`配置加载失败: ${name} (${response.status})`);
return parseCsv(await response.text());
}
export async function loadDataTables(): Promise<DataTables> {
const [floorRows, gearRows, statusRows, blessingRows, potionRows, shopRows, eventRows] = await Promise.all([
load("floor-balance.csv"),
load("gear-catalog.csv"),
load("gear-status.csv"),
load("blessings.csv"),
load("potions.csv"),
load("shop-offers.csv"),
load("events.csv"),
]);
const floors: FloorRow[] = floorRows.map((row) => ({
floor: number(row.floor),
attackPower: number(row.attack_power),
health: number(row.health),
defense: number(row.defense),
attackSpeed: number(row.attack_speed, 1),
evasion: number(row.evasion),
aim: number(row.aim),
}));
const gear: GearCatalogRow[] = gearRows.map((row) => ({
catalogId: number(row.catalog_id),
stringId: row.string_id,
slot: row.slot as GearSlot,
iconName: row.icon_name,
indexInSlot: number(row.index_in_slot),
}));
const gearStatuses: GearStatusRow[] = statusRows.map((row) => ({
statusId: number(row.status_id),
stringId: row.string_id,
baseMin: number(row.base_min),
baseMax: number(row.base_max),
plusValue: number(row.plus_value),
isPercent: bool(row.is_percent),
}));
const blessings: BlessingRow[] = blessingRows.map((row) => ({
blessingId: row.blessing_id,
stringId: row.string_id,
enabled: bool(row.enabled),
weight: number(row.prob_weight, 1),
linearValue: number(row.linear_value),
values: row.values ? row.values.split("|").map((item) => number(item)) : [],
stackable: bool(row.stackable),
effectContract: row.effect_contract,
}));
const potions: PotionRow[] = potionRows.map((row) => ({
recordId: row.record_id,
recordType: row.record_type,
level: number(row.level),
value: number(row.value),
valueA: number(row.value_a),
valueB: number(row.value_b),
}));
const shop: ShopOfferRow[] = shopRows.map((row) => ({
offerId: number(row.offer_id),
stringId: row.string_id,
itemType: row.item_type as ShopOfferRow["itemType"],
itemId: row.item_id,
fixedKey: row.fixed_key,
fixedSort: number(row.fixed_sort),
fixedSlot: number(row.fixed_slot, -1),
price: number(row.price),
buyCount: number(row.buy_count, 1),
}));
const events: EventRow[] = eventRows.map((row) => ({
eventId: row.event_id,
tileId: row.tile_id,
stringId: row.string_id,
naturalCore: bool(row.natural_core),
boostPlacement: row.boost_placement,
baseRule: row.base_rule,
}));
const tables = { floors, gear, gearStatuses, blessings, potions, shop, events };
assertDataTables(tables);
return tables;
}
export function assertDataTables(data: DataTables): void {
const assertions: [boolean, string][] = [
[data.floors.length === 10, `楼层配置应为10行,实际${data.floors.length}`],
[data.gear.length === 40, `装备配置应为40件,实际${data.gear.length}`],
[data.gearStatuses.length === 13, `装备属性应为13条,实际${data.gearStatuses.length}`],
[data.blessings.filter((item) => item.enabled).length === 32, "启用祝福应为32条"],
[data.shop.length === 97, `商店商品应为97条,实际${data.shop.length}`],
[data.events.length === 13, `事件应为13条,实际${data.events.length}`],
];
const failed = assertions.find(([ok]) => !ok);
if (failed) throw new Error(`GDD配置校验失败:${failed[1]}`);
}
+911
View File
@@ -0,0 +1,911 @@
import { sound } from "./audio.js";
import { GameEngine } from "./core.js";
import { loadDataTables } from "./data.js";
import { clearRun, loadRun, markTutorialSeen, saveRun, tutorialSeen } from "./storage.js";
import type { DataTables, EngineEvent, EventInstance, GearInstance, GearQuality, GearSlot, RunState, ShopOfferRow } from "./types.js";
const $ = <T extends HTMLElement>(selector: string): T => {
const element = document.querySelector<T>(selector);
if (!element) throw new Error(`缺少界面节点 ${selector}`);
return element;
};
const escapeHtml = (value: unknown): string => String(value)
.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
const clamp = (value: number, min: number, max: number): number => Math.max(min, Math.min(max, value));
const startScreen = $("#start-screen");
const gameScreen = $("#game-screen");
const seedInput = $("#seed-input") as HTMLInputElement;
const newButton = $("#new-run-button") as HTMLButtonElement;
const continueButton = $("#continue-button") as HTMLButtonElement;
const clearButton = $("#clear-save-button") as HTMLButtonElement;
const boardRing = $("#board-ring");
const heroMarker = $("#hero-marker");
const boardView = $("#board-view");
const battleView = $("#battle-view");
const battleEnemies = $("#battle-enemies");
const battleLog = $("#battle-log");
const diceResult = $("#dice-result");
const floorBanner = $("#floor-banner");
const modalLayer = $("#modal-layer");
const toastLayer = $("#toast-layer");
const tutorialLayer = $("#tutorial-layer");
const rollButton = $("#roll-button") as HTMLButtonElement;
const potionButton = $("#potion-button") as HTMLButtonElement;
const debugPanel = $("#debug-panel");
const debugFloor = $("#debug-floor") as HTMLInputElement;
const debugPoints = $("#debug-points") as HTMLInputElement;
let data: DataTables;
let engine: GameEngine | null = null;
let lastFrame = performance.now();
let battleSpeed = 1;
let savePending = false;
let restored: RunState | null = null;
let paused = false;
let presentationLock = false;
let presentationVisualIndex: number | null = null;
let presentationFloor: number | null = null;
let presentationPoints: number | null = null;
let presentationBossGauge: RunState["bossGauge"] | null = null;
let presentationTiles: RunState["tiles"] | null = null;
let dicePresentation: Promise<void> = Promise.resolve();
let battleIntroUntil = 0;
let battleOutroUntil = 0;
const wait = (milliseconds: number): Promise<void> => new Promise((resolve) => window.setTimeout(resolve, milliseconds));
const EVENT_NAMES: Record<string, string> = {
"EV-MERCHANT": "商人", "EV-137": "铁匠铺", "EV-138": "营火", "EV-139": "传奇装备",
"EV-140": "挖掘", "EV-141": "宝箱", "EV-142": "彩票", "EV-143": "21点",
"EV-144": "祝福点", "EV-151": "抓娃娃", "EV-152": "黄金马车", "EV-164": "骰子机", "EV-166": "泡泡龙",
};
const BLESSING_NAMES: Record<string, string> = {
AttackShield: "攻击护盾", CritHeal: "暴击治疗", CritDamageUp: "暴击伤害", Revive: "复生祝福",
FirstAttackDMGUp: "先手强击", DMGUpAfterSecond: "蓄势增伤", ASPUpAfterSecond: "蓄势攻速", DoubleHeal: "双重治疗",
LowHPASPUp: "残血狂热", BattleStartHeal: "战前治疗", LuckyHit: "幸运一击", DiceSumDMGUp: "高点增伤",
DiceSumShield: "点数护盾", KillHPUp: "击杀成长", ReduceEnemyDEF: "破甲", EnemyCountStatUp: "以寡敌众",
OddDiceCritUp: "奇数暴击", KillInTimeGetGear: "速胜战利品", HealAfterSecond: "周期治疗", LightingEverySec: "连锁闪电",
KillForHeroGear: "英雄猎手", KillAllEmeny: "清场", PointSupply: "点数补给", DicePotionGet: "对子药水",
KillPotionGet: "击杀药水", ShopFreeReroll: "免费刷新", EmptyTripleBuild: "空地建设", EvaHeal: "闪避治疗",
BattleEndHeal: "战后治疗", BurnEnemyDead: "燃烧爆裂", FirstEnemyAllBurn: "引燃", ThirdDieReplace: "第三骰替换",
};
const QUALITY_COLOR: Record<GearQuality, string> = {
COMMON: "#858585", UNCOMMON: "#45aa62", RARE: "#4587d6", EPIC: "#a34bd0", LEGENDARY: "#eea52c",
};
const QUALITY_NAME: Record<GearQuality, string> = {
COMMON: "普通", UNCOMMON: "优秀", RARE: "稀有", EPIC: "史诗", LEGENDARY: "传奇",
};
const STAT_NAMES: Record<string, string> = {
ATTACK_POWER: "攻击", HEALTH: "生命", AIM: "命中", DEFENSE: "防御", EVASION: "闪避",
REGEN_PER_SECOND: "每秒回复", TOTAL_DAMAGE: "总伤害", TRUE_DAMAGE: "真实伤害", KILL_HEAL: "击杀回复",
COUNTER_ATTACK: "反击", ATTACK_SPEED: "攻速", CRITICAL_RATE: "暴击", LIFE_STEAL: "吸血",
};
function generatedSeed(): string {
const now = new Date();
return `HEROLL-${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}-${Math.random().toString(36).slice(2, 7).toUpperCase()}`;
}
function showGame(): void {
startScreen.classList.remove("active");
gameScreen.classList.add("active");
renderAll();
}
function showStart(): void {
gameScreen.classList.remove("active");
startScreen.classList.add("active");
}
function scheduleSave(): void {
if (!engine || savePending) return;
savePending = true;
queueMicrotask(async () => {
savePending = false;
if (engine) await saveRun(engine.snapshot());
});
}
function act(action: () => void, render = true): void {
if (!engine) return;
try {
action();
processEvents();
if (render) renderAll();
} catch (error) {
toast(error instanceof Error ? error.message : String(error));
}
}
function processEvents(events?: EngineEvent[]): void {
if (!engine) return;
for (const event of events ?? engine.drainEvents()) handleEngineEvent(event);
}
function showBattleAnnouncement(text: string, tone: "intro" | "victory" | "defeat", duration: number): void {
const announcer = $("#battle-announcer");
announcer.textContent = text;
announcer.dataset.tone = tone;
announcer.classList.remove("show");
void announcer.offsetWidth;
announcer.classList.add("show");
window.setTimeout(() => announcer.classList.remove("show"), duration);
}
function startBattleIntro(): void {
battleIntroUntil = performance.now() + 850;
const title = engine?.state.phase === "BOSS" ? "BOSS BATTLE" : engine?.state.battle?.isMiniBoss ? "MINI BOSS" : "BATTLE!";
showBattleAnnouncement(title, "intro", 760);
}
function startBattleOutro(reason: string): void {
battleOutroUntil = performance.now() + 1050;
const defeated = reason === "REVIVE_OFFER" || reason === "RUN_DEFEAT";
if (reason === "RUN_VICTORY" || reason === "RUN_DEFEAT") {
debugPanel.hidden = true;
tutorialLayer.innerHTML = "";
toastLayer.innerHTML = "";
}
battleView.classList.add("active", "battle-outro");
boardView.classList.remove("active");
showBattleAnnouncement(defeated ? "DEFEAT" : "VICTORY!", defeated ? "defeat" : "victory", 980);
}
function handleEngineEvent(event: EngineEvent): void {
switch (event.type) {
case "STATE_CHANGED": {
scheduleSave();
const reason = String(event.payload?.reason ?? "");
if (reason === "BATTLE_START") startBattleIntro();
if (["BATTLE_VICTORY", "RUN_VICTORY", "REVIVE_OFFER", "RUN_DEFEAT"].includes(reason)) startBattleOutro(reason);
break;
}
case "DICE_ROLLED":
sound("roll");
gameScreen.classList.add("showing-dice");
diceResult.dataset.sum = String(Number(event.payload?.a ?? 0) + Number(event.payload?.b ?? 0));
diceResult.innerHTML = `<span class="die-face" data-die="${event.payload?.a}" style="--die-image:url('../analysis/visual-study/unity-samples/ui/sharedassets3__dice_red_${event.payload?.a}.png')"></span><span class="die-face" data-die="${event.payload?.b}" style="--die-image:url('../analysis/visual-study/unity-samples/ui/sharedassets3__dice_blue_${event.payload?.b}.png')"></span><strong class="dice-sum">${diceResult.dataset.sum}</strong>`;
dicePresentation = wait(1050).then(() => {
diceResult.innerHTML = "";
delete diceResult.dataset.sum;
gameScreen.classList.remove("showing-dice");
});
break;
case "FLOOR_ENTERED":
sound("floor");
floorBanner.textContent = `第 ${event.payload?.floor} 层 · 恢复 30%`;
floorBanner.classList.remove("show");
requestAnimationFrame(() => floorBanner.classList.add("show"));
break;
case "BOSS_GAUGE_CHANGED": {
presentationBossGauge = {
mode: "BUILDUP",
playerRolls: Number(event.payload?.playerRolls ?? 0),
buildupRollsRequired: Number(event.payload?.required ?? 2),
countdownRemaining: 0,
countdownRolls: 4,
bossActions: engine?.state.bossGauge.bossActions ?? 0,
};
renderBossMeter(presentationBossGauge);
floorBanner.textContent = `BOSS 蓄势 · ${presentationBossGauge.playerRolls}/${presentationBossGauge.buildupRollsRequired}`;
floorBanner.classList.remove("show");
requestAnimationFrame(() => floorBanner.classList.add("show"));
pulseBossMeter();
break;
}
case "BOSS_COUNTDOWN_STARTED":
case "BOSS_COUNTDOWN_TICK": {
presentationBossGauge = {
mode: "COUNTDOWN",
playerRolls: 0,
buildupRollsRequired: 2,
countdownRemaining: Number(event.payload?.remaining ?? 0),
countdownRolls: Number(event.payload?.total ?? 4),
bossActions: engine?.state.bossGauge.bossActions ?? 0,
};
renderBossMeter(presentationBossGauge);
floorBanner.textContent = presentationBossGauge.countdownRemaining > 0
? `BOSS 行动倒计时 · 还剩 ${presentationBossGauge.countdownRemaining} 次 ROLL`
: "BOSS ACTION · 倒计时结束";
floorBanner.classList.remove("show");
requestAnimationFrame(() => floorBanner.classList.add("show"));
pulseBossMeter();
sound("floor");
break;
}
case "BOSS_ACTION": break;
case "DAMAGE":
sound(event.payload?.critical ? "crit" : "hit");
animateDamage(event.payload ?? {});
break;
case "HEAL": sound("heal"); break;
case "GEAR_REWARD":
case "BLESSING_REWARD": sound("reward"); break;
case "EVENT_RESULT": toast(String(event.payload?.text ?? "事件结算")); break;
case "BOOST_COMMITTED": break;
case "SOURCE_REVIVE": toast(`复生祝福先结算:恢复 ${event.payload?.hp} 生命`); break;
case "FORTUNE_TRIGGERED": {
const stat = { damagePercent: "伤害", maxHpPercent: "最大生命", defensePercent: "防御" }[String(event.payload?.stat)] ?? "属性";
toast(`机缘:${stat} +10%,下一场战斗获得先攻`);
sound("reward");
break;
}
case "FORTUNE_STRIKE": toast(`机缘先攻:造成 ${event.payload?.damage} 伤害`); break;
case "FORTUNE_EXPIRED": toast("未落入战斗格,机缘先攻已消散"); break;
case "COUNTER":
animateDamage({ actor: "player-counter", target: event.payload?.target, damage: event.payload?.damage, critical: false });
toast(`反击 ${event.payload?.damage} 伤害`);
sound("hit");
break;
case "TILE_INSTALLED": toast("有益地块已安装到棋盘"); break;
case "TILE_BECAME_WASTELAND": toast(`地块 ${event.payload?.index} 已变为荒地`); break;
case "BOSS_TILE_POLLUTION": toast(`污染Boss生效:${event.payload?.changed} 个怪物地块污染+1,剩余 ${event.payload?.remaining} 次`); break;
}
}
function animateDamage(payload: Record<string, unknown>): void {
const actor = String(payload.actor ?? "");
const targetId = String(payload.target ?? "");
const actorNode = actor.startsWith("player") ? battleView.querySelector<HTMLElement>(".player .pixel-fighter") : battleView.querySelector<HTMLElement>(`[data-enemy-id="${CSS.escape(actor)}"] .pixel-fighter`);
const targetNode = targetId === "player" ? battleView.querySelector<HTMLElement>(".player .pixel-fighter") : battleView.querySelector<HTMLElement>(`[data-enemy-id="${CSS.escape(targetId)}"] .pixel-fighter`);
if (actorNode) {
actorNode.classList.remove("attack");
requestAnimationFrame(() => actorNode.classList.add("attack"));
setTimeout(() => actorNode.classList.remove("attack"), 260);
}
if (targetNode) {
targetNode.classList.remove("hit");
requestAnimationFrame(() => targetNode.classList.add("hit"));
setTimeout(() => targetNode.classList.remove("hit"), 210);
const target = targetNode.getBoundingClientRect();
const view = battleView.getBoundingClientRect();
const number = document.createElement("span");
number.className = `damage-number ${payload.critical ? "critical" : ""}`;
number.textContent = `${payload.critical ? "CRIT " : ""}${Math.round(Number(payload.damage ?? 0))}`;
number.style.left = `${target.left - view.left + target.width / 2}px`;
number.style.top = `${target.top - view.top}px`;
battleView.append(number);
setTimeout(() => number.remove(), 700);
}
}
function toast(message: string): void {
const node = document.createElement("div");
node.className = "toast";
node.textContent = message;
toastLayer.append(node);
setTimeout(() => node.remove(), 2200);
}
function tutorial(key: string, text: string, title: string): void {
if (tutorialSeen(key)) return;
markTutorialSeen(key);
tutorialLayer.innerHTML = `<div class="tutorial-bubble"><strong>${escapeHtml(title)}</strong><p>${escapeHtml(text)}</p></div>`;
setTimeout(() => { tutorialLayer.innerHTML = ""; }, 4200);
}
function boardPosition(index: number): [number, number] {
// 原作从底部 START 沿左边向上前进:底 → 左 → 顶 → 右 → 底,即屏幕顺时针。
const corners: [number, number][] = [[50, 88], [12, 50], [50, 12], [88, 50]];
const normalized = ((index % 40) + 40) % 40;
const side = Math.floor(normalized / 10);
const progress = (normalized % 10) / 10;
const start = corners[side];
const end = corners[(side + 1) % corners.length];
return [start[0] + (end[0] - start[0]) * progress, start[1] + (end[1] - start[1]) * progress];
}
function placeHeroAtVisualIndex(index: number): void {
const [left, top] = boardPosition(index);
boardRing.style.setProperty("--camera-rotation", "0deg");
heroMarker.style.setProperty("--hero-counter-rotation", "0deg");
heroMarker.style.left = `${left}%`;
heroMarker.style.top = `${top}%`;
boardRing.querySelector(".board-tile.current")?.classList.remove("current");
boardRing.querySelector(`[data-visual-index="${index}"]`)?.classList.add("current");
}
function pulseValue(element: HTMLElement, className: "value-gain" | "value-loss"): void {
element.classList.remove("value-gain", "value-loss");
void element.offsetWidth;
element.classList.add(className);
window.setTimeout(() => element.classList.remove(className), 380);
}
function setChangingText(element: HTMLElement, text: string, numericValue: number): void {
const previous = Number(element.dataset.numericValue);
if (element.textContent !== text) {
element.textContent = text;
if (Number.isFinite(previous)) pulseValue(element, numericValue >= previous ? "value-gain" : "value-loss");
}
element.dataset.numericValue = String(numericValue);
}
function tileVisual(tile: RunState["tiles"][number], _floor: number): { label: string; image?: string } {
const boardAsset = (file: string): string => `../design/ue-ui/assets/original/board/${file}`;
if (tile.kind === "START") return { label: "起点" };
if (tile.kind === "EMPTY") return { label: "荒地" };
if (tile.kind === "BATTLE") {
const enemyIcon = [
"ea03894ec24c74f4095de1ea7b5a2659__icon_NormalEnemy_1.png",
"9bb18c908db3fb6448042afb9752cdc8__icon_NormalEnemy_2.png",
"716296b43a497b949852fef9c59d6871__icon_NormalEnemy_3.png",
"4856a4e7080ad8b4d9c296ffb5119dfc__icon_NormalEnemy_4.png",
][Math.max(0, Math.min(3, (tile.enemyCount ?? 1) - 1))];
const pollution = tile.pollutionLevel ? ` · 污染+${tile.pollutionLevel}` : "";
return { label: `${tile.enemyCount ?? 1}怪物地块${pollution}`, image: boardAsset(enemyIcon) };
}
if (tile.kind === "MINIBOSS") return { label: `小Boss地块 · ${tile.enemyCount ?? 1}只`, image: boardAsset("ce3b61dc36c187c4781d4b3168051d9f__tile_bossattack_enemy.png") };
if (tile.kind === "INFECTED_BOSS") return { label: `污染Boss地块 · 剩余${tile.infectionTurnsRemaining ?? 0}次污染`, image: boardAsset("2cd4cfb302836b249a408e6e2e374afd__tile_bossattack_infected.png") };
if (tile.kind === "MERCHANT") return { label: "商人", image: boardAsset("58bb69a9e461f0c4386f10032c6c506b__icon_start_shop.png") };
const eventArt: Record<string, string> = {
"EV-137": "fe86e2927f09e2547a163dc598a1ff6b__Installtile_smithy.png",
"EV-138": "c88d98a79ea71a246b9c7fbf93ac1f53__Installtile_healing.png",
"EV-139": "a37e5d662c3684f46a2693e15d612d3e__Installtile_legend.png",
"EV-140": "fe7a5cb3a642a924587c7bb444f3f3b1__Installtile_corpse.png",
"EV-141": "7237b87adad60774eb3589f0347261ed__Installtile_treasure_chest.png",
"EV-142": "be2598339e6274d4f80afa8df58f699d__Installtile_lotto.png",
"EV-143": "bd7818e8419390b478c433459daadce2__Installtile_blackjack.png",
"EV-144": "d9b9557b551791545b76fc41d5c993ca__Installtile_power_star.png",
"EV-151": "21ccfa151dc151949b3edcc7867e17dc__Installtile_clwa.png",
"EV-152": "70d2344590cba584a93a608274b01d20__Installtile_goldCarriage.png",
"EV-164": "b631583d0764e804ca5dd3f9b7bac0dd__Installtile_dice_machine.png",
"EV-166": "b18a620bc0c7d774c948d5f61f0f5d74__Installtile_bubble_shooter.png",
};
const image = eventArt[tile.eventId ?? ""];
return { label: tile.eventId ? EVENT_NAMES[tile.eventId] ?? tile.eventId : "事件地块", image: image ? boardAsset(image) : undefined };
}
function renderBoard(): void {
if (!engine) return;
const state = engine.state;
const tiles = presentationTiles ?? state.tiles;
const currentVisualIndex = presentationVisualIndex ?? state.tileIndex;
boardRing.innerHTML = '<div class="board-path"></div>';
for (let visualIndex = 0; visualIndex < 40; visualIndex += 1) {
const tile = tiles[visualIndex];
const [left, top] = boardPosition(visualIndex);
const button = document.createElement("div");
const kind = tile?.kind ?? "EMPTY";
const visual = tile ? tileVisual(tile, state.floor) : undefined;
button.className = `board-tile logical ${visualIndex % 3 === 0 ? "stone" : "purple"} ${kind.toLowerCase()} ${kind === "EVENT" ? "good" : ""} ${tile?.pollutionLevel ? "polluted" : ""} ${visualIndex === currentVisualIndex ? "current" : ""}`;
button.dataset.visualIndex = String(visualIndex);
button.style.left = `${left}%`;
button.style.top = `${top}%`;
button.title = visual?.label ?? kind;
button.setAttribute("aria-label", visual?.label ?? kind);
button.innerHTML = `<span class="tile-icon">${visual?.image ? `<img class="tile-art" src="${visual.image}" alt="${escapeHtml(visual.label)}" />` : ""}</span><span class="tile-index">${tile?.index === 0 ? "START" : ""}</span>`;
boardRing.append(button);
}
boardRing.append(heroMarker);
placeHeroAtVisualIndex(currentVisualIndex);
}
function renderHud(): void {
if (!engine) return;
const state = engine.state;
const stats = engine.effectiveStats();
const shownPoints = presentationPoints ?? state.points;
const shownFloor = presentationFloor ?? state.floor;
setChangingText($("#points-value"), String(shownPoints), shownPoints);
renderBossMeter(presentationBossGauge ?? state.bossGauge);
$("#hud-floor").textContent = String(shownFloor);
setChangingText($("#hp-label"), `${Math.ceil(state.currentHp)}/${Math.ceil(stats.maxHp)}${state.shield > 0 ? ` +${Math.ceil(state.shield)}` : ""}`, state.currentHp);
($("#hp-fill") as HTMLElement).style.width = `${clamp(state.currentHp / Math.max(1, stats.maxHp) * 100, 0, 100)}%`;
$("#potion-count").textContent = `${state.potion.charges}/${state.potion.maxCharges}`;
potionButton.title = `唯一丹药 · 基础恢复 ${state.potion.healAmount} HP`;
rollButton.disabled = state.phase !== "READY" || paused || presentationLock;
const actionLabel: Record<string, string> = {
READY: "ROLL", ROLLING: "ROLL", MOVING: "MOVING...", THIRD_DIE: "SELECT",
BATTLE: "FIGHTING...", BOSS: "FIGHTING...", EVENT: "EXIT", GEAR_REWARD: "EXIT",
BLESSING_REWARD: "GET", REVIVE: "REVIVE", VICTORY: "EXIT", DEFEAT: "EXIT",
};
rollButton.textContent = actionLabel[state.phase] ?? "ROLL";
if (gameScreen.classList.contains("showing-dice")) rollButton.textContent = "ROLLING...";
if (presentationLock && !gameScreen.classList.contains("showing-dice")) rollButton.textContent = "MOVING...";
rollButton.classList.toggle("ready", state.phase === "READY" && !paused && !presentationLock);
potionButton.disabled = !["BATTLE", "BOSS"].includes(state.phase) || state.potion.charges <= 0 || paused;
for (const button of document.querySelectorAll<HTMLButtonElement>("#gear-slots button")) {
const slot = button.dataset.slot as GearSlot;
const gear = state.gear[slot];
button.classList.toggle("equipped", Boolean(gear));
button.style.setProperty("--quality", gear ? QUALITY_COLOR[gear.quality] : "#685876");
button.title = gear ? `${QUALITY_NAME[gear.quality]} ${STAT_NAMES[gear.statId]} +${gear.value}${gear.isPercent ? "%" : ""}` : button.title;
}
$("#debug-seed").textContent = state.seed;
debugFloor.value = String(state.floor);
debugPoints.value = String(state.points);
}
function renderBattle(): void {
if (!engine) return;
const state = engine.state;
const holdingOutro = battleOutroUntil > performance.now();
const inBattle = !presentationLock && (holdingOutro || Boolean(state.battle && ["BATTLE", "BOSS", "REVIVE"].includes(state.phase)));
boardView.classList.toggle("active", !inBattle);
battleView.classList.toggle("active", inBattle);
if (holdingOutro) return;
if (!state.battle) return;
const maxEnemyHp = state.battle.enemies.reduce((sum, enemy) => sum + enemy.maxHp, 0);
const enemyHp = state.battle.enemies.reduce((sum, enemy) => sum + Math.max(0, enemy.hp), 0);
const playerHpPercent = clamp(state.currentHp / Math.max(1, engine.effectiveStats().maxHp) * 100, 0, 100);
const enemyHpPercent = clamp(enemyHp / Math.max(1, maxEnemyHp) * 100, 0, 100);
(battleView.querySelector(".versus-player") as HTMLElement).style.width = `${playerHpPercent}%`;
(battleView.querySelector(".versus-enemy") as HTMLElement).style.width = `${enemyHpPercent}%`;
battleEnemies.innerHTML = state.battle.enemies.map((enemy, index) => {
const hp = clamp(enemy.hp / Math.max(1, enemy.maxHp) * 100, 0, 100);
const defeated = enemy.hp <= 0 || enemy.id.endsWith(":dead");
const presentationId = enemy.id.replace(/:dead$/, "");
return `<div class="combatant enemy ${enemy.role.toLowerCase()} ${defeated ? "defeated" : ""}" data-enemy-id="${escapeHtml(presentationId)}" style="top:${39 + index * 14}%">
<div class="pixel-fighter"></div><div class="entity-name">${escapeHtml(enemy.name)}</div>
<div class="entity-hp"><i style="width:${hp}%"></i></div>
</div>`;
}).join("");
battleLog.textContent = state.battle.log[state.battle.log.length - 1] ?? "战斗中";
const player = $("#battle-player");
player.style.opacity = state.currentHp <= 0 ? "0.45" : "1";
$("#battle-speed").textContent = `${battleSpeed}×`;
$("#battle-speed").toggleAttribute("hidden", battleSpeed === 1);
}
function gearCard(gear: GearInstance): string {
if (!engine) return "";
const current = engine.state.gear[gear.slot];
const delta = current?.statId === gear.statId ? gear.value - current.value : gear.value;
return `<div class="gear-card" style="--quality:${QUALITY_COLOR[gear.quality]}">
<div class="gear-icon">${{ WEAPON: "⚔", RING: "◉", GAUNTLET: "✊", BOOTS: "♞" }[gear.slot]}</div>
<h3>${QUALITY_NAME[gear.quality]} · ${escapeHtml(gear.stringId)}</h3>
<p><strong>${STAT_NAMES[gear.statId] ?? gear.statId} +${gear.value}${gear.isPercent ? "%" : ""}</strong></p>
<p class="stat-delta ${delta >= 0 ? "positive" : "negative"}">相对当前同属性 ${delta >= 0 ? "+" : ""}${Math.round(delta * 10) / 10}</p>
<small>未处理时关闭:自动分解为 ${gear.salvageValue} 点</small>
</div>`;
}
function modal(title: string, body: string, actions = "", className = ""): string {
return `<section class="modal ${className}"><h2>${escapeHtml(title)}</h2>${body}${actions ? `<div class="modal-actions">${actions}</div>` : ""}</section>`;
}
function renderModal(): void {
if (!engine || paused) return;
if (presentationLock || battleOutroUntil > performance.now()) {
modalLayer.innerHTML = "";
return;
}
const state = engine.state;
let html = "";
if (state.phase === "THIRD_DIE" && state.pendingThirdDie) {
const roll = state.pendingThirdDie;
html = modal("第三骰替换", `<p>基础骰结果不可重掷。第三骰只能替换 A、替换 B 或放弃。</p><div class="third-dice-row"><div class="third-die">${roll.a}</div><div class="third-die">${roll.b}</div><div class="third-die legend">${roll.c}</div></div>`,
`<button data-action="third-a">替换 A</button><button data-action="third-b">替换 B</button><button class="secondary" data-action="third-keep">放弃</button>`);
} else if (state.phase === "GEAR_REWARD" && state.pendingGear) {
html = modal("装备掉落", gearCard(state.pendingGear), `<button data-action="gear-equip">装备</button><button class="secondary" data-action="gear-salvage">分解 +${state.pendingGear.salvageValue}</button>`);
tutorial("gear", "新装备飞入固定槽,旧装备自动掉出并分解;未处理的掉落也会自动分解。", "换装规则");
} else if (state.phase === "BLESSING_REWARD" && state.pendingBlessing) {
const row = data.blessings.find((item) => item.blessingId === state.pendingBlessing)!;
html = modal("祝福揭示", `<div class="blessing-card"><div class="blessing-icon">✦</div><h3>${escapeHtml(BLESSING_NAMES[row.stringId] ?? row.stringId)}</h3><p>${escapeHtml(row.effectContract)}</p><small>单卡揭示 · 选择后直接加入本局</small></div>`, `<button data-action="blessing-accept">接受祝福</button>`);
tutorial("blessing", "祝福是本局构筑。第三骰祝福会在正常双骰之后提供一次替换决策。", "祝福构筑");
} else if (state.phase === "VICTORY") {
html = modal("STAGE CLEAR", `<div class="result-hero victory" aria-hidden="true"></div><p class="result-copy">第 10 层守卫已被击败</p><div class="result-rewards"><span>◆ <strong class="count-up" data-count="${state.points}">0</strong></span><span>祝福 <strong class="count-up" data-count="${state.blessings.length}">0</strong></span><span>楼层 <strong class="count-up" data-count="10">0</strong></span></div><small class="result-seed">种子 ${escapeHtml(state.seed)}</small>`, `<button data-action="new-run">领取并再来一局</button><button class="secondary" data-action="export">导出技术日志</button>`, "result-modal victory-modal");
} else if (state.phase === "DEFEAT") {
html = modal("本局结束", `<div class="result-hero defeat" aria-hidden="true"></div><p class="result-copy">登天之路止步于第 ${state.floor} 层</p><div class="result-rewards"><span>◆ <strong class="count-up" data-count="${state.points}">0</strong></span><span>祝福 <strong class="count-up" data-count="${state.blessings.length}">0</strong></span><span>楼层 <strong class="count-up" data-count="${state.floor}">0</strong></span></div>`, `<button data-action="retry">相同种子重试</button><button class="secondary" data-action="home">返回标题</button>`, "result-modal defeat-modal");
} else if (state.phase === "EVENT" && state.event) {
html = renderEventModal(state.event);
}
modalLayer.innerHTML = html;
if (state.phase === "VICTORY" || state.phase === "DEFEAT") animateSettlementNumbers();
}
function animateSettlementNumbers(): void {
for (const node of modalLayer.querySelectorAll<HTMLElement>(".count-up")) {
const target = Number(node.dataset.count ?? 0);
const started = performance.now();
const update = (now: number): void => {
const progress = clamp((now - started) / 850, 0, 1);
const eased = 1 - (1 - progress) ** 3;
node.textContent = String(Math.round(target * eased));
if (progress < 1 && node.isConnected) requestAnimationFrame(update);
};
requestAnimationFrame(update);
}
}
function renderEventModal(event: EventInstance): string {
switch (event.eventId) {
case "EV-MERCHANT": return renderShop();
case "EV-137": {
const gearButtons = Object.entries(engine!.state.gear).map(([slot, gear]) => gear
? `<button data-action="smith" data-slot="${slot}">${slot}<small>${QUALITY_NAME[gear.quality]} ${STAT_NAMES[gear.statId]} +${gear.value}</small></button>`
: `<button disabled>${slot}<small>空槽</small></button>`).join("");
return modal("铁匠铺", `<div class="smith-scene" aria-hidden="true"></div><p>升阶概率:80% / 50% / 30% / 10%。失败时装备销毁并自动分解。</p>`, `${gearButtons}<button class="secondary" data-action="event-close">保留并离开</button>`, "event-modal smith-modal");
}
case "EV-138": return modal("营火", `<div class="campfire-scene" aria-hidden="true"></div><p>基础恢复 30% 最大生命。</p>`, `<button data-action="event-claim">休息</button>`, "event-modal campfire-modal");
case "EV-140": {
const opened = event.state.opened as number[] ?? [];
const cells = Array.from({ length: 25 }, (_, index) => `<button class="dig-cell ${opened.includes(index) ? "revealed" : ""}" data-action="dig" data-index="${index}" ${opened.includes(index) || Number(event.state.attempts) <= 0 ? "disabled" : ""}>${opened.includes(index) ? "◆" : "?"}</button>`).join("");
return modal("挖掘", `<p>剩余次数:<strong>${event.state.attempts}</strong></p><div class="dig-grid">${cells}</div>`, `<button class="secondary" data-action="event-close">结束挖掘</button>`);
}
case "EV-141": return modal("宝箱", `<p>找到装着装备的箱子。</p><div class="chest-scene" aria-hidden="true"><span></span></div>`, `<button data-action="event-claim">OPEN</button><button class="secondary" data-action="event-close">EXIT</button>`, "event-modal chest-modal");
case "EV-144": return modal("祝福点", `<p>揭示一张启用祝福;换卡后的结果必须接受。</p>`, `<button data-action="event-claim">揭示祝福</button><button class="secondary" data-action="event-close">离开</button>`);
case "EV-139": return modal("传奇装备", `<p>生成一件传奇品质局内装备。</p>`, `<button data-action="event-claim">领取</button>`);
case "EV-142": return modal("彩票", `<p>一次开奖,高风险点数回报。</p>`, `<button data-action="event-claim">开奖</button>`);
case "EV-143": return modal("21点", `<p>一次快速对局:不超过 21 且高于庄家即可获胜。</p>`, `<button data-action="event-claim">发牌</button>`);
case "EV-151": return modal("抓娃娃", `<p>剩余抓取:${event.state.attempts}</p><div class="claw-scene" aria-hidden="true"></div>`, `<button data-action="event-claim" ${Number(event.state.attempts) <= 0 ? "disabled" : ""}>抓取</button><button class="secondary" data-action="event-close">离开</button>`, "event-modal claw-modal");
case "EV-152": return modal("黄金马车", `<p>选择一项补给。</p><div class="carriage-grid"><div class="carriage">生命</div><div class="carriage">点数</div></div>`, `<button data-action="carriage-heal">恢复 20% HP</button><button data-action="carriage-points">获得 15 点</button>`);
case "EV-164": return modal("骰子机", `<p>掷三骰:三同号 50 点,对子 15 点,否则取最高点。</p>`, `<button data-action="event-claim">启动机器</button>`);
case "EV-166": {
const shots = Number(event.state.shots ?? 0);
const bubbles = Array.from({ length: 20 }, (_, i) => `<span class="bubble" style="--bubble:hsl(${i * 31} 65% 58%)">${(i % 6) + 1}</span>`).join("");
return modal("泡泡龙", `<p>剩余发射:${shots} · 累计:${event.state.score}</p><div class="bubble-shooter-scene"><div class="bubble-grid">${bubbles}</div><span class="bubble-cannon"></span></div>`, `<button data-action="event-claim" ${shots <= 0 ? "disabled" : ""}>SHOOT</button><button class="secondary" data-action="event-close">EXIT</button>`, "event-modal bubble-modal");
}
default: return modal(EVENT_NAMES[event.eventId] ?? event.eventId, "<p>事件准备完成。</p>", `<button data-action="event-close">离开</button>`);
}
}
function offerName(offer: ShopOfferRow): string {
if (offer.offerId === 196) return "第三骰祝福";
if (offer.itemType === "TILE") return `安装事件地块 ${offer.itemId}`;
if (offer.itemType === "AUGMENT") return `祝福 · ${BLESSING_NAMES[offer.itemId] ?? offer.itemId}`;
if (offer.itemType === "POTION") return `丹药祝福 ${offer.itemId}`;
if (offer.itemType === "STAT") return `属性强化 ${offer.itemId}`;
if (offer.itemType === "FIXED") return ({
potionAdd: "补充 1 瓶丹药(不超过上限)",
potionCount: "丹药持有上限 +1",
potionHeal: "丹药基础恢复 +30 HP",
}[offer.fixedKey] ?? offer.fixedKey);
return "彩票";
}
function renderShop(): string {
const state = engine!.state;
const shop = state.shop!;
const offers = engine!.getShopOffers();
const cards = offers.map((offer) => {
const soldOut = offer.buyCount >= 0 && offer.sold >= offer.buyCount;
const potionFull = offer.fixedKey === "potionAdd" && state.potion.charges >= state.potion.maxCharges;
const category = offer.itemType === "FIXED" ? "固定丹药" : offer.itemType === "AUGMENT" ? "祝福" : offer.itemType;
return `<button class="shop-offer" data-offer="${offer.offerId}" ${soldOut || potionFull || state.points < offer.price ? "disabled" : ""}><strong>${escapeHtml(offerName(offer))}</strong><small>${category} · ${potionFull ? "已满" : `${offer.sold}/${offer.buyCount < 0 ? "∞" : offer.buyCount}`}</small><span class="price">◆ ${offer.price}</span></button>`;
});
const rerollCost = shop.freeRerolls > 0 ? 0 : 3 + 2 * shop.rerollCount;
return modal("商人", `<div class="shop-master"><span>你还活着呀?</span></div><h3 class="shop-section-title">一般商品</h3><div class="shop-grid">${cards.slice(0, 3).join("")}</div><h3 class="shop-section-title">特别商品</h3><div class="shop-grid">${cards.slice(3, 6).join("")}</div>`, `<button data-action="shop-reroll">刷新 ◆${rerollCost}</button><button class="secondary" data-action="event-close">EXIT</button>`, "shop-modal");
}
function renderAll(): void {
if (!engine) return;
renderHud();
renderBoard();
renderBattle();
renderModal();
}
async function animateMovement(sum: number, startIndex: number, startFloor: number, startPoints: number): Promise<void> {
if (!engine) return;
const totalVisualSteps = sum;
const startVisual = startIndex;
const finalPoints = engine.state.points;
presentationVisualIndex = startVisual;
presentationFloor = startFloor;
presentationPoints = startPoints;
gameScreen.classList.add("animating-move");
heroMarker.classList.add("running");
renderAll();
for (let step = 1; step <= totalVisualSteps; step += 1) {
await wait(58);
const absoluteVisual = startVisual + step;
presentationVisualIndex = absoluteVisual % 40;
if (absoluteVisual % 40 === 0) {
presentationFloor = Math.min(10, (presentationFloor ?? startFloor) + 1);
if (presentationFloor === engine.state.floor) presentationTiles = structuredClone(engine.state.tiles);
renderBoard();
sound("floor");
} else {
placeHeroAtVisualIndex(presentationVisualIndex);
}
presentationPoints = Math.round(startPoints + (finalPoints - startPoints) * step / totalVisualSteps);
renderHud();
sound("click");
}
presentationVisualIndex = engine.state.tileIndex;
presentationFloor = engine.state.floor;
presentationPoints = finalPoints;
renderBoard();
heroMarker.classList.remove("running");
heroMarker.classList.add("landing");
await wait(280);
heroMarker.classList.remove("landing");
gameScreen.classList.remove("animating-move");
}
function clearPresentationState(): void {
presentationVisualIndex = null;
presentationFloor = null;
presentationPoints = null;
presentationBossGauge = null;
presentationTiles = null;
presentationLock = false;
}
function renderBossMeter(gauge: RunState["bossGauge"]): void {
const meter = $("#floor-meter");
const countdown = gauge.mode === "COUNTDOWN";
meter.dataset.mode = gauge.mode;
$("#floor-value").textContent = countdown ? String(gauge.countdownRemaining) : `${gauge.playerRolls}/${gauge.buildupRollsRequired}`;
($("#floor-meter-fill") as HTMLElement).style.height = countdown
? `${clamp(gauge.countdownRemaining / Math.max(1, gauge.countdownRolls) * 100, 0, 100)}%`
: `${clamp(gauge.playerRolls / Math.max(1, gauge.buildupRollsRequired) * 100, 0, 100)}%`;
meter.title = countdown
? `Boss 行动倒计时:还剩 ${gauge.countdownRemaining} 次 ROLL`
: `玩家再 ROLL ${gauge.buildupRollsRequired - gauge.playerRolls} 次后开始 Boss 倒计时`;
}
function pulseBossMeter(): void {
const meter = $("#floor-meter");
meter.classList.remove("meter-pulse");
void meter.offsetWidth;
meter.classList.add("meter-pulse");
window.setTimeout(() => meter.classList.remove("meter-pulse"), 360);
}
async function animateBossAction(event: EngineEvent): Promise<void> {
if (!engine) return;
const changes = Array.isArray(event.payload?.changes)
? event.payload.changes as Array<{ index: number; fromKind: RunState["tiles"][number]["kind"]; fromEventId?: string; fromConsumed: boolean }>
: [];
const finalTiles = structuredClone(engine.state.tiles);
const stagedTiles = structuredClone(finalTiles);
for (const change of changes) {
const tile = stagedTiles.find((item) => item.index === change.index);
if (!tile) continue;
tile.kind = change.fromKind;
tile.eventId = change.fromEventId;
tile.consumed = change.fromConsumed;
}
presentationTiles = stagedTiles;
renderBoard();
const targetCount = changes.length;
floorBanner.textContent = `BOSS ACTION · ${targetCount} 个地块即将转为怪物地块`;
floorBanner.classList.remove("show");
boardView.classList.add("boss-acting");
requestAnimationFrame(() => floorBanner.classList.add("show"));
sound("floor");
await wait(520);
for (let step = 0; step < changes.length; step += 1) {
const change = changes[step];
const staged = stagedTiles.find((item) => item.index === change.index);
const final = finalTiles.find((item) => item.index === change.index);
if (!staged || !final) continue;
Object.assign(staged, final);
presentationTiles = stagedTiles;
renderBoard();
boardRing.querySelector<HTMLElement>(`[data-visual-index="${change.index}"]`)?.classList.add("boss-converting");
floorBanner.textContent = `BOSS ACTION · ${step + 1}/${targetCount} 地块已感染`;
floorBanner.classList.remove("show");
requestAnimationFrame(() => floorBanner.classList.add("show"));
sound("hit");
await wait(340);
}
await wait(360);
boardView.classList.remove("boss-acting");
}
async function processPostMoveEvents(events: EngineEvent[]): Promise<void> {
const bossEventTypes = new Set(["BOSS_GAUGE_CHANGED", "BOSS_COUNTDOWN_STARTED", "BOSS_COUNTDOWN_TICK", "BOSS_ACTION"]);
const bossEvents = events.filter((event) => bossEventTypes.has(event.type));
const remaining = events.filter((event) => !bossEventTypes.has(event.type));
for (const event of bossEvents) {
if (event.type === "BOSS_ACTION") await animateBossAction(event);
else {
handleEngineEvent(event);
await wait(300);
}
}
processEvents(remaining);
}
async function runRollSequence(): Promise<void> {
if (!engine || presentationLock || engine.state.phase !== "READY") return;
const startIndex = engine.state.tileIndex;
const startFloor = engine.state.floor;
const startPoints = engine.state.points;
presentationLock = true;
presentationVisualIndex = startIndex;
presentationFloor = startFloor;
presentationPoints = startPoints;
presentationBossGauge = structuredClone(engine.state.bossGauge);
presentationTiles = structuredClone(engine.state.tiles);
try {
const roll = engine.rollDice();
const events = engine.drainEvents();
const diceEvent = events.find((event) => event.type === "DICE_ROLLED");
if (diceEvent) handleEngineEvent(diceEvent);
renderAll();
await dicePresentation;
if ((engine.state.phase as RunState["phase"]) === "THIRD_DIE") {
processEvents(events.filter((event) => event !== diceEvent));
clearPresentationState();
renderAll();
tutorial("roll", "A、B 是两枚独立公平 D6,移动值为点数和;对子触发机缘:随机永久强化一项属性,并为下一落点战斗准备先攻。", "公平双骰与机缘");
return;
}
await animateMovement(roll.sum, startIndex, startFloor, startPoints);
await processPostMoveEvents(events.filter((event) => event !== diceEvent));
clearPresentationState();
renderAll();
tutorial("roll", "A、B 是两枚独立公平 D6。玩家 ROLL 2 次后开启 Boss 的 4 次 ROLL 倒计时;倒计时结束,Boss 将 4 个非祝福地块转为怪物地块。", "公平双骰与 Boss 倒计时");
} catch (error) {
clearPresentationState();
renderAll();
toast(error instanceof Error ? error.message : String(error));
}
}
async function runThirdDieSequence(choice: "A" | "B" | "KEEP"): Promise<void> {
if (!engine || presentationLock || engine.state.phase !== "THIRD_DIE") return;
const startIndex = engine.state.tileIndex;
const startFloor = engine.state.floor;
const startPoints = engine.state.points;
presentationLock = true;
presentationVisualIndex = startIndex;
presentationFloor = startFloor;
presentationPoints = startPoints;
presentationBossGauge = structuredClone(engine.state.bossGauge);
presentationTiles = structuredClone(engine.state.tiles);
modalLayer.innerHTML = "";
try {
const roll = engine.chooseThirdDie(choice);
const events = engine.drainEvents();
await wait(180);
await animateMovement(roll.sum, startIndex, startFloor, startPoints);
await processPostMoveEvents(events);
clearPresentationState();
renderAll();
} catch (error) {
clearPresentationState();
renderAll();
toast(error instanceof Error ? error.message : String(error));
}
}
function exportLogs(): void {
if (!engine) return;
const payload = JSON.stringify({ exportedAt: new Date().toISOString(), state: engine.snapshot() }, null, 2);
const url = URL.createObjectURL(new Blob([payload], { type: "application/json" }));
const link = document.createElement("a");
link.href = url;
link.download = `Heroll_${engine.state.seed}_technical-log.json`;
link.click();
URL.revokeObjectURL(url);
}
modalLayer.addEventListener("click", (event) => {
const target = (event.target as HTMLElement).closest<HTMLButtonElement>("button");
if (!target || target.disabled || !engine) return;
sound("click");
const action = target.dataset.action;
if (action === "third-a" || action === "third-b" || action === "third-keep") {
const choice = action === "third-a" ? "A" : action === "third-b" ? "B" : "KEEP";
void runThirdDieSequence(choice);
return;
}
act(() => {
switch (action) {
case "gear-equip": engine!.equipPendingGear(); break;
case "gear-salvage": engine!.salvagePendingGear(); break;
case "blessing-accept": engine!.acceptPendingBlessing(); break;
case "event-claim": engine!.resolveSimpleEvent(); break;
case "event-close": engine!.closeEvent(); break;
case "dig": engine!.digCell(Number(target.dataset.index)); break;
case "smith": engine!.useSmithy(target.dataset.slot as GearSlot, true); break;
case "carriage-heal": engine!.resolveSimpleEvent("HEAL"); break;
case "carriage-points": engine!.resolveSimpleEvent("POINTS"); break;
case "shop-reroll": if (!engine!.rerollShop()) toast("点数不足或无法刷新"); break;
case "new-run": clearRun(); engine = new GameEngine(data, generatedSeed()); break;
case "retry": clearRun(); engine = new GameEngine(data, engine!.state.seed); break;
case "home": showStart(); return;
case "export": exportLogs(); break;
}
if (target.dataset.offer && !engine!.buyShopOffer(Number(target.dataset.offer))) toast("点数不足或商品已售罄");
});
});
rollButton.addEventListener("click", () => { void runRollSequence(); });
potionButton.addEventListener("click", () => act(() => engine!.usePotion()));
$("#pause-button").addEventListener("click", () => {
if (!engine || presentationLock || battleOutroUntil > performance.now()) return;
paused = true;
const stats = engine.effectiveStats();
const bossText = engine.state.bossGauge.mode === "COUNTDOWN"
? `Boss 行动倒计时 ${engine.state.bossGauge.countdownRemaining}/${engine.state.bossGauge.countdownRolls}`
: `Boss 蓄势 ${engine.state.bossGauge.playerRolls}/${engine.state.bossGauge.buildupRollsRequired}`;
modalLayer.innerHTML = modal("暂停 · 玩家面板", `<p>攻击 ${Math.round(stats.attack)} · 防御 ${Math.round(stats.defense)} · 暴击 ${Math.round(stats.critRate)}% · <strong>反击 ${Math.round(stats.counterAttack)}%</strong></p><p>机缘:伤害 +${engine.state.fortune.damagePercent}% · 最大生命 +${engine.state.fortune.maxHpPercent}% · 防御 +${engine.state.fortune.defensePercent}%</p><p>丹药 ${engine.state.potion.charges}/${engine.state.potion.maxCharges} · 基础恢复 ${engine.state.potion.healAmount} HP</p><p>${bossText}</p><p>本局已写入最近安全点。种子:${escapeHtml(engine.state.seed)}</p>`, `<button data-pause="resume">继续</button><button class="secondary" data-pause="home">返回标题</button>`);
modalLayer.querySelector("[data-pause=resume]")?.addEventListener("click", () => { paused = false; renderAll(); });
modalLayer.querySelector("[data-pause=home]")?.addEventListener("click", () => { paused = false; modalLayer.innerHTML = ""; showStart(); });
});
$("#debug-toggle").addEventListener("click", () => { debugPanel.hidden = false; renderHud(); });
$("#debug-close").addEventListener("click", () => { debugPanel.hidden = true; });
debugPanel.addEventListener("click", (event) => {
const button = (event.target as HTMLElement).closest<HTMLButtonElement>("button[data-debug]");
if (!button || !engine || presentationLock || battleOutroUntil > performance.now()) return;
const eventMap: Record<string, string> = {
chest: "EV-141", campfire: "EV-138", smithy: "EV-137", blessing: "EV-144", dig: "EV-140", merchant: "EV-MERCHANT",
legend: "EV-139", lotto: "EV-142", blackjack: "EV-143", claw: "EV-151", carriage: "EV-152", machine: "EV-164", bubble: "EV-166",
};
act(() => {
const action = button.dataset.debug!;
if (action === "apply") engine!.debugSet(Number(debugFloor.value), Number(debugPoints.value));
else if (eventMap[action]) engine!.debugForceEvent(eventMap[action]);
else if (action === "battle") engine!.startBattle(false);
else if (action === "win") engine!.debugWinBattle();
else if (action === "death") engine!.debugForceDeath();
else if (action === "boss") engine!.debugBoss();
else if (action === "speed") { battleSpeed = battleSpeed === 1 ? 4 : 1; toast(`战斗速度 ${battleSpeed}×(仅调试)`); }
else if (action === "export") exportLogs();
});
});
newButton.addEventListener("click", () => {
clearRun();
engine = new GameEngine(data, seedInput.value.trim() || generatedSeed());
processEvents();
showGame();
});
continueButton.addEventListener("click", () => {
if (!restored) return;
engine = new GameEngine(data, restored);
processEvents();
showGame();
});
clearButton.addEventListener("click", () => {
clearRun();
restored = null;
continueButton.hidden = true;
clearButton.hidden = true;
toast("安全点已清除");
});
function frame(now: number): void {
const delta = now - lastFrame;
lastFrame = now;
if (battleOutroUntil > 0 && now >= battleOutroUntil) {
battleOutroUntil = 0;
battleView.classList.remove("battle-outro");
renderAll();
}
if (battleIntroUntil > 0 && now >= battleIntroUntil) battleIntroUntil = 0;
if (engine && !paused && !presentationLock && battleOutroUntil === 0 && now >= battleIntroUntil && engine.state.battle && ["BATTLE", "BOSS"].includes(engine.state.phase)) {
engine.tickBattle(delta * battleSpeed);
const events = engine.drainEvents();
const ending = events.some((event) => event.type === "STATE_CHANGED" && ["BATTLE_VICTORY", "RUN_VICTORY", "REVIVE_OFFER", "RUN_DEFEAT"].includes(String(event.payload?.reason ?? "")));
if (!ending) {
renderHud();
renderBattle();
}
processEvents(events);
if (ending) renderHud();
tutorial("battle", "普通攻击与技能自动执行;玩家唯一战斗干预是手动使用药水。正常入口固定 1×。", "自动战斗");
}
requestAnimationFrame(frame);
}
async function boot(): Promise<void> {
seedInput.value = generatedSeed();
try {
data = await loadDataTables();
const saved = await loadRun();
if (saved) {
restored = saved.state;
continueButton.hidden = false;
clearButton.hidden = false;
continueButton.textContent = saved.recovered ? "从备份安全点恢复" : `继续安全点 · 第 ${saved.state.floor} 层`;
}
requestAnimationFrame(frame);
} catch (error) {
newButton.disabled = true;
$(".scope-note").textContent = `启动失败:${error instanceof Error ? error.message : String(error)}`;
}
}
void boot();
+99
View File
@@ -0,0 +1,99 @@
import type { RngState } from "./types.js";
function hashSeed(value: string): [number, number, number, number] {
let h = 1779033703 ^ value.length;
for (let i = 0; i < value.length; i += 1) {
h = Math.imul(h ^ value.charCodeAt(i), 3432918353);
h = (h << 13) | (h >>> 19);
}
const next = (): number => {
h = Math.imul(h ^ (h >>> 16), 2246822507);
h = Math.imul(h ^ (h >>> 13), 3266489909);
return (h ^= h >>> 16) >>> 0;
};
return [next(), next(), next(), next()];
}
function rotl(value: number, shift: number): number {
return ((value << shift) | (value >>> (32 - shift))) >>> 0;
}
export class SeededRng {
private state: RngState;
constructor(seedOrState: string | RngState) {
if (typeof seedOrState === "string") {
const [a, b, c, d] = hashSeed(seedOrState);
this.state = { seed: seedOrState, a, b, c, d, index: 0 };
} else {
this.state = { ...seedOrState };
}
}
next(): number {
const result = Math.imul(rotl(Math.imul(this.state.b, 5) >>> 0, 7), 9) >>> 0;
const t = (this.state.b << 9) >>> 0;
this.state.c ^= this.state.a;
this.state.d ^= this.state.b;
this.state.b ^= this.state.c;
this.state.a ^= this.state.d;
this.state.c ^= t;
this.state.d = rotl(this.state.d, 11);
this.state.a >>>= 0;
this.state.b >>>= 0;
this.state.c >>>= 0;
this.state.d >>>= 0;
this.state.index += 1;
return result / 4294967296;
}
int(min: number, max: number): number {
if (max < min) throw new Error(`invalid rng range ${min}..${max}`);
return min + Math.floor(this.next() * (max - min + 1));
}
chance(probability: number): boolean {
return this.next() < Math.max(0, Math.min(1, probability));
}
pick<T>(items: readonly T[]): T {
if (items.length === 0) throw new Error("cannot pick an empty collection");
return items[this.int(0, items.length - 1)];
}
weighted<T>(items: readonly T[], getWeight: (item: T) => number): T {
const total = items.reduce((sum, item) => sum + Math.max(0, getWeight(item)), 0);
if (total <= 0) return this.pick(items);
let cursor = this.next() * total;
for (const item of items) {
cursor -= Math.max(0, getWeight(item));
if (cursor <= 0) return item;
}
return items[items.length - 1];
}
snapshot(): RngState {
return { ...this.state };
}
}
export const RNG_STREAMS = ["dice", "board", "loot", "combat", "shop", "event"] as const;
export type RngStreamName = (typeof RNG_STREAMS)[number];
export class RngBundle {
readonly streams: Record<RngStreamName, SeededRng>;
constructor(seed: string, saved?: Record<string, RngState>) {
this.streams = Object.fromEntries(
RNG_STREAMS.map((name) => [name, new SeededRng(saved?.[name] ?? `${seed}:${name}`)]),
) as Record<RngStreamName, SeededRng>;
}
get(name: RngStreamName): SeededRng {
return this.streams[name];
}
snapshot(): Record<string, RngState> {
return Object.fromEntries(RNG_STREAMS.map((name) => [name, this.streams[name].snapshot()]));
}
}
+68
View File
@@ -0,0 +1,68 @@
import type { RunState } from "./types.js";
const PRIMARY = "heroll.validation.run.v1";
const BACKUP = "heroll.validation.run.v1.backup";
const TUTORIALS = "heroll.validation.tutorials.v1";
interface Envelope {
payload: string;
checksum: string;
savedAt: number;
}
function stable(value: unknown): string {
if (value === null || typeof value !== "object") return JSON.stringify(value);
if (Array.isArray(value)) return `[${value.map(stable).join(",")}]`;
const record = value as Record<string, unknown>;
return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stable(record[key])}`).join(",")}}`;
}
async function checksum(payload: string): Promise<string> {
const bytes = new TextEncoder().encode(payload);
const digest = await crypto.subtle.digest("SHA-256", bytes);
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
}
async function read(key: string): Promise<RunState | null> {
const raw = localStorage.getItem(key);
if (!raw) return null;
try {
const envelope = JSON.parse(raw) as Envelope;
if (await checksum(envelope.payload) !== envelope.checksum) return null;
const parsed = JSON.parse(envelope.payload) as RunState;
return [1, 2, 3].includes(parsed.saveVersion) ? parsed : null;
} catch {
return null;
}
}
export async function saveRun(state: RunState): Promise<void> {
const payload = stable(state);
const envelope: Envelope = { payload, checksum: await checksum(payload), savedAt: Date.now() };
const previous = localStorage.getItem(PRIMARY);
if (previous) localStorage.setItem(BACKUP, previous);
localStorage.setItem(PRIMARY, JSON.stringify(envelope));
}
export async function loadRun(): Promise<{ state: RunState; recovered: boolean } | null> {
const primary = await read(PRIMARY);
if (primary) return { state: primary, recovered: false };
const backup = await read(BACKUP);
return backup ? { state: backup, recovered: true } : null;
}
export function clearRun(): void {
localStorage.removeItem(PRIMARY);
localStorage.removeItem(BACKUP);
}
export function tutorialSeen(key: string): boolean {
const seen = JSON.parse(localStorage.getItem(TUTORIALS) ?? "[]") as string[];
return seen.includes(key);
}
export function markTutorialSeen(key: string): void {
const seen = new Set(JSON.parse(localStorage.getItem(TUTORIALS) ?? "[]") as string[]);
seen.add(key);
localStorage.setItem(TUTORIALS, JSON.stringify([...seen]));
}
+313
View File
@@ -0,0 +1,313 @@
export type RunPhase =
| "READY"
| "ROLLING"
| "THIRD_DIE"
| "MOVING"
| "BATTLE"
| "EVENT"
| "GEAR_REWARD"
| "BLESSING_REWARD"
| "REVIVE"
| "BOSS"
| "VICTORY"
| "DEFEAT";
export type GearSlot = "WEAPON" | "RING" | "GAUNTLET" | "BOOTS";
export type GearQuality = "COMMON" | "UNCOMMON" | "RARE" | "EPIC" | "LEGENDARY";
export type EnemyRole = "SWORD" | "STRAIGHT" | "CURVED" | "RUSH" | "BOSS";
export interface RngState {
seed: string;
a: number;
b: number;
c: number;
d: number;
index: number;
}
export interface DiceRoll {
a: number;
b: number;
c?: number;
chosenA: number;
chosenB: number;
sum: number;
isDouble: boolean;
rollIndex: number;
}
export interface CombatStats {
attack: number;
maxHp: number;
defense: number;
attackSpeed: number;
evasion: number;
aim: number;
lifeSteal: number;
critRate: number;
critDamage: number;
totalDamage: number;
trueDamage: number;
regenPerSecond: number;
killHeal: number;
counterAttack: number;
}
export interface GearInstance {
instanceId: string;
catalogId: number;
stringId: string;
slot: GearSlot;
iconName: string;
quality: GearQuality;
rank: number;
statusId: number;
statId: string;
value: number;
isPercent: boolean;
salvageValue: number;
}
export interface BlessingInstance {
blessingId: string;
stringId: string;
stack: number;
value: number;
}
export interface PotionState {
countLevel: number;
healLevel: number;
charges: number;
maxCharges: number;
/** 原作 HealLevel.value:固定 HP 恢复量,不是最大生命百分比。 */
healAmount: number;
/** 旧错误版本的迁移字段。 */
healPercent?: number;
}
export interface BossGaugeState {
mode: "BUILDUP" | "COUNTDOWN";
playerRolls: number;
buildupRollsRequired: number;
countdownRemaining: number;
countdownRolls: number;
bossActions: number;
}
export interface FortuneState {
damagePercent: number;
maxHpPercent: number;
defensePercent: number;
pendingOpeningStrike: boolean;
}
export interface EnemyState {
id: string;
role: EnemyRole;
name: string;
hp: number;
maxHp: number;
attack: number;
defense: number;
attackSpeed: number;
evasion: number;
aim: number;
attackClock: number;
burnDps: number;
burnRemaining: number;
firstHit: boolean;
}
export interface BattleSnapshot {
id: string;
isBoss: boolean;
floor: number;
elapsedMs: number;
playerAttackClock: number;
playerSkillClock: number;
bossSkillClock: number;
regenClock: number;
lightningClock: number;
periodicHealClock: number;
successfulHits: number;
firstPlayerHit: boolean;
fortuneOpeningStrike: boolean;
temporaryTotalDamage: number;
temporaryAttackSpeed: number;
temporaryExpired: boolean;
playerHp: number;
playerShield: number;
enemies: EnemyState[];
log: string[];
sourceTileIndex?: number;
sourceTileKind?: TileState["kind"];
isMiniBoss?: boolean;
}
export interface TileState {
index: number;
kind: "START" | "EMPTY" | "BATTLE" | "MINIBOSS" | "INFECTED_BOSS" | "EVENT" | "MERCHANT";
eventId?: string;
consumed: boolean;
/** 普通/小Boss战的敌人数。普通战斗格与1–4骷髅图标严格一致。 */
enemyCount?: 1 | 2 | 3 | 4;
/** 污染Boss对普通怪物格累计的污染等级,最多3级。 */
pollutionLevel?: 0 | 1 | 2 | 3;
/** 污染Boss仍可在过层时生效的次数。 */
infectionTurnsRemaining?: number;
}
export interface EventInstance {
instanceId: string;
eventId: string;
state: Record<string, unknown>;
boosted: boolean;
resolved: boolean;
sourceTileIndex?: number;
}
export interface ShopOfferState {
offerId: number;
sold: number;
}
export interface ShopState {
instanceId: string;
offers: ShopOfferState[];
rerollCount: number;
freeRerolls: number;
boosted: boolean;
}
export interface DdaState {
recentBattleMs: number[];
recentHpRatios: number[];
threatScale: number;
}
export interface TechnicalLog {
at: number;
type: string;
payload: Record<string, unknown>;
}
export interface RunState {
saveVersion: number;
runId: string;
seed: string;
phase: RunPhase;
floor: number;
tileIndex: number;
points: number;
rollIndex: number;
baseStats: CombatStats;
currentHp: number;
shield: number;
gear: Record<GearSlot, GearInstance | null>;
blessings: BlessingInstance[];
potion: PotionState;
bossGauge: BossGaugeState;
fortune: FortuneState;
tiles: TileState[];
coreEventSchedule: Record<number, string>;
generatedCoreEvents: string[];
reviveUsedThisFloor: boolean;
sourceReviveConsumed: boolean;
lastDice?: DiceRoll;
pendingThirdDie?: DiceRoll;
battle?: BattleSnapshot;
event?: EventInstance;
shop?: ShopState;
pendingGear?: GearInstance;
pendingBlessing?: string;
potionAugments: string[];
purchaseCounts: Record<string, number>;
committedTransactions: string[];
rng: Record<string, RngState>;
dda: DdaState;
logs: TechnicalLog[];
}
export interface FloorRow {
floor: number;
attackPower: number;
health: number;
defense: number;
attackSpeed: number;
evasion: number;
aim: number;
}
export interface GearCatalogRow {
catalogId: number;
stringId: string;
slot: GearSlot;
iconName: string;
indexInSlot: number;
}
export interface GearStatusRow {
statusId: number;
stringId: string;
baseMin: number;
baseMax: number;
plusValue: number;
isPercent: boolean;
}
export interface BlessingRow {
blessingId: string;
stringId: string;
enabled: boolean;
weight: number;
linearValue: number;
values: number[];
stackable: boolean;
effectContract: string;
}
export interface PotionRow {
recordId: string;
recordType: string;
level: number;
value: number;
valueA: number;
valueB: number;
}
export interface ShopOfferRow {
offerId: number;
stringId: string;
itemType: "TILE" | "AUGMENT" | "POTION" | "STAT" | "FIXED" | "LOTTO";
itemId: string;
fixedKey: string;
fixedSort: number;
fixedSlot: number;
price: number;
buyCount: number;
}
export interface EventRow {
eventId: string;
tileId: string;
stringId: string;
naturalCore: boolean;
boostPlacement: string;
baseRule: string;
}
export interface DataTables {
floors: FloorRow[];
gear: GearCatalogRow[];
gearStatuses: GearStatusRow[];
blessings: BlessingRow[];
potions: PotionRow[];
shop: ShopOfferRow[];
events: EventRow[];
}
export interface EngineEvent {
type: string;
payload?: Record<string, unknown>;
}
+1753
View File
File diff suppressed because it is too large Load Diff
+180
View File
@@ -0,0 +1,180 @@
import assert from "node:assert/strict";
import { chromium } from "file:///C:/Users/toshio/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright/index.mjs";
const BASE = process.env.HEROLL_MVP_URL ?? "http://127.0.0.1:4173/mvp/";
const browser = await chromium.launch({
headless: true,
executablePath: "C:/Program Files/Google/Chrome/Application/chrome.exe",
});
const context = await browser.newContext({ viewport: { width: 750, height: 1334 }, deviceScaleFactor: 1 });
const page = await context.newPage();
const errors = [];
const failedRequests = [];
page.on("console", (message) => {
if (message.type() === "error") {
const location = message.location();
errors.push(`console: ${message.text()} @ ${location.url || "unknown"}:${location.lineNumber ?? 0}`);
}
});
page.on("pageerror", (error) => errors.push(`pageerror: ${error.message}`));
page.on("requestfailed", (request) => failedRequests.push(`${request.url()} ${request.failure()?.errorText}`));
page.on("response", (response) => { if (response.status() >= 400) errors.push(`http ${response.status()}: ${response.url()}`); });
try {
await page.goto(BASE, { waitUntil: "networkidle" });
await page.locator("#seed-input").fill("BROWSER-SMOKE-001");
await page.locator("#new-run-button").click();
await page.locator("#game-screen.active").waitFor();
assert.equal(await page.locator(".board-tile").count(), 40);
assert.equal(await page.locator(".board-tile.logical").count(), 40);
assert.equal(await page.locator(".board-tile.presentation").count(), 0);
assert.equal(await page.locator(".board-tile.miniboss").count(), 1);
assert.equal(await page.locator(".board-tile.infected_boss").count(), 1);
assert.equal(await page.locator("#floor-value").textContent(), "0/2");
assert.equal(await page.locator("#auto-indicator").textContent(), "AUTO");
assert.equal(await page.locator("button.boost-button, button[data-placement]").count(), 0, "界面不得出现广告增强按钮");
const layout = await page.evaluate(() => Object.fromEntries(["#top-hud", ".resource-chip", "#auto-indicator", "#floor-meter", "#board-ring", "#boss-figure", "#bottom-hud", "#gear-slots", "#roll-button", "#potion-button"].map((selector) => {
const rect = document.querySelector(selector).getBoundingClientRect();
return [selector, { x: rect.x, y: rect.y, width: rect.width, height: rect.height }];
})));
assert.ok(layout[".resource-chip"].x < 30 && layout[".resource-chip"].y < 100);
assert.ok(layout["#auto-indicator"].x > 500 && layout["#floor-meter"].x > 680);
assert.ok(Math.abs(layout["#boss-figure"].x + layout["#boss-figure"].width / 2 - 375) < 4);
assert.ok(layout["#bottom-hud"].y > 940 && layout["#bottom-hud"].y < 1010 && layout["#gear-slots"].y > 1020);
assert.ok(layout["#roll-button"].y > 1200 && layout["#potion-button"].x > 630);
console.log("STEP board layout passed");
await page.screenshot({ path: "tests/artifacts/heroll-mvp-board.png", fullPage: true });
console.log("STEP board screenshot captured");
await page.locator("#roll-button").click();
await page.waitForTimeout(180);
assert.equal(await page.locator(".die-face").count(), 2);
const rolledSum = Number(await page.locator("#dice-result").getAttribute("data-sum"));
assert.equal(Number(await page.locator(".dice-sum").textContent()), rolledSum);
console.log("STEP dice rendered");
await page.screenshot({ path: "tests/artifacts/heroll-mvp-dice.png", fullPage: true });
await page.locator("#game-screen.animating-move").waitFor({ state: "visible", timeout: 2500 });
assert.equal(await page.locator("#modal-layer .modal").count(), 0, "移动期间不允许事件或战斗结算硬切入");
await page.locator("#game-screen.animating-move").waitFor({ state: "hidden", timeout: 6000 });
const landedIndex = Number(await page.locator(".board-tile.current").getAttribute("data-visual-index"));
assert.equal(landedIndex, rolledSum, `从START ROLL ${rolledSum}必须顺时针移动${rolledSum}个实际地块`);
assert.match(await page.locator("#points-value").textContent(), /^\d+$/);
assert.equal(await page.locator("#floor-value").textContent(), "1/2");
await page.waitForTimeout(1200);
await page.locator("#debug-toggle").click();
await page.locator("#debug-points").fill("999");
await page.locator("button[data-debug=apply]").click();
await page.locator("button[data-debug=merchant]").click();
await page.locator(".shop-modal").waitFor();
assert.equal(await page.locator(".shop-offer").count(), 6);
const fixedPotionLabels = await page.locator(".shop-offer").evaluateAll((nodes) => nodes.slice(0, 3).map((node) => node.querySelector("strong")?.textContent ?? ""));
assert.deepEqual(fixedPotionLabels, ["补充 1 瓶丹药(不超过上限)", "丹药持有上限 +1", "丹药基础恢复 +30 HP"]);
assert.match(await page.locator(".shop-offer").first().textContent(), /已满/);
await page.locator("#debug-panel").evaluate((element) => { element.hidden = true; });
await page.locator("#tutorial-layer").evaluate((element) => { element.innerHTML = ""; });
await page.locator("#toast-layer").evaluate((element) => { element.innerHTML = ""; });
await page.screenshot({ path: "tests/artifacts/heroll-mvp-shop.png", fullPage: true });
await page.locator("#debug-panel").evaluate((element) => { element.hidden = false; });
await page.locator("#debug-panel").evaluate((element) => { element.hidden = true; });
await page.locator("button[data-action=shop-reroll]").click();
assert.equal(await page.locator(".shop-offer").count(), 6);
await page.locator("button[data-action=event-close]").click();
await page.locator("#debug-panel").evaluate((element) => { element.hidden = false; });
await page.locator("button[data-debug=dig]").click();
await page.getByRole("heading", { name: "挖掘" }).waitFor();
assert.match(await page.locator(".modal p").first().textContent(), /5/);
await page.locator(".dig-cell").first().click();
if (await page.getByRole("heading", { name: "装备掉落" }).count()) await page.locator("button[data-action=gear-salvage]").click();
await page.locator("button[data-action=event-close]").click();
await page.locator("button[data-debug=chest]").click();
await page.locator("button[data-action=event-claim]").click();
await page.getByRole("heading", { name: "装备掉落" }).waitFor();
assert.equal(await page.locator(".gear-card").count(), 1);
await page.locator("button[data-action=gear-equip]").click();
await page.locator("button[data-debug=blessing]").click();
await page.locator("button[data-action=event-claim]").click();
await page.getByRole("heading", { name: "祝福揭示" }).waitFor();
await page.locator("button[data-action=blessing-accept]").click();
await page.locator("button[data-debug=campfire]").click();
await page.getByRole("heading", { name: "营火" }).waitFor();
await page.locator("button[data-action=event-claim]").click();
await page.locator("button[data-debug=smithy]").click();
await page.getByRole("heading", { name: "铁匠铺" }).waitFor();
const smithTarget = page.locator("button[data-action=smith]:not([disabled])").first();
if (await smithTarget.count()) await smithTarget.click();
else await page.locator("button[data-action=event-close]").click();
await page.locator("button[data-debug=legend]").click();
await page.locator("button[data-action=event-claim]").click();
await page.getByRole("heading", { name: "装备掉落" }).waitFor();
await page.locator("button[data-action=gear-salvage]").click();
for (const [debug, heading] of [["lotto", "彩票"], ["blackjack", "21点"], ["machine", "骰子机"]]) {
await page.locator(`button[data-debug=${debug}]`).click();
await page.getByRole("heading", { name: heading }).waitFor();
await page.locator("button[data-action=event-claim]").click();
}
await page.locator("button[data-debug=claw]").click();
await page.getByRole("heading", { name: "抓娃娃" }).waitFor();
await page.locator("button[data-action=event-claim]").click();
if (await page.getByRole("heading", { name: "装备掉落" }).count()) await page.locator("button[data-action=gear-salvage]").click();
if (await page.getByRole("heading", { name: "抓娃娃" }).count()) await page.locator("button[data-action=event-close]").click();
await page.locator("button[data-debug=carriage]").click();
await page.getByRole("heading", { name: "黄金马车" }).waitFor();
await page.locator("button[data-action=carriage-points]").click();
await page.locator("button[data-debug=bubble]").click();
await page.getByRole("heading", { name: "泡泡龙" }).waitFor();
for (let shot = 0; shot < 3; shot += 1) await page.locator("button[data-action=event-claim]").click();
await page.locator("button[data-action=event-close]").click();
await page.locator("button[data-debug=battle]").click();
await page.locator("#battle-view.active").waitFor();
await page.locator("#battle-announcer.show").waitFor();
await page.locator("#debug-panel").evaluate((element) => { element.hidden = true; });
await page.locator("#tutorial-layer").evaluate((element) => { element.innerHTML = ""; });
await page.locator("#toast-layer").evaluate((element) => { element.innerHTML = ""; });
await page.screenshot({ path: "tests/artifacts/heroll-mvp-battle.png", fullPage: true });
await page.locator("#debug-panel").evaluate((element) => { element.hidden = false; });
await page.locator("button[data-debug=death]").click();
await page.locator("#battle-announcer.show").waitFor();
assert.equal(await page.locator(".result-modal.defeat-modal").count(), 0, "失败结算必须等待受击与 DEFEAT 演出完成");
await page.locator(".result-modal.defeat-modal").waitFor({ timeout: 4000 });
assert.equal(await page.locator("button.boost-button, button[data-placement], button[data-action=revive]").count(), 0, "失败界面不得出现广告复活按钮");
await page.locator("button[data-action=retry]").click();
await page.locator("#game-screen.active").waitFor();
await page.locator("#debug-panel").evaluate((element) => { element.hidden = false; });
await page.locator("button[data-debug=boss]").click();
await page.locator("#battle-announcer.show").waitFor();
await page.locator("button[data-debug=win]").click();
await page.locator("#battle-announcer.show").waitFor();
assert.match(await page.locator("#battle-announcer").textContent(), /VICTORY/);
assert.equal(await page.locator(".result-modal").count(), 0, "最终结算不得跳过 VICTORY 演出直接出现");
await page.locator(".result-modal.victory-modal").waitFor({ timeout: 4000 });
assert.match(await page.locator(".result-modal h2").textContent(), /STAGE CLEAR/);
assert.equal(await page.locator(".result-rewards .count-up").count(), 3);
await page.waitForTimeout(1050);
assert.equal(await page.locator(".result-rewards .count-up").first().textContent(), await page.locator("#points-value").textContent());
await page.screenshot({ path: "tests/artifacts/heroll-mvp-result.png", fullPage: true });
const saved = await page.evaluate(() => localStorage.getItem("heroll.validation.run.v1"));
assert.ok(saved && saved.length > 100);
await page.locator("#debug-panel").evaluate((element) => { element.hidden = true; });
await page.screenshot({ path: "tests/artifacts/heroll-mvp-smoke.png", fullPage: true });
assert.deepEqual(errors, [], `浏览器错误:\n${errors.join("\n")}`);
assert.deepEqual(failedRequests, [], `资源失败:\n${failedRequests.join("\n")}`);
console.log("PASS browser smoke: fixed board/clockwise move → shop → events → gear → blessing → battle → no-ad defeat → victory/result → save");
console.log("SCREENSHOTS tests/artifacts/heroll-mvp-{board,dice,shop,battle,result,smoke}.png");
} finally {
await browser.close();
}
+423
View File
@@ -0,0 +1,423 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import path from "node:path";
import { GameEngine, QUALITY, QUALITY_WEIGHTS } from "../dist/core.js";
import { parseCsv } from "../dist/data.js";
import { SeededRng } from "../dist/rng.js";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const SPECS = path.resolve(HERE, "../../design/gameplay-validation/specs");
const number = (value, fallback = 0) => Number.isFinite(Number(value)) ? Number(value) : fallback;
const bool = (value) => String(value).toLowerCase() === "true";
const csv = async (name) => parseCsv(await readFile(path.join(SPECS, name), "utf8"));
async function loadTables() {
const [floors, gear, statuses, blessings, potions, shop, events] = await Promise.all([
csv("floor-balance.csv"), csv("gear-catalog.csv"), csv("gear-status.csv"), csv("blessings.csv"),
csv("potions.csv"), csv("shop-offers.csv"), csv("events.csv"),
]);
return {
floors: floors.map((r) => ({ floor: number(r.floor), attackPower: number(r.attack_power), health: number(r.health), defense: number(r.defense), attackSpeed: number(r.attack_speed, 1), evasion: number(r.evasion), aim: number(r.aim) })),
gear: gear.map((r) => ({ catalogId: number(r.catalog_id), stringId: r.string_id, slot: r.slot, iconName: r.icon_name, indexInSlot: number(r.index_in_slot) })),
gearStatuses: statuses.map((r) => ({ statusId: number(r.status_id), stringId: r.string_id, baseMin: number(r.base_min), baseMax: number(r.base_max), plusValue: number(r.plus_value), isPercent: bool(r.is_percent) })),
blessings: blessings.map((r) => ({ blessingId: r.blessing_id, stringId: r.string_id, enabled: bool(r.enabled), weight: number(r.prob_weight, 1), linearValue: number(r.linear_value), values: r.values ? r.values.split("|").map(Number) : [], stackable: bool(r.stackable), effectContract: r.effect_contract })),
potions: potions.map((r) => ({ recordId: r.record_id, recordType: r.record_type, level: number(r.level), value: number(r.value), valueA: number(r.value_a), valueB: number(r.value_b) })),
shop: shop.map((r) => ({ offerId: number(r.offer_id), stringId: r.string_id, itemType: r.item_type, itemId: r.item_id, fixedKey: r.fixed_key, fixedSort: number(r.fixed_sort), fixedSlot: number(r.fixed_slot, -1), price: number(r.price), buyCount: number(r.buy_count, 1) })),
events: events.map((r) => ({ eventId: r.event_id, tileId: r.tile_id, stringId: r.string_id, naturalCore: bool(r.natural_core), boostPlacement: r.boost_placement, baseRule: r.base_rule })),
};
}
const tests = [];
const test = (name, fn) => tests.push([name, fn]);
const data = await loadTables();
test("GDD 配置规模精确", () => {
assert.equal(data.floors.length, 10);
assert.equal(data.gear.length, 40);
assert.equal(data.gearStatuses.length, 13);
assert.equal(data.blessings.filter((item) => item.enabled).length, 32);
assert.equal(data.shop.length, 97);
assert.equal(data.events.length, 13);
});
test("百万次公平双骰的 36 个有序结果通过卡方检查", () => {
const rng = new SeededRng("HEROLL-FAIRNESS-1M");
const bins = Array(36).fill(0);
for (let i = 0; i < 1_000_000; i += 1) {
const a = rng.int(1, 6);
const b = rng.int(1, 6);
bins[(a - 1) * 6 + b - 1] += 1;
}
const expected = 1_000_000 / 36;
const chi2 = bins.reduce((sum, observed) => sum + ((observed - expected) ** 2) / expected, 0);
assert.ok(chi2 < 66.62, `chi²=${chi2.toFixed(2)} 未通过 df=35, p=0.001 门槛`);
assert.ok(bins.every((count) => Math.abs(count - expected) / expected < 0.02));
});
test("品质权重遵循 210:60:28:4:1", () => {
const rng = new SeededRng("HEROLL-QUALITY");
const bins = Object.fromEntries(QUALITY.map((name) => [name, 0]));
for (let i = 0; i < 200_000; i += 1) bins[rng.weighted(QUALITY, (item) => QUALITY_WEIGHTS[QUALITY.indexOf(item)])] += 1;
const totalWeight = QUALITY_WEIGHTS.reduce((a, b) => a + b, 0);
QUALITY.forEach((name, index) => {
const actual = bins[name] / 200_000;
const expected = QUALITY_WEIGHTS[index] / totalWeight;
assert.ok(Math.abs(actual - expected) < 0.004, `${name} ${actual} != ${expected}`);
});
});
test("同 seed 生成同棋盘、同骰子与同 RNG 索引", () => {
const a = new GameEngine(data, "DETERMINISTIC-SEED");
const b = new GameEngine(data, "DETERMINISTIC-SEED");
assert.deepEqual(a.state.tiles, b.state.tiles);
assert.deepEqual(a.state.coreEventSchedule, b.state.coreEventSchedule);
assert.deepEqual(a.rollDice(), b.rollDice());
assert.deepEqual(a.state.rng, b.state.rng);
});
test("40格棋盘越过起点进入下一层并恢复30%", () => {
const game = new GameEngine(data, "FLOOR-CROSSING");
game.state.currentHp = 50;
game.state.tileIndex = 39;
game.state.reviveUsedThisFloor = true;
game.rollDice();
assert.ok(game.state.floor >= 2);
assert.ok(game.state.currentHp >= 95);
assert.equal(game.state.reviveUsedThisFloor, false);
assert.equal(game.state.tiles.filter((tile) => tile.kind === "EVENT").length >= 2, true);
});
test("ROLL点数严格等于顺时针移动的实际格数", () => {
const game = new GameEngine(data, "ONE-POINT-ONE-TILE");
assert.equal(game.state.tiles.length, 40);
const roll = game.rollDice();
assert.equal(game.state.tileIndex, roll.sum, `ROLL ${roll.sum} 应从0移动到${roll.sum}`);
assert.ok(roll.sum >= 2 && roll.sum <= 12);
});
test("1至4骷髅地块严格生成对应数量怪物并在胜利后变荒地", () => {
for (const count of [1, 2, 3, 4]) {
const seed = `SKULL-TILE-${count}`;
const probe = new GameEngine(data, seed);
const target = probe.rollDice().sum;
const game = new GameEngine(data, seed);
game.state.tiles[target] = { index: target, kind: "BATTLE", consumed: false, enemyCount: count, pollutionLevel: 0 };
const roll = game.rollDice();
assert.equal(game.state.tileIndex, roll.sum);
assert.equal(game.state.battle.enemies.length, count);
game.debugWinBattle();
assert.equal(game.state.tiles[target].kind, "EMPTY");
}
});
test("小Boss与污染Boss地块进入战斗且胜利后变荒地", () => {
for (const kind of ["MINIBOSS", "INFECTED_BOSS"]) {
const seed = `SPECIAL-BOSS-${kind}`;
const probe = new GameEngine(data, seed);
const target = probe.rollDice().sum;
const game = new GameEngine(data, seed);
game.state.tiles[target] = { index: target, kind, consumed: false, enemyCount: 3, infectionTurnsRemaining: kind === "INFECTED_BOSS" ? 3 : undefined };
game.rollDice();
assert.equal(game.state.battle.isMiniBoss, true);
assert.equal(game.state.battle.enemies.length, 3);
game.debugWinBattle();
assert.equal(game.state.tiles[target].kind, "EMPTY");
}
});
test("未消灭的污染Boss在过层时连续3次令普通怪物地块污染加1", () => {
const game = new GameEngine(data, "INFECTED-BOSS-THREE-FLOORS");
game.state.tiles = Array.from({ length: 40 }, (_, index) => ({ index, kind: index === 0 ? "START" : "EMPTY", consumed: false }));
game.state.tiles[20] = { index: 20, kind: "BATTLE", consumed: false, enemyCount: 1, pollutionLevel: 0 };
game.state.tiles[25] = { index: 25, kind: "INFECTED_BOSS", consumed: false, enemyCount: 1, infectionTurnsRemaining: 3 };
for (let level = 1; level <= 3; level += 1) {
game.state.tileIndex = 39;
game.state.phase = "READY";
game.state.event = undefined;
game.state.battle = undefined;
game.rollDice();
assert.equal(game.state.tiles[20].enemyCount, level + 1);
assert.equal(game.state.tiles[20].pollutionLevel, level);
assert.equal(game.state.tiles[25].infectionTurnsRemaining, 3 - level);
}
});
test("事件结算后变为荒地且未被覆盖的荒地跨层保留", () => {
const seed = "EVENT-TO-WASTELAND";
const probe = new GameEngine(data, seed);
const target = probe.rollDice().sum;
const game = new GameEngine(data, seed);
game.state.tiles[target] = { index: target, kind: "EVENT", eventId: "EV-138", consumed: false };
game.rollDice();
assert.equal(game.state.phase, "EVENT");
game.resolveSimpleEvent();
assert.equal(game.state.tiles[target].kind, "EMPTY");
const emptyBefore = new Set(game.state.tiles.filter((tile) => tile.kind === "EMPTY").map((tile) => tile.index));
game.state.tileIndex = 39;
game.state.phase = "READY";
game.rollDice();
const retained = game.state.tiles.filter((tile) => tile.kind === "EMPTY" && emptyBefore.has(tile.index));
assert.ok(retained.length >= emptyBefore.size - 3, "除本层新增地块覆盖外,荒地必须跨层保留");
});
test("Boss 按 2 次 ROLL 蓄势、4 次 ROLL 倒计时并转换 4 个非祝福地块", () => {
let seed = "";
for (let attempt = 0; attempt < 100 && !seed; attempt += 1) {
const probe = new GameEngine(data, `BOSS-CYCLE-${attempt}`);
let sixth;
for (let roll = 0; roll < 6; roll += 1) {
probe.state.phase = "READY";
probe.state.battle = undefined;
probe.state.event = undefined;
probe.state.shop = undefined;
sixth = probe.rollDice();
}
if (sixth.sum < 10) seed = `BOSS-CYCLE-${attempt}`;
}
assert.ok(seed);
const game = new GameEngine(data, seed);
const rollAgain = () => {
game.state.phase = "READY";
game.state.battle = undefined;
game.state.event = undefined;
game.state.shop = undefined;
game.rollDice();
return game.drainEvents();
};
const firstEvents = rollAgain();
assert.equal(game.state.bossGauge.mode, "BUILDUP");
assert.equal(game.state.bossGauge.playerRolls, 1);
assert.ok(firstEvents.some((event) => event.type === "BOSS_GAUGE_CHANGED" && event.payload.playerRolls === 1));
assert.ok(!firstEvents.some((event) => event.type === "BOSS_ACTION"));
const secondEvents = rollAgain();
assert.equal(game.state.bossGauge.mode, "COUNTDOWN");
assert.equal(game.state.bossGauge.countdownRemaining, 4);
assert.ok(secondEvents.some((event) => event.type === "BOSS_COUNTDOWN_STARTED" && event.payload.remaining === 4));
for (const expected of [3, 2, 1]) {
const events = rollAgain();
assert.equal(game.state.bossGauge.countdownRemaining, expected);
assert.ok(events.some((event) => event.type === "BOSS_COUNTDOWN_TICK" && event.payload.remaining === expected));
assert.ok(!events.some((event) => event.type === "BOSS_ACTION"));
}
game.state.tileIndex = 0;
game.state.tiles = Array.from({ length: 40 }, (_, index) => ({
index,
kind: index === 0 ? "START" : index === 38 ? "EVENT" : "EMPTY",
eventId: index === 38 ? "EV-144" : undefined,
consumed: false,
}));
const sixthEvents = rollAgain();
const action = sixthEvents.find((event) => event.type === "BOSS_ACTION");
assert.ok(action);
assert.equal(action.payload.tiles.length, 4);
assert.equal(new Set(action.payload.tiles).size, 4);
assert.ok(!action.payload.tiles.includes(38), "祝福地块不得被 Boss 转换");
assert.equal(game.state.bossGauge.mode, "BUILDUP");
assert.equal(game.state.bossGauge.playerRolls, 0);
assert.equal(game.state.bossGauge.countdownRemaining, 0);
assert.equal(game.state.bossGauge.bossActions, 1);
assert.equal(game.state.tiles.filter((tile) => tile.kind === "BATTLE").length, 4);
assert.equal(game.state.tiles[38].eventId, "EV-144");
});
test("十层自然调度覆盖六类核心事件且商人仅保证在 3/6/9 层", () => {
const game = new GameEngine(data, "CORE-EVENT-COVERAGE");
const seen = new Set();
for (let floor = 1; floor <= 10; floor += 1) {
game.debugSet(floor, 0);
for (const tile of game.state.tiles) if (tile.eventId) seen.add(tile.eventId);
assert.equal(game.state.tiles.some((tile) => tile.kind === "MERCHANT"), [3, 6, 9].includes(floor));
}
for (const id of ["EV-MERCHANT", "EV-137", "EV-138", "EV-140", "EV-141", "EV-144"]) assert.ok(seen.has(id), `missing ${id}`);
});
test("第十层越过 Start 必定进入 Boss", () => {
const game = new GameEngine(data, "BOSS-TRIGGER");
game.debugSet(10, 999);
game.state.tileIndex = 39;
game.rollDice();
assert.equal(game.state.phase, "BOSS");
assert.equal(game.state.battle.isBoss, true);
assert.equal(game.state.battle.enemies[0].role, "BOSS");
});
test("第三骰只在基础双骰后提供替换决策", () => {
const game = new GameEngine(data, "THIRD-DIE-FLOW");
game.state.blessings.push({ blessingId: "AUG-THIRD-DIE", stringId: "ThirdDieReplace", stack: 1, value: 1 });
const roll = game.rollDice();
assert.equal(game.state.phase, "THIRD_DIE");
assert.ok(roll.c >= 1 && roll.c <= 6);
const resolved = game.chooseThirdDie("A");
assert.equal(resolved.chosenA, roll.c);
assert.equal(resolved.chosenB, roll.b);
assert.equal(game.state.pendingThirdDie, undefined);
});
test("商人固定显示三个升级槽和三个随机槽", () => {
const game = new GameEngine(data, "SHOP-SIX-SLOTS");
game.debugForceEvent("EV-MERCHANT");
const offers = game.getShopOffers();
assert.equal(offers.length, 6);
assert.equal(offers.slice(0, 3).every((offer) => offer.itemType === "FIXED"), true);
assert.equal(offers.slice(3).every((offer) => offer.itemType !== "FIXED"), true);
});
test("事件增强 transactionId 幂等且失败重入不重复发奖", () => {
const game = new GameEngine(data, "IDEMPOTENCY");
game.debugForceEvent("EV-140");
assert.equal(game.state.event.state.attempts, 5);
assert.equal(game.useEventBoost("BOOST_DIG_EXTRA"), true);
assert.equal(game.useEventBoost("BOOST_DIG_EXTRA"), false);
assert.equal(game.state.event.state.attempts, 10);
assert.equal(game.state.committedTransactions.length, 1);
});
test("广告复活入口已禁用,致死后直接进入本局失败", () => {
const game = new GameEngine(data, "NO-AD-REVIVE");
game.startBattle(false);
game.debugForceDeath();
assert.equal(game.state.phase, "DEFEAT");
assert.equal(game.instantRevive(), false);
});
test("玩家面板反击属性保留且受击后能触发反击事件", () => {
const game = new GameEngine(data, "COUNTER-ATTACK");
game.state.baseStats.counterAttack = 101;
game.state.baseStats.attackSpeed = 0.1;
game.startBattle(false);
game.drainEvents();
const events = [];
for (let i = 0; i < 40 && !events.some((event) => event.type === "COUNTER"); i += 1) {
game.tickBattle(250);
events.push(...game.drainEvents());
}
assert.equal(game.effectiveStats().counterAttack, 101);
assert.ok(events.some((event) => event.type === "COUNTER"));
});
test("暴击恢复为 criticalRate 大于 0..100 整数掷值", () => {
const collect = (rate) => {
const game = new GameEngine(data, `CRITICAL-${rate}`);
game.state.baseStats.attack = 1;
game.state.baseStats.attackSpeed = 1000;
game.state.baseStats.critRate = rate;
game.startBattle(false);
game.drainEvents();
const damageEvents = [];
for (let i = 0; i < 20 && damageEvents.length < 5; i += 1) {
game.tickBattle(250);
damageEvents.push(...game.drainEvents().filter((event) => event.type === "DAMAGE" && event.payload.actor === "player"));
}
return damageEvents;
};
const zero = collect(0);
const full = collect(101);
assert.ok(zero.length > 0 && zero.every((event) => event.payload.critical === false));
assert.ok(full.length > 0 && full.every((event) => event.payload.critical === true));
assert.ok(full.every((event) => event.payload.criticalRoll >= 0 && event.payload.criticalRoll <= 100));
});
test("唯一丹药仅由三个固定商品补充、加上限和增加恢复量", () => {
const game = new GameEngine(data, "POTION-SHOP");
game.debugForceEvent("EV-MERCHANT");
game.state.points = 999;
const offers = game.getShopOffers();
const fixed = Object.fromEntries(offers.filter((offer) => offer.itemType === "FIXED").map((offer) => [offer.fixedKey, offer]));
assert.deepEqual(Object.keys(fixed).sort(), ["potionAdd", "potionCount", "potionHeal"]);
assert.ok(offers.slice(3).every((offer) => offer.itemType !== "POTION"));
assert.equal(game.buyShopOffer(fixed.potionAdd.offerId), false);
assert.equal(game.state.potion.charges, 1);
game.state.potion.charges = 0;
assert.equal(game.buyShopOffer(fixed.potionAdd.offerId), true);
assert.equal(game.state.potion.charges, 1);
assert.equal(game.buyShopOffer(fixed.potionCount.offerId), true);
assert.equal(game.state.potion.maxCharges, 2);
assert.equal(game.state.potion.charges, 1);
assert.equal(game.buyShopOffer(fixed.potionHeal.offerId), true);
assert.equal(game.state.potion.healAmount, 80);
game.closeEvent();
game.startBattle(false);
game.state.currentHp = 1;
game.state.battle.playerHp = 1;
assert.equal(game.usePotion(), true);
assert.equal(game.state.currentHp, 81, "原作 HealLevel 80 是固定恢复 80 HP");
});
test("旧安全点可迁移固定治疗、Boss循环和40格棋盘", () => {
const original = new GameEngine(data, "LEGACY-SAVE").snapshot();
original.saveVersion = 2;
original.tiles = Array.from({ length: 10 }, (_, index) => ({ index, kind: index === 0 ? "START" : index === 3 ? "BATTLE" : "EMPTY", consumed: false }));
original.tileIndex = 3;
original.potion.healPercent = 80;
delete original.potion.healAmount;
original.bossGauge = { current: 50, maximum: 100, perPlayerAction: 50, bossActions: 2 };
const restored = new GameEngine(data, original);
assert.equal(restored.state.potion.healAmount, 80);
assert.equal(restored.state.potion.healPercent, undefined);
assert.deepEqual(restored.state.bossGauge, { mode: "BUILDUP", playerRolls: 1, buildupRollsRequired: 2, countdownRemaining: 0, countdownRolls: 4, bossActions: 2 });
assert.equal(restored.state.saveVersion, 3);
assert.equal(restored.state.tiles.length, 40);
assert.equal(restored.state.tileIndex, 12);
assert.equal(restored.state.tiles[12].kind, "BATTLE");
});
test("对子机缘永久叠加属性并只把先攻带入下一场战斗", () => {
let game;
let fortuneEvent;
for (let i = 0; i < 200 && !fortuneEvent; i += 1) {
game = new GameEngine(data, `FORTUNE-${i}`);
game.rollDice();
fortuneEvent = game.drainEvents().find((event) => event.type === "FORTUNE_TRIGGERED");
}
assert.ok(game && fortuneEvent);
assert.equal(game.state.fortune.damagePercent + game.state.fortune.maxHpPercent + game.state.fortune.defensePercent, 10);
game.state.phase = "READY";
game.state.battle = undefined;
game.state.fortune.pendingOpeningStrike = true;
game.startBattle(false);
assert.equal(game.state.fortune.pendingOpeningStrike, false);
assert.equal(game.state.battle.fortuneOpeningStrike, true);
game.drainEvents();
game.tickBattle(1);
assert.ok(game.drainEvents().some((event) => event.type === "FORTUNE_STRIKE"));
});
test("第一层自动战斗可在基准数值下完成", () => {
const game = new GameEngine(data, "BATTLE-BASELINE");
game.startBattle(false);
for (let i = 0; i < 500 && ["BATTLE", "BOSS"].includes(game.state.phase); i += 1) game.tickBattle(100);
assert.ok(["READY", "GEAR_REWARD"].includes(game.state.phase), `unexpected ${game.state.phase}`);
});
test("初始配置无需广告复活即可连续击败前三组怪物", () => {
const game = new GameEngine(data, "THREE-EARLY-GROUPS");
for (let encounter = 0; encounter < 3; encounter += 1) {
game.startBattle(false);
for (let i = 0; i < 600 && ["BATTLE", "BOSS"].includes(game.state.phase); i += 1) game.tickBattle(100);
assert.notEqual(game.state.phase, "DEFEAT", `第 ${encounter + 1} 组怪物导致失败`);
if (game.state.phase === "GEAR_REWARD") game.salvagePendingGear();
assert.equal(game.state.phase, "READY");
}
assert.ok(game.state.currentHp > 0);
assert.ok(game.state.dda.threatScale <= 1);
});
let failed = 0;
const started = performance.now();
for (const [name, fn] of tests) {
try {
await fn();
console.log(`PASS ${name}`);
} catch (error) {
failed += 1;
console.error(`FAIL ${name}`);
console.error(error);
}
}
console.log(`\n${tests.length - failed}/${tests.length} passed in ${Math.round(performance.now() - started)}ms`);
if (failed) process.exitCode = 1;
+120
View File
@@ -0,0 +1,120 @@
import assert from "node:assert/strict";
import { chromium } from "file:///C:/Users/toshio/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright/index.mjs";
const BASE = process.env.HEROLL_MVP_URL ?? "http://127.0.0.1:4173/mvp/";
const browser = await chromium.launch({
headless: true,
executablePath: "C:/Program Files/Google/Chrome/Application/chrome.exe",
});
const context = await browser.newContext({ viewport: { width: 758, height: 1352 }, deviceScaleFactor: 1 });
const page = await context.newPage();
const errors = [];
const failed = [];
page.on("console", (message) => { if (message.type() === "error") errors.push(message.text()); });
page.on("pageerror", (error) => errors.push(error.message));
page.on("requestfailed", (request) => failed.push(`${request.url()} ${request.failure()?.errorText ?? ""}`));
page.on("response", (response) => { if (response.status() >= 400) errors.push(`${response.status()} ${response.url()}`); });
const box = async (selector) => {
const rect = await page.locator(selector).boundingBox();
assert.ok(rect, `缺少布局节点 ${selector}`);
return rect;
};
try {
await page.goto(BASE, { waitUntil: "networkidle" });
await page.locator("#seed-input").fill("ORIGINAL-RESTORATION-001");
await page.locator("#new-run-button").click();
await page.locator("#game-screen.active").waitFor();
assert.equal(await page.locator(".board-tile").count(), 40, "原作细格环形路径应为40个实际地块");
assert.equal(await page.locator(".board-tile.logical").count(), 40, "每个画面格都必须是独立逻辑落点");
assert.equal(await page.locator("#gear-slots button").count(), 4, "底部固定四装备槽");
assert.equal(await page.locator("#auto-indicator").textContent(), "AUTO");
assert.equal(await page.locator("#roll-button").textContent(), "ROLL");
assert.equal(await page.locator("#board-ring").evaluate((node) => getComputedStyle(node).transform), "none", "棋盘必须固定,不得随玩家位置旋转");
const clockwise = await page.evaluate(() => {
const next = document.querySelector('[data-visual-index="1"]').getBoundingClientRect();
const previous = document.querySelector('[data-visual-index="39"]').getBoundingClientRect();
return { nextX: next.x, previousX: previous.x };
});
assert.ok(clockwise.nextX < clockwise.previousX, "玩家应从底部 START 沿左侧顺时针移动");
assert.ok(await page.locator(".board-tile.logical.battle .tile-art").count() > 0, "怪物地块必须显示原作1至4骷髅图标");
assert.ok(await page.locator(".board-tile.logical.good .tile-art").count() > 0, "事件地块必须显示原作事件图标");
const battleTiles = await page.locator(".board-tile.logical.battle").evaluateAll((nodes) => nodes.map((node) => ({ label: node.getAttribute("aria-label"), src: node.querySelector("img")?.getAttribute("src") })));
assert.ok(battleTiles.every(({ label, src }) => /[1-4]怪物地块/.test(label) && /icon_NormalEnemy_[1-4]/.test(src)), "骷髅图标必须与怪物数量一一对应");
assert.match(await page.locator(".board-tile.logical.good .tile-art").first().getAttribute("src"), /Installtile_/, "事件格应使用 newtile.csv 指定的 Installtile 原作图标");
assert.equal(await page.locator(".board-tile.miniboss .tile-art").count(), 1, "必须显示原作小Boss地块");
assert.equal(await page.locator(".board-tile.infected_boss .tile-art").count(), 1, "必须显示原作污染Boss地块");
const app = await box("#app");
const resource = await box(".resource-chip");
const auto = await box("#auto-indicator");
const meter = await box("#floor-meter");
const boss = await box("#boss-figure");
const gear = await box("#gear-slots");
const roll = await box("#roll-button");
const potion = await box("#potion-button");
assert.ok(resource.x / app.width < 0.04 && resource.y / app.height < 0.08, "点数条应位于左上");
assert.ok(auto.x / app.width > 0.70 && meter.x / app.width > 0.90, "AUTO 与 Boss Gauge 应位于右上/右侧");
assert.ok(Math.abs((boss.x + boss.width / 2) / app.width - 0.5) < 0.02, "Boss 应保持棋盘中心");
assert.ok(gear.y / app.height > 0.76 && roll.y / app.height > 0.90, "装备槽与主操作键应位于底部操作区");
assert.ok(potion.x / app.width > 0.84, "药水入口应位于右下");
const sourceStyles = await page.evaluate(() => ({
hero: getComputedStyle(document.querySelector("#hero-marker")).backgroundImage,
boss: getComputedStyle(document.querySelector("#boss-figure")).backgroundImage,
tile: getComputedStyle(document.querySelector(".board-tile.empty")).backgroundImage,
}));
assert.match(sourceStyles.hero, /knight_m_idle_anim_f0/, "棋子必须使用逆向导出的原作骑士帧");
assert.match(sourceStyles.boss, /001_boss_idle_2/, "中心 Boss 必须使用原作 boss_0 帧");
assert.match(sourceStyles.tile, /tile_normal/, "棋盘格必须使用原作 Tile 纹理");
await page.locator("#roll-button").click();
await page.waitForTimeout(160);
assert.ok(await page.locator("#game-screen.showing-dice").count(), "掷骰阶段必须保持独立演出态");
assert.equal(await page.locator(".die-face").count(), 2, "必须显示红/蓝两枚原作骰面");
const exactSum = Number(await page.locator("#dice-result").getAttribute("data-sum"));
assert.equal(Number(await page.locator(".dice-sum").textContent()), exactSum, "骰子总点数必须明确显示");
assert.equal(await page.locator("#board-view").evaluate((node) => getComputedStyle(node).display), "block", "掷骰时仍显示棋盘");
assert.equal(await page.locator("#battle-view").evaluate((node) => getComputedStyle(node).display), "none", "骰子演出结束前不得抢先切战斗");
const diceStyles = await page.locator(".die-face").evaluateAll((nodes) => nodes.map((node) => getComputedStyle(node).getPropertyValue("--die-image")));
assert.match(diceStyles[0], /dice_red_[1-6]/);
assert.match(diceStyles[1], /dice_blue_[1-6]/);
await page.waitForTimeout(1050);
assert.equal(await page.locator("#game-screen.showing-dice").count(), 0, "骰子演出应在落点结算前结束");
await page.locator("#game-screen.animating-move").waitFor({ state: "visible" });
assert.equal(await page.locator("#modal-layer .modal").count(), 0, "逐格移动完成前不得弹出地块结算");
const runningFrame = await page.locator("#hero-marker.running").evaluate((node) => getComputedStyle(node).backgroundImage);
assert.match(runningFrame, /knight_m_run_anim_f[0-3]/, "逐格移动必须使用原作骑士跑步帧");
await page.locator("#game-screen.animating-move").waitFor({ state: "hidden", timeout: 6000 });
assert.equal(Number(await page.locator(".board-tile.current").getAttribute("data-visual-index")), exactSum, "从START时ROLL点数必须等于实际移动格数");
assert.equal(await page.locator("#board-ring").evaluate((node) => getComputedStyle(node).transform), "none", "移动结束后棋盘仍必须固定");
await page.locator("#debug-toggle").click();
await page.locator("button[data-debug=battle]").click();
await page.locator("#battle-view.active").waitFor();
await page.locator("#battle-announcer.show").waitFor();
assert.match(await page.locator("#battle-announcer").textContent(), /BATTLE/);
assert.equal(await page.locator("#battle-versus").count(), 1, "战斗顶部应显示双方生命对比条");
const battleStyles = await page.evaluate(() => ({
player: getComputedStyle(document.querySelector(".player-silhouette")).backgroundImage,
enemy: getComputedStyle(document.querySelector(".enemy.sword .pixel-fighter")).backgroundImage,
}));
assert.match(battleStyles.player, /knight_m_idle_anim_f[0-3]/, "战斗角色必须沿用原作红披风骑士帧");
assert.match(battleStyles.enemy, /m_004/, "第一层剑士型敌人必须使用 enemy_sword_0 的原作纹理");
await page.locator(".damage-number").first().waitFor({ state: "attached", timeout: 5000 });
await page.locator("button[data-debug=merchant]").click();
await page.locator(".shop-modal").waitFor();
assert.equal(await page.locator(".shop-section-title").allTextContents().then((values) => values.join("/")), "一般商品/特别商品");
assert.equal(await page.locator(".shop-offer").count(), 6, "原作商店应显示 3+3 商品");
const shopBackground = await page.locator(".shop-modal").evaluate((node) => getComputedStyle(node).backgroundImage);
assert.match(shopBackground, /ingame_shop_bg/, "商店必须使用原作棚架面板");
assert.deepEqual(errors, [], `浏览器错误:\n${errors.join("\n")}`);
assert.deepEqual(failed, [], `资源加载失败:\n${failed.join("\n")}`);
console.log("PASS original restoration: layout → source assets → dice hold → combat sprites → shop structure");
} finally {
await browser.close();
}
+71
View File
@@ -0,0 +1,71 @@
import http from "node:http";
import { readFile, stat } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const rootDir = path.resolve(scriptDir, "../..");
const port = Number(process.env.HEROLL_MVP_PORT || 4173);
const parentPidFlag = process.argv.indexOf("--parent-pid");
const parentPid = parentPidFlag >= 0 ? Number(process.argv[parentPidFlag + 1]) : 0;
const mime = new Map([
[".html", "text/html; charset=utf-8"],
[".js", "text/javascript; charset=utf-8"],
[".mjs", "text/javascript; charset=utf-8"],
[".css", "text/css; charset=utf-8"],
[".json", "application/json; charset=utf-8"],
[".csv", "text/csv; charset=utf-8"],
[".png", "image/png"],
[".jpg", "image/jpeg"],
[".jpeg", "image/jpeg"],
[".svg", "image/svg+xml"],
[".wav", "audio/wav"],
[".ogg", "audio/ogg"]
]);
function safePath(urlPath) {
const decoded = decodeURIComponent(urlPath.split("?")[0] || "/");
const relative = decoded === "/" ? "mvp/index.html" : decoded.replace(/^\/+/, "");
const resolved = path.resolve(rootDir, relative);
if (!resolved.startsWith(rootDir + path.sep) && resolved !== rootDir) return null;
return resolved;
}
const server = http.createServer(async (req, res) => {
try {
let target = safePath(req.url || "/");
if (!target) {
res.writeHead(403).end("Forbidden");
return;
}
const info = await stat(target);
if (info.isDirectory()) target = path.join(target, "index.html");
const body = await readFile(target);
res.writeHead(200, {
"Content-Type": mime.get(path.extname(target).toLowerCase()) || "application/octet-stream",
"Cache-Control": "no-store",
"Cross-Origin-Resource-Policy": "same-origin"
});
res.end(body);
} catch (error) {
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
res.end(`Not found\n${String(error)}`);
}
});
server.listen(port, "127.0.0.1", () => {
console.log(`HEROLL MVP running at http://127.0.0.1:${port}/mvp/`);
console.log("Press Ctrl+C to stop.");
});
if (Number.isInteger(parentPid) && parentPid > 0) {
const parentWatch = setInterval(() => {
try {
process.kill(parentPid, 0);
} catch {
clearInterval(parentWatch);
server.close(() => process.exit(0));
}
}, 1000);
parentWatch.unref();
}
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "Bundler",
"rootDir": "src",
"outDir": "dist",
"strict": true,
"noUncheckedIndexedAccess": false,
"exactOptionalPropertyTypes": false,
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"sourceMap": true
},
"include": ["src/**/*.ts"]
}