Compare commits
4 Commits
e11264168c
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| c4da8c942e | |||
| f071837ed6 | |||
| 856f0049b7 | |||
| 30e3f88b21 |
@@ -2,7 +2,7 @@
|
|||||||
cmds = ["npm run build"]
|
cmds = ["npm run build"]
|
||||||
|
|
||||||
[start]
|
[start]
|
||||||
cmd = "npx serve dist -l 80 -s"
|
cmd = "serve dist -l 80 -s"
|
||||||
|
|
||||||
[healthcheck]
|
[healthcheck]
|
||||||
cmd = "curl -f http://localhost:80/ || exit 1"
|
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",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"start": "npx serve dist -l 80 -s",
|
"start": "serve dist -l 80 -s",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^6.0.2",
|
"typescript": "^6.0.2",
|
||||||
"vite": "^5.4.19"
|
"vite": "^5.4.19"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"serve": "^14.2.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import type { GameState, InitiativeDraftState, PlayerId } from "./types";
|
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) {
|
export function getSeatBonuses(state: GameState) {
|
||||||
const graceRounds = Math.max(0, Math.floor(state.config.columns / state.players.length) - state.players.length);
|
const graceRounds = getInitiativeGraceRounds(state);
|
||||||
const firstSeatBonus = state.round <= graceRounds ? 0 : 1;
|
const firstSeatBonus = state.round - 1 < graceRounds ? 0 : 1;
|
||||||
|
|
||||||
return Array.from({ length: state.players.length }, (_, index) => (index === 0 ? firstSeatBonus : 0));
|
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);
|
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) {
|
export function createDefaultSeedInputs(playerCount: number, columns: number, startingNodesPerPlayer: number) {
|
||||||
const totalSeeds = playerCount * startingNodesPerPlayer;
|
// Calculate spacing to place players equidistant from each other and edges
|
||||||
const positions = Array.from({ length: totalSeeds }, (_, index) => Math.floor(((index + 0.5) * columns) / totalSeeds));
|
// 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) => {
|
return Array.from({ length: playerCount }, (_, playerId) => {
|
||||||
const start = playerId * startingNodesPerPlayer;
|
const playerCenter = Math.round((playerId + 1) * spacing);
|
||||||
return positions
|
|
||||||
.slice(start, start + startingNodesPerPlayer)
|
// For multiple seeds per player, spread them around the center
|
||||||
.map((column) => String(column + 1))
|
if (startingNodesPerPlayer === 1) {
|
||||||
.join(", ");
|
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(
|
export function createSetupState(
|
||||||
playerCount = 3,
|
playerCount = 2,
|
||||||
|
playerNames: string[] | null = null,
|
||||||
columns = 18,
|
columns = 18,
|
||||||
rows = 16,
|
rows = 16,
|
||||||
startingNodesPerPlayer = 1,
|
startingNodesPerPlayer = 1,
|
||||||
@@ -79,19 +111,23 @@ export function createSetupState(
|
|||||||
initiativeMode: SetupState["initiativeMode"] = "fixed",
|
initiativeMode: SetupState["initiativeMode"] = "fixed",
|
||||||
biddingOrderRule: SetupState["biddingOrderRule"] = "rotating",
|
biddingOrderRule: SetupState["biddingOrderRule"] = "rotating",
|
||||||
weatherDraftEnabled = true,
|
weatherDraftEnabled = true,
|
||||||
weatherDraftCount = playerCount + 2,
|
weatherDraftCount = playerCount,
|
||||||
|
bankingEnabled = true,
|
||||||
winCondition: SetupState["winCondition"] = "rounds",
|
winCondition: SetupState["winCondition"] = "rounds",
|
||||||
maxRounds = 12,
|
maxRounds = 12,
|
||||||
topLeafTarget = 4,
|
topLeafTarget = 4,
|
||||||
): SetupState {
|
): SetupState {
|
||||||
console.log("[DEBUG] createSetupState started");
|
// Adjust columns to ensure even spacing between players and edges
|
||||||
const clampedSeeds = Math.min(startingNodesPerPlayer, getMaxStartingNodesPerPlayer(playerCount, columns));
|
const adjustedColumns = getMinimumColumnsForEvenSpacing(playerCount, columns);
|
||||||
const defaults = createDefaultSeedInputs(playerCount, columns, clampedSeeds);
|
|
||||||
|
const clampedSeeds = Math.min(startingNodesPerPlayer, getMaxStartingNodesPerPlayer(playerCount, adjustedColumns));
|
||||||
|
const defaults = createDefaultSeedInputs(playerCount, adjustedColumns, clampedSeeds);
|
||||||
const paletteDefaults = createDefaultPaletteOrder(playerCount);
|
const paletteDefaults = createDefaultPaletteOrder(playerCount);
|
||||||
|
|
||||||
const result = {
|
return {
|
||||||
playerCount,
|
playerCount,
|
||||||
columns,
|
playerNames: Array.from({ length: playerCount }, (_, index) => playerNames?.[index] ?? `Player ${index + 1}`),
|
||||||
|
columns: adjustedColumns,
|
||||||
rows,
|
rows,
|
||||||
startingNodesPerPlayer: clampedSeeds,
|
startingNodesPerPlayer: clampedSeeds,
|
||||||
sunbeamChance,
|
sunbeamChance,
|
||||||
@@ -102,20 +138,23 @@ export function createSetupState(
|
|||||||
biddingOrderRule,
|
biddingOrderRule,
|
||||||
weatherDraftEnabled,
|
weatherDraftEnabled,
|
||||||
weatherDraftCount: Math.max(1, weatherDraftCount),
|
weatherDraftCount: Math.max(1, weatherDraftCount),
|
||||||
|
bankingEnabled,
|
||||||
winCondition,
|
winCondition,
|
||||||
maxRounds,
|
maxRounds,
|
||||||
topLeafTarget: Math.max(1, Math.min(columns, topLeafTarget)),
|
topLeafTarget: Math.max(1, Math.min(adjustedColumns, 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) => {
|
return Array.from({ length: playerCount }, (_, index) => {
|
||||||
const palette = PLAYER_PALETTE[paletteOrder[index] % PLAYER_PALETTE.length];
|
const palette = PLAYER_PALETTE[paletteOrder[index] % PLAYER_PALETTE.length];
|
||||||
return {
|
return {
|
||||||
id: index,
|
id: index,
|
||||||
name: `Player ${index + 1}`,
|
name: playerNames[index] ?? `Player ${index + 1}`,
|
||||||
color: palette.primary,
|
color: palette.primary,
|
||||||
glow: palette.glow,
|
glow: palette.glow,
|
||||||
totalScore: 0,
|
totalScore: 0,
|
||||||
@@ -167,9 +206,8 @@ export function normalizeSeedInputs(setup: SetupState) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createInitialState(setup: SetupState): GameState {
|
export function createInitialState(setup: SetupState): GameState {
|
||||||
console.log("[DEBUG] createInitialState started");
|
|
||||||
const playerPaletteOrder = [...setup.paletteOrder];
|
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 turnOrder = players.map((player) => player.id);
|
||||||
const nodes = new Map();
|
const nodes = new Map();
|
||||||
const edges = [];
|
const edges = [];
|
||||||
@@ -180,8 +218,6 @@ export function createInitialState(setup: SetupState): GameState {
|
|||||||
nodes.set(keyFor(setup.rows - 1, column), { ownerId: index });
|
nodes.set(keyFor(setup.rows - 1, column), { ownerId: index });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("[DEBUG] createInitialState completed");
|
|
||||||
return {
|
return {
|
||||||
config: {
|
config: {
|
||||||
columns: setup.columns,
|
columns: setup.columns,
|
||||||
@@ -193,6 +229,7 @@ export function createInitialState(setup: SetupState): GameState {
|
|||||||
biddingOrderRule: setup.biddingOrderRule,
|
biddingOrderRule: setup.biddingOrderRule,
|
||||||
weatherDraftEnabled: setup.weatherDraftEnabled,
|
weatherDraftEnabled: setup.weatherDraftEnabled,
|
||||||
weatherDraftCount: setup.weatherDraftCount,
|
weatherDraftCount: setup.weatherDraftCount,
|
||||||
|
bankingEnabled: setup.bankingEnabled,
|
||||||
winCondition: setup.winCondition,
|
winCondition: setup.winCondition,
|
||||||
maxRounds: setup.maxRounds,
|
maxRounds: setup.maxRounds,
|
||||||
topLeafTarget: setup.topLeafTarget,
|
topLeafTarget: setup.topLeafTarget,
|
||||||
@@ -14,6 +14,7 @@ export type Position = {
|
|||||||
|
|
||||||
export type SetupState = {
|
export type SetupState = {
|
||||||
playerCount: number;
|
playerCount: number;
|
||||||
|
playerNames: string[];
|
||||||
columns: number;
|
columns: number;
|
||||||
rows: number;
|
rows: number;
|
||||||
startingNodesPerPlayer: number;
|
startingNodesPerPlayer: number;
|
||||||
@@ -25,6 +26,7 @@ export type SetupState = {
|
|||||||
biddingOrderRule: "rotating" | "lowest_growth_income";
|
biddingOrderRule: "rotating" | "lowest_growth_income";
|
||||||
weatherDraftEnabled: boolean;
|
weatherDraftEnabled: boolean;
|
||||||
weatherDraftCount: number;
|
weatherDraftCount: number;
|
||||||
|
bankingEnabled: boolean;
|
||||||
winCondition: "rounds" | "top_leaves";
|
winCondition: "rounds" | "top_leaves";
|
||||||
maxRounds: number;
|
maxRounds: number;
|
||||||
topLeafTarget: number;
|
topLeafTarget: number;
|
||||||
@@ -54,6 +56,7 @@ export type GameConfig = {
|
|||||||
biddingOrderRule: SetupState["biddingOrderRule"];
|
biddingOrderRule: SetupState["biddingOrderRule"];
|
||||||
weatherDraftEnabled: boolean;
|
weatherDraftEnabled: boolean;
|
||||||
weatherDraftCount: number;
|
weatherDraftCount: number;
|
||||||
|
bankingEnabled: boolean;
|
||||||
winCondition: SetupState["winCondition"];
|
winCondition: SetupState["winCondition"];
|
||||||
maxRounds: number;
|
maxRounds: number;
|
||||||
topLeafTarget: number;
|
topLeafTarget: number;
|
||||||
@@ -123,6 +126,7 @@ export type RootBurst = {
|
|||||||
key: NodeKey;
|
key: NodeKey;
|
||||||
playerId: PlayerId;
|
playerId: PlayerId;
|
||||||
count: number;
|
count: number;
|
||||||
|
displayCount: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type EnergySimulation = {
|
export type EnergySimulation = {
|
||||||
@@ -164,7 +168,7 @@ export type RoundSummary = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type ScoreSnapshot = {
|
export type ScoreSnapshot = {
|
||||||
currentExposure: number;
|
projectedIncome: number;
|
||||||
growthPoints: number;
|
growthPoints: number;
|
||||||
bankedPoints: number;
|
bankedPoints: number;
|
||||||
lifetimeGrowthIncome: number;
|
lifetimeGrowthIncome: number;
|
||||||
@@ -187,6 +191,7 @@ export type WeatherCardId =
|
|||||||
| "leaf_surge"
|
| "leaf_surge"
|
||||||
| "branching_season"
|
| "branching_season"
|
||||||
| "storehouse"
|
| "storehouse"
|
||||||
|
| "compound_interest"
|
||||||
| "sun_ladder"
|
| "sun_ladder"
|
||||||
| "west_light"
|
| "west_light"
|
||||||
| "east_light"
|
| "east_light"
|
||||||
@@ -194,9 +199,9 @@ export type WeatherCardId =
|
|||||||
| "edge_bloom"
|
| "edge_bloom"
|
||||||
| "wide_reach"
|
| "wide_reach"
|
||||||
| "tall_reward"
|
| "tall_reward"
|
||||||
|
| "deep_roots"
|
||||||
| "stalemate"
|
| "stalemate"
|
||||||
| "split_light"
|
| "split_light";
|
||||||
| "shared_light";
|
|
||||||
|
|
||||||
export type WeatherCardDefinition = {
|
export type WeatherCardDefinition = {
|
||||||
id: WeatherCardId;
|
id: WeatherCardId;
|
||||||
@@ -204,6 +209,11 @@ export type WeatherCardDefinition = {
|
|||||||
description: string;
|
description: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type WeatherOfferPair = {
|
||||||
|
id: string;
|
||||||
|
options: [WeatherCardId, WeatherCardId];
|
||||||
|
};
|
||||||
|
|
||||||
export type InitiativeDraftState = {
|
export type InitiativeDraftState = {
|
||||||
biddingOrder: PlayerId[];
|
biddingOrder: PlayerId[];
|
||||||
biddingIndex: number;
|
biddingIndex: number;
|
||||||
@@ -214,9 +224,10 @@ export type InitiativeDraftState = {
|
|||||||
export type WeatherDraftState = {
|
export type WeatherDraftState = {
|
||||||
playerOrder: PlayerId[];
|
playerOrder: PlayerId[];
|
||||||
draftIndex: number;
|
draftIndex: number;
|
||||||
row: WeatherCardId[];
|
offers: WeatherOfferPair[];
|
||||||
drafted: WeatherCardId[];
|
drafted: WeatherCardId[];
|
||||||
banned: WeatherCardId[];
|
banned: WeatherCardId[];
|
||||||
|
locked: WeatherCardId[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GameState = {
|
export type GameState = {
|
||||||
1508
src/main.js
1508
src/main.js
File diff suppressed because it is too large
Load Diff
594
src/main.ts
594
src/main.ts
@@ -1,11 +1,11 @@
|
|||||||
import "./styles.css";
|
import "./styles/globals.css";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ROOT_SHIFT_COST,
|
ROOT_SHIFT_COST,
|
||||||
ROUND_ANIMATION_BONUS_MS,
|
ROUND_ANIMATION_BONUS_MS,
|
||||||
ROUND_ANIMATION_BRANCH_MS,
|
ROUND_ANIMATION_BRANCH_MS,
|
||||||
ROUND_ANIMATION_SUN_MS,
|
ROUND_ANIMATION_SUN_MS,
|
||||||
} from "./constants";
|
} from "./engine/constants";
|
||||||
import {
|
import {
|
||||||
buildChildrenMap as buildChildrenMapForState,
|
buildChildrenMap as buildChildrenMapForState,
|
||||||
buildParentMap as buildParentMapForState,
|
buildParentMap as buildParentMapForState,
|
||||||
@@ -14,31 +14,34 @@ import {
|
|||||||
getNodeOwner as getNodeOwnerForState,
|
getNodeOwner as getNodeOwnerForState,
|
||||||
getRootShiftMove as getRootShiftMoveForState,
|
getRootShiftMove as getRootShiftMoveForState,
|
||||||
playerHasLegalMove as playerHasLegalMoveForState,
|
playerHasLegalMove as playerHasLegalMoveForState,
|
||||||
} from "./rules-board";
|
} from "./engine/rules-board";
|
||||||
import {
|
import {
|
||||||
buildEnergySimulation,
|
buildEnergySimulation,
|
||||||
buildRoundAnimation as buildRoundAnimationForState,
|
buildRoundAnimation as buildRoundAnimationForState,
|
||||||
maybeRollDisease as maybeRollDiseaseForState,
|
maybeRollDisease as maybeRollDiseaseForState,
|
||||||
maybeRollSunbeam as maybeRollSunbeamForState,
|
maybeRollSunbeam as maybeRollSunbeamForState,
|
||||||
scoreColumns as scoreColumnsForState,
|
scoreColumns as scoreColumnsForState,
|
||||||
} from "./rules-scoring";
|
} from "./engine/rules-scoring";
|
||||||
import {
|
import {
|
||||||
createInitiativeDraft,
|
createInitiativeDraft,
|
||||||
} from "./rules-initiative";
|
getInitiativeGraceRounds,
|
||||||
|
} from "./engine/rules-initiative";
|
||||||
import {
|
import {
|
||||||
WEATHER_CARDS,
|
WEATHER_OFFER_PAIRS,
|
||||||
createWeatherDraft,
|
createWeatherDraft,
|
||||||
getCurrentWeatherPlayerId,
|
getCurrentWeatherPlayerId,
|
||||||
getWeatherCard,
|
getWeatherCard,
|
||||||
isWeatherCardAvailable,
|
isWeatherCardAvailable,
|
||||||
} from "./rules-weather";
|
isWeatherOfferResolved,
|
||||||
|
} from "./engine/rules-weather";
|
||||||
import {
|
import {
|
||||||
createInitialState,
|
createInitialState,
|
||||||
createPlayers,
|
createPlayers,
|
||||||
createRandomizedSeedInputs,
|
createRandomizedSeedInputs,
|
||||||
createSetupState,
|
createSetupState,
|
||||||
getMaxStartingNodesPerPlayer,
|
getMaxStartingNodesPerPlayer,
|
||||||
} from "./state";
|
normalizeSeedInputs,
|
||||||
|
} from "./engine/state";
|
||||||
import type {
|
import type {
|
||||||
GameState,
|
GameState,
|
||||||
GrowTarget,
|
GrowTarget,
|
||||||
@@ -49,8 +52,8 @@ import type {
|
|||||||
ShiftMove,
|
ShiftMove,
|
||||||
TurnMove,
|
TurnMove,
|
||||||
WeatherCardId,
|
WeatherCardId,
|
||||||
} from "./types";
|
} from "./engine/types";
|
||||||
import { keyFor, parseKey, tint, wait } from "./utils";
|
import { keyFor, parseKey, tint, wait } from "./engine/utils";
|
||||||
|
|
||||||
const app = document.querySelector("#app");
|
const app = document.querySelector("#app");
|
||||||
|
|
||||||
@@ -64,10 +67,13 @@ let state: GameState = createInitialState(setup);
|
|||||||
let isNewGameModalOpen = false;
|
let isNewGameModalOpen = false;
|
||||||
let previousScoreSnapshot: ScoreSnapshot[] | null = null;
|
let previousScoreSnapshot: ScoreSnapshot[] | null = null;
|
||||||
let setupTab: "board" | "rules" | "events" | "players" = "board";
|
let setupTab: "board" | "rules" | "events" | "players" = "board";
|
||||||
|
let draggedSetupSeed: { playerId: number; seedIndex: number } | null = null;
|
||||||
|
let isDraftPanelDocked = false;
|
||||||
|
|
||||||
function rebuildSetup(overrides: Partial<SetupState> = {}) {
|
function rebuildSetup(overrides: Partial<SetupState> = {}) {
|
||||||
setup = createSetupState(
|
setup = createSetupState(
|
||||||
overrides.playerCount ?? setup.playerCount,
|
overrides.playerCount ?? setup.playerCount,
|
||||||
|
overrides.playerNames ?? setup.playerNames,
|
||||||
overrides.columns ?? setup.columns,
|
overrides.columns ?? setup.columns,
|
||||||
overrides.rows ?? setup.rows,
|
overrides.rows ?? setup.rows,
|
||||||
overrides.startingNodesPerPlayer ?? setup.startingNodesPerPlayer,
|
overrides.startingNodesPerPlayer ?? setup.startingNodesPerPlayer,
|
||||||
@@ -79,16 +85,40 @@ function rebuildSetup(overrides: Partial<SetupState> = {}) {
|
|||||||
overrides.biddingOrderRule ?? setup.biddingOrderRule,
|
overrides.biddingOrderRule ?? setup.biddingOrderRule,
|
||||||
overrides.weatherDraftEnabled ?? setup.weatherDraftEnabled,
|
overrides.weatherDraftEnabled ?? setup.weatherDraftEnabled,
|
||||||
overrides.weatherDraftCount ?? setup.weatherDraftCount,
|
overrides.weatherDraftCount ?? setup.weatherDraftCount,
|
||||||
|
overrides.bankingEnabled ?? setup.bankingEnabled,
|
||||||
overrides.winCondition ?? setup.winCondition,
|
overrides.winCondition ?? setup.winCondition,
|
||||||
overrides.maxRounds ?? setup.maxRounds,
|
overrides.maxRounds ?? setup.maxRounds,
|
||||||
overrides.topLeafTarget ?? setup.topLeafTarget,
|
overrides.topLeafTarget ?? setup.topLeafTarget,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value: unknown) {
|
||||||
|
return String(value).replace(/[&<>"']/g, (character) => {
|
||||||
|
switch (character) {
|
||||||
|
case "&":
|
||||||
|
return "&";
|
||||||
|
case "<":
|
||||||
|
return "<";
|
||||||
|
case ">":
|
||||||
|
return ">";
|
||||||
|
case "\"":
|
||||||
|
return """;
|
||||||
|
case "'":
|
||||||
|
return "'";
|
||||||
|
default:
|
||||||
|
return character;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function getLiveExposureScores() {
|
function getLiveExposureScores() {
|
||||||
return buildEnergySimulation(state).scores;
|
return buildEnergySimulation(state).scores;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getProjectedIncomeScores() {
|
||||||
|
return getLiveExposureScores().map((score) => score + 1);
|
||||||
|
}
|
||||||
|
|
||||||
function getTopLeafCount() {
|
function getTopLeafCount() {
|
||||||
const childrenMap = buildChildrenMap();
|
const childrenMap = buildChildrenMap();
|
||||||
let count = 0;
|
let count = 0;
|
||||||
@@ -120,9 +150,9 @@ function getWinConditionSummary() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getScoreSnapshot() {
|
function getScoreSnapshot() {
|
||||||
const exposureScores = getLiveExposureScores();
|
const projectedIncomeScores = getProjectedIncomeScores();
|
||||||
return state.players.map((player, index) => ({
|
return state.players.map((player, index) => ({
|
||||||
currentExposure: exposureScores[index],
|
projectedIncome: projectedIncomeScores[index],
|
||||||
growthPoints: player.growthPoints,
|
growthPoints: player.growthPoints,
|
||||||
bankedPoints: player.bankedPoints,
|
bankedPoints: player.bankedPoints,
|
||||||
lifetimeGrowthIncome: player.lifetimeGrowthIncome,
|
lifetimeGrowthIncome: player.lifetimeGrowthIncome,
|
||||||
@@ -133,24 +163,37 @@ function getCurrentPlayer() {
|
|||||||
return state.players[state.activePlayerId];
|
return state.players[state.activePlayerId];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getTreeOpacity(playerId: number) {
|
||||||
|
if (state.gameOver) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return playerId === state.activePlayerId ? 1 : 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPlayerById(playerId: number) {
|
||||||
|
return state.players.find((player) => player.id === playerId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
function getOrderedPlayers(playerIds: number[]) {
|
function getOrderedPlayers(playerIds: number[]) {
|
||||||
return playerIds.map((playerId) => state.players[playerId]);
|
return playerIds.map((playerId) => state.players[playerId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTurnLabel() {
|
function getTurnLabel() {
|
||||||
if (state.phase === "initiative" && state.initiativeDraft) {
|
if (state.phase === "initiative" && state.initiativeDraft) {
|
||||||
return `${getCurrentPlayer().name} drafts initiative`;
|
return `${escapeHtml(getCurrentPlayer().name)} drafts initiative`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.phase === "weather" && state.weatherDraft) {
|
if (state.phase === "weather" && state.weatherDraft) {
|
||||||
return `${getCurrentPlayer().name} drafts weather`;
|
return `${escapeHtml(getCurrentPlayer().name)} drafts weather`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return state.gameOver ? "Game Over" : `${getCurrentPlayer().name}'s turn`;
|
return state.gameOver ? "Game Over" : `${escapeHtml(getCurrentPlayer().name)}'s turn`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isBankingEnabled() {
|
function isBankingEnabled() {
|
||||||
return state.activeRoundEffects.includes("storehouse");
|
// Banking is enabled if setup allows it OR if storehouse effect is active
|
||||||
|
return state.config.bankingEnabled || state.activeRoundEffects.includes("storehouse");
|
||||||
}
|
}
|
||||||
|
|
||||||
function awardGrowth(player: Player, amount: number) {
|
function awardGrowth(player: Player, amount: number) {
|
||||||
@@ -170,6 +213,17 @@ function getCurrentBiddingPlayer() {
|
|||||||
return state.players[state.initiativeDraft.biddingOrder[state.initiativeDraft.biddingIndex]];
|
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() {
|
function getCurrentWeatherDraftPlayer() {
|
||||||
if (!state.weatherDraft) {
|
if (!state.weatherDraft) {
|
||||||
return null;
|
return null;
|
||||||
@@ -212,6 +266,13 @@ function getLegalMovesForSource(sourceKey: NodeKey, player: Player) {
|
|||||||
return moves;
|
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"
|
return moves.map((move) => move.direction === "vertical"
|
||||||
? { ...move, cost: Math.max(0, move.cost - 1) }
|
? { ...move, cost: Math.max(0, move.cost - 1) }
|
||||||
: move);
|
: move);
|
||||||
@@ -362,6 +423,7 @@ function startWeatherDraft() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
state.phase = "weather";
|
state.phase = "weather";
|
||||||
|
isDraftPanelDocked = false;
|
||||||
state.turnMoves = [];
|
state.turnMoves = [];
|
||||||
updateSelection(null);
|
updateSelection(null);
|
||||||
state.weatherDraft = createWeatherDraft(state);
|
state.weatherDraft = createWeatherDraft(state);
|
||||||
@@ -371,6 +433,7 @@ function startWeatherDraft() {
|
|||||||
|
|
||||||
function startInitiativeDraft() {
|
function startInitiativeDraft() {
|
||||||
state.phase = "initiative";
|
state.phase = "initiative";
|
||||||
|
isDraftPanelDocked = false;
|
||||||
state.turnMoves = [];
|
state.turnMoves = [];
|
||||||
updateSelection(null);
|
updateSelection(null);
|
||||||
state.initiativeDraft = createInitiativeDraft(state);
|
state.initiativeDraft = createInitiativeDraft(state);
|
||||||
@@ -409,15 +472,17 @@ function finalizeInitiativeDraft() {
|
|||||||
|
|
||||||
nextTurnOrder.forEach((playerId, seatIndex) => {
|
nextTurnOrder.forEach((playerId, seatIndex) => {
|
||||||
const bonus = draft.seatBonuses[seatIndex] ?? 0;
|
const bonus = draft.seatBonuses[seatIndex] ?? 0;
|
||||||
if (bonus > 0) {
|
const player = getPlayerById(playerId);
|
||||||
awardGrowth(state.players[playerId], bonus);
|
if (bonus > 0 && player) {
|
||||||
|
awardGrowth(player, bonus);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const seatSummary = nextTurnOrder
|
const seatSummary = nextTurnOrder
|
||||||
.map((playerId, seatIndex) => {
|
.map((playerId, seatIndex) => {
|
||||||
const bonus = draft.seatBonuses[seatIndex] ?? 0;
|
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(" | ");
|
.join(" | ");
|
||||||
|
|
||||||
@@ -434,7 +499,7 @@ function chooseInitiativeSeat(seatIndex: number) {
|
|||||||
|
|
||||||
const playerId = draft.biddingOrder[draft.biddingIndex];
|
const playerId = draft.biddingOrder[draft.biddingIndex];
|
||||||
draft.seatAssignments[seatIndex] = playerId;
|
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) {
|
if (draft.biddingIndex >= draft.biddingOrder.length - 1) {
|
||||||
finalizeInitiativeDraft();
|
finalizeInitiativeDraft();
|
||||||
@@ -481,20 +546,33 @@ function finalizeWeatherDraft() {
|
|||||||
moveToFirstPlayableTurn();
|
moveToFirstPlayableTurn();
|
||||||
}
|
}
|
||||||
|
|
||||||
function chooseWeatherAction(cardId: WeatherCardId, action: "draft" | "ban") {
|
function chooseWeatherAction(offerId: string, cardId: WeatherCardId, action: "draft" | "ban") {
|
||||||
const draft = state.weatherDraft;
|
const draft = state.weatherDraft;
|
||||||
if (!draft || !isWeatherCardAvailable(draft, cardId)) {
|
const offer = draft?.offers.find((entry) => entry.id === offerId);
|
||||||
|
if (!draft || !offer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const otherCardId = offer?.options.find((option) => option !== cardId) ?? null;
|
||||||
|
|
||||||
|
const playerId = getCurrentWeatherPlayerId(draft);
|
||||||
|
if (action === "draft") {
|
||||||
|
if (!isWeatherCardAvailable(draft, offerId, cardId)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const playerId = getCurrentWeatherPlayerId(draft);
|
|
||||||
const card = getWeatherCard(cardId);
|
const card = getWeatherCard(cardId);
|
||||||
if (action === "draft") {
|
|
||||||
draft.drafted.push(cardId);
|
draft.drafted.push(cardId);
|
||||||
state.history.unshift(`${state.players[playerId].name} drafted ${card?.title ?? cardId}.`);
|
state.history.unshift(`${state.players[playerId].name} drafted ${card?.title ?? cardId}.`);
|
||||||
|
if (otherCardId && !draft.locked.includes(otherCardId)) {
|
||||||
|
draft.locked.push(otherCardId);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
draft.banned.push(cardId);
|
offer.options.forEach((option) => {
|
||||||
state.history.unshift(`${state.players[playerId].name} banned ${card?.title ?? cardId}.`);
|
if (!draft.banned.includes(option)) {
|
||||||
|
draft.banned.push(option);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
state.history.unshift(`${state.players[playerId].name} banned both cards in an offer.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (draft.draftIndex >= draft.playerOrder.length - 1) {
|
if (draft.draftIndex >= draft.playerOrder.length - 1) {
|
||||||
@@ -695,6 +773,22 @@ async function endRound() {
|
|||||||
player.roundScore = scores[index];
|
player.roundScore = scores[index];
|
||||||
player.totalScore += scores[index];
|
player.totalScore += scores[index];
|
||||||
player.bonusPoints = nextGrowth[index] - scores[index];
|
player.bonusPoints = nextGrowth[index] - scores[index];
|
||||||
|
|
||||||
|
// Apply banking effects from weather cards
|
||||||
|
const hasStorehouse = state.activeRoundEffects.includes("storehouse");
|
||||||
|
const hasCompoundInterest = state.activeRoundEffects.includes("compound_interest");
|
||||||
|
|
||||||
|
if (hasStorehouse) {
|
||||||
|
// Storehouse: Lose 1 banked energy (min 0)
|
||||||
|
player.bankedPoints = Math.max(0, player.bankedPoints - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasCompoundInterest) {
|
||||||
|
// Compound Interest: Gain 20% interest (rounded down)
|
||||||
|
const interest = Math.floor(player.bankedPoints * 0.2);
|
||||||
|
player.bankedPoints += interest;
|
||||||
|
}
|
||||||
|
|
||||||
player.growthPoints = player.bankedPoints;
|
player.growthPoints = player.bankedPoints;
|
||||||
player.lifetimeGrowthIncome += nextGrowth[index];
|
player.lifetimeGrowthIncome += nextGrowth[index];
|
||||||
player.growthPoints += nextGrowth[index];
|
player.growthPoints += nextGrowth[index];
|
||||||
@@ -790,6 +884,45 @@ function moveSetupPlayer(fromIndex: number, toIndex: number) {
|
|||||||
|
|
||||||
[setup.paletteOrder[fromIndex], setup.paletteOrder[toIndex]] = [setup.paletteOrder[toIndex], setup.paletteOrder[fromIndex]];
|
[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.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();
|
render();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -809,8 +942,15 @@ function renderNewGameModal() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const maxSeeds = getMaxStartingNodesPerPlayer(setup.playerCount, setup.columns);
|
const maxSeeds = getMaxStartingNodesPerPlayer(setup.playerCount, setup.columns);
|
||||||
const previewPlayers = createPlayers(setup.playerCount, setup.paletteOrder);
|
const previewPlayers = createPlayers(setup.playerCount, setup.paletteOrder, setup.playerNames);
|
||||||
const draftCountMax = WEATHER_CARDS.length;
|
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 `
|
return `
|
||||||
<div class="modal-backdrop" id="new-game-modal-backdrop">
|
<div class="modal-backdrop" id="new-game-modal-backdrop">
|
||||||
@@ -820,7 +960,6 @@ function renderNewGameModal() {
|
|||||||
<p class="eyebrow">New Game</p>
|
<p class="eyebrow">New Game</p>
|
||||||
<h1 id="new-game-title">Configure the next canopy</h1>
|
<h1 id="new-game-title">Configure the next canopy</h1>
|
||||||
</div>
|
</div>
|
||||||
<button class="ghost-button" id="close-new-game">Close</button>
|
|
||||||
</header>
|
</header>
|
||||||
<nav class="setup-tabs" aria-label="Setup categories">
|
<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>
|
<button class="setup-tab${setupTab === "board" ? " setup-tab--active" : ""}" data-setup-tab="board"><span aria-hidden="true">▦</span><span>Board</span></button>
|
||||||
@@ -834,11 +973,12 @@ function renderNewGameModal() {
|
|||||||
<section class="setup-section">
|
<section class="setup-section">
|
||||||
<h2 class="setup-section__title">Board Settings</h2>
|
<h2 class="setup-section__title">Board Settings</h2>
|
||||||
<div class="setup-grid">
|
<div class="setup-grid">
|
||||||
<label class="setup-field setup-field--range">
|
<label class="setup-field">
|
||||||
<span class="setup-field__label">Players</span>
|
<span class="setup-field__label">Players</span>
|
||||||
<div class="setup-field__input">
|
<div class="setup-stepper">
|
||||||
<input id="player-count" type="range" min="2" max="6" step="1" value="${setup.playerCount}" />
|
<button class="stepper-button" id="player-count-decrease" ${setup.playerCount <= 2 ? "disabled" : ""}>-</button>
|
||||||
<strong class="setup-field__value">${setup.playerCount}</strong>
|
<strong class="setup-stepper__value">${setup.playerCount}</strong>
|
||||||
|
<button class="stepper-button" id="player-count-increase" ${setup.playerCount >= 8 ? "disabled" : ""}>+</button>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<label class="setup-field">
|
<label class="setup-field">
|
||||||
@@ -905,6 +1045,10 @@ function renderNewGameModal() {
|
|||||||
<input id="weather-draft-count" type="number" min="1" max="${draftCountMax}" step="1" value="${setup.weatherDraftCount}" />
|
<input id="weather-draft-count" type="number" min="1" max="${draftCountMax}" step="1" value="${setup.weatherDraftCount}" />
|
||||||
</label>
|
</label>
|
||||||
` : ""}
|
` : ""}
|
||||||
|
<label class="setup-field setup-field--checkbox">
|
||||||
|
<span class="setup-field__label">Enable Banking</span>
|
||||||
|
<input id="banking-toggle" type="checkbox" ${setup.bankingEnabled ? "checked" : ""} />
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
` : ""}
|
` : ""}
|
||||||
@@ -934,7 +1078,7 @@ function renderNewGameModal() {
|
|||||||
<div class="player-row">
|
<div class="player-row">
|
||||||
<div class="player-row__info">
|
<div class="player-row__info">
|
||||||
<span class="player-dot" style="--player-color: ${currentPlayer.color}; --player-glow: ${currentPlayer.glow};"></span>
|
<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="${escapeHtml(currentPlayer.name)}" aria-label="${escapeHtml(currentPlayer.name)} name" />
|
||||||
</div>
|
</div>
|
||||||
<div class="player-row__actions">
|
<div class="player-row__actions">
|
||||||
<button class="mini-button" data-move-player="${index}" data-direction="up" ${index === 0 ? "disabled" : ""}>↑</button>
|
<button class="mini-button" data-move-player="${index}" data-direction="up" ${index === 0 ? "disabled" : ""}>↑</button>
|
||||||
@@ -946,13 +1090,24 @@ function renderNewGameModal() {
|
|||||||
</section>
|
</section>
|
||||||
<section class="setup-section">
|
<section class="setup-section">
|
||||||
<h2 class="setup-section__title">Starting columns</h2>
|
<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>
|
<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">
|
<div class="player-list">
|
||||||
${previewPlayers.map((currentPlayer, index) => `
|
${previewPlayers.map((currentPlayer, index) => `
|
||||||
<label class="setup-field">
|
<label class="setup-field">
|
||||||
<span class="setup-field__label" style="color: ${currentPlayer.color};">${currentPlayer.name}</span>
|
<span class="setup-field__label" style="color: ${currentPlayer.color};">${escapeHtml(currentPlayer.name)}</span>
|
||||||
<input class="seed-input" data-player-id="${index}" type="text" value="${setup.seedInputs[index] ?? ""}" placeholder="e.g. 2, 5" />
|
<input class="seed-input" data-player-id="${index}" type="text" value="${escapeHtml(setup.seedInputs[index] ?? "")}" placeholder="e.g. 2, 5" />
|
||||||
</label>
|
</label>
|
||||||
`).join("")}
|
`).join("")}
|
||||||
</div>
|
</div>
|
||||||
@@ -962,7 +1117,7 @@ function renderNewGameModal() {
|
|||||||
|
|
||||||
<footer class="modal-footer">
|
<footer class="modal-footer">
|
||||||
<button class="ghost-button" id="cancel-new-game">Cancel</button>
|
<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>
|
</footer>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -978,48 +1133,67 @@ function renderWeatherDraftModal() {
|
|||||||
const currentPlayer = getCurrentWeatherDraftPlayer();
|
const currentPlayer = getCurrentWeatherDraftPlayer();
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<section class="draft-panel panel" role="dialog" aria-modal="true" aria-labelledby="weather-title">
|
<section class="draft-panel panel${isDraftPanelDocked ? " draft-panel--docked" : ""}" role="dialog" aria-modal="true" aria-labelledby="weather-title">
|
||||||
<div class="panel__title-row">
|
<div class="panel__title-row">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Round ${state.round}</p>
|
<p class="eyebrow">Round ${state.round}</p>
|
||||||
<h1 id="weather-title">Weather Draft</h1>
|
<h1 id="weather-title">Weather Draft</h1>
|
||||||
</div>
|
</div>
|
||||||
|
<button class="ghost-button draft-panel__toggle" id="draft-panel-toggle" aria-label="${isDraftPanelDocked ? "Expand draft panel" : "Dock draft panel"}">${isDraftPanelDocked ? "←" : "→"}</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="seed-editor">
|
<div class="weather-draft-header">
|
||||||
<p class="seed-help">${currentPlayer?.name ?? "A player"} must draft one card for this round or ban one to deny it.</p>
|
<p class="weather-draft-instructions">${escapeHtml(currentPlayer?.name ?? "A player")} can draft either card, or ban both cards in an offer.</p>
|
||||||
<div class="weather-key" aria-label="Weather action key">
|
<div class="weather-draft-actions">
|
||||||
<span><strong>☀ Draft</strong>: apply to the board for 1 round</span>
|
<span class="weather-draft-action"><strong>☀ Draft</strong> - take that card for 1 round</span>
|
||||||
<span><strong>✕ Ban</strong>: remove it this round</span>
|
<span class="weather-draft-action"><strong>✕ Ban Both</strong> - remove both cards in that offer</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="initiative-order-row">
|
<div class="weather-draft-order">
|
||||||
${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("")}
|
${getOrderedPlayers(draft.playerOrder).map((player, index) => {
|
||||||
|
const isActive = index === draft.draftIndex;
|
||||||
|
const isNext = index === (draft.draftIndex + 1) % draft.playerOrder.length;
|
||||||
|
return `
|
||||||
|
<div class="weather-draft-player${isActive ? ' weather-draft-player--active' : ''}" style="--player-color: ${player.color};">
|
||||||
|
<span class="weather-draft-player__name">${escapeHtml(player.name)}</span>
|
||||||
|
${isNext ? '<span class="weather-draft-player__label">next draft</span>' : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join("")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="weather-grid">
|
<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-offer-layout">
|
||||||
|
<div class="weather-pair">
|
||||||
|
${offer.options.map((cardId, optionIndex) => {
|
||||||
const card = getWeatherCard(cardId);
|
const card = getWeatherCard(cardId);
|
||||||
const drafted = draft.drafted.includes(cardId);
|
const drafted = draft.drafted.includes(cardId);
|
||||||
const banned = draft.banned.includes(cardId);
|
const banned = draft.banned.includes(cardId);
|
||||||
const available = !drafted && !banned;
|
const locked = draft.locked.includes(cardId);
|
||||||
return `
|
const available = isWeatherCardAvailable(draft, offer.id, cardId);
|
||||||
<article class="weather-card${drafted ? " weather-card--drafted" : ""}${banned ? " weather-card--banned" : ""}">
|
return `${optionIndex === 1 ? `<div class="weather-pair__divider">-- OR --</div>` : ""}
|
||||||
<div>
|
<div class="weather-pair__option${drafted ? " weather-pair__option--drafted" : ""}${banned ? " weather-pair__option--banned" : ""}${locked ? " weather-pair__option--locked" : ""}">
|
||||||
<p class="eyebrow">${drafted ? "Drafted" : banned ? "Banned" : "Open"}</p>
|
|
||||||
<h2>${card?.title ?? cardId}</h2>
|
<h2>${card?.title ?? cardId}</h2>
|
||||||
<p>${card?.description ?? ""}</p>
|
<p>${card?.description ?? ""}</p>
|
||||||
</div>
|
|
||||||
${available ? `
|
${available ? `
|
||||||
<div class="weather-card__actions">
|
<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 class="weather-action__icon" aria-hidden="true">☀</span>
|
||||||
<span><strong>Draft</strong></span>
|
<span><strong>Draft</strong></span>
|
||||||
</button>
|
</button>
|
||||||
<button class="weather-action weather-action--ban" data-weather-action="ban" data-weather-card="${cardId}">
|
|
||||||
<span class="weather-action__icon" aria-hidden="true">✕</span>
|
|
||||||
<span><strong>Ban</strong></span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
` : ""}
|
` : `<p class="weather-card__status">${drafted ? "Drafted" : banned ? "Banned" : "Locked"}</p>`}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join("")}
|
||||||
|
</div>
|
||||||
|
${!resolved ? `<button class="weather-action weather-action--ban-both" data-weather-action="ban" data-weather-offer="${offer.id}" data-weather-card="${offer.options[0]}"><span class="weather-action__icon" aria-hidden="true">✕</span><span><strong>Ban Both</strong></span></button>` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
`;
|
`;
|
||||||
}).join("")}
|
}).join("")}
|
||||||
@@ -1036,19 +1210,26 @@ function renderInitiativeModal() {
|
|||||||
const draft = state.initiativeDraft;
|
const draft = state.initiativeDraft;
|
||||||
const currentBidder = getCurrentBiddingPlayer();
|
const currentBidder = getCurrentBiddingPlayer();
|
||||||
const orderedBidders = getOrderedPlayers(draft.biddingOrder);
|
const orderedBidders = getOrderedPlayers(draft.biddingOrder);
|
||||||
|
const initiativeBonusStatus = getInitiativeBonusStatus();
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<section class="draft-panel panel" role="dialog" aria-modal="true" aria-labelledby="initiative-title">
|
<section class="draft-panel panel${isDraftPanelDocked ? " draft-panel--docked" : ""}" role="dialog" aria-modal="true" aria-labelledby="initiative-title">
|
||||||
<div class="panel__title-row">
|
<div class="panel__title-row">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Round ${state.round}</p>
|
<p class="eyebrow">Round ${state.round}</p>
|
||||||
<h1 id="initiative-title">Initiative Draft</h1>
|
<h1 id="initiative-title">Initiative Draft</h1>
|
||||||
</div>
|
</div>
|
||||||
|
<button class="ghost-button draft-panel__toggle" id="draft-panel-toggle" aria-label="${isDraftPanelDocked ? "Expand draft panel" : "Dock draft panel"}">${isDraftPanelDocked ? "←" : "→"}</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="seed-editor">
|
<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="seed-help">${escapeHtml(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">
|
<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("")}
|
${orderedBidders.map((player, index) => `<span class="initiative-pill${index === draft.biddingIndex ? " initiative-pill--active" : ""}" style="--player-color: ${player.color};">${escapeHtml(player.name)}</span>`).join("")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="initiative-seat-grid">
|
<div class="initiative-seat-grid">
|
||||||
@@ -1060,7 +1241,7 @@ function renderInitiativeModal() {
|
|||||||
<button class="initiative-seat${playerId === null ? "" : " initiative-seat--taken"}" data-seat-choice="${seatIndex}" ${disabled}>
|
<button class="initiative-seat${playerId === null ? "" : " initiative-seat--taken"}" data-seat-choice="${seatIndex}" ${disabled}>
|
||||||
<strong>Seat ${seatIndex + 1}</strong>
|
<strong>Seat ${seatIndex + 1}</strong>
|
||||||
<span>${bonus > 0 ? `+${bonus} growth` : "+0 growth"}</span>
|
<span>${bonus > 0 ? `+${bonus} growth` : "+0 growth"}</span>
|
||||||
<span>${assignedPlayer ? assignedPlayer.name : "Open"}</span>
|
<span>${assignedPlayer ? escapeHtml(assignedPlayer.name) : "Open"}</span>
|
||||||
</button>
|
</button>
|
||||||
`;
|
`;
|
||||||
}).join("")}
|
}).join("")}
|
||||||
@@ -1070,12 +1251,12 @@ function renderInitiativeModal() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderScoreboard() {
|
function renderScoreboard() {
|
||||||
const liveExposureScores = getLiveExposureScores();
|
const projectedIncomeScores = getProjectedIncomeScores();
|
||||||
return state.players.map((player, index) => {
|
return state.players.map((player, index) => {
|
||||||
const isActive = player.id === state.activePlayerId && !state.gameOver;
|
const isActive = player.id === state.activePlayerId && !state.gameOver;
|
||||||
const seatIndex = state.turnOrder.indexOf(player.id);
|
const seatIndex = state.turnOrder.indexOf(player.id);
|
||||||
const previous = previousScoreSnapshot?.[index];
|
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 growthChanged = previous && previous.growthPoints !== player.growthPoints;
|
||||||
const bankChanged = previous && previous.bankedPoints !== player.bankedPoints;
|
const bankChanged = previous && previous.bankedPoints !== player.bankedPoints;
|
||||||
return `
|
return `
|
||||||
@@ -1083,18 +1264,18 @@ function renderScoreboard() {
|
|||||||
<div class="score-card__head">
|
<div class="score-card__head">
|
||||||
<div class="score-card__identity">
|
<div class="score-card__identity">
|
||||||
<span class="player-dot"></span>
|
<span class="player-dot"></span>
|
||||||
<h2>${player.name}</h2>
|
<h2>${escapeHtml(player.name)}</h2>
|
||||||
</div>
|
</div>
|
||||||
<span class="score-card__meta">Lifetime ${player.lifetimeGrowthIncome}</span>
|
<span class="score-card__meta">Lifetime ${player.lifetimeGrowthIncome}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="score-card__numbers">
|
<div class="score-card__numbers">
|
||||||
<div>
|
<div>
|
||||||
<span>Current</span>
|
<span>Next</span>
|
||||||
<strong class="${sunlightChanged ? "score-value changed" : "score-value"}">${liveExposureScores[index]}</strong>
|
<strong class="${projectedIncomeChanged ? "score-value changed" : "score-value"}">${projectedIncomeScores[index]}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>Energy</span>
|
<span>Energy</span>
|
||||||
<strong class="${growthChanged ? "score-value changed" : "score-value"}">${player.growthPoints}</strong>
|
<strong class="${growthChanged ? "score-value changed" : "score-value"}" data-energy-score="${player.id}">${player.growthPoints}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>Bank</span>
|
<span>Bank</span>
|
||||||
@@ -1148,14 +1329,6 @@ function renderAnimationOverlay(columns: number, rows: number) {
|
|||||||
});
|
});
|
||||||
}).join("") : "";
|
}).join("") : "";
|
||||||
|
|
||||||
const roots = state.animation.rootBursts.map((burst) => {
|
|
||||||
const player = state.players[burst.playerId];
|
|
||||||
const root = parseKey(burst.key);
|
|
||||||
const x = ((root.column + 0.5) / columns) * 100;
|
|
||||||
const y = ((root.row + 0.5) / rows) * 100;
|
|
||||||
return `<g class="board__root-burst" style="--trace-delay: 300ms; --burst-color: ${player.color};" transform="translate(${x} ${y})"><circle r="2.5"></circle><text text-anchor="middle" dominant-baseline="central">+${burst.count}</text></g>`;
|
|
||||||
}).join("");
|
|
||||||
|
|
||||||
const disease = state.animation.diseaseKeys.map((key, index) => {
|
const disease = state.animation.diseaseKeys.map((key, index) => {
|
||||||
const node = parseKey(key);
|
const node = parseKey(key);
|
||||||
const x = ((node.column + 0.5) / columns) * 100;
|
const x = ((node.column + 0.5) / columns) * 100;
|
||||||
@@ -1184,15 +1357,6 @@ function renderAnimationOverlay(columns: number, rows: number) {
|
|||||||
return `<div class="board__energy-cell board__energy-cell--bonus" style="--flash-delay: ${index * 175}ms; --flash-color: #ffd85e; left: ${left}%; top: ${top}%; width: ${width}%; height: ${height}%;"></div>`;
|
return `<div class="board__energy-cell board__energy-cell--bonus" style="--flash-delay: ${index * 175}ms; --flash-color: #ffd85e; left: ${left}%; top: ${top}%; width: ${width}%; height: ${height}%;"></div>`;
|
||||||
}).join("");
|
}).join("");
|
||||||
|
|
||||||
const sunbeam = state.animation.bonusBurst === null || state.animation.bonusBurst === undefined
|
|
||||||
? ""
|
|
||||||
: (() => {
|
|
||||||
const root = parseKey(state.animation?.bonusBurst?.key as string);
|
|
||||||
const x = ((root.column + 0.5) / columns) * 100;
|
|
||||||
const y = ((root.row + 0.5) / rows) * 100;
|
|
||||||
return `<g class="board__sunbeam-burst" transform="translate(${x} ${y})"><circle r="2.5"></circle><text text-anchor="middle" dominant-baseline="central">+1</text></g>`;
|
|
||||||
})();
|
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="board__drop-layer board__drop-layer--${state.animation.phase}" aria-hidden="true">
|
<div class="board__drop-layer board__drop-layer--${state.animation.phase}" aria-hidden="true">
|
||||||
${state.animation.phase === "bonus" ? bonusSunbeam : ""}
|
${state.animation.phase === "bonus" ? bonusSunbeam : ""}
|
||||||
@@ -1203,13 +1367,103 @@ function renderAnimationOverlay(columns: number, rows: number) {
|
|||||||
${state.animation.phase === "bonus" ? bonusFlashes : ""}
|
${state.animation.phase === "bonus" ? bonusFlashes : ""}
|
||||||
</div>
|
</div>
|
||||||
<svg class="board__fx board__fx--${state.animation.phase}" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
|
<svg class="board__fx board__fx--${state.animation.phase}" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
|
||||||
${roots}
|
|
||||||
${disease}
|
${disease}
|
||||||
${sunbeam}
|
|
||||||
</svg>
|
</svg>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderScoreFlightOverlay() {
|
||||||
|
if (!state.animation || (state.animation.phase !== "branches" && state.animation.phase !== "bonus")) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootBursts = state.animation.phase === "branches"
|
||||||
|
? state.animation.rootBursts.map((burst, index) => `
|
||||||
|
<div
|
||||||
|
class="score-flight-badge"
|
||||||
|
data-root-key="${burst.key}"
|
||||||
|
data-player-id="${burst.playerId}"
|
||||||
|
data-flight-order="${index}"
|
||||||
|
style="--burst-color: ${state.players[burst.playerId].color};"
|
||||||
|
>
|
||||||
|
+${burst.displayCount}
|
||||||
|
</div>
|
||||||
|
`).join("")
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const bonusBurst = state.animation.phase === "bonus" && state.animation.bonusBurst
|
||||||
|
? `
|
||||||
|
<div
|
||||||
|
class="score-flight-badge score-flight-badge--bonus"
|
||||||
|
data-root-key="${state.animation.bonusBurst.key}"
|
||||||
|
data-player-id="${state.animation.bonusBurst.playerId}"
|
||||||
|
data-flight-order="${state.animation.rootBursts.length}"
|
||||||
|
style="--burst-color: #ffd85e;"
|
||||||
|
>
|
||||||
|
+1
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return `<div class="score-flight-layer" aria-hidden="true">${rootBursts}${bonusBurst}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pulseEnergyScore(playerId: number, delayMs: number) {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
const targetValue = document.querySelector<HTMLElement>(`[data-energy-score="${playerId}"]`);
|
||||||
|
if (!targetValue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
targetValue.classList.remove("score-value--landing");
|
||||||
|
void targetValue.offsetWidth;
|
||||||
|
targetValue.classList.add("score-value--landing");
|
||||||
|
}, delayMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function positionScoreFlightBadges() {
|
||||||
|
const flightLayer = document.querySelector<HTMLElement>(".score-flight-layer");
|
||||||
|
if (!flightLayer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const layerRect = flightLayer.getBoundingClientRect();
|
||||||
|
const badges = Array.from(flightLayer.querySelectorAll<HTMLElement>(".score-flight-badge"));
|
||||||
|
|
||||||
|
badges.forEach((badge, index) => {
|
||||||
|
const rootKey = badge.dataset.rootKey;
|
||||||
|
const playerId = Number(badge.dataset.playerId);
|
||||||
|
if (!rootKey || Number.isNaN(playerId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { row, column } = parseKey(rootKey);
|
||||||
|
const sourceCell = document.querySelector<HTMLElement>(`.cell[data-row="${row}"][data-column="${column}"]`);
|
||||||
|
const targetValue = document.querySelector<HTMLElement>(`[data-energy-score="${playerId}"]`);
|
||||||
|
if (!sourceCell || !targetValue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceRect = sourceCell.getBoundingClientRect();
|
||||||
|
const targetRect = targetValue.getBoundingClientRect();
|
||||||
|
const startX = sourceRect.left + sourceRect.width / 2;
|
||||||
|
const startY = sourceRect.top + sourceRect.height / 2;
|
||||||
|
const endX = targetRect.left + targetRect.width / 2;
|
||||||
|
const endY = targetRect.top + targetRect.height / 2;
|
||||||
|
const deltaX = endX - startX;
|
||||||
|
const deltaY = endY - startY;
|
||||||
|
|
||||||
|
badge.style.left = `${startX}px`;
|
||||||
|
badge.style.top = `${startY}px`;
|
||||||
|
badge.style.setProperty("--flight-x", `${deltaX}px`);
|
||||||
|
badge.style.setProperty("--flight-y", `${deltaY}px`);
|
||||||
|
badge.style.setProperty("--flight-mid-x", `${deltaX * 0.55}px`);
|
||||||
|
badge.style.setProperty("--flight-mid-y", `${deltaY - 42}px`);
|
||||||
|
badge.style.setProperty("--flight-delay", `${index * 260}ms`);
|
||||||
|
pulseEnergyScore(playerId, index * 260 + 2200);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function renderBoard() {
|
function renderBoard() {
|
||||||
const columns = state.config.columns;
|
const columns = state.config.columns;
|
||||||
const rows = state.config.rows;
|
const rows = state.config.rows;
|
||||||
@@ -1217,12 +1471,13 @@ function renderBoard() {
|
|||||||
const parentMap = buildParentMap();
|
const parentMap = buildParentMap();
|
||||||
const lines = state.edges.map((edge) => {
|
const lines = state.edges.map((edge) => {
|
||||||
const player = state.players[edge.ownerId];
|
const player = state.players[edge.ownerId];
|
||||||
|
const opacity = getTreeOpacity(edge.ownerId);
|
||||||
const x1 = ((edge.from.column + 0.5) / columns) * 100;
|
const x1 = ((edge.from.column + 0.5) / columns) * 100;
|
||||||
const y1 = ((edge.from.row + 0.5) / rows) * 100;
|
const y1 = ((edge.from.row + 0.5) / rows) * 100;
|
||||||
const x2 = ((edge.to.column + 0.5) / columns) * 100;
|
const x2 = ((edge.to.column + 0.5) / columns) * 100;
|
||||||
const y2 = ((edge.to.row + 0.5) / rows) * 100;
|
const y2 = ((edge.to.row + 0.5) / rows) * 100;
|
||||||
|
|
||||||
return `<line x1="${x1}%" y1="${y1}%" x2="${x2}%" y2="${y2}%" stroke="${player.color}" stroke-width="0.9" stroke-linecap="round" />`;
|
return `<line x1="${x1}%" y1="${y1}%" x2="${x2}%" y2="${y2}%" stroke="${player.color}" stroke-opacity="${opacity}" stroke-width="0.9" stroke-linecap="round" />`;
|
||||||
}).join("");
|
}).join("");
|
||||||
|
|
||||||
const cells = Array.from({ length: rows }, (_, row) => {
|
const cells = Array.from({ length: rows }, (_, row) => {
|
||||||
@@ -1237,13 +1492,14 @@ function renderBoard() {
|
|||||||
const background = columnLeader.ownerId === null || columnLeader.tied
|
const background = columnLeader.ownerId === null || columnLeader.tied
|
||||||
? "transparent"
|
? "transparent"
|
||||||
: tint(state.players[columnLeader.ownerId].color);
|
: tint(state.players[columnLeader.ownerId].color);
|
||||||
|
const treeOpacity = player ? getTreeOpacity(player.id) : 1;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<button
|
<button
|
||||||
class="cell${player ? " occupied" : ""}${target ? " target" : ""}${pending ? " pending" : ""}${isRoot ? " root" : ""}${state.selectedSource === nodeKey ? " selected" : ""}"
|
class="cell${player ? " occupied" : ""}${target ? " target" : ""}${pending ? " pending" : ""}${isRoot ? " root" : ""}${state.selectedSource === nodeKey ? " selected" : ""}"
|
||||||
data-row="${row}"
|
data-row="${row}"
|
||||||
data-column="${column}"
|
data-column="${column}"
|
||||||
style="--column-tint: ${background}; ${player ? `--node-color: ${player.color}; --node-glow: ${player.glow};` : ""}"
|
style="--column-tint: ${background}; --tree-opacity: ${treeOpacity}; ${player ? `--node-color: ${player.color}; --node-glow: ${player.glow};` : ""}"
|
||||||
${isInteractionLocked() ? "disabled" : ""}
|
${isInteractionLocked() ? "disabled" : ""}
|
||||||
>
|
>
|
||||||
<span class="cell__shade"></span>
|
<span class="cell__shade"></span>
|
||||||
@@ -1272,59 +1528,95 @@ function renderSidebar() {
|
|||||||
const player = getCurrentPlayer();
|
const player = getCurrentPlayer();
|
||||||
const rootShiftMoves = getSelectedRootShiftMoves();
|
const rootShiftMoves = getSelectedRootShiftMoves();
|
||||||
const boardLocked = isInteractionLocked();
|
const boardLocked = isInteractionLocked();
|
||||||
const turnOrderSummary = getOrderedPlayers(state.turnOrder).map((orderedPlayer, index) => `${index + 1}. ${orderedPlayer.name}`).join(" | ");
|
const turnOrderSummary = getOrderedPlayers(state.turnOrder).map((orderedPlayer, index) => `${index + 1}. ${escapeHtml(orderedPlayer.name)}`).join(" | ");
|
||||||
const activeEffectsMarkup = state.activeRoundEffects.length > 0
|
const activeEffectsMarkup = state.activeRoundEffects.length > 0
|
||||||
? `<div class="active-effects">${state.activeRoundEffects.map((cardId) => {
|
? `<div class="active-effects">${state.activeRoundEffects.map((cardId) => {
|
||||||
const card = getWeatherCard(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>`
|
}).join("")}</div>`
|
||||||
: `<p class="effect-empty">No weather effects active.</p>`;
|
: `<p class="effect-empty">No weather effects active.</p>`;
|
||||||
const nextGrowthText = state.roundSummary
|
const projectedIncomeText = getProjectedIncomeScores().map((score, index) => `${escapeHtml(state.players[index].name)}: ${score}`).join(" | ");
|
||||||
? state.players.map((current) => `${current.name}: ${current.growthPoints}`).join(" | ")
|
const phaseHint = state.phase === "initiative"
|
||||||
: "Next round growth = 1 + columns owned + any banked growth.";
|
? "Choose a seat for this round."
|
||||||
|
: state.phase === "weather"
|
||||||
|
? "Draft one card or ban both cards in an offer."
|
||||||
|
: state.gameOver
|
||||||
|
? "Final totals are locked."
|
||||||
|
: `${player.growthPoints} energy. Click a selected pending node again to undo.`;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
|
<section class="panel accordion-panel accordion-panel--top">
|
||||||
|
<details class="accordion">
|
||||||
|
<summary>In Effect</summary>
|
||||||
|
<div class="accordion__content">
|
||||||
|
${activeEffectsMarkup}
|
||||||
|
${state.roundSummary?.event ? `<p class="event-note">${state.roundSummary.event}</p>` : ""}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="panel controls-panel">
|
<section class="panel controls-panel">
|
||||||
<div class="panel__title-row">
|
<details class="accordion" open>
|
||||||
<div>
|
<summary>End Turn</summary>
|
||||||
<p class="eyebrow">Canopy</p>
|
<div class="accordion__content">
|
||||||
<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};">
|
<div class="active-turn" style="--player-color: ${player.color}; --player-glow: ${player.glow};">
|
||||||
<p class="eyebrow">Round ${state.round}</p>
|
<p class="eyebrow">Round ${state.round}</p>
|
||||||
<h2>${getTurnLabel()}</h2>
|
<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>
|
<p>${phaseHint}</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.gameOver ? `<p>${state.turnMoves.length} pending move${state.turnMoves.length === 1 ? "" : "s"}. Energy ${player.growthPoints}. Bank ${player.bankedPoints}.</p>` : ""}
|
||||||
${state.round === 1 ? `<p>Select a root on the bottom row to shift it left or right for ${ROOT_SHIFT_COST} point during round 1.</p>` : ""}
|
|
||||||
<p>Turn order: ${turnOrderSummary}</p>
|
|
||||||
<p>${getWinConditionSummary()}</p>
|
|
||||||
${rootShiftMoves.length > 0 ? `<div class="root-shift-row">${rootShiftMoves.map((move) => `<button class="ghost-button root-shift-button" data-root-shift="${move.direction === "left" ? -1 : 1}" ${boardLocked ? "disabled" : ""}>${move.direction === "left" ? "<- Root" : "Root ->"}</button>`).join("")}</div>` : ""}
|
${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>
|
||||||
<div class="button-row">
|
<div class="button-row">
|
||||||
<button id="end-turn" ${boardLocked ? "disabled" : ""}>End Turn</button>
|
<button id="end-turn" ${boardLocked ? "disabled" : ""}>End Turn</button>
|
||||||
<button id="bank-turn" class="ghost-button" ${boardLocked || !isBankingEnabled() ? "disabled" : ""}>Bank Remaining</button>
|
<button id="bank-turn" class="ghost-button" ${boardLocked || !isBankingEnabled() ? "disabled" : ""}>Bank Remaining</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="panel status-panel">
|
|
||||||
<h2>Round economy</h2>
|
|
||||||
<p>${nextGrowthText}</p>
|
|
||||||
${activeEffectsMarkup}
|
|
||||||
${state.roundSummary?.event ? `<p class="event-note">${state.roundSummary.event}</p>` : ""}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="panel log-panel">
|
|
||||||
<h2>Round log</h2>
|
|
||||||
<div class="log-list">
|
|
||||||
${state.history.slice(0, 8).map((entry) => `<p>${entry}</p>`).join("")}
|
|
||||||
</div>
|
</div>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<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>Banking is enabled. Bank your remaining energy to save it for next round.</p>` : `<p>Banking is disabled in this game.</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>${escapeHtml(entry)}</p>`).join("")}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel finish-panel">
|
<section class="panel finish-panel">
|
||||||
|
<div class="button-row finish-panel__actions">
|
||||||
|
<button id="new-game" class="ghost-button finish-game-button">New Game</button>
|
||||||
<button id="finish-game" class="ghost-button finish-game-button" ${boardLocked ? "disabled" : ""}>End Game / Score Now</button>
|
<button id="finish-game" class="ghost-button finish-game-button" ${boardLocked ? "disabled" : ""}>End Game / Score Now</button>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</aside>
|
</aside>
|
||||||
`;
|
`;
|
||||||
@@ -1368,7 +1660,6 @@ function attachEvents() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
document.querySelector("#new-game")?.addEventListener("click", openNewGameModal);
|
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("#cancel-new-game")?.addEventListener("click", closeNewGameModal);
|
||||||
document.querySelector("#start-new-game")?.addEventListener("click", startNewGameFromModal);
|
document.querySelector("#start-new-game")?.addEventListener("click", startNewGameFromModal);
|
||||||
document.querySelector("#new-game-modal-backdrop")?.addEventListener("click", (event) => {
|
document.querySelector("#new-game-modal-backdrop")?.addEventListener("click", (event) => {
|
||||||
@@ -1379,13 +1670,16 @@ function attachEvents() {
|
|||||||
document.querySelector("#end-turn")?.addEventListener("click", endTurn);
|
document.querySelector("#end-turn")?.addEventListener("click", endTurn);
|
||||||
document.querySelector("#bank-turn")?.addEventListener("click", bankGrowthAndEndTurn);
|
document.querySelector("#bank-turn")?.addEventListener("click", bankGrowthAndEndTurn);
|
||||||
document.querySelector("#finish-game")?.addEventListener("click", finishGameNow);
|
document.querySelector("#finish-game")?.addEventListener("click", finishGameNow);
|
||||||
document.querySelector<HTMLInputElement>("#player-count")?.addEventListener("input", (event) => {
|
document.querySelector("#draft-panel-toggle")?.addEventListener("click", () => {
|
||||||
const input = event.currentTarget as HTMLInputElement;
|
isDraftPanelDocked = !isDraftPanelDocked;
|
||||||
const output = input.parentElement?.querySelector("strong");
|
render();
|
||||||
if (output) {
|
});
|
||||||
output.textContent = input.value;
|
document.querySelector<HTMLElement>("#player-count-decrease")?.addEventListener("click", () => {
|
||||||
}
|
rebuildSetup({ playerCount: Math.max(2, setup.playerCount - 1) });
|
||||||
rebuildSetup({ playerCount: Number(input.value) });
|
render();
|
||||||
|
});
|
||||||
|
document.querySelector<HTMLElement>("#player-count-increase")?.addEventListener("click", () => {
|
||||||
|
rebuildSetup({ playerCount: Math.min(8, setup.playerCount + 1) });
|
||||||
render();
|
render();
|
||||||
});
|
});
|
||||||
document.querySelector<HTMLInputElement>("#column-count")?.addEventListener("change", (event) => {
|
document.querySelector<HTMLInputElement>("#column-count")?.addEventListener("change", (event) => {
|
||||||
@@ -1434,6 +1728,10 @@ function attachEvents() {
|
|||||||
setup.weatherDraftEnabled = (event.currentTarget as HTMLInputElement).checked;
|
setup.weatherDraftEnabled = (event.currentTarget as HTMLInputElement).checked;
|
||||||
render();
|
render();
|
||||||
});
|
});
|
||||||
|
document.querySelector<HTMLInputElement>("#banking-toggle")?.addEventListener("change", (event) => {
|
||||||
|
setup.bankingEnabled = (event.currentTarget as HTMLInputElement).checked;
|
||||||
|
render();
|
||||||
|
});
|
||||||
document.querySelector<HTMLInputElement>("#weather-draft-count")?.addEventListener("change", (event) => {
|
document.querySelector<HTMLInputElement>("#weather-draft-count")?.addEventListener("change", (event) => {
|
||||||
rebuildSetup({ weatherDraftCount: Math.max(1, Number((event.currentTarget as HTMLInputElement).value) || 1) });
|
rebuildSetup({ weatherDraftCount: Math.max(1, Number((event.currentTarget as HTMLInputElement).value) || 1) });
|
||||||
render();
|
render();
|
||||||
@@ -1455,6 +1753,13 @@ function attachEvents() {
|
|||||||
setup.seedInputs[playerId] = target.value;
|
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.querySelector("#randomize-starting-locations")?.addEventListener("click", randomizeStartingLocations);
|
||||||
document.querySelectorAll<HTMLElement>("[data-move-player]").forEach((button) => {
|
document.querySelectorAll<HTMLElement>("[data-move-player]").forEach((button) => {
|
||||||
button.addEventListener("click", () => {
|
button.addEventListener("click", () => {
|
||||||
@@ -1468,6 +1773,29 @@ function attachEvents() {
|
|||||||
shiftSelectedRoot(Number(button.dataset.rootShift));
|
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) => {
|
document.querySelectorAll<HTMLElement>("[data-seat-choice]").forEach((button) => {
|
||||||
button.addEventListener("click", () => {
|
button.addEventListener("click", () => {
|
||||||
chooseInitiativeSeat(Number(button.dataset.seatChoice));
|
chooseInitiativeSeat(Number(button.dataset.seatChoice));
|
||||||
@@ -1475,7 +1803,11 @@ function attachEvents() {
|
|||||||
});
|
});
|
||||||
document.querySelectorAll<HTMLElement>("[data-weather-card]").forEach((button) => {
|
document.querySelectorAll<HTMLElement>("[data-weather-card]").forEach((button) => {
|
||||||
button.addEventListener("click", () => {
|
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) => {
|
document.querySelectorAll<HTMLElement>("[data-setup-tab]").forEach((button) => {
|
||||||
@@ -1497,11 +1829,15 @@ function render() {
|
|||||||
${renderScoreboard()}
|
${renderScoreboard()}
|
||||||
</footer>
|
</footer>
|
||||||
</main>
|
</main>
|
||||||
|
${renderScoreFlightOverlay()}
|
||||||
${renderNewGameModal()}
|
${renderNewGameModal()}
|
||||||
${renderInitiativeModal()}
|
${renderInitiativeModal()}
|
||||||
${renderWeatherDraftModal()}
|
${renderWeatherDraftModal()}
|
||||||
`;
|
`;
|
||||||
attachEvents();
|
attachEvents();
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
positionScoreFlightBadges();
|
||||||
|
});
|
||||||
previousScoreSnapshot = getScoreSnapshot();
|
previousScoreSnapshot = getScoreSnapshot();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,334 +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 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 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] += 0.5;
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.activeRoundEffects.includes("shared_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") {
|
|
||||||
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,
|
|
||||||
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 };
|
|
||||||
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,46 +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: "storehouse", title: "Storehouse", description: "Banking is enabled this round." },
|
|
||||||
{ id: "sun_ladder", title: "Sun Ladder", description: "Straight-up growth costs 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." },
|
|
||||||
{ 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." },
|
|
||||||
{ 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 function createWeatherDraft(state: GameState): WeatherDraftState {
|
|
||||||
const rowSize = Math.min(WEATHER_CARDS.length, state.config.weatherDraftCount);
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
964
src/styles.css
964
src/styles.css
@@ -1,964 +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: auto;
|
|
||||||
height: auto;
|
|
||||||
max-width: 100%;
|
|
||||||
max-height: 100%;
|
|
||||||
aspect-ratio: var(--board-columns) / var(--board-rows);
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(var(--board-columns), minmax(0, 1fr));
|
|
||||||
grid-template-rows: repeat(var(--board-rows), minmax(0, 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;
|
|
||||||
aspect-ratio: 1 / 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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