162 lines
5.5 KiB
JavaScript
162 lines
5.5 KiB
JavaScript
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,
|
||
);
|
||
}
|