From 3e9f81fcbf5ed3d67976fb6d2d49db8fab488cde Mon Sep 17 00:00:00 2001 From: sganggs Date: Sun, 4 Oct 2026 17:38:07 +0800 Subject: [PATCH] =?UTF-8?q?feedback3=20WB:=20=E5=9C=A3=E8=81=86=E5=88=9D?= =?UTF-8?q?=E9=9B=AA=20S2=20freezes=20the=20protection=20point=20(the=20bl?= =?UTF-8?q?ue=20gate)=20once=20it=20holds=205=20snow=20layers,=20deploy=20?= =?UTF-8?q?attributes=20ignored,=20and=20no=20other=20tile;=20the=20frozen?= =?UTF-8?q?=20gate=20shows=20as=20an=20ice=20diamond=20instead=20of=20her?= =?UTF-8?q?=20own=20face=20(PRTS=20=E5=A4=87=E6=B3=A8,=20community=20repor?= =?UTF-8?q?t=20#32)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/ASSETS.md | 2 +- public/js/render/textures.js | 14 +++++++++++++- public/js/render/units.js | 11 +++++++++-- server/sim/content/kits/tier6.js | 29 +++++++++++++++++++++------- test/content/kits_alt_t6.test.js | 33 ++++++++++++++++++++++++++------ test/render/unitview.test.js | 11 +++++++++++ 6 files changed, 83 insertions(+), 17 deletions(-) diff --git a/docs/ASSETS.md b/docs/ASSETS.md index b125a568..7d2724b1 100644 --- a/docs/ASSETS.md +++ b/docs/ASSETS.md @@ -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. diff --git a/public/js/render/textures.js b/public/js/render/textures.js index 60fe2388..c525c0cc 100644 --- a/public/js/render/textures.js +++ b/public/js/render/textures.js @@ -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); diff --git a/public/js/render/units.js b/public/js/render/units.js index 864b4993..da3a3646 100644 --- a/public/js/render/units.js +++ b/public/js/render/units.js @@ -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; } diff --git a/server/sim/content/kits/tier6.js b/server/sim/content/kits/tier6.js index 7a406772..eddbd1c3 100644 --- a/server/sim/content/kits/tier6.js +++ b/server/sim/content/kits/tier6.js @@ -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) diff --git a/test/content/kits_alt_t6.test.js b/test/content/kits_alt_t6.test.js index af52643b..196d082a 100644 --- a/test/content/kits_alt_t6.test.js +++ b/test/content/kits_alt_t6.test.js @@ -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); } }); diff --git a/test/render/unitview.test.js b/test/render/unitview.test.js index 09db64ba..f48ec0cd 100644 --- a/test/render/unitview.test.js +++ b/test/render/unitview.test.js @@ -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;