Skip to content

Commit 0bc0634

Browse files
Merge pull request #74 from codelovingcat/fix/restore-natural-snake-route
P37 — Restore natural snake route implementation
2 parents 35b3f99 + 73596ca commit 0bc0634

1 file changed

Lines changed: 227 additions & 1 deletion

File tree

‎src/snake-path.ts‎

Lines changed: 227 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { isAdjacent, pointKey, type Point } from "./domain.js";
22
import type { ContributionGrid } from "./contribution-grid.js";
33

44
const DEFAULT_SEED = 1;
5+
const ROUTE_VARIANT_COUNT = 3;
6+
57
export function planSnakePath(grid: ContributionGrid, seed = DEFAULT_SEED): readonly Point[] {
68
if (grid.columns < 1 || grid.rows < 1) {
79
return [];
@@ -11,12 +13,236 @@ export function planSnakePath(grid: ContributionGrid, seed = DEFAULT_SEED): read
1113
throw new Error("Snake path seed must be an integer.");
1214
}
1315

14-
const path = buildJitteredRowSnake(grid.columns, grid.rows, seed);
16+
const routeVariant = (seed >>> 0) % ROUTE_VARIANT_COUNT;
17+
const path = buildRouteVariant(grid.columns, grid.rows, routeVariant, seed);
1518

1619
validatePath(path, grid.columns, grid.rows);
1720
return path;
1821
}
1922

