578 lines
22 KiB
TypeScript
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>;
|
|
}
|
|
|
|
}
|
|
|