diff --git a/index.html b/index.html index ffea47c..d85c0a1 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,7 @@ Canopy - +
diff --git a/package-lock.json b/package-lock.json index 41fa371..215cc5d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "canopy-game", "version": "0.0.1", "devDependencies": { + "typescript": "^6.0.2", "vite": "^5.4.19" } }, @@ -923,6 +924,20 @@ "node": ">=0.10.0" } }, + "node_modules/typescript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", + "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", diff --git a/package.json b/package.json index 22b2bfe..ee2888a 100644 --- a/package.json +++ b/package.json @@ -6,9 +6,11 @@ "scripts": { "dev": "vite", "build": "vite build", - "preview": "vite preview" + "preview": "vite preview", + "typecheck": "tsc --noEmit" }, "devDependencies": { + "typescript": "^6.0.2", "vite": "^5.4.19" } } diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..8abe002 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,16 @@ +import type { PlayerPalette } from "./types"; + +export const STARTING_POINTS = 3; +export const ROOT_SHIFT_COST = 1; +export const ROUND_ANIMATION_SUN_MS = 900; +export const ROUND_ANIMATION_BRANCH_MS = 1200; +export const ROUND_ANIMATION_BONUS_MS = 900; + +export const PLAYER_PALETTE: PlayerPalette[] = [ + { name: "Coral", primary: "#ff6b8a", glow: "rgba(255, 107, 138, 0.35)" }, + { name: "Aqua", primary: "#4de0ff", glow: "rgba(77, 224, 255, 0.35)" }, + { name: "Amber", primary: "#ffbf47", glow: "rgba(255, 191, 71, 0.35)" }, + { name: "Mint", primary: "#6fffb0", glow: "rgba(111, 255, 176, 0.35)" }, + { name: "Violet", primary: "#b28dff", glow: "rgba(178, 141, 255, 0.35)" }, + { name: "Rose", primary: "#ff8dbf", glow: "rgba(255, 141, 191, 0.35)" }, +]; diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..0cba93d --- /dev/null +++ b/src/main.ts @@ -0,0 +1,1008 @@ +import "./styles.css"; + +import { + ROOT_SHIFT_COST, + ROUND_ANIMATION_BONUS_MS, + ROUND_ANIMATION_BRANCH_MS, + ROUND_ANIMATION_SUN_MS, +} from "./constants"; +import { + buildChildrenMap as buildChildrenMapForState, + buildParentMap as buildParentMapForState, + getColumnLeaders as getColumnLeadersForState, + getLegalMovesForSource as getLegalMovesForSourceForState, + getNodeOwner as getNodeOwnerForState, + getRootShiftMove as getRootShiftMoveForState, + playerHasLegalMove as playerHasLegalMoveForState, +} from "./rules-board"; +import { + buildEnergySimulation, + buildRoundAnimation as buildRoundAnimationForState, + maybeRollDisease as maybeRollDiseaseForState, + maybeRollSunbeam as maybeRollSunbeamForState, + scoreColumns as scoreColumnsForState, +} from "./rules-scoring"; +import { + createInitialState, + createPlayers, + createRandomizedSeedInputs, + createSetupState, + getMaxStartingNodesPerPlayer, +} from "./state"; +import type { + GameState, + GrowTarget, + NodeKey, + Player, + ScoreSnapshot, + SetupState, + ShiftMove, + TurnMove, +} from "./types"; +import { keyFor, parseKey, tint, wait } from "./utils"; + +const app = document.querySelector("#app"); + +if (!(app instanceof HTMLElement)) { + throw new Error("#app container not found"); +} + +let roundAnimationToken = 0; +let setup: SetupState = createSetupState(); +let state: GameState = createInitialState(setup); +let isNewGameModalOpen = false; +let previousScoreSnapshot: ScoreSnapshot[] | null = null; + +function getScoreSnapshot() { + return state.players.map((player) => ({ + totalScore: player.totalScore, + roundScore: player.roundScore, + growthPoints: player.growthPoints, + bankedPoints: player.bankedPoints, + })); +} + +function getCurrentPlayer() { + return state.players[state.activePlayerId]; +} + +function getNodeOwner(row: number, column: number) { + return getNodeOwnerForState(state, row, column); +} + +function buildParentMap() { + return buildParentMapForState(state); +} + +function buildChildrenMap(ownerId = null) { + return buildChildrenMapForState(state, ownerId); +} + +function getRootShiftMove(sourceKey: NodeKey, delta: number, player: Player) { + return getRootShiftMoveForState(state, sourceKey, delta, player); +} + +function getSelectedRootShiftMoves() { + const player = getCurrentPlayer(); + if (!state.selectedSource) { + return []; + } + + return [-1, 1] + .map((delta) => getRootShiftMove(state.selectedSource as NodeKey, delta, player)) + .filter(Boolean) as ShiftMove[]; +} + +function getLegalMovesForSource(sourceKey: NodeKey, player: Player) { + return getLegalMovesForSourceForState(state, sourceKey, player); +} + +function playerHasLegalMove(player: Player) { + return playerHasLegalMoveForState(state, player); +} + +function scoreColumns() { + return scoreColumnsForState(state); +} + +function maybeRollSunbeam(scores: number[]) { + return maybeRollSunbeamForState(state, scores); +} + +function maybeRollDisease() { + return maybeRollDiseaseForState(state); +} + +function buildRoundAnimation(energySimulation, sunbeamPlayerId, diseaseKeys) { + return buildRoundAnimationForState(state, energySimulation, sunbeamPlayerId, diseaseKeys); +} + +function getColumnLeaders() { + return getColumnLeadersForState(state); +} + +function getMoveUndoKeys(move: TurnMove) { + return move.undoKeys ?? ("targetKey" in move ? [move.targetKey] : []); +} + +function findTurnMoveIndex(targetKey: NodeKey) { + return state.turnMoves.findIndex((move) => getMoveUndoKeys(move).includes(targetKey)); +} + +function applyShiftMove(move: ShiftMove, player: Player) { + move.movedNodes.forEach((node) => { + state.nodes.delete(node.fromKey); + }); + + move.movedNodes.forEach((node) => { + state.nodes.set(node.toKey, { ownerId: player.id }); + }); + + move.movedEdges.forEach((edgeMove) => { + const edgeIndex = state.edges.findIndex((edge) => { + return edge.ownerId === edgeMove.before.ownerId + && edge.from.row === edgeMove.before.from.row + && edge.from.column === edgeMove.before.from.column + && edge.to.row === edgeMove.before.to.row + && edge.to.column === edgeMove.before.to.column; + }); + + if (edgeIndex !== -1) { + state.edges[edgeIndex] = { + from: { ...edgeMove.after.from }, + to: { ...edgeMove.after.to }, + ownerId: edgeMove.after.ownerId, + }; + } + }); + + player.growthPoints -= move.cost; + state.turnMoves.push(move); + state.history.unshift(`${player.name} shifted a root ${move.direction} for ${move.cost} point.`); + updateSelection(move.selectKey); + render(); +} + +function undoTurnMove(move: TurnMove, player: Player) { + if (move.type === "shift") { + move.movedNodes.forEach((node) => { + state.nodes.delete(node.toKey); + }); + + move.movedNodes.forEach((node) => { + state.nodes.set(node.fromKey, { ownerId: player.id }); + }); + + move.movedEdges.forEach((edgeMove) => { + const edgeIndex = state.edges.findIndex((edge) => { + return edge.ownerId === edgeMove.after.ownerId + && edge.from.row === edgeMove.after.from.row + && edge.from.column === edgeMove.after.from.column + && edge.to.row === edgeMove.after.to.row + && edge.to.column === edgeMove.after.to.column; + }); + + if (edgeIndex !== -1) { + state.edges[edgeIndex] = { + from: { ...edgeMove.before.from }, + to: { ...edgeMove.before.to }, + ownerId: edgeMove.before.ownerId, + }; + } + }); + + player.growthPoints += move.cost; + return; + } + + state.nodes.delete(move.targetKey); + const edgeIndex = state.edges.findIndex((edge) => { + return edge.ownerId === player.id + && edge.from.row === move.from.row + && edge.from.column === move.from.column + && edge.to.row === move.to.row + && edge.to.column === move.to.column; + }); + + if (edgeIndex !== -1) { + state.edges.splice(edgeIndex, 1); + } + + player.growthPoints += move.cost; +} + +function applyDisease(killedKeys: NodeKey[]) { + const killed = new Set(killedKeys); + if (killed.size === 0) { + return; + } + + state.edges = state.edges.filter((edge) => { + return !killed.has(keyFor(edge.to.row, edge.to.column)) && !killed.has(keyFor(edge.from.row, edge.from.column)); + }); + killedKeys.forEach((key) => { + state.nodes.delete(key); + }); +} + +function updateSelection(sourceKey: NodeKey | null = null) { + const player = getCurrentPlayer(); + state.selectedSource = sourceKey; + state.availableTargets = sourceKey ? getLegalMovesForSource(sourceKey, player) : []; +} + +function isInteractionLocked() { + return state.gameOver || Boolean(state.animation); +} + +function advanceTurn() { + if (state.players.every((player) => player.passed || !playerHasLegalMove(player))) { + endRound(); + return; + } + + let nextPlayerId = state.activePlayerId; + for (let step = 0; step < state.players.length; step += 1) { + nextPlayerId = (nextPlayerId + 1) % state.players.length; + const candidate = state.players[nextPlayerId]; + + if (!candidate.passed && playerHasLegalMove(candidate)) { + state.activePlayerId = nextPlayerId; + state.turnMoves = []; + updateSelection(null); + render(); + return; + } + } + + endRound(); +} + +function moveToFirstPlayableTurn() { + const nextPlayerId = state.players.findIndex((player) => playerHasLegalMove(player)); + + if (nextPlayerId === -1) { + state.gameOver = true; + state.history.unshift("No player can grow further. Final totals are locked."); + return false; + } + + state.activePlayerId = nextPlayerId; + state.turnMoves = []; + updateSelection(null); + return true; +} + +function undoMovesThrough(targetKey: NodeKey) { + const player = getCurrentPlayer(); + const moveIndex = findTurnMoveIndex(targetKey); + + if (moveIndex === -1) { + return false; + } + + const undoneMoves = state.turnMoves.slice(moveIndex).reverse(); + undoneMoves.forEach((move) => { + undoTurnMove(move, player); + }); + + state.turnMoves = state.turnMoves.slice(0, moveIndex); + state.history.unshift(`${player.name} rewound ${undoneMoves.length} move${undoneMoves.length === 1 ? "" : "s"} before ending the turn.`); + + const lastMove = state.turnMoves.at(-1); + const newSelection = lastMove?.selectKey ?? (lastMove && "targetKey" in lastMove ? lastMove.targetKey : null); + updateSelection(newSelection); + render(); + return true; +} + +function growTo(target: GrowTarget) { + const player = getCurrentPlayer(); + if (!state.selectedSource) { + return; + } + + const source = parseKey(state.selectedSource); + const targetKey = keyFor(target.row, target.column); + if (state.nodes.has(targetKey) || target.cost > player.growthPoints) { + return; + } + + state.nodes.set(targetKey, { ownerId: player.id }); + state.edges.push({ + from: { row: source.row, column: source.column }, + to: { row: target.row, column: target.column }, + ownerId: player.id, + }); + state.turnMoves.push({ + type: "grow", + from: source, + to: { row: target.row, column: target.column }, + cost: target.cost, + targetKey, + undoKeys: [targetKey], + selectKey: targetKey, + }); + player.growthPoints -= target.cost; + state.history.unshift( + `${player.name} grew ${target.direction === "vertical" ? "upward" : `diagonally ${target.direction}`} for ${target.cost} point${target.cost === 1 ? "" : "s"}.` + ); + + updateSelection(targetKey); + render(); +} + +function shiftSelectedRoot(delta: number) { + const player = getCurrentPlayer(); + if (!state.selectedSource) { + return; + } + + const move = getRootShiftMove(state.selectedSource, delta, player); + if (!move) { + return; + } + + applyShiftMove(move, player); +} + +function endTurn() { + const player = getCurrentPlayer(); + if (player.growthPoints > 0) { + const confirmed = window.confirm( + `${player.name} still has ${player.growthPoints} growth point${player.growthPoints === 1 ? "" : "s"}. End the turn and lose them?` + ); + + if (!confirmed) { + return; + } + } + + const lostGrowth = player.growthPoints; + state.turnMoves = []; + player.growthPoints = 0; + player.passed = true; + state.history.unshift( + `${player.name} ended their turn${lostGrowth > 0 ? ` and let ${lostGrowth} growth point${lostGrowth === 1 ? "" : "s"} wither` : ""}.` + ); + advanceTurn(); +} + +function bankGrowthAndEndTurn() { + const player = getCurrentPlayer(); + if (player.growthPoints <= 0) { + endTurn(); + return; + } + + player.bankedPoints += player.growthPoints; + state.history.unshift( + `${player.name} banked ${player.growthPoints} growth point${player.growthPoints === 1 ? "" : "s"} for next round.` + ); + player.growthPoints = 0; + state.turnMoves = []; + player.passed = true; + advanceTurn(); +} + +async function endRound() { + const { scores, columnResults, energySimulation } = scoreColumns(); + const { nextGrowth, event: sunbeamEvent, awardedPlayer } = maybeRollSunbeam(scores); + const { killedKeys, event: diseaseEvent } = maybeRollDisease(); + const token = ++roundAnimationToken; + + state.animation = buildRoundAnimation(energySimulation, awardedPlayer, killedKeys); + render(); + + await wait(ROUND_ANIMATION_SUN_MS); + if (token !== roundAnimationToken) { + return; + } + + state.animation = { ...state.animation!, phase: "branches" }; + render(); + + await wait(ROUND_ANIMATION_BRANCH_MS); + if (token !== roundAnimationToken) { + return; + } + + if (awardedPlayer !== null) { + state.animation = { ...state.animation!, phase: "bonus" }; + render(); + + await wait(ROUND_ANIMATION_BONUS_MS); + if (token !== roundAnimationToken) { + return; + } + } + + state.animation = { ...state.animation!, phase: "events" }; + render(); + + await wait(900); + if (token !== roundAnimationToken) { + return; + } + + applyDisease(killedKeys); + + state.players.forEach((player, index) => { + player.roundScore = scores[index]; + player.totalScore += scores[index]; + player.bonusPoints = nextGrowth[index] - scores[index]; + player.growthPoints = nextGrowth[index] + player.bankedPoints; + player.bankedPoints = 0; + player.passed = false; + }); + + state.roundSummary = { + scores, + columnResults, + event: [sunbeamEvent, diseaseEvent].filter(Boolean).join(" "), + }; + + state.history.unshift( + `Round ${state.round} scored. ${state.players.map((player) => `${player.name}: ${player.roundScore}`).join(" | ")}` + ); + [sunbeamEvent, diseaseEvent].filter(Boolean).forEach((eventText) => { + state.history.unshift(eventText as string); + }); + + state.animation = null; + + const boardFull = state.nodes.size >= state.config.columns * state.config.rows || state.players.every((player) => !playerHasLegalMove(player)); + if (boardFull) { + state.gameOver = true; + state.history.unshift("The canopy is complete. Final totals are locked."); + render(); + return; + } + + state.round += 1; + state.history.unshift(`Round ${state.round} begins.`); + moveToFirstPlayableTurn(); + render(); +} + +function resetGame() { + roundAnimationToken += 1; + state = createInitialState(setup); + moveToFirstPlayableTurn(); + render(); +} + +function openNewGameModal() { + isNewGameModalOpen = true; + render(); +} + +function closeNewGameModal() { + isNewGameModalOpen = false; + render(); +} + +function startNewGameFromModal() { + isNewGameModalOpen = false; + resetGame(); +} + +function finishGameNow() { + if (state.gameOver) { + return; + } + + roundAnimationToken += 1; + state.animation = null; + const { scores, columnResults } = scoreColumns(); + state.players.forEach((player, index) => { + player.roundScore = scores[index]; + player.totalScore += scores[index]; + }); + state.roundSummary = { scores, columnResults, event: null }; + state.gameOver = true; + state.history.unshift("Game ended manually. Final scores tallied from the current canopy."); + render(); +} + +function getTargetForCell(row: number, column: number) { + return state.availableTargets.find((target) => target.row === row && target.column === column) ?? null; +} + +function isPendingTurnNode(row: number, column: number) { + const nodeKey = keyFor(row, column); + return state.turnMoves.some((move) => getMoveUndoKeys(move).includes(nodeKey)); +} + +function moveSetupPlayer(fromIndex: number, toIndex: number) { + if (toIndex < 0 || toIndex >= setup.playerCount) { + return; + } + + [setup.paletteOrder[fromIndex], setup.paletteOrder[toIndex]] = [setup.paletteOrder[toIndex], setup.paletteOrder[fromIndex]]; + [setup.seedInputs[fromIndex], setup.seedInputs[toIndex]] = [setup.seedInputs[toIndex], setup.seedInputs[fromIndex]]; + render(); +} + +function randomizeStartingLocations() { + setup.seedInputs = createRandomizedSeedInputs(setup.playerCount, setup.columns, setup.startingNodesPerPlayer); + render(); +} + +function renderNewGameModal() { + if (!isNewGameModalOpen) { + return ""; + } + + const maxSeeds = getMaxStartingNodesPerPlayer(setup.playerCount, setup.columns); + const previewPlayers = createPlayers(setup.playerCount, setup.paletteOrder); + + return ` + + `; +} + +function renderScoreboard() { + return state.players.map((player, index) => { + const isActive = index === state.activePlayerId && !state.gameOver; + const previous = previousScoreSnapshot?.[index]; + const totalChanged = previous && previous.totalScore !== player.totalScore; + const sunlightChanged = previous && previous.roundScore !== player.roundScore; + const growthChanged = previous && previous.growthPoints !== player.growthPoints; + const bankChanged = previous && previous.bankedPoints !== player.bankedPoints; + return ` +
+
+ +

${player.name}

+
+
+
+ Total + ${player.totalScore} +
+
+ Sunlight + ${player.roundScore} +
+
+ Energy + ${player.growthPoints} +
+
+ Bank + ${player.bankedPoints} +
+
+
+ `; + }).join(""); +} + +function renderAnimationOverlay(columns: number, rows: number) { + if (!state.animation) { + return ""; + } + + const showSunlightWave = state.animation.phase === "sunlight"; + const showBranchFlow = state.animation.phase !== "sunlight"; + + const sunlightCells = showSunlightWave ? state.animation.columns.flatMap((columnState) => { + const flashColor = columnState.ownerId === null ? "rgba(255, 241, 186, 0.9)" : state.players[columnState.ownerId].color; + const verticalCells = Array.from({ length: columnState.terminalRow + 1 }, (_, row) => ({ row, column: columnState.column })); + + return verticalCells.map((node, index) => { + const left = (node.column / columns) * 100; + const top = (node.row / rows) * 100; + const width = (1 / columns) * 100; + const height = (1 / rows) * 100; + return `
`; + }); + }).join("") : ""; + + const flashes = showBranchFlow ? state.animation.traces.flatMap((trace) => { + const player = state.players[trace.playerId]; + const nodes = trace.branchNodes; + + return nodes.map((node, index) => { + const left = (node.column / columns) * 100; + const top = (node.row / rows) * 100; + const width = (1 / columns) * 100; + const height = (1 / rows) * 100; + return ` +
+ `; + }); + }).join("") : ""; + + const roots = state.animation.rootBursts.map((burst) => { + const player = state.players[burst.playerId]; + const root = parseKey(burst.key); + const x = ((root.column + 0.5) / columns) * 100; + const y = ((root.row + 0.5) / rows) * 100; + return `+${burst.count}`; + }).join(""); + + const disease = state.animation.diseaseKeys.map((key, index) => { + const node = parseKey(key); + const x = ((node.column + 0.5) / columns) * 100; + const y = ((node.row + 0.5) / rows) * 100; + return ``; + }).join(""); + + const bonusSunbeam = !state.animation.bonusTrace + ? "" + : ` +
+ + + + +
+ `; + + const bonusFlashes = !state.animation.bonusTrace + ? "" + : state.animation.bonusTrace.branchNodes.map((node, index) => { + const left = (node.column / columns) * 100; + const top = (node.row / rows) * 100; + const width = (1 / columns) * 100; + const height = (1 / rows) * 100; + return `
`; + }).join(""); + + const sunbeam = state.animation.bonusBurst === null || state.animation.bonusBurst === undefined + ? "" + : (() => { + const root = parseKey(state.animation?.bonusBurst?.key as string); + const x = ((root.column + 0.5) / columns) * 100; + const y = ((root.row + 0.5) / rows) * 100; + return `+1`; + })(); + + return ` + + + + `; +} + +function renderBoard() { + const columns = state.config.columns; + const rows = state.config.rows; + const columnLeaders = getColumnLeaders(); + const parentMap = buildParentMap(); + const lines = state.edges.map((edge) => { + const player = state.players[edge.ownerId]; + const x1 = ((edge.from.column + 0.5) / columns) * 100; + const y1 = ((edge.from.row + 0.5) / rows) * 100; + const x2 = ((edge.to.column + 0.5) / columns) * 100; + const y2 = ((edge.to.row + 0.5) / rows) * 100; + + return ``; + }).join(""); + + const cells = Array.from({ length: rows }, (_, row) => { + return Array.from({ length: columns }, (_, column) => { + const ownerId = getNodeOwner(row, column); + const player = ownerId === null ? null : state.players[ownerId]; + const target = getTargetForCell(row, column); + const pending = isPendingTurnNode(row, column); + const nodeKey = keyFor(row, column); + const isRoot = ownerId !== null && row === rows - 1 && !parentMap.has(nodeKey); + const columnLeader = columnLeaders[column]; + const background = columnLeader.ownerId === null || columnLeader.tied + ? "transparent" + : tint(state.players[columnLeader.ownerId].color); + + return ` + + `; + }).join(""); + }).join(""); + + return ` +
+
+ + ${cells} + ${renderAnimationOverlay(columns, rows)} +
+
+ `; +} + +function renderSidebar() { + const player = getCurrentPlayer(); + const rootShiftMoves = getSelectedRootShiftMoves(); + const boardLocked = isInteractionLocked(); + const nextGrowthText = state.roundSummary + ? state.players.map((current) => `${current.name}: ${current.growthPoints}`).join(" | ") + : "Next round growth = 1 + columns owned + any banked growth."; + + return ` + + `; +} + +function attachEvents() { + document.querySelectorAll(".cell").forEach((cell) => { + cell.addEventListener("click", () => { + if (isInteractionLocked()) { + return; + } + + const row = Number(cell.dataset.row); + const column = Number(cell.dataset.column); + const currentPlayer = getCurrentPlayer(); + const ownerId = getNodeOwner(row, column); + const target = getTargetForCell(row, column); + const nodeKey = keyFor(row, column); + + if (target) { + growTo(target); + return; + } + + if (ownerId === currentPlayer.id && undoMovesThrough(nodeKey)) { + return; + } + + if (ownerId === currentPlayer.id) { + const sourceKey = nodeKey; + if (state.selectedSource === sourceKey) { + updateSelection(null); + } else { + updateSelection(sourceKey); + } + render(); + } + }); + }); + + document.querySelector("#new-game")?.addEventListener("click", openNewGameModal); + document.querySelector("#close-new-game")?.addEventListener("click", closeNewGameModal); + document.querySelector("#cancel-new-game")?.addEventListener("click", closeNewGameModal); + document.querySelector("#start-new-game")?.addEventListener("click", startNewGameFromModal); + document.querySelector("#new-game-modal-backdrop")?.addEventListener("click", (event) => { + if ((event.target as HTMLElement).id === "new-game-modal-backdrop") { + closeNewGameModal(); + } + }); + document.querySelector("#end-turn")?.addEventListener("click", endTurn); + document.querySelector("#bank-turn")?.addEventListener("click", bankGrowthAndEndTurn); + document.querySelector("#finish-game")?.addEventListener("click", finishGameNow); + document.querySelector("#player-count")?.addEventListener("input", (event) => { + const input = event.currentTarget as HTMLInputElement; + const output = input.parentElement?.querySelector("strong"); + if (output) { + output.textContent = input.value; + } + setup = createSetupState(Number(input.value), setup.columns, setup.rows, setup.startingNodesPerPlayer, setup.sunbeamChance, setup.diseaseChance, setup.seedInputs, setup.paletteOrder, setup.shuffleTurnOrder); + render(); + }); + document.querySelector("#column-count")?.addEventListener("change", (event) => { + const columns = Number((event.currentTarget as HTMLInputElement).value); + if (!Number.isInteger(columns)) { + return; + } + + setup = createSetupState(setup.playerCount, Math.max(6, Math.min(24, columns)), setup.rows, setup.startingNodesPerPlayer, setup.sunbeamChance, setup.diseaseChance, setup.seedInputs, setup.paletteOrder, setup.shuffleTurnOrder); + render(); + }); + document.querySelector("#row-count")?.addEventListener("change", (event) => { + const rows = Number((event.currentTarget as HTMLInputElement).value); + if (!Number.isInteger(rows)) { + return; + } + + setup = createSetupState(setup.playerCount, setup.columns, Math.max(6, Math.min(24, rows)), setup.startingNodesPerPlayer, setup.sunbeamChance, setup.diseaseChance, setup.seedInputs, setup.paletteOrder, setup.shuffleTurnOrder); + render(); + }); + document.querySelector("#starting-nodes")?.addEventListener("change", (event) => { + const nextValue = Number((event.currentTarget as HTMLInputElement).value); + if (!Number.isInteger(nextValue)) { + return; + } + + setup = createSetupState(setup.playerCount, setup.columns, setup.rows, Math.max(1, nextValue), setup.sunbeamChance, setup.diseaseChance, setup.seedInputs, setup.paletteOrder, setup.shuffleTurnOrder); + render(); + }); + document.querySelector("#sunbeam-chance")?.addEventListener("change", (event) => { + setup.sunbeamChance = Math.max(0, Math.min(100, Number((event.currentTarget as HTMLInputElement).value) || 0)); + state.randomEffects.sunbeamChance = setup.sunbeamChance; + }); + document.querySelector("#disease-chance")?.addEventListener("change", (event) => { + setup.diseaseChance = Math.max(0, Math.min(100, Number((event.currentTarget as HTMLInputElement).value) || 0)); + state.randomEffects.diseaseChance = setup.diseaseChance; + }); + document.querySelector("#shuffle-order-toggle")?.addEventListener("change", (event) => { + setup.shuffleTurnOrder = (event.currentTarget as HTMLInputElement).checked; + }); + document.querySelectorAll(".seed-input").forEach((input) => { + input.addEventListener("input", (event) => { + const target = event.currentTarget as HTMLInputElement; + const playerId = Number(target.dataset.playerId); + setup.seedInputs[playerId] = target.value; + }); + }); + document.querySelector("#randomize-starting-locations")?.addEventListener("click", randomizeStartingLocations); + document.querySelectorAll("[data-move-player]").forEach((button) => { + button.addEventListener("click", () => { + const fromIndex = Number(button.dataset.movePlayer); + const direction = button.dataset.direction === "up" ? -1 : 1; + moveSetupPlayer(fromIndex, fromIndex + direction); + }); + }); + document.querySelectorAll("[data-root-shift]").forEach((button) => { + button.addEventListener("click", () => { + shiftSelectedRoot(Number(button.dataset.rootShift)); + }); + }); +} + +function render() { + app.innerHTML = ` +
+
+ ${renderBoard()} + ${renderSidebar()} +
+
+ ${renderScoreboard()} +
+
+ ${renderNewGameModal()} + `; + + attachEvents(); + previousScoreSnapshot = getScoreSnapshot(); +} + +render(); diff --git a/src/rules-board.ts b/src/rules-board.ts new file mode 100644 index 0000000..1ec1de8 --- /dev/null +++ b/src/rules-board.ts @@ -0,0 +1,173 @@ +import { ROOT_SHIFT_COST } from "./constants"; +import type { ColumnLeader, GameState, GrowTarget, NodeKey, Player, PlayerId, ShiftMove } from "./types"; +import { keyFor, parseKey } from "./utils"; + +export function getNodeOwner(state: GameState, row: number, column: number): PlayerId | null { + return state.nodes.get(keyFor(row, column))?.ownerId ?? null; +} + +export function buildParentMap(state: GameState) { + return new Map(state.edges.map((edge) => [keyFor(edge.to.row, edge.to.column), keyFor(edge.from.row, edge.from.column)])); +} + +export function buildChildrenMap(state: GameState, ownerId: PlayerId | null = null) { + const childrenMap = new Map(); + + state.edges.forEach((edge) => { + if (ownerId !== null && edge.ownerId !== ownerId) { + return; + } + + const fromKey = keyFor(edge.from.row, edge.from.column); + const target = keyFor(edge.to.row, edge.to.column); + const entry = childrenMap.get(fromKey) ?? []; + entry.push(target); + childrenMap.set(fromKey, entry); + }); + + return childrenMap; +} + +export function collectSubtreeKeys(state: GameState, rootKey: NodeKey, player: Player) { + const childrenMap = buildChildrenMap(state, player.id); + const subtree = new Set([rootKey]); + const queue: NodeKey[] = [rootKey]; + + while (queue.length > 0) { + const current = queue.shift(); + if (!current) { + continue; + } + + (childrenMap.get(current) ?? []).forEach((childKey) => { + if (!subtree.has(childKey)) { + subtree.add(childKey); + queue.push(childKey); + } + }); + } + + return subtree; +} + +export function getRootShiftMove(state: GameState, sourceKey: NodeKey, delta: number, player: Player): ShiftMove | null { + if (state.round !== 1 || player.growthPoints < ROOT_SHIFT_COST) { + return null; + } + + const parentMap = buildParentMap(state); + const source = parseKey(sourceKey); + if (source.row !== state.config.rows - 1 || state.nodes.get(sourceKey)?.ownerId !== player.id || parentMap.has(sourceKey)) { + return null; + } + + const subtree = collectSubtreeKeys(state, sourceKey, player); + const movedNodes: ShiftMove["movedNodes"] = []; + + for (const nodeKey of subtree) { + const node = parseKey(nodeKey); + const targetColumn = node.column + delta; + if (targetColumn < 0 || targetColumn >= state.config.columns) { + return null; + } + + const targetKey = keyFor(node.row, targetColumn); + if (!subtree.has(targetKey) && state.nodes.has(targetKey)) { + return null; + } + + movedNodes.push({ fromKey: nodeKey, toKey: targetKey, row: node.row, fromColumn: node.column, toColumn: targetColumn }); + } + + const movedEdges = state.edges + .filter((edge) => subtree.has(keyFor(edge.from.row, edge.from.column)) && subtree.has(keyFor(edge.to.row, edge.to.column))) + .map((edge) => ({ + before: { + from: { ...edge.from }, + to: { ...edge.to }, + ownerId: edge.ownerId, + }, + after: { + from: { row: edge.from.row, column: edge.from.column + delta }, + to: { row: edge.to.row, column: edge.to.column + delta }, + ownerId: edge.ownerId, + }, + })); + + return { + type: "shift", + cost: ROOT_SHIFT_COST, + direction: delta < 0 ? "left" : "right", + movedNodes, + movedEdges, + undoKeys: movedNodes.map((node) => node.toKey), + selectKey: keyFor(source.row, source.column + delta), + }; +} + +export function playerHasRootShiftMove(state: GameState, player: Player) { + if (state.round !== 1 || player.growthPoints < ROOT_SHIFT_COST) { + return false; + } + + return Array.from(state.nodes.entries()).some(([nodeKey, node]) => { + if (node.ownerId !== player.id) { + return false; + } + + return Boolean(getRootShiftMove(state, nodeKey, -1, player) || getRootShiftMove(state, nodeKey, 1, player)); + }); +} + +export function getLegalMovesForSource(state: GameState, sourceKey: NodeKey, player: Player): GrowTarget[] { + const columns = state.config.columns; + const { row, column } = parseKey(sourceKey); + if (player.id !== getNodeOwner(state, row, column) || row === 0) { + return []; + } + + const moves: GrowTarget[] = [ + { row: row - 1, column, cost: 1, direction: "vertical" }, + { row: row - 1, column: column - 1, cost: 2, direction: "left" }, + { row: row - 1, column: column + 1, cost: 2, direction: "right" }, + ]; + + return moves.filter((move) => { + if (move.column < 0 || move.column >= columns) { + return false; + } + + if (state.nodes.has(keyFor(move.row, move.column))) { + return false; + } + + return move.cost <= player.growthPoints; + }); +} + +export function playerHasLegalMove(state: GameState, player: Player) { + if (player.growthPoints <= 0) { + return false; + } + + return Array.from(state.nodes.entries()).some(([nodeKey, node]) => { + if (node.ownerId !== player.id) { + return false; + } + + return getLegalMovesForSource(state, nodeKey, player).length > 0; + }) || playerHasRootShiftMove(state, player); +} + +export function getColumnLeaders(state: GameState): ColumnLeader[] { + return Array.from({ length: state.config.columns }, (_, column) => { + for (let row = 0; row < state.config.rows; row += 1) { + const owner = getNodeOwner(state, row, column); + if (owner !== null) { + return { ownerId: owner, row, tied: false }; + } + } + + return { ownerId: null, row: null, tied: false }; + }); +} diff --git a/src/rules-scoring.ts b/src/rules-scoring.ts new file mode 100644 index 0000000..1d7b63d --- /dev/null +++ b/src/rules-scoring.ts @@ -0,0 +1,179 @@ +import type { EnergySimulation, GameState, NodeKey, PlayerId, RootBurst, RoundAnimation } from "./types"; +import { keyFor, parseKey, shuffleArray } from "./utils"; +import { buildChildrenMap, buildParentMap } from "./rules-board"; + +export function buildEnergySimulation(state: GameState): EnergySimulation { + const parentMap = buildParentMap(state); + const columns = []; + const scores = state.players.map(() => 0); + + for (let column = 0; column < state.config.columns; column += 1) { + let hitNodeKey: NodeKey | null = null; + + for (let row = 0; row < state.config.rows; row += 1) { + const nodeKey = keyFor(row, column); + if (state.nodes.has(nodeKey)) { + hitNodeKey = nodeKey; + break; + } + } + + if (!hitNodeKey) { + columns.push({ + column, + terminalRow: state.config.rows - 1, + intercepted: false, + ownerId: null, + hitNode: null, + rootKey: null, + branchNodes: [], + branchEdges: [], + }); + continue; + } + + const hitNode = parseKey(hitNodeKey); + const ownerId = state.nodes.get(hitNodeKey)?.ownerId as PlayerId; + const branchNodes = [hitNode]; + const branchEdges = []; + let cursor = hitNodeKey; + + while (parentMap.has(cursor)) { + const parentKey = parentMap.get(cursor) as NodeKey; + branchEdges.push({ from: parseKey(cursor), to: parseKey(parentKey) }); + branchNodes.push(parseKey(parentKey)); + cursor = parentKey; + } + + scores[ownerId] += 1; + columns.push({ + column, + terminalRow: hitNode.row, + intercepted: true, + ownerId, + hitNode, + rootKey: cursor, + branchNodes, + branchEdges, + }); + } + + const rootBurstMap = columns.reduce((map, column) => { + if (!column.intercepted || !column.rootKey) { + return map; + } + + const entry = map.get(column.rootKey) ?? { key: column.rootKey, playerId: column.ownerId as PlayerId, count: 0 }; + entry.count += 1; + map.set(column.rootKey, entry); + return map; + }, new Map()); + const rootBursts: RootBurst[] = [...rootBurstMap.values()]; + + return { + scores, + columns, + rootBursts, + }; +} + +export function buildRoundAnimation( + state: GameState, + energySimulation: EnergySimulation, + sunbeamPlayerId: PlayerId | null, + diseaseKeys: NodeKey[], +): RoundAnimation { + const traces = energySimulation.columns + .filter((column) => column.intercepted) + .map((column) => ({ + playerId: column.ownerId as PlayerId, + verticalCells: Array.from({ length: column.terminalRow + 1 }, (_, row) => ({ row, column: column.column })), + ray: { + x: ((column.column + 0.5) / state.config.columns) * 100, + y: ((column.terminalRow + 0.5) / state.config.rows) * 100, + }, + rootKey: column.rootKey, + branchNodes: column.branchNodes, + })); + + const bonusTrace = sunbeamPlayerId === null ? null : traces.find((trace) => trace.playerId === sunbeamPlayerId) ?? null; + const bonusBurst = bonusTrace ? energySimulation.rootBursts.find((burst) => burst.key === bonusTrace.rootKey) ?? null : null; + + return { + phase: "sunlight", + columns: energySimulation.columns, + traces, + rootBursts: energySimulation.rootBursts, + sunbeamPlayerId, + bonusTrace, + bonusBurst, + diseaseKeys, + }; +} + +export function scoreColumns(state: GameState) { + const energySimulation = buildEnergySimulation(state); + const columnResults = energySimulation.columns.map((column) => ({ + column: column.column, + ownerId: column.ownerId, + topRow: column.intercepted ? column.terminalRow : null, + tied: false, + })); + + return { scores: energySimulation.scores, columnResults, energySimulation }; +} + +export function maybeRollSunbeam(state: GameState, scores: number[]) { + const nextGrowth = scores.map((score) => score + 1); + const { sunbeamChance } = state.randomEffects; + + if (sunbeamChance <= 0 || Math.random() * 100 >= sunbeamChance) { + return { + nextGrowth, + event: null, + awardedPlayer: null, + }; + } + + const awardedPlayer = Math.floor(Math.random() * state.players.length); + nextGrowth[awardedPlayer] += 1; + + return { + nextGrowth, + awardedPlayer, + event: `${state.players[awardedPlayer].name} caught a stray sunbeam and gains +1 growth next round.`, + }; +} + +export function maybeRollDisease(state: GameState) { + const { diseaseChance } = state.randomEffects; + if (diseaseChance <= 0 || Math.random() * 100 >= diseaseChance) { + return { + killedKeys: [], + event: null, + }; + } + + const childrenMap = buildChildrenMap(state); + const parentMap = buildParentMap(state); + const twigKeys = Array.from(state.nodes.keys()).filter((nodeKey) => { + const { row } = parseKey(nodeKey); + return row !== state.config.rows - 1 && !childrenMap.has(nodeKey) && parentMap.has(nodeKey); + }); + + if (twigKeys.length === 0) { + return { + killedKeys: [], + event: null, + }; + } + + const shuffled = shuffleArray(twigKeys); + const killCount = Math.min(shuffled.length, 1 + Math.floor(Math.random() * Math.min(3, shuffled.length))); + const killedKeys = shuffled.slice(0, killCount); + + return { + killedKeys, + event: `Disease spread through ${killCount} twig node${killCount === 1 ? "" : "s"} before the next round.`, + }; +} diff --git a/src/state.ts b/src/state.ts new file mode 100644 index 0000000..8fa075c --- /dev/null +++ b/src/state.ts @@ -0,0 +1,194 @@ +import { PLAYER_PALETTE, STARTING_POINTS } from "./constants"; +import type { GameState, Player, SetupState } from "./types"; +import { keyFor, shuffleArray } from "./utils"; + +export function createDefaultPaletteOrder(playerCount: number) { + return Array.from({ length: playerCount }, (_, index) => index % PLAYER_PALETTE.length); +} + +export function createDefaultSeedInputs(playerCount: number, columns: number, startingNodesPerPlayer: number) { + const totalSeeds = playerCount * startingNodesPerPlayer; + const positions = Array.from({ length: totalSeeds }, (_, index) => Math.floor(((index + 0.5) * columns) / totalSeeds)); + + return Array.from({ length: playerCount }, (_, playerId) => { + const start = playerId * startingNodesPerPlayer; + return positions + .slice(start, start + startingNodesPerPlayer) + .map((column) => String(column + 1)) + .join(", "); + }); +} + +function pickNearestOpenColumn(preferredColumn: number, columns: number, usedColumns: Set) { + if (!usedColumns.has(preferredColumn)) { + return preferredColumn; + } + + for (let distance = 1; distance < columns; distance += 1) { + const left = preferredColumn - distance; + if (left >= 0 && !usedColumns.has(left)) { + return left; + } + + const right = preferredColumn + distance; + if (right < columns && !usedColumns.has(right)) { + return right; + } + } + + return preferredColumn; +} + +export function createRandomizedSeedInputs(playerCount: number, columns: number, startingNodesPerPlayer: number) { + const zoneWidth = columns / playerCount; + const usedColumns = new Set(); + + return Array.from({ length: playerCount }, (_, playerId) => { + const picks: number[] = []; + + for (let seedIndex = 0; seedIndex < startingNodesPerPlayer; seedIndex += 1) { + const localRatio = (seedIndex + 1) / (startingNodesPerPlayer + 1); + const center = (playerId + localRatio) * zoneWidth; + const subZoneWidth = zoneWidth / (startingNodesPerPlayer + 1); + const maxJitter = Math.max(0.35, Math.min(zoneWidth * 0.22, subZoneWidth * 0.42)); + const jitter = (Math.random() * 2 - 1) * maxJitter; + const preferredColumn = Math.max(0, Math.min(columns - 1, Math.round(center + jitter - 0.5))); + const chosenColumn = pickNearestOpenColumn(preferredColumn, columns, usedColumns); + + usedColumns.add(chosenColumn); + picks.push(chosenColumn + 1); + } + + return picks.sort((left, right) => left - right).join(", "); + }); +} + +export function getMaxStartingNodesPerPlayer(playerCount: number, columns: number) { + return Math.max(1, Math.floor(columns / playerCount)); +} + +export function createSetupState( + playerCount = 3, + columns = 18, + rows = 16, + startingNodesPerPlayer = 1, + sunbeamChance = 0, + diseaseChance = 0, + seedInputs: string[] | null = null, + paletteOrder: number[] | null = null, + shuffleTurnOrder = true, +): SetupState { + const clampedSeeds = Math.min(startingNodesPerPlayer, getMaxStartingNodesPerPlayer(playerCount, columns)); + const defaults = createDefaultSeedInputs(playerCount, columns, clampedSeeds); + const paletteDefaults = createDefaultPaletteOrder(playerCount); + + return { + playerCount, + columns, + rows, + startingNodesPerPlayer: clampedSeeds, + sunbeamChance, + diseaseChance, + seedInputs: Array.from({ length: playerCount }, (_, index) => seedInputs?.[index] ?? defaults[index]), + paletteOrder: Array.from({ length: playerCount }, (_, index) => paletteOrder?.[index] ?? paletteDefaults[index]), + shuffleTurnOrder, + }; +} + +export function createPlayers(playerCount: number, paletteOrder = createDefaultPaletteOrder(playerCount)): Player[] { + return Array.from({ length: playerCount }, (_, index) => { + const palette = PLAYER_PALETTE[paletteOrder[index] % PLAYER_PALETTE.length]; + return { + id: index, + name: `Player ${index + 1}`, + color: palette.primary, + glow: palette.glow, + totalScore: 0, + roundScore: 0, + growthPoints: STARTING_POINTS, + bankedPoints: 0, + bonusPoints: 0, + passed: false, + }; + }); +} + +export function normalizeSeedInputs(setup: SetupState) { + const assigned = new Set(); + const fallback = createDefaultSeedInputs(setup.playerCount, setup.columns, setup.startingNodesPerPlayer) + .map((input) => input.split(",").map((part) => Number(part.trim()) - 1)); + + return Array.from({ length: setup.playerCount }, (_, playerId) => { + const requested = (setup.seedInputs[playerId] ?? "") + .split(",") + .map((part) => Number.parseInt(part.trim(), 10) - 1) + .filter((column) => Number.isInteger(column) && column >= 0 && column < setup.columns); + + const uniqueColumns: number[] = []; + requested.forEach((column) => { + if (!assigned.has(column) && uniqueColumns.length < setup.startingNodesPerPlayer) { + assigned.add(column); + uniqueColumns.push(column); + } + }); + + fallback[playerId].forEach((column) => { + if (!assigned.has(column) && uniqueColumns.length < setup.startingNodesPerPlayer) { + assigned.add(column); + uniqueColumns.push(column); + } + }); + + for (let column = 0; column < setup.columns && uniqueColumns.length < setup.startingNodesPerPlayer; column += 1) { + if (!assigned.has(column)) { + assigned.add(column); + uniqueColumns.push(column); + } + } + + return uniqueColumns; + }); +} + +export function createInitialState(setup: SetupState): GameState { + const playerPaletteOrder = setup.shuffleTurnOrder ? shuffleArray(setup.paletteOrder) : [...setup.paletteOrder]; + const players = createPlayers(setup.playerCount, playerPaletteOrder); + const nodes = new Map(); + const edges = []; + const seedColumnsByPlayer = normalizeSeedInputs(setup); + + seedColumnsByPlayer.forEach((seedColumns, index) => { + seedColumns.forEach((column) => { + nodes.set(keyFor(setup.rows - 1, column), { ownerId: index }); + }); + }); + + return { + config: { + columns: setup.columns, + rows: setup.rows, + playerCount: setup.playerCount, + startingNodesPerPlayer: setup.startingNodesPerPlayer, + playerPaletteOrder, + }, + players, + nodes, + edges, + round: 1, + activePlayerId: 0, + turnMoves: [], + selectedSource: null, + availableTargets: [], + animation: null, + randomEffects: { + sunbeamChance: setup.sunbeamChance, + diseaseChance: setup.diseaseChance, + }, + gameOver: false, + history: [ + `Round 1 begins on a ${setup.columns}x${setup.rows} board with ${setup.startingNodesPerPlayer} starting node${setup.startingNodesPerPlayer === 1 ? "" : "s"} each.`, + `${setup.shuffleTurnOrder ? "Turn order was randomized for this game." : "Turn order uses the setup order."}`, + ], + roundSummary: null, + }; +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..8f93e42 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,184 @@ +export type PlayerId = number; +export type NodeKey = `${number}:${number}`; + +export type PlayerPalette = { + name: string; + primary: string; + glow: string; +}; + +export type Position = { + row: number; + column: number; +}; + +export type SetupState = { + playerCount: number; + columns: number; + rows: number; + startingNodesPerPlayer: number; + sunbeamChance: number; + diseaseChance: number; + seedInputs: string[]; + paletteOrder: number[]; + shuffleTurnOrder: boolean; +}; + +export type Player = { + id: PlayerId; + name: string; + color: string; + glow: string; + totalScore: number; + roundScore: number; + growthPoints: number; + bankedPoints: number; + bonusPoints: number; + passed: boolean; +}; + +export type GameConfig = { + columns: number; + rows: number; + playerCount: number; + startingNodesPerPlayer: number; + playerPaletteOrder: number[]; +}; + +export type NodeState = { + ownerId: PlayerId; +}; + +export type Edge = { + from: Position; + to: Position; + ownerId: PlayerId; +}; + +export type GrowDirection = "vertical" | "left" | "right"; + +export type GrowTarget = Position & { + cost: number; + direction: GrowDirection; +}; + +export type ShiftMove = { + type: "shift"; + cost: number; + direction: "left" | "right"; + movedNodes: Array<{ + fromKey: NodeKey; + toKey: NodeKey; + row: number; + fromColumn: number; + toColumn: number; + }>; + movedEdges: Array<{ + before: Edge; + after: Edge; + }>; + undoKeys: NodeKey[]; + selectKey: NodeKey; +}; + +export type GrowMove = { + type: "grow"; + from: Position; + to: Position; + cost: number; + targetKey: NodeKey; + undoKeys: NodeKey[]; + selectKey: NodeKey; +}; + +export type TurnMove = GrowMove | ShiftMove; + +export type ColumnEnergy = { + column: number; + terminalRow: number; + intercepted: boolean; + ownerId: PlayerId | null; + hitNode: Position | null; + rootKey: NodeKey | null; + branchNodes: Position[]; + branchEdges: Array<{ from: Position; to: Position }>; +}; + +export type RootBurst = { + key: NodeKey; + playerId: PlayerId; + count: number; +}; + +export type EnergySimulation = { + scores: number[]; + columns: ColumnEnergy[]; + rootBursts: RootBurst[]; +}; + +export type RoundAnimationTrace = { + playerId: PlayerId; + verticalCells: Position[]; + ray: { x: number; y: number }; + rootKey: NodeKey | null; + branchNodes: Position[]; +}; + +export type RoundAnimation = { + phase: "sunlight" | "branches" | "bonus" | "events"; + columns: ColumnEnergy[]; + traces: RoundAnimationTrace[]; + rootBursts: RootBurst[]; + sunbeamPlayerId: PlayerId | null; + bonusTrace: RoundAnimationTrace | null; + bonusBurst: RootBurst | null; + diseaseKeys: NodeKey[]; +}; + +export type ColumnResult = { + column: number; + ownerId: PlayerId | null; + topRow: number | null; + tied: boolean; +}; + +export type RoundSummary = { + scores: number[]; + columnResults: ColumnResult[]; + event: string | null; +}; + +export type ScoreSnapshot = { + totalScore: number; + roundScore: number; + growthPoints: number; + bankedPoints: number; +}; + +export type ColumnLeader = { + ownerId: PlayerId | null; + row: number | null; + tied: boolean; +}; + +export type RandomEffects = { + sunbeamChance: number; + diseaseChance: number; +}; + +export type GameState = { + config: GameConfig; + players: Player[]; + nodes: Map; + edges: Edge[]; + round: number; + activePlayerId: PlayerId; + turnMoves: TurnMove[]; + selectedSource: NodeKey | null; + availableTargets: GrowTarget[]; + animation: RoundAnimation | null; + randomEffects: RandomEffects; + gameOver: boolean; + history: string[]; + roundSummary: RoundSummary | null; +}; diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..c53ce37 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,41 @@ +import type { NodeKey, Position } from "./types"; + +export function keyFor(row: number, column: number): NodeKey { + return `${row}:${column}`; +} + +export function parseKey(key: string): Position { + const [row, column] = key.split(":").map(Number); + return { row, column }; +} + +export function hexToRgb(hex: string) { + const value = hex.replace("#", ""); + const normalized = value.length === 3 ? value.split("").map((part) => part + part).join("") : value; + const int = Number.parseInt(normalized, 16); + return { + r: (int >> 16) & 255, + g: (int >> 8) & 255, + b: int & 255, + }; +} + +export function tint(hex: string, alpha = 0.16) { + const { r, g, b } = hexToRgb(hex); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; +} + +export function wait(milliseconds: number) { + return new Promise((resolve) => window.setTimeout(resolve, milliseconds)); +} + +export function shuffleArray(items: T[]) { + const next = [...items]; + + for (let index = next.length - 1; index > 0; index -= 1) { + const swapIndex = Math.floor(Math.random() * (index + 1)); + [next[index], next[swapIndex]] = [next[swapIndex], next[index]]; + } + + return next; +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..cbe652d --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1 @@ +declare module "*.css"; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..725b6eb --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "allowJs": false, + "skipLibCheck": true, + "strict": false, + "noEmit": true, + "isolatedModules": true, + "esModuleInterop": true + }, + "include": ["src"] +}