feedback3 WB: 圣聆初雪 S2 freezes the protection point (the blue gate) once it holds 5 snow layers, deploy attributes ignored, and no other tile; the frozen gate shows as an ice diamond instead of her own face (PRTS 备注, community report #32)

This commit is contained in:
sganggs committed 2026-10-04 17:38:07 +08:00
1 parent e53a181ae9
commit 3e9f81fcbf
6 files changed
+83 -17

No files matched your search

+1 -1
View File
@@ -286,7 +286,7 @@ Other renderer rules from research 07 §5.4–5.5:
- **Emotes and 玩法说明 pages** (`public/js/data.js artUrls / nextArtUrl`, `ui/guide.js guideStage`): the local-client picture (`data/local-assets.json`) first, then the mirror copy (`ui['emoticon/…']`, `ui['guide/…']`), each tried in turn when one fails to load; when none is left — none listed, or every copy failed (for example data/assets.json lists the downloaded pages but the files are not on disk yet: a `git pull` and restart without setup) — the neutral emote glyph, and for a page the official tips text (`config.tips`). The rest of the local-client art (the 3D board, the official HUD sprites, module type icons, the two enemy models above) is not downloaded: the client looks it up in `data/local-assets.json` only (most of the HUD sprites are on the mirror too, DESIGN §22.5); docs/DEPLOY.md §6 lists what falls back without it.
- **Tokens:**
- Without an avatar, use `chars[owner].avatar` with a 召唤物 badge, or `prof.battlecard.token`.
- Without an avatar, use `chars[owner].avatar` with a 召唤物 badge, or `prof.battlecard.token` — except 圣聆初雪's 保护目标(冻结状态) (PRTS 无头像; the frozen gate), drawn as a procedural ice diamond (`render/units.js ICE_TOKENS`).
- Without a Spine, draw the avatar sprite with a bob tween.
- `spineVariant` names the skin-variant model that stands in for the missing default model.
- **Enemies without a spine** (for example `enemy_9016_acstmr`): draw `icon` in a diamond. Enemies with no manifest entry at all (`enemy_5601_entlec` 心烛): draw a procedural glyph.
+13 -1
View File
@@ -1077,7 +1077,7 @@ const DIAMOND_MAX = 160; // ≈ 16 MB of 160×160 canvases at most (plus t
* @param {{ enemy?: boolean, golden?: boolean }} [o]
*/
export function diamondTexture(key, img, color, o = {}) {
const k = `${key}|${img ? 1 : 0}|${color}|${o.golden ? 1 : 0}`;
const k = `${key}|${img ? 1 : 0}|${color}|${o.golden ? 1 : 0}|${o.ice ? 1 : 0}`;
let tex = _diamonds.get(k);
if (tex) { _diamonds.delete(k); _diamonds.set(k, tex); return tex; }
const P = PIXI();
@@ -1095,6 +1095,18 @@ export function diamondTexture(key, img, color, o = {}) {
const sc = Math.max((S - 20) / img.width, (S - 20) / img.height) * 1.02;
const w = img.width * sc, hh = img.height * sc;
try { c.drawImage(img, h - w / 2, h - hh / 2, w, hh); } catch { /* tainted/broken image */ }
} else if (o.ice) {
// 圣聆初雪's frozen gate (保护目标(冻结状态), PRTS 无头像, no model in the manifest): a snowflake on a frosty field
const g = c.createRadialGradient(h, h, 4, h, h, h);
g.addColorStop(0, 'rgba(205,240,255,0.95)'); g.addColorStop(1, 'rgba(0,0,0,0)');
c.fillStyle = g; c.fillRect(0, 0, S, S);
c.strokeStyle = '#f2fbff'; c.lineWidth = 6; c.lineCap = 'round';
for (let i = 0; i < 6; i++) {
const a = (i * Math.PI) / 3, x1 = h + Math.cos(a) * 34, y1 = h + Math.sin(a) * 34;
c.beginPath(); c.moveTo(h, h); c.lineTo(x1, y1); c.stroke();
const bx = h + Math.cos(a) * 20, by = h + Math.sin(a) * 20;
for (const s of [-1, 1]) { c.beginPath(); c.moveTo(bx, by); c.lineTo(bx + Math.cos(a + s * 0.8) * 11, by + Math.sin(a + s * 0.8) * 11); c.stroke(); }
}
} else {
// procedural glyph (e.g. 心烛 enemy_5601_entlec has no art anywhere)
const g = c.createRadialGradient(h, h, 4, h, h, h);
+9 -2
View File
@@ -99,6 +99,11 @@ export function dieClipDur(entry) {
/** World step (x = col, y = row) of a direction. */
export const DIR_STEP = Object.freeze({ UP: [0, 1], RIGHT: [1, 0], DOWN: [0, -1], LEFT: [-1, 0] });
const nowMs = () => (globalThis.performance ? globalThis.performance.now() : Date.now());
/** Units with no art in the game data drawn as an ice diamond: 圣聆初雪 S2's frozen protection point (保护目标(冻结状态), PRTS
* 无头像; data/assets.json has no avatar or model for it, so the token fallback showed 圣聆初雪's own face) — the marker of a
* frozen gate. */
const ICE_TOKENS = new Set(['token_10058_sbell2_icetgt']);
const ICE_FRAME = 0x9fe6ff;
/** How long a view waits for its avatar before showing the image-less placeholder diamond. */
const PIC_WAIT_MS = 400;
/**
@@ -380,6 +385,7 @@ export class UnitView {
_frameColor() {
if (this.isEnemy) return this.isBoss ? ENEMY_FRAME.boss : this.tier >= 2 ? ENEMY_FRAME.elite : ENEMY_FRAME.normal;
if (ICE_TOKENS.has(this.info.defId)) return ICE_FRAME;
if (this.golden) return 0xffc600;
return TIER_COLORS[this.tier] || TIER_COLORS[1];
}
@@ -390,7 +396,8 @@ export class UnitView {
// is missing or still loading after PIC_WAIT_MS).
_loadPicture() {
const a = this.ctx.assets;
const url = a && (a.picture ? a.picture(this.info.avatar) || a.picture(this.info.defId) || a.picture(this.info.spine) : null);
// (an ICE_TOKENS unit takes no picture: the token fallback would be its owner's face — assets.js tokenAvatarUrl)
const url = ICE_TOKENS.has(this.info.defId) ? null : a && (a.picture ? a.picture(this.info.avatar) || a.picture(this.info.defId) || a.picture(this.info.spine) : null);
this._pic = { key: String(this.info.avatar || this.info.defId || 'unknown'), color: this._frameColor(), img: null, state: 'none', shown: null, t0: nowMs() };
if (!url || !a.image) return;
const cached = typeof a.imageNow === 'function' ? a.imageNow(url) : null;
@@ -409,7 +416,7 @@ export class UnitView {
let want = pic.state === 'img' ? 'img' : 'placeholder';
if (pic.state === 'wait' && nowMs() - pic.t0 < PIC_WAIT_MS) want = null;
if (!want || pic.shown === want) return;
this.fallback.texture = diamondTexture(pic.key, want === 'img' ? pic.img : null, pic.color, { enemy: this.isEnemy, golden: this.golden });
this.fallback.texture = diamondTexture(pic.key, want === 'img' ? pic.img : null, pic.color, { enemy: this.isEnemy, golden: this.golden, ice: ICE_TOKENS.has(this.info.defId) });
pic.shown = want;
}
+22 -7
View File
@@ -73,8 +73,9 @@
// 6_01 S2 the aim (disarmed meanwhile) spends its bullet when it begins; target = a wanted enemy (lowest DEF first).
// 6_02 S1 casts with an enemy in her range (data SEARCH read as "search in range" [ASSUMED]; the engine SEARCH =
// any enemy on the field wasted both charges); the snow spreads along her facing line (≤ trig_cnt tiles);
// S2 "目标点变为冻结状态" = the frozen 保护目标 token (icetgt, one at a time) on the tile reaching max snow, whose
// snow is used up; spreads go to the thinnest 4-neighbour; the 20 % DoT ticks once per second.
// S2 "目标点变为冻结状态" = the frozen 保护目标 token (icetgt, one at a time) on a protection point (goal tile) reaching
// max snow, deploy attributes ignored, whose snow is used up (PRTS 备注; until 0.1.3 any standable tile froze, never the
// gate); spreads go to the thinnest 4-neighbour; the 20 % DoT ticks once per second.
// 6_03 S2 teleport = the ground-reachable (grid path) enemies of the skill grid moved onto his tile, in the cast's
// tick (PRTS: 0.13 s after the damage), leaders included unless 自缚.
// 6_04 S1 transfer: the ally takes ×(1 − share), she takes the rest as true damage from the attacker; module 新生代
@@ -612,16 +613,20 @@ function sbell2(bb, chess, def) {
/**
* S2 霜涛覆岭 while it runs (`unit.mem.sbellS2`): a layer landing on a tile already at max snow spreads one layer to
* a neighbouring ground tile (at most max_cast_tile_count spreads per activation); ground enemies on snow take
* s2_magic_scale × ATK arts per second; an enemy leaving snow gets `cold` s of 寒冷; a tile reaching max snow turns
* into the frozen 保护目标 (token icetgt: blocks 3, one at a time) and its snow is used up.
* s2_magic_scale × ATK arts per second; an enemy leaving snow gets `cold` s of 寒冷; "积雪在目标点积累至5层时,使目标点变为
* 冻结状态": a protection point (the blue gate) reaching max snow turns into the frozen 保护目标 (freezeTile).
*/
const addSnow = (battle, unit, k) => {
const snow = unit.mem.snow;
if (!snow) return false;
const r = (k / COLS) | 0, c = k % COLS;
if (!battle.grid.inRect(r, c) || !battle.grid.groundPassable(r, c, true)) return false;
// PRTS 备注 "存在自身的该召唤物的地块不会积雪"
if (battle.allyUnits.some((t) => isTok(t, iceId, unit) && t.alive && t.tileR === r && t.tileC === c)) return false;
const S2 = unit.mem.sbellS2;
const cur = snow.get(k) ?? 0;
// a protection point already at max snow (its freeze waited for her other token to go) freezes on its next layer
if (S2 && cur >= maxL && freezeTile(battle, unit, k)) return true;
if (cur >= maxL) {
if (!S2 || S2.spreadLeft <= 0) return false;
// "积雪超过5层时会向周围扩散一层": the thinnest neighbouring ground tile gets the layer
@@ -640,14 +645,24 @@ function sbell2(bb, chess, def) {
if (S2 && cur + 1 >= maxL) freezeTile(battle, unit, k);
return true;
};
/**
* "使目标点变为冻结状态" — PRTS 圣聆初雪 S2 备注: "目标点冻结的实际效果为令圣聆初雪在该地块上召唤一个保护目标(冻结状态)(无视部署
* 属性),并去除相应地块上的积雪", "可以被'变为冻结状态'的目标点包括常规的保护目标点与促融共竞的保护目标点"; the token's page:
* "技能发动后于保护目标叠加5层积雪". So only a protection point (a goal tile, glyph E) freezes, whatever its deploy attributes
* (nobody may stand on a gate): her token icetgt (blocks 3; one at a time, maxDeployCount 1) appears on it and blocks the
* enemies walking in, and that tile's snow is used up. Until 0.1.3 (community report #32): any free tile a melee
* operator could stand on froze at max snow, and the gate never did. Returns true when the token appeared.
*/
const freezeTile = (battle, unit, k) => {
const r = (k / COLS) | 0, c = k % COLS;
if (battle.allyUnits.some((t) => isTok(t, iceId, unit) && t.alive)) return;
if (battle.isReservedTile(r, c) || !battle.grid.canStand(r, c, { ranged: false })) return;
if (battle.grid.tile(r, c).special !== 'end') return false;
if (battle.allyUnits.some((t) => isTok(t, iceId, unit) && t.alive)) return false;
if (battle.isReservedTile(r, c)) return false;
const ice = battle.spawnToken(unit, iceId, r, c);
if (!ice) return;
if (!ice) return false;
unit.mem.snow.delete(k);
battle.fx('summon', { x: c, y: r, id: ice.id, src: unit.id });
return true;
};
const talents = [
{ install(battle, unit) { // 无垠的雪景 (+ S2 snow rules)
+27 -6
View File
@@ -194,12 +194,16 @@ test('6_02 圣聆初雪 S1 铃音吹雪: 2 charges (cast with enemies in range);
}
});
test('6_02 圣聆初雪 S2 霜涛覆岭: toggle; group attacks at atk_scale_s2 × ATK; ground enemies on snow take 20 %/s, leaving snow chills, 5 layers freeze the tile (保护目标)', () => {
// PRTS 圣聆初雪 S2 霜涛覆岭 "积雪在目标点积累至5层时,使目标点变为冻结状态", 备注 "目标点冻结的实际效果为令圣聆初雪在该地块上召唤一个保护目标
// (冻结状态)(无视部署属性),并去除相应地块上的积雪(且存在自身的该召唤物的地块不会积雪)", "可以被'变为冻结状态'的目标点包括常规的保护目标点…";
// the token's page: "技能发动后于保护目标叠加5层积雪". Community report #32: until 0.1.3 any free standable tile froze and the blue
// gate never did (nobody can stand on it).
test('6_02 圣聆初雪 S2 霜涛覆岭: toggle; group attacks at atk_scale_s2 × ATK; ground enemies on snow take 20 %/s, leaving snow chills, 5 layers on the protection point freeze it (保护目标(冻结状态)), no other tile', () => {
for (const id of both('chess_char_6_02')) {
const sid = 'skchr_sbell2_2', bb = bbOf(id, sid);
const h = run({
defs: { enemies: { e: dummy('e'), w: enemyRec({ key: 'w', hp: 1e7, speed: 1 }) } },
units: [U(id, sid, 10, 4, { carryState: READY })], enemies: [{ key: 'e', pos: [10, 5] }, { key: 'w', route: 0, time: 1 }],
units: [U(id, sid, 10, 3, { carryState: READY })], enemies: [{ key: 'e', pos: [10, 5] }, { key: 'w', route: 0, time: 1 }, { key: 'w', route: 0, time: 40 }],
});
const u = h.unit(id);
usesSkill(u, sid);
@@ -218,13 +222,30 @@ test('6_02 圣聆初雪 S2 霜涛覆岭: toggle; group attacks at atk_scale_s2
approx(dot[0].amount, u.s.atk * bb['talent@s2_magic_scale'] * u.s.dmgDealtMul, '20 % ATK per second');
assert.ok(h.runUntil(() => statuses(h, 'cold', (c) => c.source === u && isKey(c.target, 'w')).length > 0, 20), 'leaving snow ⇒ cold');
approx(statuses(h, 'cold', (c) => c.source === u && isKey(c.target, 'w'))[0].duration, bb['talent@cold'], 'cold duration');
// a free ground tile of her range at 4 layers: the next layer freezes it
const iceOf = () => h.b.allyUnits.find((t) => t.defId === 'token_10058_sbell2_icetgt' && t.alive);
// a free ground tile of her range reaching 5 layers: no freeze (until 0.1.3 it turned into the token)
const fk = 11 * COLS + 4;
assert.ok(u.rangeKeys.includes(fk));
u.mem.snow.set(fk, 4);
assert.ok(h.runUntil(() => h.b.allyUnits.some((t) => t.defId === 'token_10058_sbell2_icetgt' && t.alive), 7), '保护目标(冻结状态)');
const ice = h.b.allyUnits.find((t) => t.defId === 'token_10058_sbell2_icetgt' && t.alive);
assert.deepEqual([ice.tileR, ice.tileC], [11, 4]);
h.run(7);
assert.equal(u.mem.snow.get(fk), 5);
assert.equal(iceOf(), undefined, 'only a protection point freezes');
// the blue gate (9,2) — build NONE, nobody may stand there — at 4 layers: the next layer freezes it
const gk = 9 * COLS + 2;
assert.equal(h.b.grid.tile(9, 2).special, 'end');
assert.ok(u.rangeKeys.includes(gk));
u.mem.snow.set(gk, 4);
assert.ok(h.runUntil(() => !!iceOf(), 7), '保护目标(冻结状态)');
const ice = iceOf();
assert.deepEqual([ice.tileR, ice.tileC], [9, 2]);
assert.equal(ice.s.blockCnt, 3);
assert.equal(u.mem.snow.get(gk), undefined, 'its snow is used up');
h.run(7);
assert.equal(u.mem.snow.get(gk), undefined, 'no snow gathers under her token');
// a walker reaching the gate is held there by it, not leaked
const leaks0 = h.result().perPlayer.p1.leaked.length;
assert.ok(h.runUntil(() => h.b.enemies.some((x) => x.alive && isKey(x, 'w') && x.blockedBy === ice), 60), 'blocked by the frozen gate');
assert.equal(h.result().perPlayer.p1.leaked.length, leaks0);
done(h);
}
});
+11
View File
@@ -86,6 +86,17 @@ describe('fallback portraits (avatar diamonds)', () => {
assert.notEqual(v.fallback.texture, fake.P.Texture.EMPTY);
});
test('圣聆初雪 S2: the frozen gate (保护目标(冻结状态), no art in the data) is an ice diamond, not the plain placeholder', async () => {
const before = diamonds().length;
const v = view({ kind: 'token', defId: 'token_10058_sbell2_icetgt' }, {}, store()); // (an owner avatar would load)
await tick(); await tick();
for (let i = 0; i < 3; i++) v.update(1 / 60, cam(), i / 60);
assert.equal(v._frameColor(), 0x9fe6ff, 'ice frame');
assert.equal(diamonds().length - before, 1);
assert.notEqual(v.fallback.texture, T.diamondTexture('token_10058_sbell2_icetgt', null, 0x9fe6ff), 'its own (ice) glyph, not the procedural one');
assert.equal(v.fallback.texture, T.diamondTexture('token_10058_sbell2_icetgt', null, 0x9fe6ff, { ice: true }));
});
test('a slow avatar shows the placeholder meanwhile, then the picture', async () => {
const before = diamonds().length;
let release;