11import { latticeDirections , step } from './directions.js' ;
22import { normaliseText } from '../markov/textPipeline.js' ;
3+ /**
4+ * Count how many distinct already-placed words a candidate placement would
5+ * touch — either by overlapping a shared cell or by sitting orthogonally/
6+ * diagonally adjacent to one. Uses the per-cell ownership map.
7+ *
8+ * @param {Array<{x:number,y:number,ch:string}> } coords
9+ * @param {Map<string, Set<number>> } ownership cellKey -> set of word indices
10+ * @param {import('./Grid.js').Grid } grid
11+ * @returns {number }
12+ */
13+ function countAdjacentWords ( coords , ownership , grid ) {
14+ const touched = new Set ( ) ;
15+ const selfKeys = new Set ( coords . map ( ( c ) => `${ c . x } ,${ c . y } ` ) ) ;
16+ for ( const c of coords ) {
17+ // Check the cell itself (overlap) and its 8 neighbours (adjacency).
18+ for ( let dy = - 1 ; dy <= 1 ; dy ++ ) {
19+ for ( let dx = - 1 ; dx <= 1 ; dx ++ ) {
20+ const nx = c . x + dx ;
21+ const ny = c . y + dy ;
22+ if ( ! grid . inBounds ( nx , ny ) ) continue ;
23+ const key = `${ nx } ,${ ny } ` ;
24+ // Skip cells that are part of this same candidate placement.
25+ if ( selfKeys . has ( key ) ) continue ;
26+ const owners = ownership . get ( key ) ;
27+ if ( owners ) for ( const idx of owners ) touched . add ( idx ) ;
28+ }
29+ }
30+ }
31+ return touched . size ;
32+ }
33+ /**
34+ * Fisher–Yates shuffle (in place) using the supplied rng. Returns the array.
35+ * @template T
36+ * @param {T[] } arr
37+ * @param {() => number } rng
38+ * @returns {T[] }
39+ */
40+ function shuffle ( arr , rng ) {
41+ for ( let i = arr . length - 1 ; i > 0 ; i -- ) {
42+ const j = Math . floor ( rng ( ) * ( i + 1 ) ) ;
43+ const tmp = arr [ i ] ;
44+ arr [ i ] = arr [ j ] ;
45+ arr [ j ] = tmp ;
46+ }
47+ return arr ;
48+ }
49+
50+ /**
51+ * Walk a candidate placement of `word` starting at (x, y) heading in
52+ * direction `dirName`. Returns a descriptor of the run, or null if the
53+ * word can't fit / conflicts with an incompatible existing letter.
54+ *
55+ * The descriptor includes an `overlap` count: how many cells coincide
56+ * with an already-filled (matching) letter. Higher overlap means a more
57+ * interlocked, less predictable layout.
58+ *
59+ * @param {import('./Grid.js').Grid } grid
60+ * @param {string } word
61+ * @param {number } x
62+ * @param {number } y
63+ * @param {string } dirName
64+ * @param {'square'|'hex'|'triangular' } lattice
65+ * @returns {{coords:Array<{x:number,y:number,ch:string}>, overlap:number}|null }
66+ */
67+ function evaluatePlacement ( grid , word , x , y , dirName , lattice ) {
68+ const len = word . length ;
69+ const coords = [ ] ;
70+ let overlap = 0 ;
71+ let cx = x ;
72+ let cy = y ;
73+ for ( let i = 0 ; i < len ; i ++ ) {
74+ if ( ! grid . inBounds ( cx , cy ) ) return null ;
75+ const existing = grid . get ( cx , cy ) ;
76+ if ( existing ) {
77+ if ( existing !== word [ i ] ) return null ;
78+ overlap += 1 ;
79+ }
80+ coords . push ( { x : cx , y : cy , ch : word [ i ] } ) ;
81+ const next = step ( lattice , cx , cy , dirName , 1 ) ;
82+ if ( ! next ) return null ;
83+ cx = next . x ;
84+ cy = next . y ;
85+ }
86+ if ( coords . length !== len ) return null ;
87+ return { coords, overlap } ;
88+ }
389
490/**
5- * Attempt to place a single word along a random direction/position.
6- * Returns the placement record or null if no valid spot found within
7- * `tries`.
91+ * Attempt to place a single word. Rather than picking one random
92+ * direction per attempt (which biases the resulting pattern), we sample a
93+ * random starting position then evaluate ALL orientations there in a
94+ * shuffled order. Across the search we keep the best-scoring valid
95+ * placement, preferring ones that intersect existing words so the seed
96+ * words form an interlocked arrangement.
97+ *
98+ * @param {import('./Grid.js').Grid } grid
99+ * @param {string } word
100+ * @param {() => number } rng
8101 * @param {object } [opts]
9102 * @param {'square'|'hex'|'triangular' } [opts.lattice]
10103 * @param {boolean } [opts.includeBackwards]
104+ * @param {number } [opts.maxAdjacency]
105+ * @param {Map<string, Set<number>> } [opts.ownership]
106+ * @param {number } [opts.wordIndex]
11107 */
12108function tryPlaceWord ( grid , word , rng , opts = { } , tries = 200 ) {
13- const { lattice = 'square' , includeBackwards = true } = opts ;
109+ const {
110+ lattice = 'square' ,
111+ includeBackwards = true ,
112+ maxAdjacency = 1 ,
113+ ownership = new Map ( ) ,
114+ } = opts ;
115+ const seekIntersections = maxAdjacency > 0 ;
116+
117+ let best = null ; // { coords, overlap, dirName, x, y }
118+
14119 for ( let t = 0 ; t < tries ; t ++ ) {
15120 const x = Math . floor ( rng ( ) * grid . width ) ;
16121 const y = Math . floor ( rng ( ) * grid . height ) ;
17- const dirs = latticeDirections ( lattice , y , { includeBackwards } ) ;
18- const dir = dirs [ Math . floor ( rng ( ) * dirs . length ) ] ;
19- const len = word . length ;
20-
21- // Walk the word using row-aware stepping.
22- let ok = true ;
23- const coords = [ ] ;
24- let cx = x ;
25- let cy = y ;
26- for ( let i = 0 ; i < len ; i ++ ) {
27- if ( ! grid . inBounds ( cx , cy ) ) {
28- ok = false ;
29- break ;
30- }
31- const existing = grid . get ( cx , cy ) ;
32- if ( existing && existing !== word [ i ] ) {
33- ok = false ;
34- break ;
122+ // Direction vectors depend on the row for hex/tri lattices.
123+ const dirs = shuffle ( latticeDirections ( lattice , y , { includeBackwards } ) . slice ( ) , rng ) ;
124+ for ( const dir of dirs ) {
125+ const res = evaluatePlacement ( grid , word , x , y , dir . name , lattice ) ;
126+ if ( ! res ) continue ;
127+ // Reject placements that touch too many existing words.
128+ const adj = countAdjacentWords ( res . coords , ownership , grid ) ;
129+ if ( adj > maxAdjacency ) continue ;
130+ // When adjacency is disallowed entirely, never accept overlaps.
131+ if ( ! seekIntersections && res . overlap > 0 ) continue ;
132+ if ( best === null || res . overlap > best . overlap ) {
133+ best = { coords : res . coords , overlap : res . overlap , dirName : dir . name , x, y } ;
134+ // An intersecting placement is exactly what we want; commit early
135+ // so later (larger) words still have room and direction stays varied.
136+ if ( seekIntersections && res . overlap > 0 ) break ;
35137 }
36- coords . push ( { x : cx , y : cy , ch : word [ i ] } ) ;
37- const next = step ( lattice , cx , cy , dir . name , 1 ) ;
38- if ( ! next ) {
39- ok = false ;
40- break ;
41- }
42- cx = next . x ;
43- cy = next . y ;
44138 }
45- if ( ! ok || coords . length !== len ) continue ;
139+ // Found an interlocking spot — no need to keep searching.
140+ if ( seekIntersections && best && best . overlap > 0 ) break ;
141+ }
46142
47- for ( const c of coords ) {
48- grid . set ( c . x , c . y , c . ch ) ;
49- grid . lock ( c . x , c . y ) ;
50- }
51- return { word , dir : dir . name , x, y , coords } ;
143+ if ( ! best ) return null ;
144+
145+ for ( const c of best . coords ) {
146+ grid . set ( c . x , c . y , c . ch ) ;
147+ grid . lock ( c . x , c . y ) ;
52148 }
53- return null ;
149+ return { word , dir : best . dirName , x : best . x , y : best . y , coords : best . coords } ;
54150}
55151
56152/**
@@ -61,20 +157,38 @@ function tryPlaceWord(grid, word, rng, opts = {}, tries = 200) {
61157 * @param {object } [opts]
62158 * @param {'square'|'hex'|'triangular' } [opts.lattice]
63159 * @param {boolean } [opts.includeBackwards]
160+ * @param {number } [opts.maxAdjacency]
64161 */
65162export function placeWords ( grid , words , rng = Math . random , opts = { } ) {
66163 const placed = [ ] ;
67164 const failed = [ ] ;
68- // Place longer words first (harder to fit).
165+ // Track which placed-word indices occupy each cell so we can measure how
166+ // many distinct words a candidate placement would touch.
167+ /** @type {Map<string, Set<number>> } */
168+ const ownership = new Map ( ) ;
169+ // Place longer words first (harder to fit, and they form the backbone
170+ // that shorter words can interlock with).
69171 const clean = words
70172 . map ( ( w ) => normaliseText ( w ) )
71173 . filter ( Boolean )
72174 . sort ( ( a , b ) => b . length - a . length ) ;
73175
74176 for ( const w of clean ) {
75- const rec = tryPlaceWord ( grid , w , rng , opts ) ;
76- if ( rec ) placed . push ( rec ) ;
77- else failed . push ( w ) ;
177+ const wordIndex = placed . length ;
178+ const rec = tryPlaceWord ( grid , w , rng , { ...opts , ownership, wordIndex } ) ;
179+ if ( rec ) {
180+ placed . push ( rec ) ;
181+ // Record ownership of every cell this word occupies.
182+ for ( const c of rec . coords ) {
183+ const key = `${ c . x } ,${ c . y } ` ;
184+ let set = ownership . get ( key ) ;
185+ if ( ! set ) {
186+ set = new Set ( ) ;
187+ ownership . set ( key , set ) ;
188+ }
189+ set . add ( wordIndex ) ;
190+ }
191+ } else failed . push ( w ) ;
78192 }
79193 return { placed, failed } ;
80194}
0 commit comments