Skip to content

Commit

Permalink
Merge pull request #41 from skedwards88/custom-puzzles
Browse files Browse the repository at this point in the history
Allow custom puzzles
  • Loading branch information
skedwards88 committed Aug 25, 2024
2 parents 9c19cb4 + 7ec7e61 commit cb8e6b0
Show file tree
Hide file tree
Showing 29 changed files with 1,741 additions and 54 deletions.
16 changes: 16 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,19 @@ 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 later PR:

- add some custom daily puzzles
- Use the hasVisited state to announce
- relocate to new file:
- getWordValidityGrids
- countingGrid
- just rename dispatch params to dispatcher everywhere
- put arrayToGrid to common or to word-logic package
- consolidate control buttons into hamburger menu (and add share button)
- add link to sponsors?
- piecesOverlapQ should use getGridFromPieces
- rules = use line-spacing in css instead of multiple br
- rules = use the icons instead of words
- improve large screen
167 changes: 166 additions & 1 deletion src/components/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,32 @@ import Game from "./Game";
import Heart from "./Heart";
import Rules from "./Rules";
import Stats from "./Stats";
import CustomCreation from "./CustomCreation";
import CustomShare from "./CustomShare";
import ControlBar from "./ControlBar";
import {
handleAppInstalled,
handleBeforeInstallPrompt,
} 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 {parseUrlQuery} from "../logic/parseUrlQuery";
import {getInitialState} from "../common/getInitialState";
import {hasVisitedSince} from "../common/hasVisitedSince";
import {handleShare} from "../common/handleShare";
import {convertGridToRepresentativeString} from "../logic/convertGridToRepresentativeString";
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,
// 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
Expand Down Expand Up @@ -47,6 +56,7 @@ export default function App() {
{
seed,
numLetters,
isCustom,
},
gameInit,
);
Expand All @@ -57,9 +67,53 @@ export default function App() {
gameInit,
);

const [customState, dispatchCustomState] = React.useReducer(
gameReducer,
{},
customInit,
);

// todo consolidate lastVisited and setLastOpened?
const [, setLastOpened] = React.useState(Date.now());

function handleCustomGeneration() {
// If there is nothing to share, display a message with errors
if (!customState.pieces.some((piece) => piece.boardTop >= 0)) {
throw new Error("Add some letters to the board first!");
}

// Validate the grid
// - The UI restricts the grid size, so don't need to validate that
// - Make sure all letters are connected
// - Make sure all horizontal and vertical words are known
const grid = getGridFromPieces({
pieces: customState.pieces,
gridSize: customState.gridSize,
solution: false,
});

const {gameIsSolved, reason} = crosswordValidQ({
grid: grid,
trie: trie,
});

// If the board is not valid, display a message with errors
if (!gameIsSolved) {
throw new Error(reason);
}

// 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(
resizedGrid,
cipherShift,
);

return representativeString;
}

function handleVisibilityChange() {
// If the visibility of the app changes to become visible,
// update the state to force the app to re-render.
Expand Down Expand Up @@ -119,6 +173,13 @@ export default function App() {
);
}, [dailyGameState]);

React.useEffect(() => {
window.localStorage.setItem(
"crossjigCustomCreation",
JSON.stringify(customState),
);
}, [customState]);

