From 84d93eb91048301130a565fdf8a3733e7f949922 Mon Sep 17 00:00:00 2001 From: skedwards88 Date: Mon, 12 Aug 2024 18:48:52 -0700 Subject: [PATCH 01/36] generate puzzle from custom string --- src/components/App.js | 3 +- src/components/GameOver.js | 1 - src/logic/arrayToGrid.js | 18 ++++ src/logic/arrayToGrid.test.js | 32 ++++++ src/logic/cipherLetter.js | 18 ++++ src/logic/cipherLetter.test.js | 85 +++++++++++++++ .../convertRepresentativeStringToGrid.js | 58 ++++++++++ .../convertRepresentativeStringToGrid.test.js | 100 ++++++++++++++++++ src/logic/gameInit.js | 42 +++++++- src/logic/gameReducer.js | 2 +- .../generatePuzzleFromRepresentativeString.js | 39 +++++++ src/logic/parseUrlQuery.js | 18 +++- 12 files changed, 403 insertions(+), 13 deletions(-) create mode 100644 src/logic/arrayToGrid.js create mode 100644 src/logic/arrayToGrid.test.js create mode 100644 src/logic/cipherLetter.js create mode 100644 src/logic/cipherLetter.test.js create mode 100644 src/logic/convertRepresentativeStringToGrid.js create mode 100644 src/logic/convertRepresentativeStringToGrid.test.js create mode 100644 src/logic/generatePuzzleFromRepresentativeString.js diff --git a/src/components/App.js b/src/components/App.js index fcfdbb0..332e3f2 100644 --- a/src/components/App.js +++ b/src/components/App.js @@ -19,7 +19,7 @@ import {hasVisitedSince} from "../common/hasVisitedSince"; export default function App() { // If a query string was passed, // parse it to get the data to regenerate the game described by the query string - const [seed, numLetters] = parseUrlQuery(); + const [isCustom, seed, numLetters] = parseUrlQuery(); // Determine when the player last visited the game // This is used to determine whether to show the rules or an announcement instead of the game @@ -47,6 +47,7 @@ export default function App() { { seed, numLetters, + isCustom, }, gameInit, ); diff --git a/src/components/GameOver.js b/src/components/GameOver.js index 4acf5cf..757319d 100644 --- a/src/components/GameOver.js +++ b/src/components/GameOver.js @@ -33,7 +33,6 @@ export default function GameOver({dispatchGameState, gameState, setDisplay}) { appName="Crossjig" text="Check out this word puzzle!" url="https://crossjig.com" - query="puzzle" seed={`${gameState.seed}_${gameState.numLetters}`} > diff --git a/src/logic/arrayToGrid.js b/src/logic/arrayToGrid.js new file mode 100644 index 0000000..308efbe --- /dev/null +++ b/src/logic/arrayToGrid.js @@ -0,0 +1,18 @@ +//todo put in word logic package +// Converts a 1D array to a 2D square array +export function arrayToGrid(array) { + // Error if array length does not allow for a square grid + if (!Number.isInteger(Math.sqrt(array.length))) { + throw new Error("Array length cannot form a square grid"); + } + + const gridSize = Math.sqrt(array.length); + let grid = []; + for (let rowIndex = 0; rowIndex < gridSize; rowIndex++) { + const start = rowIndex * gridSize; + const end = start + gridSize; + const row = array.slice(start, end); + grid.push(row); + } + return grid; +} diff --git a/src/logic/arrayToGrid.test.js b/src/logic/arrayToGrid.test.js new file mode 100644 index 0000000..6c0edeb --- /dev/null +++ b/src/logic/arrayToGrid.test.js @@ -0,0 +1,32 @@ +import {arrayToGrid} from "./arrayToGrid"; + +describe("arrayToGrid", () => { + test("converts a 1D array to a 2D array", () => { + const input = [1, "2", 3, 4, 5, "Z", 7, 8, 9]; + const output = arrayToGrid(input); + expect(output).toEqual([ + [1, "2", 3], + [4, 5, "Z"], + [7, 8, 9], + ]); + }); + + test("works on arrays of length 1", () => { + const input = [10]; + const output = arrayToGrid(input); + expect(output).toEqual([[10]]); + }); + + test("empty arrays are returned as empty arrays", () => { + const input = []; + const output = arrayToGrid(input); + expect(output).toEqual([]); + }); + + test("errors if array doesn't form a square", () => { + const input = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]; + expect(() => arrayToGrid(input)).toThrow( + "Array length cannot form a square grid", + ); + }); +}); diff --git a/src/logic/cipherLetter.js b/src/logic/cipherLetter.js new file mode 100644 index 0000000..c230fc0 --- /dev/null +++ b/src/logic/cipherLetter.js @@ -0,0 +1,18 @@ +export function cipherLetter(letter, shift) { + // Error if the letter is not a single uppercase character + if (!/^[A-Z]$/.test(letter)) { + throw new Error("Input must be a single uppercase character A-Z"); + } + + // Convert the letter to its ASCII code + const ascii = letter.charCodeAt(0); + + // Shift the ASCII by the shift amount, wrapping around if necessary. + // -65 converts the ASCII code for A-Z to a + // number between 0 and 25 (corresponding to alphabet position) + const shiftedAscii = ((((ascii - 65 + shift) % 26) + 26) % 26) + 65; + + const cipheredLetter = String.fromCharCode(shiftedAscii); + + return cipheredLetter; +} diff --git a/src/logic/cipherLetter.test.js b/src/logic/cipherLetter.test.js new file mode 100644 index 0000000..e1c0188 --- /dev/null +++ b/src/logic/cipherLetter.test.js @@ -0,0 +1,85 @@ +import {cipherLetter} from "./cipherLetter"; + +describe("cipherLetter", () => { + test("correctly shifts a middle letter with a positive shift", () => { + expect(cipherLetter("M", 5)).toBe("R"); + }); + + test("correctly shifts a middle letter with a negative shift", () => { + expect(cipherLetter("M", -5)).toBe("H"); + }); + + test("returns the same letter when shifted by 26", () => { + expect(cipherLetter("M", 26)).toBe("M"); + }); + + test("ciphering a letter and then deciphering it with the negative shift returns the original letter", () => { + const letter = "M"; + const shift = 5; + const cipheredLetter = cipherLetter(letter, shift); + const decipheredLetter = cipherLetter(cipheredLetter, -shift); + expect(decipheredLetter).toBe(letter); + }); + + test("wraps from 'Z' to 'A' with a positive shift", () => { + expect(cipherLetter("Z", 1)).toBe("A"); + }); + + test("wraps from 'A' to 'Z' with a negative shift", () => { + expect(cipherLetter("A", -1)).toBe("Z"); + }); + + test("errors for lowercase letters", () => { + expect(() => cipherLetter("a", 1)).toThrow( + "Input must be a single uppercase character A-Z", + ); + }); + + test("errors for non-alphabetical characters", () => { + expect(() => cipherLetter("1", 1)).toThrow( + "Input must be a single uppercase character A-Z", + ); + }); + + test("errors for empty strings", () => { + expect(() => cipherLetter("", 1)).toThrow( + "Input must be a single uppercase character A-Z", + ); + }); + + test("errors for multiple characters", () => { + expect(() => cipherLetter("AB", 1)).toThrow( + "Input must be a single uppercase character A-Z", + ); + }); + + test("errors for objects", () => { + expect(() => cipherLetter({a: 5}, 1)).toThrow( + "Input must be a single uppercase character A-Z", + ); + }); + + test("errors for numbers", () => { + expect(() => cipherLetter(5, 1)).toThrow( + "Input must be a single uppercase character A-Z", + ); + }); + + test("errors for arrays", () => { + expect(() => cipherLetter([5], 1)).toThrow( + "Input must be a single uppercase character A-Z", + ); + }); + + test("errors for undefined", () => { + expect(() => cipherLetter(undefined, 1)).toThrow( + "Input must be a single uppercase character A-Z", + ); + }); + + test("errors for null", () => { + expect(() => cipherLetter(null, 1)).toThrow( + "Input must be a single uppercase character A-Z", + ); + }); +}); diff --git a/src/logic/convertRepresentativeStringToGrid.js b/src/logic/convertRepresentativeStringToGrid.js new file mode 100644 index 0000000..3d4320a --- /dev/null +++ b/src/logic/convertRepresentativeStringToGrid.js @@ -0,0 +1,58 @@ +import {arrayToGrid} from "./arrayToGrid"; +import {cipherLetter} from "./cipherLetter"; + +// Converts a string of letters and integers into a square grid of single letters and empty strings. +// The first character in the string represents how much the letters in the string have been shifted in the alphabet. +// Integers (excluding the first character) in the string are expanded into an equivalent number of empty strings. +// Letters in the string are capitalized and shifted in the alphabet by the amount described by the first character. +export function convertRepresentativeStringToGrid(string) { + // error if the string includes anything other than letters and numbers + if (!/^[a-zA-Z0-9]+$/.test(string)) { + throw new Error( + "Input string must only contain letters and numbers, and must not be empty", + ); + } + + // error if the first character is not an integer + if (!/^\d+$/.test(string[0])) { + throw new Error("First character in input string must be an integer that represents the cipher shift"); + } + + // The first character in the string is the cipher shift; the rest is the grid representation + const cipherShift = parseInt(string[0]); + const representativeString = string.slice(1); + + // Convert the string to a list of stringified integers and single letters + // e.g. "2A11GT1" becomes ['2', 'A', '11','G', 'T', '1','1'] + const splitString = representativeString.match(/\d+|[a-zA-Z]/gi); + + // Expand the stringified integers in the list to an equal number of empty strings + // Also capitalize the letters + let list = []; + for (const value of splitString) { + if (/[a-zA-Z]/.test(value)) { + const decipheredLetter = cipherLetter(value.toUpperCase(), cipherShift); + list.push(decipheredLetter); + } else { + const numSpaces = parseInt(value); + list = [...list, ...Array(numSpaces).fill("")]; + } + } + + // Error if the list does not form a square grid between 8x8 and 12x12 + const dimension = Math.sqrt(list.length); + if (dimension % 1 !== 0) { + throw new Error("Input string does not form a square grid"); + } + if (dimension < 8 || dimension > 12) { + throw new Error("dimension Input string must form a grid between 8x8 and 12x12"); + } + + // I'm assuming that people won't build custom query strings outside of the UI, + // so I don't need to remove whitespace from the edges or center the grid. + // (Since this is done when the custom query is generated via the UI.) + // By this same logic, I'm not validating that the puzzle consists of known words. + + const grid = arrayToGrid(list); + return grid; +} diff --git a/src/logic/convertRepresentativeStringToGrid.test.js b/src/logic/convertRepresentativeStringToGrid.test.js new file mode 100644 index 0000000..19aea01 --- /dev/null +++ b/src/logic/convertRepresentativeStringToGrid.test.js @@ -0,0 +1,100 @@ +import {convertRepresentativeStringToGrid} from "./convertRepresentativeStringToGrid"; + +describe("convertRepresentativeStringToGrid", () => { + test("converts a representative string with consecutive empty strings represented by a the number of empty strings into a 2D grid of single characters and empty strings.", () => { + const input = "02ABCDE6F1G5HIJKLM4N1O1P5Q1R1S5T2U6VWXYZA4B2C9DEFGH5I5"; + + const output = convertRepresentativeStringToGrid(input); + + expect(output).toEqual([ + ["", "", "A", "B", "C", "D", "E", "", "", ""], + ["", "", "", "F", "", "G", "", "", "", ""], + ["", "H", "I", "J", "K", "L", "M", "", "", ""], + ["", "N", "", "O", "", "P", "", "", "", ""], + ["", "Q", "", "R", "", "S", "", "", "", ""], + ["", "T", "", "", "U", "", "", "", "", ""], + ["", "V", "W", "X", "Y", "Z", "A", "", "", ""], + ["", "B", "", "", "C", "", "", "", "", ""], + ["", "", "", "", "D", "E", "F", "G", "H", ""], + ["", "", "", "", "I", "", "", "", "", ""], + ]); + }); + + test("shifts the letters by the amount indicated by the first character", () => { + const input = "32ABCDE6F1G5HIJKLM4N1O1P5Q1R1S5T2U6VWXYZA4B2C9DEFGH5I5"; + + const output = convertRepresentativeStringToGrid(input); + + expect(output).toEqual([ + ["", "", "D", "E", "F", "G", "H", "", "", ""], + ["", "", "", "I", "", "J", "", "", "", ""], + ["", "K", "L", "M", "N", "O", "P", "", "", ""], + ["", "Q", "", "R", "", "S", "", "", "", ""], + ["", "T", "", "U", "", "V", "", "", "", ""], + ["", "W", "", "", "X", "", "", "", "", ""], + ["", "Y", "Z", "A", "B", "C", "D", "", "", ""], + ["", "E", "", "", "F", "", "", "", "", ""], + ["", "", "", "", "G", "H", "I", "J", "K", ""], + ["", "", "", "", "L", "", "", "", "", ""], + ]); + }); + + test("returns an empty grid if there are no letters in the string", () => { + const input = "364"; + + const output = convertRepresentativeStringToGrid(input); + + expect(output).toEqual([ + ["", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", ""], + ]); + }); + + test("errors if input string contains characters other than letters and numbers", () => { + const input = "2-ABCDE5G"; + expect(() => convertRepresentativeStringToGrid(input)).toThrow( + "Input string must only contain letters and numbers, and must not be empty", + ); + }); + + test("errors if input string is empty", () => { + const input = ""; + expect(() => convertRepresentativeStringToGrid(input)).toThrow( + "Input string must only contain letters and numbers, and must not be empty", + ); + }); + + test("errors if first character is not an integer", () => { + const input = "ABCDEFGHI"; + expect(() => convertRepresentativeStringToGrid(input, 3.5)).toThrow( + "First character in input string must be an integer that represents the cipher shift", + ); + }); + + test("throws an error if the resulting list does not form a square grid", () => { + const input = "0AB2EF4"; + expect(() => convertRepresentativeStringToGrid(input)).toThrow( + "Input string does not form a square grid", + ); + }); + + test("throws an error if the resulting grid is smaller than 8x8", () => { + const input = "0AB2EF3"; + expect(() => convertRepresentativeStringToGrid(input)).toThrow( + "Input string must form a grid between 8x8 and 12x12", + ); + }); + + test("throws an error if the resulting grid is larger than 12x12", () => { + const input = "0AB165CD"; + expect(() => convertRepresentativeStringToGrid(input)).toThrow( + "Input string must form a grid between 8x8 and 12x12", + ); + }); +}); diff --git a/src/logic/gameInit.js b/src/logic/gameInit.js index 93390a4..042b4e7 100644 --- a/src/logic/gameInit.js +++ b/src/logic/gameInit.js @@ -4,6 +4,7 @@ import getRandomSeed from "../common/getRandomSeed"; import getDailySeed from "../common/getDailySeed"; import {getNumLettersForDay} from "./getNumLettersForDay"; import {getGridSizeForLetters} from "./getGridSizeForLetters"; +import {generatePuzzleFromRepresentativeString} from "./generatePuzzleFromRepresentativeString"; function validateSavedState(savedState) { if (typeof savedState !== "object" || savedState === null) { @@ -32,6 +33,7 @@ export function gameInit({ validityOpacity = 0.15, useSaved = true, isDaily = false, + isCustom = false, seed, }) { const savedStateName = isDaily ? "dailyCrossjigState" : "crossjigState"; @@ -52,9 +54,9 @@ export function gameInit({ savedState && savedState.seed && //todo verify comment clarity - // If daily, use the saved state if the seed matches + // If daily or custom, use the saved state if the seed matches // otherwise, we don't care if the seed matches - (!isDaily || savedState.seed == seed) && + ((!isDaily && !isCustom) || savedState.seed == seed) && validateSavedState(savedState) && // Use the saved state if daily even if the game is solved // otherwise, don't use the saved state if the game is solved @@ -63,11 +65,41 @@ export function gameInit({ return savedState; } - const minLetters = isDaily ? getNumLettersForDay() : numLetters || 30; + let pieces; + let maxShiftLeft; + let maxShiftRight; + let maxShiftUp; + let maxShiftDown; + let minLetters = isDaily ? getNumLettersForDay() : numLetters || 30; let gridSize = getGridSizeForLetters(minLetters); - let {pieces, maxShiftLeft, maxShiftRight, maxShiftUp, maxShiftDown} = - generatePuzzle({gridSize: gridSize, minLetters: minLetters, seed: seed}); + // If custom, attempt to generate the custom puzzle represented by the seed. + // If any errors are raised, catch them and just generate a random puzzle instead + if (isCustom) { + try { + ({ + gridSize, + minLetters, + pieces, + maxShiftLeft, + maxShiftRight, + maxShiftUp, + maxShiftDown, + } = generatePuzzleFromRepresentativeString({representativeString: seed})); + } catch (error) { + console.error(error); + + ({pieces, maxShiftLeft, maxShiftRight, maxShiftUp, maxShiftDown} = + generatePuzzle({ + gridSize: gridSize, + minLetters: minLetters, + seed: seed, + })); + } + } else { + ({pieces, maxShiftLeft, maxShiftRight, maxShiftUp, maxShiftDown} = + generatePuzzle({gridSize: gridSize, minLetters: minLetters, seed: seed})); + } // Pad the puzzle with a square on each side and recenter the solution maxShiftRight++; diff --git a/src/logic/gameReducer.js b/src/logic/gameReducer.js index e55faec..9b0448d 100644 --- a/src/logic/gameReducer.js +++ b/src/logic/gameReducer.js @@ -499,7 +499,7 @@ function updateCompletionState(gameState) { export function gameReducer(currentGameState, payload) { if (payload.action === "newGame") { - return gameInit({...payload, seed: undefined, useSaved: false}); + return gameInit({...payload, seed: undefined, useSaved: false, isCustom: false}); } else if (payload.action === "changeValidityOpacity") { return { ...currentGameState, diff --git a/src/logic/generatePuzzleFromRepresentativeString.js b/src/logic/generatePuzzleFromRepresentativeString.js new file mode 100644 index 0000000..6c86e8c --- /dev/null +++ b/src/logic/generatePuzzleFromRepresentativeString.js @@ -0,0 +1,39 @@ +import seedrandom from "seedrandom"; +import {makePieces} from "./makePieces"; +import {shuffleArray, getMaxShifts} from "@skedwards88/word_logic"; +import {convertRepresentativeStringToGrid} from "./convertRepresentativeStringToGrid"; + +export function generatePuzzleFromRepresentativeString({representativeString}) { + const pseudoRandomGenerator = seedrandom(representativeString); + + const grid = convertRepresentativeStringToGrid(representativeString); + + const gridSize = grid.length; + const minLetters = grid.flat().filter((i) => i).length; + + const {maxShiftLeft, maxShiftRight, maxShiftUp, maxShiftDown} = getMaxShifts( + grid, + "", + ); + + const pieces = shuffleArray(makePieces(grid), pseudoRandomGenerator); + const pieceData = pieces.map((piece, index) => ({ + letters: piece.letters, + id: index, + boardTop: undefined, + boardLeft: undefined, + poolIndex: index, + solutionTop: piece.solutionTop, + solutionLeft: piece.solutionLeft, + })); + + return { + pieces: pieceData, + maxShiftLeft: maxShiftLeft, + maxShiftRight: maxShiftRight, + maxShiftUp: maxShiftUp, + maxShiftDown: maxShiftDown, + gridSize, + minLetters, + }; +} diff --git a/src/logic/parseUrlQuery.js b/src/logic/parseUrlQuery.js index 3c1ddbf..9881004 100644 --- a/src/logic/parseUrlQuery.js +++ b/src/logic/parseUrlQuery.js @@ -1,14 +1,22 @@ export function parseUrlQuery() { const searchParams = new URLSearchParams(document.location.search); - const seedQuery = searchParams.get("puzzle"); + const query = searchParams.get("id"); - // The seed query consists of two parts: the seed and the min number of letters, separated by an underscore + let isCustom; let numLetters; let seed; - if (seedQuery) { - [seed, numLetters] = seedQuery.split("_"); + + // The query differs depending on whether it represents a random puzzle or a custom puzzle: + // For custom puzzles, it is "custom-" followed by a representative string + // For random puzzles, it is the seed and the min number of letters, separated by an underscore + if (query && query.startsWith("custom-")) { + seed = query.substring("custom-".length); + isCustom = true; + } else if (query) { + [seed, numLetters] = query.split("_"); numLetters = parseInt(numLetters); + isCustom = false; } - return [seed, numLetters]; + return [isCustom, seed, numLetters]; } From aceede956dd25f7d848dcd2aca6fa94055aa679c Mon Sep 17 00:00:00 2001 From: skedwards88 Date: Mon, 12 Aug 2024 18:56:21 -0700 Subject: [PATCH 02/36] prettier --- src/logic/arrayToGrid.js | 1 - src/logic/convertRepresentativeStringToGrid.js | 6 ++++-- src/logic/gameReducer.js | 7 ++++++- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/logic/arrayToGrid.js b/src/logic/arrayToGrid.js index 308efbe..71538d4 100644 --- a/src/logic/arrayToGrid.js +++ b/src/logic/arrayToGrid.js @@ -1,4 +1,3 @@ -//todo put in word logic package // Converts a 1D array to a 2D square array export function arrayToGrid(array) { // Error if array length does not allow for a square grid diff --git a/src/logic/convertRepresentativeStringToGrid.js b/src/logic/convertRepresentativeStringToGrid.js index 3d4320a..083da71 100644 --- a/src/logic/convertRepresentativeStringToGrid.js +++ b/src/logic/convertRepresentativeStringToGrid.js @@ -15,7 +15,9 @@ export function convertRepresentativeStringToGrid(string) { // error if the first character is not an integer if (!/^\d+$/.test(string[0])) { - throw new Error("First character in input string must be an integer that represents the cipher shift"); + throw new Error( + "First character in input string must be an integer that represents the cipher shift", + ); } // The first character in the string is the cipher shift; the rest is the grid representation @@ -45,7 +47,7 @@ export function convertRepresentativeStringToGrid(string) { throw new Error("Input string does not form a square grid"); } if (dimension < 8 || dimension > 12) { - throw new Error("dimension Input string must form a grid between 8x8 and 12x12"); + throw new Error("Input string must form a grid between 8x8 and 12x12"); } // I'm assuming that people won't build custom query strings outside of the UI, diff --git a/src/logic/gameReducer.js b/src/logic/gameReducer.js index 9b0448d..161eb90 100644 --- a/src/logic/gameReducer.js +++ b/src/logic/gameReducer.js @@ -499,7 +499,12 @@ function updateCompletionState(gameState) { export function gameReducer(currentGameState, payload) { if (payload.action === "newGame") { - return gameInit({...payload, seed: undefined, useSaved: false, isCustom: false}); + return gameInit({ + ...payload, + seed: undefined, + useSaved: false, + isCustom: false, + }); } else if (payload.action === "changeValidityOpacity") { return { ...currentGameState, From f4f92ec5f1c20d3e57537489b1ad3924f1c003ad Mon Sep 17 00:00:00 2001 From: skedwards88 Date: Fri, 16 Aug 2024 07:02:42 -0700 Subject: [PATCH 03/36] WIP ui updates --- TODO.md | 4 + src/components/App.js | 27 ++++++ src/components/CustomBoard.js | 63 ++++++++++++++ src/components/CustomCreation.js | 46 ++++++++++ src/components/CustomPool.js | 27 ++++++ .../convertGridToRepresentativeString.js | 40 +++++++++ .../convertGridToRepresentativeString.test.js | 26 ++++++ src/logic/customInit.js | 27 ++++++ src/logic/customReducer.js | 87 +++++++++++++++++++ 9 files changed, 347 insertions(+) create mode 100644 src/components/CustomBoard.js create mode 100644 src/components/CustomCreation.js create mode 100644 src/components/CustomPool.js create mode 100644 src/logic/convertGridToRepresentativeString.js create mode 100644 src/logic/convertGridToRepresentativeString.test.js create mode 100644 src/logic/customInit.js create mode 100644 src/logic/customReducer.js diff --git a/TODO.md b/TODO.md index c243f18..7a0f515 100644 --- a/TODO.md +++ b/TODO.md @@ -28,3 +28,7 @@ For screenshots: ] const pieces = [{"letters":[["P","U","Z"],["L","",""],["A","",""]],"solutionTop":3,"solutionLeft":2},{"letters":[["B"],["R"],["A"]],"solutionTop":2,"solutionLeft":9},{"letters":[["","T","H"],["","E",""],["O","R","D"]],"solutionTop":6,"solutionLeft":5},{"letters":[["M","E"]],"solutionTop":5,"solutionLeft":3},{"letters":[["","I",""],["I","N","K"]],"solutionTop":5,"solutionLeft":8},{"letters":[["W"]],"solutionTop":8,"solutionLeft":4},{"letters":[["G"]],"solutionTop":5,"solutionLeft":1},{"letters":[["Y"]],"solutionTop":6,"solutionLeft":2},{"letters":[["Z","L","E"],["","E",""],["","T",""]],"solutionTop":3,"solutionLeft":5}]; + +// todo wip new, incomplete file +- Use the hasVisited state to announce +- put arrayToGrid to common or to word-logic package diff --git a/src/components/App.js b/src/components/App.js index 332e3f2..ab39664 100644 --- a/src/components/App.js +++ b/src/components/App.js @@ -3,6 +3,7 @@ import Game from "./Game"; import Heart from "./Heart"; import Rules from "./Rules"; import Stats from "./Stats"; +import CustomCreation from "./CustomCreation"; import ControlBar from "./ControlBar"; import { handleAppInstalled, @@ -10,8 +11,10 @@ import { } from "../common/handleInstall"; import Settings from "./Settings"; import {gameInit} from "../logic/gameInit"; +import {customInit} from "../logic/customInit"; import getDailySeed from "../common/getDailySeed"; import {gameReducer} from "../logic/gameReducer"; +import {customReducer} from "../logic/customReducer"; import {parseUrlQuery} from "../logic/parseUrlQuery"; import {getInitialState} from "../common/getInitialState"; import {hasVisitedSince} from "../common/hasVisitedSince"; @@ -58,6 +61,13 @@ export default function App() { gameInit, ); + const [customState, dispatchCustomState] = React.useReducer( + customReducer, + { + }, + customInit, + ); + // todo consolidate lastVisited and setLastOpened? const [, setLastOpened] = React.useState(Date.now()); @@ -183,6 +193,23 @@ export default function App() { ); + case "custom": + return ( +
+
+ +
+ +
+ ) + default: return (
diff --git a/src/components/CustomBoard.js b/src/components/CustomBoard.js new file mode 100644 index 0000000..7dac830 --- /dev/null +++ b/src/components/CustomBoard.js @@ -0,0 +1,63 @@ +// todo wip new, incomplete file + +import React from "react"; +import Piece from "./Piece"; +import DragShadow from "./DragShadow"; +import {countingGrid} from "./Board"; + +export default function CustomBoard({ + customState, + dispatchGameState, + dragDestination, + dragPieceIDs +}) { + + const boardPieces = customState.pieces.filter( + (piece) => piece.boardTop >= 0 && piece.boardLeft >= 0, + ); + + const pieceElements = boardPieces.map((piece) => ( + + )); + + // Any pieces that are currently being dragged over the board will render on the board as a single drag shadow + let dragShadow; + if (dragDestination?.where === "board") { + const draggedPieces = customState.pieces.filter((piece) => + dragPieceIDs.includes(piece.id), + ); + const grid = countingGrid(12, 12, draggedPieces); + dragShadow = ( + + ); + } + + return ( +
{ + event.preventDefault(); + dispatchGameState({ + action: "shiftStart",//todo rename to boardShiftStart + pointerID: event.pointerId, + pointer: {x: event.clientX, y: event.clientY}, + }); + }} + > + {pieceElements} + {dragShadow} +
+ ); +} diff --git a/src/components/CustomCreation.js b/src/components/CustomCreation.js new file mode 100644 index 0000000..4cc4616 --- /dev/null +++ b/src/components/CustomCreation.js @@ -0,0 +1,46 @@ +// todo wip new, incomplete file + +import React from "react"; +import CustomPool from "./CustomPool"; +import CustomBoard from "./CustomBoard"; +import DragGroup from "./DragGroup"; + +function CustomCreation({dispatchCustomState, customState, validityOpacity}) { + // dragCount ensures a different key each time, so a fresh DragGroup is mounted even if there's + // no render between one drag ending and the next one starting. + const dragGroup = customState.dragState ? ( + + ) : null; + + return ( +
+ 0} + dragDestination={customState.dragState?.destination} + dragPieceIDs={customState.dragState?.pieceIDs} + > + + {dragGroup} +
+ ); +} + +export default CustomCreation; diff --git a/src/components/CustomPool.js b/src/components/CustomPool.js new file mode 100644 index 0000000..ad02c4f --- /dev/null +++ b/src/components/CustomPool.js @@ -0,0 +1,27 @@ +// todo wip new, incomplete file + +import React from "react"; +import Piece from "./Piece"; + + + +// Unlike the game pool, where letters in the pool can be moved around, letters in the pool never get used up or reordered. +export default function CustomPool({customState, dispatchGameState}) { + + const poolPieces = customState.pieces.filter((piece) => piece.poolIndex >= 0); + + const pieceElements = poolPieces.map((piece) => ( +
+ +
+ )); + + return
{pieceElements}
; +} diff --git a/src/logic/convertGridToRepresentativeString.js b/src/logic/convertGridToRepresentativeString.js new file mode 100644 index 0000000..fbc15cf --- /dev/null +++ b/src/logic/convertGridToRepresentativeString.js @@ -0,0 +1,40 @@ +// todo wip new, incomplete file + +// Converts a 2D grid of letters and spaces into a representative string. +// Spaces are represented by an integer indicating the number of consecutive spaces. +// (Spaces that span rows are considered part of the same consecutive group of spaces.) +export function convertGridToRepresentativeString(grid) { + // validateGrid(grid) todo call this function. include in tests. + + // todo remove whitespace from edges, down to 8x8 + // todo center the grid + // todo validate that the puzzle consists of known words vert and horiz + + + let stringifiedGrid = ""; + let spaceCount = 0; + + for (const row of grid) { + for (const character of row) { + if (character === "") { + // If the character is a space, just increase the space count + spaceCount++; + } else { + // Otherwise, add the space count (if it is non-zero) and the letter to the string, + // and reset the space count + if (spaceCount) { + stringifiedGrid += spaceCount; + spaceCount = 0; + } + stringifiedGrid += character; + } + } + } + + // If there are trailing spaces, add them to the string + if (spaceCount) { + stringifiedGrid += spaceCount; + } + + return stringifiedGrid; +} diff --git a/src/logic/convertGridToRepresentativeString.test.js b/src/logic/convertGridToRepresentativeString.test.js new file mode 100644 index 0000000..6fdd76f --- /dev/null +++ b/src/logic/convertGridToRepresentativeString.test.js @@ -0,0 +1,26 @@ +// todo wip new, incomplete file + +import {convertGridToRepresentativeString} from "./convertGridToRepresentativeString"; + +describe("convertGridToRepresentativeString", () => { + test("converts a 2D grid of single characters and empty strings into a representative string with consecutive empty strings represented by a the number of empty strings. Row breaks are ignored.", () => { + const input = [ + ["", "", "L", "U", "M", "P", "Y", "", "", ""], + ["", "", "", "L", "", "I", "", "", "", ""], + ["", "F", "A", "T", "T", "E", "R", "", "", ""], + ["", "L", "", "R", "", "R", "", "", "", ""], + ["", "A", "", "A", "", "S", "", "", "", ""], + ["", "M", "", "", "R", "", "", "", "", ""], + ["", "E", "U", "R", "E", "K", "A", "", "", ""], + ["", "S", "", "", "A", "", "", "", "", ""], + ["", "", "", "", "D", "R", "A", "W", "S", ""], + ["", "", "", "", "S", "", "", "", "", ""], + ]; + + const output = convertGridToRepresentativeString(input); + + expect(output).toEqual( + "2LUMPY6L1I5FATTER4L1R1R5A1A1S5M2R6EUREKA4S2A9DRAWS5S5", + ); + }); +}); diff --git a/src/logic/customInit.js b/src/logic/customInit.js new file mode 100644 index 0000000..1a48ded --- /dev/null +++ b/src/logic/customInit.js @@ -0,0 +1,27 @@ +// todo wip new, incomplete file + +export function customInit() { + // todo saved state + + const alphabet = [ + 'A', 'B', 'C', 'D', 'E', 'F', + 'G', 'H', 'I', 'J', 'K', 'L', + 'M', 'N', 'O', 'P', 'Q', 'R', + 'S', 'T', 'U', 'V', 'W', 'X', + 'Y', 'Z' + ]; + + const pieces = alphabet.map((letter, index) => ({ + "letters": [ + [ + letter + ] + ], + "id": index, + "poolIndex": index, +})); + + return { + pieces, + } +} diff --git a/src/logic/customReducer.js b/src/logic/customReducer.js new file mode 100644 index 0000000..a29a4d1 --- /dev/null +++ b/src/logic/customReducer.js @@ -0,0 +1,87 @@ +import {dragStart, dragEnd, getConnectedPieceIDs, hasMoved} from "./gameReducer"; + +// todo wip new, incomplete file + +// todo reconcile currentGameState vs currentState +export function customReducer(currentGameState, payload) { + console.log("custom: " + payload.action) + console.log(currentGameState) + + // todo consolidate shared action handling between this reducer and other reducer. (pull out into functions.) + + if (payload.action === "dragStart") { + // Fired on pointerdown on a piece anywhere. + // Captures initial `dragState`. `destination` is initialized to where the piece already is. + const {pieceID, pointerID, pointer} = payload; + return dragStart({ + currentGameState, + isPartOfCurrentDrag: (piece) => piece.id === pieceID, + pointerID, + pointer, + isShifting: false, + }); + } else if (payload.action === "dragNeighbors") { + // Fired when the timer fires, if `!dragHasMoved`. + // + // Set `piece.isDragging` on all neighbors using `destination` to figure out + // which pieces are neighbors. Implemented by dropping the current piece, then picking + // it and all connected pieces up again. + const {dragState} = currentGameState; + if (dragState === undefined || dragState.pieceIDs.length !== 1) { + return currentGameState; + } + + const droppedGameState = dragEnd(currentGameState); + const connectedPieceIDs = getConnectedPieceIDs({ + pieces: droppedGameState.pieces, + gridSize: droppedGameState.gridSize, + draggedPieceID: dragState.pieceIDs[0], + }); + return dragStart({ + currentGameState: droppedGameState, + isPartOfCurrentDrag: (piece) => connectedPieceIDs.includes(piece.id), + pointerID: dragState.pointerID, + pointer: dragState.pointer, + isShifting: false, + previousDragState: dragState, + }); + } else if (payload.action === "dragMove") { + // Fired on pointermove and on lostpointercapture. + const prevDrag = currentGameState.dragState; + if (prevDrag === undefined) { + return currentGameState; + } + const {pointer, destination} = payload; + return { + ...currentGameState, + dragState: { + ...prevDrag, + pointer, + destination: destination ?? prevDrag.destination, + dragHasMoved: + prevDrag.dragHasMoved || hasMoved(prevDrag.pointerStart, pointer), + }, + }; + } else if (payload.action === "dragEnd") { + // Fired on lostpointercapture, after `dragMove`. + // + // Drop all dragged pieces to `destination` and clear `dragState`. + return dragEnd(currentGameState); + } else if (payload.action === "shiftStart") { + // Fired on pointerdown in an empty square on the board. + // + // Initializes `dragState`. Starts a drag on all pieces that are on the board. + // Sets `destination` to where they currently are. + const {pointerID, pointer} = payload; + return dragStart({ + currentGameState, + isPartOfCurrentDrag: (piece) => piece.boardTop !== undefined, + pointerID, + pointer, + isShifting: true, + }); + } else { + console.log(`unknown action: ${payload.action}`); + return currentGameState; + } +} From 1a0d340e9428bb298d68a930b8a44d3a01eb3609 Mon Sep 17 00:00:00 2001 From: skedwards88 Date: Fri, 16 Aug 2024 19:53:54 -0700 Subject: [PATCH 04/36] use consolidated piece update function --- .../generatePuzzleFromRepresentativeString.js | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/logic/generatePuzzleFromRepresentativeString.js b/src/logic/generatePuzzleFromRepresentativeString.js index 6c86e8c..8462abb 100644 --- a/src/logic/generatePuzzleFromRepresentativeString.js +++ b/src/logic/generatePuzzleFromRepresentativeString.js @@ -2,6 +2,7 @@ import seedrandom from "seedrandom"; import {makePieces} from "./makePieces"; import {shuffleArray, getMaxShifts} from "@skedwards88/word_logic"; import {convertRepresentativeStringToGrid} from "./convertRepresentativeStringToGrid"; +import {updatePieceDatum} from "./assemblePiece"; export function generatePuzzleFromRepresentativeString({representativeString}) { const pseudoRandomGenerator = seedrandom(representativeString); @@ -17,15 +18,12 @@ export function generatePuzzleFromRepresentativeString({representativeString}) { ); const pieces = shuffleArray(makePieces(grid), pseudoRandomGenerator); - const pieceData = pieces.map((piece, index) => ({ - letters: piece.letters, - id: index, - boardTop: undefined, - boardLeft: undefined, - poolIndex: index, - solutionTop: piece.solutionTop, - solutionLeft: piece.solutionLeft, - })); + const pieceData = pieces.map((piece, index) => + updatePieceDatum(piece, { + id: index, + poolIndex: index, + }), + ); return { pieces: pieceData, From 2e2aa7797028b27dbb52d0bc2d422577104a57d2 Mon Sep 17 00:00:00 2001 From: skedwards88 Date: Fri, 16 Aug 2024 19:54:25 -0700 Subject: [PATCH 05/36] custom UI working like game --- src/common/getInitialState.js | 2 + src/components/CustomBoard.js | 214 ++++++++++++++++++++---- src/components/CustomCreation.js | 28 ++-- src/components/CustomPool.js | 77 ++++++++- src/logic/customInit.js | 62 +++++-- src/logic/customReducer.js | 277 ++++++++++++++++++++++++++++--- 6 files changed, 569 insertions(+), 91 deletions(-) diff --git a/src/common/getInitialState.js b/src/common/getInitialState.js index faa34f7..6b89036 100644 --- a/src/common/getInitialState.js +++ b/src/common/getInitialState.js @@ -1,4 +1,6 @@ export function getInitialState(savedDisplay, hasVisited) { + // todo revert + return "custom"; if (!hasVisited) { return "rules"; } diff --git a/src/components/CustomBoard.js b/src/components/CustomBoard.js index 7dac830..6951bad 100644 --- a/src/components/CustomBoard.js +++ b/src/components/CustomBoard.js @@ -1,48 +1,159 @@ -// todo wip new, incomplete file - import React from "react"; import Piece from "./Piece"; import DragShadow from "./DragShadow"; -import {countingGrid} from "./Board"; +import {getGridFromPieces} from "../logic/getGridFromPieces"; +import {isKnown} from "@skedwards88/word_logic"; +import {trie} from "../logic/trie"; +import {getWordsFromPieces} from "../logic/getWordsFromPieces"; +import {transposeGrid} from "@skedwards88/word_logic"; + +// Returns a grid with the number of letters at each location in the grid +export function countingGrid(height, width, pieces) { + let grid = Array(height) + .fill(undefined) + .map(() => Array(width).fill(0)); + + for (let piece of pieces) { + const letters = piece.letters; + let top = piece.boardTop ?? piece.dragGroupTop; + for (let rowIndex = 0; rowIndex < letters.length; rowIndex++) { + let left = piece.boardLeft ?? piece.dragGroupLeft; + for (let colIndex = 0; colIndex < letters[rowIndex].length; colIndex++) { + if (letters[rowIndex][colIndex]) { + grid[top][left]++; + } + left++; + } + top++; + } + } + return grid; +} + +function getHorizontalValidityGrid({grid, originalWords}) { + // return a 2D array of bools indicating whether + // the position corresponds to a letter on the board + // that is part of a valid horizontal word + const height = grid.length; + const width = grid[0].length; + + const horizontalValidityGrid = Array(height) + .fill(undefined) + .map(() => Array(width).fill(false)); + + for (const [rowIndex, row] of grid.entries()) { + let word = ""; + let indexes = []; + for (const [columnIndex, letter] of row.entries()) { + if (letter != "") { + word += letter; + indexes.push(columnIndex); + } else { + if (word.length > 1) { + // If the word is one of the original words, always consider it valid (in case we updated the dictionary in the interim). + // Otherwise, check whether it is a word in the trie. + let isWord = originalWords.includes(word); + if (!isWord) { + ({isWord} = isKnown(word, trie)); + } + if (isWord) { + indexes.forEach( + (index) => (horizontalValidityGrid[rowIndex][index] = true), + ); + } + } + word = ""; + indexes = []; + } + } + // Also end the word if we reach the end of the row + if (word.length > 1) { + // If the word is one of the original words, always consider it valid (in case we updated the dictionary in the interim). + // Otherwise, check whether it is a word in the trie. + let isWord = originalWords.includes(word); + if (!isWord) { + ({isWord} = isKnown(word, trie)); + } + if (isWord) { + indexes.forEach( + (index) => (horizontalValidityGrid[rowIndex][index] = true), + ); + } + } + } + + return horizontalValidityGrid; +} + +function getWordValidityGrids({pieces, gridSize}) { + const originalWords = getWordsFromPieces({ + pieces, + gridSize, + solution: true, + }); + + const grid = getGridFromPieces({pieces, gridSize, solution: false}); + + const horizontalValidityGrid = getHorizontalValidityGrid({ + grid, + originalWords, + }); + + const transposedGrid = transposeGrid(grid); + const horizontalTransposedValidityGrid = getHorizontalValidityGrid({ + grid: transposedGrid, + originalWords, + }); + const verticalValidityGrid = transposeGrid(horizontalTransposedValidityGrid); + + return [horizontalValidityGrid, verticalValidityGrid]; +} export default function CustomBoard({ - customState, - dispatchGameState, + pieces, + gridSize, + dragPieceIDs, dragDestination, - dragPieceIDs + gameIsSolved, + dispatchGameState, + indicateValidity, }) { - - const boardPieces = customState.pieces.filter( + const boardPieces = pieces.filter( (piece) => piece.boardTop >= 0 && piece.boardLeft >= 0, ); + const overlapGrid = countingGrid(gridSize, gridSize, boardPieces); + const [horizontalValidityGrid, verticalValidityGrid] = indicateValidity + ? getWordValidityGrids({pieces, gridSize}) + : [undefined, undefined]; const pieceElements = boardPieces.map((piece) => ( )); - // Any pieces that are currently being dragged over the board will render on the board as a single drag shadow - let dragShadow; - if (dragDestination?.where === "board") { - const draggedPieces = customState.pieces.filter((piece) => - dragPieceIDs.includes(piece.id), - ); - const grid = countingGrid(12, 12, draggedPieces); - dragShadow = ( - - ); - } + // Any pieces that are currently being dragged over the board will render on the board as a single drag shadow + let dragShadow; + if (dragDestination?.where === "board") { + const draggedPieces = pieces.filter((piece) => + dragPieceIDs.includes(piece.id), + ); + const grid = countingGrid(gridSize, gridSize, draggedPieces); + dragShadow = ( + + ); + } return (
{ event.preventDefault(); dispatchGameState({ - action: "shiftStart",//todo rename to boardShiftStart + action: "shiftStart", pointerID: event.pointerId, - pointer: {x: event.clientX, y: event.clientY}, + pointerStartPosition: {x: event.clientX, y: event.clientY}, }); }} > @@ -61,3 +172,50 @@ export default function CustomBoard({
); } + +export function dragDestinationOnBoard(gameState, pointer) { + const boardRect = document.getElementById("board").getBoundingClientRect(); + if ( + gameState.dragState.destination.where === "board" || + (boardRect.left <= pointer.x && + pointer.x <= boardRect.right && + boardRect.top <= pointer.y && + pointer.y <= boardRect.bottom) + ) { + const draggedPieceIDs = gameState.dragState.pieceIDs; + const draggedPieces = gameState.pieces.filter((piece) => + draggedPieceIDs.includes(piece.id), + ); + + const groupHeight = Math.max( + ...draggedPieces.map( + (piece) => piece.dragGroupTop + piece.letters.length, + ), + ); + const groupWidth = Math.max( + ...draggedPieces.map( + (piece) => piece.dragGroupLeft + piece.letters[0].length, + ), + ); + const maxTop = gameState.gridSize - groupHeight; + const maxLeft = gameState.gridSize - groupWidth; + + // Subtract 1 before dividing because the board is n squares wide, but has n+1 1px borders. + // (It's admittedly silly to care about this, since the impact is only 1/n of a pixel!) + const squareWidth = (boardRect.width - 1) / gameState.gridSize; + const squareHeight = (boardRect.height - 1) / gameState.gridSize; + const pointerOffset = gameState.dragState.pointerOffset; + const unclampedLeft = Math.round( + (pointer.x - pointerOffset.x - boardRect.left) / squareWidth, + ); + const unclampedTop = Math.round( + (pointer.y - pointerOffset.y - boardRect.top) / squareHeight, + ); + const left = Math.max(0, Math.min(maxLeft, unclampedLeft)); + const top = Math.max(0, Math.min(maxTop, unclampedTop)); + + return {where: "board", top, left}; + } + + return undefined; +} diff --git a/src/components/CustomCreation.js b/src/components/CustomCreation.js index 4cc4616..56feb0f 100644 --- a/src/components/CustomCreation.js +++ b/src/components/CustomCreation.js @@ -1,12 +1,10 @@ -// todo wip new, incomplete file - import React from "react"; import CustomPool from "./CustomPool"; import CustomBoard from "./CustomBoard"; import DragGroup from "./DragGroup"; function CustomCreation({dispatchCustomState, customState, validityOpacity}) { - // dragCount ensures a different key each time, so a fresh DragGroup is mounted even if there's + // dragCount ensures a different key each time, so a fresh DragGroup is mounted even if there's // no render between one drag ending and the next one starting. const dragGroup = customState.dragState ? ( 0} - dragDestination={customState.dragState?.destination} + // indicateValidity={validityOpacity > 0} + indicateValidity={false} // todo resolve ^ (errors if true) dragPieceIDs={customState.dragState?.pieceIDs} + dragDestination={customState.dragState?.destination} + gridSize={12} > - {dragGroup} + pieces={customState.pieces} + dragDestination={customState.dragState?.destination} + dispatchGameState={dispatchCustomState} // todo just rename dispatch params to dispatcher everywhere + > + {dragGroup}
); } diff --git a/src/components/CustomPool.js b/src/components/CustomPool.js index ad02c4f..9ad2fdd 100644 --- a/src/components/CustomPool.js +++ b/src/components/CustomPool.js @@ -1,14 +1,15 @@ -// todo wip new, incomplete file - import React from "react"; import Piece from "./Piece"; +import DragShadow from "./DragShadow"; +import {countingGrid} from "./Board"; - - -// Unlike the game pool, where letters in the pool can be moved around, letters in the pool never get used up or reordered. -export default function CustomPool({customState, dispatchGameState}) { - - const poolPieces = customState.pieces.filter((piece) => piece.poolIndex >= 0); +export default function CustomPool({ + pieces, + dragDestination, + dispatchGameState, +}) { + const poolPieces = pieces.filter((piece) => piece.poolIndex >= 0); + poolPieces.sort((a, b) => a.poolIndex - b.poolIndex); const pieceElements = poolPieces.map((piece) => (
@@ -23,5 +24,65 @@ export default function CustomPool({customState, dispatchGameState}) {
)); + if (dragDestination?.where === "pool") { + const draggedPieces = pieces.filter((piece) => piece.dragGroupTop >= 0); + pieceElements.splice( + dragDestination.index, + 0, + draggedPieces.map((piece) => ( +
+ +
+ )), + ); + } + return
{pieceElements}
; } + +export function dragDestinationInPool(pointer) { + const poolElement = + document.getElementById("pool") || document.getElementById("result"); + const poolRect = poolElement.getBoundingClientRect(); + if ( + poolRect.left <= pointer.x && + pointer.x <= poolRect.right && + poolRect.top <= pointer.y && + pointer.y <= poolRect.bottom + ) { + let index = 0; + for (let element of poolElement.children) { + // Note: Exact match on className so we don't count shadows. + if (element.className === "pool-slot") { + const slotRect = element.getBoundingClientRect(); + if (positionIsBeforeRectangle(pointer, slotRect)) { + break; + } + index++; + } + } + return {where: "pool", index}; + } + return undefined; +} + +function positionIsBeforeRectangle(point, rect) { + if (rect.bottom < point.y) { + return false; + } else if (point.y < rect.top) { + return true; + } else if (rect.right < point.x) { + return false; + } else if (point.x < rect.left) { + return true; + } else { + // The point is inside the rectangle. + // We'll say it's before if it's left of the center. + return point.x < (rect.right + rect.left) / 2; + } +} diff --git a/src/logic/customInit.js b/src/logic/customInit.js index 1a48ded..e5f25f2 100644 --- a/src/logic/customInit.js +++ b/src/logic/customInit.js @@ -1,27 +1,57 @@ -// todo wip new, incomplete file +import {updatePieceDatum} from "./assemblePiece"; export function customInit() { // todo saved state const alphabet = [ - 'A', 'B', 'C', 'D', 'E', 'F', - 'G', 'H', 'I', 'J', 'K', 'L', - 'M', 'N', 'O', 'P', 'Q', 'R', - 'S', 'T', 'U', 'V', 'W', 'X', - 'Y', 'Z' + "A", + "B", + "C", + "D", + "E", + "F", + "G", + "H", + "I", + "J", + "K", + "L", + "M", + "N", + "O", + "P", + "Q", + "R", + "S", + "T", + "U", + "V", + "W", + "X", + "Y", + "Z", ]; - const pieces = alphabet.map((letter, index) => ({ - "letters": [ - [ - letter - ] - ], - "id": index, - "poolIndex": index, -})); + const pieces = alphabet.map((letter, index) => + updatePieceDatum({ + letters: [letter], + solutionTop: undefined, + solutionLeft: undefined, + boardTop: undefined, + boardLeft: undefined, + id: index, + poolIndex: index, + }), + ); return { pieces, - } + gridSize: 12, + allPiecesAreUsed: false, //todo don't need? + gameIsSolved: false, //todo don't need? + gameIsSolvedReason: "", //todo don't need? + dragCount: 0, + dragState: undefined, + validityOpacity: 0.5, //todo don't need? + }; } diff --git a/src/logic/customReducer.js b/src/logic/customReducer.js index a29a4d1..33eb884 100644 --- a/src/logic/customReducer.js +++ b/src/logic/customReducer.js @@ -1,24 +1,251 @@ -import {dragStart, dragEnd, getConnectedPieceIDs, hasMoved} from "./gameReducer"; +import sendAnalytics from "../common/sendAnalytics"; +import {gameSolvedQ} from "./gameSolvedQ"; +import {updatePieceDatum} from "./assemblePiece"; +import {getConnectedPieceIDs} from "./getConnectedPieceIDs"; +import {updateDragState} from "./updateDragState"; -// todo wip new, incomplete file +function updateStateForDragStart({ + currentGameState, + isPartOfCurrentDrag, // function that takes a piece and returns a bool indicating whether the piece is being dragged + pointerID, // (integer): The ID of the pointer, as captured by the pointer down event. + pointerStartPosition, // (object with fields `x` and `y`): The x and y position of the pointer, as captured by the pointer down event. + boardIsShifting, // (boolean): Whether the whole board is being dragged. + previousDragState, +}) { + if (currentGameState.dragState !== undefined) { + console.warn("Tried to start a drag while a drag was in progress"); + return currentGameState; + } -// todo reconcile currentGameState vs currentState -export function customReducer(currentGameState, payload) { - console.log("custom: " + payload.action) - console.log(currentGameState) + // Find which pieces are selected, which are not, and the top left of the group (in board squares). + let piecesBeingDragged = []; + let piecesNotBeingDragged = []; + let groupBoardTop = currentGameState.gridSize; + let groupBoardLeft = currentGameState.gridSize; + const poolPieces = currentGameState.pieces.filter( + (piece) => piece.poolIndex >= 0, + ); + let poolIndex = poolPieces.length; + + for (const piece of currentGameState.pieces) { + if (isPartOfCurrentDrag(piece)) { + piecesBeingDragged.push(piece); + // todo figure out what is going on here + if (groupBoardTop !== undefined) { + if (piece.boardTop !== undefined) { + groupBoardTop = Math.min(groupBoardTop, piece.boardTop); + groupBoardLeft = Math.min(groupBoardLeft, piece.boardLeft); + } else { + groupBoardTop = undefined; + groupBoardLeft = undefined; + if (piece.poolIndex !== undefined) { + poolIndex = Math.min(poolIndex, piece.poolIndex); + } + } + } + } else { + piecesNotBeingDragged.push(piece); + } + } + + if (piecesBeingDragged.length === 0) { + console.warn("Tried to start a drag but no pieces are being dragged"); + return currentGameState; + } + + let pointerOffset; + if (previousDragState && previousDragState.pieceIDs.length == 1) { + // If we were previously just dragging one piece and have now potentially expanded to drag multiple pieces, + // use previous pointerOffset, adjusted for the different group of pieces we have now. + const previousPiece = currentGameState.pieces.filter( + (piece) => piece.id == previousDragState.pieceIDs[0], + )[0]; + const extraSquaresLeft = previousPiece.boardLeft - groupBoardLeft; + const extraSquaresTop = previousPiece.boardTop - groupBoardTop; + const boardRect = document.getElementById("board").getBoundingClientRect(); + const squareWidth = (boardRect.width - 1) / currentGameState.gridSize; + const squareHeight = (boardRect.height - 1) / currentGameState.gridSize; + pointerOffset = { + x: previousDragState.pointerOffset.x + squareWidth * extraSquaresLeft, + y: previousDragState.pointerOffset.y + squareHeight * extraSquaresTop, + }; + } else { + // Find the top left of the group in client coordinates, to get pointerOffset. + const rectangles = piecesBeingDragged.flatMap((piece) => { + const element = document.getElementById(`piece-${piece.id}`); + if (!element) { + console.warn( + `dragStart: element for piece ${piece.id} not found in DOM`, + ); + return []; + } + return [element.getBoundingClientRect()]; + }); + if (rectangles.length === 0) { + return currentGameState; + } + const dragGroupTop = Math.min(...rectangles.map((rect) => rect.top)); + const dragGroupLeft = Math.min(...rectangles.map((rect) => rect.left)); + pointerOffset = { + x: pointerStartPosition.x - dragGroupLeft, + y: pointerStartPosition.y - dragGroupTop, + }; + } + + currentGameState = { + ...currentGameState, + pieces: piecesNotBeingDragged.concat( + piecesBeingDragged.map((piece) => + updatePieceDatum(piece, { + boardTop: undefined, + boardLeft: undefined, + poolIndex: undefined, + dragGroupTop: + groupBoardTop === undefined ? 0 : piece.boardTop - groupBoardTop, + dragGroupLeft: + groupBoardLeft === undefined ? 0 : piece.boardLeft - groupBoardLeft, + }), + ), + ), + dragCount: currentGameState.dragCount + 1, + dragState: updateDragState({ + pieceIDs: piecesBeingDragged.map((piece) => piece.id), + boardIsShifting, + dragHasMoved: false, + pointerID, + pointerStartPosition: pointerStartPosition, + pointer: pointerStartPosition, + pointerOffset, + destination: + groupBoardTop !== undefined + ? {where: "board", top: groupBoardTop, left: groupBoardLeft} + : {where: "pool", index: poolIndex}, + }), + }; - // todo consolidate shared action handling between this reducer and other reducer. (pull out into functions.) + if (piecesBeingDragged.some((piece) => piece.poolIndex !== undefined)) { + // A piece was removed from the pool, so recompute poolIndex for the other pieces. + let remainingPoolPieces = currentGameState.pieces.filter( + (piece) => piece.poolIndex !== undefined, + ); + remainingPoolPieces.sort((a, b) => a.poolIndex - b.poolIndex); + let poolIndices = Array(currentGameState.pieces.length).fill(-1); + remainingPoolPieces.forEach((piece, index) => { + poolIndices[piece.id] = index; + }); + currentGameState = { + ...currentGameState, + pieces: currentGameState.pieces.map((piece) => + piece.poolIndex === undefined + ? piece + : updatePieceDatum(piece, {poolIndex: poolIndices[piece.id]}), + ), + }; + } + // Clear `gameIsSolved`, but don't recompute the whole completion state. This prevents + // the `gameIsSolvedReason` from disappearing on each drag when all the pieces are + // on the board but the puzzle isn't solved yet. + return { + ...currentGameState, + gameIsSolved: false, + }; +} + +// We let the pointer wander a few pixels before setting dragHasMoved. +function pointerHasMovedQ(start, pointer) { + const NOT_FAR = 9.0; // pixels + return Math.hypot(pointer.x - start.x, pointer.y - start.y) > NOT_FAR; +} + +function updateStateForDragEnd(currentGameState) { + if (currentGameState.dragState === undefined) { + console.warn("dragEnd called with no dragState"); + return currentGameState; + } + + const destination = currentGameState.dragState.destination; + const draggedPieceIDs = currentGameState.dragState.pieceIDs; + let mapper; + if (destination.where === "board") { + mapper = (piece) => + draggedPieceIDs.includes(piece.id) + ? updatePieceDatum(piece, { + boardTop: destination.top + piece.dragGroupTop, + boardLeft: destination.left + piece.dragGroupLeft, + dragGroupTop: undefined, + dragGroupLeft: undefined, + }) + : piece; + } else { + let poolIndex = destination.index; + mapper = (piece) => + draggedPieceIDs.includes(piece.id) + ? updatePieceDatum(piece, { + poolIndex: poolIndex++, + dragGroupTop: undefined, + dragGroupLeft: undefined, + }) + : piece.poolIndex !== undefined && piece.poolIndex >= destination.index + ? updatePieceDatum(piece, { + poolIndex: piece.poolIndex + draggedPieceIDs.length, + }) + : piece; + } + return updateCompletionState({ + ...currentGameState, + pieces: currentGameState.pieces.map(mapper), + dragState: undefined, + }); +} + +function getCompletionData(currentGameState) { + const allPiecesAreUsed = currentGameState.pieces.every( + (piece) => piece.boardTop >= 0 && piece.boardLeft >= 0, + ); + + if (!allPiecesAreUsed) { + return { + allPiecesAreUsed: false, + gameIsSolved: false, + gameIsSolvedReason: "", + }; + } + + const {gameIsSolved, reason: gameIsSolvedReason} = gameSolvedQ( + currentGameState.pieces, + currentGameState.gridSize, + ); + + if (gameIsSolved && !currentGameState.gameIsSolved) { + sendAnalytics("won"); + } + + return { + allPiecesAreUsed: true, + gameIsSolved: gameIsSolved, + gameIsSolvedReason: gameIsSolvedReason, + }; +} + +function updateCompletionState(gameState) { + return { + ...gameState, + ...getCompletionData(gameState), + }; +} + +export function customReducer(currentGameState, payload) { if (payload.action === "dragStart") { - // Fired on pointerdown on a piece anywhere. + // Fired on pointerdown on a piece anywhere. // Captures initial `dragState`. `destination` is initialized to where the piece already is. - const {pieceID, pointerID, pointer} = payload; - return dragStart({ + const {pieceID, pointerID, pointerStartPosition} = payload; + return updateStateForDragStart({ currentGameState, isPartOfCurrentDrag: (piece) => piece.id === pieceID, pointerID, - pointer, - isShifting: false, + pointerStartPosition, + boardIsShifting: false, }); } else if (payload.action === "dragNeighbors") { // Fired when the timer fires, if `!dragHasMoved`. @@ -31,18 +258,18 @@ export function customReducer(currentGameState, payload) { return currentGameState; } - const droppedGameState = dragEnd(currentGameState); + const droppedGameState = updateStateForDragEnd(currentGameState); const connectedPieceIDs = getConnectedPieceIDs({ pieces: droppedGameState.pieces, gridSize: droppedGameState.gridSize, draggedPieceID: dragState.pieceIDs[0], }); - return dragStart({ + return updateStateForDragStart({ currentGameState: droppedGameState, isPartOfCurrentDrag: (piece) => connectedPieceIDs.includes(piece.id), pointerID: dragState.pointerID, - pointer: dragState.pointer, - isShifting: false, + pointerStartPosition: dragState.pointer, + boardIsShifting: false, previousDragState: dragState, }); } else if (payload.action === "dragMove") { @@ -54,31 +281,31 @@ export function customReducer(currentGameState, payload) { const {pointer, destination} = payload; return { ...currentGameState, - dragState: { - ...prevDrag, + dragState: updateDragState(prevDrag, { pointer, destination: destination ?? prevDrag.destination, dragHasMoved: - prevDrag.dragHasMoved || hasMoved(prevDrag.pointerStart, pointer), - }, + prevDrag.dragHasMoved || + pointerHasMovedQ(prevDrag.pointerStartPosition, pointer), + }), }; } else if (payload.action === "dragEnd") { // Fired on lostpointercapture, after `dragMove`. // // Drop all dragged pieces to `destination` and clear `dragState`. - return dragEnd(currentGameState); + return updateStateForDragEnd(currentGameState); } else if (payload.action === "shiftStart") { // Fired on pointerdown in an empty square on the board. // // Initializes `dragState`. Starts a drag on all pieces that are on the board. // Sets `destination` to where they currently are. - const {pointerID, pointer} = payload; - return dragStart({ + const {pointerID, pointerStartPosition} = payload; + return updateStateForDragStart({ currentGameState, isPartOfCurrentDrag: (piece) => piece.boardTop !== undefined, pointerID, - pointer, - isShifting: true, + pointerStartPosition, + boardIsShifting: true, }); } else { console.log(`unknown action: ${payload.action}`); From 24860fa20562766a19d70440baf3804af5e21598 Mon Sep 17 00:00:00 2001 From: skedwards88 Date: Sun, 18 Aug 2024 12:49:24 -0700 Subject: [PATCH 06/36] add note --- src/logic/customInit.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/logic/customInit.js b/src/logic/customInit.js index e5f25f2..42ca6ce 100644 --- a/src/logic/customInit.js +++ b/src/logic/customInit.js @@ -2,6 +2,7 @@ import {updatePieceDatum} from "./assemblePiece"; export function customInit() { // todo saved state + // todo analytics const alphabet = [ "A", From 21492bec2bae0e2267b98a693a703519f1933f19 Mon Sep 17 00:00:00 2001 From: skedwards88 Date: Sun, 18 Aug 2024 12:49:50 -0700 Subject: [PATCH 07/36] WIP custom ui interaction is functional --- src/logic/customReducer.js | 145 ++++++++++++++++--------------------- 1 file changed, 61 insertions(+), 84 deletions(-) diff --git a/src/logic/customReducer.js b/src/logic/customReducer.js index 33eb884..e43c682 100644 --- a/src/logic/customReducer.js +++ b/src/logic/customReducer.js @@ -1,5 +1,3 @@ -import sendAnalytics from "../common/sendAnalytics"; -import {gameSolvedQ} from "./gameSolvedQ"; import {updatePieceDatum} from "./assemblePiece"; import {getConnectedPieceIDs} from "./getConnectedPieceIDs"; import {updateDragState} from "./updateDragState"; @@ -97,6 +95,7 @@ function updateStateForDragStart({ pieces: piecesNotBeingDragged.concat( piecesBeingDragged.map((piece) => updatePieceDatum(piece, { + //todo add an empty letter here if dragging from pool? boardTop: undefined, boardLeft: undefined, poolIndex: undefined, @@ -116,6 +115,10 @@ function updateStateForDragStart({ pointerStartPosition: pointerStartPosition, pointer: pointerStartPosition, pointerOffset, + origin: + groupBoardTop !== undefined + ? {where: "board"} + : {where: "pool", index: poolIndex}, // todo add to updateDragState docs destination: groupBoardTop !== undefined ? {where: "board", top: groupBoardTop, left: groupBoardLeft} @@ -123,25 +126,7 @@ function updateStateForDragStart({ }), }; - if (piecesBeingDragged.some((piece) => piece.poolIndex !== undefined)) { - // A piece was removed from the pool, so recompute poolIndex for the other pieces. - let remainingPoolPieces = currentGameState.pieces.filter( - (piece) => piece.poolIndex !== undefined, - ); - remainingPoolPieces.sort((a, b) => a.poolIndex - b.poolIndex); - let poolIndices = Array(currentGameState.pieces.length).fill(-1); - remainingPoolPieces.forEach((piece, index) => { - poolIndices[piece.id] = index; - }); - currentGameState = { - ...currentGameState, - pieces: currentGameState.pieces.map((piece) => - piece.poolIndex === undefined - ? piece - : updatePieceDatum(piece, {poolIndex: poolIndices[piece.id]}), - ), - }; - } + // Don' bother updating the pool index like we do in the game, since the pool will never be depleted // Clear `gameIsSolved`, but don't recompute the whole completion state. This prevents // the `gameIsSolvedReason` from disappearing on each drag when all the pieces are @@ -165,80 +150,73 @@ function updateStateForDragEnd(currentGameState) { } const destination = currentGameState.dragState.destination; + const origin = currentGameState.dragState.origin; const draggedPieceIDs = currentGameState.dragState.pieceIDs; - let mapper; + let newPieces = []; if (destination.where === "board") { - mapper = (piece) => - draggedPieceIDs.includes(piece.id) - ? updatePieceDatum(piece, { + let maxID = Math.max(...currentGameState.pieces.map((piece) => piece.id)); + for (const piece of currentGameState.pieces) { + if (draggedPieceIDs.includes(piece.id)) { + newPieces.push( + updatePieceDatum(piece, { boardTop: destination.top + piece.dragGroupTop, boardLeft: destination.left + piece.dragGroupLeft, dragGroupTop: undefined, dragGroupLeft: undefined, - }) - : piece; - } else { - let poolIndex = destination.index; - mapper = (piece) => - draggedPieceIDs.includes(piece.id) - ? updatePieceDatum(piece, { - poolIndex: poolIndex++, + }), + ); + // If dragging from pool to board, also add a replacement to the pool + if (origin.where === "pool") { + maxID++; + newPieces.push( + updatePieceDatum(piece, { + dragGroupTop: undefined, + dragGroupLeft: undefined, + poolIndex: origin.index, + id: maxID, + }), + ); + } + } else { + newPieces.push(piece); + } + } + } else if (destination.where === "pool" && origin.where === "board") { + // If dragging from board to pool, clear the piece from the board but don't add it to the pool + for (const piece of currentGameState.pieces) { + if (draggedPieceIDs.includes(piece.id)) { + continue; + } else { + newPieces.push(piece); + } + } + } + // If dragging from pool to pool, readd the piece to the pool at its original position + else if (destination.where === "pool" && origin.where === "pool") { + for (const piece of currentGameState.pieces) { + if (draggedPieceIDs.includes(piece.id)) { + newPieces.push( + updatePieceDatum(piece, { + poolIndex: origin.index, dragGroupTop: undefined, dragGroupLeft: undefined, - }) - : piece.poolIndex !== undefined && piece.poolIndex >= destination.index - ? updatePieceDatum(piece, { - poolIndex: piece.poolIndex + draggedPieceIDs.length, - }) - : piece; - } - return updateCompletionState({ - ...currentGameState, - pieces: currentGameState.pieces.map(mapper), - dragState: undefined, - }); -} - -function getCompletionData(currentGameState) { - const allPiecesAreUsed = currentGameState.pieces.every( - (piece) => piece.boardTop >= 0 && piece.boardLeft >= 0, - ); - - if (!allPiecesAreUsed) { - return { - allPiecesAreUsed: false, - gameIsSolved: false, - gameIsSolvedReason: "", - }; - } - - const {gameIsSolved, reason: gameIsSolvedReason} = gameSolvedQ( - currentGameState.pieces, - currentGameState.gridSize, - ); - - if (gameIsSolved && !currentGameState.gameIsSolved) { - sendAnalytics("won"); + }), + ); + } else { + newPieces.push(piece); + } + } } return { - allPiecesAreUsed: true, - gameIsSolved: gameIsSolved, - gameIsSolvedReason: gameIsSolvedReason, - }; -} - -function updateCompletionState(gameState) { - return { - ...gameState, - ...getCompletionData(gameState), + ...currentGameState, + pieces: newPieces, + dragState: undefined, }; } export function customReducer(currentGameState, payload) { if (payload.action === "dragStart") { - // Fired on pointerdown on a piece anywhere. - // Captures initial `dragState`. `destination` is initialized to where the piece already is. const {pieceID, pointerID, pointerStartPosition} = payload; return updateStateForDragStart({ currentGameState, @@ -248,22 +226,21 @@ export function customReducer(currentGameState, payload) { boardIsShifting: false, }); } else if (payload.action === "dragNeighbors") { - // Fired when the timer fires, if `!dragHasMoved`. - // - // Set `piece.isDragging` on all neighbors using `destination` to figure out - // which pieces are neighbors. Implemented by dropping the current piece, then picking - // it and all connected pieces up again. + // Fired when the timer fires, if `!dragHasMoved` + // Drop the current piece, then pick up it and all connected pieces const {dragState} = currentGameState; if (dragState === undefined || dragState.pieceIDs.length !== 1) { return currentGameState; } const droppedGameState = updateStateForDragEnd(currentGameState); + const connectedPieceIDs = getConnectedPieceIDs({ pieces: droppedGameState.pieces, gridSize: droppedGameState.gridSize, draggedPieceID: dragState.pieceIDs[0], }); + return updateStateForDragStart({ currentGameState: droppedGameState, isPartOfCurrentDrag: (piece) => connectedPieceIDs.includes(piece.id), From 72736cf8fef8309b4f71043adc50b87cbe5af7fe Mon Sep 17 00:00:00 2001 From: skedwards88 Date: Sun, 18 Aug 2024 15:10:47 -0700 Subject: [PATCH 08/36] overwrite letters on the board --- src/logic/customReducer.js | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/logic/customReducer.js b/src/logic/customReducer.js index e43c682..a9bff4a 100644 --- a/src/logic/customReducer.js +++ b/src/logic/customReducer.js @@ -1,6 +1,7 @@ import {updatePieceDatum} from "./assemblePiece"; import {getConnectedPieceIDs} from "./getConnectedPieceIDs"; import {updateDragState} from "./updateDragState"; +import {arraysMatchQ} from "@skedwards88/word_logic"; function updateStateForDragStart({ currentGameState, @@ -155,6 +156,19 @@ function updateStateForDragEnd(currentGameState) { let newPieces = []; if (destination.where === "board") { let maxID = Math.max(...currentGameState.pieces.map((piece) => piece.id)); + + // Any letters dropped on the board will overwrite anything at that position + // (this is a deviation from the standard game) + let overwrittenPositions = []; + for (const piece of currentGameState.pieces) { + if (draggedPieceIDs.includes(piece.id)) { + overwrittenPositions.push([ + destination.top + piece.dragGroupTop, + destination.left + piece.dragGroupLeft, + ]); + } + } + for (const piece of currentGameState.pieces) { if (draggedPieceIDs.includes(piece.id)) { newPieces.push( @@ -165,6 +179,7 @@ function updateStateForDragEnd(currentGameState) { dragGroupLeft: undefined, }), ); + // If dragging from pool to board, also add a replacement to the pool if (origin.where === "pool") { maxID++; @@ -177,7 +192,12 @@ function updateStateForDragEnd(currentGameState) { }), ); } - } else { + } else if ( + !overwrittenPositions.some( + (position) => + position[0] == piece.boardTop && position[1] == piece.boardLeft, + ) + ) { newPieces.push(piece); } } From 82236bd2b0a88c3c9bcb73d97f653f5fcc6be08e Mon Sep 17 00:00:00 2001 From: skedwards88 Date: Sun, 18 Aug 2024 19:23:57 -0700 Subject: [PATCH 09/36] make custom pool ui reflect possible actions --- TODO.md | 1 + src/components/CustomPool.js | 21 --------------------- src/logic/customReducer.js | 19 +++++++++++++++---- 3 files changed, 16 insertions(+), 25 deletions(-) diff --git a/TODO.md b/TODO.md index 7a0f515..d5742cd 100644 --- a/TODO.md +++ b/TODO.md @@ -32,3 +32,4 @@ For screenshots: // todo wip new, incomplete file - Use the hasVisited state to announce - put arrayToGrid to common or to word-logic package +- make way to share (of if can't share, to get link) diff --git a/src/components/CustomPool.js b/src/components/CustomPool.js index 9ad2fdd..e3d99a0 100644 --- a/src/components/CustomPool.js +++ b/src/components/CustomPool.js @@ -1,11 +1,8 @@ import React from "react"; import Piece from "./Piece"; -import DragShadow from "./DragShadow"; -import {countingGrid} from "./Board"; export default function CustomPool({ pieces, - dragDestination, dispatchGameState, }) { const poolPieces = pieces.filter((piece) => piece.poolIndex >= 0); @@ -24,24 +21,6 @@ export default function CustomPool({ )); - if (dragDestination?.where === "pool") { - const draggedPieces = pieces.filter((piece) => piece.dragGroupTop >= 0); - pieceElements.splice( - dragDestination.index, - 0, - draggedPieces.map((piece) => ( -
- -
- )), - ); - } - return
{pieceElements}
; } diff --git a/src/logic/customReducer.js b/src/logic/customReducer.js index a9bff4a..2752b78 100644 --- a/src/logic/customReducer.js +++ b/src/logic/customReducer.js @@ -1,7 +1,6 @@ import {updatePieceDatum} from "./assemblePiece"; import {getConnectedPieceIDs} from "./getConnectedPieceIDs"; import {updateDragState} from "./updateDragState"; -import {arraysMatchQ} from "@skedwards88/word_logic"; function updateStateForDragStart({ currentGameState, @@ -91,6 +90,17 @@ function updateStateForDragStart({ }; } + // If dragging from the pool, add a dummy placeholder + let placeholderPoolPieces = []; + if (groupBoardTop === undefined) { + placeholderPoolPieces = piecesBeingDragged.map((piece) => + updatePieceDatum(piece, { + letters: [[""]], + id: (piece.id + 1) * (-1) + }), + ) + } + currentGameState = { ...currentGameState, pieces: piecesNotBeingDragged.concat( @@ -106,7 +116,7 @@ function updateStateForDragStart({ groupBoardLeft === undefined ? 0 : piece.boardLeft - groupBoardLeft, }), ), - ), + ).concat(placeholderPoolPieces), dragCount: currentGameState.dragCount + 1, dragState: updateDragState({ pieceIDs: piecesBeingDragged.map((piece) => piece.id), @@ -193,6 +203,7 @@ function updateStateForDragEnd(currentGameState) { ); } } else if ( + piece.letters[0][0] !== "" && !overwrittenPositions.some( (position) => position[0] == piece.boardTop && position[1] == piece.boardLeft, @@ -206,7 +217,7 @@ function updateStateForDragEnd(currentGameState) { for (const piece of currentGameState.pieces) { if (draggedPieceIDs.includes(piece.id)) { continue; - } else { + } else if (piece.letters[0][0] !== ""){ newPieces.push(piece); } } @@ -222,7 +233,7 @@ function updateStateForDragEnd(currentGameState) { dragGroupLeft: undefined, }), ); - } else { + } else if (piece.letters[0][0] !== ""){ newPieces.push(piece); } } From eb5ca020d2847725a1c30eed881563d9d588ff75 Mon Sep 17 00:00:00 2001 From: skedwards88 Date: Sun, 18 Aug 2024 20:13:48 -0700 Subject: [PATCH 10/36] prettier --- src/components/App.js | 5 +-- src/components/CustomPool.js | 5 +-- .../convertGridToRepresentativeString.js | 1 - src/logic/customReducer.js | 40 ++++++++++--------- 4 files changed, 25 insertions(+), 26 deletions(-) diff --git a/src/components/App.js b/src/components/App.js index ab39664..a31af63 100644 --- a/src/components/App.js +++ b/src/components/App.js @@ -63,8 +63,7 @@ export default function App() { const [customState, dispatchCustomState] = React.useReducer( customReducer, - { - }, + {}, customInit, ); @@ -208,7 +207,7 @@ export default function App() { setDisplay={setDisplay} > - ) + ); default: return ( diff --git a/src/components/CustomPool.js b/src/components/CustomPool.js index e3d99a0..9bc564d 100644 --- a/src/components/CustomPool.js +++ b/src/components/CustomPool.js @@ -1,10 +1,7 @@ import React from "react"; import Piece from "./Piece"; -export default function CustomPool({ - pieces, - dispatchGameState, -}) { +export default function CustomPool({pieces, dispatchGameState}) { const poolPieces = pieces.filter((piece) => piece.poolIndex >= 0); poolPieces.sort((a, b) => a.poolIndex - b.poolIndex); diff --git a/src/logic/convertGridToRepresentativeString.js b/src/logic/convertGridToRepresentativeString.js index fbc15cf..3df1f36 100644 --- a/src/logic/convertGridToRepresentativeString.js +++ b/src/logic/convertGridToRepresentativeString.js @@ -10,7 +10,6 @@ export function convertGridToRepresentativeString(grid) { // todo center the grid // todo validate that the puzzle consists of known words vert and horiz - let stringifiedGrid = ""; let spaceCount = 0; diff --git a/src/logic/customReducer.js b/src/logic/customReducer.js index 2752b78..d934df3 100644 --- a/src/logic/customReducer.js +++ b/src/logic/customReducer.js @@ -96,27 +96,31 @@ function updateStateForDragStart({ placeholderPoolPieces = piecesBeingDragged.map((piece) => updatePieceDatum(piece, { letters: [[""]], - id: (piece.id + 1) * (-1) + id: (piece.id + 1) * -1, }), - ) + ); } currentGameState = { ...currentGameState, - pieces: piecesNotBeingDragged.concat( - piecesBeingDragged.map((piece) => - updatePieceDatum(piece, { - //todo add an empty letter here if dragging from pool? - boardTop: undefined, - boardLeft: undefined, - poolIndex: undefined, - dragGroupTop: - groupBoardTop === undefined ? 0 : piece.boardTop - groupBoardTop, - dragGroupLeft: - groupBoardLeft === undefined ? 0 : piece.boardLeft - groupBoardLeft, - }), - ), - ).concat(placeholderPoolPieces), + pieces: piecesNotBeingDragged + .concat( + piecesBeingDragged.map((piece) => + updatePieceDatum(piece, { + //todo add an empty letter here if dragging from pool? + boardTop: undefined, + boardLeft: undefined, + poolIndex: undefined, + dragGroupTop: + groupBoardTop === undefined ? 0 : piece.boardTop - groupBoardTop, + dragGroupLeft: + groupBoardLeft === undefined + ? 0 + : piece.boardLeft - groupBoardLeft, + }), + ), + ) + .concat(placeholderPoolPieces), dragCount: currentGameState.dragCount + 1, dragState: updateDragState({ pieceIDs: piecesBeingDragged.map((piece) => piece.id), @@ -217,7 +221,7 @@ function updateStateForDragEnd(currentGameState) { for (const piece of currentGameState.pieces) { if (draggedPieceIDs.includes(piece.id)) { continue; - } else if (piece.letters[0][0] !== ""){ + } else if (piece.letters[0][0] !== "") { newPieces.push(piece); } } @@ -233,7 +237,7 @@ function updateStateForDragEnd(currentGameState) { dragGroupLeft: undefined, }), ); - } else if (piece.letters[0][0] !== ""){ + } else if (piece.letters[0][0] !== "") { newPieces.push(piece); } } From 4860a060666e3821a06de61375c76ad0acb1a0e9 Mon Sep 17 00:00:00 2001 From: skedwards88 Date: Mon, 19 Aug 2024 08:30:02 -0700 Subject: [PATCH 11/36] consolidate some redundant copies, make validation work for custom --- src/components/Board.js | 18 +++-- src/components/CustomBoard.js | 113 +------------------------------ src/components/CustomCreation.js | 3 +- src/components/Piece.js | 1 + 4 files changed, 17 insertions(+), 118 deletions(-) diff --git a/src/components/Board.js b/src/components/Board.js index 5fb7bcb..eb86499 100644 --- a/src/components/Board.js +++ b/src/components/Board.js @@ -85,12 +85,18 @@ function getHorizontalValidityGrid({grid, originalWords}) { return horizontalValidityGrid; } -function getWordValidityGrids({pieces, gridSize}) { - const originalWords = getWordsFromPieces({ - pieces, - gridSize, - solution: true, - }); +export function getWordValidityGrids({ + pieces, + gridSize, + includeOriginalSolution = true, +}) { + const originalWords = includeOriginalSolution + ? getWordsFromPieces({ + pieces, + gridSize, + solution: true, + }) + : []; const grid = getGridFromPieces({pieces, gridSize, solution: false}); diff --git a/src/components/CustomBoard.js b/src/components/CustomBoard.js index 6951bad..ca2c283 100644 --- a/src/components/CustomBoard.js +++ b/src/components/CustomBoard.js @@ -1,113 +1,7 @@ import React from "react"; import Piece from "./Piece"; import DragShadow from "./DragShadow"; -import {getGridFromPieces} from "../logic/getGridFromPieces"; -import {isKnown} from "@skedwards88/word_logic"; -import {trie} from "../logic/trie"; -import {getWordsFromPieces} from "../logic/getWordsFromPieces"; -import {transposeGrid} from "@skedwards88/word_logic"; - -// Returns a grid with the number of letters at each location in the grid -export function countingGrid(height, width, pieces) { - let grid = Array(height) - .fill(undefined) - .map(() => Array(width).fill(0)); - - for (let piece of pieces) { - const letters = piece.letters; - let top = piece.boardTop ?? piece.dragGroupTop; - for (let rowIndex = 0; rowIndex < letters.length; rowIndex++) { - let left = piece.boardLeft ?? piece.dragGroupLeft; - for (let colIndex = 0; colIndex < letters[rowIndex].length; colIndex++) { - if (letters[rowIndex][colIndex]) { - grid[top][left]++; - } - left++; - } - top++; - } - } - return grid; -} - -function getHorizontalValidityGrid({grid, originalWords}) { - // return a 2D array of bools indicating whether - // the position corresponds to a letter on the board - // that is part of a valid horizontal word - const height = grid.length; - const width = grid[0].length; - - const horizontalValidityGrid = Array(height) - .fill(undefined) - .map(() => Array(width).fill(false)); - - for (const [rowIndex, row] of grid.entries()) { - let word = ""; - let indexes = []; - for (const [columnIndex, letter] of row.entries()) { - if (letter != "") { - word += letter; - indexes.push(columnIndex); - } else { - if (word.length > 1) { - // If the word is one of the original words, always consider it valid (in case we updated the dictionary in the interim). - // Otherwise, check whether it is a word in the trie. - let isWord = originalWords.includes(word); - if (!isWord) { - ({isWord} = isKnown(word, trie)); - } - if (isWord) { - indexes.forEach( - (index) => (horizontalValidityGrid[rowIndex][index] = true), - ); - } - } - word = ""; - indexes = []; - } - } - // Also end the word if we reach the end of the row - if (word.length > 1) { - // If the word is one of the original words, always consider it valid (in case we updated the dictionary in the interim). - // Otherwise, check whether it is a word in the trie. - let isWord = originalWords.includes(word); - if (!isWord) { - ({isWord} = isKnown(word, trie)); - } - if (isWord) { - indexes.forEach( - (index) => (horizontalValidityGrid[rowIndex][index] = true), - ); - } - } - } - - return horizontalValidityGrid; -} - -function getWordValidityGrids({pieces, gridSize}) { - const originalWords = getWordsFromPieces({ - pieces, - gridSize, - solution: true, - }); - - const grid = getGridFromPieces({pieces, gridSize, solution: false}); - - const horizontalValidityGrid = getHorizontalValidityGrid({ - grid, - originalWords, - }); - - const transposedGrid = transposeGrid(grid); - const horizontalTransposedValidityGrid = getHorizontalValidityGrid({ - grid: transposedGrid, - originalWords, - }); - const verticalValidityGrid = transposeGrid(horizontalTransposedValidityGrid); - - return [horizontalValidityGrid, verticalValidityGrid]; -} +import {countingGrid, getWordValidityGrids} from "./Board"; export default function CustomBoard({ pieces, @@ -122,16 +16,15 @@ export default function CustomBoard({ (piece) => piece.boardTop >= 0 && piece.boardLeft >= 0, ); - const overlapGrid = countingGrid(gridSize, gridSize, boardPieces); const [horizontalValidityGrid, verticalValidityGrid] = indicateValidity - ? getWordValidityGrids({pieces, gridSize}) + ? getWordValidityGrids({pieces, gridSize, includeOriginalSolution: false}) : [undefined, undefined]; const pieceElements = boardPieces.map((piece) => ( 0} - indicateValidity={false} // todo resolve ^ (errors if true) + indicateValidity={validityOpacity > 0} dragPieceIDs={customState.dragState?.pieceIDs} dragDestination={customState.dragState?.destination} gridSize={12} diff --git a/src/components/Piece.js b/src/components/Piece.js index 47ebc94..c430df2 100644 --- a/src/components/Piece.js +++ b/src/components/Piece.js @@ -106,6 +106,7 @@ export default function Piece({ pieceColIndex={colIndex} overlapping={ isOnBoard && + overlapGrid && overlapGrid[piece.boardTop + rowIndex][ piece.boardLeft + colIndex ] > 1 From b3c56a4912f4d3d4e1d96a734b042bd330d074b4 Mon Sep 17 00:00:00 2001 From: skedwards88 Date: Mon, 19 Aug 2024 08:40:58 -0700 Subject: [PATCH 12/36] consolidate Board and CustomBoard --- TODO.md | 5 ++ src/components/Board.js | 13 +++- src/components/CustomBoard.js | 114 ------------------------------- src/components/CustomCreation.js | 7 +- 4 files changed, 20 insertions(+), 119 deletions(-) delete mode 100644 src/components/CustomBoard.js diff --git a/TODO.md b/TODO.md index d5742cd..23352e8 100644 --- a/TODO.md +++ b/TODO.md @@ -33,3 +33,8 @@ For screenshots: - Use the hasVisited state to announce - put arrayToGrid to common or to word-logic package - make way to share (of if can't share, to get link) + +// todo later PR +- relocate to new file: + - getWordValidityGrids + - countingGrid diff --git a/src/components/Board.js b/src/components/Board.js index eb86499..f76a2be 100644 --- a/src/components/Board.js +++ b/src/components/Board.js @@ -123,15 +123,24 @@ export default function Board({ gameIsSolved, dispatchGameState, indicateValidity, + customCreation = false, }) { const boardPieces = pieces.filter( (piece) => piece.boardTop >= 0 && piece.boardLeft >= 0, ); - const overlapGrid = countingGrid(gridSize, gridSize, boardPieces); + const overlapGrid = customCreation + ? undefined + : countingGrid(gridSize, gridSize, boardPieces); + const [horizontalValidityGrid, verticalValidityGrid] = indicateValidity - ? getWordValidityGrids({pieces, gridSize}) + ? getWordValidityGrids({ + pieces, + gridSize, + includeOriginalSolution: !customCreation, + }) : [undefined, undefined]; + const pieceElements = boardPieces.map((piece) => ( piece.boardTop >= 0 && piece.boardLeft >= 0, - ); - - const [horizontalValidityGrid, verticalValidityGrid] = indicateValidity - ? getWordValidityGrids({pieces, gridSize, includeOriginalSolution: false}) - : [undefined, undefined]; - const pieceElements = boardPieces.map((piece) => ( - - )); - - // Any pieces that are currently being dragged over the board will render on the board as a single drag shadow - let dragShadow; - if (dragDestination?.where === "board") { - const draggedPieces = pieces.filter((piece) => - dragPieceIDs.includes(piece.id), - ); - const grid = countingGrid(gridSize, gridSize, draggedPieces); - dragShadow = ( - - ); - } - - return ( -
{ - event.preventDefault(); - dispatchGameState({ - action: "shiftStart", - pointerID: event.pointerId, - pointerStartPosition: {x: event.clientX, y: event.clientY}, - }); - }} - > - {pieceElements} - {dragShadow} -
- ); -} - -export function dragDestinationOnBoard(gameState, pointer) { - const boardRect = document.getElementById("board").getBoundingClientRect(); - if ( - gameState.dragState.destination.where === "board" || - (boardRect.left <= pointer.x && - pointer.x <= boardRect.right && - boardRect.top <= pointer.y && - pointer.y <= boardRect.bottom) - ) { - const draggedPieceIDs = gameState.dragState.pieceIDs; - const draggedPieces = gameState.pieces.filter((piece) => - draggedPieceIDs.includes(piece.id), - ); - - const groupHeight = Math.max( - ...draggedPieces.map( - (piece) => piece.dragGroupTop + piece.letters.length, - ), - ); - const groupWidth = Math.max( - ...draggedPieces.map( - (piece) => piece.dragGroupLeft + piece.letters[0].length, - ), - ); - const maxTop = gameState.gridSize - groupHeight; - const maxLeft = gameState.gridSize - groupWidth; - - // Subtract 1 before dividing because the board is n squares wide, but has n+1 1px borders. - // (It's admittedly silly to care about this, since the impact is only 1/n of a pixel!) - const squareWidth = (boardRect.width - 1) / gameState.gridSize; - const squareHeight = (boardRect.height - 1) / gameState.gridSize; - const pointerOffset = gameState.dragState.pointerOffset; - const unclampedLeft = Math.round( - (pointer.x - pointerOffset.x - boardRect.left) / squareWidth, - ); - const unclampedTop = Math.round( - (pointer.y - pointerOffset.y - boardRect.top) / squareHeight, - ); - const left = Math.max(0, Math.min(maxLeft, unclampedLeft)); - const top = Math.max(0, Math.min(maxTop, unclampedTop)); - - return {where: "board", top, left}; - } - - return undefined; -} diff --git a/src/components/CustomCreation.js b/src/components/CustomCreation.js index bc63f0c..c5ca61c 100644 --- a/src/components/CustomCreation.js +++ b/src/components/CustomCreation.js @@ -1,6 +1,6 @@ import React from "react"; import CustomPool from "./CustomPool"; -import CustomBoard from "./CustomBoard"; +import Board from "./Board"; import DragGroup from "./DragGroup"; function CustomCreation({dispatchCustomState, customState, validityOpacity}) { @@ -23,7 +23,7 @@ function CustomCreation({dispatchCustomState, customState, validityOpacity}) { "--validity-opacity": validityOpacity, }} > - + customCreation={true} + > Date: Mon, 19 Aug 2024 08:46:22 -0700 Subject: [PATCH 13/36] consolidate Pool and CustomPool --- TODO.md | 10 +++-- src/components/CustomCreation.js | 10 ++--- src/components/CustomPool.js | 64 -------------------------------- 3 files changed, 11 insertions(+), 73 deletions(-) delete mode 100644 src/components/CustomPool.js diff --git a/TODO.md b/TODO.md index 23352e8..0ed9c84 100644 --- a/TODO.md +++ b/TODO.md @@ -29,12 +29,14 @@ For screenshots: const pieces = [{"letters":[["P","U","Z"],["L","",""],["A","",""]],"solutionTop":3,"solutionLeft":2},{"letters":[["B"],["R"],["A"]],"solutionTop":2,"solutionLeft":9},{"letters":[["","T","H"],["","E",""],["O","R","D"]],"solutionTop":6,"solutionLeft":5},{"letters":[["M","E"]],"solutionTop":5,"solutionLeft":3},{"letters":[["","I",""],["I","N","K"]],"solutionTop":5,"solutionLeft":8},{"letters":[["W"]],"solutionTop":8,"solutionLeft":4},{"letters":[["G"]],"solutionTop":5,"solutionLeft":1},{"letters":[["Y"]],"solutionTop":6,"solutionLeft":2},{"letters":[["Z","L","E"],["","E",""],["","T",""]],"solutionTop":3,"solutionLeft":5}]; -// todo wip new, incomplete file +// todo wip new, incomplete file: - Use the hasVisited state to announce - put arrayToGrid to common or to word-logic package - make way to share (of if can't share, to get link) -// todo later PR +// todo later PR: + - relocate to new file: - - getWordValidityGrids - - countingGrid + - getWordValidityGrids + - countingGrid +- just rename dispatch params to dispatcher everywhere diff --git a/src/components/CustomCreation.js b/src/components/CustomCreation.js index c5ca61c..ea9eb98 100644 --- a/src/components/CustomCreation.js +++ b/src/components/CustomCreation.js @@ -1,5 +1,5 @@ import React from "react"; -import CustomPool from "./CustomPool"; +import Pool from "./Pool"; import Board from "./Board"; import DragGroup from "./DragGroup"; @@ -33,11 +33,11 @@ function CustomCreation({dispatchCustomState, customState, validityOpacity}) { gridSize={12} customCreation={true} > - + dispatchGameState={dispatchCustomState} + > {dragGroup} ); diff --git a/src/components/CustomPool.js b/src/components/CustomPool.js deleted file mode 100644 index 9bc564d..0000000 --- a/src/components/CustomPool.js +++ /dev/null @@ -1,64 +0,0 @@ -import React from "react"; -import Piece from "./Piece"; - -export default function CustomPool({pieces, dispatchGameState}) { - const poolPieces = pieces.filter((piece) => piece.poolIndex >= 0); - poolPieces.sort((a, b) => a.poolIndex - b.poolIndex); - - const pieceElements = poolPieces.map((piece) => ( -
- -
- )); - - return
{pieceElements}
; -} - -export function dragDestinationInPool(pointer) { - const poolElement = - document.getElementById("pool") || document.getElementById("result"); - const poolRect = poolElement.getBoundingClientRect(); - if ( - poolRect.left <= pointer.x && - pointer.x <= poolRect.right && - poolRect.top <= pointer.y && - pointer.y <= poolRect.bottom - ) { - let index = 0; - for (let element of poolElement.children) { - // Note: Exact match on className so we don't count shadows. - if (element.className === "pool-slot") { - const slotRect = element.getBoundingClientRect(); - if (positionIsBeforeRectangle(pointer, slotRect)) { - break; - } - index++; - } - } - return {where: "pool", index}; - } - return undefined; -} - -function positionIsBeforeRectangle(point, rect) { - if (rect.bottom < point.y) { - return false; - } else if (point.y < rect.top) { - return true; - } else if (rect.right < point.x) { - return false; - } else if (point.x < rect.left) { - return true; - } else { - // The point is inside the rectangle. - // We'll say it's before if it's left of the center. - return point.x < (rect.right + rect.left) / 2; - } -} From 97db75ff19f37872b5489eeb89222f4b197bb90a Mon Sep 17 00:00:00 2001 From: skedwards88 Date: Mon, 19 Aug 2024 09:07:00 -0700 Subject: [PATCH 14/36] adjust styling for custom pool --- TODO.md | 6 ++++-- src/components/CustomCreation.js | 2 +- src/styles/App.css | 10 +++++++++- src/styles/LargeScreen.css | 6 +++--- 4 files changed, 17 insertions(+), 7 deletions(-) diff --git a/TODO.md b/TODO.md index 0ed9c84..1827b56 100644 --- a/TODO.md +++ b/TODO.md @@ -29,9 +29,10 @@ For screenshots: const pieces = [{"letters":[["P","U","Z"],["L","",""],["A","",""]],"solutionTop":3,"solutionLeft":2},{"letters":[["B"],["R"],["A"]],"solutionTop":2,"solutionLeft":9},{"letters":[["","T","H"],["","E",""],["O","R","D"]],"solutionTop":6,"solutionLeft":5},{"letters":[["M","E"]],"solutionTop":5,"solutionLeft":3},{"letters":[["","I",""],["I","N","K"]],"solutionTop":5,"solutionLeft":8},{"letters":[["W"]],"solutionTop":8,"solutionLeft":4},{"letters":[["G"]],"solutionTop":5,"solutionLeft":1},{"letters":[["Y"]],"solutionTop":6,"solutionLeft":2},{"letters":[["Z","L","E"],["","E",""],["","T",""]],"solutionTop":3,"solutionLeft":5}]; -// todo wip new, incomplete file: +// todo now + +- wip new, incomplete file: - Use the hasVisited state to announce -- put arrayToGrid to common or to word-logic package - make way to share (of if can't share, to get link) // todo later PR: @@ -40,3 +41,4 @@ For screenshots: - getWordValidityGrids - countingGrid - just rename dispatch params to dispatcher everywhere +- put arrayToGrid to common or to word-logic package diff --git a/src/components/CustomCreation.js b/src/components/CustomCreation.js index ea9eb98..d3f660d 100644 --- a/src/components/CustomCreation.js +++ b/src/components/CustomCreation.js @@ -16,7 +16,7 @@ function CustomCreation({dispatchCustomState, customState, validityOpacity}) { return (
Date: Mon, 19 Aug 2024 09:16:54 -0700 Subject: [PATCH 15/36] add button to access custom creation mode --- src/common/getInitialState.js | 2 -- src/components/ControlBar.js | 5 +++++ src/images/icons/custom.svg | 4 ++++ src/styles/ControlBar.css | 4 ++++ 4 files changed, 13 insertions(+), 2 deletions(-) create mode 100644 src/images/icons/custom.svg diff --git a/src/common/getInitialState.js b/src/common/getInitialState.js index 6b89036..faa34f7 100644 --- a/src/common/getInitialState.js +++ b/src/common/getInitialState.js @@ -1,6 +1,4 @@ export function getInitialState(savedDisplay, hasVisited) { - // todo revert - return "custom"; if (!hasVisited) { return "rules"; } diff --git a/src/components/ControlBar.js b/src/components/ControlBar.js index e5feff3..59303c2 100644 --- a/src/components/ControlBar.js +++ b/src/components/ControlBar.js @@ -38,6 +38,11 @@ function ControlBar({ className="controlButton" onClick={() => setDisplay("rules")} > + +
); + case "customError": + return ( +
+
{`Your game isn't ready to share yet: \n\n${customState.invalidReason}`}
+ +
+ ); + + case "customShare": + return ( + + ); + default: return (
diff --git a/src/components/CustomShare.js b/src/components/CustomShare.js new file mode 100644 index 0000000..a99a17d --- /dev/null +++ b/src/components/CustomShare.js @@ -0,0 +1,40 @@ +import React from "react"; + +export default function CustomShare({ + representativeString, + dispatchCustomState, + setDisplay, +}) { + const link = `https://crossjig.com?id=custom-${representativeString}`; + + return ( +
+
{`Share your custom puzzle with this link!`}
+ {link} +
+ + +
+
+ ); +} diff --git a/src/logic/customInit.js b/src/logic/customInit.js index 4c94def..afdab54 100644 --- a/src/logic/customInit.js +++ b/src/logic/customInit.js @@ -58,5 +58,7 @@ export function customInit(useSaved = true) { gridSize: 12, dragCount: 0, dragState: undefined, + representativeString: "", + invalidReason: "", }; } diff --git a/src/logic/customReducer.js b/src/logic/customReducer.js index 1bdeee4..4d641bf 100644 --- a/src/logic/customReducer.js +++ b/src/logic/customReducer.js @@ -148,7 +148,7 @@ function updateStateForDragStart({ // Don' bother updating the pool index like we do in the game, since the pool will never be depleted - return currentGameState + return currentGameState; } // We let the pointer wander a few pixels before setting dragHasMoved. @@ -318,6 +318,16 @@ export function customReducer(currentGameState, payload) { pointerStartPosition, boardIsShifting: true, }); + } else if (payload.action === "updateInvalidReason") { + return { + ...currentGameState, + invalidReason: payload.invalidReason, + }; + } else if (payload.action === "updateRepresentativeString") { + return { + ...currentGameState, + representativeString: payload.representativeString, + }; } else { console.log(`unknown action: ${payload.action}`); return currentGameState; diff --git a/src/styles/App.css b/src/styles/App.css index be6e481..ead27c5 100644 --- a/src/styles/App.css +++ b/src/styles/App.css @@ -324,3 +324,34 @@ body:has(#drag-group) { display: flex; flex-direction: column; } + +.customMessage { + white-space: pre-line; + display: flex; + flex-direction: column; + text-align: center; + overflow: scroll; + justify-items: center; + justify-content: space-evenly; + align-items: center; + font-size: calc(var(--default-font-size)* 0.75); +} + +.customMessage > div, .customMessage > a { + white-space: pre-line; + text-wrap: wrap; + overflow-wrap: break-word; + word-break: break-all; + padding: 10px; +} + +#custom-message-buttons { + display: flex; + flex-direction: row; + justify-content: space-around; + width: 100%; +} + +#custom-message-buttons > button { + margin: 10px; +} From 49988c478d5053500f75c94ac3842b9200aa67e4 Mon Sep 17 00:00:00 2001 From: skedwards88 Date: Sat, 24 Aug 2024 08:45:31 -0700 Subject: [PATCH 27/36] add more tests --- TODO.md | 2 +- src/components/App.js | 5 +- .../convertGridToRepresentativeString.js | 11 ++- .../convertGridToRepresentativeString.test.js | 90 ++++++++++++++++++- 4 files changed, 98 insertions(+), 10 deletions(-) diff --git a/TODO.md b/TODO.md index 3b733f2..0d63c69 100644 --- a/TODO.md +++ b/TODO.md @@ -33,10 +33,10 @@ For screenshots: - wip new, incomplete file - Use the hasVisited state to announce -- make way to share (or if can't share, to get link) - add some custom daily puzzles - consolidate logic between the two reducers - large screen +- todo comments // todo later PR: diff --git a/src/components/App.js b/src/components/App.js index f847613..c603534 100644 --- a/src/components/App.js +++ b/src/components/App.js @@ -24,6 +24,7 @@ import {convertGridToRepresentativeString} from "../logic/convertGridToRepresent import {getGridFromPieces} from "../logic/getGridFromPieces"; import {crosswordValidQ, pickRandomIntBetween} from "@skedwards88/word_logic"; import {trie} from "../logic/trie"; +import {resizeGrid} from "../logic/resizeGrid"; export default function App() { // If a query string was passed, @@ -250,10 +251,12 @@ export default function App() { return; } + // Center and resize/pad the grid // Convert the grid to a representative string + const resizedGrid = resizeGrid(grid); const cipherShift = pickRandomIntBetween(5, 9); const representativeString = convertGridToRepresentativeString( - grid, + resizedGrid, cipherShift, ); diff --git a/src/logic/convertGridToRepresentativeString.js b/src/logic/convertGridToRepresentativeString.js index 25befbd..643435f 100644 --- a/src/logic/convertGridToRepresentativeString.js +++ b/src/logic/convertGridToRepresentativeString.js @@ -1,17 +1,20 @@ -import {resizeGrid} from "./resizeGrid"; import {cipherLetter} from "./cipherLetter"; // Converts a 2D grid of letters and spaces into a representative string. // Spaces are represented by an integer indicating the number of consecutive spaces. // (Spaces that span rows are considered part of the same consecutive group of spaces.) +// Letters in the string are shifted by the cipherShift amount, +// and the shift amount is prepended to the string export function convertGridToRepresentativeString(grid, cipherShift = 0) { - // Center and resize/pad the grid - const resizedGrid = resizeGrid(grid); + // Error if cipherShift is not an int between 0 and 9 + if (!Number.isInteger(cipherShift) || cipherShift < 0 || cipherShift > 9) { + throw new Error("Input cipherShift must be a single digit integer"); + } let stringifiedGrid = `${cipherShift}`; let spaceCount = 0; - for (const row of resizedGrid) { + for (const row of grid) { for (const character of row) { if (character === "") { // If the character is a space, just increase the space count diff --git a/src/logic/convertGridToRepresentativeString.test.js b/src/logic/convertGridToRepresentativeString.test.js index 6fdd76f..e6a6f10 100644 --- a/src/logic/convertGridToRepresentativeString.test.js +++ b/src/logic/convertGridToRepresentativeString.test.js @@ -1,9 +1,49 @@ -// todo wip new, incomplete file - import {convertGridToRepresentativeString} from "./convertGridToRepresentativeString"; describe("convertGridToRepresentativeString", () => { - test("converts a 2D grid of single characters and empty strings into a representative string with consecutive empty strings represented by a the number of empty strings. Row breaks are ignored.", () => { + test("converts a 2D grid of single characters and empty strings into a representative string with consecutive empty strings represented by a the number of empty strings. Row breaks are ignored. The shift amount is prepended to the string.", () => { + const input = [ + ["", "", "L", "U", "M", "P", "Y", "", "", ""], + ["", "", "", "L", "", "I", "", "", "", ""], + ["", "F", "A", "T", "T", "E", "R", "", "", ""], + ["", "L", "", "R", "", "R", "", "", "", ""], + ["", "A", "", "A", "", "S", "", "", "", ""], + ["", "M", "", "", "R", "", "", "", "", ""], + ["", "E", "U", "R", "E", "K", "A", "", "", ""], + ["", "S", "", "", "A", "", "", "", "", ""], + ["", "", "", "", "D", "R", "A", "W", "S", ""], + ["", "", "", "", "S", "", "", "", "", ""], + ]; + + const output = convertGridToRepresentativeString(input, 0); + + expect(output).toEqual( + "02LUMPY6L1I5FATTER4L1R1R5A1A1S5M2R6EUREKA4S2A9DRAWS5S5", + ); + }); + + test("shifts the letters by the negative of the amount indicated", () => { + const input = [ + ["", "", "L", "U", "M", "P", "Y", "", "", ""], + ["", "", "", "L", "", "I", "", "", "", ""], + ["", "F", "A", "T", "T", "E", "R", "", "", ""], + ["", "L", "", "R", "", "R", "", "", "", ""], + ["", "A", "", "A", "", "S", "", "", "", ""], + ["", "M", "", "", "R", "", "", "", "", ""], + ["", "E", "U", "R", "E", "K", "A", "", "", ""], + ["", "S", "", "", "A", "", "", "", "", ""], + ["", "", "", "", "D", "R", "A", "W", "S", ""], + ["", "", "", "", "S", "", "", "", "", ""], + ]; + + const output = convertGridToRepresentativeString(input, 3); + + expect(output).toEqual( + "32IRJMV6I1F5CXQQBO4I1O1O5X1X1P5J2O6BROBHX4P2X9AOXTP5P5", + ); + }); + + test("if no shift is indicated, the letters are not shifted, and 0 is prepended to the string", () => { const input = [ ["", "", "L", "U", "M", "P", "Y", "", "", ""], ["", "", "", "L", "", "I", "", "", "", ""], @@ -20,7 +60,49 @@ describe("convertGridToRepresentativeString", () => { const output = convertGridToRepresentativeString(input); expect(output).toEqual( - "2LUMPY6L1I5FATTER4L1R1R5A1A1S5M2R6EUREKA4S2A9DRAWS5S5", + "02LUMPY6L1I5FATTER4L1R1R5A1A1S5M2R6EUREKA4S2A9DRAWS5S5", + ); + }); + + test("errors if the cipher shift is not an integer", () => { + const input = [ + ["", "", "L", "U", "M", "P", "Y", "", "", ""], + ["", "", "", "L", "", "I", "", "", "", ""], + ["", "F", "A", "T", "T", "E", "R", "", "", ""], + ["", "L", "", "R", "", "R", "", "", "", ""], + ["", "A", "", "A", "", "S", "", "", "", ""], + ["", "M", "", "", "R", "", "", "", "", ""], + ["", "E", "U", "R", "E", "K", "A", "", "", ""], + ["", "S", "", "", "A", "", "", "", "", ""], + ["", "", "", "", "D", "R", "A", "W", "S", ""], + ["", "", "", "", "S", "", "", "", "", ""], + ]; + + expect(() => convertGridToRepresentativeString(input, 1.5)).toThrow( + "Input cipherShift must be a single digit integer", + ); + }); + + test("errors if the cipher shift is not between 0 and 9", () => { + const input = [ + ["", "", "L", "U", "M", "P", "Y", "", "", ""], + ["", "", "", "L", "", "I", "", "", "", ""], + ["", "F", "A", "T", "T", "E", "R", "", "", ""], + ["", "L", "", "R", "", "R", "", "", "", ""], + ["", "A", "", "A", "", "S", "", "", "", ""], + ["", "M", "", "", "R", "", "", "", "", ""], + ["", "E", "U", "R", "E", "K", "A", "", "", ""], + ["", "S", "", "", "A", "", "", "", "", ""], + ["", "", "", "", "D", "R", "A", "W", "S", ""], + ["", "", "", "", "S", "", "", "", "", ""], + ]; + + expect(() => convertGridToRepresentativeString(input, 11)).toThrow( + "Input cipherShift must be a single digit integer", + ); + + expect(() => convertGridToRepresentativeString(input, -1)).toThrow( + "Input cipherShift must be a single digit integer", ); }); }); From a59649400eae1d1dd7c35a35edf527e4f3820957 Mon Sep 17 00:00:00 2001 From: skedwards88 Date: Sat, 24 Aug 2024 08:54:16 -0700 Subject: [PATCH 28/36] more tests --- src/logic/resizeGrid.test.js | 107 ++++++++++++++++++++++++++++++++++- 1 file changed, 105 insertions(+), 2 deletions(-) diff --git a/src/logic/resizeGrid.test.js b/src/logic/resizeGrid.test.js index 92c5d0f..f15a6c6 100644 --- a/src/logic/resizeGrid.test.js +++ b/src/logic/resizeGrid.test.js @@ -1,7 +1,5 @@ import {resizeGrid} from "./resizeGrid"; -// todo add more tests - describe("resizeGrid", () => { test("trims a grid down to 8x8", () => { const input = [ @@ -133,6 +131,111 @@ describe("resizeGrid", () => { expect(resizeGrid(input)).toEqual(expected); }); + test("works if the grid contents are on the right edge", () => { + const input = [ + ["", "", "", "", "", "", "", "", "", "", "", "A"], + ["", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", "D"], + ["", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", "C"], + ]; + const expected = [ + ["", "", "", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "A", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "D", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "C", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", "", "", ""], + ]; + + expect(resizeGrid(input)).toEqual(expected); + }); + + test("works if the grid contents are on the top edge", () => { + const input = [ + ["A", "", "", "D", "", "", "", "", "", "", "", "C"], + ["", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", ""], + ]; + const expected = [ + ["", "", "", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", "", "", ""], + ["", "A", "", "", "D", "", "", "", "", "", "", "", "C", ""], + ["", "", "", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", "", "", ""], + ]; + + expect(resizeGrid(input)).toEqual(expected); + }); + + test("works if the grid contents are on the bottom edge", () => { + const input = [ + ["", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", ""], + ["A", "", "", "D", "", "", "", "", "", "", "", "C"], + ]; + const expected = [ + ["", "", "", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", "", "", ""], + ["", "A", "", "", "D", "", "", "", "", "", "", "", "C", ""], + ["", "", "", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", "", "", ""], + ]; + + expect(resizeGrid(input)).toEqual(expected); + }); + test("works on an empty grid", () => { const input = [ ["", "", "", "", "", "", "", "", "", "", "", ""], From 7a39bf89c95828d036f22eb04f8e6e63ec8ad4db Mon Sep 17 00:00:00 2001 From: skedwards88 Date: Sat, 24 Aug 2024 08:54:31 -0700 Subject: [PATCH 29/36] mention custom puzzles in rules --- src/components/Rules.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/components/Rules.js b/src/components/Rules.js index 06e1c91..eb1ebee 100644 --- a/src/components/Rules.js +++ b/src/components/Rules.js @@ -29,6 +29,9 @@ export default function Rules({setDisplay}) { challenge is easiest on Monday and gets harder over the week.

+ Click the pencil to build your own puzzle to share with friends. +
+

+ +
Date: Sat, 24 Aug 2024 10:42:32 -0700 Subject: [PATCH 33/36] run stylelint --- src/styles/App.css | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/styles/App.css b/src/styles/App.css index ead27c5..d645cad 100644 --- a/src/styles/App.css +++ b/src/styles/App.css @@ -334,10 +334,11 @@ body:has(#drag-group) { justify-items: center; justify-content: space-evenly; align-items: center; - font-size: calc(var(--default-font-size)* 0.75); + font-size: calc(var(--default-font-size) * 0.75); } -.customMessage > div, .customMessage > a { +.customMessage > div, +.customMessage > a { white-space: pre-line; text-wrap: wrap; overflow-wrap: break-word; From 56ccd4a8bbcf6736bdb9b2145fa0e08eb256e33a Mon Sep 17 00:00:00 2001 From: skedwards88 Date: Sat, 24 Aug 2024 10:43:50 -0700 Subject: [PATCH 34/36] update note --- TODO.md | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/TODO.md b/TODO.md index cb64b57..601dac5 100644 --- a/TODO.md +++ b/TODO.md @@ -29,11 +29,6 @@ For screenshots: const pieces = [{"letters":[["P","U","Z"],["L","",""],["A","",""]],"solutionTop":3,"solutionLeft":2},{"letters":[["B"],["R"],["A"]],"solutionTop":2,"solutionLeft":9},{"letters":[["","T","H"],["","E",""],["O","R","D"]],"solutionTop":6,"solutionLeft":5},{"letters":[["M","E"]],"solutionTop":5,"solutionLeft":3},{"letters":[["","I",""],["I","N","K"]],"solutionTop":5,"solutionLeft":8},{"letters":[["W"]],"solutionTop":8,"solutionLeft":4},{"letters":[["G"]],"solutionTop":5,"solutionLeft":1},{"letters":[["Y"]],"solutionTop":6,"solutionLeft":2},{"letters":[["Z","L","E"],["","E",""],["","T",""]],"solutionTop":3,"solutionLeft":5}]; -// todo now - -- large screen -- todo comments - // todo later PR: - add some custom daily puzzles @@ -48,3 +43,4 @@ For screenshots: - piecesOverlapQ should use getGridFromPieces - rules = use line-spacing in css instead of multiple br - rules = use the icons instead of words +- improve large screen From b8b0acd843df6784357eae47167b03bdf1572050 Mon Sep 17 00:00:00 2001 From: skedwards88 Date: Sat, 24 Aug 2024 19:21:00 -0700 Subject: [PATCH 35/36] fix padding logic and add more tests --- src/logic/resizeGrid.js | 34 +++--- src/logic/resizeGrid.test.js | 209 ++++++++++++++++++++++++++++++++++- 2 files changed, 223 insertions(+), 20 deletions(-) diff --git a/src/logic/resizeGrid.js b/src/logic/resizeGrid.js index c2444f0..6558973 100644 --- a/src/logic/resizeGrid.js +++ b/src/logic/resizeGrid.js @@ -1,11 +1,16 @@ import cloneDeep from "lodash.clonedeep"; import {getMaxShifts} from "@skedwards88/word_logic"; -// `grid` starts out as a 12x12 2D array -// Remove or add empty edge columns or rows to get as close as possible to one empty row/column on each edge +// Remove/add empty edge columns or rows to +// get as close as possible to one empty row/column on each edge +// and to center the contents, // while still keeping the grid square // and not making the grid smaller than 8x8 export function resizeGrid(grid) { + if (grid.some((row) => row.length != grid.length)) { + throw new Error("Input grid is not square"); + } + let paddedGrid = cloneDeep(grid); // Get the current maximum number of empty columns/rows that are on the edge of the grid @@ -14,12 +19,15 @@ export function resizeGrid(grid) { "", ); - // + // Get the width/height, not counting empty rows/cols on the edge const usedWidth = grid.length - maxShiftLeft - maxShiftRight; const usedHeight = grid[0].length - maxShiftUp - maxShiftDown; + // The optimum dimension is 1 empty row/col on each side + // but extra will be added to keep the grid square and at least 8x8 const targetDimension = Math.max(usedHeight + 2, usedWidth + 2, 8); + // Figure out the number of empty rows/cols on each edge const targetPadTop = Math.floor((targetDimension - usedHeight) / 2); const targetPadBottom = Math.ceil((targetDimension - usedHeight) / 2); const targetPadLeft = Math.floor((targetDimension - usedWidth) / 2); @@ -30,12 +38,10 @@ export function resizeGrid(grid) { paddedGrid.splice(0, maxShiftUp - targetPadTop); } else { // or pad - paddedGrid = [ - ...Array(targetPadTop - maxShiftUp).fill( - Array(paddedGrid[0].length).fill(""), - ), - ...paddedGrid, - ]; + const newRows = Array.from({length: targetPadTop - maxShiftUp}, () => + Array(paddedGrid[0].length).fill(""), + ); + paddedGrid = [...newRows, ...paddedGrid]; } if (targetPadBottom < maxShiftDown) { @@ -43,12 +49,10 @@ export function resizeGrid(grid) { paddedGrid.splice(-(maxShiftDown - targetPadBottom)); } else { // or pad - paddedGrid = [ - ...paddedGrid, - ...Array(targetPadBottom - maxShiftDown).fill( - Array(paddedGrid[0].length).fill(""), - ), - ]; + const newRows = Array.from({length: targetPadBottom - maxShiftDown}, () => + Array(paddedGrid[0].length).fill(""), + ); + paddedGrid = [...paddedGrid, ...newRows]; } if (targetPadLeft < maxShiftLeft) { diff --git a/src/logic/resizeGrid.test.js b/src/logic/resizeGrid.test.js index f15a6c6..e49f25f 100644 --- a/src/logic/resizeGrid.test.js +++ b/src/logic/resizeGrid.test.js @@ -30,6 +30,29 @@ describe("resizeGrid", () => { expect(resizeGrid(input)).toEqual(expected); }); + test("expands a grid up to 8x8", () => { + const input = [ + ["A", "B", "C", "", "", ""], + ["A", "B", "C", "", "", ""], + ["X", "Y", "Z", "", "", ""], + ["", "", "", "", "", ""], + ["", "", "", "", "", ""], + ["", "", "", "", "", ""], + ]; + const expected = [ + ["", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", ""], + ["", "", "A", "B", "C", "", "", ""], + ["", "", "A", "B", "C", "", "", ""], + ["", "", "X", "Y", "Z", "", "", ""], + ["", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", ""], + ]; + + expect(resizeGrid(input)).toEqual(expected); + }); + test("pads a grid so that there 1 empty row/col on each edge", () => { const input = [ ["A", "", "", "", "", "", "", "", "", "", "", "B"], @@ -80,7 +103,6 @@ describe("resizeGrid", () => { ["", "", "", "", "", "", "", "", "", "", "", ""], ["", "", "", "", "", "", "", "", "", "", "", ""], ]; - console.log(JSON.stringify(resizeGrid(input))); const expected = [ ["", "", "", "", "", "", "", ""], @@ -96,7 +118,7 @@ describe("resizeGrid", () => { expect(resizeGrid(input)).toEqual(expected); }); - test("works if the grid contents are on the left edge", () => { + test("works if the grid contents are on the left edge and need padding", () => { const input = [ ["A", "", "", "", "", "", "", "", "", "", "", ""], ["", "", "", "", "", "", "", "", "", "", "", ""], @@ -131,7 +153,7 @@ describe("resizeGrid", () => { expect(resizeGrid(input)).toEqual(expected); }); - test("works if the grid contents are on the right edge", () => { + test("works if the grid contents are on the right edge and need padding", () => { const input = [ ["", "", "", "", "", "", "", "", "", "", "", "A"], ["", "", "", "", "", "", "", "", "", "", "", ""], @@ -166,7 +188,7 @@ describe("resizeGrid", () => { expect(resizeGrid(input)).toEqual(expected); }); - test("works if the grid contents are on the top edge", () => { + test("works if the grid contents are on the top edge and need padding", () => { const input = [ ["A", "", "", "D", "", "", "", "", "", "", "", "C"], ["", "", "", "", "", "", "", "", "", "", "", ""], @@ -201,7 +223,7 @@ describe("resizeGrid", () => { expect(resizeGrid(input)).toEqual(expected); }); - test("works if the grid contents are on the bottom edge", () => { + test("works if the grid contents are on the bottom edge and need padding", () => { const input = [ ["", "", "", "", "", "", "", "", "", "", "", ""], ["", "", "", "", "", "", "", "", "", "", "", ""], @@ -236,6 +258,153 @@ describe("resizeGrid", () => { expect(resizeGrid(input)).toEqual(expected); }); + test("works if the grid contents are near bottom edge and need trimming on the top and padding on the bottom", () => { + const input = [ + ["", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "A", "B", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", ""], + ]; + + const expected = [ + ["", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", ""], + ["", "", "", "A", "B", "", "", ""], + ["", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", ""], + ]; + + expect(resizeGrid(input)).toEqual(expected); + }); + + test("works if the grid contents are near top edge and need trimming on the bottom and padding on the top", () => { + const input = [ + ["", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "A", "B", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", ""], + ]; + + const expected = [ + ["", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", ""], + ["", "", "", "A", "B", "", "", ""], + ["", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", ""], + ]; + + expect(resizeGrid(input)).toEqual(expected); + }); + + test("works if the grid contents are near left edge", () => { + const input = [ + ["", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "A", "B", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", "", ""], + ]; + + const expected = [ + ["", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", ""], + ["", "", "", "A", "B", "", "", ""], + ["", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", ""], + ]; + + expect(resizeGrid(input)).toEqual(expected); + }); + + test("works if the grid contents are near right edge", () => { + const input = [ + ["", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "A", "B", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", ""], + ]; + + const expected = [ + ["", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", ""], + ["", "", "", "A", "B", "", "", ""], + ["", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", ""], + ]; + + expect(resizeGrid(input)).toEqual(expected); + }); + + test("testing odd number row/cols", () => { + const input = [ + ["", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "A", "B", "C", "", "", "", "", ""], + ["", "", "", "A", "B", "C", "", "", "", "", ""], + ["", "", "", "X", "Y", "Z", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", "", "", ""], + ]; + const expected = [ + ["", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", ""], + ["", "", "A", "B", "C", "", "", ""], + ["", "", "A", "B", "C", "", "", ""], + ["", "", "X", "Y", "Z", "", "", ""], + ["", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", ""], + ]; + + expect(resizeGrid(input)).toEqual(expected); + }); + test("works on an empty grid", () => { const input = [ ["", "", "", "", "", "", "", "", "", "", "", ""], @@ -281,4 +450,34 @@ describe("resizeGrid", () => { expect(resizeGrid(input)).toEqual(input); expect(resizeGrid(input)).not.toBe(input); }); + + test("errors if grid is taller than wide", () => { + const input = [ + ["", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", ""], + ["", "", "A", "B", "C", "", "", ""], + ["", "", "A", "B", "C", "", "", ""], + ["", "", "X", "Y", "Z", "", "", ""], + ["", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", ""], + ]; + + expect(() => resizeGrid(input)).toThrow("Input grid is not square"); + }); + + test("errors if grid is wider than tall", () => { + const input = [ + ["", "", "", "", "", "", "", ""], + ["", "", "A", "B", "C", "", "", ""], + ["", "", "A", "B", "C", "", "", ""], + ["", "", "X", "Y", "Z", "", "", ""], + ["", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", ""], + ["", "", "", "", "", "", "", ""], + ]; + + expect(() => resizeGrid(input)).toThrow("Input grid is not square"); + }); }); From 7ec7e615b6cbb1b967e218ae10a605da83d502da Mon Sep 17 00:00:00 2001 From: skedwards88 Date: Sat, 24 Aug 2024 19:28:52 -0700 Subject: [PATCH 36/36] clean up some comments --- src/components/CustomCreation.js | 2 +- src/logic/convertGridToRepresentativeString.js | 2 +- src/logic/convertRepresentativeStringToGrid.js | 1 + src/logic/gameInit.js | 8 ++++---- src/logic/gameReducer.js | 9 ++++++--- src/logic/generatePuzzleFromRepresentativeString.js | 8 ++++---- 6 files changed, 17 insertions(+), 13 deletions(-) diff --git a/src/components/CustomCreation.js b/src/components/CustomCreation.js index d3f660d..765dd03 100644 --- a/src/components/CustomCreation.js +++ b/src/components/CustomCreation.js @@ -30,7 +30,7 @@ function CustomCreation({dispatchCustomState, customState, validityOpacity}) { indicateValidity={validityOpacity > 0} dragPieceIDs={customState.dragState?.pieceIDs} dragDestination={customState.dragState?.destination} - gridSize={12} + gridSize={customState.gridSize} customCreation={true} > 9) { diff --git a/src/logic/convertRepresentativeStringToGrid.js b/src/logic/convertRepresentativeStringToGrid.js index 083da71..d665d2b 100644 --- a/src/logic/convertRepresentativeStringToGrid.js +++ b/src/logic/convertRepresentativeStringToGrid.js @@ -54,6 +54,7 @@ export function convertRepresentativeStringToGrid(string) { // so I don't need to remove whitespace from the edges or center the grid. // (Since this is done when the custom query is generated via the UI.) // By this same logic, I'm not validating that the puzzle consists of known words. + // (Since that is done when a player tries to generate the query string via the UI.) const grid = arrayToGrid(list); return grid; diff --git a/src/logic/gameInit.js b/src/logic/gameInit.js index a3d0531..02b2873 100644 --- a/src/logic/gameInit.js +++ b/src/logic/gameInit.js @@ -54,9 +54,7 @@ export function gameInit({ if ( savedState && savedState.seed && - //todo verify comment clarity - // If daily or custom, use the saved state if the seed matches - // otherwise, we don't care if the seed matches + // Make sure the seed matches (unless this isn't a daily or custom game) ((!isDaily && !isCustom) || savedState.seed == seed) && validateSavedState(savedState) && // Use the saved state if daily even if the game is solved @@ -90,7 +88,9 @@ export function gameInit({ maxShiftDown, } = generatePuzzleFromRepresentativeString({representativeString: seed})); } catch (error) { - console.error(error); + console.error( + `Error generating custom puzzle from seed ${seed}. Will proceed to generate random game instead. Caught error: ${error}`, + ); ({pieces, maxShiftLeft, maxShiftRight, maxShiftUp, maxShiftDown} = generatePuzzle({ diff --git a/src/logic/gameReducer.js b/src/logic/gameReducer.js index 3a74ff5..b699431 100644 --- a/src/logic/gameReducer.js +++ b/src/logic/gameReducer.js @@ -101,7 +101,8 @@ function updateStateForDragStart({ }; } - // (For custom creation only) If dragging from the pool, add a dummy placeholder + // (For custom creation only) + // If dragging from the pool, add a dummy placeholder let placeholderPoolPieces = []; if (isCustomCreation && groupBoardTop === undefined) { placeholderPoolPieces = piecesBeingDragged.map((piece) => @@ -289,8 +290,9 @@ function updateStateForCustomDragEnd(currentGameState) { newPieces.push(piece); } } - } else if (destination.where === "pool" && origin.where === "board") { - // If dragging from board to pool, clear the piece from the board but don't add it to the pool + } + // If dragging from board to pool, clear the piece from the board but don't add it to the pool + else if (destination.where === "pool" && origin.where === "board") { for (const piece of currentGameState.pieces) { if (draggedPieceIDs.includes(piece.id)) { continue; @@ -300,6 +302,7 @@ function updateStateForCustomDragEnd(currentGameState) { } } // If dragging from pool to pool, readd the piece to the pool at its original position + // (and get rid of the empty placeholder piece) else if (destination.where === "pool" && origin.where === "pool") { for (const piece of currentGameState.pieces) { if (draggedPieceIDs.includes(piece.id)) { diff --git a/src/logic/generatePuzzleFromRepresentativeString.js b/src/logic/generatePuzzleFromRepresentativeString.js index 8462abb..a3675b5 100644 --- a/src/logic/generatePuzzleFromRepresentativeString.js +++ b/src/logic/generatePuzzleFromRepresentativeString.js @@ -27,10 +27,10 @@ export function generatePuzzleFromRepresentativeString({representativeString}) { return { pieces: pieceData, - maxShiftLeft: maxShiftLeft, - maxShiftRight: maxShiftRight, - maxShiftUp: maxShiftUp, - maxShiftDown: maxShiftDown, + maxShiftLeft, + maxShiftRight, + maxShiftUp, + maxShiftDown, gridSize, minLetters, };