Compare commits
6 Commits
8b50482621
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| c4da8c942e | |||
| f071837ed6 | |||
| 856f0049b7 | |||
| 30e3f88b21 | |||
| e11264168c | |||
| 1cc85397bd |
@@ -2,7 +2,7 @@
|
||||
cmds = ["npm run build"]
|
||||
|
||||
[start]
|
||||
cmd = "npx serve dist -l 80 -s"
|
||||
cmd = "serve dist -l 80 -s"
|
||||
|
||||
[healthcheck]
|
||||
cmd = "curl -f http://localhost:80/ || exit 1"
|
||||
|
||||
960
package-lock.json
generated
960
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -7,11 +7,14 @@
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"start": "npx serve dist -l 80 -s",
|
||||
"start": "serve dist -l 80 -s",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^6.0.2",
|
||||
"vite": "^5.4.19"
|
||||
},
|
||||
"dependencies": {
|
||||
"serve": "^14.2.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
453
src/engine/rules-scoring.ts
Normal file
453
src/engine/rules-scoring.ts
Normal file
@@ -0,0 +1,453 @@
|
||||
import type {
|
||||
EnergySimulation,
|
||||
GameState,
|
||||
NodeKey,
|
||||
PlayerId,
|
||||
RootBurst,
|
||||
RoundAnimation,
|
||||
} from "./types";
|
||||
import { keyFor, parseKey, shuffleArray } from "./utils";
|
||||
import { buildChildrenMap, buildParentMap } from "./rules-board";
|
||||
|
||||
function getColumnRegion(state: GameState, column: number) {
|
||||
const third = state.config.columns / 3;
|
||||
if (column < third) {
|
||||
return "left";
|
||||
}
|
||||
|
||||
if (column >= state.config.columns - third) {
|
||||
return "right";
|
||||
}
|
||||
|
||||
return "center";
|
||||
}
|
||||
|
||||
function getLeafCounts(state: GameState) {
|
||||
const childrenMap = buildChildrenMap(state);
|
||||
const counts = state.players.map(() => 0);
|
||||
|
||||
Array.from(state.nodes.entries()).forEach(([nodeKey, node]) => {
|
||||
if (!childrenMap.get(nodeKey)?.length) {
|
||||
counts[node.ownerId] += 1;
|
||||
}
|
||||
});
|
||||
|
||||
return counts;
|
||||
}
|
||||
|
||||
function getColumnPresence(state: GameState, column: number) {
|
||||
const owners = new Set<PlayerId>();
|
||||
|
||||
for (let row = 0; row < state.config.rows; row += 1) {
|
||||
const ownerId = state.nodes.get(keyFor(row, column))?.ownerId;
|
||||
if (ownerId !== undefined) {
|
||||
owners.add(ownerId);
|
||||
}
|
||||
}
|
||||
|
||||
return [...owners];
|
||||
}
|
||||
|
||||
function addRoundedHalfBonus(scores: number[], counts: number[]) {
|
||||
counts.forEach((count, playerId) => {
|
||||
if (count > 0) {
|
||||
scores[playerId] += Math.ceil(count * 0.5);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function applyColumnBaseEnergy(
|
||||
state: GameState,
|
||||
scores: number[],
|
||||
ownerId: PlayerId,
|
||||
playersPresent: PlayerId[],
|
||||
) {
|
||||
const contested = playersPresent.length > 1;
|
||||
|
||||
if (!contested) {
|
||||
scores[ownerId] += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.activeRoundEffects.includes("stalemate")) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.activeRoundEffects.includes("split_light")) {
|
||||
playersPresent.forEach((playerId) => {
|
||||
scores[playerId] += 1;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
scores[ownerId] += 1;
|
||||
}
|
||||
|
||||
function applyWeatherEffects(
|
||||
state: GameState,
|
||||
scores: number[],
|
||||
energySimulation: EnergySimulation,
|
||||
) {
|
||||
if (state.activeRoundEffects.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const leafCounts = getLeafCounts(state);
|
||||
const childrenMap = buildChildrenMap(state);
|
||||
const tallestLeaves = state.players.map(() => null as number | null);
|
||||
|
||||
Array.from(state.nodes.entries()).forEach(([nodeKey, node]) => {
|
||||
const leafCount = leafCounts[node.ownerId];
|
||||
if (leafCount <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (childrenMap.get(nodeKey)?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { row } = parseKey(nodeKey);
|
||||
const currentTallest = tallestLeaves[node.ownerId];
|
||||
if (currentTallest === null || row < currentTallest) {
|
||||
tallestLeaves[node.ownerId] = row;
|
||||
}
|
||||
});
|
||||
|
||||
state.activeRoundEffects.forEach((effectId) => {
|
||||
if (effectId === "leaf_surge") {
|
||||
leafCounts.forEach((count, playerId) => {
|
||||
scores[playerId] += count;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (effectId === "branching_season") {
|
||||
// Find the player with the most leaves
|
||||
let maxLeaves = 0;
|
||||
let playerWithMostLeaves = -1;
|
||||
|
||||
leafCounts.forEach((count, playerId) => {
|
||||
scores[playerId] += Math.max(0, count - 1);
|
||||
if (count > maxLeaves) {
|
||||
maxLeaves = count;
|
||||
playerWithMostLeaves = playerId;
|
||||
}
|
||||
});
|
||||
|
||||
// Give the player with most leaves 50% more energy (rounded down)
|
||||
if (playerWithMostLeaves !== -1 && maxLeaves > 1) {
|
||||
const bonus = Math.floor((maxLeaves - 1) * 0.5);
|
||||
scores[playerWithMostLeaves] += bonus;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (effectId === "tall_reward" || effectId === "deep_roots") {
|
||||
const bestRow = tallestLeaves.reduce<number | null>(
|
||||
(currentBest, row) => {
|
||||
if (row === null) {
|
||||
return currentBest;
|
||||
}
|
||||
|
||||
if (
|
||||
(effectId === "deep_roots" &&
|
||||
(currentBest === null || row > currentBest)) ||
|
||||
(effectId === "tall_reward" &&
|
||||
(currentBest === null || row < currentBest))
|
||||
) {
|
||||
return row;
|
||||
}
|
||||
|
||||
return currentBest;
|
||||
},
|
||||
null,
|
||||
);
|
||||
|
||||
if (bestRow !== null) {
|
||||
tallestLeaves.forEach((row, playerId) => {
|
||||
if (row === bestRow) {
|
||||
scores[playerId] += effectId === "deep_roots" ? 4 : 2;
|
||||
}
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (effectId === "wide_reach") {
|
||||
const maxScore = Math.max(...energySimulation.scores);
|
||||
energySimulation.scores.forEach((score, playerId) => {
|
||||
if (score === maxScore && maxScore > 0) {
|
||||
scores[playerId] += 2;
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const westLightCounts = state.players.map(() => 0);
|
||||
const eastLightCounts = state.players.map(() => 0);
|
||||
const highNoonCounts = state.players.map(() => 0);
|
||||
|
||||
energySimulation.columns.forEach((column) => {
|
||||
if (!column.intercepted || column.ownerId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const region = getColumnRegion(state, column.column);
|
||||
if (region === "left") {
|
||||
westLightCounts[column.ownerId] += 1;
|
||||
}
|
||||
if (region === "right") {
|
||||
eastLightCounts[column.ownerId] += 1;
|
||||
}
|
||||
if (region === "center") {
|
||||
highNoonCounts[column.ownerId] += 1;
|
||||
}
|
||||
if (
|
||||
effectId === "edge_bloom" &&
|
||||
(column.column === 0 || column.column === state.config.columns - 1)
|
||||
) {
|
||||
scores[column.ownerId] += 1;
|
||||
}
|
||||
});
|
||||
|
||||
if (effectId === "west_light") {
|
||||
addRoundedHalfBonus(scores, westLightCounts);
|
||||
}
|
||||
if (effectId === "east_light") {
|
||||
addRoundedHalfBonus(scores, eastLightCounts);
|
||||
}
|
||||
if (effectId === "high_noon") {
|
||||
addRoundedHalfBonus(scores, highNoonCounts);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function buildEnergySimulation(state: GameState): EnergySimulation {
|
||||
const parentMap = buildParentMap(state);
|
||||
const columns = [];
|
||||
const scores = state.players.map(() => 0);
|
||||
|
||||
for (let column = 0; column < state.config.columns; column += 1) {
|
||||
let hitNodeKey: NodeKey | null = null;
|
||||
|
||||
for (let row = 0; row < state.config.rows; row += 1) {
|
||||
const nodeKey = keyFor(row, column);
|
||||
if (state.nodes.has(nodeKey)) {
|
||||
hitNodeKey = nodeKey;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hitNodeKey) {
|
||||
columns.push({
|
||||
column,
|
||||
terminalRow: state.config.rows - 1,
|
||||
intercepted: false,
|
||||
ownerId: null,
|
||||
playersPresent: [],
|
||||
hitNode: null,
|
||||
rootKey: null,
|
||||
branchNodes: [],
|
||||
branchEdges: [],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const hitNode = parseKey(hitNodeKey);
|
||||
const ownerId = state.nodes.get(hitNodeKey)?.ownerId as PlayerId;
|
||||
const playersPresent = getColumnPresence(state, column);
|
||||
const branchNodes = [hitNode];
|
||||
const branchEdges = [];
|
||||
let cursor = hitNodeKey;
|
||||
|
||||
while (parentMap.has(cursor)) {
|
||||
const parentKey = parentMap.get(cursor) as NodeKey;
|
||||
branchEdges.push({ from: parseKey(cursor), to: parseKey(parentKey) });
|
||||
branchNodes.push(parseKey(parentKey));
|
||||
cursor = parentKey;
|
||||
}
|
||||
|
||||
applyColumnBaseEnergy(state, scores, ownerId, playersPresent);
|
||||
columns.push({
|
||||
column,
|
||||
terminalRow: hitNode.row,
|
||||
intercepted: true,
|
||||
ownerId,
|
||||
playersPresent,
|
||||
hitNode,
|
||||
rootKey: cursor,
|
||||
branchNodes,
|
||||
branchEdges,
|
||||
});
|
||||
}
|
||||
|
||||
const rootBurstMap = columns.reduce((map, column) => {
|
||||
if (!column.intercepted || !column.rootKey) {
|
||||
return map;
|
||||
}
|
||||
|
||||
const entry = map.get(column.rootKey) ?? {
|
||||
key: column.rootKey,
|
||||
playerId: column.ownerId as PlayerId,
|
||||
count: 0,
|
||||
displayCount: 0,
|
||||
};
|
||||
entry.count += 1;
|
||||
map.set(column.rootKey, entry);
|
||||
return map;
|
||||
}, new Map<NodeKey, RootBurst>());
|
||||
const rootBursts: RootBurst[] = [...rootBurstMap.values()];
|
||||
|
||||
applyWeatherEffects(state, scores, { scores, columns, rootBursts });
|
||||
|
||||
const playerRootTotals = state.players.map(() => 0);
|
||||
rootBursts.forEach((burst) => {
|
||||
playerRootTotals[burst.playerId] += burst.count;
|
||||
});
|
||||
|
||||
const playerLargestBurst = new Map<PlayerId, RootBurst>();
|
||||
rootBursts.forEach((burst) => {
|
||||
const current = playerLargestBurst.get(burst.playerId);
|
||||
if (!current || burst.count > current.count) {
|
||||
playerLargestBurst.set(burst.playerId, burst);
|
||||
}
|
||||
});
|
||||
|
||||
rootBursts.forEach((burst) => {
|
||||
burst.displayCount = burst.count;
|
||||
});
|
||||
|
||||
state.players.forEach((player) => {
|
||||
const largestBurst = playerLargestBurst.get(player.id);
|
||||
if (!largestBurst) {
|
||||
return;
|
||||
}
|
||||
|
||||
const extraEarned = scores[player.id] - playerRootTotals[player.id] + 1;
|
||||
largestBurst.displayCount += extraEarned;
|
||||
});
|
||||
|
||||
return {
|
||||
scores,
|
||||
columns,
|
||||
rootBursts,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildRoundAnimation(
|
||||
state: GameState,
|
||||
energySimulation: EnergySimulation,
|
||||
sunbeamPlayerId: PlayerId | null,
|
||||
diseaseKeys: NodeKey[],
|
||||
): RoundAnimation {
|
||||
const traces = energySimulation.columns
|
||||
.filter((column) => column.intercepted)
|
||||
.map((column) => ({
|
||||
playerId: column.ownerId as PlayerId,
|
||||
verticalCells: Array.from(
|
||||
{ length: column.terminalRow + 1 },
|
||||
(_, row) => ({ row, column: column.column }),
|
||||
),
|
||||
ray: {
|
||||
x: ((column.column + 0.5) / state.config.columns) * 100,
|
||||
y: ((column.terminalRow + 0.5) / state.config.rows) * 100,
|
||||
},
|
||||
rootKey: column.rootKey,
|
||||
branchNodes: column.branchNodes,
|
||||
}));
|
||||
|
||||
const bonusTrace =
|
||||
sunbeamPlayerId === null
|
||||
? null
|
||||
: (traces.find((trace) => trace.playerId === sunbeamPlayerId) ?? null);
|
||||
const bonusBurst = bonusTrace
|
||||
? (energySimulation.rootBursts.find(
|
||||
(burst) => burst.key === bonusTrace.rootKey,
|
||||
) ?? null)
|
||||
: null;
|
||||
|
||||
return {
|
||||
phase: "sunlight",
|
||||
columns: energySimulation.columns,
|
||||
traces,
|
||||
rootBursts: energySimulation.rootBursts,
|
||||
sunbeamPlayerId,
|
||||
bonusTrace,
|
||||
bonusBurst,
|
||||
diseaseKeys,
|
||||
};
|
||||
}
|
||||
|
||||
export function scoreColumns(state: GameState) {
|
||||
const energySimulation = buildEnergySimulation(state);
|
||||
const columnResults = energySimulation.columns.map((column) => ({
|
||||
column: column.column,
|
||||
ownerId: column.ownerId,
|
||||
topRow: column.intercepted ? column.terminalRow : null,
|
||||
tied: false,
|
||||
}));
|
||||
|
||||
return { scores: energySimulation.scores, columnResults, energySimulation };
|
||||
}
|
||||
|
||||
export function maybeRollSunbeam(state: GameState, scores: number[]) {
|
||||
const nextGrowth = scores.map((score) => score + 1);
|
||||
const { sunbeamChance } = state.randomEffects;
|
||||
|
||||
if (sunbeamChance <= 0 || Math.random() * 100 >= sunbeamChance) {
|
||||
return {
|
||||
nextGrowth,
|
||||
event: null,
|
||||
awardedPlayer: null,
|
||||
};
|
||||
}
|
||||
|
||||
const awardedPlayer = Math.floor(Math.random() * state.players.length);
|
||||
nextGrowth[awardedPlayer] += 1;
|
||||
|
||||
return {
|
||||
nextGrowth,
|
||||
awardedPlayer,
|
||||
event: `${state.players[awardedPlayer].name} caught a stray sunbeam and gains +1 growth next round.`,
|
||||
};
|
||||
}
|
||||
|
||||
export function maybeRollDisease(state: GameState) {
|
||||
const { diseaseChance } = state.randomEffects;
|
||||
if (diseaseChance <= 0 || Math.random() * 100 >= diseaseChance) {
|
||||
return {
|
||||
killedKeys: [],
|
||||
event: null,
|
||||
};
|
||||
}
|
||||
|
||||
const childrenMap = buildChildrenMap(state);
|
||||
const parentMap = buildParentMap(state);
|
||||
const twigKeys = Array.from(state.nodes.keys()).filter((nodeKey) => {
|
||||
const { row } = parseKey(nodeKey);
|
||||
return (
|
||||
row !== state.config.rows - 1 &&
|
||||
!childrenMap.has(nodeKey) &&
|
||||
parentMap.has(nodeKey)
|
||||
);
|
||||
});
|
||||
|
||||
if (twigKeys.length === 0) {
|
||||
return {
|
||||
killedKeys: [],
|
||||
event: null,
|
||||
};
|
||||
}
|
||||
|
||||
const shuffled = shuffleArray(twigKeys);
|
||||
const killCount = Math.min(
|
||||
shuffled.length,
|
||||
1 + Math.floor(Math.random() * Math.min(3, shuffled.length)),
|
||||
);
|
||||
const killedKeys = shuffled.slice(0, killCount);
|
||||
|
||||
return {
|
||||
killedKeys,
|
||||
event: `Disease spread through ${killCount} twig node${killCount === 1 ? "" : "s"} before the next round.`,
|
||||
};
|
||||
}
|
||||
83
src/engine/rules-weather.ts
Normal file
83
src/engine/rules-weather.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
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. Player with most branches gets 50% more energy (rounded down)." },
|
||||
{ id: "storehouse", title: "Storehouse", description: "Banked energy is safe but earns no interest. Lose 1 banked energy (min 0)." },
|
||||
{ id: "compound_interest", title: "Compound Interest", description: "Gain 20% interest on all banked energy (rounded down)." },
|
||||
{ id: "sun_ladder", title: "Sun Ladder", description: "Your first 3 vertical growths cost 0." },
|
||||
{ id: "west_light", title: "West Light", description: "Left third energy gets +50%, rounded up." },
|
||||
{ id: "east_light", title: "East Light", description: "Right third energy gets +50%, rounded up." },
|
||||
{ id: "high_noon", title: "High Noon", description: "Center third energy gets +50%, rounded up." },
|
||||
{ id: "edge_bloom", title: "Edge Bloom", description: "Edge columns give +1." },
|
||||
{ id: "wide_reach", title: "Wide Reach", description: "Most columns gets +2." },
|
||||
{ id: "tall_reward", title: "Tall Reward", description: "Tallest leaf on the board gives +2." },
|
||||
{ id: "deep_roots", title: "Deep Roots", description: "Shortest plant receives +4 energy." },
|
||||
{ id: "stalemate", title: "Stalemate", description: "Contested columns give no energy." },
|
||||
{ id: "split_light", title: "Split Light", description: "Contested columns give 1 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: "banking_mix", options: ["storehouse", "compound_interest"] },
|
||||
{ id: "tempo", options: ["sun_ladder", "edge_bloom"] },
|
||||
{ id: "side_bias", options: ["west_light", "east_light"] },
|
||||
{ id: "shape", options: ["high_noon", "wide_reach"] },
|
||||
{ id: "height", options: ["tall_reward", "deep_roots"] },
|
||||
{ id: "contest_soft", options: ["stalemate", "split_light"] },
|
||||
];
|
||||
|
||||
export function createWeatherDraft(state: GameState): WeatherDraftState {
|
||||
const rowSize = Math.min(WEATHER_OFFER_PAIRS.length, state.config.weatherDraftCount);
|
||||
|
||||
// Rotate draft order based on round number to ensure fairness
|
||||
const rotatedOrder = [...state.turnOrder];
|
||||
const rotation = (state.round - 1) % state.turnOrder.length;
|
||||
if (rotation > 0) {
|
||||
const start = rotatedOrder.splice(0, rotation);
|
||||
rotatedOrder.push(...start);
|
||||
}
|
||||
|
||||
return {
|
||||
playerOrder: rotatedOrder,
|
||||
draftIndex: 0,
|
||||
offers: shuffleArray(WEATHER_OFFER_PAIRS).slice(0, rowSize),
|
||||
drafted: [],
|
||||
banned: [],
|
||||
locked: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function getCurrentWeatherPlayerId(draft: WeatherDraftState) {
|
||||
return draft.playerOrder[draft.draftIndex] as PlayerId;
|
||||
}
|
||||
|
||||
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)
|
||||
&& !draft.locked.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.some((cardId) => draft.banned.includes(cardId) || draft.drafted.includes(cardId));
|
||||
}
|
||||
|
||||
export function getWeatherCard(cardId: WeatherCardId) {
|
||||
return WEATHER_CARD_LOOKUP.get(cardId) ?? null;
|
||||
}
|
||||
@@ -6,16 +6,47 @@ export function createDefaultPaletteOrder(playerCount: number) {
|
||||
return Array.from({ length: playerCount }, (_, index) => index % PLAYER_PALETTE.length);
|
||||
}
|
||||
|
||||
function getMinimumColumnsForEvenSpacing(playerCount: number, minColumns: number): number {
|
||||
// We need columns to be divisible by (playerCount + 1) for even spacing
|
||||
// Each player needs at least 1 column, so minimum is playerCount
|
||||
// But for even spacing from edges, we need: columns % (playerCount + 1) === 0
|
||||
const spacingDivisor = playerCount + 1;
|
||||
|
||||
// Start from at least minColumns or playerCount (whichever is larger)
|
||||
let columns = Math.max(minColumns, playerCount);
|
||||
|
||||
// Increase columns until it's divisible by (playerCount + 1)
|
||||
while (columns % spacingDivisor !== 0) {
|
||||
columns += 1;
|
||||
}
|
||||
|
||||
return columns;
|
||||
}
|
||||
|
||||
export function createDefaultSeedInputs(playerCount: number, columns: number, startingNodesPerPlayer: number) {
|
||||
const totalSeeds = playerCount * startingNodesPerPlayer;
|
||||
const positions = Array.from({ length: totalSeeds }, (_, index) => Math.floor(((index + 0.5) * columns) / totalSeeds));
|
||||
// Calculate spacing to place players equidistant from each other and edges
|
||||
// Formula: space = columns / (playerCount + 1)
|
||||
// This ensures equal spacing between players and from edges
|
||||
const spacing = columns / (playerCount + 1);
|
||||
|
||||
return Array.from({ length: playerCount }, (_, playerId) => {
|
||||
const start = playerId * startingNodesPerPlayer;
|
||||
return positions
|
||||
.slice(start, start + startingNodesPerPlayer)
|
||||
.map((column) => String(column + 1))
|
||||
.join(", ");
|
||||
const playerCenter = Math.round((playerId + 1) * spacing);
|
||||
|
||||
// For multiple seeds per player, spread them around the center
|
||||
if (startingNodesPerPlayer === 1) {
|
||||
return String(playerCenter + 1);
|
||||
}
|
||||
|
||||
// For multiple seeds, alternate left and right from center
|
||||
const positions = [];
|
||||
for (let i = 0; i < startingNodesPerPlayer; i++) {
|
||||
// Alternate: 0, +1, -1, +2, -2, etc.
|
||||
const offset = i === 0 ? 0 : (i % 2 === 1 ? Math.ceil(i / 2) : -Math.ceil(i / 2));
|
||||
const pos = Math.max(1, Math.min(columns, playerCenter + offset + 1));
|
||||
positions.push(pos);
|
||||
}
|
||||
|
||||
return positions.join(", ");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -68,7 +99,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,
|
||||
@@ -79,17 +111,23 @@ export function createSetupState(
|
||||
initiativeMode: SetupState["initiativeMode"] = "fixed",
|
||||
biddingOrderRule: SetupState["biddingOrderRule"] = "rotating",
|
||||
weatherDraftEnabled = true,
|
||||
weatherDraftCount = playerCount,
|
||||
bankingEnabled = true,
|
||||
winCondition: SetupState["winCondition"] = "rounds",
|
||||
maxRounds = 12,
|
||||
topLeafTarget = 4,
|
||||
): SetupState {
|
||||
const clampedSeeds = Math.min(startingNodesPerPlayer, getMaxStartingNodesPerPlayer(playerCount, columns));
|
||||
const defaults = createDefaultSeedInputs(playerCount, columns, clampedSeeds);
|
||||
// Adjust columns to ensure even spacing between players and edges
|
||||
const adjustedColumns = getMinimumColumnsForEvenSpacing(playerCount, columns);
|
||||
|
||||
const clampedSeeds = Math.min(startingNodesPerPlayer, getMaxStartingNodesPerPlayer(playerCount, adjustedColumns));
|
||||
const defaults = createDefaultSeedInputs(playerCount, adjustedColumns, clampedSeeds);
|
||||
const paletteDefaults = createDefaultPaletteOrder(playerCount);
|
||||
|
||||
return {
|
||||
playerCount,
|
||||
columns,
|
||||
playerNames: Array.from({ length: playerCount }, (_, index) => playerNames?.[index] ?? `Player ${index + 1}`),
|
||||
columns: adjustedColumns,
|
||||
rows,
|
||||
startingNodesPerPlayer: clampedSeeds,
|
||||
sunbeamChance,
|
||||
@@ -99,18 +137,24 @@ export function createSetupState(
|
||||
initiativeMode,
|
||||
biddingOrderRule,
|
||||
weatherDraftEnabled,
|
||||
weatherDraftCount: Math.max(1, weatherDraftCount),
|
||||
bankingEnabled,
|
||||
winCondition,
|
||||
maxRounds,
|
||||
topLeafTarget: Math.max(1, Math.min(columns, topLeafTarget)),
|
||||
topLeafTarget: Math.max(1, Math.min(adjustedColumns, topLeafTarget)),
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -163,7 +207,7 @@ export function normalizeSeedInputs(setup: SetupState) {
|
||||
|
||||
export function createInitialState(setup: SetupState): GameState {
|
||||
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 = [];
|
||||
@@ -174,7 +218,6 @@ export function createInitialState(setup: SetupState): GameState {
|
||||
nodes.set(keyFor(setup.rows - 1, column), { ownerId: index });
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
config: {
|
||||
columns: setup.columns,
|
||||
@@ -185,6 +228,8 @@ export function createInitialState(setup: SetupState): GameState {
|
||||
initiativeMode: setup.initiativeMode,
|
||||
biddingOrderRule: setup.biddingOrderRule,
|
||||
weatherDraftEnabled: setup.weatherDraftEnabled,
|
||||
weatherDraftCount: setup.weatherDraftCount,
|
||||
bankingEnabled: setup.bankingEnabled,
|
||||
winCondition: setup.winCondition,
|
||||
maxRounds: setup.maxRounds,
|
||||
topLeafTarget: setup.topLeafTarget,
|
||||
@@ -14,6 +14,7 @@ export type Position = {
|
||||
|
||||
export type SetupState = {
|
||||
playerCount: number;
|
||||
playerNames: string[];
|
||||
columns: number;
|
||||
rows: number;
|
||||
startingNodesPerPlayer: number;
|
||||
@@ -24,6 +25,8 @@ export type SetupState = {
|
||||
initiativeMode: "fixed" | "bid";
|
||||
biddingOrderRule: "rotating" | "lowest_growth_income";
|
||||
weatherDraftEnabled: boolean;
|
||||
weatherDraftCount: number;
|
||||
bankingEnabled: boolean;
|
||||
winCondition: "rounds" | "top_leaves";
|
||||
maxRounds: number;
|
||||
topLeafTarget: number;
|
||||
@@ -52,6 +55,8 @@ export type GameConfig = {
|
||||
initiativeMode: SetupState["initiativeMode"];
|
||||
biddingOrderRule: SetupState["biddingOrderRule"];
|
||||
weatherDraftEnabled: boolean;
|
||||
weatherDraftCount: number;
|
||||
bankingEnabled: boolean;
|
||||
winCondition: SetupState["winCondition"];
|
||||
maxRounds: number;
|
||||
topLeafTarget: number;
|
||||
@@ -110,6 +115,7 @@ export type ColumnEnergy = {
|
||||
terminalRow: number;
|
||||
intercepted: boolean;
|
||||
ownerId: PlayerId | null;
|
||||
playersPresent: PlayerId[];
|
||||
hitNode: Position | null;
|
||||
rootKey: NodeKey | null;
|
||||
branchNodes: Position[];
|
||||
@@ -120,6 +126,7 @@ export type RootBurst = {
|
||||
key: NodeKey;
|
||||
playerId: PlayerId;
|
||||
count: number;
|
||||
displayCount: number;
|
||||
};
|
||||
|
||||
export type EnergySimulation = {
|
||||
@@ -161,7 +168,7 @@ export type RoundSummary = {
|
||||
};
|
||||
|
||||
export type ScoreSnapshot = {
|
||||
currentExposure: number;
|
||||
projectedIncome: number;
|
||||
growthPoints: number;
|
||||
bankedPoints: number;
|
||||
lifetimeGrowthIncome: number;
|
||||
@@ -183,12 +190,18 @@ export type GamePhase = "initiative" | "turn" | "round_end" | "game_over";
|
||||
export type WeatherCardId =
|
||||
| "leaf_surge"
|
||||
| "branching_season"
|
||||
| "storehouse"
|
||||
| "compound_interest"
|
||||
| "sun_ladder"
|
||||
| "west_light"
|
||||
| "east_light"
|
||||
| "high_noon"
|
||||
| "edge_bloom"
|
||||
| "wide_reach"
|
||||
| "tall_reward";
|
||||
| "tall_reward"
|
||||
| "deep_roots"
|
||||
| "stalemate"
|
||||
| "split_light";
|
||||
|
||||
export type WeatherCardDefinition = {
|
||||
id: WeatherCardId;
|
||||
@@ -196,6 +209,11 @@ export type WeatherCardDefinition = {
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type WeatherOfferPair = {
|
||||
id: string;
|
||||
options: [WeatherCardId, WeatherCardId];
|
||||
};
|
||||
|
||||
export type InitiativeDraftState = {
|
||||
biddingOrder: PlayerId[];
|
||||
biddingIndex: number;
|
||||
@@ -206,9 +224,10 @@ export type InitiativeDraftState = {
|
||||
export type WeatherDraftState = {
|
||||
playerOrder: PlayerId[];
|
||||
draftIndex: number;
|
||||
row: WeatherCardId[];
|
||||
offers: WeatherOfferPair[];
|
||||
drafted: WeatherCardId[];
|
||||
banned: WeatherCardId[];
|
||||
locked: WeatherCardId[];
|
||||
};
|
||||
|
||||
export type GameState = {
|
||||
1508
src/main.js
1508
src/main.js
File diff suppressed because it is too large
Load Diff
750
src/main.ts
750
src/main.ts
File diff suppressed because it is too large
Load Diff
@@ -1,289 +0,0 @@
|
||||
import type { EnergySimulation, GameState, NodeKey, PlayerId, RootBurst, RoundAnimation } from "./types";
|
||||
import { keyFor, parseKey, shuffleArray } from "./utils";
|
||||
import { buildChildrenMap, buildParentMap } from "./rules-board";
|
||||
|
||||
function getColumnRegion(state: GameState, column: number) {
|
||||
const third = state.config.columns / 3;
|
||||
if (column < third) {
|
||||
return "left";
|
||||
}
|
||||
|
||||
if (column >= state.config.columns - third) {
|
||||
return "right";
|
||||
}
|
||||
|
||||
return "center";
|
||||
}
|
||||
|
||||
function getLeafCounts(state: GameState) {
|
||||
const childrenMap = buildChildrenMap(state);
|
||||
const counts = state.players.map(() => 0);
|
||||
|
||||
Array.from(state.nodes.entries()).forEach(([nodeKey, node]) => {
|
||||
if (!(childrenMap.get(nodeKey)?.length)) {
|
||||
counts[node.ownerId] += 1;
|
||||
}
|
||||
});
|
||||
|
||||
return counts;
|
||||
}
|
||||
|
||||
function applyWeatherEffects(state: GameState, scores: number[], energySimulation: EnergySimulation) {
|
||||
if (state.activeRoundEffects.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const leafCounts = getLeafCounts(state);
|
||||
const childrenMap = buildChildrenMap(state);
|
||||
const tallestLeaves = state.players.map(() => null as number | null);
|
||||
|
||||
Array.from(state.nodes.entries()).forEach(([nodeKey, node]) => {
|
||||
const leafCount = leafCounts[node.ownerId];
|
||||
if (leafCount <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (childrenMap.get(nodeKey)?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { row } = parseKey(nodeKey);
|
||||
const currentTallest = tallestLeaves[node.ownerId];
|
||||
if (currentTallest === null || row < currentTallest) {
|
||||
tallestLeaves[node.ownerId] = row;
|
||||
}
|
||||
});
|
||||
|
||||
state.activeRoundEffects.forEach((effectId) => {
|
||||
if (effectId === "leaf_surge") {
|
||||
leafCounts.forEach((count, playerId) => {
|
||||
scores[playerId] += count;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (effectId === "branching_season") {
|
||||
leafCounts.forEach((count, playerId) => {
|
||||
scores[playerId] += Math.max(0, count - 1);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (effectId === "tall_reward") {
|
||||
tallestLeaves.forEach((row, playerId) => {
|
||||
if (row !== null) {
|
||||
scores[playerId] += 2;
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (effectId === "wide_reach") {
|
||||
const maxScore = Math.max(...energySimulation.scores);
|
||||
energySimulation.scores.forEach((score, playerId) => {
|
||||
if (score === maxScore && maxScore > 0) {
|
||||
scores[playerId] += 2;
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
energySimulation.columns.forEach((column) => {
|
||||
if (!column.intercepted || column.ownerId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const region = getColumnRegion(state, column.column);
|
||||
if (effectId === "west_light" && region === "left") {
|
||||
scores[column.ownerId] += 1;
|
||||
}
|
||||
if (effectId === "east_light" && region === "right") {
|
||||
scores[column.ownerId] += 1;
|
||||
}
|
||||
if (effectId === "high_noon" && region === "center") {
|
||||
scores[column.ownerId] += 1;
|
||||
}
|
||||
if (effectId === "edge_bloom" && (column.column === 0 || column.column === state.config.columns - 1)) {
|
||||
scores[column.ownerId] += 1;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function buildEnergySimulation(state: GameState): EnergySimulation {
|
||||
const parentMap = buildParentMap(state);
|
||||
const columns = [];
|
||||
const scores = state.players.map(() => 0);
|
||||
|
||||
for (let column = 0; column < state.config.columns; column += 1) {
|
||||
let hitNodeKey: NodeKey | null = null;
|
||||
|
||||
for (let row = 0; row < state.config.rows; row += 1) {
|
||||
const nodeKey = keyFor(row, column);
|
||||
if (state.nodes.has(nodeKey)) {
|
||||
hitNodeKey = nodeKey;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hitNodeKey) {
|
||||
columns.push({
|
||||
column,
|
||||
terminalRow: state.config.rows - 1,
|
||||
intercepted: false,
|
||||
ownerId: null,
|
||||
hitNode: null,
|
||||
rootKey: null,
|
||||
branchNodes: [],
|
||||
branchEdges: [],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const hitNode = parseKey(hitNodeKey);
|
||||
const ownerId = state.nodes.get(hitNodeKey)?.ownerId as PlayerId;
|
||||
const branchNodes = [hitNode];
|
||||
const branchEdges = [];
|
||||
let cursor = hitNodeKey;
|
||||
|
||||
while (parentMap.has(cursor)) {
|
||||
const parentKey = parentMap.get(cursor) as NodeKey;
|
||||
branchEdges.push({ from: parseKey(cursor), to: parseKey(parentKey) });
|
||||
branchNodes.push(parseKey(parentKey));
|
||||
cursor = parentKey;
|
||||
}
|
||||
|
||||
scores[ownerId] += 1;
|
||||
columns.push({
|
||||
column,
|
||||
terminalRow: hitNode.row,
|
||||
intercepted: true,
|
||||
ownerId,
|
||||
hitNode,
|
||||
rootKey: cursor,
|
||||
branchNodes,
|
||||
branchEdges,
|
||||
});
|
||||
}
|
||||
|
||||
const rootBurstMap = columns.reduce((map, column) => {
|
||||
if (!column.intercepted || !column.rootKey) {
|
||||
return map;
|
||||
}
|
||||
|
||||
const entry = map.get(column.rootKey) ?? { key: column.rootKey, playerId: column.ownerId as PlayerId, count: 0 };
|
||||
entry.count += 1;
|
||||
map.set(column.rootKey, entry);
|
||||
return map;
|
||||
}, new Map<NodeKey, RootBurst>());
|
||||
const rootBursts: RootBurst[] = [...rootBurstMap.values()];
|
||||
|
||||
applyWeatherEffects(state, scores, { scores, columns, rootBursts });
|
||||
|
||||
return {
|
||||
scores,
|
||||
columns,
|
||||
rootBursts,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildRoundAnimation(
|
||||
state: GameState,
|
||||
energySimulation: EnergySimulation,
|
||||
sunbeamPlayerId: PlayerId | null,
|
||||
diseaseKeys: NodeKey[],
|
||||
): RoundAnimation {
|
||||
const traces = energySimulation.columns
|
||||
.filter((column) => column.intercepted)
|
||||
.map((column) => ({
|
||||
playerId: column.ownerId as PlayerId,
|
||||
verticalCells: Array.from({ length: column.terminalRow + 1 }, (_, row) => ({ row, column: column.column })),
|
||||
ray: {
|
||||
x: ((column.column + 0.5) / state.config.columns) * 100,
|
||||
y: ((column.terminalRow + 0.5) / state.config.rows) * 100,
|
||||
},
|
||||
rootKey: column.rootKey,
|
||||
branchNodes: column.branchNodes,
|
||||
}));
|
||||
|
||||
const bonusTrace = sunbeamPlayerId === null ? null : traces.find((trace) => trace.playerId === sunbeamPlayerId) ?? null;
|
||||
const bonusBurst = bonusTrace ? energySimulation.rootBursts.find((burst) => burst.key === bonusTrace.rootKey) ?? null : null;
|
||||
|
||||
return {
|
||||
phase: "sunlight",
|
||||
columns: energySimulation.columns,
|
||||
traces,
|
||||
rootBursts: energySimulation.rootBursts,
|
||||
sunbeamPlayerId,
|
||||
bonusTrace,
|
||||
bonusBurst,
|
||||
diseaseKeys,
|
||||
};
|
||||
}
|
||||
|
||||
export function scoreColumns(state: GameState) {
|
||||
const energySimulation = buildEnergySimulation(state);
|
||||
const columnResults = energySimulation.columns.map((column) => ({
|
||||
column: column.column,
|
||||
ownerId: column.ownerId,
|
||||
topRow: column.intercepted ? column.terminalRow : null,
|
||||
tied: false,
|
||||
}));
|
||||
|
||||
return { scores: energySimulation.scores, columnResults, energySimulation };
|
||||
}
|
||||
|
||||
export function maybeRollSunbeam(state: GameState, scores: number[]) {
|
||||
const nextGrowth = scores.map((score) => score + 1);
|
||||
const { sunbeamChance } = state.randomEffects;
|
||||
|
||||
if (sunbeamChance <= 0 || Math.random() * 100 >= sunbeamChance) {
|
||||
return {
|
||||
nextGrowth,
|
||||
event: null,
|
||||
awardedPlayer: null,
|
||||
};
|
||||
}
|
||||
|
||||
const awardedPlayer = Math.floor(Math.random() * state.players.length);
|
||||
nextGrowth[awardedPlayer] += 1;
|
||||
|
||||
return {
|
||||
nextGrowth,
|
||||
awardedPlayer,
|
||||
event: `${state.players[awardedPlayer].name} caught a stray sunbeam and gains +1 growth next round.`,
|
||||
};
|
||||
}
|
||||
|
||||
export function maybeRollDisease(state: GameState) {
|
||||
const { diseaseChance } = state.randomEffects;
|
||||
if (diseaseChance <= 0 || Math.random() * 100 >= diseaseChance) {
|
||||
return {
|
||||
killedKeys: [],
|
||||
event: null,
|
||||
};
|
||||
}
|
||||
|
||||
const childrenMap = buildChildrenMap(state);
|
||||
const parentMap = buildParentMap(state);
|
||||
const twigKeys = Array.from(state.nodes.keys()).filter((nodeKey) => {
|
||||
const { row } = parseKey(nodeKey);
|
||||
return row !== state.config.rows - 1 && !childrenMap.has(nodeKey) && parentMap.has(nodeKey);
|
||||
});
|
||||
|
||||
if (twigKeys.length === 0) {
|
||||
return {
|
||||
killedKeys: [],
|
||||
event: null,
|
||||
};
|
||||
}
|
||||
|
||||
const shuffled = shuffleArray(twigKeys);
|
||||
const killCount = Math.min(shuffled.length, 1 + Math.floor(Math.random() * Math.min(3, shuffled.length)));
|
||||
const killedKeys = shuffled.slice(0, killCount);
|
||||
|
||||
return {
|
||||
killedKeys,
|
||||
event: `Disease spread through ${killCount} twig node${killCount === 1 ? "" : "s"} before the next round.`,
|
||||
};
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import type { GameState, PlayerId, WeatherCardDefinition, WeatherCardId, WeatherDraftState } 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: "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." },
|
||||
{ id: "edge_bloom", title: "Edge Bloom", description: "Edge columns give +1." },
|
||||
{ id: "wide_reach", title: "Wide Reach", description: "Most columns gets +2." },
|
||||
{ id: "tall_reward", title: "Tall Reward", description: "Your tallest leaf gives +2." },
|
||||
];
|
||||
|
||||
export const WEATHER_CARD_LOOKUP = new Map<WeatherCardId, WeatherCardDefinition>(
|
||||
WEATHER_CARDS.map((card) => [card.id, card]),
|
||||
);
|
||||
|
||||
export function createWeatherDraft(state: GameState): WeatherDraftState {
|
||||
const rowSize = Math.min(WEATHER_CARDS.length, state.players.length + 2);
|
||||
|
||||
return {
|
||||
playerOrder: [...state.turnOrder],
|
||||
draftIndex: 0,
|
||||
row: shuffleArray(WEATHER_CARDS.map((card) => card.id)).slice(0, rowSize),
|
||||
drafted: [],
|
||||
banned: [],
|
||||
};
|
||||
}
|
||||
|
||||
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 getWeatherCard(cardId: WeatherCardId) {
|
||||
return WEATHER_CARD_LOOKUP.get(cardId) ?? null;
|
||||
}
|
||||
963
src/styles.css
963
src/styles.css
@@ -1,963 +0,0 @@
|
||||
/* Grid-based TV-Optimized Layout Framework */
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(48, 72, 104, 0.35), transparent 42%),
|
||||
linear-gradient(180deg, #0b1220 0%, #070b13 100%);
|
||||
color: #f4f7fb;
|
||||
|
||||
/* Layout constants */
|
||||
--bottom-bar-height: 100px;
|
||||
--sidebar-min-width: 280px;
|
||||
--sidebar-max-width: 380px;
|
||||
--gap-size: 0.75rem;
|
||||
--padding-size: 0.75rem;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Main App Container - fills viewport accounting for browser chrome */
|
||||
#app {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
#app > * {
|
||||
width: 100%;
|
||||
height: calc(100vh - 100px);
|
||||
max-width: 100%;
|
||||
max-height: calc(100vh - 100px);
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Main Layout Grid */
|
||||
.layout {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
"main sidebar"
|
||||
"bottom bottom";
|
||||
grid-template-columns: 1fr minmax(var(--sidebar-min-width), var(--sidebar-max-width));
|
||||
grid-template-rows: 1fr var(--bottom-bar-height);
|
||||
gap: var(--gap-size);
|
||||
padding: var(--padding-size);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Game Area - Main left section */
|
||||
.game-area {
|
||||
grid-area: main;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Board shell fills the game area */
|
||||
.board-shell {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 1.25rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(9, 16, 29, 0.72);
|
||||
backdrop-filter: blur(20px);
|
||||
padding: 0.5rem;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Board - fits within shell */
|
||||
.board {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
aspect-ratio: var(--board-columns) / var(--board-rows);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--board-columns), 1fr);
|
||||
grid-template-rows: repeat(var(--board-rows), 1fr);
|
||||
gap: clamp(2px, 0.3cqmin, 4px);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Sidebar - Right panel */
|
||||
.sidebar {
|
||||
grid-area: sidebar;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Bottom bar - Fixed height player scores */
|
||||
.scoreboard {
|
||||
grid-area: bottom;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--player-count, 3), 1fr);
|
||||
gap: 0.75rem;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.score-card {
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 1rem;
|
||||
padding: 1rem 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;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.score-card.active {
|
||||
border-color: color-mix(in srgb, var(--player-color) 55%, white);
|
||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.08), 0 0 24px var(--player-glow);
|
||||
}
|
||||
|
||||
/* Sidebar panels */
|
||||
.panel {
|
||||
border-radius: 1.25rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(9, 16, 29, 0.72);
|
||||
backdrop-filter: blur(20px);
|
||||
padding: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.controls-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.log-panel {
|
||||
max-height: 120px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Cell styling */
|
||||
.cell {
|
||||
position: relative;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-radius: clamp(4px, 15%, 0.6rem);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.cell__shade {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), var(--column-tint));
|
||||
}
|
||||
|
||||
.cell__root-ring {
|
||||
position: absolute;
|
||||
inset: 18% 18%;
|
||||
border: 1px dashed rgba(255, 255, 255, 0.28);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.cell__node,
|
||||
.cell__target-label {
|
||||
position: absolute;
|
||||
inset: 50% auto auto 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.cell__node {
|
||||
width: clamp(8px, 35%, 1.2rem);
|
||||
height: clamp(8px, 35%, 1.2rem);
|
||||
border-radius: 50%;
|
||||
background: var(--node-color);
|
||||
box-shadow: 0 0 0 0.15rem rgba(255, 255, 255, 0.06), 0 0 1rem var(--node-glow);
|
||||
aspect-ratio: 1 / 1;
|
||||
}
|
||||
|
||||
.cell.selected {
|
||||
border-color: rgba(255, 255, 255, 0.55);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.22);
|
||||
}
|
||||
|
||||
.cell.pending {
|
||||
border-color: rgba(255, 255, 255, 0.28);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.12), 0 0 18px rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.cell.pending .cell__node {
|
||||
box-shadow: 0 0 0 0.18rem rgba(255, 255, 255, 0.08), 0 0 1.1rem var(--node-glow), 0 0 1.5rem rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.cell.target {
|
||||
border-color: rgba(255, 255, 255, 0.22);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.cell.target:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.cell__target-label {
|
||||
width: min(60%, 1.5rem);
|
||||
height: min(60%, 1.5rem);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.09);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
font-weight: 700;
|
||||
font-size: clamp(0.6rem, 2cqmin, 0.9rem);
|
||||
}
|
||||
|
||||
/* Board overlays */
|
||||
.board__lines {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
filter: drop-shadow(0 0 8px rgba(255, 255, 255, 0.18));
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.board__fx {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
.board__drop-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.board__energy-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
.board__energy-cell {
|
||||
position: absolute;
|
||||
border-radius: clamp(4px, 15%, 0.6rem);
|
||||
background:
|
||||
radial-gradient(circle at 50% 50%, rgba(255, 255, 255, 0.95), color-mix(in srgb, var(--flash-color) 70%, #ffe08a) 34%, rgba(255, 224, 138, 0.18) 72%, transparent 100%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.18), transparent);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.12), 0 0 18px color-mix(in srgb, var(--flash-color) 55%, #ffe08a);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.board__energy-cell--sunlight {
|
||||
inset: 12%;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 244, 214, 0.05), rgba(255, 244, 214, 0.008)),
|
||||
radial-gradient(circle at 50% 50%, rgba(255, 242, 196, 0.11), color-mix(in srgb, var(--flash-color) 10%, #ffe3a3) 42%, rgba(255, 221, 128, 0.03) 72%, transparent 100%);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 245, 224, 0.02), 0 0 6px color-mix(in srgb, var(--flash-color) 8%, #ffe3a3);
|
||||
}
|
||||
|
||||
.board--sunlight .board__energy-cell--sunlight,
|
||||
.board--branches .board__energy-cell,
|
||||
.board--bonus .board__energy-cell--bonus {
|
||||
animation: energy-cell-flash 0.48s ease forwards;
|
||||
animation-delay: var(--flash-delay, 0ms);
|
||||
}
|
||||
|
||||
.board__energy-cell--bonus {
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.18), 0 0 22px rgba(255, 216, 94, 0.9);
|
||||
}
|
||||
|
||||
.board--bonus .board__drop--bonus {
|
||||
animation: sunlight-drop 0.85s cubic-bezier(0.2, 0.7, 0.2, 1) forwards;
|
||||
}
|
||||
|
||||
.board__drop-core,
|
||||
.board__drop-spark {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.board__drop-core {
|
||||
background: radial-gradient(circle at 35% 30%, rgba(255, 255, 255, 0.98), color-mix(in srgb, var(--drop-color) 72%, #ffe38a) 45%, rgba(255, 227, 138, 0.18) 100%);
|
||||
box-shadow: 0 0 18px color-mix(in srgb, var(--drop-color) 40%, #ffe38a), 0 0 32px rgba(255, 236, 174, 0.65);
|
||||
}
|
||||
|
||||
.board__drop-spark {
|
||||
inset: 35%;
|
||||
border: 1px solid rgba(255, 248, 220, 0.95);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.board__drop-spark--a { transform: translate(-0.8rem, -0.15rem) scale(0.55); }
|
||||
.board__drop-spark--b { transform: translate(0.75rem, -0.1rem) scale(0.45); }
|
||||
.board__drop-spark--c { transform: translate(0.1rem, -0.75rem) scale(0.35); }
|
||||
|
||||
.board__root-burst,
|
||||
.board__disease-mark,
|
||||
.board__sunbeam-burst {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.board__root-burst circle,
|
||||
.board__sunbeam-burst {
|
||||
fill: color-mix(in srgb, var(--burst-color, #ffd85e) 70%, white);
|
||||
stroke: rgba(255, 255, 255, 0.65);
|
||||
stroke-width: 0.35;
|
||||
}
|
||||
|
||||
.board__root-burst text {
|
||||
fill: #08111c;
|
||||
font-size: 2.1px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.board__disease-mark circle {
|
||||
fill: rgba(162, 255, 142, 0.2);
|
||||
stroke: rgba(162, 255, 142, 0.9);
|
||||
stroke-width: 0.35;
|
||||
}
|
||||
|
||||
.board__disease-mark path {
|
||||
stroke: rgba(162, 255, 142, 1);
|
||||
stroke-width: 0.5;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.board--branches .board__root-burst,
|
||||
.board--events .board__root-burst,
|
||||
.board--events .board__disease-mark,
|
||||
.board--bonus .board__sunbeam-burst,
|
||||
.board--events .board__sunbeam-burst {
|
||||
animation: pop-fade 0.8s ease forwards;
|
||||
animation-delay: var(--trace-delay, 0ms);
|
||||
}
|
||||
|
||||
.board__sunbeam-burst text {
|
||||
fill: #08111c;
|
||||
font-size: 2.1px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
/* Score card content */
|
||||
.score-card__head,
|
||||
.score-card__numbers,
|
||||
.panel__title-row,
|
||||
.button-row,
|
||||
.setup-grid,
|
||||
.toggle-row,
|
||||
.active-turn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.score-card__head,
|
||||
.panel__title-row,
|
||||
.button-row,
|
||||
.toggle-row {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.score-card__identity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.score-card__head h2,
|
||||
.panel h1,
|
||||
.panel h2,
|
||||
.active-turn h2 {
|
||||
margin: 0;
|
||||
font-size: clamp(0.9rem, 2.5cqmin, 1.2rem);
|
||||
}
|
||||
|
||||
.score-card__numbers {
|
||||
margin-top: 0.5rem;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.score-card__footer {
|
||||
margin-top: 0.5rem;
|
||||
padding-top: 0.4rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
color: rgba(231, 238, 247, 0.72);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.score-card__numbers div {
|
||||
display: grid;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.score-card__numbers span,
|
||||
.eyebrow,
|
||||
label span,
|
||||
.log-list p,
|
||||
.status-panel p,
|
||||
.active-turn p,
|
||||
.effect-empty {
|
||||
color: rgba(231, 238, 247, 0.72);
|
||||
}
|
||||
|
||||
.score-card__meta {
|
||||
font-size: 0.75rem;
|
||||
color: rgba(231, 238, 247, 0.7);
|
||||
}
|
||||
|
||||
.score-card__numbers strong {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.score-value {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.score-value.changed {
|
||||
animation: score-pop 0.7s ease;
|
||||
}
|
||||
|
||||
.player-dot {
|
||||
width: 0.85rem;
|
||||
height: 0.85rem;
|
||||
border-radius: 999px;
|
||||
background: var(--player-color);
|
||||
box-shadow: 0 0 16px var(--player-glow);
|
||||
}
|
||||
|
||||
/* Sidebar content */
|
||||
.panel__actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.button-row {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.button-row button,
|
||||
.ghost-button {
|
||||
min-height: 2.4rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.85rem;
|
||||
background: #f4f7fb;
|
||||
color: #0a1020;
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ghost-button,
|
||||
#finish-game {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #f4f7fb;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.log-list {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.8rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.log-list p,
|
||||
.status-panel p,
|
||||
.active-turn p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.event-note {
|
||||
color: #ffd577;
|
||||
}
|
||||
|
||||
.active-turn {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.25rem;
|
||||
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));
|
||||
}
|
||||
|
||||
/* Form elements */
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
input[type="number"],
|
||||
input[type="text"],
|
||||
select {
|
||||
width: 100%;
|
||||
min-height: 2.4rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #f4f7fb;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
input[type="range"] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Modal styles */
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1rem;
|
||||
background: rgba(3, 8, 16, 0.72);
|
||||
backdrop-filter: blur(14px);
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.modal {
|
||||
width: min(1180px, 100%);
|
||||
max-height: min(92%, 980px);
|
||||
overflow: auto;
|
||||
border-radius: 1.25rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(9, 16, 29, 0.72);
|
||||
backdrop-filter: blur(20px);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* Setup Modal - Redesigned */
|
||||
.setup-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 900px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
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);
|
||||
}
|
||||
|
||||
.modal-header__title h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.setup-tab {
|
||||
min-height: 3rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 0.15rem;
|
||||
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);
|
||||
}
|
||||
|
||||
.setup-tab--active {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #f4f7fb;
|
||||
border-color: rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
|
||||
.setup-tab span:first-child {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.setup-tab span:last-child {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
/* Setup Sections */
|
||||
.setup-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.setup-section__title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
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 {
|
||||
font-size: 0.875rem;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Setup Grid */
|
||||
.setup-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.setup-grid--2col {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.setup-grid--3col {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.setup-tabs {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.setup-grid,
|
||||
.setup-grid--2col,
|
||||
.setup-grid--3col {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Setup Field */
|
||||
.setup-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.setup-field__label {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.setup-field__input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.setup-field__value {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
min-width: 1.5rem;
|
||||
}
|
||||
|
||||
.setup-field--range input[type="range"] {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.setup-field--checkbox {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.setup-field--checkbox .setup-field__label {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.setup-field input[type="number"],
|
||||
.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);
|
||||
color: #f4f7fb;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.setup-field input[type="checkbox"] {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
accent-color: #4a9eff;
|
||||
}
|
||||
|
||||
/* Player List */
|
||||
.player-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.player-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.625rem 0.875rem;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 0.625rem;
|
||||
}
|
||||
|
||||
.player-row__info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
.player-row__name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.player-row__actions {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.player-row__actions .mini-button {
|
||||
min-height: 1.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Mini button for player reordering */
|
||||
.mini-button {
|
||||
min-height: 2rem;
|
||||
padding: 0.375rem 0.625rem;
|
||||
border-radius: 0.5rem;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #f4f7fb;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mini-button:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.mini-button:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.draft-panel {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
width: max(320px, 30%);
|
||||
max-width: calc(100% - 2rem);
|
||||
max-height: calc(100% - 9.5rem);
|
||||
overflow: auto;
|
||||
z-index: 24;
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
background: rgba(9, 16, 29, 0.5);
|
||||
backdrop-filter: blur(18px);
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.32);
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes sunlight-drop {
|
||||
0% {
|
||||
opacity: 0;
|
||||
top: -0.9rem;
|
||||
transform: scale(0.65);
|
||||
}
|
||||
12% {
|
||||
opacity: 1;
|
||||
}
|
||||
85% {
|
||||
opacity: 1;
|
||||
top: calc(var(--drop-end) - 0.55rem);
|
||||
transform: scale(1);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
top: calc(var(--drop-end) - 0.55rem);
|
||||
transform: scale(1.25);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pop-fade {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.65);
|
||||
}
|
||||
25% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes energy-cell-flash {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
20% {
|
||||
opacity: 0.98;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes score-pop {
|
||||
0% {
|
||||
transform: scale(0.88);
|
||||
color: #fff7d6;
|
||||
text-shadow: 0 0 0 rgba(255, 235, 153, 0);
|
||||
}
|
||||
35% {
|
||||
transform: scale(1.18);
|
||||
color: #ffe480;
|
||||
text-shadow: 0 0 16px rgba(255, 228, 128, 0.9);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
color: inherit;
|
||||
text-shadow: 0 0 0 rgba(255, 235, 153, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Fullscreen mode adjustments */
|
||||
:fullscreen #app > *,
|
||||
:-webkit-full-screen #app > *,
|
||||
:-moz-full-screen #app > * {
|
||||
max-height: 100vh;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 900px) {
|
||||
:root {
|
||||
--bottom-bar-height: 80px;
|
||||
--sidebar-min-width: 240px;
|
||||
}
|
||||
|
||||
.layout {
|
||||
grid-template-columns: 1fr minmax(var(--sidebar-min-width), 320px);
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.score-card {
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
|
||||
.score-card__head h2 {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.score-card__numbers strong {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.layout {
|
||||
grid-template-areas:
|
||||
"main"
|
||||
"sidebar"
|
||||
"bottom";
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 2fr auto var(--bottom-bar-height);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
flex-direction: row;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.panel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
1700
src/styles/globals.css
Normal file
1700
src/styles/globals.css
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user