A special terrain tile explains itself on a tap, and 活性源石 is one material on both boards (GitHub #184) (#185)
Tapping a 活性源石 / 沼泽 / 深水区 / 排气格栅 / gate / teleport tile opens a read-only card with that stage's own terrain numbers (the boss-prep board picks the board tile once, through prepXf.toBoard); the 2D atlas cell and the 3D shader now share one 活性源石 palette and recipe and draw to the tile edge. Contributed by @Convey123.
This commit is contained in:
1 parent
d582925816
commit
f6f5ed50cd
16 files changed
+905
-37
No files matched your search
@@ -0,0 +1,79 @@
|
||||
// GitHub #184 (second half): 活性源石 was drawn as two different materials — the 2D atlas cell was a beveled brick with
|
||||
// random crystal clusters, the 3D board's quad a world-space crust with pulsing veins — so the same tile read as two
|
||||
// different floors. These are the couplings that keep it ONE material: the palette of `render/style.js ORIGINIUM`, the
|
||||
// concrete slab it lies on, and no per-tile frame (which made a field of 活性源石 look like separate patches).
|
||||
// The pixels themselves are checked in test/render/originium-cell.e2e.test.js (it needs a real canvas).
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const read = (p) => readFileSync(path.join(ROOT, p), 'utf8');
|
||||
const style = read('public/js/render/style.js');
|
||||
const textures = read('public/js/render/textures.js');
|
||||
const materials = read('public/js/render/board3d/materials.js');
|
||||
const tiles = read('public/js/render/tiles.js');
|
||||
|
||||
/** The source of one exported function of a module (its JSDoc through the closing brace at column 0). */
|
||||
function fnSource(src, name) {
|
||||
const at = src.indexOf(`export function ${name}(`);
|
||||
assert.ok(at > 0, `${name} not found`);
|
||||
const end = src.indexOf('\n}\n', at);
|
||||
return src.slice(src.lastIndexOf('/**', at), end + 2);
|
||||
}
|
||||
|
||||
test('活性源石: one palette for both boards, and the canvas converts the working space', () => {
|
||||
assert.match(style, /export const ORIGINIUM = Object\.freeze\(\{/, 'the palette lives in style.js');
|
||||
for (const k of ['base', 'crust', 'vein', 'spec']) assert.match(style, new RegExp(`${k}: Object\\.freeze\\(\\[`), k);
|
||||
assert.match(style, /export function linearToSrgb255/, 'the sRGB conversion the canvas needs');
|
||||
// the 2D drawer takes its colours from the palette (no private copy) and converts them for the canvas
|
||||
assert.match(textures, /import \{[^}]*ORIGINIUM[^}]*\} from '\.\/style\.js'/);
|
||||
assert.match(textures, /ORIGINIUM\.vein/);
|
||||
assert.match(textures, /linearToSrgb255\(/, 'the canvas stores sRGB, the palette is the shaders\' linear space');
|
||||
// so does the 3D shader, through the vec3 helper
|
||||
assert.match(materials, /import \{ ORIGINIUM \} from '\.\.\/style\.js'/);
|
||||
assert.match(materials, /const V3 = \(c\) => `vec3\(/);
|
||||
for (const k of ['base', 'crust', 'vein', 'spec']) assert.match(materials, new RegExp(`V3\\(ORIGINIUM\\.${k}\\)`), k);
|
||||
// the identical recipe (the shader's own thresholds, mirrored in the drawer's smoothstep calls)
|
||||
assert.match(materials, /smoothstep\(0\.38, 0\.62, n \* 0\.7 \+ n2 \* 0\.45\)/);
|
||||
assert.match(textures, /smoothstep\(0\.38, 0\.62, n1 \* 0\.7 \+ n2 \* 0\.45\)/);
|
||||
assert.match(materials, /smoothstep\(0\.86, 0\.98, 1\.0 - abs\(n2 \* 2\.0 - 1\.0\)\)/);
|
||||
assert.match(textures, /smoothstep\(0\.86, 0\.98, 1 - Math\.abs\(n2 \* 2 - 1\)\)/);
|
||||
});
|
||||
|
||||
test('活性源石: the 2D tile lies on the same concrete the 3D board lays its crust on', () => {
|
||||
// board3d/atlas.js `concrete` and textures.js DEFAULT_ART_LAYERS.infection must name the same atlas rect: with the
|
||||
// board art installed the 2D tile is composed exactly like the 3D one (official slab + our crust).
|
||||
const atlas = read('public/js/render/board3d/atlas.js');
|
||||
const m2 = atlas.match(/concrete: S\('D', \[([\d, ]+)\]\)/);
|
||||
const m1 = textures.match(/infection: Object\.freeze\(\[Object\.freeze\(\{ src: 'D', rect: \[([\d, ]+)\] \}\)/);
|
||||
assert.ok(m1 && m2, 'both define the slab');
|
||||
assert.equal(m1[1].replace(/\s/g, ''), m2[1].replace(/\s/g, ''), 'the same concrete cell');
|
||||
});
|
||||
|
||||
test('活性源石: neither board fades the tile out at its own border', () => {
|
||||
// the 3D crust used to be multiplied by a per-tile `edge` fade (min(vUv, 1 − vUv)), which left a transparent band
|
||||
// between neighbouring tiles; the drawer must reach the cell's edges the same way. Its siblings (mire / water) keep
|
||||
// their own fade — this is about 活性源石 only.
|
||||
const inf = fnSource(materials, 'infectionMaterial');
|
||||
assert.ok(!/min\(e\.x, e\.y\)|float edge/.test(inf), 'no per-tile edge fade in the infection shader');
|
||||
assert.match(inf, /float a = clamp\(crust \* 0\.72 \+ vein, 0\.0, 1\.0\)/);
|
||||
const mire = fnSource(materials, 'mireMaterial');
|
||||
assert.match(mire, /float edge = smoothstep/, 'the mire keeps its own fade');
|
||||
// the 2D cell has no frame either: no bevelTile in the drawer's two variants
|
||||
const draw = textures.slice(textures.indexOf(' infection(ctx, x, y, s, r) {'), textures.indexOf(' cliff(ctx, x, y, s) {'));
|
||||
assert.ok(!/bevelTile/.test(draw), 'the old beveled brick is gone');
|
||||
assert.match(draw, /floorBase\(ctx, x, y, s, r\); originiumOverlay\(ctx, x, y, s, false\);/);
|
||||
assert.match(draw, /infection2\(ctx, x, y, s, r\) \{ floorBase\(ctx, x, y, s, r\); originiumOverlay\(ctx, x, y, s, true\); \}/);
|
||||
});
|
||||
|
||||
test('活性源石: two variants are registered, picked by tile, and the pulse uses the shared tint', () => {
|
||||
assert.match(textures, /'deepsea', 'infection', 'infection2', 'cliff'/);
|
||||
assert.match(textures, /originium2\(ctx, x, y, s\) \{ originiumOverlay\(ctx, x, y, s, true\); \}/, 'the proc layer for the art path');
|
||||
assert.match(textures, /infection2: Object\.freeze\(\[Object\.freeze\(\{ src: 'D', rect: \[256, 512, 256, 256\] \}\), Object\.freeze\(\{ proc: 'originium2' \}\)\]\)/);
|
||||
assert.match(tiles, /case 'infection': return h < 4 \? 'infection' : 'infection2';/);
|
||||
assert.match(tiles, /add\('infect', 'glow', P\.BLEND_MODES\.ADD, ORIGINIUM\.glow, 1\)/);
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
// GitHub #184 (second half), the pixels: the 2D board's 活性源石 cell must be the material the 3D board's shader draws
|
||||
// (`render/board3d/materials.js infectionMaterial`) — the palette of `render/style.js ORIGINIUM`, the crust reaching the
|
||||
// tile's edges (no per-tile frame: a field of 活性源石 must read as ONE floor, not as separate patches), and the cell
|
||||
// joining itself without a seam (the noise wraps, so a repeated cell lines up).
|
||||
// SP_E2E=1 CHROME_PATH=… node --test test/render/originium-cell.e2e.test.js
|
||||
// Both configurations are checked: the atlas a deployment WITH the board art renders (the official concrete slab + our
|
||||
// crust, `DEFAULT_ART_LAYERS.infection`) and the procedural one upstream players without the art see (`MAT_DRAW.infection`).
|
||||
// The couplings between the two renderers' sources are in test/render/originium-art.test.js (no browser needed).
|
||||
|
||||
import { test, describe, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync } from 'node:fs';
|
||||
|
||||
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
||||
const ENABLED = process.env.SP_E2E === '1' && existsSync(CHROME);
|
||||
const f3 = (v) => Math.round(v * 1000) / 1000;
|
||||
|
||||
describe('活性源石 cell (2D board)', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome) to run' }, () => {
|
||||
let srv;
|
||||
let browser;
|
||||
let base;
|
||||
let configs = []; // [{ name, art, infection, infection2, road, palette }]
|
||||
|
||||
before(async () => {
|
||||
const { startServer } = await import('../../server/index.js');
|
||||
const puppeteer = (await import('puppeteer-core')).default;
|
||||
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
|
||||
base = `http://127.0.0.1:${srv.port}`;
|
||||
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox', '--force-device-scale-factor=1'] });
|
||||
const page = await browser.newPage();
|
||||
await page.goto(`${base}/dev/game-mock.html?phase=PREP&render=fallback`, { waitUntil: 'domcontentloaded' });
|
||||
await page.addScriptTag({ url: '/vendor/pixi.min.js' }); // textures.js builds its atlas with PIXI
|
||||
configs = await page.evaluate(async () => {
|
||||
const m = await import('/js/render/textures.js');
|
||||
const st = await import('/js/render/style.js');
|
||||
const { assets } = await import('/js/assets.js');
|
||||
const { loadBoardArt } = await import('/js/render/boardArt.js');
|
||||
// the atlas a deployment with the board art renders, and the procedural one (no art) upstream sees
|
||||
await assets.load?.().catch?.(() => null);
|
||||
const art = await loadBoardArt(assets).catch(() => null);
|
||||
const read = (atlas, name) => {
|
||||
const uv = atlas.uv[name];
|
||||
if (!uv) return null;
|
||||
const W = atlas.canvas.width, H = atlas.canvas.height;
|
||||
const x0 = Math.round(uv[0] * W), y0 = Math.round(uv[1] * H);
|
||||
const w = Math.round((uv[2] - uv[0]) * W), h = Math.round((uv[3] - uv[1]) * H);
|
||||
const img = atlas.canvas.getContext('2d').getImageData(x0, y0, w, h);
|
||||
const px = [];
|
||||
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
|
||||
const k = (y * w + x) * 4;
|
||||
px.push({ x, y, r: img.data[k] / 255, g: img.data[k + 1] / 255, b: img.data[k + 2] / 255, a: img.data[k + 3] / 255 });
|
||||
}
|
||||
return { w, h, px, data: Array.from(img.data) };
|
||||
};
|
||||
const cells = (atlas, name) => ({ name, art: atlas.art, artCount: atlas.artCount,
|
||||
infection: read(atlas, 'infection'), infection2: read(atlas, 'infection2'), road: read(atlas, 'road') });
|
||||
// the vein's DISPLAYED colour (what a canvas stores): the palette converted out of the shaders' working space
|
||||
const hex = st.linearToHex(st.ORIGINIUM.vein);
|
||||
const rgb = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255);
|
||||
const palette = { veinHex: hex, gr: rgb[1] / rgb[0], br: rgb[2] / rgb[0] };
|
||||
const out = [cells(m.tileAtlas(art || null), art ? 'board art' : 'no art installed')];
|
||||
if (art) out.push(cells(m.tileAtlas(null), 'procedural'));
|
||||
return out.map((c) => ({ ...c, palette }));
|
||||
});
|
||||
await page.close();
|
||||
});
|
||||
after(async () => {
|
||||
await browser?.close();
|
||||
await srv?.close();
|
||||
});
|
||||
|
||||
/** The numbers of one cell: veins, edges, ring vs middle, and the difference to another cell. */
|
||||
function stats(c) {
|
||||
const veinish = (p) => p.a > 0.6 && p.r > 0.55 && p.g > 0.2 && p.g < 0.8 && p.b < 0.5;
|
||||
const mean = (arr, k) => arr.reduce((n, p) => n + p[k], 0) / Math.max(1, arr.length);
|
||||
const hot = c.px.filter(veinish);
|
||||
const ring = c.px.filter((p) => Math.min(p.x, p.y, c.w - 1 - p.x, c.h - 1 - p.y) < c.w * 0.08);
|
||||
const lum = (arr) => arr.reduce((n, p) => n + (0.2126 * p.r + 0.7152 * p.g + 0.0722 * p.b) * p.a, 0) / Math.max(1, arr.length);
|
||||
const band = (pred) => { const a = c.px.filter(pred); return f3(a.filter(veinish).length / Math.max(1, a.length)); };
|
||||
// the vein CORES are opaque, so their colour does not depend on the slab underneath (the art config draws one):
|
||||
// the brightest few percent are exactly those, whichever base the cell has
|
||||
const core = [...c.px].sort((p, q) => (q.r + q.g + q.b) - (p.r + p.g + p.b)).slice(0, Math.max(1, Math.round(c.px.length * 0.03)));
|
||||
return {
|
||||
veinPct: f3(hot.length / c.px.length), ringVeinPct: f3(ring.filter(veinish).length / ring.length),
|
||||
hotGR: f3(mean(hot, 'g') / mean(hot, 'r')), hotBR: f3(mean(hot, 'b') / mean(hot, 'r')), borderLum: f3(lum(ring)),
|
||||
coreGR: f3(mean(core, 'g') / mean(core, 'r')), coreBR: f3(mean(core, 'b') / mean(core, 'r')),
|
||||
// our own crust at the tile's borders (the base slab may be official art with edges of its own, so the veins — the
|
||||
// layer this change drew — are what shows whether the tile joins the next one)
|
||||
veinsL: band((p) => p.x < 3), veinsR: band((p) => p.x >= c.w - 3),
|
||||
veinsT: band((p) => p.y < 3), veinsB: band((p) => p.y >= c.h - 3),
|
||||
};
|
||||
}
|
||||
const diff = (a, b) => f3(a.data.reduce((n, v, i) => n + Math.abs(v - b.data[i]), 0) / a.data.length);
|
||||
|
||||
test('the cell wears the shared palette: the veins hit the colour the 3D shader writes', () => {
|
||||
for (const cfg of configs) {
|
||||
const s = stats(cfg.infection);
|
||||
const { gr, br, veinHex } = cfg.palette;
|
||||
assert.ok(s.veinPct >= 0.05, `${cfg.name}: the crust carries veins (${s.veinPct})`);
|
||||
assert.ok(Math.abs(s.coreGR - gr) <= 0.06, `${cfg.name}: the veins wear the palette's colour (g/r ${s.coreGR} vs ${f3(gr)} for ${veinHex})`);
|
||||
assert.ok(Math.abs(s.coreBR - br) <= 0.06, `${cfg.name}: …and its blue (b/r ${s.coreBR} vs ${f3(br)})`);
|
||||
// the whole cell is that material, not the bare slab the atlas holds for an ordinary tile
|
||||
assert.ok(diff(cfg.infection, cfg.road) > 8, `${cfg.name}: the overlay really draws over the slab (${diff(cfg.infection, cfg.road)})`);
|
||||
}
|
||||
});
|
||||
|
||||
test('no per-tile frame — the crust reaches the edges, and the cell joins itself', () => {
|
||||
for (const cfg of configs) {
|
||||
const s = stats(cfg.infection);
|
||||
// veins at the border like anywhere else: the old beveled brick had a frame and none at the very edge
|
||||
assert.ok(s.ringVeinPct >= s.veinPct * 0.6, `${cfg.name}: veins reach the tile's border (${s.ringVeinPct} at the ring vs ${s.veinPct})`);
|
||||
// a repeated cell must line up: the noise wraps, so opposite edges sample the same field — the veins run through
|
||||
// the edge on both sides instead of stopping at it. Compared exactly on the cell WE compose (floor + crust); with
|
||||
// the official slab under the crust the vein/not-vein classification of the composite pixels follows the slab's
|
||||
// own shading (its edges are the official art's, on every tile of the board), so there only the ring rule above
|
||||
// applies — an edge band is 3 of 240 columns, and one slab of the noise is not the cell's average.
|
||||
if (!cfg.art) {
|
||||
for (const [a, b, where] of [[s.veinsL, s.veinsR, 'left/right'], [s.veinsT, s.veinsB, 'top/bottom']]) {
|
||||
assert.ok(Math.abs(a - b) <= 0.06, `${cfg.name}: ${where} edge veins ${a} vs ${b}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('two variants, so a field of 活性源石 does not repeat one pattern', () => {
|
||||
for (const cfg of configs) {
|
||||
assert.ok(cfg.infection2, `${cfg.name}: infection2 is an atlas cell`);
|
||||
const a = stats(cfg.infection), b = stats(cfg.infection2);
|
||||
assert.ok(Math.abs(a.veinPct - b.veinPct) <= 0.02, `${cfg.name}: the same recipe (${a.veinPct} vs ${b.veinPct})`);
|
||||
assert.ok(diff(cfg.infection, cfg.infection2) > 6, `${cfg.name}: the second variant is a different pattern (${diff(cfg.infection, cfg.infection2)})`);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user