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

+131 -54
View File
@@ -14,19 +14,23 @@
// tile on the line walkable and not a crate; a diagonal step needs both orthogonal neighbours clear). A ground enemy
// walks straight toward `next[tile]` of its current tile (ai.js). Fields are cached per (destination, grid version).
//
// Blockable-ground preference (user playtest: "the lower-gate enemies of 战场#01 walk up the col-9 floor lane where no
// operator can block them"). Two refinements on top of the official algorithm; `dist` (so the crate cost 1000 and
// every grid route length) stays exactly the official one:
// * tie-break: the SPFA relaxes on (dist, pen) lexicographically, `pen` = number of NON-BLOCKABLE walkable tiles
// (floor / gate / goal / teleport tiles — `blockable()` false: not LOW ground buildable for melee) on the chain.
// Among equal-length chains the one with the fewest non-blockable tiles wins; remaining ties go to the first
// parent in SPFA order (the official order unless a pen improvement re-queued a tile).
// * smoothing: a line of sight may only cross a non-blockable tile (including the corner tiles of a diagonal step)
// that lies on the tile's own raw chain between its two ends — a smoothed segment never cuts across floor the grid
// route does not walk, so an enemy never slips past an operator standing on the road next to the floor lane.
// This one also bends routes whose raw chain is the official one (a diagonal that would clip off-chain floor
// becomes an L, mostly inside the boss arena's central floor): the smoothed polyline can be up to 2 tiles
// longer than the official one (test/sim/pathing-blockable.test.js bounds it).
// Blockable-ground preference (user playtest #2: "the lower-gate enemies of 战场#01 walk up the col-9 floor lane where
// no operator can block them"), kept to where the official route really walks over more floor (community report after
// 0.1.0: on 战场#04 活性源石 the lower-gate enemies must cross diagonally from row 9 into row 10 as officially, not
// walk two tiles left of the gate and then straight up). A tile is non-blockable when it is walkable but not LOW
// ground buildable for melee (floor / gate / goal / teleport — `blockable()` false). A segment CROSSES the tiles whose
// interior it passes through (`crossTiles`, exact geometry); one it only touches at a corner point is not crossed.
// Each field holds two candidate pointers per tile, both with the official `dist` (so the crate cost 1000 and every
// grid route length stay official) and the official line-of-sight walk:
// * official: the plain algorithm above;
// * preference: the SPFA relaxes on (dist, pen) lexicographically, `pen` = non-blockable tiles on the chain (among
// equal-length chains the fewest wins; remaining ties go to the first parent in SPFA order), and a smoothed
// segment that would cross a non-blockable tile off the tile's own raw chain backs off to the farthest earlier
// waypoint whose segment does not — it never cuts across floor its grid route does not walk.
// `next[tile]` is the official pointer unless the preference pointer leads to a route crossing strictly fewer
// non-blockable tiles (`cost`, counted to the destination in increasing `dist` order), or as few with a farther
// waypoint. So the route from every tile crosses no more floor than either candidate and is the official one wherever
// that is not floor-heavier (test/sim/pathing-official.test.js lists the routes that still differ on every stage).
// Unavoidable non-blockable tiles (gates, goals, teleports and single-exit floor, e.g. (12,9) next to the upper
// gate) stay on the route; test/sim/pathing-blockable.test.js lists them per stage, gate and field.
//
@@ -236,18 +240,59 @@ export class Grid {
_buildField(er, ec, allowDiagonal, ignore) {
const N = ROWS * COLS;
const dist = new Int32Array(N).fill(-1);
const parent = new Int32Array(N).fill(-1);
/** non-blockable tiles on the raw chain (tile included, destination excluded) — the equal-length tie-break */
const pen = new Int32Array(N);
const f = { dest: -1, dist, parent, pen, next: parent, len: null, version: this.version, allowDiagonal, ignore };
if (!this.inBounds(er, ec)) return f;
const f = { dest: -1, dist: null, parent: null, pen: null, next: null, official: null, cost: null, len: null, version: this.version, allowDiagonal, ignore };
if (!this.inBounds(er, ec)) {
const none = new Int32Array(N).fill(-1);
return Object.assign(f, { dist: none, parent: none, next: none, official: none, pen: new Int32Array(N), cost: new Int32Array(N) });
}
const dest = er * COLS + ec;
f.dest = dest;
const inQ = new Uint8Array(N);
const unb = this.unblockable;
const crate = (k) => !ignore && (this.obstacle[k] & OB_CRATE) !== 0;
// SPFA (FIFO), seeded with the destination (walkable or not); relaxation on (dist, pen) lexicographically
const official = this._spfa(dest, ignore, null);
const pref = this._spfa(dest, ignore, unb);
const dist = pref.dist; // the lexicographic relaxation leaves every distance the official one
const walk = (r, c) => this.walkable(r, c, ignore) && (ignore || !(this.obstacle[r * COLS + c] & OB_CRATE));
const los = allowDiagonal ? (a, b) => bresenhamClear(a, b, walk) : (a, b) => segmentClear(a, b, walk);
const nextO = smoothChains(official.parent, dist, los, null);
// preference smoothing: a segment may cross a non-blockable tile only on the tile's own raw chain between its ends
const onChain = new Int32Array(N).fill(-1);
const nextP = smoothChains(pref.parent, dist, los, {
begin: (n) => { for (let x = n, g = N; x >= 0 && g-- > 0; x = pref.parent[x]) onChain[x] = n; },
ok: (n, b) => crossTiles(n, b, (r, c) => { const k = r * COLS + c; return !unb[k] || (onChain[k] === n && dist[k] >= dist[b]); }),
});
// per tile, in increasing distance: the official pointer unless the preference one crosses fewer non-blockable
// tiles on the way to the destination (or as few with a farther waypoint)
const order = [];
for (let k = 0; k < N; k++) if (dist[k] >= 0) order.push(k);
order.sort((a, b) => dist[a] - dist[b] || a - b);
const through = (a, b) => { let n = -unb[a]; crossTiles(a, b, (r, c) => { n += unb[r * COLS + c]; }); return n; };
const next = new Int32Array(N).fill(-1);
const cost = new Int32Array(N);
for (const k of order) {
const o = nextO[k], p = nextP[k];
if (o < 0) continue; // the destination
let use = o, best = through(k, o) + cost[o];
if (p >= 0 && p !== o) {
const cp = through(k, p) + cost[p];
if (cp < best || (cp === best && dist[p] < dist[o])) { use = p; best = cp; }
}
next[k] = use;
cost[k] = best;
}
return Object.assign(f, { dist, parent: pref.parent, pen: pref.pen, next, official: nextO, cost });
}
/**
* FIFO SPFA from `dest` (walkable or not) with the official neighbour order and move costs. With `penalty` (per-tile
* 0/1) the relaxation is lexicographic on (dist, pen): equal-length chains keep the fewest penalised tiles.
*/
_spfa(dest, ignore, penalty) {
const N = ROWS * COLS;
const dist = new Int32Array(N).fill(-1);
const parent = new Int32Array(N).fill(-1);
/** penalised tiles on the raw chain (tile included, destination excluded) */
const pen = new Int32Array(N);
const inQ = new Uint8Array(N);
const q = new Int32Array(N * 4 + 8);
let qh = 0, qt = 0;
const push = (k) => {
@@ -265,8 +310,8 @@ export class Grid {
const nr = r + FOUR_WAYS[i][0], nc = c + FOUR_WAYS[i][1];
if (!this.walkable(nr, nc, ignore)) continue;
const nb = nr * COLS + nc;
const nd = dist[cur] + (crate(nb) ? OBSTACLE_COST : 1);
const np = pen[cur] + unb[nb];
const nd = dist[cur] + (!ignore && (this.obstacle[nb] & OB_CRATE) ? OBSTACLE_COST : 1);
const np = penalty ? pen[cur] + penalty[nb] : 0;
if (dist[nb] < 0 || nd < dist[nb] || (nd === dist[nb] && np < pen[nb])) {
dist[nb] = nd;
pen[nb] = np;
@@ -275,32 +320,7 @@ export class Grid {
}
}
}
// smoothing: row-major, in place (client `_PostprocessAndMakeNextMapSmoothly`); a line from n toward ancestor a
// may cross a non-blockable tile only when that tile is on n's raw chain between n and a (onChain / minDist)
const next = new Int32Array(parent);
const onChain = new Int32Array(N).fill(-1);
let from = -1, minDist = 0;
const clear = (r, c) => {
if (!this.walkable(r, c, ignore)) return false;
const k = r * COLS + c;
if (crate(k)) return false;
return !unb[k] || (onChain[k] === from && dist[k] >= minDist);
};
const los = allowDiagonal ? (a, b) => bresenhamClear(a, b, clear) : (a, b) => segmentClear(a, b, clear);
for (let n = 0; n < N; n++) {
if (dist[n] < 0 || next[n] < 0) continue;
for (let x = n, guard = N; x >= 0 && guard-- > 0; x = parent[x]) onChain[x] = n;
from = n;
let b = next[n];
while (next[b] >= 0) {
minDist = dist[next[b]];
if (!los(n, next[b])) break;
b = next[b];
}
next[n] = b;
}
f.next = next;
return f;
return { dist, parent, pen };
}
/** Geometric length (tiles) of the smoothed route from tile key k to the field's destination (memoised). */
@@ -370,13 +390,41 @@ export class Grid {
/**
* @typedef {object} FlowField
* @property {number} dest destination tile key (-1 outside the grid)
* @property {Int32Array} dist SPFA distance to the destination (-1 = unreachable)
* @property {Int32Array} parent raw BFS parent
* @property {Int32Array} pen non-blockable tiles on the raw chain (tie-break among equal `dist`)
* @property {Int32Array} next smoothed parent (the tile to walk straight toward)
* @property {Int32Array} dist SPFA distance to the destination (-1 = unreachable; the official one)
* @property {Int32Array} parent raw SPFA parent of the preference chain (fewest non-blockable tiles among equal `dist`)
* @property {Int32Array} pen non-blockable tiles on that raw chain (tile included, destination excluded)
* @property {Int32Array} official smoothed parent of the pure official algorithm
* @property {Int32Array} next the tile to walk straight toward (official, or the preference pointer when it crosses fewer
* non-blockable tiles — see the module header)
* @property {Int32Array} cost non-blockable tiles the route of `next` crosses from the tile (excluded) to the destination
* @property {Float64Array|null} len memoised smoothed length (fieldLength)
*/
/**
* Row-major, in-place smoothing (client `_PostprocessAndMakeNextMapSmoothly`): each tile's pointer jumps along its chain
* (already smoothed pointers for tiles processed earlier) while `los(tile, ancestor)` holds. With `guard`
* ({ begin(n), ok(n, b) }) the jump then backs off to the farthest of the waypoints it passed that `ok` accepts (the
* raw parent always is). Returns the smoothed pointers.
*/
function smoothChains(parent, dist, los, guard) {
const N = parent.length;
const next = new Int32Array(parent);
const hops = new Int32Array(N);
for (let n = 0; n < N; n++) {
if (dist[n] < 0 || next[n] < 0) continue;
let h = 0, b = next[n];
hops[h++] = b;
while (next[b] >= 0 && los(n, next[b])) { b = next[b]; hops[h++] = b; }
if (guard && h > 1) {
guard.begin(n);
while (h > 1 && !guard.ok(n, hops[h - 1])) h--;
b = hops[h - 1];
}
next[n] = b;
}
return next;
}
/** Bresenham line of sight between tile keys a → b (client `_RaycastBresenhamLine`). */
function bresenhamClear(a, b, clear) {
let r = (a / COLS) | 0, c = a - r * COLS;
@@ -429,6 +477,35 @@ export function bresenhamTiles(a, b) {
return out;
}
/**
* Exact traversal of the straight segment between the centres of tile keys a → b: calls `fn(r, c)` for every tile whose
* interior the segment crosses, a and b included, in order — a tile it only touches at a corner point (the segment
* passes exactly through a lattice corner) is skipped. Stops early and returns false when `fn` returns false.
* Integer arithmetic: the k-th column boundary is crossed at t = (2k − 1) / 2dx, the m-th row boundary at
* (2m − 1) / 2dy.
*/
function crossTiles(a, b, fn) {
let r = (a / COLS) | 0, c = a - r * COLS;
const r1 = (b / COLS) | 0, c1 = b - r1 * COLS;
const dy = Math.abs(r1 - r), dx = Math.abs(c1 - c);
const sr = r1 > r ? 1 : -1, sc = c1 > c ? 1 : -1;
if (fn(r, c) === false) return false;
for (let kx = 1, ky = 1; r !== r1 || c !== c1;) {
const tx = kx <= dx ? (2 * kx - 1) * dy : Infinity;
const ty = ky <= dy ? (2 * ky - 1) * dx : Infinity;
if (tx === ty) { c += sc; r += sr; kx++; ky++; } else if (tx < ty) { c += sc; kx++; } else { r += sr; ky++; }
if (fn(r, c) === false) return false;
}
return true;
}
/** Tiles [[r,c]…] whose interior the segment between the centres of tiles a = [r,c] and b crosses (crossTiles). */
export function segmentTiles(a, b) {
const out = [];
crossTiles(a[0] * COLS + a[1], b[0] * COLS + b[1], (r, c) => { out.push([r, c]); });
return out;
}
/**
* Whether a straight move from (x, y) to the centre of tile p = {x: col, y: row} only crosses ground-walkable,
* crate-free tiles (the start and end tiles excepted). Exact grid traversal (tile (r,c) spans c ± 0.5, r ± 0.5); a