diff --git a/package.json b/package.json index fcf1e90..2748aac 100644 --- a/package.json +++ b/package.json @@ -17,8 +17,11 @@ "check-all": "yarn type-check && yarn lint && yarn format && yarn test" }, "dependencies": { + "clsx": "^2.0.0", + "immer": "^10.0.2", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "use-immer": "^0.9.0" }, "devDependencies": { "@testing-library/jest-dom": "^5.16.4", diff --git a/src/GameState.tsx b/src/GameState.tsx new file mode 100644 index 0000000..4d86c61 --- /dev/null +++ b/src/GameState.tsx @@ -0,0 +1,81 @@ +import { NumPlayers, Player, PlayerId, createPlayers } from "./Player"; +import { DiceRoll } from "./reducer/reducerFunction"; + +export type DiveRoute = Piece[]; +export interface GameState { + remainingAir: number; + diveRoute: DiveRoute; + players: Player[]; + whoseTurn: PlayerId; + lastRoll: DiceRoll | null; +} + +export type Piece = TreasurePiece | BlankPiece; +export type PieceId = string & { readonly __brand: "pieceId" }; +export type TreasurePiece = { + id: PieceId; + tag: "treasure"; + state: "faceUp" | "faceDown"; + shape: TreasureShape; + value: TreasureValue; +}; +export type BlankPiece = { tag: "blank"; id: string }; +export type TreasureShape = "triangle" | "square" | "pentagon" | "hexagon"; +export type TreasureValue = number; +export type TreasureLevel = 1 | 2 | 3 | 4; + +export function createInitialState(numPlayers: NumPlayers): GameState { + const treasurePieces: TreasurePiece[] = shuffle(createTreasurePieces()); + const players: Player[] = createPlayers(numPlayers); + return { + players, + remainingAir: 25, + diveRoute: treasurePieces, + whoseTurn: players[0].id, + lastRoll: null, + }; +} +function shuffle(arr: T[]): T[] { + return [...arr].sort(() => (Math.random() < 0.5 ? -1 : 1)); +} + +function createTreasurePieces(): TreasurePiece[] { + const pieces: TreasurePiece[] = []; + let pieceId = 1; + for (let value = 0; value < 16; value++) { + const level = (1 + Math.floor(value / 4)) as TreasureLevel; + if (!isTreasureLevel(level)) { + throw new Error( + "illegal treasure level: " + level + " from value: " + value + ); + } + + for (let i = 0; i < 2; i++) { + const piece: TreasurePiece = { + id: ("" + pieceId++) as PieceId, + tag: "treasure", + state: "faceDown", + shape: shapeForTreasureLevel(level), + value, + }; + pieces.push(piece); + } + } + return pieces; +} +function shapeForTreasureLevel(level: TreasureLevel): TreasureShape { + const lookup: Record = { + 1: "triangle", + 2: "square", + 3: "pentagon", + 4: "hexagon", + }; + const s = lookup[level]; + if (s === undefined) { + throw new Error("missing shape for level: " + level); + } + return s; +} +function isTreasureLevel(level: number): level is TreasureLevel { + return [1, 2, 3, 4].includes(level); +} diff --git a/src/Player.ts b/src/Player.ts new file mode 100644 index 0000000..4493d32 --- /dev/null +++ b/src/Player.ts @@ -0,0 +1,33 @@ +import { TreasurePiece } from "./GameState"; + +export type NumPlayers = 2 | 3 | 4 | 5 | 6; +export type DiveRoutePosition = number & { __brand: "diveRoutePosition" }; +export type PlayerId = string & { readonly __brand: "playerId" }; +export interface Player { + id: PlayerId; + isDescending: boolean; + num: NumPlayers; + score: number; + position: DiveRoutePosition | "on-boat"; + carried: TreasurePiece[]; +} +export function createPlayers(numPlayers: NumPlayers): Player[] { + const players: Player[] = []; + + for (let i = 0; i < numPlayers; i++) { + const id = ("player_" + (players.length + 1)) as PlayerId; + const p: Player = { + id, + num: (i + 1) as NumPlayers, + score: 10, + carried: [], + isDescending: true, + position: + i === 0 + ? "on-boat" + : (Math.floor(Math.random() * 10) as DiveRoutePosition), //"on-boat", + }; + players.push(p); + } + return players; +} diff --git a/src/components/App.css b/src/components/App.css index 15cd55f..7ed66ab 100644 --- a/src/components/App.css +++ b/src/components/App.css @@ -1,3 +1,65 @@ html { background: #f8f8f8; + font-size: 30px; +} + +.diveRoute { + display: flex; + flex-direction: row; + gap: 0.5rem; + flex-wrap: wrap; +} + +.diveRoutePiece { + background: lightblue; + padding: 0.5rem; + aspect-ratio: 1; + display: grid; + place-items: center; + width: 100px; +} + +.diveRoutePiece.blank { + border-radius: 50%; +} + +.diveRoutePiece.pentagon { + clip-path: polygon(50% 0%, 100% 38%, 82% 100%, 18% 100%, 0% 38%); +} +.diveRoutePiece.hexagon { + clip-path: polygon(50% 0%, 95% 25%, 95% 75%, 50% 100%, 5% 75%, 5% 25%); +} +.diveRoutePiece.triangle { + clip-path: polygon(50% 0%, 100% 100%, 0% 100%); +} + +.player { + border-radius: 50%; + aspect-ratio: 1; + width: 1.5rem; + display: grid; + place-items: center; +} + +.player.p1 { + background: lime; +} +.player.p2 { + background: tomato; +} +.player.p3 { + background: yellow; +} +.player.p4 { + background: purple; +} +.player.p5 { + background: black; +} +.player.p6 { + background: white; +} + +button { + font-size: 2rem; } diff --git a/src/components/App.tsx b/src/components/App.tsx index 7341b62..3f7b19d 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -1,10 +1,58 @@ -import { MyComponent } from "./MyComponent"; +import clsx from "clsx"; +import { useImmerReducer } from "use-immer"; import "./App.css"; +import { DiveRoutePieceView } from "./DiveRoutePieceView"; +import { createInitialState } from "../GameState"; +import { PlayerSummary } from "./PlayerSummary"; +import { reducerFunction } from "../reducer/reducerFunction"; +import { Player } from "../Player"; function App() { + const initialState = createInitialState(3); + const [gs, dispatch] = useImmerReducer(reducerFunction, initialState); return (
- + + Pieces:{" "} +
+ {gs.diveRoute.map((p, slotIx) => { + const playersAtSlot: Player[] = gs.players.filter( + (p) => p.position === slotIx + ); + + return ( + + {playersAtSlot.map((player) => ( +
+ {player.num} +
+ ))} +
+ ); + })} +
+ Remaining Air: {gs.remainingAir} +
+ Last Roll:{" "} + {gs.lastRoll ? ( + <> + {gs.lastRoll.sum}{" "} + ( {gs.lastRoll.faces.join(", ")} ) + + ) : ( + "none" + )} +
+ Players:{" "} + {gs.players.map((p) => ( + + ))} +
); } diff --git a/src/components/DiveRoutePieceView.tsx b/src/components/DiveRoutePieceView.tsx new file mode 100644 index 0000000..49bdf18 --- /dev/null +++ b/src/components/DiveRoutePieceView.tsx @@ -0,0 +1,24 @@ +import clsx from "clsx"; +import { Piece } from "../GameState"; +import { ReactNode } from "react"; + +export function DiveRoutePieceView({ + piece, + children, +}: { + piece: Piece; + children: ReactNode; +}): JSX.Element { + return ( +
+ {piece.tag === "blank" ? "C" : piece.id} + {children} +
+ ); +} diff --git a/src/components/PlayerSummary.tsx b/src/components/PlayerSummary.tsx new file mode 100644 index 0000000..1eeec1a --- /dev/null +++ b/src/components/PlayerSummary.tsx @@ -0,0 +1,12 @@ +import { Player } from "../Player"; + +export function PlayerSummary({ player }: { player: Player }): JSX.Element { + return ( +
+ id: {player.id} + score: {player.score} + carried: {player.carried.length} + position: {player.position} +
+ ); +} diff --git a/src/gameCore b/src/gameCore new file mode 100644 index 0000000..e69de29 diff --git a/src/reducer/Action.tsx b/src/reducer/Action.tsx new file mode 100644 index 0000000..62263ca --- /dev/null +++ b/src/reducer/Action.tsx @@ -0,0 +1,7 @@ +export type Action = NoOpAction | RollToSwimAction; +interface NoOpAction { + tag: "no-op"; +} +interface RollToSwimAction { + tag: "roll-to-swim"; +} diff --git a/src/reducer/doRollToSwim.tsx b/src/reducer/doRollToSwim.tsx new file mode 100644 index 0000000..07423b1 --- /dev/null +++ b/src/reducer/doRollToSwim.tsx @@ -0,0 +1,27 @@ +import { GameState } from "../GameState"; +import { DiveRoutePosition } from "../Player"; +import { randomRoll } from "./reducerFunction"; + +export function doRollToSwim(draft: GameState) { + const roll = randomRoll(); + const p = draft.players[0]; + + if (p.position === "on-boat") { + p.position = (roll.sum - 1) as DiveRoutePosition; + } else { + if (p.isDescending) { + p.position = Math.min( + p.position + roll.sum, + draft.diveRoute.length - 1 + ) as DiveRoutePosition; + if (p.position === draft.diveRoute.length - 1) { + p.isDescending = false; + } + } else { + const result = Math.max(p.position + roll.sum, -1); + p.position = + result === -1 ? "on-boat" : (result as DiveRoutePosition); + } + } + draft.lastRoll = roll; +} diff --git a/src/reducer/reducerFunction.tsx b/src/reducer/reducerFunction.tsx new file mode 100644 index 0000000..2a26df8 --- /dev/null +++ b/src/reducer/reducerFunction.tsx @@ -0,0 +1,31 @@ +import { original } from "immer"; +import { Action } from "./Action"; +import { GameState } from "../GameState"; +import { doRollToSwim } from "./doRollToSwim"; + +export function reducerFunction(draft: GameState, action: Action): void { + console.log(original(draft)); + switch (action.tag) { + case "no-op": + return; + case "roll-to-swim": { + doRollToSwim(draft); + + return; + } + } +} + +export function randomRoll(): DiceRoll { + const a = pick([1, 2, 3] as DieFace[]); + const b = pick([1, 2, 3] as DieFace[]); + const sum = (a + b) as DiceSum; + return { sum, faces: [a, b] }; +} +export type DiceRoll = { sum: DiceSum; faces: [DieFace, DieFace] }; +export type DiceSum = 2 | 3 | 4 | 5 | 6; +export type DieFace = 1 | 2 | 3; + +function pick(arr: T[]): T { + return arr[Math.floor(Math.random() * arr.length)]; +} diff --git a/yarn.lock b/yarn.lock index b77faf8..3dadb65 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2048,6 +2048,11 @@ ci-info@^3.2.0: resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.8.0.tgz#81408265a5380c929f0bc665d62256628ce9ef91" integrity sha512-eXTggHWSooYhq49F2opQhuHWgzucfF2YgODK4e1566GQs5BIfP30B0oenwBJHfWxAs2fyPB1s7Mg949zLf61Yw== +clsx@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.0.0.tgz#12658f3fd98fafe62075595a5c30e43d18f3d00b" + integrity sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q== + color-convert@^1.9.0: version "1.9.3" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" @@ -3009,6 +3014,11 @@ ignore@^5.2.0: resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.4.tgz#a291c0c6178ff1b960befe47fcdec301674a6324" integrity sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ== +immer@^10.0.2: + version "10.0.2" + resolved "https://registry.yarnpkg.com/immer/-/immer-10.0.2.tgz#11636c5b77acf529e059582d76faf338beb56141" + integrity sha512-Rx3CqeqQ19sxUtYV9CU911Vhy8/721wRFnJv3REVGWUmoAcIwzifTsdmJte/MV+0/XpM35LZdQMBGkRIoLPwQA== + import-fresh@^3.0.0, import-fresh@^3.2.1: version "3.3.0" resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b" @@ -4424,6 +4434,11 @@ url-parse@^1.5.3: querystringify "^2.1.1" requires-port "^1.0.0" +use-immer@^0.9.0: + version "0.9.0" + resolved "https://registry.yarnpkg.com/use-immer/-/use-immer-0.9.0.tgz#66e4e8f7ab75df45e96dfd5c56337f9fd49db9fd" + integrity sha512-/L+enLi0nvuZ6j4WlyK0US9/ECUtV5v9RUbtxnn5+WbtaXYUaOBoKHDNL9I5AETdurQ4rIFIj/s+Z5X80ATyKw== + vite-node@0.32.0: version "0.32.0" resolved "https://registry.yarnpkg.com/vite-node/-/vite-node-0.32.0.tgz#8ee54539fa75d1271adaa9788c8ba526480f4519"