Split UI code into separate files
This commit is contained in:
parent
de2605af67
commit
974751bda0
6 changed files with 616 additions and 594 deletions
206
ui/src/Game.tsx
Normal file
206
ui/src/Game.tsx
Normal file
|
@ -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<number>(-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<number>(1);
|
||||
const playerAndScores: PlayerAndScore[] = useMemo(() => {
|
||||
return props.wasm.get_scores();
|
||||
}, [turnCount]);
|
||||
const boardLetters: LetterData[] = useMemo(() => {
|
||||
return props.wasm.get_board_letters();
|
||||
}, [turnCount]);
|
||||
|
||||
useEffect(() => {
|
||||
logDispatch(<h4>Turn {turnCount}</h4>);
|
||||
setConfirmedScorePoints(-1);
|
||||
trayDispatch({action: TileDispatchActionType.RETRIEVE});
|
||||
|
||||
}, [turnCount]);
|
||||
|
||||
const logDivRef = useRef(null);
|
||||
|
||||
const [isTileExchangeOpen, setIsTileExchangeOpen] = useState<boolean>(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<boolean>) {
|
||||
|
||||
let numSelected = 0;
|
||||
selectedArray.forEach((x) => {
|
||||
if (x){
|
||||
numSelected++;
|
||||
}
|
||||
})
|
||||
|
||||
const result: MyResult<Tray | string> = props.wasm.exchange_tiles("Player", selectedArray);
|
||||
console.log({result});
|
||||
|
||||
if(result.response_type === "ERR") {
|
||||
logDispatch(<div><em>{(result.value as string)}</em></div>);
|
||||
} else {
|
||||
logDispatch(<div><em>You exchanged {numSelected} tiles.</em></div>);
|
||||
setTurnCount(turnCount + 1);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
return <>
|
||||
<TileExchangeModal
|
||||
playerLetters={playerLetters}
|
||||
isOpen={isTileExchangeOpen}
|
||||
setOpen={setIsTileExchangeOpen}
|
||||
exchangeFunction={exchangeFunction}
|
||||
/>
|
||||
<div className="board-log">
|
||||
<Grid cellTypes={cellTypes} playerLetters={playerLetters} boardLetters={boardLetters} dispatch={trayDispatch}/>
|
||||
<div className="message-log">
|
||||
<Scores playerScores={playerAndScores}/>
|
||||
<div className="log" ref={logDivRef}>
|
||||
{logInfo}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TileTray letters={playerLetters} trayLength={props.settings.trayLength} dispatch={trayDispatch}/>
|
||||
<button onClick={(e) => {
|
||||
const playedTiles = playerLetters.map((i) => {
|
||||
if (i === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (i.location === LocationType.GRID) {
|
||||
let result: PlayedTile = {
|
||||
index: i.index,
|
||||
character: undefined
|
||||
};
|
||||
if (i.is_blank) {
|
||||
result.character = i.text;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
const result: MyResult<Array<WordResult> | string> = props.wasm.receive_play("Player", playedTiles, confirmedScorePoints > -1);
|
||||
console.log({result});
|
||||
|
||||
if(result.response_type === "ERR") {
|
||||
logDispatch(<div><em>{(result.value as string)}</em></div>);
|
||||
} else {
|
||||
|
||||
let total_points = 0;
|
||||
for (let word_result of (result.value as Array<WordResult>)) {
|
||||
total_points += word_result.score;
|
||||
}
|
||||
|
||||
let msg: string;
|
||||
if (confirmedScorePoints > -1) {
|
||||
msg = `You scored ${total_points} points.`;
|
||||
setTurnCount(turnCount + 1);
|
||||
}
|
||||
|
||||
|
||||
logDispatch(<div><em>{msg}</em></div>);
|
||||
|
||||
setConfirmedScorePoints(total_points);
|
||||
}
|
||||
|
||||
|
||||
|
||||
}}>{confirmedScorePoints > -1 ? `Score ${confirmedScorePoints} points ✅` : "Check"}</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
trayDispatch({action: TileDispatchActionType.RETURN}); // want all tiles back on tray for tile exchange
|
||||
setIsTileExchangeOpen(true);
|
||||
}}
|
||||
>Open Tile Exchange</button>
|
||||
<button onClick={(e) => {
|
||||
trayDispatch({action: TileDispatchActionType.RETURN});
|
||||
}}>Return Tiles</button>
|
||||
</>;
|
||||
|
||||
|
||||
}
|
126
ui/src/TileExchange.tsx
Normal file
126
ui/src/TileExchange.tsx
Normal file
|
@ -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<boolean>) => void
|
||||
}) {
|
||||
|
||||
function clearExchangeTiles() {
|
||||
const array: boolean[] = [];
|
||||
addNTimes(array, false, props.playerLetters.length);
|
||||
return array;
|
||||
}
|
||||
|
||||
const [tilesToExchange, setTilesToExchange] = useState<boolean[]>(clearExchangeTiles);
|
||||
useEffect(() => {
|
||||
setTilesToExchange(clearExchangeTiles());
|
||||
}, [props.playerLetters])
|
||||
|
||||
let tilesExchangedSelected = 0;
|
||||
for (let i of tilesToExchange) {
|
||||
if(i) {
|
||||
tilesExchangedSelected++;
|
||||
}
|
||||
}
|
||||
|
||||
return <Modal isOpen={props.isOpen} setOpen={(isOpen) => {
|
||||
setTilesToExchange(clearExchangeTiles());
|
||||
props.setOpen(isOpen);
|
||||
}}>
|
||||
<div className="tile-exchange-dialog">
|
||||
<div className="instructions">
|
||||
Click on each tile you'd like to exchange. You currently have {tilesExchangedSelected} tiles selected.
|
||||
</div>
|
||||
<div className="selection-buttons">
|
||||
<button onClick={(e) => {
|
||||
const array: boolean[] = [];
|
||||
addNTimes(array, true, props.playerLetters.length);
|
||||
setTilesToExchange(array);
|
||||
}}>Select All</button>
|
||||
<button onClick={(e) => {
|
||||
setTilesToExchange(clearExchangeTiles());
|
||||
}}>Select None</button>
|
||||
</div>
|
||||
|
||||
<TilesExchangedTray
|
||||
tray={props.playerLetters}
|
||||
selectedArray={tilesToExchange}
|
||||
setSelectedArray={setTilesToExchange}
|
||||
trayLength={props.playerLetters.length}
|
||||
/>
|
||||
<div className="finish-buttons">
|
||||
<button onClick={() => {
|
||||
setTilesToExchange(clearExchangeTiles());
|
||||
props.setOpen(false);
|
||||
}}>Cancel</button>
|
||||
<button disabled = {tilesExchangedSelected == 0} onClick={(e) => {
|
||||
props.exchangeFunction(tilesToExchange);
|
||||
props.setOpen(false);
|
||||
}}>Exchange</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>;
|
||||
|
||||
}
|
||||
|
||||
function TilesExchangedTray(props: {
|
||||
tray: Array<PlayableLetterData>,
|
||||
trayLength: number,
|
||||
selectedArray: Array<boolean>,
|
||||
setSelectedArray: (x: Array<boolean>) => void,
|
||||
}){
|
||||
|
||||
const divContent = [];
|
||||
for(let i=0; i<props.trayLength; i++) {
|
||||
divContent.push(<span key={i} />); // empty tile elements
|
||||
}
|
||||
|
||||
for(let i = 0; i<props.trayLength; i++){
|
||||
const tileData = props.tray[i];
|
||||
|
||||
const toggleFunction = () => {
|
||||
props.selectedArray[i] = !props.selectedArray[i];
|
||||
props.setSelectedArray(props.selectedArray.slice());
|
||||
}
|
||||
|
||||
if(tileData != null){
|
||||
divContent[tileData.index] =
|
||||
<DummyExchangeTile key={tileData.index} letter={tileData} isSelected={props.selectedArray[i]} onClick={toggleFunction}/>;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
return <div className="tray">
|
||||
{divContent}
|
||||
</div>;
|
||||
|
||||
}
|
||||
|
||||
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 <div className={letterClassName} onClick={props.onClick}>
|
||||
<div className={textClassName}>{props.letter.text}</div>
|
||||
<div className="letter-points">{props.letter.points}</div>
|
||||
</div>;
|
||||
}
|
196
ui/src/UI.tsx
Normal file
196
ui/src/UI.tsx
Normal file
|
@ -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<HTMLDivElement>) {
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.setData("wordGrid/coords", JSON.stringify(props.location));
|
||||
}
|
||||
|
||||
function onDrop(e: React.DragEvent<HTMLDivElement>) {
|
||||
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 <div className={className}
|
||||
draggable={isDraggable}
|
||||
onDragStart={onDragStart}
|
||||
onDrop={onDrop}
|
||||
onDragOver={(e) => {e.preventDefault()}}
|
||||
>
|
||||
{props.tile}
|
||||
</div>
|
||||
|
||||
}
|
||||
|
||||
export function Letter(props: { data: LetterData, letterUpdater?: (value: string) => void }): React.JSX.Element {
|
||||
|
||||
function modifyThisLetter(value:string){
|
||||
props.letterUpdater(value);
|
||||
}
|
||||
|
||||
|
||||
function onBlankInput(e: ChangeEvent<HTMLInputElement>){
|
||||
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 <div className="letter">
|
||||
<input className="blank-input" type="text" onChange={onBlankInput} value={props.data.text} />
|
||||
<div className="letter-points">{props.data.points}</div>
|
||||
</div>
|
||||
} else {
|
||||
let className = "text";
|
||||
if (props.data.is_blank) { // not ephemeral
|
||||
className += " prev-blank";
|
||||
}
|
||||
return <div className="letter">
|
||||
<div className={className}>{props.data.text}</div>
|
||||
<div className="letter-points">{props.data.points}</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
export function TileTray(props: { letters: Array<PlayableLetterData>, trayLength: number, dispatch: TileDispatch }): React.JSX.Element {
|
||||
|
||||
let elements: JSX.Element[] = [];
|
||||
for (let i=0; i<props.trayLength; i++) {
|
||||
elements.push(
|
||||
<TileSlot
|
||||
key={"empty" + i}
|
||||
location={{location: LocationType.TRAY, index: i}}
|
||||
dispatch={props.dispatch} />
|
||||
);
|
||||
}
|
||||
|
||||
props.letters
|
||||
.filter((x) => {return x !== undefined;})
|
||||
.forEach((letter, i) => {
|
||||
if (letter.location === LocationType.TRAY) {
|
||||
elements[letter.index] =
|
||||
<TileSlot
|
||||
tile={<Letter
|
||||
data={letter}
|
||||
letterUpdater={(value) => {
|
||||
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 (
|
||||
<div className="tray">
|
||||
{elements}
|
||||
</div>
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
export function Grid(props: {
|
||||
cellTypes: CellType[],
|
||||
playerLetters: Array<PlayableLetterData>,
|
||||
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 = <Letter data={props.boardLetters[i]} />;
|
||||
} else {
|
||||
tileElement = <>
|
||||
<span>{text}</span>
|
||||
<TileSlot
|
||||
location={{location: LocationType.GRID, index: i}}
|
||||
dispatch={props.dispatch} /></>;
|
||||
}
|
||||
|
||||
return <div key={i} className={"grid-spot " + className}>
|
||||
|
||||
{tileElement}
|
||||
</div>;
|
||||
});
|
||||
|
||||
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] =
|
||||
<div key={"letter" + letter.index} className={"grid-spot " + className}>
|
||||
<TileSlot
|
||||
tile={<Letter
|
||||
data={letter}
|
||||
letterUpdater={(value) => {
|
||||
props.dispatch({action: TileDispatchActionType.SET_BLANK, blankIndex: i, newBlankValue: value});
|
||||
}}
|
||||
/>}
|
||||
location={{location: LocationType.GRID, index: letter.index}}
|
||||
dispatch={props.dispatch} />
|
||||
</div>;
|
||||
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
return <div className="board-grid">
|
||||
{elements}
|
||||
</div>
|
||||
}
|
||||
|
||||
export function Scores(props: {playerScores: Array<PlayerAndScore>}){
|
||||
let elements = props.playerScores.map((ps, i) => {
|
||||
return <div key={ps.name}>
|
||||
<h3>{ps.name}</h3>
|
||||
<span>{ps.score}</span>
|
||||
</div>;
|
||||
});
|
||||
|
||||
return <div className="scoring">
|
||||
{elements}
|
||||
</div>
|
||||
}
|
|
@ -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<playerLetters.length; i++){
|
||||
let letter = playerLetters[i];
|
||||
|
||||
if (letter !== undefined && letter.location === coords.location && letter.index === coords.index) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return null; // no match
|
||||
|
||||
}
|
||||
|
||||
enum TileDispatchActionType {
|
||||
MOVE,
|
||||
RETRIEVE,
|
||||
SET_BLANK,
|
||||
RETURN,
|
||||
}
|
||||
type TileDispatchAction = {action: TileDispatchActionType, start?: CoordinateData, end?: CoordinateData, newBlankValue?: string, blankIndex?: number};
|
||||
type TileDispatch = React.Dispatch<TileDispatchAction>;
|
||||
|
||||
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<number>(-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<number>(1);
|
||||
const playerAndScores: PlayerAndScore[] = useMemo(() => {
|
||||
return props.wasm.get_scores();
|
||||
}, [turnCount]);
|
||||
const boardLetters: LetterData[] = useMemo(() => {
|
||||
return props.wasm.get_board_letters();
|
||||
}, [turnCount]);
|
||||
|
||||
useEffect(() => {
|
||||
logDispatch(<h4>Turn {turnCount}</h4>);
|
||||
setConfirmedScorePoints(-1);
|
||||
trayDispatch({action: TileDispatchActionType.RETRIEVE});
|
||||
|
||||
}, [turnCount]);
|
||||
|
||||
const logDivRef = useRef(null);
|
||||
|
||||
const [isTileExchangeOpen, setIsTileExchangeOpen] = useState<boolean>(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<boolean>) {
|
||||
|
||||
let numSelected = 0;
|
||||
selectedArray.forEach((x) => {
|
||||
if (x){
|
||||
numSelected++;
|
||||
}
|
||||
})
|
||||
|
||||
const result: MyResult<Tray | string> = props.wasm.exchange_tiles("Player", selectedArray);
|
||||
console.log({result});
|
||||
|
||||
if(result.response_type === "ERR") {
|
||||
logDispatch(<div><em>{(result.value as string)}</em></div>);
|
||||
} else {
|
||||
logDispatch(<div><em>You exchanged {numSelected} tiles.</em></div>);
|
||||
setTurnCount(turnCount + 1);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
return <>
|
||||
<TileExchangeModal
|
||||
playerLetters={playerLetters}
|
||||
isOpen={isTileExchangeOpen}
|
||||
setOpen={setIsTileExchangeOpen}
|
||||
exchangeFunction={exchangeFunction}
|
||||
/>
|
||||
<div className="board-log">
|
||||
<Grid cellTypes={cellTypes} playerLetters={playerLetters} boardLetters={boardLetters} dispatch={trayDispatch}/>
|
||||
<div className="message-log">
|
||||
<Scores playerScores={playerAndScores}/>
|
||||
<div className="log" ref={logDivRef}>
|
||||
{logInfo}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TileTray letters={playerLetters} trayLength={props.settings.trayLength} dispatch={trayDispatch}/>
|
||||
<button onClick={(e) => {
|
||||
const playedTiles = playerLetters.map((i) => {
|
||||
if (i === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (i.location === LocationType.GRID) {
|
||||
let result: PlayedTile = {
|
||||
index: i.index,
|
||||
character: undefined
|
||||
};
|
||||
if (i.is_blank) {
|
||||
result.character = i.text;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
const result: MyResult<Array<WordResult> | string> = props.wasm.receive_play("Player", playedTiles, confirmedScorePoints > -1);
|
||||
console.log({result});
|
||||
|
||||
if(result.response_type === "ERR") {
|
||||
logDispatch(<div><em>{(result.value as string)}</em></div>);
|
||||
} else {
|
||||
|
||||
let total_points = 0;
|
||||
for (let word_result of (result.value as Array<WordResult>)) {
|
||||
total_points += word_result.score;
|
||||
}
|
||||
|
||||
let msg: string;
|
||||
if (confirmedScorePoints > -1) {
|
||||
msg = `You scored ${total_points} points.`;
|
||||
setTurnCount(turnCount + 1);
|
||||
}
|
||||
|
||||
|
||||
logDispatch(<div><em>{msg}</em></div>);
|
||||
|
||||
setConfirmedScorePoints(total_points);
|
||||
}
|
||||
|
||||
|
||||
|
||||
}}>{confirmedScorePoints > -1 ? `Score ${confirmedScorePoints} points ✅` : "Check"}</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
trayDispatch({action: TileDispatchActionType.RETURN}); // want all tiles back on tray for tile exchange
|
||||
setIsTileExchangeOpen(true);
|
||||
}}
|
||||
>Open Tile Exchange</button>
|
||||
<button onClick={(e) => {
|
||||
trayDispatch({action: TileDispatchActionType.RETURN});
|
||||
}}>Return Tiles</button>
|
||||
</>;
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
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<HTMLDivElement>) {
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.setData("wordGrid/coords", JSON.stringify(props.location));
|
||||
}
|
||||
|
||||
function onDrop(e: React.DragEvent<HTMLDivElement>) {
|
||||
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 <div className={className}
|
||||
draggable={isDraggable}
|
||||
onDragStart={onDragStart}
|
||||
onDrop={onDrop}
|
||||
onDragOver={(e) => {e.preventDefault()}}
|
||||
>
|
||||
{props.tile}
|
||||
</div>
|
||||
|
||||
}
|
||||
|
||||
export function Letter(props: { data: LetterData, letterUpdater?: (value: string) => void }): React.JSX.Element {
|
||||
|
||||
function modifyThisLetter(value:string){
|
||||
props.letterUpdater(value);
|
||||
}
|
||||
|
||||
|
||||
function onBlankInput(e: ChangeEvent<HTMLInputElement>){
|
||||
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 <div className="letter">
|
||||
<input className="blank-input" type="text" onChange={onBlankInput} value={props.data.text} />
|
||||
<div className="letter-points">{props.data.points}</div>
|
||||
</div>
|
||||
} else {
|
||||
let className = "text";
|
||||
if (props.data.is_blank) { // not ephemeral
|
||||
className += " prev-blank";
|
||||
}
|
||||
return <div className="letter">
|
||||
<div className={className}>{props.data.text}</div>
|
||||
<div className="letter-points">{props.data.points}</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
export function TileTray(props: { letters: Array<PlayableLetterData>, trayLength: number, dispatch: TileDispatch }): React.JSX.Element {
|
||||
|
||||
let elements: JSX.Element[] = [];
|
||||
for (let i=0; i<props.trayLength; i++) {
|
||||
elements.push(
|
||||
<TileSlot
|
||||
key={"empty" + i}
|
||||
location={{location: LocationType.TRAY, index: i}}
|
||||
dispatch={props.dispatch} />
|
||||
);
|
||||
}
|
||||
|
||||
props.letters
|
||||
.filter((x) => {return x !== undefined;})
|
||||
.forEach((letter, i) => {
|
||||
if (letter.location === LocationType.TRAY) {
|
||||
elements[letter.index] =
|
||||
<TileSlot
|
||||
tile={<Letter
|
||||
data={letter}
|
||||
letterUpdater={(value) => {
|
||||
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 (
|
||||
<div className="tray">
|
||||
{elements}
|
||||
</div>
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
function Grid(props: {
|
||||
cellTypes: CellType[],
|
||||
playerLetters: Array<PlayableLetterData>,
|
||||
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 = <Letter data={props.boardLetters[i]} />;
|
||||
} else {
|
||||
tileElement = <>
|
||||
<span>{text}</span>
|
||||
<TileSlot
|
||||
location={{location: LocationType.GRID, index: i}}
|
||||
dispatch={props.dispatch} /></>;
|
||||
}
|
||||
|
||||
return <div key={i} className={"grid-spot " + className}>
|
||||
|
||||
{tileElement}
|
||||
</div>;
|
||||
});
|
||||
|
||||
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] =
|
||||
<div key={"letter" + letter.index} className={"grid-spot " + className}>
|
||||
<TileSlot
|
||||
tile={<Letter
|
||||
data={letter}
|
||||
letterUpdater={(value) => {
|
||||
props.dispatch({action: TileDispatchActionType.SET_BLANK, blankIndex: i, newBlankValue: value});
|
||||
}}
|
||||
/>}
|
||||
location={{location: LocationType.GRID, index: letter.index}}
|
||||
dispatch={props.dispatch} />
|
||||
</div>;
|
||||
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
return <div className="board-grid">
|
||||
{elements}
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
function Scores(props: {playerScores: Array<PlayerAndScore>}){
|
||||
let elements = props.playerScores.map((ps, i) => {
|
||||
return <div key={ps.name}>
|
||||
<h3>{ps.name}</h3>
|
||||
<span>{ps.score}</span>
|
||||
</div>;
|
||||
});
|
||||
|
||||
return <div className="scoring">
|
||||
{elements}
|
||||
</div>
|
||||
}
|
||||
|
||||
function TileExchangeModal(props: {
|
||||
playerLetters: PlayableLetterData[],
|
||||
isOpen: boolean,
|
||||
setOpen: (isOpen: boolean) => void,
|
||||
exchangeFunction: (selectedArray: Array<boolean>) => void
|
||||
}) {
|
||||
|
||||
function clearExchangeTiles() {
|
||||
const array: boolean[] = [];
|
||||
addNTimes(array, false, props.playerLetters.length);
|
||||
return array;
|
||||
}
|
||||
|
||||
const [tilesToExchange, setTilesToExchange] = useState<boolean[]>(clearExchangeTiles);
|
||||
useEffect(() => {
|
||||
setTilesToExchange(clearExchangeTiles());
|
||||
}, [props.playerLetters])
|
||||
|
||||
let tilesExchangedSelected = 0;
|
||||
for (let i of tilesToExchange) {
|
||||
if(i) {
|
||||
tilesExchangedSelected++;
|
||||
}
|
||||
}
|
||||
|
||||
return <Modal isOpen={props.isOpen} setOpen={(isOpen) => {
|
||||
setTilesToExchange(clearExchangeTiles());
|
||||
props.setOpen(isOpen);
|
||||
}}>
|
||||
<div className="tile-exchange-dialog">
|
||||
<div className="instructions">
|
||||
Click on each tile you'd like to exchange. You currently have {tilesExchangedSelected} tiles selected.
|
||||
</div>
|
||||
<div className="selection-buttons">
|
||||
<button onClick={(e) => {
|
||||
const array: boolean[] = [];
|
||||
addNTimes(array, true, props.playerLetters.length);
|
||||
setTilesToExchange(array);
|
||||
}}>Select All</button>
|
||||
<button onClick={(e) => {
|
||||
setTilesToExchange(clearExchangeTiles());
|
||||
}}>Select None</button>
|
||||
</div>
|
||||
|
||||
<TilesExchangedTray
|
||||
tray={props.playerLetters}
|
||||
selectedArray={tilesToExchange}
|
||||
setSelectedArray={setTilesToExchange}
|
||||
trayLength={props.playerLetters.length}
|
||||
/>
|
||||
<div className="finish-buttons">
|
||||
<button onClick={() => {
|
||||
setTilesToExchange(clearExchangeTiles());
|
||||
props.setOpen(false);
|
||||
}}>Cancel</button>
|
||||
<button disabled = {tilesExchangedSelected == 0} onClick={(e) => {
|
||||
props.exchangeFunction(tilesToExchange);
|
||||
props.setOpen(false);
|
||||
}}>Exchange</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>;
|
||||
|
||||
}
|
||||
|
||||
function TilesExchangedTray(props: {
|
||||
tray: Array<PlayableLetterData>,
|
||||
trayLength: number,
|
||||
selectedArray: Array<boolean>,
|
||||
setSelectedArray: (x: Array<boolean>) => void,
|
||||
}){
|
||||
|
||||
const divContent = [];
|
||||
for(let i=0; i<props.trayLength; i++) {
|
||||
divContent.push(<span key={i} />); // empty tile elements
|
||||
}
|
||||
|
||||
for(let i = 0; i<props.trayLength; i++){
|
||||
const tileData = props.tray[i];
|
||||
|
||||
const toggleFunction = () => {
|
||||
props.selectedArray[i] = !props.selectedArray[i];
|
||||
props.setSelectedArray(props.selectedArray.slice());
|
||||
}
|
||||
|
||||
if(tileData != null){
|
||||
divContent[tileData.index] =
|
||||
<DummyExchangeTile key={tileData.index} letter={tileData} isSelected={props.selectedArray[i]} onClick={toggleFunction}/>;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
return <div className="tray">
|
||||
{divContent}
|
||||
</div>;
|
||||
|
||||
}
|
||||
|
||||
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 <div className={letterClassName} onClick={props.onClick}>
|
||||
<div className={textClassName}>{props.letter.text}</div>
|
||||
<div className="letter-points">{props.letter.points}</div>
|
||||
</div>;
|
||||
}
|
|
@ -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";
|
||||
|
||||
|
|
|
@ -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<TileDispatchAction>;
|
||||
|
||||
export function matchCoordinate(playerLetters: PlayableLetterData[], coords: CoordinateData): number {
|
||||
|
||||
for (let i=0; i<playerLetters.length; i++){
|
||||
let letter = playerLetters[i];
|
||||
|
||||
if (letter !== undefined && letter.location === coords.location && letter.index === coords.index) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return null; // no match
|
||||
|
||||
}
|
||||
|
||||
export function cellTypeToDetails(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 function addNTimes<T>(array: T[], toAdd: T, times: number) {
|
||||
for (let i=0; i<times; i++) {
|
||||
|
|
Loading…
Reference in a new issue