feedback1d WO: official route wherever it crosses no more floor (D5 战场#04 diagonal)

Community report: on 战场#04 活性源石 the lower-gate enemies walked two tiles left
of the red gate and then straight up instead of crossing diagonally from row 9
into row 10 as in the official game. The playtest #2 road-over-floor smoothing
counted the corner tiles of a diagonal step as crossed, so it bent the official
(9,10) -> (10,7) into (9,10) -> (9,8) -> (10,8) although that line only brushes
the corner of the floor tile (10,9).

grid.js now keeps the official flow field (research 08 §3.1) and replaces a
tile's pointer by the road-over-floor preference one only where its route
crosses strictly fewer non-blockable tiles (or as few with a farther waypoint);
a segment crosses the tiles whose interior it passes through (segmentTiles,
exact geometry). 140 of the 154 stage x gate x field routes are now official
(was 109); the 14 left avoid floor the official route walks over (战场#01's
col-8 road of playtest #2, boss exits of 战场#01 / #02, two 联防 partner gates).
data/stages.json groundPaths regenerated.
This commit is contained in:
sganggs committed 2026-10-03 04:14:43 +08:00
1 parent 7f134737ce
commit d8c62a96b5
9 files changed
+460 -153

No files matched your search

+96 -50
View File
@@ -1,9 +1,11 @@
// Adversarial cross-check of grid.js against an independent port of the official client pathing (research 08 §3.4,
// `Torappu.Battle.SPFA`: FIFO SPFA from the destination over the WHOLE level map, UP/RIGHT/DOWN/LEFT, crates cost 1000,
// strict improvement; row-major in-place Bresenham smoothing with the diagonal corner rule), run on the raw level files
// in .cache/gamedata (skipped when absent), extended by our blockable-ground preference (grid.js header: equal-length
// ties go to the chain with the fewest non-blockable tiles; a smoothed line crosses non-blockable tiles only on its own
// raw chain):
// in .cache/gamedata (skipped when absent), extended by our blockable-ground preference (grid.js header: a second,
// preference field — equal-length ties to the chain with the fewest non-blockable tiles, smoothing that backs off from
// segments through non-blockable tiles off the own raw chain — whose pointer replaces the official one where its route
// crosses fewer non-blockable tiles, or as few with a farther waypoint; "crosses" = positive-length intersection with
// the tile, found here by clipping the segment against each tile, independently of grid.js crossTiles):
// * the 8 active stages with their match-start devices: the smoothed chain from EVERY walkable tile of the normal,
// 联防 and boss rects to every goal equals the full-map reference one (the sim's rect limit changes nothing);
// * 400 random crate / block layouts: identical chains to the same algorithm limited to the rect;
@@ -26,9 +28,8 @@ const FOUR = [[1, 0], [0, 1], [-1, 0], [0, -1]];
/**
* The official algorithm on a raw level (non-hidden crates cost 1000; platforms / mounds blocked like the sim [ASSUMED]).
* `prefer` adds the blockable-ground preference (lexicographic (dist, non-blockable count) relaxation; smoothing lines
* cross non-blockable tiles only on the tile's own raw chain between its ends). Returns dest → (start → chain string),
* with `.dist(start)` / `.covered(start)` (tiles of the smoothed route incl. diagonal-step corners) on each dest.
* `prefer` adds the blockable-ground preference (see the header). Returns dest → (start → chain string), with
* `.dist(start)` / `.crossedNb(start)` (non-blockable tiles the route crosses, start excluded) on each dest.
*/
function official(sid, { crates: extraCrates = [], blocks: extraBlocks = [], rect = null, prefer = true } = {}) {
const lv = JSON.parse(readFileSync(file(sid), 'utf8'));
@@ -66,48 +67,94 @@ function official(sid, { crates: extraCrates = [], blocks: extraBlocks = [], rec
}
return clear ? out.every(([a, b]) => clear(a, b)) : out;
};
return (dest) => {
const D = dest.join();
const dist = new Map([[D, 0]]);
const pen = new Map([[D, 0]]);
const par = new Map();
const q = [dest];
const inq = new Set([D]);
while (q.length) {
const cur = q.shift();
const ck = cur.join();
inq.delete(ck);
for (const [dr, dc] of FOUR) {
const nb2 = [cur[0] + dr, cur[1] + dc];
const nk = nb2.join();
if (!passable(nb2[0], nb2[1])) continue;
const nd = dist.get(ck) + (crates.has(nk) ? 1000 : 1);
const np = pen.get(ck) + (nb(nb2[0], nb2[1]) ? 1 : 0);
const better = !dist.has(nk) || nd < dist.get(nk) || (prefer && nd === dist.get(nk) && np < pen.get(nk));
if (better) {
dist.set(nk, nd);
pen.set(nk, np);
par.set(nk, cur);
if (!inq.has(nk)) { q.push(nb2); inq.add(nk); }
// tiles the centre-to-centre segment a → b passes through with positive length (Liang–Barsky clip per tile)
const through = ([r0, c0], [r1, c1]) => {
const out = [];
for (let r = Math.min(r0, r1); r <= Math.max(r0, r1); r++) {
for (let c = Math.min(c0, c1); c <= Math.max(c0, c1); c++) {
let t0 = 0, t1 = 1;
for (const [pp, qq] of [[-(c1 - c0), c0 - (c - 0.5)], [c1 - c0, c + 0.5 - c0], [-(r1 - r0), r0 - (r - 0.5)], [r1 - r0, r + 0.5 - r0]]) {
if (pp === 0) { if (qq < 0) t1 = -1; continue; }
if (pp < 0) t0 = Math.max(t0, qq / pp); else t1 = Math.min(t1, qq / pp);
}
if (t1 - t0 > 1e-9) out.push([r, c]);
}
}
const nxt = new Map(par);
for (let r = 0; r < H; r++) {
for (let c = 0; c < W; c++) {
const k = `${r},${c}`;
if (!nxt.has(k)) continue;
const chain = new Set();
for (let x = [r, c]; x; x = par.get(x.join())) chain.add(x.join());
const los = (a) => {
const lo = dist.get(a.join());
return bres([r, c], a, (y, x) => passable(y, x) && !crates.has(`${y},${x}`)
&& (!prefer || !nb(y, x) || (chain.has(`${y},${x}`) && dist.get(`${y},${x}`) >= lo)));
};
let b = nxt.get(k);
while (nxt.has(b.join()) && los(nxt.get(b.join()))) b = nxt.get(b.join());
nxt.set(k, b);
return out;
};
const key = (p) => p.join();
const unkey = (k) => k.split(',').map(Number);
const losClear = (y, x) => passable(y, x) && !crates.has(`${y},${x}`);
return (dest) => {
const D = dest.join();
const spfa = (usePen) => {
const dist = new Map([[D, 0]]);
const pen = new Map([[D, 0]]);
const par = new Map();
const q = [dest];
const inq = new Set([D]);
while (q.length) {
const cur = q.shift();
const ck = cur.join();
inq.delete(ck);
for (const [dr, dc] of FOUR) {
const nb2 = [cur[0] + dr, cur[1] + dc];
const nk = nb2.join();
if (!passable(nb2[0], nb2[1])) continue;
const nd = dist.get(ck) + (crates.has(nk) ? 1000 : 1);
const np = pen.get(ck) + (usePen && nb(nb2[0], nb2[1]) ? 1 : 0);
if (!dist.has(nk) || nd < dist.get(nk) || (usePen && nd === dist.get(nk) && np < pen.get(nk))) {
dist.set(nk, nd);
pen.set(nk, np);
par.set(nk, cur);
if (!inq.has(nk)) { q.push(nb2); inq.add(nk); }
}
}
}
return { dist, par };
};
// row-major in-place smoothing; `guard`: back off to the farthest passed waypoint whose segment crosses
// non-blockable tiles only on the tile's own raw chain
const smooth = ({ dist, par }, guard) => {
const nxt = new Map(par);
for (let r = 0; r < H; r++) {
for (let c = 0; c < W; c++) {
const k = `${r},${c}`;
if (!nxt.has(k)) continue;
let b = nxt.get(k);
const hops = [b];
while (nxt.has(b.join()) && bres([r, c], nxt.get(b.join()), losClear)) { b = nxt.get(b.join()); hops.push(b); }
if (guard) {
const chain = new Set();
for (let x = [r, c]; x; x = par.get(x.join())) chain.add(x.join());
const ok = (a) => through([r, c], a).every(([y, x]) => !nb(y, x) || (chain.has(`${y},${x}`) && dist.get(`${y},${x}`) >= dist.get(a.join())));
while (hops.length > 1 && !ok(hops[hops.length - 1])) hops.pop();
b = hops[hops.length - 1];
}
nxt.set(k, b);
}
}
return nxt;
};
const off = spfa(false);
const dist = off.dist;
const segNb = (a, b) => through(a, b).filter(([y, x]) => !(y === a[0] && x === a[1]) && nb(y, x)).length;
let nxt = smooth(off, false);
if (prefer) {
// per tile in increasing distance: the preference pointer when its route crosses fewer non-blockable tiles (or as
// few with a farther waypoint)
const nxtP = smooth(spfa(true), true);
const cost = new Map([[D, 0]]);
const chosen = new Map();
for (const k of [...dist.keys()].sort((a, b) => dist.get(a) - dist.get(b))) {
if (k === D) continue;
const a = unkey(k), o = nxt.get(k), p = nxtP.get(k);
const co = segNb(a, o) + cost.get(key(o)), cp = segNb(a, p) + cost.get(key(p));
const useP = cp < co || (cp === co && dist.get(key(p)) < dist.get(key(o)));
chosen.set(k, useP ? p : o);
cost.set(k, useP ? cp : co);
}
nxt = chosen;
}
const chainOf = (start) => {
if (!dist.has(start.join())) return null;
@@ -118,13 +165,12 @@ function official(sid, { crates: extraCrates = [], blocks: extraBlocks = [], rec
};
const fn = (start) => { const ch = chainOf(start); return ch ? ch.map((p) => `(${p})`).join(' ') : null; };
fn.dist = (start) => dist.get(start.join()) ?? -1;
fn.coveredNb = (start) => {
fn.crossedNb = (start) => {
const ch = chainOf(start);
if (!ch) return null;
const s = new Set();
for (let i = 1; i < ch.length; i++) for (const [y, x] of bres(ch[i - 1], ch[i])) if (nb(y, x)) s.add(`${y},${x}`);
if (nb(...ch[0])) s.add(ch[0].join());
return s;
let n = 0;
for (let i = 1; i < ch.length; i++) n += segNb(ch[i - 1], ch[i]);
return n;
};
return fn;
};
@@ -200,7 +246,7 @@ test('vs the PURE official algorithm: same route length from every tile, never m
for (let c = rect.c0; c <= rect.c1; c++) {
if (!g.walkable(r, c) || p.dist([r, c]) < 0) continue;
assert.equal(f.dist[r * 21 + c], p.dist([r, c]), `${sid} (${r},${c}) → (${dest}): official length`);
const a = p.coveredNb([r, c]).size, b = o.coveredNb([r, c]).size;
const a = p.crossedNb([r, c]), b = o.crossedNb([r, c]);
assert.ok(b <= a, `${sid} (${r},${c}) → (${dest}): ${b} non-blockable tiles vs official ${a}`);
if (b < a) fewer++;
n++;