1440 lines
51 KiB
TypeScript
1440 lines
51 KiB
TypeScript
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 {
|
|
createInitiativeDraft,
|
|
} from "./rules-initiative";
|
|
import {
|
|
createWeatherDraft,
|
|
getCurrentWeatherPlayerId,
|
|
getWeatherCard,
|
|
isWeatherCardAvailable,
|
|
} from "./rules-weather";
|
|
import {
|
|
createInitialState,
|
|
createPlayers,
|
|
createRandomizedSeedInputs,
|
|
createSetupState,
|
|
getMaxStartingNodesPerPlayer,
|
|
} from "./state";
|
|
import type {
|
|
GameState,
|
|
GrowTarget,
|
|
NodeKey,
|
|
Player,
|
|
ScoreSnapshot,
|
|
SetupState,
|
|
ShiftMove,
|
|
TurnMove,
|
|
WeatherCardId,
|
|
} 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 rebuildSetup(overrides: Partial<SetupState> = {}) {
|
|
setup = createSetupState(
|
|
overrides.playerCount ?? setup.playerCount,
|
|
overrides.columns ?? setup.columns,
|
|
overrides.rows ?? setup.rows,
|
|
overrides.startingNodesPerPlayer ?? setup.startingNodesPerPlayer,
|
|
overrides.sunbeamChance ?? setup.sunbeamChance,
|
|
overrides.diseaseChance ?? setup.diseaseChance,
|
|
overrides.seedInputs ?? setup.seedInputs,
|
|
overrides.paletteOrder ?? setup.paletteOrder,
|
|
overrides.initiativeMode ?? setup.initiativeMode,
|
|
overrides.biddingOrderRule ?? setup.biddingOrderRule,
|
|
overrides.weatherDraftEnabled ?? setup.weatherDraftEnabled,
|
|
overrides.winCondition ?? setup.winCondition,
|
|
overrides.maxRounds ?? setup.maxRounds,
|
|
overrides.topLeafTarget ?? setup.topLeafTarget,
|
|
);
|
|
}
|
|
|
|
function getLiveExposureScores() {
|
|
return buildEnergySimulation(state).scores;
|
|
}
|
|
|
|
function getTopLeafCount() {
|
|
const childrenMap = buildChildrenMap();
|
|
let count = 0;
|
|
|
|
state.nodes.forEach((_, nodeKey) => {
|
|
const { row } = parseKey(nodeKey);
|
|
if (row === 0 && !(childrenMap.get(nodeKey)?.length)) {
|
|
count += 1;
|
|
}
|
|
});
|
|
|
|
return count;
|
|
}
|
|
|
|
function hasReachedWinCondition() {
|
|
if (state.config.winCondition === "rounds") {
|
|
return state.round >= state.config.maxRounds;
|
|
}
|
|
|
|
return getTopLeafCount() >= state.config.topLeafTarget;
|
|
}
|
|
|
|
function getWinConditionSummary() {
|
|
if (state.config.winCondition === "rounds") {
|
|
return `Game ends after round ${state.config.maxRounds}.`;
|
|
}
|
|
|
|
return `Game ends when ${state.config.topLeafTarget} top-row leaf${state.config.topLeafTarget === 1 ? " is" : "s are"} occupied.`;
|
|
}
|
|
|
|
function getScoreSnapshot() {
|
|
const exposureScores = getLiveExposureScores();
|
|
return state.players.map((player, index) => ({
|
|
currentExposure: exposureScores[index],
|
|
growthPoints: player.growthPoints,
|
|
bankedPoints: player.bankedPoints,
|
|
lifetimeGrowthIncome: player.lifetimeGrowthIncome,
|
|
}));
|
|
}
|
|
|
|
function getCurrentPlayer() {
|
|
return state.players[state.activePlayerId];
|
|
}
|
|
|
|
function getOrderedPlayers(playerIds: number[]) {
|
|
return playerIds.map((playerId) => state.players[playerId]);
|
|
}
|
|
|
|
function getTurnLabel() {
|
|
if (state.phase === "initiative" && state.initiativeDraft) {
|
|
return `${getCurrentPlayer().name} drafts initiative`;
|
|
}
|
|
|
|
if (state.phase === "weather" && state.weatherDraft) {
|
|
return `${getCurrentPlayer().name} drafts weather`;
|
|
}
|
|
|
|
return state.gameOver ? "Game Over" : `${getCurrentPlayer().name}'s turn`;
|
|
}
|
|
|
|
function awardGrowth(player: Player, amount: number) {
|
|
if (amount <= 0) {
|
|
return;
|
|
}
|
|
|
|
player.growthPoints += amount;
|
|
player.lifetimeGrowthIncome += amount;
|
|
}
|
|
|
|
function getCurrentBiddingPlayer() {
|
|
if (!state.initiativeDraft) {
|
|
return null;
|
|
}
|
|
|
|
return state.players[state.initiativeDraft.biddingOrder[state.initiativeDraft.biddingIndex]];
|
|
}
|
|
|
|
function getCurrentWeatherDraftPlayer() {
|
|
if (!state.weatherDraft) {
|
|
return null;
|
|
}
|
|
|
|
return state.players[getCurrentWeatherPlayerId(state.weatherDraft)];
|
|
}
|
|
|
|
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 || state.phase !== "turn" || Boolean(state.animation);
|
|
}
|
|
|
|
function startWeatherDraft() {
|
|
if (!state.config.weatherDraftEnabled) {
|
|
moveToFirstPlayableTurn();
|
|
return;
|
|
}
|
|
|
|
state.phase = "weather";
|
|
state.turnMoves = [];
|
|
updateSelection(null);
|
|
state.weatherDraft = createWeatherDraft(state);
|
|
state.activePlayerId = state.weatherDraft.playerOrder[0];
|
|
state.history.unshift(`Round ${state.round} weather draft begins.`);
|
|
}
|
|
|
|
function startInitiativeDraft() {
|
|
state.phase = "initiative";
|
|
state.turnMoves = [];
|
|
updateSelection(null);
|
|
state.initiativeDraft = createInitiativeDraft(state);
|
|
state.activePlayerId = state.initiativeDraft.biddingOrder[0];
|
|
state.history.unshift(`Round ${state.round} initiative draft begins.`);
|
|
}
|
|
|
|
function moveToFirstPlayableTurn() {
|
|
const nextPlayerId = state.turnOrder.find((playerId) => playerHasLegalMove(state.players[playerId]));
|
|
|
|
if (nextPlayerId === undefined) {
|
|
state.gameOver = true;
|
|
state.phase = "game_over";
|
|
state.history.unshift("No player can grow further. Final totals are locked.");
|
|
return false;
|
|
}
|
|
|
|
state.phase = "turn";
|
|
state.activePlayerId = nextPlayerId;
|
|
state.turnMoves = [];
|
|
updateSelection(null);
|
|
return true;
|
|
}
|
|
|
|
function finalizeInitiativeDraft() {
|
|
const draft = state.initiativeDraft;
|
|
if (!draft) {
|
|
return;
|
|
}
|
|
|
|
const nextTurnOrder = draft.seatAssignments.filter((playerId): playerId is number => playerId !== null);
|
|
state.turnOrder = nextTurnOrder;
|
|
state.players.forEach((player) => {
|
|
player.passed = false;
|
|
});
|
|
|
|
nextTurnOrder.forEach((playerId, seatIndex) => {
|
|
const bonus = draft.seatBonuses[seatIndex] ?? 0;
|
|
if (bonus > 0) {
|
|
awardGrowth(state.players[playerId], bonus);
|
|
}
|
|
});
|
|
|
|
const seatSummary = nextTurnOrder
|
|
.map((playerId, seatIndex) => {
|
|
const bonus = draft.seatBonuses[seatIndex] ?? 0;
|
|
return `${state.players[playerId].name}: Seat ${seatIndex + 1}${bonus > 0 ? ` (+${bonus})` : ""}`;
|
|
})
|
|
.join(" | ");
|
|
|
|
state.initiativeDraft = null;
|
|
state.history.unshift(`Round ${state.round} initiative set. ${seatSummary}`);
|
|
startWeatherDraft();
|
|
}
|
|
|
|
function chooseInitiativeSeat(seatIndex: number) {
|
|
const draft = state.initiativeDraft;
|
|
if (!draft || seatIndex < 0 || seatIndex >= draft.seatAssignments.length || draft.seatAssignments[seatIndex] !== null) {
|
|
return;
|
|
}
|
|
|
|
const playerId = draft.biddingOrder[draft.biddingIndex];
|
|
draft.seatAssignments[seatIndex] = playerId;
|
|
state.history.unshift(`${state.players[playerId].name} claimed seat ${seatIndex + 1} for round ${state.round}.`);
|
|
|
|
if (draft.biddingIndex >= draft.biddingOrder.length - 1) {
|
|
finalizeInitiativeDraft();
|
|
render();
|
|
return;
|
|
}
|
|
|
|
draft.biddingIndex += 1;
|
|
state.activePlayerId = draft.biddingOrder[draft.biddingIndex];
|
|
render();
|
|
}
|
|
|
|
function beginRound() {
|
|
if (state.gameOver) {
|
|
state.phase = "game_over";
|
|
return;
|
|
}
|
|
|
|
state.players.forEach((player) => {
|
|
player.passed = false;
|
|
});
|
|
|
|
if (state.config.initiativeMode === "bid") {
|
|
startInitiativeDraft();
|
|
return;
|
|
}
|
|
|
|
startWeatherDraft();
|
|
}
|
|
|
|
function finalizeWeatherDraft() {
|
|
if (!state.weatherDraft) {
|
|
moveToFirstPlayableTurn();
|
|
return;
|
|
}
|
|
|
|
state.activeRoundEffects = [...state.weatherDraft.drafted];
|
|
const draftedSummary = state.activeRoundEffects.map((cardId) => getWeatherCard(cardId)?.title ?? cardId).join(", ");
|
|
const bannedSummary = state.weatherDraft.banned.map((cardId) => getWeatherCard(cardId)?.title ?? cardId).join(", ");
|
|
state.history.unshift(
|
|
`Round ${state.round} weather set.${draftedSummary ? ` Active: ${draftedSummary}.` : ""}${bannedSummary ? ` Banned: ${bannedSummary}.` : ""}`
|
|
);
|
|
state.weatherDraft = null;
|
|
moveToFirstPlayableTurn();
|
|
}
|
|
|
|
function chooseWeatherAction(cardId: WeatherCardId, action: "draft" | "ban") {
|
|
const draft = state.weatherDraft;
|
|
if (!draft || !isWeatherCardAvailable(draft, cardId)) {
|
|
return;
|
|
}
|
|
|
|
const playerId = getCurrentWeatherPlayerId(draft);
|
|
const card = getWeatherCard(cardId);
|
|
if (action === "draft") {
|
|
draft.drafted.push(cardId);
|
|
state.history.unshift(`${state.players[playerId].name} drafted ${card?.title ?? cardId}.`);
|
|
} else {
|
|
draft.banned.push(cardId);
|
|
state.history.unshift(`${state.players[playerId].name} banned ${card?.title ?? cardId}.`);
|
|
}
|
|
|
|
if (draft.draftIndex >= draft.playerOrder.length - 1) {
|
|
finalizeWeatherDraft();
|
|
render();
|
|
return;
|
|
}
|
|
|
|
draft.draftIndex += 1;
|
|
state.activePlayerId = draft.playerOrder[draft.draftIndex];
|
|
render();
|
|
}
|
|
|
|
function advanceTurn() {
|
|
if (state.players.every((player) => player.passed || !playerHasLegalMove(player))) {
|
|
endRound();
|
|
return;
|
|
}
|
|
|
|
const currentOrderIndex = Math.max(0, state.turnOrder.indexOf(state.activePlayerId));
|
|
let nextPlayerId = state.activePlayerId;
|
|
for (let step = 1; step <= state.turnOrder.length; step += 1) {
|
|
nextPlayerId = state.turnOrder[(currentOrderIndex + step) % state.turnOrder.length];
|
|
const candidate = state.players[nextPlayerId];
|
|
|
|
if (!candidate.passed && playerHasLegalMove(candidate)) {
|
|
state.activePlayerId = nextPlayerId;
|
|
state.turnMoves = [];
|
|
updateSelection(null);
|
|
render();
|
|
return;
|
|
}
|
|
}
|
|
|
|
endRound();
|
|
}
|
|
|
|
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() {
|
|
state.phase = "round_end";
|
|
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 = player.bankedPoints;
|
|
player.lifetimeGrowthIncome += nextGrowth[index];
|
|
player.growthPoints += nextGrowth[index];
|
|
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;
|
|
state.activeRoundEffects = [];
|
|
|
|
const boardFull = state.nodes.size >= state.config.columns * state.config.rows || state.players.every((player) => !playerHasLegalMove(player));
|
|
if (boardFull || hasReachedWinCondition()) {
|
|
state.gameOver = true;
|
|
state.phase = "game_over";
|
|
state.history.unshift(boardFull ? "The canopy is complete. Final totals are locked." : `Win condition reached. ${getWinConditionSummary()}`);
|
|
render();
|
|
return;
|
|
}
|
|
|
|
state.round += 1;
|
|
state.history.unshift(`Round ${state.round} begins.`);
|
|
beginRound();
|
|
render();
|
|
}
|
|
|
|
function resetGame() {
|
|
roundAnimationToken += 1;
|
|
state = createInitialState(setup);
|
|
beginRound();
|
|
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;
|
|
state.phase = "game_over";
|
|
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 `
|
|
<div class="modal-backdrop" id="new-game-modal-backdrop">
|
|
<section class="modal panel" role="dialog" aria-modal="true" aria-labelledby="new-game-title">
|
|
<div class="panel__title-row">
|
|
<div>
|
|
<p class="eyebrow">New Game</p>
|
|
<h1 id="new-game-title">Configure the next canopy</h1>
|
|
</div>
|
|
<button class="ghost-button" id="close-new-game">Close</button>
|
|
</div>
|
|
<div class="setup-grid modal-setup-grid">
|
|
<label>
|
|
<span>Players</span>
|
|
<input id="player-count" type="range" min="2" max="6" step="1" value="${setup.playerCount}" />
|
|
<strong>${setup.playerCount}</strong>
|
|
</label>
|
|
<label>
|
|
<span>Columns</span>
|
|
<input id="column-count" type="number" min="6" max="24" step="1" value="${setup.columns}" />
|
|
</label>
|
|
<label>
|
|
<span>Rows</span>
|
|
<input id="row-count" type="number" min="6" max="24" step="1" value="${setup.rows}" />
|
|
</label>
|
|
<label>
|
|
<span>Starting nodes each</span>
|
|
<input id="starting-nodes" type="number" min="1" max="${maxSeeds}" step="1" value="${setup.startingNodesPerPlayer}" />
|
|
</label>
|
|
<label class="toggle-row">
|
|
<span>Sunbeam %</span>
|
|
<input id="sunbeam-chance" type="number" min="0" max="100" step="5" value="${setup.sunbeamChance}" />
|
|
</label>
|
|
<label class="toggle-row">
|
|
<span>Disease %</span>
|
|
<input id="disease-chance" type="number" min="0" max="100" step="5" value="${setup.diseaseChance}" />
|
|
</label>
|
|
<label>
|
|
<span>Initiative</span>
|
|
<select id="initiative-mode">
|
|
<option value="fixed" ${setup.initiativeMode === "fixed" ? "selected" : ""}>Fixed Order</option>
|
|
<option value="bid" ${setup.initiativeMode === "bid" ? "selected" : ""}>Seat Draft</option>
|
|
</select>
|
|
</label>
|
|
${setup.initiativeMode === "bid" ? `
|
|
<label>
|
|
<span>Bid Order</span>
|
|
<select id="bidding-order-rule">
|
|
<option value="rotating" ${setup.biddingOrderRule === "rotating" ? "selected" : ""}>Rotating</option>
|
|
<option value="lowest_growth_income" ${setup.biddingOrderRule === "lowest_growth_income" ? "selected" : ""}>Lowest Growth</option>
|
|
</select>
|
|
</label>
|
|
` : ""}
|
|
<label class="toggle-row">
|
|
<span>Weather Draft</span>
|
|
<input id="weather-draft-toggle" type="checkbox" ${setup.weatherDraftEnabled ? "checked" : ""} />
|
|
</label>
|
|
<label>
|
|
<span>Win Condition</span>
|
|
<select id="win-condition">
|
|
<option value="rounds" ${setup.winCondition === "rounds" ? "selected" : ""}>Round Limit</option>
|
|
<option value="top_leaves" ${setup.winCondition === "top_leaves" ? "selected" : ""}>Top Leaves</option>
|
|
</select>
|
|
</label>
|
|
${setup.winCondition === "rounds" ? `
|
|
<label>
|
|
<span>Max rounds</span>
|
|
<input id="max-rounds" type="number" min="1" max="99" step="1" value="${setup.maxRounds}" />
|
|
</label>
|
|
` : `
|
|
<label>
|
|
<span>Top leaf target</span>
|
|
<input id="top-leaf-target" type="number" min="1" max="${setup.columns}" step="1" value="${setup.topLeafTarget}" />
|
|
</label>
|
|
`}
|
|
</div>
|
|
<div class="modal-grid">
|
|
<div class="seed-editor">
|
|
<div>
|
|
<p class="eyebrow">Turn order and colors</p>
|
|
<p class="seed-help">Move players up or down to set the manual color order. Leave shuffle on to randomize the order each new game.</p>
|
|
</div>
|
|
${previewPlayers.map((currentPlayer, index) => `
|
|
<div class="order-row">
|
|
<div class="order-row__label">
|
|
<span class="player-dot" style="--player-color: ${currentPlayer.color}; --player-glow: ${currentPlayer.glow};"></span>
|
|
<span>${currentPlayer.name}</span>
|
|
</div>
|
|
<div class="order-row__actions">
|
|
<button class="mini-button" data-move-player="${index}" data-direction="up" ${index === 0 ? "disabled" : ""}>Up</button>
|
|
<button class="mini-button" data-move-player="${index}" data-direction="down" ${index === previewPlayers.length - 1 ? "disabled" : ""}>Down</button>
|
|
</div>
|
|
</div>
|
|
`).join("")}
|
|
</div>
|
|
<div class="seed-editor">
|
|
<div>
|
|
<p class="eyebrow">Starting columns</p>
|
|
<p class="seed-help">Use 1-based column numbers, comma-separated. Duplicate or invalid picks are auto-corrected when you start a new game.</p>
|
|
</div>
|
|
<button class="ghost-button randomize-button" id="randomize-starting-locations">Randomize Starting Locations</button>
|
|
${previewPlayers.map((currentPlayer, index) => `
|
|
<label class="seed-row">
|
|
<span style="color: ${currentPlayer.color};">${currentPlayer.name}</span>
|
|
<input
|
|
class="seed-input"
|
|
data-player-id="${index}"
|
|
type="text"
|
|
value="${setup.seedInputs[index] ?? ""}"
|
|
placeholder="e.g. 2, 5"
|
|
/>
|
|
</label>
|
|
`).join("")}
|
|
</div>
|
|
</div>
|
|
<div class="button-row modal-actions">
|
|
<button class="ghost-button" id="cancel-new-game">Cancel</button>
|
|
<button id="start-new-game">Start New Game</button>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderWeatherDraftModal() {
|
|
if (state.phase !== "weather" || !state.weatherDraft) {
|
|
return "";
|
|
}
|
|
|
|
const draft = state.weatherDraft;
|
|
const currentPlayer = getCurrentWeatherDraftPlayer();
|
|
|
|
return `
|
|
<section class="draft-panel panel" role="dialog" aria-modal="true" aria-labelledby="weather-title">
|
|
<div class="panel__title-row">
|
|
<div>
|
|
<p class="eyebrow">Round ${state.round}</p>
|
|
<h1 id="weather-title">Weather Draft</h1>
|
|
</div>
|
|
</div>
|
|
<div class="seed-editor">
|
|
<p class="seed-help">${currentPlayer?.name ?? "A player"} must draft one card for this round or ban one to deny it.</p>
|
|
<div class="weather-key" aria-label="Weather action key">
|
|
<span><strong>☀ Draft</strong>: apply to the board for 1 round</span>
|
|
<span><strong>✕ Ban</strong>: remove it this round</span>
|
|
</div>
|
|
<div class="initiative-order-row">
|
|
${getOrderedPlayers(draft.playerOrder).map((player, index) => `<span class="initiative-pill${index === draft.draftIndex ? " initiative-pill--active" : ""}" style="--player-color: ${player.color};">${player.name}</span>`).join("")}
|
|
</div>
|
|
</div>
|
|
<div class="weather-grid">
|
|
${draft.row.map((cardId) => {
|
|
const card = getWeatherCard(cardId);
|
|
const drafted = draft.drafted.includes(cardId);
|
|
const banned = draft.banned.includes(cardId);
|
|
const available = !drafted && !banned;
|
|
return `
|
|
<article class="weather-card${drafted ? " weather-card--drafted" : ""}${banned ? " weather-card--banned" : ""}">
|
|
<div>
|
|
<p class="eyebrow">${drafted ? "Drafted" : banned ? "Banned" : "Open"}</p>
|
|
<h2>${card?.title ?? cardId}</h2>
|
|
<p>${card?.description ?? ""}</p>
|
|
</div>
|
|
${available ? `
|
|
<div class="weather-card__actions">
|
|
<button class="weather-action weather-action--draft" data-weather-action="draft" data-weather-card="${cardId}">
|
|
<span class="weather-action__icon" aria-hidden="true">☀</span>
|
|
<span><strong>Draft</strong></span>
|
|
</button>
|
|
<button class="weather-action weather-action--ban" data-weather-action="ban" data-weather-card="${cardId}">
|
|
<span class="weather-action__icon" aria-hidden="true">✕</span>
|
|
<span><strong>Ban</strong></span>
|
|
</button>
|
|
</div>
|
|
` : ""}
|
|
</article>
|
|
`;
|
|
}).join("")}
|
|
</div>
|
|
</section>
|
|
`;
|
|
}
|
|
|
|
function renderInitiativeModal() {
|
|
if (state.phase !== "initiative" || !state.initiativeDraft) {
|
|
return "";
|
|
}
|
|
|
|
const draft = state.initiativeDraft;
|
|
const currentBidder = getCurrentBiddingPlayer();
|
|
const orderedBidders = getOrderedPlayers(draft.biddingOrder);
|
|
|
|
return `
|
|
<section class="draft-panel panel" role="dialog" aria-modal="true" aria-labelledby="initiative-title">
|
|
<div class="panel__title-row">
|
|
<div>
|
|
<p class="eyebrow">Round ${state.round}</p>
|
|
<h1 id="initiative-title">Initiative Draft</h1>
|
|
</div>
|
|
</div>
|
|
<div class="seed-editor">
|
|
<p class="seed-help">${currentBidder?.name ?? "A player"} chooses a seat. Earlier seats gain extra growth before the round begins.</p>
|
|
<div class="initiative-order-row">
|
|
${orderedBidders.map((player, index) => `<span class="initiative-pill${index === draft.biddingIndex ? " initiative-pill--active" : ""}" style="--player-color: ${player.color};">${player.name}</span>`).join("")}
|
|
</div>
|
|
</div>
|
|
<div class="initiative-seat-grid">
|
|
${draft.seatAssignments.map((playerId, seatIndex) => {
|
|
const assignedPlayer = playerId === null ? null : state.players[playerId];
|
|
const bonus = draft.seatBonuses[seatIndex] ?? 0;
|
|
const disabled = playerId !== null ? "disabled" : "";
|
|
return `
|
|
<button class="initiative-seat${playerId === null ? "" : " initiative-seat--taken"}" data-seat-choice="${seatIndex}" ${disabled}>
|
|
<strong>Seat ${seatIndex + 1}</strong>
|
|
<span>${bonus > 0 ? `+${bonus} growth` : "+0 growth"}</span>
|
|
<span>${assignedPlayer ? assignedPlayer.name : "Open"}</span>
|
|
</button>
|
|
`;
|
|
}).join("")}
|
|
</div>
|
|
</section>
|
|
`;
|
|
}
|
|
|
|
function renderScoreboard() {
|
|
const liveExposureScores = getLiveExposureScores();
|
|
return state.players.map((player, index) => {
|
|
const isActive = player.id === state.activePlayerId && !state.gameOver;
|
|
const seatIndex = state.turnOrder.indexOf(player.id);
|
|
const previous = previousScoreSnapshot?.[index];
|
|
const sunlightChanged = previous && previous.currentExposure !== liveExposureScores[index];
|
|
const growthChanged = previous && previous.growthPoints !== player.growthPoints;
|
|
const bankChanged = previous && previous.bankedPoints !== player.bankedPoints;
|
|
return `
|
|
<article class="score-card${isActive ? " active" : ""}" style="--player-color: ${player.color}; --player-glow: ${player.glow};">
|
|
<div class="score-card__head">
|
|
<div class="score-card__identity">
|
|
<span class="player-dot"></span>
|
|
<h2>${player.name}</h2>
|
|
</div>
|
|
<span class="score-card__meta">Lifetime ${player.lifetimeGrowthIncome}</span>
|
|
</div>
|
|
<div class="score-card__numbers">
|
|
<div>
|
|
<span>Current</span>
|
|
<strong class="${sunlightChanged ? "score-value changed" : "score-value"}">${liveExposureScores[index]}</strong>
|
|
</div>
|
|
<div>
|
|
<span>Energy</span>
|
|
<strong class="${growthChanged ? "score-value changed" : "score-value"}">${player.growthPoints}</strong>
|
|
</div>
|
|
<div>
|
|
<span>Bank</span>
|
|
<strong class="${bankChanged ? "score-value changed" : "score-value"}">${player.bankedPoints}</strong>
|
|
</div>
|
|
</div>
|
|
<div class="score-card__footer">
|
|
<span>Seat ${seatIndex === -1 ? "-" : seatIndex + 1}</span>
|
|
</div>
|
|
</article>
|
|
`;
|
|
}).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 `<div class="board__energy-cell board__energy-cell--sunlight" style="--flash-delay: ${index * 65}ms; --flash-color: ${flashColor}; left: ${left}%; top: ${top}%; width: ${width}%; height: ${height}%;"></div>`;
|
|
});
|
|
}).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 `
|
|
<div
|
|
class="board__energy-cell${state.animation?.phase === "bonus" ? " board__energy-cell--bonus" : ""}"
|
|
style="--flash-delay: ${index * 175}ms; --flash-color: ${player.color}; left: ${left}%; top: ${top}%; width: ${width}%; height: ${height}%;"
|
|
></div>
|
|
`;
|
|
});
|
|
}).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 `<g class="board__root-burst" style="--trace-delay: 300ms; --burst-color: ${player.color};" transform="translate(${x} ${y})"><circle r="2.5"></circle><text text-anchor="middle" dominant-baseline="central">+${burst.count}</text></g>`;
|
|
}).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 `<g class="board__disease-mark" style="--trace-delay: ${index * 100}ms;" transform="translate(${x} ${y})"><circle r="2.2"></circle><path d="M-1.2 -1.2 L1.2 1.2 M1.2 -1.2 L-1.2 1.2" /></g>`;
|
|
}).join("");
|
|
|
|
const bonusSunbeam = !state.animation.bonusTrace
|
|
? ""
|
|
: `
|
|
<div class="board__drop board__drop--bonus" style="--drop-x: ${state.animation.bonusTrace.ray.x}%; --drop-end: ${state.animation.bonusTrace.ray.y}%; --drop-color: #ffd85e;">
|
|
<span class="board__drop-core"></span>
|
|
<span class="board__drop-spark board__drop-spark--a"></span>
|
|
<span class="board__drop-spark board__drop-spark--b"></span>
|
|
<span class="board__drop-spark board__drop-spark--c"></span>
|
|
</div>
|
|
`;
|
|
|
|
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 `<div class="board__energy-cell board__energy-cell--bonus" style="--flash-delay: ${index * 175}ms; --flash-color: #ffd85e; left: ${left}%; top: ${top}%; width: ${width}%; height: ${height}%;"></div>`;
|
|
}).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 `<g class="board__sunbeam-burst" transform="translate(${x} ${y})"><circle r="2.5"></circle><text text-anchor="middle" dominant-baseline="central">+1</text></g>`;
|
|
})();
|
|
|
|
return `
|
|
<div class="board__drop-layer board__drop-layer--${state.animation.phase}" aria-hidden="true">
|
|
${state.animation.phase === "bonus" ? bonusSunbeam : ""}
|
|
</div>
|
|
<div class="board__energy-layer" aria-hidden="true">
|
|
${sunlightCells}
|
|
${flashes}
|
|
${state.animation.phase === "bonus" ? bonusFlashes : ""}
|
|
</div>
|
|
<svg class="board__fx board__fx--${state.animation.phase}" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
|
|
${roots}
|
|
${disease}
|
|
${sunbeam}
|
|
</svg>
|
|
`;
|
|
}
|
|
|
|
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 `<line x1="${x1}%" y1="${y1}%" x2="${x2}%" y2="${y2}%" stroke="${player.color}" stroke-width="0.9" stroke-linecap="round" />`;
|
|
}).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 `
|
|
<button
|
|
class="cell${player ? " occupied" : ""}${target ? " target" : ""}${pending ? " pending" : ""}${isRoot ? " root" : ""}${state.selectedSource === nodeKey ? " selected" : ""}"
|
|
data-row="${row}"
|
|
data-column="${column}"
|
|
style="--column-tint: ${background}; ${player ? `--node-color: ${player.color}; --node-glow: ${player.glow};` : ""}"
|
|
${isInteractionLocked() ? "disabled" : ""}
|
|
>
|
|
<span class="cell__shade"></span>
|
|
${target ? `<span class="cell__target-label">${target.cost}</span>` : ""}
|
|
${isRoot ? '<span class="cell__root-ring"></span>' : ""}
|
|
${player ? '<span class="cell__node"></span>' : ""}
|
|
</button>
|
|
`;
|
|
}).join("");
|
|
}).join("");
|
|
|
|
return `
|
|
<section class="board-shell">
|
|
<div class="board${state.animation ? ` board--${state.animation.phase}` : ""}" style="--board-columns: ${columns}; --board-rows: ${rows}; grid-template-columns: repeat(${columns}, 1fr); grid-template-rows: repeat(${rows}, 1fr);">
|
|
<svg class="board__lines" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
|
|
${lines}
|
|
</svg>
|
|
${cells}
|
|
${renderAnimationOverlay(columns, rows)}
|
|
</div>
|
|
</section>
|
|
`;
|
|
}
|
|
|
|
function renderSidebar() {
|
|
const player = getCurrentPlayer();
|
|
const rootShiftMoves = getSelectedRootShiftMoves();
|
|
const boardLocked = isInteractionLocked();
|
|
const turnOrderSummary = getOrderedPlayers(state.turnOrder).map((orderedPlayer, index) => `${index + 1}. ${orderedPlayer.name}`).join(" | ");
|
|
const activeEffectsMarkup = state.activeRoundEffects.length > 0
|
|
? `<div class="active-effects">${state.activeRoundEffects.map((cardId) => {
|
|
const card = getWeatherCard(cardId);
|
|
return `<div class="effect-chip" title="${card?.description ?? ""}"><span class="effect-chip__title">${card?.title ?? cardId}</span></div>`;
|
|
}).join("")}</div>`
|
|
: `<p class="effect-empty">No weather effects active.</p>`;
|
|
const nextGrowthText = state.roundSummary
|
|
? state.players.map((current) => `${current.name}: ${current.growthPoints}`).join(" | ")
|
|
: "Next round growth = 1 + columns owned + any banked growth.";
|
|
|
|
return `
|
|
<aside class="sidebar">
|
|
<section class="panel controls-panel">
|
|
<div class="panel__title-row">
|
|
<div>
|
|
<p class="eyebrow">Canopy</p>
|
|
<h1>Sunlight decides the next round.</h1>
|
|
</div>
|
|
<button class="ghost-button" id="new-game">New Game</button>
|
|
</div>
|
|
<div class="active-turn" style="--player-color: ${player.color}; --player-glow: ${player.glow};">
|
|
<p class="eyebrow">Round ${state.round}</p>
|
|
<h2>${getTurnLabel()}</h2>
|
|
<p>${state.phase === "initiative" ? "Choose a public seat for this round. Earlier seats gain more growth, later seats act later." : state.gameOver ? "Tallies are final." : "Spend growth by extending upward. Vertical costs 1. Diagonal costs 2. Click a glowing new node to undo back to that point before you commit the turn."}</p>
|
|
${!state.gameOver ? `<p>${state.turnMoves.length} pending move${state.turnMoves.length === 1 ? "" : "s"} this turn. ${player.bankedPoints} banked for the next round.</p>` : ""}
|
|
${state.round === 1 ? `<p>Select a root on the bottom row to shift it left or right for ${ROOT_SHIFT_COST} point during round 1.</p>` : ""}
|
|
<p>Turn order: ${turnOrderSummary}</p>
|
|
<p>${getWinConditionSummary()}</p>
|
|
${rootShiftMoves.length > 0 ? `<div class="root-shift-row">${rootShiftMoves.map((move) => `<button class="ghost-button root-shift-button" data-root-shift="${move.direction === "left" ? -1 : 1}" ${boardLocked ? "disabled" : ""}>${move.direction === "left" ? "<- Root" : "Root ->"}</button>`).join("")}</div>` : ""}
|
|
</div>
|
|
<div class="button-row">
|
|
<button id="end-turn" ${boardLocked ? "disabled" : ""}>End Turn</button>
|
|
<button id="bank-turn" class="ghost-button" ${boardLocked ? "disabled" : ""}>Bank Remaining</button>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel status-panel">
|
|
<h2>Round economy</h2>
|
|
<p>${nextGrowthText}</p>
|
|
${activeEffectsMarkup}
|
|
${state.roundSummary?.event ? `<p class="event-note">${state.roundSummary.event}</p>` : ""}
|
|
</section>
|
|
|
|
<section class="panel log-panel">
|
|
<h2>Round log</h2>
|
|
<div class="log-list">
|
|
${state.history.slice(0, 8).map((entry) => `<p>${entry}</p>`).join("")}
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel finish-panel">
|
|
<button id="finish-game" class="ghost-button finish-game-button" ${boardLocked ? "disabled" : ""}>End Game / Score Now</button>
|
|
</section>
|
|
</aside>
|
|
`;
|
|
}
|
|
|
|
function attachEvents() {
|
|
document.querySelectorAll<HTMLElement>(".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) {
|
|
const sourceKey = nodeKey;
|
|
const isPendingNode = isPendingTurnNode(row, column);
|
|
|
|
if (state.selectedSource === sourceKey) {
|
|
if (isPendingNode && undoMovesThrough(nodeKey)) {
|
|
return;
|
|
}
|
|
|
|
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<HTMLInputElement>("#player-count")?.addEventListener("input", (event) => {
|
|
const input = event.currentTarget as HTMLInputElement;
|
|
const output = input.parentElement?.querySelector("strong");
|
|
if (output) {
|
|
output.textContent = input.value;
|
|
}
|
|
rebuildSetup({ playerCount: Number(input.value) });
|
|
render();
|
|
});
|
|
document.querySelector<HTMLInputElement>("#column-count")?.addEventListener("change", (event) => {
|
|
const columns = Number((event.currentTarget as HTMLInputElement).value);
|
|
if (!Number.isInteger(columns)) {
|
|
return;
|
|
}
|
|
|
|
rebuildSetup({ columns: Math.max(6, Math.min(24, columns)) });
|
|
render();
|
|
});
|
|
document.querySelector<HTMLInputElement>("#row-count")?.addEventListener("change", (event) => {
|
|
const rows = Number((event.currentTarget as HTMLInputElement).value);
|
|
if (!Number.isInteger(rows)) {
|
|
return;
|
|
}
|
|
|
|
rebuildSetup({ rows: Math.max(6, Math.min(24, rows)) });
|
|
render();
|
|
});
|
|
document.querySelector<HTMLInputElement>("#starting-nodes")?.addEventListener("change", (event) => {
|
|
const nextValue = Number((event.currentTarget as HTMLInputElement).value);
|
|
if (!Number.isInteger(nextValue)) {
|
|
return;
|
|
}
|
|
|
|
rebuildSetup({ startingNodesPerPlayer: Math.max(1, nextValue) });
|
|
render();
|
|
});
|
|
document.querySelector<HTMLInputElement>("#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<HTMLInputElement>("#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<HTMLSelectElement>("#initiative-mode")?.addEventListener("change", (event) => {
|
|
setup.initiativeMode = (event.currentTarget as HTMLSelectElement).value as SetupState["initiativeMode"];
|
|
render();
|
|
});
|
|
document.querySelector<HTMLSelectElement>("#bidding-order-rule")?.addEventListener("change", (event) => {
|
|
setup.biddingOrderRule = (event.currentTarget as HTMLSelectElement).value as SetupState["biddingOrderRule"];
|
|
});
|
|
document.querySelector<HTMLInputElement>("#weather-draft-toggle")?.addEventListener("change", (event) => {
|
|
setup.weatherDraftEnabled = (event.currentTarget as HTMLInputElement).checked;
|
|
});
|
|
document.querySelector<HTMLSelectElement>("#win-condition")?.addEventListener("change", (event) => {
|
|
setup.winCondition = (event.currentTarget as HTMLSelectElement).value as SetupState["winCondition"];
|
|
render();
|
|
});
|
|
document.querySelector<HTMLInputElement>("#max-rounds")?.addEventListener("change", (event) => {
|
|
setup.maxRounds = Math.max(1, Number((event.currentTarget as HTMLInputElement).value) || 1);
|
|
});
|
|
document.querySelector<HTMLInputElement>("#top-leaf-target")?.addEventListener("change", (event) => {
|
|
setup.topLeafTarget = Math.max(1, Math.min(setup.columns, Number((event.currentTarget as HTMLInputElement).value) || 1));
|
|
});
|
|
document.querySelectorAll<HTMLInputElement>(".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<HTMLElement>("[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<HTMLElement>("[data-root-shift]").forEach((button) => {
|
|
button.addEventListener("click", () => {
|
|
shiftSelectedRoot(Number(button.dataset.rootShift));
|
|
});
|
|
});
|
|
document.querySelectorAll<HTMLElement>("[data-seat-choice]").forEach((button) => {
|
|
button.addEventListener("click", () => {
|
|
chooseInitiativeSeat(Number(button.dataset.seatChoice));
|
|
});
|
|
});
|
|
document.querySelectorAll<HTMLElement>("[data-weather-card]").forEach((button) => {
|
|
button.addEventListener("click", () => {
|
|
chooseWeatherAction(button.dataset.weatherCard as WeatherCardId, button.dataset.weatherAction as "draft" | "ban");
|
|
});
|
|
});
|
|
}
|
|
|
|
function render() {
|
|
app.innerHTML = `
|
|
<main class="layout">
|
|
<section class="game-area">
|
|
${renderBoard()}
|
|
${renderSidebar()}
|
|
</section>
|
|
<footer class="scoreboard scoreboard--bottom">
|
|
${renderScoreboard()}
|
|
</footer>
|
|
</main>
|
|
${renderNewGameModal()}
|
|
${renderInitiativeModal()}
|
|
${renderWeatherDraftModal()}
|
|
`;
|
|
|
|
attachEvents();
|
|
previousScoreSnapshot = getScoreSnapshot();
|
|
}
|
|
|
|
render();
|