diff --git a/docs/ASSETS.md b/docs/ASSETS.md index 74ebc706..37eda137 100644 --- a/docs/ASSETS.md +++ b/docs/ASSETS.md @@ -226,8 +226,12 @@ Other renderer rules from research 07 §5.4–5.5: the extraction, it has no `/assets/local/` URL, and setup / doctor / the manifest tests never miss these files. - The client (`assets.js spineEntry`, with `assets.local()`, which `createFieldView` awaits with the manifest) draws the official model when the local manifest lists every file of it; otherwise, or when it fails to load - (`UnitView`: the entry's `fallback`), the web alias. A release bundle carries the models when it is zipped from a - checkout where the extraction ran. + (`UnitView`: the entry's `fallback`), the web alias, tinted toward the slug's own lava colours + (`render/units.js ALIAS_TINT`: 灼热 orange, 炽焰 red-orange; research 07 §5.6 "a hue shift", [ASSUMED] look) so a + source install without the extraction still tells them from the plain 源石虫. A release bundle carries the + models only when it is zipped from a checkout where the extraction ran with the `spine/enemy` job (an extraction + made with 0.1.0 lacks it: `node tools/setup.mjs --local` again, then check that `data/local-assets.json` lists + `spine/enemy/enemy_1305_mhslim` and `spine/enemy/enemy_1305_mhslim_2`). User feedback after 0.1.0 (D3: "所有特殊源石虫的模型全表现为普通源石虫"): the ELEMENT faction spawns up to ten of them a round. A 2026-10-03 audit of every enemy of `data/enemies.json` (249) against the client's battle prefabs (the skeleton each prefab's Spine renderer draws) found no other enemy drawn with another enemy's model; 伊利昂的木驮兽 diff --git a/public/js/render/units.js b/public/js/render/units.js index 36de765c..a3313f48 100644 --- a/public/js/render/units.js +++ b/public/js/render/units.js @@ -5,8 +5,9 @@ // with ready glow / draining skill bar, tier chip, status icons, blocked marker). The fallback shows at once and // cross-fades to the Spine model when it has loaded; missing or failed models keep the fallback forever (the // game never blocks on Spine) — except an optional local-client model (assets.js spineEntry `fallback`, DESIGN §13: -// 灼热源石虫 / 炽焰源石虫), which falls back to the web model first. Every bar is a tinted Texture.WHITE sprite, so -// HUDs batch into few draw calls. +// 灼热源石虫 / 炽焰源石虫), which falls back to the web model first; that web alias (the plain 源石虫) is drawn tinted +// toward the slug's own colours (ALIAS_TINT). Every bar is a tinted Texture.WHITE sprite, so HUDs batch into few draw +// calls. // // Placement: feet anchored at world (x, y, z); scale = camera px-per-tile at the feet × UNIT.modelScale, so // chibis shrink with distance like the original. An enemy's model is also scaled by its official prefab factor @@ -94,6 +95,13 @@ export const DOWN_LOOK = Object.freeze({ ring: Object.freeze({ [DOWN_STATE.COUNTING]: 0x4ed8af, [DOWN_STATE.WAIT_DP]: 0xffc600, [DOWN_STATE.WAIT_TILE]: 0xff4b3e }), size: 0.42, height: 1.02, }); +/** + * An enemy drawn with another enemy's web model because its own is only in the local client (manifest `spineAliasOf` + * + `spineLocal`: 灼热 / 炽焰源石虫 → the plain 源石虫's skeleton, feedback D3 after 0.1.0): a multiply tint toward its own + * lava colours (orange / red-orange), so it reads apart from the plain slug where the local art was not extracted + * (research 07 §5.6 "a hue shift") [ASSUMED look]. Never on its official (local) model; status tints win over it. + */ +export const ALIAS_TINT = Object.freeze({ enemy_1305_mhslim: 0xffc48a, enemy_1305_mhslim_2: 0xff9070 }); /** * Element gauge row under the bars (see header): the disc's diameter in tiles and its pixel clamp (enemies × `enemy`), * the gap under the bars (px). The white bar is as tall as the SP bar and fills the rest of the bars' width. During a @@ -255,6 +263,7 @@ export class UnitView { this.body.addChild(this.fallback); this.actor = null; this.spineReady = false; + this.baseTint = 0xffffff; // the drawn model's own tint (ALIAS_TINT), under the status tints this.hud = new P.Container(); ctx.layers.bars.addChild(this.hud); @@ -340,6 +349,7 @@ export class UnitView { if (swap) this._dropActor(); this.actor = actor; this._actorEntry = entry; + this.baseTint = (!entry.local && ALIAS_TINT[id]) || 0xffffff; // the web alias of a local-only model this.body.addChild(this.actor.spine); this.spineReady = true; this.swapT = swap ? 1 : 0; @@ -704,7 +714,7 @@ export class UnitView { this.fallback.visible = this.swapT < 1; const sc = s * UNIT.modelScale * this.modelK; const flashK = this.flash > 0 ? this.flash : 0; - let tint = 0xffffff; + let tint = this.baseTint; if (this.down) tint = DOWN_LOOK.tint; else if (this.flags & UF.FROZEN) tint = 0x9fd4ff; else if (this.flags & UF.COLD) tint = 0xcfe6ff; diff --git a/test/feedback1d-models.test.js b/test/feedback1d-models.test.js index 6cf875b1..326812ea 100644 --- a/test/feedback1d-models.test.js +++ b/test/feedback1d-models.test.js @@ -8,6 +8,8 @@ // tools/assets/local-enemy-spines.json — never from the disk, so the manifest is the same with or without the // extraction). The client (assets.js spineEntry) draws the official model when data/local-assets.json lists every one // of its files and falls back to the web model when it fails to load (DESIGN §13: local art is optional). +// Without the extraction the web alias is drawn tinted toward the slug's own colours (render/units.js ALIAS_TINT, +// research 07 §5.6 "a hue shift" [ASSUMED look]), so source installs still tell them apart from the plain slug. // 高能 / 冰爆 / 简饲源石虫 and “庞贝” always had their own models (checked in headless Chrome). import { test, describe, before, after } from 'node:test'; @@ -207,7 +209,12 @@ describe('D3 client: the official model only when data/local-assets.json lists i describe('UnitView', () => { let fake, UnitView; - before(async () => { fake = installFakePixi(); ({ UnitView } = await import('../public/js/render/units.js')); }); + let ALIAS_TINT, UF; + before(async () => { + fake = installFakePixi(); + ({ UnitView, ALIAS_TINT } = await import('../public/js/render/units.js')); + ({ UF } = await import('../shared/constants.js')); + }); after(() => fake.restore()); const tick = () => new Promise((r) => setImmediate(r)); const cam = () => presetCamera('normal', { width: 1280, height: 720 }); @@ -238,6 +245,35 @@ describe('D3 client: the official model only when data/local-assets.json lists i assert.equal(bad.actor?.entry, web, 'drawn with the web model'); assert.equal(bad.entry, web); }); + + test('the web alias of a local-only slug is drawn tinted toward its own colours; the official model and the plain 源石虫 are not', async () => { + const store = (local, fail = () => false) => ({ + picture: () => null, image: async () => null, + spineEntry: (id) => spineEntry(MANIFEST, id, local ? { local } : undefined), + spine: { acquire: async (e) => { if (fail(e)) throw new Error('404'); return { animations: Object.keys(e.animations).map((name) => ({ name })) }; }, release() {} }, + }); + const local = localManifest(SLUGS); + const mk = (id, st) => new UnitView(fakeViewCtx(fake.P, { assets: st, cam }), info(id)); + const web0 = mk(SLUGS[0], store(null)), web1 = mk(SLUGS[1], store(null)); + const official = mk(SLUGS[0], store(local)), failed = mk(SLUGS[1], store(local, (e) => e.local)); + const plain = mk('enemy_1007_slime', store(local)); + for (let i = 0; i < 4; i++) await tick(); + const all = [web0, web1, official, failed, plain]; + for (const v of all) v.update(1 / 60, cam(), 0); + assert.equal(web0.actor.entry, webOf(SLUGS[0]), 'not extracted: the plain 源石虫 skeleton'); + assert.equal(web0.actor.spine.tint, ALIAS_TINT[SLUGS[0]], '灼热源石虫: orange'); + assert.equal(web1.actor.spine.tint, ALIAS_TINT[SLUGS[1]], '炽焰源石虫: red-orange'); + assert.notEqual(ALIAS_TINT[SLUGS[0]], ALIAS_TINT[SLUGS[1]], 'the two read apart'); + assert.ok(official.actor.entry.local); + assert.equal(official.actor.spine.tint, 0xffffff, 'the official model as it is'); + assert.equal(failed.actor.entry, webOf(SLUGS[1])); + assert.equal(failed.actor.spine.tint, ALIAS_TINT[SLUGS[1]], 'the web fallback of a failed official model is tinted too'); + assert.equal(plain.actor.spine.tint, 0xffffff, 'the plain 源石虫 itself'); + web0.flags = UF.FROZEN; + web0.update(1 / 60, cam(), 1 / 60); + assert.equal(web0.actor.spine.tint, 0x9fd4ff, 'a status tint wins (frozen)'); + for (const v of all) v.destroy?.(); + }); }); });