├── LICENSE ├── README.md ├── index.html └── script.js /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright © 2022 Evelyn "Eevee" Woods 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and 6 | associated documentation files (the "Software"), to deal in the Software without restriction, 7 | including without limitation the rights to use, copy, modify, merge, publish, distribute, 8 | sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all copies or substantial 12 | portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT 15 | NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 16 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES 17 | OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # digitle 2 | 3 | This is a number puzzle game you can play in a browser, inspired by the numbers round of the UK game show Countdown. 4 | 5 | Canonical home is at [https://c.eev.ee/digitle/](https://c.eev.ee/digitle/). 6 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | digitle 7 | 390 | 391 | 392 |
393 |
394 |
395 | 396 |
397 |

digitle

398 |
399 | 400 | 401 |
402 |
403 |
404 |
405 | 406 |
407 |
???
408 |
normal rules
409 | 410 |
411 |
412 |
    413 |
    414 |
    415 |
    416 |

    Congratulations!

    417 | 418 |
    419 |
    420 | 421 | 422 | 423 | 424 | 425 | 426 |
    427 |
    428 |

    Daily puzzle for 2022-02-04

    429 | 430 | 431 |
    432 |
    433 |
    434 |
    435 |
    436 |

    about

    437 |
    438 | 439 |
    440 |
    441 |

    digitle is a number puzzle game, best known from the UK game show Countdown.

    442 |

    Try to reach the target number by combining the six given numbers and the four basic arithmetic operations. Click on available numbers to insert them into an expression, or type them on your keyboard.

    443 |
    444 |
      445 |
      446 | 1 447 | + 448 | 2 449 | × 450 | 3 451 |
      452 |
      453 | = 454 | 7 455 |
      456 |
    457 |

    Order of operations matters. You can now use this 7 again later.

    458 |
      459 |
      460 | 7 461 | - 462 | 9 463 | + 464 | 10 465 |
      466 |
      467 | = 468 | 8 469 |
      470 |
    471 |

    Negative numbers aren't allowed. A hidden −2 is created here.

    472 |
      473 |
      474 | 7 475 | + 476 | 10 477 | - 478 | 9 479 |
      480 |
      481 | = 482 | 8 483 |
      484 |
    485 |

    Rearranging can help.

    486 |
      487 |
      488 | 8 489 | ÷ 490 | 3 491 |
      492 |
      493 | = 494 | 2⅔ 495 |
      496 |
    497 |

    Fractions aren't allowed, either.

    498 |
    499 |

    You earn ⭐ for getting within 10 of the target, ⭐⭐ for getting within 5, and ⭐⭐⭐ for reaching it exactly. Note that in rare cases, a puzzle may not be exactly solvable.

    500 |

    Made by Eevee (@eevee). Hope you enjoy! Source code on GitHub, but also in your browser; it's only two files.

    501 |
    502 |
    503 |
    504 |
    505 |

    options

    506 |
    507 | 508 |
    509 |
    510 |
    511 |

    Affects all puzzles:

    512 |

    513 | 514 | 515 |

    516 | 517 |

    Affects random puzzles only:

    518 |

    519 | 520 | 526 |

    527 |

    528 | 529 | 538 |

    539 |

    540 | 541 | 547 |

    548 |
    549 |
    550 | 554 | 555 | 556 | -------------------------------------------------------------------------------- /script.js: -------------------------------------------------------------------------------- 1 | // thanks, https://stackoverflow.com/a/47593316/17875 2 | // string hash function 3 | function xmur3(str) { 4 | for(var i = 0, h = 1779033703 ^ str.length; i < str.length; i++) { 5 | h = Math.imul(h ^ str.charCodeAt(i), 3432918353); 6 | h = h << 13 | h >>> 19; 7 | } return function() { 8 | h = Math.imul(h ^ (h >>> 16), 2246822507); 9 | h = Math.imul(h ^ (h >>> 13), 3266489909); 10 | return (h ^= h >>> 16) >>> 0; 11 | } 12 | } 13 | // simple PRNG, seeded by an integer 14 | function mulberry32(a) { 15 | return function() { 16 | let t = a += 0x6D2B79F5; 17 | t = Math.imul(t ^ t >>> 15, t | 1); 18 | t ^= t + Math.imul(t ^ t >>> 7, t | 61); 19 | return ((t ^ t >>> 14) >>> 0) / 4294967296; 20 | } 21 | } 22 | 23 | export class RNG { 24 | constructor(seed) { 25 | if (seed === undefined) { 26 | seed = Date.now(); 27 | } 28 | this.seed = seed; 29 | 30 | if (typeof seed !== 'number') { 31 | seed = xmur3(String(seed))(); 32 | } 33 | this.faucet = mulberry32(seed); 34 | } 35 | 36 | // This is the Python random module interface, which is the pinnacle of RNG interfaces 37 | random() { 38 | return this.faucet(); 39 | } 40 | 41 | randrange(a, b) { 42 | if (b === undefined) { 43 | b = a; 44 | a = 0; 45 | } 46 | return a + Math.floor((b - a) * this.random()); 47 | } 48 | 49 | choice(seq) { 50 | return seq[Math.floor(this.random() * seq.length)]; 51 | } 52 | 53 | sample(seq, k) { 54 | let pool = Array.from(seq); 55 | let n = pool.length; 56 | let ret = []; 57 | for (let i = 0; i < k; i++) { 58 | let j = this.randrange(n - i); 59 | ret.push(pool[j]); 60 | pool[j] = pool[n - i - 1]; 61 | } 62 | return ret; 63 | } 64 | } 65 | 66 | 67 | function _mk(el, children) { 68 | if (children.length > 0) { 69 | if (!(children[0] instanceof Node) && children[0] !== undefined && typeof(children[0]) !== "string" && typeof(children[0]) !== "number") { 70 | let [attrs] = children.splice(0, 1); 71 | for (let [key, value] of Object.entries(attrs)) { 72 | el.setAttribute(key, value); 73 | } 74 | } 75 | el.append(...children); 76 | } 77 | return el; 78 | } 79 | 80 | export function mk(tag_selector, ...children) { 81 | let [tag, ...classes] = tag_selector.split('.'); 82 | let el = document.createElement(tag); 83 | if (classes.length > 0) { 84 | el.classList = classes.join(' '); 85 | } 86 | return _mk(el, children); 87 | } 88 | 89 | 90 | const BIG_NUMBERS = [25, 50, 75, 100]; 91 | const SMALL_NUMBERS = [1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10]; 92 | export class Game { 93 | constructor(rng) { 94 | this.rng = rng ?? new RNG; 95 | this.target = this.rng.randrange(100, 1000); 96 | // How many bigguns? 97 | let big; 98 | let n = this.rng.random(); 99 | if (n < 0.5) { 100 | big = 1; 101 | } 102 | else if (n < 0.8) { 103 | big = 2; 104 | } 105 | else if (n < 0.9) { 106 | big = 3; 107 | } 108 | else if (n < 0.95) { 109 | big = 4; 110 | } 111 | else { 112 | big = 0; 113 | } 114 | 115 | this.numbers = []; 116 | this.numbers.push(...this.rng.sample(BIG_NUMBERS, big)); 117 | this.numbers.push(...this.rng.sample(SMALL_NUMBERS, 6 - big)); 118 | 119 | this.used = []; 120 | this.reset(); 121 | } 122 | 123 | reset() { 124 | this.numbers.splice(6); 125 | this.used = [false, false, false, false, false, false]; 126 | this.win = null; 127 | } 128 | 129 | check_for_win() { 130 | let closest = null; 131 | let closest_off = null; 132 | for (let [i, n] of this.numbers.entries()) { 133 | if (this.used[i]) 134 | continue; 135 | 136 | let off = Math.abs(n - this.target); 137 | if (closest === null || off < closest_off) { 138 | closest = i; 139 | closest_off = off; 140 | } 141 | } 142 | 143 | if (closest_off > 10) { 144 | this.win = null; 145 | return; 146 | } 147 | 148 | this.win = { 149 | closest: this.numbers[closest], 150 | index: closest, 151 | off: closest_off, 152 | stars: 1, 153 | }; 154 | if (closest_off === 0) { 155 | this.win.stars = 3; 156 | } 157 | else if (closest_off <= 5) { 158 | this.win.stars = 2; 159 | } 160 | 161 | return this.win; 162 | } 163 | } 164 | 165 | 166 | const OPERATORS = { 167 | '+': { 168 | text: '+', 169 | emoji: '➕', 170 | evaluate: (a, b) => a + b, 171 | }, 172 | '-': { 173 | text: '−', 174 | emoji: '➖', 175 | evaluate: (a, b) => a - b, 176 | }, 177 | '*': { 178 | text: '×', 179 | emoji: '✖️', 180 | evaluate: (a, b) => a * b, 181 | }, 182 | '/': { 183 | text: '÷', 184 | emoji: '➗', 185 | evaluate: (a, b) => a / b, 186 | }, 187 | }; 188 | class IntegerError extends RangeError {} 189 | class Expression { 190 | constructor(game, parent_el) { 191 | this.game = game; 192 | this.expn_el = mk('div.-expn'); 193 | this.result_el = mk('div.-eq'); 194 | parent_el.append(this.expn_el, this.result_el); 195 | 196 | this.parts = []; // alternates between numbers and ops 197 | this.add_number(); 198 | 199 | this.error = null; 200 | } 201 | 202 | add_number() { 203 | let element = mk('span.num.-pending'); 204 | this.expn_el.append(element); 205 | this.pending_part = { 206 | value: 0, 207 | element, 208 | }; 209 | this.parts.push(this.pending_part); 210 | } 211 | 212 | set_pending_number(n) { 213 | if (! this.pending_part) 214 | return; 215 | 216 | this.pending_part.value = n; 217 | this.pending_part.element.textContent = n ? String(n) : NBSP; 218 | } 219 | 220 | evaluate() { 221 | let parts = this.parts.map(part => part.value); 222 | 223 | // Perform multiplication and division 224 | let i = 1; 225 | while (i < parts.length) { 226 | if (parts[i] === '*') { 227 | parts.splice(i - 1, 3, parts[i - 1] * parts[i + 1]); 228 | } 229 | else if (parts[i] === '/') { 230 | let result = parts[i - 1] / parts[i + 1]; 231 | if (result !== Math.floor(result)) { 232 | throw new IntegerError(`Fractions are not allowed`); 233 | } 234 | parts.splice(i - 1, 3, result); 235 | } 236 | else { 237 | i += 2; 238 | } 239 | } 240 | // Perform addition and subtraction 241 | i = 1; 242 | while (i < parts.length) { 243 | if (parts[i] === '+') { 244 | parts.splice(i - 1, 3, parts[i - 1] + parts[i + 1]); 245 | } 246 | else if (parts[i] === '-') { 247 | let result = parts[i - 1] - parts[i + 1]; 248 | if (result < 0) { 249 | throw new IntegerError(`Negative values are not allowed`); 250 | } 251 | parts.splice(i - 1, 3, result); 252 | } 253 | else { 254 | i += 2; 255 | } 256 | } 257 | 258 | if (parts.length !== 1) { 259 | console.error("Didn't end up with 1 part left", parts); 260 | } 261 | 262 | return parts[0]; 263 | } 264 | 265 | commit_number(index = null) { 266 | this.uncommit_number(); 267 | 268 | let {value, element} = this.pending_part; 269 | element.classList.remove('-pending'); 270 | 271 | // Double-check that the index matches our number 272 | if (index !== null && this.game.numbers[index] !== value) { 273 | index = null; 274 | } 275 | 276 | // Find the first matching number 277 | if (index === null) { 278 | for (let [j, n] of this.game.numbers.entries()) { 279 | if (n === value && ! this.game.used[j] && 280 | ! this.parts.some(part => part.index === j)) 281 | { 282 | index = j; 283 | break; 284 | } 285 | } 286 | } 287 | 288 | if (index !== null) { 289 | if (index >= 6) { 290 | element.classList.add('intermed'); 291 | } 292 | this.pending_part.index = index; 293 | return true; 294 | } 295 | else { 296 | element.classList.add('-error'); 297 | this.error = `No ${value} is available`; 298 | return false; 299 | } 300 | } 301 | 302 | uncommit_number() { 303 | this.pending_part.index = null; 304 | let element = this.pending_part.element; 305 | element.classList.remove('-error', 'intermed', 'used'); 306 | element.classList.add('-pending'); 307 | this.error = null; 308 | } 309 | 310 | is_empty() { 311 | return this.parts.length === 1 && this.pending_part.value === 0; 312 | } 313 | 314 | // User input API 315 | 316 | append_digit(digit) { 317 | this.set_pending_number(this.pending_part.value * 10 + digit); 318 | this.uncommit_number(); 319 | } 320 | 321 | set_number_by_index(index) { 322 | this.set_pending_number(this.game.numbers[index]); 323 | this.commit_number(index); 324 | } 325 | 326 | add_operator(op) { 327 | let opdef = OPERATORS[op]; 328 | if (! opdef) 329 | return; 330 | 331 | if (this.pending_part.value === 0) { 332 | // No number; try to prefill the result of the last expression 333 | if (this.game.numbers.length > 6) { 334 | this.set_pending_number(this.game.numbers[this.game.numbers.length - 1]); 335 | } 336 | else { 337 | return; 338 | } 339 | } 340 | 341 | if (! this.commit_number()) 342 | return; 343 | 344 | let element = mk('span.op', opdef.text); 345 | this.parts.push({ value: op, element }); 346 | this.expn_el.append(element); 347 | 348 | this.add_number(); 349 | return true; 350 | } 351 | 352 | backspace() { 353 | if (this.pending_part.value === 0) { 354 | // Erase the previous operator, if any (if none, do nothing) 355 | if (this.parts.length > 1) { 356 | this.parts.pop().element.remove(); // number 357 | this.parts.pop().element.remove(); // operator 358 | this.pending_part = this.parts[this.parts.length - 1]; 359 | // Don't uncommit the number yet, for consistency with click entry 360 | } 361 | } 362 | else { 363 | // Delete the last digit of the current number 364 | this.set_pending_number(Math.floor(this.pending_part.value / 10)); 365 | this.uncommit_number(); 366 | } 367 | } 368 | 369 | commit() { 370 | if (this.pending_part.value === 0) { 371 | // Can't be done with no number 372 | return; 373 | } 374 | if (this.parts.length < 3) { 375 | // Need to have an expression to evaluate 376 | return; 377 | } 378 | 379 | if (! this.commit_number()) 380 | return; 381 | 382 | let result; 383 | try { 384 | result = this.evaluate(); 385 | } 386 | catch (e) { 387 | if (e instanceof IntegerError) { 388 | this.uncommit_number(); 389 | this.error = e.message; 390 | return; 391 | } 392 | throw e; 393 | } 394 | 395 | let element = mk('span.num.intermed', result); 396 | this.result_el.append( 397 | mk('span.op', "="), 398 | element, 399 | ); 400 | 401 | let used = []; 402 | for (let [i, part] of this.parts.entries()) { 403 | if (i % 2 === 0) { 404 | used.push(part.index); 405 | } 406 | } 407 | 408 | return { 409 | value: result, 410 | used, 411 | element, 412 | }; 413 | } 414 | 415 | uncommit() { 416 | this.result_el.textContent = ''; 417 | } 418 | } 419 | 420 | const NBSP = "\xa0"; 421 | const SEED_POOL = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; 422 | const SEED_LENGTH = 8; 423 | 424 | export class UI { 425 | constructor(root) { 426 | this.root = root; 427 | 428 | this.target_el = root.querySelector('#board .num.target'); 429 | this.givens_el = root.querySelector('#given'); 430 | this.expns_el = root.querySelector('#inputs'); 431 | this.error_el = root.querySelector('#error'); 432 | 433 | this.number_els = []; 434 | for (let i = 0; i < 6; i++) { 435 | let el = mk('li.num', {'data-index': i}, "?"); 436 | this.number_els.push(el); 437 | this.givens_el.append(el); 438 | } 439 | 440 | this.expressions = []; 441 | this.current_expn = null; 442 | 443 | // Handle clicks on available numbers 444 | this.root.addEventListener('click', ev => { 445 | let num = ev.target.closest('.num[data-index]'); 446 | if (! num) 447 | return; 448 | 449 | this.input_number(parseInt(num.getAttribute('data-index'))); 450 | }); 451 | 452 | this.options = { 453 | hard_mode: true, 454 | pool: 'normal', 455 | selection: 'auto', 456 | base: 10, 457 | }; 458 | 459 | // Handle keypresses 460 | // TODO make it clear when we have focus? 461 | document.body.addEventListener('keydown', ev => { 462 | if ('0123456789'.indexOf(ev.key) >= 0) { 463 | this.input_digit(parseInt(ev.key, 10)); 464 | } 465 | else if (ev.key === '+' || ev.key === '-' || ev.key === '*' || ev.key === '/') { 466 | // TODO would be nice to be able to do this and automatically bring down the result from the previous line 467 | this.input_operator(ev.key); 468 | } 469 | else if (ev.key === ':') { 470 | // Firefox nicety 471 | this.input_operator('/'); 472 | } 473 | else if (ev.key === '=' || ev.key === 'Enter' || ev.key === 'Return') { 474 | this.input_done(); 475 | } 476 | else if (ev.key === 'Backspace') { 477 | this.input_backspace(); 478 | } 479 | else { 480 | return; 481 | } 482 | 483 | ev.preventDefault(); 484 | }); 485 | 486 | // Wire up the keyboard 487 | this.root.querySelector('#keyboard').addEventListener('click', ev => { 488 | let button = ev.target; 489 | if (button.tagName !== 'BUTTON') 490 | return; 491 | 492 | let type = button.getAttribute('data-type'); 493 | if (type === 'digit') { 494 | this.input_digit(parseInt(button.getAttribute('data-digit'), 10)); 495 | } 496 | else if (type === 'operator') { 497 | this.input_operator(button.getAttribute('data-op')); 498 | } 499 | else if (type === 'erase') { 500 | this.input_backspace(); 501 | } 502 | else if (type === 'done') { 503 | this.input_done(); 504 | } 505 | }); 506 | 507 | this.root.querySelector('#button-reroll').addEventListener('click', () => { 508 | this.reroll(); 509 | }); 510 | this.root.querySelector('#button-reset').addEventListener('click', () => { 511 | this.reset(); 512 | }); 513 | this.root.querySelector('#button-copy-link').addEventListener('click', ev => { 514 | this.copy_link().then(() => { 515 | this.confirm_copy(ev); 516 | }); 517 | }); 518 | 519 | this.mode_button = this.root.querySelector('#button-mode'); 520 | this.mode_button.addEventListener('click', () => { 521 | if (this.daily_mode) { 522 | this.switch_to_random_mode(); 523 | } 524 | else { 525 | this.switch_to_daily_mode(); 526 | } 527 | }); 528 | 529 | this.root.querySelector('#button-copy-results').addEventListener('click', ev => { 530 | this.copy_results().then(() => { 531 | this.confirm_copy(ev); 532 | }); 533 | }); 534 | 535 | // Set up tabs (which are outside the root oops) 536 | this.root.querySelector('#button-show-about').addEventListener('click', () => { 537 | this.switch_to_tab('main-about'); 538 | }); 539 | document.querySelector('#button-close-about').addEventListener('click', () => { 540 | this.switch_to_tab('main-game'); 541 | }); 542 | this.root.querySelector('#button-settings').addEventListener('click', () => { 543 | this.switch_to_tab('main-settings'); 544 | }); 545 | document.querySelector('#button-close-settings').addEventListener('click', () => { 546 | this.switch_to_tab('main-game'); 547 | }); 548 | 549 | // Figure out what game we're playing. If we have a seed in the URL, read that. 550 | let seed; 551 | if (location.hash) { 552 | let params = new URLSearchParams(location.hash.substring(1)); 553 | seed = params.get('seed'); 554 | if (seed) { 555 | let m = seed.match(/^(\d{4}-\d{2}-\d{2})(?:[/](\d+))?$/); 556 | if (m) { 557 | this.daily_mode = true; 558 | // FIXME come on 559 | this.mode_button.textContent = "📆"; 560 | this.mode_button.setAttribute('title', "Daily"); 561 | this.daily_date = m[1]; 562 | this.daily_number = m[2] ? parseInt(m[2], 10) : 1; 563 | console.log(seed, m, this.daily_date, this.daily_number); 564 | } 565 | else { 566 | this.daily_mode = false; 567 | this.mode_button.textContent = "🎲"; 568 | this.mode_button.setAttribute('title', "Random"); 569 | } 570 | this.set_game(new Game(new RNG(seed))); 571 | } 572 | history.replaceState({}, document.title, location.origin + location.pathname); 573 | } 574 | 575 | // Otherwise, default to today's game 576 | if (! seed) { 577 | this.switch_to_daily_mode(); 578 | } 579 | } 580 | 581 | switch_to_tab(id) { 582 | for (let el of document.querySelectorAll('main')) { 583 | el.setAttribute('hidden', ''); 584 | } 585 | document.querySelector('#' + id).removeAttribute('hidden'); 586 | } 587 | 588 | get_datestamp() { 589 | return new Date().toISOString().substring(0, 10); // yyyy-mm-dd 590 | } 591 | 592 | switch_to_daily_mode() { 593 | this.daily_mode = true; 594 | this.mode_button.textContent = "📆"; 595 | this.mode_button.setAttribute('title', "Daily"); 596 | 597 | this.daily_date = this.get_datestamp(); 598 | // FIXME switching back should remember what number you're on 599 | this.daily_number = 1; 600 | this.generate_game(); 601 | } 602 | 603 | switch_to_random_mode() { 604 | this.daily_mode = false; 605 | this.mode_button.textContent = "🎲"; 606 | this.mode_button.setAttribute('title', "Random"); 607 | 608 | this.generate_game(); 609 | } 610 | 611 | reroll() { 612 | if (this.daily_mode) { 613 | let stamp = this.get_datestamp(); 614 | if (stamp === this.daily_date) { 615 | this.daily_number += 1; 616 | } 617 | else { 618 | this.daily_date = stamp; 619 | this.daily_number = 1; 620 | } 621 | } 622 | 623 | this.generate_game(); 624 | } 625 | 626 | generate_game() { 627 | let seed; 628 | if (this.daily_mode) { 629 | seed = this.daily_date; 630 | if (this.daily_number > 1) { 631 | seed += `/${this.daily_number}`; 632 | } 633 | } 634 | else { 635 | // Generate a random seed from [a-zA-Z0-9], eight chars long 636 | let chars = []; 637 | for (let i = 0; i < SEED_LENGTH; i++) { 638 | chars.push(SEED_POOL[Math.floor(Math.random() * SEED_POOL.length)]); 639 | } 640 | seed = chars.join(''); 641 | } 642 | 643 | this.set_game(new Game(new RNG(seed))); 644 | } 645 | 646 | set_game(game) { 647 | this.game = game; 648 | this.target_el.textContent = this.game.target; 649 | for (let [i, n] of this.game.numbers.entries()) { 650 | this.number_els[i].textContent = n; 651 | } 652 | 653 | let p = this.root.querySelector('#puzzle-desc p'); 654 | if (this.daily_mode) { 655 | let bits = ["Daily puzzle"]; 656 | if (this.daily_number > 1) { 657 | bits.push(" #", this.daily_number); 658 | } 659 | bits.push(" for ", this.daily_date); 660 | p.textContent = bits.join(""); 661 | } 662 | else { 663 | p.textContent = `Random puzzle ${this.game.rng.seed}`; 664 | } 665 | 666 | this.reset(); 667 | } 668 | 669 | reset() { 670 | this.game.reset(); 671 | 672 | this.root.classList.remove('won'); 673 | this.error_el.textContent = ''; 674 | this.expns_el.textContent = ''; 675 | this.expressions = []; 676 | this.add_new_expression(); 677 | this.number_els.splice(6); 678 | for (let el of this.givens_el.querySelectorAll('.num.used')) { 679 | el.classList.remove('used'); 680 | } 681 | this.update_ui(); 682 | } 683 | 684 | add_new_expression() { 685 | this.current_expn = new Expression(this.game, this.expns_el); 686 | this.expressions.push(this.current_expn); 687 | } 688 | 689 | update_ui() { 690 | // Scroll the expression list to the bottom 691 | this.expns_el.parentNode.scrollTo(0, this.expns_el.parentNode.scrollHeight); 692 | 693 | // Update the error + win elements 694 | if (this.current_expn && this.current_expn.error) { 695 | this.error_el.textContent = this.current_expn.error; 696 | this.root.classList.remove('won'); 697 | } 698 | else { 699 | // If all is well, check for a win 700 | let win = this.game.check_for_win(); 701 | this.root.classList.toggle('won', !!win); 702 | if (win) { 703 | let element = this.root.querySelector('#win-message'); 704 | if (win.stars === 3) { 705 | element.textContent = `Bang on! ⭐⭐⭐`; 706 | } 707 | else { 708 | element.textContent = `${win.off} away! ` + "⭐".repeat(win.stars); 709 | } 710 | } 711 | } 712 | } 713 | 714 | input_digit(digit) { 715 | if (! this.current_expn) 716 | return; 717 | this.current_expn.append_digit(digit); 718 | this.update_ui(); 719 | } 720 | 721 | input_number(index) { 722 | if (! this.current_expn) 723 | return; 724 | this.current_expn.set_number_by_index(index); 725 | this.update_ui(); 726 | } 727 | 728 | input_operator(op) { 729 | if (! this.current_expn) 730 | return; 731 | this.current_expn.add_operator(op); 732 | this.update_ui(); 733 | } 734 | 735 | input_backspace() { 736 | if (! this.current_expn || this.current_expn.is_empty()) { 737 | if (this.expressions.length > 1 || 738 | (! this.current_expn && this.expressions.length >= 1)) 739 | { 740 | // Delete the current one's DOM 741 | if (this.current_expn) { 742 | this.current_expn.expn_el.remove(); 743 | this.current_expn.result_el.remove(); 744 | this.expressions.pop(); 745 | } 746 | 747 | // Scrap it and make the previous one 'current' 748 | this.current_expn = this.expressions[this.expressions.length - 1]; 749 | 750 | // Uncommit the previous one 751 | this.current_expn.uncommit(); 752 | // Mark its numbers as no longer used 753 | for (let [i, part] of this.current_expn.parts.entries()) { 754 | if (i % 2 === 0) { 755 | this.game.used[part.index] = false; 756 | this.number_els[part.index].classList.remove('used'); 757 | } 758 | } 759 | // Remove everything 760 | this.game.numbers.pop(); 761 | this.game.used.pop(); 762 | this.number_els.pop(); 763 | } 764 | } 765 | else { 766 | this.current_expn.backspace(); 767 | } 768 | 769 | this.update_ui(); 770 | } 771 | 772 | input_done() { 773 | if (! this.current_expn) 774 | return; 775 | 776 | let result = this.current_expn.commit(); 777 | if (result) { 778 | this.game.numbers.push(result.value); 779 | this.game.used.push(false); 780 | 781 | this.number_els.push(result.element); 782 | result.element.setAttribute('data-index', this.game.numbers.length - 1); 783 | 784 | let off = Math.abs(result.value - this.game.target); 785 | if (off === 0) { 786 | result.element.classList.add('win3'); 787 | } 788 | else if (off <= 5) { 789 | result.element.classList.add('win2'); 790 | } 791 | else if (off <= 10) { 792 | result.element.classList.add('win1'); 793 | } 794 | 795 | for (let index of result.used) { 796 | this.game.used[index] = true; 797 | this.number_els[index].classList.add('used'); 798 | } 799 | 800 | // Add a new expression only if at least 2 unused numbers remain 801 | if (this.game.used.filter(x => ! x).length >= 2) { 802 | this.add_new_expression(); 803 | } 804 | else { 805 | this.current_expn = null; 806 | } 807 | } 808 | this.update_ui(); 809 | } 810 | 811 | copy_link() { 812 | // Rebuild this without any query or fragment 813 | let base_url = location.origin + location.pathname; 814 | 815 | // Construct the fragment 816 | let params = new URLSearchParams([['seed', this.game.rng.seed]]); 817 | 818 | return navigator.clipboard.writeText(base_url + '#' + params.toString()); 819 | } 820 | 821 | copy_results() { 822 | if (! this.game.win) 823 | return; 824 | 825 | let text = []; 826 | 827 | if (this.daily_mode) { 828 | text.push(`daily digitle ${this.daily_date}`); 829 | if (this.daily_number > 1) { 830 | text.push(' #', this.daily_number); 831 | } 832 | } 833 | else { 834 | text.push(`random digitle `); 835 | text.push(this.game.rng.seed); 836 | } 837 | text.push("\n"); 838 | 839 | for (let expn of this.expressions) { 840 | if (expn === this.current_expn) 841 | // Still pending, ignore it 842 | continue; 843 | 844 | for (let [i, part] of expn.parts.entries()) { 845 | if (i % 2 === 0) { 846 | if (part.index < 6) { 847 | text.push("🟦"); 848 | } 849 | else { 850 | text.push("🟨"); 851 | } 852 | } 853 | else { 854 | text.push(OPERATORS[part.value].emoji); 855 | } 856 | } 857 | text.push("\n"); 858 | } 859 | 860 | // TODO indicate all numbers used? hm 861 | text.push("⭐".repeat(this.game.win.stars)); 862 | text.push(" "); 863 | if (this.game.win.off === 0) { 864 | text.push("perfect!"); 865 | } 866 | else { 867 | text.push(`${this.game.win.off} away`); 868 | } 869 | 870 | return navigator.clipboard.writeText(text.join("")); 871 | } 872 | 873 | confirm_copy(ev) { 874 | let clipboard = mk('div.clipboard-confirm', "📋"); 875 | clipboard.style.left = `${ev.clientX}px`; 876 | clipboard.style.top = `${ev.clientY}px`; 877 | document.body.append(clipboard); 878 | setTimeout(() => clipboard.remove(), 1000); 879 | } 880 | } 881 | 882 | // TODO: 883 | // - figure out a way to accept a partial answer 884 | // - obviously if you hit the target, you win immediately (unless on "hard mode" where you must use every number) 885 | // - if you run out of numbers and your last one is within X, accept that? (eh but, you might want to backspace and try again) 886 | // - default to daily, but add random twiddles 887 | // - hard mode: must use every number 888 | // - big number pool: normal (25s), hard (12, 37, 62, 87), awkward (gross primes: 17, 43, 71, 89), chaos (anything from 11-100) 889 | // - force count of big numbers 890 | // - do it in other bases?? 891 | // - store prefs 892 | // - remember streak, score? 893 | // - fragment trick 894 | // - limit intermediate results to 5 digits 895 | // - hide last expression and ignore input when only one number is left 896 | // - give this an embed and a favicon i guess 897 | --------------------------------------------------------------------------------