Last active
June 27, 2026 02:45
-
-
Save CrandellWS/5b11b732d7436e41e52908ec14989edc to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Canvas Word Search Maker</title> | |
| <style> | |
| :root { | |
| color-scheme: light dark; | |
| --bg: #111827; | |
| --panel: #1f2937; | |
| --panel-2: #111827; | |
| --text: #f9fafb; | |
| --muted: #cbd5e1; | |
| --accent: #8b5cf6; | |
| --accent-hover: #7c3aed; | |
| --danger: #ef4444; | |
| --grid: #475569; | |
| --canvas-bg: #ffffff; | |
| --canvas-text: #111827; | |
| } | |
| * { | |
| box-sizing: border-box; | |
| } | |
| body { | |
| margin: 0; | |
| min-height: 100vh; | |
| font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; | |
| background: | |
| radial-gradient(circle at top left, rgba(139, 92, 246, 0.22), transparent 35%), | |
| var(--bg); | |
| color: var(--text); | |
| } | |
| .app { | |
| display: grid; | |
| grid-template-columns: minmax(280px, 360px) minmax(0, 1fr); | |
| gap: 22px; | |
| width: min(1400px, 100%); | |
| margin: 0 auto; | |
| padding: 22px; | |
| } | |
| .panel { | |
| background: rgba(31, 41, 55, 0.96); | |
| border: 1px solid rgba(255, 255, 255, 0.08); | |
| border-radius: 18px; | |
| padding: 20px; | |
| box-shadow: 0 18px 45px rgba(0, 0, 0, 0.28); | |
| } | |
| h1 { | |
| margin: 0 0 8px; | |
| font-size: clamp(1.55rem, 3vw, 2.2rem); | |
| } | |
| .subtitle { | |
| margin: 0 0 20px; | |
| color: var(--muted); | |
| line-height: 1.45; | |
| } | |
| label, | |
| legend { | |
| font-weight: 700; | |
| } | |
| textarea, | |
| select, | |
| input[type="number"], | |
| input[type="text"] { | |
| width: 100%; | |
| border: 1px solid #475569; | |
| border-radius: 10px; | |
| background: #0f172a; | |
| color: var(--text); | |
| padding: 11px 12px; | |
| font: inherit; | |
| } | |
| textarea { | |
| min-height: 180px; | |
| resize: vertical; | |
| line-height: 1.45; | |
| } | |
| .field { | |
| margin-bottom: 16px; | |
| } | |
| .field small { | |
| display: block; | |
| color: var(--muted); | |
| margin-top: 6px; | |
| line-height: 1.4; | |
| } | |
| .entry-heading { | |
| display: flex; | |
| align-items: flex-start; | |
| justify-content: space-between; | |
| gap: 12px; | |
| margin-bottom: 12px; | |
| } | |
| .entry-heading small { | |
| margin-top: 4px; | |
| } | |
| .small-button { | |
| flex: 0 0 auto; | |
| padding: 9px 12px; | |
| background: #334155; | |
| color: white; | |
| font-size: 0.9rem; | |
| } | |
| .small-button:hover { | |
| background: #475569; | |
| } | |
| .entry-labels, | |
| .entry-row { | |
| display: grid; | |
| grid-template-columns: repeat(3, minmax(0, 1fr)) 42px; | |
| gap: 8px; | |
| align-items: stretch; | |
| } | |
| .entry-labels { | |
| padding: 0 3px 6px; | |
| color: var(--muted); | |
| font-size: 0.78rem; | |
| font-weight: 800; | |
| text-transform: uppercase; | |
| letter-spacing: 0.06em; | |
| } | |
| .entry-rows { | |
| display: grid; | |
| gap: 10px; | |
| } | |
| .entry-row { | |
| padding: 9px; | |
| border: 1px solid #334155; | |
| border-radius: 12px; | |
| background: rgba(15, 23, 42, 0.72); | |
| } | |
| .entry-part { | |
| position: relative; | |
| min-width: 0; | |
| display: grid; | |
| grid-template-rows: auto auto; | |
| gap: 6px; | |
| padding: 7px; | |
| border: 2px solid transparent; | |
| border-radius: 10px; | |
| background: rgba(30, 41, 59, 0.74); | |
| transition: border-color 120ms ease, background 120ms ease; | |
| } | |
| .entry-part.is-shown { | |
| border-color: #8b5cf6; | |
| background: rgba(91, 33, 182, 0.22); | |
| } | |
| .entry-part input[type="text"] { | |
| min-width: 0; | |
| } | |
| .show-choice { | |
| display: flex; | |
| align-items: center; | |
| gap: 7px; | |
| min-height: 23px; | |
| color: var(--muted); | |
| font-size: 0.78rem; | |
| font-weight: 800; | |
| cursor: pointer; | |
| user-select: none; | |
| } | |
| .show-choice input { | |
| accent-color: #8b5cf6; | |
| margin: 0; | |
| } | |
| .entry-part.is-shown .show-choice { | |
| color: #ddd6fe; | |
| } | |
| .shown-badge { | |
| display: none; | |
| margin-left: auto; | |
| padding: 2px 6px; | |
| border-radius: 999px; | |
| background: #8b5cf6; | |
| color: white; | |
| font-size: 0.65rem; | |
| letter-spacing: 0.05em; | |
| text-transform: uppercase; | |
| } | |
| .entry-part.is-shown .shown-badge { | |
| display: inline-block; | |
| } | |
| .remove-entry-button { | |
| width: 42px; | |
| min-height: 42px; | |
| padding: 0; | |
| display: grid; | |
| place-items: center; | |
| background: #7f1d1d; | |
| color: white; | |
| font-size: 1.15rem; | |
| line-height: 1; | |
| } | |
| .remove-entry-button:hover { | |
| background: #991b1b; | |
| } | |
| .entry-row:only-child .remove-entry-button { | |
| opacity: 0.38; | |
| cursor: not-allowed; | |
| } | |
| @media (max-width: 760px) { | |
| .entry-heading { | |
| align-items: stretch; | |
| flex-direction: column; | |
| } | |
| .entry-labels { | |
| display: none; | |
| } | |
| .entry-row { | |
| grid-template-columns: 1fr 42px; | |
| } | |
| .entry-part { | |
| grid-column: 1; | |
| } | |
| .entry-row .remove-entry-button { | |
| grid-column: 2; | |
| grid-row: 1 / span 3; | |
| align-self: stretch; | |
| width: 42px; | |
| height: auto; | |
| } | |
| } | |
| .entry-labels { | |
| display: none; | |
| } | |
| .entry-row { | |
| grid-template-columns: 1fr 42px; | |
| } | |
| .entry-row input { | |
| grid-column: 1; | |
| } | |
| .entry-row .remove-entry-button { | |
| grid-column: 2; | |
| grid-row: 1 / span 3; | |
| align-self: stretch; | |
| height: auto; | |
| } | |
| .entry-row input::placeholder { | |
| opacity: 1; | |
| } | |
| } | |
| .row { | |
| display: grid; | |
| grid-template-columns: 1fr 1fr; | |
| gap: 12px; | |
| } | |
| .check-list { | |
| display: grid; | |
| gap: 10px; | |
| margin: 12px 0 18px; | |
| } | |
| .check { | |
| display: flex; | |
| gap: 10px; | |
| align-items: flex-start; | |
| font-weight: 500; | |
| color: var(--muted); | |
| } | |
| .check input { | |
| margin-top: 3px; | |
| accent-color: var(--accent); | |
| } | |
| .buttons { | |
| display: grid; | |
| grid-template-columns: 1fr 1fr; | |
| gap: 10px; | |
| } | |
| button { | |
| appearance: none; | |
| border: 0; | |
| border-radius: 11px; | |
| padding: 12px 14px; | |
| font: inherit; | |
| font-weight: 800; | |
| cursor: pointer; | |
| transition: transform 120ms ease, background 120ms ease, opacity 120ms ease; | |
| } | |
| button:hover { | |
| transform: translateY(-1px); | |
| } | |
| button:active { | |
| transform: translateY(0); | |
| } | |
| .primary { | |
| background: var(--accent); | |
| color: white; | |
| } | |
| .primary:hover { | |
| background: var(--accent-hover); | |
| } | |
| .secondary { | |
| background: #334155; | |
| color: white; | |
| } | |
| .secondary:hover { | |
| background: #475569; | |
| } | |
| .canvas-panel { | |
| display: flex; | |
| flex-direction: column; | |
| min-width: 0; | |
| } | |
| .toolbar { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 10px; | |
| justify-content: space-between; | |
| align-items: center; | |
| margin-bottom: 14px; | |
| } | |
| .status { | |
| color: var(--muted); | |
| font-size: 0.95rem; | |
| } | |
| .canvas-wrap { | |
| display: grid; | |
| place-items: center; | |
| min-height: 520px; | |
| padding: 16px; | |
| overflow: auto; | |
| background: | |
| linear-gradient(45deg, rgba(255, 255, 255, 0.025) 25%, transparent 25%), | |
| linear-gradient(-45deg, rgba(255, 255, 255, 0.025) 25%, transparent 25%), | |
| linear-gradient(45deg, transparent 75%, rgba(255, 255, 255, 0.025) 75%), | |
| linear-gradient(-45deg, transparent 75%, rgba(255, 255, 255, 0.025) 75%); | |
| background-size: 24px 24px; | |
| background-position: 0 0, 0 12px, 12px -12px, -12px 0; | |
| border-radius: 14px; | |
| } | |
| canvas { | |
| display: block; | |
| max-width: 100%; | |
| height: auto; | |
| background: var(--canvas-bg); | |
| border-radius: 8px; | |
| box-shadow: 0 18px 50px rgba(0, 0, 0, 0.35); | |
| touch-action: manipulation; | |
| } | |
| .word-list { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 8px 14px; | |
| margin-top: 16px; | |
| color: var(--muted); | |
| } | |
| .word-pill { | |
| background: #0f172a; | |
| border: 1px solid #334155; | |
| border-radius: 999px; | |
| padding: 6px 10px; | |
| font-weight: 700; | |
| letter-spacing: 0.04em; | |
| } | |
| .word-pill.scrambled::after { | |
| content: attr(data-original); | |
| display: none; | |
| } | |
| .answer-legend { | |
| display: none; | |
| flex-wrap: wrap; | |
| gap: 12px 18px; | |
| margin-top: 14px; | |
| color: var(--muted); | |
| font-size: 0.92rem; | |
| } | |
| .answer-legend.visible { | |
| display: flex; | |
| } | |
| .legend-item { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 7px; | |
| } | |
| .legend-swatch { | |
| width: 28px; | |
| height: 10px; | |
| border-radius: 999px; | |
| border: 1px solid rgba(255, 255, 255, 0.28); | |
| } | |
| .legend-swatch.code { | |
| background: rgba(245, 158, 11, 0.78); | |
| } | |
| .legend-swatch.word { | |
| background: rgba(239, 68, 68, 0.78); | |
| } | |
| .code-key { | |
| display: grid; | |
| gap: 9px; | |
| margin-top: 16px; | |
| } | |
| .code-key[hidden] { | |
| display: none; | |
| } | |
| .code-key-title { | |
| margin: 0 0 2px; | |
| font-size: 1rem; | |
| } | |
| .code-row { | |
| display: grid; | |
| grid-template-columns: minmax(80px, 0.7fr) auto minmax(0, 1.5fr); | |
| align-items: center; | |
| gap: 10px; | |
| padding: 9px 11px; | |
| border: 1px solid #334155; | |
| border-radius: 10px; | |
| background: #0f172a; | |
| } | |
| .code-clue { | |
| font-weight: 800; | |
| letter-spacing: 0.04em; | |
| } | |
| .code-arrow { | |
| color: var(--muted); | |
| } | |
| .code-value { | |
| display: flex; | |
| flex-wrap: wrap; | |
| align-items: center; | |
| font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; | |
| font-weight: 900; | |
| letter-spacing: 0.08em; | |
| overflow-wrap: anywhere; | |
| } | |
| .code-part { | |
| color: #fbbf24; | |
| } | |
| .target-part { | |
| color: #f87171; | |
| text-decoration: underline; | |
| text-decoration-thickness: 2px; | |
| text-underline-offset: 3px; | |
| } | |
| @media (max-width: 520px) { | |
| .code-row { | |
| grid-template-columns: 1fr; | |
| gap: 5px; | |
| } | |
| .code-arrow { | |
| display: none; | |
| } | |
| } | |
| .message { | |
| min-height: 24px; | |
| margin-top: 12px; | |
| color: #fca5a5; | |
| font-weight: 700; | |
| } | |
| @media (max-width: 900px) { | |
| .app { | |
| grid-template-columns: 1fr; | |
| padding: 12px; | |
| } | |
| .canvas-wrap { | |
| min-height: 380px; | |
| } | |
| } | |
| @media print { | |
| body { | |
| background: white; | |
| color: black; | |
| } | |
| .controls, | |
| .toolbar, | |
| .message { | |
| display: none !important; | |
| } | |
| .app { | |
| display: block; | |
| padding: 0; | |
| } | |
| .panel { | |
| box-shadow: none; | |
| border: 0; | |
| background: white; | |
| padding: 0; | |
| } | |
| .canvas-wrap { | |
| background: white; | |
| padding: 0; | |
| min-height: 0; | |
| } | |
| .word-list { | |
| color: black; | |
| } | |
| .word-pill { | |
| border-color: #999; | |
| background: white; | |
| color: black; | |
| } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <main class="app"> | |
| <section class="panel controls"> | |
| <h1>Word Search Maker</h1> | |
| <p class="subtitle"> | |
| Build a word search that secretly carries complete drop codes. Players see only | |
| the required words; optional prefix and suffix characters stay hidden in the grid. | |
| </p> | |
| <div class="field"> | |
| <div class="entry-heading"> | |
| <div> | |
| <label>Code entries</label> | |
| <small> | |
| Enter up to three parts in order, then choose which one players | |
| will see in the public word list. The other parts remain hidden. | |
| </small> | |
| </div> | |
| <button id="addEntryButton" class="small-button" type="button"> | |
| + Add Row | |
| </button> | |
| </div> | |
| <div class="entry-labels" aria-hidden="true"> | |
| <span>Part 1</span> | |
| <span>Part 2</span> | |
| <span>Part 3</span> | |
| <span></span> | |
| </div> | |
| <div id="entryRows" class="entry-rows"></div> | |
| </div> | |
| <div class="row"> | |
| <div class="field"> | |
| <label for="gridSize">Grid size</label> | |
| <input id="gridSize" type="number" min="8" max="30" value="15"> | |
| </div> | |
| <div class="field"> | |
| <label for="seedInput">Seed</label> | |
| <input id="seedInput" type="text" value=""> | |
| </div> | |
| </div> | |
| <div class="check-list"> | |
| <label class="check"> | |
| <input id="allowReverse" type="checkbox" checked> | |
| <span>Allow words to run backwards</span> | |
| </label> | |
| <label class="check"> | |
| <input id="allowDiagonal" type="checkbox" checked> | |
| <span>Allow diagonal placement</span> | |
| </label> | |
| <label class="check"> | |
| <input id="scrambleWords" type="checkbox"> | |
| <span>Scramble the selected public word before placing it (hard mode)</span> | |
| </label> | |
| <label class="check"> | |
| <input id="scrambleClues" type="checkbox"> | |
| <span>Scramble the word list shown below the puzzle</span> | |
| </label> | |
| </div> | |
| <div class="buttons"> | |
| <button id="generateButton" class="primary">Generate</button> | |
| <button id="revealButton" class="secondary">Reveal Answers & Codes</button> | |
| <button id="downloadButton" class="secondary">Download PNG</button> | |
| <button id="printButton" class="secondary">Print</button> | |
| </div> | |
| <div id="message" class="message" role="status"></div> | |
| </section> | |
| <section class="panel canvas-panel"> | |
| <div class="toolbar"> | |
| <strong>Canvas Preview</strong> | |
| <span id="status" class="status">Ready</span> | |
| </div> | |
| <div class="canvas-wrap"> | |
| <canvas id="wordCanvas" width="900" height="900"></canvas> | |
| </div> | |
| <div id="wordList" class="word-list" aria-label="Words to find"></div> | |
| <div id="answerLegend" class="answer-legend" aria-label="Answer colors"> | |
| <span class="legend-item"> | |
| <span class="legend-swatch code"></span> | |
| Complete hidden code | |
| </span> | |
| <span class="legend-item"> | |
| <span class="legend-swatch word"></span> | |
| Listed search word | |
| </span> | |
| </div> | |
| <section id="codeKey" class="code-key" hidden aria-label="Revealed code key"> | |
| <h2 class="code-key-title">Revealed code key</h2> | |
| </section> | |
| </section> | |
| </main> | |
| <script> | |
| "use strict"; | |
| const canvas = document.getElementById("wordCanvas"); | |
| const ctx = canvas.getContext("2d"); | |
| const entryRowsElement = document.getElementById("entryRows"); | |
| const addEntryButton = document.getElementById("addEntryButton"); | |
| const gridSizeInput = document.getElementById("gridSize"); | |
| const seedInput = document.getElementById("seedInput"); | |
| const allowReverseInput = document.getElementById("allowReverse"); | |
| const allowDiagonalInput = document.getElementById("allowDiagonal"); | |
| const scrambleWordsInput = document.getElementById("scrambleWords"); | |
| const scrambleCluesInput = document.getElementById("scrambleClues"); | |
| const generateButton = document.getElementById("generateButton"); | |
| const revealButton = document.getElementById("revealButton"); | |
| const downloadButton = document.getElementById("downloadButton"); | |
| const printButton = document.getElementById("printButton"); | |
| const wordListElement = document.getElementById("wordList"); | |
| const answerLegendElement = document.getElementById("answerLegend"); | |
| const codeKeyElement = document.getElementById("codeKey"); | |
| const messageElement = document.getElementById("message"); | |
| const statusElement = document.getElementById("status"); | |
| const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; | |
| const alphaNumeric = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; | |
| let puzzle = { | |
| grid: [], | |
| placedWords: [], | |
| size: 15, | |
| revealed: false | |
| }; | |
| let random = Math.random; | |
| /** | |
| * Produces a repeatable pseudo-random number generator from a string seed. | |
| */ | |
| function createSeededRandom(seedText) { | |
| let hash = 2166136261; | |
| for (let index = 0; index < seedText.length; index++) { | |
| hash ^= seedText.charCodeAt(index); | |
| hash = Math.imul(hash, 16777619); | |
| } | |
| return function seededRandom() { | |
| hash += 0x6D2B79F5; | |
| let value = hash; | |
| value = Math.imul(value ^ (value >>> 15), value | 1); | |
| value ^= value + Math.imul(value ^ (value >>> 7), value | 61); | |
| return ((value ^ (value >>> 14)) >>> 0) / 4294967296; | |
| }; | |
| } | |
| function randomInteger(maxExclusive) { | |
| return Math.floor(random() * maxExclusive); | |
| } | |
| function shuffle(values) { | |
| const copy = [...values]; | |
| for (let index = copy.length - 1; index > 0; index--) { | |
| const swapIndex = randomInteger(index + 1); | |
| [copy[index], copy[swapIndex]] = [copy[swapIndex], copy[index]]; | |
| } | |
| return copy; | |
| } | |
| function scrambleWord(word) { | |
| if (word.length < 2) { | |
| return word; | |
| } | |
| let scrambled = word; | |
| let attempts = 0; | |
| while (scrambled === word && attempts < 10) { | |
| scrambled = shuffle([...word]).join(""); | |
| attempts++; | |
| } | |
| return scrambled; | |
| } | |
| function normalizeToken(value) { | |
| return value | |
| .trim() | |
| .toUpperCase() | |
| .replace(/[^A-Z0-9]/g, ""); | |
| } | |
| function createPartEditor(rowId, partNumber, value, shownPart) { | |
| const wrapper = document.createElement("div"); | |
| wrapper.className = "entry-part"; | |
| const textInput = document.createElement("input"); | |
| textInput.type = "text"; | |
| textInput.className = `part-input part-${partNumber}-input`; | |
| textInput.placeholder = `Part ${partNumber}`; | |
| textInput.value = value || ""; | |
| textInput.maxLength = 40; | |
| textInput.autocomplete = "off"; | |
| textInput.spellcheck = false; | |
| textInput.setAttribute("aria-label", `Code part ${partNumber}`); | |
| const choiceLabel = document.createElement("label"); | |
| choiceLabel.className = "show-choice"; | |
| const radio = document.createElement("input"); | |
| radio.type = "radio"; | |
| radio.name = `shown-part-${rowId}`; | |
| radio.value = String(partNumber); | |
| radio.checked = partNumber === shownPart; | |
| radio.setAttribute( | |
| "aria-label", | |
| `Show part ${partNumber} in the public word list` | |
| ); | |
| const choiceText = document.createElement("span"); | |
| choiceText.textContent = "Show this word"; | |
| const badge = document.createElement("span"); | |
| badge.className = "shown-badge"; | |
| badge.textContent = "Shown"; | |
| choiceLabel.append(radio, choiceText, badge); | |
| wrapper.append(textInput, choiceLabel); | |
| function updateShownState() { | |
| const row = wrapper.closest(".entry-row"); | |
| if (!row) { | |
| return; | |
| } | |
| row.querySelectorAll(".entry-part").forEach(part => { | |
| const partRadio = part.querySelector('input[type="radio"]'); | |
| part.classList.toggle("is-shown", partRadio.checked); | |
| }); | |
| } | |
| radio.addEventListener("change", updateShownState); | |
| // Clicking inside a filled part makes it easy to select that part as public. | |
| textInput.addEventListener("dblclick", () => { | |
| radio.checked = true; | |
| updateShownState(); | |
| }); | |
| return wrapper; | |
| } | |
| let entryRowCounter = 0; | |
| function createEntryRow(entry = {}) { | |
| const row = document.createElement("div"); | |
| row.className = "entry-row"; | |
| entryRowCounter++; | |
| const rowId = entryRowCounter; | |
| const shownPart = Number(entry.shownPart || 2); | |
| const parts = entry.parts || [ | |
| entry.prefix || "", | |
| entry.word || "", | |
| entry.suffix || "" | |
| ]; | |
| const partOne = createPartEditor(rowId, 1, parts[0], shownPart); | |
| const partTwo = createPartEditor(rowId, 2, parts[1], shownPart); | |
| const partThree = createPartEditor(rowId, 3, parts[2], shownPart); | |
| const removeButton = document.createElement("button"); | |
| removeButton.type = "button"; | |
| removeButton.className = "remove-entry-button"; | |
| removeButton.textContent = "×"; | |
| removeButton.title = "Remove this row"; | |
| removeButton.setAttribute("aria-label", "Remove code entry"); | |
| removeButton.addEventListener("click", () => { | |
| const rows = entryRowsElement.querySelectorAll(".entry-row"); | |
| if (rows.length <= 1) { | |
| row.querySelectorAll('.part-input').forEach(input => { | |
| input.value = ""; | |
| }); | |
| const defaultRadio = row.querySelector( | |
| 'input[type="radio"][value="2"]' | |
| ); | |
| defaultRadio.checked = true; | |
| row.querySelectorAll(".entry-part").forEach(part => { | |
| const radio = part.querySelector('input[type="radio"]'); | |
| part.classList.toggle("is-shown", radio.checked); | |
| }); | |
| row.querySelector(".part-2-input").focus(); | |
| return; | |
| } | |
| row.remove(); | |
| updateRemoveButtons(); | |
| }); | |
| row.append(partOne, partTwo, partThree, removeButton); | |
| entryRowsElement.appendChild(row); | |
| row.querySelectorAll(".entry-part").forEach(part => { | |
| const radio = part.querySelector('input[type="radio"]'); | |
| part.classList.toggle("is-shown", radio.checked); | |
| }); | |
| updateRemoveButtons(); | |
| return row; | |
| } | |
| function updateRemoveButtons() { | |
| const rows = [...entryRowsElement.querySelectorAll(".entry-row")]; | |
| const onlyOneRow = rows.length <= 1; | |
| for (const row of rows) { | |
| const button = row.querySelector(".remove-entry-button"); | |
| button.disabled = onlyOneRow; | |
| button.title = onlyOneRow | |
| ? "At least one row is required" | |
| : "Remove this row"; | |
| } | |
| } | |
| function collectCodeEntries() { | |
| const rows = [...entryRowsElement.querySelectorAll(".entry-row")]; | |
| const entries = []; | |
| const invalidRows = []; | |
| const seen = new Set(); | |
| rows.forEach((row, index) => { | |
| const parts = [1, 2, 3].map(partNumber => { | |
| return normalizeToken( | |
| row.querySelector(`.part-${partNumber}-input`).value | |
| ); | |
| }); | |
| const selectedRadio = row.querySelector( | |
| 'input[type="radio"]:checked' | |
| ); | |
| const shownPart = selectedRadio | |
| ? Number(selectedRadio.value) | |
| : 2; | |
| const completelyBlank = parts.every(part => !part); | |
| if (completelyBlank) { | |
| return; | |
| } | |
| const shownWord = parts[shownPart - 1]; | |
| if (!shownWord) { | |
| invalidRows.push({ | |
| rowNumber: index + 1, | |
| shownPart | |
| }); | |
| return; | |
| } | |
| const fullText = parts.join(""); | |
| const signature = `${parts.join("|")}|shown:${shownPart}`; | |
| if (seen.has(signature)) { | |
| return; | |
| } | |
| seen.add(signature); | |
| entries.push({ | |
| parts, | |
| shownPart, | |
| shownWord, | |
| fullText, | |
| beforeText: parts.slice(0, shownPart - 1).join(""), | |
| afterText: parts.slice(shownPart).join("") | |
| }); | |
| }); | |
| return { | |
| entries, | |
| invalidRows | |
| }; | |
| } | |
| function createEmptyGrid(size) { | |
| return Array.from( | |
| { length: size }, | |
| () => Array.from({ length: size }, () => "") | |
| ); | |
| } | |
| function getDirections() { | |
| const directions = [ | |
| { key: "R", x: 1, y: 0, diagonal: false }, | |
| { key: "D", x: 0, y: 1, diagonal: false } | |
| ]; | |
| if (allowDiagonalInput.checked) { | |
| directions.push( | |
| { key: "DR", x: 1, y: 1, diagonal: true }, | |
| { key: "DL", x: -1, y: 1, diagonal: true } | |
| ); | |
| } | |
| if (allowReverseInput.checked) { | |
| directions.push( | |
| { key: "L", x: -1, y: 0, diagonal: false }, | |
| { key: "U", x: 0, y: -1, diagonal: false } | |
| ); | |
| if (allowDiagonalInput.checked) { | |
| directions.push( | |
| { key: "UL", x: -1, y: -1, diagonal: true }, | |
| { key: "UR", x: 1, y: -1, diagonal: true } | |
| ); | |
| } | |
| } | |
| return directions; | |
| } | |
| function canPlaceWord(grid, word, startX, startY, direction, size) { | |
| for (let index = 0; index < word.length; index++) { | |
| const x = startX + direction.x * index; | |
| const y = startY + direction.y * index; | |
| if (x < 0 || x >= size || y < 0 || y >= size) { | |
| return false; | |
| } | |
| const currentLetter = grid[y][x]; | |
| if (currentLetter !== "" && currentLetter !== word[index]) { | |
| return false; | |
| } | |
| } | |
| return true; | |
| } | |
| function getLineKey(startX, startY, direction) { | |
| if (direction.y === 0) { | |
| return `H:${startY}`; | |
| } | |
| if (direction.x === 0) { | |
| return `V:${startX}`; | |
| } | |
| if (direction.x === direction.y) { | |
| return `DS:${startX - startY}`; | |
| } | |
| return `DA:${startX + startY}`; | |
| } | |
| function countNearbyLetters(grid, positions, size) { | |
| const candidateCells = new Set( | |
| positions.map(position => `${position.x},${position.y}`) | |
| ); | |
| let nearby = 0; | |
| for (const position of positions) { | |
| if (grid[position.y][position.x] !== "") { | |
| continue; | |
| } | |
| for (let offsetY = -1; offsetY <= 1; offsetY++) { | |
| for (let offsetX = -1; offsetX <= 1; offsetX++) { | |
| if (offsetX === 0 && offsetY === 0) { | |
| continue; | |
| } | |
| const x = position.x + offsetX; | |
| const y = position.y + offsetY; | |
| if (x < 0 || x >= size || y < 0 || y >= size) { | |
| continue; | |
| } | |
| if (candidateCells.has(`${x},${y}`)) { | |
| continue; | |
| } | |
| if (grid[y][x] !== "") { | |
| nearby++; | |
| } | |
| } | |
| } | |
| } | |
| return nearby; | |
| } | |
| function collectPlacementCandidates( | |
| grid, | |
| word, | |
| size, | |
| directions, | |
| placementState, | |
| targetDiagonalCount | |
| ) { | |
| const candidates = []; | |
| const directionCounts = placementState.directionCounts; | |
| const lineCounts = placementState.lineCounts; | |
| const currentDiagonalCount = placementState.diagonalCount; | |
| const maximumDirectionCount = Math.max( | |
| 0, | |
| ...directions.map(direction => directionCounts.get(direction.key) || 0) | |
| ); | |
| for (const direction of directions) { | |
| for (let startY = 0; startY < size; startY++) { | |
| for (let startX = 0; startX < size; startX++) { | |
| if (!canPlaceWord(grid, word, startX, startY, direction, size)) { | |
| continue; | |
| } | |
| const positions = []; | |
| let overlapCount = 0; | |
| for (let index = 0; index < word.length; index++) { | |
| const x = startX + direction.x * index; | |
| const y = startY + direction.y * index; | |
| positions.push({ x, y }); | |
| if (grid[y][x] === word[index]) { | |
| overlapCount++; | |
| } | |
| } | |
| const lineKey = getLineKey(startX, startY, direction); | |
| const lineUseCount = lineCounts.get(lineKey) || 0; | |
| const directionUseCount = directionCounts.get(direction.key) || 0; | |
| const nearbyLetters = countNearbyLetters(grid, positions, size); | |
| const first = positions[0]; | |
| const last = positions[positions.length - 1]; | |
| const centerX = (first.x + last.x) / 2; | |
| const centerY = (first.y + last.y) / 2; | |
| const gridCenter = (size - 1) / 2; | |
| const centerDistance = Math.hypot( | |
| centerX - gridCenter, | |
| centerY - gridCenter | |
| ); | |
| let score = 0; | |
| // Crossings make the puzzle less visually predictable. | |
| score += overlapCount * 28; | |
| // Strongly avoid repeating the same row, column, or diagonal. | |
| score -= lineUseCount * 38; | |
| // Avoid obvious parallel stacking beside existing words. | |
| score -= nearbyLetters * 2.8; | |
| // Favor directions that have been used less often. | |
| score += (maximumDirectionCount - directionUseCount) * 13; | |
| score += 18 / (directionUseCount + 1); | |
| // Ensure that diagonals actually appear when enabled. | |
| if ( | |
| direction.diagonal && | |
| currentDiagonalCount < targetDiagonalCount | |
| ) { | |
| score += 55; | |
| } else if (direction.diagonal) { | |
| score += 10; | |
| } | |
| // Use more of the board instead of clustering in one strip. | |
| score += Math.min(centerDistance, size / 2) * 1.4; | |
| // Add controlled randomness so identical scores do not look patterned. | |
| score += random() * 18; | |
| candidates.push({ | |
| direction, | |
| positions, | |
| lineKey, | |
| overlapCount, | |
| score | |
| }); | |
| } | |
| } | |
| } | |
| return candidates; | |
| } | |
| function commitPlacement( | |
| grid, | |
| entry, | |
| placedTarget, | |
| placedText, | |
| candidate, | |
| placementState | |
| ) { | |
| for (let index = 0; index < candidate.positions.length; index++) { | |
| const position = candidate.positions[index]; | |
| grid[position.y][position.x] = placedText[index]; | |
| } | |
| placementState.directionCounts.set( | |
| candidate.direction.key, | |
| (placementState.directionCounts.get(candidate.direction.key) || 0) + 1 | |
| ); | |
| placementState.lineCounts.set( | |
| candidate.lineKey, | |
| (placementState.lineCounts.get(candidate.lineKey) || 0) + 1 | |
| ); | |
| if (candidate.direction.diagonal) { | |
| placementState.diagonalCount++; | |
| } | |
| placementState.overlapCount += candidate.overlapCount; | |
| const beforeLength = entry.beforeText.length; | |
| const targetLength = placedTarget.length; | |
| const wordStart = beforeLength; | |
| const wordEnd = wordStart + targetLength; | |
| return { | |
| originalWord: entry.shownWord, | |
| parts: entry.parts, | |
| shownPart: entry.shownPart, | |
| beforeText: entry.beforeText, | |
| afterText: entry.afterText, | |
| placedTarget, | |
| placedText, | |
| fullCode: entry.fullText, | |
| fullPositions: candidate.positions, | |
| beforePositions: candidate.positions.slice(0, wordStart), | |
| wordPositions: candidate.positions.slice(wordStart, wordEnd), | |
| afterPositions: candidate.positions.slice(wordEnd), | |
| positions: candidate.positions.slice(wordStart, wordEnd), | |
| direction: candidate.direction.key, | |
| diagonal: candidate.direction.diagonal, | |
| overlapCount: candidate.overlapCount | |
| }; | |
| } | |
| function placeWord( | |
| grid, | |
| entry, | |
| placedTarget, | |
| placedText, | |
| size, | |
| directions, | |
| placementState, | |
| targetDiagonalCount | |
| ) { | |
| const candidates = collectPlacementCandidates( | |
| grid, | |
| placedText, | |
| size, | |
| directions, | |
| placementState, | |
| targetDiagonalCount | |
| ); | |
| if (candidates.length === 0) { | |
| return null; | |
| } | |
| candidates.sort((a, b) => b.score - a.score); | |
| const choicePoolSize = Math.min(12, candidates.length); | |
| const choiceIndex = Math.floor( | |
| Math.pow(random(), 2.4) * choicePoolSize | |
| ); | |
| return commitPlacement( | |
| grid, | |
| entry, | |
| placedTarget, | |
| placedText, | |
| candidates[choiceIndex], | |
| placementState | |
| ); | |
| } | |
| function buildPuzzleAttempt(entries, size, seed, attemptNumber) { | |
| random = createSeededRandom(`${seed}:layout:${attemptNumber}`); | |
| const grid = createEmptyGrid(size); | |
| const directions = getDirections(); | |
| const placedWords = []; | |
| const failedEntries = []; | |
| const placementState = { | |
| directionCounts: new Map(), | |
| lineCounts: new Map(), | |
| diagonalCount: 0, | |
| overlapCount: 0 | |
| }; | |
| const targetDiagonalCount = allowDiagonalInput.checked | |
| ? Math.max(1, Math.round(entries.length * 0.45)) | |
| : 0; | |
| const sortedEntries = [...entries] | |
| .map(entry => ({ | |
| entry, | |
| tieBreaker: random() | |
| })) | |
| .sort((a, b) => { | |
| if (b.entry.fullText.length !== a.entry.fullText.length) { | |
| return b.entry.fullText.length - a.entry.fullText.length; | |
| } | |
| return a.tieBreaker - b.tieBreaker; | |
| }) | |
| .map(item => item.entry); | |
| for (const entry of sortedEntries) { | |
| const placedTarget = scrambleWordsInput.checked | |
| ? scrambleWord(entry.shownWord) | |
| : entry.shownWord; | |
| const placedText = | |
| `${entry.beforeText}${placedTarget}${entry.afterText}`; | |
| const placed = placeWord( | |
| grid, | |
| entry, | |
| placedTarget, | |
| placedText, | |
| size, | |
| directions, | |
| placementState, | |
| targetDiagonalCount | |
| ); | |
| if (placed) { | |
| placedWords.push(placed); | |
| } else { | |
| failedEntries.push(entry); | |
| } | |
| } | |
| const distinctDirectionCount = placementState.directionCounts.size; | |
| const mostUsedDirection = Math.max( | |
| 0, | |
| ...placementState.directionCounts.values() | |
| ); | |
| const score = | |
| placedWords.length * 100000 + | |
| placementState.diagonalCount * 500 + | |
| distinctDirectionCount * 420 + | |
| placementState.overlapCount * 260 - | |
| failedEntries.length * 100000 - | |
| mostUsedDirection * 90; | |
| return { | |
| grid, | |
| placedWords, | |
| failedEntries, | |
| placementState, | |
| targetDiagonalCount, | |
| score | |
| }; | |
| } | |
| function fillEmptyCells(grid, size, characterSet) { | |
| for (let y = 0; y < size; y++) { | |
| for (let x = 0; x < size; x++) { | |
| if (grid[y][x] === "") { | |
| grid[y][x] = | |
| characterSet[randomInteger(characterSet.length)]; | |
| } | |
| } | |
| } | |
| } | |
| function generatePuzzle() { | |
| messageElement.textContent = ""; | |
| const size = Number.parseInt(gridSizeInput.value, 10); | |
| const collected = collectCodeEntries(); | |
| const entries = collected.entries; | |
| if (!Number.isInteger(size) || size < 8 || size > 30) { | |
| messageElement.textContent = "Grid size must be between 8 and 30."; | |
| return; | |
| } | |
| if (collected.invalidRows.length > 0) { | |
| messageElement.textContent = | |
| collected.invalidRows | |
| .map(item => | |
| `Row ${item.rowNumber}: Part ${item.shownPart} is selected ` + | |
| "to appear in the public list, but it is empty." | |
| ) | |
| .join(" "); | |
| return; | |
| } | |
| if (entries.length === 0) { | |
| messageElement.textContent = "Add at least one required word."; | |
| return; | |
| } | |
| const tooLong = entries.filter(entry => entry.fullText.length > size); | |
| if (tooLong.length > 0) { | |
| messageElement.textContent = | |
| "These complete code strings are longer than the grid: " + | |
| tooLong.map(entry => entry.fullText).join(", "); | |
| return; | |
| } | |
| const seed = seedInput.value.trim() || String(Date.now()); | |
| seedInput.value = seed; | |
| let bestAttempt = null; | |
| const attemptCount = Math.min(100, Math.max(35, entries.length * 8)); | |
| for (let attemptNumber = 0; attemptNumber < attemptCount; attemptNumber++) { | |
| const attempt = buildPuzzleAttempt( | |
| entries, | |
| size, | |
| seed, | |
| attemptNumber | |
| ); | |
| if (!bestAttempt || attempt.score > bestAttempt.score) { | |
| bestAttempt = attempt; | |
| } | |
| const enoughDirections = Math.min( | |
| getDirections().length, | |
| Math.max(3, Math.ceil(entries.length * 0.55)) | |
| ); | |
| if ( | |
| attempt.failedEntries.length === 0 && | |
| attempt.placementState.diagonalCount >= attempt.targetDiagonalCount && | |
| attempt.placementState.directionCounts.size >= enoughDirections | |
| ) { | |
| bestAttempt = attempt; | |
| break; | |
| } | |
| } | |
| const containsNumbers = bestAttempt.placedWords.some( | |
| placedWord => /\d/.test(placedWord.placedText) | |
| ); | |
| const fillerCharacters = containsNumbers ? alphaNumeric : alphabet; | |
| random = createSeededRandom(`${seed}:fill`); | |
| fillEmptyCells(bestAttempt.grid, size, fillerCharacters); | |
| puzzle = { | |
| grid: bestAttempt.grid, | |
| placedWords: bestAttempt.placedWords, | |
| size, | |
| revealed: false | |
| }; | |
| revealButton.textContent = "Reveal Answers & Codes"; | |
| drawPuzzle(); | |
| random = createSeededRandom(`${seed}:clues`); | |
| renderWordList(); | |
| renderCodeKey(); | |
| const diagonalCount = bestAttempt.placementState.diagonalCount; | |
| const directionCount = bestAttempt.placementState.directionCounts.size; | |
| const crossingCount = bestAttempt.placementState.overlapCount; | |
| const codedCount = bestAttempt.placedWords.filter( | |
| placedWord => placedWord.beforeText || placedWord.afterText | |
| ).length; | |
| statusElement.textContent = | |
| `${bestAttempt.placedWords.length} words · ` + | |
| `${codedCount} coded · ` + | |
| `${diagonalCount} diagonal · ` + | |
| `${directionCount} directions · ` + | |
| `${crossingCount} crossings · Seed ${seed}`; | |
| if (bestAttempt.failedEntries.length > 0) { | |
| messageElement.textContent = | |
| "Could not place: " + | |
| bestAttempt.failedEntries.map(entry => entry.fullText).join(", ") + | |
| ". Try a larger grid or shorter code parts."; | |
| } | |
| } | |
| function drawRoundedLine( | |
| startX, | |
| startY, | |
| endX, | |
| endY, | |
| width, | |
| fillColor, | |
| outlineColor | |
| ) { | |
| ctx.save(); | |
| ctx.strokeStyle = fillColor; | |
| ctx.lineWidth = width; | |
| ctx.lineCap = "round"; | |
| ctx.beginPath(); | |
| ctx.moveTo(startX, startY); | |
| ctx.lineTo(endX, endY); | |
| ctx.stroke(); | |
| ctx.strokeStyle = outlineColor; | |
| ctx.lineWidth = Math.max(2, width * 0.07); | |
| ctx.beginPath(); | |
| ctx.moveTo(startX, startY); | |
| ctx.lineTo(endX, endY); | |
| ctx.stroke(); | |
| ctx.restore(); | |
| } | |
| function getLineCoordinates(positions, margin, cellSize) { | |
| const first = positions[0]; | |
| const last = positions[positions.length - 1]; | |
| return { | |
| startX: margin + first.x * cellSize + cellSize / 2, | |
| startY: margin + first.y * cellSize + cellSize / 2, | |
| endX: margin + last.x * cellSize + cellSize / 2, | |
| endY: margin + last.y * cellSize + cellSize / 2 | |
| }; | |
| } | |
| function drawPuzzle() { | |
| const size = puzzle.size; | |
| const canvasSize = 900; | |
| const margin = 36; | |
| const usableSize = canvasSize - margin * 2; | |
| const cellSize = usableSize / size; | |
| canvas.width = canvasSize; | |
| canvas.height = canvasSize; | |
| ctx.clearRect(0, 0, canvas.width, canvas.height); | |
| ctx.fillStyle = "#ffffff"; | |
| ctx.fillRect(0, 0, canvas.width, canvas.height); | |
| ctx.strokeStyle = "#cbd5e1"; | |
| ctx.lineWidth = 1; | |
| for (let index = 0; index <= size; index++) { | |
| const offset = margin + index * cellSize; | |
| ctx.beginPath(); | |
| ctx.moveTo(margin, offset); | |
| ctx.lineTo(margin + usableSize, offset); | |
| ctx.stroke(); | |
| ctx.beginPath(); | |
| ctx.moveTo(offset, margin); | |
| ctx.lineTo(offset, margin + usableSize); | |
| ctx.stroke(); | |
| } | |
| if (puzzle.revealed) { | |
| for (const placedWord of puzzle.placedWords) { | |
| // Gold marks the entire prefix + word + suffix code string. | |
| const fullLine = getLineCoordinates( | |
| placedWord.fullPositions, | |
| margin, | |
| cellSize | |
| ); | |
| drawRoundedLine( | |
| fullLine.startX, | |
| fullLine.startY, | |
| fullLine.endX, | |
| fullLine.endY, | |
| Math.max(17, cellSize * 0.76), | |
| "rgba(245, 158, 11, 0.56)", | |
| "rgba(146, 64, 14, 0.88)" | |
| ); | |
| // Red marks only the required word shown in the clue list. | |
| const wordLine = getLineCoordinates( | |
| placedWord.wordPositions, | |
| margin, | |
| cellSize | |
| ); | |
| drawRoundedLine( | |
| wordLine.startX, | |
| wordLine.startY, | |
| wordLine.endX, | |
| wordLine.endY, | |
| Math.max(12, cellSize * 0.48), | |
| "rgba(239, 68, 68, 0.62)", | |
| "rgba(127, 29, 29, 0.9)" | |
| ); | |
| } | |
| } | |
| const fontSize = Math.max(14, Math.min(38, cellSize * 0.5)); | |
| ctx.fillStyle = "#111827"; | |
| ctx.font = `800 ${fontSize}px Arial, sans-serif`; | |
| ctx.textAlign = "center"; | |
| ctx.textBaseline = "middle"; | |
| for (let y = 0; y < size; y++) { | |
| for (let x = 0; x < size; x++) { | |
| ctx.fillText( | |
| puzzle.grid[y][x], | |
| margin + x * cellSize + cellSize / 2, | |
| margin + y * cellSize + cellSize / 2 | |
| ); | |
| } | |
| } | |
| ctx.strokeStyle = "#334155"; | |
| ctx.lineWidth = 3; | |
| ctx.strokeRect(margin, margin, usableSize, usableSize); | |
| } | |
| function renderWordList() { | |
| wordListElement.replaceChildren(); | |
| for (const placedWord of puzzle.placedWords) { | |
| const pill = document.createElement("span"); | |
| pill.className = "word-pill"; | |
| const displayedWord = scrambleCluesInput.checked | |
| ? scrambleWord(placedWord.originalWord) | |
| : placedWord.originalWord; | |
| pill.textContent = displayedWord; | |
| if (scrambleCluesInput.checked) { | |
| pill.classList.add("scrambled"); | |
| pill.dataset.original = placedWord.originalWord; | |
| pill.title = "Scrambled clue"; | |
| } | |
| wordListElement.appendChild(pill); | |
| } | |
| } | |
| function appendCodePart(container, value, className) { | |
| if (!value) { | |
| return; | |
| } | |
| const span = document.createElement("span"); | |
| span.className = className; | |
| span.textContent = value; | |
| container.appendChild(span); | |
| } | |
| function renderCodeKey() { | |
| codeKeyElement.replaceChildren(); | |
| const title = document.createElement("h2"); | |
| title.className = "code-key-title"; | |
| title.textContent = "Revealed code key"; | |
| codeKeyElement.appendChild(title); | |
| for (const placedWord of puzzle.placedWords) { | |
| const row = document.createElement("div"); | |
| row.className = "code-row"; | |
| const clue = document.createElement("span"); | |
| clue.className = "code-clue"; | |
| clue.textContent = placedWord.originalWord; | |
| const arrow = document.createElement("span"); | |
| arrow.className = "code-arrow"; | |
| arrow.textContent = "→"; | |
| const code = document.createElement("span"); | |
| code.className = "code-value"; | |
| code.title = `Complete code: ${placedWord.fullCode}`; | |
| placedWord.parts.forEach((part, index) => { | |
| const className = | |
| index + 1 === placedWord.shownPart | |
| ? "target-part" | |
| : "code-part"; | |
| appendCodePart(code, part, className); | |
| }); | |
| row.append(clue, arrow, code); | |
| codeKeyElement.appendChild(row); | |
| } | |
| codeKeyElement.hidden = !puzzle.revealed; | |
| answerLegendElement.classList.toggle("visible", puzzle.revealed); | |
| } | |
| function toggleAnswers() { | |
| if (puzzle.placedWords.length === 0) { | |
| messageElement.textContent = "Generate a puzzle first."; | |
| return; | |
| } | |
| puzzle.revealed = !puzzle.revealed; | |
| revealButton.textContent = puzzle.revealed | |
| ? "Hide Answers & Codes" | |
| : "Reveal Answers & Codes"; | |
| drawPuzzle(); | |
| renderCodeKey(); | |
| } | |
| function downloadCanvas() { | |
| if (puzzle.grid.length === 0) { | |
| messageElement.textContent = "Generate a puzzle first."; | |
| return; | |
| } | |
| const link = document.createElement("a"); | |
| link.download = puzzle.revealed | |
| ? "word-search-code-key.png" | |
| : "word-search-puzzle.png"; | |
| link.href = canvas.toDataURL("image/png"); | |
| link.click(); | |
| } | |
| generateButton.addEventListener("click", generatePuzzle); | |
| revealButton.addEventListener("click", toggleAnswers); | |
| downloadButton.addEventListener("click", downloadCanvas); | |
| printButton.addEventListener("click", () => window.print()); | |
| scrambleCluesInput.addEventListener("change", () => { | |
| if (puzzle.placedWords.length > 0) { | |
| const seed = seedInput.value.trim() || "word-list"; | |
| random = createSeededRandom(`${seed}-clues`); | |
| renderWordList(); | |
| } | |
| }); | |
| addEntryButton.addEventListener("click", () => { | |
| const row = createEntryRow({ | |
| parts: ["", "", ""], | |
| shownPart: 2 | |
| }); | |
| row.querySelector(".part-2-input").focus(); | |
| }); | |
| const initialEntries = [ | |
| { parts: ["X7", "APPLE", "Q2"], shownPart: 2 }, | |
| { parts: ["BANANA", "BONUS", "VIP"], shownPart: 1 }, | |
| { parts: ["WIN", "ORANGE", "9K"], shownPart: 2 }, | |
| { parts: ["GRAPE", "", ""], shownPart: 1 }, | |
| { parts: ["M", "MANGO", "Z"], shownPart: 2 }, | |
| { parts: ["CODE", "CHERRY", "DROP"], shownPart: 2 }, | |
| { parts: ["PEACH", "PLUS", "WIN"], shownPart: 1 } | |
| ]; | |
| for (const entry of initialEntries) { | |
| createEntryRow(entry); | |
| } | |
| generatePuzzle(); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment