0.2.5-dev: an optional 「官方伤害数字」 setting (default off) shows only the numbers the official game shows per the PRTS red-number rule (#412, the owner's decision of 2026-10-12); a small link to the project's GitHub repository on the title page (#543); golden: unchanged
This commit is contained in:
1 parent
7fd0478dc0
commit
a3e8460bb0
26 files changed
+555
-43
No files matched your search
@@ -16,6 +16,8 @@
|
||||
- 修正韩语界面和维多利亚英文按装备逐件加成的表述。
|
||||
- 修复干员一次攻击后模型继续循环、多挥一次的问题,保留迟到消息的现有前摇同步方式。
|
||||
- 自在重生后的无敌期不再被普攻或需要可选目标的自动技能锁定;修复 AI 托管遮罩挡住“在此页面继续”按钮。
|
||||
- 设置增加「官方伤害数字」(默认关闭)。打开后只按 PRTS「伤害红字(暴击)」显示红色整数;关闭时仍显示每一次命中(#412)。
|
||||
- 标题画面页脚增加「项目仓库」,在新标签页打开本项目仓库(#543)。
|
||||
|
||||
## 0.2.4 — 2026-10-11
|
||||
|
||||
|
||||
@@ -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 条)。战斗中复制时,默认附上这场战斗的数据,开发者可以用它重现这场战斗;不想附上可以关掉「附上本场战斗」。诊断信息只在你的浏览器里生成,不会自动上传;你和其他玩家的名字、房间号会被替换成 `<me>`、`<player2>`、`<room>` 这样的占位符。剪贴板不可用时(部分 App 内置浏览器),内容会显示在下方的文本框里,手动全选复制即可。
|
||||
|
||||
## 13. 统计(本机)
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -147,6 +147,7 @@
|
||||
"输入你的代号(最多 {NAME_MAX_LEN} 字)": "{NAME_MAX_LEN}文字以内で入力",
|
||||
"开始": "開始",
|
||||
"非官方同人复刻 · 游戏素材版权归 上海鹰角网络 / Yostar 所有": "非公式ファンリメイク · ゲーム素材の権利は Hypergryph / Yostar に帰属します",
|
||||
"项目仓库": "リポジトリ",
|
||||
"高": "高",
|
||||
"中": "中",
|
||||
"低": "低",
|
||||
@@ -161,6 +162,8 @@
|
||||
"音效": "効果音",
|
||||
"静音": "ミュート",
|
||||
"显示伤害数字": "ダメージ数値を表示",
|
||||
"官方伤害数字": "公式ダメージ数値",
|
||||
"只显示达到官方暴击条件的红色数字。关闭时仍显示每一次命中。": "公式のクリティカル条件を満たす赤い数値だけを表示します。オフのときは、これまで通り命中ごとに表示します。",
|
||||
"画面质量": "画質",
|
||||
"触屏操作:点击单位选中(撤退 / 出售)· 长按单位或卡牌查看详情 · 拖动部署后滑动选择朝向": "タッチ操作:ユニットをタップで選択(撤退 / 売却)· ユニットやカードを長押しで詳細表示 · ドラッグで配置後、スワイプで向きを選択",
|
||||
"快捷键": "ショートカットキー",
|
||||
|
||||
@@ -143,6 +143,7 @@
|
||||
"输入你的代号(最多 {NAME_MAX_LEN} 字)": "코드네임 입력 (최대 {NAME_MAX_LEN}자)",
|
||||
"开始": "시작",
|
||||
"非官方同人复刻 · 游戏素材版权归 上海鹰角网络 / Yostar 所有": "비공식 팬 리메이크 · 게임 리소스 © Hypergryph / Yostar",
|
||||
"项目仓库": "저장소",
|
||||
"高": "높음",
|
||||
"中": "보통",
|
||||
"低": "낮음",
|
||||
@@ -157,6 +158,8 @@
|
||||
"音效": "효과음",
|
||||
"静音": "음소거",
|
||||
"显示伤害数字": "대미지 수치 표시",
|
||||
"官方伤害数字": "공식 대미지 수치",
|
||||
"只显示达到官方暴击条件的红色数字。关闭时仍显示每一次命中。": "공식 치명타 조건을 만족하는 빨간 숫자만 표시합니다. 끄면 지금까지처럼 모든 타격에 숫자가 표시됩니다.",
|
||||
"画面质量": "그래픽 품질",
|
||||
"触屏操作:点击单位选中(撤退 / 出售)· 长按单位或卡牌查看详情 · 拖动部署后滑动选择朝向": "터치 조작: 유닛을 탭하여 선택(퇴각 / 판매) · 유닛이나 카드를 길게 눌러 상세 정보 확인 · 드래그로 배치한 뒤 스와이프로 방향 선택",
|
||||
"快捷键": "단축키",
|
||||
|
||||
@@ -145,6 +145,7 @@
|
||||
"输入你的代号(最多 {NAME_MAX_LEN} 字)": "輸入你的代號(最多 {NAME_MAX_LEN} 字)",
|
||||
"开始": "開始",
|
||||
"非官方同人复刻 · 游戏素材版权归 上海鹰角网络 / Yostar 所有": "非官方同人復刻 · 遊戲素材版權歸 上海鷹角網絡 / Yostar 所有",
|
||||
"项目仓库": "專案儲存庫",
|
||||
"高": "高",
|
||||
"中": "中",
|
||||
"低": "低",
|
||||
@@ -159,6 +160,8 @@
|
||||
"音效": "音效",
|
||||
"静音": "靜音",
|
||||
"显示伤害数字": "顯示傷害數字",
|
||||
"官方伤害数字": "官方傷害數字",
|
||||
"只显示达到官方暴击条件的红色数字。关闭时仍显示每一次命中。": "只顯示達到官方暴擊條件的紅色數字。關閉時仍顯示每一次命中。",
|
||||
"画面质量": "畫面品質",
|
||||
"触屏操作:点击单位选中(撤退 / 出售)· 长按单位或卡牌查看详情 · 拖动部署后滑动选择朝向": "觸控操作:點擊單位選中(撤退 / 出售)· 長按單位或卡牌查看詳情 · 拖動部署後滑動選擇朝向",
|
||||
"快捷键": "快捷鍵",
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
@@ -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(); }
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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': {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -291,8 +291,11 @@ export function TitleScreen() {
|
||||
<${ResumeMatchButton} />
|
||||
<${PwaInstallButton} />
|
||||
<span>${t('非官方同人复刻 · 游戏素材版权归 上海鹰角网络 / Yostar 所有')}</span>
|
||||
<${MicroLabel}>v${APP_VERSION} · WEB SIMULATION<//>
|
||||
${DEV_BUILD ? html`<span class="title-dev" role="note">${t('开发版 · 不稳定,请勿用于公开服务器')}</span>` : null}
|
||||
<span class="title-foot__meta">
|
||||
<a class="title-repo" href="https://github.com/sganggs/Stronghold-Protocol" target="_blank" rel="noopener noreferrer">${t('项目仓库')}</a>
|
||||
<${MicroLabel}>v${APP_VERSION} · WEB SIMULATION<//>
|
||||
${DEV_BUILD ? html`<span class="title-dev" role="note">${t('开发版 · 不稳定,请勿用于公开服务器')}</span>` : null}
|
||||
</span>
|
||||
</footer>
|
||||
</div>`;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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<string, string>, muted: boolean, damageNumbers: boolean, quality: 'high'|'medium'|'low',
|
||||
* @returns {{ bgm: number, sfx: number, voice: number, voiceLang: 'cn'|'jp', voiceOverrides: Record<string, string>, 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),
|
||||
|
||||
@@ -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 })} />
|
||||
<p class="set-hint">${t('只显示达到官方暴击条件的红色数字。关闭时仍显示每一次命中。')}</p>
|
||||
<div class="set-row">
|
||||
<span class="set-row__label">${t('画面质量')}<${MicroLabel}>QUALITY<//></span>
|
||||
<div class="set-seg" role="radiogroup">
|
||||
|
||||
+76
-19
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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\('项目仓库'\)/);
|
||||
});
|
||||
Reference in new issue
Block a user