Files
ark-stronghold-protocol/test/render/tiles.test.js
T
sganggs 01a44cd288 卫戍协议:盟约 web remake — initial import
Online co-op browser remake of Arknights' Stronghold Protocol: Alliance
(client-simulated combat, official waves/bonds/items/strategies, 3D board,
facing wheel, operator loadout, official emotes, deployment scripts).
2026-09-29 16:21:31 +08:00

139 lines
6.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// test/render/tiles.test.js — pure parts of the tile renderer: stage parsing (glyph legend, heights, bands,
// margin fading, scenery rows), style helpers. Importing render/tiles.js must not need PIXI.
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseStage, mixColor } from '../../public/js/render/tiles.js';
import { GLYPH, TILE_H, dmgStyleKey, STATUS_ICON, PROJ } from '../../public/js/render/style.js';
import { STATUS_KEYS } from '../../public/js/render/textures.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const stages = JSON.parse(readFileSync(path.join(ROOT, 'data/stages.json'), 'utf8'));
describe('parseStage', () => {
test('every data stage parses into a 19×21 grid with known materials and sane heights', () => {
for (const [id, st] of Object.entries(stages)) {
const g = parseStage(st);
assert.equal(g.length, 19, id);
for (const row of g) {
assert.equal(row.length, 21, id);
for (const t of row) {
assert.ok(typeof t.mat === 'string' && t.mat, `${id} ${t.r},${t.c} mat`);
assert.ok(Number.isFinite(t.h) && t.h >= 0 && t.h <= 1, `${id} height`);
assert.ok(t.alpha > 0 && t.alpha <= 1);
}
}
// the own board is always drawn and playable; hand slots are raised benches
for (let r = 9; r <= 12; r++) for (let c = 3; c <= 8; c++) assert.ok(g[r][c].drawn, `${id} board ${r},${c}`);
for (let c = 0; c < 10; c++) {
assert.equal(g[7][c].glyph, 'a', `${id} hand ${c}`);
assert.equal(g[7][c].h, TILE_H.bench);
}
assert.equal(g[9][2].glyph, 'E', `${id} objective`);
}
});
test('glyph legend: heights and materials follow DATA.md §12', () => {
const st = stages.act2autochess_m02;
const g = parseStage(st);
const find = (glyph) => g.flat().find((t) => t.glyph === glyph);
assert.equal(find('h').h, TILE_H.wall);
assert.equal(find('h').mat, 'wall');
assert.equal(find('X').h, TILE_H.sep);
assert.equal(find('m').mat, 'mire');
assert.equal(find('m').h, 0);
assert.equal(find('S').mat, 'start');
assert.equal(find('E').mat, 'end');
for (const [glyph, def] of Object.entries(GLYPH)) assert.ok(def.mat, glyph);
});
test('bands: rows outside the band are not drawn; pen rows are scenery; far margins skipped', () => {
const st = stages.act2autochess_m01;
const field = parseStage(st, [6, 18]);
for (let c = 0; c < 21; c++) for (let r = 0; r <= 5; r++) assert.equal(field[r][c].drawn, false, `boss rows hidden ${r},${c}`);
assert.ok(field[15][7].scenery && field[15][7].drawn, 'pen gate drawn as scenery');
assert.equal(field[15][7].playable, false);
const boss = parseStage(st, [0, 6]);
for (let r = 7; r <= 18; r++) for (let c = 0; c < 21; c++) assert.equal(boss[r][c].drawn, false);
assert.ok(boss[2][2].drawn && boss[2][2].glyph === 'E');
// margin '#' far from anything playable fades then disappears
const full = parseStage(st);
const margin = full.flat().filter((t) => t.glyph === '#' && t.drawn && t.dist === 2);
assert.ok(margin.length > 0 && margin.every((t) => t.alpha < 1 && t.mat === 'margin'));
});
test('junk / missing stage data never throws', () => {
for (const bad of [null, undefined, {}, { rows: 'x' }, { rows: [null, 42, 'zz??'] }, { rows: Array(19).fill('Q'.repeat(21)), tiles: { Q: { tileKey: 'tile_road', height: 'LOW' } } }]) {
const g = parseStage(bad);
assert.equal(g.length, 19);
assert.equal(g[0].length, 21);
}
const q = parseStage({ rows: Array(19).fill('Q'.repeat(21)), tiles: { Q: { tileKey: 'tile_road', height: 'LOW' } } });
assert.equal(q[10][5].glyph, 'r', 'unknown glyph mapped through its tileKey');
});
});
describe('style helpers', () => {
test('damage style keys and status icons', () => {
assert.equal(dmgStyleKey('phys'), 'phys');
assert.equal(dmgStyleKey('arts'), 'arts');
assert.equal(dmgStyleKey('true'), 'true');
assert.equal(dmgStyleKey('burn'), 'elem');
assert.equal(dmgStyleKey('necrosis'), 'elem');
assert.equal(dmgStyleKey('???'), 'phys');
for (const k of Object.values(STATUS_ICON)) assert.ok(STATUS_KEYS.includes(k), `status icon ${k} drawn in the atlas`);
for (const p of Object.values(PROJ)) assert.ok(p.speed > 0 && p.width > 0);
});
test('mixColor', () => {
assert.equal(mixColor(0x000000, 0xffffff, 0), 0x000000);
assert.equal(mixColor(0x000000, 0xffffff, 1), 0xffffff);
assert.equal(mixColor(0xff0000, 0x0000ff, 0.5), 0x800080);
assert.equal(mixColor(0x123456, 0x654321, -5), 0x123456);
});
});
describe('TileField external board (the 3D board layer draws the tiles)', () => {
test('external mode builds no Pixi board, keeps heights / levels / highlights; switching back rebuilds the 2D board', async () => {
const { installFakePixi } = await import('./fakepixi.js');
const fake = installFakePixi();
try {
const P = globalThis.PIXI;
// minimal mesh stack for the 2D board (not part of the shared fake)
P.Buffer = class { constructor(data) { this.data = data; } update() {} };
P.Geometry = class { addAttribute() { return this; } addIndex() { return this; } };
P.Shader = { from: (v, f, u) => ({ uniforms: { ...u } }) };
P.Mesh = class extends P.Container { constructor(g, s) { super(); this.geometry = g; this.shader = s; } };
P.BLEND_MODES = { ADD: 1, NORMAL: 0 };
const { TileField } = await import('../../public/js/render/tiles.js');
const layers = { ground: new P.Container(), overlay: new P.Container(), props: new P.Container(), anim: new P.Container() };
for (const l of Object.values(layers)) l.addChild = function (...kids) { for (const k of kids) { k.parent = this; this.children.push(k); } return kids[0]; };
const f = new TileField(layers);
f.setView([6, 18], { r0: 7, r1: 12, c0: 0, c1: 10 }, [6, 13]);
f.setExternal(true);
f.setStage(stages.act2autochess_m01);
assert.equal(f.meshes.length, 0, 'no board meshes');
assert.equal(f.rowMeshes.size, 0);
assert.equal(f.animSprites.length, 0);
assert.equal(f.boxes.length, 0);
assert.equal(f.surfaceLayer(10), null, 'units on raised tops use their ground layer');
assert.equal(f.heightAt(10, 4), TILE_H.wall, 'high ground height still known (units / picking)');
assert.ok(f.levels.includes(TILE_H.wall) && f.levels.includes(0));
assert.ok(f.blowers.length > 0, 'blower airflow streaks stay in Pixi');
f.setHighlights([[10, 5]], 'legal');
assert.equal(f.highlights.size, 1);
f.setExternal(false);
assert.ok(f.meshes.length > 0 && f.rowMeshes.size > 0, '2D board rebuilt');
assert.ok(f.boxes.length > 0, 'gate boxes back');
f.setExternal(true);
assert.equal(f.meshes.length, 0);
f.destroy();
} finally {
fake.restore();
}
});
});