WordGrid/ui/src/Game.tsx

578 lines
22 KiB
TypeScript

import * as React from "react";
import {useEffect, useReducer, useRef, useState} from "react";
import {
Direction,
GRID_LENGTH,
GridArrowData,
GridArrowDispatchAction,
GridArrowDispatchActionType,
HighlightableLetterData,
LocationType,
matchCoordinate,
mergeTrays,
PlayableLetterData,
Settings,
TileDispatchAction,
TileDispatchActionType
} from "./utils";
import {TileExchangeModal} from "./TileExchange";
import {Grid, Scores, TileTray} from "./UI";
import {API, APIState, GameState, TurnAction, Tray, PlayedTile, Letter} from "./api";
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: {
api: API,
settings: Settings,
end_game_fn: () => void,
}) {
const [api_state, setAPIState] = useState<APIState>(undefined);
const [confirmedScorePoints, setConfirmedScorePoints] = useState<number>(-1);
const currentTurnNumber = useRef<number>(-1);
const historyProcessedNumber = useRef<number>(0);
let isGameOver = false;
if (api_state != null) {
isGameOver = api_state.public_information.game_state.type === "Ended";
}
function waitForUpdate() {
const result = props.api.load(true);
result.then(
(state) => {
setAPIState(state);
}
)
.catch((error) => {
console.log("waitForUpdate() failed")
console.log(error);
});
}
const [boardLetters, setBoardLetters] = useState<HighlightableLetterData[]>(() => {
const newLetterData = [] as HighlightableLetterData[];
for (let i = 0; i < GRID_LENGTH * GRID_LENGTH; i++) {
newLetterData.push(null);
}
return newLetterData;
});
function adjustGridArrow(existing: GridArrowData, update: GridArrowDispatchAction): GridArrowData {
if (update.action == GridArrowDispatchActionType.CLEAR) {
return null;
} else if (update.action == GridArrowDispatchActionType.CYCLE) {
// if there's nothing where the user clicked, we create a right arrow.
if (existing == null || existing.position != update.position) {
return {
direction: Direction.RIGHT, position: update.position
}
// if there's a right arrow, we shift to downwards
} else if (existing.direction == Direction.RIGHT) {
return {
direction: Direction.DOWN, position: existing.position
}
// if there's a down arrow, we clear it
} else if (existing.direction == Direction.DOWN) {
return null;
}
} else if (update.action == GridArrowDispatchActionType.SHIFT) {
if (existing == null) {
// no arrow to shift
return null;
} else {
let current_x = existing.position % GRID_LENGTH;
let current_y = Math.floor(existing.position / GRID_LENGTH);
// we loop because we want to skip over letters that are already set
while (current_x < GRID_LENGTH && current_y < GRID_LENGTH) {
if (existing.direction == Direction.RIGHT) {
current_x += 1;
} else {
current_y += 1;
}
const new_position = current_x + current_y * GRID_LENGTH;
let tray_letter_at_position = false; // need to also check if the player put a letter in the spot
for (const letter of update.playerLetters) {
if (letter != null && letter.location == LocationType.GRID && letter.index == new_position) {
tray_letter_at_position = true;
break
}
}
if (current_x < GRID_LENGTH && current_y < GRID_LENGTH && boardLetters[new_position] == null && !tray_letter_at_position) {
return {
direction: existing.direction,
position: new_position,
}
}
}
// if we reached this point without returning then we went off the board, remove arrow
return null;
}
}
}
function exchangeFunction(selectedArray: Array<boolean>) {
const result = props.api.exchange(selectedArray);
result
.then(
(api_state) => {
setAPIState(api_state);
})
.catch((error) => {
console.error({error});
logDispatch(<div>{error}</div>);
});
}
const [gridArrow, gridArrowDispatch] = useReducer(adjustGridArrow, null);
const [logInfo, logDispatch] = useReducer(addLogInfo, []);
useEffect(() => {
props.api.load(false)
.then((api_state) => {
setAPIState(api_state);
});
}, []);
function movePlayableLetters(playerLetters: PlayableLetterData[], update: TileDispatchAction) {
if (update.action === TileDispatchActionType.RETRIEVE) {
let tray: Tray = api_state.tray;
if (update.override) {
playerLetters = [];
}
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();
} else 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) {
setConfirmedScorePoints(-1);
gridArrowDispatch({action: GridArrowDispatchActionType.CLEAR});
return mergeTrays(playerLetters, playerLetters);
} else if (update.action === TileDispatchActionType.MOVE_TO_ARROW) {
// let's verify that the arrow is defined, otherwise do nothing
if (gridArrow != null) {
const end_position = {
location: LocationType.GRID,
index: gridArrow.position,
};
gridArrowDispatch({
action: GridArrowDispatchActionType.SHIFT,
playerLetters: playerLetters
});
return movePlayableLetters(playerLetters, {
action: TileDispatchActionType.MOVE,
start: update.start,
end: end_position,
});
} else {
return playerLetters;
}
} else {
console.error("Unknown tray update");
console.error({update});
}
}
const [playerLetters, trayDispatch] = useReducer(movePlayableLetters, []);
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 handlePlayerAction(action: TurnAction, playerName: string) {
if (action.type == "PlayTiles") {
const result = action.result;
result.words.sort((a, b) => b.score - a.score);
for (let word of result.words) {
logDispatch(<div>{playerName} received {word.score} points for playing '{word.word}.'</div>);
}
logDispatch(<div>{playerName} received a total of <strong>{result.total} points</strong> for their turn.
</div>);
} else if (action.type == "ExchangeTiles") {
logDispatch(
<div>{playerName} exchanged {action.tiles_exchanged} tile{action.tiles_exchanged > 1 ? 's' : ''} for
their turn.</div>);
} else if (action.type == "Pass") {
logDispatch(<div>{playerName} passed.</div>);
} else if (action.type == "AddToDictionary") {
logDispatch(<div>{playerName} added {action.word} to the dictionary.</div>)
} else {
console.error("Received unknown turn action: ", action);
}
}
function endGame(state: GameState) {
if (state.type != "InProgress") {
logDispatch(<h4>Scoring</h4>);
const scores = api_state.public_information.players;
let pointsBonus = 0;
for (const playerAndScore of scores) {
const name = playerAndScore.name;
if (name == state.finisher) {
// we'll do the finisher last
continue
}
const letters = state.remaining_tiles[name];
if (letters.length == 0) {
logDispatch(<div>{name} has no remaining tiles.</div>);
} else {
let pointsLost = 0;
let letterListStr = '';
for (let i = 0; i < letters.length; i++) {
const letter = letters[i];
const letterText = letter.is_blank ? 'a blank' : letter.text;
pointsLost += letter.points;
letterListStr += letterText;
// we're doing a list of 3 or more so add commas
if (letters.length > 2) {
if (i == letters.length - 2) {
letterListStr += ', and ';
} else if (i < letters.length - 2) {
letterListStr += ', ';
}
} else if (i == 0 && letters.length == 2) {
// list of 2
letterListStr += ' and ';
}
}
logDispatch(<div>{name} penalized {pointsLost} points for not using {letterListStr}.</div>);
pointsBonus += pointsLost;
}
}
if (state.finisher != null) {
logDispatch(<div>{state.finisher} receives {pointsBonus} bonus for completing first.</div>);
}
const highestScore = scores
.map((score) => score.score)
.sort((a, b) => b - a)
.at(0);
const playersAtHighest = scores.filter((score) => score.score == highestScore);
let endGameMsg: string;
if (playersAtHighest.length > 1 && state.finisher == null) {
endGameMsg = "Tie game!";
} else if (playersAtHighest.length > 1 && state.finisher != null) {
// if there's a tie then the finisher gets the win
endGameMsg = `${playersAtHighest[0].name} won by finishing first!`;
} else {
endGameMsg = `${playersAtHighest[0].name} won!`;
}
logDispatch(<h4>Game over - {endGameMsg}</h4>);
} else {
// what are we doing in this function?!
console.error("endGame was called despite the state being InProgress!");
}
}
function updateBoardLetters(newLetters: Array<Letter>) {
const newLetterData = newLetters as HighlightableLetterData[];
for (let i = 0; i < newLetterData.length; i++) {
const newLetter = newLetterData[i];
if (newLetter != null) {
newLetter.highlight = false;
}
}
// loop through the histories backwards until we reach our player
for (let j = api_state.public_information.history.length - 1; j >= 0; j--) {
const update = api_state.public_information.history[j];
if (update.player == props.settings.playerName) {
break
}
if (update.type.type === "PlayTiles") {
for (let i of update.type.locations) {
newLetterData[i].highlight = true;
}
}
}
setBoardLetters(newLetterData);
}
useEffect(() => {
if (api_state) {
console.log("In state: ", api_state.public_information.current_turn_number);
console.log("In ref: ", currentTurnNumber.current);
console.debug(api_state);
if(currentTurnNumber.current < api_state.public_information.current_turn_number){
// We only clear everything if there's a chance the board changed
// We may have gotten a dictionary update event which doesn't count
gridArrowDispatch({action: GridArrowDispatchActionType.CLEAR});
trayDispatch({action: TileDispatchActionType.RETRIEVE});
setConfirmedScorePoints(-1);
updateBoardLetters(api_state.public_information.board);
}
for (let i = historyProcessedNumber.current; i < api_state.public_information.history.length; i++) {
const update = api_state.public_information.history[i];
if (update.turn_number > currentTurnNumber.current) {
currentTurnNumber.current = update.turn_number;
logDispatch(<h4>Turn {update.turn_number + 1}</h4>);
const playerAtTurn = api_state.public_information.players[(update.turn_number) % api_state.public_information.players.length].name;
logDispatch(<div>{playerAtTurn}'s turn</div>);
}
handlePlayerAction(update.type, update.player);
}
historyProcessedNumber.current = api_state.public_information.history.length;
if (!isGameOver) {
if(api_state.public_information.current_turn_number > currentTurnNumber.current){
logDispatch(<h4>Turn {api_state.public_information.current_turn_number + 1}</h4>);
logDispatch(<div>{api_state.public_information.current_player}'s turn</div>);
currentTurnNumber.current = api_state.public_information.current_turn_number;
}
} else {
endGame(api_state.public_information.game_state);
}
if(api_state.public_information.current_player != props.settings.playerName) {
waitForUpdate();
}
}
}, [api_state]);
if(api_state == null){
return <div>Still loading</div>;
}
const playerAndScores = api_state.public_information.players;
const remainingTiles = api_state.public_information.remaining_tiles;
const isPlayersTurn = api_state.public_information.current_player.toLowerCase() == props.settings.playerName.toLowerCase();
return <>
<TileExchangeModal
playerLetters={playerLetters}
isOpen={isTileExchangeOpen}
setOpen={setIsTileExchangeOpen}
exchangeFunction={exchangeFunction}
/>
<div className="board-log">
<Grid
cellTypes={api_state.public_information.cell_types}
playerLetters={playerLetters}
boardLetters={boardLetters}
tileDispatch={trayDispatch}
arrow={gridArrow}
arrowDispatch={gridArrowDispatch}
/>
<div className="message-log">
<button className="end-game"
onClick={() => {
props.end_game_fn();
}}
>
End Game
</button>
<Scores playerScores={playerAndScores}/>
<div className="log" ref={logDivRef}>
{logInfo}
</div>
</div>
</div>
<div className="tray-row">
<div>
<div>
{remainingTiles} letters remaining
</div>
<button
disabled={remainingTiles == 0 || isGameOver || !isPlayersTurn}
onClick={() => {
trayDispatch({action: TileDispatchActionType.RETURN}); // want all tiles back on tray for tile exchange
setIsTileExchangeOpen(true);
}}>Open Tile Exchange
</button>
</div>
<TileTray letters={playerLetters} trayLength={props.settings.trayLength} trayDispatch={trayDispatch}/>
<div className="player-controls">
<button
className="check"
disabled={isGameOver || !isPlayersTurn}
onClick={async () => {
const playedTiles = playerLetters.map((i) => {
if (i == null) {
return null;
}
if (i.location === LocationType.GRID) {
let result: PlayedTile = {
index: i.index,
character: null
};
if (i.is_blank) {
result.character = i.text;
}
return result;
}
return null;
});
const committing = confirmedScorePoints > -1;
const result = props.api.play(playedTiles, committing);
result
.then(
(api_state) => {
console.log("Testing45")
console.log({api_state});
const play_tiles: TurnAction = api_state.update.type;
if (play_tiles.type == "PlayTiles") {
setConfirmedScorePoints(play_tiles.result.total);
if (committing) {
setAPIState(api_state);
}
} else {
console.error("Inaccessible branch!!!")
}
}
)
.catch((error) => {
console.error({error});
if (error.endsWith(" is not a valid word")) {
const word = error.split(' ')[0];
// For whatever reason I can't pass props.api.add_to_dictionary directly
logDispatch(<AddWordButton word={word}
addWordFn={(word) => {
props.api.add_to_dictionary(word)
.then((api_state) => {
setAPIState(api_state);
})
}}/>);
} else {
logDispatch(<div>{error}</div>);
}
});
}}>{confirmedScorePoints > -1 ? `Score ${confirmedScorePoints} points` : "Check"}</button>
<button
className="return"
disabled={isGameOver}
onClick={() => {
trayDispatch({action: TileDispatchActionType.RETURN});
}}>Return Tiles
</button>
<button
className="pass"
disabled={isGameOver || !isPlayersTurn}
onClick={() => {
if (window.confirm("Are you sure you want to pass?")) {
const result = props.api.pass();
result
.then(
(api_state) => {
setAPIState(api_state);
})
.catch((error) => {
console.error({error});
logDispatch(<div>{error}</div>);
});
}
}}>Pass
</button>
</div>
</div>
</>;
}
function AddWordButton(props: { word: string, addWordFn: (x: string) => void }) {
const [isClicked, setIsClicked] = useState<boolean>(false);
if (!isClicked) {
return <div>
<em>{props.word} is not a valid word.</em>
<button
className="add-to-dictionary"
disabled={isClicked}
onClick={() => {
setIsClicked(true);
props.addWordFn(props.word);
}}>
Add to dictionary
</button>
</div>;
} else {
return <div>
<em>Adding {props.word} to dictionary.</em>
</div>;
}
}