2023-08-24 03:46:43 +00:00
|
|
|
import * as React from "react";
|
2023-08-25 03:36:41 +00:00
|
|
|
import {
|
2023-09-21 01:59:32 +00:00
|
|
|
GameState,
|
|
|
|
GameWasm, Letter,
|
2023-08-25 03:36:41 +00:00
|
|
|
Letter as LetterData,
|
|
|
|
MyResult,
|
|
|
|
PlayedTile,
|
|
|
|
PlayerAndScore,
|
|
|
|
ScoreResult,
|
2023-09-15 01:53:47 +00:00
|
|
|
Tray, TurnAction, TurnAdvanceResult
|
2023-09-16 03:37:40 +00:00
|
|
|
} from "../../pkg/word_grid";
|
2023-08-24 03:46:43 +00:00
|
|
|
import {
|
2023-09-15 02:38:34 +00:00
|
|
|
HighlightableLetterData,
|
2023-08-24 03:46:43 +00:00
|
|
|
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();
|
|
|
|
}
|
|
|
|
|
2023-09-09 06:32:44 +00:00
|
|
|
export function Game(props: {
|
|
|
|
wasm: GameWasm,
|
|
|
|
settings: Settings,
|
|
|
|
end_game_fn: () => void,
|
|
|
|
}) {
|
2023-08-24 03:46:43 +00:00
|
|
|
|
|
|
|
const cellTypes = useMemo(() => {
|
|
|
|
return props.wasm.get_board_cell_types();
|
|
|
|
}, []);
|
|
|
|
|
2023-09-21 01:59:32 +00:00
|
|
|
const [isGameOver, setGameOver] = useState<boolean>(false);
|
2023-08-24 03:46:43 +00:00
|
|
|
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});
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
2023-09-15 02:14:19 +00:00
|
|
|
function exchangeFunction(selectedArray: Array<boolean>) {
|
|
|
|
|
|
|
|
const result: MyResult<TurnAction | string> = props.wasm.exchange_tiles(selectedArray);
|
|
|
|
|
|
|
|
if(result.response_type === "ERR") {
|
|
|
|
logDispatch(<div><em>{(result.value as string)}</em></div>);
|
|
|
|
} else {
|
2023-09-16 04:01:49 +00:00
|
|
|
handlePlayerAction(result.value as TurnAction, props.settings.playerName);
|
2023-09-15 02:14:19 +00:00
|
|
|
setTurnCount(turnCount + 1);
|
2023-09-21 01:59:32 +00:00
|
|
|
|
|
|
|
if(result.game_state.type === "Ended") {
|
|
|
|
endGame(result.game_state);
|
|
|
|
}
|
2023-09-15 02:14:19 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
function addWordFn(word: string) {
|
|
|
|
props.wasm.add_word(word);
|
|
|
|
}
|
|
|
|
|
2023-08-24 03:46:43 +00:00
|
|
|
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();
|
2023-09-21 01:59:32 +00:00
|
|
|
}, [turnCount, isGameOver]);
|
2023-09-15 02:38:34 +00:00
|
|
|
|
|
|
|
const [boardLetters, setBoardLetters] = useState<HighlightableLetterData[]>(() => {
|
|
|
|
const newLetterData = [] as HighlightableLetterData[];
|
|
|
|
for(let i=0; i<15*15; i++) {
|
|
|
|
newLetterData.push(undefined);
|
|
|
|
}
|
|
|
|
return newLetterData;
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
useEffect(() => {
|
2023-09-16 03:37:40 +00:00
|
|
|
const newLetterData = props.wasm.get_board_letters() as HighlightableLetterData[];
|
2023-09-15 02:38:34 +00:00
|
|
|
|
|
|
|
// we need to go through and set 'highlight' field to either true or false
|
|
|
|
// it will always be false if the player that just went was the AI
|
|
|
|
// TODO - build in support for multiple other players
|
|
|
|
for(let i=0; i<newLetterData.length; i++) {
|
|
|
|
const newLetter = newLetterData[i];
|
|
|
|
if(boardLetters[i] === undefined && newLetter !== undefined && playerTurnName == props.settings.playerName) {
|
|
|
|
newLetter.highlight = true;
|
|
|
|
} else if(newLetter !== undefined) {
|
|
|
|
newLetter.highlight = false;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
setBoardLetters(newLetterData);
|
2023-08-24 03:46:43 +00:00
|
|
|
}, [turnCount]);
|
2023-09-15 02:38:34 +00:00
|
|
|
|
2023-09-15 02:14:19 +00:00
|
|
|
const playerTurnName = useMemo(() => {
|
|
|
|
return props.wasm.get_current_player();
|
|
|
|
}, [turnCount]);
|
|
|
|
|
2023-08-24 03:46:43 +00:00
|
|
|
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
|
|
|
|
}
|
2023-09-16 04:01:49 +00:00
|
|
|
}, [logInfo]);
|
|
|
|
|
2023-09-17 04:08:10 +00:00
|
|
|
const remainingTiles = useMemo(() => {
|
|
|
|
return props.wasm.get_remaining_tiles();
|
2023-09-21 01:59:32 +00:00
|
|
|
}, [turnCount, isGameOver]);
|
2023-09-17 04:08:10 +00:00
|
|
|
|
|
|
|
const remainingAITiles = useMemo(() => {
|
|
|
|
let result = props.wasm.get_player_tile_count(props.settings.aiName) as MyResult<number | String>;
|
|
|
|
if(result.response_type == "OK") {
|
|
|
|
return result.value as number;
|
|
|
|
} else {
|
|
|
|
console.error(result.value);
|
|
|
|
return -1;
|
|
|
|
}
|
|
|
|
|
2023-09-21 01:59:32 +00:00
|
|
|
}, [turnCount, isGameOver]);
|
2023-09-17 04:08:10 +00:00
|
|
|
|
2023-09-16 04:01:49 +00:00
|
|
|
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>);
|
|
|
|
}
|
|
|
|
}
|
2023-08-24 03:46:43 +00:00
|
|
|
|
2023-09-21 01:59:32 +00:00
|
|
|
function endGame(state: GameState) {
|
|
|
|
|
|
|
|
if(state.type != "InProgress") {
|
|
|
|
|
|
|
|
setGameOver(true);
|
|
|
|
logDispatch(<h4>Scoring</h4>);
|
|
|
|
|
|
|
|
const scores = props.wasm.get_scores() as PlayerAndScore[];
|
|
|
|
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.get(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 runAI() {
|
|
|
|
const result: MyResult<TurnAdvanceResult> = props.wasm.advance_turn();
|
|
|
|
if(result.response_type === "OK" && result.value.type == "AIMove") {
|
|
|
|
handlePlayerAction(result.value.action, props.settings.aiName);
|
|
|
|
if(result.game_state.type === "Ended") {
|
|
|
|
endGame(result.game_state);
|
|
|
|
}
|
|
|
|
|
|
|
|
} else {
|
|
|
|
// this would be quite surprising
|
|
|
|
console.error({result});
|
|
|
|
}
|
|
|
|
|
|
|
|
setTurnCount(turnCount + 1);
|
|
|
|
}
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
logDispatch(<h4>Turn {turnCount}</h4>);
|
|
|
|
logDispatch(<div>{playerTurnName}'s turn</div>)
|
|
|
|
setConfirmedScorePoints(-1);
|
|
|
|
trayDispatch({action: TileDispatchActionType.RETRIEVE});
|
|
|
|
if(playerTurnName != props.settings.playerName && !isGameOver) {
|
|
|
|
runAI();
|
|
|
|
}
|
|
|
|
|
|
|
|
}, [turnCount]);
|
|
|
|
|
2023-08-24 03:46:43 +00:00
|
|
|
|
|
|
|
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">
|
2023-09-22 02:01:56 +00:00
|
|
|
<button className="end-game"
|
|
|
|
onClick={() => {
|
|
|
|
props.end_game_fn();
|
|
|
|
}}
|
|
|
|
>
|
|
|
|
End Game
|
|
|
|
</button>
|
2023-08-24 03:46:43 +00:00
|
|
|
<Scores playerScores={playerAndScores}/>
|
|
|
|
<div className="log" ref={logDivRef}>
|
|
|
|
{logInfo}
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
2023-09-17 04:08:10 +00:00
|
|
|
<div className="tray-row">
|
|
|
|
<div>
|
|
|
|
<div>
|
|
|
|
{remainingTiles} letters remaining
|
|
|
|
</div>
|
|
|
|
<div>
|
|
|
|
{props.settings.aiName} has {remainingAITiles} tiles
|
|
|
|
</div>
|
2023-09-21 01:59:32 +00:00
|
|
|
<button
|
|
|
|
disabled={remainingTiles == 0}
|
|
|
|
onClick={() => {
|
2023-09-17 04:08:10 +00:00
|
|
|
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} dispatch={trayDispatch}/>
|
|
|
|
<div className="player-controls">
|
|
|
|
<button className="check" onClick={() => {
|
|
|
|
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<{ type: "PlayTiles"; result: ScoreResult } | string> = props.wasm.receive_play(playedTiles, confirmedScorePoints > -1);
|
|
|
|
console.log({result});
|
|
|
|
|
|
|
|
if(result.response_type === "ERR") {
|
|
|
|
const message = result.value as string;
|
|
|
|
if (message.endsWith("is not a valid word")) {
|
|
|
|
// extract out word
|
|
|
|
const word = message.split(" ")[0];
|
2023-09-22 01:55:33 +00:00
|
|
|
logDispatch(<AddWordButton word={word} addWordFn={addWordFn} />);
|
2023-09-17 04:08:10 +00:00
|
|
|
} else {
|
|
|
|
logDispatch(<div><em>{message}</em></div>);
|
|
|
|
}
|
|
|
|
|
|
|
|
} else {
|
|
|
|
|
|
|
|
const score_result = (result.value as { type: "PlayTiles"; result: ScoreResult }).result;
|
|
|
|
const total_points = score_result.total;
|
|
|
|
|
|
|
|
|
|
|
|
if (confirmedScorePoints > -1) {
|
|
|
|
handlePlayerAction({type: "PlayTiles", result: score_result}, props.settings.playerName);
|
|
|
|
setTurnCount(turnCount + 1);
|
|
|
|
}
|
|
|
|
|
2023-09-21 01:59:32 +00:00
|
|
|
if (result.game_state.type === "Ended") {
|
|
|
|
endGame(result.game_state);
|
|
|
|
}
|
|
|
|
|
2023-09-17 04:08:10 +00:00
|
|
|
setConfirmedScorePoints(total_points);
|
2023-08-24 03:46:43 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
2023-09-16 04:01:49 +00:00
|
|
|
|
2023-09-17 04:08:10 +00:00
|
|
|
}}>{confirmedScorePoints > -1 ? `Score ${confirmedScorePoints} points` : "Check"}</button>
|
|
|
|
<button className="return" onClick={() => {
|
|
|
|
trayDispatch({action: TileDispatchActionType.RETURN});
|
|
|
|
}}>Return Tiles</button>
|
|
|
|
<button className="pass" onClick={() => {
|
2023-09-22 01:40:41 +00:00
|
|
|
if (window.confirm("Are you sure you want to pass?")) {
|
|
|
|
const result = props.wasm.skip_turn() as MyResult<string>;
|
|
|
|
handlePlayerAction({type: "Pass"}, props.settings.playerName);
|
|
|
|
setTurnCount(turnCount + 1);
|
2023-09-21 01:59:32 +00:00
|
|
|
|
2023-09-22 01:40:41 +00:00
|
|
|
if (result.game_state.type === "Ended") {
|
|
|
|
endGame(result.game_state);
|
|
|
|
}
|
2023-09-21 01:59:32 +00:00
|
|
|
}
|
2023-09-17 04:08:10 +00:00
|
|
|
}}>Pass</button>
|
|
|
|
</div>
|
|
|
|
</div>
|
2023-08-24 03:46:43 +00:00
|
|
|
|
|
|
|
|
|
|
|
</>;
|
|
|
|
|
|
|
|
}
|
2023-08-25 03:29:08 +00:00
|
|
|
|
|
|
|
function AddWordButton(props: {word: string, addWordFn: (x: string) => void}) {
|
|
|
|
const [isClicked, setIsClicked] = useState<boolean>(false);
|
|
|
|
|
2023-09-22 01:55:33 +00:00
|
|
|
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>{props.word} was added to dictionary.</em>
|
|
|
|
</div>;
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|