Playtest #4 fixes: tile picking, knocked-down state, element gauges, draft timer, live stats, shop items, drones, 赫默, 炎佑, UI flash
- #1 picking by the tile under the pointer (prep, battle, pen, equipment drops); the dragged model stays under the pointer (mouse and touch); pixel probe removed; phone ghost-click on the underframe fixed - #2 two-tap confirm on 机变 cards (like the shop) - #3 a 同盟 room with one human is untimed like 独立模拟 (no countdown outside battles) - #4 strategy draft: one synced countdown, 30 s per human turn, timeout takes the highlighted band - #5 special 维式重锤 (4) and 突变细胞 no longer sold; item card shows where they come from - #6 operator skill-mode sounds no longer play on normal attacks (纯烬艾雅法拉 and 55 others) - #7 detail card shows live battle stats and the prep-time battle-start stats (g.unitStats) - #8 official element gauges on units; burst cooldown (阻回) per PRTS - #9 knocked-down operators stay on their tile with a redeploy countdown ring - #10 drone behaviour per PRTS (暴鸰, 先兆者 shells, 黑云, 萨科塔之眼) - #11 赫默's drone only from her skill; placeable summons limited to talent tokens - #12 炎佑 stays where it is with no enemies; 祛恶之焰 as a continuous channel - #13 the occasional giant bond icon (loading texture scaled ×46) fixed - docs (DESIGN §18), recordings, data and tests updated (2467 unit tests, all browser suites green)
This commit is contained in:
1 parent
0aba602ecf
commit
d4f868cd19
107 files changed
+4510
-1653
No files matched your search
@@ -37,6 +37,11 @@ input, textarea, select { -webkit-user-select: text; user-select: text; }
|
||||
/* the selected unit's underframe (ui/underframe.js) is placed at the tile's CLIENT px (view.tileScreen) inside the HUD
|
||||
layer: step back over the HUD's safe-area offset so it stays on its unit on a notched phone */
|
||||
.gm__hud > .uframe { margin-left: calc(-1 * var(--sa-l)); margin-top: calc(-1 * var(--sa-t)); }
|
||||
/* the shop bar keeps its place at the bottom edge (it may reach into the home-indicator strip): raised by the bottom
|
||||
inset it covered ~3/4 of the bench pads 2–9 on a notched phone (844×390 with the iPhone insets: 23 % of those tiles
|
||||
left free, 71 % now — as without insets), and a bench piece is pressed / dropped on by its tile (user playtest #4
|
||||
item 1). Its right edge stays inside the inset (the notch / rounded corner). */
|
||||
.gm__hud > .shopbar { bottom: calc(.2rem - var(--sa-b)); }
|
||||
.gm > .spov { left: calc(2.8rem + var(--sa-l)); right: var(--sa-r); }
|
||||
/* every other screen is laid out inside the safe area (the app backdrop fills the notch strip) */
|
||||
.screen:not(.gm) { top: var(--sa-t); right: var(--sa-r); bottom: var(--sa-b); left: var(--sa-l); }
|
||||
|
||||
@@ -111,6 +111,19 @@
|
||||
.dstat__row { display: flex; align-items: baseline; min-width: 0; white-space: nowrap; }
|
||||
.dstat__v { font-size: .17rem; line-height: 1.15; color: #fff; }
|
||||
.dstat small { font-size: .11rem; color: var(--text-dim); margin-left: .04rem; }
|
||||
/* live stats (user playtest #4 item 7): buffed values green, debuffed red (the official card), the difference beside
|
||||
them; a 实时 (battle) / 开战时 (prep preview) tag over the grid's right edge */
|
||||
.dstats.is-live, .dstats-wrap.is-live { margin-top: .14rem; }
|
||||
.dstats.is-live, .dstats-wrap.is-live .dstats { position: relative; }
|
||||
.dstat.is-up .dstat__v { color: #6dfa9c; text-shadow: 0 0 .08rem rgba(80, 240, 140, .35); }
|
||||
.dstat.is-up small { color: #56e08a; }
|
||||
.dstat.is-down .dstat__v { color: var(--red-premium); text-shadow: 0 0 .08rem rgba(255, 84, 84, .3); }
|
||||
.dstat.is-down small { color: #ff8a80; }
|
||||
.dstats__tag {
|
||||
position: absolute; right: -1px; bottom: 100%; padding: .01rem .08rem; font-size: .11rem; font-weight: 700; letter-spacing: .1em;
|
||||
color: var(--text-on-mint); background: var(--mint-500); pointer-events: auto; cursor: help;
|
||||
}
|
||||
.dstats__tag.is-battle { background: var(--orange); color: #1d0c00; }
|
||||
.drange { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: .06rem; padding: .06rem .1rem; background: #0f1311; border: 1px solid var(--line); min-width: 1.1rem; }
|
||||
.rgrid { --rg: .14rem; display: grid; gap: 2px; }
|
||||
.rgrid i { width: var(--rg); height: var(--rg); border: 1px solid rgba(255, 255, 255, .06); }
|
||||
@@ -370,6 +383,23 @@
|
||||
font-size: .14rem; font-weight: 700; letter-spacing: .2em; color: var(--mint-400); background: rgba(8, 11, 10, .85);
|
||||
}
|
||||
.spcard__busy::after { content: ''; position: absolute; left: 0; bottom: 0; width: 40%; height: 2px; background: var(--mint-400); animation: btn-busy 1.1s var(--ease-in-out) infinite; }
|
||||
/* two-tap pick (user playtest #4 item 2 — the shop's confirm language, game-shop.css .scard.is-armed): the selected card
|
||||
lifts with a gold frame and a 确认选择 · 再次点击 strip; the others dim; the header shows 确认选择 */
|
||||
.spcard.is-armed, .spcard.is-armed:hover {
|
||||
z-index: 2; transform: translateY(-.1rem) scale(1.03); border-color: var(--gold);
|
||||
background: linear-gradient(180deg, rgba(255, 198, 0, .16), rgba(255, 198, 0, .04));
|
||||
box-shadow: 0 0 0 2px var(--gold), 0 .14rem .32rem rgba(0, 0, 0, .6), 0 0 .3rem rgba(255, 198, 0, .35);
|
||||
}
|
||||
.spov.has-armed .spcard.is-pickable:not(.is-armed) { filter: brightness(.7); }
|
||||
.spov.has-armed .spcard.is-pickable:not(.is-armed):hover { filter: none; }
|
||||
.spcard__confirm {
|
||||
position: absolute; left: 0; right: 0; bottom: 0; z-index: 3; display: flex; flex-direction: column; align-items: center;
|
||||
padding: .05rem 0 .05rem; background: linear-gradient(0deg, var(--gold), #e0a800); color: #1d1400; pointer-events: none;
|
||||
animation: confirm-in var(--t-fast) var(--ease-out);
|
||||
}
|
||||
.spcard__confirm b { font-size: .18rem; font-weight: 900; letter-spacing: .22em; padding-left: .22em; }
|
||||
.spcard__confirm small { font-size: .11rem; letter-spacing: .1em; opacity: .75; }
|
||||
.spov__confirm { animation: pulse-gold 1.2s var(--ease-in-out) infinite; }
|
||||
|
||||
/* Grid image boxes: definite 100% tracks so `height: 100%` images resolve against the box (an auto track would
|
||||
let non-square art overflow its frame). */
|
||||
|
||||
@@ -316,10 +316,9 @@
|
||||
font-size: .8em; font-weight: 700; color: var(--text-on-mint); background: var(--mint-500); border-radius: 1em;
|
||||
}
|
||||
|
||||
/* Untimed phases (user playtest #11: the official 独立模拟 shows no countdown outside combat — the server sends
|
||||
deadline 0 for them, Match.soloUntimed): ui/components.js Countdown renders its "无倒计时" placeholder ("--" digits +
|
||||
COUNTDOWN label), which reads as a timer — it is not shown at all. (Owner of components.js: returning null there
|
||||
for a missing deadline makes this rule a no-op.) */
|
||||
/* Untimed phases (user playtest #11 / #4 item 3: the official 独立模拟 shows no countdown outside combat — the server
|
||||
sends deadline 0 for them, Match.soloUntimed): ui/components.js Countdown now renders nothing without a deadline;
|
||||
this rule stays as a safety net for the old "无倒计时" placeholder ("--" digits + COUNTDOWN label). */
|
||||
.countdown[aria-label="无倒计时"] { display: none; }
|
||||
|
||||
/* the app screens behind the overlay do not need to animate */
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"version":1,"name":"boss-m02","title":"最终攻势 · 卢西恩,“猩红血钻” · R14","kind":"boss","stageId":"act2autochess_m02","modeId":"mode_multi_normal","round":14,"wave":"act2autochess_h07_05","tick":0.03333333333333333,"snapEvery":3,"duration":75,"recordedAt":"2026-09-27","field":{"fieldId":"b1","kind":"boss","rect":{"r0":0,"r1":5,"c0":0,"c1":20},"stageId":"act2autochess_m02","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_11_b","name":"塞雷娅","tier":5,"golden":true,"spine":"char_202_demkni","avatar":"char_202_demkni_2","x":9,"y":2,"facing":1,"dir":"RIGHT","maxHp":3003,"uid":1,"skillIndex":1,"moduleId":"uniequip_002_demkni"},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_17_b","name":"山","tier":5,"golden":true,"spine":"char_264_f12yin","avatar":"char_264_f12yin_2","x":8,"y":2,"facing":1,"dir":"RIGHT","maxHp":2738,"uid":2,"skillIndex":1,"moduleId":"uniequip_002_f12yin"},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_07_b","name":"史尔特尔","tier":5,"golden":true,"spine":"char_350_surtr","avatar":"char_350_surtr_2","x":7,"y":2,"facing":1,"dir":"RIGHT","maxHp":2680,"uid":3,"skillIndex":2,"moduleId":"uniequip_002_surtr"},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_12_b","name":"夕","tier":5,"golden":true,"spine":"char_2015_dusk","avatar":"char_2015_dusk_2","x":4,"y":3,"facing":1,"dir":"RIGHT","maxHp":1787,"uid":4,"skillIndex":0,"moduleId":"uniequip_002_dusk"},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_02_a","name":"缇缇","tier":5,"golden":false,"spine":"char_4056_titi","avatar":"char_4056_titi","x":5,"y":4,"facing":1,"dir":"RIGHT","maxHp":1208,"uid":5,"skillIndex":2},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_03_b","name":"烛煌","tier":5,"golden":true,"spine":"char_1040_blaze2","avatar":"char_1040_blaze2_2","x":6,"y":5,"facing":1,"dir":"RIGHT","maxHp":1472,"uid":6,"skillIndex":2,"moduleId":"uniequip_002_blaze2"},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_10_a","name":"铃兰","tier":5,"golden":false,"spine":"char_358_lisa","avatar":"char_358_lisa","x":7,"y":5,"facing":1,"dir":"RIGHT","maxHp":1184,"uid":7,"skillIndex":2},{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_6_05_a","name":"异客","tier":6,"golden":false,"spine":"char_472_pasngr","avatar":"char_472_pasngr","x":8,"y":5,"facing":1,"dir":"RIGHT","maxHp":1215,"uid":8,"skillIndex":2},{"id":9,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_03_b","name":"余","tier":6,"golden":true,"spine":"char_2026_yu","avatar":"char_2026_yu_2","x":11,"y":2,"facing":-1,"dir":"LEFT","maxHp":3430,"uid":101,"skillIndex":2,"moduleId":"uniequip_002_yu"},{"id":10,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_17_a","name":"耀骑士临光","tier":6,"golden":false,"spine":"char_1014_nearl2","avatar":"char_1014_nearl2","x":12,"y":2,"facing":-1,"dir":"LEFT","maxHp":2698,"uid":102,"skillIndex":2},{"id":11,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_19_b","name":"锏","tier":6,"golden":true,"spine":"char_4116_blkkgt","avatar":"char_4116_blkkgt_2","x":13,"y":2,"facing":-1,"dir":"LEFT","maxHp":2767,"uid":103,"skillIndex":2,"moduleId":"uniequip_002_blkkgt"},{"id":12,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_01_b","name":"蕾缪安","tier":6,"golden":true,"spine":"char_4193_lemuen","avatar":"char_4193_lemuen_2","x":16,"y":3,"facing":-1,"dir":"LEFT","maxHp":1470,"uid":104,"skillIndex":2,"moduleId":"uniequip_002_lemuen"},{"id":13,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_08_a","name":"焰影苇草","tier":6,"golden":false,"spine":"char_1020_reed2","avatar":"char_1020_reed2","x":15,"y":4,"facing":-1,"dir":"LEFT","maxHp":1361,"uid":105,"skillIndex":2},{"id":14,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_12_b","name":"迷迭香","tier":6,"golden":true,"spine":"char_391_rosmon","avatar":"char_391_rosmon_2","x":14,"y":5,"facing":-1,"dir":"LEFT","maxHp":2887,"uid":106,"skillIndex":1,"moduleId":"uniequip_002_rosmon"},{"id":15,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_14_a","name":"流明","tier":6,"golden":false,"spine":"char_4042_lumen","avatar":"char_4042_lumen","x":13,"y":5,"facing":-1,"dir":"LEFT","maxHp":1569,"uid":107,"skillIndex":2},{"id":16,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_20_a","name":"纯烬艾雅法拉","tier":6,"golden":false,"spine":"char_1016_agoat2","avatar":"char_1016_agoat2","x":12,"y":5,"facing":-1,"dir":"LEFT","maxHp":1237,"uid":108,"skillIndex":2},{"id":17,"kind":"enemy","side":"enemy","ownerId":"P1","defId":"enemy_2016_csphtm","name":"卢西恩,“猩红血钻”","tier":3,"golden":false,"spine":"enemy_2016_csphtm","avatar":"enemy_2016_csphtm","x":9.99,"y":4,"facing":1,"dir":"RIGHT","maxHp":195000,"boss":true}]},"frames":[{"snap":{"fieldId":"b1","t":0.1,"units":[[1,9,2,3003,Line truncated
|
||||
{"version":1,"name":"boss-m02","title":"最终攻势 · 卢西恩,“猩红血钻” · R14","kind":"boss","stageId":"act2autochess_m02","modeId":"mode_multi_normal","round":14,"wave":"act2autochess_h07_05","tick":0.03333333333333333,"snapEvery":3,"duration":75,"recordedAt":"2026-09-27","field":{"fieldId":"b1","kind":"boss","rect":{"r0":0,"r1":5,"c0":0,"c1":20},"stageId":"act2autochess_m02","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_11_b","name":"塞雷娅","tier":5,"golden":true,"spine":"char_202_demkni","avatar":"char_202_demkni_2","x":9,"y":2,"facing":1,"dir":"RIGHT","maxHp":3003,"uid":1,"skillIndex":1,"moduleId":"uniequip_002_demkni"},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_17_b","name":"山","tier":5,"golden":true,"spine":"char_264_f12yin","avatar":"char_264_f12yin_2","x":8,"y":2,"facing":1,"dir":"RIGHT","maxHp":2738,"uid":2,"skillIndex":1,"moduleId":"uniequip_002_f12yin"},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_07_b","name":"史尔特尔","tier":5,"golden":true,"spine":"char_350_surtr","avatar":"char_350_surtr_2","x":7,"y":2,"facing":1,"dir":"RIGHT","maxHp":2680,"uid":3,"skillIndex":2,"moduleId":"uniequip_002_surtr"},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_12_b","name":"夕","tier":5,"golden":true,"spine":"char_2015_dusk","avatar":"char_2015_dusk_2","x":4,"y":3,"facing":1,"dir":"RIGHT","maxHp":1787,"uid":4,"skillIndex":0,"moduleId":"uniequip_002_dusk"},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_02_a","name":"缇缇","tier":5,"golden":false,"spine":"char_4056_titi","avatar":"char_4056_titi","x":5,"y":4,"facing":1,"dir":"RIGHT","maxHp":1208,"uid":5,"skillIndex":2},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_03_b","name":"烛煌","tier":5,"golden":true,"spine":"char_1040_blaze2","avatar":"char_1040_blaze2_2","x":6,"y":5,"facing":1,"dir":"RIGHT","maxHp":1472,"uid":6,"skillIndex":2,"moduleId":"uniequip_002_blaze2"},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_10_a","name":"铃兰","tier":5,"golden":false,"spine":"char_358_lisa","avatar":"char_358_lisa","x":7,"y":5,"facing":1,"dir":"RIGHT","maxHp":1184,"uid":7,"skillIndex":2},{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_6_05_a","name":"异客","tier":6,"golden":false,"spine":"char_472_pasngr","avatar":"char_472_pasngr","x":8,"y":5,"facing":1,"dir":"RIGHT","maxHp":1215,"uid":8,"skillIndex":2},{"id":9,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_03_b","name":"余","tier":6,"golden":true,"spine":"char_2026_yu","avatar":"char_2026_yu_2","x":11,"y":2,"facing":-1,"dir":"LEFT","maxHp":3430,"uid":101,"skillIndex":2,"moduleId":"uniequip_002_yu"},{"id":10,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_17_a","name":"耀骑士临光","tier":6,"golden":false,"spine":"char_1014_nearl2","avatar":"char_1014_nearl2","x":12,"y":2,"facing":-1,"dir":"LEFT","maxHp":2698,"uid":102,"skillIndex":2},{"id":11,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_19_b","name":"锏","tier":6,"golden":true,"spine":"char_4116_blkkgt","avatar":"char_4116_blkkgt_2","x":13,"y":2,"facing":-1,"dir":"LEFT","maxHp":2767,"uid":103,"skillIndex":2,"moduleId":"uniequip_002_blkkgt"},{"id":12,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_01_b","name":"蕾缪安","tier":6,"golden":true,"spine":"char_4193_lemuen","avatar":"char_4193_lemuen_2","x":16,"y":3,"facing":-1,"dir":"LEFT","maxHp":1470,"uid":104,"skillIndex":2,"moduleId":"uniequip_002_lemuen"},{"id":13,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_08_a","name":"焰影苇草","tier":6,"golden":false,"spine":"char_1020_reed2","avatar":"char_1020_reed2","x":15,"y":4,"facing":-1,"dir":"LEFT","maxHp":1361,"uid":105,"skillIndex":2},{"id":14,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_12_b","name":"迷迭香","tier":6,"golden":true,"spine":"char_391_rosmon","avatar":"char_391_rosmon_2","x":14,"y":5,"facing":-1,"dir":"LEFT","maxHp":2887,"uid":106,"skillIndex":1,"moduleId":"uniequip_002_rosmon"},{"id":15,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_14_a","name":"流明","tier":6,"golden":false,"spine":"char_4042_lumen","avatar":"char_4042_lumen","x":13,"y":5,"facing":-1,"dir":"LEFT","maxHp":1569,"uid":107,"skillIndex":2},{"id":16,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_20_a","name":"纯烬艾雅法拉","tier":6,"golden":false,"spine":"char_1016_agoat2","avatar":"char_1016_agoat2","x":12,"y":5,"facing":-1,"dir":"LEFT","maxHp":1237,"uid":108,"skillIndex":2},{"id":17,"kind":"enemy","side":"enemy","ownerId":"P1","defId":"enemy_2016_csphtm","name":"卢西恩,“猩红血钻”","tier":3,"golden":false,"spine":"enemy_2016_csphtm","avatar":"enemy_2016_csphtm","x":9.99,"y":4,"facing":1,"dir":"RIGHT","maxHp":195000,"boss":true}]},"frames":[{"snap":{"fieldId":"b1","t":0.1,"units":[[1,9,2,3003,Line truncated
|
||||
@@ -8,10 +8,10 @@
|
||||
"frames": 550,
|
||||
"maxUnits": 30,
|
||||
"file": "normal-m01.json",
|
||||
"bytes": 417250,
|
||||
"bytes": 433457,
|
||||
"result": {
|
||||
"reason": "timeout",
|
||||
"killed": 14,
|
||||
"killed": 12,
|
||||
"total": 32
|
||||
}
|
||||
},
|
||||
@@ -22,12 +22,12 @@
|
||||
"stageId": "act2autochess_m02",
|
||||
"duration": 85,
|
||||
"frames": 850,
|
||||
"maxUnits": 24,
|
||||
"maxUnits": 26,
|
||||
"file": "normal-m02.json",
|
||||
"bytes": 558479,
|
||||
"bytes": 618614,
|
||||
"result": {
|
||||
"reason": "timeout",
|
||||
"killed": 15,
|
||||
"killed": 16,
|
||||
"total": 35
|
||||
}
|
||||
},
|
||||
@@ -38,12 +38,12 @@
|
||||
"stageId": "act2autochess_m03",
|
||||
"duration": 90,
|
||||
"frames": 900,
|
||||
"maxUnits": 31,
|
||||
"maxUnits": 33,
|
||||
"file": "normal-m03.json",
|
||||
"bytes": 720825,
|
||||
"bytes": 791453,
|
||||
"result": {
|
||||
"reason": "timeout",
|
||||
"killed": 11,
|
||||
"killed": 15,
|
||||
"total": 46
|
||||
}
|
||||
},
|
||||
@@ -54,12 +54,12 @@
|
||||
"stageId": "act2autochess_m04",
|
||||
"duration": 70,
|
||||
"frames": 700,
|
||||
"maxUnits": 33,
|
||||
"maxUnits": 35,
|
||||
"file": "normal-m04.json",
|
||||
"bytes": 560135,
|
||||
"bytes": 597582,
|
||||
"result": {
|
||||
"reason": "timeout",
|
||||
"killed": 18,
|
||||
"killed": 23,
|
||||
"total": 39
|
||||
}
|
||||
},
|
||||
@@ -72,7 +72,7 @@
|
||||
"frames": 843,
|
||||
"maxUnits": 30,
|
||||
"file": "late-m03-r12.json",
|
||||
"bytes": 571183,
|
||||
"bytes": 638858,
|
||||
"result": {
|
||||
"reason": "cleared",
|
||||
"killed": 12,
|
||||
@@ -88,7 +88,7 @@
|
||||
"frames": 1100,
|
||||
"maxUnits": 33,
|
||||
"file": "late-m01-r13.json",
|
||||
"bytes": 849895,
|
||||
"bytes": 904761,
|
||||
"result": {
|
||||
"reason": "timeout",
|
||||
"killed": 6,
|
||||
@@ -104,7 +104,7 @@
|
||||
"frames": 600,
|
||||
"maxUnits": 36,
|
||||
"file": "unite-m01.json",
|
||||
"bytes": 620002,
|
||||
"bytes": 636264,
|
||||
"result": {
|
||||
"reason": "timeout",
|
||||
"killed": 17,
|
||||
@@ -120,7 +120,7 @@
|
||||
"frames": 750,
|
||||
"maxUnits": 24,
|
||||
"file": "boss-m02.json",
|
||||
"bytes": 591355,
|
||||
"bytes": 714082,
|
||||
"result": {
|
||||
"reason": "forced",
|
||||
"killed": 12,
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"version":1,"name":"late-m01-r13","title":"常规作战 · 源石流发生装置 · R13(官方波次)","kind":"normal","stageId":"act2autochess_m01","modeId":"mode_multi_hard","round":13,"wave":"act1autochess_h06","tick":0.03333333333333333,"snapEvery":3,"duration":110,"recordedAt":"2026-09-27","field":{"fieldId":"n:P1","kind":"normal","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"stageId":"act2autochess_m01","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_17_b","name":"星熊","tier":4,"golden":true,"spine":"char_136_hsguma","avatar":"char_136_hsguma_2","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":3513,"uid":1,"skillIndex":1,"moduleId":"uniequip_002_hsguma"},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_22_b","name":"银灰","tier":4,"golden":true,"spine":"char_172_svrash","avatar":"char_172_svrash_2","x":8,"y":9,"facing":1,"dir":"RIGHT","maxHp":2569,"uid":2,"skillIndex":2,"moduleId":"uniequip_002_svrash"},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_05_b","name":"斯卡蒂","tier":3,"golden":true,"spine":"char_263_skadi","avatar":"char_263_skadi_2","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":3853,"uid":3,"skillIndex":2,"moduleId":"uniequip_002_skadi"},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_02_b","name":"莫斯提马","tier":4,"golden":true,"spine":"char_213_mostma","avatar":"char_213_mostma_2","x":4,"y":10,"facing":1,"dir":"RIGHT","maxHp":1695,"uid":4,"skillIndex":2,"moduleId":"uniequip_002_mostma"},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_21_a","name":"白面鸮","tier":4,"golden":false,"spine":"char_128_plosis","avatar":"char_128_plosis","x":4,"y":11,"facing":1,"dir":"RIGHT","maxHp":1271,"uid":5,"skillIndex":1},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_06_b","name":"寒芒克洛丝","tier":4,"golden":true,"spine":"char_1021_kroos2","avatar":"char_1021_kroos2_2","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1561,"uid":6,"skillIndex":1,"moduleId":"uniequip_002_kroos2"},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_10_a","name":"阿罗玛","tier":4,"golden":false,"spine":"char_446_aroma","avatar":"char_446_aroma","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1288,"uid":7,"skillIndex":1},{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_20_b","name":"远牙","tier":4,"golden":true,"spine":"char_430_fartth","avatar":"char_430_fartth_2","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1419,"uid":8,"skillIndex":2,"moduleId":"uniequip_002_fartth"}]},"preview":[{"enemyKey":"enemy_1254_lypa_2","count":5,"gate":"lower","t":2,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"upper","t":3,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":7,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":1,"gate":"upper","t":7,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":10,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1254_lypa_2","count":5,"gate":"lower","t":10,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"lower","t":11,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1254_lypa_2","count":3,"gate":"upper","t":21,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1254_lypa_2","count":5,"gate":"lower","t":32,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"lower","t":37,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":39,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"lower","t":55,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":62,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null}],"frames":[{"snap":{"fieldId":"n:P1","t":0.1,"units":[[1,9,9,3513,3513,0,0,0,6],[2,8,9,2569,2569,60.1,90,0,6],[3,7,9,3853,3853,60.1,90,0,6],[4,4,10,1695,1695,62.1,110,0,6],[5,4,11,1271,1271,70.1,100,0,6],[6,4,12,1561,1561,20.1,40,0,6],[7,5,12,1288,1288,20.1,55,0,6],[8,6,12,1419,1419,7.1,24,0,6]],"dp":10,"killed":0,"total":55},"ev":[["spawn",{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_06_b","name":"寒芒克洛丝","tier":4,"golden":true,"spine":"char_1021_kroos2","avatar":"char_1021_kroos2_2","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1561,"uid":6,"skillIndex":1,"moduleId":"uniequip_002_kroos2"}],["deploy",6],["spawn",{"id":7,"kind":"op","side":"Line truncated
|
||||
{"version":1,"name":"late-m01-r13","title":"常规作战 · 源石流发生装置 · R13(官方波次)","kind":"normal","stageId":"act2autochess_m01","modeId":"mode_multi_hard","round":13,"wave":"act1autochess_h06","tick":0.03333333333333333,"snapEvery":3,"duration":110,"recordedAt":"2026-09-27","field":{"fieldId":"n:P1","kind":"normal","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"stageId":"act2autochess_m01","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_17_b","name":"星熊","tier":4,"golden":true,"spine":"char_136_hsguma","avatar":"char_136_hsguma_2","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":3513,"uid":1,"skillIndex":1,"moduleId":"uniequip_002_hsguma"},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_22_b","name":"银灰","tier":4,"golden":true,"spine":"char_172_svrash","avatar":"char_172_svrash_2","x":8,"y":9,"facing":1,"dir":"RIGHT","maxHp":2569,"uid":2,"skillIndex":2,"moduleId":"uniequip_002_svrash"},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_05_b","name":"斯卡蒂","tier":3,"golden":true,"spine":"char_263_skadi","avatar":"char_263_skadi_2","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":3853,"uid":3,"skillIndex":2,"moduleId":"uniequip_002_skadi"},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_02_b","name":"莫斯提马","tier":4,"golden":true,"spine":"char_213_mostma","avatar":"char_213_mostma_2","x":4,"y":10,"facing":1,"dir":"RIGHT","maxHp":1695,"uid":4,"skillIndex":2,"moduleId":"uniequip_002_mostma"},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_21_a","name":"白面鸮","tier":4,"golden":false,"spine":"char_128_plosis","avatar":"char_128_plosis","x":4,"y":11,"facing":1,"dir":"RIGHT","maxHp":1271,"uid":5,"skillIndex":1},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_06_b","name":"寒芒克洛丝","tier":4,"golden":true,"spine":"char_1021_kroos2","avatar":"char_1021_kroos2_2","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1561,"uid":6,"skillIndex":1,"moduleId":"uniequip_002_kroos2"},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_10_a","name":"阿罗玛","tier":4,"golden":false,"spine":"char_446_aroma","avatar":"char_446_aroma","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1288,"uid":7,"skillIndex":1},{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_20_b","name":"远牙","tier":4,"golden":true,"spine":"char_430_fartth","avatar":"char_430_fartth_2","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1419,"uid":8,"skillIndex":2,"moduleId":"uniequip_002_fartth"}]},"preview":[{"enemyKey":"enemy_1254_lypa_2","count":5,"gate":"lower","t":2,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"upper","t":3,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":7,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":1,"gate":"upper","t":7,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":10,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1254_lypa_2","count":5,"gate":"lower","t":10,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"lower","t":11,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1254_lypa_2","count":3,"gate":"upper","t":21,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1254_lypa_2","count":5,"gate":"lower","t":32,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"lower","t":37,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":39,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"lower","t":55,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":62,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null}],"frames":[{"snap":{"fieldId":"n:P1","t":0.1,"units":[[1,9,9,3513,3513,0,0,0,6],[2,8,9,2569,2569,60.1,90,0,6],[3,7,9,3853,3853,60.1,90,0,6],[4,4,10,1695,1695,62.1,110,0,6],[5,4,11,1271,1271,70.1,100,0,6],[6,4,12,1561,1561,20.1,40,0,6],[7,5,12,1288,1288,20.1,55,0,6],[8,6,12,1419,1419,7.1,24,0,6]],"dp":10,"killed":0,"total":55},"ev":[["spawn",{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_06_b","name":"寒芒克洛丝","tier":4,"golden":true,"spine":"char_1021_kroos2","avatar":"char_1021_kroos2_2","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1561,"uid":6,"skillIndex":1,"moduleId":"uniequip_002_kroos2"}],["deploy",6],["spawn",{"id":7,"kind":"op","side":"Line truncated
|
||||
@@ -1 +1 @@
|
||||
{"version":1,"name":"late-m03-r12","title":"常规作战 · 排气格栅 · R12(官方波次)","kind":"normal","stageId":"act2autochess_m03","modeId":"mode_multi_hard","round":12,"wave":"act1autochess_h05","tick":0.03333333333333333,"snapEvery":3,"duration":84.23,"recordedAt":"2026-09-27","field":{"fieldId":"n:P1","kind":"normal","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"stageId":"act2autochess_m03","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_11_b","name":"塞雷娅","tier":5,"golden":true,"spine":"char_202_demkni","avatar":"char_202_demkni_2","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":3003,"uid":1,"skillIndex":1,"moduleId":"uniequip_002_demkni"},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_17_b","name":"星熊","tier":4,"golden":true,"spine":"char_136_hsguma","avatar":"char_136_hsguma_2","x":6,"y":9,"facing":1,"dir":"RIGHT","maxHp":3513,"uid":2,"skillIndex":1,"moduleId":"uniequip_002_hsguma"},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_17_b","name":"山","tier":5,"golden":true,"spine":"char_264_f12yin","avatar":"char_264_f12yin_2","x":5,"y":9,"facing":1,"dir":"RIGHT","maxHp":2738,"uid":3,"skillIndex":1,"moduleId":"uniequip_002_f12yin"},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_07_b","name":"史尔特尔","tier":5,"golden":true,"spine":"char_350_surtr","avatar":"char_350_surtr_2","x":4,"y":9,"facing":1,"dir":"RIGHT","maxHp":2680,"uid":4,"skillIndex":2,"moduleId":"uniequip_002_surtr"},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_12_b","name":"夕","tier":5,"golden":true,"spine":"char_2015_dusk","avatar":"char_2015_dusk_2","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1787,"uid":5,"skillIndex":0,"moduleId":"uniequip_002_dusk"},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_02_b","name":"莫斯提马","tier":4,"golden":true,"spine":"char_213_mostma","avatar":"char_213_mostma_2","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1695,"uid":6,"skillIndex":2,"moduleId":"uniequip_002_mostma"},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_10_b","name":"铃兰","tier":5,"golden":true,"spine":"char_358_lisa","avatar":"char_358_lisa_2","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1480,"uid":7,"skillIndex":2,"moduleId":"uniequip_002_lisa"},{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_08_b","name":"号角","tier":5,"golden":true,"spine":"char_4039_horn","avatar":"char_4039_horn_2","x":3,"y":9,"facing":1,"dir":"RIGHT","maxHp":2775,"uid":8,"skillIndex":2,"moduleId":"uniequip_002_horn"},{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":3,"y":12,"facing":1,"dir":"RIGHT","maxHp":100},{"id":10,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":3,"y":11,"facing":1,"dir":"RIGHT","maxHp":100},{"id":11,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"dir":"RIGHT","maxHp":100}]},"preview":[{"enemyKey":"enemy_1254_lypa_2","count":5,"gate":"lower","t":2,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":5,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":3,"gate":"upper","t":5,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":3,"gate":"lower","t":10,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1254_lypa_2","count":5,"gate":"lower","t":10,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"upper","t":10,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"lower","t":12,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1254_lypa_2","count":3,"gate":"upper","t":21,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":3,"gate":"lower","t":30,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1254_lypa_2","count":3,"gate":"lower","t":31,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":35,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"lower","t":37,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":51,"fly":false,"eLine truncated
|
||||
{"version":1,"name":"late-m03-r12","title":"常规作战 · 排气格栅 · R12(官方波次)","kind":"normal","stageId":"act2autochess_m03","modeId":"mode_multi_hard","round":12,"wave":"act1autochess_h05","tick":0.03333333333333333,"snapEvery":3,"duration":84.23,"recordedAt":"2026-09-27","field":{"fieldId":"n:P1","kind":"normal","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"stageId":"act2autochess_m03","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_11_b","name":"塞雷娅","tier":5,"golden":true,"spine":"char_202_demkni","avatar":"char_202_demkni_2","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":3003,"uid":1,"skillIndex":1,"moduleId":"uniequip_002_demkni"},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_17_b","name":"星熊","tier":4,"golden":true,"spine":"char_136_hsguma","avatar":"char_136_hsguma_2","x":6,"y":9,"facing":1,"dir":"RIGHT","maxHp":3513,"uid":2,"skillIndex":1,"moduleId":"uniequip_002_hsguma"},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_17_b","name":"山","tier":5,"golden":true,"spine":"char_264_f12yin","avatar":"char_264_f12yin_2","x":5,"y":9,"facing":1,"dir":"RIGHT","maxHp":2738,"uid":3,"skillIndex":1,"moduleId":"uniequip_002_f12yin"},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_07_b","name":"史尔特尔","tier":5,"golden":true,"spine":"char_350_surtr","avatar":"char_350_surtr_2","x":4,"y":9,"facing":1,"dir":"RIGHT","maxHp":2680,"uid":4,"skillIndex":2,"moduleId":"uniequip_002_surtr"},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_12_b","name":"夕","tier":5,"golden":true,"spine":"char_2015_dusk","avatar":"char_2015_dusk_2","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1787,"uid":5,"skillIndex":0,"moduleId":"uniequip_002_dusk"},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_02_b","name":"莫斯提马","tier":4,"golden":true,"spine":"char_213_mostma","avatar":"char_213_mostma_2","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1695,"uid":6,"skillIndex":2,"moduleId":"uniequip_002_mostma"},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_10_b","name":"铃兰","tier":5,"golden":true,"spine":"char_358_lisa","avatar":"char_358_lisa_2","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1480,"uid":7,"skillIndex":2,"moduleId":"uniequip_002_lisa"},{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_08_b","name":"号角","tier":5,"golden":true,"spine":"char_4039_horn","avatar":"char_4039_horn_2","x":3,"y":9,"facing":1,"dir":"RIGHT","maxHp":2775,"uid":8,"skillIndex":2,"moduleId":"uniequip_002_horn"},{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":3,"y":12,"facing":1,"dir":"RIGHT","maxHp":100},{"id":10,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":3,"y":11,"facing":1,"dir":"RIGHT","maxHp":100},{"id":11,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"dir":"RIGHT","maxHp":100}]},"preview":[{"enemyKey":"enemy_1254_lypa_2","count":5,"gate":"lower","t":2,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":5,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":3,"gate":"upper","t":5,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":3,"gate":"lower","t":10,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1254_lypa_2","count":5,"gate":"lower","t":10,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"upper","t":10,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"lower","t":12,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1254_lypa_2","count":3,"gate":"upper","t":21,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":3,"gate":"lower","t":30,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1254_lypa_2","count":3,"gate":"lower","t":31,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":35,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"lower","t":37,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":51,"fly":false,"eLine truncated
|
||||
@@ -1 +1 @@
|
||||
{"version":1,"name":"normal-m01","title":"常规作战 · 源石流发生装置 · R5","kind":"normal","stageId":"act2autochess_m01","modeId":"mode_multi_normal","round":5,"wave":"act1autochess_05","tick":0.03333333333333333,"snapEvery":3,"duration":55,"recordedAt":"2026-09-27","field":{"fieldId":"n:P1","kind":"normal","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"stageId":"act2autochess_m01","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_02_a","name":"角峰","tier":1,"golden":false,"spine":"char_199_yak","avatar":"char_199_yak","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":2216,"uid":1,"skillIndex":1},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_08_a","name":"德克萨斯","tier":1,"golden":false,"spine":"char_102_texas","avatar":"char_102_texas","x":8,"y":9,"facing":1,"dir":"RIGHT","maxHp":1285,"uid":2,"skillIndex":1},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_12_b","name":"艾丝黛尔","tier":1,"golden":true,"spine":"char_127_estell","avatar":"char_127_estell_2","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":2389,"uid":3,"skillIndex":1,"moduleId":"uniequip_002_estell"},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_01_a","name":"隐现","tier":1,"golden":false,"spine":"char_498_inside","avatar":"char_498_inside","x":4,"y":10,"facing":1,"dir":"RIGHT","maxHp":1123,"uid":4,"skillIndex":1},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_14_a","name":"格雷伊","tier":1,"golden":false,"spine":"char_253_greyy","avatar":"char_253_greyy","x":4,"y":11,"facing":1,"dir":"RIGHT","maxHp":1260,"uid":5,"skillIndex":1},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_2_02_a","name":"赫默","tier":2,"golden":false,"spine":"char_108_silent","avatar":"char_108_silent","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1400,"uid":6,"skillIndex":1},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_01_a","name":"能天使","tier":3,"golden":false,"spine":"char_103_angel","avatar":"char_103_angel","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1472,"uid":7,"skillIndex":2}]},"frames":[{"snap":{"fieldId":"n:P1","t":0.1,"units":[[1,9,9,2216,2216,0.1,47,0,6],[2,8,9,1285,1285,23.1,40,0,6],[3,7,9,2389,2389,10,23,0,6],[4,4,10,1123,1123,8.1,24,0,6],[5,4,11,1260,1260,0.1,60,0,6],[6,4,12,1400,1400,0.1,26,0,6],[7,5,12,1472,1472,20.1,44,0,6]],"dp":11,"killed":0,"total":32},"ev":[["spawn",{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_2_02_a","name":"赫默","tier":2,"golden":false,"spine":"char_108_silent","avatar":"char_108_silent","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1400,"uid":6,"skillIndex":1}],["deploy",6],["spawn",{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_01_a","name":"能天使","tier":3,"golden":false,"spine":"char_103_angel","avatar":"char_103_angel","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1472,"uid":7,"skillIndex":2}],["deploy",7],["spawn",{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_14_a","name":"格雷伊","tier":1,"golden":false,"spine":"char_253_greyy","avatar":"char_253_greyy","x":4,"y":11,"facing":1,"dir":"RIGHT","maxHp":1145,"uid":5,"skillIndex":1}],["deploy",5],["spawn",{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_01_a","name":"隐现","tier":1,"golden":false,"spine":"char_498_inside","avatar":"char_498_inside","x":4,"y":10,"facing":1,"dir":"RIGHT","maxHp":1123,"uid":4,"skillIndex":1}],["deploy",4],["spawn",{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_12_b","name":"艾丝黛尔","tier":1,"golden":true,"spine":"char_127_estell","avatar":"char_127_estell_2","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":2389,"uid":3,"skillIndex":1,"moduleId":"uniequip_002_estell"}],["deploy",3],["spawn",{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_08_a","name":"德克萨斯","tier":1,"golden":false,"spine":"char_102_texas","avatar":"char_102_texas","x":8,"y":9,"facing":1,"dir":"RIGHT","maxHp":1285,"uid":2,"skillIndex":1}],["deploy",2],["spawn",{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_02_a","name":"角峰","tier":1,"golden":false,"spine":"char_199_yak","avatar":"char_199_yak","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":2216,"uid":1,"skillIndex":1}],["deploy",1],["status",5,"talent:angel_bless_ally",1],["fx","buff",4,11,{"id":5,"src":7,"talent":"angel_bless"}]]},{"snap":{"fieldId":"n:P1","t":0.2,"units":[[1,9,9,2216,2216,0.2,47,0,6],[2,8,9,1285,1285,23.2,40,0,6],[3,7,9,2389,2389,10,23,0,6],[4,4,10,1123,1123,8.2,24,0,6],[5,4,11,1260,1260,0.2,60,0,6],[6,4,12,1400,1400,0.2,26,0,6],[7,5,12,1472,1472,20.2,44,0,6]],"dp":11,"killed":0,"total":32},"ev":[]},{"snap":{"fieldId":"n:P1","t":0.3,"units":[[1,9,9,2216,2216,0.3,47,0,6],[2,8,9,1285,1285,23.3,40,0,6],[3,7,9,2389,2389,10,23,0,6],[4,4,10,1123,1123,8.3,24,0,6],[5,4,11,1260,1260,0.3,60,0,6],[6,4,12,1400,1400,0.3,26,0,6],[7,5,12,1472,1472,20Line truncated
|
||||
{"version":1,"name":"normal-m01","title":"常规作战 · 源石流发生装置 · R5","kind":"normal","stageId":"act2autochess_m01","modeId":"mode_multi_normal","round":5,"wave":"act1autochess_05","tick":0.03333333333333333,"snapEvery":3,"duration":55,"recordedAt":"2026-09-27","field":{"fieldId":"n:P1","kind":"normal","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"stageId":"act2autochess_m01","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_02_a","name":"角峰","tier":1,"golden":false,"spine":"char_199_yak","avatar":"char_199_yak","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":2216,"uid":1,"skillIndex":1},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_08_a","name":"德克萨斯","tier":1,"golden":false,"spine":"char_102_texas","avatar":"char_102_texas","x":8,"y":9,"facing":1,"dir":"RIGHT","maxHp":1285,"uid":2,"skillIndex":1},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_12_b","name":"艾丝黛尔","tier":1,"golden":true,"spine":"char_127_estell","avatar":"char_127_estell_2","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":2389,"uid":3,"skillIndex":1,"moduleId":"uniequip_002_estell"},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_01_a","name":"隐现","tier":1,"golden":false,"spine":"char_498_inside","avatar":"char_498_inside","x":4,"y":10,"facing":1,"dir":"RIGHT","maxHp":1123,"uid":4,"skillIndex":1},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_14_a","name":"格雷伊","tier":1,"golden":false,"spine":"char_253_greyy","avatar":"char_253_greyy","x":4,"y":11,"facing":1,"dir":"RIGHT","maxHp":1260,"uid":5,"skillIndex":1},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_2_02_a","name":"赫默","tier":2,"golden":false,"spine":"char_108_silent","avatar":"char_108_silent","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1400,"uid":6,"skillIndex":1},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_01_a","name":"能天使","tier":3,"golden":false,"spine":"char_103_angel","avatar":"char_103_angel","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1472,"uid":7,"skillIndex":2}]},"frames":[{"snap":{"fieldId":"n:P1","t":0.1,"units":[[1,9,9,2216,2216,0.1,47,0,6],[2,8,9,1285,1285,23.1,40,0,6],[3,7,9,2389,2389,10,23,0,6],[4,4,10,1123,1123,8.1,24,0,6],[5,4,11,1260,1260,0.1,60,0,6],[6,4,12,1400,1400,0.1,26,0,6],[7,5,12,1472,1472,20.1,44,0,6]],"dp":11,"killed":0,"total":32},"ev":[["spawn",{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_2_02_a","name":"赫默","tier":2,"golden":false,"spine":"char_108_silent","avatar":"char_108_silent","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1400,"uid":6,"skillIndex":1}],["deploy",6],["spawn",{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_01_a","name":"能天使","tier":3,"golden":false,"spine":"char_103_angel","avatar":"char_103_angel","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1472,"uid":7,"skillIndex":2}],["deploy",7],["spawn",{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_14_a","name":"格雷伊","tier":1,"golden":false,"spine":"char_253_greyy","avatar":"char_253_greyy","x":4,"y":11,"facing":1,"dir":"RIGHT","maxHp":1145,"uid":5,"skillIndex":1}],["deploy",5],["spawn",{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_01_a","name":"隐现","tier":1,"golden":false,"spine":"char_498_inside","avatar":"char_498_inside","x":4,"y":10,"facing":1,"dir":"RIGHT","maxHp":1123,"uid":4,"skillIndex":1}],["deploy",4],["spawn",{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_12_b","name":"艾丝黛尔","tier":1,"golden":true,"spine":"char_127_estell","avatar":"char_127_estell_2","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":2389,"uid":3,"skillIndex":1,"moduleId":"uniequip_002_estell"}],["deploy",3],["spawn",{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_08_a","name":"德克萨斯","tier":1,"golden":false,"spine":"char_102_texas","avatar":"char_102_texas","x":8,"y":9,"facing":1,"dir":"RIGHT","maxHp":1285,"uid":2,"skillIndex":1}],["deploy",2],["spawn",{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_02_a","name":"角峰","tier":1,"golden":false,"spine":"char_199_yak","avatar":"char_199_yak","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":2216,"uid":1,"skillIndex":1}],["deploy",1],["status",5,"talent:angel_bless_ally",1],["fx","buff",4,11,{"id":5,"src":7,"talent":"angel_bless"}]]},{"snap":{"fieldId":"n:P1","t":0.2,"units":[[1,9,9,2216,2216,0.2,47,0,6],[2,8,9,1285,1285,23.2,40,0,6],[3,7,9,2389,2389,10,23,0,6],[4,4,10,1123,1123,8.2,24,0,6],[5,4,11,1260,1260,0.2,60,0,6],[6,4,12,1400,1400,0.2,26,0,6],[7,5,12,1472,1472,20.2,44,0,6]],"dp":11,"killed":0,"total":32},"ev":[]},{"snap":{"fieldId":"n:P1","t":0.3,"units":[[1,9,9,2216,2216,0.3,47,0,6],[2,8,9,1285,1285,23.3,40,0,6],[3,7,9,2389,2389,10,23,0,6],[4,4,10,1123,1123,8.3,24,0,6],[5,4,11,1260,1260,0.3,60,0,6],[6,4,12,1400,1400,0.3,26,0,6],[7,5,12,1472,1472,20Line truncated
|
||||
@@ -1 +1 @@
|
||||
{"version":1,"name":"normal-m02","title":"常规作战 · 沼泽控制 · R8","kind":"normal","stageId":"act2autochess_m02","modeId":"mode_multi_hard","round":8,"wave":"act1autochess_h01","tick":0.03333333333333333,"snapEvery":3,"duration":85,"recordedAt":"2026-09-27","field":{"fieldId":"n:P1","kind":"normal","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"stageId":"act2autochess_m02","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_17_a","name":"星熊","tier":4,"golden":false,"spine":"char_136_hsguma","avatar":"char_136_hsguma","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":2849,"uid":1,"skillIndex":1},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_22_a","name":"银灰","tier":4,"golden":false,"spine":"char_172_svrash","avatar":"char_172_svrash","x":8,"y":9,"facing":1,"dir":"RIGHT","maxHp":2022,"uid":2,"skillIndex":2},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_05_a","name":"斯卡蒂","tier":3,"golden":false,"spine":"char_263_skadi","avatar":"char_263_skadi","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":2938,"uid":3,"skillIndex":2},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_02_a","name":"莫斯提马","tier":4,"golden":false,"spine":"char_213_mostma","avatar":"char_213_mostma","x":4,"y":10,"facing":1,"dir":"RIGHT","maxHp":1428,"uid":4,"skillIndex":2},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_21_a","name":"白面鸮","tier":4,"golden":false,"spine":"char_128_plosis","avatar":"char_128_plosis","x":5,"y":11,"facing":1,"dir":"RIGHT","maxHp":1271,"uid":5,"skillIndex":1},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_06_a","name":"寒芒克洛丝","tier":4,"golden":false,"spine":"char_1021_kroos2","avatar":"char_1021_kroos2","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1246,"uid":6,"skillIndex":1},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_10_a","name":"阿罗玛","tier":4,"golden":false,"spine":"char_446_aroma","avatar":"char_446_aroma","x":7,"y":12,"facing":1,"dir":"RIGHT","maxHp":1288,"uid":7,"skillIndex":1},{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_20_a","name":"远牙","tier":4,"golden":false,"spine":"char_430_fartth","avatar":"char_430_fartth","x":8,"y":12,"facing":1,"dir":"RIGHT","maxHp":1217,"uid":8,"skillIndex":2},{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"dir":"RIGHT","maxHp":100},{"id":10,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":10,"facing":1,"dir":"RIGHT","maxHp":100}]},"frames":[{"snap":{"fieldId":"n:P1","t":0.1,"units":[[1,9,9,2849,2849,0,0,0,6],[2,8,9,2022,2022,55.1,90,0,6],[3,7,9,2938,2938,55.1,90,0,6],[4,4,10,1428,1428,56.1,110,0,6],[5,5,11,1271,1271,70.1,100,0,6],[6,6,12,1246,1246,15.1,45,0,6],[7,7,12,1288,1288,20.1,55,0,6],[8,8,12,1217,1217,6.1,27,0,6],[9,9,11,100,100,0,0,0,6],[10,9,10,100,100,0,0,0,6]],"dp":10,"killed":0,"total":35},"ev":[["spawn",{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":10,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":10,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_06_a","name":"寒芒克洛丝","tier":4,"golden":false,"spine":"char_1021_kroos2","avatar":"char_1021_kroos2","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1246,"uid":6,"skillIndex":1}],["deploy",6],["spawn",{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_10_a","name":"阿罗玛","tier":4,"golden":false,"spine":"char_446_aroma","avatar":"char_446_aroma","x":7,"y":12,"facing":1,"dir":"RIGHT","maxHp":1288,"uid":7,"skillIndex":1}],["deploy",7],["spawn",{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_20_a","name":"远牙","tier":4,"golden":false,"spine":"char_430_fartth","avatar":"char_430_fartth","x":8,"y":12,"facing":1,"dir":"RIGHT","maxHp":1217,"uid":8,"skillIndex":2}],["deploy",8],["spawn",{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_21_a","name":"白面鸮","tier":4,"golden":false,"spine":"char_128_plosis","avatar":"char_128_plosis","x":5,"y":11,"facing":1,"dir":"RIGHT","maxHp":1271,"uid":5,"skillIndex":1}],["deploy",5],["spawn",{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_02_a","name":"莫斯提马","tier":4,"golden":false,"spine":"char_213_mostma","avatar":"char_213_mostma","x":4,"y":1Line truncated
|
||||
{"version":1,"name":"normal-m02","title":"常规作战 · 沼泽控制 · R8","kind":"normal","stageId":"act2autochess_m02","modeId":"mode_multi_hard","round":8,"wave":"act1autochess_h01","tick":0.03333333333333333,"snapEvery":3,"duration":85,"recordedAt":"2026-09-27","field":{"fieldId":"n:P1","kind":"normal","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"stageId":"act2autochess_m02","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_17_a","name":"星熊","tier":4,"golden":false,"spine":"char_136_hsguma","avatar":"char_136_hsguma","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":2849,"uid":1,"skillIndex":1},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_22_a","name":"银灰","tier":4,"golden":false,"spine":"char_172_svrash","avatar":"char_172_svrash","x":8,"y":9,"facing":1,"dir":"RIGHT","maxHp":2022,"uid":2,"skillIndex":2},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_05_a","name":"斯卡蒂","tier":3,"golden":false,"spine":"char_263_skadi","avatar":"char_263_skadi","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":2938,"uid":3,"skillIndex":2},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_02_a","name":"莫斯提马","tier":4,"golden":false,"spine":"char_213_mostma","avatar":"char_213_mostma","x":4,"y":10,"facing":1,"dir":"RIGHT","maxHp":1428,"uid":4,"skillIndex":2},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_21_a","name":"白面鸮","tier":4,"golden":false,"spine":"char_128_plosis","avatar":"char_128_plosis","x":5,"y":11,"facing":1,"dir":"RIGHT","maxHp":1271,"uid":5,"skillIndex":1},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_06_a","name":"寒芒克洛丝","tier":4,"golden":false,"spine":"char_1021_kroos2","avatar":"char_1021_kroos2","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1246,"uid":6,"skillIndex":1},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_10_a","name":"阿罗玛","tier":4,"golden":false,"spine":"char_446_aroma","avatar":"char_446_aroma","x":7,"y":12,"facing":1,"dir":"RIGHT","maxHp":1288,"uid":7,"skillIndex":1},{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_20_a","name":"远牙","tier":4,"golden":false,"spine":"char_430_fartth","avatar":"char_430_fartth","x":8,"y":12,"facing":1,"dir":"RIGHT","maxHp":1217,"uid":8,"skillIndex":2},{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"dir":"RIGHT","maxHp":100},{"id":10,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":10,"facing":1,"dir":"RIGHT","maxHp":100}]},"frames":[{"snap":{"fieldId":"n:P1","t":0.1,"units":[[1,9,9,2849,2849,0,0,0,6],[2,8,9,2022,2022,55.1,90,0,6],[3,7,9,2938,2938,55.1,90,0,6],[4,4,10,1428,1428,56.1,110,0,6],[5,5,11,1271,1271,70.1,100,0,6],[6,6,12,1246,1246,15.1,45,0,6],[7,7,12,1288,1288,20.1,55,0,6],[8,8,12,1217,1217,6.1,27,0,6],[9,9,11,100,100,0,0,0,6],[10,9,10,100,100,0,0,0,6]],"dp":10,"killed":0,"total":35},"ev":[["spawn",{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":10,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":10,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_06_a","name":"寒芒克洛丝","tier":4,"golden":false,"spine":"char_1021_kroos2","avatar":"char_1021_kroos2","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1246,"uid":6,"skillIndex":1}],["deploy",6],["spawn",{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_10_a","name":"阿罗玛","tier":4,"golden":false,"spine":"char_446_aroma","avatar":"char_446_aroma","x":7,"y":12,"facing":1,"dir":"RIGHT","maxHp":1288,"uid":7,"skillIndex":1}],["deploy",7],["spawn",{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_20_a","name":"远牙","tier":4,"golden":false,"spine":"char_430_fartth","avatar":"char_430_fartth","x":8,"y":12,"facing":1,"dir":"RIGHT","maxHp":1217,"uid":8,"skillIndex":2}],["deploy",8],["spawn",{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_21_a","name":"白面鸮","tier":4,"golden":false,"spine":"char_128_plosis","avatar":"char_128_plosis","x":5,"y":11,"facing":1,"dir":"RIGHT","maxHp":1271,"uid":5,"skillIndex":1}],["deploy",5],["spawn",{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_02_a","name":"莫斯提马","tier":4,"golden":false,"spine":"char_213_mostma","avatar":"char_213_mostma","x":4,"y":1Line truncated
|
||||
@@ -1 +1 @@
|
||||
{"version":1,"name":"normal-m03","title":"常规作战 · 排气格栅 · R11","kind":"normal","stageId":"act2autochess_m03","modeId":"mode_multi_hard","round":11,"wave":"act1autochess_h04","tick":0.03333333333333333,"snapEvery":3,"duration":90,"recordedAt":"2026-09-27","field":{"fieldId":"n:P1","kind":"normal","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"stageId":"act2autochess_m03","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_11_b","name":"塞雷娅","tier":5,"golden":true,"spine":"char_202_demkni","avatar":"char_202_demkni_2","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":3003,"uid":1,"skillIndex":1,"moduleId":"uniequip_002_demkni"},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_17_a","name":"山","tier":5,"golden":false,"spine":"char_264_f12yin","avatar":"char_264_f12yin","x":6,"y":9,"facing":1,"dir":"RIGHT","maxHp":2250,"uid":2,"skillIndex":1},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_07_a","name":"史尔特尔","tier":5,"golden":false,"spine":"char_350_surtr","avatar":"char_350_surtr","x":5,"y":9,"facing":1,"dir":"RIGHT","maxHp":2216,"uid":3,"skillIndex":2},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_12_a","name":"夕","tier":5,"golden":false,"spine":"char_2015_dusk","avatar":"char_2015_dusk","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1404,"uid":4,"skillIndex":0},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_02_a","name":"缇缇","tier":5,"golden":false,"spine":"char_4056_titi","avatar":"char_4056_titi","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1208,"uid":5,"skillIndex":2},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_03_a","name":"烛煌","tier":5,"golden":false,"spine":"char_1040_blaze2","avatar":"char_1040_blaze2","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1206,"uid":6,"skillIndex":2},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_10_a","name":"铃兰","tier":5,"golden":false,"spine":"char_358_lisa","avatar":"char_358_lisa","x":7,"y":12,"facing":1,"dir":"RIGHT","maxHp":1184,"uid":7,"skillIndex":2},{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_08_b","name":"号角","tier":5,"golden":true,"spine":"char_4039_horn","avatar":"char_4039_horn_2","x":4,"y":9,"facing":1,"dir":"RIGHT","maxHp":2775,"uid":8,"skillIndex":2,"moduleId":"uniequip_002_horn"},{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":3,"y":12,"facing":1,"dir":"RIGHT","maxHp":100},{"id":10,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":3,"y":11,"facing":1,"dir":"RIGHT","maxHp":100},{"id":11,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"dir":"RIGHT","maxHp":100}]},"frames":[{"snap":{"fieldId":"n:P1","t":0.1,"units":[[1,9,9,3003,3003,0.1,8,0,6],[2,6,9,2250,2250,0.1,9,0,6],[3,5,9,2216,2216,0.1,9,0,6],[4,4,12,1404,1404,0.1,7,0,6],[5,5,12,1208,1208,20.1,45,0,6],[6,6,12,1206,1206,13.1,37,8,6],[7,7,12,1184,1184,43.1,70,0,6],[8,4,9,2775,2775,25.1,40,0,6],[9,3,12,100,100,0,0,0,6],[10,3,11,100,100,0,0,0,6],[11,9,11,100,100,0,0,0,6]],"dp":10,"killed":0,"total":46},"ev":[["spawn",{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":3,"y":12,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":10,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":3,"y":11,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":11,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_12_a","name":"夕","tier":5,"golden":false,"spine":"char_2015_dusk","avatar":"char_2015_dusk","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1404,"uid":4,"skillIndex":0}],["deploy",4],["spawn",{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_02_a","name":"缇缇","tier":5,"golden":false,"spine":"char_4056_titi","avatar":"char_4056_titi","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1208,"uid":5,"skillIndex":2}],["deploy",5],["spawn",{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_03_a","name":"烛煌","tier":5,"golden":false,"spine":"char_1040_blaze2","avatar":"char_1040_blaze2","x":6,"y":12,"facing":1,"dir":"RILine truncated
|
||||
{"version":1,"name":"normal-m03","title":"常规作战 · 排气格栅 · R11","kind":"normal","stageId":"act2autochess_m03","modeId":"mode_multi_hard","round":11,"wave":"act1autochess_h04","tick":0.03333333333333333,"snapEvery":3,"duration":90,"recordedAt":"2026-09-27","field":{"fieldId":"n:P1","kind":"normal","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"stageId":"act2autochess_m03","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_11_b","name":"塞雷娅","tier":5,"golden":true,"spine":"char_202_demkni","avatar":"char_202_demkni_2","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":3003,"uid":1,"skillIndex":1,"moduleId":"uniequip_002_demkni"},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_17_a","name":"山","tier":5,"golden":false,"spine":"char_264_f12yin","avatar":"char_264_f12yin","x":6,"y":9,"facing":1,"dir":"RIGHT","maxHp":2250,"uid":2,"skillIndex":1},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_07_a","name":"史尔特尔","tier":5,"golden":false,"spine":"char_350_surtr","avatar":"char_350_surtr","x":5,"y":9,"facing":1,"dir":"RIGHT","maxHp":2216,"uid":3,"skillIndex":2},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_12_a","name":"夕","tier":5,"golden":false,"spine":"char_2015_dusk","avatar":"char_2015_dusk","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1404,"uid":4,"skillIndex":0},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_02_a","name":"缇缇","tier":5,"golden":false,"spine":"char_4056_titi","avatar":"char_4056_titi","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1208,"uid":5,"skillIndex":2},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_03_a","name":"烛煌","tier":5,"golden":false,"spine":"char_1040_blaze2","avatar":"char_1040_blaze2","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1206,"uid":6,"skillIndex":2},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_10_a","name":"铃兰","tier":5,"golden":false,"spine":"char_358_lisa","avatar":"char_358_lisa","x":7,"y":12,"facing":1,"dir":"RIGHT","maxHp":1184,"uid":7,"skillIndex":2},{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_08_b","name":"号角","tier":5,"golden":true,"spine":"char_4039_horn","avatar":"char_4039_horn_2","x":4,"y":9,"facing":1,"dir":"RIGHT","maxHp":2775,"uid":8,"skillIndex":2,"moduleId":"uniequip_002_horn"},{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":3,"y":12,"facing":1,"dir":"RIGHT","maxHp":100},{"id":10,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":3,"y":11,"facing":1,"dir":"RIGHT","maxHp":100},{"id":11,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"dir":"RIGHT","maxHp":100}]},"frames":[{"snap":{"fieldId":"n:P1","t":0.1,"units":[[1,9,9,3003,3003,0.1,8,0,6],[2,6,9,2250,2250,0.1,9,0,6],[3,5,9,2216,2216,0.1,9,0,6],[4,4,12,1404,1404,0.1,7,0,6],[5,5,12,1208,1208,20.1,45,0,6],[6,6,12,1206,1206,13.1,37,8,6],[7,7,12,1184,1184,43.1,70,0,6],[8,4,9,2775,2775,25.1,40,0,6],[9,3,12,100,100,0,0,0,6],[10,3,11,100,100,0,0,0,6],[11,9,11,100,100,0,0,0,6]],"dp":10,"killed":0,"total":46},"ev":[["spawn",{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":3,"y":12,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":10,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":3,"y":11,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":11,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_12_a","name":"夕","tier":5,"golden":false,"spine":"char_2015_dusk","avatar":"char_2015_dusk","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1404,"uid":4,"skillIndex":0}],["deploy",4],["spawn",{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_02_a","name":"缇缇","tier":5,"golden":false,"spine":"char_4056_titi","avatar":"char_4056_titi","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1208,"uid":5,"skillIndex":2}],["deploy",5],["spawn",{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_03_a","name":"烛煌","tier":5,"golden":false,"spine":"char_1040_blaze2","avatar":"char_1040_blaze2","x":6,"y":12,"facing":1,"dir":"RILine truncated
|
||||
@@ -1 +1 @@
|
||||
{"version":1,"name":"normal-m04","title":"常规作战 · 涨潮控制 · R6","kind":"normal","stageId":"act2autochess_m04","modeId":"mode_multi_normal","round":6,"wave":"act1autochess_06","tick":0.03333333333333333,"snapEvery":3,"duration":70,"recordedAt":"2026-09-27","field":{"fieldId":"n:P1","kind":"normal","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"stageId":"act2autochess_m04","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_16_a","name":"蛇屠箱","tier":3,"golden":false,"spine":"char_150_snakek","avatar":"char_150_snakek","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":2173,"uid":1,"skillIndex":1},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_18_a","name":"忍冬","tier":3,"golden":false,"spine":"char_4026_vulpis","avatar":"char_4026_vulpis","x":8,"y":9,"facing":1,"dir":"RIGHT","maxHp":1481,"uid":2,"skillIndex":2},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_02_a","name":"断崖","tier":3,"golden":false,"spine":"char_294_ayer","avatar":"char_294_ayer","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":1911,"uid":3,"skillIndex":1},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_17_a","name":"流星","tier":3,"golden":false,"spine":"char_126_shotst","avatar":"char_126_shotst","x":3,"y":12,"facing":1,"dir":"RIGHT","maxHp":1404,"uid":4,"skillIndex":1},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_21_a","name":"空弦","tier":3,"golden":false,"spine":"char_332_archet","avatar":"char_332_archet","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1364,"uid":5,"skillIndex":2},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_2_14_a","name":"调香师","tier":2,"golden":false,"spine":"char_181_flower","avatar":"char_181_flower","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1232,"uid":6,"skillIndex":1},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_14_a","name":"初雪","tier":3,"golden":false,"spine":"char_174_slbell","avatar":"char_174_slbell","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1251,"uid":7,"skillIndex":1},{"id":8,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"dir":"RIGHT","maxHp":100},{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":10,"facing":1,"dir":"RIGHT","maxHp":100}]},"frames":[{"snap":{"fieldId":"n:P1","t":0.1,"units":[[1,9,9,2173,2173,0.1,40,0,6],[2,8,9,1481,1481,6.1,21,0,6],[3,7,9,1911,1911,18.1,54,0,6],[4,3,12,1404,1404,0,19,0,6],[5,4,12,1364,1364,15,37,32,6],[6,5,12,1232,1232,20.1,60,0,6],[7,6,12,1251,1251,13.1,40,0,6],[8,9,11,100,100,0,0,0,6],[9,9,10,100,100,0,0,0,6]],"dp":10,"killed":0,"total":39},"ev":[["spawn",{"id":8,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":10,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_17_a","name":"流星","tier":3,"golden":false,"spine":"char_126_shotst","avatar":"char_126_shotst","x":3,"y":12,"facing":1,"dir":"RIGHT","maxHp":1404,"uid":4,"skillIndex":1}],["deploy",4],["spawn",{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_21_a","name":"空弦","tier":3,"golden":false,"spine":"char_332_archet","avatar":"char_332_archet","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1364,"uid":5,"skillIndex":2}],["deploy",5],["status",5,"talent:archet_shield",1],["fx","shield",4,12,{"id":5}],["spawn",{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_2_14_a","name":"调香师","tier":2,"golden":false,"spine":"char_181_flower","avatar":"char_181_flower","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1232,"uid":6,"skillIndex":1}],["deploy",6],["spawn",{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_14_a","name":"初雪","tier":3,"golden":false,"spine":"char_174_slbell","avatar":"char_174_slbell","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1251,"uid":7,"skillIndex":1}],["deploy",7],["spawn",{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_02_a","name":"断崖","tier":3,"golden":false,"spine":"char_294_ayer","avatar":"char_294_ayer","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":1911,"uid":3,"skillIndex":1}],["deploy",3],["spawn",{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_18_a","name":"忍冬","tier":3,"golden":false,"spine":"char_4026_vulpis","avatar":"char_4026_vulLine truncated
|
||||
{"version":1,"name":"normal-m04","title":"常规作战 · 涨潮控制 · R6","kind":"normal","stageId":"act2autochess_m04","modeId":"mode_multi_normal","round":6,"wave":"act1autochess_06","tick":0.03333333333333333,"snapEvery":3,"duration":70,"recordedAt":"2026-09-27","field":{"fieldId":"n:P1","kind":"normal","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"stageId":"act2autochess_m04","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_16_a","name":"蛇屠箱","tier":3,"golden":false,"spine":"char_150_snakek","avatar":"char_150_snakek","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":2173,"uid":1,"skillIndex":1},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_18_a","name":"忍冬","tier":3,"golden":false,"spine":"char_4026_vulpis","avatar":"char_4026_vulpis","x":8,"y":9,"facing":1,"dir":"RIGHT","maxHp":1481,"uid":2,"skillIndex":2},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_02_a","name":"断崖","tier":3,"golden":false,"spine":"char_294_ayer","avatar":"char_294_ayer","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":1911,"uid":3,"skillIndex":1},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_17_a","name":"流星","tier":3,"golden":false,"spine":"char_126_shotst","avatar":"char_126_shotst","x":3,"y":12,"facing":1,"dir":"RIGHT","maxHp":1404,"uid":4,"skillIndex":1},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_21_a","name":"空弦","tier":3,"golden":false,"spine":"char_332_archet","avatar":"char_332_archet","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1364,"uid":5,"skillIndex":2},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_2_14_a","name":"调香师","tier":2,"golden":false,"spine":"char_181_flower","avatar":"char_181_flower","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1232,"uid":6,"skillIndex":1},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_14_a","name":"初雪","tier":3,"golden":false,"spine":"char_174_slbell","avatar":"char_174_slbell","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1251,"uid":7,"skillIndex":1},{"id":8,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"dir":"RIGHT","maxHp":100},{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":10,"facing":1,"dir":"RIGHT","maxHp":100}]},"frames":[{"snap":{"fieldId":"n:P1","t":0.1,"units":[[1,9,9,2173,2173,0.1,40,0,6],[2,8,9,1481,1481,6.1,21,0,6],[3,7,9,1911,1911,18.1,54,0,6],[4,3,12,1404,1404,0,19,0,6],[5,4,12,1364,1364,15,37,32,6],[6,5,12,1232,1232,20.1,60,0,6],[7,6,12,1251,1251,13.1,40,0,6],[8,9,11,100,100,0,0,0,6],[9,9,10,100,100,0,0,0,6]],"dp":10,"killed":0,"total":39},"ev":[["spawn",{"id":8,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":10,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_17_a","name":"流星","tier":3,"golden":false,"spine":"char_126_shotst","avatar":"char_126_shotst","x":3,"y":12,"facing":1,"dir":"RIGHT","maxHp":1404,"uid":4,"skillIndex":1}],["deploy",4],["spawn",{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_21_a","name":"空弦","tier":3,"golden":false,"spine":"char_332_archet","avatar":"char_332_archet","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1364,"uid":5,"skillIndex":2}],["deploy",5],["status",5,"talent:archet_shield",1],["fx","shield",4,12,{"id":5}],["spawn",{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_2_14_a","name":"调香师","tier":2,"golden":false,"spine":"char_181_flower","avatar":"char_181_flower","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1232,"uid":6,"skillIndex":1}],["deploy",6],["spawn",{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_14_a","name":"初雪","tier":3,"golden":false,"spine":"char_174_slbell","avatar":"char_174_slbell","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1251,"uid":7,"skillIndex":1}],["deploy",7],["spawn",{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_02_a","name":"断崖","tier":3,"golden":false,"spine":"char_294_ayer","avatar":"char_294_ayer","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":1911,"uid":3,"skillIndex":1}],["deploy",3],["spawn",{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_18_a","name":"忍冬","tier":3,"golden":false,"spine":"char_4026_vulpis","avatar":"char_4026_vulLine truncated
|
||||
@@ -1 +1 @@
|
||||
{"version":1,"name":"unite-m01","title":"联防 · 源石流发生装置 · R9","kind":"unite","stageId":"act2autochess_m01","modeId":"mode_multi_normal","round":9,"wave":"act1autochess_escaped_multi","tick":0.03333333333333333,"snapEvery":3,"duration":60,"recordedAt":"2026-09-27","field":{"fieldId":"u","kind":"unite","rect":{"r0":9,"r1":12,"c0":0,"c1":20},"stageId":"act2autochess_m01","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_17_a","name":"星熊","tier":4,"golden":false,"spine":"char_136_hsguma","avatar":"char_136_hsguma","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":2849,"uid":1,"skillIndex":1},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_08_a","name":"德克萨斯","tier":1,"golden":false,"spine":"char_102_texas","avatar":"char_102_texas","x":8,"y":9,"facing":1,"dir":"RIGHT","maxHp":1414,"uid":2,"skillIndex":1},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_01_a","name":"能天使","tier":3,"golden":false,"spine":"char_103_angel","avatar":"char_103_angel","x":4,"y":10,"facing":1,"dir":"RIGHT","maxHp":1472,"uid":3,"skillIndex":2},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_2_02_a","name":"赫默","tier":2,"golden":false,"spine":"char_108_silent","avatar":"char_108_silent","x":4,"y":11,"facing":1,"dir":"RIGHT","maxHp":1400,"uid":4,"skillIndex":1},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_14_a","name":"格雷伊","tier":1,"golden":false,"spine":"char_253_greyy","avatar":"char_253_greyy","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1145,"uid":5,"skillIndex":1},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_22_a","name":"银灰","tier":4,"golden":false,"spine":"char_172_svrash","avatar":"char_172_svrash","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":2022,"uid":6,"skillIndex":2},{"id":7,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_5_11_a","name":"塞雷娅","tier":5,"golden":false,"spine":"char_202_demkni","avatar":"char_202_demkni","x":17,"y":9,"facing":1,"dir":"RIGHT","maxHp":2268,"uid":101,"skillIndex":1},{"id":8,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_2_16_a","name":"拉普兰德","tier":2,"golden":false,"spine":"char_140_whitew","avatar":"char_140_whitew","x":16,"y":9,"facing":1,"dir":"RIGHT","maxHp":1791,"uid":102,"skillIndex":1},{"id":9,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_4_02_a","name":"莫斯提马","tier":4,"golden":false,"spine":"char_213_mostma","avatar":"char_213_mostma","x":12,"y":10,"facing":1,"dir":"RIGHT","maxHp":1428,"uid":103,"skillIndex":2},{"id":10,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_4_21_a","name":"白面鸮","tier":4,"golden":false,"spine":"char_128_plosis","avatar":"char_128_plosis","x":12,"y":11,"facing":1,"dir":"RIGHT","maxHp":1271,"uid":104,"skillIndex":1},{"id":11,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_4_20_a","name":"远牙","tier":4,"golden":false,"spine":"char_430_fartth","avatar":"char_430_fartth","x":12,"y":12,"facing":1,"dir":"RIGHT","maxHp":1217,"uid":105,"skillIndex":2},{"id":12,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_5_02_a","name":"缇缇","tier":5,"golden":false,"spine":"char_4056_titi","avatar":"char_4056_titi","x":13,"y":12,"facing":1,"dir":"RIGHT","maxHp":1208,"uid":106,"skillIndex":2}]},"frames":[{"snap":{"fieldId":"u","t":0.1,"units":[[1,9,9,2849,2849,0,0,0,6],[2,8,9,1414,1414,23.1,40,0,6],[3,4,10,1472,1472,20.1,44,0,6],[4,4,11,1400,1400,0.1,26,0,6],[5,4,12,1145,1145,0.1,60,0,6],[6,7,9,2022,2022,55.1,90,0,6],[7,17,9,2268,2268,0.1,9,0,6],[8,16,9,1791,1791,0,27,0,6],[9,12,10,1428,1428,56.1,110,0,6],[10,12,11,1271,1271,70.1,100,0,6],[11,12,12,1217,1217,6.1,27,0,6],[12,13,12,1208,1208,20.1,45,0,6]],"dp":11,"killed":0,"total":40,"dps":{"P1":11,"P2":10}},"ev":[["spawn",{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_14_a","name":"格雷伊","tier":1,"golden":false,"spine":"char_253_greyy","avatar":"char_253_greyy","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1145,"uid":5,"skillIndex":1}],["deploy",5],["spawn",{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_2_02_a","name":"赫默","tier":2,"golden":false,"spine":"char_108_silent","avatar":"char_108_silent","x":4,"y":11,"facing":1,"dir":"RIGHT","maxHp":1400,"uid":4,"skillIndex":1}],["deploy",4],["spawn",{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_01_a","name":"能天使","tier":3,"golden":false,"spine":"char_103_angel","avatar":"char_103_angel","x":4,"y":10,"facing":1,"dir":"RIGHT","maxHp":1472,"uid":3,"skillIndex":2}],["deploy",3],["spawn",{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_22_a","name":"银灰","tier":4,"golden":false,"spine":"char_172_svrash","avatar":"char_172_svrash","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":2022,"uid":6,"skillIndex":2}],["deploy",6],["spawn",{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"cheLine truncated
|
||||
{"version":1,"name":"unite-m01","title":"联防 · 源石流发生装置 · R9","kind":"unite","stageId":"act2autochess_m01","modeId":"mode_multi_normal","round":9,"wave":"act1autochess_escaped_multi","tick":0.03333333333333333,"snapEvery":3,"duration":60,"recordedAt":"2026-09-27","field":{"fieldId":"u","kind":"unite","rect":{"r0":9,"r1":12,"c0":0,"c1":20},"stageId":"act2autochess_m01","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_17_a","name":"星熊","tier":4,"golden":false,"spine":"char_136_hsguma","avatar":"char_136_hsguma","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":2849,"uid":1,"skillIndex":1},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_08_a","name":"德克萨斯","tier":1,"golden":false,"spine":"char_102_texas","avatar":"char_102_texas","x":8,"y":9,"facing":1,"dir":"RIGHT","maxHp":1414,"uid":2,"skillIndex":1},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_01_a","name":"能天使","tier":3,"golden":false,"spine":"char_103_angel","avatar":"char_103_angel","x":4,"y":10,"facing":1,"dir":"RIGHT","maxHp":1472,"uid":3,"skillIndex":2},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_2_02_a","name":"赫默","tier":2,"golden":false,"spine":"char_108_silent","avatar":"char_108_silent","x":4,"y":11,"facing":1,"dir":"RIGHT","maxHp":1400,"uid":4,"skillIndex":1},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_14_a","name":"格雷伊","tier":1,"golden":false,"spine":"char_253_greyy","avatar":"char_253_greyy","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1145,"uid":5,"skillIndex":1},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_22_a","name":"银灰","tier":4,"golden":false,"spine":"char_172_svrash","avatar":"char_172_svrash","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":2022,"uid":6,"skillIndex":2},{"id":7,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_5_11_a","name":"塞雷娅","tier":5,"golden":false,"spine":"char_202_demkni","avatar":"char_202_demkni","x":17,"y":9,"facing":1,"dir":"RIGHT","maxHp":2268,"uid":101,"skillIndex":1},{"id":8,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_2_16_a","name":"拉普兰德","tier":2,"golden":false,"spine":"char_140_whitew","avatar":"char_140_whitew","x":16,"y":9,"facing":1,"dir":"RIGHT","maxHp":1791,"uid":102,"skillIndex":1},{"id":9,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_4_02_a","name":"莫斯提马","tier":4,"golden":false,"spine":"char_213_mostma","avatar":"char_213_mostma","x":12,"y":10,"facing":1,"dir":"RIGHT","maxHp":1428,"uid":103,"skillIndex":2},{"id":10,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_4_21_a","name":"白面鸮","tier":4,"golden":false,"spine":"char_128_plosis","avatar":"char_128_plosis","x":12,"y":11,"facing":1,"dir":"RIGHT","maxHp":1271,"uid":104,"skillIndex":1},{"id":11,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_4_20_a","name":"远牙","tier":4,"golden":false,"spine":"char_430_fartth","avatar":"char_430_fartth","x":12,"y":12,"facing":1,"dir":"RIGHT","maxHp":1217,"uid":105,"skillIndex":2},{"id":12,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_5_02_a","name":"缇缇","tier":5,"golden":false,"spine":"char_4056_titi","avatar":"char_4056_titi","x":13,"y":12,"facing":1,"dir":"RIGHT","maxHp":1208,"uid":106,"skillIndex":2}]},"frames":[{"snap":{"fieldId":"u","t":0.1,"units":[[1,9,9,2849,2849,0,0,0,6],[2,8,9,1414,1414,23.1,40,0,6],[3,4,10,1472,1472,20.1,44,0,6],[4,4,11,1400,1400,0.1,26,0,6],[5,4,12,1145,1145,0.1,60,0,6],[6,7,9,2022,2022,55.1,90,0,6],[7,17,9,2268,2268,0.1,9,0,6],[8,16,9,1791,1791,0,27,0,6],[9,12,10,1428,1428,56.1,110,0,6],[10,12,11,1271,1271,70.1,100,0,6],[11,12,12,1217,1217,6.1,27,0,6],[12,13,12,1208,1208,20.1,45,0,6]],"dp":11,"killed":0,"total":40,"dps":{"P1":11,"P2":10}},"ev":[["spawn",{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_14_a","name":"格雷伊","tier":1,"golden":false,"spine":"char_253_greyy","avatar":"char_253_greyy","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1145,"uid":5,"skillIndex":1}],["deploy",5],["spawn",{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_2_02_a","name":"赫默","tier":2,"golden":false,"spine":"char_108_silent","avatar":"char_108_silent","x":4,"y":11,"facing":1,"dir":"RIGHT","maxHp":1400,"uid":4,"skillIndex":1}],["deploy",4],["spawn",{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_01_a","name":"能天使","tier":3,"golden":false,"spine":"char_103_angel","avatar":"char_103_angel","x":4,"y":10,"facing":1,"dir":"RIGHT","maxHp":1472,"uid":3,"skillIndex":2}],["deploy",3],["spawn",{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_22_a","name":"银灰","tier":4,"golden":false,"spine":"char_172_svrash","avatar":"char_172_svrash","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":2022,"uid":6,"skillIndex":2}],["deploy",6],["spawn",{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"cheLine truncated
|
||||
@@ -457,11 +457,12 @@ function fxScene(view, stageId) {
|
||||
case 'dp': return { ...base, id: 3, n: 10 };
|
||||
case 'element': return { ...base, id: 21, element: ['burn', 'neural', 'apoptosis'][i % 3] };
|
||||
case 'shell': return { id: 2, r: 1.5, t: 0.3, i: 0 }; // 蕾缪安 (id = the shooter) shelling an enemy
|
||||
case 'flame': return { id: 1, target: 20, r: 1, n: 1, dur: 1 }; // 炎佑's jet (id = the dragon) onto its target
|
||||
default: return k === 'lock' ? { id: 21, src: 2 } : FX_KINDS[k].pt ? { id: 2, r: 1.5 } : base;
|
||||
}
|
||||
};
|
||||
// `pt` kinds name the shooter in `id` and happen at an enemy's spot
|
||||
const posFor = (k, ex) => unitAt(FX_KINDS[k].pt ? [21, 23, 20][i % 3] : ex.id);
|
||||
// `pt` kinds name the shooter in `id` and happen at an enemy's spot; a 'flame' happens at its target
|
||||
const posFor = (k, ex) => unitAt(FX_KINDS[k].pt ? [21, 23, 20][i % 3] : FX_KINDS[k].a === 'flame' ? ex.target : ex.id);
|
||||
// 蕾缪安 S3 as the sim plays it (sim/content/kits/tier6.js): a lock every 0.5 s, then after the skill one shell every
|
||||
// 0.3 s on the locks in order, each landing 0.3 s later with its bombard — queued [game time, event]
|
||||
const queue = [];
|
||||
|
||||
+57
-9
@@ -11,8 +11,17 @@
|
||||
// - Channels: master → { bgm, sfx } gains; volumes from settings (0..1) + mute. Tab hidden ⇒ suspend.
|
||||
// - BGM: `intro` then `loop` (1 s crossfade); switching tracks fades out/in (0.8 s). The same loop URL
|
||||
// keeps playing across phases (prep and combat share a track).
|
||||
// - Battle SFX from `b.ev` tuples (`handleBattleEvents`): at most MAX_VOICES concurrent unit sounds,
|
||||
// a per-unit cooldown and a per-URL minimum gap (SfxLimiter), so a 60-unit fight stays listenable.
|
||||
// - Battle SFX from `b.ev` tuples (`handleBattleEvents`): at most MAX_VOICES concurrent unit sounds, at most
|
||||
// MAX_PER_URL overlapping copies of one sound (the official banks' maxSoundAllowed 2), a per-unit cooldown and a
|
||||
// per-URL minimum gap (SfxLimiter), so a 60-unit fight stays listenable.
|
||||
// - Impact sounds (user playtest #4 item 6): a 'dmg' plays the `hit` sound of the unit whose hostile attack ('atk' on a
|
||||
// unit of the other side) aimed at the target — once, within IMPACT_WINDOW_MS, and only for phys / arts / true damage.
|
||||
// A heal "attack" ('atk' of a healer on an ally, chain heals) never makes the healer the author of the next damage
|
||||
// on that ally (纯烬艾雅法拉's heals made every later hit on a healed ally ring her impact sound), element gauge fills
|
||||
// and DoTs play none, and a chain bounce ('chain' / 'chainHeal': its first id is the previous target) plays no attack
|
||||
// sound of that target. An operator's attack / hit sound that is a skill-mode file of its own (official names end in
|
||||
// `_n` for the normal attack, `_d` / `_h` / `_s` for its skill modes — the manifest picked 纯烬艾雅法拉's S3 impact
|
||||
// p_imp_gtshpbrnch_s as her `hit`) never plays for a normal attack (normalAttackSfx).
|
||||
// - Deaths/deployments follow the official per-class defaults (unitSoundClass): only operators play the
|
||||
// operator-knocked-down sound; summons use the token sounds; a summon used up by its own effect (fx `consumed`,
|
||||
// 香槟炸弹) plays its impact sound instead of a death sound.
|
||||
@@ -26,9 +35,18 @@ import { PHASE } from '../../shared/constants.js';
|
||||
const MAX_VOICES = 8;
|
||||
const UNIT_COOLDOWN_MS = 160;
|
||||
const URL_GAP_MS = 45;
|
||||
const MAX_PER_URL = 2;
|
||||
const BUFFER_CACHE = 180;
|
||||
const XFADE_S = 1;
|
||||
const FADE_S = 0.8;
|
||||
/** 'atk' projectile kinds whose first id is the previous bounce target (sim ai.js), not the attacker. */
|
||||
const CHAIN_KINDS = new Set(['chain', 'chainHeal']);
|
||||
/** 'dmg' types that are an attack's impact (element gauge fills / 元素伤害 carry the element's name instead). */
|
||||
const IMPACT_TYPES = new Set(['phys', 'arts', 'true']);
|
||||
/** A 'dmg' later than this (real ms) after the attack aimed at the target is not that attack's impact. */
|
||||
const IMPACT_WINDOW_MS = 2500;
|
||||
/** Official operator sound files of a skill mode: `…_d` / `…_h` / `…_s` (+ digits) — the normal attack's end in `_n`. */
|
||||
const SKILL_MODE_FILE = /_(d|h|s)\d*\.mp3$/i;
|
||||
|
||||
// ---- pure helpers (unit-tested) -----------------------------------------------------------------------
|
||||
|
||||
@@ -133,29 +151,45 @@ export function deploySfxUrl(manifest, info) {
|
||||
return typeof url === 'string' ? url : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a unit's manifest `attack` / `hit` sound may play for its normal attacks: an operator's (`char_*`) sound file
|
||||
* of one of its skill modes (`_d` / `_h` / `_s`, see header) may not. Enemy files use `_h` for heavy weapons (always
|
||||
* allowed), and so may summons.
|
||||
* @param {string} defId the unit's model id (sfx.units key)
|
||||
* @param {string} url
|
||||
*/
|
||||
export function normalAttackSfx(defId, url) {
|
||||
return typeof url === 'string' && !(typeof defId === 'string' && defId.startsWith('char_') && SKILL_MODE_FILE.test(url));
|
||||
}
|
||||
|
||||
/** Concurrency + cooldown gate for battle SFX. Pure (time is passed in). */
|
||||
/** Gestures that may unlock audio: iOS Safari only accepts touchend / click / keydown; pointerdown covers the rest. */
|
||||
const UNLOCK_EVENTS = ['pointerdown', 'touchend', 'click', 'keydown'];
|
||||
|
||||
export class SfxLimiter {
|
||||
/** @param {{ maxVoices?: number, unitCooldownMs?: number, urlGapMs?: number }} [o] */
|
||||
/** @param {{ maxVoices?: number, unitCooldownMs?: number, urlGapMs?: number, maxPerUrl?: number }} [o] */
|
||||
constructor(o = {}) {
|
||||
this.maxVoices = o.maxVoices ?? MAX_VOICES;
|
||||
this.unitCooldownMs = o.unitCooldownMs ?? UNIT_COOLDOWN_MS;
|
||||
this.urlGapMs = o.urlGapMs ?? URL_GAP_MS;
|
||||
// the official battle banks (attack, impact, heal, born, dead…) allow at most 2 overlapping copies of a sound
|
||||
// (audio_data maxSoundAllowed 2): a heal / impact heard on every tick of a crowd never piles up
|
||||
this.maxPerUrl = o.maxPerUrl ?? MAX_PER_URL;
|
||||
this.active = 0;
|
||||
this.lastByUnit = new Map();
|
||||
this.lastByUrl = new Map();
|
||||
this.activeByUrl = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a sound may start now; records it when allowed (call `release()` when it ends).
|
||||
* Whether a sound may start now; records it when allowed (call `release(url)` when it ends).
|
||||
* @param {number} now ms
|
||||
* @param {string|number|null} unitKey e.g. `${unitId}:atk`
|
||||
* @param {string} url
|
||||
*/
|
||||
tryAcquire(now, unitKey, url) {
|
||||
if (this.active >= this.maxVoices) return false;
|
||||
if ((this.activeByUrl.get(url) || 0) >= this.maxPerUrl) return false;
|
||||
if (unitKey != null) {
|
||||
const t = this.lastByUnit.get(unitKey);
|
||||
if (t != null && now - t < this.unitCooldownMs) return false;
|
||||
@@ -167,10 +201,16 @@ export class SfxLimiter {
|
||||
if (this.lastByUnit.size > 600) this.lastByUnit.clear();
|
||||
if (this.lastByUrl.size > 400) this.lastByUrl.clear();
|
||||
this.active += 1;
|
||||
this.activeByUrl.set(url, (this.activeByUrl.get(url) || 0) + 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
release() { this.active = Math.max(0, this.active - 1); }
|
||||
/** A sound started by tryAcquire ended. */
|
||||
release(url) {
|
||||
this.active = Math.max(0, this.active - 1);
|
||||
const n = this.activeByUrl.get(url) || 0;
|
||||
if (n <= 1) this.activeByUrl.delete(url); else this.activeByUrl.set(url, n - 1);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- manager -----------------------------------------------------------------------------------------------
|
||||
@@ -195,7 +235,7 @@ export class AudioManager {
|
||||
this.bgm = null; // { key, loopUrl, nodes: [{src, gain}], gain }
|
||||
this.bgmToken = 0;
|
||||
this.units = new Map(); // battle unit id → defId
|
||||
this.lastAttacker = new Map(); // target id → attacker defId
|
||||
this.lastAttacker = new Map(); // target id → { def, at } of the hostile attack last aimed at it (its impact sound)
|
||||
this.consumed = new Set(); // summons used up by their own effect (香槟炸弹 exploded): no death sound
|
||||
this.installed = false;
|
||||
this._unlock = this._unlock.bind(this);
|
||||
@@ -454,7 +494,7 @@ export class AudioManager {
|
||||
if (limited) { if (!this.limiter.tryAcquire(now, unitKey, url)) return; }
|
||||
else if (this.uiVoices >= 12) return;
|
||||
else this.uiVoices += 1;
|
||||
const release = () => { if (limited) this.limiter.release(); else this.uiVoices = Math.max(0, this.uiVoices - 1); };
|
||||
const release = () => { if (limited) this.limiter.release(url); else this.uiVoices = Math.max(0, this.uiVoices - 1); };
|
||||
this._buffer(url).then((buf) => {
|
||||
if (!buf || !this.ctx) { release(); return; }
|
||||
try {
|
||||
@@ -507,6 +547,7 @@ export class AudioManager {
|
||||
const own = kind === 'skill' && Number.isInteger(skillIndex) && u?.skills ? u.skills[skillIndex] : null;
|
||||
const url = typeof own === 'string' ? own : u?.[kind];
|
||||
if (typeof url !== 'string') return false;
|
||||
if ((kind === 'attack' || kind === 'hit') && !normalAttackSfx(defId, url)) return false;
|
||||
this._play(url, { volume: kind === 'attack' || kind === 'hit' ? 0.55 : 0.8, limited: true, unitKey: `${unitId}:${kind}` });
|
||||
return true;
|
||||
} catch { return false; }
|
||||
@@ -542,18 +583,25 @@ export class AudioManager {
|
||||
handleBattleEvents(ev) {
|
||||
if (!this.ctx || !Array.isArray(ev)) return;
|
||||
try {
|
||||
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||
for (const e of ev) {
|
||||
if (!Array.isArray(e)) continue;
|
||||
const kind = e[0];
|
||||
if (kind === 'spawn') { this._track(e[1]); continue; }
|
||||
if (kind === 'atk') {
|
||||
// a chain bounce: its first id is the previous target, whose attack sound this is not (see header)
|
||||
if (CHAIN_KINDS.has(e[3])) { this.lastAttacker.delete(e[2]); continue; }
|
||||
const src = this.units.get(e[1]);
|
||||
if (!src) continue;
|
||||
this.lastAttacker.set(e[2], src.def);
|
||||
// only a hostile attack authors the target's next impact (a heal — an ally aiming at an ally — never does)
|
||||
const tgt = this.units.get(e[2]);
|
||||
if (tgt && tgt.side !== src.side) this.lastAttacker.set(e[2], { def: src.def, at: now });
|
||||
if (!this.unit(src.def, 'attack', e[1]) && src.side === 'enemy') this.battle('enemyHit', { unitKey: `${e[1]}:atk`, volume: 0.35 });
|
||||
} else if (kind === 'dmg') {
|
||||
const by = this.lastAttacker.get(e[1]);
|
||||
if (by) this.unit(by, 'hit', `h${e[1]}`);
|
||||
if (!by || !IMPACT_TYPES.has(e[3])) continue;
|
||||
this.lastAttacker.delete(e[1]); // one impact per attack
|
||||
if (now - by.at <= IMPACT_WINDOW_MS) this.unit(by.def, 'hit', `h${e[1]}`);
|
||||
} else if (kind === 'heal') {
|
||||
this.battle('heal', { unitKey: `heal:${e[1]}`, volume: 0.35 });
|
||||
} else if (kind === 'skill' && e[2]) {
|
||||
|
||||
@@ -32,12 +32,19 @@
|
||||
// battleRunner.on('snap' | 'ev' | 'field' | 'state', fn) → off
|
||||
// battleRunner.state() → { battleId, fieldId, kind, authoritative, watch, done, own, members, loading, paused, leaks } | null
|
||||
// battleRunner.stats() → { ticks, stepMs, avgTickMs, maxFrameMs, catchups, errors, battles }
|
||||
// battleRunner.unitStats(unitId, fieldId?) → the live stats of a unit of the battle on screen (shared/protocol.js
|
||||
// unitStatsEntry: current HP, effective max HP / ATK / DEF / RES / interval / block / move
|
||||
// speed next to its base) | null — the detail card reads it a few times a second (user
|
||||
// playtest #4 item 7). Read-only: it takes the stats the sim computed last (`unit._s`) and
|
||||
// never makes the unit recompute them, so looking never changes the battle's floats.
|
||||
// battleRunner.unitIdOf(uid, ownerId, fieldId?) → the id of an own board piece's unit in that battle | null
|
||||
//
|
||||
// createBattleRunner(deps) builds an instance with injectable net / store / clock / frame scheduler / sim loader
|
||||
// (test/match/runner.test.js drives it under Node).
|
||||
|
||||
import { net as appNet } from '../net.js';
|
||||
import { store as appStore } from '../store.js';
|
||||
import { unitStatsEntry } from '../../../shared/protocol.js';
|
||||
|
||||
const TICK = 1 / 30;
|
||||
/** Fast-forward budget per frame (ticks) when far behind. */
|
||||
@@ -561,6 +568,32 @@ export function createBattleRunner(deps) {
|
||||
stats() {
|
||||
return { ...stats, avgTickMs: stats.ticks ? stats.stepMs / stats.ticks : 0, entries: entries.size, loadingSim: !!simP };
|
||||
},
|
||||
/**
|
||||
* Live stats of unit `unitId` of the battle on screen (null: no such battle / unit, or `fieldId` names another
|
||||
* field). Reads the sim's last computed stats (`unit._s`, falling back to the base) — never `unit.s`, whose lazy
|
||||
* recompute would run earlier than the sim itself would run it.
|
||||
* @param {number} unitId @param {string|null} [fieldId]
|
||||
*/
|
||||
unitStats(unitId, fieldId = null) {
|
||||
const e = cur;
|
||||
if (!e || !Number.isInteger(unitId) || (fieldId != null && e.fieldId !== fieldId)) return null;
|
||||
let u = null;
|
||||
try { u = typeof e.battle.unitById === 'function' ? e.battle.unitById(unitId) : null; } catch { u = null; }
|
||||
if (!u) return null;
|
||||
try { return unitStatsEntry(u, u._s || null); } catch { return null; }
|
||||
},
|
||||
/**
|
||||
* The unit id of the board piece `uid` owned by `ownerId` in the battle on screen (an own operator's card opened in
|
||||
* prep and left open into the battle turns live); null when that battle has no such unit.
|
||||
* @param {number} uid @param {string} ownerId @param {string|null} [fieldId]
|
||||
*/
|
||||
unitIdOf(uid, ownerId, fieldId = null) {
|
||||
const e = cur;
|
||||
if (!e || !Number.isInteger(uid) || (fieldId != null && e.fieldId !== fieldId)) return null;
|
||||
const list = Array.isArray(e.battle.allyUnits) ? e.battle.allyUnits : [];
|
||||
const u = list.find((x) => x && x.uid === uid && x.ownerId === ownerId);
|
||||
return u && Number.isInteger(u.id) ? u.id : null;
|
||||
},
|
||||
/** Re-show the current battle (the game screen remounted). */
|
||||
reshow() { if (cur) show(cur); },
|
||||
/** Test / debug hooks. */
|
||||
|
||||
+103
-144
@@ -8,28 +8,26 @@
|
||||
// view.enterBattle(fieldMeta) m.field { fieldId, kind, rect, stageId, units: [UnitInfo] }
|
||||
// view.pushSnapshot(snap); view.pushEvents(ev | { ev, gt }) b.snap / b.ev wire frames as received (game time in
|
||||
// `gt`; a numeric `t` is accepted for raw Battle snapshots / recordings)
|
||||
// — 100 ms interpolation buffer
|
||||
// — 100 ms interpolation buffer; b.snap `down` keeps knocked-out
|
||||
// operators on the field under a redeploy ring and `elem` draws the
|
||||
// element gauges (user playtest #4 items 8 / 9, render/units.js)
|
||||
// view.setLocalFeed({ on, speed }) frames come from the local sim every frame (client-side combat):
|
||||
// ~2-frame buffer at the battle's game speed
|
||||
// view.highlightTiles(tiles, style) [[r,c]] | [{row,col}]; style 'legal'|'illegal'|'range'|'rangeStand'|
|
||||
// 'hover'|'target'|{color,fill,line,group}; highlightTiles(null) clears all
|
||||
// view.on(name, fn) → unsubscribe ; view.off(name, fn)
|
||||
// pieceDragStart { uid, piece, from } · pieceDrop { uid, piece, from, target, over } · pieceDragEnd {uid, dropped, over}
|
||||
// pieceDragOver { uid, over, clientX, clientY } — while an item is dragged: `over` = uid of the own operator /
|
||||
// summon under the dragged plate (null: none), sent when it changes, BEFORE the drag asks canPlace
|
||||
// pieceDragStart { uid, piece, from } · pieceDrop { uid, piece, from, target } · pieceDragEnd {uid, dropped}
|
||||
// pieceClick { uid, piece, button, detail, clientX, clientY } (battle units: { unitId, uid, unit, … })
|
||||
// pieceDetail (right-click / long-press) · pieceHover { uid } | { uid: null } (battle: + unitId, unit)
|
||||
// tileHover { row, col, area, idx } | null (while dragging: the drop target, the tile under the ghost's feet)
|
||||
// view.pieceScreenRect(uid) → { left, top, right, bottom, width, height, x, y, shape } (client px) | null;
|
||||
// `shape` = the body for render/pick.js (ui/gameLogic.js pickPieceAt)
|
||||
// Picking (user playtest #3 item 7): every "which unit is under the pointer" — prep press / drag start / detail
|
||||
// (pieceAt), battle clicks and hover (battleUnitAt), the pen (penUnitAt), the operator under a dragged item
|
||||
// (pieceUnder) — is render/pick.js pickBody over the views' drawn bodies (UnitView.pickShape) and the tile under the
|
||||
// pointer, with a pixel probe (drawnAt: a view rendered alone into a 1×1 target) wherever bodies overlap: the front-most
|
||||
// unit whose model draws the pixel under the pointer wins; elsewhere the face / torso of a unit is its own, then the
|
||||
// unit standing on the pressed tile. A dragged
|
||||
// piece's ghost stands on its drop point — the pointer (mouse / pen) or TOUCH_LIFT_TILES above the finger (touch) —
|
||||
// and the drop target is the tile under it (render/drag.js dropPoint).
|
||||
// tileHover { row, col, area, idx } | null (while dragging: the drop target — the tile under the pointer)
|
||||
// view.pieceScreenRect(uid) → { left, top, right, bottom, width, height, x, y } (client px: the drawn body) | null
|
||||
// Picking (user playtest #4 item 1: the ground is drawn as tiles — a press on a tile is a press on the unit standing
|
||||
// there): every "which unit is under the pointer" — prep press / click / detail / drag start / hover (pieceAt), battle
|
||||
// clicks and hover (battleUnitAt), the pen (penUnitAt) — is render/pick.js over the tile under the pointer (groundTile:
|
||||
// raised tops first); battle enemies, which walk between tiles, by their ground position (flying ones by their drawn
|
||||
// body). A dragged piece is held under the pointer — a unit with its drawn feet DRAG_HOLD_TILES below it (the pointer
|
||||
// on its body), an item plate centred on it — and drops on the tile under the pointer (render/drag.js); an item
|
||||
// dropped on a unit's tile equips that unit.
|
||||
// Direction step (ui/facingWheel.js, research 09 §1.2):
|
||||
// view.tileScreen(row, col) → { x, y, s, poly: [[x,y]×4] } (client px: tile-top centre, px per tile, corners) | null
|
||||
// view.holdPiece(uid, {row,col} | null) keep a dropped prep piece standing on a tile while its direction is
|
||||
@@ -95,7 +93,7 @@ import { BoardScene } from './board3d/scene.js';
|
||||
import { AREAS, areaFor, unionAreas } from './board3d/layout.js';
|
||||
import { layoutPen, penSignature } from './pen.js';
|
||||
import { IDENTITY, bossPrepField, tilesToDisp } from './prepfield.js';
|
||||
import { pickBody } from './pick.js';
|
||||
import { pickOnTile, pickBattle } from './pick.js';
|
||||
|
||||
const VENDOR = { pixi: '/vendor/pixi.min.js', spine: '/vendor/pixi-spine.js' };
|
||||
const PIECE_DIRS = new Set(['UP', 'RIGHT', 'DOWN', 'LEFT']);
|
||||
@@ -112,20 +110,26 @@ const RANGE_GROUPS = new Set(['facing', 'range', 'rangeStand', 'select', 'sel',
|
||||
/** atk projectile kinds whose first id is the previous bounce target (sim ai.js), not the attacker. */
|
||||
const CHAIN_KINDS = new Set(['chain', 'chainHeal']);
|
||||
const DROP_PENDING_MS = 1300;
|
||||
/** A touch drag holds its ghost — and so its drop point — this many tiles above the finger (neither is hidden). */
|
||||
export const TOUCH_LIFT_TILES = 0.6;
|
||||
/**
|
||||
* A dragged unit is held with its drawn feet this many tiles below the pointer — the pointer on its body, the model
|
||||
* under the finger / mouse (user playtest #4 item 1: as in v2.1; mouse and touch alike). An item plate is centred on it.
|
||||
*/
|
||||
export const DRAG_HOLD_TILES = 0.45;
|
||||
|
||||
/** The display tile a view stands on (its drawn position). */
|
||||
const viewTile = (v) => ({ row: Math.round(v.y), col: Math.round(v.x) });
|
||||
/** A view's body for render/pick.js, or null when it cannot be picked (gone, faded out, dead). */
|
||||
function pickShapeOf(v, tile) {
|
||||
if (!v || v.destroyed || typeof v.pickShape !== 'function' || v.alive === false) return null;
|
||||
/**
|
||||
* A view as a render/pick.js unit, or null when it cannot be picked (gone, faded out, dead): standing on the display
|
||||
* tile it is drawn on, or — `walks` (battle enemies) — by its ground position and its drawn body (feet to head, on
|
||||
* screen; a flying one by its body alone).
|
||||
*/
|
||||
function pickUnitOf(v, walks = false) {
|
||||
// a knocked-out operator lying on its tile waiting to redeploy (b.snap `down`, user playtest #4 item 9) is on that tile
|
||||
// too: a press there selects it; other dead / dying views are gone
|
||||
if (!v || v.destroyed || (v.alive === false && !v.down)) return null;
|
||||
if (Number.isFinite(v.alpha) && v.alpha < 0.05) return null;
|
||||
const b = v.pickShape();
|
||||
if (!b) return null;
|
||||
b.tile = tile === undefined ? viewTile(v) : tile;
|
||||
b.ref = v;
|
||||
return b;
|
||||
const sc = v.screen;
|
||||
const body = walks && sc && sc.s > 0 && !v.culled ? { x: sc.x, top: Number.isFinite(sc.top) ? sc.top : sc.y, feet: sc.y, s: sc.s } : null;
|
||||
const tile = walks ? null : { row: Math.round(v.y), col: Math.round(v.x) };
|
||||
return { tile, x: v.x, y: v.y, fly: walks && !!v.flying, body, depth: v.root && !v.root.destroyed ? v.root.zIndex : 0, ref: v };
|
||||
}
|
||||
|
||||
let pixiPromise = null;
|
||||
@@ -921,12 +925,12 @@ export async function createFieldView(host, options = {}) {
|
||||
penList = null;
|
||||
}
|
||||
|
||||
/** The pen enemy under a canvas point (render/pick.js; the figures idle on their pen tiles). */
|
||||
/** The pen enemy under a canvas point: a figure on the tile under it (render/pick.js; ≤ 3 idle per pen tile). */
|
||||
function penUnitAt(x, y) {
|
||||
if (penHidden) return null;
|
||||
const bodies = [];
|
||||
for (const v of penViews.values()) { const b = pickShapeOf(v); if (b) bodies.push(b); }
|
||||
const hit = pickBody(bodies, x, y, groundTile(x, y), probeAt(x, y));
|
||||
const units = [];
|
||||
for (const v of penViews.values()) { const u = pickUnitOf(v); if (u) units.push(u); }
|
||||
const hit = pickOnTile(units, groundTile(x, y));
|
||||
return hit ? hit.ref : null;
|
||||
}
|
||||
|
||||
@@ -937,100 +941,28 @@ export async function createFieldView(host, options = {}) {
|
||||
|
||||
// ---- drag & pointer ----------------------------------------------------------------------------------------
|
||||
|
||||
/** The display tile under a canvas point (raised tops first), or null off the grid. */
|
||||
/** The display tile under a canvas point (raised tops first) and the point on its top (world x, y), or null. */
|
||||
function groundTile(x, y) {
|
||||
const t = pickTile(cam, x, y, heightAt, tiles.levels);
|
||||
return t ? { row: t.row, col: t.col } : null;
|
||||
return t ? { row: t.row, col: t.col, x: t.x, y: t.y } : null;
|
||||
}
|
||||
|
||||
// pixel probe of render/pick.js: one view's display tree rendered alone into a 1×1 target over the canvas pixel
|
||||
let probeRT = null;
|
||||
let probeM = null;
|
||||
/**
|
||||
* Does view `v` draw an opaque pixel at canvas point (x, y) — as last rendered (its world transforms of the last
|
||||
* frame: what the player sees)? null when it cannot tell (no WebGL readback, lost context…). Only asked where two or
|
||||
* more bodies overlap (render/pick.js probeBodies), i.e. a few 1-px renders per press / hover move there.
|
||||
*/
|
||||
function drawnAt(v, x, y) {
|
||||
const root = v && v.root;
|
||||
if (!root || root.destroyed) return false;
|
||||
if (!root.visible || !(root.worldAlpha > 0.02)) return false;
|
||||
try {
|
||||
const R = app.renderer;
|
||||
if (!R || !R.extract || typeof R.extract.pixels !== 'function' || !P.RenderTexture || !P.Matrix) return null;
|
||||
if (!probeRT) probeRT = P.RenderTexture.create({ width: 1, height: 1, resolution: 1 });
|
||||
if (!probeM) probeM = new P.Matrix();
|
||||
probeM.set(1, 0, 0, 1, 0.5 - x, 0.5 - y); // the pixel centred on the point
|
||||
R.render(root, { renderTexture: probeRT, clear: true, transform: probeM, skipUpdateTransform: true });
|
||||
const px = R.extract.pixels(probeRT);
|
||||
return px && px.length >= 4 ? px[3] > 40 : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
const probeAt = (x, y) => (b) => drawnAt(b.ref, x, y);
|
||||
// Hover picks (mouse moves over the field, item drags) probe at most once per frame: every readback waits for the
|
||||
// GPU and a mouse reports up to 1000 moves per second, so a second hover pick in the same frame gets the shape rule
|
||||
// alone (it only moves a highlight). Presses, releases and drops always probe.
|
||||
let hoverProbeFrame = -1;
|
||||
const hoverProbeAt = (x, y) => {
|
||||
if (hoverProbeFrame === frameNo) return null;
|
||||
hoverProbeFrame = frameNo;
|
||||
return probeAt(x, y);
|
||||
};
|
||||
|
||||
/** The prep piece entry under a canvas point (render/pick.js), among the entries `accept` lets through. */
|
||||
function prepEntryAt(x, y, accept = null, hover = false) {
|
||||
const bodies = [];
|
||||
for (const e of prepPieces) {
|
||||
if (accept && !accept(e)) continue;
|
||||
const b = pickShapeOf(views.get(e.key));
|
||||
if (b) { b.entry = e; bodies.push(b); }
|
||||
}
|
||||
const hit = pickBody(bodies, x, y, groundTile(x, y), hover ? hoverProbeAt(x, y) : probeAt(x, y));
|
||||
return hit ? hit.entry : null;
|
||||
}
|
||||
|
||||
function pieceAt(x, y, hover = false) {
|
||||
/** The prep piece under a canvas point: the one on the tile under it (render/pick.js; board, bench and temp rows). */
|
||||
function pieceAt(x, y) {
|
||||
if (mode !== 'prep') return null;
|
||||
const best = prepEntryAt(x, y, null, hover);
|
||||
const units = [];
|
||||
for (const e of prepPieces) {
|
||||
const u = pickUnitOf(views.get(e.key));
|
||||
if (u) { u.entry = e; units.push(u); }
|
||||
}
|
||||
const best = pickOnTile(units, groundTile(x, y))?.entry;
|
||||
if (!best) return null;
|
||||
return { uid: best.uid, kind: best.piece.kind, id: best.piece.id, area: best.area, idx: best.idx, row: best.row, col: best.col, piece: best.piece, draggable: true };
|
||||
}
|
||||
|
||||
/** uid of the own operator / summon under a canvas point (not `exceptUid`): where a dragged item would go. */
|
||||
function pieceUnder(x, y, exceptUid, hover = false) {
|
||||
if (mode !== 'prep') return null;
|
||||
const e = prepEntryAt(x, y, (en) => en.uid !== exceptUid && (en.piece.kind === 'chess' || en.piece.kind === 'token'), hover);
|
||||
return e ? e.uid : null;
|
||||
}
|
||||
|
||||
// the current drag's drop point and the own unit under it (items): { uid, over, x, y }
|
||||
let dragHit = null;
|
||||
/**
|
||||
* Drop point of a dragged piece (render/drag.js dropPoint): the pointer, or TOUCH_LIFT_TILES above a finger. For an
|
||||
* item it also finds the operator under the plate and tells the UI (pieceDragOver) before the drag asks canPlace
|
||||
* (a move is a hover pick; the release (`final`) always probes).
|
||||
*/
|
||||
function dropPoint(e, piece, final = false) {
|
||||
let x = e.x, y = e.y;
|
||||
if (e.pointerType === 'touch') {
|
||||
const g = cam.unproject(x, y, 0);
|
||||
y -= TOUCH_LIFT_TILES * (g ? cam.scaleAt(g.x, g.y, 0) : cam.scale);
|
||||
}
|
||||
const uid = piece ? piece.uid : null;
|
||||
const over = piece && piece.kind === 'item' ? pieceUnder(x, y, uid, !final) : null;
|
||||
const changed = !dragHit || dragHit.uid !== uid || dragHit.over !== over;
|
||||
dragHit = { uid, over, x, y };
|
||||
if (changed && piece && piece.kind === 'item') {
|
||||
const r = canvas.getBoundingClientRect();
|
||||
emit('pieceDragOver', { uid, over, clientX: r.left + x, clientY: r.top + y });
|
||||
}
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
const drag = createDragController({
|
||||
hitPiece: (x, y, hover) => pieceAt(x, y, !!hover),
|
||||
hitPiece: (x, y) => pieceAt(x, y),
|
||||
pickTile: (x, y) => pickBoardTile(x, y),
|
||||
dropPoint: (e, piece, final) => dropPoint(e, piece, !!final),
|
||||
isOverCanvas: (cx, cy) => {
|
||||
try { const el = document.elementFromPoint(cx, cy); return !el || el === canvas; } catch { return true; }
|
||||
},
|
||||
@@ -1044,10 +976,8 @@ export async function createFieldView(host, options = {}) {
|
||||
|
||||
function onDragEvent(name, payload) {
|
||||
const out = payload && payload.piece ? { ...payload, piece: publicPiece(payload.piece) } : payload;
|
||||
// the own unit under a dropped item (render/pick.js at the drop point; null for units / nothing there)
|
||||
if ((name === 'pieceDrop' || name === 'pieceDragEnd') && out && typeof out === 'object') out.over = dragHit && dragHit.uid === payload.uid ? dragHit.over : null;
|
||||
switch (name) {
|
||||
case 'pieceDragStart': dragHit = null; startDragVisual(payload); break;
|
||||
case 'pieceDragStart': startDragVisual(payload); break;
|
||||
case 'pieceDragMove': moveDragVisual(payload); return; // internal
|
||||
case 'pieceDrop': dropDragVisual(payload); break;
|
||||
case 'pieceDragEnd': if (!payload.dropped) endDragVisual(true); else clearHl('dragTarget'); break;
|
||||
@@ -1111,11 +1041,18 @@ export async function createFieldView(host, options = {}) {
|
||||
function moveDragVisual(p) {
|
||||
if (!dragState) return;
|
||||
const v = dragState.view;
|
||||
// the ghost stands (an item: floats centred) on its drop point — the tile the drop goes to is the one under it
|
||||
const hx = Number.isFinite(p.hx) ? p.hx : p.x, hy = Number.isFinite(p.hy) ? p.hy : p.y;
|
||||
const t = pickTile(cam, hx, hy, heightAt, tiles.levels);
|
||||
// held under the pointer: a unit with its drawn (lifted) feet DRAG_HOLD_TILES of its own px per tile below it (the
|
||||
// scale at the pointer's ground, refined once at the feet), an item plate centred on it; the drop target
|
||||
// (p.target, highlighted below) is the tile under the pointer
|
||||
const item = v instanceof ItemView;
|
||||
const g = cam.unproject(p.x, p.y, 0);
|
||||
const hold = item ? 0 : DRAG_HOLD_TILES;
|
||||
const s0 = g ? cam.scaleAt(g.x, g.y, 0) : cam.scale;
|
||||
const t = pickTile(cam, p.x, p.y + hold * s0, heightAt, tiles.levels);
|
||||
const z = t ? heightAt(t.row, t.col) : 0;
|
||||
const w = cam.unproject(hx, hy, z);
|
||||
const up = item ? z : z + (v.lift || 0) + (v.hover || 0);
|
||||
let w = cam.unproject(p.x, p.y + hold * s0, up);
|
||||
if (w && hold) w = cam.unproject(p.x, p.y + hold * cam.scaleAt(w.x, w.y, up), up) || w;
|
||||
if (w) {
|
||||
v.x = Math.max(-1, Math.min(21, w.x));
|
||||
v.y = Math.max(-1, Math.min(19, w.y));
|
||||
@@ -1167,15 +1104,15 @@ export async function createFieldView(host, options = {}) {
|
||||
return { pointerId: e.pointerId, pointerType: e.pointerType, button: e.button, x: p.x, y: p.y, clientX: e.clientX, clientY: e.clientY };
|
||||
}
|
||||
|
||||
/** The battle unit under a canvas point (render/pick.js; allies stand on their tiles, enemies walk: no tile). */
|
||||
function battleUnitAt(x, y, hover = false) {
|
||||
const bodies = [];
|
||||
/** The battle unit under a canvas point (render/pick.js: an ally on the tile under it, an enemy near its ground). */
|
||||
function battleUnitAt(x, y) {
|
||||
const units = [];
|
||||
for (const v of views.values()) {
|
||||
if (!v.info || v.info.kind === 'device') continue;
|
||||
const b = pickShapeOf(v, v.info.side === 'ally' ? viewTile(v) : null);
|
||||
if (b) bodies.push(b);
|
||||
const u = pickUnitOf(v, v.info.side !== 'ally');
|
||||
if (u) units.push(u);
|
||||
}
|
||||
const hit = pickBody(bodies, x, y, groundTile(x, y), hover ? hoverProbeAt(x, y) : probeAt(x, y));
|
||||
const hit = pickBattle(units, groundTile(x, y), x, y);
|
||||
return hit ? hit.ref : null;
|
||||
}
|
||||
|
||||
@@ -1206,7 +1143,7 @@ export async function createFieldView(host, options = {}) {
|
||||
const ev = evPayload(e);
|
||||
if (mode === 'battle') {
|
||||
if (e.pointerType === 'touch') return;
|
||||
const v = battleUnitAt(ev.x, ev.y, true);
|
||||
const v = battleUnitAt(ev.x, ev.y);
|
||||
if (v !== hoverUnit) {
|
||||
hoverUnit = v;
|
||||
const info = v ? infos.get(v.id) || v.info : null;
|
||||
@@ -1220,12 +1157,18 @@ export async function createFieldView(host, options = {}) {
|
||||
const onPointerCancel = (e) => { if (!destroyed) drag.pointerCancel(evPayload(e)); };
|
||||
const onPointerLeave = (e) => { if (!destroyed && !drag.dragging) drag.pointerLeave(evPayload(e)); if (hoverUnit) { hoverUnit = null; emit('pieceHover', { uid: null, unitId: null }); } };
|
||||
const onContext = (e) => e.preventDefault();
|
||||
// A finger is handled through the pointer events above only. The compatibility mouse events + click of a tap come
|
||||
// after touchend, hit-tested at the finger again — where the tap may just have opened DOM UI: a tap on a unit's tile
|
||||
// selects it and its underframe opens over the tile (clamped under the top bar on a phone), and the click pressed
|
||||
// 撤退 / 出售 (user playtest #4 item 1 on a phone). Cancelling touchend drops them.
|
||||
const onTouchEnd = (e) => { if (e.cancelable) e.preventDefault(); };
|
||||
canvas.addEventListener('pointerdown', onPointerDown);
|
||||
canvas.addEventListener('pointermove', onPointerMove);
|
||||
canvas.addEventListener('pointerup', onPointerUp);
|
||||
canvas.addEventListener('pointercancel', onPointerCancel);
|
||||
canvas.addEventListener('pointerleave', onPointerLeave);
|
||||
canvas.addEventListener('contextmenu', onContext);
|
||||
canvas.addEventListener('touchend', onTouchEnd, { passive: false });
|
||||
|
||||
// ---- battle ---------------------------------------------------------------------------------------------
|
||||
|
||||
@@ -1436,6 +1379,7 @@ export async function createFieldView(host, options = {}) {
|
||||
}
|
||||
|
||||
let renderT0Battle = null; // game time of the first rendered battle frame (spawn puffs skip the initial wave)
|
||||
let downSeq = 0; // syncBattle pass counter: a view still marked down after a pass left the `down` list
|
||||
function syncBattle(renderT) {
|
||||
if (renderT0Battle == null) renderT0Battle = renderT;
|
||||
interp.sample(renderT, sample);
|
||||
@@ -1448,13 +1392,36 @@ export async function createFieldView(host, options = {}) {
|
||||
if (!v) v = battleView(id) || createUnknown(id, s);
|
||||
if (!v) continue;
|
||||
if (!v._seen) { v._seen = true; v.fadeIn = 0; }
|
||||
if (v.alive || v.info?.kind === 'device') v.sync(s);
|
||||
if (v.alive || v.info?.kind === 'device') v.sync(s, renderT);
|
||||
else if (v.dying > 0) { v.x = s.x; v.y = s.y; }
|
||||
else if (s.anim !== ANIM.DIE && s.hp > 0) { v.revive?.(); v.sync(s); }
|
||||
else if (s.anim !== ANIM.DIE && s.hp > 0) { v.revive?.(); v.sync(s, renderT); }
|
||||
}
|
||||
// knocked-out operators waiting to redeploy (b.snap `down`, user playtest #4 item 9): their view stays on the
|
||||
// field knocked down under a redeploy ring (UnitView.setDown) — made on the spot for one already down when this
|
||||
// field was entered; one that leaves the list without a redeploy fades out
|
||||
const down = interp.downAt(renderT);
|
||||
downSeq++;
|
||||
if (down) {
|
||||
for (const d of down) {
|
||||
let v = views.get(d[0]);
|
||||
let fresh = false;
|
||||
if (!v) {
|
||||
const info = infos.get(d[0]);
|
||||
if (!info || info.side !== 'ally' || info.kind === 'device') continue;
|
||||
gone.delete(d[0]);
|
||||
v = battleView(d[0]);
|
||||
fresh = true;
|
||||
}
|
||||
if (!v || typeof v.setDown !== 'function') continue;
|
||||
v._seen = true;
|
||||
v.setDown(d, renderT, fresh);
|
||||
v._downSeq = downSeq;
|
||||
}
|
||||
}
|
||||
const now = performance.now();
|
||||
for (const [id, v] of views) {
|
||||
if (sample.has(id)) continue;
|
||||
if (v.down && v._downSeq !== downSeq) v.setDown(null, renderT);
|
||||
if (sample.has(id) || v.down) continue;
|
||||
if (v._seen) {
|
||||
// left the snapshot: removed (leaked / hidden / died and its DIE window passed)
|
||||
if (v.alive) { v.alive = false; v.dying = 0.25; v.dieDur = 0.25; }
|
||||
@@ -1654,14 +1621,7 @@ export async function createFieldView(host, options = {}) {
|
||||
const b = v.bounds();
|
||||
const r = canvas.getBoundingClientRect();
|
||||
const left = r.left + b.x, top = r.top + b.y;
|
||||
// the drawn body in client px (render/pick.js, ui/gameLogic.js pickPieceAt)
|
||||
let shape = null;
|
||||
if (typeof v.pickShape === 'function') {
|
||||
const s = v.pickShape();
|
||||
const hud = s.hud ? { x0: s.hud.x0 + r.left, y0: s.hud.y0 + r.top, x1: s.hud.x1 + r.left, y1: s.hud.y1 + r.top } : null;
|
||||
shape = { ...s, x: s.x + r.left, y: s.y + r.top, hud };
|
||||
}
|
||||
return { left, top, right: left + b.width, bottom: top + b.height, width: b.width, height: b.height, x: left, y: top, shape };
|
||||
return { left, top, right: left + b.width, bottom: top + b.height, width: b.width, height: b.height, x: left, y: top };
|
||||
},
|
||||
/**
|
||||
* Screen geometry of a BOARD-space tile (in the Final Assault prep: where that board tile is drawn on the boss
|
||||
@@ -1736,6 +1696,7 @@ export async function createFieldView(host, options = {}) {
|
||||
canvas.removeEventListener('pointercancel', onPointerCancel);
|
||||
canvas.removeEventListener('pointerleave', onPointerLeave);
|
||||
canvas.removeEventListener('contextmenu', onContext);
|
||||
canvas.removeEventListener('touchend', onTouchEnd);
|
||||
app.ticker.remove(frame);
|
||||
app.ticker.remove(preRender);
|
||||
app.ticker.remove(postRender);
|
||||
@@ -1746,8 +1707,6 @@ export async function createFieldView(host, options = {}) {
|
||||
try { fx.destroy(); } catch { /* ignore */ }
|
||||
try { tiles.destroy(); } catch { /* ignore */ }
|
||||
try { impostors.destroy(); } catch { /* ignore */ }
|
||||
try { probeRT?.destroy(true); } catch { /* ignore */ }
|
||||
probeRT = null;
|
||||
listeners.clear();
|
||||
try { board3d?.destroy(); } catch { /* ignore */ }
|
||||
board3d = null;
|
||||
@@ -1768,8 +1727,8 @@ export async function createFieldView(host, options = {}) {
|
||||
/** Dev hooks (demo / tests). */
|
||||
debug: {
|
||||
app, get cam() { return cam; }, get board3d() { return board3d; }, tiles, views, penViews, interp, fx, ctx, drag, get camKind() { return viewKind(camKind, camOpts); },
|
||||
// picking (render/pick.js) at canvas px: the prep piece / battle view / pen view / own unit under a dragged item
|
||||
pick: { pieceAt, battleUnitAt, penUnitAt, pieceUnder, groundTile, drawnAt }, get dragHit() { return dragHit; },
|
||||
// picking (render/pick.js) at canvas px: the prep piece / battle view / pen view there, the ground tile under it
|
||||
pick: { pieceAt, battleUnitAt, penUnitAt, groundTile },
|
||||
},
|
||||
};
|
||||
return view;
|
||||
|
||||
+13
-30
@@ -9,11 +9,10 @@
|
||||
// target (temp slot, enemy lane, `canPlace` false, or the piece's own bench slot) cancels the drag without a
|
||||
// pieceDrop. A board piece released on its OWN tile is a drop (when `canPlace` allows it): the UI opens the
|
||||
// direction wheel to re-orient it in place (research 09 §1.2 — "drag the unit onto its own tile and swipe").
|
||||
// While dragging, the target is the tile under the dragged ghost's feet: `dropPoint(e, piece)` (app.js) gives that
|
||||
// canvas point — the pointer itself (mouse / pen), a little above the finger (touch) — and the ghost is drawn standing
|
||||
// on it, so what the player sees is where the piece lands (user playtest #3 item 7); whether DOM UI covers the drop is
|
||||
// tested there too (a lifted touch ghost stands on the bench while the finger is on the shop bar below it). Hover and
|
||||
// presses use the pointer.
|
||||
// Everything is judged at the pointer (user playtest #4 item 1: the ground is drawn as tiles): a press / hover is on the
|
||||
// piece standing on the tile under it (`hitPiece`, render/pick.js), and while dragging the target, `tileHover` and the
|
||||
// legality are that tile — an item dropped on a unit's tile goes to that unit; app.js holds the dragged ghost under
|
||||
// the pointer. A release over DOM UI covering the canvas (the shop bar) is 'outside' (mouse and touch alike).
|
||||
//
|
||||
// Events emitted through `emit(name, payload)`:
|
||||
// pieceHover { uid, piece, clientX, clientY } | { uid: null } (mouse/pen hover changes)
|
||||
@@ -21,8 +20,7 @@
|
||||
// pieceClick { uid, piece, button, detail, clientX, clientY } (detail: right-click / long-press)
|
||||
// pieceDetail { uid, piece, clientX, clientY } (right-click / long-press)
|
||||
// pieceDragStart { uid, piece, from }
|
||||
// pieceDragMove { uid, piece, x, y, hx, hy, clientX, clientY, target, legal } (for the ghost; high frequency;
|
||||
// hx, hy = the drop point)
|
||||
// pieceDragMove { uid, piece, x, y, clientX, clientY, target, legal } (for the ghost; high frequency)
|
||||
// pieceDrop { uid, piece, from, target }
|
||||
// pieceDragEnd { uid, piece, dropped, cancelled }
|
||||
|
||||
@@ -96,11 +94,8 @@ const DEFAULTS = { threshold: 6, touchThreshold: 10, longPressMs: 480 };
|
||||
/**
|
||||
* Pointer state machine.
|
||||
* @param {{
|
||||
* hitPiece: (x, y, hover) => (object|null), // piece under canvas point (render/pick.js rule); piece.uid required;
|
||||
* // hover = a mouse move (not a press): may skip the pixel probe
|
||||
* hitPiece: (x, y) => (object|null), // piece on the tile under canvas point (render/pick.js); piece.uid required
|
||||
* pickTile: (x, y) => ({row, col}|null), // grid tile under canvas point
|
||||
* dropPoint?: (e, piece, final) => ({x, y}|null), // canvas point of the dragged ghost's feet (default: the pointer);
|
||||
* // final = the release (a move may skip the pixel probe)
|
||||
* isOverCanvas?: (clientX, clientY) => boolean, // false when DOM UI covers the point (default true)
|
||||
* canPlace?: (piece, row, col, target) => boolean,
|
||||
* emit: (name, payload) => void,
|
||||
@@ -121,14 +116,8 @@ export function createDragController(hooks) {
|
||||
const emit = (name, payload) => { try { h.emit(name, payload); } catch (err) { console.error('[drag] listener failed', err); } };
|
||||
const draggable = (piece) => !!piece && piece.draggable !== false && piece.uid != null;
|
||||
const tileAt = (x, y) => { try { return h.pickTile(x, y); } catch { return null; } };
|
||||
const pieceAt = (x, y, hover = false) => { try { return h.hitPiece(x, y, hover); } catch { return null; } };
|
||||
const pieceAt = (x, y) => { try { return h.hitPiece(x, y); } catch { return null; } };
|
||||
const overCanvas = (cx, cy) => { try { return h.isOverCanvas ? h.isOverCanvas(cx, cy) !== false : true; } catch { return true; } };
|
||||
/** The drop point of a dragged piece (its ghost's feet) for pointer event `e`. */
|
||||
const dropAt = (e, piece, final = false) => {
|
||||
let p = null;
|
||||
try { p = typeof h.dropPoint === 'function' ? h.dropPoint(e, piece, final) : null; } catch { p = null; }
|
||||
return p && Number.isFinite(p.x) && Number.isFinite(p.y) ? p : { x: e.x, y: e.y };
|
||||
};
|
||||
|
||||
function setHoverTile(tile) {
|
||||
const slot = tile ? tileSlot(tile) : null;
|
||||
@@ -155,18 +144,13 @@ export function createDragController(hooks) {
|
||||
moveDrag(e);
|
||||
}
|
||||
|
||||
/** Is the drop point (not the finger) on the canvas? A touch ghost is lifted over the board while the finger may be
|
||||
* on the DOM below it (the shop bar under the bench on a phone): what counts is where the piece is drawn. */
|
||||
const dropOverCanvas = (e, pt) => overCanvas(e.clientX + (pt.x - e.x), e.clientY + (pt.y - e.y));
|
||||
|
||||
function moveDrag(e) {
|
||||
const pt = dropAt(e, st.piece);
|
||||
const tile = tileAt(pt.x, pt.y);
|
||||
const over = dropOverCanvas(e, pt);
|
||||
const tile = tileAt(e.x, e.y);
|
||||
const over = overCanvas(e.clientX, e.clientY);
|
||||
const slot = over && tile ? tileSlot(tile) : null;
|
||||
const legal = !!slot && slot.area !== 'temp' && (slot.area === 'board' || !sameSlot(slot, st.from)) && isLegal(canPlace, st.piece, slot);
|
||||
setHoverTile(over ? tile : null);
|
||||
emit('pieceDragMove', { uid: st.piece.uid, piece: st.piece, x: e.x, y: e.y, hx: pt.x, hy: pt.y, clientX: e.clientX, clientY: e.clientY, target: slot, legal });
|
||||
emit('pieceDragMove', { uid: st.piece.uid, piece: st.piece, x: e.x, y: e.y, clientX: e.clientX, clientY: e.clientY, target: slot, legal });
|
||||
}
|
||||
|
||||
function endDrag(e, cancelled) {
|
||||
@@ -175,9 +159,8 @@ export function createDragController(hooks) {
|
||||
if (!s) return;
|
||||
let dropped = false;
|
||||
if (!cancelled) {
|
||||
const pt = dropAt(e, s.piece, true);
|
||||
const tile = tileAt(pt.x, pt.y);
|
||||
const d = resolveDrop({ piece: s.piece, from: s.from, tile, overCanvas: dropOverCanvas(e, pt), clientX: e.clientX, clientY: e.clientY, canPlace });
|
||||
const tile = tileAt(e.x, e.y);
|
||||
const d = resolveDrop({ piece: s.piece, from: s.from, tile, overCanvas: overCanvas(e.clientX, e.clientY), clientX: e.clientX, clientY: e.clientY, canPlace });
|
||||
if (d.kind === 'drop') {
|
||||
dropped = true;
|
||||
emit('pieceDrop', { uid: s.piece.uid, piece: s.piece, from: s.from, target: d.target });
|
||||
@@ -226,7 +209,7 @@ export function createDragController(hooks) {
|
||||
pointerMove(e) {
|
||||
if (!st) {
|
||||
if (e.pointerType === 'touch') return;
|
||||
setHoverPiece(pieceAt(e.x, e.y, true), e);
|
||||
setHoverPiece(pieceAt(e.x, e.y), e);
|
||||
setHoverTile(tileAt(e.x, e.y));
|
||||
return;
|
||||
}
|
||||
|
||||
+154
-8
@@ -103,6 +103,10 @@ const BOOM_SPIN = 22, BOOM_BOW = 0.35, BOOM_MAX_T = 6;
|
||||
* S3: ammo grants / reloads lock far more than the 5 base shots, ≤ 33 shells).
|
||||
*/
|
||||
const LOCK_T = 5, LOCK_FADE = 0.2, MAX_LOCKS = 48;
|
||||
// 炎佑 fire jet: real s a jet burns past its tick's `dur` (the next 1 game s tick re-aims and extends it before it fades:
|
||||
// one continuous stream), its fade-out, the most jets at once, the fire colours
|
||||
const FLAME_TAIL = 0.2, FLAME_FADE = 0.15, MAX_FLAMES = 8;
|
||||
const FLAME_TINTS = Object.freeze([0xffd27a, 0xffa94d, 0xff8a3d, 0xff5a2a]);
|
||||
/** Bombard shell: share of its flight spent rising from the shooter, and the height it climbs / falls from (tiles). */
|
||||
const SHELL_RISE = 0.34, SHELL_UP = 5.5;
|
||||
/** 蕾缪安 S3 shell (fx 'bombardShell'); `look` 'mortar' is its own flight (_stepMortar). */
|
||||
@@ -198,7 +202,9 @@ export const FX_KINDS = Object.freeze({
|
||||
knack: { a: 'buff', c: 0xffd45a }, bloodBattle: { a: 'buff', c: 0xff4b3e }, sword: { a: 'buff', c: 0xdfe8ff }, equip: { a: 'buff', c: 0x4ed8af },
|
||||
garrisonGrant: { a: 'buff', c: 0x4ed8af }, extraAttack: { a: 'buff', c: 0xffe066 }, soul: { a: 'buff', c: 0xb36bff }, jungleSoul: { a: 'buff', c: 0x7fd37a },
|
||||
candle: { a: 'buff', c: 0xffb347 }, mote: { a: 'buff', c: 0xfff0a8 }, ember: { a: 'buff', c: 0xff7a33 }, ignite: { a: 'buff', c: 0xff6a2a },
|
||||
flame: { a: 'buff', c: 0xff6a2a }, yanyouFlame: { a: 'buff', c: 0xff8a3d }, grow: { a: 'buff', c: 0x7fd37a }, weightlessBuff: { a: 'buff', c: 0xcfe0ff },
|
||||
flame: { a: 'buff', c: 0xff6a2a }, grow: { a: 'buff', c: 0x7fd37a }, weightlessBuff: { a: 'buff', c: 0xcfe0ff },
|
||||
// 炎佑 祛恶之焰: a continuous jet from the dragon (`id`) onto its locked target (`target`) + a burning disc (_flame)
|
||||
yanyouFlame: { a: 'flame', c: 0xff8a3d, r: 1 },
|
||||
// states
|
||||
takeoff: { a: 'lift', c: 0xcfe0ff }, levitate: { a: 'lift', c: 0xcfe0ff }, weightless: { a: 'lift', c: 0xcfe0ff },
|
||||
sleep: { a: 'sleep', c: 0xa8b6ff }, crit: { a: 'crit', c: 0xffe066 },
|
||||
@@ -310,6 +316,7 @@ export class FxSystem {
|
||||
this.beams.blendMode = P.BLEND_MODES.ADD;
|
||||
ctx.layers.fxAdd.addChild(this.beams);
|
||||
this.beamList = [];
|
||||
this.flames = []; // fire jets (炎佑 祛恶之焰) { src, tgt, x, y, z, r, col, t, end, disc, edge, … }
|
||||
this.nums = [];
|
||||
this._numPools = new Map(); // bitmap font → free number records
|
||||
this.auras = new Map(); // unit id → { sprite, ring, t }
|
||||
@@ -925,6 +932,128 @@ export class FxSystem {
|
||||
this.beamList.length = w;
|
||||
}
|
||||
|
||||
// ---- fire jets (炎佑 祛恶之焰) ---------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* fx 'yanyouFlame' (sim/content/tokens.js yanyouKit; user playtest #4 item 12 "一段持续时间的喷火"): the sim emits one
|
||||
* event per game second of the channel; each keeps the jet of dragon `srcId` burning for `dur` real s + FLAME_TAIL
|
||||
* onto its locked target `tgtId` (else the spot (x, y)), so the 1 s ticks join into ONE continuous stream, with a
|
||||
* burning disc of radius `r` tiles following the target. One jet per dragon (a new tick re-aims and extends it).
|
||||
*/
|
||||
_flame(srcId, tgtId, x, y, r, col, dur) {
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) return;
|
||||
let F = srcId != null ? this.flames.find((f) => f.src === srcId) : null;
|
||||
if (!F) {
|
||||
const P = this.P;
|
||||
const disc = new P.Sprite(this.tex.soft);
|
||||
disc.anchor.set(0.5); disc.blendMode = P.BLEND_MODES.ADD; disc.alpha = 0;
|
||||
const edge = new P.Sprite(this.tex.ring);
|
||||
edge.anchor.set(0.5); edge.blendMode = P.BLEND_MODES.ADD; edge.alpha = 0;
|
||||
F = { src: srcId, disc, edge, t: 0, end: 0, jet: 0, fire: 0, smoke: 0, seed: Math.random() * 100 };
|
||||
this.flames.push(F);
|
||||
if (this.flames.length > MAX_FLAMES) this._freeFlame(this.flames.shift());
|
||||
}
|
||||
F.tgt = tgtId; F.x = x; F.y = y; F.z = this._groundZ(x, y); F.r = r; F.col = col;
|
||||
F.end = Math.max(F.end, F.t + dur + FLAME_TAIL);
|
||||
F.disc.tint = F.edge.tint = col;
|
||||
}
|
||||
|
||||
_freeFlame(F) { F.disc.destroy(); F.edge.destroy(); }
|
||||
|
||||
_updateFlames(dt) {
|
||||
if (!this.flames.length) return;
|
||||
const cam = this.ctx.cam();
|
||||
const g = this.beams; // drawn after _updateBeams cleared it
|
||||
const p = this._p, q = this._q, m = this._g;
|
||||
const low = this.quality === 'low';
|
||||
let w = 0;
|
||||
for (const F of this.flames) {
|
||||
F.t += dt;
|
||||
const sv = this._viewOf(F.src);
|
||||
// the channel is over (no tick kept it going) or the dragon is gone
|
||||
if (F.t >= F.end || (F.src != null && this.ctx.view && (!sv || sv.alive === false))) { this._freeFlame(F); continue; }
|
||||
const tv = this._viewOf(F.tgt);
|
||||
if (tv && tv.alive !== false && Number.isFinite(tv.x) && Number.isFinite(tv.y)) { F.x = tv.x; F.y = tv.y; F.z = tv.z || 0; }
|
||||
const fade = Math.min(1, F.t / 0.12, (F.end - F.t) / FLAME_FADE);
|
||||
const flick = 0.82 + 0.18 * Math.sin(F.t * 23 + F.seed) * Math.sin(F.t * 7.3);
|
||||
// burning disc on the ground around the target
|
||||
this._onGround(F.disc, F.y, F.z); this._onGround(F.edge, F.y, F.z);
|
||||
cam.project(F.x, F.y, F.z + 0.02, p);
|
||||
cam.project(F.x, F.y + F.r, F.z + 0.02, q);
|
||||
const cx = p.x, cy = p.y, s = p.s;
|
||||
const rx = s * F.r, ry = Math.max(1, cy - q.y);
|
||||
F.disc.position.set(cx, cy); F.disc.scale.set((rx * 2) / 128, (ry * 2) / 128); F.disc.alpha = 0.5 * fade * flick;
|
||||
F.edge.position.set(cx, cy); F.edge.scale.set((rx * 2.05) / 128, (ry * 2.05) / 128); F.edge.alpha = 0.7 * fade * flick;
|
||||
// flame tongues and embers rising from the disc
|
||||
F.fire += dt * (low ? 14 : 34) * fade;
|
||||
while (F.fire >= 1) {
|
||||
F.fire -= 1;
|
||||
if (!this._room()) { F.fire = 0; break; }
|
||||
const a = Math.random() * Math.PI * 2, d = Math.sqrt(Math.random()) * 0.9;
|
||||
const px = cx + Math.cos(a) * rx * d, py = cy + Math.sin(a) * ry * d;
|
||||
const ember = Math.random() < 0.3;
|
||||
const po = this._o();
|
||||
po.tint = ember ? 0xffd27a : FLAME_TINTS[(Math.random() * FLAME_TINTS.length) | 0];
|
||||
po.vx = (Math.random() - 0.5) * s * 0.3; po.vy = -s * (ember ? 1.6 : 0.9 + Math.random() * 0.6); po.drag = ember ? 0.5 : 1.5;
|
||||
po.life = ember ? 0.7 : 0.35 + Math.random() * 0.25;
|
||||
po.s0 = (s / 128) * (ember ? 0.06 : 0.28 + Math.random() * 0.12); po.s1 = (s / 128) * (ember ? 0.02 : 0.08);
|
||||
po.a0 = 0.9; po.a1 = 0; po.fadeIn = 0.05;
|
||||
this.particle(ember ? 'dot' : 'soft', px, py, po);
|
||||
}
|
||||
if (this.rich) {
|
||||
F.smoke += dt * 5 * fade;
|
||||
while (F.smoke >= 1) {
|
||||
F.smoke -= 1;
|
||||
if (!this._room()) { F.smoke = 0; break; }
|
||||
const po = this._o();
|
||||
po.add = false; po.tint = 0x3a3430; po.vx = (Math.random() - 0.5) * s * 0.2; po.vy = -s * 0.5; po.drag = 1;
|
||||
po.life = 0.9; po.s0 = (s / 128) * 0.35; po.s1 = (s / 128) * 0.8; po.a0 = 0.32; po.a1 = 0; po.fadeIn = 0.15;
|
||||
this.particle('smoke', cx + (Math.random() - 0.5) * rx, cy - s * 0.2, po);
|
||||
}
|
||||
}
|
||||
// the jet: from the dragon's chest onto the target (its chest; the disc centre without a target view)
|
||||
if (sv) {
|
||||
this._chest(sv, m);
|
||||
const mx = m.x, my = m.y;
|
||||
if (tv && tv.alive !== false) this._chest(tv, q); else { q.x = cx; q.y = cy - s * 0.15; }
|
||||
const tx = q.x, ty = q.y;
|
||||
const segs = 6;
|
||||
for (let pass = 0; pass < 3; pass++) {
|
||||
const col = pass === 2 ? 0xfff2c0 : pass === 1 ? 0xffb347 : F.col;
|
||||
const alpha = (pass === 0 ? 0.3 : pass === 1 ? 0.55 : 0.8) * fade * flick;
|
||||
let x0 = mx, y0 = my;
|
||||
for (let i = 1; i <= segs; i++) {
|
||||
const f = i / segs;
|
||||
// widens towards the target (a cone of fire), wobbling
|
||||
const wd = s * (pass === 0 ? 0.1 + 0.22 * f : pass === 1 ? 0.05 + 0.1 * f : 0.02 + 0.03 * f);
|
||||
const j = i < segs ? Math.sin(F.seed + i * 3.1 + F.t * 30) * s * 0.05 * f : 0;
|
||||
const x1 = mx + (tx - mx) * f + j, y1 = my + (ty - my) * f - j * 0.5;
|
||||
g.lineStyle(Math.max(1, wd), col, alpha);
|
||||
g.moveTo(x0, y0); g.lineTo(x1, y1);
|
||||
x0 = x1; y0 = y1;
|
||||
}
|
||||
}
|
||||
// fire puffs streaming along it
|
||||
F.jet += dt * (low ? 18 : 42) * fade;
|
||||
const T = 0.2;
|
||||
while (F.jet >= 1) {
|
||||
F.jet -= 1;
|
||||
if (!this._room()) { F.jet = 0; break; }
|
||||
const a = Math.random() * Math.PI * 2, d = Math.random() * 0.5;
|
||||
const gx = tx + Math.cos(a) * rx * d, gy = ty + Math.sin(a) * ry * d;
|
||||
const po = this._o();
|
||||
po.tint = FLAME_TINTS[(Math.random() * FLAME_TINTS.length) | 0];
|
||||
po.vx = (gx - mx) / T; po.vy = (gy - my) / T;
|
||||
po.life = T * (0.9 + Math.random() * 0.3);
|
||||
po.s0 = (s / 128) * 0.1; po.s1 = (s / 128) * (0.38 + Math.random() * 0.2); po.a0 = 0.85; po.a1 = 0.15;
|
||||
this.particle('soft', mx, my, po);
|
||||
}
|
||||
}
|
||||
this.flames[w++] = F;
|
||||
}
|
||||
this.flames.length = w;
|
||||
}
|
||||
|
||||
// ---- lock-on reticles (蕾缪安) -------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -1508,10 +1637,10 @@ export class FxSystem {
|
||||
/**
|
||||
* b.ev 'fx': every kind the sim / content emits has a visual (FX_KINDS archetypes: blast, shell, zone, telegraph,
|
||||
* heal, sp, shield, shatter, summon, vanish, blink, move, wave, mark, reticle, buff, lift, sleep, crit, dodge,
|
||||
* counter, dp, coin, crate, down, beam, bolt, strike, volley, pillar, lp, chill, element); unknown kinds get a generic
|
||||
* sparkle. `extra` keys used: id (anchor unit — or the shooter of a `pt` kind), r | radius, dur | duration, t (shell
|
||||
* flight, game s), src / from / to / targets (unit ids), fx, fy / fromX, fromY / tx, ty (positions), element, n,
|
||||
* scale, kind, tiles.
|
||||
* counter, dp, coin, crate, down, beam, bolt, strike, volley, pillar, lp, chill, element, flame); unknown kinds get a
|
||||
* generic sparkle. `extra` keys used: id (anchor unit — or the shooter of a `pt` kind), r | radius, dur | duration,
|
||||
* t (shell flight, game s), src / from / to / target / targets (unit ids), fx, fy / fromX, fromY / tx, ty (positions),
|
||||
* element, n, scale, kind, tiles.
|
||||
*/
|
||||
simFx(kind, x, y, extra) {
|
||||
const ex = extra && typeof extra === 'object' ? extra : {};
|
||||
@@ -1720,6 +1849,12 @@ export class FxSystem {
|
||||
else this.strike(at.x, at.y, at.z, col);
|
||||
break;
|
||||
}
|
||||
case 'flame': {
|
||||
// at the event's spot (the locked target), not snapped onto the dragon hovering 0.25 tile from it
|
||||
const fx0 = Number(x), fy0 = Number(y);
|
||||
this._flame(ex.id ?? ex.src ?? null, ex.target ?? ex.to ?? null, fx0, fy0, r, col, Math.max(0.2, dur || 0.5));
|
||||
break;
|
||||
}
|
||||
case 'strike': case 'pillar': this.strike(at.x, at.y, at.z, col, spec.a === 'pillar'); break;
|
||||
case 'volley': {
|
||||
const list = Array.isArray(ex.targets) ? ex.targets.slice(0, 12) : [];
|
||||
@@ -1938,9 +2073,17 @@ export class FxSystem {
|
||||
glow.anchor.set(0.5); glow.tint = tint; glow.blendMode = P.BLEND_MODES.ADD; glow.scale.set(0.9);
|
||||
const sp = new P.Sprite(icon);
|
||||
sp.anchor.set(0.5);
|
||||
const k = 46 / Math.max(1, Math.max(icon.width, icon.height));
|
||||
sp.scale.set(k);
|
||||
sp.tint = tint;
|
||||
// 46 px along its longer side. A bond icon (app.js 'layer': PIXI.Texture.from(url)) is a 1×1 placeholder until
|
||||
// its image has loaded: sized from that it was drawn 46× too big, ~5000 px over the whole screen for the pop's
|
||||
// 1.4 s (user playtest #4 item 13) — so it is sized once its texture is valid, and hidden until then.
|
||||
const fit = () => {
|
||||
if (sp.destroyed) return;
|
||||
sp.scale.set(46 / Math.max(1, icon.width, icon.height));
|
||||
sp.visible = true;
|
||||
};
|
||||
if (icon.valid) fit();
|
||||
else { sp.visible = false; icon.once('update', fit); }
|
||||
c.addChild(glow, sp);
|
||||
}
|
||||
if (label) {
|
||||
@@ -1989,6 +2132,8 @@ export class FxSystem {
|
||||
this.pops.length = 0;
|
||||
this.beamList.length = 0;
|
||||
this.beams.clear();
|
||||
for (const F of this.flames) this._freeFlame(F);
|
||||
this.flames.length = 0;
|
||||
this.vigT = 0;
|
||||
this.vignette.alpha = 0;
|
||||
for (const zn of this.zones) this._freeZone(zn);
|
||||
@@ -2006,6 +2151,7 @@ export class FxSystem {
|
||||
this._updateProjs(dt);
|
||||
this._updateLocks(dt);
|
||||
this._updateBeams(dt);
|
||||
this._updateFlames(dt);
|
||||
this._updateNums(dt);
|
||||
this._updateRings(dt);
|
||||
this._updateAuras(dt);
|
||||
@@ -2028,7 +2174,7 @@ export class FxSystem {
|
||||
}
|
||||
|
||||
get counts() {
|
||||
return { particles: this.parts.length, projectiles: this.projs.length, numbers: this.nums.length, rings: this.rings.length, auras: this.auras.size, locks: this.locks.length };
|
||||
return { particles: this.parts.length, projectiles: this.projs.length, numbers: this.nums.length, rings: this.rings.length, auras: this.auras.size, locks: this.locks.length, flames: this.flames.length };
|
||||
}
|
||||
|
||||
destroy() {
|
||||
|
||||
@@ -18,9 +18,18 @@
|
||||
// and handed out by `takeEvents()` once renderT passes the stamp. Stale cosmetic events (> `eventMaxLag`
|
||||
// game s behind) are dropped by the caller's choice (`isCosmeticEvent`); state events are always delivered.
|
||||
//
|
||||
// Snapshot tuple layout (DESIGN §8.2): [id, x, y, hp, maxHp, sp, spMax, flags, anim].
|
||||
// Snapshot tuple layout (DESIGN §8.2): [id, x, y, hp, maxHp, sp, spMax, flags, anim]. Two optional lists ride along
|
||||
// (server/sim/Battle.js snapshot, user playtest #4 items 8 / 9):
|
||||
// * `elem` [[id, element, fill, cooldownEnd, cooldown]] — the element gauge a unit shows: appended to that unit's
|
||||
// normalised tuple (EL…EL_DUR) and handed out by sample() as `el`, `elFill`, `elUntil`, `elDur` (from the older
|
||||
// snapshot, like flags);
|
||||
// * `down` [[id, respawnAt, respawnTime, state]] — knocked-out operators waiting to redeploy (they are no longer in
|
||||
// `units`): downAt(time) returns the list of the snapshot at `time`.
|
||||
// Game times in both (`cooldownEnd`, `respawnAt`) are on the snapshots' clock, so a view compares them with renderT.
|
||||
|
||||
export const TUPLE = Object.freeze({ ID: 0, X: 1, Y: 2, HP: 3, MAXHP: 4, SP: 5, SPMAX: 6, FLAGS: 7, ANIM: 8 });
|
||||
export const TUPLE = Object.freeze({ ID: 0, X: 1, Y: 2, HP: 3, MAXHP: 4, SP: 5, SPMAX: 6, FLAGS: 7, ANIM: 8, EL: 9, EL_FILL: 10, EL_UNTIL: 11, EL_DUR: 12 });
|
||||
/** Element keys a snapshot `elem` entry may carry (server/sim/constants.js ELEMENT_ORDER). */
|
||||
const ELEMENT_KEYS = new Set(['neural', 'erosion', 'burn', 'apoptosis', 'necrosis']);
|
||||
|
||||
const MAX_EVENTS = 6000;
|
||||
const finite = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : d);
|
||||
@@ -42,8 +51,9 @@ export function frameTime(msg) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate & normalise a b.snap payload. Returns `{ t, units: Map<id, tuple>, raw }` or null when unusable.
|
||||
* Tuples with a non-finite id/x/y are skipped; other numbers default to 0.
|
||||
* Validate & normalise a b.snap payload. Returns `{ t, units: Map<id, tuple>, down: [[id, respawnAt, respawnTime,
|
||||
* state]] | null, raw }` or null when unusable. Tuples with a non-finite id/x/y are skipped; other numbers default
|
||||
* to 0; a unit's `elem` entry (see header) is appended to its tuple; malformed `elem` / `down` entries are dropped.
|
||||
*/
|
||||
export function normalizeSnapshot(snap) {
|
||||
if (!snap || typeof snap !== 'object') return null;
|
||||
@@ -59,7 +69,21 @@ export function normalizeSnapshot(snap) {
|
||||
if (typeof x !== 'number' || typeof y !== 'number' || !Number.isFinite(x) || !Number.isFinite(y)) continue;
|
||||
units.set(id, [id, x, y, finite(u[3]), finite(u[4]), finite(u[5]), finite(u[6]), finite(u[7]) | 0, finite(u[8]) | 0]);
|
||||
}
|
||||
return { t, units, raw: snap };
|
||||
if (Array.isArray(snap.elem)) {
|
||||
for (const e of snap.elem) {
|
||||
if (!Array.isArray(e) || !ELEMENT_KEYS.has(e[1])) continue;
|
||||
const tu = units.get(e[0]);
|
||||
if (tu && tu.length === 9) tu.push(e[1], clamp(finite(e[2]), 0, 1), finite(e[3]), Math.max(0, finite(e[4])));
|
||||
}
|
||||
}
|
||||
let down = null;
|
||||
if (Array.isArray(snap.down)) {
|
||||
for (const d of snap.down) {
|
||||
if (!Array.isArray(d) || !(typeof d[0] === 'number' || typeof d[0] === 'string')) continue;
|
||||
(down || (down = [])).push([d[0], finite(d[1]), Math.max(0, finite(d[2])), finite(d[3]) | 0]);
|
||||
}
|
||||
}
|
||||
return { t, units, down, raw: snap };
|
||||
}
|
||||
|
||||
export class SnapshotBuffer {
|
||||
@@ -212,10 +236,21 @@ export class SnapshotBuffer {
|
||||
return ans;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `down` list (knocked-out operators waiting to redeploy, see header) of the snapshot shown at `time` (default
|
||||
* renderT) — the same snapshot sample() reads flags from — or null.
|
||||
*/
|
||||
downAt(time = this.renderT) {
|
||||
const s = this.snaps;
|
||||
if (!s.length || !Number.isFinite(time)) return null;
|
||||
const ia = this._indexAt(time);
|
||||
return s[ia < 0 ? 0 : ia].down || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpolated state at `time` (default renderT). Fills and returns `out` (a Map id → sample object reused
|
||||
* across calls: `{ id, x, y, hp, maxHp, sp, spMax, flags, anim, vx, vy, seen }`). Samples of units no longer
|
||||
* present are deleted from `out`.
|
||||
* across calls: `{ id, x, y, hp, maxHp, sp, spMax, flags, anim, vx, vy, seen, el, elFill, elUntil, elDur }` —
|
||||
* `el` = the shown element gauge (null: none), see header). Samples of units no longer present are deleted from `out`.
|
||||
*/
|
||||
sample(time = this.renderT, out = new Map()) {
|
||||
const s = this.snaps;
|
||||
@@ -231,7 +266,7 @@ export class SnapshotBuffer {
|
||||
const stamp = A.t;
|
||||
for (const [id, a] of A.units) {
|
||||
let o = out.get(id);
|
||||
if (!o) { o = { id, x: 0, y: 0, hp: 0, maxHp: 0, sp: 0, spMax: 0, flags: 0, anim: 0, vx: 0, vy: 0, seen: 0 }; out.set(id, o); }
|
||||
if (!o) { o = { id, x: 0, y: 0, hp: 0, maxHp: 0, sp: 0, spMax: 0, flags: 0, anim: 0, vx: 0, vy: 0, seen: 0, el: null, elFill: 0, elUntil: 0, elDur: 0 }; out.set(id, o); }
|
||||
const b = B ? B.units.get(id) : null;
|
||||
if (b) {
|
||||
const dx = b[1] - a[1], dy = b[2] - a[2];
|
||||
@@ -264,6 +299,7 @@ export class SnapshotBuffer {
|
||||
else if (!(o.hp > 0)) o.hp = 0;
|
||||
o.flags = a[7];
|
||||
o.anim = a[8];
|
||||
if (a.length > 9) { o.el = a[9]; o.elFill = a[10]; o.elUntil = a[11]; o.elDur = a[12]; } else if (o.el !== null) { o.el = null; o.elFill = 0; o.elUntil = 0; o.elDur = 0; }
|
||||
o.seen = stamp;
|
||||
}
|
||||
for (const id of out.keys()) if (!A.units.has(id)) out.delete(id);
|
||||
|
||||
+78
-207
@@ -1,224 +1,95 @@
|
||||
// render/pick.js — which unit the pointer is on (user playtest #3 item 7: "clicking a unit on the map often selects
|
||||
// the one a row below it; dropping equipment too"). Pure screen-space geometry (no PIXI, no DOM), the ONE rule behind
|
||||
// every picking path: prep press / drag start / right-click detail and equipment dropped on an operator
|
||||
// (render/app.js pieceAt / pieceUnder, and ui/gameLogic.js pickPieceAt over view.pieceScreenRect shapes), battle
|
||||
// clicks and hover (battleUnitAt) and the enemy preview pen (penUnitAt).
|
||||
//
|
||||
// Why "the front-most rectangle under the pointer" failed: a chibi is an upright billboard ~1.27 tiles tall (median
|
||||
// of the 138 official operators' Front / Back skeletons in their idle pose, p10–p90 1.17–1.41) standing on its tile,
|
||||
// while the rows of the official 30° cameras are only ~0.84 (prep) – 0.91 (battle) of a tile apart on screen. The unit
|
||||
// in front covers the legs / feet of the unit behind it and its head sits over that unit's tile; its 0.7-tile-wide
|
||||
// rectangle also covered the ground and the legs showing beside its head (a head is only ~0.5 tile wide), and a
|
||||
// pressed tile was only looked at when no rectangle at all was hit — so a press on the lower part of a unit, on its
|
||||
// feet or on its tile went to the unit in front of it: the one a row lower on screen.
|
||||
//
|
||||
// Body of a unit (all px on one screen; `x`, `y` = its feet, `s` = px per tile at the feet, `h` = model height in
|
||||
// tiles, `w` = width factor (1 for operators; enemies scale with their size), `flip` = +1 facing right / −1 left). The
|
||||
// measured opaque profile of the idle chibis is ~±0.3 tile wide through the body (the back side a little wider: hair,
|
||||
// capes) and ~±0.2 at the crown; a taller model is taller hair / ears / hats — its face stays ~0.86 tile up:
|
||||
// outline head ellipse (centre h − 0.33·w above the feet, radii 0.27·w × 0.33·w) ∪ body box (0.08 below the feet …
|
||||
// the shoulders at min(0.62·h, 0.78·w); −0.34·w … +0.28·w facing right) ∪ the unit's HUD (tier chip /
|
||||
// bars) — what is drawn
|
||||
// core face (ellipse 0.21·w × 0.15·w centred min(0.86·w, h − 0.36·w) above the feet) ∪ torso / legs (±0.2·w, from
|
||||
// 0.1 above the feet to the shoulders) — what a player aims at
|
||||
// 'diamond' (the avatar diamond shown until the Spine model is there): the rhombus of side `d` tiles above the feet
|
||||
// 'plate' (hand items): a square of half-size `r` tiles centred on (x, y)
|
||||
// Rule (pickBody):
|
||||
// 1. the pointer is on units' cores → the front-most of them (drawn on top: the face of the unit in front is its own;
|
||||
// the torso of the unit behind is its own above that face)
|
||||
// 2. else a unit stands on the tile under the pointer → that unit (its feet, its tile — also where the hair or a
|
||||
// weapon of the unit in front hangs over it)
|
||||
// 3. else the pointer is on outlines only (hair, hands, feet, weapon edges) → the unit whose core is nearest (px / its
|
||||
// own scale); ties → the front-most
|
||||
// The feet of a unit hidden behind the face of the unit in front are not pickable (nothing of them shows there); its
|
||||
// tile beside that face and everything of it above that face are.
|
||||
//
|
||||
// Pixel probe (optional `probe(body)` → true / false / null, render/app.js drawnAt): the shapes above are a model of
|
||||
// the drawn chibi, and where the bodies of two units overlap the model cannot tell whose arm, hair or weapon is drawn
|
||||
// on top. Measured on the mock match against the rendered pixels (1920×1080, every opaque unit pixel sampled, the
|
||||
// front-most drawn unit = the truth): the rule alone put 7 % of the prep presses (18 % in battle) on the wrong unit —
|
||||
// mostly the unit BEHIND: the hair crown and the sides (arms, clothes, weapons) of the unit in front lie over the legs /
|
||||
// tile of the one behind — against 2 % / 11 % for the old front-most rectangle; with the probe 0.7 % / 1.7 % (the rest:
|
||||
// anti-aliased edges). So wherever the probe can change the answer (two or more bodies within their outline +
|
||||
// PROBE_MARGIN of the pointer, or one the rule did not pick), the front-most body whose model really draws the pixel
|
||||
// under the pointer wins — what the player sees. Only when none of them draws there (a gap, the bare tile) or the probe
|
||||
// cannot tell (null) does the rule above decide.
|
||||
// render/pick.js — which unit the pointer is on (user playtest #4 item 1: "地上都画好了一个一个方格,点击对应方格就选中那个
|
||||
// 方格的人物就行" — the ground is drawn as tiles: a press on a tile selects the unit on that tile). Pure logic (no PIXI, no
|
||||
// DOM): render/app.js hands in the units and the ground under the pointer (render/projection.js pickTile, raised tops
|
||||
// first, in the tiles the views are drawn on). The one rule behind every picking path — prep press / click / right-click
|
||||
// and long-press detail / drag start / hover (app.js pieceAt), battle clicks and hover (battleUnitAt), the enemy preview
|
||||
// pen (penUnitAt); a dragged piece (an item too) drops on the tile under the pointer (render/drag.js):
|
||||
// * prep pieces (board tiles, bench row 7, temp row 8), battle allies and the figures of the enemy pen stand on
|
||||
// tiles: the unit on the tile under the pointer. An empty tile selects nothing — a press on a unit's head where it is
|
||||
// drawn over the tile behind it is a press on that tile. Several on one tile (the pen's clusters of up to 3, a piece
|
||||
// held for a moment on an occupied tile) → the one nearest to the pointer's point on the tile, ties → the front-most.
|
||||
// * battle enemies walk between tiles: the one within ENEMY_REACH tile of the pointer — its ground position from the
|
||||
// pointer's point on the ground, or its drawn body (on screen: the upright line from its feet to its head, in its px
|
||||
// per tile), whichever is nearer, so a press on a tall enemy's / a boss's body picks it too; a flying enemy (drawn in
|
||||
// the air, over whatever tile it crosses) by its drawn body alone.
|
||||
// * an ally's tile and an enemy both qualify → the nearer one (distance in tiles to the pointer's point), ties → the
|
||||
// ally, then the front-most.
|
||||
// (Replaces the drawn-body shapes and the 1-px render probe of v2.2, DESIGN §17.2: the player aims at the grid.)
|
||||
|
||||
/** Median model height of the official chibis (tiles above the feet), when the model's own is not known. */
|
||||
export const BODY_H = 1.27;
|
||||
/** Shape constants in tiles (see the header). */
|
||||
export const BODY = Object.freeze({
|
||||
headRx: 0.27, headRy: 0.33, faceRx: 0.21, faceRy: 0.15, face: 0.86, faceTop: 0.36, back: 0.34, front: 0.28,
|
||||
below: 0.08, shoulder: 0.62, neck: 0.78, torso: 0.2, torsoFrom: 0.1, lean: 0.03,
|
||||
});
|
||||
/** Battle enemies are picked within this many tiles of the pointer's ground point or of their drawn body on screen. */
|
||||
export const ENEMY_REACH = 0.6;
|
||||
|
||||
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
|
||||
const inRect = (r, px, py) => !!r && px >= r.x0 && px <= r.x1 && py >= r.y0 && py <= r.y1;
|
||||
const inEllipse = (e, px, py) => ((px - e.cx) / e.rx) ** 2 + ((py - e.cy) / e.ry) ** 2 <= 1;
|
||||
const rectDist = (r, px, py) => Math.hypot(Math.max(r.x0 - px, 0, px - r.x1), Math.max(r.y0 - py, 0, py - r.y1));
|
||||
function ellipseDist(e, px, py) {
|
||||
const k = Math.hypot((px - e.cx) / e.rx, (py - e.cy) / e.ry);
|
||||
return k > 1 ? (k - 1) * Math.min(e.rx, e.ry) : 0;
|
||||
const EPS = 1e-9;
|
||||
const isTile = (t) => !!t && Number.isInteger(t.row) && Number.isInteger(t.col);
|
||||
const onTile = (u, t) => isTile(u.tile) && isTile(t) && u.tile.row === t.row && u.tile.col === t.col;
|
||||
const depthOf = (u) => (u && Number.isFinite(u.depth) ? u.depth : 0);
|
||||
/** Tiles between a unit's ground position and the pointer's point on the ground (0 when either is unknown). */
|
||||
function groundDist(u, g) {
|
||||
if (!g || !Number.isFinite(g.x) || !Number.isFinite(g.y) || !Number.isFinite(u.x) || !Number.isFinite(u.y)) return 0;
|
||||
return Math.hypot(u.x - g.x, u.y - g.y);
|
||||
}
|
||||
/** Rhombus |dx|/a + |dy|/a ≤ k around (cx, cy). */
|
||||
const inRhombus = (d, px, py, k = 1) => Math.abs(px - d.cx) / d.a + Math.abs(py - d.cy) / d.a <= k;
|
||||
|
||||
/**
|
||||
* @typedef {{ x: number, y: number, s: number, h?: number, w?: number, flip?: number, kind?: 'chibi'|'diamond'|'plate',
|
||||
* d?: number, r?: number, depth?: number, hud?: { x0: number, y0: number, x1: number, y1: number } | null,
|
||||
* tile?: { row: number, col: number } | null }} PickBody
|
||||
* `depth`: draw order (larger = nearer the camera, drawn on top); `tile`: the tile it stands on (null: moving units)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Screen geometry of a body: `{ outline: [parts], core: [parts] }`, parts `{ rect }` / `{ ellipse }` / `{ rhombus }`.
|
||||
* @param {PickBody} b
|
||||
*/
|
||||
export function bodyParts(b) {
|
||||
const s = b.s, x = b.x, y = b.y;
|
||||
const hud = b.hud && [b.hud.x0, b.hud.y0, b.hud.x1, b.hud.y1].every(Number.isFinite) ? { rect: b.hud } : null;
|
||||
if (b.kind === 'plate') {
|
||||
const a = (Number(b.r) > 0 ? b.r : 0.31) * s;
|
||||
return { outline: [{ rect: { x0: x - a, x1: x + a, y0: y - a, y1: y + a } }], core: [{ rect: { x0: x - a * 0.7, x1: x + a * 0.7, y0: y - a * 0.7, y1: y + a * 0.7 } }] };
|
||||
}
|
||||
if (b.kind === 'diamond') {
|
||||
const half = ((Number(b.d) > 0 ? b.d : 0.78) / 2) * s;
|
||||
const rh = { cx: x, cy: y - BODY.below * s - half, a: half };
|
||||
return { outline: [{ rhombus: rh, k: 1 }, ...(hud ? [hud] : [])], core: [{ rhombus: rh, k: 0.7 }] };
|
||||
}
|
||||
const w = Number(b.w) > 0 ? b.w : 1;
|
||||
const h = clamp(Number(b.h) > 0 ? b.h : BODY_H, 0.3, 4);
|
||||
const flip = b.flip === -1 ? -1 : 1;
|
||||
const cx = x - BODY.lean * flip * w * s;
|
||||
const head = { cx, cy: y - (h - BODY.headRy * w) * s, rx: BODY.headRx * w * s, ry: BODY.headRy * w * s };
|
||||
const face = { cx, cy: y - Math.min(BODY.face * w, h - BODY.faceTop * w) * s, rx: BODY.faceRx * w * s, ry: BODY.faceRy * w * s };
|
||||
const top = y - Math.min(BODY.shoulder * h, BODY.neck * w) * s;
|
||||
const box = { x0: x - (flip > 0 ? BODY.back : BODY.front) * w * s, x1: x + (flip > 0 ? BODY.front : BODY.back) * w * s, y0: top, y1: y + BODY.below * s };
|
||||
const torso = { x0: cx - BODY.torso * w * s, x1: cx + BODY.torso * w * s, y0: top, y1: y - BODY.torsoFrom * Math.min(1, h) * s };
|
||||
// the HUD (tier chip / bars) is drawn above every unit, but over the unit behind: an outline, never a core
|
||||
return { outline: [{ ellipse: head }, { rect: box }, ...(hud ? [hud] : [])], core: [{ ellipse: face }, { rect: torso }] };
|
||||
}
|
||||
|
||||
const partHit = (p, px, py) => (p.rect ? inRect(p.rect, px, py) : p.ellipse ? inEllipse(p.ellipse, px, py) : inRhombus(p.rhombus, px, py, p.k));
|
||||
function partDist(p, px, py) {
|
||||
if (p.rect) return rectDist(p.rect, px, py);
|
||||
if (p.ellipse) return ellipseDist(p.ellipse, px, py);
|
||||
const r = p.rhombus, k = (Math.abs(px - r.cx) + Math.abs(py - r.cy)) / r.a;
|
||||
return k > p.k ? (k - p.k) * r.a * Math.SQRT1_2 : 0;
|
||||
}
|
||||
|
||||
/** 2 = on the body's core, 1 = on its outline only, 0 = off it. */
|
||||
export function hitBody(b, px, py) {
|
||||
if (!b || !(b.s > 0) || !Number.isFinite(b.x) || !Number.isFinite(b.y)) return 0;
|
||||
// cheap reject (battle hover tests every unit on every pointer move): far outside the body and its HUD
|
||||
if (b.kind !== 'plate' && !(b.hud && inRect(b.hud, px, py))) {
|
||||
const w = Number(b.w) > 0 ? b.w : 1, reach = Math.max(BODY.back * w, (Number(b.d) || 0) / 2) * b.s + 1;
|
||||
const top = b.y - (b.kind === 'diamond' ? (Number(b.d) || 0.78) + BODY.below : clamp(Number(b.h) > 0 ? b.h : BODY_H, 0.3, 4)) * b.s - 1;
|
||||
if (Math.abs(px - b.x) > reach || py < top || py > b.y + BODY.below * b.s + 1) return 0;
|
||||
}
|
||||
const parts = bodyParts(b);
|
||||
if (parts.core.some((p) => partHit(p, px, py))) return 2;
|
||||
return parts.outline.some((p) => partHit(p, px, py)) ? 1 : 0;
|
||||
}
|
||||
|
||||
/** Distance (px) from a point to the body's core, divided by the body's scale (0 on the core). */
|
||||
export function coreDistance(b, px, py) {
|
||||
const parts = bodyParts(b);
|
||||
let d = Infinity;
|
||||
for (const p of parts.core) d = Math.min(d, partDist(p, px, py));
|
||||
return d / b.s;
|
||||
}
|
||||
|
||||
/** The screen bounding box of a body's outline (px): `{ x, y, width, height }`. */
|
||||
export function bodyBounds(b) {
|
||||
const parts = bodyParts(b);
|
||||
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
|
||||
for (const p of [...parts.outline, ...parts.core]) {
|
||||
if (p.rect) { x0 = Math.min(x0, p.rect.x0); x1 = Math.max(x1, p.rect.x1); y0 = Math.min(y0, p.rect.y0); y1 = Math.max(y1, p.rect.y1); }
|
||||
else if (p.ellipse) { const e = p.ellipse; x0 = Math.min(x0, e.cx - e.rx); x1 = Math.max(x1, e.cx + e.rx); y0 = Math.min(y0, e.cy - e.ry); y1 = Math.max(y1, e.cy + e.ry); }
|
||||
else { const r = p.rhombus, a = r.a * p.k; x0 = Math.min(x0, r.cx - a); x1 = Math.max(x1, r.cx + a); y0 = Math.min(y0, r.cy - a); y1 = Math.max(y1, r.cy + a); }
|
||||
}
|
||||
if (!Number.isFinite(x0)) return { x: b.x, y: b.y, width: 0, height: 0 };
|
||||
return { x: x0, y: y0, width: x1 - x0, height: y1 - y0 };
|
||||
}
|
||||
|
||||
const depthOf = (b) => (Number.isFinite(b.depth) ? b.depth : 0);
|
||||
|
||||
/** Margin (tiles) around a body's outline in which the pixel probe still looks (weapons, capes, hair, big enemies past
|
||||
* the modelled outline; 0.25 left 4 % of the battle presses wrong, 0.5 1.7 %). */
|
||||
export const PROBE_MARGIN = 0.5;
|
||||
/** At most this many overlapping bodies are probed (front-most first). */
|
||||
const PROBE_MAX = 6;
|
||||
|
||||
/** Bodies whose outline (+ PROBE_MARGIN) contains the point, front-most first. */
|
||||
export function probeCandidates(bodies, px, py) {
|
||||
const out = [];
|
||||
for (const b of bodies) {
|
||||
if (!b || !(b.s > 0) || !Number.isFinite(b.x) || !Number.isFinite(b.y)) continue;
|
||||
const r = bodyBounds({ ...b, hud: null }), m = PROBE_MARGIN * b.s;
|
||||
if (px >= r.x - m && px <= r.x + r.width + m && py >= r.y - m && py <= r.y + r.height + m) out.push(b);
|
||||
}
|
||||
return out.sort((a, b) => depthOf(b) - depthOf(a));
|
||||
/** Tiles (its px per tile) between screen point (px, py) and an enemy's drawn body — the line from its feet up to its head. */
|
||||
function bodyDist(b, px, py) {
|
||||
if (!b || !(b.s > 0) || ![b.x, b.top, b.feet, px, py].every(Number.isFinite)) return Infinity;
|
||||
const y = Math.max(Math.min(b.top, b.feet), Math.min(Math.max(b.top, b.feet), py));
|
||||
return Math.hypot(px - b.x, py - y) / b.s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pixel probe step of pickBody: the front-most body around the point whose model draws the pixel under it. Asked only
|
||||
* when it can change the answer: two or more bodies there, or one that the shape rule did not pick (`rule`: e.g. its
|
||||
* arm over the tile of a unit standing behind it). null when none of them draws there or the probe cannot tell for one
|
||||
* in front of the first that does (then the shape rule decides).
|
||||
* @typedef {{ row: number, col: number, x?: number, y?: number }} PickGround the tile under the pointer and the pointer's
|
||||
* point on its top (world tiles: x = col, y = row), or null off the grid
|
||||
* @typedef {{ tile?: { row: number, col: number } | null, x?: number, y?: number, depth?: number, fly?: boolean,
|
||||
* body?: { x: number, top: number, feet: number, s: number } | null }} PickUnit
|
||||
* `tile`: the tile it stands on (null: it walks — a battle enemy); `x`, `y`: its ground position (world tiles);
|
||||
* `body`: an enemy's drawn body on screen (px: x, head `top`, `feet`; `s` px per tile there); `fly`: in the air — its
|
||||
* ground position does not count; `depth`: draw order (larger = in front)
|
||||
*/
|
||||
function probeBodies(bodies, px, py, probe, rule) {
|
||||
const cand = probeCandidates(bodies, px, py);
|
||||
if (!cand.length || (cand.length === 1 && cand[0] === rule)) return null;
|
||||
for (const b of cand.slice(0, PROBE_MAX)) {
|
||||
let d = null;
|
||||
try { d = probe(b); } catch { d = null; }
|
||||
if (d === true) return b;
|
||||
if (d !== false) return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const onTile = (b, t) => !!t && !!b.tile && b.tile.row === t.row && b.tile.col === t.col;
|
||||
function nearest(list, px, py) {
|
||||
|
||||
/**
|
||||
* The unit standing on the tile under the pointer (several → the nearest to the pointer's point, ties → the front-most).
|
||||
* @param {Array<PickUnit|null>} units
|
||||
* @param {PickGround|null} ground
|
||||
* @returns {PickUnit|null} one of `units`
|
||||
*/
|
||||
export function pickOnTile(units, ground) {
|
||||
if (!Array.isArray(units) || !isTile(ground)) return null;
|
||||
let best = null, bd = Infinity;
|
||||
for (const b of list) {
|
||||
const d = coreDistance(b, px, py);
|
||||
if (d < bd - 1e-9 || (Math.abs(d - bd) <= 1e-9 && best && depthOf(b) > depthOf(best))) { best = b; bd = d; }
|
||||
for (const u of units) {
|
||||
if (!u || !onTile(u, ground)) continue;
|
||||
const d = groundDist(u, ground);
|
||||
if (d < bd - EPS || (Math.abs(d - bd) <= EPS && depthOf(u) > depthOf(best))) { best = u; bd = d; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* The body the pointer is on (see the header for the rule), or null.
|
||||
* @param {Array<PickBody|null>} bodies
|
||||
* @param {number} px
|
||||
* Battle pick (see the header): allies on the tile under the pointer, enemies within ENEMY_REACH of it; the nearer wins.
|
||||
* @param {Array<PickUnit|null>} units allies with `tile`, enemies with `tile: null` and their drawn `body`
|
||||
* @param {PickGround|null} ground
|
||||
* @param {number} px pointer on screen (enemies' drawn bodies)
|
||||
* @param {number} py
|
||||
* @param {{ row: number, col: number } | null} [ground] the tile under the pointer (same space as the bodies' `tile`)
|
||||
* @param {((body: PickBody) => (boolean|null)) | null} [probe] does this body's model draw the pixel under the pointer
|
||||
* (null: cannot tell)? See the header.
|
||||
* @returns {PickBody|null} one of `bodies`
|
||||
* @returns {PickUnit|null} one of `units`
|
||||
*/
|
||||
export function pickBody(bodies, px, py, ground = null, probe = null) {
|
||||
if (!Number.isFinite(px) || !Number.isFinite(py) || !Array.isArray(bodies)) return null;
|
||||
const rule = ruleBody(bodies, px, py, ground);
|
||||
if (typeof probe === 'function') {
|
||||
const drawn = probeBodies(bodies, px, py, probe, rule);
|
||||
if (drawn) return drawn;
|
||||
export function pickBattle(units, ground, px, py) {
|
||||
if (!Array.isArray(units)) return null;
|
||||
const onGround = !!ground && Number.isFinite(ground.x) && Number.isFinite(ground.y);
|
||||
let best = null, bd = Infinity;
|
||||
const better = (u, d) => {
|
||||
if (d < bd - EPS) return true;
|
||||
if (Math.abs(d - bd) > EPS) return false;
|
||||
if (!!u.tile !== !!best.tile) return !!u.tile; // a tie: the ally standing there
|
||||
return depthOf(u) > depthOf(best);
|
||||
};
|
||||
for (const u of units) {
|
||||
if (!u) continue;
|
||||
let d = Infinity;
|
||||
if (u.tile) { if (onTile(u, ground)) d = groundDist(u, ground); }
|
||||
else {
|
||||
d = bodyDist(u.body, px, py);
|
||||
if (!u.fly && onGround && Number.isFinite(u.x) && Number.isFinite(u.y)) d = Math.min(d, Math.hypot(u.x - ground.x, u.y - ground.y));
|
||||
if (!(d <= ENEMY_REACH)) continue;
|
||||
}
|
||||
if (d < Infinity && better(u, d)) { best = u; bd = d; }
|
||||
}
|
||||
return rule;
|
||||
}
|
||||
|
||||
/** The shape rule of pickBody (the header's steps 1–3). */
|
||||
function ruleBody(bodies, px, py, ground) {
|
||||
let core = null;
|
||||
const rims = [];
|
||||
for (const b of bodies) {
|
||||
const hit = hitBody(b, px, py);
|
||||
if (hit === 2) { if (!core || depthOf(b) > depthOf(core)) core = b; }
|
||||
else if (hit === 1) rims.push(b);
|
||||
}
|
||||
if (core) return core;
|
||||
const standing = ground ? bodies.filter((b) => b && b.s > 0 && onTile(b, ground)) : [];
|
||||
if (standing.length) return nearest(standing, px, py);
|
||||
return rims.length ? nearest(rims, px, py) : null;
|
||||
return best;
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
// renderer swaps the texture without touching geometry,
|
||||
// * FX atlas (one base texture ⇒ particles batch / fit a ParticleContainer),
|
||||
// * status icons, tier chips, backdrop gradients,
|
||||
// * HUD rings: element gauge icons and the redeploy countdown ring (hudRings / ringArc),
|
||||
// * avatar-in-rarity-diamond composites (cached per unit asset id) for the Spine fallback.
|
||||
// Everything procedural is deterministic (seeded noise) and generated lazily on first use. Requires
|
||||
// globalThis.PIXI and a DOM canvas at call time (never at import time).
|
||||
@@ -1156,3 +1157,130 @@ export function itemTexture(key, img, color) {
|
||||
_items.set(k, tex);
|
||||
return tex;
|
||||
}
|
||||
|
||||
// =============================================================================================================
|
||||
// HUD rings (user playtest #4 items 8 and 9): the element gauge icon of a unit (PRTS 元素: "模型下部会显示对应的元素
|
||||
// 图标,并以白条显示剩余的元素值" — operators: the element's disc with its glyph; enemies: "小尺寸图标(不显示元素图标,
|
||||
// 仅根据元素种类改变背景色)") inside a white ring of the remaining 元素值, and the redeploy countdown ring above a
|
||||
// knocked-out operator. One 512×576 atlas: RING_STEPS + 1 arc frames (white, clockwise from 12 o'clock) and the discs.
|
||||
// Official element colours / glyphs after the client icons (图标 元素 sanity / water / fire / dark), drawn procedurally
|
||||
// (the local-client extraction has no general battle HUD sprites).
|
||||
|
||||
/** Arc frames: `arcs[k]` covers k / RING_STEPS of the circle. */
|
||||
export const RING_STEPS = 48;
|
||||
/** Element disc colours (official icons: 神经 teal, 侵蚀 steel blue, 灼燃 rust, 凋亡 charcoal) and glyph colours. */
|
||||
export const ELEMENT_RING = Object.freeze({
|
||||
neural: Object.freeze({ disc: '#13806c', glyph: '#bfe6df', tint: 0x1fae93 }),
|
||||
erosion: Object.freeze({ disc: '#2d5d86', glyph: '#cfdcea', tint: 0x4a8cc4 }),
|
||||
burn: Object.freeze({ disc: '#8f3512', glyph: '#f0cdbd', tint: 0xe0632c }),
|
||||
apoptosis: Object.freeze({ disc: '#363338', glyph: '#bdb8c2', tint: 0x9c8fb0 }),
|
||||
necrosis: Object.freeze({ disc: '#363338', glyph: '#bdb8c2', tint: 0x9c8fb0 }),
|
||||
});
|
||||
const RING_KEYS = Object.keys(ELEMENT_RING);
|
||||
const RC = 64; // cell px
|
||||
const RING_R = 26, RING_W = 6.5, DISC_R = 21.5;
|
||||
let _rings = null;
|
||||
|
||||
function drawElementGlyph(c, el, cx, cy, col) {
|
||||
c.fillStyle = col; c.strokeStyle = col; c.lineCap = 'round'; c.lineJoin = 'round';
|
||||
if (el === 'neural') { // concentric target
|
||||
c.lineWidth = 2.4;
|
||||
c.beginPath(); c.arc(cx, cy, 12, 0, Math.PI * 2); c.stroke();
|
||||
c.beginPath(); c.arc(cx, cy, 7, 0, Math.PI * 2); c.stroke();
|
||||
c.beginPath(); c.arc(cx, cy, 2.6, 0, Math.PI * 2); c.fill();
|
||||
} else if (el === 'erosion') { // a big and a small water drop over a wave
|
||||
const drop = (x, y, r) => {
|
||||
c.beginPath(); c.moveTo(x, y - r * 2.1);
|
||||
c.bezierCurveTo(x + r * 0.5, y - r * 1.2, x + r, y - r * 0.5, x + r, y + r * 0.1);
|
||||
c.arc(x, y + r * 0.1, r, 0, Math.PI);
|
||||
c.bezierCurveTo(x - r, y - r * 0.5, x - r * 0.5, y - r * 1.2, x, y - r * 2.1); c.fill();
|
||||
};
|
||||
drop(cx - 3, cy - 1, 5.6); drop(cx + 7.5, cy + 2, 3.1);
|
||||
c.lineWidth = 2.2; c.beginPath(); c.moveTo(cx - 13, cy + 11); c.quadraticCurveTo(cx - 6.5, cy + 7, cx, cy + 11); c.quadraticCurveTo(cx + 6.5, cy + 15, cx + 13, cy + 11); c.stroke();
|
||||
} else if (el === 'burn') { // flame
|
||||
c.beginPath(); c.moveTo(cx, cy - 13); c.quadraticCurveTo(cx + 11, cy - 1, cx + 7, cy + 9); c.quadraticCurveTo(cx, cy + 14, cx - 7, cy + 9);
|
||||
c.quadraticCurveTo(cx - 11, cy - 1, cx - 2, cy - 5); c.quadraticCurveTo(cx - 1, cy - 9, cx, cy - 13); c.fill();
|
||||
c.globalCompositeOperation = 'destination-out';
|
||||
c.beginPath(); c.moveTo(cx, cy - 1); c.quadraticCurveTo(cx + 5, cy + 5, cx + 2, cy + 9); c.quadraticCurveTo(cx - 2, cy + 11, cx - 4, cy + 7); c.quadraticCurveTo(cx - 4, cy + 3, cx, cy - 1); c.fill();
|
||||
c.globalCompositeOperation = 'source-over';
|
||||
} else { // 凋亡: an eye-like lozenge
|
||||
c.lineWidth = 2.2;
|
||||
c.beginPath(); c.moveTo(cx - 13, cy); c.lineTo(cx, cy - 8); c.lineTo(cx + 13, cy); c.lineTo(cx, cy + 8); c.closePath(); c.stroke();
|
||||
c.beginPath(); c.moveTo(cx - 6, cy); c.lineTo(cx, cy - 3.5); c.lineTo(cx + 6, cy); c.lineTo(cx, cy + 3.5); c.closePath(); c.fill();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The HUD ring atlas: `{ arcs: Texture[RING_STEPS + 1], track, disc: { [element]: Texture }, discEnemy: { [element] },
|
||||
* downDisc }` — every frame RC px square, centred on the ring centre (anchor 0.5). Built once, lazily.
|
||||
*/
|
||||
export function hudRings() {
|
||||
if (_rings) return _rings;
|
||||
const P = PIXI();
|
||||
const W = 8 * RC, rowsArc = Math.ceil((RING_STEPS + 1) / 8), H = (rowsArc + 2) * RC;
|
||||
const canvas = makeCanvas(W, H);
|
||||
const c = canvas.getContext('2d');
|
||||
const cell = (i) => [(i % 8) * RC, Math.floor(i / 8) * RC];
|
||||
const frames = { arcs: [] };
|
||||
// arcs: white with a thin dark edge (readable on light and dark ground)
|
||||
for (let k = 0; k <= RING_STEPS; k++) {
|
||||
const [x, y] = cell(k);
|
||||
frames.arcs.push([x, y]);
|
||||
if (!k) continue;
|
||||
const cx = x + RC / 2, cy = y + RC / 2, a0 = -Math.PI / 2, a1 = a0 + (Math.PI * 2 * k) / RING_STEPS;
|
||||
c.lineCap = k === RING_STEPS ? 'butt' : 'round';
|
||||
c.strokeStyle = 'rgba(0,0,0,0.55)'; c.lineWidth = RING_W + 2.5;
|
||||
c.beginPath(); c.arc(cx, cy, RING_R, a0, a1); c.stroke();
|
||||
c.strokeStyle = '#ffffff'; c.lineWidth = RING_W;
|
||||
c.beginPath(); c.arc(cx, cy, RING_R, a0, a1); c.stroke();
|
||||
}
|
||||
const row = rowsArc * RC;
|
||||
// row A: ally discs (with glyph), the track ring, the dark disc of the redeploy ring
|
||||
RING_KEYS.forEach((el, i) => {
|
||||
const x = i * RC, cx = x + RC / 2, cy = row + RC / 2;
|
||||
c.fillStyle = ELEMENT_RING[el].disc; c.beginPath(); c.arc(cx, cy, DISC_R, 0, Math.PI * 2); c.fill();
|
||||
c.strokeStyle = 'rgba(0,0,0,0.5)'; c.lineWidth = 1.5; c.stroke();
|
||||
c.save(); c.beginPath(); c.arc(cx, cy, DISC_R - 1, 0, Math.PI * 2); c.clip();
|
||||
drawElementGlyph(c, el, cx, cy, ELEMENT_RING[el].glyph);
|
||||
c.restore();
|
||||
frames['a:' + el] = [x, row];
|
||||
});
|
||||
{
|
||||
const x = RING_KEYS.length * RC, cx = x + RC / 2, cy = row + RC / 2;
|
||||
c.strokeStyle = 'rgba(12,16,15,0.72)'; c.lineWidth = RING_W + 2.5; c.beginPath(); c.arc(cx, cy, RING_R, 0, Math.PI * 2); c.stroke();
|
||||
c.strokeStyle = 'rgba(255,255,255,0.16)'; c.lineWidth = RING_W; c.beginPath(); c.arc(cx, cy, RING_R, 0, Math.PI * 2); c.stroke();
|
||||
frames.track = [x, row];
|
||||
const x2 = x + RC, cx2 = x2 + RC / 2;
|
||||
const g = c.createRadialGradient(cx2, cy, 2, cx2, cy, RING_R + 3);
|
||||
g.addColorStop(0, 'rgba(18,24,22,0.92)'); g.addColorStop(0.8, 'rgba(10,14,13,0.86)'); g.addColorStop(1, 'rgba(10,14,13,0)');
|
||||
c.fillStyle = g; c.beginPath(); c.arc(cx2, cy, RING_R + 3, 0, Math.PI * 2); c.fill();
|
||||
frames.downDisc = [x2, row];
|
||||
}
|
||||
// row B: enemy discs (element colour only)
|
||||
RING_KEYS.forEach((el, i) => {
|
||||
const x = i * RC, cx = x + RC / 2, cy = row + RC + RC / 2;
|
||||
c.fillStyle = ELEMENT_RING[el].disc; c.beginPath(); c.arc(cx, cy, DISC_R, 0, Math.PI * 2); c.fill();
|
||||
c.strokeStyle = 'rgba(0,0,0,0.5)'; c.lineWidth = 1.5; c.stroke();
|
||||
frames['e:' + el] = [x, row + RC];
|
||||
});
|
||||
const base = P.BaseTexture.from(canvas, { mipmap: P.MIPMAP_MODES.ON });
|
||||
const tex = ([x, y]) => new P.Texture(base, new P.Rectangle(x, y, RC, RC));
|
||||
_rings = {
|
||||
base, canvas, size: RC,
|
||||
arcs: frames.arcs.map(tex),
|
||||
track: tex(frames.track),
|
||||
downDisc: tex(frames.downDisc),
|
||||
disc: Object.fromEntries(RING_KEYS.map((el) => [el, tex(frames['a:' + el])])),
|
||||
discEnemy: Object.fromEntries(RING_KEYS.map((el) => [el, tex(frames['e:' + el])])),
|
||||
};
|
||||
return _rings;
|
||||
}
|
||||
|
||||
/** Arc frame of a fraction 0..1 (rounded to the nearest step; a non-zero fraction never shows an empty ring). */
|
||||
export function ringArc(frac) {
|
||||
const r = hudRings();
|
||||
const f = frac > 1 ? 1 : frac > 0 ? frac : 0;
|
||||
let k = Math.round(f * RING_STEPS);
|
||||
if (k === 0 && f > 0) k = 1;
|
||||
return r.arcs[k];
|
||||
}
|
||||
+165
-73
@@ -14,6 +14,17 @@
|
||||
// plane). `setDir(dir)` re-orients a live view (swapping Front ⇄ Back without a fallback flash). Enemies flip by the
|
||||
// sign of their horizontal velocity (with hysteresis).
|
||||
//
|
||||
// Knocked-out operators (user playtest #4 item 9, b.snap `down`): `setDown([id, respawnAt, respawnTime, state])`
|
||||
// keeps a dead operator on its tile in its knocked-down pose — the Spine Die clip played once and held on its last
|
||||
// frame (the collapsed / kneeling pose with closed eyes), slightly greyed — with a redeploy ring above its head:
|
||||
// a dark disc, a mint arc filling as the respawn timer runs and the seconds left; once the timer is done and it still
|
||||
// waits, a full amber ring with "DP" (not enough DP) or a red ring with "!" (its tile is taken). `onDeploy` (the
|
||||
// redeploy) plays the deploy clip and restores the normal look; `setDown(null)` on a dead view lets it fade out.
|
||||
// Element gauges (item 8, b.snap `elem` → sample `el` / `elFill` / `elUntil` / `elDur`): the official element icon
|
||||
// beside the bars (PRTS 元素: operators the element's disc with its glyph, enemies a smaller plain disc of the
|
||||
// element's colour) inside a white ring of the remaining 元素值 (1 − fill); during a 爆发冷却 the ring refills over
|
||||
// the cooldown. Both rings are sprites of one atlas (textures.js hudRings), created on first use and hidden when idle.
|
||||
//
|
||||
// ItemView renders hand items as a floating icon plate; DeviceView renders battle devices (crates) as 3D boxes
|
||||
// with an HP bar once damaged.
|
||||
//
|
||||
@@ -23,17 +34,15 @@
|
||||
// (`ctx.loadLevel()` 1–3, app.js) small / far units (< ~56 px per tile) animate every 2nd frame, and from level 2 on
|
||||
// every unit does.
|
||||
//
|
||||
// Picking (user playtest #3 item 7): `pickShape()` describes the body as drawn — feet, px per tile, the model's own
|
||||
// height (measured once from the posed skeleton's bounds; the median chibi until then), facing, draw order and the
|
||||
// HUD it shows (tier chip / bars) — for the shared rule of render/pick.js; `bounds()` / `hitTest()` use the same shape.
|
||||
// A dragged (lifted) item plate is drawn centred on its ground point, i.e. on the pointer (render/app.js).
|
||||
// Picking is by tile (render/pick.js, user playtest #4 item 1): views carry no hit shapes; `bounds()` is the drawn body's
|
||||
// screen rect for tooltips and overlays (view.pieceScreenRect). A dragged (lifted) item plate is drawn centred on its
|
||||
// ground point, i.e. on the pointer (render/app.js).
|
||||
|
||||
import { UF, ANIM } from '../../../shared/constants.js';
|
||||
import { SpineActor } from './spine.js';
|
||||
import { diamondTexture, shadowTexture, fxAtlas, tierChip, statusTexture, itemTexture } from './textures.js';
|
||||
import { diamondTexture, shadowTexture, fxAtlas, tierChip, statusTexture, itemTexture, hudRings, ringArc } from './textures.js';
|
||||
import { COLORS, TIER_COLORS, ENEMY_FRAME, UNIT, statusIconKey } from './style.js';
|
||||
import { drawCrate, rowDepthKey, ROW_KEY, deviceBoxOf, DEVICE_BOX } from './tiles.js';
|
||||
import { BODY_H, bodyBounds, hitBody } from './pick.js';
|
||||
|
||||
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
|
||||
const DIRS = ['UP', 'RIGHT', 'DOWN', 'LEFT'];
|
||||
@@ -54,6 +63,17 @@ const RAISED_Z = 0.12;
|
||||
/** Flying units hover this many tiles above the ground they cross. */
|
||||
export const FLY_HOVER = 0.32;
|
||||
|
||||
/** b.snap `down` entry states (server/sim/constants.js DOWN_STATE). */
|
||||
export const DOWN_STATE = Object.freeze({ COUNTING: 0, WAIT_DP: 1, WAIT_TILE: 2 });
|
||||
/** Knocked-down look: model tint and alpha; redeploy ring colours per state; ring size (tiles) and height. */
|
||||
export const DOWN_LOOK = Object.freeze({
|
||||
tint: 0xb4b4b4, alpha: 0.92,
|
||||
ring: Object.freeze({ [DOWN_STATE.COUNTING]: 0x4ed8af, [DOWN_STATE.WAIT_DP]: 0xffc600, [DOWN_STATE.WAIT_TILE]: 0xff4b3e }),
|
||||
size: 0.42, height: 1.02,
|
||||
});
|
||||
/** Element ring diameter in tiles (operators; enemies ×0.8) and its pixel clamp. */
|
||||
const EL_RING = 0.3;
|
||||
|
||||
/**
|
||||
* Keep `obj` in the right layer: the surface container of block row round(y) when it lies on a raised top at
|
||||
* height `z` (drawn after that row's blocks), else `fallback`. Reparents only on change.
|
||||
@@ -132,8 +152,13 @@ export class UnitView {
|
||||
this.statuses = new Set();
|
||||
this.alive = true;
|
||||
this.dying = 0; // seconds left of the death fade (0 = not dying)
|
||||
this.dieT = 0;
|
||||
this.dieT = 0; // seconds since die() (the Die clip's clock; a late Spine model catches up)
|
||||
this.remove = false; // set when the death fade ended (owner removes the view)
|
||||
this.down = null; // knocked out, waiting to redeploy: { until, total, state } (setDown) — no fade meanwhile
|
||||
this.gameT = 0; // battle game time of the frame (render clock), for the rings' countdowns
|
||||
this.el = null; this.elFill = 0; this.elUntil = 0; this.elDur = 0; // shown element gauge (sync)
|
||||
this._elRing = null; // { track, disc, arc } sprites, built on first use
|
||||
this._downRing = null; // { disc, track, arc, text } sprites, built on first use
|
||||
this.alpha = 1; this.fadeIn = this.prep ? 1 : 0;
|
||||
this.lunge = 0; this.lungeDir = { x: 1, y: 0 };
|
||||
this.flash = 0;
|
||||
@@ -143,9 +168,6 @@ export class UnitView {
|
||||
this.shake = 0;
|
||||
this.screen = { x: 0, y: 0, s: 1, top: 0 };
|
||||
this.destroyed = false;
|
||||
this._bodyH = null; // model height (tiles above the feet), measured once per Spine model (pickShape)
|
||||
this._hudBox = null; // screen rect of the HUD shown this frame (tier chip / bars), or null
|
||||
this._flip = this.visFacing; // drawn facing (±1) of the model
|
||||
|
||||
// --- display objects
|
||||
this.shadow = new P.Sprite(ctx.shadowTex || shadowTexture());
|
||||
@@ -253,9 +275,12 @@ export class UnitView {
|
||||
this.spineReady = true;
|
||||
this.swapT = swap ? 1 : 0;
|
||||
this.actor.spine.alpha = swap ? 1 : 0;
|
||||
// replay current state
|
||||
if (!this.alive) this.actor.die();
|
||||
else {
|
||||
// replay current state (a dead model resumes its Die clip where it would be — a knocked-down one holds its end)
|
||||
if (!this.alive) {
|
||||
const d = this.actor.die();
|
||||
const at = Math.min(d, this.dieT * (this.ctx.animRate?.() || 1));
|
||||
if (at > 0) this.actor.update(at);
|
||||
} else {
|
||||
if (this.flags & UF.SKILL) this.actor.setSkill(true);
|
||||
this.actor.setBase(this._baseFromAnim());
|
||||
}
|
||||
@@ -339,9 +364,13 @@ export class UnitView {
|
||||
return 'idle';
|
||||
}
|
||||
|
||||
/** Apply an interpolated sample (render/interp.js). */
|
||||
sync(s) {
|
||||
/** Apply an interpolated sample (render/interp.js); `t` = the render clock's game time (ring countdowns). */
|
||||
sync(s, t) {
|
||||
if (!s) return;
|
||||
if (Number.isFinite(t)) this.gameT = t;
|
||||
// the element gauge shown (b.snap `elem`): element, fill 0..1, cooldown end (game s) and length
|
||||
this.el = typeof s.el === 'string' ? s.el : null;
|
||||
this.elFill = this.el ? s.elFill || 0 : 0; this.elUntil = this.el ? s.elUntil || 0 : 0; this.elDur = this.el ? s.elDur || 0 : 0;
|
||||
this.x = s.x; this.y = s.y;
|
||||
this.flying = !!(s.flags & UF.FLYING) || this.info.motion === 'FLY';
|
||||
// ground enemies only ever walk low tiles (a rounding step onto a block edge must not pop them up)
|
||||
@@ -406,7 +435,6 @@ export class UnitView {
|
||||
this.imp = null;
|
||||
}
|
||||
this._box = null;
|
||||
this._bodyH = null; // the next model is measured again
|
||||
const old = this.actor;
|
||||
this.actor = null;
|
||||
this.spineReady = false;
|
||||
@@ -480,10 +508,38 @@ export class UnitView {
|
||||
revive() {
|
||||
this.alive = true;
|
||||
this.dying = 0; this.remove = false; this.alpha = 1;
|
||||
this.down = null;
|
||||
this.hp = this.maxHp; this.ghostHp = this.hp;
|
||||
if (this.actor) this.actor.revive();
|
||||
}
|
||||
|
||||
/**
|
||||
* Knocked-out state (b.snap `down` entry `[id, respawnAt, respawnTime, state]`, render/interp.js downAt) or null;
|
||||
* `t` = the render clock's game time. A living view is knocked down first (its Die clip plays; `instant`: a view made
|
||||
* for a unit that is already down — a field joined mid-battle — starts on the held end of the clip). While down the
|
||||
* view never fades: the Die clip's last frame stays on the tile under the redeploy ring. null on a view still down
|
||||
* (it left for good) starts the fade; the redeploy itself comes through onDeploy / revive.
|
||||
*/
|
||||
setDown(d, t, instant = false) {
|
||||
if (Number.isFinite(t)) this.gameT = t;
|
||||
if (!d) {
|
||||
if (!this.down) return;
|
||||
this.down = null;
|
||||
if (!this.alive) { this.dying = 0.55; this.dieDur = 0.55; }
|
||||
return;
|
||||
}
|
||||
if (this.alive) {
|
||||
this.die();
|
||||
if (instant) { this.dieT = 30; if (this.actor) this.actor.update(30); }
|
||||
}
|
||||
if (this.zTarget == null) this.z = this.zTarget = groundZ(this.ctx, this.x, this.y); // never synced: its tile top
|
||||
this.dying = 0; // no death fade while down
|
||||
const dn = this.down || (this.down = { until: 0, total: 0, state: DOWN_STATE.COUNTING });
|
||||
dn.until = Number(d[1]) || 0;
|
||||
dn.total = Math.max(0, Number(d[2]) || 0);
|
||||
dn.state = d[3] | 0;
|
||||
}
|
||||
|
||||
// ---- per frame -------------------------------------------------------------------------------------------
|
||||
|
||||
/** @param {number} dt real seconds @param {any} cam camera @param {number} t real clock */
|
||||
@@ -501,7 +557,11 @@ export class UnitView {
|
||||
// fades
|
||||
if (this.fadeIn < 1) this.fadeIn = Math.min(1, this.fadeIn + dt * 4);
|
||||
let alpha = this.fadeIn;
|
||||
if (this.dying > 0) {
|
||||
if (this.down) {
|
||||
// knocked down: the Die clip ends and holds its last frame (the actor keeps its clock), no fade
|
||||
this.dieT += dt;
|
||||
alpha *= DOWN_LOOK.alpha;
|
||||
} else if (this.dying > 0) {
|
||||
this.dieT += dt;
|
||||
this.dying -= dt;
|
||||
const tail = 0.55;
|
||||
@@ -524,7 +584,6 @@ export class UnitView {
|
||||
// off-screen: nothing to animate or draw (bounds / hit-testing still follow `screen`)
|
||||
if (this._cull(bx, by, s, dt)) return;
|
||||
const flip = this.isEnemy ? (ENEMY_MODEL_FACES_LEFT ? -this.visFacing : this.visFacing) : this.visFacing;
|
||||
this._flip = flip;
|
||||
|
||||
// shadow (on a raised top it is drawn with that block row, else in the shadow layer under everything)
|
||||
placeOnGround(this.ctx, this.shadow, this.ctx.layers.shadow, this.y, this.z);
|
||||
@@ -543,7 +602,8 @@ export class UnitView {
|
||||
const sc = s * UNIT.modelScale;
|
||||
const flashK = this.flash > 0 ? this.flash : 0;
|
||||
let tint = 0xffffff;
|
||||
if (this.flags & UF.FROZEN) tint = 0x9fd4ff;
|
||||
if (this.down) tint = DOWN_LOOK.tint;
|
||||
else if (this.flags & UF.FROZEN) tint = 0x9fd4ff;
|
||||
else if (this.flags & UF.COLD) tint = 0xcfe6ff;
|
||||
if (flashK > 0) tint = mixTint(tint, 0xff8a80, flashK * 0.8);
|
||||
let animDt = dt * (this.ctx.animRate?.() || 1);
|
||||
@@ -569,8 +629,6 @@ export class UnitView {
|
||||
this.actor.update(animDt);
|
||||
if (this._tint !== tint) { this._tint = tint; this.actor.spine.tint = tint; }
|
||||
}
|
||||
// the model's own height for picking: once, from its first resting pose (not a deploy / skill clip)
|
||||
if (this._bodyH == null && this.actor.clock > 0 && this.actor.mode === 'base') this._bodyH = this._measureBody();
|
||||
}
|
||||
// the diamond is only needed while no model shows (a cross-fade keeps whatever diamond was already up)
|
||||
if (!spineShown) this._ensurePicture();
|
||||
@@ -579,7 +637,7 @@ export class UnitView {
|
||||
const bob = this.alive ? Math.sin(t * 2.4 + this.bob) * s * 0.03 : 0;
|
||||
this.fallback.scale.set(size / 160);
|
||||
this.fallback.position.set(0, -s * 0.08 + bob);
|
||||
this.fallback.tint = this.flash > 0 ? mixTint(0xffffff, 0xff8a80, this.flash) : (this.flags & UF.FROZEN ? 0x9fd4ff : 0xffffff);
|
||||
this.fallback.tint = this.down ? DOWN_LOOK.tint : this.flash > 0 ? mixTint(0xffffff, 0xff8a80, this.flash) : (this.flags & UF.FROZEN ? 0x9fd4ff : 0xffffff);
|
||||
if (!this.alive) this.fallback.alpha = Math.max(0, this.fallback.alpha);
|
||||
}
|
||||
this.flash = Math.max(0, this.flash - dt * 6);
|
||||
@@ -656,7 +714,8 @@ export class UnitView {
|
||||
const showHp = showBars && (!this.isEnemy || damaged || this.isBoss);
|
||||
const bw = clamp(s * (this.isBoss ? UNIT.bossBarWidth : UNIT.barWidth), 24, this.isBoss ? 260 : 96);
|
||||
const bh = clamp(s * (this.isBoss ? 0.12 : 0.075), 3, this.isBoss ? 12 : 7);
|
||||
this.hud.alpha = this.dying > 0 ? 0 : alpha;
|
||||
// a knocked-down operator's HUD is its redeploy ring alone, drawn at full strength over the greyed model
|
||||
this.hud.alpha = this.down ? this.fadeIn : this.dying > 0 ? 0 : alpha;
|
||||
let sx = this.shake > 0 ? Math.sin(t * 90) * this.shake * 10 : 0;
|
||||
this.shake = Math.max(0, this.shake - dt);
|
||||
const x0 = x - bw / 2 + sx;
|
||||
@@ -706,16 +765,6 @@ export class UnitView {
|
||||
if (prep) this.chip.position.set(x, y - this.chip.height / 2 + 2);
|
||||
else this.chip.position.set(x0 - this.chip.width / 2 - 1, cy + (showSp ? spH / 2 : 0));
|
||||
}
|
||||
// the HUD's screen rect (drawn above every unit: a press on a unit's chip / bars is on that unit, render/pick.js)
|
||||
let hx0 = Infinity, hy0 = Infinity, hx1 = -Infinity, hy1 = -Infinity;
|
||||
const hudAdd = (ax, ay, bx2, by2) => { hx0 = Math.min(hx0, ax); hy0 = Math.min(hy0, ay); hx1 = Math.max(hx1, bx2); hy1 = Math.max(hy1, by2); };
|
||||
if (showHp) hudAdd(x0 - 1, cy - bh / 2 - 1, x0 + bw + 1, cy + bh / 2 + 1);
|
||||
if (showSp) hudAdd(x0 - 1, cy, x0 + bw + 1, this._spY + spH / 2 + 1);
|
||||
if (this.chip && this.chip.visible) {
|
||||
const cw = this.chip.width / 2, ch = this.chip.height / 2;
|
||||
hudAdd(this.chip.position.x - cw, this.chip.position.y - ch, this.chip.position.x + cw, this.chip.position.y + ch);
|
||||
}
|
||||
this._hudBox = hx1 > hx0 && hy1 > hy0 && this.hud.alpha > 0.05 ? { x0: hx0, y0: hy0, x1: hx1, y1: hy1 } : null;
|
||||
// status icons row above the bars
|
||||
const icons = this._iconKeys();
|
||||
const isz = clamp(s * 0.26, 12, 26);
|
||||
@@ -731,6 +780,9 @@ export class UnitView {
|
||||
ic.width = ic.height = isz;
|
||||
ic.position.set(x - ((icons.length - 1) * (isz + 2)) / 2 + i * (isz + 2), iy);
|
||||
}
|
||||
// element gauge right of the bars (b.snap `elem`); redeploy ring above a knocked-down operator (b.snap `down`)
|
||||
this._updateElementRing(showBars && !!this.el, x0 + bw, cy + (showSp ? spH / 2 : 0), s);
|
||||
this._updateDownRing(!prep && !!this.down && !this.alive, x, this.screen.y - DOWN_LOOK.height * s, s, t);
|
||||
// blocked marker at the feet (enemies held by a blocker)
|
||||
const blocked = !prep && this.alive && this.isEnemy && (this.flags & UF.BLOCKED);
|
||||
this.blockIcon.visible = !!blocked;
|
||||
@@ -754,6 +806,72 @@ export class UnitView {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Element gauge icon (see header): the element's disc — operators with its glyph, enemies plain and smaller — in a
|
||||
* white ring of the remaining 元素值 (1 − fill), which refills over a 爆发冷却; left edge at `xl`, centred on `cy`.
|
||||
* Sprites of the hudRings atlas, built on the first gauge and hidden while there is none.
|
||||
*/
|
||||
_updateElementRing(show, xl, cy, s) {
|
||||
let r = this._elRing;
|
||||
if (!show) { if (r) r.root.visible = false; return; }
|
||||
const tex = hudRings();
|
||||
if (!r) {
|
||||
const P = this.P;
|
||||
const root = new P.Container();
|
||||
const mk = (tx) => { const sp = new P.Sprite(tx); sp.anchor.set(0.5); root.addChild(sp); return sp; };
|
||||
r = this._elRing = { root, track: mk(tex.track), disc: mk(tex.disc.burn), arc: mk(tex.arcs[0]) };
|
||||
this.hud.addChild(root);
|
||||
}
|
||||
const cooling = this.elDur > 0 && this.elUntil > 0;
|
||||
const frac = cooling ? clamp(1 - (this.elUntil - this.gameT) / this.elDur, 0, 1) : clamp(1 - this.elFill, 0, 1);
|
||||
r.disc.texture = (this.isEnemy ? tex.discEnemy : tex.disc)[this.el] || tex.disc.burn;
|
||||
r.arc.texture = ringArc(frac);
|
||||
const d = clamp(s * EL_RING * (this.isEnemy ? 0.8 : 1), 13, 32);
|
||||
r.root.scale.set(d / tex.size);
|
||||
r.root.position.set(xl + d / 2 + 2, cy);
|
||||
r.root.visible = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redeploy ring above a knocked-down operator (see header), centred on (cx, cy): a mint arc filling as its respawn
|
||||
* timer runs with the seconds left; a full pulsing amber ring with "DP" / red ring with "!" once it only waits.
|
||||
*/
|
||||
_updateDownRing(show, cx, cy, s, t) {
|
||||
let r = this._downRing;
|
||||
if (!show) { if (r) r.root.visible = false; return; }
|
||||
const tex = hudRings();
|
||||
if (!r) {
|
||||
const P = this.P;
|
||||
const root = new P.Container();
|
||||
const mk = (tx) => { const sp = new P.Sprite(tx); sp.anchor.set(0.5); root.addChild(sp); return sp; };
|
||||
const disc = mk(tex.downDisc), track = mk(tex.track), arc = mk(tex.arcs[0]);
|
||||
const text = new P.Text('', { fontFamily: 'Bender, Oxanium, "Noto Sans SC", sans-serif', fontSize: 32, fontWeight: '700', fill: '#ffffff', stroke: '#0b0f0e', strokeThickness: 6 });
|
||||
text.anchor.set(0.5);
|
||||
root.addChild(text);
|
||||
r = this._downRing = { root, disc, track, arc, text, label: null };
|
||||
this.hud.addChild(root);
|
||||
}
|
||||
const dn = this.down;
|
||||
const counting = dn.state === DOWN_STATE.COUNTING;
|
||||
const left = Math.max(0, dn.until - this.gameT);
|
||||
r.arc.texture = ringArc(counting ? (dn.total > 0 ? clamp(1 - left / dn.total, 0, 1) : 1) : 1);
|
||||
const color = DOWN_LOOK.ring[dn.state] ?? DOWN_LOOK.ring[DOWN_STATE.COUNTING];
|
||||
r.arc.tint = color;
|
||||
const label = counting ? String(Math.ceil(left - 1e-6)) : dn.state === DOWN_STATE.WAIT_DP ? 'DP' : '!';
|
||||
if (r.label !== label) {
|
||||
r.label = label;
|
||||
r.text.text = label;
|
||||
r.text.style.fill = counting ? '#ffffff' : '#' + color.toString(16).padStart(6, '0');
|
||||
}
|
||||
const d = clamp(s * DOWN_LOOK.size, 22, 52);
|
||||
const k = d / tex.size;
|
||||
r.disc.scale.set(k); r.track.scale.set(k); r.arc.scale.set(k);
|
||||
r.text.scale.set((d * (label.length > 2 ? 0.34 : 0.44)) / 32);
|
||||
r.root.position.set(cx, cy);
|
||||
r.root.alpha = counting ? 1 : 0.72 + 0.28 * Math.sin(t * 5);
|
||||
r.root.visible = true;
|
||||
}
|
||||
|
||||
// ---- impostor mode (crowded fields, clipped skeletons): the skeleton is rendered into a slot of the shared
|
||||
// impostor atlas (render/impostor.js) every `interval` frames (staggered per unit; every frame for clipped
|
||||
// skeletons, whose stencil masks must stay out of the main pass) and shown as one sprite. Without a free atlas
|
||||
@@ -872,6 +990,9 @@ export class UnitView {
|
||||
for (const k of this.statuses) {
|
||||
if (out.length >= 4) break;
|
||||
if (k === 'skill') continue;
|
||||
// a burst's lock ('burnBurst', 'neuralBurst' … — the 爆发冷却) is shown by the element icon beside the bars
|
||||
// (b.snap `elem`); only a feed without gauges (an older recording) shows it as a status
|
||||
if (this.el && k.endsWith('Burst')) continue;
|
||||
const icon = statusIconKey(k);
|
||||
// flag-driven states are authoritative (a stale 'stun' status must not outlive the flag)
|
||||
if (!icon || icon === 'stun' || icon === 'freeze' || icon === 'sleep' || icon === 'stealth' || icon === 'invuln') continue;
|
||||
@@ -881,39 +1002,13 @@ export class UnitView {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Model height (tiles above the feet) of the posed Spine model: its local bounds' top, clamped per kind. */
|
||||
_measureBody() {
|
||||
let b = null;
|
||||
try { b = this.actor.spine.getLocalBounds(); } catch { b = null; }
|
||||
const top = b && Number.isFinite(b.y) && b.height > 1 ? -b.y * UNIT.modelScale : NaN;
|
||||
const [lo, hi] = this.isEnemy ? (this.isBoss ? [0.6, 3.2] : [0.45, 2.4]) : [1.0, 1.55];
|
||||
if (Number.isFinite(top) && top > 0.2) return clamp(top, lo, hi);
|
||||
return this.isEnemy ? clamp(this._headTiles || BODY_H, lo, hi) : BODY_H;
|
||||
}
|
||||
|
||||
/**
|
||||
* The body as drawn, for render/pick.js (canvas CSS px): the Spine chibi (feet, px per tile, its height — measured,
|
||||
* else the median chibi / the enemy's bounds —, width factor, facing, draw order, HUD) or the avatar diamond shown
|
||||
* until the model is there.
|
||||
* @returns {import('./pick.js').PickBody}
|
||||
*/
|
||||
pickShape() {
|
||||
const sc = this.screen;
|
||||
const depth = this.root && !this.root.destroyed ? this.root.zIndex : 0;
|
||||
const hud = this.culled ? null : this._hudBox; // a culled unit's HUD box is where it was last drawn: stale
|
||||
if (!(this.actor && this.spineReady)) return { kind: 'diamond', x: sc.x, y: sc.y, s: sc.s || 1, d: UNIT.diamond * (this.isBoss ? 1.5 : 1), depth, hud };
|
||||
const h = this._bodyH || (this.isEnemy ? clamp(this._headTiles || BODY_H, 0.45, this.isBoss ? 3.2 : 2.4) : BODY_H);
|
||||
return { kind: 'chibi', x: sc.x, y: sc.y, s: sc.s || 1, h, w: this.isEnemy ? clamp(h / 1.2, 0.75, 2.2) : 1, flip: this._flip === -1 ? -1 : 1, depth, hud };
|
||||
}
|
||||
|
||||
/** Canvas-space bounds (CSS px) of the drawn body (the outline of pickShape, without the HUD). */
|
||||
/** Canvas-space bounds (CSS px) of the body: 0.7 tile wide, from its head (UNIT.headroom; enemies: their model) down
|
||||
* to just below the feet — tooltips and overlays (view.pieceScreenRect), not picking (render/pick.js is by tile). */
|
||||
bounds() {
|
||||
return bodyBounds({ ...this.pickShape(), hud: null });
|
||||
}
|
||||
|
||||
hitTest(x, y) {
|
||||
if (!this.alive || this.alpha < 0.05) return false;
|
||||
return hitBody(this.pickShape(), x, y) > 0;
|
||||
const s = this.screen.s || 1;
|
||||
const h = (this._headTiles || UNIT.headroom) * s;
|
||||
const w = s * 0.7;
|
||||
return { x: this.screen.x - w / 2, y: this.screen.y - h, width: w, height: h + s * 0.1 };
|
||||
}
|
||||
|
||||
setHover(on) { this.hovered = !!on; }
|
||||
@@ -967,7 +1062,7 @@ function defaultInterval(ctx, info) {
|
||||
|
||||
/**
|
||||
* Hand item: icon plate floating above its slot. While dragged (`lift` > 0) the plate is centred on its ground point —
|
||||
* the pointer (render/app.js moveDragVisual) — so the item lands, and equips, where it is drawn.
|
||||
* the pointer (render/app.js moveDragVisual); it drops on (and equips the unit on) the tile under the pointer.
|
||||
*/
|
||||
export class ItemView {
|
||||
constructor(ctx, info) {
|
||||
@@ -1008,14 +1103,11 @@ export class ItemView {
|
||||
this.shadow.alpha = 0.35;
|
||||
this.screen.top = lifted ? p.y - size / 2 : p.y - size;
|
||||
}
|
||||
/** The plate on screen for render/pick.js: a square centred on the icon. */
|
||||
pickShape() {
|
||||
const s = this.screen.s || 1;
|
||||
const cy = this.lift > 0 ? this.screen.y : this.screen.y - s * 0.31;
|
||||
return { kind: 'plate', x: this.screen.x, y: cy, s, r: 0.31, depth: this.root && !this.root.destroyed ? this.root.zIndex : 0, hud: null };
|
||||
/** Canvas-space bounds (CSS px) of the plate (centred on the pointer while dragged). */
|
||||
bounds() {
|
||||
const w = (this.screen.s || 1) * 0.62;
|
||||
return { x: this.screen.x - w / 2, y: this.lift > 0 ? this.screen.y - w / 2 : this.screen.y - w, width: w, height: w };
|
||||
}
|
||||
bounds() { return bodyBounds(this.pickShape()); }
|
||||
hitTest(x, y) { return hitBody(this.pickShape(), x, y) > 0; }
|
||||
setHover() {}
|
||||
destroy() { if (this.destroyed) return; this.destroyed = true; this.shadow.destroy(); this.root.destroy({ children: true }); }
|
||||
}
|
||||
|
||||
@@ -3,15 +3,19 @@
|
||||
// for the mode type (icon, name, LP); a band a teammate already picked carries the picker's avatar and is marked
|
||||
// 队友已选 — it cannot be chosen again (research 09 §5, guidebook 策略与轮选; the server refuses it too); right =
|
||||
// detail pane (icon, 初始生命值, name, effect name + rich description) with 跳过 (co-op, once) and 确认选择.
|
||||
// A timeout assigns 「华法琳」, or — when a teammate already holds it — the first free strategy (timeoutBand; the tip
|
||||
// under the order list names the one I would get). Solo: free pick, no timer.
|
||||
// One countdown (user playtest #4 item 4): every turn has the same clock (Match BAND_TURN_SECONDS, m.public.draft
|
||||
// turnSeconds) and the step header counts it down — m.public.deadline IS the turn's end, the same number as the
|
||||
// current picker's row. The highlighted band (the detail pane's) is what a turn that runs out takes: every change of it
|
||||
// is reported (g.bandFocus) and the server assigns it while it is free, else 「华法琳」, else the first free strategy
|
||||
// (timeoutBand). It starts on that default, so the tip under the order list always names what a timeout gives.
|
||||
// Solo, and a co-op match with a single human (the server's soloUntimed: draft.untimed): no clock at all.
|
||||
|
||||
import { useEffect, useMemo, useState } from '../../vendor/hooks.module.js';
|
||||
import { useEffect, useMemo, useRef, useState } from '../../vendor/hooks.module.js';
|
||||
import { html, Button, Icon, MicroLabel, useTicker, secondsLeft } from '../ui/components.js';
|
||||
import { useGameData, BandIcon, RichText, PlayerAvatar, LpTower, Sprite } from '../ui/gameComponents.js';
|
||||
import { StepHeader, ExitModal } from '../ui/matchChrome.js';
|
||||
import { actions } from '../ui/gameActions.js';
|
||||
import { normalizeDraft, sortedPlayers, phaseTotalSeconds } from '../ui/gameLogic.js';
|
||||
import { actions, act } from '../ui/gameActions.js';
|
||||
import { normalizeDraft, sortedPlayers } from '../ui/gameLogic.js';
|
||||
import { useStore } from '../store.js';
|
||||
import { audio } from '../audio.js';
|
||||
|
||||
@@ -64,19 +68,62 @@ export function teammateBands(picks, myId) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The band the detail pane shows: the current one, else my pick, else the first band no teammate took. When it is my
|
||||
* turn and the shown band was taken meanwhile (队友已选), the first free one instead (confirm would be disabled).
|
||||
* The band the detail pane shows (= the highlighted band a turn that runs out takes): the current one, else my pick,
|
||||
* else the band a timeout would give me (timeoutBand: 「华法琳」 while free, else the first free one). When it is my
|
||||
* turn and the shown band was taken meanwhile (队友已选), that default instead (confirm would be disabled).
|
||||
* @param {string|null} sel
|
||||
* @param {{ bands: any[], taken: Map<string, any>, myPick: string|null, myTurn: boolean }} o
|
||||
* @param {{ bands: any[], taken: Map<string, any>, myPick: string|null, myTurn: boolean, defaultId?: string }} o
|
||||
*/
|
||||
export function draftSelection(sel, { bands, taken, myPick, myTurn }) {
|
||||
export function draftSelection(sel, { bands, taken, myPick, myTurn, defaultId = DEFAULT_TIMEOUT_BAND }) {
|
||||
if (!Array.isArray(bands) || !bands.length) return sel;
|
||||
const free = (bands.find((b) => !taken.has(b.bandId)) || bands[0]).bandId;
|
||||
const free = timeoutBand(bands, taken, defaultId) || bands[0].bandId;
|
||||
if (!sel) return myPick || free;
|
||||
if (!myPick && myTurn && taken.has(sel)) return free;
|
||||
return sel;
|
||||
}
|
||||
|
||||
/**
|
||||
* The strategy a turn that runs out assigns me (server Match.timeoutBand): the highlighted band while it is one of
|
||||
* the mode's and no teammate holds it, else timeoutBand. Null after my pick.
|
||||
* @param {string|null} sel the highlighted band
|
||||
* @param {{ bands: any[], taken: Map<string, any>, myPick?: string|null, defaultId?: string }} o
|
||||
*/
|
||||
export function autoPickBand(sel, { bands, taken, myPick = null, defaultId = DEFAULT_TIMEOUT_BAND }) {
|
||||
if (myPick) return null;
|
||||
const list = Array.isArray(bands) ? bands : [];
|
||||
const has = (id) => !!(taken && typeof taken.has === 'function' && taken.has(id));
|
||||
if (sel && !has(sel) && list.some((b) => b.bandId === sel)) return sel;
|
||||
return timeoutBand(list, taken, defaultId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The tip under the co-op draft order: the one skip, the turn clock and what a turn that runs out assigns me (the
|
||||
* highlighted band while free — autoPickBand). Untimed drafts (a single human) name no clock.
|
||||
* @param {{ timed: boolean, turnSeconds?: number|null, autoName?: string|null, selected?: boolean }} o
|
||||
* selected: the auto pick is the highlighted band (not the default standing in for a band a teammate holds)
|
||||
*/
|
||||
export function draftTip({ timed, turnSeconds = null, autoName = null, selected = true }) {
|
||||
const skip = '联合模拟在选择策略时可以进行一次跳过';
|
||||
if (!timed) return `${skip};本局不限时`;
|
||||
const clock = Number(turnSeconds) > 0 ? `每位博士有 ${Math.round(turnSeconds)} 秒` : '每位博士限时决策';
|
||||
if (!autoName) return `${skip};${clock}`;
|
||||
return `${skip};${clock},超时将自动选择${selected ? '当前选中的' : ''}「${autoName}」`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The step header's countdown during the draft: the current turn's (m.public.deadline = draft.turnDeadline) with a
|
||||
* turn's length as the gauge total — null when the draft is untimed.
|
||||
* @param {any} pub m.public
|
||||
* @returns {{ deadline: number, total: number|null } | null}
|
||||
*/
|
||||
export function draftClock(pub) {
|
||||
const d = pub && typeof pub.draft === 'object' ? pub.draft : null;
|
||||
if (!d || d.untimed) return null;
|
||||
const deadline = Number(pub.deadline) > 0 ? Number(pub.deadline) : Number(d.turnDeadline) || 0;
|
||||
if (!(deadline > 0)) return null;
|
||||
return { deadline, total: Number(d.turnSeconds) > 0 ? Number(d.turnSeconds) : null };
|
||||
}
|
||||
|
||||
/** BAND_DRAFT screen. */
|
||||
export function BandDraftScreen() {
|
||||
const pub = useStore((s) => s.match.public);
|
||||
@@ -106,21 +153,31 @@ export function BandDraftScreen() {
|
||||
pickers.get(bid).push(p || { playerId: pid, name: '?' });
|
||||
}
|
||||
|
||||
// default selection: my pick, else the first band nobody else took; when my turn comes while the selected band has
|
||||
// been taken by a teammate meanwhile (队友已选), move the selection to the first free one
|
||||
// default selection: my pick, else what a timeout gives me (华法琳 while free); when my turn comes while the selected
|
||||
// band has been taken by a teammate meanwhile (队友已选), move the selection back to that default
|
||||
const defaultId = gd.config?.bandDraft?.timeoutBandId || DEFAULT_TIMEOUT_BAND;
|
||||
const takenKey = [...taken.keys()].sort().join(',');
|
||||
useEffect(() => {
|
||||
const next = draftSelection(sel, { bands, taken, myPick, myTurn });
|
||||
const next = draftSelection(sel, { bands, taken, myPick, myTurn, defaultId });
|
||||
if (next !== sel) setSel(next);
|
||||
}, [bands.length, myPick, myTurn, takenKey]);
|
||||
// "your turn" cue
|
||||
useEffect(() => { if (myTurn && !solo) audio.sfx('yourTurn'); }, [myTurn]);
|
||||
|
||||
// what a timeout gives me (the server never assigns a strategy a teammate holds — Match.js defaultBand)
|
||||
const defaultId = gd.config?.bandDraft?.timeoutBandId || DEFAULT_TIMEOUT_BAND;
|
||||
const autoId = myPick ? null : timeoutBand(bands, taken, defaultId);
|
||||
const defaultName = gd.band(defaultId)?.name || '华法琳';
|
||||
const autoName = (autoId && gd.band(autoId)?.name) || defaultName;
|
||||
// one countdown (user playtest #4 item 4): the current turn's — m.public.deadline, the same clock as the picker's row
|
||||
const clock = solo ? null : draftClock(pub);
|
||||
// the highlighted band is what a turn that runs out takes (Match.timeoutBand): report every change before my pick
|
||||
const timed = !solo && !!pub?.draft && !pub.draft.untimed;
|
||||
const focusSent = useRef(null);
|
||||
useEffect(() => {
|
||||
if (!timed || myPick || !sel || focusSent.current === sel) return;
|
||||
focusSent.current = sel;
|
||||
act('g.bandFocus', { bandId: sel }, { sfx: false, quiet: true });
|
||||
}, [sel, timed, myPick]);
|
||||
|
||||
// what a timeout gives me: the highlighted band while free, else the default (never 队友已选 — Match.js timeoutBand)
|
||||
const autoId = autoPickBand(sel, { bands, taken, myPick, defaultId });
|
||||
const autoName = (autoId && gd.band(autoId)?.name) || gd.band(defaultId)?.name || '华法琳';
|
||||
|
||||
const band = sel ? gd.band(sel) : null;
|
||||
const selTaken = !!band && taken.has(band.bandId);
|
||||
@@ -137,14 +194,15 @@ export function BandDraftScreen() {
|
||||
setBusy(null);
|
||||
};
|
||||
const turnName = players.find((p) => p.playerId === draft.turnPid)?.name;
|
||||
const turnDeadline = Number(pub?.draft?.turnDeadline) || 0;
|
||||
useTicker(!solo && turnDeadline > 0 ? 250 : 0);
|
||||
const turnSecs = !solo && turnDeadline > 0 ? secondsLeft(turnDeadline) : null;
|
||||
useTicker(clock ? 250 : 0);
|
||||
// the picker's row shows the step header's number (both read the one turn deadline)
|
||||
const turnSecs = clock ? secondsLeft(clock.deadline) : null;
|
||||
const turnLen = Number(pub?.draft?.turnSeconds) > 0 ? Math.round(pub.draft.turnSeconds) : null;
|
||||
|
||||
return html`<div class="screen draft">
|
||||
<div class="brief__bg" aria-hidden="true"></div>
|
||||
<${StepHeader} step=${2} of=${2} title="选择策略" micro="STRATEGY // BAND CHECK" pub=${pub}
|
||||
total=${phaseTotalSeconds(pub, gd.config)} onExit=${() => setExit(true)} />
|
||||
<${StepHeader} step=${2} of=${2} title="选择策略" micro="STRATEGY // BAND CHECK" pub=${clock ? { ...pub, deadline: clock.deadline } : { ...pub, deadline: 0 }}
|
||||
total=${clock ? clock.total : null} onExit=${() => setExit(true)} />
|
||||
<main class="draft__main">
|
||||
<aside class="draft-order">
|
||||
<h3 class="brief-h"><span>${solo ? '独立模拟' : '决策顺序'}</span><${MicroLabel}>${solo ? 'FREE PICK' : 'RANDOM ORDER'}</${MicroLabel}></h3>
|
||||
@@ -168,7 +226,7 @@ export function BandDraftScreen() {
|
||||
</span>
|
||||
</div>`;
|
||||
})}
|
||||
${!solo ? html`<p class="draft-order__tip">联合模拟在选择策略时可以进行一次跳过;超时将自动选择「${autoName}」${autoId && autoId !== defaultId ? `(「${defaultName}」已被队友选择)` : ''}</p>` : null}
|
||||
${!solo ? html`<p class="draft-order__tip" data-testid="draft-tip">${draftTip({ timed, turnSeconds: turnLen, autoName: myPick ? null : autoName, selected: autoId === sel })}</p>` : null}
|
||||
</aside>
|
||||
|
||||
<section class="draft-grid" role="listbox" aria-label="策略">
|
||||
|
||||
+56
-79
@@ -39,6 +39,9 @@
|
||||
// state().leaks → ui/hud.js liveLp → top bar + own team row; teammates' rows from m.public players[].pendingLp); while
|
||||
// the temp overflow row (临时整备区) holds pieces it is framed and labelled on the board (ui/underframe.js
|
||||
// TempRowNotice) and 准备就绪 / Space say why they are refused (item 3).
|
||||
// User playtest #4: the detail card shows live stats (item 7) — in battle the local sim's unit (battle/runner.js
|
||||
// unitStats), in prep an own board unit's start-of-battle stats (g.unitStats → m.unitStats); 机变 cards take two taps
|
||||
// (item 2, ui/choiceOverlay.js).
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from '../../vendor/hooks.module.js';
|
||||
import { PHASE, GEO } from '../../../shared/constants.js';
|
||||
@@ -70,7 +73,7 @@ import { pieceTile } from '../render/drag.js';
|
||||
import {
|
||||
phaseMode, phaseBanner, isCombatPhase, showDeadPill, isBossPhase, placementContext, canPlace, boardTargets, dropIntent,
|
||||
snapHud, activeBubbles, shortcutFor, shortcutBlocked, closesOnFieldPress, phaseTotalSeconds, homeFieldId, ownFieldId, normalizeSp, sortedPlayers,
|
||||
countdownState, shopBlockReason, stageOverrides, effectiveStage, watchTarget, pickPieceAt, equipRetarget, dropFailureReason,
|
||||
countdownState, shopBlockReason, stageOverrides, effectiveStage, watchTarget, dropFailureReason,
|
||||
previewEnemyKey, prepCamera, panelSide, panelSlots, bondPopupPlace, chessLoadout, unitLoadout,
|
||||
} from '../ui/gameLogic.js';
|
||||
import { toast } from '../ui/toasts.js';
|
||||
@@ -312,8 +315,6 @@ function MatchScreen() {
|
||||
}, [view, ownView, ownStage, baseStage]);
|
||||
const staleFieldRef = useRef(null);
|
||||
const enteredFieldRef = useRef(null);
|
||||
// drop target adjustment while dragging (equipment released over an operator's sprite → that operator)
|
||||
const retargetRef = useRef((uid, target) => target);
|
||||
const pressSel = useRef(null); // the selected piece when the current field press began
|
||||
useEffect(() => {
|
||||
if (!view) return;
|
||||
@@ -331,7 +332,7 @@ function MatchScreen() {
|
||||
setHud(null);
|
||||
}
|
||||
if (priv) {
|
||||
view.setPrep(priv, { editable, canPlace: (uid, target) => canPlace(live.current.placeCtx, uid, retargetRef.current(uid, target)).ok });
|
||||
view.setPrep(priv, { editable, canPlace: (uid, target) => canPlace(live.current.placeCtx, uid, target).ok });
|
||||
// stored facings (m.private board `dir`); the piece in the direction step shows the wheel's preview instead
|
||||
syncPieceDirs(view, priv, live.current.facing?.uid ?? null);
|
||||
}
|
||||
@@ -590,46 +591,10 @@ function MatchScreen() {
|
||||
useEffect(() => {
|
||||
if (!view) return undefined;
|
||||
let moveOff = null;
|
||||
// `over`: the render engine's own pick of the unit under the dragged item (pieceDragOver / pieceDrop `over`, the
|
||||
// shared rule of render/pick.js at the plate) — undefined while the view does not report it (DOM fallback)
|
||||
const ptr = { x: NaN, y: NaN, released: false, tile: null, unit: null, over: undefined };
|
||||
const insideBar = (x, y) => {
|
||||
const el = barRef.current;
|
||||
if (!el || !Number.isFinite(x)) return false;
|
||||
const r = el.getBoundingClientRect();
|
||||
return x >= r.left && x <= r.right && y >= r.top && y <= r.bottom;
|
||||
};
|
||||
// the drop target is the tile under the pointer (render/drag.js; an item dropped on a unit's tile equips that unit —
|
||||
// user playtest #4 item 1); `tile` = the last target tile (tileHover), `released` = the pointer went up (not a cancel)
|
||||
const ptr = { released: false, tile: null };
|
||||
const lookups = { getChess: gd.chess, getToken: gd.token, getItem: gd.item, chessRecord: (rec) => chessLoadout(rec, live.current.priv?.loadout ?? null, gd.chess)?.record };
|
||||
// own operator / summon under the dragged item (not the dragged piece): the engine's report, else the pieces'
|
||||
// screen shapes at the pointer (ui/gameLogic.js pickPieceAt — the same rule; the tile under the pointer decides
|
||||
// where no face / torso is hit)
|
||||
const unitUnderPointer = (uid) => {
|
||||
if (ptr.over !== undefined) return ptr.over === uid ? null : ptr.over;
|
||||
const L = live.current;
|
||||
if (typeof view.pieceScreenRect !== 'function' || !L.placeCtx || !Number.isFinite(ptr.x)) return null;
|
||||
const rects = [];
|
||||
for (const en of L.placeCtx.pieces.values()) {
|
||||
if (en.piece.uid === uid || (en.area !== 'board' && en.area !== 'hand') || (en.piece.kind !== 'chess' && en.piece.kind !== 'token')) continue;
|
||||
let r = null;
|
||||
try { r = view.pieceScreenRect(en.piece.uid); } catch { r = null; }
|
||||
if (r) rects.push({ uid: en.piece.uid, left: r.left, top: r.top, right: r.right, bottom: r.bottom, shape: r.shape || null, tile: pieceTile(en) });
|
||||
}
|
||||
const at = ptr.tile && Number.isInteger(ptr.tile.row) ? { row: ptr.tile.row, col: ptr.tile.col } : null;
|
||||
return pickPieceAt(rects, ptr.x, ptr.y, at);
|
||||
};
|
||||
const retarget = (uid, target) => {
|
||||
const L = live.current;
|
||||
if (L.placeCtx?.pieces.get(uid)?.piece.kind !== 'item' || insideBar(ptr.x, ptr.y)) return target;
|
||||
return equipRetarget(L.placeCtx, uid, target, unitUnderPointer(uid));
|
||||
};
|
||||
retargetRef.current = retarget;
|
||||
/** Light the tile of the operator a dragged item would equip (the drop target highlight follows the plate). */
|
||||
const markItemTarget = (uid) => {
|
||||
const t = retarget(uid, null);
|
||||
const unit = t && t.area === 'board' ? [[t.row, t.col]] : t && t.area === 'hand' ? [[GEO.HAND_ROW, t.idx]] : [];
|
||||
const key = JSON.stringify(unit);
|
||||
if (key !== ptr.unit) { ptr.unit = key; view.highlightTiles(unit, 'target'); }
|
||||
};
|
||||
const runIntent = async (intent) => {
|
||||
const L = live.current;
|
||||
if (intent.confirmReplace) {
|
||||
@@ -654,7 +619,6 @@ function MatchScreen() {
|
||||
};
|
||||
const endDrag = () => {
|
||||
moveOff?.(); moveOff = null;
|
||||
ptr.over = undefined; // (a drop / drag end hands its own `over` in again)
|
||||
setDrag(null);
|
||||
view.highlightTiles(null, null);
|
||||
};
|
||||
@@ -680,36 +644,20 @@ function MatchScreen() {
|
||||
const { legal } = boardTargets(L.placeCtx, entry.piece.uid);
|
||||
view.highlightTiles(null, null);
|
||||
view.highlightTiles(legal, 'legal');
|
||||
ptr.released = false; ptr.tile = null; ptr.unit = null; ptr.over = undefined;
|
||||
const isItem = entry.piece.kind === 'item';
|
||||
// capture phase: the pointer position is current before the drag controller (canvas listener) asks canPlace
|
||||
const onMove = (ev) => {
|
||||
ptr.x = ev.clientX; ptr.y = ev.clientY;
|
||||
if (isItem && ptr.over === undefined) markItemTarget(entry.piece.uid); // the engine reports via pieceDragOver
|
||||
};
|
||||
const onUp = (ev) => { ptr.x = ev.clientX; ptr.y = ev.clientY; ptr.released = true; };
|
||||
window.addEventListener('pointermove', onMove, { passive: true, capture: true });
|
||||
ptr.released = false; ptr.tile = null;
|
||||
// capture phase: marked before the drag controller (canvas listener) ends the drag
|
||||
const onUp = () => { ptr.released = true; };
|
||||
window.addEventListener('pointerup', onUp, { passive: true, capture: true });
|
||||
moveOff = () => {
|
||||
window.removeEventListener('pointermove', onMove, { capture: true });
|
||||
window.removeEventListener('pointerup', onUp, { capture: true });
|
||||
};
|
||||
moveOff = () => window.removeEventListener('pointerup', onUp, { capture: true });
|
||||
}),
|
||||
view.on('tileHover', (t) => { ptr.tile = t && typeof t === 'object' ? t : null; }),
|
||||
// the render engine's pick of the unit under a dragged item (sent before the drag asks canPlace)
|
||||
view.on('pieceDragOver', (e) => {
|
||||
if (!e || !live.current.placeCtx?.pieces.get(e.uid)) return;
|
||||
ptr.over = Number.isInteger(e.over) ? e.over : null;
|
||||
markItemTarget(e.uid);
|
||||
}),
|
||||
view.on('pieceDrop', async (e) => {
|
||||
endDrag();
|
||||
const L = live.current;
|
||||
if (!e || !L.editable) return;
|
||||
const entry = L.placeCtx?.pieces.get(e.uid);
|
||||
if (!entry) return;
|
||||
if ('over' in e) ptr.over = Number.isInteger(e.over) ? e.over : null;
|
||||
const t = retarget(e.uid, e.target || {});
|
||||
const t = e.target || {};
|
||||
if (t.area === 'outside') return; // no drag-to-sell (research 09 §5): the piece goes back
|
||||
const res = canPlace(L.placeCtx, e.uid, t);
|
||||
if (needsFacing(L.placeCtx, e.uid, t, res)) { openFacing(entry, t); return; }
|
||||
@@ -720,8 +668,9 @@ function MatchScreen() {
|
||||
}
|
||||
await runIntent(intent);
|
||||
}),
|
||||
view.on('pieceDragEnd', async (e) => {
|
||||
// a cancelled drag (no pieceDrop) must not leave the sell zone / highlights behind
|
||||
view.on('pieceDragEnd', (e) => {
|
||||
// a cancelled drag (no pieceDrop) must not leave the highlights behind; a release on a tile that takes nothing
|
||||
// (the drag controller found no legal target there) says why
|
||||
const released = ptr.released;
|
||||
const tile = ptr.tile;
|
||||
endDrag();
|
||||
@@ -732,17 +681,6 @@ function MatchScreen() {
|
||||
const onOwnSlot = !!tile && ((src.area === 'board' && tile.area === 'board' && tile.row === src.row && tile.col === src.col)
|
||||
|| (src.area !== 'board' && tile.area === src.area && (tile.idx ?? tile.col) === src.idx));
|
||||
if (onOwnSlot) return; // put back where it was
|
||||
// released where the drag controller found no target: equipment over an operator's sprite still equips it,
|
||||
// anything else says why it was refused
|
||||
if ('over' in e) ptr.over = Number.isInteger(e.over) ? e.over : null;
|
||||
const t = retarget(e.uid, null);
|
||||
if (t) {
|
||||
const res = canPlace(L.placeCtx, e.uid, t);
|
||||
if (needsFacing(L.placeCtx, e.uid, t, res)) { openFacing(src, t); return; }
|
||||
const intent = dropIntent(L.placeCtx, e.uid, t);
|
||||
if (intent) { await runIntent(intent); return; }
|
||||
if (res.code && res.code !== 'ALREADY') { refuse(res.reason); return; }
|
||||
}
|
||||
const reason = dropFailureReason(L.placeCtx, e.uid, tile);
|
||||
if (reason) refuse(reason);
|
||||
}),
|
||||
@@ -923,6 +861,45 @@ function MatchScreen() {
|
||||
return t ? { hp: t[3], max: t[4] } : null;
|
||||
})();
|
||||
|
||||
// ---- live stats of the detail card (user playtest #4 item 7) ------------------------------------------------------
|
||||
// battle: the local sim's unit (battle/runner.js unitStats — a getter the panel re-reads 4× a second; any unit of the
|
||||
// battle on screen: own, a teammate's, an enemy). Prep: an own board unit's stats at the start of its next battle,
|
||||
// asked from the server (g.unitStats → m.unitStats; only the newest request's answer counts) whenever m.private or the
|
||||
// phase changes while such a card is open — the last answer stays on show until the next one lands
|
||||
const [unitStats, setUnitStats] = useState(null); // m.unitStats: { seq, round, units: Map<uid, entry> }
|
||||
const statsSeqRef = useRef(0);
|
||||
useEffect(() => net.on('m.unitStats', (msg) => {
|
||||
if (!msg || msg.seq !== statsSeqRef.current || !Array.isArray(msg.units)) return;
|
||||
setUnitStats({ seq: msg.seq, round: msg.round, units: new Map(msg.units.filter((u) => u && Number.isInteger(u.uid)).map((u) => [u.uid, u])) });
|
||||
}), []);
|
||||
const prepStatsUid = (phase === PHASE.PREP || phase === PHASE.SP_DRAFT || phase === PHASE.ROUND_START) && showPrep && alive
|
||||
&& !!resolved?.piece && (resolved.type === 'chess' || resolved.type === 'token') && placeCtx.pieces.get(resolved.piece.uid)?.area === 'board'
|
||||
? resolved.piece.uid : null;
|
||||
useEffect(() => {
|
||||
if (prepStatsUid == null) return undefined;
|
||||
const t = setTimeout(() => {
|
||||
statsSeqRef.current = (statsSeqRef.current % 1e9) + 1;
|
||||
try { Promise.resolve(net.request('g.unitStats', { seq: statsSeqRef.current })).catch(() => {}); } catch { /* offline */ }
|
||||
}, 120);
|
||||
return () => clearTimeout(t);
|
||||
}, [prepStatsUid, priv, phase]);
|
||||
const liveStats = (() => {
|
||||
if (prepStatsUid != null) {
|
||||
const e = unitStats && unitStats.round === pub?.round ? unitStats.units.get(prepStatsUid) : null;
|
||||
return e ? { ...e, src: 'prep' } : null;
|
||||
}
|
||||
// a battle unit's card, or an own board piece's card left open into the battle (its unit found by uid)
|
||||
const id = resolved?.unitId;
|
||||
const pieceUid = id == null && Number.isInteger(resolved?.piece?.uid) ? resolved.piece.uid : null;
|
||||
if ((id == null && pieceUid == null) || !cc || !battleRunner || !field?.local || showPrep) return null;
|
||||
const fid = field.fieldId;
|
||||
return () => {
|
||||
const uid = id ?? battleRunner.unitIdOf(pieceUid, myId, fid);
|
||||
const e = uid != null ? battleRunner.unitStats(uid, fid) : null;
|
||||
return e ? { ...e, src: 'battle' } : null;
|
||||
};
|
||||
})();
|
||||
|
||||
// ---- keyboard ---------------------------------------------------------------------------------------------
|
||||
useEffect(() => {
|
||||
const onKey = async (e) => {
|
||||
@@ -1102,7 +1079,7 @@ function MatchScreen() {
|
||||
onClose=${() => setBondOpen(null)} onMember=${(id) => setDetail({ kind: 'chess', id })} />` : null}
|
||||
|
||||
${resolved ? html`<${DetailPanel} detail=${resolved} snapHp=${snapHp} onClose=${() => { setDetail(null); setSel(null); }}
|
||||
bonds=${stripBonds} loadout=${detailLoadout} side=${dSide} shopOpen=${shopOpen}
|
||||
bonds=${stripBonds} loadout=${detailLoadout} side=${dSide} shopOpen=${shopOpen} live=${liveStats}
|
||||
onBond=${(id) => { setBondOpen((b) => (b === id ? null : id)); audio.sfx('click', { volume: 0.4 }); }} />` : null}
|
||||
|
||||
${selEntry && editable && !facing && !drag && showPrep ? html`<${Underframe} key=${sel.uid} view=${view} uid=${sel.uid}
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
// 机变 draft overlay (research 06 §4.4 / §11.5): family title | description, "倒计时结束后仍未选定将自动分配",
|
||||
// whose turn ("当前轮到你决策" / "{name} 正在决策…") with countdown, pick order with ✓ / ⌛ / … / door,
|
||||
// and a 3×2 grid (solo: 3 cards) of cards with icon, title, rich description, tier chip and the taker's
|
||||
// avatar badge. Clicking an available card on your turn sends g.choice; while it is in flight the card shows a
|
||||
// "选择中" strip with a sweeping bar (never a spinner over its text — user playtest #3 item 9), dropped as soon as the
|
||||
// pick shows in m.public (spBusy itself resets when the request settles, ≤ 8 s, or the phase moves on).
|
||||
// avatar badge. Picking takes two taps, like buying in the shop (user playtest #4 item 2 — extra enemies, items and
|
||||
// tactics were picked by a slip of the finger; research 09 §5 EventOnFirstClick → EventOnConfirm): the first tap on an
|
||||
// available card selects it (it lifts with a gold frame and a 确认选择 · 再次点击 strip; the header shows 确认选择), a
|
||||
// second tap on the same card — or 确认选择 — sends g.choice; a tap on another card moves the selection, a tap
|
||||
// elsewhere or Esc drops it. Cards are buttons (Tab / Enter work the same way). While the pick is in flight the card
|
||||
// shows a "选择中" strip with a sweeping bar (never a spinner over its text — user playtest #3 item 9), dropped as soon
|
||||
// as the pick shows in m.public (spBusy itself resets when the request settles, ≤ 8 s, or the phase moves on).
|
||||
// Untimed drafts (solo, a single-human match: sp.untimed) show no countdown and say so.
|
||||
|
||||
import { html, Icon, TierChip, Countdown, MicroLabel } from './components.js';
|
||||
import { useEffect, useState } from '../../vendor/hooks.module.js';
|
||||
import { html, Icon, TierChip, Countdown, MicroLabel, Button } from './components.js';
|
||||
import { Img, RichText, PlayerAvatar, GIcon } from './gameComponents.js';
|
||||
import { itemIconUrl, enemyIconUrl, uiUrl } from './assetUrls.js';
|
||||
import { richTextPlain } from './richText.js';
|
||||
@@ -59,9 +65,74 @@ export function pickBusy(busyIdx, card, mine) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether I may pick a card right now: my turn (solo: always), no pick of mine yet, the card free and no pick in flight.
|
||||
* @param {any} sp normalizeSp(...) @param {{ idx: number, takenBy?: string|null } | null | undefined} card
|
||||
* @param {{ myId: string, solo: boolean, busyIdx?: number|null }} o
|
||||
*/
|
||||
export function cardPickable(sp, card, { myId, solo, busyIdx = null }) {
|
||||
if (!sp || !card) return false;
|
||||
const myTurn = solo || sp.turnPid === myId;
|
||||
return myTurn && sp.pickOf.get(myId) == null && !card.takenBy && busyIdx == null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-tap step of a card tap (like the shop's first tap → confirm): tapping an available card selects it, tapping the
|
||||
* selected card again confirms it. A card that cannot be picked changes nothing.
|
||||
* @param {number|null} armed the selected card's idx
|
||||
* @param {number} idx the tapped card
|
||||
* @param {boolean} pickable cardPickable(...) of the tapped card
|
||||
* @returns {{ armed: number|null, pick: number|null }} the new selection and the card to send (g.choice) or null
|
||||
*/
|
||||
export function spTap(armed, idx, pickable) {
|
||||
if (!pickable || !Number.isInteger(idx)) return { armed, pick: null };
|
||||
if (armed === idx) return { armed: null, pick: idx };
|
||||
return { armed: idx, pick: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* The selected card while it can still be picked (my turn, not taken, nothing in flight), else null — a selection
|
||||
* never outlives the state that allowed it.
|
||||
* @param {number|null} armed @param {any} sp @param {{ myId: string, solo: boolean, busyIdx?: number|null }} o
|
||||
*/
|
||||
export function armedCard(armed, sp, o) {
|
||||
if (armed == null || !sp) return null;
|
||||
const card = sp.cards.find((c) => c && c.idx === armed);
|
||||
return cardPickable(sp, card, o) ? armed : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The overlay: keeps the two-tap selection and renders ChoiceView. `onPick(idx)` sends the confirmed card (g.choice).
|
||||
* @param {{ pub:any, sp:any, myId:string, solo:boolean, onPick:(idx:number)=>void, busyIdx?:number|null, total?:number|null }} props
|
||||
*/
|
||||
export function ChoiceOverlay({ pub, sp, myId, solo, onPick, busyIdx = null, total = null }) {
|
||||
export function ChoiceOverlay(props) {
|
||||
const { sp, myId, solo, busyIdx = null, onPick } = props;
|
||||
const [sel, setSel] = useState(null);
|
||||
const armed = armedCard(sel, sp, { myId, solo, busyIdx });
|
||||
// a selection whose card cannot be picked any more (taken, the turn moved on, a pick in flight) is dropped
|
||||
useEffect(() => { if (sel != null && armed == null) setSel(null); }, [sel, armed]);
|
||||
useEffect(() => {
|
||||
if (armed == null) return undefined;
|
||||
const onKey = (e) => { if (e.key === 'Escape') setSel(null); };
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [armed]);
|
||||
if (!sp) return null;
|
||||
const tap = (idx) => {
|
||||
const card = sp.cards.find((c) => c && c.idx === idx);
|
||||
const r = spTap(armed, idx, cardPickable(sp, card, { myId, solo, busyIdx }));
|
||||
setSel(r.armed);
|
||||
if (r.pick != null) onPick(r.pick);
|
||||
};
|
||||
return html`<${ChoiceView} ...${props} armed=${armed} onTap=${tap}
|
||||
onConfirm=${() => { if (armed != null) tap(armed); }} onDisarm=${() => setSel(null)} />`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The overlay's view (pure: no hooks — test/ui renders it as a function).
|
||||
* @param {{ pub:any, sp:any, myId:string, solo:boolean, busyIdx?:number|null, total?:number|null, armed?:number|null,
|
||||
* onTap?:(idx:number)=>void, onConfirm?:()=>void, onDisarm?:()=>void }} props
|
||||
*/
|
||||
export function ChoiceView({ pub, sp, myId, solo, busyIdx = null, total = null, armed = null, onTap = () => {}, onConfirm = () => {}, onDisarm = () => {} }) {
|
||||
if (!sp) return null;
|
||||
const fam = data.get('choices')?.families?.[sp.family] || null;
|
||||
const players = new Map(sortedPlayers(pub).map((p) => [p.playerId, p]));
|
||||
@@ -70,20 +141,32 @@ export function ChoiceOverlay({ pub, sp, myId, solo, onPick, busyIdx = null, tot
|
||||
const turnName = players.get(sp.turnPid)?.name || '队友';
|
||||
const special = /_s$/.test(String(sp.family || ''));
|
||||
const order = solo ? [] : sp.order;
|
||||
return html`<div class="spov" role="dialog" aria-label="机变阶段">
|
||||
const timed = !solo && !sp.untimed;
|
||||
const armedCardRec = armed != null ? sp.cards.find((c) => c && c.idx === armed) : null;
|
||||
const armedName = armedCardRec ? resolveSpCard(armedCardRec, sp.family).name : null;
|
||||
// a press anywhere but a card or the confirm button drops the selection
|
||||
const onDown = (e) => {
|
||||
if (armed == null) return;
|
||||
const t = e.target;
|
||||
if (t && typeof t.closest === 'function' && t.closest('.spcard, .spov__confirm')) return;
|
||||
onDisarm();
|
||||
};
|
||||
return html`<div class=${cx('spov', armed != null && 'has-armed')} role="dialog" aria-label="机变阶段" onPointerDown=${onDown}>
|
||||
<div class="spov__veil" aria-hidden="true"></div>
|
||||
<div class="spov__inner">
|
||||
<header class="spov__head">
|
||||
<div class="spov__titles">
|
||||
<${MicroLabel} tone="mint">CONTINGENCY // 机变阶段</${MicroLabel}>
|
||||
<h2 class=${cx('spov__title', special && 'is-special')}>${sp.name || fam?.name || '机变'}<span class="spov__bar">|</span><span class="spov__desc"><${RichText} text=${sp.desc || fam?.desc || '选择一项'} /></span></h2>
|
||||
${!solo ? html`<p class="spov__sub">倒计时结束后仍未选定将自动分配</p>` : html`<p class="spov__sub">选择一项(无时间限制)</p>`}
|
||||
<p class="spov__sub">${timed ? '倒计时结束后仍未选定将自动分配' : '选择一项(无时间限制)'}${mine == null && myTurn ? ' · 点击卡牌选中,再次点击确认' : ''}</p>
|
||||
</div>
|
||||
<div class="spov__turn">
|
||||
${mine != null ? html`<span class="spov__turntxt is-done"><${Icon} name="check" />已完成选择</span>`
|
||||
: myTurn ? html`<span class="spov__turntxt is-mine">当前轮到你决策</span>`
|
||||
: html`<span class="spov__turntxt">${turnName} 正在决策…<${Icon} name="hourglass" /></span>`}
|
||||
${!solo && !sp.untimed ? html`<${Countdown} deadline=${pub?.deadline} total=${total ?? undefined} size="sm" />` : null}
|
||||
${armed != null ? html`<${Button} variant="primary" size="lg" icon="check" class="spov__confirm" data-testid="sp-confirm"
|
||||
title=${armedName ? `确认选择「${armedName}」(再次点击卡牌亦可)` : '确认选择'} onClick=${onConfirm}>确认选择<//>` : null}
|
||||
${timed ? html`<${Countdown} deadline=${pub?.deadline} total=${total ?? undefined} size="sm" />` : null}
|
||||
</div>
|
||||
</header>
|
||||
${order.length ? html`<div class="spov__order" aria-label="决策顺序">
|
||||
@@ -104,11 +187,12 @@ export function ChoiceOverlay({ pub, sp, myId, solo, onPick, busyIdx = null, tot
|
||||
${sp.cards.map((card) => {
|
||||
const r = resolveSpCard(card, sp.family);
|
||||
const taker = card.takenBy ? players.get(card.takenBy) : null;
|
||||
const can = myTurn && mine == null && !card.takenBy && busyIdx == null;
|
||||
const can = cardPickable(sp, card, { myId, solo, busyIdx });
|
||||
const busy = pickBusy(busyIdx, card, mine);
|
||||
return html`<button key=${card.idx} type="button" class=${cx('spcard', `spcard--${r.kind}`, card.takenBy && 'is-taken', card.takenBy === myId && 'is-mine', can && 'is-pickable', busy && 'is-busy')}
|
||||
aria-busy=${busy ? 'true' : undefined}
|
||||
disabled=${!can} onClick=${() => can && onPick(card.idx)} aria-label=${r.name} title=${`${r.name}\n${richTextPlain(r.desc)}`}>
|
||||
const isArmed = can && armed === card.idx;
|
||||
return html`<button key=${card.idx} type="button" class=${cx('spcard', `spcard--${r.kind}`, card.takenBy && 'is-taken', card.takenBy === myId && 'is-mine', can && 'is-pickable', isArmed && 'is-armed', busy && 'is-busy')}
|
||||
aria-busy=${busy ? 'true' : undefined} aria-pressed=${can ? String(isArmed) : undefined}
|
||||
disabled=${!can} onClick=${() => can && onTap(card.idx)} aria-label=${isArmed ? `${r.name},已选中,再次点击确认` : r.name} title=${`${r.name}\n${richTextPlain(r.desc)}`}>
|
||||
<span class="spcard__glow" aria-hidden="true"></span>
|
||||
${r.tier ? html`<${TierChip} tier=${r.tier} size="md" class="spcard__tier" />` : null}
|
||||
<span class="spcard__icon"><${Img} src=${r.icon} fallback=${html`<${GIcon} name=${r.kind === 'bounty' ? 'target' : 'bolt'} />`} /></span>
|
||||
@@ -121,6 +205,7 @@ export function ChoiceOverlay({ pub, sp, myId, solo, onPick, busyIdx = null, tot
|
||||
</span>
|
||||
</span>
|
||||
${taker ? html`<span class="spcard__taker" title=${`${taker.name} 已选择`}><${PlayerAvatar} player=${taker} size="sm" /><span>${card.takenBy === myId ? '你' : taker.name}</span></span>` : null}
|
||||
${isArmed ? html`<span class="spcard__confirm" role="status"><b>确认选择</b><small>再次点击</small></span>` : null}
|
||||
${busy ? html`<span class="spcard__busy" role="status">选择中</span>` : null}
|
||||
</button>`;
|
||||
})}
|
||||
|
||||
@@ -342,6 +342,9 @@ export function secondsLeft(deadline) {
|
||||
|
||||
/**
|
||||
* 7-segment countdown with "COUNTDOWN" micro label and a 5-bar gauge; turns orange ≤ warnAt s.
|
||||
* Untimed (no `seconds` and no deadline — the server sends deadline 0 for every phase outside the battles of a solo
|
||||
* match, Match.soloUntimed): renders nothing at all. It used to draw a "--" placeholder with the COUNTDOWN label,
|
||||
* which reads as a timer (user playtest #4 item 3; css/screens/loadout.css keeps hiding the old placeholder).
|
||||
* @param {{ deadline?: number, seconds?: number, total?: number, warnAt?: number, label?: string,
|
||||
* size?: 'sm'|'md'|'lg', gauge?: boolean, onExpire?: Function, class?: string }} props
|
||||
* deadline: server epoch ms (live); seconds: static value (overrides deadline); total: seconds for the gauge.
|
||||
@@ -364,6 +367,8 @@ export function Countdown({ deadline, seconds, total, warnAt = 10, label = 'COUN
|
||||
}
|
||||
});
|
||||
|
||||
// untimed phase: no timer on screen (hooks above run unconditionally, so the component may toggle freely)
|
||||
if (remain == null) return null;
|
||||
return html`<div class=${cx('countdown', `countdown--${size}`, warn && 'is-warn', cls)} role="timer" aria-label=${remain == null ? '无倒计时' : `剩余${remain}秒`}>
|
||||
<div class="countdown__main">
|
||||
<${SevenSeg} text=${text} tone=${warn ? 'orange' : 'mint'} flicker=${warn && remain > 0} />
|
||||
|
||||
+126
-30
@@ -10,8 +10,14 @@
|
||||
// match (research 09 §5, ui/underframe.js): the panel's own 出售 / 销毁 buttons only render for callers that pass
|
||||
// `editable` + handlers. `side` 'right' docks the panel at the right edge (the game screen picks the side away from a
|
||||
// selected unit's underframe, gameLogic panelSide).
|
||||
// Live stats (user playtest #4 item 7 — the card used to show the fixed record numbers): `live` = the unit's current
|
||||
// stats (shared/protocol.js unitStatsEntry + `src`) — in battle the browser's own sim (battle/runner.js unitStats; a
|
||||
// getter re-read 4× a second: current HP, max HP, ATK, DEF, RES, attack interval, block), in prep the stats the own
|
||||
// board's units start their next battle with (m.unitStats: equipment, bonds / layers, 特质, band and 机变 effects). Each
|
||||
// value is coloured against the unit's base like the official card — green when it helps (higher, or a shorter attack
|
||||
// interval) with the difference beside it, red when it hurts — and a 实时 / 开战时 tag says which it is.
|
||||
|
||||
import { html, Icon, TierChip, MicroLabel, Button, confirmDialog } from './components.js';
|
||||
import { html, Icon, TierChip, MicroLabel, Button, confirmDialog, useTicker } from './components.js';
|
||||
import { Img, RichText, UnitThumb, BondGlyph, GIcon } from './gameComponents.js';
|
||||
import { attackInterval, rangeGridBox, fmtNum, tileKey, chessLoadout, nextThreshold, bondTier } from './gameLogic.js';
|
||||
import { chessPortraitUrl, skillIconUrl, skillRecordIconUrl, profIconUrl, subProfIconUrl, itemIconUrl, enemyIconUrl, tokenAvatarUrl, factionIconUrl, uiUrl, moduleTypeIconUrl } from './assetUrls.js';
|
||||
@@ -43,8 +49,64 @@ export function RangeGrid({ grid, class: cls }) {
|
||||
return html`<div class=${cx('rgrid', cls)} style=${`grid-template-columns:repeat(${box.cols}, var(--rg))`} aria-label="攻击范围">${cells}</div>`;
|
||||
}
|
||||
|
||||
function Stat({ k, v, sub }) {
|
||||
return html`<div class="dstat"><span class="dstat__k">${k}</span><span class="dstat__row"><b class="dstat__v num">${v}</b>${sub ? html`<small>${sub}</small>` : null}</span></div>`;
|
||||
function Stat({ k, v, sub, tone = null, title }) {
|
||||
return html`<div class=${cx('dstat', tone && `is-${tone}`)} title=${title}><span class="dstat__k">${k}</span><span class="dstat__row"><b class="dstat__v num">${v}</b>${sub ? html`<small>${sub}</small>` : null}</span></div>`;
|
||||
}
|
||||
|
||||
/** Tolerance below which a live stat counts as its base (display rounding). */
|
||||
const STAT_EPS = { interval: 0.005, res: 0.05, moveSpeed: 0.005 };
|
||||
|
||||
/**
|
||||
* How a live stat compares with the unit's base (the official card's colours): 'up' (green) when it helps — higher,
|
||||
* or a shorter attack interval —, 'down' (red) when it hurts, null when equal or unknown.
|
||||
* @param {string} key maxHp | atk | def | res | interval | blockCnt | moveSpeed
|
||||
* @param {any} cur @param {any} base
|
||||
* @returns {'up'|'down'|null}
|
||||
*/
|
||||
export function statTone(key, cur, base) {
|
||||
if (!Number.isFinite(cur) || !Number.isFinite(base)) return null;
|
||||
const d = cur - base;
|
||||
if (Math.abs(d) < (STAT_EPS[key] ?? 0.5)) return null;
|
||||
return (key === 'interval' ? d < 0 : d > 0) ? 'up' : 'down';
|
||||
}
|
||||
|
||||
/**
|
||||
* One stat of the card: the live value (when `live` has it) coloured against `live.base`, with the difference as the
|
||||
* small text; else the record value.
|
||||
* @param {any} live unitStatsEntry (+ src) or null
|
||||
* @param {string} key unitStatsEntry key
|
||||
* @param {any} fallback record value
|
||||
* @param {(v: any) => any} [fmt]
|
||||
* @returns {{ v: any, tone: 'up'|'down'|null, sub: string|null, title: string|undefined }}
|
||||
*/
|
||||
export function liveStat(live, key, fallback, fmt = fmtNum) {
|
||||
const cur = live && Number.isFinite(live[key]) ? live[key] : null;
|
||||
if (cur == null) return { v: fallback == null ? '—' : fmt(fallback), tone: null, sub: null, title: undefined };
|
||||
const base = live.base && Number.isFinite(live.base[key]) ? live.base[key] : null;
|
||||
const tone = statTone(key, cur, base);
|
||||
let sub = null;
|
||||
if (tone) {
|
||||
const d = cur - base;
|
||||
sub = key === 'interval' ? `${d > 0 ? '+' : '−'}${Math.abs(d).toFixed(2)}` : `${d > 0 ? '+' : '−'}${key === 'res' || key === 'moveSpeed' ? Math.round(Math.abs(d) * 10) / 10 : fmtNum(Math.abs(d))}`;
|
||||
}
|
||||
return { v: fmt(cur), tone, sub, title: base != null ? `基础 ${fmt(base)}` : undefined };
|
||||
}
|
||||
|
||||
/** The tag of a live stats block: 实时 (battle) / 开战时 (the prep preview). */
|
||||
function LiveTag({ live }) {
|
||||
if (!live) return null;
|
||||
const battle = live.src === 'battle';
|
||||
return html`<span class=${cx('dstats__tag', battle && 'is-battle')} title=${battle ? '当前作战中的实时数值(绿色为增益,红色为减益)'
|
||||
: '下一场作战开始时的数值:已计入装备、盟约层数、特质、策略与机变效果(不含技能与作战中的临时效果)'}>${battle ? '实时' : '开战时'}</span>`;
|
||||
}
|
||||
|
||||
const fmtInterval = (v) => (Number.isFinite(v) && v > 0 ? `${v.toFixed(2)}s` : '—');
|
||||
const fmtRes = (v) => (Number.isFinite(v) ? String(Math.round(v * 10) / 10) : '0');
|
||||
|
||||
/** HP bar of the card header: the live HP in battle, else the snapshot's. */
|
||||
function hpOf(live, snapHp) {
|
||||
if (live && live.src === 'battle' && Number.isFinite(live.hp) && Number.isFinite(live.maxHp)) return { hp: live.hp, max: live.maxHp };
|
||||
return snapHp || null;
|
||||
}
|
||||
|
||||
function Section({ title, micro, children, class: cls }) {
|
||||
@@ -139,8 +201,9 @@ function GarrisonBlock({ garrison, m }) {
|
||||
</section>`;
|
||||
}
|
||||
|
||||
export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bonds, loadout, onBond }) {
|
||||
export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bonds, loadout, onBond, live = null }) {
|
||||
const m = data.get('assets');
|
||||
const hp = hpOf(live, snapHp);
|
||||
const lo = chessLoadout(chess, loadout, (id) => data.lookup('chess', id));
|
||||
const c = chess;
|
||||
// stats / talents the unit fights with: the chosen module's (or none — statsBase) for an elite (DESIGN §16)
|
||||
@@ -177,21 +240,28 @@ export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bond
|
||||
<span>${c.subProfessionName || ''}</span>
|
||||
<span class="dhead__pos">${c.position === 'MELEE' ? '近战位' : '远程位'}</span>
|
||||
</div>
|
||||
${snapHp ? html`<div class="dhp"><i style=${`width:${Math.max(0, Math.min(100, (snapHp.hp / Math.max(1, snapHp.max)) * 100))}%`}></i><span class="num">${fmtNum(snapHp.hp)} / ${fmtNum(snapHp.max)}</span></div>` : null}
|
||||
${hp ? html`<div class="dhp"><i style=${`width:${Math.max(0, Math.min(100, (hp.hp / Math.max(1, hp.max)) * 100))}%`}></i><span class="num">${fmtNum(hp.hp)} / ${fmtNum(hp.max)}</span></div>` : null}
|
||||
<${BondChips} bondIds=${c.bonds} bonds=${bonds} onBond=${onBond} />
|
||||
</div>
|
||||
</div>`;
|
||||
blocks.garrison = garrison ? html`<${GarrisonBlock} key="garrison" garrison=${garrison} m=${m} />` : null;
|
||||
blocks.trait = c.trait?.desc ? html`<p key="trait" class="dtrait"><${Icon} name="info" /><${RichText} text=${traitText(c, golden, lo)} /></p>` : null;
|
||||
// live (battle) / start-of-battle (prep) values against the base, else the record's (liveStat)
|
||||
const st = {
|
||||
maxHp: liveStat(live, 'maxHp', s.maxHp), atk: liveStat(live, 'atk', s.atk), def: liveStat(live, 'def', s.def),
|
||||
res: liveStat(live, 'res', s.res ?? 0, fmtRes), interval: liveStat(live, 'interval', interval, fmtInterval),
|
||||
blockCnt: liveStat(live, 'blockCnt', s.blockCnt, (v) => String(v)),
|
||||
};
|
||||
blocks.stats = html`
|
||||
<div key="stats" class="dstats-wrap">
|
||||
<div key="stats" class=${cx('dstats-wrap', live && 'is-live')} data-live=${live ? live.src || 'prep' : undefined}>
|
||||
<div class="dstats">
|
||||
<${Stat} k="生命上限" v=${fmtNum(s.maxHp)} />
|
||||
<${Stat} k="攻击" v=${fmtNum(s.atk)} />
|
||||
<${Stat} k="防御" v=${fmtNum(s.def)} />
|
||||
<${Stat} k="法术抗性" v=${s.res ?? 0} />
|
||||
<${Stat} k="攻击间隔" v=${interval ? `${interval.toFixed(2)}s` : '—'} />
|
||||
<${Stat} k="阻挡数" v=${s.blockCnt ?? '—'} />
|
||||
<${LiveTag} live=${live} />
|
||||
<${Stat} k="生命上限" ...${st.maxHp} />
|
||||
<${Stat} k="攻击" ...${st.atk} />
|
||||
<${Stat} k="防御" ...${st.def} />
|
||||
<${Stat} k="法术抗性" ...${st.res} />
|
||||
<${Stat} k="攻击间隔" ...${st.interval} />
|
||||
<${Stat} k="阻挡数" ...${st.blockCnt} />
|
||||
<${Stat} k="部署费用" v=${s.cost ?? '—'} />
|
||||
<${Stat} k="再部署" v=${s.respawnTime != null ? `${s.respawnTime}s` : '—'} />
|
||||
</div>
|
||||
@@ -234,7 +304,11 @@ export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bond
|
||||
return CHESS_SECTIONS.map((k) => blocks[k]).filter(Boolean);
|
||||
}
|
||||
|
||||
function ItemDetail({ item, piece, editable, onDestroy }) {
|
||||
/**
|
||||
* An item's card (hand / temp / shop / equipped). An effect-only item (items.json `shopExcluded`: the special 维式重锤,
|
||||
* 突变细胞 — user playtest #4 item 5) says it is never sold and where it comes from (`shopExcludedBy`).
|
||||
*/
|
||||
export function ItemDetail({ item, piece, editable, onDestroy }) {
|
||||
const m = data.get('assets');
|
||||
return html`
|
||||
<div class="dhead dhead--item">
|
||||
@@ -250,16 +324,24 @@ function ItemDetail({ item, piece, editable, onDestroy }) {
|
||||
${item.itemType === 'MAGIC'
|
||||
? html`<p class="dhint"><${Icon} name="info" />将其拖拽至战场上的格子使用</p>`
|
||||
: html`<p class="dhint"><${Icon} name="info" />拖拽至干员身上进行配发(每名干员最多 2 件,配发后无法取下)${item.mergeable ? ';2 件相同装备自动合成进阶装备' : ''}</p>`}
|
||||
${item.shopExcluded ? html`<p class="dhint dhint--source"><${Icon} name="info" />调度中心不出售 · 获取途径:${item.shopExcludedBy || '效果获得'}</p>` : null}
|
||||
${piece && editable ? html`<div class="dactions"><${Button} variant="danger" onClick=${() => onDestroy(piece, item)}>销毁道具<//></div>` : null}`;
|
||||
}
|
||||
|
||||
function EnemyDetail({ enemy, snapHp, count }) {
|
||||
function EnemyDetail({ enemy, snapHp, count, live = null }) {
|
||||
const m = data.get('assets');
|
||||
const s = enemy.stats || {};
|
||||
const types = Array.isArray(enemy.acTypes) ? enemy.acTypes : enemy.acType ? [enemy.acType] : [];
|
||||
const factions = data.get('factions')?.types || {};
|
||||
const imm = Object.entries(s.immunities || {}).filter(([, v]) => v).map(([k]) => ({ stun: '晕眩', silence: '沉默', sleep: '沉睡', frozen: '冻结', levitate: '浮空' }[k] || k));
|
||||
const interval = attackInterval(s.bat, s.aspd);
|
||||
const hp = hpOf(live, snapHp);
|
||||
// a battle enemy: its live stats against its spawned ones (the round's multipliers included — unitStatsEntry base)
|
||||
const st = {
|
||||
maxHp: liveStat(live, 'maxHp', s.maxHp), atk: liveStat(live, 'atk', s.atk), def: liveStat(live, 'def', s.def),
|
||||
res: liveStat(live, 'res', s.res ?? 0, fmtRes), moveSpeed: liveStat(live, 'moveSpeed', s.moveSpeed, (v) => String(Math.round(v * 100) / 100)),
|
||||
interval: liveStat(live, 'interval', interval, (v) => (Number.isFinite(v) && v > 0 ? `${v.toFixed(1)}s` : '—')),
|
||||
};
|
||||
return html`
|
||||
<div class="dhead dhead--enemy">
|
||||
<div class=${cx('dhead__icon', 'dhead__icon--enemy', enemy.rank === 'BOSS' && 'is-boss', enemy.rank === 'ELITE' && 'is-elite')}>
|
||||
@@ -273,16 +355,17 @@ function EnemyDetail({ enemy, snapHp, count }) {
|
||||
</div>
|
||||
<h3 class="dhead__name">${enemy.name}</h3>
|
||||
<div class="dfactions">${types.map((t) => html`<span key=${t} class="dfaction"><${Img} src=${factionIconUrl(m, factions[t]?.icon)} />${factions[t]?.name || t}</span>`)}</div>
|
||||
${snapHp ? html`<div class="dhp dhp--enemy"><i style=${`width:${Math.max(0, Math.min(100, (snapHp.hp / Math.max(1, snapHp.max)) * 100))}%`}></i><span class="num">${fmtNum(snapHp.hp)} / ${fmtNum(snapHp.max)}</span></div>` : null}
|
||||
${hp ? html`<div class="dhp dhp--enemy"><i style=${`width:${Math.max(0, Math.min(100, (hp.hp / Math.max(1, hp.max)) * 100))}%`}></i><span class="num">${fmtNum(hp.hp)} / ${fmtNum(hp.max)}</span></div>` : null}
|
||||
</div>
|
||||
</div>
|
||||
<div class="dstats">
|
||||
<${Stat} k="生命上限" v=${fmtNum(s.maxHp)} />
|
||||
<${Stat} k="攻击" v=${fmtNum(s.atk)} sub=${DMG[s.dmgType] || ''} />
|
||||
<${Stat} k="防御" v=${fmtNum(s.def)} />
|
||||
<${Stat} k="法术抗性" v=${s.res ?? 0} />
|
||||
<${Stat} k="移动速度" v=${s.moveSpeed ?? '—'} />
|
||||
<${Stat} k="攻击间隔" v=${interval ? `${interval.toFixed(1)}s` : '—'} />
|
||||
<div class=${cx('dstats', live && 'is-live')} data-live=${live ? live.src || 'battle' : undefined}>
|
||||
<${LiveTag} live=${live} />
|
||||
<${Stat} k="生命上限" ...${st.maxHp} />
|
||||
<${Stat} k="攻击" ...${st.atk} sub=${st.atk.sub || DMG[s.dmgType] || ''} />
|
||||
<${Stat} k="防御" ...${st.def} />
|
||||
<${Stat} k="法术抗性" ...${st.res} />
|
||||
<${Stat} k="移动速度" ...${st.moveSpeed} />
|
||||
<${Stat} k="攻击间隔" ...${st.interval} />
|
||||
<${Stat} k="攻击范围" v=${s.rangeRadius > 0 ? s.rangeRadius : '近战'} />
|
||||
<${Stat} k="目标价值" v=${s.lpr ?? 1} />
|
||||
</div>
|
||||
@@ -292,20 +375,27 @@ function EnemyDetail({ enemy, snapHp, count }) {
|
||||
<//>` : enemy.descRaw || enemy.desc ? html`<${Section} title="说明"><${RichText} as="p" text=${enemy.descRaw || enemy.desc} class="dtext" /><//>` : null}`;
|
||||
}
|
||||
|
||||
function TokenDetail({ token, piece }) {
|
||||
function TokenDetail({ token, piece, snapHp = null, live = null }) {
|
||||
const m = data.get('assets');
|
||||
const s = token.stats || {};
|
||||
const hp = hpOf(live, snapHp);
|
||||
const st = {
|
||||
maxHp: liveStat(live, 'maxHp', s.maxHp), atk: liveStat(live, 'atk', s.atk), def: liveStat(live, 'def', s.def),
|
||||
blockCnt: liveStat(live, 'blockCnt', s.blockCnt, (v) => String(v)),
|
||||
};
|
||||
return html`
|
||||
<div class="dhead dhead--item">
|
||||
<div class="dhead__icon"><${Img} src=${tokenAvatarUrl(m, token.tokenId)} fallback=${html`<${GIcon} name="target" />`} /></div>
|
||||
<div class="dhead__info">
|
||||
<div class="dhead__chips"><span class="dtag-token">召唤物</span>${piece?.count > 1 ? html`<span class="dtag-kind num">×${piece.count}</span>` : null}</div>
|
||||
<h3 class="dhead__name">${token.name}</h3>
|
||||
${hp ? html`<div class="dhp"><i style=${`width:${Math.max(0, Math.min(100, (hp.hp / Math.max(1, hp.max)) * 100))}%`}></i><span class="num">${fmtNum(hp.hp)} / ${fmtNum(hp.max)}</span></div>` : null}
|
||||
</div>
|
||||
</div>
|
||||
<div class="dstats">
|
||||
<${Stat} k="生命上限" v=${fmtNum(s.maxHp)} /><${Stat} k="攻击" v=${fmtNum(s.atk)} />
|
||||
<${Stat} k="防御" v=${fmtNum(s.def)} /><${Stat} k="阻挡数" v=${s.blockCnt ?? '—'} />
|
||||
<div class=${cx('dstats', live && 'is-live')} data-live=${live ? live.src || 'prep' : undefined}>
|
||||
<${LiveTag} live=${live} />
|
||||
<${Stat} k="生命上限" ...${st.maxHp} /><${Stat} k="攻击" ...${st.atk} />
|
||||
<${Stat} k="防御" ...${st.def} /><${Stat} k="阻挡数" ...${st.blockCnt} />
|
||||
</div>
|
||||
${token.descRaw || token.desc ? html`<${Section} title="说明"><${RichText} as="p" text=${token.descRaw || token.desc} class="dtext" /><//>` : null}`;
|
||||
}
|
||||
@@ -351,9 +441,15 @@ export function resolveDetail(target, pieces) {
|
||||
* bonds: the owner's m.private.bonds (counts / tiers of the bond chips); loadout: m.private.loadout (DESIGN §16) for
|
||||
* the player's own operators and shop cards; a teammate's unit gets its owner's choice (gameLogic unitLoadout); null
|
||||
* = the defaults
|
||||
* live: the unit's live stats (unitStatsEntry + src 'battle' | 'prep') — an object, or a getter the panel re-reads 4×
|
||||
* a second (the battle's own sim, battle/runner.js unitStats); null ⇒ the record's numbers
|
||||
*/
|
||||
export function DetailPanel({ detail, editable, snapHp, onClose, onSell, onDestroy, bonds = [], loadout = null, onBond = null, side = 'left', shopOpen = false }) {
|
||||
export function DetailPanel({ detail, editable, snapHp, onClose, onSell, onDestroy, bonds = [], loadout = null, onBond = null, side = 'left', shopOpen = false, live = null }) {
|
||||
const getter = typeof live === 'function' ? live : null;
|
||||
useTicker(detail && getter ? 250 : 0);
|
||||
if (!detail) return null;
|
||||
let liveNow = null;
|
||||
try { liveNow = getter ? getter() : live && typeof live === 'object' ? live : null; } catch { liveNow = null; }
|
||||
const sellIt = async (piece, chess) => {
|
||||
const golden = piece.golden || chess?.isGolden;
|
||||
if (golden) {
|
||||
@@ -371,10 +467,10 @@ export function DetailPanel({ detail, editable, snapHp, onClose, onSell, onDestr
|
||||
<button type="button" class="dpanel__close" aria-label="关闭" onClick=${onClose}><${Icon} name="close" /></button>
|
||||
<div class="dpanel__scroll">
|
||||
${detail.type === 'chess' ? html`<${ChessDetail} chess=${detail.chess} piece=${detail.piece} snapHp=${snapHp} editable=${editable} onSell=${sellIt}
|
||||
bonds=${bonds} loadout=${loadout} onBond=${onBond} />` : null}
|
||||
bonds=${bonds} loadout=${loadout} onBond=${onBond} live=${liveNow} />` : null}
|
||||
${detail.type === 'item' ? html`<${ItemDetail} item=${detail.item} piece=${detail.piece} editable=${editable} onDestroy=${destroyIt} />` : null}
|
||||
${detail.type === 'enemy' ? html`<${EnemyDetail} enemy=${detail.enemy} snapHp=${snapHp} count=${detail.count} />` : null}
|
||||
${detail.type === 'token' ? html`<${TokenDetail} token=${detail.token} piece=${detail.piece} />` : null}
|
||||
${detail.type === 'enemy' ? html`<${EnemyDetail} enemy=${detail.enemy} snapHp=${snapHp} count=${detail.count} live=${liveNow} />` : null}
|
||||
${detail.type === 'token' ? html`<${TokenDetail} token=${detail.token} piece=${detail.piece} snapHp=${snapHp} live=${liveNow} />` : null}
|
||||
</div>
|
||||
</aside>`;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import { GEO, PHASE, UF } from '../../../shared/constants.js';
|
||||
import { resolveLoadout, loadoutOptions, MODULE_NONE } from '../../../shared/protocol.js';
|
||||
import { resolveRecordLoadout, loadoutRecord } from '../../../shared/loadoutRecord.js';
|
||||
import { layoutPen } from '../render/pen.js';
|
||||
import { pickBody } from '../render/pick.js';
|
||||
import { bossLevelSeconds } from './matchStatus.js';
|
||||
|
||||
// ---- small helpers -------------------------------------------------------------------------------
|
||||
@@ -148,7 +147,8 @@ export function phaseTotalSeconds(pub, config, myId = null) {
|
||||
const num = (v) => (Number.isFinite(v) && v > 0 ? v : null);
|
||||
switch (pub.phase) {
|
||||
case PHASE.INFO_CHECK: return num(timers.infoCheck) ?? 25;
|
||||
case PHASE.BAND_DRAFT: return num(timers.bandTurn) ?? 12;
|
||||
// one countdown: the current turn's (m.public.draft.turnSeconds = Match.BAND_TURN_SECONDS; user playtest #4 item 4)
|
||||
case PHASE.BAND_DRAFT: return num(pub.draft?.turnSeconds) ?? num(timers.bandTurn) ?? 30;
|
||||
case PHASE.BATTLE_CHECK: return num(timers.battleCheck) ?? 3;
|
||||
case PHASE.SP_DRAFT: {
|
||||
const sp = normalizeSp(pub.sp, pub.players);
|
||||
@@ -871,56 +871,6 @@ export function dropIntent(ctx, uid, target) {
|
||||
return { t: 'g.move', fields: { uid, to } };
|
||||
}
|
||||
|
||||
/**
|
||||
* The piece under a screen point (user playtest #3 item 7). Rects of the render engine carry the drawn body
|
||||
* (`shape`, view.pieceScreenRect) and are picked by the shared rule of render/pick.js — the face / torso of a unit is
|
||||
* its own; elsewhere the piece standing on the tile under the pointer (`tile`, same space as the rects' `tile`) wins.
|
||||
* Plain rects (the DOM fallback) go to the one whose centre is nearest relative to its size (a unit standing in front
|
||||
* no longer takes the whole overlap), ties → the front-most (largest bottom).
|
||||
* @param {Array<{uid:number, left:number, top:number, right:number, bottom:number, shape?:object, tile?:{row:number,col:number}}|null>} rects
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {{row:number, col:number}|null} [tile] the tile under the point
|
||||
* @returns {number|null} uid
|
||||
*/
|
||||
export function pickPieceAt(rects, x, y, tile = null) {
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
|
||||
const list = (Array.isArray(rects) ? rects : []).filter((r) => isObj(r) && Number.isInteger(r.uid));
|
||||
const shaped = list.filter((r) => isObj(r.shape) && r.shape.s > 0);
|
||||
if (shaped.length && shaped.length === list.length) {
|
||||
const hit = pickBody(shaped.map((r) => ({ ...r.shape, tile: r.tile || null, uid: r.uid })), x, y, tile);
|
||||
return hit ? hit.uid : null;
|
||||
}
|
||||
let best = null, bestD = Infinity;
|
||||
for (const r of list) {
|
||||
if (x < r.left || x > r.right || y < r.top || y > r.bottom) continue;
|
||||
const hw = Math.max(1, (r.right - r.left) / 2), hh = Math.max(1, (r.bottom - r.top) / 2);
|
||||
const d = Math.hypot((x - (r.left + r.right) / 2) / hw, (y - (r.top + r.bottom) / 2) / hh);
|
||||
if (d < bestD - 1e-9 || (Math.abs(d - bestD) <= 1e-9 && r.bottom > best.bottom)) { best = r; bestD = d; }
|
||||
}
|
||||
return best ? best.uid : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* An equipment item released over an operator's sprite goes to that operator. The sprite stands about a tile taller
|
||||
* than its own tile, so the tile under the pointer (the drop target of render/drag.js) is often the one behind it.
|
||||
* @param {ReturnType<typeof placementContext>} ctx
|
||||
* @param {number} uid the dragged piece
|
||||
* @param {any} target the tile target (board / hand / outside / null)
|
||||
* @param {number|null} hitUid the own unit piece whose sprite is under the pointer (pickPieceAt)
|
||||
* @returns {any} the target to use: the hit unit's tile / slot, or `target` unchanged
|
||||
*/
|
||||
export function equipRetarget(ctx, uid, target, hitUid) {
|
||||
const src = ctx?.pieces?.get(uid);
|
||||
if (!src || src.piece.kind !== 'item' || !Number.isInteger(hitUid) || hitUid === uid) return target;
|
||||
if (ctx.getItem(src.piece.id)?.itemType === 'MAGIC') return target; // Arts are used on the tile itself
|
||||
const hit = ctx.pieces.get(hitUid);
|
||||
if (!hit || (hit.piece.kind !== 'chess' && hit.piece.kind !== 'token')) return target;
|
||||
if (hit.area === 'board') return { area: 'board', row: hit.row, col: hit.col };
|
||||
if (hit.area === 'hand') return { area: 'hand', idx: hit.idx };
|
||||
return target;
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a drag released over the field was refused (null when there is nothing to say): the canPlace reason for a
|
||||
* board tile / hand slot, a generic line for other tiles of the own board rows (lanes, blocked tiles) and the
|
||||
|
||||
Reference in new issue
Block a user