export const GRID_SIZE = 5; // Weighted letter pool tuned for playability (vowel-rich, rare letters suppressed) export const LETTER_WEIGHTS = { A:9, B:2, C:3, D:4, E:13, F:2, G:2, H:3, I:8, J:1, K:2, L:4, M:3, N:6, O:7, P:2, R:6, S:5, T:7, U:4, V:2, W:2, X:1, Y:3, Z:1, }; const LETTER_POOL = []; for (const [l, w] of Object.entries(LETTER_WEIGHTS)) { for (let i = 0; i < w; i++) LETTER_POOL.push(l); } export function randomLetter(rng = Math.random) { return LETTER_POOL[Math.floor(rng() * LETTER_POOL.length)]; } // Default spawn chances for multiplier tiles on refills (late-game floor). export const SPECIAL_TILE_CHANCES = { gold: 0.03, diamond: 0.02 }; // Level schedule for multiplier-tile spawn chances. Interpolates linearly // from cfg.start at cfg.startLevel down to cfg.end at cfg.endLevel, then // holds cfg.end. Returns { goldChance, diamondChance }. // cfg = { startLevel: 1, endLevel: 10, // start: { gold: 0.10, diamond: 0.06 }, // end: { gold: 0.03, diamond: 0.02 } } export function specialTileChances(level, cfg = {}) { const start = cfg.start ?? SPECIAL_TILE_CHANCES; const end = cfg.end ?? SPECIAL_TILE_CHANCES; const from = cfg.startLevel ?? 1; const to = cfg.endLevel ?? from; const t = to > from ? Math.max(0, Math.min(1, (level - from) / (to - from))) : 0; if (t <= 0) return { goldChance: start.gold, diamondChance: start.diamond }; if (t >= 1) return { goldChance: end.gold, diamondChance: end.diamond }; return { goldChance: start.gold + (end.gold - start.gold) * t, diamondChance: start.diamond + (end.diamond - start.diamond) * t, }; } export function makeGrid(rng = Math.random) { const grid = []; for (let r = 0; r < GRID_SIZE; r++) { grid.push([]); for (let c = 0; c < GRID_SIZE; c++) { grid[r].push({ letter: randomLetter(rng), type: 'normal' }); } } return grid; } export function getAdjacent(r, c) { const out = []; for (let dr = -1; dr <= 1; dr++) { for (let dc = -1; dc <= 1; dc++) { if (dr === 0 && dc === 0) continue; const nr = r + dr, nc = c + dc; if (nr >= 0 && nr < GRID_SIZE && nc >= 0 && nc < GRID_SIZE) { out.push({ r: nr, c: nc }); } } } return out; } export function isAdjacent(a, b) { return Math.abs(a.r - b.r) <= 1 && Math.abs(a.c - b.c) <= 1 && !(a.r === b.r && a.c === b.c); } export function wordFromCells(grid, cells) { return cells.map(({ r, c }) => grid[r][c].letter).join(''); } // Damage by word length; gold = 1.5×, diamond = 2× (stacking) const DMG_BY_LEN = [0, 0, 0, 1, 2, 4, 7, 11, 15]; export function computeDamage(cells, grid) { const len = cells.length; const base = len < DMG_BY_LEN.length ? DMG_BY_LEN[len] : 15 + (len - 8) * 3; let mult = 1; for (const { r, c } of cells) { const t = grid[r][c].type; if (t === 'gold') mult *= 1.5; if (t === 'diamond') mult *= 2; } return Math.max(1, Math.round(base * mult)); } // Fire tile self-damage: 5 per fire tile when word length < 5 export function computeSelfDamage(cells, grid) { if (cells.length >= 5) return 0; return cells.filter(({ r, c }) => grid[r][c].type === 'fire').length * 5; } // Cascade tiles down in each column, fill top with new random tiles. // opts may override spawn chances: { goldChance, diamondChance }. export function clearAndRefill(grid, usedCells, rng = Math.random, opts = {}) { const used = new Set(usedCells.map(({ r, c }) => `${r},${c}`)); const goldChance = opts.goldChance ?? SPECIAL_TILE_CHANCES.gold; const diamondChance = opts.diamondChance ?? SPECIAL_TILE_CHANCES.diamond; const next = grid.map((row) => row.map((cell) => ({ ...cell }))); for (let c = 0; c < GRID_SIZE; c++) { // Collect surviving tiles from bottom to top const survive = []; for (let r = GRID_SIZE - 1; r >= 0; r--) { if (!used.has(`${r},${c}`)) survive.push({ ...next[r][c] }); } // Fill remainder with new normal tiles while (survive.length < GRID_SIZE) { const gold = rng() < goldChance; const diamond = !gold && rng() < diamondChance; const type = gold ? 'gold' : diamond ? 'diamond' : 'normal'; survive.push({ letter: randomLetter(rng), type }); } // Assign back: survive[0] = bottom row for (let r = GRID_SIZE - 1; r >= 0; r--) { next[r][c] = survive[GRID_SIZE - 1 - r]; } } return next; } export function dropSpecialTile(grid, type, rng = Math.random) { const normals = []; for (let r = 0; r < GRID_SIZE; r++) { for (let c = 0; c < GRID_SIZE; c++) { if (grid[r][c].type === 'normal') normals.push({ r, c }); } } if (!normals.length) return grid; const next = grid.map((row) => row.map((cell) => ({ ...cell }))); const { r, c } = normals[Math.floor(rng() * normals.length)]; next[r][c] = { ...next[r][c], type }; return next; } export function countPoisonTiles(grid) { let n = 0; for (let r = 0; r < GRID_SIZE; r++) { for (let c = 0; c < GRID_SIZE; c++) { if (grid[r][c].type === 'poison') n++; } } return n; } // Compute player max HP from config + levelsCompleted export function computeMaxHp(config, levelsCompleted) { const base = config.playerBaseHp ?? 100; const bonus = (config.milestones ?? []) .filter((m) => levelsCompleted >= m.afterLevel) .reduce((sum, m) => sum + (m.maxHpBonus ?? 0), 0); return base + bonus; } export function isPotionUnlocked(config, levelsCompleted) { return (config.milestones ?? []).some( (m) => m.unlock === 'potion' && levelsCompleted >= m.afterLevel, ); }