commit
2519a23a55
24 files changed
+343
-50
No files matched your search
+1
-1
@@ -1 +1 @@
|
||||
{"version":1,"hash":"7f118cf0f7bb","generator":"tools/fetch-assets.mjs","stats":{"files":4014,"bytes":282091345,"chars":138,"charsWithBack":135,"enemies":252,"enemiesWithSpine":251,"tokens":20,"tokensWithSpine":13,"spineModels":529,"bonds":23,"items":59,"bands":40,"skills":317,"ui":601,"sfxUnits":382},"chars":{"char_1012_skadi2":{"avatar":"/assets/char/avatar/char_1012_skadi2.png","avatarE2":"/assets/char/avatar/char_1012_skadi2_2.png","portrait":"/assets/char/portrait/char_1012_skadi2_1.png","portraitE2":"/assets/char/portrait/char_1012_skadi2_2.png","spine":{"front":{"skel":"/assets/spine/op/char_1012_skadi2/front/char_1012_skadi2.skel","atlas":"/assets/spine/op/char_1012_skadi2/front/char_1012_skadi2.atlas","textures":["/assets/spine/op/char_1012_skadi2/front/char_1012_skadi2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Skill_1_Loop","end":null,"via":"skill"},"attackDown":null,"skill":{"begin":"Skill_3_Begin","loop":"Skill_3_Loop","end":null,"index":2,"idle":null},"die":"Die","move":null,"stun":{"begin":"Stun_Begin","loop":"Stun","end":null},"skills":{"0":{"begin":"Skill_1_Begin","loop":"Skill_1_Loop","end":null,"index":0,"idle":null},"1":{"begin":"Skill_2_Begin","loop":"Skill_2_Loop","end":null,"index":1,"idle":null},"2":{"begin":"Skill_3_Begin","loop":"Skill_3_Loop","end":null,"index":2,"idle":null}}},"animations":{"Default":0,"Die":1,"Idle":8.767,"Skill_1_Begin":3.2,"Skill_1_Loop":8.767,"Skill_2_Begin":3.933,"Skill_2_Loop":3.333,"Skill_3_Begin":4.4,"Skill_3_Loop":8.767,"Start":1,"Stun":2,"Stun_Begin":1},"events":["OnAttack","OnStart"],"hits":{},"bounds":{"x":-190.586,"y":-25.785,"width":393.516,"height":443.426}}}},"char_1014_nearl2":{"avatar":"/assets/char/avatar/char_1014_nearl2.png","avatarE2":"/assets/char/avatar/char_1014_nearl2_2.png","portrait":"/assets/char/portrait/char_1014_nearl2_1.png","portraitE2":"/assets/char/portrait/char_1014_nearl2_2.png","spine":{"front":{"skel":"/assets/spine/op/char_1014_nearl2/front/char_1014_nearl2.skel","atlas":"/assets/spine/op/char_1014_nearl2/front/char_1014_nearl2.atlas","textures":["/assets/spine/op/char_1014_nearl2/front/char_1014_nearl2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":"Attack_Begin","loop":"Attack_Loop","end":"Attack_End"},"attackDown":null,"skill":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"},"die":"Die","move":null,"stun":null,"skills":{"0":{"begin":"Skill_1_Begin","loop":"Skill_1_Loop","end":null,"index":0,"idle":"Skill_1_Idle"},"1":{"begin":null,"loop":"Skill_2_Die","end":null,"index":1,"idle":null},"2":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"}}},"animations":{"Attack_Begin":0.1,"Attack_End":0.167,"Attack_Loop":1.5,"Default":0,"Die":1,"Idle":2.667,"Skill_3":1.5,"Skill_1_Begin":0.1,"Skill_1_Idle":1,"Skill_1_Loop":1.5,"Skill_1_Loop_Down":1.5,"Skill_2_Die":1.167,"Skill_3_Down":1.5,"Skill_3_Idle":1.333,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack_Loop":[0.433],"Skill_3":[0.5],"Skill_1_Loop":[0.467],"Skill_1_Loop_Down":[0.467],"Skill_3_Down":[0.5]},"bounds":{"x":-400.77,"y":-461.007,"width":720.545,"height":872.242}},"back":{"skel":"/assets/spine/op/char_1014_nearl2/back/char_1014_nearl2.skel","atlas":"/assets/spine/op/char_1014_nearl2/back/char_1014_nearl2.atlas","textures":["/assets/spine/op/char_1014_nearl2/back/char_1014_nearl2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":"Attack_Begin","loop":"Attack_Loop","end":"Attack_End"},"attackDown":null,"skill":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"},"die":null,"move":null,"stun":null,"skills":{"0":{"begin":"Skill_1_Begin","loop":"Skill_1_Loop","end":null,"index":0,"idle":"Skill_1_Idle"},"1":{"begin":"Attack_Begin","loop":"Attack_Loop","end":"Attack_End","via":"attack","index":1,"idle":null},"2":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"}}},"animations":{"Attack_Begin":0.1,"Attack_End":0.167,"Attack_Loop":1.5,"Default":0,"Idle":2.667,"Skill_1_Begin":0.1,"Skill_1_Idle":1,"Skill_1_Loop":1.5,"Skill_3":1.5,"Skill_3_Idle":1.333,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack_Loop":[0.433],"Skill_1_Loop":[0.467],"Skill_3":[0.5]},"bounds":null}}},"char_1016_agoat2":{"avatar":"/assets/char/avatar/char_1016_agoat2.png","avatarE2":"/assets/char/avatar/char_1016_agoat2_2.png","portrait":"/assets/char/portrait/char_1016_agoat2_1.png","portraitE2":"/assets/char/portrait/char_1016_agoat2_2.png","spine":{"front":{"skel":"/assets/spine/op/char_1016_agoat2/front/char_1016_agoat2.skel","atlas":"/assets/spine/op/char_1016_agoat2/front/char_1016_agoat2.atlas","textures":["/assets/spine/op/char_1016_agoat2/front/char_1016_agoat2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":"Skill_3_Begin","loop":"Skill_3_Loop","end":"Skill_3_End","index":2,"idle":"Skill_3_Idle"},"diLine truncated
|
||||
{"version":1,"hash":"a7a431fb0726","generator":"tools/fetch-assets.mjs","stats":{"files":4016,"bytes":284530642,"chars":138,"charsWithBack":135,"enemies":252,"enemiesWithSpine":251,"tokens":20,"tokensWithSpine":13,"spineModels":529,"bonds":23,"items":59,"bands":40,"skills":317,"ui":601,"sfxUnits":382},"chars":{"char_1012_skadi2":{"avatar":"/assets/char/avatar/char_1012_skadi2.png","avatarE2":"/assets/char/avatar/char_1012_skadi2_2.png","portrait":"/assets/char/portrait/char_1012_skadi2_1.png","portraitE2":"/assets/char/portrait/char_1012_skadi2_2.png","spine":{"front":{"skel":"/assets/spine/op/char_1012_skadi2/front/char_1012_skadi2.skel","atlas":"/assets/spine/op/char_1012_skadi2/front/char_1012_skadi2.atlas","textures":["/assets/spine/op/char_1012_skadi2/front/char_1012_skadi2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Skill_1_Loop","end":null,"via":"skill"},"attackDown":null,"skill":{"begin":"Skill_3_Begin","loop":"Skill_3_Loop","end":null,"index":2,"idle":null},"die":"Die","move":null,"stun":{"begin":"Stun_Begin","loop":"Stun","end":null},"skills":{"0":{"begin":"Skill_1_Begin","loop":"Skill_1_Loop","end":null,"index":0,"idle":null},"1":{"begin":"Skill_2_Begin","loop":"Skill_2_Loop","end":null,"index":1,"idle":null},"2":{"begin":"Skill_3_Begin","loop":"Skill_3_Loop","end":null,"index":2,"idle":null}}},"animations":{"Default":0,"Die":1,"Idle":8.767,"Skill_1_Begin":3.2,"Skill_1_Loop":8.767,"Skill_2_Begin":3.933,"Skill_2_Loop":3.333,"Skill_3_Begin":4.4,"Skill_3_Loop":8.767,"Start":1,"Stun":2,"Stun_Begin":1},"events":["OnAttack","OnStart"],"hits":{},"bounds":{"x":-190.586,"y":-25.785,"width":393.516,"height":443.426}}}},"char_1014_nearl2":{"avatar":"/assets/char/avatar/char_1014_nearl2.png","avatarE2":"/assets/char/avatar/char_1014_nearl2_2.png","portrait":"/assets/char/portrait/char_1014_nearl2_1.png","portraitE2":"/assets/char/portrait/char_1014_nearl2_2.png","spine":{"front":{"skel":"/assets/spine/op/char_1014_nearl2/front/char_1014_nearl2.skel","atlas":"/assets/spine/op/char_1014_nearl2/front/char_1014_nearl2.atlas","textures":["/assets/spine/op/char_1014_nearl2/front/char_1014_nearl2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":"Attack_Begin","loop":"Attack_Loop","end":"Attack_End"},"attackDown":null,"skill":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"},"die":"Die","move":null,"stun":null,"skills":{"0":{"begin":"Skill_1_Begin","loop":"Skill_1_Loop","end":null,"index":0,"idle":"Skill_1_Idle"},"1":{"begin":null,"loop":"Skill_2_Die","end":null,"index":1,"idle":null},"2":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"}}},"animations":{"Attack_Begin":0.1,"Attack_End":0.167,"Attack_Loop":1.5,"Default":0,"Die":1,"Idle":2.667,"Skill_3":1.5,"Skill_1_Begin":0.1,"Skill_1_Idle":1,"Skill_1_Loop":1.5,"Skill_1_Loop_Down":1.5,"Skill_2_Die":1.167,"Skill_3_Down":1.5,"Skill_3_Idle":1.333,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack_Loop":[0.433],"Skill_3":[0.5],"Skill_1_Loop":[0.467],"Skill_1_Loop_Down":[0.467],"Skill_3_Down":[0.5]},"bounds":{"x":-400.77,"y":-461.007,"width":720.545,"height":872.242}},"back":{"skel":"/assets/spine/op/char_1014_nearl2/back/char_1014_nearl2.skel","atlas":"/assets/spine/op/char_1014_nearl2/back/char_1014_nearl2.atlas","textures":["/assets/spine/op/char_1014_nearl2/back/char_1014_nearl2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":"Attack_Begin","loop":"Attack_Loop","end":"Attack_End"},"attackDown":null,"skill":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"},"die":null,"move":null,"stun":null,"skills":{"0":{"begin":"Skill_1_Begin","loop":"Skill_1_Loop","end":null,"index":0,"idle":"Skill_1_Idle"},"1":{"begin":"Attack_Begin","loop":"Attack_Loop","end":"Attack_End","via":"attack","index":1,"idle":null},"2":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"}}},"animations":{"Attack_Begin":0.1,"Attack_End":0.167,"Attack_Loop":1.5,"Default":0,"Idle":2.667,"Skill_1_Begin":0.1,"Skill_1_Idle":1,"Skill_1_Loop":1.5,"Skill_3":1.5,"Skill_3_Idle":1.333,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack_Loop":[0.433],"Skill_1_Loop":[0.467],"Skill_3":[0.5]},"bounds":null}}},"char_1016_agoat2":{"avatar":"/assets/char/avatar/char_1016_agoat2.png","avatarE2":"/assets/char/avatar/char_1016_agoat2_2.png","portrait":"/assets/char/portrait/char_1016_agoat2_1.png","portraitE2":"/assets/char/portrait/char_1016_agoat2_2.png","spine":{"front":{"skel":"/assets/spine/op/char_1016_agoat2/front/char_1016_agoat2.skel","atlas":"/assets/spine/op/char_1016_agoat2/front/char_1016_agoat2.atlas","textures":["/assets/spine/op/char_1016_agoat2/front/char_1016_agoat2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":"Skill_3_Begin","loop":"Skill_3_Loop","end":"Skill_3_End","index":2,"idle":"Skill_3_Idle"},"diLine truncated
|
||||
+5
-1
@@ -153,7 +153,11 @@ All paths are URL paths relative to the site root, for example `/assets/char/ava
|
||||
// group + name of the same picture (the client takes the local one first)
|
||||
prof: { icon: {caster…warrior}, large: {…}, battlecard: {…, token}, sub: {[subProfessionId]: url} },
|
||||
audio: {
|
||||
bgm: { lobby, prep, combat, boss: { intro?, loop } }, // intro then crossfade to loop (1 s)
|
||||
bgm: { lobby, prep, combat, unite?: { intro?, loop }, boss: { intro?, loop } },
|
||||
// intro then crossfade to loop (1 s); `unite` = 联防's own track — the official
|
||||
// escaped_single / escaped_multi levels declare `bgmEvent = corrosion` (卡西米尔 act13d5d0),
|
||||
// so the rescue phase does not reuse the 作战's track (audio.js bgmKeyFor 'unite', falling
|
||||
// back to `bgm.combat` for a manifest that lacks it)
|
||||
bossBgm: { [bossId]: { intro?, loop } }, // per-boss track of its R14/R15 level
|
||||
sfx: {
|
||||
ui: { click, back, confirm, tab, pick, drop, error, buy, sell, income, refresh, freeze, levelup,
|
||||
|
||||
+2
-2
@@ -1126,13 +1126,13 @@ table: `hitSleep` (targets and damages sleeping enemies — "可以攻击沉睡
|
||||
| chainhealer | heal bounces 3× (−25 %, bb chain.*) within 2.5 tiles |
|
||||
| healer (流明) | heal ×0.8 (bb heal_scale) beyond 2 tiles |
|
||||
| wandermedic | heal + reduce element gauges by 50 % ATK (bb ep_heal_ratio); also targets uninjured allies with gauge |
|
||||
| incantationmedic | arts attack; heals the lowest ally in range for 50 % (bb scale) of damage dealt |
|
||||
| incantationmedic | arts attack; EVERY damage the unit deals heals the lowest ally in range for 50 % (bb scale) of it — the official trait buff (`vendla_tr` / `reed2_tr` / `titi_tr`) is ON_AFTER_OUTPUT_DAMAGE, so skill and DoT damage heals too (缇缇's 凝固的时光 ticks, 焰影苇草's S2 fireballs while she is disarmed); a skill that triggers it for one named ally says so ("仅对该角色触发…特性") and the damage instance carries that ally (`DamageInfo.traitAlly`) |
|
||||
| slower | sluggish 0.8 s on hit (bb sluggish) |
|
||||
| bard | no attack; every second heals allies in range 10 % ATK (bb atk_to_hp_recovery_ratio) |
|
||||
| craftsman | melee phys (support devices via kit) |
|
||||
| shotprotector | ranged phys, can hit FLY, blocks 3 |
|
||||
| fortress | melee single target while blocking, ranged 1.0 splash otherwise, ground only (never hits FLY) |
|
||||
| unyield / musha / reaper | cannot be healed by others; musha heals itself 50 (bb value) per hit; reaper hits every enemy in range and heals 50 × min(hits, block) |
|
||||
| unyield / musha / reaper | cannot be healed by others; the heal fires on every enemy the unit damages itself — a normal attack (musha 50 / bb value per hit; reaper 50 × min(hits, block)) and any damage it outputs that no buff produced (the official trait's ON_OUTPUT_DAMAGE, which is why 隐德来希's S2 血镰 cuts heal her while she is disarmed), 50 (bb value) per enemy, the reaper's capped at the block count per instant |
|
||||
| centurion / crusher / pusher | hit every blocked enemy at once |
|
||||
| hammer | 50 % splash (bb atk_scale_2) to others within 1 tile |
|
||||
| instructor | ×1.2 (bb atk_scale) vs enemies it doesn't block |
|
||||
|
||||
@@ -587,7 +587,7 @@ button.bpop__harmony:hover { border-style: solid; color: #fff; }
|
||||
.uframe__btn:hover:not(:disabled) .uframe__glyph { background: #ff4a2b; }
|
||||
.uframe__btn:hover:not(:disabled) .uframe__plate { filter: brightness(1.12) drop-shadow(0 0 .08rem rgba(255, 198, 90, .7)); }
|
||||
.uframe__btn--destroy:hover:not(:disabled) .uframe__plate { filter: brightness(1.12) drop-shadow(0 0 .08rem rgba(255, 90, 60, .7)); }
|
||||
.uframe__label { font-size: .14rem; font-weight: 700; letter-spacing: .12em; padding: 0 .06rem; background: rgba(8, 11, 10, .82); text-shadow: 0 1px 2px #000; }
|
||||
.uframe__label { font-size: .14rem; font-weight: 700; letter-spacing: .12em; white-space: nowrap; padding: 0 .06rem; background: rgba(8, 11, 10, .82); text-shadow: 0 1px 2px #000; }
|
||||
.uframe__btn--sell .uframe__label { color: #ffd98a; }
|
||||
.uframe__price { position: absolute; right: -.12rem; top: -.06rem; --hex-h: .24rem; }
|
||||
|
||||
|
||||
+10
-3
@@ -59,7 +59,7 @@ const SKILL_MODE_FILE = /_(d|h|s)\d*\.mp3$/i;
|
||||
* BGM key for a route + match phase.
|
||||
* @param {'title'|'lobby'|'room'|'game'|string} route
|
||||
* @param {any} pub m.public (may be null)
|
||||
* @returns {string|null} 'lobby' | 'prep' | 'combat' | 'boss' | 'boss:<bossId>' | null
|
||||
* @returns {string|null} 'lobby' | 'prep' | 'combat' | 'unite' | 'boss' | 'boss:<bossId>' | null
|
||||
*/
|
||||
export function bgmKeyFor(route, pub) {
|
||||
if (route !== 'game') return route === 'title' || route === 'lobby' || route === 'room' ? 'lobby' : null;
|
||||
@@ -68,7 +68,12 @@ export function bgmKeyFor(route, pub) {
|
||||
switch (phase) {
|
||||
case PHASE.INFO_CHECK: case PHASE.BAND_DRAFT: case PHASE.BATTLE_CHECK: case PHASE.RESULT: case PHASE.LOBBY:
|
||||
return 'lobby';
|
||||
case PHASE.COMBAT: case PHASE.UNITE:
|
||||
case PHASE.UNITE:
|
||||
// 联防 has its own track: the official `escaped_single` / `escaped_multi` levels declare
|
||||
// `bgmEvent = corrosion` (level_act1autochess_escaped_*.json), so the rescue phase is not the 作战's track.
|
||||
// resolveBgm falls back to `bgm.combat` when a manifest predates it.
|
||||
return 'unite';
|
||||
case PHASE.COMBAT:
|
||||
return 'combat';
|
||||
case PHASE.FINAL_ASSAULT:
|
||||
return pub.bossId ? `boss:${pub.bossId}` : 'boss';
|
||||
@@ -80,7 +85,8 @@ export function bgmKeyFor(route, pub) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a BGM key to { intro?, loop } URLs from the manifest (boss:<id> falls back to the generic boss track).
|
||||
* Resolve a BGM key to { intro?, loop } URLs from the manifest: `boss:<id>` falls back to the generic boss track and
|
||||
* `unite` (联防's own track) to `bgm.combat` when the manifest predates it.
|
||||
* @param {any} manifest
|
||||
* @param {string|null} key
|
||||
* @returns {{ intro: string|null, loop: string }|null}
|
||||
@@ -90,6 +96,7 @@ export function resolveBgm(manifest, key) {
|
||||
if (!a || !key) return null;
|
||||
let t = null;
|
||||
if (key.startsWith('boss:')) t = a.bossBgm?.[key.slice(5)] || a.bgm?.boss;
|
||||
else if (key === 'unite') t = a.bgm?.unite || a.bgm?.combat;
|
||||
else t = a.bgm?.[key];
|
||||
if (!t || typeof t.loop !== 'string') return null;
|
||||
return { intro: typeof t.intro === 'string' ? t.intro : null, loop: t.loop };
|
||||
|
||||
@@ -273,7 +273,7 @@ function MatchScreen() {
|
||||
priv, stage: gd.stage(pub?.stageId), editable, field: deployField,
|
||||
getChess: gd.chess, getToken: gd.token, getItem: gd.item, getEffect: gd.effect,
|
||||
}), [priv, pub?.stageId, editable, gd.ready, deployField]);
|
||||
live.current = { pub, priv, field, editable, placeCtx, watching, watchWho, home, myId, detail, drawer, bondOpen, emoteOpen, settingsOpen, exitOpen, drag, facing, sel, pen, collapsedNow: collapsed, localDone: false, canPause: false, paused };
|
||||
live.current = { pub, priv, field, editable, placeCtx, watching, watchWho, home, myId, detail, drawer, bondOpen, emoteOpen, settingsOpen, exitOpen, drag, facing, sel, selBusy, pen, collapsedNow: collapsed, localDone: false, canPause: false, paused };
|
||||
|
||||
// ---- camera: every request goes through setCam, which remembers it for the pen's way back -----------------------
|
||||
// the own prep board: the normal board, or — in the prep of a boss round — the player's half of the boss field
|
||||
@@ -972,6 +972,7 @@ function MatchScreen() {
|
||||
}, [editable, placeCtx, facing]);
|
||||
// the selected piece: gone / not editable → deselect; on the board its range tiles show (rotated to its facing)
|
||||
const selEntry = sel ? placeCtx.pieces.get(sel.uid) || null : null;
|
||||
live.current.showPrep = showPrep;
|
||||
useEffect(() => { if (sel && (!selEntry || !editable || !showPrep)) setSel(null); }, [sel, selEntry, editable, showPrep]);
|
||||
const selRangeKey = selEntry && selEntry.area === 'board' ? `${selEntry.piece.uid}:${selEntry.row},${selEntry.col}:${pieceDir(selEntry.piece)}` : '';
|
||||
useEffect(() => {
|
||||
@@ -1095,6 +1096,15 @@ function MatchScreen() {
|
||||
return;
|
||||
}
|
||||
if (!L.editable) return;
|
||||
if (act === 'retreat' || act === 'sell') {
|
||||
if (!L.showPrep || L.drag || L.facing || L.selBusy || !L.sel) return;
|
||||
const selected = L.placeCtx.pieces.get(L.sel.uid);
|
||||
if (selected?.piece.kind !== 'chess') return;
|
||||
const available = underframeActions(L.placeCtx, L.sel.uid);
|
||||
if (act === 'retreat' && available?.retreat) await retreatSel();
|
||||
else if (act === 'sell' && available?.sell != null) await sellSel();
|
||||
return;
|
||||
}
|
||||
const reason = shopBlockReason(act, { priv: L.priv, editable: L.editable });
|
||||
if (reason) { audio.sfx('error', { volume: 0.5 }); return; }
|
||||
if (act === 'refresh') actions.refresh();
|
||||
|
||||
@@ -115,6 +115,20 @@ export function normalizeCode(v) {
|
||||
return s.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, ROOM_CODE_LEN);
|
||||
}
|
||||
|
||||
/**
|
||||
* A handler that Preact binds as `onClick=${fn}` receives the click EVENT as its first argument, and a default
|
||||
* parameter only applies to `undefined` — so `fn(c = code)` would normalise the event target into a nonsense code
|
||||
* (`String(el)` → `"[object HTMLElement]"` → "OBJE"). Only a string is ever a code; anything else falls back to the
|
||||
* input field. Returns null when neither yields a well-formed code.
|
||||
* @param {unknown} arg the argument a handler was called with
|
||||
* @param {string} field the current input-field value
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function codeArg(arg, field) {
|
||||
const k = normalizeCode(typeof arg === 'string' ? arg : field);
|
||||
return CODE_RE.test(k) ? k : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Room code from a deep link query string (`?room=CODE`), or null when absent/malformed.
|
||||
* Accepts ROOM_CODE_LEN..ROOM_CODE_LEN+2 alphanumerics (the protocol's join limit).
|
||||
@@ -248,15 +262,30 @@ export function LobbyScreen() {
|
||||
};
|
||||
const create = () => run('create', () => net.request('room.create', { mode: roomMode, difficulty }));
|
||||
const join = (c = code) => {
|
||||
const k = normalizeCode(c);
|
||||
if (!CODE_RE.test(k)) { toast(`同盟密钥为 ${ROOM_CODE_LEN} 位字母或数字`, 'warn'); return; }
|
||||
// `onClick=${join}` hands the click EVENT as the first argument, and a default parameter only applies to
|
||||
// `undefined` — codeArg keeps an event target out of the key and falls back to the input field
|
||||
const k = codeArg(c, code);
|
||||
if (!k) { toast(`同盟密钥为 ${ROOM_CODE_LEN} 位字母或数字`, 'warn'); return; }
|
||||
run('join', () => net.request('room.join', { code: k }));
|
||||
};
|
||||
// a spectator seat: no player seat taken, nothing to do but watch (also a match already running)
|
||||
const spectate = () => {
|
||||
const k = normalizeCode(code);
|
||||
if (!CODE_RE.test(k)) { toast(`同盟密钥为 ${ROOM_CODE_LEN} 位字母或数字`, 'warn'); return; }
|
||||
run('spectate', () => net.request('room.spectate', { code: k }));
|
||||
const spectate = (c = code) => {
|
||||
// same guard as join: `onClick=${spectate}` passes the click event, not a code
|
||||
const k = codeArg(c, code);
|
||||
if (!k) { toast(`同盟密钥为 ${ROOM_CODE_LEN} 位字母或数字`, 'warn'); return; }
|
||||
run('spectate', () => net.request('room.spectate', { code: k }).catch((err) => {
|
||||
// Clearer than the bare ERR_TEXT: the usual cause is a code that is not the host's (a remembered one from an
|
||||
// earlier room, or another machine's) — the server can only answer "no such room".
|
||||
if (err?.code === ERR.ROOM_NOT_FOUND) {
|
||||
toast(`没有找到密钥 ${k} 对应的同盟:请和房主核对密钥(同盟结束后密钥即失效)`, 'warn');
|
||||
return;
|
||||
}
|
||||
if (err?.code === ERR.ALREADY) {
|
||||
toast('你已经是该同盟的博士:先离开同盟,才能以观战身份进入', 'warn');
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}));
|
||||
};
|
||||
const backToTitle = () => {
|
||||
identity.setEntered(false);
|
||||
@@ -305,7 +334,8 @@ export function LobbyScreen() {
|
||||
</div>
|
||||
<div class="join-foot">
|
||||
${recent.length ? html`<span class="t-lo">最近的同盟</span>
|
||||
${recent.map((c) => html`<button key=${c} type="button" class="code-chip num" onClick=${() => { setCode(c); join(c); }}>${c}</button>`)}`
|
||||
${recent.map((c) => html`<button key=${c} type="button" class="code-chip num" title="填入密钥(不会直接加入)"
|
||||
onClick=${() => setCode(c)}>${c}</button>`)}`
|
||||
: html`<span class="t-dim">向同伴索取 ${ROOM_CODE_LEN} 位同盟密钥,或直接打开邀请链接</span>`}
|
||||
</div>
|
||||
<//>
|
||||
|
||||
@@ -1528,11 +1528,11 @@ export function rangeGridBox(grid, mirror = false) {
|
||||
// ---- keyboard ---------------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Map a keydown to a game shortcut (R refresh, F freeze, D level-up, Space ready, Esc close).
|
||||
* Map a keydown to a game shortcut (R refresh, F freeze, D level-up, Q retreat, X sell, Space ready, Esc close).
|
||||
* Space means ready even while a HUD button has focus (a mouse click leaves the shop card / 刷新 focused, and
|
||||
* Space must not re-trigger it); the caller prevents the button's own activation. Enter still activates buttons.
|
||||
* @param {{ key?: string, code?: string, ctrlKey?: boolean, metaKey?: boolean, altKey?: boolean, repeat?: boolean, target?: any }} e
|
||||
* @returns {'refresh'|'freeze'|'levelUp'|'ready'|'escape'|null}
|
||||
* @returns {'refresh'|'freeze'|'levelUp'|'retreat'|'sell'|'ready'|'escape'|null}
|
||||
*/
|
||||
export function shortcutFor(e) {
|
||||
if (!e || e.ctrlKey || e.metaKey || e.altKey) return null;
|
||||
@@ -1546,6 +1546,8 @@ export function shortcutFor(e) {
|
||||
if (code === 'KeyR' || key === 'r') return 'refresh';
|
||||
if (code === 'KeyF' || key === 'f') return 'freeze';
|
||||
if (code === 'KeyD' || key === 'd') return 'levelUp';
|
||||
if (code === 'KeyQ' || key === 'q') return 'retreat';
|
||||
if (code === 'KeyX' || key === 'x') return 'sell';
|
||||
if (code === 'Space' || key === ' ') return 'ready';
|
||||
return null;
|
||||
}
|
||||
@@ -1561,7 +1563,7 @@ export const closesOnFieldPress = (detail) => detail?.kind === 'piece' || detail
|
||||
* Whether an open overlay swallows a game shortcut: a modal / the guide own the keyboard (Esc included — they close
|
||||
* themselves); the 本局信息 / 敌方情报 drawer is a dialog too — only Esc (it closes the drawer) passes, R / F / D / Space
|
||||
* never act behind it.
|
||||
* @param {'refresh'|'freeze'|'levelUp'|'ready'|'escape'|null} act shortcutFor
|
||||
* @param {'refresh'|'freeze'|'levelUp'|'retreat'|'sell'|'ready'|'escape'|null} act shortcutFor
|
||||
* @param {{ modal?: boolean, drawer?: boolean }} open
|
||||
*/
|
||||
export function shortcutBlocked(act, { modal = false, drawer = false } = {}) {
|
||||
|
||||
@@ -73,7 +73,7 @@ export function SettingsModal({ open, onClose }) {
|
||||
</div>
|
||||
${touchUi
|
||||
? html`<p class="set-hint">触屏操作:点击单位选中(撤退 / 出售)· 长按单位或卡牌查看详情 · 拖动部署后滑动选择朝向</p>`
|
||||
: html`<p class="set-hint">快捷键:<kbd>R</kbd> 刷新 · <kbd>F</kbd> 冻结 · <kbd>D</kbd> 升级 · <kbd>Space</kbd> 准备就绪 · <kbd>Esc</kbd> 关闭弹窗 · 右键查看详情</p>`}
|
||||
: html`<p class="set-hint">快捷键:<kbd>R</kbd> 刷新 · <kbd>F</kbd> 冻结 · <kbd>D</kbd> 升级 · <kbd>Q</kbd> 撤退选中干员 · <kbd>X</kbd> 出售选中干员 · <kbd>Space</kbd> 准备就绪 · <kbd>Esc</kbd> 关闭弹窗 · 右键查看详情</p>`}
|
||||
</div>
|
||||
<//>`;
|
||||
}
|
||||
@@ -91,13 +91,13 @@ export function Underframe({ view, uid = null, row, col, actions, name = '', bus
|
||||
<path class="uframe__corner" d="M-100 0 L-86 -14 M-100 0 L-86 14 M100 0 L86 -14 M100 0 L86 14 M0 -100 L-14 -86 M0 -100 L14 -86 M0 100 L-14 86 M0 100 L14 86" />
|
||||
</svg>
|
||||
${actions.retreat ? html`<button type="button" class="uframe__btn uframe__btn--retreat" disabled=${busy} onPointerDown=${stop}
|
||||
onClick=${(e) => { stop(e); onRetreat?.(); }} title="撤退至整备区" aria-label="撤退">
|
||||
<${RetreatGlyph} /><span class="uframe__label">撤退</span>
|
||||
onClick=${(e) => { stop(e); onRetreat?.(); }} title=${actions.sell != null ? '撤退至整备区(Q)' : '撤退至整备区'} aria-label="撤退" aria-keyshortcuts=${actions.sell != null ? 'Q' : undefined}>
|
||||
<${RetreatGlyph} /><span class="uframe__label">${actions.sell != null ? '撤退[Q]' : '撤退'}</span>
|
||||
</button>` : null}
|
||||
${actions.sell != null ? html`<button type="button" class="uframe__btn uframe__btn--sell" disabled=${busy} onPointerDown=${stop}
|
||||
onClick=${(e) => { stop(e); onSell?.(); }} title=${`出售(+${actions.sell} 资金)`} aria-label=${`出售,获得 ${actions.sell} 资金`}>
|
||||
onClick=${(e) => { stop(e); onSell?.(); }} title=${`出售(+${actions.sell} 资金,X)`} aria-label=${`出售,获得 ${actions.sell} 资金`} aria-keyshortcuts="X">
|
||||
<${PlateIcon} sprite="icon_sell" glyph="sell" tone="sell" />
|
||||
<span class="uframe__label">出售</span>
|
||||
<span class="uframe__label">出售[X]</span>
|
||||
<${HexBadge} value=${`+${actions.sell}`} tone="gold" size="sm" class="uframe__price" />
|
||||
</button>` : null}
|
||||
${actions.destroy ? html`<button type="button" class=${cx('uframe__btn', 'uframe__btn--destroy')} disabled=${busy} onPointerDown=${stop}
|
||||
|
||||
@@ -476,10 +476,17 @@ export default {
|
||||
return {
|
||||
skills: { 'skcom_magic_rage[3]': { kind: 'duration', mods: { aspd: num(skillBbOf(chess, 'skcom_magic_rage[3]').attack_speed) } } },
|
||||
trait: {
|
||||
afterHit(battle, u, target, info) {
|
||||
if (!(info.dealt > 0)) return;
|
||||
const ally = protege(u) ?? battle.lowestHpAllyInRange(u);
|
||||
if (ally) battle.heal(u, ally, info.dealt * (u.profile.healRatio ?? 0.5), { tags: ['incantation'] });
|
||||
// 咒愈师 trait: EVERY damage she deals heals an ally for 50 % of it (professions.js `installIncantation`,
|
||||
// buff_template_data `vendla_tr` = ON_AFTER_OUTPUT_DAMAGE) — while 荆藤庇荫 runs her S2 says "仅对该角色触发刺玫
|
||||
// 特性", so her protégé is the target then; otherwise it is the lowest-HP ally in range.
|
||||
install(battle, u) {
|
||||
battle.on('damaged', (c) => {
|
||||
const t = c.target;
|
||||
if (c.source !== u || !u.alive || !t || t.side !== 'enemy' || !(c.amount > 0)) return;
|
||||
if (c.type === 'element' || c.type === 'elemental') return;
|
||||
const ally = (c.dmg && c.dmg.traitAlly) || protege(u) || battle.lowestHpAllyInRange(u);
|
||||
if (ally) battle.heal(u, ally, c.amount * (u.profile.healRatio ?? 0.5), { tags: ['incantation'] });
|
||||
}, { owner: u });
|
||||
},
|
||||
},
|
||||
skill: {
|
||||
@@ -501,9 +508,10 @@ export default {
|
||||
battle.on('damaged', (ctx) => {
|
||||
const p = protege(unit);
|
||||
if (!p || ctx.target !== p || !byEnemyAttack(ctx) || !ctx.source.alive || !unit.canAct) return;
|
||||
const dealt = battle.dealDamage(unit, ctx.source, { amount: unit.s.atk * num(bb.atk_scale), type: 'arts', isSkill: true, canDodge: false, tags: ['counter'] });
|
||||
// "并仅对该角色触发刺玫特性": this counter damage is healed by the trait (install above) for the protégé — the
|
||||
// damage instance names her, so no separate heal here (it would double)
|
||||
battle.dealDamage(unit, ctx.source, { amount: unit.s.atk * num(bb.atk_scale), type: 'arts', isSkill: true, canDodge: false, tags: ['counter'], traitAlly: p });
|
||||
battle.fx('counter', { x: ctx.source.x, y: ctx.source.y, id: unit.id });
|
||||
if (dealt > 0) battle.heal(unit, p, dealt * (unit.profile.healRatio ?? 0.5), { tags: ['incantation'] });
|
||||
}, { owner: unit });
|
||||
if (hs !== 1) {
|
||||
let cacheT = -1, top = null;
|
||||
|
||||
@@ -1470,7 +1470,7 @@ function reed2(bb, chess, def) {
|
||||
}
|
||||
},
|
||||
onTick({ battle, unit, dt }) {
|
||||
const cd = Math.max(0.1, num(bb.cooldown, 1.5)), ratio = num(unit.profile?.healRatio, num(tb.scale, 0.5));
|
||||
const cd = Math.max(0.1, num(bb.cooldown, 1.5));
|
||||
for (const F of unit.mem.reedFire || []) {
|
||||
if (!live(F.a)) continue;
|
||||
F.acc += dt;
|
||||
@@ -1480,9 +1480,10 @@ function reed2(bb, chess, def) {
|
||||
if (!c.length) c = enemiesIn(battle, unit);
|
||||
if (!c.length) continue;
|
||||
sortEnemyTargets(battle, F.a, c, null);
|
||||
const dealt = battle.dealDamage(unit, c[0], { amount: unit.s.atk * num(bb.atk_scale, 1), type: 'arts', isSkill: true, tags: ['skill', 'fireball'] });
|
||||
// "每1.5秒对一名敌人造成…法术伤害并仅对该干员触发焰影苇草特性": the trait heal (professions.js
|
||||
// installIncantation) runs for this damage and names the carrier instead of the lowest-HP ally in range
|
||||
battle.dealDamage(unit, c[0], { amount: unit.s.atk * num(bb.atk_scale, 1), type: 'arts', isSkill: true, tags: ['skill', 'fireball'], traitAlly: F.a });
|
||||
battle.fx('strike', { x: c[0].x, y: c[0].y, id: c[0].id, src: F.a.id });
|
||||
if (dealt > 0 && ratio > 0) battle.heal(unit, F.a, dealt * ratio, { tags: ['incantation'] });
|
||||
}
|
||||
},
|
||||
onEnd({ battle, unit }) {
|
||||
|
||||
@@ -92,6 +92,10 @@ export function makeDamageInfo(d = {}) {
|
||||
ignoreSelect: !!d.ignoreSelect,
|
||||
sourceless: !!d.sourceless,
|
||||
attackId: d.attackId ?? 0,
|
||||
// 咒愈师 skills whose damage triggers their trait for ONE named ally ("并仅对该角色/干员触发…特性"): the instance
|
||||
// carries that ally, and the trait hook (professions.js installIncantation) heals it instead of the lowest-HP ally
|
||||
// in range. null = the ordinary target of the trait.
|
||||
traitAlly: d.traitAlly ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
// (`dir`, sim/dir.js): offsets are compared in its facing-RIGHT frame.
|
||||
|
||||
import { toLocal, frontOf } from './dir.js';
|
||||
import { isHpLoss } from './damage.js';
|
||||
import { absoluteRangeKeys } from './targeting.js';
|
||||
import { bodyInKeys, bodyKeys, bodyOnTile } from './body.js';
|
||||
import { COLS, CHAIN_RADIUS } from './constants.js';
|
||||
@@ -50,13 +51,66 @@ export const PROFESSION_DEFAULTS = Object.freeze({
|
||||
// --------------------------------------------------------------------------------------------------------------
|
||||
// install helpers (per-unit hooks). All use engine helpers only; tunables come from unit.profile.
|
||||
|
||||
/**
|
||||
* 武者 / 收割者 self-heal ("每次攻击到敌人回复自身50生命"; 收割者 adds "最大生效数等于阻挡数"). Normal attacks arrive as one
|
||||
* 'attack' event whose `targets` are every enemy hit.
|
||||
*
|
||||
* The official trait is a buff ON the operator that fires on ON_OUTPUT_DAMAGE — buff_template_data `etlchi_trait`,
|
||||
* `excu2_trait`, `utage_trait`, `helage_trait`, `zuole_trait` all react to any damage the unit outputs, and 隐德来希's
|
||||
* filters the attackType BUFF out (damage produced by a buff/talent — e.g. her own 萃血 DoT). So skill damage heals too:
|
||||
* 隐德来希's S2 blood sickles restore her life although the skill stops her attacks (`attack: { noAttack: true }`) — the
|
||||
* sickle's AOEDamage nodes are attackType NORMAL and PRTS 备注 says "伤害来源始终视为隐德来希". Every such hit arrives as
|
||||
* one 'damaged' event per enemy, so the reaper cap ("最大生效数") is applied per instant here: the official `[heal_fake]`
|
||||
* window is 0.05 s and its stack count is the block number (`SetStackCountViaBlockNum`). [ASSUMED] the sim uses the same
|
||||
* `battle.time` as its window, so simultaneous hits (both 血镰, an AoE) share the block-count cap; a normal attack has its
|
||||
* own event and its own cap, as before.
|
||||
*/
|
||||
const installSelfHealOnHit = (capByBlock) => (battle, unit) => {
|
||||
const heal = (n) => { if (n > 0 && unit.alive) battle.heal(unit, unit, (unit.profile.selfHeal ?? 50) * n, { self: true }); };
|
||||
battle.on('attack', (ctx) => {
|
||||
if (ctx.attacker !== unit || !unit.alive) return;
|
||||
let n = ctx.targets.length;
|
||||
if (capByBlock) n = Math.min(n, Math.max(1, unit.s.blockCnt));
|
||||
if (n > 0) battle.heal(unit, unit, (unit.profile.selfHeal ?? 50) * n, { self: true });
|
||||
heal(n);
|
||||
}, { owner: unit, priority: -10 });
|
||||
battle.on('damaged', (c) => {
|
||||
if (c.source !== unit || !unit.alive || !c.target || c.target.side !== 'enemy') return;
|
||||
const dmg = c.dmg;
|
||||
if (!dmg || dmg.isAttack || isHpLoss(dmg)) return; // normal attacks: the 'attack' hook; a 流失 is not damage dealt
|
||||
const tags = dmg.tags || [];
|
||||
if (tags.includes('talent') || tags.includes('dot') || tags.includes('periodic')) return; // attackType BUFF
|
||||
const mem = unit.mem;
|
||||
if (mem.selfHealAt !== battle.time) { mem.selfHealAt = battle.time; mem.selfHealN = 0; }
|
||||
if (capByBlock && mem.selfHealN >= Math.max(1, unit.s.blockCnt)) return;
|
||||
mem.selfHealN++;
|
||||
heal(1);
|
||||
}, { owner: unit, priority: -10 });
|
||||
};
|
||||
|
||||
/**
|
||||
* 咒愈师 (incantationmedic) trait "攻击造成法术伤害,攻击敌人时为攻击范围内一名友方干员治疗相当于50%伤害的生命值".
|
||||
*
|
||||
* The official trait is a buff ON the operator that fires on ON_AFTER_OUTPUT_DAMAGE — buff_template_data `vendla_tr`,
|
||||
* `reed2_tr` and `titi_tr` all are (`IsDamage` → `AssignDamageValueToBlackboard` → heal through an ability selector),
|
||||
* i.e. the heal follows EVERY damage the operator deals, not only a normal attack. The 咒愈师 skills that damage without
|
||||
* an attack say so themselves: 焰影苇草 S2 "每1.5秒对一名敌人造成…法术伤害并仅对该干员触发焰影苇草特性", 刺玫 S2
|
||||
* "…造成攻击力20%的法术伤害并仅对该角色触发刺玫特性" — the official text only makes sense if damage (not an attack)
|
||||
* triggers the trait.
|
||||
*
|
||||
* The sim used to heal from the attack path only (`profile.afterHit`, ai.js), so 缇缇's per-second 凝固的时光 ticks and
|
||||
* every other non-attack damage healed nothing. A damage instance may name the one ally it triggers for
|
||||
* (`DamageInfo.traitAlly`, the skills' "仅对该角色/干员触发特性"): the heal then goes to that operator instead of the
|
||||
* lowest-HP ally in range.
|
||||
*/
|
||||
const installIncantation = (battle, unit) => {
|
||||
battle.on('damaged', (c) => {
|
||||
const t = c.target;
|
||||
if (c.source !== unit || !unit.alive || !t || t.side !== 'enemy' || !(c.amount > 0)) return;
|
||||
// a gauge fill (元素损伤) removes no HP and is not "伤害" for the heal; a 流失 is not damage dealt either
|
||||
if (c.type === 'element' || c.type === 'elemental') return;
|
||||
const ally = (c.dmg && c.dmg.traitAlly) || battle.lowestHpAllyInRange(unit);
|
||||
if (ally) battle.heal(unit, ally, c.amount * (unit.profile.healRatio ?? 0.5), { tags: ['incantation'] });
|
||||
}, { owner: unit });
|
||||
};
|
||||
|
||||
const installHpDrain = (battle, unit) => {
|
||||
@@ -415,12 +469,7 @@ export const SUB = Object.freeze({
|
||||
chainhealer: P({ heal: { mode: 'chain', count: 3, falloff: 0.25 } }),
|
||||
healer: P({ heal: { mode: 'single', farMul: 0.8, nearDist: 2 } }),
|
||||
wandermedic: P({ heal: { mode: 'single', elementHealRatio: 0.5 } }),
|
||||
incantationmedic: P({ dmgType: 'arts', projectile: 'bolt', heal: null,
|
||||
afterHit: (battle, unit, target, info) => {
|
||||
if (!(info.dealt > 0)) return;
|
||||
const ally = battle.lowestHpAllyInRange(unit);
|
||||
if (ally) battle.heal(unit, ally, info.dealt * (unit.profile.healRatio ?? 0.5), { tags: ['incantation'] });
|
||||
} }),
|
||||
incantationmedic: P({ dmgType: 'arts', projectile: 'bolt', heal: null, install: installIncantation }),
|
||||
// --- SUPPORT
|
||||
slower: P({ onHitStatus: { key: 'sluggish', duration: 0.8 } }),
|
||||
underminer: P({}),
|
||||
|
||||
@@ -992,6 +992,28 @@ describe('screen helpers', () => {
|
||||
assert.equal(difficultyInfo('coop', 'BOGUS').rounds, 14);
|
||||
});
|
||||
|
||||
// Regression: `onClick=${handler}` hands Preact's click EVENT as the first argument, and a default parameter only
|
||||
// applies to `undefined` — so `normalizeCode(eventTarget)` produced "[object HTMLElement]" → "OBJE", and every
|
||||
// 观战 click sent the same nonsense key no matter what was typed in the field. codeArg() is the guard for that.
|
||||
test('lobby: a click event is never mistaken for an alliance key (codeArg)', async () => {
|
||||
const { codeArg, normalizeCode } = await mod('screens/lobby.js');
|
||||
// a DOM element stringifies to "[object HTMLElement]" — the shape that caused the bug
|
||||
const element = { toString: () => '[object HTMLElement]' };
|
||||
assert.equal(normalizeCode(element), 'OBJE', 'the old bug: the element normalises into a fake 4-letter code');
|
||||
assert.equal(codeArg(element, 'NJBU'), 'NJBU', 'a non-string argument falls back to the input field');
|
||||
// plain event-like objects and other non-strings behave the same
|
||||
for (const weird of [{}, [], 42, true, null, undefined, Symbol('x'), () => {}]) {
|
||||
assert.equal(codeArg(weird, 'NJBU'), 'NJBU', `falls back for ${String(weird)}`);
|
||||
}
|
||||
// an explicit string always wins (the full-room prompt passes the code it asked for)
|
||||
assert.equal(codeArg('ab cd', 'NJBU'), 'ABCD');
|
||||
// no usable code at all
|
||||
assert.equal(codeArg(element, ''), null);
|
||||
assert.equal(codeArg(element, 'AB'), null);
|
||||
assert.equal(codeArg(undefined, undefined), null);
|
||||
assert.equal(codeArg('ZZZ QQQ', ''), 'ZZZQ', 'a real string is still truncated to ROOM_CODE_LEN');
|
||||
});
|
||||
|
||||
test('lobby: battlefield note per difficulty (标准 fixed 战场#01, 险境 8 / 绝境·终极 7 random) matches config.json modes[].stages', async () => {
|
||||
const { difficultyInfo, stageNote, stageLabel, STAGE_POOL } = await mod('screens/lobby.js');
|
||||
const cfg = JSON.parse(readFileSync(path.join(ROOT, 'data/config.json'), 'utf8'));
|
||||
|
||||
@@ -232,6 +232,17 @@ test('缇缇 S2 封护: no attacks; she and the lowest-HP op in range sleep (inv
|
||||
approx(dream.at(-1).amount, u.s.atk * t0.damage_atk_scale * bb.talent_scale, 'T1 ×talent_scale');
|
||||
assert.ok(h.runUntil(() => !u.skill.active, 30));
|
||||
assert.equal(during(h, u, attacks(h, u)).length, 0, 'no attacks during the skill');
|
||||
// …and every 凝固的时光 tick healed a 咒愈师 ally: the official trait buff (`titi_tr`) is ON_AFTER_OUTPUT_DAMAGE, so
|
||||
// the heal follows ANY damage she deals. The sim ran the trait from the attack path only and she does not attack at
|
||||
// all while this skill runs, so those ticks healed nothing (the same hook gap that left 隐德来希 S2 without heals).
|
||||
const dreams = during(h, u, dealt(h, u, (c) => (c.dmg?.tags || []).includes('titiDream')));
|
||||
const incHeals = during(h, u, heals(h, u, (c) => (c.opts?.tags || []).includes('incantation')));
|
||||
assert.ok(dreams.length > 0, `${dreams.length} 凝固的时光 ticks`);
|
||||
assert.equal(incHeals.length, dreams.length, 'one trait heal per tick');
|
||||
for (const c of incHeals) {
|
||||
approx(c.amount, dreams[0].amount * u.profile.healRatio, 'heal = 50 % of the tick');
|
||||
assert.ok([hurt, fine, u].includes(c.target), 'on an ally in her range');
|
||||
}
|
||||
assert.ok(!u.findBuff('titi:ward') && !hurt.findBuff('titi:ward'), 'both wake at the end');
|
||||
done(h);
|
||||
}
|
||||
@@ -417,6 +428,51 @@ test('隐德来希 S2 绯红壁合: no attacks; blood sickles on her and on the
|
||||
}
|
||||
});
|
||||
|
||||
test('隐德来希 S2 绯红壁合: every 血镰 cut heals the 收割者 trait (每攻击到一个敌人回复自身50生命)', () => {
|
||||
for (const id of pair('06')) {
|
||||
const h = run({
|
||||
defs: { enemies: { enemy_dummy: dummy('enemy_dummy') } },
|
||||
units: [entry(id, 'skchr_etlchi_2', { row: 10, col: 4 })],
|
||||
enemies: [{ key: 'enemy_dummy', pos: [10, 5] }],
|
||||
});
|
||||
const u = sel(h, id, 'skchr_etlchi_2');
|
||||
h.step();
|
||||
cast(h, u);
|
||||
u.hp = u.s.maxHp * 0.4; // wounded (and above 重盈's 25 %), so every heal shows
|
||||
const hp0 = u.hp, t0 = h.b.time;
|
||||
h.run(3);
|
||||
const cuts = tagged(h, u, 'bloodSickle').filter((c) => c.t > t0 + 1e-9);
|
||||
const healed = heals(h, u, (c) => c.target === u && c.t > t0 + 1e-9);
|
||||
assert.ok(cuts.length >= 5, `${cuts.length} cuts`);
|
||||
assert.equal(healed.length, cuts.length, 'one trait heal per cut (the trait fires on every damage she outputs)');
|
||||
for (const c of healed) approx(c.amount, u.profile.selfHeal, 'per-hit heal = the trait 生命值');
|
||||
approx(u.hp - hp0, healed.length * u.profile.selfHeal, 'HP restored');
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('隐德来希 S2 绯红壁合: the 收割者 heal is capped by the block count per cut (最大生效数等于阻挡数)', () => {
|
||||
const h = run({
|
||||
defs: { enemies: { enemy_dummy: dummy('enemy_dummy') } },
|
||||
units: [entry('chess_char_5_06_a', 'skchr_etlchi_2', { row: 10, col: 4 })],
|
||||
enemies: [{ key: 'enemy_dummy', pos: [10, 5] }, { key: 'enemy_dummy', pos: [9, 4] }, { key: 'enemy_dummy', pos: [11, 4] }],
|
||||
});
|
||||
const u = sel(h, 'chess_char_5_06_a', 'skchr_etlchi_2');
|
||||
h.step();
|
||||
cast(h, u);
|
||||
u.hp = u.s.maxHp * 0.4;
|
||||
const hp0 = u.hp, t0 = h.b.time;
|
||||
h.run(3);
|
||||
const cuts = tagged(h, u, 'bloodSickle').filter((c) => c.t > t0 + 1e-9);
|
||||
const healed = heals(h, u, (c) => c.target === u && c.t > t0 + 1e-9);
|
||||
const ticks = cuts.length / 3; // three enemies stand in the sickle's ring
|
||||
assert.ok(ticks >= 5 && Number.isInteger(ticks), `${cuts.length} cuts over ${ticks} ticks`);
|
||||
assert.equal(healed.length, ticks * Math.max(1, u.s.blockCnt), `${healed.length} heals (阻挡数 ${u.s.blockCnt})`);
|
||||
for (const c of healed) approx(c.amount, u.profile.selfHeal, 'per-hit heal');
|
||||
approx(u.hp - hp0, healed.length * u.profile.selfHeal, 'HP restored');
|
||||
done(h);
|
||||
});
|
||||
|
||||
test('隐德来希 module REA-Y (玫瑰色故事集): ASPD +12 with ≥ 2 enemies in range', () => {
|
||||
const h = run({
|
||||
defs: { enemies: { enemy_dummy: dummy('enemy_dummy') } },
|
||||
|
||||
@@ -348,6 +348,16 @@ test('incantation medics heal an ally for 50 % of damage dealt; wandermedics cle
|
||||
h.unit('t_tank').hp = 1000;
|
||||
h.run(0.5);
|
||||
approx(h.unit('t_tank').hp, 1000 + 500, 1e-6);
|
||||
// …and not only for attacks: the official trait buff is ON_AFTER_OUTPUT_DAMAGE (`vendla_tr` / `reed2_tr` / `titi_tr`),
|
||||
// so ANY damage the 咒愈师 deals heals. The sim used to run the trait from the attack path (`profile.afterHit`) only,
|
||||
// which is why 缇缇's 凝固的时光 ticks — and every 咒愈师 skill that damages without an attack — healed nothing.
|
||||
h.unit('t_tank').hp = 1000;
|
||||
h.b.dealDamage(h.unit('t_inc'), h.enemy('enemy_dummy'), { amount: 400, type: 'arts', isSkill: true });
|
||||
approx(h.unit('t_tank').hp, 1000 + 200, 1e-6, 'skill damage heals 50 %');
|
||||
// a gauge fill removes no HP: it is not "伤害" for the trait
|
||||
h.unit('t_tank').hp = 1000;
|
||||
h.b.emit?.('damaged', { source: h.unit('t_inc'), target: h.enemy('enemy_dummy'), amount: 400, type: 'element', dmg: null });
|
||||
approx(h.unit('t_tank').hp, 1000, 1e-6, 'element 损伤 does not heal');
|
||||
const wm = chessRec({ id: 't_wm', profession: 'MEDIC', subProfessionId: 'wandermedic', attackKind: 'heal', dmgType: 'heal', rangeGrid: R3, skill: null, stats: { atk: 400 } });
|
||||
const h2 = makeBattle({ defs: { chess: { t_wm: wm, t_tank: tank } }, units: [{ chessId: 't_wm', row: 10, col: 4 }, { chessId: 't_tank', row: 10, col: 5 }], content: 'none' });
|
||||
h2.step();
|
||||
|
||||
+10
-1
@@ -30,7 +30,8 @@ describe('bgm selection', () => {
|
||||
assert.equal(bgmKeyFor('game', { phase: PHASE.PREP }), 'prep');
|
||||
assert.equal(bgmKeyFor('game', { phase: PHASE.SP_DRAFT }), 'prep');
|
||||
assert.equal(bgmKeyFor('game', { phase: PHASE.COMBAT }), 'combat');
|
||||
assert.equal(bgmKeyFor('game', { phase: PHASE.UNITE }), 'combat');
|
||||
// 联防 has its own track: the official escaped_single / escaped_multi levels declare bgmEvent = corrosion
|
||||
assert.equal(bgmKeyFor('game', { phase: PHASE.UNITE }), 'unite');
|
||||
assert.equal(bgmKeyFor('game', { phase: PHASE.FINAL_ASSAULT, bossId: 'boss_4' }), 'boss:boss_4');
|
||||
assert.equal(bgmKeyFor('game', { phase: PHASE.FINAL_ASSAULT }), 'boss');
|
||||
assert.equal(bgmKeyFor('game', { phase: PHASE.HIDDEN_CORE, bossId: 'boss_1', hiddenBossId: 'boss_9' }), 'boss:boss_9');
|
||||
@@ -44,6 +45,14 @@ describe('bgm selection', () => {
|
||||
assert.equal(b4.loop, manifest.audio.bossBgm.boss_4.loop);
|
||||
assert.equal(resolveBgm(manifest, 'boss:nope').loop, manifest.audio.bgm.boss.loop);
|
||||
assert.equal(resolveBgm(manifest, 'prep').intro, manifest.audio.bgm.prep.intro ?? null);
|
||||
// 联防's own track (bgm.unite = corrosion, the official escaped levels' bgmEvent), and the fallback for an older
|
||||
// manifest that has no `unite` entry (the music must not go silent)
|
||||
const unite = manifest.audio.bgm.unite;
|
||||
assert.ok(unite && typeof unite.loop === 'string', 'the manifest carries 联防’s own track');
|
||||
assert.equal(resolveBgm(manifest, 'unite').loop, unite.loop);
|
||||
assert.equal(resolveBgm(manifest, 'unite').intro, unite.intro ?? null);
|
||||
assert.notEqual(unite.loop, manifest.audio.bgm.combat.loop, 'not the shop / default combat loop');
|
||||
assert.equal(resolveBgm({ audio: { bgm: { combat: { loop: '/shop.mp3' } } } }, 'unite').loop, '/shop.mp3');
|
||||
assert.equal(resolveBgm(null, 'lobby'), null);
|
||||
assert.equal(resolveBgm(manifest, null), null);
|
||||
assert.equal(resolveBgm(manifest, 'nope'), null);
|
||||
|
||||
@@ -18,7 +18,17 @@ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
|
||||
const OUT = path.join(ROOT, 'test/e2e/out');
|
||||
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
||||
const ENABLED = process.env.SP_E2E === '1' && existsSync(CHROME);
|
||||
const HAS_ART = existsSync(path.join(ROOT, 'public/assets/local/emoticon/fooldoctor'));
|
||||
// The official pictures reach the wheel through a two-link chain (data.js artUrls): the local-client extraction
|
||||
// first (tools/local-extract → data/local-assets.json), then the copies tools/fetch-assets.mjs downloads into
|
||||
// public/assets/ui/emoticon (GitHub #42). A deployment has one link or the other; the assertions below check that
|
||||
// the pictures LOAD, never which link served them, so either one is enough to run the suite.
|
||||
const HAS_ART = existsSync(path.join(ROOT, 'public/assets/local/emoticon/fooldoctor'))
|
||||
|| existsSync(path.join(ROOT, 'public/assets/ui/emoticon/fooldoctor'));
|
||||
// The WHEEL's own chrome is a different story: emoji_cell_bkg / emoji_bkg / emoji_btn come from the local client's
|
||||
// ui/battle atlas (ui/emotes.js emoteUiSprite → localAsset), which tools/fetch-assets.mjs does NOT carry — no dump,
|
||||
// no official cell, and the wheel draws its own. One assertion below is about that official chrome, so it asks for it
|
||||
// separately instead of quietly failing on a fetch-assets-only deployment.
|
||||
const HAS_UI = existsSync(path.join(ROOT, 'public/assets/local/ui/battle/emoji_cell_bkg.png'));
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
describe('official emotes in the browser', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome) to run' }, () => {
|
||||
@@ -77,7 +87,7 @@ describe('official emotes in the browser', { skip: !ENABLED && 'set SP_E2E=1 (an
|
||||
};
|
||||
}, root);
|
||||
|
||||
test('UI kit: pager per theme, picture-only, swipe / keys / dots, cooldown, remembered theme, 3 s bubble', { skip: !HAS_ART && 'no extracted emote art' }, async () => {
|
||||
test('UI kit: pager per theme, picture-only, swipe / keys / dots, cooldown, remembered theme, 3 s bubble', { skip: !HAS_ART && 'no official emote art (neither the local extraction nor the fetched copies)' }, async () => {
|
||||
const { page, problems } = await open('/dev/uikit.html');
|
||||
await page.evaluate(() => localStorage.removeItem('sp.pref.emoteTheme'));
|
||||
await page.reload({ waitUntil: 'networkidle0' });
|
||||
@@ -160,7 +170,7 @@ describe('official emotes in the browser', { skip: !ENABLED && 'set SP_E2E=1 (an
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('wheel: one trackpad swipe (momentum included) turns one page, a mouse-wheel notch turns one page', { skip: !HAS_ART && 'no extracted emote art' }, async () => {
|
||||
test('wheel: one trackpad swipe (momentum included) turns one page, a mouse-wheel notch turns one page', { skip: !HAS_ART && 'no official emote art (neither the local extraction nor the fetched copies)' }, async () => {
|
||||
const { page, problems } = await open('/dev/uikit.html');
|
||||
await page.evaluate(() => localStorage.removeItem('sp.pref.emoteTheme'));
|
||||
await page.reload({ waitUntil: 'networkidle0' });
|
||||
@@ -196,12 +206,19 @@ describe('official emotes in the browser', { skip: !ENABLED && 'set SP_E2E=1 (an
|
||||
const hover = await page.$eval('#emo-stage .ewheel__item:nth-child(2)', (el) => ({
|
||||
sprite: el.closest('.ewheel__panel').classList.contains('has-cell'), bg: getComputedStyle(el).backgroundColor, filter: getComputedStyle(el).filter,
|
||||
}));
|
||||
assert.deepEqual(hover, { sprite: true, bg: 'rgba(0, 0, 0, 0)', filter: 'brightness(1.35)' });
|
||||
if (HAS_UI) {
|
||||
assert.deepEqual(hover, { sprite: true, bg: 'rgba(0, 0, 0, 0)', filter: 'brightness(1.35)' }, 'the official emoji_cell_bkg cell');
|
||||
} else {
|
||||
// no local client dump: the wheel draws its own cell and the hover is a drawn fill — the pictures are what this
|
||||
// test is about on such a deployment, so only pin that it is NOT pretending to be the official sprite cell
|
||||
assert.equal(hover.sprite, false, 'without ui/battle/emoji_cell_bkg the cell is drawn, not the official sprite');
|
||||
assert.ok(hover.bg !== '' && hover.filter !== '', `the drawn cell is still styled: ${JSON.stringify(hover)}`);
|
||||
}
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('the cooldown survives a remount of the wheel; a bubble mounted late resumes its timeline', { skip: !HAS_ART && 'no extracted emote art' }, async () => {
|
||||
test('the cooldown survives a remount of the wheel; a bubble mounted late resumes its timeline', { skip: !HAS_ART && 'no official emote art (neither the local extraction nor the fetched copies)' }, async () => {
|
||||
const { page, problems } = await open('/dev/uikit.html');
|
||||
await page.waitForSelector('#emo-stage .ewheel__panel');
|
||||
await page.evaluate(async () => {
|
||||
@@ -251,7 +268,7 @@ describe('official emotes in the browser', { skip: !ENABLED && 'set SP_E2E=1 (an
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('touch: a horizontal swipe on the pager turns the page, a tap sends', { skip: !HAS_ART && 'no extracted emote art' }, async () => {
|
||||
test('touch: a horizontal swipe on the pager turns the page, a tap sends', { skip: !HAS_ART && 'no official emote art (neither the local extraction nor the fetched copies)' }, async () => {
|
||||
const { page, problems } = await open('/dev/uikit.html');
|
||||
await page.setViewport({ width: 1280, height: 720, hasTouch: true, isMobile: false });
|
||||
await page.evaluate(() => localStorage.removeItem('sp.pref.emoteTheme'));
|
||||
@@ -302,7 +319,7 @@ describe('official emotes in the browser', { skip: !ENABLED && 'set SP_E2E=1 (an
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('in-match mock: bubbles beside the senders\' avatars, wheel above 交流', { skip: !HAS_ART && 'no extracted emote art' }, async () => {
|
||||
test('in-match mock: bubbles beside the senders\' avatars, wheel above 交流', { skip: !HAS_ART && 'no official emote art (neither the local extraction nor the fetched copies)' }, async () => {
|
||||
const { page, problems } = await open('/dev/game-mock.html?shot=1&render=fallback&phase=PREP&variant=emote', { w: 1920, h: 1080 });
|
||||
await page.waitForFunction(() => !!document.querySelector('.screen:not(.gload)'), { timeout: 15000 });
|
||||
await page.waitForSelector('.team__bubble');
|
||||
|
||||
@@ -494,6 +494,20 @@ describe('keyboard & settings', () => {
|
||||
assert.equal(shortcutFor({ key: 'r', code: 'KeyR' }), 'refresh');
|
||||
assert.equal(shortcutFor({ key: 'F', code: 'KeyF' }), 'freeze');
|
||||
assert.equal(shortcutFor({ key: 'd' }), 'levelUp');
|
||||
assert.equal(shortcutFor({ key: 'q', code: 'KeyQ' }), 'retreat');
|
||||
assert.equal(shortcutFor({ key: 'Q' }), 'retreat');
|
||||
assert.equal(shortcutFor({ code: 'KeyX' }), 'sell');
|
||||
assert.equal(shortcutFor({ key: 'X' }), 'sell');
|
||||
for (const key of ['q', 'x']) {
|
||||
assert.equal(shortcutFor({ key, repeat: true }), null);
|
||||
assert.equal(shortcutFor({ key, ctrlKey: true }), null);
|
||||
assert.equal(shortcutFor({ key, metaKey: true }), null);
|
||||
assert.equal(shortcutFor({ key, altKey: true }), null);
|
||||
for (const tagName of ['INPUT', 'TEXTAREA', 'SELECT']) {
|
||||
assert.equal(shortcutFor({ key, target: { tagName } }), null);
|
||||
}
|
||||
assert.equal(shortcutFor({ key, target: { isContentEditable: true } }), null);
|
||||
}
|
||||
assert.equal(shortcutFor({ key: ' ', code: 'Space' }), 'ready');
|
||||
assert.equal(shortcutFor({ key: 'Escape' }), 'escape');
|
||||
assert.equal(shortcutFor({ key: 'r', ctrlKey: true }), null);
|
||||
@@ -503,7 +517,7 @@ describe('keyboard & settings', () => {
|
||||
assert.equal(shortcutFor({ key: ' ', code: 'Space', target: { tagName: 'BUTTON' } }), 'ready', 'space readies even with a HUD button focused');
|
||||
assert.equal(shortcutFor({ key: ' ', code: 'Space', target: { tagName: 'TEXTAREA' } }), null);
|
||||
assert.equal(shortcutFor({ key: 'd', target: { tagName: 'DIV', isContentEditable: true } }), null);
|
||||
assert.equal(shortcutFor({ key: 'x' }), null);
|
||||
assert.equal(shortcutFor({ key: 'z' }), null);
|
||||
assert.equal(shortcutFor(null), null);
|
||||
});
|
||||
test('sanitizeSettings', () => {
|
||||
|
||||
@@ -149,6 +149,48 @@ describe('in-match UI (mock harness, headless Chrome)', { skip: !ENABLED && 'set
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('prep: Q retreats and X sells only the selected operator', async () => {
|
||||
const { page, problems } = await open('phase=PREP', { render: 'fallback' });
|
||||
const initial = await mockState(page);
|
||||
await page.keyboard.press('KeyQ');
|
||||
await page.keyboard.press('KeyX');
|
||||
assert.deepEqual(await mockState(page), initial, 'no selection means no action');
|
||||
const unit = initial.board.find((piece) => piece.kind === 'chess' && !piece.golden);
|
||||
await page.click(`.ff-piece[data-uid="${unit.uid}"]`);
|
||||
await page.waitForSelector('.uframe__btn--retreat');
|
||||
await page.evaluate(() => {
|
||||
const input = document.createElement('input');
|
||||
input.id = 'shortcut-test-input';
|
||||
document.body.append(input);
|
||||
input.focus();
|
||||
});
|
||||
await page.keyboard.press('KeyQ');
|
||||
await page.keyboard.press('KeyX');
|
||||
assert.deepEqual(await mockState(page), initial, 'typing must not retreat or sell');
|
||||
await page.evaluate(() => document.getElementById('shortcut-test-input').remove());
|
||||
await page.keyboard.press('KeyQ');
|
||||
await page.waitForFunction((uid) => globalThis.__MOCK__.S().priv.hand.some((piece) => piece?.uid === uid), {}, unit.uid);
|
||||
const retreated = await mockState(page);
|
||||
assert.ok(!retreated.board.some((piece) => piece.uid === unit.uid));
|
||||
assert.equal(retreated.funds, initial.funds, 'retreat does not sell');
|
||||
await page.click(`.ff-piece[data-uid="${unit.uid}"]`);
|
||||
await page.waitForSelector('.uframe__btn--sell');
|
||||
assert.equal(await page.$('.uframe__btn--retreat'), null, 'bench operator cannot retreat');
|
||||
await page.keyboard.press('KeyQ');
|
||||
assert.deepEqual(await mockState(page), retreated, 'Q on the bench does nothing');
|
||||
await page.keyboard.press('KeyX');
|
||||
await page.waitForFunction((uid) => !globalThis.__MOCK__.S().priv.hand.some((piece) => piece?.uid === uid), {}, unit.uid);
|
||||
assert.ok((await mockState(page)).funds > retreated.funds, 'X sells the selected operator');
|
||||
const item = (await mockState(page)).hand.find((piece) => piece?.kind === 'item');
|
||||
await page.click(`.ff-piece[data-uid="${item.uid}"]`);
|
||||
await page.waitForSelector('.uframe__btn--destroy');
|
||||
await page.keyboard.press('KeyX');
|
||||
assert.ok((await mockState(page)).hand.some((piece) => piece?.uid === item.uid), 'X must not destroy an item');
|
||||
assert.equal(await page.$('.modal'), null, 'no destroy confirmation');
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('prep: drag & drop — tap-sell (no sell zone), hand → board + wheel, item → unit (equip)', async () => {
|
||||
// DOM drag targets only exist in the fallback view (the Pixi engine's own drag is tested by the render module)
|
||||
const { page, problems } = await open('phase=PREP', { render: 'fallback' });
|
||||
|
||||
@@ -115,6 +115,8 @@ describe('8: the detail card never covers the selected unit\'s underframe', () =
|
||||
assert.doesNotMatch(css, /\.gm__bonds > \* \{ pointer-events: auto; \}/);
|
||||
const dev = readFileSync(new URL('../../public/css/devices.css', import.meta.url), 'utf8');
|
||||
assert.match(dev, /\.gm__hud > \.uframe \{ margin-left: calc\(-1 \* var\(--sa-l\)\); margin-top: calc\(-1 \* var\(--sa-t\)\); \}/);
|
||||
const panels = readFileSync(new URL('../../public/css/screens/game-panels.css', import.meta.url), 'utf8');
|
||||
assert.match(panels, /\.uframe__label\s*\{[^}]*white-space:\s*nowrap\s*;/, 'shortcut labels stay on one line');
|
||||
});
|
||||
|
||||
test('underframeRect covers the diamond and its buttons', () => {
|
||||
|
||||
@@ -35,7 +35,7 @@ describe('暂离 (AI 托管) is scoped to one match', () => {
|
||||
|
||||
describe('game shortcuts behind overlays', () => {
|
||||
test('the 本局信息 / 敌方情报 drawer swallows R / F / D / Space, Esc still closes it', () => {
|
||||
for (const act of ['refresh', 'freeze', 'levelUp', 'ready']) {
|
||||
for (const act of ['refresh', 'freeze', 'levelUp', 'retreat', 'sell', 'ready']) {
|
||||
assert.equal(shortcutBlocked(act, { drawer: true }), true, act);
|
||||
assert.equal(shortcutBlocked(act, {}), false, act);
|
||||
assert.equal(shortcutBlocked(act, { modal: true }), true, act);
|
||||
|
||||
@@ -492,11 +492,17 @@ export function buildPlan({ assets07, ops03, enemies05, maps05, audio, modelsDat
|
||||
}
|
||||
return node;
|
||||
};
|
||||
// 联防 BGM: the official 联防 levels (`escaped_single` / `escaped_multi`) declare `bgmEvent = corrosion` — a
|
||||
// 卡西米尔 act13d5d0 battle track — so the rescue phase does NOT reuse the 作战's track. Kept out of `bgm` when the
|
||||
// bank is missing (the client then falls back to `bgm.combat`; public/js/audio.js resolveBgm), so the manifest stays
|
||||
// valid for an older audio_data.
|
||||
const unite = flatBgm(bgmLeaf('battle.ON_GAME_READY.corrosion'));
|
||||
const bgm = {
|
||||
lobby: flatBgm(bgmLeaf('sys.ON_ACTIVITY_LOADED.act2autochess')),
|
||||
prep: flatBgm(bgmLeaf('battle.ON_GAME_READY.act1autochess_shop')),
|
||||
combat: flatBgm(bgmLeaf('battle.ON_GAME_READY.act1autochess_shop')),
|
||||
boss: flatBgm(bgmLeaf('battle.ON_GAME_READY.rglk1phantomcastle')),
|
||||
...(unite ? { unite } : {}),
|
||||
};
|
||||
const bossBgm = {};
|
||||
for (const lv of Object.values(maps05?.roundLevels || {})) {
|
||||
|
||||
Reference in new issue
Block a user