ts refactor

This commit is contained in:
2026-04-08 16:44:16 -04:00
parent 2ce04c7bbb
commit 4034ca55cf
12 changed files with 1831 additions and 2 deletions

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Canopy</title>
<script type="module" src="/src/main.js"></script>
<script type="module" src="/src/main.ts"></script>
</head>
<body>
<div id="app"></div>

15
package-lock.json generated
View File

@@ -8,6 +8,7 @@
"name": "canopy-game",
"version": "0.0.1",
"devDependencies": {
"typescript": "^6.0.2",
"vite": "^5.4.19"
}
},
@@ -923,6 +924,20 @@
"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": {
"version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",

View File

@@ -6,9 +6,11 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"typescript": "^6.0.2",
"vite": "^5.4.19"
}
}

16
src/constants.ts Normal file
View 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)" },
];

1008
src/main.ts Normal file

File diff suppressed because it is too large Load Diff

173
src/rules-board.ts Normal file
View 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 };
});
}

179
src/rules-scoring.ts Normal file
View File

@@ -0,0 +1,179 @@
import type { EnergySimulation, GameState, NodeKey, PlayerId, RootBurst, RoundAnimation } from "./types";
import { keyFor, parseKey, shuffleArray } from "./utils";
import { buildChildrenMap, buildParentMap } from "./rules-board";
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()];
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.`,
};
}

194
src/state.ts Normal file
View File

@@ -0,0 +1,194 @@
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,
): 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,
};
}
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,
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 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,
},
players,
nodes,
edges,
round: 1,
activePlayerId: 0,
turnMoves: [],
selectedSource: null,
availableTargets: [],
animation: null,
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,
};
}

184
src/types.ts Normal file
View File

@@ -0,0 +1,184 @@
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;
};
export type Player = {
id: PlayerId;
name: string;
color: string;
glow: string;
totalScore: number;
roundScore: number;
growthPoints: number;
bankedPoints: number;
bonusPoints: number;
passed: boolean;
};
export type GameConfig = {
columns: number;
rows: number;
playerCount: number;
startingNodesPerPlayer: number;
playerPaletteOrder: 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 = {
totalScore: number;
roundScore: number;
growthPoints: number;
bankedPoints: number;
};
export type ColumnLeader = {
ownerId: PlayerId | null;
row: number | null;
tied: boolean;
};
export type RandomEffects = {
sunbeamChance: number;
diseaseChance: number;
};
export type GameState = {
config: GameConfig;
players: Player[];
nodes: Map<NodeKey, NodeState>;
edges: Edge[];
round: number;
activePlayerId: PlayerId;
turnMoves: TurnMove[];
selectedSource: NodeKey | null;
availableTargets: GrowTarget[];
animation: RoundAnimation | null;
randomEffects: RandomEffects;
gameOver: boolean;
history: string[];
roundSummary: RoundSummary | null;
};

41
src/utils.ts Normal file
View 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
View File

@@ -0,0 +1 @@
declare module "*.css";

16
tsconfig.json Normal file
View 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"]
}