From 974751bda0f2cbb6a28fbea6e348c7177633f385 Mon Sep 17 00:00:00 2001 From: Joel Therrien Date: Wed, 23 Aug 2023 20:46:43 -0700 Subject: [PATCH] Split UI code into separate files --- ui/src/Game.tsx | 206 ++++++++++++++ ui/src/TileExchange.tsx | 126 +++++++++ ui/src/UI.tsx | 196 +++++++++++++ ui/src/elements.tsx | 590 ---------------------------------------- ui/src/index.tsx | 4 +- ui/src/utils.ts | 88 +++++- 6 files changed, 616 insertions(+), 594 deletions(-) create mode 100644 ui/src/Game.tsx create mode 100644 ui/src/TileExchange.tsx create mode 100644 ui/src/UI.tsx delete mode 100644 ui/src/elements.tsx diff --git a/ui/src/Game.tsx b/ui/src/Game.tsx new file mode 100644 index 0000000..fb55262 --- /dev/null +++ b/ui/src/Game.tsx @@ -0,0 +1,206 @@ +import * as React from "react"; +import {GameWasm, Letter as LetterData, MyResult, PlayedTile, PlayerAndScore, Tray, WordResult} from "word_grid"; +import { + LocationType, + matchCoordinate, + mergeTrays, + PlayableLetterData, + Settings, + TileDispatchAction, + TileDispatchActionType +} from "./utils"; +import {useEffect, useMemo, useReducer, useRef, useState} from "react"; +import {TileExchangeModal} from "./TileExchange"; +import {Grid, Scores, TileTray} from "./UI"; + +function addLogInfo(existingLog: React.JSX.Element[], newItem: React.JSX.Element) { + newItem = React.cloneElement(newItem, { key: existingLog.length }) + existingLog.push(newItem); + return existingLog.slice(); +} + +export function Game(props: {wasm: GameWasm, settings: Settings}) { + + const cellTypes = useMemo(() => { + return props.wasm.get_board_cell_types(); + }, []); + + const [confirmedScorePoints, setConfirmedScorePoints] = useState(-1); + + function movePlayableLetters(playerLetters: PlayableLetterData[], update: TileDispatchAction) { + + if(update.action === TileDispatchActionType.RETRIEVE) { + + let tray: Tray = props.wasm.get_tray("Player"); + + return mergeTrays(playerLetters, tray.letters); + } else if (update.action === TileDispatchActionType.MOVE) { + + let startIndex = matchCoordinate(playerLetters, update.start); + let endIndex = matchCoordinate(playerLetters, update.end); + + if(startIndex != null) { + let startLetter = playerLetters[startIndex]; + startLetter.location = update.end.location; + startLetter.index = update.end.index; + } + + if(endIndex != null) { + let endLetter = playerLetters[endIndex]; + endLetter.location = update.start.location; + endLetter.index = update.start.index; + } + + setConfirmedScorePoints(-1); + + return playerLetters.slice(); + } if (update.action === TileDispatchActionType.SET_BLANK) { + const blankLetter = playerLetters[update.blankIndex]; + if(blankLetter.text !== update.newBlankValue) { + blankLetter.text = update.newBlankValue; + if (blankLetter.location == LocationType.GRID) { + setConfirmedScorePoints(-1); + } + } + return playerLetters.slice(); + } else if (update.action === TileDispatchActionType.RETURN) { + return mergeTrays(playerLetters, playerLetters); + } else { + console.error("Unknown tray update"); + console.error({update}); + } + + } + + + + const [playerLetters, trayDispatch] = useReducer(movePlayableLetters, []); + const [logInfo, logDispatch] = useReducer(addLogInfo, []); + + const [turnCount, setTurnCount] = useState(1); + const playerAndScores: PlayerAndScore[] = useMemo(() => { + return props.wasm.get_scores(); + }, [turnCount]); + const boardLetters: LetterData[] = useMemo(() => { + return props.wasm.get_board_letters(); + }, [turnCount]); + + useEffect(() => { + logDispatch(

Turn {turnCount}

); + setConfirmedScorePoints(-1); + trayDispatch({action: TileDispatchActionType.RETRIEVE}); + + }, [turnCount]); + + const logDivRef = useRef(null); + + const [isTileExchangeOpen, setIsTileExchangeOpen] = useState(false); + + useEffect(() => { + // Effect is to keep the log window scrolled down + if (logDivRef.current != null) { + logDivRef.current.scrollTo(0, logDivRef.current.scrollHeight); // scroll down + } + }, [logInfo]) + + function exchangeFunction(selectedArray: Array) { + + let numSelected = 0; + selectedArray.forEach((x) => { + if (x){ + numSelected++; + } + }) + + const result: MyResult = props.wasm.exchange_tiles("Player", selectedArray); + console.log({result}); + + if(result.response_type === "ERR") { + logDispatch(
{(result.value as string)}
); + } else { + logDispatch(
You exchanged {numSelected} tiles.
); + setTurnCount(turnCount + 1); + } + + } + + + return <> + +
+ +
+ +
+ {logInfo} +
+
+
+ + + + + + ; + + +} diff --git a/ui/src/TileExchange.tsx b/ui/src/TileExchange.tsx new file mode 100644 index 0000000..7388e92 --- /dev/null +++ b/ui/src/TileExchange.tsx @@ -0,0 +1,126 @@ +import {addNTimes, PlayableLetterData} from "./utils"; +import {useEffect, useState} from "react"; +import {Modal} from "./Modal"; +import {Letter as LetterData} from "word_grid"; +import * as React from "react"; + +export function TileExchangeModal(props: { + playerLetters: PlayableLetterData[], + isOpen: boolean, + setOpen: (isOpen: boolean) => void, + exchangeFunction: (selectedArray: Array) => void +}) { + + function clearExchangeTiles() { + const array: boolean[] = []; + addNTimes(array, false, props.playerLetters.length); + return array; + } + + const [tilesToExchange, setTilesToExchange] = useState(clearExchangeTiles); + useEffect(() => { + setTilesToExchange(clearExchangeTiles()); + }, [props.playerLetters]) + + let tilesExchangedSelected = 0; + for (let i of tilesToExchange) { + if(i) { + tilesExchangedSelected++; + } + } + + return { + setTilesToExchange(clearExchangeTiles()); + props.setOpen(isOpen); + }}> +
+
+ Click on each tile you'd like to exchange. You currently have {tilesExchangedSelected} tiles selected. +
+
+ + +
+ + +
+ + +
+
+
; + +} + +function TilesExchangedTray(props: { + tray: Array, + trayLength: number, + selectedArray: Array, + setSelectedArray: (x: Array) => void, +}){ + + const divContent = []; + for(let i=0; i); // empty tile elements + } + + for(let i = 0; i { + props.selectedArray[i] = !props.selectedArray[i]; + props.setSelectedArray(props.selectedArray.slice()); + } + + if(tileData != null){ + divContent[tileData.index] = + ; + + } + } + + return
+ {divContent} +
; + +} + +function DummyExchangeTile(props: { + letter: LetterData, + isSelected: boolean, + onClick: () => void, +}){ + + let textClassName = "text"; + if(props.letter.is_blank) { + textClassName += " prev-blank"; + } + + let letterClassName = "letter"; + if(props.isSelected){ + letterClassName += ' selected-tile'; + } + + + return
+
{props.letter.text}
+
{props.letter.points}
+
; +} diff --git a/ui/src/UI.tsx b/ui/src/UI.tsx new file mode 100644 index 0000000..4e41e4e --- /dev/null +++ b/ui/src/UI.tsx @@ -0,0 +1,196 @@ +import * as React from "react"; +import {Letter as LetterData, PlayerAndScore} from "word_grid"; +import {ChangeEvent, JSX} from "react"; +import { + cellTypeToDetails, + CellType, + LocationType, + PlayableLetterData, + CoordinateData, TileDispatchActionType, TileDispatch, +} from "./utils"; + + +export function TileSlot(props: { tile?: React.JSX.Element | undefined, location: CoordinateData, dispatch: TileDispatch }): React.JSX.Element { + let isDraggable = props.tile !== undefined; + + function onDragStart(e: React.DragEvent) { + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("wordGrid/coords", JSON.stringify(props.location)); + } + + function onDrop(e: React.DragEvent) { + const startLocation: CoordinateData = JSON.parse(e.dataTransfer.getData("wordGrid/coords")); + const thisLocation = props.location; + + props.dispatch({action: TileDispatchActionType.MOVE, start: startLocation, end: thisLocation}); + } + + let className = "tileSpot"; + if (props.location.location === LocationType.GRID) { + className += " ephemeral"; + } + + return
{e.preventDefault()}} + > + {props.tile} +
+ +} + +export function Letter(props: { data: LetterData, letterUpdater?: (value: string) => void }): React.JSX.Element { + + function modifyThisLetter(value:string){ + props.letterUpdater(value); + } + + + function onBlankInput(e: ChangeEvent){ + let value = e.target.value.toUpperCase(); + if(value.length > 1){ + value = value[value.length - 1]; + } else if(value.length == 0){ + modifyThisLetter(value); + return; + } + + // Now check that it's a letter + let is_letter = value.match("[A-Z]") != null; + if(is_letter){ + modifyThisLetter(value); + } else { + // Cancel and do nothing + e.preventDefault(); + } + + } + + if(props.data.is_blank && props.data.ephemeral) { + return
+ +
{props.data.points}
+
+ } else { + let className = "text"; + if (props.data.is_blank) { // not ephemeral + className += " prev-blank"; + } + return
+
{props.data.text}
+
{props.data.points}
+
+ } + + + +} + +export function TileTray(props: { letters: Array, trayLength: number, dispatch: TileDispatch }): React.JSX.Element { + + let elements: JSX.Element[] = []; + for (let i=0; i + ); + } + + props.letters + .filter((x) => {return x !== undefined;}) + .forEach((letter, i) => { + if (letter.location === LocationType.TRAY) { + elements[letter.index] = + { + props.dispatch({action: TileDispatchActionType.SET_BLANK, blankIndex: i, newBlankValue: value}) + }} + />} + key={"letter" + letter.index} + location={{location: LocationType.TRAY, index: letter.index}} + dispatch={props.dispatch} /> + + } + }); + + return ( +
+ {elements} +
+ ) + +} + +export function Grid(props: { + cellTypes: CellType[], + playerLetters: Array, + boardLetters: LetterData[], + dispatch: TileDispatch}) { + + const elements = props.cellTypes.map((ct, i) => { + const {className, text} = cellTypeToDetails(ct); + + let tileElement: JSX.Element; + if (props.boardLetters[i] !== undefined) { + tileElement = ; + } else { + tileElement = <> + {text} + ; + } + + return
+ + {tileElement} +
; + }); + + props.playerLetters + .filter((letter) => {return letter !== undefined}) + .forEach((letter, i) => { + if (letter.location === LocationType.GRID) { + const ct = props.cellTypes[letter.index]; + const {className, text} = cellTypeToDetails(ct); + + elements[letter.index] = +
+ { + props.dispatch({action: TileDispatchActionType.SET_BLANK, blankIndex: i, newBlankValue: value}); + }} + />} + location={{location: LocationType.GRID, index: letter.index}} + dispatch={props.dispatch} /> +
; + + + } + }); + + return
+ {elements} +
+} + +export function Scores(props: {playerScores: Array}){ + let elements = props.playerScores.map((ps, i) => { + return
+

{ps.name}

+ {ps.score} +
; + }); + + return
+ {elements} +
+} diff --git a/ui/src/elements.tsx b/ui/src/elements.tsx deleted file mode 100644 index b371d47..0000000 --- a/ui/src/elements.tsx +++ /dev/null @@ -1,590 +0,0 @@ -import * as React from "react"; -import {GameWasm, Letter as LetterData, MyResult, PlayedTile, PlayerAndScore, Tray, WordResult} from "word_grid"; -import {ChangeEvent, JSX, useEffect, useMemo, useReducer, useRef, useState} from "react"; -import {Modal} from "./Modal"; -import {addNTimes, mergeTrays} from "./utils"; - -export interface Settings { - trayLength: number; -} - -export enum LocationType { - GRID, - TRAY -} - -enum CellType { - Normal = "Normal", - DoubleWord = "DoubleWord", - DoubleLetter = "DoubleLetter", - TripleLetter = "TripleLetter", - TripleWord = "TripleWord", - Start = "Start", -} - -function cell_type_to_details(cell_type: CellType): {className: string, text: string} { - let className: string; - let text: string; - - switch (cell_type) { - case CellType.Normal: - className = "grid-spot-normal"; - text = ""; - break; - case CellType.DoubleWord: - className = "grid-spot-double-word"; - text = "Double Word Score"; - break; - case CellType.DoubleLetter: - className = "grid-spot-double-letter"; - text = "Double Letter Score"; - break; - case CellType.TripleLetter: - className = "grid-spot-triple-letter"; - text = "Triple Letter Score"; - break; - case CellType.TripleWord: - className = "grid-spot-triple-word"; - text = "Triple Word Score"; - break; - case CellType.Start: - className = "grid-spot-start"; - text = "★"; - break; - - } - - return {className: className, text: text}; -} - -export interface CoordinateData { - location: LocationType; - index: number; -} - -export type PlayableLetterData = LetterData & CoordinateData; - -function matchCoordinate(playerLetters: PlayableLetterData[], coords: CoordinateData): number { - - for (let i=0; i; - -function addLogInfo(existingLog: React.JSX.Element[], newItem: React.JSX.Element) { - newItem = React.cloneElement(newItem, { key: existingLog.length }) - existingLog.push(newItem); - return existingLog.slice(); -} - -export function Game(props: {wasm: GameWasm, settings: Settings}) { - - const cellTypes = useMemo(() => { - return props.wasm.get_board_cell_types(); - }, []); - - const [confirmedScorePoints, setConfirmedScorePoints] = useState(-1); - - function movePlayableLetters(playerLetters: PlayableLetterData[], update: TileDispatchAction) { - - if(update.action === TileDispatchActionType.RETRIEVE) { - - let tray: Tray = props.wasm.get_tray("Player"); - - return mergeTrays(playerLetters, tray.letters); - } else if (update.action === TileDispatchActionType.MOVE) { - - let startIndex = matchCoordinate(playerLetters, update.start); - let endIndex = matchCoordinate(playerLetters, update.end); - - if(startIndex != null) { - let startLetter = playerLetters[startIndex]; - startLetter.location = update.end.location; - startLetter.index = update.end.index; - } - - if(endIndex != null) { - let endLetter = playerLetters[endIndex]; - endLetter.location = update.start.location; - endLetter.index = update.start.index; - } - - setConfirmedScorePoints(-1); - - return playerLetters.slice(); - } if (update.action === TileDispatchActionType.SET_BLANK) { - const blankLetter = playerLetters[update.blankIndex]; - if(blankLetter.text !== update.newBlankValue) { - blankLetter.text = update.newBlankValue; - if (blankLetter.location == LocationType.GRID) { - setConfirmedScorePoints(-1); - } - } - return playerLetters.slice(); - } else if (update.action === TileDispatchActionType.RETURN) { - return mergeTrays(playerLetters, playerLetters); - } else { - console.error("Unknown tray update"); - console.error({update}); - } - - } - - - - const [playerLetters, trayDispatch] = useReducer(movePlayableLetters, []); - const [logInfo, logDispatch] = useReducer(addLogInfo, []); - - const [turnCount, setTurnCount] = useState(1); - const playerAndScores: PlayerAndScore[] = useMemo(() => { - return props.wasm.get_scores(); - }, [turnCount]); - const boardLetters: LetterData[] = useMemo(() => { - return props.wasm.get_board_letters(); - }, [turnCount]); - - useEffect(() => { - logDispatch(

Turn {turnCount}

); - setConfirmedScorePoints(-1); - trayDispatch({action: TileDispatchActionType.RETRIEVE}); - - }, [turnCount]); - - const logDivRef = useRef(null); - - const [isTileExchangeOpen, setIsTileExchangeOpen] = useState(false); - - useEffect(() => { - // Effect is to keep the log window scrolled down - if (logDivRef.current != null) { - logDivRef.current.scrollTo(0, logDivRef.current.scrollHeight); // scroll down - } - }, [logInfo]) - - function exchangeFunction(selectedArray: Array) { - - let numSelected = 0; - selectedArray.forEach((x) => { - if (x){ - numSelected++; - } - }) - - const result: MyResult = props.wasm.exchange_tiles("Player", selectedArray); - console.log({result}); - - if(result.response_type === "ERR") { - logDispatch(
{(result.value as string)}
); - } else { - logDispatch(
You exchanged {numSelected} tiles.
); - setTurnCount(turnCount + 1); - } - - } - - - return <> - -
- -
- -
- {logInfo} -
-
-
- - - - - - ; - - -} - - - -export function TileSlot(props: { tile?: React.JSX.Element | undefined, location: CoordinateData, dispatch: TileDispatch }): React.JSX.Element { - let isDraggable = props.tile !== undefined; - - function onDragStart(e: React.DragEvent) { - e.dataTransfer.effectAllowed = "move"; - e.dataTransfer.setData("wordGrid/coords", JSON.stringify(props.location)); - } - - function onDrop(e: React.DragEvent) { - const startLocation: CoordinateData = JSON.parse(e.dataTransfer.getData("wordGrid/coords")); - const thisLocation = props.location; - - props.dispatch({action: TileDispatchActionType.MOVE, start: startLocation, end: thisLocation}); - } - - let className = "tileSpot"; - if (props.location.location === LocationType.GRID) { - className += " ephemeral"; - } - - return
{e.preventDefault()}} - > - {props.tile} -
- -} - -export function Letter(props: { data: LetterData, letterUpdater?: (value: string) => void }): React.JSX.Element { - - function modifyThisLetter(value:string){ - props.letterUpdater(value); - } - - - function onBlankInput(e: ChangeEvent){ - let value = e.target.value.toUpperCase(); - if(value.length > 1){ - value = value[value.length - 1]; - } else if(value.length == 0){ - modifyThisLetter(value); - return; - } - - // Now check that it's a letter - let is_letter = value.match("[A-Z]") != null; - if(is_letter){ - modifyThisLetter(value); - } else { - // Cancel and do nothing - e.preventDefault(); - } - - } - - if(props.data.is_blank && props.data.ephemeral) { - return
- -
{props.data.points}
-
- } else { - let className = "text"; - if (props.data.is_blank) { // not ephemeral - className += " prev-blank"; - } - return
-
{props.data.text}
-
{props.data.points}
-
- } - - - -} - - -export function TileTray(props: { letters: Array, trayLength: number, dispatch: TileDispatch }): React.JSX.Element { - - let elements: JSX.Element[] = []; - for (let i=0; i - ); - } - - props.letters - .filter((x) => {return x !== undefined;}) - .forEach((letter, i) => { - if (letter.location === LocationType.TRAY) { - elements[letter.index] = - { - props.dispatch({action: TileDispatchActionType.SET_BLANK, blankIndex: i, newBlankValue: value}) - }} - />} - key={"letter" + letter.index} - location={{location: LocationType.TRAY, index: letter.index}} - dispatch={props.dispatch} /> - - } - }); - - return ( -
- {elements} -
- ) - -} - -function Grid(props: { - cellTypes: CellType[], - playerLetters: Array, - boardLetters: LetterData[], - dispatch: TileDispatch}) { - - const elements = props.cellTypes.map((ct, i) => { - const {className, text} = cell_type_to_details(ct); - - let tileElement: JSX.Element; - if (props.boardLetters[i] !== undefined) { - tileElement = ; - } else { - tileElement = <> - {text} - ; - } - - return
- - {tileElement} -
; - }); - - props.playerLetters - .filter((letter) => {return letter !== undefined}) - .forEach((letter, i) => { - if (letter.location === LocationType.GRID) { - const ct = props.cellTypes[letter.index]; - const {className, text} = cell_type_to_details(ct); - - elements[letter.index] = -
- { - props.dispatch({action: TileDispatchActionType.SET_BLANK, blankIndex: i, newBlankValue: value}); - }} - />} - location={{location: LocationType.GRID, index: letter.index}} - dispatch={props.dispatch} /> -
; - - - } - }); - - return
- {elements} -
-} - - -function Scores(props: {playerScores: Array}){ - let elements = props.playerScores.map((ps, i) => { - return
-

{ps.name}

- {ps.score} -
; - }); - - return
- {elements} -
-} - -function TileExchangeModal(props: { - playerLetters: PlayableLetterData[], - isOpen: boolean, - setOpen: (isOpen: boolean) => void, - exchangeFunction: (selectedArray: Array) => void - }) { - - function clearExchangeTiles() { - const array: boolean[] = []; - addNTimes(array, false, props.playerLetters.length); - return array; - } - - const [tilesToExchange, setTilesToExchange] = useState(clearExchangeTiles); - useEffect(() => { - setTilesToExchange(clearExchangeTiles()); - }, [props.playerLetters]) - - let tilesExchangedSelected = 0; - for (let i of tilesToExchange) { - if(i) { - tilesExchangedSelected++; - } - } - - return { - setTilesToExchange(clearExchangeTiles()); - props.setOpen(isOpen); - }}> -
-
- Click on each tile you'd like to exchange. You currently have {tilesExchangedSelected} tiles selected. -
-
- - -
- - -
- - -
-
-
; - -} - -function TilesExchangedTray(props: { - tray: Array, - trayLength: number, - selectedArray: Array, - setSelectedArray: (x: Array) => void, -}){ - - const divContent = []; - for(let i=0; i); // empty tile elements - } - - for(let i = 0; i { - props.selectedArray[i] = !props.selectedArray[i]; - props.setSelectedArray(props.selectedArray.slice()); - } - - if(tileData != null){ - divContent[tileData.index] = - ; - - } - } - - return
- {divContent} -
; - -} - -function DummyExchangeTile(props: { - letter: LetterData, - isSelected: boolean, - onClick: () => void, -}){ - - let textClassName = "text"; - if(props.letter.is_blank) { - textClassName += " prev-blank"; - } - - let letterClassName = "letter"; - if(props.isSelected){ - letterClassName += ' selected-tile'; - } - - - return
-
{props.letter.text}
-
{props.letter.points}
-
; -} diff --git a/ui/src/index.tsx b/ui/src/index.tsx index 8fe64bc..1cf42f7 100644 --- a/ui/src/index.tsx +++ b/ui/src/index.tsx @@ -1,5 +1,5 @@ -import init, {greet, GameWasm} from '../node_modules/word_grid/word_grid.js'; -import {Game} from "./elements"; +import init, {GameWasm} from '../node_modules/word_grid/word_grid.js'; +import {Game} from "./Game"; import {createRoot} from "react-dom/client"; import * as React from "react"; diff --git a/ui/src/utils.ts b/ui/src/utils.ts index f33640c..5618673 100644 --- a/ui/src/utils.ts +++ b/ui/src/utils.ts @@ -1,5 +1,89 @@ -import {Letter} from "word_grid"; -import {LocationType, PlayableLetterData} from "./elements"; +import {Letter as LetterData, Letter} from "word_grid"; +import * as React from "react"; + +export enum CellType { + Normal = "Normal", + DoubleWord = "DoubleWord", + DoubleLetter = "DoubleLetter", + TripleLetter = "TripleLetter", + TripleWord = "TripleWord", + Start = "Start", +} + + +export interface Settings { + trayLength: number; +} + +export enum LocationType { + GRID, + TRAY +} + +export interface CoordinateData { + location: LocationType; + index: number; +} + +export type PlayableLetterData = LetterData & CoordinateData; + +export enum TileDispatchActionType { + MOVE, + RETRIEVE, + SET_BLANK, + RETURN, +} +export type TileDispatchAction = {action: TileDispatchActionType, start?: CoordinateData, end?: CoordinateData, newBlankValue?: string, blankIndex?: number}; +export type TileDispatch = React.Dispatch; + +export function matchCoordinate(playerLetters: PlayableLetterData[], coords: CoordinateData): number { + + for (let i=0; i(array: T[], toAdd: T, times: number) { for (let i=0; i