Initial import of HEROLL source and design documents
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
*.log
|
||||
.server.pid
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
# HEROLL 玩法验证 MVP
|
||||
|
||||
这是 `Heroll_Gameplay_Validation_GDD.md` 的本地单机 H5 垂直切片。目标是验证“公平双骰 → 绕棋盘推进 → 自动战斗 → 局内构筑 → 十层 Boss”的可玩闭环,不是完整 IAA 产品,也不包含其他项目内容。
|
||||
|
||||
> 仅限本机内部验证。提取的 HEROLL 原型资产未取得发行授权,不得公开、上传或分发。
|
||||
|
||||

|
||||
|
||||
最新整合玩法文档:[`../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 或用户统计。
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+24
@@ -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: {}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+148
@@ -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
@@ -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("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
||||
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();
|
||||
@@ -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()]));
|
||||
}
|
||||
}
|
||||
@@ -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]));
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user