Compare commits
2 Commits
2ce04c7bbb
...
a99a1f32e3
| Author | SHA1 | Date | |
|---|---|---|---|
| a99a1f32e3 | |||
| 4034ca55cf |
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Canopy</title>
|
<title>Canopy</title>
|
||||||
<script type="module" src="/src/main.js"></script>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
5
nixpacks.toml
Normal file
5
nixpacks.toml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
[phases.build]
|
||||||
|
cmds = ["npm ci", "npm run build"]
|
||||||
|
|
||||||
|
[start]
|
||||||
|
cmd = "npx serve dist -l 3000 -s"
|
||||||
15
package-lock.json
generated
15
package-lock.json
generated
@@ -8,6 +8,7 @@
|
|||||||
"name": "canopy-game",
|
"name": "canopy-game",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"typescript": "^6.0.2",
|
||||||
"vite": "^5.4.19"
|
"vite": "^5.4.19"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -923,6 +924,20 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "6.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz",
|
||||||
|
"integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "5.4.21",
|
"version": "5.4.21",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||||
|
|||||||
@@ -6,9 +6,11 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"typescript": "^6.0.2",
|
||||||
"vite": "^5.4.19"
|
"vite": "^5.4.19"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
16
src/constants.ts
Normal file
16
src/constants.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import type { PlayerPalette } from "./types";
|
||||||
|
|
||||||
|
export const STARTING_POINTS = 3;
|
||||||
|
export const ROOT_SHIFT_COST = 1;
|
||||||
|
export const ROUND_ANIMATION_SUN_MS = 900;
|
||||||
|
export const ROUND_ANIMATION_BRANCH_MS = 1200;
|
||||||
|
export const ROUND_ANIMATION_BONUS_MS = 900;
|
||||||
|
|
||||||
|
export const PLAYER_PALETTE: PlayerPalette[] = [
|
||||||
|
{ name: "Coral", primary: "#ff6b8a", glow: "rgba(255, 107, 138, 0.35)" },
|
||||||
|
{ name: "Aqua", primary: "#4de0ff", glow: "rgba(77, 224, 255, 0.35)" },
|
||||||
|
{ name: "Amber", primary: "#ffbf47", glow: "rgba(255, 191, 71, 0.35)" },
|
||||||
|
{ name: "Mint", primary: "#6fffb0", glow: "rgba(111, 255, 176, 0.35)" },
|
||||||
|
{ name: "Violet", primary: "#b28dff", glow: "rgba(178, 141, 255, 0.35)" },
|
||||||
|
{ name: "Rose", primary: "#ff8dbf", glow: "rgba(255, 141, 191, 0.35)" },
|
||||||
|
];
|
||||||
1420
src/main.ts
Normal file
1420
src/main.ts
Normal file
File diff suppressed because it is too large
Load Diff
173
src/rules-board.ts
Normal file
173
src/rules-board.ts
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import { ROOT_SHIFT_COST } from "./constants";
|
||||||
|
import type { ColumnLeader, GameState, GrowTarget, NodeKey, Player, PlayerId, ShiftMove } from "./types";
|
||||||
|
import { keyFor, parseKey } from "./utils";
|
||||||
|
|
||||||
|
export function getNodeOwner(state: GameState, row: number, column: number): PlayerId | null {
|
||||||
|
return state.nodes.get(keyFor(row, column))?.ownerId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildParentMap(state: GameState) {
|
||||||
|
return new Map(state.edges.map((edge) => [keyFor(edge.to.row, edge.to.column), keyFor(edge.from.row, edge.from.column)]));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildChildrenMap(state: GameState, ownerId: PlayerId | null = null) {
|
||||||
|
const childrenMap = new Map<NodeKey, NodeKey[]>();
|
||||||
|
|
||||||
|
state.edges.forEach((edge) => {
|
||||||
|
if (ownerId !== null && edge.ownerId !== ownerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromKey = keyFor(edge.from.row, edge.from.column);
|
||||||
|
const target = keyFor(edge.to.row, edge.to.column);
|
||||||
|
const entry = childrenMap.get(fromKey) ?? [];
|
||||||
|
entry.push(target);
|
||||||
|
childrenMap.set(fromKey, entry);
|
||||||
|
});
|
||||||
|
|
||||||
|
return childrenMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectSubtreeKeys(state: GameState, rootKey: NodeKey, player: Player) {
|
||||||
|
const childrenMap = buildChildrenMap(state, player.id);
|
||||||
|
const subtree = new Set<NodeKey>([rootKey]);
|
||||||
|
const queue: NodeKey[] = [rootKey];
|
||||||
|
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const current = queue.shift();
|
||||||
|
if (!current) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
(childrenMap.get(current) ?? []).forEach((childKey) => {
|
||||||
|
if (!subtree.has(childKey)) {
|
||||||
|
subtree.add(childKey);
|
||||||
|
queue.push(childKey);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return subtree;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRootShiftMove(state: GameState, sourceKey: NodeKey, delta: number, player: Player): ShiftMove | null {
|
||||||
|
if (state.round !== 1 || player.growthPoints < ROOT_SHIFT_COST) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentMap = buildParentMap(state);
|
||||||
|
const source = parseKey(sourceKey);
|
||||||
|
if (source.row !== state.config.rows - 1 || state.nodes.get(sourceKey)?.ownerId !== player.id || parentMap.has(sourceKey)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subtree = collectSubtreeKeys(state, sourceKey, player);
|
||||||
|
const movedNodes: ShiftMove["movedNodes"] = [];
|
||||||
|
|
||||||
|
for (const nodeKey of subtree) {
|
||||||
|
const node = parseKey(nodeKey);
|
||||||
|
const targetColumn = node.column + delta;
|
||||||
|
if (targetColumn < 0 || targetColumn >= state.config.columns) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetKey = keyFor(node.row, targetColumn);
|
||||||
|
if (!subtree.has(targetKey) && state.nodes.has(targetKey)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
movedNodes.push({ fromKey: nodeKey, toKey: targetKey, row: node.row, fromColumn: node.column, toColumn: targetColumn });
|
||||||
|
}
|
||||||
|
|
||||||
|
const movedEdges = state.edges
|
||||||
|
.filter((edge) => subtree.has(keyFor(edge.from.row, edge.from.column)) && subtree.has(keyFor(edge.to.row, edge.to.column)))
|
||||||
|
.map((edge) => ({
|
||||||
|
before: {
|
||||||
|
from: { ...edge.from },
|
||||||
|
to: { ...edge.to },
|
||||||
|
ownerId: edge.ownerId,
|
||||||
|
},
|
||||||
|
after: {
|
||||||
|
from: { row: edge.from.row, column: edge.from.column + delta },
|
||||||
|
to: { row: edge.to.row, column: edge.to.column + delta },
|
||||||
|
ownerId: edge.ownerId,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "shift",
|
||||||
|
cost: ROOT_SHIFT_COST,
|
||||||
|
direction: delta < 0 ? "left" : "right",
|
||||||
|
movedNodes,
|
||||||
|
movedEdges,
|
||||||
|
undoKeys: movedNodes.map((node) => node.toKey),
|
||||||
|
selectKey: keyFor(source.row, source.column + delta),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function playerHasRootShiftMove(state: GameState, player: Player) {
|
||||||
|
if (state.round !== 1 || player.growthPoints < ROOT_SHIFT_COST) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(state.nodes.entries()).some(([nodeKey, node]) => {
|
||||||
|
if (node.ownerId !== player.id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Boolean(getRootShiftMove(state, nodeKey, -1, player) || getRootShiftMove(state, nodeKey, 1, player));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLegalMovesForSource(state: GameState, sourceKey: NodeKey, player: Player): GrowTarget[] {
|
||||||
|
const columns = state.config.columns;
|
||||||
|
const { row, column } = parseKey(sourceKey);
|
||||||
|
if (player.id !== getNodeOwner(state, row, column) || row === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const moves: GrowTarget[] = [
|
||||||
|
{ row: row - 1, column, cost: 1, direction: "vertical" },
|
||||||
|
{ row: row - 1, column: column - 1, cost: 2, direction: "left" },
|
||||||
|
{ row: row - 1, column: column + 1, cost: 2, direction: "right" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return moves.filter((move) => {
|
||||||
|
if (move.column < 0 || move.column >= columns) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.nodes.has(keyFor(move.row, move.column))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return move.cost <= player.growthPoints;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function playerHasLegalMove(state: GameState, player: Player) {
|
||||||
|
if (player.growthPoints <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(state.nodes.entries()).some(([nodeKey, node]) => {
|
||||||
|
if (node.ownerId !== player.id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return getLegalMovesForSource(state, nodeKey, player).length > 0;
|
||||||
|
}) || playerHasRootShiftMove(state, player);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getColumnLeaders(state: GameState): ColumnLeader[] {
|
||||||
|
return Array.from({ length: state.config.columns }, (_, column) => {
|
||||||
|
for (let row = 0; row < state.config.rows; row += 1) {
|
||||||
|
const owner = getNodeOwner(state, row, column);
|
||||||
|
if (owner !== null) {
|
||||||
|
return { ownerId: owner, row, tied: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ownerId: null, row: null, tied: false };
|
||||||
|
});
|
||||||
|
}
|
||||||
51
src/rules-initiative.ts
Normal file
51
src/rules-initiative.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import type { GameState, InitiativeDraftState, PlayerId } from "./types";
|
||||||
|
|
||||||
|
export function getSeatBonuses(state: GameState) {
|
||||||
|
const graceRounds = Math.max(0, Math.floor(state.config.columns / state.players.length) - state.players.length);
|
||||||
|
const firstSeatBonus = state.round <= graceRounds ? 0 : 1;
|
||||||
|
|
||||||
|
return Array.from({ length: state.players.length }, (_, index) => (index === 0 ? firstSeatBonus : 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRotatingBiddingOrder(state: GameState) {
|
||||||
|
const start = (state.initiativeAnchorPlayerId + state.round - 1) % state.players.length;
|
||||||
|
return Array.from({ length: state.players.length }, (_, index) => (start + index) % state.players.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLowestGrowthBiddingOrder(state: GameState) {
|
||||||
|
const orderRank = new Map<PlayerId, number>(state.turnOrder.map((playerId, index) => [playerId, index]));
|
||||||
|
|
||||||
|
return [...state.players]
|
||||||
|
.sort((left, right) => {
|
||||||
|
if (left.lifetimeGrowthIncome !== right.lifetimeGrowthIncome) {
|
||||||
|
return left.lifetimeGrowthIncome - right.lifetimeGrowthIncome;
|
||||||
|
}
|
||||||
|
|
||||||
|
const leftRank = orderRank.get(left.id) ?? left.id;
|
||||||
|
const rightRank = orderRank.get(right.id) ?? right.id;
|
||||||
|
if (leftRank !== rightRank) {
|
||||||
|
return leftRank - rightRank;
|
||||||
|
}
|
||||||
|
|
||||||
|
return left.id - right.id;
|
||||||
|
})
|
||||||
|
.map((player) => player.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createInitiativeDraft(state: GameState): InitiativeDraftState {
|
||||||
|
const biddingOrder = state.config.biddingOrderRule === "lowest_growth_income"
|
||||||
|
? getLowestGrowthBiddingOrder(state)
|
||||||
|
: getRotatingBiddingOrder(state);
|
||||||
|
|
||||||
|
return {
|
||||||
|
biddingOrder,
|
||||||
|
biddingIndex: 0,
|
||||||
|
seatAssignments: Array.from({ length: state.players.length }, () => null),
|
||||||
|
seatBonuses: getSeatBonuses(state),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSeatNumberForPlayer(draft: InitiativeDraftState, playerId: PlayerId) {
|
||||||
|
const seatIndex = draft.seatAssignments.findIndex((assignedPlayerId) => assignedPlayerId === playerId);
|
||||||
|
return seatIndex === -1 ? null : seatIndex + 1;
|
||||||
|
}
|
||||||
289
src/rules-scoring.ts
Normal file
289
src/rules-scoring.ts
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
import type { EnergySimulation, GameState, NodeKey, PlayerId, RootBurst, RoundAnimation } from "./types";
|
||||||
|
import { keyFor, parseKey, shuffleArray } from "./utils";
|
||||||
|
import { buildChildrenMap, buildParentMap } from "./rules-board";
|
||||||
|
|
||||||
|
function getColumnRegion(state: GameState, column: number) {
|
||||||
|
const third = state.config.columns / 3;
|
||||||
|
if (column < third) {
|
||||||
|
return "left";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (column >= state.config.columns - third) {
|
||||||
|
return "right";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "center";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLeafCounts(state: GameState) {
|
||||||
|
const childrenMap = buildChildrenMap(state);
|
||||||
|
const counts = state.players.map(() => 0);
|
||||||
|
|
||||||
|
Array.from(state.nodes.entries()).forEach(([nodeKey, node]) => {
|
||||||
|
if (!(childrenMap.get(nodeKey)?.length)) {
|
||||||
|
counts[node.ownerId] += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyWeatherEffects(state: GameState, scores: number[], energySimulation: EnergySimulation) {
|
||||||
|
if (state.activeRoundEffects.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const leafCounts = getLeafCounts(state);
|
||||||
|
const childrenMap = buildChildrenMap(state);
|
||||||
|
const tallestLeaves = state.players.map(() => null as number | null);
|
||||||
|
|
||||||
|
Array.from(state.nodes.entries()).forEach(([nodeKey, node]) => {
|
||||||
|
const leafCount = leafCounts[node.ownerId];
|
||||||
|
if (leafCount <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (childrenMap.get(nodeKey)?.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { row } = parseKey(nodeKey);
|
||||||
|
const currentTallest = tallestLeaves[node.ownerId];
|
||||||
|
if (currentTallest === null || row < currentTallest) {
|
||||||
|
tallestLeaves[node.ownerId] = row;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
state.activeRoundEffects.forEach((effectId) => {
|
||||||
|
if (effectId === "leaf_surge") {
|
||||||
|
leafCounts.forEach((count, playerId) => {
|
||||||
|
scores[playerId] += count;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (effectId === "branching_season") {
|
||||||
|
leafCounts.forEach((count, playerId) => {
|
||||||
|
scores[playerId] += Math.max(0, count - 1);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (effectId === "tall_reward") {
|
||||||
|
tallestLeaves.forEach((row, playerId) => {
|
||||||
|
if (row !== null) {
|
||||||
|
scores[playerId] += 2;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (effectId === "wide_reach") {
|
||||||
|
const maxScore = Math.max(...energySimulation.scores);
|
||||||
|
energySimulation.scores.forEach((score, playerId) => {
|
||||||
|
if (score === maxScore && maxScore > 0) {
|
||||||
|
scores[playerId] += 2;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
energySimulation.columns.forEach((column) => {
|
||||||
|
if (!column.intercepted || column.ownerId === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const region = getColumnRegion(state, column.column);
|
||||||
|
if (effectId === "west_light" && region === "left") {
|
||||||
|
scores[column.ownerId] += 1;
|
||||||
|
}
|
||||||
|
if (effectId === "east_light" && region === "right") {
|
||||||
|
scores[column.ownerId] += 1;
|
||||||
|
}
|
||||||
|
if (effectId === "high_noon" && region === "center") {
|
||||||
|
scores[column.ownerId] += 1;
|
||||||
|
}
|
||||||
|
if (effectId === "edge_bloom" && (column.column === 0 || column.column === state.config.columns - 1)) {
|
||||||
|
scores[column.ownerId] += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildEnergySimulation(state: GameState): EnergySimulation {
|
||||||
|
const parentMap = buildParentMap(state);
|
||||||
|
const columns = [];
|
||||||
|
const scores = state.players.map(() => 0);
|
||||||
|
|
||||||
|
for (let column = 0; column < state.config.columns; column += 1) {
|
||||||
|
let hitNodeKey: NodeKey | null = null;
|
||||||
|
|
||||||
|
for (let row = 0; row < state.config.rows; row += 1) {
|
||||||
|
const nodeKey = keyFor(row, column);
|
||||||
|
if (state.nodes.has(nodeKey)) {
|
||||||
|
hitNodeKey = nodeKey;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hitNodeKey) {
|
||||||
|
columns.push({
|
||||||
|
column,
|
||||||
|
terminalRow: state.config.rows - 1,
|
||||||
|
intercepted: false,
|
||||||
|
ownerId: null,
|
||||||
|
hitNode: null,
|
||||||
|
rootKey: null,
|
||||||
|
branchNodes: [],
|
||||||
|
branchEdges: [],
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hitNode = parseKey(hitNodeKey);
|
||||||
|
const ownerId = state.nodes.get(hitNodeKey)?.ownerId as PlayerId;
|
||||||
|
const branchNodes = [hitNode];
|
||||||
|
const branchEdges = [];
|
||||||
|
let cursor = hitNodeKey;
|
||||||
|
|
||||||
|
while (parentMap.has(cursor)) {
|
||||||
|
const parentKey = parentMap.get(cursor) as NodeKey;
|
||||||
|
branchEdges.push({ from: parseKey(cursor), to: parseKey(parentKey) });
|
||||||
|
branchNodes.push(parseKey(parentKey));
|
||||||
|
cursor = parentKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
scores[ownerId] += 1;
|
||||||
|
columns.push({
|
||||||
|
column,
|
||||||
|
terminalRow: hitNode.row,
|
||||||
|
intercepted: true,
|
||||||
|
ownerId,
|
||||||
|
hitNode,
|
||||||
|
rootKey: cursor,
|
||||||
|
branchNodes,
|
||||||
|
branchEdges,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootBurstMap = columns.reduce((map, column) => {
|
||||||
|
if (!column.intercepted || !column.rootKey) {
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = map.get(column.rootKey) ?? { key: column.rootKey, playerId: column.ownerId as PlayerId, count: 0 };
|
||||||
|
entry.count += 1;
|
||||||
|
map.set(column.rootKey, entry);
|
||||||
|
return map;
|
||||||
|
}, new Map<NodeKey, RootBurst>());
|
||||||
|
const rootBursts: RootBurst[] = [...rootBurstMap.values()];
|
||||||
|
|
||||||
|
applyWeatherEffects(state, scores, { scores, columns, rootBursts });
|
||||||
|
|
||||||
|
return {
|
||||||
|
scores,
|
||||||
|
columns,
|
||||||
|
rootBursts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRoundAnimation(
|
||||||
|
state: GameState,
|
||||||
|
energySimulation: EnergySimulation,
|
||||||
|
sunbeamPlayerId: PlayerId | null,
|
||||||
|
diseaseKeys: NodeKey[],
|
||||||
|
): RoundAnimation {
|
||||||
|
const traces = energySimulation.columns
|
||||||
|
.filter((column) => column.intercepted)
|
||||||
|
.map((column) => ({
|
||||||
|
playerId: column.ownerId as PlayerId,
|
||||||
|
verticalCells: Array.from({ length: column.terminalRow + 1 }, (_, row) => ({ row, column: column.column })),
|
||||||
|
ray: {
|
||||||
|
x: ((column.column + 0.5) / state.config.columns) * 100,
|
||||||
|
y: ((column.terminalRow + 0.5) / state.config.rows) * 100,
|
||||||
|
},
|
||||||
|
rootKey: column.rootKey,
|
||||||
|
branchNodes: column.branchNodes,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const bonusTrace = sunbeamPlayerId === null ? null : traces.find((trace) => trace.playerId === sunbeamPlayerId) ?? null;
|
||||||
|
const bonusBurst = bonusTrace ? energySimulation.rootBursts.find((burst) => burst.key === bonusTrace.rootKey) ?? null : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
phase: "sunlight",
|
||||||
|
columns: energySimulation.columns,
|
||||||
|
traces,
|
||||||
|
rootBursts: energySimulation.rootBursts,
|
||||||
|
sunbeamPlayerId,
|
||||||
|
bonusTrace,
|
||||||
|
bonusBurst,
|
||||||
|
diseaseKeys,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scoreColumns(state: GameState) {
|
||||||
|
const energySimulation = buildEnergySimulation(state);
|
||||||
|
const columnResults = energySimulation.columns.map((column) => ({
|
||||||
|
column: column.column,
|
||||||
|
ownerId: column.ownerId,
|
||||||
|
topRow: column.intercepted ? column.terminalRow : null,
|
||||||
|
tied: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { scores: energySimulation.scores, columnResults, energySimulation };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function maybeRollSunbeam(state: GameState, scores: number[]) {
|
||||||
|
const nextGrowth = scores.map((score) => score + 1);
|
||||||
|
const { sunbeamChance } = state.randomEffects;
|
||||||
|
|
||||||
|
if (sunbeamChance <= 0 || Math.random() * 100 >= sunbeamChance) {
|
||||||
|
return {
|
||||||
|
nextGrowth,
|
||||||
|
event: null,
|
||||||
|
awardedPlayer: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const awardedPlayer = Math.floor(Math.random() * state.players.length);
|
||||||
|
nextGrowth[awardedPlayer] += 1;
|
||||||
|
|
||||||
|
return {
|
||||||
|
nextGrowth,
|
||||||
|
awardedPlayer,
|
||||||
|
event: `${state.players[awardedPlayer].name} caught a stray sunbeam and gains +1 growth next round.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function maybeRollDisease(state: GameState) {
|
||||||
|
const { diseaseChance } = state.randomEffects;
|
||||||
|
if (diseaseChance <= 0 || Math.random() * 100 >= diseaseChance) {
|
||||||
|
return {
|
||||||
|
killedKeys: [],
|
||||||
|
event: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const childrenMap = buildChildrenMap(state);
|
||||||
|
const parentMap = buildParentMap(state);
|
||||||
|
const twigKeys = Array.from(state.nodes.keys()).filter((nodeKey) => {
|
||||||
|
const { row } = parseKey(nodeKey);
|
||||||
|
return row !== state.config.rows - 1 && !childrenMap.has(nodeKey) && parentMap.has(nodeKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (twigKeys.length === 0) {
|
||||||
|
return {
|
||||||
|
killedKeys: [],
|
||||||
|
event: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const shuffled = shuffleArray(twigKeys);
|
||||||
|
const killCount = Math.min(shuffled.length, 1 + Math.floor(Math.random() * Math.min(3, shuffled.length)));
|
||||||
|
const killedKeys = shuffled.slice(0, killCount);
|
||||||
|
|
||||||
|
return {
|
||||||
|
killedKeys,
|
||||||
|
event: `Disease spread through ${killCount} twig node${killCount === 1 ? "" : "s"} before the next round.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
41
src/rules-weather.ts
Normal file
41
src/rules-weather.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import type { GameState, PlayerId, WeatherCardDefinition, WeatherCardId, WeatherDraftState } from "./types";
|
||||||
|
import { shuffleArray } from "./utils";
|
||||||
|
|
||||||
|
export const WEATHER_CARDS: WeatherCardDefinition[] = [
|
||||||
|
{ id: "leaf_surge", title: "Leaf Surge", description: "Each leaf gives +1." },
|
||||||
|
{ id: "branching_season", title: "Branching Season", description: "Each leaf after your first gives +1." },
|
||||||
|
{ id: "west_light", title: "West Light", description: "Left third columns give +1." },
|
||||||
|
{ id: "east_light", title: "East Light", description: "Right third columns give +1." },
|
||||||
|
{ id: "high_noon", title: "High Noon", description: "Center third columns give +1." },
|
||||||
|
{ id: "edge_bloom", title: "Edge Bloom", description: "Edge columns give +1." },
|
||||||
|
{ id: "wide_reach", title: "Wide Reach", description: "Most columns gets +2." },
|
||||||
|
{ id: "tall_reward", title: "Tall Reward", description: "Your tallest leaf gives +2." },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const WEATHER_CARD_LOOKUP = new Map<WeatherCardId, WeatherCardDefinition>(
|
||||||
|
WEATHER_CARDS.map((card) => [card.id, card]),
|
||||||
|
);
|
||||||
|
|
||||||
|
export function createWeatherDraft(state: GameState): WeatherDraftState {
|
||||||
|
const rowSize = Math.min(WEATHER_CARDS.length, state.players.length + 2);
|
||||||
|
|
||||||
|
return {
|
||||||
|
playerOrder: [...state.turnOrder],
|
||||||
|
draftIndex: 0,
|
||||||
|
row: shuffleArray(WEATHER_CARDS.map((card) => card.id)).slice(0, rowSize),
|
||||||
|
drafted: [],
|
||||||
|
banned: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCurrentWeatherPlayerId(draft: WeatherDraftState) {
|
||||||
|
return draft.playerOrder[draft.draftIndex] as PlayerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWeatherCardAvailable(draft: WeatherDraftState, cardId: WeatherCardId) {
|
||||||
|
return draft.row.includes(cardId) && !draft.drafted.includes(cardId) && !draft.banned.includes(cardId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWeatherCard(cardId: WeatherCardId) {
|
||||||
|
return WEATHER_CARD_LOOKUP.get(cardId) ?? null;
|
||||||
|
}
|
||||||
220
src/state.ts
Normal file
220
src/state.ts
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
import { PLAYER_PALETTE, STARTING_POINTS } from "./constants";
|
||||||
|
import type { GameState, Player, SetupState } from "./types";
|
||||||
|
import { keyFor, shuffleArray } from "./utils";
|
||||||
|
|
||||||
|
export function createDefaultPaletteOrder(playerCount: number) {
|
||||||
|
return Array.from({ length: playerCount }, (_, index) => index % PLAYER_PALETTE.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDefaultSeedInputs(playerCount: number, columns: number, startingNodesPerPlayer: number) {
|
||||||
|
const totalSeeds = playerCount * startingNodesPerPlayer;
|
||||||
|
const positions = Array.from({ length: totalSeeds }, (_, index) => Math.floor(((index + 0.5) * columns) / totalSeeds));
|
||||||
|
|
||||||
|
return Array.from({ length: playerCount }, (_, playerId) => {
|
||||||
|
const start = playerId * startingNodesPerPlayer;
|
||||||
|
return positions
|
||||||
|
.slice(start, start + startingNodesPerPlayer)
|
||||||
|
.map((column) => String(column + 1))
|
||||||
|
.join(", ");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickNearestOpenColumn(preferredColumn: number, columns: number, usedColumns: Set<number>) {
|
||||||
|
if (!usedColumns.has(preferredColumn)) {
|
||||||
|
return preferredColumn;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let distance = 1; distance < columns; distance += 1) {
|
||||||
|
const left = preferredColumn - distance;
|
||||||
|
if (left >= 0 && !usedColumns.has(left)) {
|
||||||
|
return left;
|
||||||
|
}
|
||||||
|
|
||||||
|
const right = preferredColumn + distance;
|
||||||
|
if (right < columns && !usedColumns.has(right)) {
|
||||||
|
return right;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return preferredColumn;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRandomizedSeedInputs(playerCount: number, columns: number, startingNodesPerPlayer: number) {
|
||||||
|
const zoneWidth = columns / playerCount;
|
||||||
|
const usedColumns = new Set<number>();
|
||||||
|
|
||||||
|
return Array.from({ length: playerCount }, (_, playerId) => {
|
||||||
|
const picks: number[] = [];
|
||||||
|
|
||||||
|
for (let seedIndex = 0; seedIndex < startingNodesPerPlayer; seedIndex += 1) {
|
||||||
|
const localRatio = (seedIndex + 1) / (startingNodesPerPlayer + 1);
|
||||||
|
const center = (playerId + localRatio) * zoneWidth;
|
||||||
|
const subZoneWidth = zoneWidth / (startingNodesPerPlayer + 1);
|
||||||
|
const maxJitter = Math.max(0.35, Math.min(zoneWidth * 0.22, subZoneWidth * 0.42));
|
||||||
|
const jitter = (Math.random() * 2 - 1) * maxJitter;
|
||||||
|
const preferredColumn = Math.max(0, Math.min(columns - 1, Math.round(center + jitter - 0.5)));
|
||||||
|
const chosenColumn = pickNearestOpenColumn(preferredColumn, columns, usedColumns);
|
||||||
|
|
||||||
|
usedColumns.add(chosenColumn);
|
||||||
|
picks.push(chosenColumn + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return picks.sort((left, right) => left - right).join(", ");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMaxStartingNodesPerPlayer(playerCount: number, columns: number) {
|
||||||
|
return Math.max(1, Math.floor(columns / playerCount));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSetupState(
|
||||||
|
playerCount = 3,
|
||||||
|
columns = 18,
|
||||||
|
rows = 16,
|
||||||
|
startingNodesPerPlayer = 1,
|
||||||
|
sunbeamChance = 0,
|
||||||
|
diseaseChance = 0,
|
||||||
|
seedInputs: string[] | null = null,
|
||||||
|
paletteOrder: number[] | null = null,
|
||||||
|
shuffleTurnOrder = true,
|
||||||
|
initiativeMode: SetupState["initiativeMode"] = "fixed",
|
||||||
|
biddingOrderRule: SetupState["biddingOrderRule"] = "rotating",
|
||||||
|
weatherDraftEnabled = true,
|
||||||
|
winCondition: SetupState["winCondition"] = "rounds",
|
||||||
|
maxRounds = 12,
|
||||||
|
topLeafTarget = 4,
|
||||||
|
): SetupState {
|
||||||
|
const clampedSeeds = Math.min(startingNodesPerPlayer, getMaxStartingNodesPerPlayer(playerCount, columns));
|
||||||
|
const defaults = createDefaultSeedInputs(playerCount, columns, clampedSeeds);
|
||||||
|
const paletteDefaults = createDefaultPaletteOrder(playerCount);
|
||||||
|
|
||||||
|
return {
|
||||||
|
playerCount,
|
||||||
|
columns,
|
||||||
|
rows,
|
||||||
|
startingNodesPerPlayer: clampedSeeds,
|
||||||
|
sunbeamChance,
|
||||||
|
diseaseChance,
|
||||||
|
seedInputs: Array.from({ length: playerCount }, (_, index) => seedInputs?.[index] ?? defaults[index]),
|
||||||
|
paletteOrder: Array.from({ length: playerCount }, (_, index) => paletteOrder?.[index] ?? paletteDefaults[index]),
|
||||||
|
shuffleTurnOrder,
|
||||||
|
initiativeMode,
|
||||||
|
biddingOrderRule,
|
||||||
|
weatherDraftEnabled,
|
||||||
|
winCondition,
|
||||||
|
maxRounds,
|
||||||
|
topLeafTarget: Math.max(1, Math.min(columns, topLeafTarget)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPlayers(playerCount: number, paletteOrder = createDefaultPaletteOrder(playerCount)): Player[] {
|
||||||
|
return Array.from({ length: playerCount }, (_, index) => {
|
||||||
|
const palette = PLAYER_PALETTE[paletteOrder[index] % PLAYER_PALETTE.length];
|
||||||
|
return {
|
||||||
|
id: index,
|
||||||
|
name: `Player ${index + 1}`,
|
||||||
|
color: palette.primary,
|
||||||
|
glow: palette.glow,
|
||||||
|
totalScore: 0,
|
||||||
|
roundScore: 0,
|
||||||
|
growthPoints: STARTING_POINTS,
|
||||||
|
bankedPoints: 0,
|
||||||
|
bonusPoints: 0,
|
||||||
|
lifetimeGrowthIncome: STARTING_POINTS,
|
||||||
|
passed: false,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeSeedInputs(setup: SetupState) {
|
||||||
|
const assigned = new Set<number>();
|
||||||
|
const fallback = createDefaultSeedInputs(setup.playerCount, setup.columns, setup.startingNodesPerPlayer)
|
||||||
|
.map((input) => input.split(",").map((part) => Number(part.trim()) - 1));
|
||||||
|
|
||||||
|
return Array.from({ length: setup.playerCount }, (_, playerId) => {
|
||||||
|
const requested = (setup.seedInputs[playerId] ?? "")
|
||||||
|
.split(",")
|
||||||
|
.map((part) => Number.parseInt(part.trim(), 10) - 1)
|
||||||
|
.filter((column) => Number.isInteger(column) && column >= 0 && column < setup.columns);
|
||||||
|
|
||||||
|
const uniqueColumns: number[] = [];
|
||||||
|
requested.forEach((column) => {
|
||||||
|
if (!assigned.has(column) && uniqueColumns.length < setup.startingNodesPerPlayer) {
|
||||||
|
assigned.add(column);
|
||||||
|
uniqueColumns.push(column);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
fallback[playerId].forEach((column) => {
|
||||||
|
if (!assigned.has(column) && uniqueColumns.length < setup.startingNodesPerPlayer) {
|
||||||
|
assigned.add(column);
|
||||||
|
uniqueColumns.push(column);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let column = 0; column < setup.columns && uniqueColumns.length < setup.startingNodesPerPlayer; column += 1) {
|
||||||
|
if (!assigned.has(column)) {
|
||||||
|
assigned.add(column);
|
||||||
|
uniqueColumns.push(column);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return uniqueColumns;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createInitialState(setup: SetupState): GameState {
|
||||||
|
const playerPaletteOrder = setup.shuffleTurnOrder ? shuffleArray(setup.paletteOrder) : [...setup.paletteOrder];
|
||||||
|
const players = createPlayers(setup.playerCount, playerPaletteOrder);
|
||||||
|
const turnOrder = players.map((player) => player.id);
|
||||||
|
const nodes = new Map();
|
||||||
|
const edges = [];
|
||||||
|
const seedColumnsByPlayer = normalizeSeedInputs(setup);
|
||||||
|
|
||||||
|
seedColumnsByPlayer.forEach((seedColumns, index) => {
|
||||||
|
seedColumns.forEach((column) => {
|
||||||
|
nodes.set(keyFor(setup.rows - 1, column), { ownerId: index });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
config: {
|
||||||
|
columns: setup.columns,
|
||||||
|
rows: setup.rows,
|
||||||
|
playerCount: setup.playerCount,
|
||||||
|
startingNodesPerPlayer: setup.startingNodesPerPlayer,
|
||||||
|
playerPaletteOrder,
|
||||||
|
initiativeMode: setup.initiativeMode,
|
||||||
|
biddingOrderRule: setup.biddingOrderRule,
|
||||||
|
weatherDraftEnabled: setup.weatherDraftEnabled,
|
||||||
|
winCondition: setup.winCondition,
|
||||||
|
maxRounds: setup.maxRounds,
|
||||||
|
topLeafTarget: setup.topLeafTarget,
|
||||||
|
},
|
||||||
|
players,
|
||||||
|
nodes,
|
||||||
|
edges,
|
||||||
|
round: 1,
|
||||||
|
activePlayerId: turnOrder[0],
|
||||||
|
turnOrder,
|
||||||
|
phase: setup.initiativeMode === "bid" ? "initiative" : "turn",
|
||||||
|
turnMoves: [],
|
||||||
|
selectedSource: null,
|
||||||
|
availableTargets: [],
|
||||||
|
animation: null,
|
||||||
|
initiativeAnchorPlayerId: Math.floor(Math.random() * players.length),
|
||||||
|
initiativeDraft: null,
|
||||||
|
weatherDraft: null,
|
||||||
|
activeRoundEffects: [],
|
||||||
|
randomEffects: {
|
||||||
|
sunbeamChance: setup.sunbeamChance,
|
||||||
|
diseaseChance: setup.diseaseChance,
|
||||||
|
},
|
||||||
|
gameOver: false,
|
||||||
|
history: [
|
||||||
|
`Round 1 begins on a ${setup.columns}x${setup.rows} board with ${setup.startingNodesPerPlayer} starting node${setup.startingNodesPerPlayer === 1 ? "" : "s"} each.`,
|
||||||
|
`${setup.shuffleTurnOrder ? "Turn order was randomized for this game." : "Turn order uses the setup order."}`,
|
||||||
|
],
|
||||||
|
roundSummary: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
216
src/styles.css
216
src/styles.css
@@ -18,12 +18,14 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
button,
|
button,
|
||||||
input {
|
input,
|
||||||
|
select {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="number"],
|
input[type="number"],
|
||||||
input[type="text"] {
|
input[type="text"],
|
||||||
|
select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 2.8rem;
|
min-height: 2.8rem;
|
||||||
padding: 0.7rem 0.85rem;
|
padding: 0.7rem 0.85rem;
|
||||||
@@ -58,6 +60,8 @@ button {
|
|||||||
|
|
||||||
.scoreboard--bottom {
|
.scoreboard--bottom {
|
||||||
align-items: end;
|
align-items: end;
|
||||||
|
position: relative;
|
||||||
|
z-index: 25;
|
||||||
}
|
}
|
||||||
|
|
||||||
.score-card {
|
.score-card {
|
||||||
@@ -91,6 +95,12 @@ button {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.score-card__identity {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
.score-card__head h2,
|
.score-card__head h2,
|
||||||
.panel h1,
|
.panel h1,
|
||||||
.panel h2,
|
.panel h2,
|
||||||
@@ -103,6 +113,14 @@ button {
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.score-card__footer {
|
||||||
|
margin-top: 0.65rem;
|
||||||
|
padding-top: 0.55rem;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
color: rgba(231, 238, 247, 0.72);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
.score-card__numbers div {
|
.score-card__numbers div {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.15rem;
|
gap: 0.15rem;
|
||||||
@@ -113,10 +131,16 @@ button {
|
|||||||
label span,
|
label span,
|
||||||
.log-list p,
|
.log-list p,
|
||||||
.status-panel p,
|
.status-panel p,
|
||||||
.active-turn p {
|
.active-turn p,
|
||||||
|
.effect-empty {
|
||||||
color: rgba(231, 238, 247, 0.72);
|
color: rgba(231, 238, 247, 0.72);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.score-card__meta {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: rgba(231, 238, 247, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
.score-card__numbers strong {
|
.score-card__numbers strong {
|
||||||
font-size: 1.35rem;
|
font-size: 1.35rem;
|
||||||
}
|
}
|
||||||
@@ -139,6 +163,7 @@ label span,
|
|||||||
|
|
||||||
.game-area {
|
.game-area {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1.9fr) minmax(320px, 0.85fr);
|
grid-template-columns: minmax(0, 1.9fr) minmax(320px, 0.85fr);
|
||||||
gap: 0.85rem;
|
gap: 0.85rem;
|
||||||
@@ -415,6 +440,21 @@ label span,
|
|||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.draft-panel {
|
||||||
|
position: fixed;
|
||||||
|
top: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
width: max(320px, calc(((100vw - 2rem) - 0.85rem) * 0.3091));
|
||||||
|
max-width: calc(100vw - 2rem);
|
||||||
|
max-height: calc(100vh - 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);
|
||||||
|
}
|
||||||
|
|
||||||
.modal-setup-grid,
|
.modal-setup-grid,
|
||||||
.modal-grid {
|
.modal-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -503,10 +543,180 @@ label span,
|
|||||||
color: rgba(231, 238, 247, 0.72);
|
color: rgba(231, 238, 247, 0.72);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.initiative-order-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.initiative-pill {
|
||||||
|
padding: 0.45rem 0.7rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
color: rgba(231, 238, 247, 0.76);
|
||||||
|
}
|
||||||
|
|
||||||
|
.initiative-pill--active {
|
||||||
|
border-color: color-mix(in srgb, var(--player-color) 62%, white);
|
||||||
|
box-shadow: 0 0 18px color-mix(in srgb, var(--player-color) 40%, transparent);
|
||||||
|
color: #f4f7fb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.initiative-seat-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 0.8rem;
|
||||||
|
margin-top: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.initiative-seat {
|
||||||
|
min-height: 8rem;
|
||||||
|
padding: 0.9rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
text-align: left;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
color: #f4f7fb;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.initiative-seat--taken {
|
||||||
|
opacity: 0.68;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 0.9rem;
|
||||||
|
margin-top: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.8rem;
|
||||||
|
padding: 0.95rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-card h2,
|
||||||
|
.weather-card p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-card--drafted {
|
||||||
|
border-color: rgba(130, 224, 182, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-card--banned {
|
||||||
|
border-color: rgba(255, 128, 128, 0.45);
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-card__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.6rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-key {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.25rem;
|
||||||
|
color: rgba(231, 238, 247, 0.78);
|
||||||
|
font-size: 0.86rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-effects {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.55rem;
|
||||||
|
margin-top: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-chip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 3rem;
|
||||||
|
padding: 0.75rem 0.9rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: linear-gradient(180deg, rgba(255, 208, 96, 0.14), rgba(255, 208, 96, 0.04));
|
||||||
|
border: 1px solid rgba(255, 208, 96, 0.2);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-chip__title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #f4f7fb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-action {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.7rem;
|
||||||
|
padding: 0.75rem 0.85rem;
|
||||||
|
border-radius: 0.95rem;
|
||||||
|
color: #f4f7fb;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-action span {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.12rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-action strong {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-action__icon {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-action--draft {
|
||||||
|
background: linear-gradient(180deg, rgba(255, 208, 96, 0.2), rgba(255, 208, 96, 0.08));
|
||||||
|
border-color: rgba(255, 208, 96, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-action--draft .weather-action__icon {
|
||||||
|
background: rgba(255, 208, 96, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-action--ban {
|
||||||
|
background: linear-gradient(180deg, rgba(255, 110, 110, 0.16), rgba(255, 110, 110, 0.06));
|
||||||
|
border-color: rgba(255, 110, 110, 0.32);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-action--ban .weather-action__icon {
|
||||||
|
background: rgba(255, 110, 110, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-action:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
.randomize-button {
|
.randomize-button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.finish-game-button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
input[type="range"] {
|
input[type="range"] {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|||||||
236
src/types.ts
Normal file
236
src/types.ts
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
export type PlayerId = number;
|
||||||
|
export type NodeKey = `${number}:${number}`;
|
||||||
|
|
||||||
|
export type PlayerPalette = {
|
||||||
|
name: string;
|
||||||
|
primary: string;
|
||||||
|
glow: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Position = {
|
||||||
|
row: number;
|
||||||
|
column: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SetupState = {
|
||||||
|
playerCount: number;
|
||||||
|
columns: number;
|
||||||
|
rows: number;
|
||||||
|
startingNodesPerPlayer: number;
|
||||||
|
sunbeamChance: number;
|
||||||
|
diseaseChance: number;
|
||||||
|
seedInputs: string[];
|
||||||
|
paletteOrder: number[];
|
||||||
|
shuffleTurnOrder: boolean;
|
||||||
|
initiativeMode: "fixed" | "bid";
|
||||||
|
biddingOrderRule: "rotating" | "lowest_growth_income";
|
||||||
|
weatherDraftEnabled: boolean;
|
||||||
|
winCondition: "rounds" | "top_leaves";
|
||||||
|
maxRounds: number;
|
||||||
|
topLeafTarget: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Player = {
|
||||||
|
id: PlayerId;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
glow: string;
|
||||||
|
totalScore: number;
|
||||||
|
roundScore: number;
|
||||||
|
growthPoints: number;
|
||||||
|
bankedPoints: number;
|
||||||
|
bonusPoints: number;
|
||||||
|
lifetimeGrowthIncome: number;
|
||||||
|
passed: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GameConfig = {
|
||||||
|
columns: number;
|
||||||
|
rows: number;
|
||||||
|
playerCount: number;
|
||||||
|
startingNodesPerPlayer: number;
|
||||||
|
playerPaletteOrder: number[];
|
||||||
|
initiativeMode: SetupState["initiativeMode"];
|
||||||
|
biddingOrderRule: SetupState["biddingOrderRule"];
|
||||||
|
weatherDraftEnabled: boolean;
|
||||||
|
winCondition: SetupState["winCondition"];
|
||||||
|
maxRounds: number;
|
||||||
|
topLeafTarget: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NodeState = {
|
||||||
|
ownerId: PlayerId;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Edge = {
|
||||||
|
from: Position;
|
||||||
|
to: Position;
|
||||||
|
ownerId: PlayerId;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GrowDirection = "vertical" | "left" | "right";
|
||||||
|
|
||||||
|
export type GrowTarget = Position & {
|
||||||
|
cost: number;
|
||||||
|
direction: GrowDirection;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ShiftMove = {
|
||||||
|
type: "shift";
|
||||||
|
cost: number;
|
||||||
|
direction: "left" | "right";
|
||||||
|
movedNodes: Array<{
|
||||||
|
fromKey: NodeKey;
|
||||||
|
toKey: NodeKey;
|
||||||
|
row: number;
|
||||||
|
fromColumn: number;
|
||||||
|
toColumn: number;
|
||||||
|
}>;
|
||||||
|
movedEdges: Array<{
|
||||||
|
before: Edge;
|
||||||
|
after: Edge;
|
||||||
|
}>;
|
||||||
|
undoKeys: NodeKey[];
|
||||||
|
selectKey: NodeKey;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GrowMove = {
|
||||||
|
type: "grow";
|
||||||
|
from: Position;
|
||||||
|
to: Position;
|
||||||
|
cost: number;
|
||||||
|
targetKey: NodeKey;
|
||||||
|
undoKeys: NodeKey[];
|
||||||
|
selectKey: NodeKey;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TurnMove = GrowMove | ShiftMove;
|
||||||
|
|
||||||
|
export type ColumnEnergy = {
|
||||||
|
column: number;
|
||||||
|
terminalRow: number;
|
||||||
|
intercepted: boolean;
|
||||||
|
ownerId: PlayerId | null;
|
||||||
|
hitNode: Position | null;
|
||||||
|
rootKey: NodeKey | null;
|
||||||
|
branchNodes: Position[];
|
||||||
|
branchEdges: Array<{ from: Position; to: Position }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RootBurst = {
|
||||||
|
key: NodeKey;
|
||||||
|
playerId: PlayerId;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EnergySimulation = {
|
||||||
|
scores: number[];
|
||||||
|
columns: ColumnEnergy[];
|
||||||
|
rootBursts: RootBurst[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RoundAnimationTrace = {
|
||||||
|
playerId: PlayerId;
|
||||||
|
verticalCells: Position[];
|
||||||
|
ray: { x: number; y: number };
|
||||||
|
rootKey: NodeKey | null;
|
||||||
|
branchNodes: Position[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RoundAnimation = {
|
||||||
|
phase: "sunlight" | "branches" | "bonus" | "events";
|
||||||
|
columns: ColumnEnergy[];
|
||||||
|
traces: RoundAnimationTrace[];
|
||||||
|
rootBursts: RootBurst[];
|
||||||
|
sunbeamPlayerId: PlayerId | null;
|
||||||
|
bonusTrace: RoundAnimationTrace | null;
|
||||||
|
bonusBurst: RootBurst | null;
|
||||||
|
diseaseKeys: NodeKey[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ColumnResult = {
|
||||||
|
column: number;
|
||||||
|
ownerId: PlayerId | null;
|
||||||
|
topRow: number | null;
|
||||||
|
tied: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RoundSummary = {
|
||||||
|
scores: number[];
|
||||||
|
columnResults: ColumnResult[];
|
||||||
|
event: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ScoreSnapshot = {
|
||||||
|
currentExposure: number;
|
||||||
|
growthPoints: number;
|
||||||
|
bankedPoints: number;
|
||||||
|
lifetimeGrowthIncome: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ColumnLeader = {
|
||||||
|
ownerId: PlayerId | null;
|
||||||
|
row: number | null;
|
||||||
|
tied: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RandomEffects = {
|
||||||
|
sunbeamChance: number;
|
||||||
|
diseaseChance: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GamePhase = "initiative" | "turn" | "round_end" | "game_over";
|
||||||
|
|
||||||
|
export type WeatherCardId =
|
||||||
|
| "leaf_surge"
|
||||||
|
| "branching_season"
|
||||||
|
| "west_light"
|
||||||
|
| "east_light"
|
||||||
|
| "high_noon"
|
||||||
|
| "edge_bloom"
|
||||||
|
| "wide_reach"
|
||||||
|
| "tall_reward";
|
||||||
|
|
||||||
|
export type WeatherCardDefinition = {
|
||||||
|
id: WeatherCardId;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InitiativeDraftState = {
|
||||||
|
biddingOrder: PlayerId[];
|
||||||
|
biddingIndex: number;
|
||||||
|
seatAssignments: Array<PlayerId | null>;
|
||||||
|
seatBonuses: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WeatherDraftState = {
|
||||||
|
playerOrder: PlayerId[];
|
||||||
|
draftIndex: number;
|
||||||
|
row: WeatherCardId[];
|
||||||
|
drafted: WeatherCardId[];
|
||||||
|
banned: WeatherCardId[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GameState = {
|
||||||
|
config: GameConfig;
|
||||||
|
players: Player[];
|
||||||
|
nodes: Map<NodeKey, NodeState>;
|
||||||
|
edges: Edge[];
|
||||||
|
round: number;
|
||||||
|
activePlayerId: PlayerId;
|
||||||
|
turnOrder: PlayerId[];
|
||||||
|
phase: GamePhase | "weather";
|
||||||
|
turnMoves: TurnMove[];
|
||||||
|
selectedSource: NodeKey | null;
|
||||||
|
availableTargets: GrowTarget[];
|
||||||
|
animation: RoundAnimation | null;
|
||||||
|
initiativeAnchorPlayerId: PlayerId;
|
||||||
|
initiativeDraft: InitiativeDraftState | null;
|
||||||
|
weatherDraft: WeatherDraftState | null;
|
||||||
|
activeRoundEffects: WeatherCardId[];
|
||||||
|
randomEffects: RandomEffects;
|
||||||
|
gameOver: boolean;
|
||||||
|
history: string[];
|
||||||
|
roundSummary: RoundSummary | null;
|
||||||
|
};
|
||||||
41
src/utils.ts
Normal file
41
src/utils.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import type { NodeKey, Position } from "./types";
|
||||||
|
|
||||||
|
export function keyFor(row: number, column: number): NodeKey {
|
||||||
|
return `${row}:${column}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseKey(key: string): Position {
|
||||||
|
const [row, column] = key.split(":").map(Number);
|
||||||
|
return { row, column };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hexToRgb(hex: string) {
|
||||||
|
const value = hex.replace("#", "");
|
||||||
|
const normalized = value.length === 3 ? value.split("").map((part) => part + part).join("") : value;
|
||||||
|
const int = Number.parseInt(normalized, 16);
|
||||||
|
return {
|
||||||
|
r: (int >> 16) & 255,
|
||||||
|
g: (int >> 8) & 255,
|
||||||
|
b: int & 255,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tint(hex: string, alpha = 0.16) {
|
||||||
|
const { r, g, b } = hexToRgb(hex);
|
||||||
|
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wait(milliseconds: number) {
|
||||||
|
return new Promise<void>((resolve) => window.setTimeout(resolve, milliseconds));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shuffleArray<T>(items: T[]) {
|
||||||
|
const next = [...items];
|
||||||
|
|
||||||
|
for (let index = next.length - 1; index > 0; index -= 1) {
|
||||||
|
const swapIndex = Math.floor(Math.random() * (index + 1));
|
||||||
|
[next[index], next[swapIndex]] = [next[swapIndex], next[index]];
|
||||||
|
}
|
||||||
|
|
||||||
|
return next;
|
||||||
|
}
|
||||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
declare module "*.css";
|
||||||
16
tsconfig.json
Normal file
16
tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": false,
|
||||||
|
"noEmit": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"esModuleInterop": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user