switch (display) {
case "rules":
return <Rules setDisplay={setDisplay}></Rules>;
Expand Down Expand Up @@ -182,6 +243,110 @@ export default function App() {
<Stats setDisplay={setDisplay} stats={dailyGameState.stats}></Stats>
);

case "custom":
return (
<div className="App" id="crossjig">
<div id="controls">
<button
id="playCustomButton"
onClick={() => {
let representativeString;
try {
representativeString = handleCustomGeneration();
} catch (error) {
const invalidReason = error.message;
dispatchCustomState({
action: "updateInvalidReason",
invalidReason: invalidReason,
});
setDisplay("customError");
return;
}

dispatchGameState({
action: "playCustom",
representativeString,
});
setDisplay("game");
}}
>
Play
</button>
<button
id="shareCustomButton"
onClick={() => {
let representativeString;
try {
representativeString = handleCustomGeneration();
} catch (error) {
const invalidReason = error.message;
dispatchCustomState({
action: "updateInvalidReason",
invalidReason: invalidReason,
});
setDisplay("customError");
return;
}

// Share (or show the link if sharing is not supported)
if (navigator.canShare) {
handleShare({
appName: "Crossjig",
text: "Try this custom crossjig that I created!",
url: "https://crossjig.com",
representativeString,
});
} else {
dispatchCustomState({
action: "updateRepresentativeString",
representativeString,
});
setDisplay("customShare");
}
}}
>
Share
</button>
<button id="exitCustomButton" onClick={() => setDisplay("game")}>
Cancel
</button>
</div>
<CustomCreation
dispatchCustomState={dispatchCustomState}
validityOpacity={gameState.validityOpacity}
customState={customState}
setDisplay={setDisplay}
></CustomCreation>
</div>
);

case "customError":
return (
<div className="App customMessage">
<div>{`Your game isn't ready to share yet: \n\n${customState.invalidReason}`}</div>
<button
onClick={() => {
dispatchCustomState({
action: "updateInvalidReason",
invalidReason: "",
});
setDisplay("custom");
}}
>
Ok
</button>
</div>
);

case "customShare":
return (
<CustomShare
representativeString={customState.representativeString}
dispatchCustomState={dispatchCustomState}
setDisplay={setDisplay}
></CustomShare>
);

default:
return (
<div className="App" id="crossjig">
Expand Down
31 changes: 23 additions & 8 deletions src/components/Board.js
Original file line number Diff line number Diff line change
Expand Up @@ -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});

Expand All @@ -117,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
key={piece.id}
Expand Down
5 changes: 5 additions & 0 deletions src/components/ControlBar.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ function ControlBar({
className="controlButton"
onClick={() => setDisplay("rules")}
></button>
<button
id="customButton"
className="controlButton"
onClick={() => setDisplay("custom")}
></button>
<button
id="heartButton"
className="controlButton"
Expand Down
46 changes: 46 additions & 0 deletions src/components/CustomCreation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import React from "react";
import Pool from "./Pool";
import Board from "./Board";
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 ? (
<DragGroup
key={customState.dragCount}
dispatchGameState={dispatchCustomState}
gameState={customState}
/>
) : null;

return (
<div
id="custom"
style={{
"--grid-rows": customState.gridSize,
"--grid-columns": customState.gridSize,
"--validity-opacity": validityOpacity,
}}
>
<Board
pieces={customState.pieces}
gameIsSolved={false}
dispatchGameState={dispatchCustomState}
indicateValidity={validityOpacity > 0}
dragPieceIDs={customState.dragState?.pieceIDs}
dragDestination={customState.dragState?.destination}
gridSize={customState.gridSize}
customCreation={true}
></Board>
<Pool
// don't pass drag destination so that won't render drag shadow on pool
pieces={customState.pieces}
dispatchGameState={dispatchCustomState}
></Pool>
{dragGroup}
</div>
);
}

export default CustomCreation;
40 changes: 40 additions & 0 deletions src/components/CustomShare.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import React from "react";

export default function CustomShare({
representativeString,
dispatchCustomState,
setDisplay,
}) {
const link = `https://crossjig.com?id=custom-${representativeString}`;

return (
<div className="App customMessage">
<div>{`Share your custom puzzle with this link!`}</div>
<a href={link}>{link}</a>
<div id="custom-message-buttons">
<button
onClick={() => {
try {
navigator.clipboard.writeText(link);
} catch (error) {
console.log("Error copying", error);
}
}}
>
Copy
</button>
<button
onClick={() => {
dispatchCustomState({
action: "updateRepresentativeString",
representativeString: "",
});
setDisplay("custom");
}}
>
Ok
</button>
</div>
</div>
);
}
Loading

0 comments on commit cb8e6b0

Please sign in to comment.