Refine setup and draft interactions
This commit is contained in:
305
src/main.ts
305
src/main.ts
@@ -24,13 +24,15 @@ import {
|
||||
} from "./rules-scoring";
|
||||
import {
|
||||
createInitiativeDraft,
|
||||
getInitiativeGraceRounds,
|
||||
} from "./rules-initiative";
|
||||
import {
|
||||
WEATHER_CARDS,
|
||||
WEATHER_OFFER_PAIRS,
|
||||
createWeatherDraft,
|
||||
getCurrentWeatherPlayerId,
|
||||
getWeatherCard,
|
||||
isWeatherCardAvailable,
|
||||
isWeatherOfferResolved,
|
||||
} from "./rules-weather";
|
||||
import {
|
||||
createInitialState,
|
||||
@@ -38,6 +40,7 @@ import {
|
||||
createRandomizedSeedInputs,
|
||||
createSetupState,
|
||||
getMaxStartingNodesPerPlayer,
|
||||
normalizeSeedInputs,
|
||||
} from "./state";
|
||||
import type {
|
||||
GameState,
|
||||
@@ -64,10 +67,12 @@ let state: GameState = createInitialState(setup);
|
||||
let isNewGameModalOpen = false;
|
||||
let previousScoreSnapshot: ScoreSnapshot[] | null = null;
|
||||
let setupTab: "board" | "rules" | "events" | "players" = "board";
|
||||
let draggedSetupSeed: { playerId: number; seedIndex: number } | null = null;
|
||||
|
||||
function rebuildSetup(overrides: Partial<SetupState> = {}) {
|
||||
setup = createSetupState(
|
||||
overrides.playerCount ?? setup.playerCount,
|
||||
overrides.playerNames ?? setup.playerNames,
|
||||
overrides.columns ?? setup.columns,
|
||||
overrides.rows ?? setup.rows,
|
||||
overrides.startingNodesPerPlayer ?? setup.startingNodesPerPlayer,
|
||||
@@ -89,6 +94,10 @@ function getLiveExposureScores() {
|
||||
return buildEnergySimulation(state).scores;
|
||||
}
|
||||
|
||||
function getProjectedIncomeScores() {
|
||||
return getLiveExposureScores().map((score) => score + 1);
|
||||
}
|
||||
|
||||
function getTopLeafCount() {
|
||||
const childrenMap = buildChildrenMap();
|
||||
let count = 0;
|
||||
@@ -120,9 +129,9 @@ function getWinConditionSummary() {
|
||||
}
|
||||
|
||||
function getScoreSnapshot() {
|
||||
const exposureScores = getLiveExposureScores();
|
||||
const projectedIncomeScores = getProjectedIncomeScores();
|
||||
return state.players.map((player, index) => ({
|
||||
currentExposure: exposureScores[index],
|
||||
projectedIncome: projectedIncomeScores[index],
|
||||
growthPoints: player.growthPoints,
|
||||
bankedPoints: player.bankedPoints,
|
||||
lifetimeGrowthIncome: player.lifetimeGrowthIncome,
|
||||
@@ -133,6 +142,10 @@ function getCurrentPlayer() {
|
||||
return state.players[state.activePlayerId];
|
||||
}
|
||||
|
||||
function getPlayerById(playerId: number) {
|
||||
return state.players.find((player) => player.id === playerId) ?? null;
|
||||
}
|
||||
|
||||
function getOrderedPlayers(playerIds: number[]) {
|
||||
return playerIds.map((playerId) => state.players[playerId]);
|
||||
}
|
||||
@@ -170,6 +183,17 @@ function getCurrentBiddingPlayer() {
|
||||
return state.players[state.initiativeDraft.biddingOrder[state.initiativeDraft.biddingIndex]];
|
||||
}
|
||||
|
||||
function getInitiativeBonusStatus() {
|
||||
const graceRounds = getInitiativeGraceRounds(state);
|
||||
const roundsRemaining = Math.max(0, Math.ceil(graceRounds - (state.round - 1)));
|
||||
const bonusActive = roundsRemaining === 0;
|
||||
|
||||
return {
|
||||
bonusActive,
|
||||
roundsRemaining,
|
||||
};
|
||||
}
|
||||
|
||||
function getCurrentWeatherDraftPlayer() {
|
||||
if (!state.weatherDraft) {
|
||||
return null;
|
||||
@@ -212,6 +236,13 @@ function getLegalMovesForSource(sourceKey: NodeKey, player: Player) {
|
||||
return moves;
|
||||
}
|
||||
|
||||
const freeVerticalMovesUsed = state.turnMoves.filter((move) => move.type === "grow" && move.cost === 0).length;
|
||||
const freeVerticalMovesRemaining = Math.max(0, 3 - freeVerticalMovesUsed);
|
||||
|
||||
if (freeVerticalMovesRemaining <= 0) {
|
||||
return moves;
|
||||
}
|
||||
|
||||
return moves.map((move) => move.direction === "vertical"
|
||||
? { ...move, cost: Math.max(0, move.cost - 1) }
|
||||
: move);
|
||||
@@ -409,15 +440,17 @@ function finalizeInitiativeDraft() {
|
||||
|
||||
nextTurnOrder.forEach((playerId, seatIndex) => {
|
||||
const bonus = draft.seatBonuses[seatIndex] ?? 0;
|
||||
if (bonus > 0) {
|
||||
awardGrowth(state.players[playerId], bonus);
|
||||
const player = getPlayerById(playerId);
|
||||
if (bonus > 0 && player) {
|
||||
awardGrowth(player, bonus);
|
||||
}
|
||||
});
|
||||
|
||||
const seatSummary = nextTurnOrder
|
||||
.map((playerId, seatIndex) => {
|
||||
const bonus = draft.seatBonuses[seatIndex] ?? 0;
|
||||
return `${state.players[playerId].name}: Seat ${seatIndex + 1}${bonus > 0 ? ` (+${bonus})` : ""}`;
|
||||
const player = getPlayerById(playerId);
|
||||
return `${player?.name ?? `Player ${playerId + 1}`}: Seat ${seatIndex + 1}${bonus > 0 ? ` (+${bonus})` : ""}`;
|
||||
})
|
||||
.join(" | ");
|
||||
|
||||
@@ -434,7 +467,7 @@ function chooseInitiativeSeat(seatIndex: number) {
|
||||
|
||||
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}.`);
|
||||
state.history.unshift(`${getPlayerById(playerId)?.name ?? `Player ${playerId + 1}`} claimed seat ${seatIndex + 1} for round ${state.round}.`);
|
||||
|
||||
if (draft.biddingIndex >= draft.biddingOrder.length - 1) {
|
||||
finalizeInitiativeDraft();
|
||||
@@ -481,18 +514,19 @@ function finalizeWeatherDraft() {
|
||||
moveToFirstPlayableTurn();
|
||||
}
|
||||
|
||||
function chooseWeatherAction(cardId: WeatherCardId, action: "draft" | "ban") {
|
||||
function chooseWeatherAction(offerId: string, cardId: WeatherCardId, action: "draft" | "ban") {
|
||||
const draft = state.weatherDraft;
|
||||
if (!draft || !isWeatherCardAvailable(draft, cardId)) {
|
||||
if (!draft || !isWeatherCardAvailable(draft, offerId, cardId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const playerId = getCurrentWeatherPlayerId(draft);
|
||||
const card = getWeatherCard(cardId);
|
||||
if (action === "draft") {
|
||||
const card = getWeatherCard(cardId);
|
||||
draft.drafted.push(cardId);
|
||||
state.history.unshift(`${state.players[playerId].name} drafted ${card?.title ?? cardId}.`);
|
||||
} else {
|
||||
const card = getWeatherCard(cardId);
|
||||
draft.banned.push(cardId);
|
||||
state.history.unshift(`${state.players[playerId].name} banned ${card?.title ?? cardId}.`);
|
||||
}
|
||||
@@ -790,6 +824,45 @@ function moveSetupPlayer(fromIndex: number, toIndex: number) {
|
||||
|
||||
[setup.paletteOrder[fromIndex], setup.paletteOrder[toIndex]] = [setup.paletteOrder[toIndex], setup.paletteOrder[fromIndex]];
|
||||
[setup.seedInputs[fromIndex], setup.seedInputs[toIndex]] = [setup.seedInputs[toIndex], setup.seedInputs[fromIndex]];
|
||||
[setup.playerNames[fromIndex], setup.playerNames[toIndex]] = [setup.playerNames[toIndex], setup.playerNames[fromIndex]];
|
||||
render();
|
||||
}
|
||||
|
||||
function getSetupSeedColumns() {
|
||||
return normalizeSeedInputs(setup).map((columns) => [...columns]);
|
||||
}
|
||||
|
||||
function setSetupSeedColumns(seedColumnsByPlayer: number[][]) {
|
||||
setup.seedInputs = seedColumnsByPlayer.map((columns) => columns.join(", ") ? columns.map((column) => String(column + 1)).join(", ") : "");
|
||||
}
|
||||
|
||||
function moveSetupSeed(playerId: number, seedIndex: number, targetColumn: number) {
|
||||
const seedColumnsByPlayer = getSetupSeedColumns();
|
||||
const originColumn = seedColumnsByPlayer[playerId]?.[seedIndex];
|
||||
if (originColumn === undefined || originColumn === targetColumn) {
|
||||
return;
|
||||
}
|
||||
|
||||
let swapped = false;
|
||||
seedColumnsByPlayer.forEach((columns, otherPlayerId) => {
|
||||
columns.forEach((column, otherSeedIndex) => {
|
||||
if (otherPlayerId === playerId && otherSeedIndex === seedIndex) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (column === targetColumn) {
|
||||
seedColumnsByPlayer[otherPlayerId][otherSeedIndex] = originColumn;
|
||||
swapped = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
seedColumnsByPlayer[playerId][seedIndex] = targetColumn;
|
||||
if (!swapped) {
|
||||
seedColumnsByPlayer[playerId] = [...seedColumnsByPlayer[playerId]];
|
||||
}
|
||||
|
||||
setSetupSeedColumns(seedColumnsByPlayer);
|
||||
render();
|
||||
}
|
||||
|
||||
@@ -809,8 +882,15 @@ function renderNewGameModal() {
|
||||
}
|
||||
|
||||
const maxSeeds = getMaxStartingNodesPerPlayer(setup.playerCount, setup.columns);
|
||||
const previewPlayers = createPlayers(setup.playerCount, setup.paletteOrder);
|
||||
const draftCountMax = WEATHER_CARDS.length;
|
||||
const previewPlayers = createPlayers(setup.playerCount, setup.paletteOrder, setup.playerNames);
|
||||
const draftCountMax = WEATHER_OFFER_PAIRS.length;
|
||||
const previewSeedColumns = getSetupSeedColumns();
|
||||
const seedMarkers = previewSeedColumns.flatMap((columns, playerId) => columns.map((column, seedIndex) => ({
|
||||
playerId,
|
||||
seedIndex,
|
||||
column,
|
||||
player: previewPlayers[playerId],
|
||||
})));
|
||||
|
||||
return `
|
||||
<div class="modal-backdrop" id="new-game-modal-backdrop">
|
||||
@@ -820,7 +900,6 @@ function renderNewGameModal() {
|
||||
<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>
|
||||
</header>
|
||||
<nav class="setup-tabs" aria-label="Setup categories">
|
||||
<button class="setup-tab${setupTab === "board" ? " setup-tab--active" : ""}" data-setup-tab="board"><span aria-hidden="true">▦</span><span>Board</span></button>
|
||||
@@ -834,11 +913,12 @@ function renderNewGameModal() {
|
||||
<section class="setup-section">
|
||||
<h2 class="setup-section__title">Board Settings</h2>
|
||||
<div class="setup-grid">
|
||||
<label class="setup-field setup-field--range">
|
||||
<label class="setup-field">
|
||||
<span class="setup-field__label">Players</span>
|
||||
<div class="setup-field__input">
|
||||
<input id="player-count" type="range" min="2" max="6" step="1" value="${setup.playerCount}" />
|
||||
<strong class="setup-field__value">${setup.playerCount}</strong>
|
||||
<div class="setup-stepper">
|
||||
<button class="stepper-button" id="player-count-decrease" ${setup.playerCount <= 2 ? "disabled" : ""}>-</button>
|
||||
<strong class="setup-stepper__value">${setup.playerCount}</strong>
|
||||
<button class="stepper-button" id="player-count-increase" ${setup.playerCount >= 8 ? "disabled" : ""}>+</button>
|
||||
</div>
|
||||
</label>
|
||||
<label class="setup-field">
|
||||
@@ -934,7 +1014,7 @@ function renderNewGameModal() {
|
||||
<div class="player-row">
|
||||
<div class="player-row__info">
|
||||
<span class="player-dot" style="--player-color: ${currentPlayer.color}; --player-glow: ${currentPlayer.glow};"></span>
|
||||
<span class="player-row__name">${currentPlayer.name}</span>
|
||||
<input class="player-name-input" data-player-id="${index}" type="text" value="${currentPlayer.name}" aria-label="${currentPlayer.name} name" />
|
||||
</div>
|
||||
<div class="player-row__actions">
|
||||
<button class="mini-button" data-move-player="${index}" data-direction="up" ${index === 0 ? "disabled" : ""}>↑</button>
|
||||
@@ -946,8 +1026,19 @@ function renderNewGameModal() {
|
||||
</section>
|
||||
<section class="setup-section">
|
||||
<h2 class="setup-section__title">Starting columns</h2>
|
||||
<p class="setup-section__help">Use 1-based column numbers. Duplicate or invalid picks are auto-corrected.</p>
|
||||
<p class="setup-section__help">Drag markers on the strip to move starting seeds. Text inputs remain available as a fallback.</p>
|
||||
<button class="ghost-button randomize-button" id="randomize-starting-locations">Randomize Starting Locations</button>
|
||||
<div class="start-strip" role="group" aria-label="Starting positions preview">
|
||||
${Array.from({ length: setup.columns }, (_, column) => {
|
||||
const marker = seedMarkers.find((entry) => entry.column === column);
|
||||
return `
|
||||
<div class="start-strip__slot" data-start-slot="${column}">
|
||||
<span class="start-strip__label">${column + 1}</span>
|
||||
${marker ? `<button class="start-marker" draggable="true" data-start-marker="${marker.playerId}:${marker.seedIndex}" style="--player-color: ${marker.player.color}; --player-glow: ${marker.player.glow};">${marker.player.name.slice(0, 1) || marker.playerId + 1}</button>` : ""}
|
||||
</div>
|
||||
`;
|
||||
}).join("")}
|
||||
</div>
|
||||
<div class="player-list">
|
||||
${previewPlayers.map((currentPlayer, index) => `
|
||||
<label class="setup-field">
|
||||
@@ -962,7 +1053,7 @@ function renderNewGameModal() {
|
||||
|
||||
<footer class="modal-footer">
|
||||
<button class="ghost-button" id="cancel-new-game">Cancel</button>
|
||||
<button id="start-new-game">Start New Game</button>
|
||||
<button class="primary-button" id="start-new-game">Start New Game</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
@@ -986,40 +1077,49 @@ function renderWeatherDraftModal() {
|
||||
</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>
|
||||
<p class="seed-help">${currentPlayer?.name ?? "A player"} can draft or ban either card in each offer.</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>
|
||||
<span><strong>☀ Draft</strong>: take that card for 1 round</span>
|
||||
<span><strong>✕ Ban</strong>: remove just that card</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) => {
|
||||
${draft.offers.map((offer) => {
|
||||
const resolved = isWeatherOfferResolved(draft, offer.id);
|
||||
return `
|
||||
<article class="weather-card${resolved ? " weather-card--resolved" : ""}">
|
||||
<div>
|
||||
<p class="eyebrow">Offer</p>
|
||||
<div class="weather-pair">
|
||||
${offer.options.map((cardId) => {
|
||||
const card = getWeatherCard(cardId);
|
||||
const drafted = draft.drafted.includes(cardId);
|
||||
const banned = draft.banned.includes(cardId);
|
||||
const available = !drafted && !banned;
|
||||
const available = isWeatherCardAvailable(draft, offer.id, cardId);
|
||||
return `
|
||||
<article class="weather-card${drafted ? " weather-card--drafted" : ""}${banned ? " weather-card--banned" : ""}">
|
||||
<div>
|
||||
<p class="eyebrow">${drafted ? "Drafted" : banned ? "Banned" : "Open"}</p>
|
||||
<div class="weather-pair__option${drafted ? " weather-pair__option--drafted" : ""}${banned ? " weather-pair__option--banned" : ""}">
|
||||
<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}">
|
||||
<button class="weather-action weather-action--draft" data-weather-action="draft" data-weather-offer="${offer.id}" 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}">
|
||||
<button class="weather-action weather-action--ban" data-weather-action="ban" data-weather-offer="${offer.id}" data-weather-card="${cardId}">
|
||||
<span class="weather-action__icon" aria-hidden="true">✕</span>
|
||||
<span><strong>Ban</strong></span>
|
||||
</button>
|
||||
</div>
|
||||
` : ""}
|
||||
` : `<p class="weather-card__status">${drafted ? "Drafted" : "Banned"}</p>`}
|
||||
</div>
|
||||
`;
|
||||
}).join("")}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
}).join("")}
|
||||
@@ -1036,6 +1136,7 @@ function renderInitiativeModal() {
|
||||
const draft = state.initiativeDraft;
|
||||
const currentBidder = getCurrentBiddingPlayer();
|
||||
const orderedBidders = getOrderedPlayers(draft.biddingOrder);
|
||||
const initiativeBonusStatus = getInitiativeBonusStatus();
|
||||
|
||||
return `
|
||||
<section class="draft-panel panel" role="dialog" aria-modal="true" aria-labelledby="initiative-title">
|
||||
@@ -1047,6 +1148,11 @@ function renderInitiativeModal() {
|
||||
</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>
|
||||
<p class="initiative-bonus-note ${initiativeBonusStatus.bonusActive ? "initiative-bonus-note--active" : ""}">
|
||||
${initiativeBonusStatus.bonusActive
|
||||
? "Seat bonuses are active: Seat 1 gains +1 growth this round."
|
||||
: `Seat bonuses start in ${initiativeBonusStatus.roundsRemaining} round${initiativeBonusStatus.roundsRemaining === 1 ? "" : "s"}.`}
|
||||
</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>
|
||||
@@ -1070,12 +1176,12 @@ function renderInitiativeModal() {
|
||||
}
|
||||
|
||||
function renderScoreboard() {
|
||||
const liveExposureScores = getLiveExposureScores();
|
||||
const projectedIncomeScores = getProjectedIncomeScores();
|
||||
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 projectedIncomeChanged = previous && previous.projectedIncome !== projectedIncomeScores[index];
|
||||
const growthChanged = previous && previous.growthPoints !== player.growthPoints;
|
||||
const bankChanged = previous && previous.bankedPoints !== player.bankedPoints;
|
||||
return `
|
||||
@@ -1089,8 +1195,8 @@ function renderScoreboard() {
|
||||
</div>
|
||||
<div class="score-card__numbers">
|
||||
<div>
|
||||
<span>Current</span>
|
||||
<strong class="${sunlightChanged ? "score-value changed" : "score-value"}">${liveExposureScores[index]}</strong>
|
||||
<span>Next</span>
|
||||
<strong class="${projectedIncomeChanged ? "score-value changed" : "score-value"}">${projectedIncomeScores[index]}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Energy</span>
|
||||
@@ -1276,31 +1382,33 @@ function renderSidebar() {
|
||||
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>`;
|
||||
return `<div class="effect-chip" title="${card?.description ?? ""}"><span class="effect-chip__title">${card?.title ?? cardId}</span><span class="effect-chip__rule">${card?.description ?? ""}</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.";
|
||||
const projectedIncomeText = getProjectedIncomeScores().map((score, index) => `${state.players[index].name}: ${score}`).join(" | ");
|
||||
const phaseHint = state.phase === "initiative"
|
||||
? "Choose a seat for this round."
|
||||
: state.phase === "weather"
|
||||
? "Draft one card or ban one card."
|
||||
: state.gameOver
|
||||
? "Final totals are locked."
|
||||
: `${player.growthPoints} energy. Click a selected pending node again to undo.`;
|
||||
|
||||
return `
|
||||
<aside class="sidebar">
|
||||
<button class="ghost-button new-game-launch" id="new-game">New Game</button>
|
||||
<section class="panel status-panel status-panel--weather">
|
||||
<h2>In Effect</h2>
|
||||
${activeEffectsMarkup}
|
||||
${state.roundSummary?.event ? `<p class="event-note">${state.roundSummary.event}</p>` : ""}
|
||||
</section>
|
||||
|
||||
<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>
|
||||
<p>${phaseHint}</p>
|
||||
${!state.gameOver ? `<p>${state.turnMoves.length} pending move${state.turnMoves.length === 1 ? "" : "s"}. Energy ${player.growthPoints}. Bank ${player.bankedPoints}.</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">
|
||||
@@ -1309,18 +1417,41 @@ function renderSidebar() {
|
||||
</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">
|
||||
<section class="panel accordion-panel">
|
||||
<details class="accordion">
|
||||
<summary>Turn Help</summary>
|
||||
<div class="accordion__content">
|
||||
<p>Vertical growth costs 1. Diagonal growth costs 2.</p>
|
||||
<p>Click a selected pending node again to undo back through it.</p>
|
||||
${state.round === 1 ? `<p>Roots can shift left or right for ${ROOT_SHIFT_COST} during round 1.</p>` : ""}
|
||||
${isBankingEnabled() ? `<p>Storehouse is active, so banking is enabled this round.</p>` : `<p>Banking is disabled unless Storehouse is active.</p>`}
|
||||
</div>
|
||||
</details>
|
||||
<details class="accordion">
|
||||
<summary>Win Condition</summary>
|
||||
<div class="accordion__content">
|
||||
<p>${getWinConditionSummary()}</p>
|
||||
<p>Turn order: ${turnOrderSummary}</p>
|
||||
<p>Next income: ${projectedIncomeText}</p>
|
||||
</div>
|
||||
</details>
|
||||
<details class="accordion">
|
||||
<summary>Weather Details</summary>
|
||||
<div class="accordion__content">
|
||||
${state.activeRoundEffects.length > 0
|
||||
? state.activeRoundEffects.map((cardId) => {
|
||||
const card = getWeatherCard(cardId);
|
||||
return `<p><strong>${card?.title ?? cardId}</strong>: ${card?.description ?? ""}</p>`;
|
||||
}).join("")
|
||||
: `<p>No weather effects active.</p>`}
|
||||
</div>
|
||||
</details>
|
||||
<details class="accordion">
|
||||
<summary>Round Log</summary>
|
||||
<div class="accordion__content log-list">
|
||||
${state.history.slice(0, 8).map((entry) => `<p>${entry}</p>`).join("")}
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section class="panel finish-panel">
|
||||
@@ -1368,7 +1499,6 @@ function attachEvents() {
|
||||
});
|
||||
|
||||
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) => {
|
||||
@@ -1379,13 +1509,12 @@ function attachEvents() {
|
||||
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) });
|
||||
document.querySelector<HTMLElement>("#player-count-decrease")?.addEventListener("click", () => {
|
||||
rebuildSetup({ playerCount: Math.max(2, setup.playerCount - 1) });
|
||||
render();
|
||||
});
|
||||
document.querySelector<HTMLElement>("#player-count-increase")?.addEventListener("click", () => {
|
||||
rebuildSetup({ playerCount: Math.min(8, setup.playerCount + 1) });
|
||||
render();
|
||||
});
|
||||
document.querySelector<HTMLInputElement>("#column-count")?.addEventListener("change", (event) => {
|
||||
@@ -1455,6 +1584,13 @@ function attachEvents() {
|
||||
setup.seedInputs[playerId] = target.value;
|
||||
});
|
||||
});
|
||||
document.querySelectorAll<HTMLInputElement>(".player-name-input").forEach((input) => {
|
||||
input.addEventListener("input", (event) => {
|
||||
const target = event.currentTarget as HTMLInputElement;
|
||||
const playerId = Number(target.dataset.playerId);
|
||||
setup.playerNames[playerId] = target.value;
|
||||
});
|
||||
});
|
||||
document.querySelector("#randomize-starting-locations")?.addEventListener("click", randomizeStartingLocations);
|
||||
document.querySelectorAll<HTMLElement>("[data-move-player]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
@@ -1468,6 +1604,29 @@ function attachEvents() {
|
||||
shiftSelectedRoot(Number(button.dataset.rootShift));
|
||||
});
|
||||
});
|
||||
document.querySelectorAll<HTMLElement>("[data-start-marker]").forEach((marker) => {
|
||||
marker.addEventListener("dragstart", () => {
|
||||
const [playerId, seedIndex] = (marker.dataset.startMarker ?? "").split(":").map(Number);
|
||||
draggedSetupSeed = { playerId, seedIndex };
|
||||
});
|
||||
marker.addEventListener("dragend", () => {
|
||||
draggedSetupSeed = null;
|
||||
});
|
||||
});
|
||||
document.querySelectorAll<HTMLElement>("[data-start-slot]").forEach((slot) => {
|
||||
slot.addEventListener("dragover", (event) => {
|
||||
event.preventDefault();
|
||||
});
|
||||
slot.addEventListener("drop", (event) => {
|
||||
event.preventDefault();
|
||||
if (!draggedSetupSeed) {
|
||||
return;
|
||||
}
|
||||
|
||||
moveSetupSeed(draggedSetupSeed.playerId, draggedSetupSeed.seedIndex, Number(slot.dataset.startSlot));
|
||||
draggedSetupSeed = null;
|
||||
});
|
||||
});
|
||||
document.querySelectorAll<HTMLElement>("[data-seat-choice]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
chooseInitiativeSeat(Number(button.dataset.seatChoice));
|
||||
@@ -1475,7 +1634,11 @@ function attachEvents() {
|
||||
});
|
||||
document.querySelectorAll<HTMLElement>("[data-weather-card]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
chooseWeatherAction(button.dataset.weatherCard as WeatherCardId, button.dataset.weatherAction as "draft" | "ban");
|
||||
chooseWeatherAction(
|
||||
button.dataset.weatherOffer as string,
|
||||
button.dataset.weatherCard as WeatherCardId,
|
||||
button.dataset.weatherAction as "draft" | "ban",
|
||||
);
|
||||
});
|
||||
});
|
||||
document.querySelectorAll<HTMLElement>("[data-setup-tab]").forEach((button) => {
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import type { GameState, InitiativeDraftState, PlayerId } from "./types";
|
||||
|
||||
export function getInitiativeGraceRounds(state: GameState) {
|
||||
return Math.max(0, (state.config.columns / state.players.length) - state.players.length);
|
||||
}
|
||||
|
||||
export function getSeatBonuses(state: GameState) {
|
||||
const graceRounds = Math.max(0, Math.floor(state.config.columns / state.players.length) - state.players.length);
|
||||
const firstSeatBonus = state.round <= graceRounds ? 0 : 1;
|
||||
const graceRounds = getInitiativeGraceRounds(state);
|
||||
const firstSeatBonus = state.round - 1 < graceRounds ? 0 : 1;
|
||||
|
||||
return Array.from({ length: state.players.length }, (_, index) => (index === 0 ? firstSeatBonus : 0));
|
||||
}
|
||||
|
||||
@@ -60,13 +60,6 @@ function applyColumnBaseEnergy(state: GameState, scores: number[], ownerId: Play
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.activeRoundEffects.includes("shared_light")) {
|
||||
playersPresent.forEach((playerId) => {
|
||||
scores[playerId] += 1;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
scores[ownerId] += 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { GameState, PlayerId, WeatherCardDefinition, WeatherCardId, WeatherDraftState } from "./types";
|
||||
import type { GameState, PlayerId, WeatherCardDefinition, WeatherCardId, WeatherDraftState, WeatherOfferPair } from "./types";
|
||||
import { shuffleArray } from "./utils";
|
||||
|
||||
export const WEATHER_CARDS: WeatherCardDefinition[] = [
|
||||
{ id: "leaf_surge", title: "Leaf Surge", description: "Each leaf gives +1." },
|
||||
{ id: "branching_season", title: "Branching Season", description: "Each leaf after your first gives +1." },
|
||||
{ id: "storehouse", title: "Storehouse", description: "Banking is enabled this round." },
|
||||
{ id: "sun_ladder", title: "Sun Ladder", description: "Straight-up growth costs 0." },
|
||||
{ id: "sun_ladder", title: "Sun Ladder", description: "Your first 3 vertical growths cost 0." },
|
||||
{ id: "west_light", title: "West Light", description: "Left third columns give +1." },
|
||||
{ id: "east_light", title: "East Light", description: "Right third columns give +1." },
|
||||
{ id: "high_noon", title: "High Noon", description: "Center third columns give +1." },
|
||||
@@ -14,20 +14,28 @@ export const WEATHER_CARDS: WeatherCardDefinition[] = [
|
||||
{ id: "tall_reward", title: "Tall Reward", description: "Your tallest leaf gives +2." },
|
||||
{ id: "stalemate", title: "Stalemate", description: "Contested columns give no energy." },
|
||||
{ id: "split_light", title: "Split Light", description: "Contested columns give half to each player there." },
|
||||
{ id: "shared_light", title: "Shared Light", description: "Contested columns give full energy to each player there." },
|
||||
];
|
||||
|
||||
export const WEATHER_CARD_LOOKUP = new Map<WeatherCardId, WeatherCardDefinition>(
|
||||
WEATHER_CARDS.map((card) => [card.id, card]),
|
||||
);
|
||||
|
||||
export const WEATHER_OFFER_PAIRS: WeatherOfferPair[] = [
|
||||
{ id: "growth_mix", options: ["leaf_surge", "branching_season"] },
|
||||
{ id: "tempo_tools", options: ["storehouse", "sun_ladder"] },
|
||||
{ id: "side_bias", options: ["west_light", "east_light"] },
|
||||
{ id: "shape_bias", options: ["high_noon", "edge_bloom"] },
|
||||
{ id: "reward_shape", options: ["wide_reach", "tall_reward"] },
|
||||
{ id: "contest_soft", options: ["stalemate", "split_light"] },
|
||||
];
|
||||
|
||||
export function createWeatherDraft(state: GameState): WeatherDraftState {
|
||||
const rowSize = Math.min(WEATHER_CARDS.length, state.config.weatherDraftCount);
|
||||
const rowSize = Math.min(WEATHER_OFFER_PAIRS.length, state.config.weatherDraftCount);
|
||||
|
||||
return {
|
||||
playerOrder: [...state.turnOrder],
|
||||
draftIndex: 0,
|
||||
row: shuffleArray(WEATHER_CARDS.map((card) => card.id)).slice(0, rowSize),
|
||||
offers: shuffleArray(WEATHER_OFFER_PAIRS).slice(0, rowSize),
|
||||
drafted: [],
|
||||
banned: [],
|
||||
};
|
||||
@@ -37,8 +45,22 @@ export function getCurrentWeatherPlayerId(draft: WeatherDraftState) {
|
||||
return draft.playerOrder[draft.draftIndex] as PlayerId;
|
||||
}
|
||||
|
||||
export function isWeatherCardAvailable(draft: WeatherDraftState, cardId: WeatherCardId) {
|
||||
return draft.row.includes(cardId) && !draft.drafted.includes(cardId) && !draft.banned.includes(cardId);
|
||||
export function isWeatherCardAvailable(draft: WeatherDraftState, offerId: string, cardId: WeatherCardId) {
|
||||
const offer = draft.offers.find((entry) => entry.id === offerId);
|
||||
if (!offer) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return offer.options.includes(cardId) && !draft.banned.includes(cardId) && !draft.drafted.includes(cardId);
|
||||
}
|
||||
|
||||
export function isWeatherOfferResolved(draft: WeatherDraftState, offerId: string) {
|
||||
const offer = draft.offers.find((entry) => entry.id === offerId);
|
||||
if (!offer) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return offer.options.every((cardId) => draft.banned.includes(cardId) || draft.drafted.includes(cardId));
|
||||
}
|
||||
|
||||
export function getWeatherCard(cardId: WeatherCardId) {
|
||||
|
||||
22
src/state.ts
22
src/state.ts
@@ -68,7 +68,8 @@ export function getMaxStartingNodesPerPlayer(playerCount: number, columns: numbe
|
||||
}
|
||||
|
||||
export function createSetupState(
|
||||
playerCount = 3,
|
||||
playerCount = 2,
|
||||
playerNames: string[] | null = null,
|
||||
columns = 18,
|
||||
rows = 16,
|
||||
startingNodesPerPlayer = 1,
|
||||
@@ -84,13 +85,13 @@ export function createSetupState(
|
||||
maxRounds = 12,
|
||||
topLeafTarget = 4,
|
||||
): SetupState {
|
||||
console.log("[DEBUG] createSetupState started");
|
||||
const clampedSeeds = Math.min(startingNodesPerPlayer, getMaxStartingNodesPerPlayer(playerCount, columns));
|
||||
const defaults = createDefaultSeedInputs(playerCount, columns, clampedSeeds);
|
||||
const paletteDefaults = createDefaultPaletteOrder(playerCount);
|
||||
|
||||
const result = {
|
||||
return {
|
||||
playerCount,
|
||||
playerNames: Array.from({ length: playerCount }, (_, index) => playerNames?.[index] ?? `Player ${index + 1}`),
|
||||
columns,
|
||||
rows,
|
||||
startingNodesPerPlayer: clampedSeeds,
|
||||
@@ -106,16 +107,18 @@ export function createSetupState(
|
||||
maxRounds,
|
||||
topLeafTarget: Math.max(1, Math.min(columns, topLeafTarget)),
|
||||
};
|
||||
console.log("[DEBUG] createSetupState completed");
|
||||
return result;
|
||||
}
|
||||
|
||||
export function createPlayers(playerCount: number, paletteOrder = createDefaultPaletteOrder(playerCount)): Player[] {
|
||||
export function createPlayers(
|
||||
playerCount: number,
|
||||
paletteOrder = createDefaultPaletteOrder(playerCount),
|
||||
playerNames: string[] = Array.from({ length: playerCount }, (_, index) => `Player ${index + 1}`),
|
||||
): Player[] {
|
||||
return Array.from({ length: playerCount }, (_, index) => {
|
||||
const palette = PLAYER_PALETTE[paletteOrder[index] % PLAYER_PALETTE.length];
|
||||
return {
|
||||
id: index,
|
||||
name: `Player ${index + 1}`,
|
||||
name: playerNames[index] ?? `Player ${index + 1}`,
|
||||
color: palette.primary,
|
||||
glow: palette.glow,
|
||||
totalScore: 0,
|
||||
@@ -167,9 +170,8 @@ export function normalizeSeedInputs(setup: SetupState) {
|
||||
}
|
||||
|
||||
export function createInitialState(setup: SetupState): GameState {
|
||||
console.log("[DEBUG] createInitialState started");
|
||||
const playerPaletteOrder = [...setup.paletteOrder];
|
||||
const players = createPlayers(setup.playerCount, playerPaletteOrder);
|
||||
const players = createPlayers(setup.playerCount, playerPaletteOrder, setup.playerNames);
|
||||
const turnOrder = players.map((player) => player.id);
|
||||
const nodes = new Map();
|
||||
const edges = [];
|
||||
@@ -180,8 +182,6 @@ export function createInitialState(setup: SetupState): GameState {
|
||||
nodes.set(keyFor(setup.rows - 1, column), { ownerId: index });
|
||||
});
|
||||
});
|
||||
|
||||
console.log("[DEBUG] createInitialState completed");
|
||||
return {
|
||||
config: {
|
||||
columns: setup.columns,
|
||||
|
||||
466
src/styles.css
466
src/styles.css
@@ -39,7 +39,7 @@ html, body {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
#app > * {
|
||||
#app > .layout {
|
||||
width: 100%;
|
||||
height: calc(100vh - 100px);
|
||||
max-width: 100%;
|
||||
@@ -92,7 +92,7 @@ html, body {
|
||||
.board {
|
||||
position: relative;
|
||||
width: auto;
|
||||
height: auto;
|
||||
height: 100%;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
aspect-ratio: var(--board-columns) / var(--board-rows);
|
||||
@@ -113,6 +113,10 @@ html, body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.new-game-launch {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
/* Bottom bar - Fixed height player scores */
|
||||
.scoreboard {
|
||||
grid-area: bottom;
|
||||
@@ -126,12 +130,13 @@ html, body {
|
||||
.score-card {
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 1rem;
|
||||
padding: 1rem 1.25rem;
|
||||
padding: 1.2rem 1.25rem;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.02));
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
justify-content: flex-start;
|
||||
gap: 0.2rem;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@@ -153,15 +158,14 @@ html, body {
|
||||
}
|
||||
|
||||
.controls-panel {
|
||||
flex: 1;
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
overflow: auto;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.log-panel {
|
||||
max-height: 120px;
|
||||
max-height: 88px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -447,6 +451,12 @@ label span,
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.score-card__numbers span {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.score-value {
|
||||
display: inline-block;
|
||||
}
|
||||
@@ -481,6 +491,7 @@ label span,
|
||||
}
|
||||
|
||||
.button-row button,
|
||||
.primary-button,
|
||||
.ghost-button {
|
||||
min-height: 2.4rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
@@ -493,6 +504,12 @@ label span,
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
background: #f4f7fb;
|
||||
color: #0a1020;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.ghost-button,
|
||||
#finish-game {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
@@ -522,16 +539,79 @@ button:disabled {
|
||||
color: #ffd577;
|
||||
}
|
||||
|
||||
.active-effects {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
.effect-chip {
|
||||
display: grid;
|
||||
gap: 0.18rem;
|
||||
min-height: 3.4rem;
|
||||
padding: 0.75rem 0.9rem;
|
||||
border-radius: 1rem;
|
||||
background: linear-gradient(180deg, rgba(255, 208, 96, 0.14), rgba(255, 208, 96, 0.04));
|
||||
border: 1px solid rgba(255, 208, 96, 0.2);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.effect-chip__title {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: #f4f7fb;
|
||||
}
|
||||
|
||||
.effect-chip__rule {
|
||||
font-size: 0.78rem;
|
||||
color: rgba(231, 238, 247, 0.76);
|
||||
}
|
||||
|
||||
.active-turn {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.25rem;
|
||||
gap: 0.15rem;
|
||||
padding: 0.6rem;
|
||||
border-radius: 0.85rem;
|
||||
background: linear-gradient(135deg, color-mix(in srgb, var(--player-color) 22%, rgba(255, 255, 255, 0.04)), rgba(255, 255, 255, 0.04));
|
||||
border: 1px solid color-mix(in srgb, var(--player-color) 35%, rgba(255, 255, 255, 0.08));
|
||||
}
|
||||
|
||||
.economy-line {
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
.accordion {
|
||||
border-radius: 0.85rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.accordion summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
padding: 0.7rem 0.85rem;
|
||||
font-weight: 700;
|
||||
color: #f4f7fb;
|
||||
}
|
||||
|
||||
.accordion summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.accordion__content {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
padding: 0 0.85rem 0.8rem;
|
||||
}
|
||||
|
||||
.accordion__content p {
|
||||
margin: 0;
|
||||
color: rgba(231, 238, 247, 0.76);
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
/* Form elements */
|
||||
button,
|
||||
input,
|
||||
@@ -583,7 +663,7 @@ input[type="range"] {
|
||||
.setup-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 900px;
|
||||
max-width: 960px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@@ -592,8 +672,7 @@ input[type="range"] {
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
padding: 1.6rem 1.75rem 1rem;
|
||||
}
|
||||
|
||||
.modal-header__title h1 {
|
||||
@@ -603,65 +682,75 @@ input[type="range"] {
|
||||
}
|
||||
|
||||
.setup-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0.5rem;
|
||||
padding: 0 1.5rem 1rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 0.65rem;
|
||||
padding: 0 1.75rem 1.1rem;
|
||||
}
|
||||
|
||||
.setup-tab {
|
||||
min-height: 3rem;
|
||||
appearance: none;
|
||||
width: 4.25rem;
|
||||
min-width: 4.25rem;
|
||||
min-height: 4.25rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 0.15rem;
|
||||
gap: 0.2rem;
|
||||
padding: 0.55rem;
|
||||
border-radius: 0.85rem;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: rgba(231, 238, 247, 0.78);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 1rem;
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
color: rgba(231, 238, 247, 0.68);
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease, border-color 120ms ease, color 120ms ease, transform 120ms ease;
|
||||
}
|
||||
|
||||
.setup-tab--active {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #f4f7fb;
|
||||
border-color: rgba(255, 255, 255, 0.16);
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
box-shadow: inset 0 -2px 0 rgba(255, 208, 96, 0.75);
|
||||
}
|
||||
|
||||
.setup-tab:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: rgba(244, 247, 251, 0.9);
|
||||
}
|
||||
|
||||
.setup-tab span:first-child {
|
||||
font-size: 1rem;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.setup-tab span:last-child {
|
||||
font-size: 0.78rem;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
line-height: 1.1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem;
|
||||
padding: 1.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
gap: 1.9rem;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
padding: 1.1rem 1.75rem 1.5rem;
|
||||
}
|
||||
|
||||
/* Setup Sections */
|
||||
.setup-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.setup-section__title {
|
||||
@@ -671,8 +760,6 @@ input[type="range"] {
|
||||
letter-spacing: 0.05em;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
margin: 0;
|
||||
padding-bottom: 0.25rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.setup-section__help {
|
||||
@@ -684,8 +771,8 @@ input[type="range"] {
|
||||
/* Setup Grid */
|
||||
.setup-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 1.15rem;
|
||||
}
|
||||
|
||||
.setup-grid--2col {
|
||||
@@ -698,7 +785,7 @@ input[type="range"] {
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.setup-tabs {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.setup-grid,
|
||||
@@ -712,13 +799,13 @@ input[type="range"] {
|
||||
.setup-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.setup-field__label {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 0.86rem;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.76);
|
||||
}
|
||||
|
||||
.setup-field__input {
|
||||
@@ -733,6 +820,39 @@ input[type="range"] {
|
||||
min-width: 1.5rem;
|
||||
}
|
||||
|
||||
.setup-stepper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.45rem;
|
||||
border-radius: 1rem;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.setup-stepper__value {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
font-size: 1.35rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.stepper-button {
|
||||
appearance: none;
|
||||
width: 3.25rem;
|
||||
min-width: 3.25rem;
|
||||
min-height: 3.25rem;
|
||||
border-radius: 0.95rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #f4f7fb;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.setup-field--range input[type="range"] {
|
||||
flex: 1;
|
||||
}
|
||||
@@ -751,13 +871,13 @@ input[type="range"] {
|
||||
.setup-field input[type="text"],
|
||||
.setup-field select {
|
||||
width: 100%;
|
||||
min-height: 2.25rem;
|
||||
padding: 0.5rem 0.625rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
min-height: 3.25rem;
|
||||
padding: 0.8rem 0.95rem;
|
||||
border-radius: 1rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: #f4f7fb;
|
||||
font-size: 0.9375rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.setup-field input[type="checkbox"] {
|
||||
@@ -773,14 +893,57 @@ input[type="range"] {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.start-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(2.4rem, 1fr));
|
||||
gap: 0.35rem;
|
||||
padding: 0.85rem;
|
||||
border-radius: 1rem;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.start-strip__slot {
|
||||
min-height: 4.2rem;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
justify-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.45rem 0.2rem;
|
||||
border-radius: 0.8rem;
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
}
|
||||
|
||||
.start-strip__label {
|
||||
font-size: 0.7rem;
|
||||
color: rgba(231, 238, 247, 0.58);
|
||||
}
|
||||
|
||||
.start-marker {
|
||||
appearance: none;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid color-mix(in srgb, var(--player-color) 55%, white);
|
||||
background: var(--player-color);
|
||||
color: #08111c;
|
||||
font-weight: 800;
|
||||
cursor: grab;
|
||||
box-shadow: 0 0 0 0.15rem rgba(255, 255, 255, 0.06), 0 0 0.9rem var(--player-glow);
|
||||
}
|
||||
|
||||
.start-marker:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.player-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.625rem 0.875rem;
|
||||
padding: 0.8rem 1rem;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 0.625rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-radius: 0.9rem;
|
||||
}
|
||||
|
||||
.player-row__info {
|
||||
@@ -793,6 +956,12 @@ input[type="range"] {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.player-name-input {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
min-height: 3.25rem;
|
||||
}
|
||||
|
||||
.player-row__actions {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
@@ -806,6 +975,7 @@ input[type="range"] {
|
||||
|
||||
/* Mini button for player reordering */
|
||||
.mini-button {
|
||||
appearance: none;
|
||||
min-height: 2rem;
|
||||
padding: 0.375rem 0.625rem;
|
||||
border-radius: 0.5rem;
|
||||
@@ -841,6 +1011,200 @@ input[type="range"] {
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.32);
|
||||
}
|
||||
|
||||
.initiative-order-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.initiative-pill {
|
||||
padding: 0.45rem 0.7rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
color: rgba(231, 238, 247, 0.76);
|
||||
}
|
||||
|
||||
.initiative-pill--active {
|
||||
border-color: color-mix(in srgb, var(--player-color) 62%, white);
|
||||
box-shadow: 0 0 18px color-mix(in srgb, var(--player-color) 40%, transparent);
|
||||
color: #f4f7fb;
|
||||
}
|
||||
|
||||
.initiative-bonus-note {
|
||||
margin: 0.2rem 0 0;
|
||||
padding: 0.55rem 0.7rem;
|
||||
border-radius: 0.8rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
color: rgba(231, 238, 247, 0.8);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.initiative-bonus-note--active {
|
||||
border-color: rgba(255, 208, 96, 0.28);
|
||||
background: rgba(255, 208, 96, 0.09);
|
||||
color: #f4f7fb;
|
||||
}
|
||||
|
||||
.initiative-seat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 0.8rem;
|
||||
margin-top: 0.9rem;
|
||||
}
|
||||
|
||||
.initiative-seat {
|
||||
appearance: none;
|
||||
min-height: 8rem;
|
||||
padding: 0.9rem;
|
||||
border-radius: 1rem;
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
text-align: left;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: #f4f7fb;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.initiative-seat--taken {
|
||||
opacity: 0.68;
|
||||
}
|
||||
|
||||
.weather-key {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
color: rgba(231, 238, 247, 0.78);
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.weather-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 0.9rem;
|
||||
margin-top: 0.9rem;
|
||||
}
|
||||
|
||||
.weather-card {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
padding: 0.95rem;
|
||||
border-radius: 1rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.weather-card h2,
|
||||
.weather-card p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.weather-pair {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.weather-pair__option {
|
||||
padding: 0.75rem 0.8rem;
|
||||
border-radius: 0.85rem;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.weather-pair__option--drafted {
|
||||
border-color: rgba(130, 224, 182, 0.55);
|
||||
}
|
||||
|
||||
.weather-pair__option--banned {
|
||||
border-color: rgba(255, 128, 128, 0.45);
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.weather-card--drafted {
|
||||
border-color: rgba(130, 224, 182, 0.55);
|
||||
}
|
||||
|
||||
.weather-card--banned {
|
||||
border-color: rgba(255, 128, 128, 0.45);
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.weather-card__actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.45rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.weather-card__status {
|
||||
margin-top: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: rgba(231, 238, 247, 0.8);
|
||||
}
|
||||
|
||||
.weather-action {
|
||||
appearance: none;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
padding: 0.58rem 0.72rem;
|
||||
border-radius: 0.95rem;
|
||||
color: #f4f7fb;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.weather-action span {
|
||||
display: grid;
|
||||
gap: 0.12rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.weather-action strong {
|
||||
display: block;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.weather-action__icon {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 999px;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.weather-action--draft {
|
||||
background: linear-gradient(180deg, rgba(255, 208, 96, 0.2), rgba(255, 208, 96, 0.08));
|
||||
border-color: rgba(255, 208, 96, 0.4);
|
||||
}
|
||||
|
||||
.weather-action--draft .weather-action__icon {
|
||||
background: rgba(255, 208, 96, 0.22);
|
||||
}
|
||||
|
||||
.weather-action--ban {
|
||||
background: linear-gradient(180deg, rgba(255, 110, 110, 0.16), rgba(255, 110, 110, 0.06));
|
||||
border-color: rgba(255, 110, 110, 0.32);
|
||||
}
|
||||
|
||||
.weather-action--ban .weather-action__icon {
|
||||
background: rgba(255, 110, 110, 0.18);
|
||||
}
|
||||
|
||||
.weather-action:hover,
|
||||
.initiative-seat:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes sunlight-drop {
|
||||
0% {
|
||||
|
||||
13
src/types.ts
13
src/types.ts
@@ -14,6 +14,7 @@ export type Position = {
|
||||
|
||||
export type SetupState = {
|
||||
playerCount: number;
|
||||
playerNames: string[];
|
||||
columns: number;
|
||||
rows: number;
|
||||
startingNodesPerPlayer: number;
|
||||
@@ -164,7 +165,7 @@ export type RoundSummary = {
|
||||
};
|
||||
|
||||
export type ScoreSnapshot = {
|
||||
currentExposure: number;
|
||||
projectedIncome: number;
|
||||
growthPoints: number;
|
||||
bankedPoints: number;
|
||||
lifetimeGrowthIncome: number;
|
||||
@@ -195,8 +196,7 @@ export type WeatherCardId =
|
||||
| "wide_reach"
|
||||
| "tall_reward"
|
||||
| "stalemate"
|
||||
| "split_light"
|
||||
| "shared_light";
|
||||
| "split_light";
|
||||
|
||||
export type WeatherCardDefinition = {
|
||||
id: WeatherCardId;
|
||||
@@ -204,6 +204,11 @@ export type WeatherCardDefinition = {
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type WeatherOfferPair = {
|
||||
id: string;
|
||||
options: [WeatherCardId, WeatherCardId];
|
||||
};
|
||||
|
||||
export type InitiativeDraftState = {
|
||||
biddingOrder: PlayerId[];
|
||||
biddingIndex: number;
|
||||
@@ -214,7 +219,7 @@ export type InitiativeDraftState = {
|
||||
export type WeatherDraftState = {
|
||||
playerOrder: PlayerId[];
|
||||
draftIndex: number;
|
||||
row: WeatherCardId[];
|
||||
offers: WeatherOfferPair[];
|
||||
drafted: WeatherCardId[];
|
||||
banned: WeatherCardId[];
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user