From a3e8460bb0c6dd686fb149386d8c7f65661a8fed Mon Sep 17 00:00:00 2001 From: sganggs Date: Mon, 12 Oct 2026 14:59:20 +0800 Subject: [PATCH] =?UTF-8?q?0.2.5-dev:=20an=20optional=20=E3=80=8C=E5=AE=98?= =?UTF-8?q?=E6=96=B9=E4=BC=A4=E5=AE=B3=E6=95=B0=E5=AD=97=E3=80=8D=20settin?= =?UTF-8?q?g=20(default=20off)=20shows=20only=20the=20numbers=20the=20offi?= =?UTF-8?q?cial=20game=20shows=20per=20the=20PRTS=20red-number=20rule=20(#?= =?UTF-8?q?412,=20the=20owner's=20decision=20of=202026-10-12);=20a=20small?= =?UTF-8?q?=20link=20to=20the=20project's=20GitHub=20repository=20on=20the?= =?UTF-8?q?=20title=20page=20(#543);=20golden:=20unchanged?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 2 + docs/PLAYING.md | 4 + docs/design/client.md | 6 + docs/history/0.2.5.md | 52 ++++++++ public/css/screens/game.css | 2 + public/css/screens/title.css | 5 + public/i18n/en.json | 3 + public/i18n/ja.json | 3 + public/i18n/ko.json | 3 + public/i18n/zh-TW.json | 3 + public/js/diag.js | 2 +- public/js/render/app.js | 9 +- public/js/render/fx/fonts.js | 2 +- public/js/render/fx/numbers.js | 58 ++++++++- public/js/render/fx/simfx.js | 4 + public/js/render/fx/system.js | 2 +- public/js/render/style.js | 3 + public/js/screens/title.js | 7 +- public/js/ui/fallbackField.js | 34 ++++- public/js/ui/gameLogic/settings.js | 5 +- public/js/ui/settings.js | 2 + server/sim/damage.js | 95 +++++++++++--- test/render/official-numbers.test.js | 79 ++++++++++++ test/sim/official-damage-numbers.test.js | 152 +++++++++++++++++++++++ test/ui/gameLogic.test.js | 2 +- test/ui/official-damage-numbers.test.js | 59 +++++++++ 26 files changed, 555 insertions(+), 43 deletions(-) create mode 100644 test/render/official-numbers.test.js create mode 100644 test/sim/official-damage-numbers.test.js create mode 100644 test/ui/official-damage-numbers.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 109841cc..d780b0b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ - 修正韩语界面和维多利亚英文按装备逐件加成的表述。 - 修复干员一次攻击后模型继续循环、多挥一次的问题,保留迟到消息的现有前摇同步方式。 - 自在重生后的无敌期不再被普攻或需要可选目标的自动技能锁定;修复 AI 托管遮罩挡住“在此页面继续”按钮。 +- 设置增加「官方伤害数字」(默认关闭)。打开后只按 PRTS「伤害红字(暴击)」显示红色整数;关闭时仍显示每一次命中(#412)。 +- 标题画面页脚增加「项目仓库」,在新标签页打开本项目仓库(#543)。 ## 0.2.4 — 2026-10-11 diff --git a/docs/PLAYING.md b/docs/PLAYING.md index 76de363a..36cc34af 100644 --- a/docs/PLAYING.md +++ b/docs/PLAYING.md @@ -176,10 +176,14 @@ 刷新、冻结、升级、撤退、出售和准备只在休整期有效,撤退 / 出售要先点选一名干员。方向轮盘(方向键预览、`Enter` 确认、`Esc` 取消;按 Tab 先到轮盘上的「点击取消」,在它上面按 `Enter` 是取消,Tab 到其他按钮上按 `Enter` 没有反应)、「玩法说明」和「干员调配」的翻页键是这些界面自己的按键,不能修改。 +**伤害数字**:标题画面、大厅、房间和对局里的「设置」有「显示伤害数字」(默认打开,每一次命中和治疗都跳出数字)和「官方伤害数字」(默认关闭)。打开「官方伤害数字」后,只在伤害达到 [PRTS 作战机制](https://prts.wiki/w/作战机制)「伤害红字(暴击)」的条件时显示红色整数(最终伤害不低于期望伤害的 1.5 倍;闪避了本应显示红字的一击时显示黄色 MISS)。关掉「显示伤害数字」则什么数字都不显示。无敌时官方客户端会显示红色 0,本作目前不会为这种未击中补一条伤害事件,因此这种红色 0 暂时看不到。 + **修改快捷键**:标题画面的「设置」按钮、大厅和房间顶栏的「设置」(玩法说明左边)或对局左下角的「设置」→「快捷键」。点一下要改的按键(只用键盘时按 Tab 移到它上面再按 Enter),出现「按下新按键…」后按新的键;按 `Esc` 取消,设置窗口不会关闭。可以用字母、数字、空格、标点符号键和 Delete / Insert / Home / End / PageUp / PageDown;`Esc`、`Tab`、`Enter`、方向键和 F1–F12 留给界面使用,也不能搭配 Ctrl / Alt / ⌘。新按键已被另一个快捷键使用时两者互换,列表下方会写明(例如把「撤退选中干员」改成 `X`,「出售选中干员」就换成原来的 `Q`)。「恢复默认」改回上表的按键。修改立即生效,商店栏、准备按钮和单位底部「撤退 / 出售」按钮上显示的按键也跟着变;设置和音量等一起保存在当前浏览器里(换浏览器或清除网站数据后回到默认)。按键按键帽上印的字符识别(AZERTY 等键盘按印着的字母),俄文等非拉丁布局按键位识别。手机等纯触屏设备要连接键盘才能用快捷键,设置里的快捷键列表默认折叠。 ## 12. 反馈问题 +标题画面页脚的「项目仓库」在新标签页打开本项目的 [GitHub 仓库](https://github.com/sganggs/Stronghold-Protocol)。 + 遇到报错、卡住或结果不对时,打开「设置」→「问题反馈」,点「复制诊断信息」,再到 [GitHub issue](https://github.com/sganggs/Stronghold-Protocol/issues) 里描述发生了什么并粘贴进去。诊断信息包括游戏版本、浏览器和设备、当前所在的阶段与回合,以及打开页面以来记录到的错误(最多保留最近 40 条)。战斗中复制时,默认附上这场战斗的数据,开发者可以用它重现这场战斗;不想附上可以关掉「附上本场战斗」。诊断信息只在你的浏览器里生成,不会自动上传;你和其他玩家的名字、房间号会被替换成 ``、``、`` 这样的占位符。剪贴板不可用时(部分 App 内置浏览器),内容会显示在下方的文本框里,手动全选复制即可。 ## 13. 统计(本机) diff --git a/docs/design/client.md b/docs/design/client.md index 1b1d1040..a7df85f7 100644 --- a/docs/design/client.md +++ b/docs/design/client.md @@ -170,3 +170,9 @@ the actor returns to base/skill idle. Late operator events still start at the st Connection recovery sits above the AI-away overlay and below global modals (§30.8), so a replaced session can be reclaimed by pointer while away. This does not change the stacking behavior of modals nested inside a screen. + +§30.17: 设置 →「官方伤害数字」 defaults off. Off, damage numbers stay the typed every-hit popups. On, and only +while 「显示伤害数字」 is also on, the field draws a red integer for a `dmg` tuple marked `'r'` and a yellow MISS +for a dodge fx whose payload is `num: 'm'` (PRTS 作战机制 「伤害红字(暴击)」). Ordinary hits and heals draw no +digits in that mode. The mark is computed in the sim; the random offset above the model is client-side. The +simplified board uses the same mark with a fixed slot. The title footer links the project repository in a new tab. diff --git a/docs/history/0.2.5.md b/docs/history/0.2.5.md index 9ebb34fe..5b694eb8 100644 --- a/docs/history/0.2.5.md +++ b/docs/history/0.2.5.md @@ -271,3 +271,55 @@ the preceding digest exactly; the changed partner/side also changes preparation All five already differed from 0.2.4, so the final release-relative total remains 45 scenarios (§30.9 plus these additional pairing effects). There are no new corpus cases or removals. The other follow-up fixes do not move this corpus; their dedicated regressions cover the reported and newly found cases. + +### 30.17 Official damage numbers and the title-page repository link + +Issues #412 and #543. #412's font swap, element-colour pass and bold crit type are not part of this change. +The new setting 「官方伤害数字」 (`officialDamageNumbers`) defaults off. While it is off, every rounded hit and +heal still pops the existing typed number (merge, colours, 90-number ceiling, sparks and slashes unchanged). +Turning 「显示伤害数字」 off still hides every digit, including official ones. The value is sanitised with the +other settings (a missing or non-boolean saved value stays off) and copied into the live field view. + +The official rule is PRTS [作战机制](https://prts.wiki/w/作战机制) 「伤害红字(暴击)」 (fetched 2026-10-12): show a +red integer of the final damage when 最终伤害 ≥ 期望伤害 × 1.5, or when a skill forces red. Expected damage follows +the normal formula except that DEF/RES penetration, post-mitigation bonuses (temporary attack-scale increases, +damage-scale increases such as 脆弱, amount rewrites, barrier absorption), final-damage resets and special HP +mechanisms are not counted. Invulnerable targets multiply expected damage by 0, and 0 ≥ 0 × 1.5 still shows red 0. +A dodge of a hit that would have been red shows yellow MISS (`#ff9000`) instead. After 卫戍协议:盟约 the official +client no longer caps red numbers at 10; this client does not restore that cap. The existing 90-number ceiling +stays as a renderer limit [ASSUMED: 「无限制」 means the old cap of 10 is not brought back]. + +In this sim the comparison runs inside `dealDamage` and does not change HP, shields, hooks or RNG: + +- Expected damage is mitigation of `dmg.amount` with penetration forced to 0, times `dmg.mul` only. +- The compared total is the pre-shield amount after source and target multipliers. The integer on the existing + `dmg` tuple — what both modes display — is what remains after shields and an `hpDamage` rewrite. +- A qualifying tuple that already fires (`amount >= 0.5`, not silent) gains a fifth element `'r'`. Other tuples + stay four elements. Element gauge fills are not marked. Heals are not marked, so official mode hides them. +- A dodge that already fires sets `num: 'm'` on its fx payload only when the same test says the landed hit would + be red. That test does not roll RNG again and does not refresh a dirty attacker's stats (a dirty `source.s` + read rescales HP). + +[ASSUMED] gaps, left unmarked rather than guessed: + +- `dmg.amount` already includes the attack's stated ATK × 攻击倍率 (`atkScaleMul` is baked in by callers). It is + not divided back out. `dmg.mul` stays on both sides of the comparison. +- Cached-ATK expected damage (raw ATK, ignoring the attack scale and the target's DEF/RES), forced red, blue + numbers and yellow BLOCK are not represented on DamageInfo. The three wiki examples are not hard-coded. +- An invulnerable or sleeping hit, and a qualifying total below 0.5, still emit no `dmg` event, so official mode + cannot show that red 0. Emitting one would increment `events.dmg` and could play an impact. The renderer can + draw a red 0 when a tuple `['dmg', id, 0, type, 'r']` is supplied. Liftoff and a cancelled or hit-capped attack + are not red 0. +- PRTS does not say that ordinary hits are silent. That is the requested setting: official mode shows only the + marked red integer and the yellow MISS. The sentence in the issue comment is not a PRTS sentence. +- The random position is about ±0.35 tiles across and 0.15–0.55 tiles above the head. The simplified board keeps + its fixed slot and only changes the colour / the MISS label. +- Sparks and the melee slash stay in both modes. + +#543: the title footer carries a text link labelled 「项目仓库」 to +`https://github.com/sganggs/Stronghold-Protocol`, `target="_blank"` and `rel="noopener noreferrer"`. It does not +replace 设置 → 问题反馈. + +Tests: `test/sim/official-damage-numbers.test.js`, `test/ui/official-damage-numbers.test.js`, +`test/render/official-numbers.test.js`. Golden digests do not move: no new event kind is emitted, and the extra +tuple field is not part of the digest. Player notes: PLAYING; client notes: design/client.md. diff --git a/public/css/screens/game.css b/public/css/screens/game.css index 190970de..515bb2ee 100644 --- a/public/css/screens/game.css +++ b/public/css/screens/game.css @@ -681,6 +681,8 @@ .ff-float--arts { color: #d59cff; } .ff-float--true { color: #fff; } .ff-float--heal { color: #6dff9c; } +.ff-float--crit { color: #ff2a2a; } +.ff-float--miss { color: #ff9000; } @keyframes float-up { to { transform: translate(-50%, -.4rem); opacity: 0; } } .ff-badge { position: absolute; left: 50%; bottom: .08rem; transform: translateX(-50%); font-family: var(--font-display); font-size: .1rem; letter-spacing: .3em; color: var(--text-dim); opacity: .5; pointer-events: none; } .gm--combat .ff-badge { display: none; } diff --git a/public/css/screens/title.css b/public/css/screens/title.css index 4b30939f..1f143a3a 100644 --- a/public/css/screens/title.css +++ b/public/css/screens/title.css @@ -265,9 +265,14 @@ html[data-script="alphabetic"] .title-login .btn--xl { letter-spacing: .3em; } display: flex; justify-content: space-between; align-items: flex-end; + flex-wrap: wrap; + gap: .12rem .2rem; font-size: calc(.13 * var(--t)); color: var(--text-dim); } +.title-foot__meta { display: flex; align-items: flex-end; gap: .16rem; flex-wrap: wrap; justify-content: flex-end; } +.title-repo { color: inherit; text-decoration: none; border-bottom: 1px solid transparent; } +.title-repo:hover, .title-repo:focus-visible { color: var(--text-hi); border-bottom-color: currentColor; } /* a development build (shared/constants.js DEV_BUILD): an unmissable tag in the footer */ .title-dev { padding: .03rem .1rem; diff --git a/public/i18n/en.json b/public/i18n/en.json index 7d34d07e..96e2cc0a 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -141,6 +141,7 @@ "输入你的代号(最多 {NAME_MAX_LEN} 字)": "Callsign (max {NAME_MAX_LEN} chars)", "开始": "Start", "非官方同人复刻 · 游戏素材版权归 上海鹰角网络 / Yostar 所有": "Unofficial fan remake · Game assets © Hypergryph / Yostar", + "项目仓库": "Repository", "高": "High", "中": "Medium", "低": "Low", @@ -155,6 +156,8 @@ "音效": "Sound Effects", "静音": "Mute", "显示伤害数字": "Damage Numbers", + "官方伤害数字": "Official Damage Numbers", + "只显示达到官方暴击条件的红色数字。关闭时仍显示每一次命中。": "Show only red numbers that meet the official crit rule. While off, every hit still shows a number.", "画面质量": "Graphics Quality", "触屏操作:点击单位选中(撤退 / 出售)· 长按单位或卡牌查看详情 · 拖动部署后滑动选择朝向": "Touch controls: tap a unit to select it (Retreat / Sell) · long-press a unit or card for details · after dragging to deploy, swipe to choose a direction", "快捷键": "Hotkeys", diff --git a/public/i18n/ja.json b/public/i18n/ja.json index cc1e8000..2b16ce51 100644 --- a/public/i18n/ja.json +++ b/public/i18n/ja.json @@ -147,6 +147,7 @@ "输入你的代号(最多 {NAME_MAX_LEN} 字)": "{NAME_MAX_LEN}文字以内で入力", "开始": "開始", "非官方同人复刻 · 游戏素材版权归 上海鹰角网络 / Yostar 所有": "非公式ファンリメイク · ゲーム素材の権利は Hypergryph / Yostar に帰属します", + "项目仓库": "リポジトリ", "高": "高", "中": "中", "低": "低", @@ -161,6 +162,8 @@ "音效": "効果音", "静音": "ミュート", "显示伤害数字": "ダメージ数値を表示", + "官方伤害数字": "公式ダメージ数値", + "只显示达到官方暴击条件的红色数字。关闭时仍显示每一次命中。": "公式のクリティカル条件を満たす赤い数値だけを表示します。オフのときは、これまで通り命中ごとに表示します。", "画面质量": "画質", "触屏操作:点击单位选中(撤退 / 出售)· 长按单位或卡牌查看详情 · 拖动部署后滑动选择朝向": "タッチ操作:ユニットをタップで選択(撤退 / 売却)· ユニットやカードを長押しで詳細表示 · ドラッグで配置後、スワイプで向きを選択", "快捷键": "ショートカットキー", diff --git a/public/i18n/ko.json b/public/i18n/ko.json index b50ef082..d5aecdfe 100644 --- a/public/i18n/ko.json +++ b/public/i18n/ko.json @@ -143,6 +143,7 @@ "输入你的代号(最多 {NAME_MAX_LEN} 字)": "코드네임 입력 (최대 {NAME_MAX_LEN}자)", "开始": "시작", "非官方同人复刻 · 游戏素材版权归 上海鹰角网络 / Yostar 所有": "비공식 팬 리메이크 · 게임 리소스 © Hypergryph / Yostar", + "项目仓库": "저장소", "高": "높음", "中": "보통", "低": "낮음", @@ -157,6 +158,8 @@ "音效": "효과음", "静音": "음소거", "显示伤害数字": "대미지 수치 표시", + "官方伤害数字": "공식 대미지 수치", + "只显示达到官方暴击条件的红色数字。关闭时仍显示每一次命中。": "공식 치명타 조건을 만족하는 빨간 숫자만 표시합니다. 끄면 지금까지처럼 모든 타격에 숫자가 표시됩니다.", "画面质量": "그래픽 품질", "触屏操作:点击单位选中(撤退 / 出售)· 长按单位或卡牌查看详情 · 拖动部署后滑动选择朝向": "터치 조작: 유닛을 탭하여 선택(퇴각 / 판매) · 유닛이나 카드를 길게 눌러 상세 정보 확인 · 드래그로 배치한 뒤 스와이프로 방향 선택", "快捷键": "단축키", diff --git a/public/i18n/zh-TW.json b/public/i18n/zh-TW.json index 4a483c41..26ea8dfa 100644 --- a/public/i18n/zh-TW.json +++ b/public/i18n/zh-TW.json @@ -145,6 +145,7 @@ "输入你的代号(最多 {NAME_MAX_LEN} 字)": "輸入你的代號(最多 {NAME_MAX_LEN} 字)", "开始": "開始", "非官方同人复刻 · 游戏素材版权归 上海鹰角网络 / Yostar 所有": "非官方同人復刻 · 遊戲素材版權歸 上海鷹角網絡 / Yostar 所有", + "项目仓库": "專案儲存庫", "高": "高", "中": "中", "低": "低", @@ -159,6 +160,8 @@ "音效": "音效", "静音": "靜音", "显示伤害数字": "顯示傷害數字", + "官方伤害数字": "官方傷害數字", + "只显示达到官方暴击条件的红色数字。关闭时仍显示每一次命中。": "只顯示達到官方暴擊條件的紅色數字。關閉時仍顯示每一次命中。", "画面质量": "畫面品質", "触屏操作:点击单位选中(撤退 / 出售)· 长按单位或卡牌查看详情 · 拖动部署后滑动选择朝向": "觸控操作:點擊單位選中(撤退 / 出售)· 長按單位或卡牌查看詳情 · 拖動部署後滑動選擇朝向", "快捷键": "快捷鍵", diff --git a/public/js/diag.js b/public/js/diag.js index 4a0c526c..959ae1e2 100644 --- a/public/js/diag.js +++ b/public/js/diag.js @@ -206,7 +206,7 @@ export function buildDiagnostics(input) { b.done ? 'done' : b.loading ? 'loading' : 'running', b.paused ? 'paused' : null, Number.isFinite(b.speed) ? `${b.speed}x` : null)}`); } if (settings) { - lines.push(`settings: ${join(`quality ${settings.quality}`, settings.damageNumbers ? 'damage numbers' : null, settings.muted ? 'muted' : null)}`); + lines.push(`settings: ${join(`quality ${settings.quality}`, settings.damageNumbers ? 'damage numbers' : null, settings.officialDamageNumbers ? 'official damage numbers' : null, settings.muted ? 'muted' : null)}`); } const shown = entries.slice().reverse(); const tot = input.total ?? total; diff --git a/public/js/render/app.js b/public/js/render/app.js index d457dbd2..1f7bea33 100644 --- a/public/js/render/app.js +++ b/public/js/render/app.js @@ -53,7 +53,7 @@ // view.holdPiece(uid, {row,col} | null) keep a dropped prep piece standing on a tile while its direction is // chosen / the move is in flight (null releases; setPrep then settles it) // view.setPieceDir(uid, dir) show a prep piece facing UP|RIGHT|DOWN|LEFT (wheel preview / stored dir) -// view.setSettings({ damageNumbers, quality: 'high'|'medium'|'low' }) ; view.resize() ; view.destroy() +// view.setSettings({ damageNumbers, officialDamageNumbers, quality: 'high'|'medium'|'low' }) ; view.resize() ; view.destroy() // view.stats() → { fps, frameMs, cpuMs, renderMs, units, particles, impostor, impostorAtlas, boardArt, … } (dev / perf) // // Enemy preview pen (research 09 §2.2 / 08 §4.2, render/pen.js): in prep the next round's enemies idle in the pen @@ -149,7 +149,7 @@ export { releaseGl } from './app/host.js'; /** * Create the battlefield view inside `host` (an element sized by CSS; the canvas fills it). * @param {HTMLElement} host - * @param {{ data?: any, assets?: any, audio?: any, settings?: { damageNumbers?: boolean, quality?: string }, signal?: AbortSignal, + * @param {{ data?: any, assets?: any, audio?: any, settings?: { damageNumbers?: boolean, officialDamageNumbers?: boolean, quality?: string }, signal?: AbortSignal, * padding?: object|((kind:string, size:{width:number,height:number}) => object), * hud?: {top:number,bottom:number}|((kind:'prep'|'bossPrep', size:{width:number,height:number}, o:{shop:boolean}) => {top:number,bottom:number}|null) }} [opts] */ @@ -162,7 +162,7 @@ export async function createFieldView(host, options = {}) { signal?.throwIfAborted(); const assets = resolveAssets(opts.assets); const data = makeData(opts.data); - const settings = { damageNumbers: true, quality: 'high', ...(opts.settings || {}) }; + const settings = { damageNumbers: true, officialDamageNumbers: false, quality: 'high', ...(opts.settings || {}) }; // the 3D board (three.js + the official art) loads in parallel with everything else const boardPref = boardPreference(opts.board); const want3d = boardPref !== '2d' && webgl2Available(boardPref === '3d'); @@ -1443,7 +1443,7 @@ export async function createFieldView(host, options = {}) { const m = meleePending.get(v.id); let src = null; if (m && now - m.t < 0.35) { src = m.src; meleePending.delete(v.id); fx._slashAt = src.id; } - fx.damage(v, amt, e[3], src); + fx.damage(v, amt, e[3], src, e[4]); break; } case 'heal': { const v = views.get(e[1]); if (v) fx.heal(v, Number(e[2]) || 0); break; } @@ -1880,6 +1880,7 @@ export async function createFieldView(host, options = {}) { if (!s || typeof s !== 'object') return; const q = settings.quality; if (typeof s.damageNumbers === 'boolean') settings.damageNumbers = s.damageNumbers; + if (typeof s.officialDamageNumbers === 'boolean') settings.officialDamageNumbers = s.officialDamageNumbers; if (s.quality === 'high' || s.quality === 'medium' || s.quality === 'low') settings.quality = s.quality; if (q !== settings.quality) { board3d?.setQuality?.(settings.quality); resize(); } }, diff --git a/public/js/render/fx/fonts.js b/public/js/render/fx/fonts.js index fcafa0c5..4b615db1 100644 --- a/public/js/render/fx/fonts.js +++ b/public/js/render/fx/fonts.js @@ -14,7 +14,7 @@ export function ensureDamageFonts() { fontFamily: ['Bender', 'Oxanium', 'Rajdhani', 'Arial Black', 'sans-serif'], fontSize: 44, fontWeight: '700', fill: st.fill, fillGradientStops: [0.25, 1], stroke: st.stroke, strokeThickness: 7, dropShadow: true, dropShadowColor: '#000000', dropShadowAlpha: 0.45, dropShadowDistance: 2, dropShadowBlur: 2, - }, { chars: [['0', '9'], '+-×!'], resolution: 2, padding: 6 }); + }, { chars: st.font === 'sp-dmg-miss' ? [['0', '9'], '+-×!', 'MISS'] : [['0', '9'], '+-×!'], resolution: 2, padding: 6 }); } catch (err) { console.warn('[fx] bitmap font', st.font, err?.message || err); } } fontsReady = true; diff --git a/public/js/render/fx/numbers.js b/public/js/render/fx/numbers.js index 38ff6307..9d2cb02c 100644 --- a/public/js/render/fx/numbers.js +++ b/public/js/render/fx/numbers.js @@ -38,8 +38,13 @@ const NUM_LANES = Object.freeze([0, -1, 1, -2, 2]); // lane order (lane widths /** Screen scale of a number's text (24 px font) at `s` px per tile. */ const numScale = (s, big, style) => clamp(s / 115, 0.42, 1.05) * (big ? 1.35 : 1) * (style === 'heal' ? 0.9 : 1); -/** Characters of a damage number as drawn (heals get a '+'). */ -const numChars = (v, style) => String(Math.round(v)).length + (style === 'heal' ? 1 : 0); +/** Characters of a damage number as drawn (heals get a '+'; a yellow MISS is four letters). */ +const numChars = (v, style) => (style === 'miss' ? 4 : String(Math.round(v)).length + (style === 'heal' ? 1 : 0)); + +// [ASSUMED] PRTS says a red number sits at a random spot in a range above the model and gives no pixels. +const OFFICIAL_JITTER_X = 0.7; // tiles, full width (±0.35) +const OFFICIAL_LIFT_MIN = 0.15; // tiles above the head +const OFFICIAL_LIFT_SPAN = 0.4; /** Sub-professions whose attacks splash around the struck target (the 阵法术师 / 轰击术师 strike every enemy in range). */ const SPLASH_SUBS = new Set(['aoesniper', 'splashcaster', 'bombarder', 'fortress', 'hammer']); @@ -48,7 +53,7 @@ export class FxNumbers { // ---- hits / numbers ----------------------------------------------------------------------------------------- /** b.ev 'dmg' visual: glow + sparks in the hit colour; a melee blow (atk 'none' just before) adds its slash. */ - damage(view, amount, type, srcView) { + damage(view, amount, type, srcView, mark) { if (!view) return; const style = dmgStyleKey(type); const p = this._chest(view); @@ -66,7 +71,13 @@ export class FxNumbers { } } view.onHit?.(); - if (this.ctx.settings?.damageNumbers !== false) this.number(view, amount, style, big); + if (this.ctx.settings?.damageNumbers === false) return; + // Official mode (设置「官方伤害数字」): only a sim-marked red integer. Sparks and the slash stay. + if (this.ctx.settings?.officialDamageNumbers) { + if (mark === 'r') this.officialNumber(view, amount, 'crit'); + return; + } + this.number(view, amount, style, big); } /** @@ -96,7 +107,42 @@ export class FxNumbers { tint: 0x7dffa8, vy: -s * 0.9, life: 0.7, s0: s / 64 * 0.32, s1: s / 64 * 0.2, a0: 0.95, a1: 0, fadeIn: 0.08, }); } - if (this.ctx.settings?.damageNumbers !== false) this.number(view, amount, 'heal', false); + if (this.ctx.settings?.damageNumbers !== false && !this.ctx.settings?.officialDamageNumbers) this.number(view, amount, 'heal', false); + } + + /** + * One official popup: a red integer (including 0) or the yellow MISS. It does not join a running total. + * Position is client-side only — the sim stays deterministic. + */ + officialNumber(view, amount, style) { + if (!view) return; + const miss = style === 'miss'; + const n = miss ? 0 : Math.round(amount); + if (!miss && (!Number.isFinite(n) || n < 0)) return; + const now = this.time; + const cam = this.ctx.cam(); + const base = (view.z || 0) + (view.hover || 0) + (view._headTiles || 1.2) * 0.8; + const a = cam.project(view.x, view.y, base, this._p); + const ax = a.x, ay = a.y, s = a.s > 0 ? a.s : 100; + const pxPerZ = ay - cam.project(view.x, view.y, base + 1, this._q).y; + const tile = pxPerZ > 1e-3 ? pxPerZ : s; + const cx = ax + (Math.random() - 0.5) * OFFICIAL_JITTER_X * tile; + const cy = ay - (OFFICIAL_LIFT_MIN + Math.random() * OFFICIAL_LIFT_SPAN) * tile; + const sc = numScale(s, false, style); + const w = numChars(n, style) * NUM_DIGIT_EM * 24 * sc * NUM_POP + NUM_GAP_PX; + const h = 24 * NUM_LINE_EM * sc * NUM_POP; + const maxNums = this.load >= 2 ? MAX_NUMBERS >> 1 : MAX_NUMBERS; + while (this.nums.length >= maxNums) this._releaseNum(this.nums.shift()); + const t = this._takeNum(style); + t.text.visible = true; + t.text.alpha = 1; + t.text.text = miss ? 'MISS' : String(n); + Object.assign(t, { + unit: view, style, value: n, life: 0, end: NUM_LIFE, born: now, lastHit: now, pop: 1, big: false, fading: false, + official: true, ox: cx - ax, oy: cy - ay, x0: view.x, y0: view.y, z0: base, + risePx: NUM_RISE * tile * 0.5, _x: cx, _y: cy, _w: w, _h: h, + }); + this.nums.push(t); } /** @@ -129,7 +175,7 @@ export class FxNumbers { // 1. merge into this unit's running total of the same style let mine = 0, youngest = null, oldest = null; for (const t of this.nums) { - if (t.unit !== view || t.fading) continue; + if (t.unit !== view || t.fading || t.official) continue; mine++; if (!oldest || t.born < oldest.born) oldest = t; if (t.style === style && (!youngest || t.born > youngest.born)) youngest = t; diff --git a/public/js/render/fx/simfx.js b/public/js/render/fx/simfx.js index 4319e8d5..01fdca43 100644 --- a/public/js/render/fx/simfx.js +++ b/public/js/render/fx/simfx.js @@ -258,6 +258,10 @@ export class FxSim { case 'dodge': { this.particle('soft', p.x, p.y, { tint: 0xffffff, life: 0.3, s0: s / 128 * 0.5, s1: s / 128 * 0.9, a0: 0.5, a1: 0 }); this.particle('slash', p.x, p.y, { tint: col, life: 0.22, s0: s / 128 * 0.8, s1: s / 128 * 1.1, a0: 0.7, a1: 0, rot: -0.5 }); + // num:'m' — the sim already decided this dodge would have been a red number (PRTS yellow MISS, #ff9000). + if (ex.num === 'm' && at.v && this.ctx.settings?.damageNumbers !== false && this.ctx.settings?.officialDamageNumbers) { + this.officialNumber(at.v, 0, 'miss'); + } break; } case 'counter': { diff --git a/public/js/render/fx/system.js b/public/js/render/fx/system.js index b57aa188..98b8a33a 100644 --- a/public/js/render/fx/system.js +++ b/public/js/render/fx/system.js @@ -159,7 +159,7 @@ export class FxSystem { } const FX_PARTS = [FxParticles, FxProjectiles, FxBeams, FxLocks, FxNumbers, FxRings, FxArrivals, FxSim, FxZones]; -const FX_METHOD_ORDER = ["quality","load","maxParticles","rich","_room","_ts","_groundZ","particle","_o","_freeParticle","_updateParticles","_proj","_onGround","_chest","_bodyPt","burst","attack","_takeProj","_dressProj","_releaseProj","_updateProjs","_shotPoint","_stepShot","_stepBoomerang","_stepMortar","_mote","_puff","_muzzle","_impact","_catch","mortar","_landed","_beam","_updateBeams","_flame","_freeFlame","_updateFlames","_lock","_touchLocks","_nearestLock","_releaseLock","_freeLock","_updateLocks","damage","_slash","heal","number","_growFits","_numSlotPx","_layoutNums","_layoutNumsOnce","_sizeNum","_fadeNum","_takeNum","_releaseNum","_updateNums","ring","_updateRings","skill","_aura","_updateAuras","deploy","promote","death","crateBreak","_viewOf","_where","_point","simFx","explosion","smoke","streak","strike","numberAt","_updateLabels","zone","_freeZone","_updateZones","tileFlash","_updateTileFlashes","flashScreen","snowfall","leak","pop","_updatePops"]; +const FX_METHOD_ORDER = ["quality","load","maxParticles","rich","_room","_ts","_groundZ","particle","_o","_freeParticle","_updateParticles","_proj","_onGround","_chest","_bodyPt","burst","attack","_takeProj","_dressProj","_releaseProj","_updateProjs","_shotPoint","_stepShot","_stepBoomerang","_stepMortar","_mote","_puff","_muzzle","_impact","_catch","mortar","_landed","_beam","_updateBeams","_flame","_freeFlame","_updateFlames","_lock","_touchLocks","_nearestLock","_releaseLock","_freeLock","_updateLocks","damage","_slash","heal","officialNumber","number","_growFits","_numSlotPx","_layoutNums","_layoutNumsOnce","_sizeNum","_fadeNum","_takeNum","_releaseNum","_updateNums","ring","_updateRings","skill","_aura","_updateAuras","deploy","promote","death","crateBreak","_viewOf","_where","_point","simFx","explosion","smoke","streak","strike","numberAt","_updateLabels","zone","_freeZone","_updateZones","tileFlash","_updateTileFlashes","flashScreen","snowfall","leak","pop","_updatePops"]; for (const key of FX_METHOD_ORDER) { let found = null; diff --git a/public/js/render/style.js b/public/js/render/style.js index c0dd24b9..30f80a19 100644 --- a/public/js/render/style.js +++ b/public/js/render/style.js @@ -112,6 +112,9 @@ export const DMG_STYLE = Object.freeze({ true: { font: 'sp-dmg-true', fill: ['#ffffff', '#e8e8e8'], stroke: '#2a2a2a' }, heal: { font: 'sp-dmg-heal', fill: ['#eafff0', '#62f08a'], stroke: '#07361a' }, elem: { font: 'sp-dmg-elem', fill: ['#fff4e0', '#ff7b3a'], stroke: '#3a1000' }, + // Official red integer and yellow MISS (PRTS 作战机制 「伤害红字(暴击)」, MISS #ff9000). Default numbers never use these. + crit: { font: 'sp-dmg-crit', fill: ['#ffd0d0', '#ff2a2a'], stroke: '#4a0000' }, + miss: { font: 'sp-dmg-miss', fill: ['#ffe0a8', '#ff9000'], stroke: '#4a2400' }, }); /** Map b.ev dmg types to a style key. */ diff --git a/public/js/screens/title.js b/public/js/screens/title.js index c9835a96..c2f80233 100644 --- a/public/js/screens/title.js +++ b/public/js/screens/title.js @@ -291,8 +291,11 @@ export function TitleScreen() { <${ResumeMatchButton} /> <${PwaInstallButton} /> ${t('非官方同人复刻 · 游戏素材版权归 上海鹰角网络 / Yostar 所有')} - <${MicroLabel}>v${APP_VERSION} · WEB SIMULATION - ${DEV_BUILD ? html`${t('开发版 · 不稳定,请勿用于公开服务器')}` : null} + + ${t('项目仓库')} + <${MicroLabel}>v${APP_VERSION} · WEB SIMULATION + ${DEV_BUILD ? html`${t('开发版 · 不稳定,请勿用于公开服务器')}` : null} + `; } diff --git a/public/js/ui/fallbackField.js b/public/js/ui/fallbackField.js index 925a5ece..217ec2f5 100644 --- a/public/js/ui/fallbackField.js +++ b/public/js/ui/fallbackField.js @@ -60,6 +60,30 @@ export function fallbackTileClass(stage, row, col) { return 'floor'; } +/** + * The simplified board's floating label for one event, or null when that event draws no digits. + * Official mode keeps only a marked red integer (including 0) and a yellow MISS. The slot stays above the unit; + * the random offset of the Pixi numbers is not repeated here [ASSUMED]. + * @param {any[]} e + * @param {{ damageNumbers?: boolean, officialDamageNumbers?: boolean }} settings + * @returns {{ id: any, text: string, type: string }|null} + */ +export function fallbackFloat(e, settings) { + if (!settings?.damageNumbers || !Array.isArray(e)) return null; + if (settings.officialDamageNumbers === true) { + if (e[0] === 'fx' && e[1] === 'dodge' && e[4] && e[4].num === 'm') return { id: e[4].id, text: 'MISS', type: 'miss' }; + if (e[0] !== 'dmg' || e[4] !== 'r') return null; + const amount = Math.round(Number(e[2]) || 0); + if (!Number.isFinite(amount) || amount < 0) return null; + return { id: e[1], text: String(amount), type: 'crit' }; + } + if (e[0] !== 'dmg' && e[0] !== 'heal') return null; + const amount = Math.round(Number(e[2]) || 0); + if (amount <= 0) return null; + const type = e[0] === 'heal' ? 'heal' : (e[3] === 'arts' ? 'arts' : e[3] === 'true' ? 'true' : 'phys'); + return { id: e[1], text: e[0] === 'heal' ? `+${amount}` : String(amount), type }; +} + /** * Create the DOM fallback view. * @param {HTMLElement} host @@ -78,7 +102,7 @@ export function createFallbackView(host, opts = {}) { stage: null, camera: 'prep', rect: { ...GEO.NORMAL_RECT }, side: 'L', deployField: 'normal', mode: 'prep', priv: null, editable: false, canPlace: null, field: null, units: new Map(), snapUnits: new Map(), highlight: new Map(), hlGroups: new Map(), held: new Map(), dirs: new Map(), - drag: null, hoverTarget: null, floats: [], settings: { damageNumbers: true, ...(opts.settings || {}) }, + drag: null, hoverTarget: null, floats: [], settings: { damageNumbers: true, officialDamageNumbers: false, ...(opts.settings || {}) }, size: { w: 0, h: 0 }, destroyed: false, pen: [], }; @@ -456,11 +480,9 @@ export function createFallbackView(host, opts = {}) { if (e[0] === 'spawn' && e[1] && e[1].id != null) { st.units.set(e[1].id, e[1]); if (!st.snapUnits.has(e[1].id)) st.snapUnits.set(e[1].id, [e[1].id, e[1].x, e[1].y, e[1].maxHp, e[1].maxHp, 0, 0, 0, 6]); - } else if ((e[0] === 'dmg' || e[0] === 'heal') && st.settings.damageNumbers && st.floats.length < 60) { - const amount = Math.round(Number(e[2]) || 0); - if (amount <= 0) continue; - const type = e[0] === 'heal' ? 'heal' : (e[3] === 'arts' ? 'arts' : e[3] === 'true' ? 'true' : 'phys'); - st.floats.push({ key: `${now}:${Math.random()}`, id: e[1], text: e[0] === 'heal' ? `+${amount}` : String(amount), type, at: now }); + } else if (st.floats.length < 60) { + const spec = fallbackFloat(e, st.settings); + if (spec) st.floats.push({ key: `${now}:${Math.random()}`, id: spec.id, text: spec.text, type: spec.type, at: now }); } } schedule(); diff --git a/public/js/ui/gameLogic/settings.js b/public/js/ui/gameLogic/settings.js index 2ca28090..d9be9b77 100644 --- a/public/js/ui/gameLogic/settings.js +++ b/public/js/ui/gameLogic/settings.js @@ -24,13 +24,13 @@ export const TEXT_SIZES = Object.freeze(['sm', 'md', 'lg', 'xl']); /** keys: the in-match shortcuts' key map (ui/gameLogic/shortcuts.js; settings → 快捷键). voiceLang: VOICE_LANGS. * textSize: TEXT_SIZES (css/theme.css `--t`, applied by ui/settings.js applyTextSize). */ -export const DEFAULT_SETTINGS = Object.freeze({ bgm: 0.6, sfx: 0.8, voice: 0.8, voiceLang: 'cn', voiceOverrides: Object.freeze({}), muted: false, damageNumbers: true, quality: 'high', textSize: 'sm', keys: DEFAULT_HOTKEYS }); +export const DEFAULT_SETTINGS = Object.freeze({ bgm: 0.6, sfx: 0.8, voice: 0.8, voiceLang: 'cn', voiceOverrides: Object.freeze({}), muted: false, damageNumbers: true, officialDamageNumbers: false, quality: 'high', textSize: 'sm', keys: DEFAULT_HOTKEYS }); const QUALITIES = ['high', 'medium', 'low']; /** * Sanitize persisted settings. * @param {any} raw - * @returns {{ bgm: number, sfx: number, voice: number, voiceLang: 'cn'|'jp', voiceOverrides: Record, muted: boolean, damageNumbers: boolean, quality: 'high'|'medium'|'low', + * @returns {{ bgm: number, sfx: number, voice: number, voiceLang: 'cn'|'jp', voiceOverrides: Record, muted: boolean, damageNumbers: boolean, officialDamageNumbers: boolean, quality: 'high'|'medium'|'low', * textSize: 'sm'|'md'|'lg'|'xl', keys: Record<'refresh'|'freeze'|'levelUp'|'retreat'|'sell'|'ready', string> }} */ export function sanitizeSettings(raw) { @@ -44,6 +44,7 @@ export function sanitizeSettings(raw) { voiceOverrides: sanitizeVoiceOverrides(r.voiceOverrides), muted: typeof r.muted === 'boolean' ? r.muted : DEFAULT_SETTINGS.muted, damageNumbers: typeof r.damageNumbers === 'boolean' ? r.damageNumbers : DEFAULT_SETTINGS.damageNumbers, + officialDamageNumbers: typeof r.officialDamageNumbers === 'boolean' ? r.officialDamageNumbers : false, quality: QUALITIES.includes(r.quality) ? r.quality : DEFAULT_SETTINGS.quality, textSize: TEXT_SIZES.includes(r.textSize) ? r.textSize : DEFAULT_SETTINGS.textSize, keys: sanitizeHotkeys(r.keys), diff --git a/public/js/ui/settings.js b/public/js/ui/settings.js index 313f54e3..2009f076 100644 --- a/public/js/ui/settings.js +++ b/public/js/ui/settings.js @@ -263,6 +263,8 @@ export function SettingsModal({ open, onClose }) { onInput=${(v) => { updateSettings({ sfx: v }); if (!tested) { setTested(true); setTimeout(() => setTested(false), 400); audio.sfx('click'); } }} /> <${Toggle} label=${t('静音')} micro="MUTE" value=${s.muted} onChange=${(v) => updateSettings({ muted: v })} /> <${Toggle} label=${t('显示伤害数字')} micro="DAMAGE NUMBERS" value=${s.damageNumbers} onChange=${(v) => updateSettings({ damageNumbers: v })} /> + <${Toggle} label=${t('官方伤害数字')} micro="OFFICIAL NUMBERS" value=${s.officialDamageNumbers} onChange=${(v) => updateSettings({ officialDamageNumbers: v })} /> +

${t('只显示达到官方暴击条件的红色数字。关闭时仍显示每一次命中。')}

${t('画面质量')}<${MicroLabel}>QUALITY
diff --git a/server/sim/damage.js b/server/sim/damage.js index ae994290..a4b27420 100644 --- a/server/sim/damage.js +++ b/server/sim/damage.js @@ -263,6 +263,61 @@ export function absorbShields(battle, target, amount, type = null) { return rest; } +/** + * Official red-number test (PRTS 作战机制 「伤害红字(暴击)」, https://prts.wiki/w/作战机制). + * A red integer is shown when 最终伤害 ≥ 期望伤害 × 1.5. `1e-9` only absorbs a binary rounding crumb. + * 0 ≥ 0 × 1.5 is red: an invulnerable hit still shows red 0 in the official client. This function reports that; + * `dealDamage` does not emit a new event for it (a new `dmg` tuple would change golden counts and play an impact). + * @returns {'r'|null} + */ +export function officialDamageMark(finalAmount, expectedAmount) { + const fin = Number.isFinite(finalAmount) ? Math.max(0, finalAmount) : 0; + const exp = Number.isFinite(expectedAmount) ? Math.max(0, expectedAmount) : 0; + return fin + 1e-9 >= exp * 1.5 ? 'r' : null; +} + +/** + * Expected damage for the red-number test. Same mitigation as the hit, without DEF/RES penetration and without + * the multipliers applied after mitigation (source dmgDealtMul / phys/artsDealtMul, target dmgTakenMul and the + * type-taken multipliers, shields, hpDamage rewrites, hit-count replacement). + * [ASSUMED] `dmg.amount` is already the attack's stated ATK × 攻击倍率 — callers bake `atkScaleMul` in, and this + * does not divide it back out. `dmg.mul` stays on both sides (the instance's own scale, including a skill-hit + * fraction). Cached-ATK expected damage (raw ATK only), forced red, blue numbers and yellow BLOCK have no + * DamageInfo flag here and are not applied. + */ +function officialExpectedAmount(dmg, type, ts, target) { + const base = mitigate(dmg.amount, type, ts, { + defIgnorePct: 0, defIgnoreFlat: 0, resIgnorePct: 0, resIgnoreFlat: 0, + elementalRes: type === 'elemental' ? (target.def?.epDamageResistance ?? 0) : 0, + }); + const mul = Number.isFinite(dmg.mul) ? dmg.mul : 1; + const exp = base * mul; + return Number.isFinite(exp) && exp > 0 ? exp : 0; +} + +/** Pre-shield damage after mitigation and the post-mitigation multipliers. Identical to the inline formula it replaced. */ +function landedPreShield(dmg, type, ts, ss, target) { + let final = mitigate(dmg.amount, type, ts, { + defIgnorePct: dmg.defIgnorePct + (ss ? ss.defIgnorePct : 0), + defIgnoreFlat: dmg.defIgnoreFlat + (ss ? ss.defIgnoreFlat : 0), + resIgnorePct: dmg.resIgnorePct + (ss ? ss.resIgnorePct : 0), + resIgnoreFlat: dmg.resIgnoreFlat + (ss ? ss.resIgnoreFlat : 0), + elementalRes: type === 'elemental' ? (target.def?.epDamageResistance ?? 0) : 0, + }); + let mul = dmg.mul * (type === 'elemental' ? 1 : ts.dmgTakenMul); + if (ss) mul *= ss.dmgDealtMul * (type === 'phys' ? ss.physDealtMul : type === 'arts' ? ss.artsDealtMul : 1); + mul *= type === 'phys' ? ts.physTakenMul : type === 'arts' ? ts.artsTakenMul : type === 'elemental' ? ts.elementalTakenMul : ts.trueTakenMul; + final *= mul; + if (!(final > 0) || !Number.isFinite(final)) final = 0; + return final; +} + +/** Source stats for a display-only dodge classification. Reading `source.s` while dirty rescales HP (units.js). */ +function peekStats(unit) { + if (!unit) return null; + return unit._dirty ? (unit._s || null) : unit.s; +} + /** * Full damage pipeline. Returns the HP actually removed (0 when dodged/cancelled/absorbed). */ @@ -284,11 +339,16 @@ export function dealDamage(battle, source, target, dmgIn) { if (ts.flags.invulnerable || sleepBlocks(target, hs, dmg)) return 0; } const type = dmg.type; - // dodge + // dodge. The yellow MISS (PRTS: a hit that would have been red, dodged) is classified after the existing roll + // and does not roll again. Source stats are peeked so a dirty attacker is not refreshed just to label the dodge. if (dmg.canDodge && (type === 'phys' || type === 'arts')) { const p = type === 'phys' ? ts.dodgePhys : ts.dodgeArts; if (p > 0 && battle.rng() < p) { - battle.fx('dodge', { x: target.x, y: target.y, id: target.id }); + const peeked = !dmg.sourceless ? peekStats(source) : null; + const red = officialDamageMark(landedPreShield(dmg, type, ts, peeked, target), officialExpectedAmount(dmg, type, ts, target)) === 'r'; + battle.fx('dodge', red + ? { x: target.x, y: target.y, id: target.id, num: 'm' } + : { x: target.x, y: target.y, id: target.id }); if (battle._hooks.dodge) battle.emit('dodge', { source, target, dmg }); return 0; } @@ -296,25 +356,19 @@ export function dealDamage(battle, source, target, dmgIn) { // 频次 units (flags hitCount / hitCountArts): every damage instance removes exactly 1 HP (maxHp = hits needed); // hitCountArts ignores physical instances. The instance keeps its own DamageInfo, so `damaged` handlers still // recognise their own (tagged) damage — never re-create such a loss with a fresh loseHp. + // The red test compares that 1 (or 0) with the ordinary expected damage: 特殊生命值机制均不考虑 on the expected side. if (ts.flags.hitCount || ts.flags.hitCountArts) { const counts = !(ts.flags.hitCountArts && !ts.flags.hitCount && type === 'phys'); - return applyHpLoss(battle, source, target, absorbShields(battle, target, counts ? 1 : 0, type), dmg); + const instance = counts ? 1 : 0; + const mark = officialDamageMark(instance, officialExpectedAmount(dmg, type, ts, target)); + return applyHpLoss(battle, source, target, absorbShields(battle, target, instance, type), dmg, mark); } // 无来源 damage (element bursts) takes nothing from its source's stats; the source still gets the credit below const ss = source && source.s && !dmg.sourceless ? source.s : null; - let final = mitigate(dmg.amount, type, ts, { - defIgnorePct: dmg.defIgnorePct + (ss ? ss.defIgnorePct : 0), - defIgnoreFlat: dmg.defIgnoreFlat + (ss ? ss.defIgnoreFlat : 0), - resIgnorePct: dmg.resIgnorePct + (ss ? ss.resIgnorePct : 0), - resIgnoreFlat: dmg.resIgnoreFlat + (ss ? ss.resIgnoreFlat : 0), - elementalRes: type === 'elemental' ? (target.def?.epDamageResistance ?? 0) : 0, - }); - // 脆弱 / "受到的伤害±" (dmgTakenMul) scale 物理、法术、真实 only; 元素伤害 takes 元素脆弱 alone (header) - let mul = dmg.mul * (type === 'elemental' ? 1 : ts.dmgTakenMul); - if (ss) mul *= ss.dmgDealtMul * (type === 'phys' ? ss.physDealtMul : type === 'arts' ? ss.artsDealtMul : 1); - mul *= type === 'phys' ? ts.physTakenMul : type === 'arts' ? ts.artsTakenMul : type === 'elemental' ? ts.elementalTakenMul : ts.trueTakenMul; - final *= mul; - if (!(final > 0) || !Number.isFinite(final)) final = 0; + let final = landedPreShield(dmg, type, ts, ss, target); + // The 1.5× test uses this pre-shield total. The integer on the dmg event is whatever remains after shields and + // hpDamage (PRTS can show a number that differs from the HP delta; [ASSUMED] the shown value stays that integer). + const mark = officialDamageMark(final, officialExpectedAmount(dmg, type, ts, target)); // 限伤: a leader's hit of ≥ BOSS_HIT_LIMIT in a boss / hidden battle is cancelled before it reaches shields / HP — what // ran before it (the attack, its SP, `hit` hook effects, separate element 损伤) stays; nothing after it happens if (final > 0 && leaderHitCancelled(battle, target, final)) return 0; @@ -325,13 +379,13 @@ export function dealDamage(battle, source, target, dmgIn) { battle.emit('hpDamage', hctx); if (Number.isFinite(hctx.amount)) final = Math.max(0, Math.min(final, hctx.amount)); } - return applyHpLoss(battle, source, target, final, dmg); + return applyHpLoss(battle, source, target, final, dmg, mark); } /** * Remove HP (after mitigation) and run damaged/fatal/kill bookkeeping. Also used by `battle.loseHp` (流失). */ -export function applyHpLoss(battle, source, target, amount, dmg) { +export function applyHpLoss(battle, source, target, amount, dmg, mark) { if (!target.alive) return 0; let dealt = 0; const hs = dmg && dmg.sourceless ? null : source; // the source hooks see (无来源: none; `credit` keeps it) @@ -369,7 +423,10 @@ export function applyHpLoss(battle, source, target, amount, dmg) { target.lastHitAt = battle.time; if (dmg && !dmg.silent && amount >= 0.5) { const shown = dmg.type === 'element' ? dmg.element : dmg.type === 'elemental' ? (dmg.element || 'true') : dmg.type; - battle._ev(['dmg', target.id, Math.round(amount), shown]); + // 'r' is display-only (official red number). Absent on every other hit, so the tuple stays four elements. + const ev = ['dmg', target.id, Math.round(amount), shown]; + if (mark === 'r') ev.push('r'); + battle._ev(ev); } if (battle._hooks.damaged) battle.emit('damaged', { source: hs, target, amount, type: dmg ? dmg.type : 'true', dmg, credit: source }); if (target.side === 'ally' && target.skill && dmg && !dmg.noSp && dmg.type !== 'element') battle._skills.onDamaged(target); diff --git a/test/render/official-numbers.test.js b/test/render/official-numbers.test.js new file mode 100644 index 00000000..7830c1e2 --- /dev/null +++ b/test/render/official-numbers.test.js @@ -0,0 +1,79 @@ +// Damage-number rendering: the default path still pops every hit; official mode shows only a marked red integer +// or a yellow MISS, and does not merge those into the typed running totals. +import { test, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { installFakePixi, fakeViewCtx } from './fakepixi.js'; +import { presetCamera } from '../../public/js/render/projection.js'; + +let fake, FX; +before(async () => { + fake = installFakePixi(); + FX = await import('../../public/js/render/fx.js'); +}); +after(() => fake?.restore()); + +const cam = presetCamera('normal', { width: 1600, height: 900 }); + +function unit(id, x, y) { + return { id, x, y, z: 0, hover: 0, _headTiles: 1.2, alive: true, destroyed: false, maxHp: 3000, info: { defId: 'char_x' }, onHit() {} }; +} + +function makeFx(views) { + const map = new Map(views.map((v) => [v.id, v])); + const settings = { quality: 'high', damageNumbers: true, officialDamageNumbers: false }; + const fx = new FX.FxSystem({ + ...fakeViewCtx(fake.P), settings, cam: () => cam, heightAt: () => 0, timeScale: () => 2, + loadLevel: () => 0, subProfOf: () => null, view: (id) => map.get(id) || null, + screenSize: () => ({ width: 1600, height: 900 }), fieldTop: () => 120, + }); + return { fx, settings }; +} + +test('official mode hides ordinary hits and heals, and draws a marked red number without merging', () => { + const v = unit(1, 4, 9); + const { fx, settings } = makeFx([v]); + fx.damage(v, 40, 'phys', null); + fx.damage(v, 60, 'phys', null); + assert.equal(fx.nums.length, 1, 'default mode still merges same-style hits'); + assert.equal(fx.nums[0].style, 'phys'); + assert.equal(fx.nums[0].value, 100); + assert.equal(fx.nums[0].text.text, '100'); + + fx.heal(v, 15); + assert.ok(fx.nums.some((n) => n.style === 'heal' && n.text.text === '+15')); + + for (const n of fx.nums.splice(0)) fx._releaseNum(n); + settings.officialDamageNumbers = true; + fx.damage(v, 40, 'phys', null); + fx.heal(v, 15); + assert.equal(fx.nums.length, 0, 'an unmarked hit and a heal draw no digits'); + fx.damage(v, 40, 'phys', null, 'r'); + fx.damage(v, 25, 'true', null, 'r'); + assert.equal(fx.nums.length, 2, 'official reds are separate popups'); + assert.deepEqual(fx.nums.map((n) => n.text.text), ['40', '25']); + assert.ok(fx.nums.every((n) => n.style === 'crit')); + + fx.damage(v, 0, 'phys', null, 'r'); + assert.equal(fx.nums.at(-1).text.text, '0'); + assert.equal(fx.nums.at(-1).style, 'crit'); + + settings.damageNumbers = false; + const held = fx.nums.length; + fx.damage(v, 90, 'phys', null, 'r'); + fx.simFx('dodge', v.x, v.y, { id: v.id, num: 'm' }); + assert.equal(fx.nums.length, held, 'turning damage numbers off hides official digits too'); +}); + +test('a dodge marked num:m shows MISS only while official numbers are on', () => { + const v = unit(3, 6, 10); + const { fx, settings } = makeFx([v]); + fx.simFx('dodge', v.x, v.y, { id: v.id, num: 'm' }); + assert.equal(fx.nums.filter((n) => n.style === 'miss').length, 0); + settings.officialDamageNumbers = true; + fx.simFx('dodge', v.x, v.y, { id: v.id }); + assert.equal(fx.nums.filter((n) => n.style === 'miss').length, 0); + fx.simFx('dodge', v.x, v.y, { id: v.id, num: 'm' }); + const miss = fx.nums.filter((n) => n.style === 'miss'); + assert.equal(miss.length, 1); + assert.equal(miss[0].text.text, 'MISS'); +}); diff --git a/test/sim/official-damage-numbers.test.js b/test/sim/official-damage-numbers.test.js new file mode 100644 index 00000000..610ab63d --- /dev/null +++ b/test/sim/official-damage-numbers.test.js @@ -0,0 +1,152 @@ +// Official red damage numbers (PRTS 作战机制 「伤害红字(暴击)」, https://prts.wiki/w/作战机制). +// The mark is display-only: HP, return values and event counts stay the same. A qualifying hit appends 'r' on the +// dmg tuple that already fires; a dodge of such a hit sets num:'m' on the dodge fx that already fires. No new event. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { officialDamageMark } from '../../server/sim/damage.js'; +import { makeBattle, chessRec, enemyRec } from '../helpers/battleHarness.js'; + +const approx = (a, b, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps, `${a} ≈ ${b}`); + +function field() { + const h = makeBattle({ + defs: { + chess: { t_a: chessRec({ id: 't_a', stats: { atk: 1000 } }) }, + enemies: { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 100000, def: 400, res: 0, speed: 0 }) }, + }, + units: [{ chessId: 't_a', row: 9, col: 3 }], + enemies: [{ key: 'enemy_dummy', pos: [11, 8] }], + content: 'none', + }); + h.step(); + return h; +} + +function added(h, kind, fn) { + const before = h.eventsOf(kind).length; + const result = fn(); + return { result, ev: h.eventsOf(kind).slice(before) }; +} + +test('officialDamageMark is the PRTS 1.5× test, including a 0-damage invulnerable hit', () => { + assert.equal(officialDamageMark(150, 100), 'r'); + assert.equal(officialDamageMark(149.9, 100), null); + assert.equal(officialDamageMark(0, 0), 'r', '0 ≥ 0 × 1.5 (PRTS 无敌: 弑君者 flash still shows red 0)'); + assert.equal(officialDamageMark(Number.NaN, 10), null); +}); + +test('a landed hit is marked only when the pre-shield total reaches 1.5× the penetration-free expected damage', () => { + const h = field(); + const e = h.enemy('enemy_dummy'); + const op = h.unit('t_a'); + + const plain = added(h, 'dmg', () => h.b.dealDamage(null, e, { amount: 100, type: 'true' })); + assert.equal(plain.result, 100); + assert.deepEqual(plain.ev[0], ['dmg', e.id, 100, 'true'], 'below 1.5×: the tuple stays four elements'); + + h.b.addBuff(e, { key: 'fr12', mods: { dmgTakenMul: 1.2 } }); + const mild = added(h, 'dmg', () => h.b.dealDamage(null, e, { amount: 100, type: 'true' })); + assert.equal(mild.result, 120); + assert.equal(mild.ev[0].length, 4, '脆弱 ×1.2 is below 1.5× the penetration-free expected damage'); + h.b.removeBuff(e, e.findBuff('fr12')); + + h.b.addBuff(e, { key: 'fr15', mods: { dmgTakenMul: 1.5 } }); + const edge = added(h, 'dmg', () => h.b.dealDamage(null, e, { amount: 100, type: 'true' })); + assert.equal(edge.result, 150); + assert.deepEqual(edge.ev[0], ['dmg', e.id, 150, 'true', 'r'], 'exactly 1.5× is red'); + h.b.removeBuff(e, e.findBuff('fr15')); + + h.b.addBuff(op, { key: 'dealt', mods: { dmgDealtMul: 2 } }); + const dealt = added(h, 'dmg', () => h.b.dealDamage(op, e, { amount: 100, type: 'true' })); + assert.equal(dealt.result, 200); + assert.equal(e.hp, 100000 - 100 - 120 - 150 - 200); + assert.equal(dealt.ev[0][4], 'r', 'source damage-dealt multipliers are excluded from expected damage'); + h.b.removeBuff(op, op.findBuff('dealt')); + + const mul = added(h, 'dmg', () => h.b.dealDamage(null, e, { amount: 100, type: 'true', mul: 2 })); + assert.equal(mul.result, 200); + assert.equal(mul.ev[0].length, 4, 'dmg.mul is the instance scale and stays on both sides of the comparison'); + + const pen = added(h, 'dmg', () => h.b.dealDamage(null, e, { amount: 1000, type: 'phys', defIgnorePct: 1 })); + assert.equal(pen.result, 1000); + assert.equal(pen.ev[0][4], 'r', 'DEF penetration is excluded from expected damage (600) but counted in the final (1000)'); + const blocked = added(h, 'dmg', () => h.b.dealDamage(null, e, { amount: 1000, type: 'phys' })); + assert.equal(blocked.result, 600); + assert.equal(blocked.ev[0].length, 4, 'the same hit without penetration is not red'); +}); + +test('shields, hit-count husks, sub-threshold reds and element fills do not invent events', () => { + const h = field(); + const e = h.enemy('enemy_dummy'); + + h.b.addBuff(e, { key: 'fr', mods: { dmgTakenMul: 2 } }); + h.b.addBuff(e, { key: 'sh', shield: 100000 }); + const soaked = added(h, 'dmg', () => h.b.dealDamage(null, e, { amount: 100, type: 'true' })); + assert.equal(soaked.result, 0); + assert.equal(soaked.ev.length, 0, 'a barrier that removes the hit emits nothing'); + h.b.removeBuff(e, 'sh'); + h.b.removeBuff(e, 'fr'); + + h.b.addBuff(e, { key: 'hc', flags: { hitCount: true } }); + const husk = added(h, 'dmg', () => h.b.dealDamage(null, e, { amount: 1000, type: 'true' })); + assert.equal(husk.result, 1); + assert.deepEqual(husk.ev[0], ['dmg', e.id, 1, 'true'], 'a hit-count rewrite to 1 is not red against a large expected value'); + h.b.removeBuff(e, 'hc'); + + h.b.addBuff(e, { key: 'tiny', mods: { dmgTakenMul: 4 } }); + const tiny = added(h, 'dmg', () => h.b.dealDamage(null, e, { amount: 0.1, type: 'true' })); + approx(tiny.result, 0.4); + assert.equal(tiny.ev.length, 0, 'a red total under 0.5 still emits no dmg event'); + h.b.removeBuff(e, 'tiny'); + + const gauge = added(h, 'dmg', () => h.b.dealDamage(null, e, { amount: 80, type: 'element', element: 'burn' })); + assert.equal(gauge.ev.length, 1); + assert.equal(gauge.ev[0].length, 4, 'an element gauge fill is not an HP crit'); + assert.equal(gauge.ev[0][3], 'burn'); + + const rewritten = field(); + const t = rewritten.enemy('enemy_dummy'); + rewritten.b.addBuff(t, { key: 'fr', mods: { dmgTakenMul: 2 } }); + rewritten.b.on('hpDamage', (c) => { c.amount = 1; }); + const cut = added(rewritten, 'dmg', () => rewritten.b.dealDamage(null, t, { amount: 100, type: 'true' })); + assert.equal(cut.result, 1); + assert.deepEqual(cut.ev[0], ['dmg', t.id, 1, 'true', 'r'], 'the 1.5× test uses the pre-rewrite total; the shown integer is what reaches HP'); +}); + +test('invulnerable and sleeping hits stay event-free; a dodge marks MISS only for a would-be red hit', () => { + const h = field(); + const e = h.enemy('enemy_dummy'); + const hp = e.hp; + h.b.addBuff(e, { key: 'inv', duration: 5, flags: { invulnerable: true } }); + const inv = added(h, 'dmg', () => h.b.dealDamage(null, e, { amount: 500, type: 'true' })); + assert.equal(inv.result, 0); + assert.equal(inv.ev.length, 0); + assert.equal(e.hp, hp); + h.b.removeBuff(e, e.findBuff('inv')); + + h.b.addBuff(e, { key: 'sleep', duration: 5, flags: { sleep: true } }); + const sleep = added(h, 'dmg', () => h.b.dealDamage(null, e, { amount: 500, type: 'true' })); + assert.equal(sleep.result, 0); + assert.equal(sleep.ev.length, 0); + h.b.removeBuff(e, e.findBuff('sleep')); + + let rolls = 0; + h.b.rng = () => { rolls++; return 0; }; + h.b.addBuff(e, { key: 'dodge', mods: { dodgePhys: 1 } }); + const miss = added(h, 'fx', () => h.b.dealDamage(null, e, { amount: 1000, type: 'phys', defIgnorePct: 1 })); + assert.equal(miss.result, 0); + assert.equal(rolls, 1, 'the hypothetical red test does not roll again'); + assert.equal(e.hp, hp); + const redFx = miss.ev.filter((ev) => ev[1] === 'dodge'); + assert.equal(redFx.length, 1); + assert.equal(redFx[0][4].id, e.id); + assert.equal(redFx[0][4].num, 'm'); + + rolls = 0; + const plain = added(h, 'fx', () => h.b.dealDamage(null, e, { amount: 100, type: 'phys' })); + assert.equal(plain.result, 0); + assert.equal(rolls, 1); + const quiet = plain.ev.filter((ev) => ev[1] === 'dodge'); + assert.equal(quiet.length, 1); + assert.equal(Object.hasOwn(quiet[0][4], 'num'), false); +}); diff --git a/test/ui/gameLogic.test.js b/test/ui/gameLogic.test.js index fb27f9d5..3516af4b 100644 --- a/test/ui/gameLogic.test.js +++ b/test/ui/gameLogic.test.js @@ -722,7 +722,7 @@ describe('keyboard & settings', () => { test('sanitizeSettings', () => { assert.deepEqual(sanitizeSettings(null), { ...DEFAULT_SETTINGS }); assert.deepEqual(sanitizeSettings({ bgm: 3, sfx: -1, voice: 2, muted: 'yes', damageNumbers: false, quality: 'ultra' }), - { bgm: 1, sfx: 0, voice: 1, voiceLang: 'cn', voiceOverrides: {}, muted: false, damageNumbers: false, quality: 'high', textSize: 'sm', keys: { ...DEFAULT_SETTINGS.keys } }, + { bgm: 1, sfx: 0, voice: 1, voiceLang: 'cn', voiceOverrides: {}, muted: false, damageNumbers: false, officialDamageNumbers: false, quality: 'high', textSize: 'sm', keys: { ...DEFAULT_SETTINGS.keys } }, 'a saved profile without `keys` (before 0.2.0) gets the default key map (test/ui/feedback5-hotkeys.test.js)'); assert.equal(sanitizeSettings({ bgm: 0.5 }).voice, DEFAULT_SETTINGS.voice, 'a saved profile without `voice` gets the default'); // 语音语言 (0.2.2): 中文 by default — a profile saved before it, or any other value, plays the Chinese dub diff --git a/test/ui/official-damage-numbers.test.js b/test/ui/official-damage-numbers.test.js new file mode 100644 index 00000000..9199a35e --- /dev/null +++ b/test/ui/official-damage-numbers.test.js @@ -0,0 +1,59 @@ +// Settings, wiring and the title-page repository link for the official damage-number option (#412) and #543. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { sanitizeSettings } from '../../public/js/ui/gameLogic/settings.js'; +import { fallbackFloat } from '../../public/js/ui/fallbackField.js'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '../..'); +const read = (rel) => readFileSync(join(ROOT, rel), 'utf8'); + +test('official damage numbers default off and only a real boolean is kept', () => { + assert.equal(sanitizeSettings(null).officialDamageNumbers, false); + assert.equal(sanitizeSettings({}).officialDamageNumbers, false, 'a profile saved before the setting stays on today\'s numbers'); + assert.equal(sanitizeSettings({ officialDamageNumbers: true }).officialDamageNumbers, true); + assert.equal(sanitizeSettings({ officialDamageNumbers: true, damageNumbers: false }).damageNumbers, false); + for (const bad of ['yes', 1, null, undefined]) { + assert.equal(sanitizeSettings({ officialDamageNumbers: bad }).officialDamageNumbers, false, String(bad)); + } +}); + +test('the settings screen, the field view and the simplified board follow the same flag', () => { + const settings = read('public/js/ui/settings.js'); + assert.match(settings, /t\('官方伤害数字'\)/); + assert.match(settings, /officialDamageNumbers/); + const app = read('public/js/render/app.js'); + assert.match(app, /officialDamageNumbers/); + assert.match(app, /fx\.damage\(v, amt, e\[3\], src, e\[4\]\)/); + assert.deepEqual(fallbackFloat(['dmg', 7, 40, 'phys'], { damageNumbers: true }), { id: 7, text: '40', type: 'phys' }); + assert.deepEqual(fallbackFloat(['heal', 7, 12], { damageNumbers: true, officialDamageNumbers: false }), { id: 7, text: '+12', type: 'heal' }); + assert.equal(fallbackFloat(['dmg', 7, 40, 'phys'], { damageNumbers: true, officialDamageNumbers: true }), null); + assert.equal(fallbackFloat(['heal', 7, 12], { damageNumbers: true, officialDamageNumbers: true }), null); + assert.deepEqual(fallbackFloat(['dmg', 7, 0, 'phys', 'r'], { damageNumbers: true, officialDamageNumbers: true }), { id: 7, text: '0', type: 'crit' }); + assert.deepEqual(fallbackFloat(['dmg', 7, 80, 'arts', 'r'], { damageNumbers: true, officialDamageNumbers: true }), { id: 7, text: '80', type: 'crit' }); + assert.deepEqual(fallbackFloat(['fx', 'dodge', 1, 2, { id: 7, num: 'm' }], { damageNumbers: true, officialDamageNumbers: true }), { id: 7, text: 'MISS', type: 'miss' }); + assert.equal(fallbackFloat(['fx', 'dodge', 1, 2, { id: 7, num: 'm' }], { damageNumbers: true }), null); + assert.equal(fallbackFloat(['dmg', 7, 80, 'phys', 'r'], { damageNumbers: false, officialDamageNumbers: true }), null); +}); + +test('every interface pack translates the new settings and title strings', () => { + for (const lang of ['en', 'ja', 'ko', 'zh-TW']) { + const pack = JSON.parse(read(`public/i18n/${lang}.json`)); + for (const id of ['官方伤害数字', '只显示达到官方暴击条件的红色数字。关闭时仍显示每一次命中。', '项目仓库']) { + assert.equal(typeof pack[id], 'string', `${lang} ${id}`); + assert.ok(pack[id].length > 0, `${lang} ${id}`); + if (lang !== 'zh-TW') assert.notEqual(pack[id], id, `${lang} still has the Chinese source`); + } + } +}); + +test('the title page links the project repository in a new tab', () => { + const src = read('public/js/screens/title.js'); + assert.match(src, /class="title-repo"/); + assert.match(src, /href="https:\/\/github\.com\/sganggs\/Stronghold-Protocol"/); + assert.match(src, /target="_blank"/); + assert.match(src, /rel="noopener noreferrer"/); + assert.match(src, /t\('项目仓库'\)/); +});