-
Notifications
You must be signed in to change notification settings - Fork 111
/
wordfind.js
executable file
·508 lines (450 loc) · 18.8 KB
/
wordfind.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
/**
* Wordfind.js 0.0.1
* (c) 2012 Bill, BunKat LLC.
* Wordfind is freely distributable under the MIT license.
* For all details and documentation:
* http://github.com/bunkat/wordfind
*/
(function () {
'use strict';
/**
* Generates a new word find (word search) puzzle provided a set of words.
* Can automatically determine the smallest puzzle size in which all words
* fit, or the puzzle size can be manually configured. Will automatically
* increase puzzle size until a valid puzzle is found.
*
* WordFind has no dependencies.
*/
/**
* Initializes the WordFind object.
*
* @api private
*/
var WordFind = function () {
// Letters used to fill blank spots in the puzzle
const LETTERS = 'abcdefghijklmnoprstuvwy';
/**
* Definitions for all the different orientations in which words can be
* placed within a puzzle. New orientation definitions can be added and they
* will be automatically available.
*/
// The list of all the possible orientations
var allOrientations = ['horizontal','horizontalBack','vertical','verticalUp',
'diagonal','diagonalUp','diagonalBack','diagonalUpBack'];
// The definition of the orientation, calculates the next square given a
// starting square (x,y) and distance (i) from that square.
var orientations = {
horizontal: function(x,y,i) { return {x: x+i, y: y }; },
horizontalBack: function(x,y,i) { return {x: x-i, y: y }; },
vertical: function(x,y,i) { return {x: x, y: y+i}; },
verticalUp: function(x,y,i) { return {x: x, y: y-i}; },
diagonal: function(x,y,i) { return {x: x+i, y: y+i}; },
diagonalBack: function(x,y,i) { return {x: x-i, y: y+i}; },
diagonalUp: function(x,y,i) { return {x: x+i, y: y-i}; },
diagonalUpBack: function(x,y,i) { return {x: x-i, y: y-i}; }
};
// Determines if an orientation is possible given the starting square (x,y),
// the height (h) and width (w) of the puzzle, and the length of the word (l).
// Returns true if the word will fit starting at the square provided using
// the specified orientation.
var checkOrientations = {
horizontal: function(x,y,h,w,l) { return w >= x + l; },
horizontalBack: function(x,y,h,w,l) { return x + 1 >= l; },
vertical: function(x,y,h,w,l) { return h >= y + l; },
verticalUp: function(x,y,h,w,l) { return y + 1 >= l; },
diagonal: function(x,y,h,w,l) { return (w >= x + l) && (h >= y + l); },
diagonalBack: function(x,y,h,w,l) { return (x + 1 >= l) && (h >= y + l); },
diagonalUp: function(x,y,h,w,l) { return (w >= x + l) && (y + 1 >= l); },
diagonalUpBack: function(x,y,h,w,l) { return (x + 1 >= l) && (y + 1 >= l); }
};
// Determines the next possible valid square given the square (x,y) was ]
// invalid and a word lenght of (l). This greatly reduces the number of
// squares that must be checked. Returning {x: x+1, y: y} will always work
// but will not be optimal.
var skipOrientations = {
horizontal: function(x,y,l) { return {x: 0, y: y+1 }; },
horizontalBack: function(x,y,l) { return {x: l-1, y: y }; },
vertical: function(x,y,l) { return {x: 0, y: y+100}; },
verticalUp: function(x,y,l) { return {x: 0, y: l-1 }; },
diagonal: function(x,y,l) { return {x: 0, y: y+1 }; },
diagonalBack: function(x,y,l) { return {x: l-1, y: x>=l-1?y+1:y }; },
diagonalUp: function(x,y,l) { return {x: 0, y: y<l-1?l-1:y+1 }; },
diagonalUpBack: function(x,y,l) { return {x: l-1, y: x>=l-1?y+1:y }; }
};
/**
* Initializes the puzzle and places words in the puzzle one at a time.
*
* Returns either a valid puzzle with all of the words or null if a valid
* puzzle was not found.
*
* @param {[String]} words: The list of words to fit into the puzzle
* @param {[Options]} options: The options to use when filling the puzzle
*/
var fillPuzzle = function (words, options) {
var puzzle = [], i, j, len;
// initialize the puzzle with blanks
for (i = 0; i < options.height; i++) {
puzzle.push([]);
for (j = 0; j < options.width; j++) {
puzzle[i].push('');
}
}
// add each word into the puzzle one at a time
for (i = 0, len = words.length; i < len; i++) {
if (!placeWordInPuzzle(puzzle, options, words[i])) {
// if a word didn't fit in the puzzle, give up
return null;
}
}
// return the puzzle
return puzzle;
};
/**
* Adds the specified word to the puzzle by finding all of the possible
* locations where the word will fit and then randomly selecting one. Options
* controls whether or not word overlap should be maximized.
*
* Returns true if the word was successfully placed, false otherwise.
*
* @param {[[String]]} puzzle: The current state of the puzzle
* @param {[Options]} options: The options to use when filling the puzzle
* @param {String} word: The word to fit into the puzzle.
*/
var placeWordInPuzzle = function (puzzle, options, word) {
// find all of the best locations where this word would fit
var locations = findBestLocations(puzzle, options, word);
if (locations.length === 0) {
return false;
}
// select a location at random and place the word there
var sel = locations[Math.floor(Math.random() * locations.length)];
placeWord(puzzle, word, sel.x, sel.y, orientations[sel.orientation]);
return true;
};
/**
* Iterates through the puzzle and determines all of the locations where
* the word will fit. Options determines if overlap should be maximized or
* not.
*
* Returns a list of location objects which contain an x,y cooridinate
* indicating the start of the word, the orientation of the word, and the
* number of letters that overlapped with existing letter.
*
* @param {[[String]]} puzzle: The current state of the puzzle
* @param {[Options]} options: The options to use when filling the puzzle
* @param {String} word: The word to fit into the puzzle.
*/
var findBestLocations = function (puzzle, options, word) {
var locations = [],
height = options.height,
width = options.width,
wordLength = word.length,
maxOverlap = 0; // we'll start looking at overlap = 0
// loop through all of the possible orientations at this position
for (var k = 0, len = options.orientations.length; k < len; k++) {
var orientation = options.orientations[k],
check = checkOrientations[orientation],
next = orientations[orientation],
skipTo = skipOrientations[orientation],
x = 0, y = 0;
// loop through every position on the board
while( y < height ) {
// see if this orientation is even possible at this location
if (check(x, y, height, width, wordLength)) {
// determine if the word fits at the current position
var overlap = calcOverlap(word, puzzle, x, y, next);
// if the overlap was bigger than previous overlaps that we've seen
if (overlap >= maxOverlap || (!options.preferOverlap && overlap > -1)) {
maxOverlap = overlap;
locations.push({x: x, y: y, orientation: orientation, overlap: overlap});
}
x++;
if (x >= width) {
x = 0;
y++;
}
} else {
// if current cell is invalid, then skip to the next cell where
// this orientation is possible. this greatly reduces the number
// of checks that we have to do overall
var nextPossible = skipTo(x,y,wordLength);
x = nextPossible.x;
y = nextPossible.y;
}
}
}
// finally prune down all of the possible locations we found by
// only using the ones with the maximum overlap that we calculated
return options.preferOverlap ?
pruneLocations(locations, maxOverlap) :
locations;
};
/**
* Determines whether or not a particular word fits in a particular
* orientation within the puzzle.
*
* Returns the number of letters overlapped with existing words if the word
* fits in the specified position, -1 if the word does not fit.
*
* @param {String} word: The word to fit into the puzzle.
* @param {[[String]]} puzzle: The current state of the puzzle
* @param {int} x: The x position to check
* @param {int} y: The y position to check
* @param {function} fnGetSquare: Function that returns the next square
*/
var calcOverlap = function (word, puzzle, x, y, fnGetSquare) {
var overlap = 0;
// traverse the squares to determine if the word fits
for (var i = 0, len = word.length; i < len; i++) {
var next = fnGetSquare(x, y, i),
square = puzzle[next.y][next.x];
// if the puzzle square already contains the letter we
// are looking for, then count it as an overlap square
if (square === word[i]) {
overlap++;
}
// if it contains a different letter, than our word doesn't fit
// here, return -1
else if (square !== '' ) {
return -1;
}
}
// if the entire word is overlapping, skip it to ensure words aren't
// hidden in other words
return overlap;
};
/**
* If overlap maximization was indicated, this function is used to prune the
* list of valid locations down to the ones that contain the maximum overlap
* that was previously calculated.
*
* Returns the pruned set of locations.
*
* @param {[Location]} locations: The set of locations to prune
* @param {int} overlap: The required level of overlap
*/
var pruneLocations = function (locations, overlap) {
var pruned = [];
for(var i = 0, len = locations.length; i < len; i++) {
if (locations[i].overlap >= overlap) {
pruned.push(locations[i]);
}
}
return pruned;
};
/**
* Places a word in the puzzle given a starting position and orientation.
*
* @param {[[String]]} puzzle: The current state of the puzzle
* @param {String} word: The word to fit into the puzzle.
* @param {int} x: The x position to check
* @param {int} y: The y position to check
* @param {function} fnGetSquare: Function that returns the next square
*/
var placeWord = function (puzzle, word, x, y, fnGetSquare) {
for (var i = 0, len = word.length; i < len; i++) {
var next = fnGetSquare(x, y, i);
puzzle[next.y][next.x] = word[i];
}
};
return {
/**
* Returns the list of all of the possible orientations.
* @api public
*/
validOrientations: allOrientations,
/**
* Returns the orientation functions for traversing words.
* @api public
*/
orientations: orientations,
/**
* Generates a new word find (word search) puzzle.
*
* Settings:
*
* height: desired height of the puzzle, default: smallest possible
* width: desired width of the puzzle, default: smallest possible
* orientations: list of orientations to use, default: all orientations
* fillBlanks: true to fill in the blanks, default: true
* maxAttempts: number of tries before increasing puzzle size, default:3
* maxGridGrowth: number of puzzle grid increases, default:10
* preferOverlap: maximize word overlap or not, default: true
*
* Returns the puzzle that was created.
*
* @param {[String]} words: List of words to include in the puzzle
* @param {options} settings: The options to use for this puzzle
* @api public
*/
newPuzzle: function(words, settings) {
if (!words.length) {
throw new Error('Zero words provided');
}
var wordList, puzzle, attempts = 0, gridGrowths = 0, opts = settings || {};
// copy and sort the words by length, inserting words into the puzzle
// from longest to shortest works out the best
wordList = words.slice(0).sort();
// initialize the options
var maxWordLength = wordList[0].length;
var options = {
height: opts.height || maxWordLength,
width: opts.width || maxWordLength,
orientations: opts.orientations || allOrientations,
fillBlanks: opts.fillBlanks !== undefined ? opts.fillBlanks : true,
allowExtraBlanks: opts.allowExtraBlanks !== undefined ? opts.allowExtraBlanks : true,
maxAttempts: opts.maxAttempts || 3,
maxGridGrowth: opts.maxGridGrowth !== undefined ? opts.maxGridGrowth : 10,
preferOverlap: opts.preferOverlap !== undefined ? opts.preferOverlap : true
};
// add the words to the puzzle
// since puzzles are random, attempt to create a valid one up to
// maxAttempts and then increase the puzzle size and try again
while (!puzzle) {
while (!puzzle && attempts++ < options.maxAttempts) {
puzzle = fillPuzzle(wordList, options);
}
if (!puzzle) {
gridGrowths++;
if (gridGrowths > options.maxGridGrowth) {
throw new Error(`No valid ${options.width}x${options.height} grid found and not allowed to grow more`);
}
console.log(`No valid ${options.width}x${options.height} grid found after ${attempts - 1} attempts, trying with bigger grid`);
options.height++;
options.width++;
attempts = 0;
}
}
// fill in empty spaces with random letters
if (options.fillBlanks) {
var lettersToAdd, fillingBlanksCount = 0, extraLetterGenerator;
if (typeof options.fillBlanks === 'function') {
extraLetterGenerator = options.fillBlanks;
} else if (typeof options.fillBlanks === 'string') {
lettersToAdd = options.fillBlanks.toLowerCase().split('');
extraLetterGenerator = () => lettersToAdd.pop() || (fillingBlanksCount++ && '');
} else {
extraLetterGenerator = () => LETTERS[Math.floor(Math.random() * LETTERS.length)];
}
var extraLettersCount = this.fillBlanks({puzzle, extraLetterGenerator: extraLetterGenerator});
if (lettersToAdd && lettersToAdd.length) {
throw new Error(`Some extra letters provided were not used: ${lettersToAdd}`);
}
if (lettersToAdd && fillingBlanksCount && !options.allowExtraBlanks) {
throw new Error(`${fillingBlanksCount} extra letters were missing to fill the grid`);
}
var gridFillPercent = 100 * (1 - extraLettersCount / (options.width * options.height));
console.log(`Blanks filled with ${extraLettersCount} random letters - Final grid is filled at ${gridFillPercent.toFixed(0)}%`);
}
return puzzle;
},
/**
* Wrapper around `newPuzzle` allowing to find a solution without some words.
*
* @param {options} settings: The options to use for this puzzle.
* Same as `newPuzzle` + allowedMissingWords
*/
newPuzzleLax: function(words, opts) {
try {
return this.newPuzzle(words, opts);
} catch (e) {
if (!opts.allowedMissingWords) {
throw e;
}
var opts = Object.assign({}, opts); // shallow copy
opts.allowedMissingWords--;
for (var i = 0; i < words.length; i++) {
var wordList = words.slice(0);
wordList.splice(i, 1);
try {
var puzzle = this.newPuzzleLax(wordList, opts);
console.log(`Solution found without word "${words[i]}"`);
return puzzle;
} catch (e) {} // continue if error
}
throw e;
}
},
/**
* Fills in any empty spaces in the puzzle with random letters.
*
* @param {[[String]]} puzzle: The current state of the puzzle
* @api public
*/
fillBlanks: function ({puzzle, extraLetterGenerator}) {
var extraLettersCount = 0;
for (var i = 0, height = puzzle.length; i < height; i++) {
var row = puzzle[i];
for (var j = 0, width = row.length; j < width; j++) {
if (!puzzle[i][j]) {
puzzle[i][j] = extraLetterGenerator();
extraLettersCount++;
}
}
}
return extraLettersCount;
},
/**
* Returns the starting location and orientation of the specified words
* within the puzzle. Any words that are not found are returned in the
* notFound array.
*
* Returns
* x position of start of word
* y position of start of word
* orientation of word
* word
* overlap (always equal to word.length)
*
* @param {[[String]]} puzzle: The current state of the puzzle
* @param {[String]} words: The list of words to find
* @api public
*/
solve: function (puzzle, words) {
var options = {
height: puzzle.length,
width: puzzle[0].length,
orientations: allOrientations,
preferOverlap: true
},
found = [],
notFound = [];
for(var i = 0, len = words.length; i < len; i++) {
var word = words[i],
locations = findBestLocations(puzzle, options, word);
if (locations.length > 0 && locations[0].overlap === word.length) {
locations[0].word = word;
found.push(locations[0]);
} else {
notFound.push(word);
}
}
return { found: found, notFound: notFound };
},
/**
* Outputs a puzzle to the console, useful for debugging.
* Returns a formatted string representing the puzzle.
*
* @param {[[String]]} puzzle: The current state of the puzzle
* @api public
*/
print: function (puzzle) {
var puzzleString = '';
for (var i = 0, height = puzzle.length; i < height; i++) {
var row = puzzle[i];
for (var j = 0, width = row.length; j < width; j++) {
puzzleString += (row[j] === '' ? ' ' : row[j]) + ' ';
}
puzzleString += '\n';
}
console.log(puzzleString);
return puzzleString;
}
};
};
/**
* Allow library to be used within both the browser and node.js
*/
var root = typeof exports !== "undefined" && exports !== null ? exports : window;
root.wordfind = WordFind();
}).call(this);