611 lines
20 KiB
TypeScript
611 lines
20 KiB
TypeScript
import React, {
|
||
useState,
|
||
useEffect,
|
||
useContext,
|
||
useRef,
|
||
useMemo,
|
||
useCallback,
|
||
} from "react";
|
||
import equal from "fast-deep-equal";
|
||
|
||
import Paper from "@mui/material/Paper";
|
||
import Button from "@mui/material/Button";
|
||
import Switch from "@mui/material/Switch";
|
||
import Dialog from "@mui/material/Dialog";
|
||
import DialogTitle from "@mui/material/DialogTitle";
|
||
import DialogContent from "@mui/material/DialogContent";
|
||
import DialogActions from "@mui/material/DialogActions";
|
||
import Table from "@mui/material/Table";
|
||
import TableBody from "@mui/material/TableBody";
|
||
import TableCell from "@mui/material/TableCell";
|
||
import TableContainer from "@mui/material/TableContainer";
|
||
import TableRow from "@mui/material/TableRow";
|
||
|
||
// import "./HouseRules.css";
|
||
|
||
import boardImg from "./assets/category-board.png";
|
||
import expansionImg from "./assets/category-expansion.png";
|
||
import rollingImg from "./assets/category-rolling.png";
|
||
import rulesImg from "./assets/category-rules.png";
|
||
import volcanoTile from "./assets/single-volcano.png";
|
||
|
||
import { GlobalContext } from "./GlobalContext";
|
||
import { Placard } from "./Placard";
|
||
import Box from "@mui/material/Box/Box";
|
||
import { Typography } from "@mui/material";
|
||
|
||
const categoryImages: { [key: string]: string } = {
|
||
board: boardImg,
|
||
expansion: expansionImg,
|
||
rolling: rollingImg,
|
||
rules: rulesImg,
|
||
};
|
||
|
||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||
|
||
interface VolcanoProps {
|
||
sendJsonMessage: (message: any) => void;
|
||
rules: any;
|
||
field: string;
|
||
disabled: boolean;
|
||
}
|
||
|
||
/* Volcano based on https://www.ultraboardgames.com/catan/the-volcano.php */
|
||
const Volcano: React.FC<VolcanoProps> = ({ sendJsonMessage, rules, field, disabled }) => {
|
||
const init =
|
||
Math.random() > 0.5
|
||
? Math.floor(8 + Math.random() * 5) /* Do not include 7 */
|
||
: Math.floor(2 + Math.random() * 5); /* Do not include 7 */
|
||
const [number, setNumber] = useState<number>(
|
||
field in rules && "number" in rules[field] ? rules[field].number : init
|
||
);
|
||
const [gold, setGold] = useState<boolean>(
|
||
field in rules && "gold" in rules[field] ? rules[field].gold : false
|
||
);
|
||
|
||
console.log(`house-rules - ${field} - `, rules[field]);
|
||
|
||
useEffect(() => {
|
||
if (field in rules) {
|
||
setGold("gold" in rules[field] ? rules[field].gold : true);
|
||
setNumber("number" in rules[field] ? rules[field].number : init);
|
||
let update = false;
|
||
if (!("gold" in rules[field])) {
|
||
rules[field].gold = true;
|
||
update = true;
|
||
}
|
||
if (!("number" in rules[field])) {
|
||
rules[field].number = init;
|
||
update = true;
|
||
}
|
||
|
||
if (update && sendJsonMessage) {
|
||
sendJsonMessage({
|
||
type: "rules",
|
||
rules: rules,
|
||
});
|
||
}
|
||
}
|
||
}, [rules, field, init, sendJsonMessage]);
|
||
|
||
const toggleGold = () => {
|
||
if (!sendJsonMessage) return;
|
||
rules[field].gold = !gold;
|
||
rules[field].number = number;
|
||
setGold(rules[field].gold);
|
||
|
||
sendJsonMessage({
|
||
type: "rules",
|
||
rules: rules,
|
||
});
|
||
};
|
||
|
||
const update = (delta: number) => {
|
||
if (!sendJsonMessage) return;
|
||
let value = number + delta;
|
||
if (value < 2 || value > 12) {
|
||
return;
|
||
}
|
||
/* Number to trigger Volcano cannot be 7 */
|
||
if (value === 7) {
|
||
value = delta > 0 ? 8 : 6;
|
||
}
|
||
setNumber(value);
|
||
rules[field].gold = gold;
|
||
rules[field].number = value;
|
||
sendJsonMessage({
|
||
type: "rules",
|
||
rules: rules,
|
||
});
|
||
};
|
||
|
||
return (
|
||
<Box
|
||
className="Volcano"
|
||
sx={{
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
alignItems: "center",
|
||
gap: 2,
|
||
marginTop: 2,
|
||
}}
|
||
>
|
||
<Box
|
||
sx={{
|
||
display: "flex",
|
||
flexDirection: "row",
|
||
alignItems: "center",
|
||
gap: 2,
|
||
}}
|
||
>
|
||
<img
|
||
src={volcanoTile}
|
||
alt={"Volcano"}
|
||
style={{ width: "100px", height: "100px" }}
|
||
/>
|
||
<div>
|
||
The Volcano replaces the Desert. When the Volcano erupts, roll a die
|
||
to determine the direction the lava will flow. One of the six
|
||
intersections on the Volcano tile will be affected. If there is a
|
||
settlement on the selected intersection, it is destroyed!
|
||
</div>
|
||
</Box>
|
||
<div>
|
||
Remove it from the board (its owner may rebuild it later). If a city is
|
||
located there, it is reduced to a settlement! Replace the city with a
|
||
settlement of its owner's color. If he has no settlements
|
||
remaining, the city is destroyed instead.
|
||
</div>
|
||
<div>
|
||
The presence of the Robber on the Volcano does not prevent the Volcano
|
||
from erupting.
|
||
</div>
|
||
<div>
|
||
Roll <b>{number}</b> and the Volcano erupts!
|
||
<Button onClick={() => update(+1)}>up</Button> /
|
||
<Button onClick={() => update(-1)}> down</Button>
|
||
</div>
|
||
<Paper sx={{ flexGrow: 1, width: "100%" }}>
|
||
<Table>
|
||
<TableRow>
|
||
<TableCell>
|
||
<b>Volcanoes have gold!</b>
|
||
<br />
|
||
Volcano can produce resources when its number is rolled.
|
||
</TableCell>
|
||
<TableCell>
|
||
<Switch
|
||
size={"small"}
|
||
className="RuleSwitch"
|
||
checked={gold}
|
||
onChange={() => toggleGold()}
|
||
{...{ disabled }}
|
||
/>
|
||
</TableCell>
|
||
</TableRow>
|
||
{gold && (
|
||
<TableRow>
|
||
<TableCell colSpan={3}>
|
||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||
<div>
|
||
Volcanoes tend to be rich in valuable minerals such as gold
|
||
or gems. Each settlement that is adjacent to the Volcano
|
||
when it erupts may produce any one of the five resources
|
||
it's owner desires.
|
||
</div>
|
||
<div>
|
||
Each city adjacent to the Volcano may produce any two
|
||
resources. This resource production is taken before the
|
||
results of the volcano eruption are resolved. Note that
|
||
while the Robber can not prevent the Volcano from erupting,
|
||
he does prevent any player from producing resources from the
|
||
Volcano hex if he has been placed there.
|
||
</div>
|
||
</Box>
|
||
</TableCell>
|
||
</TableRow>
|
||
)}
|
||
</Table>
|
||
</Paper>
|
||
</Box>
|
||
);
|
||
};
|
||
|
||
interface VictoryPointsProps {
|
||
sendJsonMessage: (message: any) => void;
|
||
rules: any;
|
||
field: string;
|
||
}
|
||
|
||
const VictoryPoints: React.FC<VictoryPointsProps> = ({ sendJsonMessage, rules, field }) => {
|
||
const minVP = 10;
|
||
const [points, setPoints] = useState<number>(rules[field].points || minVP);
|
||
console.log(`house-rules - ${field} - `, rules[field]);
|
||
|
||
if (!(field in rules)) {
|
||
rules[field] = {
|
||
points: minVP,
|
||
};
|
||
}
|
||
|
||
if (rules[field].points && rules[field].points !== points) {
|
||
setPoints(rules[field].points);
|
||
}
|
||
|
||
const update = (value: number) => {
|
||
if (!sendJsonMessage) return;
|
||
const points = (rules[field].points || minVP) + value;
|
||
if (points < minVP) {
|
||
return;
|
||
}
|
||
if (points !== rules[field].points) {
|
||
setPoints(points);
|
||
rules[field].points = points;
|
||
sendJsonMessage({
|
||
type: "rules",
|
||
rules: rules,
|
||
});
|
||
}
|
||
};
|
||
|
||
return (
|
||
<Box className="VictoryPoints" sx={{display: 'flex', flexDirection: 'column', gap: 2, marginTop: 2}}>
|
||
<Box>
|
||
The first to reach <b>{points}</b> points wins!
|
||
<Button onClick={() => update(+1)}>up</Button> /
|
||
<Button onClick={() => update(-1)}> down</Button>
|
||
</Box>
|
||
<Box>
|
||
This flexible twist of a rule lets you customize the number of Victory Points needed to claim victory, whether youre aiming for a quick 7-point skirmish or a marathon 12-point conquest. Adjust the goal to match your mood—keep it low for a fast-paced showdown or crank it up for an epic battle of strategy and luck, ensuring every game feels fresh and perfectly suited to your crew’s competitive spirit!
|
||
</Box>
|
||
</Box>
|
||
);
|
||
};
|
||
|
||
interface HouseRulesProps {
|
||
houseRulesActive: boolean;
|
||
setHouseRulesActive: React.Dispatch<React.SetStateAction<boolean>>;
|
||
}
|
||
|
||
const HouseRules: React.FC<HouseRulesProps> = ({
|
||
houseRulesActive,
|
||
setHouseRulesActive,
|
||
}) => {
|
||
const { ws, name, sendJsonMessage } = useContext(GlobalContext);
|
||
const [rules, setRules] = useState<any>({});
|
||
const [state, setState] = useState<any>({});
|
||
const [gameState, setGameState] = useState<string>("");
|
||
|
||
const fields = useMemo(() => ["state", "rules"], []);
|
||
const onWsMessage = (event: MessageEvent) => {
|
||
const data = JSON.parse(event.data);
|
||
switch (data.type) {
|
||
case "game-update":
|
||
console.log(`house-rules - game-update: `, data.update);
|
||
if ("state" in data.update && data.update.state !== gameState) {
|
||
setGameState(data.update.state);
|
||
}
|
||
if ("rules" in data.update && !equal(rules, data.update.rules)) {
|
||
setRules(data.update.rules);
|
||
}
|
||
break;
|
||
default:
|
||
break;
|
||
}
|
||
};
|
||
const refWsMessage = useRef(onWsMessage);
|
||
useEffect(() => {
|
||
refWsMessage.current = onWsMessage;
|
||
});
|
||
useEffect(() => {
|
||
if (!ws) {
|
||
return;
|
||
}
|
||
const cbMessage = (e: MessageEvent) => refWsMessage.current(e);
|
||
ws.addEventListener("message", cbMessage);
|
||
return () => {
|
||
ws.removeEventListener("message", cbMessage);
|
||
};
|
||
}, [ws, refWsMessage]);
|
||
useEffect(() => {
|
||
if (!sendJsonMessage) {
|
||
return;
|
||
}
|
||
sendJsonMessage({
|
||
type: "get",
|
||
fields,
|
||
});
|
||
}, [sendJsonMessage, fields]);
|
||
|
||
const dismissClicked = useCallback(() => {
|
||
setHouseRulesActive(false);
|
||
}, [setHouseRulesActive]);
|
||
|
||
const setRule = useCallback(
|
||
(event: React.ChangeEvent<HTMLInputElement>, key: string) => {
|
||
if (!sendJsonMessage) return;
|
||
const checked = event.target.checked;
|
||
console.log(`house-rules - set rule ${key} to ${checked}`);
|
||
rules[key].enabled = checked;
|
||
setRules({ ...rules });
|
||
sendJsonMessage({
|
||
type: "rules",
|
||
rules: rules,
|
||
});
|
||
},
|
||
[rules, sendJsonMessage]
|
||
);
|
||
|
||
const ruleList = useMemo(
|
||
() =>
|
||
[
|
||
{
|
||
key: "volcano",
|
||
label: "Volcanoes are a lava fun!",
|
||
description: "A volcano is on the island! Let the lava flow!",
|
||
category: "board",
|
||
defaultChecked: false,
|
||
element: (
|
||
<Volcano
|
||
sendJsonMessage={sendJsonMessage}
|
||
rules={rules}
|
||
field={"volcano"}
|
||
disabled={gameState !== "lobby"}
|
||
/>
|
||
),
|
||
},
|
||
{
|
||
key: "victory-points",
|
||
label: "More victory points",
|
||
description: "Customize how many Victory Points are required to win.",
|
||
category: "rules",
|
||
defaultChecked: false,
|
||
element: (
|
||
<VictoryPoints sendJsonMessage={sendJsonMessage} rules={rules} field={"victory-points"} />
|
||
),
|
||
},
|
||
{
|
||
key: "tiles-start-facing-down",
|
||
label: "Tiles start facing down",
|
||
description:
|
||
"Resource tiles start upside-down while placing starting settlements.",
|
||
category: "board",
|
||
defaultChecked: false,
|
||
element: (
|
||
<div>
|
||
Once all players have placed their initial settlements and roads,
|
||
the tiles are flipped and you discover what the resources are.
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
key: "most-developed",
|
||
label: "You are so developed!",
|
||
description:
|
||
"The player with the most development cards (more than 4) receives 2VP.",
|
||
category: "expansion",
|
||
defaultChecked: false,
|
||
element: (
|
||
<Box sx={{ display: "block", flexDirection: "column" }}>
|
||
<Placard
|
||
sx={{
|
||
float: "left",
|
||
shapeOutside: "inset(0)" /* Text wraps the full rectangle */,
|
||
clipPath: "inset(0)" /* Ensures proper wrapping area */,
|
||
marginRight: "1rem",
|
||
marginBottom: "1rem",
|
||
}}
|
||
type="most-developed"
|
||
/>
|
||
<Typography variant="body2">
|
||
This card rewards the player who amasses more than 4 development
|
||
cards with a glorious 2 Victory Points, turning your strategic
|
||
savvy into a medieval masterpiece complete with towering cities
|
||
and bustling fields. Picture yourself snagging this beautifully
|
||
illustrated card—featuring hardworking villagers and a majestic
|
||
castle!
|
||
</Typography>
|
||
</Box>
|
||
),
|
||
},
|
||
{
|
||
key: "port-of-call",
|
||
label: "Another round of port?",
|
||
description:
|
||
"The player with the most harbor ports (more than 2) receives 2VP.",
|
||
category: "expansion",
|
||
defaultChecked: false,
|
||
element: (
|
||
<Box sx={{ display: "block", flexDirection: "column" }}>
|
||
<Placard
|
||
sx={{
|
||
float: "left",
|
||
shapeOutside: "inset(0)" /* Text wraps the full rectangle */,
|
||
clipPath: "inset(0)" /* Ensures proper wrapping area */,
|
||
marginRight: "1rem",
|
||
marginBottom: "1rem",
|
||
}}
|
||
type="port-of-call"
|
||
/>
|
||
<Typography variant="body2">
|
||
Raise your mugs and hoist the sails! This lively card rewards
|
||
the most seasoned seafarer among the settlers. When you control
|
||
more than two harbor ports, you claim this card and earn 2
|
||
Victory Points as a tribute to your mastery of the seas. But
|
||
beware — other ambitious captains are watching closely! The
|
||
moment someone else builds a larger network of harbors, they’ll
|
||
steal both the card and the glory right from under your nose.
|
||
Keep those ships moving and never let your rivals toast to your
|
||
downfall!
|
||
</Typography>
|
||
</Box>
|
||
),
|
||
},
|
||
{
|
||
key: "slowest-turn",
|
||
label: "Why you play so slow?",
|
||
description:
|
||
"The player with the longest turn idle time (longer than 2 minutes) so far loses 2VP.",
|
||
category: "expansion",
|
||
defaultChecked: false,
|
||
element: (
|
||
<Box sx={{ display: "block", flexDirection: "column" }}>
|
||
<Placard
|
||
sx={{
|
||
float: "left",
|
||
shapeOutside: "inset(0)" /* Text wraps the full rectangle */,
|
||
clipPath: "inset(0)" /* Ensures proper wrapping area */,
|
||
marginRight: "1rem",
|
||
marginBottom: "1rem",
|
||
}}
|
||
type="longest-turn"
|
||
/>
|
||
<Typography variant="body2" sx={{ marginTop: "1rem" }}>
|
||
If your turn idle time drags on past 2 minutes, you’re slapped
|
||
with a -2 Victory Points penalty and handed this charming
|
||
card—featuring industrious villagers raking hay with a castle
|
||
looming in the background—until someone even slower takes it
|
||
from you with a sheepish grin!
|
||
</Typography>
|
||
</Box>
|
||
),
|
||
},
|
||
{
|
||
key: "roll-double-roll-again",
|
||
label: "Roll double, roll again",
|
||
description: "Roll again if you roll two of the same number.",
|
||
category: "rolling",
|
||
defaultChecked: false,
|
||
element: (
|
||
<div>
|
||
If you roll doubles, players get those resources and then you must
|
||
roll again.
|
||
<Box sx={{ display: "block", fontWeight: "bold", pt: 1, pb: 1 }}>
|
||
Note:
|
||
</Box>
|
||
This stacks with Two and Twelve are Synonyms. So if you roll
|
||
double ones (2), you get resources for 2 and 12, then roll again!
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
key: "twelve-and-two-are-synonyms",
|
||
label: "Twelve and Two are synonyms",
|
||
description:
|
||
"If twelve is rolled, two scores as well. And vice-versa.",
|
||
category: "rolling",
|
||
defaultChecked: false,
|
||
element: (
|
||
<div>
|
||
If you roll a twelve or two, resources are triggered for both.
|
||
<Box sx={{ display: "block", fontWeight: "bold", pt: 1, pb: 1 }}>
|
||
Note:
|
||
</Box>{" "}
|
||
This stacks with Roll Double, Roll Again. So if you roll double
|
||
sixes (12), you get resources for 2 and 12, then roll again!
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
key: "robin-hood-robber",
|
||
label: "Robin Hood robber",
|
||
description:
|
||
"Robbers can't steal from players with two or less victory points.",
|
||
category: "rules",
|
||
defaultChecked: false,
|
||
element: (
|
||
<Typography variant="body2">
|
||
This rule turns the robber into a noble thief, forbidding him from
|
||
pilfering resources from players with two or fewer Victory
|
||
Points—leaving the underdogs safe while the wealthier lords
|
||
tremble. Watch as the tables turn with a wink and a grin, adding a
|
||
layer of strategy where protecting the little guy might just be
|
||
the key to your own rise to power!
|
||
</Typography>
|
||
),
|
||
},
|
||
].sort((a, b) => a.category.localeCompare(b.category)),
|
||
[rules, setRules, state, ws, setRule, name, gameState]
|
||
);
|
||
|
||
if (!houseRulesActive) {
|
||
return <></>;
|
||
}
|
||
|
||
return (
|
||
<Paper
|
||
sx={{
|
||
p: 1,
|
||
maxWidth: 600,
|
||
margin: "1rem auto",
|
||
flexDirection: "column",
|
||
display: "flex",
|
||
}}
|
||
className="HouseRules"
|
||
elevation={3}
|
||
>
|
||
<DialogTitle>House Rules</DialogTitle>
|
||
<DialogContent>
|
||
<TableContainer>
|
||
<Table sx={{ tableLayout: "fixed" }}>
|
||
<TableBody>
|
||
{ruleList.map((item) => {
|
||
const defaultChecked = item.defaultChecked;
|
||
if (!(item.key in rules)) {
|
||
rules[item.key] = {
|
||
enabled: defaultChecked,
|
||
};
|
||
}
|
||
const checked = rules[item.key].enabled;
|
||
if (checked !== state[item.key]) {
|
||
setState({ ...state, [item.key]: checked });
|
||
}
|
||
|
||
return (
|
||
<React.Fragment key={item.key}>
|
||
<TableRow>
|
||
<TableCell sx={{ width: "50px" }}>
|
||
{/* Fixed width for image */}
|
||
<img
|
||
src={categoryImages[item.category]}
|
||
alt={item.category}
|
||
style={{ width: "50px", height: "68px" }}
|
||
/>
|
||
</TableCell>
|
||
<TableCell sx={{ width: "auto" }}>
|
||
<b>{item.label}</b>
|
||
<br />
|
||
{item.description}
|
||
</TableCell>
|
||
<TableCell sx={{ width: "32px" }}>
|
||
<Switch
|
||
size={"small"}
|
||
className="RuleSwitch"
|
||
checked={checked}
|
||
id={item.key}
|
||
onChange={(e) => setRule(e, item.key)}
|
||
disabled={gameState !== "lobby"}
|
||
/>
|
||
</TableCell>
|
||
</TableRow>
|
||
{checked && (
|
||
<TableRow>
|
||
<TableCell colSpan={3}>{item.element}</TableCell>
|
||
</TableRow>
|
||
)}
|
||
</React.Fragment>
|
||
);
|
||
})}
|
||
</TableBody>
|
||
</Table>
|
||
</TableContainer>
|
||
</DialogContent>
|
||
<DialogActions>
|
||
<Button onClick={dismissClicked}>Close</Button>
|
||
</DialogActions>
|
||
</Paper>
|
||
);
|
||
};
|
||
|
||
export { HouseRules };
|