23+
function buildRouteVariant(columns: number, rows: number, variant: number, seed: number): Point[] {
24+
if (variant === 1) {
25+
return buildJitteredRowSnake(columns, rows, seed);
26+
}
27+
28+
if (variant === 2) {
29+
return buildSpiral(columns, rows);
30+
}
31+
32+
return buildRowSnake(columns, rows);
33+
}
34+
35+
function buildRowSnake(columns: number, rows: number): Point[] {
36+
const path: Point[] = [];
37+
38+
for (let y = 0; y < rows; y += 1) {
39+
if (y % 2 === 0) {
40+
for (let x = 0; x < columns; x += 1) {
41+
path.push({ x, y });
42+
}
43+
} else {
44+
for (let x = columns - 1; x >= 0; x -= 1) {
45+
path.push({ x, y });
46+
}
47+
}
48+
}
49+
50+
return path;
51+
}
52+
53+
function buildJitteredRowSnake(columns: number, rows: number, seed: number): Point[] {
54+
const basePath = buildRowSnake(columns, rows);
55+
56+
if (columns < 2 || rows < 2) {
57+
return basePath;
58+
}
59+
60+
const random = createSeededRandom(seed);
61+
const path: Point[] = [];
62+
let baseIndex = 0;
63+
let horizontalRun = 0;
64+
let nextDetourAfter = 5 + Math.floor(random() * 6);
65+
66+
const first = basePath[0];
67+
if (!first) {
68+
return path;
69+
}
70+
71+
path.push(first);
72+
73+
while (baseIndex < basePath.length - 1) {
74+
const current = basePath[baseIndex];
75+
const next = basePath[baseIndex + 1];
76+
77+
if (!current || !next) {
78+
break;
79+
}
80+
81+
const horizontal = current.y === next.y && current.x !== next.x;
82+
83+
if (horizontal) {
84+
horizontalRun += 1;
85+
86+
if (horizontalRun >= nextDetourAfter) {
87+
const direction = next.x > current.x ? 1 : -1;
88+
const maxSpan = direction > 0
89+
? columns - 1 - current.x
90+
: current.x;
91+
92+
const verticalCandidates = [-1, 1]
93+
.flatMap((verticalDirection) =>
94+
[2, 1]
95+
.filter((depth) => {
96+
const targetY = current.y + verticalDirection * depth;
97+
return targetY >= 0 && targetY < rows;
98+
})
99+
.map((depth) => ({ verticalDirection, depth }))
100+
);
101+
102+
const detour = verticalCandidates[Math.floor(random() * verticalCandidates.length)];
103+
104+
if (detour && maxSpan >= 3) {
105+
const span = Math.min(4 + Math.floor(random() * 5), maxSpan);
106+
const targetY = current.y + detour.verticalDirection * detour.depth;
107+
const targetX = current.x + direction * span;
108+
109+
for (let step = 1; step <= detour.depth; step += 1) {
110+
path.push({
111+
x: current.x,
112+
y: current.y + detour.verticalDirection * step
113+
});
114+
}
115+
116+
for (let step = 1; step <= span; step += 1) {
117+
path.push({
118+
x: current.x + direction * step,
119+
y: targetY
120+
});
121+
}
122+
123+
for (let step = detour.depth - 1; step >= 0; step -= 1) {
124+
path.push({
125+
x: targetX,
126+
y: current.y + detour.verticalDirection * step
127+
});
128+
}
129+
130+
baseIndex += span;
131+
horizontalRun = 0;
132+
nextDetourAfter = 5 + Math.floor(random() * 6);
133+
continue;
134+
}
135+
}
136+
} else {
137+
horizontalRun = 0;
138+
}
139+
140+
path.push(next);
141+
baseIndex += 1;
142+
}
143+
144+
const visited = new Set(path.map(pointKey));
145+
const expectedLength = columns * rows;
146+
let cleanupSteps = 0;
147+
const maxCleanupSteps = expectedLength * 2;
148+
149+
while (visited.size < expectedLength) {
150+
const current = path.at(-1);
151+
if (!current) {
152+
break;
153+
}
154+
155+
const adjacent = [
156+
{ x: current.x + 1, y: current.y },
157+
{ x: current.x - 1, y: current.y },
158+
{ x: current.x, y: current.y + 1 },
159+
{ x: current.x, y: current.y - 1 }
160+
].filter(
161+
(point) =>
162+
point.x >= 0 &&
163+
point.x < columns &&
164+
point.y >= 0 &&
165+
point.y < rows
166+
);
167+
168+
const unvisitedAdjacent = adjacent.filter(
169+
(point) => !visited.has(pointKey(point))
170+
);
171+
172+
let next: Point | undefined;
173+
174+
if (unvisitedAdjacent.length > 0) {
175+
const horizontal = unvisitedAdjacent.filter((point) => point.y === current.y);
176+
const candidates = horizontal.length > 0 && random() < 0.75
177+
? horizontal
178+
: unvisitedAdjacent;
179+
next = candidates[Math.floor(random() * candidates.length)];
180+
} else {
181+
const remaining: Point[] = [];
182+
183+
for (let y = 0; y < rows; y += 1) {
184+
for (let x = 0; x < columns; x += 1) {
185+
const candidate = { x, y };
186+
if (!visited.has(pointKey(candidate))) {
187+
remaining.push(candidate);
188+
}
189+
}
190+
}
191+
192+
const target = remaining
193+
.slice()
194+
.sort((left, right) => {
195+
const leftDistance =
196+
Math.abs(left.x - current.x) + Math.abs(left.y - current.y);
197+
const rightDistance =
198+
Math.abs(right.x - current.x) + Math.abs(right.y - current.y);
199+
return leftDistance - rightDistance;
200+
})[0];
201+
202+
if (!target) {
203+
break;
204+
}
205+
206+
const horizontalSteps = adjacent.filter(
207+
(point) =>
208+
point.y === current.y &&
209+
Math.abs(point.x - target.x) < Math.abs(current.x - target.x)
210+
);
211+
const verticalSteps = adjacent.filter(
212+
(point) =>
213+
point.x === current.x &&
214+
Math.abs(point.y - target.y) < Math.abs(current.y - target.y)
215+
);
216+
217+
next = horizontalSteps[0] ?? verticalSteps[0];
218+
}
219+
220+
if (!next) {
221+
break;
222+
}
223+
224+
path.push(next);
225+
visited.add(pointKey(next));
226+
cleanupSteps += 1;
227+
228+
if (cleanupSteps > maxCleanupSteps) {
229+
throw new Error("Snake path cleanup exceeded the maximum step budget.");
230+
}
231+
}
232+
233+
return path;
234+
}
235+
236+
function createSeededRandom(seed: number): () => number {
237+
let state = (seed >>> 0) + 0x6d2b79f5;
238+
239+
return () => {
240+
state = Math.imul(state ^ (state >>> 15), state | 1);
241+
state ^= state + Math.imul(state ^ (state >>> 7), state | 61);
242+
return ((state ^ (state >>> 14)) >>> 0) / 4294967296;
243+
};
244+
}
245+
20246
function buildSpiral(columns: number, rows: number): Point[] {
21247
const path: Point[] = [];
22248
let left = 0;

0 commit comments

Comments
 (0)